【Cocos Creator 3.8】Reflector(反射板)の実装:アタッチするだけで「敵弾を跳ね返して自分の弾に変える」汎用スクリプト

このガイドでは、Reflector(反射板)コンポーネントを実装します。
敵の弾(Area2D 相当の当たり判定を持つノード)がこの反射板に触れたとき、弾の進行方向を跳ね返し、所属(陣営)を自分側に変更する処理を、このコンポーネント単体で完結させます。

Reflector を任意のノードにアタッチするだけで、シールド・カウンター・壁反射ギミックなどを簡単に実装できるようになります。外部の GameManager やシングルトンには一切依存しません。


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

前提と想定

  • 使用エンジン:Cocos Creator 3.8.7
  • 言語:TypeScript
  • 物理:2D 物理(Box2D) を使用
  • 弾(Bullet)は以下のようなノード構成を想定
    • Rigidbody2D:速度ベクトルで移動している
    • Collider2D:当たり判定(CircleCollider2D / BoxCollider2D など)
    • BulletOwner 的な共通仕様:
      • 弾の所属は タグ(tag)グループ名 で識別する
      • Reflector は、弾のタグまたはグループ名を「敵」から「自分」に書き換えることで所属変更を表現する

ただし、この Reflector 自身は「弾スクリプト」には依存しません
「タグ名」「グループ名」「速度反転方法」を すべてインスペクタのプロパティで設定できるようにします。

Reflector が行うこと

  1. 自分のノードに Collider2D が付いているか確認し、なければエラーログを出す
  2. Collider2Dトリガーイベント(onBeginContact / onTriggerEnter) を購読
  3. 接触した相手が「対象弾」かどうかを、以下のいずれかで判定
    • ノードの group(グループ名)
    • Collider2D.tag(数値タグ)
    • ノード名のプレフィックス(簡易フィルタ)
  4. 対象弾だった場合:
    • 弾の Rigidbody2D を取得
    • 速度ベクトルを「反射」させる
      • 速度ベクトル v と、反射面の法線 n から v' = v - 2 * (v・n) * n を計算
      • 反射面の法線は、Reflector ノードの「向き」や設定値から決める
    • 弾の所属情報を変更(グループ/タグ/任意のカスタムプロパティ)
    • (オプション)一度反射した弾を再度反射させないようにフラグを立てる

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

Reflector コンポーネントでは、以下のプロパティを用意します。

  • useTriggerMode: boolean
    • ツールチップ:「true のとき Collider2D.isTrigger として扱い、onTriggerEnter 系イベントで処理します。」
    • 説明:true の場合は「トリガー」として反射処理のみ行い、物理的な押し返しはしません。
    • false の場合は onBeginContact(物理接触)イベントを使用します。
  • targetGroup: string
    • ツールチップ:「反射対象とする弾ノードのグループ名。空文字の場合はグループでフィルタしません。」
    • 説明:敵弾が属するグループ名(例:"enemy_bullet")を指定すると、そのグループの弾のみ反射対象になります。
  • targetTag: number
    • ツールチップ:「反射対象とする Collider2D の tag 値。負の値の場合はタグでフィルタしません。」
    • 説明:敵弾 Collider2D の tag がこの値と一致した場合に反射します。
  • targetNamePrefix: string
    • ツールチップ:「ノード名のプレフィックスで反射対象をフィルタします。空文字の場合は無視します。」
    • 説明:簡易的に「Bullet_」「EnemyShot_」など、名前のプレフィックスで対象弾を絞りたいときに使います。
  • newOwnerGroup: string
    • ツールチップ:「反射後の弾ノードに設定するグループ名。空文字の場合はグループを書き換えません。」
    • 説明:反射後に弾のグループをこの値に変更します(例:"player_bullet")。
  • newOwnerTag: number
    • ツールチップ:「反射後の Collider2D に設定する tag 値。負の値の場合はタグを書き換えません。」
    • 説明:反射後に弾 Collider2D の tag をこの値に変更します。
  • normalFromNodeRotation: boolean
    • ツールチップ:「true のとき、このノードの回転(Y軸右向き)を反射面の法線として使用します。」
    • 説明:Reflector ノードの向きで反射方向を決めたい場合に true にします。
  • customNormalAngle: number
    • ツールチップ:「反射面の法線角度(度数法)。normalFromNodeRotation が false のときのみ使用。」
    • 説明:0度は +X 方向(右)、90度は +Y 方向(上)を向く法線になります。
  • invertNormal: boolean
    • ツールチップ:「法線ベクトルを反転します。反射方向が逆に感じる場合に切り替えてください。」
    • 説明:法線の向きが逆で挙動が期待と違う場合にオンにします。
  • keepSpeedMagnitude: boolean
    • ツールチップ:「反射後も速度の大きさ(速さ)を維持します。false の場合は物理エンジンに任せます。」
    • 説明:反射前と同じ速さで跳ね返したい場合に true にします。
  • speedMultiplier: number
    • ツールチップ:「keepSpeedMagnitude が true のとき、反射後の速度に掛ける倍率。」
    • 説明:1.0 で等速、1.2 で少し加速、0.8 で減速します。
  • markReflectedByName: boolean
    • ツールチップ:「true のとき、反射後の弾ノード名に接頭辞/接尾辞を追加して分かりやすくします。」
    • 説明:デバッグや演出で「Reflected_」などを名前に付けたい場合に使用します。
  • reflectedNamePrefix: string
    • ツールチップ:「markReflectedByName が true のとき、弾ノード名の先頭に付ける文字列。」
  • reflectedNameSuffix: string
    • ツールチップ:「markReflectedByName が true のとき、弾ノード名の末尾に付ける文字列。」
  • logDebug: boolean
    • ツールチップ:「true のとき、反射処理の詳細ログをコンソールに出力します。」
    • 説明:挙動確認用。リリース時はオフ推奨。

