言語を切り替える
テーマを切り替える

ミニゲームのステートマシン設計:ホームから戦闘、リザルトまでの完全フロー

"カードゲームのバグの 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 変数を探し回る必要があります。isPlayingisPausedisGameOver——ピースがバラバラに散らばっていて、自分で全体像を組み立てるしかありません。

変更しにくい:「一時停止」状態を 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 が復帰します。

一時停止、確認ダイアログ、モーダル——現在状態を壊さずに上から被せたい場面で必須の仕組みです。

実戦の落とし穴と対策

理論の次は、実際に踏んだ罠です。搜狐の記事がまとめる棋牌ゲームの教訓のうち、特に多い問題を掘り下げます。

ステートドリフト:サーバーとクライアントの不一致

マルチプレイで最も踏みやすい罠です。

典型例:サーバーはすでにリザルト状態なのに、あるクライアントだけ戦闘アニメーションを再生中——遷移通知が遅延または欠落したためです。画面は「戦闘中」のまま、実際は終了済み。

原因:状態切り替えメッセージの欠落、または遅延が大きすぎる。

対策

  1. 状態同期:サーバーが遷移したら全クライアントへブロードキャストし、クライアントは受信後すぐ同期する。

  2. ハートビート検知:クライアントが数秒ごとに現在状態を添えてハートビートを送り、サーバーが不一致を検知したら強制同期する。

// クライアント側ハートビート
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 を切り替える——ステートマシンと相性がよい構成です。

BootLayerHomeLayerBattleLayerSettlementLayer

各 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

    ステップ1: ゲームフロー層の状態を定義する

    ゲームのメインフロー状態を列挙する:BootState、HomeState、BattleState、SettlementState。各状態を Layer またはシーンに対応させ、状態間の遷移ルールを決める。
  2. 2

    ステップ2: 戦闘フロー層のサブ状態を分割する

    BattleState 内部にサブステートマシンを構築する:BeginBattleState(初期化とロック)、PlayerTurnState(プレイヤーターン)、EnemyTurnState(任意の AI ターン)、EndBattleState(リザルト判定)。
  3. 3

    ステップ3: 状態インターフェースとマネージャーを実装する

    IGameState インターフェース(name、enter、exit、update、handleEvent)を作成し、GameStateManager で状態切り替え(changeState)とスタック操作(pushState、popState)を管理する。
  4. 4

    ステップ4: 落とし穴対策の保護機構を追加する

    重要な状態(配札、リザルト)にロックを入れて同時実行競合を防ぎ、タイムアウトでフリーズを回避し、ハートビートでステートドリフトを解消し、サーバー側の独立計算でリザルト改ざんを防ぐ。
  5. 5

    ステップ5: Cocos Creator の Layer と接続する

    enter で対応する Layer を表示してデータを初期化し、exit で Layer を非表示にしてリソースを解放する。ステートマシンが UI 切り替えを駆動し、コードロジックと見た目を分離する。

FAQ

ミニゲームに必ずステートマシンが必要ですか?
必須ではありません。ただし状態が増えると(ホーム、戦闘、一時停止、リザルトなど)if-else が散らばって混乱します。ステートマシンならロジックを一箇所に集約でき、変更も安心してできます。
三層ステートマシンは過剰設計ではありませんか?
小さなミニゲームなら三層は不要かもしれません。ただこの構成を理解しておけば、複雑な場面にも対応できます。シンプルなゲームは二層、複雑なゲームは三層に拡張すればよいです。
ステートマシンとシーン切り替えの違いは何ですか?
シーン切り替えは Cocos のリソース管理の概念で、ステートマシンはロジックの概念です。1 つのシーンが 1 つの状態に対応することもあれば、1 つの状態が UI の表示・非表示だけを管理することもあります。
マルチプレイ対戦でステートドリフトはどう解決しますか?
サーバー側で状態切り替えをブロードキャストし、クライアント側でハートビート検知を行います。3 秒ごとにハートビートを送り、サーバーが不一致を検知したら強制同期します。
リザルトデータの改ざんはどう防ぎますか?
サーバー側で独立して計算し、クライアントからアップロードされた結果は信用しません。クライアントは表示だけを担当し、判定ロジックはすべてサーバー側に置きます。

5分で読めます · 公開日: 2026年5月19日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます