【Cocos Creator 3.8】ShieldBearer の実装:アタッチするだけで「常にプレイヤー方向へ盾を向けながら、じりじり近づく敵」を実現する汎用スクリプト

このコンポーネントは、任意のノードにアタッチするだけで「プレイヤーを追尾しつつ、常にプレイヤーの方向に盾(正面)を向ける」挙動を実現します。プレイヤーの参照をインスペクタから指定するだけで動作し、外部の GameManager やシングルトンに依存しないため、どのプロジェクトにもそのまま持ち込んで使えます。

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

今回の ShieldBearer コンポーネントの要件は次の通りです。

  • 指定した「プレイヤー」ノードに向かって、一定速度でじりじり近づく。
  • 常に「プレイヤーの方向」を向くように回転する(2D/3D どちらでも利用可能な設計)。
  • 盾の無敵判定そのものはゲームごとに異なるため、本コンポーネントでは「向きと移動」に専念し、当たり判定は別途 Collider 等で実装できるようにする。
  • 外部スクリプトに一切依存せず、必要な情報はすべてインスペクタの @property から設定する。
  • 防御的実装:プレイヤーが未設定、もしくは同一位置にいる場合などの例外状況でエラーにならないようにする。

このため、ShieldBearer は以下のポリシーで設計します。

  • 「どのノードにアタッチしても動く」ことを重視し、Transform 以外の標準コンポーネントには依存しない。
  • 2D と 3D のどちらにも対応できるよう、次の2モードを用意する:
    • 2D モード:Z 回転だけを変化させる(横スクロールや見下ろし 2D 用)。
    • 3D モード:3D 空間上でターゲット方向を向く(TPS や 3D アクション用)。
  • 「じりじり近づく」ニュアンスを調整できるよう、速度や最小距離などをプロパティ化する。

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

ShieldBearer に用意する @property は以下の通りです。

  • target (Node | null)
    • 盾を向けて追いかける対象(通常はプレイヤー)のノード参照。
    • インスペクタで Hierarchy 上のプレイヤーノードをドラッグ&ドロップして設定。
    • 未設定の場合は動作せず、警告ログを出す。
  • moveSpeed (number, 既定値: 1.5)
    • ターゲットに向かって進む移動速度(単位:ユニット/秒)。
    • 値を大きくすると素早く接近し、小さくすると「じりじり」感が強くなる。
  • minDistance (number, 既定値: 0.8)
    • ターゲットとの距離がこの値以下になったら、それ以上近づかない。
    • 「盾を構えて一定距離を保つ敵」を作るのに便利。
  • rotateSpeed (number, 既定値: 360)
    • ターゲット方向へ向きを合わせる角速度(度/秒)。
    • 0 または非常に大きな値にすると「即座に向く」イメージになる。
    • 小さい値にすると、ゆっくりと盾を向けるモーションになる。
  • use2DMode (boolean, 既定値: true)
    • true:2D 用。Z 回転のみを変更し、XY 平面で移動する。
    • false:3D 用。3D 空間上でターゲット方向を向く。
  • forwardAxis (Vec3, 既定値: new Vec3(0, 1, 0))
    • 「盾の正面」がどのローカル軸かを指定する。
    • 例:
      • 2D で「上」を盾の向きにしたい場合:(0, 1, 0)
      • 2D で「右」を盾の向きにしたい場合:(1, 0, 0)
      • 3D で「Z+」を正面にしたい場合:(0, 0, 1)
    • スプライトの向きやモデルの正面に合わせて調整可能。
  • lockYPosition (boolean, 既定値: true)
    • 2D ゲームや平面上の移動で、Y 軸を固定したい場合に使用。
    • true:初期位置の Y を保持し、ターゲットが上下に動いても Y は変えない。
    • false:ターゲットの位置に対して完全に追従する。
  • debugDrawDirection (boolean, 既定値: false)
    • 有効にすると、毎フレーム「ターゲット方向」と「現在の正面方向」をログに出す簡易デバッグモード。
    • 実際のゲームではオフ推奨。

