【Cocos Creator 3.8】OrbitalShield(ビットシールド)の実装:アタッチするだけで「親の周囲を周回し、触れた敵弾を自動で消す」汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「親ノードの周囲をグルグル周回し、触れた敵弾(指定したグループのオブジェクト)を自動で消す」ビットシールドコンポーネント OrbitalShield を実装します。

プレイヤー機の周りを回るオプション、ボスの周囲を回る防御ビットなど、さまざまなシーンで使い回せるように、

  • 外部の GameManager やシングルトンに一切依存しない
  • インスペクタのプロパティだけで挙動を調整できる
  • 2D/3D どちらでも利用できる(ローカル回転を利用)

という方針で設計します。


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

1. 機能要件の整理

  • 周回運動
    • このコンポーネントがアタッチされたノード(ビットシールド)は、親ノードの周囲を円運動します。
    • 親ノードのローカル原点を中心に、一定の半径で周回します。
    • 周回速度(角速度)、初期角度、回転方向(時計回り/反時計回り)をインスペクタから調整可能にします。
    • 半径は固定値または親からの初期距離を使うかを選択できるようにします。
  • 敵弾との接触判定
    • ビットシールドが接触した敵弾(指定グループのオブジェクト)を自動で消去します。
    • 2D/3D どちらでも使えるように、2D/3D 物理のどちらを使うかを切り替えられる設計にします。
    • 2D の場合は Collider2D / RigidBody2D、3D の場合は Collider / RigidBody を利用します。
    • 敵弾の判定は「ノードのグループ名」で行い、インスペクタで enemyBulletGroup を指定します。
    • 敵弾は destroy() で削除します(ここでは汎用性重視でダメージ処理などは行わない)。
  • 完全な独立性
    • 外部のゲームマネージャやシングルトンには一切依存しません。
    • 必要な設定はすべてコンポーネント自身の @property で受け取ります。
    • 必要な標準コンポーネント(Collider, RigidBody など)が足りない場合は、ログで警告し、記事内でエディタ操作として明示します。

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

OrbitalShield に持たせるプロパティと役割は以下の通りです。

  • use3DPhysics: boolean
    • 2D 物理と 3D 物理のどちらを使用するかを切り替えます。
    • false: 2D 物理(Collider2D / RigidBody2D)を利用。
    • true: 3D 物理(Collider / RigidBody)を利用。
  • enemyBulletGroup: string
    • 「敵弾」とみなすノードのグループ名。
    • ここで指定されたグループに属するノードと接触したとき、そのノードを destroy() します。
    • 例: "enemyBullet" など。
      (Cocos Creator の「Project Settings → Layers/Groups」で事前に定義しておきます)
  • orbitRadius: number
    • 周回の半径(ローカル座標系での距離)。
    • useInitialOffsetAsRadiusfalse のとき、この値が使用されます。
    • 単位はローカル座標系の単位(2D ならピクセル相当)。
  • useInitialOffsetAsRadius: boolean
    • true の場合、ノードの「親からの初期ローカル位置」の距離を半径として使用します。
    • つまり、エディタでビットを好きな位置に配置しておけば、その距離を保ったまま周回するようになります。
    • false の場合は、orbitRadius の値を使います。
  • angularSpeedDeg: number
    • 周回の角速度(度/秒)。
    • 正の値で反時計回り、負の値で時計回りに回転します。
    • 例: 90 を指定すると、1 秒で 90 度(4 秒で一周)回転します。
  • initialAngleDeg: number
    • 周回開始時の角度(度)。
    • 0 度を基準に、反時計回りを正として角度を決めます。
    • 複数のビットを等間隔に配置したい場合は、0 / 90 / 180 / 270 のように変えて設定します。
  • rotateWithParent: boolean
    • true の場合、親ノードの回転に追従したまま周回します。
    • false の場合、親の回転に関係なく、親のローカル原点を中心にワールド的な円運動をします。
    • 2D シューティングでプレイヤーが回転しない前提なら false で問題ありません。
  • enableCollision: boolean
    • 敵弾消去機能を有効/無効にします。
    • オフにすると、周回運動だけを行う「飾りのビット」として使えます。
  • debugLogCollision: boolean
    • 敵弾を消したときに console.log でログ出力するかどうか。
    • 動作確認用で便利ですが、最終的なビルドではオフにしておくと良いです。

