【Cocos Creator 3.8】ShotgunSpawnerの実装:アタッチするだけで「扇状の散弾発射」を実現する汎用スクリプト

このコンポーネントは、指定したプレハブ弾を「一度に複数発、角度をずらして扇状に発射」するための汎用スクリプトです。プレイヤーや敵キャラのノードにアタッチし、弾プレハブと発射条件をインスペクタで設定するだけで、ショットガンのような散弾攻撃を簡単に実装できます。

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

ShotgunSpawner は以下を満たすように設計します。

  • このスクリプト単体で完結する(外部の GameManager やシングルトンに依存しない)。
  • 「弾プレハブ」と「発射パラメータ」をインスペクタから設定するだけで動作。
  • 散弾の「本数」「扇の角度」「発射方向」「連射間隔」「初速」などをすべてプロパティで調整可能。
  • 弾側に RigidBody2D / RigidBody などがあれば初速を与えるが、無くてもエラーログを出した上で「単に生成のみ」でも動作する防御的実装。
  • テストしやすいように「自動連射モード」と「手動(スクリプトからメソッド呼び出し)」の両方をサポート。

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

以下のようなプロパティを用意します。

  • projectilePrefab: Prefab
    発射する弾のプレハブ。
    ・必須。未設定の場合はエラーを出して発射処理を行わない。
    ・このプレハブには任意の見た目(Sprite や 3D Mesh)と必要に応じて RigidBody2D / RigidBody を付けてください。
  • projectileCount: number
    一度に発射する弾の本数。
    ・1以上の整数。1なら「単発」、3以上なら「散弾」。
    ・例: 5 にすると、中心+左右に2発ずつの計5発。
  • spreadAngle: number
    散弾の扇の「全体の角度(度数)」
    ・例: 60 にすると、-30°〜+30°の範囲に均等に散らばる。
    ・0 を指定すると、全弾が同じ方向(中心)に飛ぶ。
  • baseAngle: number
    発射の中心となる角度(度数)。
    ・「0度=右向き」とし、反時計回りが正の向き(一般的なCocosの回転系と合わせる)。
    ・例: 90 にすると「上方向」を中心に散弾を撃つ。
  • useNodeRotationAsBase: boolean
    ノード自身の回転を基準角度として使うかどうか。
    ・true の場合: ノードの world 角度 + baseAngle を中心に散弾を発射。
    ・false の場合: world 角度を無視して baseAngle のみを使用。
  • initialSpeed: number
    弾に与える初速(単位は任意。RigidBody2D/3D の linearVelocity にそのまま設定)。
    ・0 の場合、速度は与えない(位置だけ生成)。
    ・2D 物理なら「右向き=正X」、3D 物理なら「Z+向き」をベースに回転させて速度ベクトルを作る。
  • use2DPhysics: boolean
    弾に付与する物理の種類を指定。
    ・true: RigidBody2D を探し、あれば2Dベクトルで速度を与える。
    ・false: RigidBody(3D)を探し、あれば3Dベクトルで速度を与える。
  • spawnOffset: Vec3
    発射位置のオフセット(ローカル座標)。
    ・例: (0, 0, 0) ならノードの位置から発射。
    ・(0, 20, 0) なら少し上から発射、など。
  • autoFire: boolean
    自動連射モードのオン/オフ。
    ・true: 指定した間隔で自動的に散弾を撃ち続ける。
    ・false: 自動発射しない(外部から public メソッドを呼ぶなどして発射)。
  • fireInterval: number
    自動連射モード時の発射間隔(秒)。
    ・autoFire = true のときのみ有効。
    ・例: 0.5 なら 0.5秒ごとに散弾を発射。
  • maxAutoFireShots: number
    自動連射で発射する最大回数。
    ・0 以下なら「無制限」。
    ・例: 10 にすると、自動で10回撃ったら止まる。
  • destroyProjectilesAfter: number
    弾を自動破棄するまでの時間(秒)。
    ・0 以下なら自動破棄しない。
    ・例: 3 にすると、生成から3秒後に弾ノードを破棄。
  • debugLog: boolean
    デバッグログの出力オン/オフ。
    ・true にすると、発射時やエラー時にログを多めに出す。