TypeScriptコードの実装


import { _decorator, Component, Node, Vec2, math, Collider2D, IPhysics2DContact, Contact2DType, ERaycast2DType, RigidBody2D, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * Reflector
 * - 2D コライダーにアタッチして使用する反射板コンポーネント
 * - 接触した弾(Rigidbody2D を持つノード)の進行方向を反射し、
 *   所属情報(グループ / タグ / 名前)を書き換える
 */
@ccclass('Reflector')
export class Reflector extends Component {

    @property({
        tooltip: 'true のとき Collider2D.isTrigger として扱い、onTriggerEnter 系イベントで処理します。\nfalse のときは物理接触 (onBeginContact) で処理します。'
    })
    public useTriggerMode: boolean = true;

    @property({
        tooltip: '反射対象とする弾ノードのグループ名。\n空文字の場合はグループでフィルタしません。'
    })
    public targetGroup: string = 'enemy_bullet';

    @property({
        tooltip: '反射対象とする Collider2D の tag 値。\n負の値の場合はタグでフィルタしません。'
    })
    public targetTag: number = -1;

    @property({
        tooltip: 'ノード名のプレフィックスで反射対象をフィルタします。\n空文字の場合は無視します。'
    })
    public targetNamePrefix: string = '';

    @property({
        tooltip: '反射後の弾ノードに設定するグループ名。\n空文字の場合はグループを書き換えません。'
    })
    public newOwnerGroup: string = 'player_bullet';

    @property({
        tooltip: '反射後の Collider2D に設定する tag 値。\n負の値の場合はタグを書き換えません。'
    })
    public newOwnerTag: number = -1;

    @property({
        tooltip: 'true のとき、このノードの回転(Y軸右向き)を反射面の法線として使用します。'
    })
    public normalFromNodeRotation: boolean = true;

    @property({
        tooltip: '反射面の法線角度(度数法)。normalFromNodeRotation が false のときのみ使用。\n0度は +X 方向(右)、90度は +Y 方向(上)。'
    })
    public customNormalAngle: number = 90;

    @property({
        tooltip: '法線ベクトルを反転します。反射方向が逆に感じる場合に切り替えてください。'
    })
    public invertNormal: boolean = false;

    @property({
        tooltip: '反射後も速度の大きさ(速さ)を維持します。\nfalse の場合は物理エンジンに任せます。'
    })
    public keepSpeedMagnitude: boolean = true;

    @property({
        tooltip: 'keepSpeedMagnitude が true のとき、反射後の速度に掛ける倍率。\n1.0 で等速、1.2 で少し加速、0.8 で減速します。'
    })
    public speedMultiplier: number = 1.0;

    @property({
        tooltip: 'true のとき、反射後の弾ノード名に接頭辞/接尾辞を追加して分かりやすくします。'
    })
    public markReflectedByName: boolean = true;

    @property({
        tooltip: 'markReflectedByName が true のとき、弾ノード名の先頭に付ける文字列。'
    })
    public reflectedNamePrefix: string = 'Reflected_';

    @property({
        tooltip: 'markReflectedByName が true のとき、弾ノード名の末尾に付ける文字列。'
    })
    public reflectedNameSuffix: string = '';

    @property({
        tooltip: 'true のとき、反射処理の詳細ログをコンソールに出力します。'
    })
    public logDebug: boolean = false;

    private _collider: Collider2D | null = null;

    onLoad() {
        // 必須コンポーネントの取得
        this._collider = this.getComponent(Collider2D);
        if (!this._collider) {
            console.error('[Reflector] このノードには Collider2D コンポーネントが必要です。追加してから使用してください。', this.node.name);
            return;
        }

        // イベント購読
        if (this.useTriggerMode) {
            this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            this._collider.on(Contact2DType.TRIGGER_ENTER, this._onTriggerEnter, this);
        } else {
            this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
        }
    }

    onDestroy() {
        if (this._collider) {
            this._collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            this._collider.off(Contact2DType.TRIGGER_ENTER, this._onTriggerEnter, this);
        }
    }

    /**
     * 物理接触イベント
     */
    private _onBeginContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        this._tryReflect(other);
    }

    /**
     * トリガー接触イベント
     */
    private _onTriggerEnter(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        this._tryReflect(other);
    }

    /**
     * 接触した Collider2D が反射対象かどうか判定し、対象なら反射処理を行う
     */
    private _tryReflect(other: Collider2D) {
        const bulletNode = other.node;

        // 対象フィルタ:グループ
        if (this.targetGroup && bulletNode.group !== this.targetGroup) {
            return;
        }

        // 対象フィルタ:タグ
        if (this.targetTag >= 0 && other.tag !== this.targetTag) {
            return;
        }

        // 対象フィルタ:名前プレフィックス
        if (this.targetNamePrefix && !bulletNode.name.startsWith(this.targetNamePrefix)) {
            return;
        }

        const rb = bulletNode.getComponent(RigidBody2D);
        if (!rb) {
            if (this.logDebug) {
                console.warn('[Reflector] 接触したノードに RigidBody2D がありません。反射処理をスキップします。node=', bulletNode.name);
            }
            return;
        }

        // 現在の速度ベクトルを取得
        const currentVelocity = new Vec2();
        rb.linearVelocity.clone(currentVelocity);

        if (currentVelocity.lengthSqr() === 0) {
            if (this.logDebug) {
                console.log('[Reflector] 速度が 0 のため、反射処理を行いません。node=', bulletNode.name);
            }
            return;
        }

        // 反射面の法線ベクトルを計算
        const normal = this._getNormal();

        // 反射ベクトル v' = v - 2 * (v・n) * n
        const dot = currentVelocity.dot(normal);
        const reflected = new Vec2(
            currentVelocity.x - 2 * dot * normal.x,
            currentVelocity.y - 2 * dot * normal.y
        );

        // 速度の大きさを維持する場合
        if (this.keepSpeedMagnitude) {
            const originalSpeed = currentVelocity.length();
            let newSpeed = reflected.length();
            if (newSpeed > 0) {
                reflected.multiplyScalar((originalSpeed * this.speedMultiplier) / newSpeed);
            }
        }

        // 反射後の速度を設定
        rb.linearVelocity = reflected;

        // 所属情報を書き換え
        this._changeOwnership(bulletNode, other);

        // 名前マーキング
        if (this.markReflectedByName) {
            bulletNode.name = `${this.reflectedNamePrefix}${bulletNode.name}${this.reflectedNameSuffix}`;
        }

        if (this.logDebug) {
            console.log(
                '[Reflector] 弾を反射しました:',
                '\n  node =', bulletNode.name,
                '\n  from velocity =', currentVelocity.toString(),
                '\n  to   velocity =', reflected.toString(),
                '\n  normal =', normal.toString()
            );
        }
    }

    /**
     * 反射面の法線ベクトルを取得
     * - normalFromNodeRotation が true の場合:ノードの回転から算出
     * - false の場合:customNormalAngle から算出
     */
    private _getNormal(): Vec2 {
        let angleRad: number;

        if (this.normalFromNodeRotation) {
            // Node の回転(Z軸回転)を度からラジアンに変換
            const eulerZ = this.node.eulerAngles.z;
            angleRad = math.toRadian(eulerZ);
        } else {
            angleRad = math.toRadian(this.customNormalAngle);
        }

        const normal = new Vec2(Math.cos(angleRad), Math.sin(angleRad));

        if (this.invertNormal) {
            normal.multiplyScalar(-1);
        }

        // 正規化(念のため)
        normal.normalize();

        return normal;
    }

    /**
     * 弾ノードの所属情報(グループ / タグ)を変更する
     */
    private _changeOwnership(bulletNode: Node, collider: Collider2D) {
        if (this.newOwnerGroup) {
            bulletNode.group = this.newOwnerGroup;
        }

        if (this.newOwnerTag >= 0) {
            collider.tag = this.newOwnerTag;
        }
    }
}

