【Cocos Creator 3.8】TurretRotation(砲台旋回)の実装:アタッチするだけで「ターゲット方向へ砲塔を自動旋回」させる汎用スクリプト

このガイドでは、タンクや対空砲の「砲塔ノード」にアタッチするだけで、指定したターゲット(プレイヤー・敵・マウスカーソルなど)の方向へ自動的に向きを合わせる汎用コンポーネント TurretRotation を実装します。

本体(車体)と砲塔を別ノードにしている場合でも、砲塔ノード単体で完結して動くように設計しているため、他のゲーム管理スクリプトやシングルトンに依存せず、インスペクタでターゲットノードや回転速度などを設定するだけで利用できます。


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

1. 機能要件の整理

  • 砲塔ノード(このコンポーネントをアタッチしたノード)が、ターゲットノードの方向を向く。
  • 任意の回転速度で「スナップ回転(瞬時に向く)」と「スムーズに追従する回転」の両方をサポート。
  • 2Dゲームを主眼に、Z軸回転(eulerAngles.z)で方向を合わせる。
  • ターゲットが存在しない場合は何もしない(エラーで止めない)。
  • 「回転可能な角度範囲」や「左右反転」など、よくある砲塔の制約にも対応可能にする。
  • マウス位置をターゲットにするモードも用意し、シューティングゲームなどで使いやすくする。

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

以下のような点に注意して、完全に独立したコンポーネントにします。

  • ターゲットノードは @property(Node) でインスペクタから直接指定。
  • マウス追従モード時も、CanvasCamera@property で受け取り、シングルトンや他スクリプトに依存しない。
  • 必要な標準コンポーネント(例: UITransform)は getComponent で取得し、見つからなければ警告ログを出す。
  • 2D/3Dどちらでも最低限動くように、基本はワールド座標の差分ベクトルから角度を計算する。

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

インスペクタに表示するプロパティと、その役割は以下の通りです。

  • target (Node | null)
    砲塔が向くターゲットノード。敵キャラやプレイヤーなどをここにドラッグ&ドロップ。
    mouseFollowEnabled が true の場合は無視されます。
  • mouseFollowEnabled (boolean)
    マウスカーソルの位置をターゲットとして扱うかどうか。PC向けツインスティックシューターなどで便利。
    true の場合、canvascamera が必要になります。
  • canvas (Node | null)
    マウス座標をワールド座標に変換するための Canvas ノード。
    mouseFollowEnabled = true のときに設定必須です。
  • camera (Camera | null)
    マウス座標をワールド座標に変換するために使用するカメラ。通常は 2D UI 用カメラやメインカメラを指定。
    mouseFollowEnabled = true のときに設定必須です。
  • rotationSpeed (number)
    1秒あたりの最大回転速度(度/秒)。
    0 もしくは非常に大きい値を指定すると「ほぼ瞬時に向く」挙動になります。
    例: 90 なら「1秒で90度」まで回転します。
  • instantSnapThreshold (number)
    現在の向きとターゲット方向の角度差がこの値(度)以下のとき、補間せずに一気に目標角度まで回転させる閾値。
    小さくすると常に滑らかに回転し、大きくするとある程度近づいたらカチッと向きます。
  • limitRotation (boolean)
    回転可能な角度範囲を制限するかどうか。
    例えば「正面から左右 60 度までしか向けない砲塔」などを表現可能。
  • minAngle (number)
    制限モード時の最小角度(度)。
    ローカル Z 回転角(-180〜180 の範囲)で解釈されます。例: -60。
  • maxAngle (number)
    制限モード時の最大角度(度)。
    例: 60 とすると、-60〜60 度の範囲でしか砲塔が回転しません。
  • invertDirection (boolean)
    方向を反転させるかどうか。
    スプライトのデザイン上、「右を向いている画像だがローカル角度0が左を向く」などの場合に、ここを true にして調整します。
  • debugLog (boolean)
    コンソールにデバッグ用のログを出すかどうか。
    セットアップ時の不具合調査用で、完成後は false にしておくのがおすすめです。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    Vec3,
    math,
    director,
    EventMouse,
    input,
    Input,
    Camera,
    UITransform,
    Canvas,
} from 'cc';
const { ccclass, property, tooltip } = _decorator;

