【Cocos Creator】アタッチするだけ!StateMachine (ステートマシン)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】StateMachineの実装:アタッチするだけで「Idle / Run / Jump などの状態遷移管理」を実現する汎用スクリプト

ゲームが少し複雑になると、「今は待機中(Idle)なのか」「走っている(Run)のか」「ジャンプ中(Jump)なのか」といった「状態」をきれいに管理したくなります。この記事では、任意のノードにアタッチするだけで、ノード配下の子ノードを「状態」とみなし、名前ベースで状態遷移を制御できる汎用ステートマシンコンポーネントを実装します。

この StateMachine コンポーネントを使うと、

  • Idle / Run / Jump などを「子ノード」として整理しておき
  • インスペクタから初期状態や遷移ルールを設定し
  • コードまたはイベントから状態名を指定して切り替える

といったことが、他のカスタムスクリプトに一切依存せず実現できます。


コンポーネントの設計方針

1. コンセプト

この StateMachine は、「このコンポーネントがアタッチされたノードの直下の子ノード」を状態として扱う設計にします。

  • 親ノード:StateMachine をアタッチするノード(例:Player)
  • 子ノード:状態を表すノード(例:Idle, Run, Jump)

各「状態ノード」は、アニメーションやスプライト、サウンド、パーティクルなど、その状態で必要な見た目や処理をまとめたコンテナとして使えます。StateMachine は「どの子ノードを有効化するか/無効化するか」を切り替えることで、状態遷移を実現します。

2. 外部依存をなくす設計

完全に独立したコンポーネントとするため、以下のように設計します。

  • 他の GameManager やシングルトンには一切依存しない。
  • 状態名はすべて 文字列 として管理し、インスペクタから設定可能にする。
  • 状態遷移のルール(どの状態からどの状態へ遷移可能か)も、インスペクタの配列で定義できるようにする。
  • 現在状態の名前をインスペクタで確認できるようにし、デバッグしやすくする。
  • 状態切り替え時にイベントを飛ばしたい場合に備え、cc.EventTarget を内部で使い、外部からも購読できるようにする(ただし、購読側もこのコンポーネント1つに依存するだけ)。

3. インスペクタで設定可能なプロパティ設計

StateMachine コンポーネントが持つプロパティと、その役割は以下の通りです。

  • initialStateName: string
    – 初期状態の名前。
    – 親ノードの子ノードのうち、この名前と一致するノードを初期状態として有効化します。
    – 空文字のままだと、「最初に見つかった子ノード」を初期状態として使用します。
  • autoInitializeOnLoad: boolean
    – onLoad で自動的に状態ノードをスキャンし、初期状態へ遷移するかどうか。
    – true の場合:onLoad 時に子ノードを列挙し、初期状態に設定します。
    – false の場合:外部から手動で initialize() を呼び出して初期化します。
  • deactivateOtherStates: boolean
    – 状態遷移時に「現在状態以外の状態ノードをすべて非アクティブにする」かどうか。
    – true の場合:現在状態ノードのみ active = true、それ以外は active = false。
    – false の場合:現在状態ノードのみ active = true にし、他のノードの active は変更しません。
  • allowSelfTransition: boolean
    – すでにその状態であるときに、同じ状態名への遷移を許可するかどうか。
    – false の場合:同じ状態名への遷移要求は無視されます。
    – true の場合:同じ状態名でも onExitonEnter 相当の処理を行います。
  • logTransitions: boolean
    – 状態遷移時に console.log でログを出すかどうか。
    – デバッグ用途に便利です。
  • transitions: TransitionConfig[]
    – インスペクタで編集できる「遷移ルール」の配列。
    – 各要素は以下のフィールドを持ちます:
    • fromState: string … 遷移元状態名(空文字の場合、「どこからでも」遷移可能とみなす)
    • toState: string … 遷移先状態名
    • allowFromAny: boolean … true の場合、「fromState を無視してどこからでも toState に遷移可能」
  • currentStateName (readonly, editor-only)
    – 現在アクティブな状態名をインスペクタ表示用に保持します。
    – 外部から直接書き換えることは想定せず、changeState() 経由で変更します。

※ 状態ノードに対して特別なコンポーネント(例:StateBehaviour など)を要求せず、どんなノード構成でも状態として扱える汎用設計にします。


TypeScriptコードの実装

ここから、実際の StateMachine.ts の実装コードを示します。


import { _decorator, Component, Node, CCString, CCBoolean, EventTarget } from 'cc';
const { ccclass, property, executeInEditMode, menu } = _decorator;

