ミニゲームのステートマシン設計:ホームから戦闘、リザルトまでの完全フロー
"カードゲームのバグの 80% は状態設計の不備に由来し、ステートマシンは散在した状態判断を効果的に解決できる。"
"ネストされたステートマシンは複雑なゲームの主流パターンとなっており、Battle State 内部に BeginBattle、HeroTurn、EnemyTurn、EndBattle の 4 つのサブ状態を含められる。"
リリースしたばかりのミニゲームで、プレイヤーから「リザルト画面がよく固まる」と報告が来ました。ゲームは終わっているのに、画面はまだ戦闘中のまま——コードを開くと、if (isPlaying && !isPaused && !isGameOver && hasPlayerLeft) がずらりと並んでいます。このひどいコードを書いたのは誰かと思ったら、自分でした。
以前、カード・棋牌系のミニゲームを作ったときも同じ罠にハマりました。状態判定が boolean 変数として至る所に散らばり、1 つの機能を直すのに十数ファイルも触る羽目に。最もつらかったのはマルチプレイで、サーバーはリザルト状態なのに、クライアントは戦闘アニメーションを再生し続ける——両者の状態がズレてしまうことです。リファクタリングでステートマシンを導入してから、コードはかなりすっきりしました。
この記事では、ミニゲームのステートマシン設計を整理します。ホームから戦闘、リザルトまでの流れを、ゲームフロー層・戦闘フロー層・戦闘詳細層の三層構成で説明します。あわせて、状態ロック、サーバー側検証、タイムアウト機構といった、実戦で本当に効く落とし穴対策も共有します。
なぜミニゲームにステートマシンが必要なのか?
要するに、ゲームとは状態が行ったり来たりするものです。
ホーム、戦闘、リザルト、一時停止、観戦——各画面の裏には 1 つの状態があります。プレイヤーが「ゲーム開始」を押すとホームが消え、戦闘画面が現れる。戦闘が終わればリザルトが出る。口で言うと簡単ですが、コードにすると一気に混乱します。
if-else 連鎖の三つの問題
まず、こんなコードを見たことがあるはずです。
// 状態判断が散らばっている
if (isPlaying && !isPaused && !isGameOver) {
// プレイヤーが操作できる
}
if (isGameOver && !hasShownResult) {
// リザルト画面を表示
}
if (isPlaying && currentPlayer === 'player1') {
// プレイヤー 1 のターン
}
この書き方には、三つの問題があります。
読みにくい:今どの状態かを知るには、ファイル全体から boolean 変数を探し回る必要があります。isPlaying、isPaused、isGameOver——ピースがバラバラに散らばっていて、自分で全体像を組み立てるしかありません。
変更しにくい:「一時停止」状態を 1 つ足すだけでも、十数箇所の if を直す必要があります。1 箇所でも漏れるとバグになります。観戦モードを追加したとき、2 日かけて直しても、あるアニメーションだけ止まらないままリリースした経験があります。
デバッグしにくい:状態遷移の順序が狂うと、どこが原因か特定できません。isGameOver は true なのに hasShownResult は false——どの分岐が抜けたのでしょうか。
搜狐の記事では、カードゲームのバグの 80% は状態設計の不備に由来すると書かれています。数字は大きく聞こえますが、経験者なら「その通り」と頷くはずです。
ステートマシンは何を解決するのか?
ステートマシンは、散らばった判断を 1 か所に集約します。
各状態に「入るとき」「動いているとき」「出るとき」の振る舞いを持たせ、遷移ルールも明示します。ホームからは戦闘へしか行けず、いきなりリザルトへ飛べない——といった制約もコードに表現できます。
比較してみましょう。
// ステートマシン方式
class HomeState {
enter() { showHomeUI(); }
exit() { hideHomeUI(); }
handleEvent(event) {
if (event === 'START_BATTLE') {
manager.changeState(new BattleState());
}
}
}
class BattleState {
enter() { showBattleUI(); startBattle(); }
exit() { hideBattleUI(); cleanupBattle(); }
}
状態ごとにロジックがカプセル化されます。HomeState を直しても BattleState には波及しません。新しい状態を足すときも、State クラスを 1 つ追加して遷移ルールを 1 行足すだけです。
Better Programming の記事でも、ネストされたステートマシンが複雑なゲームの主流パターンだと述べられています。ミニゲームも例外ではありません。状態がはっきりすれば、コードもはっきりします。
三層ステートマシンアーキテクチャの詳細
ミニゲームのステートマシンは、層に分けて設計するのが前提です。分けないと、戦闘ロジックがゲームフロー層に詰め込まれ、どんどん膨らみます。
ここでは三層に分けます。ゲームフロー層、戦闘フロー層、戦闘詳細層です。
第一層:ゲームフロー層
ゲームの骨格となる、起動からリザルトまでの大きな流れです。
BootState:起動時にリソースを読み込み、エンジンを初期化します。Cocos Creator の Boot シーンがまさにこの役割——Boot を走らせてから Home へ進みます。
HomeState:ホームメニュー。ステージ選択、キャラ選択、ランキング表示など。UI を出してボタン入力を処理する、比較的シンプルな層です。
BattleState:戦闘シーン。ここは終点ではなくコンテナで、内部にさらにサブステートマシンを持ちます。
SettlementState:リザルト画面。勝敗、統計、戦闘リソースの解放を担当します。
流れは Boot → Home → Battle → Settlement → Home(または終了)です。
ゲーム起動 → BootState
↓ (読み込み完了)
HomeState
↓ (開始をクリック)
BattleState ← 戦闘サブステートマシンはここ
↓ (戦闘終了)
SettlementState
↓ (戻るをクリック)
HomeState
第二層:戦闘フロー層
BattleState の内部にも、別のステートマシンがあります。Better Programming の記事では、Battle State に BeginBattle、HeroTurn、EnemyTurn、EndBattle の 4 サブ状態を含められると説明されています。
BeginBattleState:戦闘の初期化。ステージ設定、キャラデータ読み込み、全プレイヤーの同期。マルチプレイではこの段階でロックをかけ、途中参加・退出を禁止します。
PlayerTurnState:プレイヤーターン。カードを出す、攻撃、防御——操作はここで処理し、ターン終了後に次ターンまたはリザルトへ遷移します。
EnemyTurnState(任意):AI の行動。ソロ向けミニゲームなら省略可能で、対人戦では相手のターンに相当します。
EndBattleState:リザルトロジック。勝敗判定、報酬計算、リザルト演出のトリガー。
BattleState へ入る → BeginBattleState (ロック・同期)
↓ (準備完了)
PlayerTurnState
↓ (ターン終了)
EnemyTurnState (任意)
↓ (戦闘終了)
EndBattleState
↓ (リザルト完了)
BattleState を抜ける → SettlementState
第三層:戦闘詳細層
さらに細かく分ける必要がある状態もあります。たとえば PlayerTurnState の内部には次のようなものがあります。
アニメーションステートマシン:攻撃時は攻撃モーション、被弾時は被弾モーション。Unity も Cocos Creator も組み込みのアニメーションステートマシンを持っていますが、ここでは詳細は割愛します。
ターンステートマシン:出牌 → 攻撃 → 防御 → ターン終了。各アクションをサブ状態として扱います。
Beast Card Clash では setup、scoring、results screen を独立した状態クラスに分けています。1 フェーズの変更が他フェーズに波及しない——この設計の利点です。
三層に分ける最大のメリットは責務の分離です。ホームを直しても戦闘は壊れず、ターン詳細を直してもリザルトフローは崩れません。チーム開発でも、戦闘フロー層担当と詳細層担当を分けやすくなります。
ステートマシンのコアインターフェース設計
三層の話が終わったら、コードに落とし込みます。
Zhihu の Unity 記事では、ステートマシンノードの共通インターフェースとして OnEnter、OnUpdate、OnExit、OnHandleEvent が紹介されています。これを参考に TypeScript で実装しました。
IGameState インターフェース
各状態はこのインターフェースを実装します。
interface IGameState {
name: string; // 状態名(デバッグ用)
enter(params?: any): void; // 入場時の初期化
update(dt: number): void; // 毎フレーム更新(任意)
exit(): void; // 退場時のクリーンアップ
handleEvent(event: GameEvent): void; // イベント処理・状態遷移
}
5 つのメソッドの役割は次のとおりです。
name:デバッグ用。ログに “HomeState” か “BattleState” かと出せば、boolean の組み合わせより直感的です。
enter:状態に入った瞬間に呼ばれます。HomeState ならホーム UI 表示とプレイヤーデータ読み込み、BattleState なら戦闘パラメータ初期化とサブステートマシン起動。
update:毎フレーム更新。戦闘ではカウントダウンや入力監視に使います。ホームではほぼ不要です。
exit:状態を抜ける前の後片付け。リソース解放、UI 非表示、リスナー解除。
handleEvent:イベント駆動の遷移。「ゲーム開始」クリックで START_BATTLE が飛び、HomeState が BattleState へ切り替えます。
GameStateManager 状態マネージャー
状態の切り替えにはマネージャーが必要です。Stack Exchange の記事では CGameEngine クラスの Init、Cleanup、ChangeState、PushState、PopState が紹介されています。ここでは少し簡略化した版です。
class GameStateManager {
private currentState: IGameState | null = null;
private stateStack: IGameState[] = []; // 一時停止など重ね状態用
// 初期化
init(firstState: IGameState) {
this.currentState = firstState;
this.currentState.enter();
}
// 状態切り替え
changeState(newState: IGameState, params?: any) {
if (this.currentState) {
this.currentState.exit();
}
this.currentState = newState;
this.currentState.enter(params);
}
// 重ね状態をプッシュ(一時停止・ポップアップ)
pushState(state: IGameState) {
if (this.currentState) {
// 現在状態は exit せず一時停止
this.stateStack.push(this.currentState);
}
this.currentState = state;
state.enter();
}
// 重ね状態をポップ
popState() {
if (this.currentState) {
this.currentState.exit();
}
this.currentState = this.stateStack.pop();
// 再 enter は不要(一時停止していただけ)
}
// 毎フレーム更新
update(dt: number) {
if (this.currentState) {
this.currentState.update(dt);
}
}
// イベント処理
handleEvent(event: GameEvent) {
if (this.currentState) {
this.currentState.handleEvent(event);
}
}
}
changeState:旧状態を exit し、新状態を enter。ホーム → 戦闘の切り替えに使います。
pushState / popState:重ね状態。戦闘中に一時停止すると PauseState がスタック先頭に乗り、BattleState を覆います。再開時に PauseState を pop すれば BattleState が復帰します。
一時停止、確認ダイアログ、モーダル——現在状態を壊さずに上から被せたい場面で必須の仕組みです。
実戦の落とし穴と対策
理論の次は、実際に踏んだ罠です。搜狐の記事がまとめる棋牌ゲームの教訓のうち、特に多い問題を掘り下げます。
ステートドリフト:サーバーとクライアントの不一致
マルチプレイで最も踏みやすい罠です。
典型例:サーバーはすでにリザルト状態なのに、あるクライアントだけ戦闘アニメーションを再生中——遷移通知が遅延または欠落したためです。画面は「戦闘中」のまま、実際は終了済み。
原因:状態切り替えメッセージの欠落、または遅延が大きすぎる。
対策:
-
状態同期:サーバーが遷移したら全クライアントへブロードキャストし、クライアントは受信後すぐ同期する。
-
ハートビート検知:クライアントが数秒ごとに現在状態を添えてハートビートを送り、サーバーが不一致を検知したら強制同期する。
// クライアント側ハートビート
class BattleState {
enter() {
this.startHeartbeat();
}
startHeartbeat() {
setInterval(() => {
socket.send({
type: 'HEARTBEAT',
state: this.manager.currentState.name,
roomId: this.roomId
});
}, 3000); // 3 秒ごと
}
}
同時実行競合:複数人が同時操作
テーブル上で 2 人が同時にカードを出し、遷移順序が崩れる——よくあるパターンです。
原因:状態ロックがなく、操作がキューイングされていない。
対策:状態ロック + 「現在の操作者 ID」。
class DealingState implements IGameState {
private lock: boolean = true;
private currentOperator: string = '';
enter() {
this.lock = true; // 配札フェーズはロック
// 参加・退出を禁止
// 全員の初期データを同期
setTimeout(() => this.unlock(), 3000); // 3 秒後に解除
}
unlock() {
this.lock = false;
this.currentOperator = this.getFirstPlayerId();
this.manager.changeState(new PlayerTurnState());
}
handleEvent(event: GameEvent) {
if (this.lock) {
// ロック中は操作拒否
return;
}
// 現在の操作者だけがイベントを処理
if (event.playerId === this.currentOperator) {
// 操作処理
}
}
}
配札やリザルトのようなクリティカル区間では、1 本のフローだけが走るようにロックするのが安全です。
リザルトの安全性:改ざんクライアントからの偽スコア
クライアントがスコアや勝敗をアップロードする設計だと、改ざん版で数値を書き換えられます。
原因:サーバーがクライアント送信値をそのまま信用している。
対策:サーバー側で独立計算し、クライアント結果は使わない。
// サーバー側リザルトロジック
class EndBattleState {
enter() {
// クライアントアップロードのスコアは受け付けない
// 戦闘ログからサーバー側で独立計算
const result = this.calculateResultFromLog(battleLog);
// 全クライアントへ結果を配信
this.broadcastResult(result);
}
calculateResultFromLog(log: BattleLog) {
// 出牌順・攻撃データなどログから再計算
// クライアントログは偽造可能なので、重要データはサーバー検証
}
}
原則はシンプルです。サーバーはクライアントを信用しない。クライアントは表示、サーバーが判定。
タイムアウトによるフリーズ:終了条件のない状態
ある状態から先へ進めず、永久に待ち続ける——プレイヤー切断やネットワーク詰まりで起きがちです。
原因:タイムアウトがなく、待っているイベントが来ない。
対策:各状態にタイムアウトを設定する。
class PlayerTurnState implements IGameState {
private timeoutTimer: number;
enter() {
this.timeoutTimer = setTimeout(() => {
// タイムアウトで自動スキップ
this.manager.handleEvent({
type: 'TIMEOUT_SKIP',
playerId: this.currentPlayer
});
}, 30000); // 30 秒
}
exit() {
clearTimeout(this.timeoutTimer); // 退場時にタイマー解除
}
}
待ち続ける設計だけでは不十分です。必ずフォールバックを用意しましょう。
4 つの落とし穴は、理論よりここが重要です。ステートドリフトで 2 日デバッグしたあと、ハートビートを入れてようやく収束しました。
Cocos Creator 実践例
理論、インターフェース、落とし穴対策が揃ったら、Cocos Creator への載せ方です。
前回の『Cocos Creator ミニゲームプロジェクト構造』では Boot・シーン・リザルト画面の分割を扱いました。今回はその延長で、ステートマシンと Layer の接続を説明します。
単一シーン構成でのステートマシン
Cocos Creator ミニゲームでは、単一シーン + 複数 Layer が推奨されます。1 シーンの中で Layer の active を切り替える——ステートマシンと相性がよい構成です。
BootLayer → HomeLayer → BattleLayer → SettlementLayer
各 Layer が 1 状態に対応します。
import { director, Node } from 'cc';
// HomeState
class HomeState implements IGameState {
name = 'Home';
private homeLayer: Node | null = null;
enter() {
// HomeLayer を表示
const scene = director.getScene();
this.homeLayer = scene?.getChildByName('HomeLayer') ?? null;
if (this.homeLayer) {
this.homeLayer.active = true;
this.initHomeUI();
}
}
exit() {
// HomeLayer を非表示
if (this.homeLayer) {
this.homeLayer.active = false;
this.cleanupHome();
}
}
handleEvent(event: GameEvent) {
if (event.type === 'START_BATTLE') {
// BattleState へ遷移
this.manager.changeState(new BattleState(), event.params);
}
}
initHomeUI() {
// プレイヤーデータ読み込み、ボタンイベント設定
}
cleanupHome() {
// ホームリソース解放
}
}
// BattleState
class BattleState implements IGameState {
name = 'Battle';
private battleLayer: Node | null = null;
enter(params?: any) {
const scene = director.getScene();
this.battleLayer = scene?.getChildByName('BattleLayer') ?? null;
if (this.battleLayer) {
this.battleLayer.active = true;
this.startBattle(params);
}
}
exit() {
if (this.battleLayer) {
this.battleLayer.active = false;
this.cleanupBattle();
}
}
startBattle(params: any) {
// 戦闘サブステートマシンを起動
this.battleStateManager.init(new BeginBattleState(params));
}
cleanupBattle() {
// 戦闘リソース解放
}
}
Layer の表示・非表示が、状態遷移の見た目そのものです。enter で表示、exit で非表示。
ステートマシンとデータ受け渡し
遷移時にパラメータを渡します。ホームで選んだステージ ID を BattleState が受け取る、といったケースです。
// HomeState から遷移開始
handleEvent(event: GameEvent) {
if (event.type === 'START_BATTLE') {
// ステージ情報を渡す
this.manager.changeState(new BattleState(), {
levelId: event.levelId,
difficulty: event.difficulty
});
}
}
// BattleState で受け取る
enter(params?: any) {
const levelId = params?.levelId ?? 'default';
const difficulty = params?.difficulty ?? 'normal';
this.loadLevel(levelId, difficulty);
}
途中退出からの復帰には localStorage が使えます。
// 進捗保存
exit() {
localStorage.setItem('battle_progress', JSON.stringify({
levelId: this.levelId,
round: this.currentRound,
score: this.score
}));
}
// 進捗復元
enter() {
const saved = localStorage.getItem('battle_progress');
if (saved) {
const progress = JSON.parse(saved);
this.resumeFromProgress(progress);
}
}
WeChat ミニゲームの注意点
CSDN の記事では、WeChat ミニゲームのステートマシンをグローバルファイルに置くと変更しやすいと述べられています。
パッケージサイズ:WeChat ミニゲームのメインパッケージ上限は 4MB。ステートマシンコードは簡潔に、クラス乱立は避けます。
サブパッケージ:コア(Boot、Home、Battle)はメインパッケージ、特殊ステージやイベント UI はサブパッケージへ。
グローバル初期化:エントリ game.js でステートマシンを起動し、他モジュールはグローバル経由で参照します。
// game.js
import { GameStateManager } from './states/GameStateManager';
import { BootState } from './states/BootState';
// グローバル状態マネージャー
window.gameStateManager = new GameStateManager();
window.gameStateManager.init(new BootState());
WeChat API コールバックからも、グローバルで状態を取得できます。
// WeChat ログインコールバック
wx.login({
success: (res) => {
const state = window.gameStateManager.currentState;
if (state.name === 'HomeState') {
state.handleEvent({ type: 'LOGIN_SUCCESS', code: res.code });
}
}
});
WeChat API 側でステートマシンを import しなくてよい——この構成の利点です。
前回の『Cocos Creator ミニゲームプロジェクト構造』と合わせて読むと、全体像がつかみやすくなります。プロジェクト構造がコンテナ、ステートマシンがその上で動く振る舞いロジックです。
まとめ
ステートマシンとは、ゲームフローを明確なノードに分解することです。
三層——ゲームフロー層、戦闘フロー層、戦闘詳細層——はミニゲームから大規模案件まで使えます。1 層を直しても他層に波及しにくく、チーム分担もしやすい構成です。
理論より、落とし穴対策のほうが価値があります。ステートドリフト、同時実行競合、リザルト改ざん、タイムアウトフリーズ——どれも調査に時間を取られました。状態ロック、ハートビート、サーバー検証、タイムアウトを入れてから、ようやく安定しました。最初から正しく書けば、バグは大幅に減ります。
手を動かすなら、完全なコードサンプル(GitHub リンク)も用意しています。まず前回記事で Layer の切り方を押さえ、今回でステートマシンの動き方を理解してください。2 本セットで、アーキテクチャの輪郭が見えてきます。
次回は、AI によるステートマシンテスト——遷移パスを網羅するテストケース生成——について触れる予定です。テストが自動化されれば、同じ罠を踏む確率はさらに下がります。
ミニゲームの三層ステートマシンアーキテクチャを設計する
ホームから戦闘、リザルトまでの完全な状態管理フロー
⏱️ 目安時間: 60 分
- 1
ステップ1: ゲームフロー層の状態を定義する
ゲームのメインフロー状態を列挙する:BootState、HomeState、BattleState、SettlementState。各状態を Layer またはシーンに対応させ、状態間の遷移ルールを決める。 - 2
ステップ2: 戦闘フロー層のサブ状態を分割する
BattleState 内部にサブステートマシンを構築する:BeginBattleState(初期化とロック)、PlayerTurnState(プレイヤーターン)、EnemyTurnState(任意の AI ターン)、EndBattleState(リザルト判定)。 - 3
ステップ3: 状態インターフェースとマネージャーを実装する
IGameState インターフェース(name、enter、exit、update、handleEvent)を作成し、GameStateManager で状態切り替え(changeState)とスタック操作(pushState、popState)を管理する。 - 4
ステップ4: 落とし穴対策の保護機構を追加する
重要な状態(配札、リザルト)にロックを入れて同時実行競合を防ぎ、タイムアウトでフリーズを回避し、ハートビートでステートドリフトを解消し、サーバー側の独立計算でリザルト改ざんを防ぐ。 - 5
ステップ5: Cocos Creator の Layer と接続する
enter で対応する Layer を表示してデータを初期化し、exit で Layer を非表示にしてリソースを解放する。ステートマシンが UI 切り替えを駆動し、コードロジックと見た目を分離する。
FAQ
ミニゲームに必ずステートマシンが必要ですか?
三層ステートマシンは過剰設計ではありませんか?
ステートマシンとシーン切り替えの違いは何ですか?
マルチプレイ対戦でステートドリフトはどう解決しますか?
リザルトデータの改ざんはどう防ぎますか?
5分で読めます · 公開日: 2026年5月19日 · 更新日: 2026年6月8日
AI と Cocos 小ゲーム開発実践
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
インディー開発者のミニゲーム:まずゲームプレイを検証し、システムは後から(MVP 実践ガイド)
インディー開発者がミニゲームを作るときに最も陥りやすい罠は、システムを積み上げてからコアゲームプレイが面白くないと気づくことです。本記事では MVP 検証の道筋を解説します。コアループの特定からプロトタイプテストまで、最小コストでゲームに価値があるか確認する方法を、実践事例と失敗談とともに紹介します。
第 1 / 18 記事
次の記事
AI で Cocos シーン説明ドキュメントを生成:コードアシスタントにゲームを正しく理解させる
AI が Cocos Creator ゲームのプロジェクト構造を理解できない課題を解決。CLAUDE.md の設定、シーン説明ドキュメント自動生成用プロンプト、MCP Server 案を紹介し、Claude Code / Cursor にプロジェクトを正しく把握させる方法。
第 3 / 18 記事
関連記事
Cocos Creator AI アート素材整理実践:生成からインポートまでの完全ワークフロー
Cocos Creator AI アート素材整理実践:生成からインポートまでの完全ワークフロー
Cocos スプライトシート実践:1 枚の大画像を複数のアニメーションフレームに分割する完全ガイド
Cocos スプライトシート実践:1 枚の大画像を複数のアニメーションフレームに分割する完全ガイド
ミニゲーム UI 素材の命名規則:透明画像、ボタン、アイコン、キャラクター素材
コメント
GitHubアカウントでログインしてコメントできます