これらのプロパティだけで、

  • 「2D シューティング用のビットシールド」
  • 「3D アクションゲームでボスの周りを回るバリア」
  • 「当たり判定を持たないただの周回エフェクト」

などを柔軟に構成できます。


TypeScriptコードの実装

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


import {
    _decorator,
    Component,
    Node,
    Vec3,
    math,
    Collider2D,
    Contact2DType,
    IPhysics2DContact,
    Collider,
    ICollisionEvent,
    RigidBody2D,
    RigidBody,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * OrbitalShield
 * 親の周囲を周回し、接触した指定グループのノード(敵弾など)を破壊する汎用コンポーネント。
 *
 * - 親ノードのローカル原点を中心に円運動します。
 * - 2D / 3D 物理のどちらにも対応します(use3DPhysics で切り替え)。
 * - 敵弾の判定はノードの group 名で行います。
 *
 * 注意:
 * - 2D 使用時: このノードに Collider2D(CircleCollider2D 等)を追加してください。
 * - 3D 使用時: このノードに Collider(SphereCollider 等)を追加してください。
 * - 物理エンジン(2D または 3D)が Project Settings で有効になっていることを確認してください。
 */
@ccclass('OrbitalShield')
export class OrbitalShield extends Component {

    @property({
        tooltip: 'true: 3D物理(Collider / RigidBody)を使用。\nfalse: 2D物理(Collider2D / RigidBody2D)を使用。'
    })
    public use3DPhysics: boolean = false;

    @property({
        tooltip: '敵弾として扱うノードのグループ名。\nこのグループに属するノードと接触した場合に destroy() します。'
    })
    public enemyBulletGroup: string = 'enemyBullet';

    @property({
        tooltip: '周回半径。\nuseInitialOffsetAsRadius が false の場合に使用される、親からの距離(ローカル座標系)。'
    })
    public orbitRadius: number = 100;

    @property({
        tooltip: 'true: エディタで配置した初期ローカル位置の距離を半径として使用。\nfalse: orbitRadius プロパティの値を半径として使用。'
    })
    public useInitialOffsetAsRadius: boolean = true;

    @property({
        tooltip: '周回の角速度(度/秒)。\n正の値で反時計回り、負の値で時計回りに回転します。'
    })
    public angularSpeedDeg: number = 90;

    @property({
        tooltip: '周回開始時の角度(度)。\n複数のビットを等間隔に配置したい場合、この値を変えて設定します。'
    })
    public initialAngleDeg: number = 0;

    @property({
        tooltip: 'true: 親ノードの回転に追従しながら周回します。\nfalse: 親の回転に関係なく、親のローカル原点を中心に円運動します。'
    })
    public rotateWithParent: boolean = false;

    @property({
        tooltip: '敵弾消去のための接触判定を有効にするかどうか。\n無効にすると、周回運動のみ行います。'
    })
    public enableCollision: boolean = true;

    @property({
        tooltip: '敵弾を消したときにログを出力するかどうか(デバッグ用)。'
    })
    public debugLogCollision: boolean = false;

    // 内部状態
    private _currentAngleRad: number = 0;     // 現在角度(ラジアン)
    private _radius: number = 100;           // 実際に使用する半径
    private _initialLocalPos: Vec3 = new Vec3(); // 初期ローカル位置のコピー

    // 2D / 3D 物理コンポーネント参照
    private _collider2D: Collider2D | null = null;
    private _collider3D: Collider | null = null;

    onLoad () {
        const node = this.node;

        // 親が存在するか確認
        if (!node.parent) {
            console.warn('[OrbitalShield] 親ノードが存在しません。このコンポーネントは親ノードが必須です。', node.name);
        }

        // 初期ローカル位置を保存
        this._initialLocalPos = node.position.clone();

        // 半径を決定
        if (this.useInitialOffsetAsRadius) {
            // 親からの初期ローカル位置の距離を使用
            this._radius = this._initialLocalPos.length();
        } else {
            this._radius = Math.max(0, this.orbitRadius);
        }

        // 初期角度(度)をラジアンに変換
        this._currentAngleRad = math.toRadian(this.initialAngleDeg);

        // 物理コンポーネントを取得
        if (this.use3DPhysics) {
            this._setup3DPhysics();
        } else {
            this._setup2DPhysics();
        }
    }

    start () {
        // start 時点で一度位置を更新しておく(初期配置を周回位置に合わせる)
        this._updateOrbitPosition(0);
    }

    update (deltaTime: number) {
        // 親がなければ何もしない
        if (!this.node.parent) {
            return;
        }

        // 角度を更新
        const angularSpeedRad = math.toRadian(this.angularSpeedDeg);
        this._currentAngleRad += angularSpeedRad * deltaTime;

        // 位置を更新
        this._updateOrbitPosition(deltaTime);
    }

    /**
     * 2D物理用のセットアップ
     */
    private _setup2DPhysics () {
        // Collider2D を取得
        this._collider2D = this.getComponent(Collider2D);
        if (!this._collider2D) {
            console.warn('[OrbitalShield] 2D物理を使用しますが、このノードに Collider2D がアタッチされていません。敵弾との接触判定は行われません。', this.node.name);
            return;
        }

        if (this.enableCollision) {
            // trigger として扱うことを推奨(isTrigger = true)
            this._collider2D.sensor = true;

            // 接触コールバックを登録
            this._collider2D.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact2D, this);
        }

        // RigidBody2D は必須ではないが、存在すれば sensor 用に設定しておく
        const rb2d = this.getComponent(RigidBody2D);
        if (rb2d) {
            // シールドは動かすので Kinematic か Dynamic が望ましいが、
            // ここでは特に変更しない(プロジェクト側の設定を尊重)。
        }
    }

    /**
     * 3D物理用のセットアップ
     */
    private _setup3DPhysics () {
        // Collider を取得
        this._collider3D = this.getComponent(Collider);
        if (!this._collider3D) {
            console.warn('[OrbitalShield] 3D物理を使用しますが、このノードに Collider (SphereCollider 等) がアタッチされていません。敵弾との接触判定は行われません。', this.node.name);
            return;
        }

        if (this.enableCollision) {
            // trigger として扱うことを推奨(isTrigger = true)
            this._collider3D.isTrigger = true;

            // 接触コールバックを登録
            this._collider3D.on('onTriggerEnter', this._onTriggerEnter3D, this);
        }

        // RigidBody は必須ではないが、存在すれば trigger 用に設定しておく
        const rb = this.getComponent(RigidBody);
        if (rb) {
            // 特に変更は行わず、プロジェクト側の設定を尊重。
        }
    }

    /**
     * 2D 接触開始時のコールバック
     */
    private _onBeginContact2D (selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        if (!this.enableCollision) {
            return;
        }

        const otherNode = otherCollider.node;
        if (!otherNode || !otherNode.isValid) {
            return;
        }

        // グループ名で敵弾かどうか判定
        if (this.enemyBulletGroup && otherNode.group === this.enemyBulletGroup) {
            if (this.debugLogCollision) {
                console.log(`[OrbitalShield] 2D: 敵弾を消去しました: ${otherNode.name}`);
            }
            otherNode.destroy();
        }
    }

    /**
     * 3D トリガー侵入時のコールバック
     */
    private _onTriggerEnter3D (event: ICollisionEvent) {
        if (!this.enableCollision) {
            return;
        }

        const otherNode = event.otherCollider.node;
        if (!otherNode || !otherNode.isValid) {
            return;
        }

        // グループ名で敵弾かどうか判定
        if (this.enemyBulletGroup && otherNode.group === this.enemyBulletGroup) {
            if (this.debugLogCollision) {
                console.log(`[OrbitalShield] 3D: 敵弾を消去しました: ${otherNode.name}`);
            }
            otherNode.destroy();
        }
    }

    /**
     * 親の周囲を周回する位置を更新する
     */
    private _updateOrbitPosition (deltaTime: number) {
        const node = this.node;
        const parent = node.parent;
        if (!parent) {
            return;
        }

        // 実際に使用する半径を更新(useInitialOffsetAsRadius が有効で、かつ _radius が 0 の場合などに備えて)
        if (this.useInitialOffsetAsRadius) {
            // 親からの現在ローカル位置の距離を再計算しても良いが、
            // 初期位置から固定したいので onLoad 時に決定した _radius を使用する。
        } else {
            this._radius = Math.max(0, this.orbitRadius);
        }

        const radius = this._radius;

        // 極座標 (radius, angle) からローカル座標を計算(XZ 平面 or XY 平面)
        // ここでは汎用的に X-Y 平面で回す(2D の場合そのまま、3D でも Y を高さとみなさない場合に使える)
        const x = Math.cos(this._currentAngleRad) * radius;
        const y = Math.sin(this._currentAngleRad) * radius;

        // 親のローカル空間での位置
        const localPos = new Vec3(x, y, node.position.z);

        if (this.rotateWithParent) {
            // 親の回転を考慮してローカル位置を回転
            const parentRot = parent.rotation;
            Vec3.transformQuat(localPos, localPos, parentRot);
        }

        node.setPosition(localPos);
    }

    onDestroy () {
        // コールバックの解除
        if (this._collider2D) {
            this._collider2D.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact2D, this);
        }
        if (this._collider3D) {
            this._collider3D.off('onTriggerEnter', this._onTriggerEnter3D, this);
        }
    }
}

