【Cocos Creator 3.8】PredictAim(偏差射撃)の実装:アタッチするだけで「動くターゲットの未来位置を自動で狙う」汎用スクリプト

このガイドでは、Cocos Creator 3.8.7 + TypeScript で「PredictAim」という汎用コンポーネントを実装します。
このコンポーネントをノードにアタッチするだけで、ターゲットの現在の移動速度から「数秒後の未来位置」を予測し、その方向を自動的に向く(回転する)ようになります。

敵タレットがプレイヤーの進行方向を先読みして撃つ、ホーミング弾が偏差射撃で追尾する、などの場面で非常に有用です。
外部の GameManager やシングルトンには一切依存せず、インスペクタでプロパティを設定するだけで使える完全独立コンポーネントとして設計します。


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

1. コンポーネントの役割

  • 指定したターゲットノードの「現在位置」と「直近数フレームの移動量」からターゲットの速度ベクトルを推定する。
  • 推定した速度ベクトルと「予測時間(何秒後を狙うか)」から、未来位置を計算する。
  • 自分自身(このコンポーネントがアタッチされたノード)を、その未来位置の方向へ回転させる。
  • (オプション)未来位置に向かう発射方向ベクトルをインスペクタから確認できるよう、デバッグ用情報を保持する。

※今回は「弾を発射する処理」は実装しません。あくまで「どこを狙うべきか」を計算し、ノードの向きを合わせるところまでを行います。弾の生成や移動は別コンポーネントで行ってください。

2. 外部依存をなくすためのアプローチ

  • ターゲットは @property(Node) で直接指定する。
  • ワールド座標系での位置・速度を扱うため、Transform 系 API(worldPosition)のみ使用し、他のカスタムスクリプトに依存しない。
  • ターゲットの速度は Rigidbody などに依存せず、「前フレームとの位置差分 / 経過時間」から自前で推定する。
  • ターゲットが未設定の場合や、フレームレートが極端に低い場合などは安全に処理をスキップし、警告ログを出す。

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

以下のプロパティを用意します。

  • target : Node | null
    • 種類: @property(Node)
    • 説明: 偏差射撃の対象となるターゲットノード。
    • 使い方: 敵タレットならプレイヤーのノードをドラッグ&ドロップで指定。
  • predictionTime : number
    • 種類: @property({ type: CCFloat, ... })
    • 説明: 何秒後の位置を狙うか(未来予測時間)。
    • 推奨値: 0.1 ~ 1.0(ゲームスピードに応じて調整)。
    • 例: 0.5 にすると「0.5 秒後の未来位置」を狙う。
  • maxPredictionDistance : number
    • 種類: @property({ type: CCFloat, ... })
    • 説明: 未来位置があまりにも遠くなりすぎる場合に、予測距離を制限する最大距離。
    • 役割: ターゲットが瞬間移動したり、速度推定が一時的に異常値になったときに暴走を防ぐ。
    • 例: 1000 など。0 以下の場合は制限なし。
  • rotateSpeed : number
    • 種類: @property({ type: CCFloat, ... })
    • 説明: ノードがターゲット方向へ回転する際の最大角速度(度/秒)。
    • 役割: 0 以下なら「瞬時に向きを合わせる」。正の値なら滑らかに補間しながら向く。
    • 例: 360 にすると 1 秒で 1 回転分まで回転できる。
  • useLocalRotation : boolean
    • 種類: @property
    • 説明: ローカル回転を使用するか、ワールド回転を使用するか。
    • 2D ゲームで親子構造がシンプルな場合は true でよい。
  • axisMode : 'Z_2D' | 'Y_3D'(Enum)
    • 種類: @property({ type: Enum })
    • 説明:
      • Z_2D: 2D ゲーム向け。Z 回転(上から見た回転)でターゲットを向く。
      • Y_3D: 3D TPS/FPS 向け。Y 軸回りの回転でターゲットを向く(水平回転)。
  • debugDraw : boolean
    • 種類: @property
    • 説明: 未来位置や方向ベクトルをログに出すかどうか(簡易デバッグ用)。
    • 注意: 頻繁にログが出るため、開発時のみ有効にすることを推奨。
  • velocitySmoothing : number
    • 種類: @property({ type: CCFloat, ... })
    • 説明: ターゲット速度の平滑化係数(0~1)。
    • 役割: 0 に近いほど急激な速度変化を抑え、なめらかな予測になる。1 で「平滑化なし」。
    • 例: 0.2 ~ 0.5 程度が扱いやすい。

