【Cocos Creator 3.8】ExplosionForce(爆風物理)の実装:アタッチするだけで「爆心から周囲のRigidBodyに衝撃力を与える」汎用スクリプト

このコンポーネントは、任意のノードにアタッチして「爆発イベント」を発生させると、その中心から一定半径内に存在する 2D 物理ボディ(RigidBody2D)へ外向きの衝撃力を与える汎用スクリプトです。
爆発エフェクト用ノードや弾丸の着弾地点ノードにアタッチしておけば、コードから triggerExplosion() を呼ぶ、もしくは自動発火タイマーで「爆風物理」を簡単に実現できます。


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

要件整理

  • ExplosionForce をアタッチしたノードを「爆心」とみなし、その位置を中心に爆風を発生させる。
  • 一定の半径(radius)内にある RigidBody2D を検出し、中心から外側へ向かう力(impulse)を加える。
  • 力の大きさは、距離に応じて減衰させられるようにする(線形減衰)。
  • 爆発は
    • コードから明示的に triggerExplosion() を呼ぶ
    • または、指定時間後に自動で一度だけ爆発する
    • の両方に対応。

  • 爆発後に自動でノードを破棄するかどうかを選べる。
  • 完全に独立したコンポーネントとし、他のカスタムスクリプトには一切依存しない。

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

  • 物理ワールド(PhysicsSystem2D)へのアクセスは、Cocos Creator の標準 API のみを使用。
  • 爆発の設定値(半径、強さ、減衰、レイヤーマスク、発火タイミングなど)はすべて @property 経由でインスペクタから調整可能にする。
  • RigidBody2D の取得は、ワールドの全コライダーを走査して距離判定を行う方式を採用し、特定のマネージャーやシングルトンに頼らない。
  • PhysicsSystem2D が有効かどうか、2D 物理モジュールが無効な環境では警告ログを出すなど、防御的な実装を行う。

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

ExplosionForce コンポーネントに用意するプロパティと、その役割は以下の通りです。

  • radius (number)
    • 爆風の有効半径。
    • この距離以内にある RigidBody2D に力が加わる。
    • 単位はワールド座標系の長さ(基本的にはピクセルベース)。
    • 例: 200 とすると、爆心から半径 200 の円内にあるボディが対象。
  • force (number)
    • 爆発の基本的な衝撃の強さ。
    • 距離 0(爆心)にいる場合に最大で加わるインパルスの大きさの基準値。
    • 値が大きいほど、オブジェクトが強く吹き飛ぶ。
    • 例: 500 ~ 2000 程度で調整。
  • useDistanceAttenuation (boolean)
    • true の場合、爆心からの距離に応じて力を線形に減衰させる。
    • false の場合、半径以内の全オブジェクトに同じ強さの力を与える。
    • 線形減衰の計算例: strength = force * (1 - distance / radius)(0~1 にクランプ)
  • minForceFactor (number)
    • 減衰を有効にしている場合に、どれだけ弱くなっても最低限この割合までは力を残す。
    • 0 ~ 1 の範囲で指定。
    • 例: 0.2 にすると、半径ギリギリの位置でも force * 0.2 の力は必ず加わる。
  • upwardsModifier (number)
    • 爆発方向に対して、少し上方向(Y 軸正方向)にベクトルを持ち上げる係数。
    • 0 の場合、純粋に中心から外側への向きだけを使用。
    • 0.2 ~ 0.5 程度を指定すると、爆風で少し浮き上がるような動きになる。
  • layerMask (number)
    • 爆風の対象とする 2D 物理レイヤーのビットマスク。
    • 0 の場合はすべてのレイヤーを対象とする。
    • 特定のレイヤーだけに爆風を効かせたいときに使用。
  • autoExplodeOnStart (boolean)
    • true の場合、コンポーネントが有効化されてから指定時間後に自動で一度だけ爆発する。
    • 爆発演出専用ノードにアタッチして「生成されたら自動で爆発して消える」といった用途に便利。
  • autoExplodeDelay (number)
    • autoExplodeOnStart が true のときのみ有効。
    • コンポーネントが有効化されてから爆発するまでの秒数。
    • 例: 0.1 ~ 0.5 など、小さな値にして「ほぼ即時爆発」も可能。
  • destroyNodeAfterExplosion (boolean)
    • true の場合、爆発処理が完了したあと自動でこのノードを破棄する。
    • エフェクト用ノードにアタッチしておけば、後片付けを自動化できる。
  • debugDrawGizmo (boolean)
    • エディタ上で Scene ビューに爆風半径の円を描画するかどうか。
    • ゲーム実行時の挙動には影響しない。
    • 爆風の範囲を視覚的に確認したいときに便利。