/**
 * 遷移ルール1件分の設定を表すシリアライズ可能クラス
 */
@ccclass('StateMachineTransitionConfig')
export class StateMachineTransitionConfig {
    @property({
        type: CCString,
        tooltip: '遷移元の状態名。\n空文字のままの場合、この設定は「fromState を特定しない(allowFromAny=true と同等)」として扱われます。'
    })
    public fromState: string = '';

    @property({
        type: CCString,
        tooltip: '遷移先の状態名。この名前と一致する子ノードが存在する必要があります。'
    })
    public toState: string = '';

    @property({
        type: CCBoolean,
        tooltip: 'true の場合、fromState を無視して「どの状態からでも」この toState に遷移可能とみなします。'
    })
    public allowFromAny: boolean = false;
}

/**
 * 汎用ステートマシンコンポーネント
 * - このコンポーネントがアタッチされたノードの「直下の子ノード」を状態として扱います。
 * - 状態名は子ノードの name と一致させてください。
 */
@ccclass('StateMachine')
@executeInEditMode(true)
@menu('Custom/StateMachine')
export class StateMachine extends Component {

    @property({
        type: CCString,
        tooltip: '初期状態の名前。\n空文字のままの場合、最初に見つかった子ノードを初期状態として使用します。'
    })
    public initialStateName: string = '';

    @property({
        type: CCBoolean,
        tooltip: 'true の場合、onLoad 時に自動的に状態ノードをスキャンし、初期状態へ遷移します。'
    })
    public autoInitializeOnLoad: boolean = true;

    @property({
        type: CCBoolean,
        tooltip: 'true の場合、状態遷移時に「現在状態以外の子ノード」を全て非アクティブにします。'
    })
    public deactivateOtherStates: boolean = true;

    @property({
        type: CCBoolean,
        tooltip: 'true の場合、同じ状態名への遷移も許可し、onExit/onEnter 相当の処理を再度行います。'
    })
    public allowSelfTransition: boolean = false;

    @property({
        type: CCBoolean,
        tooltip: 'true の場合、状態遷移時に console.log でログを出力します。'
    })
    public logTransitions: boolean = true;

    @property({
        type: [StateMachineTransitionConfig],
        tooltip: '状態遷移ルールの一覧です。\nfromState から toState への遷移が許可されます。\nallowFromAny が true の場合、どの状態からでも toState へ遷移可能になります。'
    })
    public transitions: StateMachineTransitionConfig[] = [];

    @property({
        type: CCString,
        readonly: true,
        tooltip: '現在アクティブな状態名(編集不可)。\n実行中に自動で更新されます。'
    })
    public currentStateName: string = '';

    // 内部イベントターゲット(状態遷移イベント用)
    private _eventTarget: EventTarget = new EventTarget();

    // 内部キャッシュ:状態名 → 子ノード
    private _stateNodeMap: Map<string, Node> = new Map();

    // 初期化済みフラグ
    private _initialized: boolean = false;

    /**
     * ランタイムまたはエディタ上で、子ノードをスキャンして状態一覧を構築します。
     * autoInitializeOnLoad が true の場合は onLoad から自動で呼ばれます。
     */
    public initialize(): void {
        const owner = this.node;
        if (!owner) {
            console.error('[StateMachine] このコンポーネントは Node にアタッチされている必要があります。');
            return;
        }

        this._stateNodeMap.clear();

        // 直下の子ノードを全てスキャンし、状態として登録
        const children = owner.children;
        for (const child of children) {
            if (!child) continue;
            const stateName = child.name;
            if (!stateName) continue;

            if (this._stateNodeMap.has(stateName)) {
                console.warn(`[StateMachine] 同じ名前の状態ノードが複数存在します: "${stateName}"。最初に見つかったノードのみ使用されます。`);
                continue;
            }
            this._stateNodeMap.set(stateName, child);
        }

        if (this._stateNodeMap.size === 0) {
            console.warn('[StateMachine] 子ノードが存在しません。少なくとも1つ以上の状態ノード(子ノード)を作成してください。');
        }

        // 初期状態を決定
        let initial = this.initialStateName;
        if (!initial) {
            // 未設定の場合は、最初に見つかった子ノードを初期状態とする
            const firstEntry = this._stateNodeMap.entries().next();
            if (!firstEntry.done) {
                initial = firstEntry.value[0];
                if (this.logTransitions) {
                    console.log(`[StateMachine] initialStateName が未設定のため、最初の状態 "${initial}" を初期状態として使用します。`);
                }
            }
        }

        if (initial) {
            this._applyState(initial, true);
        } else {
            // 初期状態が決められない場合、全ての子ノードを非アクティブにするかどうかは deactivateOtherStates に従う
            if (this.deactivateOtherStates) {
                for (const child of children) {
                    child.active = false;
                }
            }
            this.currentStateName = '';
        }

        this._initialized = true;
    }