/**
 * TurretRotation
 * 砲塔ノードをターゲット方向へ自動回転させる汎用コンポーネント。
 * - target ノード or マウス位置を追従
 * - 回転速度 / 角度制限 / 反転 に対応
 */
@ccclass('TurretRotation')
export class TurretRotation extends Component {

    @property({
        type: Node,
        tooltip: '砲塔が向くターゲットノード。mouseFollowEnabled が true の場合は無視されます。'
    })
    public target: Node | null = null;

    @property({
        tooltip: 'マウスカーソルの位置をターゲットとして扱うかどうか(PC向け)。'
    })
    public mouseFollowEnabled: boolean = false;

    @property({
        type: Node,
        tooltip: 'Canvas ノード。mouseFollowEnabled = true のとき、マウス座標をワールド座標に変換するために使用します。'
    })
    public canvas: Node | null = null;

    @property({
        type: Camera,
        tooltip: 'マウス座標をワールド座標に変換するために使用するカメラ。通常はメインカメラや UI カメラを指定します。'
    })
    public camera: Camera | null = null;

    @property({
        tooltip: '1秒あたりの最大回転速度(度/秒)。0 または非常に大きな値でほぼ瞬時に向きます。'
    })
    public rotationSpeed: number = 180;

    @property({
        tooltip: 'この角度差(度)以下になったら目標角度へ一気にスナップさせます。0 で常にスムーズ回転。'
    })
    public instantSnapThreshold: number = 1;

    @property({
        tooltip: '回転可能な角度範囲を制限するかどうか(ローカルZ軸 -180〜180度)。'
    })
    public limitRotation: boolean = false;

    @property({
        tooltip: 'limitRotation が true のときの最小角度(度)。例: -60'
    })
    public minAngle: number = -60;

    @property({
        tooltip: 'limitRotation が true のときの最大角度(度)。例: 60'
    })
    public maxAngle: number = 60;

    @property({
        tooltip: '方向を反転させるかどうか。スプライトの向きと回転の基準が合わない場合に使用します。'
    })
    public invertDirection: boolean = false;

    @property({
        tooltip: 'デバッグ用ログを有効にするかどうか。'
    })
    public debugLog: boolean = false;

    // 内部用ワーク変数(毎フレーム new しないように再利用)
    private _worldPosTurret: Vec3 = new Vec3();
    private _worldPosTarget: Vec3 = new Vec3();
    private _dir: Vec3 = new Vec3();
    private _mouseWorldPos: Vec3 = new Vec3();

    private _canvasUiTransform: UITransform | null = null;