コードの要点解説

  • onLoad
    • Collider2D を取得し、存在しない場合は console.error を出して終了します。
    • useTriggerMode に応じて Contact2DType.BEGIN_CONTACT / TRIGGER_ENTER を購読します。
  • _onBeginContact / _onTriggerEnter
    • どちらも _tryReflect(other) に処理を委譲し、ロジックを一箇所にまとめています。
  • _tryReflect
    • グループ・タグ・名前プレフィックスで「敵弾かどうか」を判定。
    • RigidBody2D を取得し、速度ベクトルを取り出す。
    • _getNormal() で反射面の法線ベクトルを計算。
    • 反射ベクトル v' = v - 2 * (v・n) * n を算出し、必要に応じて速度の大きさを維持・倍率補正。
    • _changeOwnership でグループ・タグを変更。
    • 必要なら名前に接頭辞/接尾辞を追加。
  • _getNormal
    • normalFromNodeRotation が true のときは、Reflector ノードの Z 回転(2D の見た目の回転)から法線を計算。
    • false のときは customNormalAngle(度)から法線を計算。
    • invertNormal が true の場合はベクトルを反転。
  • _changeOwnership
    • newOwnerGroup が空でなければ弾ノードの group を変更。
    • newOwnerTag が 0 以上なら Collider2D.tag を変更。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、Reflector を置きたいフォルダを選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. ファイル名を Reflector.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、本文の Reflector コードを丸ごと貼り付けて保存します。