TypeScriptコードの実装

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


import { _decorator, Component, Node, Vec3, Quat, math } from 'cc';
const { ccclass, property } = _decorator;

/**
 * ShieldBearer
 * 指定した target ノードの方向へ常に盾(正面)を向けながら、
 * 一定速度でじりじり近づく汎用コンポーネント。
 *
 * - 外部スクリプトに依存せず、target をインスペクタで指定するだけで動作します。
 * - 2D / 3D 双方に対応し、forwardAxis で「どの向きを盾の正面とみなすか」を調整できます。
 */
@ccclass('ShieldBearer')
export class ShieldBearer extends Component {

    @property({
        type: Node,
        tooltip: '追いかける対象(通常はプレイヤー)のノード。\nここで指定したノードの方向へ盾を向けながら接近します。'
    })
    public target: Node | null = null;

    @property({
        tooltip: 'ターゲットへ近づく移動速度(ユニット/秒)。\n値を小さくすると「じりじり」近づく挙動になります。'
    })
    public moveSpeed: number = 1.5;

    @property({
        tooltip: 'ターゲットとの最小距離。この距離以下には近づきません。\n盾を構えて一定距離を保つ敵を作るのに便利です。'
    })
    public minDistance: number = 0.8;

    @property({
        tooltip: 'ターゲット方向へ回転する速度(度/秒)。\n大きくすると素早く向きを合わせ、小さくするとゆっくり向きます。'
    })
    public rotateSpeed: number = 360;

    @property({
        tooltip: 'true: 2D モード(XY 平面で移動し、Z 回転のみ変更)。\nfalse: 3D モード(3D 空間上でターゲット方向を向きます)。'
    })
    public use2DMode: boolean = true;

    @property({
        tooltip: 'このノードのローカル座標で「盾の正面」とみなす方向ベクトル。\n例: 2D で上向きが正面なら (0,1,0)、右向きなら (1,0,0)。\n3D で Z+ を正面とするなら (0,0,1)。'
    })
    public forwardAxis: Vec3 = new Vec3(0, 1, 0);

    @property({
        tooltip: 'true にすると、Y 座標を初期位置にロックします(2D 横スクロール等に便利)。'
    })
    public lockYPosition: boolean = true;

    @property({
        tooltip: 'デバッグ用。true にすると毎フレーム、ターゲット方向と現在の正面方向をログ出力します。'
    })
    public debugDrawDirection: boolean = false;

    /** 初期 Y 位置を保持しておき、lockYPosition が true の場合に使用します。 */
    private _initialY: number = 0;

    private static readonly _tempVec3_1 = new Vec3();
    private static readonly _tempVec3_2 = new Vec3();
    private static readonly _tempQuat_1 = new Quat();
    private static readonly _tempQuat_2 = new Quat();

    onLoad () {
        // 初期 Y 座標を記録(lockYPosition 用)
        const pos = this.node.worldPosition;
        this._initialY = pos.y;

        // forwardAxis がゼロベクトルだと回転計算に失敗するので警告を出す
        if (this.forwardAxis.lengthSqr() === 0) {
            console.warn('[ShieldBearer] forwardAxis がゼロベクトルです。デフォルトの (0,1,0) を使用します。');
            this.forwardAxis.set(0, 1, 0);
        }
    }

    start () {
        // target 未設定のままプレイされた場合は警告を出す
        if (!this.target) {
            console.warn('[ShieldBearer] target が設定されていません。このノードは待機状態になります。');
        }
    }