TypeScriptコードの実装


import { _decorator, Component, Node, Prefab, instantiate, Vec2, Vec3, math, RigidBody2D, RigidBody, director } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('ShotgunSpawner')
export class ShotgunSpawner extends Component {

    @property({
        type: Prefab,
        tooltip: '発射する弾のプレハブ。\n必須。設定されていない場合は発射処理を行いません。'
    })
    public projectilePrefab: Prefab | null = null;

    @property({
        tooltip: '一度に発射する弾の本数(1以上の整数)。\n1: 単発, 3以上: 散弾。'
    })
    public projectileCount: number = 5;

    @property({
        tooltip: '散弾の扇の全体角度(度)。\n例: 60 → -30〜+30度に均等配置。0 → 全弾同じ方向。'
    })
    public spreadAngle: number = 60;

    @property({
        tooltip: '発射の中心となる角度(度)。\n0度=右向き、正の値で反時計回り。'
    })
    public baseAngle: number = 0;

    @property({
        tooltip: 'ノード自身の回転を基準角度として使うかどうか。\nON: ノードの角度+baseAngle を中心に発射。'
    })
    public useNodeRotationAsBase: boolean = true;

    @property({
        tooltip: '弾に与える初速(0で速度を与えない)。\n2D: linearVelocity(Vec2)、3D: linearVelocity(Vec3) に設定。'
    })
    public initialSpeed: number = 800;

    @property({
        tooltip: 'true: RigidBody2D を使用して2D物理で発射。\nfalse: RigidBody(3D) を使用して3D物理で発射。'
    })
    public use2DPhysics: boolean = true;

    @property({
        type: Vec3,
        tooltip: '発射位置のローカルオフセット。\n(0,0,0) ならノードの位置から発射。'
    })
    public spawnOffset: Vec3 = new Vec3(0, 0, 0);

    @property({
        tooltip: '自動連射モードを有効にするかどうか。'
    })
    public autoFire: boolean = false;

    @property({
        tooltip: '自動連射モード時の発射間隔(秒)。'
    })
    public fireInterval: number = 0.5;

    @property({
        tooltip: '自動連射で発射する最大回数。\n0以下で無制限。'
    })
    public maxAutoFireShots: number = 0;

    @property({
        tooltip: '弾を自動破棄するまでの時間(秒)。\n0以下で自動破棄しない。'
    })
    public destroyProjectilesAfter: number = 3;

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

    // 内部状態
    private _fireTimer: number = 0;
    private _autoShotCount: number = 0;

    onLoad() {
        // プロパティの簡易バリデーション
        if (this.projectileCount < 1) {
            if (this.debugLog) {
                console.warn('[ShotgunSpawner] projectileCount が 1 未満のため 1 に補正します。');
            }
            this.projectileCount = 1;
        }

        if (this.fireInterval <= 0) {
            if (this.debugLog) {
                console.warn('[ShotgunSpawner] fireInterval が 0 以下です。自動連射時に異常な挙動をする可能性があります。');
            }
            // ここでは補正はせず、ユーザーの指定を尊重する
        }
    }

    start() {
        this._fireTimer = 0;
        this._autoShotCount = 0;

        if (this.autoFire && this.debugLog) {
            console.log('[ShotgunSpawner] 自動連射モードで開始します。');
        }
    }

    update(deltaTime: number) {
        if (!this.autoFire) {
            return;
        }

        // maxAutoFireShots が設定されていて、上限に達したら自動連射を止める
        if (this.maxAutoFireShots > 0 && this._autoShotCount >= this.maxAutoFireShots) {
            return;
        }

        this._fireTimer += deltaTime;

        if (this._fireTimer >= this.fireInterval) {
            this._fireTimer -= this.fireInterval;
            this.fireShotgun();
            this._autoShotCount++;
        }
    }