    /**
     * onLoad
     * - autoInitializeOnLoad が true の場合、自動的に initialize() を呼び出します。
     */
    protected onLoad(): void {
        if (this.autoInitializeOnLoad) {
            this.initialize();
        }
    }

    /**
     * エディタ上での変化にも対応するため、update では特別な処理は行いません。
     * 状態遷移は changeState() を明示的に呼び出すことで行います。
     */
    protected update(deltaTime: number): void {
        // このコンポーネント自体はフレーム毎の処理を持ちません。
        // 必要に応じて、状態ノードにアタッチした別コンポーネントの update を利用してください。
    }

    /**
     * 状態遷移を要求します。
     * @param nextStateName 遷移先の状態名(子ノード名)
     * @returns 遷移に成功した場合 true、失敗(許可されていない、存在しない等)の場合 false
     */
    public changeState(nextStateName: string): boolean {
        if (!nextStateName) {
            console.warn('[StateMachine] changeState() に空の状態名が指定されました。');
            return false;
        }

        if (!this._initialized) {
            // 必要に応じて自動初期化
            this.initialize();
        }

        const current = this.currentStateName;

        if (!this.allowSelfTransition && current === nextStateName) {
            if (this.logTransitions) {
                console.log(`[StateMachine] 既に状態 "${current}" のため、同一状態への遷移はスキップされました。`);
            }
            return false;
        }

        // 遷移先ノードが存在するかチェック
        const nextNode = this._stateNodeMap.get(nextStateName);
        if (!nextNode) {
            console.warn(`[StateMachine] 状態 "${nextStateName}" に対応する子ノードが見つかりません。`);
            return false;
        }

        // 遷移ルールのチェック
        if (!this._canTransition(current, nextStateName)) {
            console.warn(`[StateMachine] "${current}" から "${nextStateName}" への遷移は許可されていません。`);
            return false;
        }

        // 実際の状態適用
        this._applyState(nextStateName, false);

        return true;
    }

    /**
     * 現在の状態名を返します。
     */
    public getCurrentState(): string {
        return this.currentStateName;
    }

    /**
     * 状態遷移イベントを購読します。
     * - "state-changed" イベント: (fromState: string, toState: string) が引数として渡されます。
     */
    public onStateChanged(callback: (fromState: string, toState: string) => void, target?: any): void {
        this._eventTarget.on('state-changed', callback, target);
    }

    /**
     * 状態遷移イベントの購読を解除します。
     */
    public offStateChanged(callback: (fromState: string, toState: string) => void, target?: any): void {
        this._eventTarget.off('state-changed', callback, target);
    }

    /**
     * 遷移ルールに基づいて、current -> next への遷移が許可されているかどうかを判定します。
     */
    private _canTransition(current: string, next: string): boolean {
        // 遷移ルールが1件もない場合、全ての遷移を許可する
        if (!this.transitions || this.transitions.length === 0) {
            return true;
        }

        for (const rule of this.transitions) {
            if (!rule) continue;
            if (!rule.toState) continue;

            // toState が一致しないならスキップ
            if (rule.toState !== next) {
                continue;
            }

            // allowFromAny が true なら、どの状態からでも遷移可能
            if (rule.allowFromAny) {
                return true;
            }

            // fromState が空文字の場合も「どこからでも」と解釈
            if (!rule.fromState) {
                return true;
            }

            // current と fromState が一致する場合に遷移許可
            if (rule.fromState === current) {
                return true;
            }
        }

        // どのルールにもマッチしなかった場合は遷移不可
        return false;
    }

    /**
     * 実際に状態を切り替え、ノードの active を更新します。
     * @param nextStateName 遷移先の状態名
     * @param isInitial 初期化時の適用かどうか(true の場合、ログ出力等を少し抑制)
     */
    private _applyState(nextStateName: string, isInitial: boolean): void {
        const owner = this.node;
        const children = owner.children;

        const prevState = this.currentStateName;
        const nextNode = this._stateNodeMap.get(nextStateName) || null;

        if (!nextNode) {
            console.warn(`[StateMachine] _applyState: 状態 "${nextStateName}" に対応するノードが見つかりません。`);
            return;
        }

        // ノードの active を更新
        for (const child of children) {
            if (!child) continue;

            if (child === nextNode) {
                child.active = true;
            } else if (this.deactivateOtherStates) {
                child.active = false;
            }
        }

        this.currentStateName = nextStateName;

        if (this.logTransitions) {
            if (isInitial) {
                console.log(`[StateMachine] 初期状態を "${nextStateName}" に設定しました。`);
            } else {
                console.log(`[StateMachine] 状態遷移: "${prevState}" → "${nextStateName}"`);
            }
        }

        // イベント発火(初期化時も通知したい場合は isInitial に関わらず発火)
        this._eventTarget.emit('state-changed', prevState, nextStateName);
    }
}