これらのプロパティはすべてインスペクタから調整可能で、他のスクリプトを一切必要としません。


TypeScriptコードの実装

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


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

/**
 * ターゲットの未来位置を予測して、その方向へ自分自身を回転させるコンポーネント。
 * 他のカスタムスクリプトには一切依存しません。
 */

enum PredictAimAxisMode {
    Z_2D = 0, // 2D 用: Z 軸回転(上から見た回転)
    Y_3D = 1, // 3D 用: Y 軸回転(水平回転)
}

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

    @property({
        type: Node,
        tooltip: '偏差射撃のターゲットとなるノード。\nここにプレイヤーなどをドラッグ&ドロップしてください。'
    })
    public target: Node | null = null;

    @property({
        type: CCFloat,
        tooltip: '何秒後の未来位置を狙うか(秒)。\n例: 0.5 なら 0.5 秒後の位置を狙います。',
        min: 0,
        step: 0.05
    })
    public predictionTime: number = 0.5;

    @property({
        type: CCFloat,
        tooltip: '未来位置までの最大距離制限(ワールド単位)。\n0 以下なら無制限。異常な速度推定の暴走を防ぎます。',
        min: 0,
        step: 10
    })
    public maxPredictionDistance: number = 1000;

    @property({
        type: CCFloat,
        tooltip: '回転速度(度/秒)。\n0 以下の場合は目標方向へ即座に向きを合わせます。',
        step: 10
    })
    public rotateSpeed: number = 360;

    @property({
        tooltip: 'true: ローカル回転を使用。\nfalse: ワールド回転を使用。'
    })
    public useLocalRotation: boolean = true;

    @property({
        type: Enum(PredictAimAxisMode),
        tooltip: 'どの軸周りでターゲットを向くか。\nZ_2D: 2D 用 Z 軸回転\nY_3D: 3D 用 Y 軸回転(水平回転)'
    })
    public axisMode: PredictAimAxisMode = PredictAimAxisMode.Z_2D;

    @property({
        tooltip: 'ターゲット速度の平滑化係数(0〜1)。\n0 に近いほど滑らか、1 で平滑化なし。',
        type: CCFloat,
        min: 0,
        max: 1,
        step: 0.05
    })
    public velocitySmoothing: number = 0.3;

    @property({
        tooltip: 'true の場合、未来位置やベクトル情報をコンソールにログ出力します。\nデバッグ時のみ有効化することを推奨。'
    })
    public debugDraw: boolean = false;

    // 内部状態: 前フレームのターゲット位置と推定速度
    private _lastTargetPos: Vec3 | null = null;
    private _estimatedVelocity: Vec3 = new Vec3();

    // デバッグ用: 計算された未来位置と方向ベクトル
    public debugPredictedPosition: Vec3 = new Vec3();
    public debugAimDirection: Vec3 = new Vec3();

    onLoad() {
        // 特に必須コンポーネントはありませんが、防御的にターゲットの存在をチェックします。
        if (!this.target) {
            console.warn('[PredictAim] target が設定されていません。このノードは何も狙いません。', this.node.name);
        }
    }

    start() {
        // 初期化:ターゲットが設定されていれば最初の位置を記録
        if (this.target) {
            this._lastTargetPos = new Vec3();
            this.target.getWorldPosition(this._lastTargetPos);
        }
    }

    update(deltaTime: number) {
        if (!this.target) {
            // ターゲット未設定なら何もしない
            return;
        }

        if (deltaTime <= 0) {
            return;
        }

        // 現在のターゲット位置を取得
        const currentTargetPos = new Vec3();
        this.target.getWorldPosition(currentTargetPos);

        // 速度推定
        if (this._lastTargetPos) {
            const frameVelocity = new Vec3(
                (currentTargetPos.x - this._lastTargetPos.x) / deltaTime,
                (currentTargetPos.y - this._lastTargetPos.y) / deltaTime,
                (currentTargetPos.z - this._lastTargetPos.z) / deltaTime,
            );

            // 平滑化: estimated = lerp(estimated, frame, smoothing)
            const s = math.clamp01(this.velocitySmoothing);
            this._estimatedVelocity.x = math.lerp(this._estimatedVelocity.x, frameVelocity.x, s);
            this._estimatedVelocity.y = math.lerp(this._estimatedVelocity.y, frameVelocity.y, s);
            this._estimatedVelocity.z = math.lerp(this._estimatedVelocity.z, frameVelocity.z, s);
        }

        // 次フレーム用に現在位置を保存
        if (!this._lastTargetPos) {
            this._lastTargetPos = new Vec3();
        }
        this._lastTargetPos.set(currentTargetPos);

        // 未来位置の計算: futurePos = currentPos + velocity * predictionTime
        const predictedPos = new Vec3(
            currentTargetPos.x + this._estimatedVelocity.x * this.predictionTime,
            currentTargetPos.y + this._estimatedVelocity.y * this.predictionTime,
            currentTargetPos.z + this._estimatedVelocity.z * this.predictionTime,
        );

        // 自身の現在位置取得
        const selfPos = new Vec3();
        this.node.getWorldPosition(selfPos);

        // 予測位置までのベクトル
        const toPredicted = new Vec3(
            predictedPos.x - selfPos.x,
            predictedPos.y - selfPos.y,
            predictedPos.z - selfPos.z,
        );

        // 距離制限が有効ならクランプ
        const distance = toPredicted.length();
        if (this.maxPredictionDistance > 0 && distance > this.maxPredictionDistance) {
            toPredicted.normalize();
            toPredicted.multiplyScalar(this.maxPredictionDistance);
            predictedPos.set(
                selfPos.x + toPredicted.x,
                selfPos.y + toPredicted.y,
                selfPos.z + toPredicted.z
            );
        }

        // デバッグ用情報を保存
        this.debugPredictedPosition.set(predictedPos);
        if (distance > 0.0001) {
            this.debugAimDirection.set(toPredicted).normalize();
        } else {
            this.debugAimDirection.set(0, 0, 0);
        }

        if (this.debugDraw) {
            // 頻繁に出るので必要に応じてコメントアウトしてください
            console.log(
                `[PredictAim] target=${this.target.name}, future=(${predictedPos.x.toFixed(2)}, ${predictedPos.y.toFixed(2)}, ${predictedPos.z.toFixed(2)})`
            );
        }

        // 方向ベクトルがほぼゼロなら回転しない
        if (distance <= 0.0001) {
            return;
        }

        // 目標回転を計算
        const targetRotation = this._computeLookRotation(selfPos, predictedPos);

        // 回転の適用
        if (this.rotateSpeed <= 0) {
            // 即座に向きを合わせる
            this._applyRotation(targetRotation);
        } else {
            // 現在の回転から目標回転へ、回転速度を制限しつつ補間
            this._rotateTowards(targetRotation, deltaTime);
        }
    }

    /**
     * 自身の位置から目標位置を向くための回転(Quat)を計算します。
     */
    private _computeLookRotation(from: Vec3, to: Vec3): Quat {
        const dir = new Vec3(
            to.x - from.x,
            to.y - from.y,
            to.z - from.z,
        );
        dir.normalize();

        const rot = new Quat();

        if (this.axisMode === PredictAimAxisMode.Z_2D) {
            // 2D: XY 平面上で Z 軸回転
            // atan2(y, x) で角度(ラジアン)を計算し、Z 回転に変換
            const angleRad = Math.atan2(dir.y, dir.x);
            const angleDeg = math.toDegree(angleRad);
            Quat.fromEuler(rot, 0, 0, angleDeg);
        } else {
            // 3D: 水平方向の Y 軸回転のみ
            // XZ 平面上の方向を使って Y 回転を決める
            const flatDir = new Vec3(dir.x, 0, dir.z);
            if (flatDir.lengthSqr() <= 0.000001) {
                // 水平成分がほぼゼロなら回転不要
                this.node.getWorldRotation(rot);
                return rot;
            }
            flatDir.normalize();
            const angleRad = Math.atan2(flatDir.x, flatDir.z); // Z 前方, X 右と仮定
            const angleDeg = math.toDegree(angleRad);
            Quat.fromEuler(rot, 0, angleDeg, 0);
        }

        return rot;
    }

    /**
     * 計算された回転をノードに適用します。
     */
    private _applyRotation(targetRot: Quat) {
        if (this.useLocalRotation) {
            this.node.setRotation(targetRot);
        } else {
            this.node.setWorldRotation(targetRot);
        }
    }

    /**
     * 現在の回転から目標回転へ、回転速度を制限しつつ補間します。
     */
    private _rotateTowards(targetRot: Quat, deltaTime: number) {
        const currentRot = new Quat();
        if (this.useLocalRotation) {
            this.node.getRotation(currentRot);
        } else {
            this.node.getWorldRotation(currentRot);
        }

        // 角度差を計算
        const angleDiff = Quat.getAngle(currentRot, targetRot); // ラジアン
        if (angleDiff <= 0.0001) {
            // ほぼ同じ向き
            this._applyRotation(targetRot);
            return;
        }

        // このフレームで回転できる最大角度(ラジアン)
        const maxStepRad = math.toRadian(this.rotateSpeed) * deltaTime;

        // slerp 係数を計算
        const t = Math.min(1, maxStepRad / angleDiff);

        const newRot = new Quat();
        Quat.slerp(newRot, currentRot, targetRot, t);

        this._applyRotation(newRot);
    }
}