TypeScriptコードの実装

以下が、ExplosionForce コンポーネントの完全な TypeScript 実装です。


import { _decorator, Component, Node, Vec2, Vec3, PhysicsSystem2D, RigidBody2D, Collider2D, ERigidBody2DType, geometry, director } from 'cc';
const { ccclass, property, executeInEditMode, menu } = _decorator;

/**
 * ExplosionForce
 * 爆心ノードを中心として、一定半径内の RigidBody2D に外向きのインパルスを与えるコンポーネント。
 */
@ccclass('ExplosionForce')
@executeInEditMode(true)
@menu('Custom/Physics/ExplosionForce')
export class ExplosionForce extends Component {

    @property({
        tooltip: '爆風の有効半径(ワールド座標系の距離)。この距離以内にある RigidBody2D に力が加わります。',
        min: 0
    })
    public radius: number = 200;

    @property({
        tooltip: '爆発の基本的な衝撃の強さ(インパルスの大きさ)。値が大きいほど強く吹き飛びます。',
        min: 0
    })
    public force: number = 1000;

    @property({
        tooltip: '距離による減衰を有効にするかどうか。ON の場合、爆心から遠いほど弱くなります。'
    })
    public useDistanceAttenuation: boolean = true;

    @property({
        tooltip: '距離減衰を有効にしている場合の最低係数(0~1)。0.2 なら最小でも 20% の力は加わります。',
        min: 0,
        max: 1,
        slide: true
    })
    public minForceFactor: number = 0.2;

    @property({
        tooltip: '爆風方向ベクトルに加える上方向(Y+)の補正係数。0 で補正なし、0.2~0.5 で少し浮き上がるような動きになります。',
        min: 0
    })
    public upwardsModifier: number = 0.0;

    @property({
        tooltip: '爆風の対象とする 2D 物理レイヤーのビットマスク。0 の場合は全レイヤー対象。',
    })
    public layerMask: number = 0;

    @property({
        tooltip: 'コンポーネント有効化後、自動で一度だけ爆発させるかどうか。'
    })
    public autoExplodeOnStart: boolean = false;

    @property({
        tooltip: '自動爆発までの遅延秒数(autoExplodeOnStart が有効なときのみ使用)。',
        min: 0
    })
    public autoExplodeDelay: number = 0.1;

    @property({
        tooltip: '爆発処理完了後、このノードを自動で破棄するかどうか。'
    })
    public destroyNodeAfterExplosion: boolean = false;

    @property({
        tooltip: 'エディタの Scene ビューで爆風半径を円として描画するかどうか。ゲーム実行時には影響しません。'
    })
    public debugDrawGizmo: boolean = true;

    private _hasExploded: boolean = false;

    onLoad() {
        // 2D物理システムが有効かチェック
        const physics2D = PhysicsSystem2D.instance;
        if (!physics2D) {
            console.warn('[ExplosionForce] PhysicsSystem2D が利用できません。このコンポーネントは 2D 物理が有効なプロジェクトで使用してください。');
            return;
        }

        // radius や force が不正値の場合に警告
        if (this.radius <= 0) {
            console.warn('[ExplosionForce] radius が 0 以下です。爆風の範囲がありません。インスペクタで正の値を設定してください。');
        }
        if (this.force <= 0) {
            console.warn('[ExplosionForce] force が 0 以下です。衝撃力が発生しません。インスペクタで正の値を設定してください。');
        }
    }