コードのポイント解説

  • StateMachineTransitionConfig クラス
    – 遷移ルール1件分を表すシリアライズクラス。
    @property([StateMachineTransitionConfig]) によって、インスペクタ上で配列として編集できます。
  • initialize()
    – 親ノードの直下の子ノードを全てスキャンし、_stateNodeMap に「状態名 → ノード」として登録します。
    initialStateName が設定されていればそれを、未設定なら最初の子ノードを初期状態として _applyState() で有効化します。
  • onLoad()
    autoInitializeOnLoad が true の場合、自動的に initialize() を呼びます。
    – false の場合、外部から initialize() を明示的に呼ぶ運用も可能です。
  • changeState(nextStateName)
    – 外部から状態名を指定して状態遷移を要求する公開メソッドです。
    – 初期化されていない場合は内部で initialize() を呼びます。
    – 遷移ルール (transitions) に基づき、許可されていない遷移は拒否します。
    – 遷移成功時には _applyState() を通じてノードの active を更新し、イベントを発火します。
  • _canTransition(current, next)
    transitions 配列を走査し、toStatefromState / allowFromAny に基づいて遷移可否を判定します。
    – ルールが1件もない場合は「全ての遷移を許可」するシンプルな挙動です。
  • _applyState(nextStateName, isInitial)
    – 実際にノードの active を切り替えるメソッドです。
    deactivateOtherStates が true の場合、遷移先以外の子ノードを全て非アクティブにします。
    currentStateName を更新し、ログ出力とイベント発火を行います。
  • イベント購読 API
    onStateChanged(callback) / offStateChanged(callback) で、'state-changed' イベントを購読・解除できます。
    – コールバックには (fromState: string, toState: string) が渡されるので、状態変更に応じた処理を簡単に追加できます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ左下の Assets パネルで、任意のフォルダ(例:assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を StateMachine.ts にします。
  3. 自動生成されたファイルをダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用ノードと状態ノードの作成

ここでは簡単な例として、「Player ノードの下に Idle / Run / Jump という3つの状態ノードを作る」手順を示します。

  1. Hierarchy パネルで右クリック → Create → Empty Node を選択し、ノード名を Player に変更します。
  2. Player ノードを選択した状態で、右クリック → Create → Empty Node を3回行い、それぞれの名前を以下のように変更します:
    • Idle
    • Run
    • Jump
  3. 各状態ノードに、見た目が分かりやすいように適当なコンポーネントを追加しておきます(任意)。
    • 例:Idle ノードに青色の Sprite
    • 例:Run ノードに緑色の Sprite
    • 例:Jump ノードに赤色の Sprite

この時点で、Hierarchy は以下のような構造になります:

Player
 ├─ Idle
 ├─ Run
 └─ Jump

3. StateMachine コンポーネントをアタッチ

  1. HierarchyPlayer ノードを選択します。
  2. Inspector パネル右下の Add Component ボタンをクリックします。
  3. 表示されたメニューから Custom → StateMachine を選択します(@menu('Custom/StateMachine') によって表示されます)。

4. インスペクタでプロパティを設定

Player ノードの Inspector に追加された StateMachine コンポーネントの各プロパティを、次のように設定してみましょう。

  • Initial State Name: Idle
    – 初期状態を Idle にします。
  • Auto Initialize On Load: チェック(true)
    – シーン読み込み時に自動初期化します。
  • Deactivate Other States: チェック(true)
    – 現在状態以外のノードは自動的に非アクティブになります。
  • Allow Self Transition: 任意(とりあえず OFF のままで OK)
  • Log Transitions: チェック(true)
    – コンソールに状態遷移ログが出るようにします。
  • Transitions: 3つほどルールを追加してみます。

Transitions の設定例:

  1. 配列の右側にある ボタンをクリックして要素を3つ追加します。
  2. 各要素を次のように設定します。
Element 0:
  fromState: Idle
  toState:   Run
  allowFromAny: false

Element 1:
  fromState: Run
  toState:   Jump
  allowFromAny: false

Element 2:
  fromState: (空のまま)
  toState:   Idle
  allowFromAny: true   // どの状態からでも Idle に戻れる

この設定により、

  • Idle → Run
  • Run → Jump
  • どこからでも → Idle

という3種類の遷移が許可されます。

5. 簡単なテストスクリプトで状態遷移を確認する

StateMachine は単体で完結していますが、動作確認のために「キー入力で状態を切り替える」簡単なテストコンポーネントを用意してみます(任意)。

  1. Assets パネルで右クリック → Create → TypeScript を選択し、ファイル名を StateMachineTester.ts にします。
  2. 以下のようなシンプルなコードを貼り付けます。

import { _decorator, Component, input, Input, EventKeyboard, KeyCode } from 'cc';
import { StateMachine } from './StateMachine';
const { ccclass, property } = _decorator;

@ccclass('StateMachineTester')
export class StateMachineTester extends Component {

    @property({
        type: StateMachine,
        tooltip: '同じノード、または別ノードにアタッチされた StateMachine コンポーネントへの参照'
    })
    public stateMachine: StateMachine | null = null;

    protected onLoad(): void {
        if (!this.stateMachine) {
            // 同じノードにある StateMachine を自動取得
            this.stateMachine = this.getComponent(StateMachine);
            if (!this.stateMachine) {
                console.error('[StateMachineTester] StateMachine コンポーネントへの参照が設定されていません。');
            }
        }
    }

    protected onEnable(): void {
        input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
    }

    protected onDisable(): void {
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
    }

    private _onKeyDown(event: EventKeyboard): void {
        if (!this.stateMachine) {
            return;
        }

        switch (event.keyCode) {
            case KeyCode.KEY_1:
                this.stateMachine.changeState('Idle');
                break;
            case KeyCode.KEY_2:
                this.stateMachine.changeState('Run');
                break;
            case KeyCode.KEY_3:
                this.stateMachine.changeState('Jump');
                break;
        }
    }
}

このテストスクリプトは完全に任意ですが、以下のように使えます。

  1. Hierarchy で Player ノードを選択。
  2. Inspector の Add ComponentCustom → StateMachineTester を追加。
  3. StateMachineTester の stateMachine プロパティは空のままで OK(同じノードにある StateMachine を自動取得します)。
  4. シーンを再生し、ゲームビュー上で 1 / 2 / 3 キー を押してみます。
    • 1キー:Idle 状態へ
    • 2キー:Run 状態へ(Idle → Run のルールがあるため成功)
    • 3キー:Jump 状態へ(Run → Jump のルールがあるため、先に 2 を押して Run にしてから 3 を押す必要あり)
  5. Console パネルに、初期状態を "Idle" に設定しました状態遷移: "Idle" → "Run" といったログが表示されれば成功です。

6. エディタ上での確認ポイント

  • シーン再生中に Hierarchy を見ると、現在状態のノードだけが active = true になっていることを確認できます(deactivateOtherStates = true の場合)。
  • Player ノードを選択して Inspector を見ると、StateMachine コンポーネントの Current State Name プロパティが、現在の状態名に変化しているのが分かります。

まとめ

この記事では、Cocos Creator 3.8.7 と TypeScript を使って、

  • 任意のノードにアタッチするだけで
  • その直下の子ノードを「状態」として扱い
  • Idle / Run / Jump などの状態遷移を、名前とルールで管理できる

汎用 StateMachine コンポーネントを実装しました。

このコンポーネントは、

  • 他のカスタムスクリプトやシングルトンに一切依存せず、この1ファイルだけで完結している
  • インスペクタ上で初期状態や遷移ルールを設定できるため、ゲームデザイナーでも編集しやすい
  • 状態ごとに子ノードを分けることで、見た目やサウンドなどを状態単位で整理しやすい

といった利点があります。

応用例としては、

  • プレイヤーキャラクターのアクション状態管理(Idle / Walk / Run / Jump / Attack …)
  • UI ウィジェットの表示状態管理(Normal / Hover / Pressed / Disabled …)
  • 敵 AI の行動パターン管理(Patrol / Chase / Attack / Flee …)

など、状態を名前で整理したいあらゆる場面で活用できます。

プロジェクト内のあらゆるノードにこの StateMachine をアタッチし、「状態ごとに子ノードを分ける」だけでロジックをきれいに整理できるようになるので、ぜひ自分のゲームに合わせてカスタマイズしながら活用してみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!