コードの主要な処理の解説

  • onLoad()
    • ターゲット未設定時に警告ログを出すのみ。必須コンポーネントはないため、getComponent は不要です。
  • start()
    • ターゲットが設定されていれば、最初のワールド位置を _lastTargetPos に記録します。
    • 次フレーム以降の速度推定に使用します。
  • update(deltaTime)
    フレームごとに以下の流れで処理します:
    1. ターゲットがなければ何もしない。
    2. ターゲットの現在ワールド位置を取得。
    3. 前フレームの位置との差分から「フレームごとの速度」を計算し、velocitySmoothing を使って平滑化した推定速度を更新。
    4. predictionTime 秒後の未来位置を currentPos + velocity * predictionTime で算出。
    5. 必要に応じて maxPredictionDistance で距離を制限。
    6. 自分の位置から未来位置への方向ベクトルを求め、その方向を向くための回転 Quat_computeLookRotation で計算。
    7. rotateSpeed が 0 以下なら即座に回転、正の値なら _rotateTowards で回転速度を制限しつつ補間。
    8. デバッグ用に debugPredictedPositiondebugAimDirection を更新し、debugDraw が true の場合はログ出力。
  • _computeLookRotation(from, to)
    • 2D モード(Z_2D)の場合は XY 平面上の方向から Z 回転角を求め、Quat.fromEuler(0,0,angle) で回転を生成。
    • 3D モード(Y_3D)の場合は XZ 平面上の方向から Y 回転角を求め、Quat.fromEuler(0,angle,0) で回転を生成。
  • _rotateTowards(targetRot, deltaTime)
    • 現在の回転と目標回転の角度差を取得し、rotateSpeed(度/秒)からこのフレームで回転できる最大角度を求める。
    • その範囲内で Quat.slerp による球面線形補間で回転を進める。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. そのフォルダ内で右クリック → CreateTypeScript を選択します。
  3. ファイル名を PredictAim.ts に変更します。
  4. 作成された PredictAim.ts をダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用シーンの準備(2D の例)