    onLoad() {
        // Canvas の UITransform をキャッシュ
        if (this.canvas) {
            this._canvasUiTransform = this.canvas.getComponent(UITransform);
            if (!this._canvasUiTransform) {
                console.warn('[TurretRotation] Canvas ノードに UITransform コンポーネントが見つかりません。マウス追従が正しく動作しない可能性があります。');
            }
        }

        if (this.mouseFollowEnabled) {
            // マウス移動イベントを監視して常に最新のマウス位置を保持
            input.on(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
        }
    }

    start() {
        if (this.debugLog) {
            console.log('[TurretRotation] start: node =', this.node.name);
        }

        if (this.mouseFollowEnabled) {
            if (!this.canvas) {
                console.warn('[TurretRotation] mouseFollowEnabled が true ですが Canvas が設定されていません。マウス追従は動作しません。');
            }
            if (!this.camera) {
                console.warn('[TurretRotation] mouseFollowEnabled が true ですが Camera が設定されていません。マウス追従は動作しません。');
            }
        }
    }

    onDestroy() {
        if (this.mouseFollowEnabled) {
            input.off(Input.EventType.MOUSE_MOVE, this._onMouseMove, this);
        }
    }

    /**
     * マウス移動時にスクリーン座標からワールド座標へ変換して保持しておく
     */
    private _onMouseMove(event: EventMouse) {
        if (!this.mouseFollowEnabled) {
            return;
        }
        if (!this.camera || !this.canvas || !this._canvasUiTransform) {
            return;
        }

        const screenPos = event.getLocation(); // Vec2 (x, y)
        const out = this._mouseWorldPos;

        // Canvas の UI 空間からワールド座標に変換
        // スクリーン座標 → Canvasローカル → ワールド
        const canvasPos = this._canvasUiTransform.convertToNodeSpaceAR(
            new Vec3(screenPos.x, screenPos.y, 0)
        );
        this.canvas!.getWorldMatrix().transformPoint(canvasPos, out);

        if (this.debugLog) {
            console.log('[TurretRotation] Mouse world pos:', out);
        }
    }

    update(deltaTime: number) {
        // 追従対象のワールド座標を決定
        const targetWorldPos = this._getTargetWorldPosition();
        if (!targetWorldPos) {
            // ターゲットがない場合は何もしない
            return;
        }

        // 砲塔ノードのワールド座標を取得
        this.node.getWorldPosition(this._worldPosTurret);

        // 方向ベクトル = ターゲット - 自分
        Vec3.subtract(this._dir, targetWorldPos, this._worldPosTurret);

        if (this._dir.lengthSqr() < 0.0001) {
            // ほぼ同じ位置なら回転不要
            return;
        }

        // 2D前提:Z軸回転で方向を向く
        // atan2(y, x) でラジアン角を求め、度に変換
        let angleRad = Math.atan2(this._dir.y, this._dir.x);
        let angleDeg = math.toDegree(angleRad);

        // Cocos の 2D スプライトは通常「右向きが 0度」なので、そのまま使える。
        // ただしデザインによっては上向きが0度などもあるため、
        // その場合は invertDirection や min/maxAngle で調整。
        if (this.invertDirection) {
            angleDeg += 180;
        }

        // 現在のローカル角度(Z軸)を取得(-180〜180 に正規化)
        const currentEuler = this.node.eulerAngles;
        let currentAngle = currentEuler.z;
        currentAngle = this._normalizeAngle(currentAngle);

        // 目標角度も -180〜180 に正規化
        let targetAngle = this._normalizeAngle(angleDeg);

        // 角度制限が有効なら、目標角度をクランプ
        if (this.limitRotation) {
            const minA = this._normalizeAngle(this.minAngle);
            const maxA = this._normalizeAngle(this.maxAngle);
            targetAngle = this._clampAngle(targetAngle, minA, maxA);
        }

        // 現在角度から目標角度への最短差分を求める
        let delta = this._deltaAngle(currentAngle, targetAngle);
        const absDelta = Math.abs(delta);

        // 回転速度が 0 以下なら即スナップ
        if (this.rotationSpeed <= 0) {
            currentAngle = targetAngle;
        } else {
            // スナップ閾値以下なら一気に目標角度へ
            if (absDelta <= this.instantSnapThreshold) {
                currentAngle = targetAngle;
            } else {
                // 1フレームで回転できる最大角度
                const maxStep = this.rotationSpeed * deltaTime;
                // 実際に回転する角度(符号付き)
                const step = math.clamp(delta, -maxStep, maxStep);
                currentAngle = this._normalizeAngle(currentAngle + step);
            }
        }

        // 最終的な角度をノードに反映
        this.node.eulerAngles = new Vec3(
            currentEuler.x,
            currentEuler.y,
            currentAngle,
        );
    }

    /**
     * ターゲットのワールド座標を取得。
     * - mouseFollowEnabled = true の場合はマウスワールド座標
     * - それ以外は target ノードのワールド座標
     */
    private _getTargetWorldPosition(): Vec3 | null {
        if (this.mouseFollowEnabled) {
            if (!this.camera || !this.canvas || !this._canvasUiTransform) {
                return null;
            }
            return this._mouseWorldPos;
        }

        if (!this.target) {
            return null;
        }

        this.target.getWorldPosition(this._worldPosTarget);
        return this._worldPosTarget;
    }

    /**
     * 角度を -180〜180度に正規化
     */
    private _normalizeAngle(angle: number): number {
        angle = angle % 360;
        if (angle > 180) angle -= 360;
        if (angle < -180) angle += 360;
        return angle;
    }

    /**
     * a から b へ回転する際の最短差分角度(b - a)を -180〜180 で返す
     */
    private _deltaAngle(a: number, b: number): number {
        let delta = this._normalizeAngle(b) - this._normalizeAngle(a);
        delta = this._normalizeAngle(delta);
        return delta;
    }

    /**
     * 角度を [min, max] の範囲にクランプ。
     * min <= max を前提とし、どちらも -180〜180 にあると仮定。
     */
    private _clampAngle(angle: number, min: number, max: number): number {
        angle = this._normalizeAngle(angle);
        min = this._normalizeAngle(min);
        max = this._normalizeAngle(max);

        // min > max の場合は範囲が壊れているので、そのまま返す
        if (min > max) {
            console.warn('[TurretRotation] minAngle > maxAngle です。設定を確認してください。');
            return angle;
        }

        if (angle < min) angle = min;
        if (angle > max) angle = max;
        return angle;
    }
}

コードのポイント解説