コードのポイント解説

  • onLoad
    • 親ノードの存在チェック。
    • 初期ローカル位置を保存し、useInitialOffsetAsRadius に応じて半径 _radius を決定。
    • initialAngleDeg をラジアンに変換して _currentAngleRad にセット。
    • use3DPhysics に応じて _setup2DPhysics / _setup3DPhysics を呼び、必要な Collider を取得してイベント登録。
  • start
    • ゲーム開始時に一度だけ _updateOrbitPosition を呼び、初期位置を周回位置に合わせます。
    • これにより、エディタでの配置に依存せず、常に「指定した半径・角度」からスタートできます。
  • update
    • 毎フレーム angularSpeedDeg をラジアンに変換して角度を更新。
    • 更新後の角度で _updateOrbitPosition を呼び、親の周囲を円運動させます。
  • 2D/3D 物理セットアップ
    • _setup2DPhysics:
      • Collider2DgetComponent で取得し、なければ警告ログ。
      • enableCollisiontrue の場合のみ BEGIN_CONTACT イベントを登録。
      • センサー用途のため sensor = true に設定。
    • _setup3DPhysics:
      • CollidergetComponent で取得し、なければ警告ログ。
      • enableCollisiontrue の場合のみ onTriggerEnter を登録。
      • センサー用途のため isTrigger = true に設定。
  • 敵弾の消去ロジック
    • 2D: _onBeginContact2D 内で otherNode.group === enemyBulletGroup を満たすノードに対して destroy()
    • 3D: _onTriggerEnter3D 内で同様にグループ名で判定して destroy()
    • debugLogCollisiontrue のときは、消したノード名をログに出力。
  • 周回位置の更新
    • _updateOrbitPosition で、radius_currentAngleRad から x, y を計算し、ローカル位置として setPosition
    • rotateWithParenttrue のときは、親の回転 parent.rotation をクォータニオンとして Vec3.transformQuat でローカル位置を回転。
    • 2D でも 3D でも使えるよう、基本は X-Y 平面で回転させています(Z は元の値を維持)。
  • onDestroy
    • 登録したイベントリスナを忘れずに解除し、メモリリークや不要なコールバックを防ぎます。