    /**
     * 外部からも呼べる公開メソッド。
     * 一度だけ散弾を発射します。
     */
    public fireShotgun() {
        if (!this.projectilePrefab) {
            console.error('[ShotgunSpawner] projectilePrefab が設定されていません。発射できません。');
            return;
        }

        const parent = this.node.scene ? this.node.scene : director.getScene();
        if (!parent) {
            console.error('[ShotgunSpawner] シーンが取得できません。発射できません。');
            return;
        }

        // 基準角度(度)を計算
        let baseDeg = this.baseAngle;
        if (this.useNodeRotationAsBase) {
            // ノードのワールド回転(Z軸回り)を取得
            const worldRot = this.node.worldRotation;
            const euler = worldRot.toEuler(new Vec3());
            // CocosではZ軸回転が2D的な角度と対応することが多い
            baseDeg += euler.z;
        }

        // 弾ごとの角度を計算して生成
        const count = this.projectileCount;
        const spread = this.spreadAngle;

        for (let i = 0; i < count; i++) {
            const angleDeg = this._calculateProjectileAngle(baseDeg, spread, count, i);
            const projectile = instantiate(this.projectilePrefab);

            // 発射位置(ワールド座標)を計算
            const spawnPos = this._calculateSpawnWorldPosition();

            projectile.setWorldPosition(spawnPos);

            // 向き(角度)を設定(2D想定ではZ回りの回転)
            const rotationRad = math.toRadian(angleDeg);
            const euler = new Vec3(0, 0, angleDeg);
            projectile.setWorldRotationFromEuler(euler);

            // シーンに追加
            parent.addChild(projectile);

            // 速度を与える
            this._applyInitialVelocity(projectile, rotationRad);

            // 一定時間後に自動破棄
            if (this.destroyProjectilesAfter > 0) {
                this._scheduleDestroy(projectile, this.destroyProjectilesAfter);
            }

            if (this.debugLog) {
                console.log(`[ShotgunSpawner] 弾を発射 angle=${angleDeg.toFixed(2)}deg pos=(${spawnPos.x.toFixed(2)}, ${spawnPos.y.toFixed(2)}, ${spawnPos.z.toFixed(2)})`);
            }
        }
    }

    /**
     * 弾ごとの発射角度(度)を計算する。
     * count=1 のときは baseDeg のみ。
     * count>1 のときは spreadAngle を等分して左右に配置。
     */
    private _calculateProjectileAngle(baseDeg: number, spreadDeg: number, count: number, index: number): number {
        if (count === 1 || spreadDeg === 0) {
            return baseDeg;
        }

        // 例: count=5, spread=60 → step=60/(5-1)=15
        // index:0 → -30, 1→ -15, 2→0, 3→15, 4→30
        const step = spreadDeg / (count - 1);
        const start = -spreadDeg / 2;
        const offset = start + step * index;
        return baseDeg + offset;
    }

    /**
     * 発射位置のワールド座標を計算。
     * ノードのワールド位置+ローカルオフセットを適用。
     */
    private _calculateSpawnWorldPosition(): Vec3 {
        const worldPos = this.node.worldPosition.clone();
        if (this.spawnOffset.equals(Vec3.ZERO)) {
            return worldPos;
        }

        // ローカルオフセットをワールド空間に変換
        const offsetWorld = this.node.getWorldMatrix().transformVector3(this.spawnOffset.clone(), new Vec3());
        return worldPos.add(offsetWorld);
    }

    /**
     * 弾に初速を与える。
     * use2DPhysics=true → RigidBody2D を探す。
     * use2DPhysics=false → RigidBody(3D) を探す。
     */
    private _applyInitialVelocity(projectile: Node, angleRad: number) {
        if (this.initialSpeed === 0) {
            return;
        }

        if (this.use2DPhysics) {
            const rb2d = projectile.getComponent(RigidBody2D);
            if (!rb2d) {
                if (this.debugLog) {
                    console.warn('[ShotgunSpawner] projectile に RigidBody2D がありません。速度を与えられません。');
                }
                return;
            }

            const dir = new Vec2(Math.cos(angleRad), Math.sin(angleRad));
            const velocity = dir.multiplyScalar(this.initialSpeed);
            rb2d.linearVelocity = velocity;
        } else {
            const rb3d = projectile.getComponent(RigidBody);
            if (!rb3d) {
                if (this.debugLog) {
                    console.warn('[ShotgunSpawner] projectile に RigidBody(3D) がありません。速度を与えられません。');
                }
                return;
            }

            // 3Dでは「Z+方向」を基準に、Y軸回転で左右に振るようなイメージでもよいが、
            // ここでは2Dと同じく X-Y 平面に投影したベクトルを使う。
            const dir3 = new Vec3(Math.cos(angleRad), Math.sin(angleRad), 0);
            const velocity3 = dir3.multiplyScalar(this.initialSpeed);
            rb3d.setLinearVelocity(velocity3);
        }
    }