  • onLoad
    • Canvas ノードから UITransform を取得し、マウス座標変換に備えています。
    • mouseFollowEnabled が true の場合、マウス移動イベントを購読して、常に最新のマウスのワールド座標を保持します。
  • start
    • デバッグログの出力と、マウス追従モード時に必要な canvas / camera が設定されているかをチェックし、足りない場合は console.warn で通知します。
  • update(deltaTime)
    • 毎フレーム、ターゲットのワールド座標を取得し、砲塔ノードとのベクトル差から向くべき角度を計算します。
    • 現在のローカル Z 回転角との最短差分を求め、rotationSpeed に応じて少しずつ回転させます。
    • instantSnapThreshold 以下の差分になったら、カチッと目標角度にスナップします。
    • limitRotation が true の場合は、minAnglemaxAngle の範囲内に目標角度をクランプします。
  • _onMouseMove
    • マウスイベントからスクリーン座標を取得し、CanvasUITransform を使ってワールド座標へ変換しています。
    • 変換結果は _mouseWorldPos に保存され、update 内でターゲット位置として使用されます。

使用手順と動作確認

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

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

2. 砲塔ノードの準備

ここでは 2D タンクを例に、シンプルなセットアップ手順を示します。

  1. Hierarchy パネルで右クリック → Create → Node を選択し、名前を TankBody に変更します。(戦車の車体)
  2. TankBody を選択した状態で右クリック → Create → Node を選択し、名前を TankTurret に変更します。(砲塔)
  3. それぞれに Sprite コンポーネントを追加し、適当な画像を設定します。
    • TankBody のスプライト:車体の画像
    • TankTurret のスプライト:砲塔のみの画像(砲身の根元が原点になるようにしておくと回転がきれいです)
  4. TankTurret の位置を、車体の上に来るように調整します。

3. ターゲットノードの準備

まずは「プレイヤーを追従する砲塔」として動かしてみます。

  1. Hierarchy パネルで右クリック → Create → Node を選択し、名前を Player に変更します。
  2. Player にも Sprite を追加して、適当な画像を設定します。
  3. シーン上で Player を少し離れた位置に配置します。

4. TurretRotation コンポーネントのアタッチ

  1. HierarchyTankTurret ノードを選択します。
  2. Inspector パネルの下部にある Add Component ボタンをクリックします。
  3. Custom ComponentTurretRotation を選択してアタッチします。

5. プロパティの設定(プレイヤー追従モード)

TankTurretTurretRotation コンポーネントを選択し、Inspector で以下のように設定します。

