【Cocos Creator 3.8】OrbitMovement の実装:アタッチするだけで「任意ターゲットの周囲をぐるぐる周回移動」できる汎用スクリプト

このガイドでは、指定したターゲットノードの周囲を、円軌道でぐるぐる回り続ける OrbitMovement コンポーネントを実装します。
任意のノード(例: 敵弾、衛星、エフェクトなど)にアタッチし、インスペクタでターゲットとパラメータを設定するだけで、すぐに周回移動を実現できます。

ターゲットを指定しなかった場合は、自分の親ノードを自動でターゲットとして扱うようにし、シーン設定の手間を最小限に抑えた設計にします。


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

要件整理

  • 任意の「ターゲットノード」の周囲を、一定半径の円軌道で移動させる。
  • 回転速度(角速度)、回転方向(時計回り/反時計回り)、半径、開始角度をインスペクタから調整可能にする。
  • ターゲットを未設定の場合は、自身の親ノードをターゲットとみなす(親がいない場合はエラーを出す)。
  • ゲーム一時停止などに対応できるように「一時停止/再開」のフラグを用意する(外部スクリプトに依存せず、インスペクタからでも制御できる)。
  • 2D/3D どちらでも基本的に動作するようにしつつ、座標は x, y を中心に扱う(z は維持)。
  • 外部の GameManager などに依存せず、このコンポーネント単体で完結する。

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

以下のような @property を持たせます。

  • target (Node | null)
    周回の中心となるターゲットノード。未設定の場合は自分の親ノードを自動でターゲットとする。
  • useLocalSpace (boolean)
    • true: ターゲットのローカル座標系で周回(親子関係の中で一緒に動く UI などに便利)。
    • false: ワールド座標系で周回(ゲーム空間内で自由に動くオブジェクト向け)。
  • radius (number)
    周回の半径。単位は「ユニット」。例: 100 にすると、ターゲットから距離 100 の円を描く。
  • angularSpeed (number)
    回転の角速度(度/秒)。正の値で反時計回り、負の値で時計回りに回転するように定義。
  • startAngle (number)
    周回開始時の角度(度)。
    0 度 = 右方向、90 度 = 上方向、として扱う。
  • alignRotationToOrbit (boolean)
    • true: 移動方向に応じてノードの回転も更新(進行方向を向く)。
    • false: ノードの回転は変更しない。
  • clockwise (boolean)
    • true: 時計回りに回転。
    • false: 反時計回りに回転。

    内部では angularSpeed と組み合わせて実際の角速度を決定する。

  • autoStart (boolean)
    コンポーネント有効時に自動で周回を開始するかどうか。
  • isPaused (boolean)
    周回を一時停止するフラグ。インスペクタから一時停止/再開をテストしやすくする。

これらを使って、アタッチするだけで柔軟に調整できる汎用コンポーネントにします。


TypeScriptコードの実装


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

/**
 * OrbitMovement
 * 指定したターゲットノードの周囲を円軌道で周回移動させるコンポーネント。
 * 外部スクリプトに依存せず、このコンポーネント単体で完結する。
 */
@ccclass('OrbitMovement')
export class OrbitMovement extends Component {

    @property({
        type: Node,
        tooltip: '周回の中心となるターゲットノード。\n未指定の場合は、このノードの親ノードを自動でターゲットとします。'
    })
    public target: Node | null = null;

    @property({
        tooltip: 'true: ターゲットのローカル座標系で周回します。\nfalse: ワールド座標系で周回します。'
    })
    public useLocalSpace: boolean = false;

    @property({
        tooltip: '周回の半径(ターゲットからの距離)。\n単位はユニット。例: 100 でターゲットから距離 100 の円を描きます。'
    })
    public radius: number = 100;

    @property({
        tooltip: '角速度(度/秒)。正の値で反時計回りが基本となります。\nclockwise が true の場合は時計回りになります。'
    })
    public angularSpeed: number = 90;

    @property({
        tooltip: '開始時の角度(度)。\n0度=右方向、90度=上方向として扱います。'
    })
    public startAngle: number = 0;

