私は普段Webエンジニアとして仕事をしているのだが、趣味でゲーム開発も少しやっている。
ゲーム開発をしていると、Web開発とはまた違った設計に出会うことが多い。
その中でも個人的にかなり面白いと思ったのが「状態管理」である。
Web開発でも状態管理という言葉自体はよく聞く。
ReactならuseState、Vueならrefやreactive、規模が大きくなればPiniaやReduxなどを使うこともあるだろう。
しかし今回話したいのは「状態をどこに保存するか」ではなく
「そもそも状態をどう表現するか」
という話である。
ゲーム開発でよくある問題を見ていて「これWeb開発でもめちゃくちゃ応用できるやんけ」と思ったので、今回はゲーム開発における状態管理からWeb開発にも応用できる考え方についてまとめる。
主にenumとFSMという管理方法について紹介をする。
enumはまだしもFSMはWeb開発者がそれと意識して明示的に使っている場面はそこまで一般的ではないと思われたため、今回紹介するものとする。
Booleanって便利~
例えばゲームを作っていて、プレイヤーが攻撃しているかどうかを管理したいとする。
最初は単純である。
let isAttacking = false;
攻撃を開始したら
isAttacking = true;
攻撃が終わったら
isAttacking = false;
これでいい。
攻撃中は移動できないようにしたいのであれば、
if (!isAttacking) {
move();
}
とすればいい。非常に分かりやすい。
しかし、ゲームを作っていくと当然仕様は増えていく。
「ダメージを受けている間も移動させたくないな」
となれば
let isDamaged = false;
が増える。
死亡中も動かしたくねぇな
let isDead = false;
あ、イベントシーン中も動かしたくねぇな
let isInCutscene = false;
ふとコードを眺めると
isAttacking
isDamaged
isDead
isInCutscene
isStunned
isDodging
canMove
boolean変数の数がえぐいことになっている。
一つ一つを見ると別に普通なのだ。
問題はこれらを組み合わせ始めたときである。
Booleanの組み合わせが状態になる
例えば移動可能かどうかを判定するコードがこうなったとする。
if (
!isAttacking &&
!isDamaged &&
!isDead &&
!isInCutscene &&
!isStunned &&
canMove
) {
move();
}
まぁ、if文の条件が多いがまだ読める。
しかし作り進めていくとこういう判定が
attack()
move()
useItem()
openMenu()
interact()
など色々な場所に出てくるようになる。
さらに新しく回避処理のために
isDodging
を追加したとする。
すると
「回避中は攻撃できない」
「回避中はアイテムを使えない」
「ほな移動処理はどうする?」
と、既存の条件式を片っ端から確認する必要が出てくる。
そして一番恐ろしいのが
isAttacking = true;
isDead = true;
canMove = true;
のような状態である。
お前今どういう状態なん!?
死んでるん?攻撃してるん?え、でも移動してるん?
Booleanとして見ればまぁおかしくもないのだ。
すべてtrueかfalseなのでプログラムとしては正常である。
が、ゲームの仕様としては意味不明な状態だ。
フラグを戻し忘れるという単純なバグであることが多いのだが、まぁ変数が多くなればなるほど見落としは増えてしまう。
これはナンセンスだろう。
ここでようやく開発者はあることに気付く。
我々は複数のBooleanを管理しているのではない。
複数のBooleanを使って「プレイヤーの状態」という一つのものを表現しようとしているのである。
enumって便利~
ならば最初から「状態」として表現してやればよくね?という判断だ。
そこでEnumなどを使って、プレイヤーの状態そのものを表現する。
enum PlayerState {
Idle,
Moving,
Attacking,
Damaged,
Dead,
Cutscene,
}
そして
let state = PlayerState.Idle;
とする。
攻撃を始めたのであれば
state = PlayerState.Attacking;
死亡したのであれば、
state = PlayerState.Dead;
である。
これだけでもかなり意味が変わる。
Boolean方式では、
isAttacking = true;
isDead = false;
isDamaged = false;
isInCutscene = false;
など複数の値を見る必要があった。
しかしEnumなら、
state === PlayerState.Attacking
を見れば「現在このプレイヤーは攻撃状態である」と一発で分かる。
そしてもう一つ大きなメリットがある。
「PlayerState.AttackingとPlayerState.Deadは同時には存在できない」
つまり、先ほどの「攻撃中だが死亡してて移動可能」というような、本来存在してほしくない状態を作りにくくできる。
しかし、ここまでは割と私もWeb開発でもやっていた。
私の場合は例えば3つの状態で0, 1, 2などの定数を割り当ててその状態を定数として管理することで簡単な
if (item.status === STATUS.inCart) {
// 商品がカートに入っていた時の処理
}
などのような形で書いていた。しかし今回私は新たにこれの拡張とも言えるFSMという概念をWeb開発でも使っていこうぜということを提唱したい。
FSMという考え方
ゲーム開発ではさらにこれを発展させた**FSM(Finite State Machine / 有限状態機械)**という考え方がよく使われる。
名前だけ聞くと急に難しそうだが、考え方自体はそれほど難しくない。
単純に「どの状態から、どの状態へ遷移できるかを静的に決めてしまおうぜ」というものだ。
例えば
Idle
↓ 攻撃
Attacking
↓ 攻撃終了
Idle
という状態遷移がある。
移動なら
Idle
↓ 移動開始
Moving
↓ 移動終了
Idle
死亡したのであれば、
Idle ─────→ Dead
Moving ───→ Dead
Attacking → Dead
となる。
一方、
Dead → Attacking
という状態遷移は存在しない。
つまり死亡状態になったキャラクターが突然攻撃を始める、といったことを状態遷移そのものによって防ぐことができる。
聡明な読者諸君は、型定義をすることによって静的に型エラーを防ぎ、コンパイル時点で望まぬ値が入ることを防ごうとする考え方、
Pythonでなく今TypeScriptが潮流として来ているこの現状にとても思想が似ているのにお気づきだろう。
コードにすると例えば、
switch (state) {
case PlayerState.Idle:
if (attackPressed) {
state = PlayerState.Attacking;
}
break;
case PlayerState.Attacking:
if (attackAnimationFinished) {
state = PlayerState.Idle;
}
break;
case PlayerState.Dead:
break;
}
のようになる。
「攻撃できるか?」を毎回大量のBooleanから計算するのではなく、
「Idleという状態からAttackingへ遷移できる」というルールとして考えるのである。
これが状態管理を考える上で非常に面白い。
これ、Web開発でも同じじゃね?
ここまでゲーム開発の話をしてきた。
しかしWebエンジニアの方なら、そろそろ何か見覚えがあるのではないだろうか。
例えばファイルアップロードを実装するとする。
最初は、
const isUploading = ref(false);
くらいだろう。
しかし仕様が増えると、
const isUploading = ref(false);
const isCompleted = ref(false);
const hasError = ref(false);
const isCanceled = ref(false);
となる。
そしてUIでは、
if (!isUploading && !hasError && !isCompleted) {
// アップロードボタンを表示
}
などと書き始める。
別のところでは、
if (isUploading && !isCanceled) {
// プログレスバーを表示
}
などと書く。
(なんだかゲーム開発で見た光景になってきたな、、、)
そして当然、
isUploading = true;
isCompleted = true;
hasError = true;
という状態もプログラム上は作ることができる。
結局アップロードは成功したのか失敗したのかどっちなんや!!
Webでも「状態」として持てばいい
この場合も同じである。
type UploadStatus =
| "idle"
| "uploading"
| "completed"
| "error"
| "canceled";
const status = ref<UploadStatus>("idle");
とすればいい。
すると、
status.value = "uploading";
status.value = "completed";
status.value = "error";
という形で現在の状態を表現できる。
uploadingとcompletedが同時に存在することもない。
UI側も
if (status.value === "uploading") {
// プログレスバー
}
if (status.value === "error") {
// エラー表示
}
と非常に読みやすくなる。
ここで重要なのは、変数が4個から1個になったことではない。
「存在しうる状態をコード上で明示したこと」
である。
TypeScriptならさらに強くできる
さらにTypeScriptならDiscriminated Unionを使うとかなり面白い。
例えばアップロード処理なら、
type UploadState =
| {
status: "idle";
}
| {
status: "uploading";
progress: number;
}
| {
status: "completed";
fileUrl: string;
}
| {
status: "error";
error: Error;
};
と書ける。
すると
if (state.status === "uploading") {
console.log(state.progress);
}
と書いた時点でTypeScriptは「uploadingならprogressを持っている」と理解してくれる。
逆に
{
status: "idle",
progress: 80
}
のような状態は作れない。
Idleなのにアップロード進捗80%?はにゃ?
のような**「仕様上存在しない状態」そのものを型で表現できないようにする**ことができる。
enumは単に「状態の種類」を定義するだけであったが、FSMはこれを拡張して「状態の遷移先」と「各状態が持ち得る変数」を同時に定義することができる。
Booleanが悪いわけではないんやで
ここまで読むと「ほなBoolean使わない方がええやん」と言い出す人がいるかもしれないが、もちろんそんなことはない。
例えばゲームなら、
isPoisoned
hasKey
isGrounded
isInvincible
などはBooleanで全く問題ない。
なぜなら
Attacking + Poisoned
Moving + Poisoned
Idle + Poisoned
は普通に存在できるからである。
つまり
state = PlayerState.Attacking;
isPoisoned = true;
は何もおかしくない。
Webでも同様で
isAdmin
hasNotification
isDarkMode
などはBooleanとして自然である。
問題なのはBooleanそのものではなく
「本来一つの状態であるものを、複数のBooleanの組み合わせによって表現していること」
である。
Booleanが増えてきたら一度立ち止まる
個人的にはコードを書いていて、
if (
!isLoading &&
!hasError &&
!isCompleted &&
!isCanceled
) {
// 本処理
}
のようなコードが出てきたら、一度考えた方がいいと思っている。
これらは本当に独立したBooleanなのか?
statusという一つの状態で表せないか?
もちろん何でもFSMにすればいいわけではない。
単純な画面に巨大なState Machineを持ち込めば、逆にコードは複雑になる。
が、
Booleanがどんどん増えていたり
同じBooleanの組み合わせ判定が色々な場所に出てきたり
Flagを追加するたびに既存の条件式を修正する必要があったり
「この組み合わせってあり得るんだっけ?」と考える必要があったり
現在何の状態なのか複数の変数を見ないと分からなかったり
こうなってきたら、
「これはFlagではなく状態なのでは?」
と一考の価値は十分にある。
まとめ
私はWeb開発からプログラミングを始めたので、ゲーム開発でFSMという考え方に触れたとき
「ゲーム特有の設計なんだろうな」
くらいに思っていた。
しかし実際に考えてみると、Web開発でも同じ問題は至るところに存在している。
API通信なら
idle
loading
success
error
認証なら
unknown
unauthenticated
authenticating
authenticated
決済なら
idle
processing
succeeded
failed
ファイルアップロードなら
idle
uploading
completed
error
canceled
といった具合だ。
普段何気なく書いている
isLoading
hasError
isCompleted
も、本当にそれぞれ独立したFlagなのだろうか。
もしかすると、それは一つの「状態」を無理やり複数のBooleanで表現しているだけなのかもしれない。
ゲーム開発をしていて学んだことではあるが、Web開発でも十分使える考え方であった。
状態を表すBooleanが増えてきたとき、
「今、自分はFlagを増やしているのか。それとも新しい状態を作っているのか」
と一度考えてみると、後々の自分が少し楽になるかもしれない。