2. テスト用の敵弾プレハブの用意(例)

すでに敵弾のプレハブがある場合はこのステップは読み飛ばして構いません。
ここでは簡単な設定例を示します。

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択して、EnemyBullet という名前のノードを作成します。
  2. InspectorSprite に任意のテクスチャを設定します。
  3. Add ComponentPhysics 2DRigidBody2D を追加します。
    • Body TypeDynamic
  4. Add ComponentPhysics 2DCircleCollider2D(または BoxCollider2D)を追加します。
    • Is Triggertrue(Reflector をトリガーモードで使う場合)
    • tag:例として 1(敵弾タグ)を設定
  5. ノードの Groupenemy_bullet など、敵弾用のグループに設定します。
    • グループが存在しない場合は、メニューの Project → Project Settings → Group で追加してください。
  6. 必要に応じて Prefab 化します(ドラッグして Assets にドロップ)。

3. Reflector ノードの作成

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、ノード名を ReflectorTest にします。
  2. InspectorSprite コンポーネントで、板のようなテクスチャ(横長の画像など)を設定します。
  3. Add ComponentPhysics 2DBoxCollider2D を追加します。
    • サイズをスプライトの大きさに合わせます。
    • Is TriggertrueuseTriggerMode = true で使う場合)
  4. Add ComponentCustomReflector を選択してアタッチします。