    start() {
        // 自動爆発が有効な場合、指定時間後に一度だけ爆発
        if (this.autoExplodeOnStart && !this._hasExploded) {
            this.scheduleOnce(() => {
                this.triggerExplosion();
            }, this.autoExplodeDelay);
        }
    }

    /**
     * 外部から呼び出して爆発を発生させる公開メソッド。
     * 1 度だけ有効(連続で呼びたい場合は _hasExploded の仕様を変更してください)。
     */
    public triggerExplosion(): void {
        if (this._hasExploded) {
            return;
        }
        this._hasExploded = true;

        const physics2D = PhysicsSystem2D.instance;
        if (!physics2D || !physics2D.enabled) {
            console.warn('[ExplosionForce] PhysicsSystem2D が有効ではありません。爆発処理は実行されません。');
            return;
        }

        if (this.radius <= 0 || this.force <= 0) {
            console.warn('[ExplosionForce] radius または force が 0 以下のため、爆発処理はスキップされました。');
            return;
        }

        // 爆心のワールド座標
        const worldPos = this.node.worldPosition.clone();

        // すべての 2D コライダーを取得して距離判定
        const colliders = physics2D.colliderList as Collider2D[];
        if (!colliders || colliders.length === 0) {
            console.log('[ExplosionForce] ワールド内に Collider2D が存在しません。影響を与える対象がありません。');
            this._afterExplosion();
            return;
        }

        const tempVec3 = new Vec3();
        const tempVec2 = new Vec2();

        for (const col of colliders) {
            if (!col || !col.node || !col.enabledInHierarchy) {
                continue;
            }

            // レイヤーマスクチェック(0 の場合は全レイヤー対象)
            if (this.layerMask !== 0) {
                const layerBit = 1 << col.node.layer;
                if ((this.layerMask & layerBit) === 0) {
                    continue;
                }
            }

            const rb = col.getComponent(RigidBody2D);
            if (!rb) {
                continue; // RigidBody2D がないコライダーは爆風対象外
            }

            // Static なボディには力を加えない(必要に応じて変更可)
            if (rb.type === ERigidBody2DType.Static) {
                continue;
            }

            // このコライダーのワールド座標(ノードの位置を使用)
            col.node.getWorldPosition(tempVec3);
            const dx = tempVec3.x - worldPos.x;
            const dy = tempVec3.y - worldPos.y;
            const distSq = dx * dx + dy * dy;
            const radiusSq = this.radius * this.radius;

            if (distSq > radiusSq) {
                continue; // 半径外
            }

            const distance = Math.sqrt(distSq);

            // 爆心と同じ位置の場合は、適当な方向を与える(デフォルトで上方向)
            if (distance === 0) {
                tempVec2.set(0, 1);
            } else {
                tempVec2.set(dx / distance, dy / distance);
            }

            // 上方向補正
            if (this.upwardsModifier > 0) {
                tempVec2.y += this.upwardsModifier;
                tempVec2.normalize();
            }

            // 距離に応じた減衰計算
            let strengthFactor = 1.0;
            if (this.useDistanceAttenuation) {
                const t = distance / this.radius; // 0 ~ 1
                strengthFactor = 1.0 - t;
                if (strengthFactor < this.minForceFactor) {
                    strengthFactor = this.minForceFactor;
                }
            }

            const impulseMagnitude = this.force * strengthFactor;
            const impulseX = tempVec2.x * impulseMagnitude;
            const impulseY = tempVec2.y * impulseMagnitude;

            // インパルスを適用
            rb.applyLinearImpulseToCenter(new Vec2(impulseX, impulseY), true);
        }

        this._afterExplosion();
    }

    /**
     * 爆発処理後の後片付け(自動破棄など)。
     */
    private _afterExplosion(): void {
        if (this.destroyNodeAfterExplosion && this.node && this.node.isValid) {
            // 次のフレームでノードを破棄
            this.scheduleOnce(() => {
                if (this.node && this.node.isValid) {
                    this.node.destroy();
                }
            }, 0);
        }
    }

