【Cocos Creator 3.8】SpriteFlipper の実装:アタッチするだけで「移動方向に合わせて自動で左右反転」する汎用スクリプト

2Dアクションや横スクロールゲームでは、「キャラが右に動いたら右向き、左に動いたら左向きに自動でスプライトが反転してほしい」というケースが非常に多くあります。
本記事では、ノードにアタッチするだけで、親ノードの移動方向(velocity.x)に合わせてスプライトを自動で左右反転する汎用コンポーネント「SpriteFlipper」を実装します。

Rigidbody2D で物理移動しているキャラにも、スクリプトで position を更新しているキャラにも対応できるよう、速度の取得方法をインスペクタから選べる設計にします。


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

要件整理

  • このコンポーネントをアタッチしたノード、またはその親ノードの「移動方向(x 方向)」を監視する。
  • 右向きに動いているときはスプライトを正方向(flipX = false)、左向きに動いているときは反転(flipX = true)にする。
  • 速度の取得方法を複数サポートする:
    • Rigidbody2D の velocity.x を読むモード
    • 前フレームからの position の差分 から速度を推定するモード
  • 他のカスタムスクリプトへの依存は禁止。必要なものはすべて @property 経由で設定できるようにする。
  • Sprite コンポーネントが見つからない場合などは、防御的にエラーログを出す

対象ノードと依存コンポーネント

  • この SpriteFlipper をアタッチするノード:
    • 基本想定: Sprite を持つ見た目用ノード(キャラ本体のノード、あるいはキャラ子ノード)
    • Sprite は Sprite コンポーネントとしてアタッチされている必要がある
  • 速度の参照元ノード:
    • デフォルトでは 親ノード の Rigidbody2D または Transform から速度を算出
    • インスペクタから任意のノードを指定することも可能

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

SpriteFlipper に用意する @property とその役割は以下の通りです。

  • velocitySourceMode(列挙型)
    • 速度の取得方法を選択する。
    • RIGIDBODY2D: Rigidbody2D の linearVelocity.x を使用。
    • POSITION_DELTA: 前フレームとの位置差分から速度を推定。
  • targetNode(Node | null)
    • 速度を取得する対象ノード。
    • null の場合は 親ノード を自動で使用。
    • Rigidbody2D モードでは、このノードから Rigidbody2D を探す。
  • spriteNode(Node | null)
    • 左右反転を行う Sprite を持つノード。
    • null の場合は このコンポーネントが付いているノード自身 を使用。
  • flipWhenMovingThreshold(number, 既定値 0.01)
    • 速度の絶対値がこの値を超えたときに「動いている」とみなす。
    • 小さすぎると微妙な揺れで頻繁に反転してしまうため、0.01〜0.1 程度を推奨。
  • invertFlip(boolean, 既定値 false)
    • true の場合、左右の判定を逆転させる。
    • アートの向きが「デフォルトで左向き」などの場合に利用。
  • lockWhenIdle(boolean, 既定値 true)
    • 速度がしきい値未満(ほぼ停止)のときの挙動。
    • true: 最後に動いていた向きを維持する。
    • false: 停止中は常に flipX = false(右向き)に戻す。
  • debugLog(boolean, 既定値 false)
    • true のとき、取得した速度や反転状態をログ出力する。
    • 動作確認やチューニングに利用。

TypeScriptコードの実装

以下が SpriteFlipper コンポーネントの完全な実装コードです。


import { _decorator, Component, Node, Sprite, RigidBody2D, Vec2, Vec3, Enum } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 速度の取得方法
 */