    @property({
        tooltip: 'true のとき、移動方向に合わせてノードの回転も更新します。\nfalse のとき、回転は変更しません。'
    })
    public alignRotationToOrbit: boolean = false;

    @property({
        tooltip: 'true: 時計回りに回転します。\nfalse: 反時計回りに回転します。'
    })
    public clockwise: boolean = false;

    @property({
        tooltip: 'コンポーネントの有効化時に自動で周回を開始するかどうか。'
    })
    public autoStart: boolean = true;

    @property({
        tooltip: '周回を一時停止するフラグです。\ntrue にすると update 中の角度更新を停止します。'
    })
    public isPaused: boolean = false;

    // 内部状態
    private _currentAngleDeg: number = 0;   // 現在の角度(度)
    private _centerPos: Vec3 = new Vec3();  // ターゲットの位置キャッシュ
    private _lastPos: Vec3 = new Vec3();    // 直前フレームの位置(進行方向算出用)

    onLoad() {
        // ターゲットが未設定なら親ノードを自動でターゲットとする
        if (!this.target) {
            if (this.node.parent) {
                this.target = this.node.parent;
                console.warn('[OrbitMovement] target が未設定だったため、親ノードをターゲットとして使用します。ノード名:', this.node.name);
            } else {
                console.error('[OrbitMovement] target が未設定で、かつ親ノードも存在しません。周回の中心を特定できません。ノード名:', this.node.name);
            }
        }

        // 開始角度を初期化
        this._currentAngleDeg = this.startAngle;

        // 初期位置を計算して反映
        this._updateCenterPosition();
        this._updateNodePosition(0); // deltaTime 0 で現在角度の位置に配置
        this._lastPos.set(this.node.worldPosition);
    }

    start() {
        // autoStart が false の場合、開始時に一旦停止状態にする
        if (!this.autoStart) {
            this.isPaused = true;
        }
    }

    update(deltaTime: number) {
        if (!this.target) {
            // onLoad でエラーを出しているので、ここでは静かにスキップ
            return;
        }

        if (this.isPaused) {
            // 一時停止中は角度更新をしない
            return;
        }

        // 現在の中心位置を更新
        this._updateCenterPosition();

        // 角速度に基づいて角度を更新
        const directionFactor = this.clockwise ? -1 : 1;
        const deltaAngle = this.angularSpeed * directionFactor * deltaTime;
        this._currentAngleDeg += deltaAngle;

        // 位置更新前の位置を保存(進行方向を求めるため)
        this._lastPos.set(this.node.worldPosition);

        // 角度に応じてノードの位置を更新
        this._updateNodePosition(deltaTime);

        // 進行方向に合わせて回転を向けるオプション
        if (this.alignRotationToOrbit) {
            this._updateNodeRotationAlongPath();
        }
    }

    /**
     * 現在のターゲット位置を _centerPos に更新する。
     */
    private _updateCenterPosition() {
        if (!this.target) {
            return;
        }
        if (this.useLocalSpace) {
            // ローカル座標系での中心位置
            this._centerPos.set(this.target.position);
        } else {
            // ワールド座標系での中心位置
            this._centerPos.set(this.target.worldPosition);
        }
    }

    /**
     * _currentAngleDeg と radius に基づいてノードの位置を更新する。
     */
    private _updateNodePosition(deltaTime: number) {
        // 角度をラジアンに変換
        const rad = math.toRadian(this._currentAngleDeg);

        // 円軌道上のオフセットを計算(2D 想定: x, y 平面)
        const offsetX = Math.cos(rad) * this.radius;
        const offsetY = Math.sin(rad) * this.radius;

        let newPos = new Vec3();

        if (this.useLocalSpace) {
            // ターゲットのローカル座標 + オフセット
            newPos.set(this._centerPos.x + offsetX, this._centerPos.y + offsetY, this.node.position.z);
            this.node.setPosition(newPos);
        } else {
            // ワールド座標での中心 + オフセット
            newPos.set(this._centerPos.x + offsetX, this._centerPos.y + offsetY, this.node.worldPosition.z);
            this.node.setWorldPosition(newPos);
        }
    }