    /**
     * エディタ上でのギズモ描画(爆風半径の可視化)。
     * 実行時のゲームには影響しません。
     */
    onDrawGizmos() {
        if (!this.debugDrawGizmo || !this.node) {
            return;
        }

        const gizmos = (globalThis as any).cc && (globalThis as any).cc['gizmos'];
        // Cocos Creator のバージョンや環境によって gizmos API が異なる可能性があるため、
        // 存在チェックのみ行い、なければ何もしない。
        if (!gizmos) {
            return;
        }

        const worldPos = this.node.worldPosition;
        const segments = 32;
        const angleStep = (Math.PI * 2) / segments;
        const points: Vec3[] = [];

        for (let i = 0; i <= segments; i++) {
            const angle = angleStep * i;
            const x = worldPos.x + Math.cos(angle) * this.radius;
            const y = worldPos.y + Math.sin(angle) * this.radius;
            points.push(new Vec3(x, y, worldPos.z));
        }

        // 線色の設定(薄い赤)
        gizmos.color(1, 0, 0, 0.4);
        for (let i = 0; i < points.length - 1; i++) {
            gizmos.drawLine(points[i], points[i + 1]);
        }
    }
}

主要メソッドの解説

  • onLoad()
    • 2D 物理システム(PhysicsSystem2D)の有効性を確認し、利用できない場合は警告ログを出します。
    • radiusforce が 0 以下の場合も警告を出し、デバッグしやすくしています。
  • start()
    • autoExplodeOnStart が true の場合、autoExplodeDelay 秒後に triggerExplosion() を一度だけ呼び出します。
  • triggerExplosion()
    • 外部から呼び出すことで爆発を発生させる公開メソッドです。
    • 内部フラグ _hasExploded により、同じコンポーネントからは 1 度だけ爆発する仕様にしています(必要ならこの制限を外しても構いません)。
    • 処理の流れ:
      1. 物理システムが有効か、radius / force が有効かをチェック。
      2. 爆心のワールド座標を取得。
      3. PhysicsSystem2D.instance.colliderList から全 Collider2D を走査。
      4. レイヤーマスク・RigidBody2D の有無・Static かどうか・距離(半径内か)を判定。
      5. 方向ベクトル(爆心 → 対象)を正規化し、上方向補正を加える。
      6. 距離減衰を計算し、インパルスの大きさを決定。
      7. RigidBody2D.applyLinearImpulseToCenter() でインパルスを適用。
  • _afterExplosion()
    • destroyNodeAfterExplosion が true の場合、次フレームでノードを破棄します。
    • エフェクト用ノードの自動クリーンアップに便利です。
  • onDrawGizmos()
    • エディタの Scene ビューで爆風半径を円として描画します。
    • ギズモ API はバージョン差分があるため、存在チェックを行い、利用できない環境では何もしません。
    • ゲーム実行時の挙動には一切影響しません。

使用手順と動作確認

1. スクリプトファイルを作成する

  1. Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を ExplosionForce.ts に変更します。
  4. 作成した ExplosionForce.ts をダブルクリックしてエディタ(VSCode など)で開き、先ほどのコード全体を貼り付けて保存します。

2. テスト用シーンと物理オブジェクトを用意する

ここでは、簡単な「箱が爆風で吹き飛ぶ」シーンを作って動作確認します。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択し、いくつかの物理オブジェクト用ノードを作成します。
    • 例: Box1, Box2, Box3 など。
  2. 各 Box ノードを選択し、Inspector で以下のコンポーネントを追加します。
    1. Add Component → Physics 2D → RigidBody2D
    2. Add Component → Physics 2D → BoxCollider2D
    • RigidBody2D の TypeDynamic を選択します(Static だと動きません)。
    • BoxCollider2D のサイズは Sprite のサイズに合わせて調整します。
  3. 必要に応じて、床となる Static なオブジェクトも用意します。
    • 例: Floor ノードを作成し、Sprite + RigidBody2D(Type: Static) + BoxCollider2D を追加。

