【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」などの子ノード状態を切り替えて管理する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで、その子ノードを「状態」として扱い、「Idle」「Run」「Jump」などのうち現在の状態だけをアクティブにし、他は自動で停止してくれる汎用ステートマシンコンポーネント StateMachine を実装します。

アニメーション状態、UI画面の切り替え、チュートリアルステップ管理など、「いくつかの状態ノードのうち、今はこれだけ動かしたい」という場面で、そのノードにこのコンポーネントを付けて状態名を切り替えるだけで運用できるようにします。


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

1. 目的と要件整理

  • このコンポーネントをアタッチしたノードの「子ノード」を「状態」とみなす。
  • 常に1つだけの状態ノードをアクティブ(active = true)にし、その他は非アクティブ (active = false) にする。
  • 状態は文字列の名前で管理する(例: "Idle", "Run")。
  • インスペクタで「初期状態名」「デフォルト状態名」などを設定できるようにする。
  • 外部の GameManager やシングルトンには一切依存しない
  • 他スクリプトからも使いやすいように、setState() / getCurrentState() などのAPIを提供する。
  • 防御的実装として、存在しない状態名を指定された場合のログ出力や、同名ノードが複数ある場合の警告を行う。

2. 状態ノードの扱い

このコンポーネントでは、次のような前提で状態を扱います:

  • 状態は「このコンポーネントを付けたノードの直下の子ノード」として配置する。
  • 子ノードの 名前 をそのまま「状態名」として扱う。
  • 状態ノードの下にさらにアニメーションやスプライト、UIなどを自由に配置してよい。

例: Player ノード構造

Player (StateMachine をアタッチ)
├─ Idle   (Idle状態用の見た目・処理)
├─ Run    (Run状態用の見た目・処理)
└─ Jump   (Jump状態用の見た目・処理)

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

今回の StateMachine コンポーネントでは、以下のプロパティを用意します。

  • initialStateName: string
    • ツールチップ: 起動時に有効にする状態名。未設定または該当ノードがない場合は fallbackStateName を使用します。
    • 説明: start() 実行時にこの状態へ切り替えを試みます。
  • fallbackStateName: string
    • ツールチップ: initialStateName が無効だった場合に使用するフォールバック状態名。
    • 説明: 例えば initialStateName = "Idle" だが該当ノードがない場合に、こちらの状態名を試します。
  • deactivateOthersOnStart: boolean
    • ツールチップ: 起動時に現在の状態以外の子ノードをすべて非アクティブにします。
    • 説明: true の場合、状態管理が明確になり、不要な状態の処理を止められます。
  • logLevel: number (enum風)
    • ツールチップ: ログ出力レベル。0=ログなし, 1=エラーのみ, 2=警告も, 3=情報ログも。
    • 説明: 状態遷移時のログ量を制御します。
  • autoRegisterChildren: boolean
    • ツールチップ: onLoad 時に直下の子ノードを自動的に状態として登録します。
    • 説明: 通常は true で問題ありません。動的に子ノードを増減する特殊ケースで制御したい場合に false にできます。
  • allowSameStateTransition: boolean
    • ツールチップ: 現在と同じ状態名への setState 呼び出しを許可するかどうか。
    • 説明: false の場合、同じ状態指定は無視し、ログのみ出力します。

これらのプロパティにより、単体スクリプトで柔軟に挙動を調整できます。


TypeScriptコードの実装

以下が完成した StateMachine.ts の全コードです。


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

/**
 * StateMachine
 * 
 * このコンポーネントをアタッチしたノードの「直下の子ノード」を状態として扱い、
 * 現在の状態ノードのみ active = true にし、他は active = false にします。
 * 
 * 状態名は子ノード名と一致させてください(例: "Idle", "Run" など)。
 */
@ccclass('StateMachine')
export class StateMachine extends Component {