    update (deltaTime: number) {
        // ターゲットがいなければ何もしない
        if (!this.target) {
            return;
        }

        const selfPos = ShieldBearer._tempVec3_1;
        const targetPos = ShieldBearer._tempVec3_2;

        this.node.getWorldPosition(selfPos);
        this.target.getWorldPosition(targetPos);

        // 必要であれば Y 座標をロック
        if (this.lockYPosition) {
            selfPos.y = this._initialY;
            // ノードに反映(他のスクリプトで Y を変えられても戻す)
            this.node.setWorldPosition(selfPos);
        }

        // ターゲットへの方向ベクトルを計算
        Vec3.subtract(targetPos, targetPos, selfPos); // targetPos = targetPos - selfPos

        // ターゲットと同じ位置(または極端に近い)なら移動も回転も行わない
        const distance = targetPos.length();
        if (distance < 1e-4) {
            return;
        }

        // ---- 回転処理:盾の正面をターゲット方向に向ける ----
        this._updateRotationTowards(targetPos, deltaTime);

        // ---- 移動処理:一定距離までじりじり近づく ----
        this._updateMovementTowards(targetPos, distance, deltaTime);

        if (this.debugDrawDirection) {
            // 現在の正面方向を計算してログ出力(デバッグ用)
            const currentForward = new Vec3();
            Vec3.transformQuat(currentForward, this.forwardAxis, this.node.worldRotation);
            const dirToTarget = targetPos.clone().normalize();
            console.log('[ShieldBearer] dirToTarget =', dirToTarget.toString(), ', currentForward =', currentForward.toString());
        }
    }

    /**
     * ターゲット方向(world space のベクトル)に盾を向けるための回転更新処理。
     * @param dirToTarget ワールド座標系での「ターゲットへの方向ベクトル」
     * @param deltaTime 経過時間
     */
    private _updateRotationTowards (dirToTarget: Vec3, deltaTime: number) {
        // 方向ベクトルを正規化
        const desiredDir = dirToTarget.clone().normalize();

        if (this.use2DMode) {
            // 2D モードでは Z 回転角を直接求める
            // forwardAxis が (0,1,0)(上向き)を想定した計算。
            // 他の forwardAxis にも対応するため、ワールド回転から現在の forward を求めて補間する。
            const currentRot = ShieldBearer._tempQuat_1;
            this.node.getWorldRotation(currentRot);

            const currentForward = new Vec3();
            Vec3.transformQuat(currentForward, this.forwardAxis, currentRot);
            currentForward.normalize();

            // 2D なので Z 軸まわりの角度差を求める
            // XY 平面に投影して角度計算
            const angleCurrent = Math.atan2(currentForward.y, currentForward.x);
            const angleDesired = Math.atan2(desiredDir.y, desiredDir.x);

            let deltaAngle = math.toDegree(angleDesired - angleCurrent);

            // -180~180 に正規化
            deltaAngle = math.repeat(deltaAngle + 180, 360) - 180;

            // フレームごとに回転できる最大角度
            const maxStep = this.rotateSpeed * deltaTime;
            const step = math.clamp(deltaAngle, -maxStep, maxStep);

            const newAngle = angleCurrent + math.toRadian(step);

            // 新しい forward ベクトルを計算
            const newForward = new Vec3(Math.cos(newAngle), Math.sin(newAngle), 0);

            // newForward を forwardAxis に対応させるクォータニオンを計算
            const newRot = ShieldBearer._tempQuat_2;
            Quat.rotationTo(newRot, this.forwardAxis, newForward);

            // ワールド回転として適用
            this.node.setWorldRotation(newRot);

        } else {
            // 3D モード:3D 空間上で forwardAxis を desiredDir に向ける
            const currentRot = ShieldBearer._tempQuat_1;
            const targetRot = ShieldBearer._tempQuat_2;

            this.node.getWorldRotation(currentRot);

            // 現在の forward と desiredDir の間の回転を求める
            const currentForward = new Vec3();
            Vec3.transformQuat(currentForward, this.forwardAxis, currentRot);
            currentForward.normalize();

            // すでにほぼ同じ向きなら何もしない
            if (Vec3.angle(currentForward, desiredDir) < math.toRadian(0.5)) {
                return;
            }

            // forwardAxis を desiredDir に合わせる回転を求める
            Quat.rotationTo(targetRot, this.forwardAxis, desiredDir);

            // 現在の回転から targetRot への補間(回転速度を考慮)
            if (this.rotateSpeed <= 0) {
                // rotateSpeed が 0 以下なら即座に向きを合わせる
                this.node.setWorldRotation(targetRot);
            } else {
                // 補間係数を角速度から計算(1 秒で rotateSpeed 度まで回転)
                const maxRadians = math.toRadian(this.rotateSpeed) * deltaTime;
                const angle = Vec3.angle(currentForward, desiredDir);
                let t = 1.0;
                if (angle > maxRadians && angle > 0) {
                    t = maxRadians / angle;
                }
                Quat.slerp(targetRot, currentRot, targetRot, t);
                this.node.setWorldRotation(targetRot);
            }
        }
    }