3. ExplosionForce コンポーネントをアタッチする

  1. Hierarchy パネルで右クリック → Create → Empty Node を選択し、新しいノードを作成します。
    • 名前を ExplosionCenter などに変更します。
  2. ExplosionCenter ノードを選択し、Scene ビュー上で Box たちの中央付近に配置します。
    • 爆風の中心になる位置に調整してください。
  3. ExplosionCenter の Inspector で Add Component → Custom → ExplosionForce を選択してアタッチします。

4. インスペクタでプロパティを設定する

ExplosionCenter の Inspector に表示される ExplosionForce の各プロパティを、まずは以下のように設定してみてください。

  • radius: 250
  • force: 1200
  • useDistanceAttenuation: ON(チェック)
  • minForceFactor: 0.2
  • upwardsModifier: 0.3
  • layerMask: 0(とりあえず全レイヤー対象)
  • autoExplodeOnStart: ON(チェック)
  • autoExplodeDelay: 0.2
  • destroyNodeAfterExplosion: ON(チェック)
  • debugDrawGizmo: ON(チェック)

この設定では、ゲーム開始から 0.2 秒後に一度だけ爆発が発生し、周囲 250 の範囲内にある Dynamic RigidBody2D を外側かつ少し上向きに吹き飛ばし、その後 ExplosionCenter ノード自体は破棄されます。

5. シーンを再生して動作確認する

  1. 上部の再生ボタン(Play)を押してゲームを実行します。
  2. 数フレーム後(0.2 秒後)、ExplosionCenter の位置を中心に爆風が発生し、周囲の Box たちが四方に吹き飛ぶ様子が確認できるはずです。
  3. 爆風の範囲が狭すぎて Box に届かない場合は、radius を大きくして再度試してください。
  4. 吹き飛びが弱い場合は force を増やしてください(例: 2000 ~ 3000)。

6. コードから任意のタイミングで爆発させる例

ExplosionForce は公開メソッド triggerExplosion() を持っているので、他のスクリプトから任意のタイミングで爆発させることもできます。
(この例は、同じノード に別スクリプトを追加する場合のイメージです。外部依存ではなく「使う側の例」としてご覧ください。)


// 例: 同じノードにつけたボタンハンドラなどから呼ぶ
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
import { ExplosionForce } from './ExplosionForce';

@ccclass('ExplosionTriggerExample')
export class ExplosionTriggerExample extends Component {
    onSomeEvent() {
        const explosion = this.getComponent(ExplosionForce);
        if (explosion) {
            explosion.triggerExplosion();
        }
    }
}

このように、ExplosionForce 自体は他のスクリプトに依存していませんが、「使う側」からは自由に呼び出せます。


まとめ

今回実装した ExplosionForce コンポーネントは、

  • 任意のノードを「爆心」として使える
  • 半径・衝撃力・距離減衰・上方向補正・レイヤーマスク・自動発火・自動破棄などをすべてインスペクタから調整できる
  • 他のカスタムスクリプトやシングルトンに一切依存せず、アタッチするだけで機能する

という特徴を持つ、汎用的な「爆風物理」コンポーネントです。

応用例としては、

  • グレネードやロケット弾の着弾地点にアタッチして、着弾時に triggerExplosion() を呼ぶ
  • ステージギミックとして、一定時間ごとに爆発するトラップに利用する
  • 演出用エフェクト(パーティクルなど)と組み合わせて、視覚的な爆発と物理的な爆風を同期させる

といった使い方が考えられます。

ExplosionForce.ts をプロジェクトに追加しておけば、今後「爆発で周囲を吹き飛ばしたい」場面で毎回ロジックを書く必要がなくなり、ゲーム開発の効率が大きく向上します。
まずはシンプルなシーンで挙動を確認しつつ、radius / force / upwardsModifier などを調整して、自分のゲームに合った爆風表現を作り込んでみてください。