    /**
     * 前フレームからの移動ベクトルに応じて、ノードの回転を進行方向に合わせる。
     * 2D を想定し、Z 軸回りの回転のみを更新する。
     */
    private _updateNodeRotationAlongPath() {
        const currentPos = this.node.worldPosition;
        const dx = currentPos.x - this._lastPos.x;
        const dy = currentPos.y - this._lastPos.y;

        // ほとんど動いていない場合は回転を更新しない
        const epsilon = 0.0001;
        if (Math.abs(dx) < epsilon && Math.abs(dy) < epsilon) {
            return;
        }

        // 進行方向の角度(ラジアン)を取得
        const angleRad = Math.atan2(dy, dx);
        // 度に変換
        const angleDeg = math.toDegree(angleRad);

        // 2D の場合、Z 軸回転が見た目の向きになる
        const currentEuler = this.node.eulerAngles;
        this.node.setRotationFromEuler(currentEuler.x, currentEuler.y, angleDeg);
    }

    /**
     * 周回を一時停止するパブリックメソッド。
     * 外部から呼び出して制御したい場合に使用できます。
     */
    public pauseOrbit() {
        this.isPaused = true;
    }

    /**
     * 周回を再開するパブリックメソッド。
     */
    public resumeOrbit() {
        this.isPaused = false;
    }

    /**
     * 現在の角度を任意の値(度)にリセットします。
     * 半径はそのままに、軌道上の位置だけを変更したい場合に使用します。
     */
    public setAngle(angleDeg: number) {
        this._currentAngleDeg = angleDeg;
        this._updateCenterPosition();
        this._updateNodePosition(0);
    }

    /**
     * 現在の角度(度)を取得します。
     */
    public getAngle(): number {
        return this._currentAngleDeg;
    }
}

コードのポイント解説

  • onLoad
    • target が未設定なら this.node.parent をターゲットとして自動設定。
    • 親も存在しない場合は console.error で明示的にエラーを出し、処理を継続しない設計。
    • startAngle を現在角度としてセットし、その角度に基づいて初期配置。
  • start
    • autoStart が false の場合は isPaused = true にして、開始直後は静止させる。
  • update(deltaTime)
    • target がない場合は何もしない(onLoad でエラー済み)。
    • isPaused が true の場合は角度更新をスキップ。
    • angularSpeedclockwise から deltaAngle を計算して角度を更新。
    • 更新前の位置を _lastPos に保存し、位置更新後に進行方向から回転を算出。
  • _updateNodePosition
    • 角度(度)をラジアンに変換し、cos/sin で円軌道上のオフセットを計算。
    • useLocalSpace によって position / worldPosition を使い分け。
  • _updateNodeRotationAlongPath
    • 前フレームからの移動ベクトル (dx, dy) をもとに atan2 で進行方向角度を取得。
    • その角度を Z 軸回転(2D の向き)として setRotationFromEuler で反映。
  • pauseOrbit / resumeOrbit / setAngle / getAngle
    • 外部から制御しやすいように最低限のパブリック API を用意。
    • ただし外部の GameManager などは前提とせず、このクラス単体で完結。

使用手順と動作確認

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

  1. エディタの Assets パネルで任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create > TypeScript を選択し、ファイル名を OrbitMovement.ts として作成します。
  3. 自動生成された OrbitMovement.ts をダブルクリックし、エディタ(VS Code など)で開きます。
  4. 中身をすべて削除し、本記事の TypeScriptコードの実装 セクションのコードをそのまま貼り付けて保存します。