    /**
     * ターゲット方向へ「じりじり」近づく移動処理。
     * @param dirToTarget ワールド座標系での「ターゲットへの方向ベクトル」
     * @param distance 現在の距離
     * @param deltaTime 経過時間
     */
    private _updateMovementTowards (dirToTarget: Vec3, distance: number, deltaTime: number) {
        // 最小距離以内なら移動しない
        if (distance <= this.minDistance) {
            return;
        }

        const moveDir = dirToTarget.clone().normalize();

        // lockYPosition が有効な場合、Y 方向への移動を抑制(XZ/XY 平面に限定)
        if (this.lockYPosition) {
            moveDir.y = 0;
            if (moveDir.lengthSqr() > 0) {
                moveDir.normalize();
            }
        }

        const moveStep = this.moveSpeed * deltaTime;
        const moveVec = moveDir.multiplyScalar(moveStep);

        const newPos = ShieldBearer._tempVec3_1;
        this.node.getWorldPosition(newPos);
        Vec3.add(newPos, newPos, moveVec);

        if (this.lockYPosition) {
            newPos.y = this._initialY;
        }

        this.node.setWorldPosition(newPos);
    }
}

コードのポイント解説

  • onLoad
    • 初期 Y 座標を _initialY に保存し、lockYPosition が有効なときに使用します。
    • forwardAxis がゼロベクトルだと回転計算が不可能なので、検出してデフォルト値に戻しています。
  • start
    • target が設定されていない場合に警告ログを出し、「なぜ動かないのか」をエディタ上で気づきやすくしています。
  • update
    • 毎フレーム、ターゲットへの方向ベクトルと距離を計算します。
    • _updateRotationTowards で盾の正面をターゲット方向へ回転させ、_updateMovementTowards で最小距離までじりじり近づきます。
    • debugDrawDirection が true の場合、現在の正面とターゲット方向をログ出力して、挙動確認に使えるようにしています。
  • _updateRotationTowards
    • 2D モードでは、XY 平面上の角度(atan2)を使って Z 回転を徐々に変化させます。
    • 3D モードでは、Quat.rotationToQuat.slerp を使って、forwardAxis をターゲット方向に向けるように回転補間します。
    • rotateSpeed が 0 以下の場合は「瞬時に向く」挙動になります。
  • _updateMovementTowards
    • minDistance より近い場合は移動を止め、「一定距離を保つ」ようにしています。
    • lockYPosition が有効な場合、Y 成分を 0 にして平面上のみで移動させます(2D 横スクロールや平面 3D に便利)。

使用手順と動作確認

ここからは、実際に Cocos Creator 3.8.7 のエディタ上で ShieldBearer を使う手順を説明します。

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

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

2. テスト用シーンとノードの準備

ここでは 2D ゲームを想定した簡単なセットアップ例を紹介します(3D でも基本は同じです)。

  1. Hierarchy パネルで右クリックし、Create → 2D Object → Sprite を選択して、プレイヤー用ノード Player を作成します。
    • Inspector の Sprite コンポーネントで、適当なテクスチャを設定して見た目を確認します。
  2. 同様に、敵用ノード ShieldEnemy を作成します(Create → 2D Object → Sprite)。
  3. ShieldEnemy のスプライト画像は「盾を構えた敵」など、前後が分かるものを使うとテストしやすいです。

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

  1. Hierarchy で ShieldEnemy ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom カテゴリから ShieldBearer を選択して追加します。

4. プロパティの設定