ここでは 2D シューティング風の簡単なテストシーンを例に説明します。

  1. Canvas の作成(まだ無い場合)
    • Hierarchy パネルで右クリック → CreateUICanvas
  2. ターゲット(プレイヤー役)のノードを作成
    • Hierarchy で Canvas を右クリック → Create2D ObjectSprite などを選択。
    • 名前を Player に変更。
    • Inspector で Player の Position を例として (-200, 0, 0) に設定。
  3. タレット(撃つ側)のノードを作成
    • Hierarchy で Canvas を右クリック → Create2D ObjectSprite
    • 名前を Turret に変更。
    • Inspector で Position を (0, 0, 0) に設定。
    • 見た目として、矢印のようなスプライトを使うと向きが分かりやすいです。
      • Sprite の画像が右向きなら、そのまま Z 回転で狙う方向が視覚的に理解しやすくなります。

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

  1. Hierarchy で Turret ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリック。
  3. CustomPredictAim を選択して追加します。
  4. Inspector に PredictAim コンポーネントのプロパティが表示されます。

4. プロパティの設定

Inspector 上で以下のように設定してみてください(2D 用の例)。

  • Target: Hierarchy から Player ノードをドラッグし、このフィールドにドロップ。
  • Prediction Time: 0.5(0.5 秒後を狙う)。
  • Max Prediction Distance: 1000(とりあえず大きめ)。
  • Rotate Speed: 360(1 秒で 1 回転分まで回転)。
  • Use Local Rotation: チェック ON(true)。
  • Axis Mode: Z_2D を選択。
  • Velocity Smoothing: 0.3
  • Debug Draw: 最初は OFF(必要に応じて ON)。