2. テスト用ノードの作成(2D の例)

  1. Hierarchy パネルで右クリックし、Create > 2D Object > Canvas を作成します(既にある場合は再利用)。
  2. Canvas の子として、周回の中心となるノードを作成します。
    • Canvas を右クリック → Create > 2D Object > Sprite
    • 名前を Center などに変更し、位置を (0, 0, 0) にしておきます。
  3. Canvas の子、あるいは Center の子として、周回させたいノードを作成します。
    • 例: Canvas を右クリック → Create > 2D Object > Sprite
    • 名前を Orbiter に変更します。

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

  1. Hierarchy で周回させたいノード(例: Orbiter)を選択します。
  2. Inspector パネルの下部で Add Component ボタンをクリックします。
  3. メニューから Custom > OrbitMovement を選択してアタッチします。

4. プロパティの設定例

Inspector 上の OrbitMovement セクションで、以下のように設定してみてください。

  1. target:
    • ドラッグ&ドロップで Center ノードを指定します。
    • もし Orbiter の親が Center であれば、あえて指定せずに「親を自動ターゲット」にしても構いません。
  2. useLocalSpace:
    • true にすると、ターゲットのローカル座標で周回します(UI などで親と一緒に動かしたい場合)。
    • 2D のシンプルなテストなら、まずは false(ワールド座標)でも問題ありません。
  3. radius: 150 など。
    Center から 150 ユニット離れた円を描きます。
  4. angularSpeed: 90
    1 秒あたり 90 度回転(4 秒で一周)します。
  5. startAngle: 0
    右方向からスタートします。90 にすると上からスタート。
  6. alignRotationToOrbit:
    • 見た目を確認したい場合は true にして、進行方向を向かせてみましょう。
    • 常に一定の向きで回らせたい場合は false のままにします。
  7. clockwise:
    • false: 反時計回り。
    • true: 時計回り。
  8. autoStart: true にしておくと、ゲーム開始と同時に周回が始まります。
  9. isPaused: 初期値は false のまま(動作中)。ゲーム中にこのチェックをオンにするとその場で停止する挙動も確認できます。

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

  1. エディタ上部の Play ボタン(ブラウザ実行)または Preview をクリックします。
  2. ゲームビューで、OrbiterCenter の周囲を円を描くように回っていることを確認します。
  3. もし動かない場合は、以下を確認してください。
    • OrbitMovement コンポーネントが正しくアタッチされているか。
    • target が設定されているか、あるいは Orbiter に親ノードが存在するか。
    • isPausedfalse になっているか。
    • radiusangularSpeed が 0 になっていないか。

6. 応用的な使い方の例

  • 複数のオブジェクトを同じターゲットの周囲に配置
    複数のノードに OrbitMovement をアタッチし、同じ target を指定して startAngle を 0, 120, 240 度のようにずらすと、衛星が等間隔で周回しているような表現ができます。
  • 一時停止と再開
    インスペクタで isPaused をオン/オフするか、別スクリプトから
    const orbit = someNode.getComponent(OrbitMovement);
    orbit?.pauseOrbit();
    // ...
    orbit?.resumeOrbit();

    のように呼び出して制御できます(このコンポーネント自体は他スクリプトに依存しません)。

  • UI のデコレーション
    useLocalSpace = true にして、ボタンの周りをエフェクトが回るような UI 演出にもそのまま利用できます。

まとめ

OrbitMovement コンポーネントは、

  • ターゲットノードの周囲を円軌道で周回する動き
  • 半径・角速度・開始角度・回転方向・進行方向への向き揃え
  • ローカル/ワールド座標の切り替え
  • 一時停止/再開 API

といった機能を、このスクリプト単体で完結して提供します。

シンプルな敵弾パターンから、ボスの周囲を回るオプション、UI の装飾エフェクトまで、さまざまな場面で「アタッチしてパラメータを変えるだけ」で再利用できるのが大きな利点です。

自分のプロジェクトに組み込む際は、今回の設計をベースに、例えば「半径を時間で変化させる(スパイラル軌道)」「ターゲットを動的に切り替える」などを追加していくことで、よりリッチな動きを簡単に量産できます。

まずはこの記事のコードをそのままコピペして動かし、プロパティをいじりながら挙動を体で覚えてみてください。その上で、必要に応じて機能を拡張していくと、汎用コンポーネント設計の良い練習にもなります。