使用手順と動作確認

ここでは、2D シューティングを想定したセットアップ手順を詳しく説明します。3D で使う場合も、Collider の種類と use3DPhysics の設定を変えるだけでほぼ同じです。

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

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

2. プレイヤー(親ノード)の用意

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などでプレイヤーノードを作成します。
  2. 名前を Player などに変更し、位置を (0, 0, 0) にしておきます。

3. ビットシールドノードの作成とアタッチ

  1. Hierarchy で Player ノードを右クリック → Create → 2D Object → Sprite などで子ノードを作成します。
  2. この子ノードの名前を OrbitalShieldNode などに変更します。
  3. OrbitalShieldNode を選択し、Inspector の Add Component → Custom → OrbitalShield を選択してコンポーネントをアタッチします。

4. Collider(当たり判定)の追加(2D の場合)

  1. OrbitalShieldNode を選択したまま、Inspector の Add Component → Physics 2D → CircleCollider2D を追加します。
    • ビットシールドの見た目に合わせて Radius を調整してください。
    • ここでは敵弾を消すための「バリア」なので、プレイヤーより少し大きめでも構いません。
  2. 必要に応じて Add Component → Physics 2D → RigidBody2D を追加します(必須ではありません)。

この時点で、OrbitalShield コンポーネントは Collider2D を自動で検出し、警告が出ない状態になります。