ShieldEnemy ノードの Inspector に表示される ShieldBearer コンポーネントの各プロパティを、次のように設定してみてください。

  • target:
    • Hierarchy から Player ノードをドラッグ&ドロップして設定します。
  • moveSpeed:
    • 例:1.02.0
    • 小さめ(1.0)だと「じりじり」近づく感じになります。
  • minDistance:
    • 例:1.0
    • 敵がプレイヤーに完全に重ならず、少し手前で止まる距離です。
  • rotateSpeed:
    • 例:360(1秒で1回転分まで向きを変えられる)
    • 素早く盾をプレイヤーに向けたい場合は 720 など、ゆっくりさせたい場合は 90 などにすると差が分かりやすいです。
  • use2DMode:
    • 2D シーンなら チェック ON にします。
  • forwardAxis:
    • スプライトが「上向き」が正面の画像なら、デフォルトの (0, 1, 0) のままで OK です。
    • もし「右向き」が正面の画像なら、(1, 0, 0) に変更してください。
  • lockYPosition:
    • 横スクロールなどで敵を水平にのみ動かしたい場合は チェック ON
    • プレイヤーが上下にも動く見下ろし型なら チェック OFF にすると、上下にも追尾します。
  • debugDrawDirection:
    • 挙動確認のために一度 チェック ON にしてログを見ても良いですが、コンソールがうるさくなるので普段は OFF 推奨です。

5. シーンを再生して動作確認

  1. Scene ビューで PlayerShieldEnemy の位置を適当に離して配置します。
    • 例:Player を (0, 0)、ShieldEnemy を (-5, 0) など。
  2. 上部の ▶︎(再生)ボタンを押してシーンを再生します。
  3. 再生中、ShieldEnemy がゆっくりと Player に向かって移動し、常にプレイヤーの方向へ向きを合わせていることを確認します。
  4. Player ノードをドラッグして動かす(または別のスクリプトで動かす)と、敵が常にプレイヤーの位置を追いかけながら盾を向け続ける挙動が確認できます。

3D シーンで使う場合のポイント

3D シーンで使用する場合は、次のように設定を変えます。

  • Hierarchy で Create → 3D Object → Capsule などを使って Player と ShieldEnemy を作成。
  • ShieldEnemyShieldBearer をアタッチ。
  • プロパティ設定:
    • use2DModeOFF
    • forwardAxis:モデルの正面に合わせる(多くの 3D モデルは Z+ が正面なので (0, 0, 1)
    • lockYPosition:地面に固定して水平移動だけにしたいなら ON、完全追従させたいなら OFF

この設定で、3D 空間上でも「常にプレイヤー方向に盾を向けながら接近する」敵キャラを簡単に実現できます。

まとめ

ShieldBearer コンポーネントは、

  • プレイヤーを追尾しつつ常に盾を向ける「盾持ち敵キャラ」の挙動を、アタッチするだけで実現できる。
  • 2D / 3D 両対応で、forwardAxis によってスプライトやモデルの向きに柔軟に合わせられる。
  • moveSpeed / minDistance / rotateSpeed などを変えるだけで、様々な性格の敵(素早い盾持ち、重厚でゆっくりな盾持ちなど)を量産できる。
  • 外部の GameManager やシングルトンに依存しないため、プロジェクトをまたいで簡単に再利用できる。

実際のゲームでは、このコンポーネントに Collider とダメージ処理用スクリプトを組み合わせることで、

  • 正面からの攻撃は無効化されるが、背後からの攻撃には弱い敵
  • 一定距離を保ちながらプレイヤーの射線を遮り続ける盾兵

といったバリエーションを手早く実装できます。

一度 ShieldBearer をプロジェクトの「汎用コンポーネント」として整備しておけば、

  • 新しいステージや敵キャラを作るときに、ノードを配置して ShieldBearer をアタッチし、プロパティを少し変えるだけで挙動を再利用できる

ため、ゲーム開発のスピードと保守性が大きく向上します。ぜひ自分のプロジェクト用にカスタマイズしながら活用してみてください。