    /**
     * 一定時間後に弾ノードを破棄する。
     */
    private _scheduleDestroy(projectile: Node, delay: number) {
        // Componentを一時的に追加して scheduleOnce を使う方法もあるが、
        // ここでは簡潔に this.scheduleOnce を利用し、クロージャでノードを保持する。
        this.scheduleOnce(() => {
            if (projectile && projectile.isValid) {
                projectile.destroy();
                if (this.debugLog) {
                    console.log('[ShotgunSpawner] 弾を自動破棄しました。');
                }
            }
        }, delay);
    }
}

コードの主要部分の解説

  • onLoad()
    ・projectileCount が 1 未満のときに 1 に補正するなど、基本的なバリデーションを実施。
    ・fireInterval が 0 以下のときは警告ログのみ出し、挙動はユーザーに委ねています。
  • start()
    ・自動連射用のタイマーとカウンタを初期化。
    ・autoFire が true の場合は、その旨をログに出力(debugLog が true のときのみ)。
  • update(deltaTime)
    ・autoFire がオンのときだけ処理。
    ・fireInterval ごとに fireShotgun() を呼び出して散弾を発射。
    ・maxAutoFireShots が 0 より大きい場合は、その回数に達したら自動連射を停止。
  • fireShotgun()
    ・公開メソッド。外部スクリプトからも呼び出して単発の散弾発射を行えます。
    ・projectilePrefab が未設定ならエラーログを出して即 return。
    ・ノードのワールド回転+baseAngle から基準角度を計算。
    ・_calculateProjectileAngle() で各弾の角度を算出し、instantiate した弾ノードに位置・回転・速度を設定。
    ・destroyProjectilesAfter > 0 の場合は _scheduleDestroy() で自動破棄を予約。
  • _calculateProjectileAngle()
    ・弾数と扇角度から、左右に等間隔で散らばる角度を計算。
    ・弾数1 or spreadAngle=0 の場合は単純に baseDeg を返します。
  • _calculateSpawnWorldPosition()
    ・ノードのワールド位置に spawnOffset(ローカル)をワールド変換して加算し、実際の発射位置を求めます。
    ・「ノードの少し前方から発射したい」といった場合に便利です。
  • _applyInitialVelocity()
    ・use2DPhysics フラグに応じて RigidBody2D または RigidBody を取得。
    ・見つからなければ警告ログを出して終了(弾は生成されるが動かない)。
    ・角度から方向ベクトルを計算し、initialSpeed を掛けて linearVelocity にセット。
  • _scheduleDestroy()
    ・this.scheduleOnce を用いて、指定秒数後に弾ノードを destroy()。
    ・弾が既に削除されている場合に備え、isValid をチェックする防御的実装。

使用手順と動作確認

1. 弾プレハブの作成

  1. Assets パネルで右クリック → Create → 2D Object → Sprite などで弾用ノードを作成します(名前は Bullet など)。
  2. 作成したノードを選択し、Inspector で以下を設定します。
    • 見た目:Sprite の SpriteFrame に任意の画像を設定。
    • 物理挙動をさせたい場合:
      • 2D で使う場合: Add Component → Physics2D → RigidBody2D を追加し、Body Type を Dynamic に設定。
      • 3D で使う場合: Add Component → Physics → RigidBody を追加。
  3. Hierarchy からこの弾ノードを Assets パネルへドラッグ&ドロップして Prefab 化します(例: Bullet.prefab)。
  4. Prefab 化したら、シーン上にある元の弾ノードは削除して構いません。

2. ShotgunSpawner.ts の作成

  1. Assets パネルで右クリック → Create → TypeScript を選択し、ファイル名を ShotgunSpawner.ts とします。
  2. 作成された ShotgunSpawner.ts をダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードを貼り付けて保存します。