5. ターゲットを動かす簡単な方法

ターゲットの動きを確認するため、まずはエディタのプレビュー中に手動で動かしてみます。

  1. 上部ツールバーの Play ボタン(▶)を押してゲームをプレビューします。
  2. Game ビューで再生中に、Scene ビューに戻り、Player ノードを選択して Position を少しずつ変えてみてください。
    • 例えば、X 座標を -200 → -100 → 0 → 100 → 200 と時間をかけて動かす。
  3. Turret ノードが、Player の「少し先」を向くように回転していれば成功です。
    • プレイヤーが右方向へ動き続けているとき、タレットはプレイヤーの進行方向の少し前を向きます。

より自然な動きを見るには、プレイヤーに簡単な移動スクリプト(左右キーで動くなど)を付けると分かりやすいですが、それはこのコンポーネントとは独立して実装できます。

6. 3D ゲームでの利用例

3D の場合も手順は同様です。

  1. Hierarchy で 3D Object → Cube などを使って Player3DTurret3D を作成。
  2. Turret3DPredictAim をアタッチし、プロパティを以下のように設定:
    • Target: Player3D
    • Prediction Time: 0.5 など
    • Axis Mode: Y_3D
    • Use Local Rotation: true
  3. プレビュー中に Player3D の Position(特に X, Z)を動かすと、Turret3D が水平回転で追従するのが確認できます。

7. よくある調整ポイント

  • 「未来を狙いすぎて外している」ように見える場合
    • predictionTime を小さくする(0.2 ~ 0.3 など)。
  • 「追いつくのが遅い/カクカクしている」場合
    • rotateSpeed を大きくする、もしくは 0 にして瞬時回転にする。
    • velocitySmoothing を 0.5 ~ 0.8 くらいに上げて、速度推定の追従性を高める。
  • 「一瞬だけ変な方向を向く」場合
    • maxPredictionDistance を適度な値(例: 500)にして、異常な未来位置を制限する。

まとめ

この「PredictAim」コンポーネントは、

  • ターゲットの現在の動きから未来位置を自動で予測し、
  • その方向へノードを回転させる偏差射撃ロジックを、
  • 外部スクリプトに一切依存せず、インスペクタ設定だけで完結させる

という設計になっています。

応用例としては:

  • 敵タレットにアタッチして、プレイヤーの進行方向を先読みして砲塔を向ける。
  • ホーミングミサイルの先端ノードにアタッチし、ターゲットの未来位置へ向かうようにする(移動は別スクリプト)。
  • カメラの注視点をプレイヤーの未来位置に合わせ、動きの先を映すシネマティックな演出に使う。

いずれも、この PredictAim.ts 単体をプロジェクトに追加してノードにアタッチし、ターゲットとパラメータを設定するだけで利用できます。
プロジェクト間での再利用性も高く、一度作っておけば様々なゲームで「偏差射撃」の挙動を簡単に実現できます。

必要に応じて、

  • 弾の発射スクリプトから debugAimDirection を参照して、その方向に弾を飛ばす。
  • 未来位置を可視化する Gizmo(Editor 拡張)を追加する。

といった拡張も可能です。まずはこのベース実装をプロジェクトに組み込み、実際のゲームスピードや演出に合わせて predictionTimerotateSpeed を調整してみてください。