  • target: Player ノードをドラッグ&ドロップ
  • mouseFollowEnabled: false
  • canvas: 空欄でOK(マウス追従を使わないため)
  • camera: 空欄でOK
  • rotationSpeed: 180(1秒で180度回転)
  • instantSnapThreshold: 2(2度以内になったらスナップ)
  • limitRotation: false(まずは制限なしで動作確認)
  • invertDirection: 砲塔スプライトが右向きなら false、上向きなら true など、見た目に合わせて調整
  • debugLog: 動作確認中にログが欲しければ true

この状態でシーンを再生すると、TankTurret が常に Player の方向を向くように回転します。
エディタの再生中に Player ノードを移動させると、砲塔が追従して向きを変えるのが確認できます。

6. マウス追従モードでの動作確認

次に、マウスカーソルの位置を追いかける砲塔として動かしてみます。

  1. シーンに Canvas がない場合は、Hierarchy で右クリック → Create → UI → Canvas で追加します。
  2. Canvas には自動で UITransform が付与されます。もし外してしまっている場合は、Add ComponentUITransform を追加してください。
  3. カメラがない場合は、Hierarchy で右クリック → Create → 3D Object → Camera などでカメラを作成します。2Dゲームであれば、カメラの Projection を Orthographic にしておくと扱いやすいです。
  4. TankTurret ノードを選択し、TurretRotation のプロパティを次のように変更します。
    • target: 空欄にする(マウス追従時は使いません)
    • mouseFollowEnabled: true
    • canvas: Hierarchy から Canvas ノードをドラッグ&ドロップ
    • camera: 使用しているカメラ(例: Main Camera)をドラッグ&ドロップ
    • その他のパラメータ(rotationSpeed など)はお好みで調整

シーンを再生し、ゲームビュー上でマウスカーソルを動かしてみてください。
TankTurret がマウスの位置を追いかけるように回転すれば成功です。

7. 回転制限と反転の調整例

「正面から左右 60 度までしか向けない砲塔」を作る例です。

  1. TurretRotation のプロパティを以下のように設定します。
    • limitRotation: true
    • minAngle: -60
    • maxAngle: 60
  2. シーンを再生し、ターゲットを大きく動かしてみてください。
    砲塔が -60〜60度の範囲からはそれ以上回転しないことが確認できます。

もし砲塔のスプライトのデザイン上、「右ではなく上が基準方向」になっている場合は、以下のように調整します。

  • invertDirectiontrue にしてみる。
  • それでもずれている場合は、スプライト画像自体を編集して「右向きが0度」になるようにするか、別途 offsetAngle のようなプロパティを追加して調整してもよいでしょう。

まとめ

この TurretRotation コンポーネントは、

  • ターゲットノードの方向を向く砲塔
  • マウスカーソルを追従するタレット
  • 左右に制限のある監視カメラ
  • ボスの追尾砲台

といった「何かの方向を向き続けるオブジェクト」を簡単に実現するための汎用スクリプトです。

外部の GameManager やシングルトンに一切依存せず、必要な情報はすべてインスペクタのプロパティから受け取る設計にしているため、

  • 任意のシーン・任意のプロジェクトにコピペで持ち込んで即利用できる
  • 「砲塔だけ別プロジェクトに流用する」といった再利用がしやすい
  • チーム開発時にも、他のスクリプトに影響を与えず安全に組み込める

というメリットがあります。

まずは基本形のこのコンポーネントをベースに、

  • 射程距離外では回転しない
  • 視界外ではターゲットを見失う
  • 発射タイミングと連携する

といった要素を追加していくことで、より高度な砲台システムへ発展させることも容易です。
本記事のコードをそのまま貼り付けて、ぜひ自分のプロジェクトの砲塔ノードにアタッチして試してみてください。