5. OrbitalShield プロパティの設定(2D 例)

OrbitalShieldNode の Inspector で、以下のように設定してみてください。

  • Use 3D Physics: OFF(2D 物理を使用するため)
  • Enemy Bullet Group: enemyBullet
    • 事前に「Project → Project Settings → Layers/Groups → Groups」で enemyBullet を作成しておきます。
  • Orbit Radius: 120(任意)
  • Use Initial Offset As Radius: ON
    • エディタ上で OrbitalShieldNode をプレイヤーから少し右上あたりにドラッグして配置し、その距離を半径として使うようにします。
  • Angular Speed Deg: 120
    • 1 秒で 120 度回転(3 秒で一周)するスピードです。
  • Initial Angle Deg: 0
  • Rotate With Parent: OFF(プレイヤーが回転しない前提なら OFF で OK)
  • Enable Collision: ON
  • Debug Log Collision: 開発中は ON にしておくと、敵弾を消したログが見えます。

複数のビットを回したい場合は、OrbitalShieldNode を複製して、

  • 1 個目: Initial Angle Deg = 0
  • 2 個目: Initial Angle Deg = 120
  • 3 個目: Initial Angle Deg = 240

のように設定すると、3 つのビットが等間隔に周回するシールドになります。

6. 敵弾ノードの作成とグループ設定

  1. Hierarchy で右クリック → Create → 2D Object → Sprite で敵弾用ノードを作成します(名前: EnemyBullet)。
  2. Inspector の Node → GroupenemyBullet に変更します。
  3. Add Component → Physics 2D → CircleCollider2D を追加し、半径を小さめに設定します。
  4. 必要に応じて Add Component → Physics 2D → RigidBody2D を追加します(敵弾を物理で動かしたい場合)。
  5. 位置をプレイヤーの近くに配置し、ビットシールドとぶつかるように調整します。

7. 物理エンジンの有効化(2D の場合)

  1. メニューから Project → Project Settings を開きます。
  2. Feature Cropping → Physics タブで、2D Physics が有効になっていることを確認します。
  3. 必要に応じて Physics 2D の設定(重力など)を調整します。

8. ゲームシーンを再生して動作確認

  1. エディタ右上の ▶︎(再生) ボタンを押してシーンを再生します。
  2. プレイヤーの周囲を OrbitalShieldNode が周回していることを確認します。
  3. 敵弾ノードをビットシールドに近づけると、接触した瞬間に敵弾ノードが destroy() され、シーンから消えるはずです。
  4. Debug Log CollisionON にしていれば、Console に
    [OrbitalShield] 2D: 敵弾を消去しました: EnemyBullet
    

    のようなログが出力されます。

9. 3D で使用する場合のポイント

  • use3DPhysicsON にする。
  • ビットシールドノードに Add Component → Physics → SphereCollider などの 3D Collider を追加する。
  • 敵弾にも 3D Collider(SphereCollider など)と必要に応じて RigidBody を追加する。
  • Project Settings で 3D Physics が有効になっていることを確認する。

まとめ

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

  • 親ノードの周囲を任意の半径・速度・初期角度で周回させる
  • 指定グループのノード(敵弾など)と接触したら自動で destroy() する
  • 2D / 3D 物理のどちらにも対応し、シーン内の他のスクリプトには一切依存しない

という汎用的な「ビットシールド」を、アタッチするだけで実現できるように設計されています。

応用例としては、

  • プレイヤーのレベルアップに応じてビットの数を増やす(ノードを複製して Initial Angle Deg をずらすだけ)
  • ボスの周りを回る破壊不能な障害物(Enable Collision = false にして、別の攻撃判定スクリプトを付与)
  • 見た目だけのエフェクトリング(Collider を付けず、enableCollision = false

などが考えられます。

すべての挙動はコンポーネント自身のプロパティで完結しているため、プロジェクト間のコピペも容易で、ゲーム開発のスピードアップに直結します。必要に応じて、

  • 敵弾を消す代わりに「反射させる」
  • 一定時間ごとにビットの半径を変化させて「パルスバリア」にする

といった拡張も、同じ設計方針で簡単に追加できます。まずはこの記事のコードをそのまま貼り付けて、エディタ上でビットシールドが回転し、敵弾を消してくれる様子を確認してみてください。