enum VelocitySourceMode {
    RIGIDBODY2D = 0,
    POSITION_DELTA = 1,
}
Enum(VelocitySourceMode); // インスペクタに表示するため

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

    @property({
        type: VelocitySourceMode,
        tooltip: '速度の取得方法を選択します。\n' +
            'RIGIDBODY2D: targetNode の RigidBody2D.linearVelocity.x を使用\n' +
            'POSITION_DELTA: 前フレームとの位置差分から速度を推定'
    })
    public velocitySourceMode: VelocitySourceMode = VelocitySourceMode.RIGIDBODY2D;

    @property({
        type: Node,
        tooltip: '速度を取得する対象ノード。\n' +
            '未指定(null)の場合は、このコンポーネントが付いているノードの親ノードを使用します。'
    })
    public targetNode: Node | null = null;

    @property({
        type: Node,
        tooltip: '左右反転を行う Sprite を持つノード。\n' +
            '未指定(null)の場合は、このコンポーネントが付いているノード自身を使用します。'
    })
    public spriteNode: Node | null = null;

    @property({
        tooltip: 'この速度(絶対値)を超えたときに「動いている」と判定します。\n' +
            '小さすぎると微小な揺れで反転が頻発するため、0.01〜0.1 程度を推奨します。'
    })
    public flipWhenMovingThreshold: number = 0.05;

    @property({
        tooltip: 'true の場合、左右の判定を反転させます。\n' +
            'アートのデフォルト向きが左向きの場合などに使用します。'
    })
    public invertFlip: boolean = false;

    @property({
        tooltip: '停止中(速度がしきい値未満)のときの挙動。\n' +
            'true: 最後に動いていた向きを維持\n' +
            'false: 常に右向き(flipX = false)に戻す'
    })
    public lockWhenIdle: boolean = true;

    @property({
        tooltip: 'true のとき、取得した速度や反転状態をログに出力します。\n' +
            'デバッグ用途に使用してください。'
    })
    public debugLog: boolean = false;

    private _sprite: Sprite | null = null;
    private _rigidbody2D: RigidBody2D | null = null;
    private _lastPosition: Vec3 | null = null;
    private _lastFacingRight: boolean = true; // 最後に向いていた方向(true: 右, false: 左)

    onLoad() {
        // spriteNode が未設定なら自ノードを使う
        const spriteNode = this.spriteNode ?? this.node;

        // Sprite コンポーネント取得
        this._sprite = spriteNode.getComponent(Sprite);
        if (!this._sprite) {
            console.error('[SpriteFlipper] Sprite コンポーネントが見つかりません。ノードに Sprite を追加してください。', spriteNode);
        }

        // targetNode が未設定なら親ノードを使う
        if (!this.targetNode) {
            this.targetNode = this.node.parent;
            if (!this.targetNode) {
                console.warn('[SpriteFlipper] targetNode が未設定で親ノードも存在しません。速度を取得できません。', this.node);
            }
        }

        // Rigidbody2D モードの場合は RigidBody2D を取得
        if (this.velocitySourceMode === VelocitySourceMode.RIGIDBODY2D && this.targetNode) {
            this._rigidbody2D = this.targetNode.getComponent(RigidBody2D);
            if (!this._rigidbody2D) {
                console.warn('[SpriteFlipper] RIGIDBODY2D モードですが、targetNode に RigidBody2D が見つかりません。POSITION_DELTA モードへの変更を検討してください。', this.targetNode);
            }
        }

        // 位置差分モード用の初期位置
        if (this.velocitySourceMode === VelocitySourceMode.POSITION_DELTA && this.targetNode) {
            this._lastPosition = this.targetNode.worldPosition.clone();
        }
    }

    start() {
        // 初期向きは現在の flipX から決定
        if (this._sprite) {
            this._lastFacingRight = !this._sprite.flipX;
        }
    }

    update(dt: number) {
        if (!this._sprite) {
            return; // 必須コンポーネントがない場合は何もしない
        }
        if (!this.targetNode) {
            return; // 参照ノードがない場合も何もしない
        }

        let vx = 0;

        if (this.velocitySourceMode === VelocitySourceMode.RIGIDBODY2D) {
            // Rigidbody2D の velocity.x を使用
            if (this._rigidbody2D) {
                const v: Vec2 = this._rigidbody2D.linearVelocity;
                vx = v.x;
            }
        } else {
            // POSITION_DELTA: 前フレームとの位置差分から速度を推定
            const currentPos = this.targetNode.worldPosition;
            if (this._lastPosition) {
                const dx = currentPos.x - this._lastPosition.x;
                // dt が 0 の場合はゼロ除算を避ける
                if (dt > 0) {
                    vx = dx / dt;
                }
            }
            // 現在位置を保存
            if (!this._lastPosition) {
                this._lastPosition = currentPos.clone();
            } else {
                this._lastPosition.set(currentPos);
            }
        }

        // デバッグログ
        if (this.debugLog) {
            console.log(`[SpriteFlipper] vx=${vx.toFixed(3)}`);
        }

        const absVx = Math.abs(vx);

        if (absVx >= this.flipWhenMovingThreshold) {
            // 動いていると判定
            const movingRight = vx > 0;
            this._lastFacingRight = movingRight;
            this.applyFlip(movingRight);
        } else {
            // 停止中
            if (this.lockWhenIdle) {
                // 最後に動いていた向きを維持
                this.applyFlip(this._lastFacingRight);
            } else {
                // 常に右向きに戻す
                this.applyFlip(true);
            }
        }
    }

    /**
     * 向きに応じて Sprite.flipX を更新
     * @param facingRight true: 右向き, false: 左向き
     */
    private applyFlip(facingRight: boolean) {
        if (!this._sprite) return;

        // invertFlip が true の場合は左右を逆転
        const effectiveFacingRight = this.invertFlip ? !facingRight : facingRight;

        // Cocos の Sprite.flipX は「左右反転しているかどうか」
        // 右向き => flipX = false, 左向き => flipX = true
        const shouldFlipX = !effectiveFacingRight;

        if (this._sprite.flipX !== shouldFlipX) {
            this._sprite.flipX = shouldFlipX;

            if (this.debugLog) {
                console.log(`[SpriteFlipper] flipX=${shouldFlipX} (facingRight=${facingRight}, invert=${this.invertFlip})`);
            }
        }
    }
}