    @property({
        type: CCString,
        tooltip: '起動時に有効にする状態名。未設定または該当ノードがない場合は fallbackStateName を使用します。'
    })
    public initialStateName: string = '';

    @property({
        type: CCString,
        tooltip: 'initialStateName が無効だった場合に使用するフォールバック状態名。'
    })
    public fallbackStateName: string = '';

    @property({
        type: CCBoolean,
        tooltip: '起動時に現在の状態以外の子ノードをすべて非アクティブにします。'
    })
    public deactivateOthersOnStart: boolean = true;

    @property({
        type: CCBoolean,
        tooltip: 'onLoad 時に直下の子ノードを自動的に状態として登録します。'
    })
    public autoRegisterChildren: boolean = true;

    @property({
        type: CCBoolean,
        tooltip: '現在と同じ状態名への setState 呼び出しを許可するかどうか。false の場合は無視します。'
    })
    public allowSameStateTransition: boolean = false;

    @property({
        type: CCInteger,
        tooltip: 'ログ出力レベル。0=ログなし, 1=エラーのみ, 2=警告も, 3=情報ログも。'
    })
    public logLevel: number = 2;

    // 現在の状態名
    private _currentStateName: string = '';

    // 状態名 → ノード
    private _stateNodes: Map<string, Node> = new Map();

    //#region ライフサイクル

    onLoad() {
        // 自動登録が有効なら、直下の子ノードを状態として登録
        if (this.autoRegisterChildren) {
            this.registerAllChildStates();
        }
    }

    start() {
        // 起動時の初期状態を決定
        let targetState = this.initialStateName.trim();

        if (!targetState) {
            // initialStateName が空なら fallbackStateName を試す
            targetState = this.fallbackStateName.trim();
            if (this.logLevel >= 2) {
                console.warn('[StateMachine] initialStateName が未設定のため、fallbackStateName を使用します。');
            }
        }

        if (targetState) {
            const success = this.setState(targetState);
            if (!success && this.logLevel >= 1) {
                console.error(`[StateMachine] 初期状態 "${targetState}" の設定に失敗しました。該当する子ノードが存在しません。`);
            }
        } else if (this.deactivateOthersOnStart) {
            // どの状態も指定されていない場合、全て非アクティブにするかどうか
            this.deactivateAllStates();
            if (this.logLevel >= 2) {
                console.warn('[StateMachine] 初期状態が指定されていないため、全ての状態ノードを非アクティブにしました。');
            }
        }
    }

    //#endregion

    //#region 公開API

    /**
     * 指定した状態名に遷移します。
     * @param stateName 遷移先の状態名(子ノード名)
     * @returns 遷移に成功した場合 true, 失敗した場合 false
     */
    public setState(stateName: string): boolean {
        const trimmed = stateName.trim();
        if (!trimmed) {
            if (this.logLevel >= 1) {
                console.error('[StateMachine] 空の状態名は指定できません。');
            }
            return false;
        }

        if (!this.allowSameStateTransition && trimmed === this._currentStateName) {
            if (this.logLevel >= 3) {
                console.info(`[StateMachine] 現在と同じ状態 "${trimmed}" への遷移要求は無視されました。`);
            }
            return true; // 状態は変わっていないが、エラーではない
        }

        const targetNode = this._stateNodes.get(trimmed) || this.findStateNodeByName(trimmed);

        if (!targetNode) {
            if (this.logLevel >= 1) {
                console.error(`[StateMachine] 状態 "${trimmed}" に対応する子ノードが見つかりません。`);
            }
            return false;
        }

        // 他の状態を非アクティブにし、対象のみアクティブにする
        this.activateOnly(targetNode);

        const prev = this._currentStateName;
        this._currentStateName = trimmed;

        if (this.logLevel >= 3) {
            console.info(`[StateMachine] 状態遷移: "${prev}" → "${trimmed}"`);
        }

        return true;
    }

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