4. Reflector プロパティの設定例

ReflectorTest ノードを選択し、Inspector の Reflector コンポーネントを以下のように設定します。

  • useTriggerModetrue
    • Collider2D.IsTrigger を true にしている前提です。
  • targetGroupenemy_bullet
  • targetTag1(敵弾 Collider2D の tag と合わせる)
  • targetNamePrefix:空(今回は使わない)
  • newOwnerGroupplayer_bullet(プレイヤー弾用グループ)
  • newOwnerTag2(プレイヤー弾タグなど任意)
  • normalFromNodeRotationtrue
  • customNormalAngle90(未使用)
  • invertNormal:必要に応じて true/false を切り替え
  • keepSpeedMagnitudetrue
  • speedMultiplier1.0
  • markReflectedByNametrue
  • reflectedNamePrefixReflected_
  • reflectedNameSuffix:空
  • logDebug:動作確認中は true にしておくとログが見やすいです。

ReflectorTest ノードの回転(Z 軸)を変えることで、反射面の向きが変わります。
例えば:

  • Z 回転 0 度:法線は右向き(+X)
  • Z 回転 90 度:法線は上向き(+Y)
  • Z 回転 -90 度:法線は下向き(-Y)

5. シーン上での動作確認

  1. シーン内に EnemyBullet ノード(またはそのプレハブインスタンス)を配置し、ReflectorTest の左側から右方向に向けて飛ぶようにします。
    • 簡単なテストとして、RigidBody2D.linearVelocity(300, 0) などに設定しておきます。
  2. Play ボタンでゲームを実行します。
  3. EnemyBullet が ReflectorTest にぶつかると、弾の進行方向が反転(反射)し、グループが player_bullet に、タグが 2 に変わり、名前に Reflected_ が付いていることを確認します。
  4. logDebugtrue にしている場合は、コンソールに
    [Reflector] 弾を反射しました: ...
    

    のようなログが表示され、元の速度と反射後の速度が確認できます。

6. よくあるつまずきポイントとチェック項目

  • 反射が発生しない
    • Reflector ノードに Collider2D が付いているか?
    • 弾ノードに Rigidbody2D が付いているか?
    • targetGroup / targetTag / targetNamePrefix が正しく設定されているか?(どれかが不一致だとスキップされます)
    • Is TriggeruseTriggerMode の組み合わせは正しいか?
    • 2D 物理が有効か?(Project Settings → Physics 2D で確認)
  • 反射方向がおかしい
    • Reflector ノードの Z 回転が意図した方向になっているか?
    • invertNormal を切り替えてみる。
    • normalFromNodeRotation = false にして customNormalAngle を明示設定してみる。
  • 速度が遅く/速くなりすぎる
    • keepSpeedMagnitudetrue にして speedMultiplier を 1.0 にする。
    • 加速させたいなら 1.2〜1.5、減速させたいなら 0.5〜0.8 などに調整。

まとめ

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

  • 任意のノードにアタッチするだけで
  • 接触した弾の進行方向を物理的に「反射」し
  • 弾の所属(グループ・タグ・名前)を「敵 → 自分」に変更する

という一連の処理を、完全に独立したスクリプトとして提供します。
外部の GameManager やシングルトンに依存せず、インスペクタのプロパティだけで挙動を調整できるため、

  • シールドやカウンター攻撃
  • 反射する壁・ギミック
  • 特定の方向だけに跳ね返すトラップ

などを簡単に量産できます。

さらに、

  • グループ名・タグ・名前プレフィックスで柔軟に対象弾をフィルタ
  • ノードの回転または任意角度で反射面の法線を指定
  • 速度の維持・倍率調整や名前マーキングによるデバッグのしやすさ

といった機能も備えているため、ゲーム全体の弾反射処理をこの 1 コンポーネントに集約することができます。

プロジェクト内で「弾を跳ね返したい場面」が出てきたら、まずはこの Reflector をアタッチしてみて、プロパティを調整しながら最適な挙動を探ってみてください。