主要メソッドの解説

  • onLoad()
    • spriteNode 未指定時に自ノードを使用。
    • Sprite コンポーネントを取得し、存在しない場合は console.error で警告。
    • targetNode 未指定時に親ノードを自動設定。
    • RIGIDBODY2D モードの場合は RigidBody2D を取得し、なければ console.warn
    • POSITION_DELTA モードでは初期位置を保存。
  • start()
    • 初期向きを this._sprite.flipX から判定し、_lastFacingRight に保存。
    • これにより、停止中に「初期向き」を維持できる。
  • update(dt)
    • 設定されたモードに応じて vx(x 方向速度)を取得。
    • RIGIDBODY2D モード: RigidBody2D.linearVelocity.x
    • POSITION_DELTA モード: (currentPos.x - lastPos.x) / dt
    • 速度の絶対値が flipWhenMovingThreshold 以上なら「動いている」と判定し、向きを更新。
    • 停止中の挙動は lockWhenIdle に従って決定。
    • 最終的に applyFlip()Sprite.flipX を更新。
  • applyFlip(facingRight)
    • invertFlip が true の場合は左右を反転して解釈。
    • 右向き => flipX = false、左向き => flipX = true を適用。
    • 状態が変わったときだけ flipX を更新し、不要な更新を避ける。

使用手順と動作確認

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

  1. Assets パネルで右クリックします。
  2. Create > TypeScript を選択します。
  3. ファイル名を SpriteFlipper.ts にします。
  4. 作成された SpriteFlipper.ts をダブルクリックしてエディタで開き、先ほどのコード全文を貼り付けて保存します。

2. テスト用ノードの作成(Sprite + Rigidbody2D の例)

  1. Hierarchy パネルで右クリックし、Create > 2D Object > Sprite などでキャラクター用ノードを作成します。(例: ノード名を Player とする)
  2. Player ノードを選択し、Inspector で以下を追加します:
    • Add Component > 2D > Physics > RigidBody2D
    • 必要に応じて BoxCollider2D などのコライダーも追加します。
  3. RigidBody2DTypeDynamic に設定し、物理挙動が有効になるようにします。
  4. Sprite の画像(右向きのキャラなど)を設定しておきます。