    /**
     * 状態名に対応するノードを取得します。(存在しない場合は null)
     */
    public getStateNode(stateName: string): Node | null {
        const trimmed = stateName.trim();
        if (!trimmed) {
            return null;
        }
        const node = this._stateNodes.get(trimmed) || this.findStateNodeByName(trimmed);
        return node || null;
    }

    /**
     * 直下の子ノードを全て状態として再登録します。
     * 動的に子ノードを増減した場合に呼び出してください。
     */
    public registerAllChildStates(): void {
        this._stateNodes.clear();

        const children = this.node.children;
        const nameCount: Record<string, number> = {};

        for (const child of children) {
            const name = child.name;
            this._stateNodes.set(name, child);

            // 同名ノードの検出
            nameCount[name] = (nameCount[name] || 0) + 1;
        }

        // 同名ノードが複数ある場合は警告
        if (this.logLevel >= 2) {
            for (const key in nameCount) {
                if (nameCount[key] > 1) {
                    console.warn(`[StateMachine] 状態名 "${key}" に対応する子ノードが ${nameCount[key]} 個存在します。最初に見つかったノードのみ使用されます。`);
                }
            }
        }

        if (this.logLevel >= 3) {
            console.info(`[StateMachine] 子ノードから状態を再登録しました。状態数: ${this._stateNodes.size}`);
        }
    }

    //#endregion

    //#region 内部処理

    /**
     * 指定ノードのみアクティブにし、他の状態ノードを全て非アクティブにします。
     */
    private activateOnly(target: Node): void {
        if (!this.deactivateOthersOnStart && !this._currentStateName) {
            // 起動時に「他を非アクティブにしない」設定で、まだ一度も状態が設定されていない場合は、
            // 単純に target だけをアクティブにし、他は現状維持する。
            target.active = true;
            return;
        }

        const children = this.node.children;
        for (const child of children) {
            child.active = (child === target);
        }
    }

    /**
     * 全ての状態ノードを非アクティブにします。
     */
    private deactivateAllStates(): void {
        const children = this.node.children;
        for (const child of children) {
            child.active = false;
        }
        this._currentStateName = '';
    }

    /**
     * 子ノードから名前で状態ノードを検索し、見つかればマップに登録して返します。
     */
    private findStateNodeByName(name: string): Node | undefined {
        const children = this.node.children;
        for (const child of children) {
            if (child.name === name) {
                // 見つかったらマップに登録
                this._stateNodes.set(name, child);
                return child;
            }
        }
        return undefined;
    }

    //#endregion
}

コードのポイント解説

  • onLoad()
    • autoRegisterChildren が true の場合に、registerAllChildStates() を呼び出して直下の子ノードを状態として登録します。
    • 外部スクリプトへの依存は一切なく、this.node.children のみを使用しています。
  • start()
    • initialStateName → だめなら fallbackStateName の順に初期状態を決定し、setState() を呼び出します。
    • どちらも設定されていない場合、deactivateOthersOnStart が true なら全状態を非アクティブにします。
  • setState(stateName: string)
    • 指定された状態名に対応する子ノードを探し、見つかればそのノードだけをアクティブに、その他を非アクティブにします。
    • allowSameStateTransition が false の場合、現在と同じ状態名は無視し、ログのみ出力します。
    • 存在しない状態名が指定された場合はエラーログを出して false を返します。
  • registerAllChildStates()
    • 直下の子ノードを列挙して _stateNodes マップに登録します。
    • 同名ノードが複数あれば警告ログを出します(最初の1つだけを使用)。
  • activateOnly(target: Node)
    • 基本的には全子ノードを走査し、target だけ active = true に、他は active = false にします。
    • 起動直後で deactivateOthersOnStart = false の場合は、他ノードの状態を変えず、target だけを有効にする挙動としています。

使用手順と動作確認

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

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

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