3. テスト用ノードに ShotgunSpawner をアタッチ

  1. Hierarchy で右クリック → Create → 2D Object → Sprite(または 3D Object → Node)などでテスト用ノードを作成します(名前: Player など)。
  2. 作成したノードを選択し、Inspector の Add Component → Custom を開き、一覧から ShotgunSpawner を選択して追加します。
  3. Inspector の ShotgunSpawner コンポーネントで、以下のように設定します(2D の例)。
    • Projectile Prefab: 先ほど作成した Bullet.prefab をドラッグ&ドロップ。
    • Projectile Count: 5
    • Spread Angle: 60
    • Base Angle: 0(右向き中心)
    • Use Node Rotation As Base: ON(チェック)
    • Initial Speed: 800(または 300〜1000 の範囲でお好み)
    • Use 2D Physics: ON(2Dの場合)
    • Spawn Offset: (0, 0, 0) のままでOK
    • Auto Fire: ON(テストしやすいように自動連射)
    • Fire Interval: 0.5
    • Max Auto Fire Shots: 0(無制限)
    • Destroy Projectiles After: 3
    • Debug Log: 必要に応じて ON

4. シーンを再生して動作確認

  1. Scene を保存し、エディタ右上の Play ボタンを押してプレビューを開始します。
  2. 画面中央付近(ShotgunSpawner を付けたノードの位置)から、0.5 秒ごとに 5 発の弾が扇状(約60度)に発射されることを確認します。
  3. 弾が3秒ほど経過すると自動で消えることも確認します。
  4. Inspector の値を変更して、以下のようなバリエーションも試してみてください。
    • Projectile Count = 1, Spread Angle = 0 → 単発ショット。
    • Projectile Count = 10, Spread Angle = 120 → 広範囲の散弾。
    • Base Angle = 90 → 上方向に向かって散弾を撃つ。
    • Use Node Rotation As Base = OFF, Base Angle = 0 → ノードを回転させても常に右方向に発射。
    • Auto Fire = OFF → 外部スクリプトから fireShotgun() を呼び出して発射(次項参照)。

5. 手動発射(スクリプトからの呼び出し例)

他のコンポーネントから手動で散弾を撃ちたい場合は、fireShotgun() を呼び出します。


import { _decorator, Component } from 'cc';
import { ShotgunSpawner } from './ShotgunSpawner';
const { ccclass, property } = _decorator;

@ccclass('ShotgunTestController')
export class ShotgunTestController extends Component {
    @property(ShotgunSpawner)
    shotgun: ShotgunSpawner | null = null;

    update(deltaTime: number) {
        // 例: スペースキーが押された瞬間に発射(擬似コード)
        // 実際には Input API を使って判定してください。
        // if (spaceKeyJustPressed) {
        //     this.shotgun?.fireShotgun();
        // }
    }
}

このように、ShotgunSpawner 自体は他のスクリプトに依存せず完結しているため、必要に応じて任意のノードにアタッチし、外部から fireShotgun() を呼ぶだけで散弾を発射できます。

まとめ

この記事では、Cocos Creator 3.8 / TypeScript で「ShotgunSpawner」という汎用コンポーネントを実装し、以下のポイントを押さえました。

  • 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結する設計
  • 弾プレハブ、散弾本数、扇の角度、基準角度、初速、自動連射などを すべてインスペクタから調整可能
  • 弾に RigidBody2D / RigidBody がない場合でも、エラーログを出しつつ最低限動作する防御的実装
  • 自動連射モードと手動発射(fireShotgun())の両対応で、プレイヤー武器・敵弾幕・ギミックなど様々な場面に再利用可能。

一度このコンポーネントをプロジェクトに用意しておけば、

  • プレイヤーのショットガン武器
  • ボスの扇状弾幕攻撃
  • タレットやトラップの散弾発射

といった要素を、ノードにアタッチしてプロパティを少し調整するだけで簡単に実装できます。
さらに、パラメータをスクリプトやUIから動的に変更することで、「パワーアップ時に弾数を増やす」「フェーズごとに散弾角度を変える」といった演出も容易に行えます。

この ShotgunSpawner をベースに、自分のゲームに合わせたカスタム弾幕や武器システムを発展させてみてください。