3. SpriteFlipper コンポーネントのアタッチ

  1. 左右反転させたい Sprite を持つノードを選択します。
    • 最もシンプルな例: Player ノード自身に Sprite が付いている場合は Player を選択。
    • もし Player の子ノードに Sprite がある場合、その子ノードにアタッチしても構いません。
  2. Inspector で Add Component > Custom > SpriteFlipper を選択し、コンポーネントを追加します。
  3. プロパティを設定します:
    • velocitySourceMode:
      • Rigidbody2D を使っている場合: RIGIDBODY2D を選択。
    • targetNode:
      • デフォルトで親ノードを参照したい場合: 空欄のままで OK(Sprite を子ノードにしている場合に便利)。
      • 今回の例のように Player 自身に SpriteFlipper を付けている場合:
        • targetNodePlayer 自身をドラッグ&ドロップしておくと確実です。
    • spriteNode:
      • SpriteFlipper を付けたノード自身に Sprite があるなら空欄で OK。
      • 別ノードの Sprite を反転させたい場合は、そのノードをドラッグ&ドロップ。
    • flipWhenMovingThreshold:
      • まずは 0.05 のままで試し、反転がカクつくようなら 0.1 などに上げてみてください。
    • invertFlip:
      • キャラ画像のデフォルト向きが「右向き」の場合: false のままで OK。
      • デフォルトが「左向き」の場合: true にすると自然な向きになります。
    • lockWhenIdle:
      • 停止中も最後の向きを維持したい場合: true(推奨)。
      • 停止中は必ず右向きに戻したい場合: false
    • debugLog:
      • 動きの確認をしたいときだけ true にし、コンソールでログを確認します。

4. 動作確認(Rigidbody2D 版)

  1. 簡単な移動用スクリプトを別途用意し、左右キーで Player に力を加える、または linearVelocity.x を更新するようにします。
    • この移動スクリプトは SpriteFlipper とは完全に独立していて構いません。
  2. ゲームを再生し、右に動かしたときに Sprite が右向き(flipX = false)、左に動かしたときに左向き(flipX = true) になることを確認します。
  3. もし反転方向が逆に感じる場合は、Inspector で invertFlip を切り替えて再生し直してください。

5. POSITION_DELTA モードでの確認(スクリプト移動キャラなど)

Rigidbody2D を使わず、update()node.position を直接更新しているキャラにも対応できます。

  1. 移動スクリプト側では、通常通り node.setPosition() などでキャラを移動させます。
  2. SpriteFlipper の設定:
    • velocitySourceMode: POSITION_DELTA を選択。
    • targetNode: 位置を動かしているノードを指定(多くの場合はキャラ本体のノード)。
  3. 再生して、左右移動に合わせてスプライトが反転することを確認します。

まとめ

SpriteFlipper コンポーネントは、ノードにアタッチするだけで「移動方向に合わせた自動左右反転」を実現できる、汎用的なユーティリティです。

  • Rigidbody2D ベースのキャラでも、スクリプトで position を更新するキャラでも同じコンポーネントを使い回せる。
  • 反転対象ノード(spriteNode)と速度参照ノード(targetNode)をプロパティで自由に指定できるので、アニメーション用子ノード構成にも柔軟に対応。
  • invertFlipflipWhenMovingThreshold によって、アートの向きや挙動に合わせた細かい調整が可能。
  • 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結しているため、他プロジェクトへの持ち運びも容易。

今後は、アニメーション再生や足音など、「向きの変化」をトリガーにした別の汎用コンポーネントと組み合わせることで、よりリッチなキャラクター挙動を簡潔な構成で実現できます。
まずはこの SpriteFlipper をベースに、あなたのプロジェクトのキャラクターへ手軽に「向きの自動制御」を導入してみてください。