ここではプレイヤーの状態管理を例にしますが、UI でも何でも構いません。

  1. Hierarchy パネルで右クリック → Create → Empty Node を選択し、ノード名を Player に変更します。
  2. Player ノードを選択した状態で、右クリック → Create → 2D Object → Sprite などを選択し、子ノードを3つ作成します。
    • 子ノード1の名前: Idle
    • 子ノード2の名前: Run
    • 子ノード3の名前: Jump
  3. 各子ノードに適当な見た目を設定します(Sprite の画像を変える、ラベルを付けるなど)。
    例: Idle は青い画像、Run は赤い画像、Jump は緑の画像にするなど。

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

  1. HierarchyPlayer ノードを選択します。
  2. Inspector パネルで Add Component ボタンをクリックします。
  3. Custom Component(または Custom カテゴリ)から StateMachine を選択して追加します。

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

Player ノードにアタッチされた StateMachine コンポーネントのプロパティを次のように設定してみましょう。

  • Initial State Name: Idle
  • Fallback State Name: Run
  • Deactivate Others On Start: チェック ON
  • Auto Register Children: チェック ON
  • Allow Same State Transition: チェック OFF(デフォルトのまま)
  • Log Level: 3(情報ログまで出す)

この設定により、ゲーム開始時に Idle 子ノードだけがアクティブになり、RunJump は自動で非アクティブになります。

5. プレビューで動作確認

  1. メインシーンに Player ノードが含まれていることを確認します。
  2. エディタ右上の Preview ボタンを押してゲームを実行します。
  3. シーンが開始すると、Idle 状態の見た目だけが表示されていることを確認します。
  4. コンソール(Console)に [StateMachine] 状態遷移: "" → "Idle" のようなログが表示されているはずです(Log Level = 3 の場合)。

6. ランタイムで状態を切り替えてみる(任意)

他のスクリプトから状態を切り替える場合も、このコンポーネント単体で完結します。
例として、スペースキーで IdleRun を切り替える簡単なスクリプトを別途作る場合:


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

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

    @property({ tooltip: '同じノード上の StateMachine コンポーネントを使用します。' })
    public useSameNodeStateMachine: boolean = true;

    private _stateMachine: StateMachine | null = null;

    onLoad() {
        if (this.useSameNodeStateMachine) {
            this._stateMachine = this.getComponent(StateMachine);
        }

        if (!this._stateMachine) {
            console.error('[StateTestController] StateMachine コンポーネントが見つかりません。同じノードにアタッチしてください。');
        }

        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    private onKeyDown(event: EventKeyboard) {
        if (!this._stateMachine) {
            return;
        }

        if (event.keyCode === KeyCode.SPACE) {
            const current = this._stateMachine.getCurrentState();
            const next = current === 'Idle' ? 'Run' : 'Idle';
            this._stateMachine.setState(next);
        }
    }
}

このように、StateMachine 自体は外部に依存せず、他スクリプトからも簡単に利用できます。


まとめ

  • StateMachine コンポーネントを任意のノードにアタッチするだけで、その直下の子ノードを「状態」として管理し、現在の状態だけをアクティブにするシンプルなステートマシンを実現しました。
  • 状態名は子ノード名で管理し、initialStateName / fallbackStateName / deactivateOthersOnStart などをインスペクタから調整できるため、他のカスタムスクリプトに依存せず柔軟に運用できます。
  • アニメーション状態切り替え、UI画面のページ切り替え、チュートリアルステップ管理など、「いくつかの状態ノードのうち1つだけを動かしたい」というシーンで、そのノードにこのコンポーネントを付けるだけで再利用できます。
  • 状態遷移用の setState()getCurrentState() を備えているため、他スクリプトからも簡潔に制御可能です。

このような「単体で完結する汎用コンポーネント」を積み重ねていくことで、プロジェクト間での再利用性が高まり、ゲーム開発のスピードと安定性を大きく向上させることができます。ぜひ自分のプロジェクト用にカスタマイズしながら活用してみてください。

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をコピーしました!