【Cocos Creator 3.8】Summoner(召喚士)の実装:アタッチするだけで定期的に敵シーンを自動生成する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「指定した敵シーンを一定間隔で自動インスタンス化して出現させる」汎用コンポーネント Summoner を実装します。

敵の出現ポイントにこのコンポーネントを付けておけば、ゲーム全体の管理クラスやシングルトンを用意しなくても、インスペクタでシーンアセットと出現間隔を設定するだけで簡単に「召喚ポイント」を量産できます。


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

実現したい機能

  • 指定した プレハブシーン(Prefab) を一定間隔でインスタンス化する。
  • 生成位置は、このコンポーネントがアタッチされたノードの位置を基準とする。
  • 最大同時出現数(上限)を超えないように制御できる。
  • ゲーム開始時にすぐ召喚を始めるか、遅延してから開始するかを選べる。
  • ランダムな出現間隔(最小〜最大)を設定できる。
  • 召喚された敵が破棄されたときにカウントを減らし、上限管理が正しく動くようにする。
  • 外部スクリプトに一切依存しない(GameManager 等は不要)。

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

  • 敵のプレハブは @property(Prefab) で直接インスペクタから指定する。
  • 生成先の親ノード(コンテナ)も @property(Node) で指定可能にし、未設定の場合は自分自身の親ノードに生成する。
  • 召喚ロジックは Summoner コンポーネント内で完結させ、schedule / unschedule を使ってタイマー管理する。
  • 敵の破棄検知も Summoner 内で完結させるため、生成したノードに対して 自前のコールバック を紐づける(外部コンポーネントに依存しない)。

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

  • enemyPrefab: Prefab
    召喚する敵のプレハブ。
    必須。未設定の場合はエラーログを出して召喚を行わない。
  • spawnParent: Node | null
    召喚した敵をぶら下げる親ノード。
    未設定の場合は Summoner がアタッチされたノードの親 を使用する。
    どこにもぶら下げられない場合(親がいない)は、エラーログを出す。
  • autoStart: boolean
    true の場合、start() で自動的に召喚を開始する。
    false の場合は、外部から startSummon() を呼び出したときのみ召喚が始まる。
  • initialDelay: number
    召喚を開始するまでの遅延時間(秒)。
    例: 1.5 にすると、ゲーム開始から 1.5 秒後に最初の召喚が行われる。
  • minInterval: number
    召喚間隔の最小値(秒)。
    次の召喚までの時間を [minInterval, maxInterval] の範囲でランダムに決定する。
  • maxInterval: number
    召喚間隔の最大値(秒)。
    minInterval > maxInterval の場合は、自動的に入れ替えて安全な値に補正する。
  • maxAlive: number
    同時に存在できる召喚済み敵の最大数。
    0 以下を指定した場合は「上限なし」とみなす。
  • randomOffsetRadius: number
    召喚位置のランダム半径(ワールド座標)。
    0 の場合は完全に同じ位置に出現。
    例: 2.0 にすると、召喚者の周囲 2 ユニット以内のランダムな位置に出現。
  • alignToSummonerRotation: boolean
    true の場合、召喚された敵の回転を召喚者ノードの回転に合わせる。
  • debugLog: boolean
    true にすると、召喚開始・停止・生成・上限到達などの情報を console.log に出力する。

TypeScriptコードの実装


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

/**
 * Summoner
 * 指定した Prefab を一定間隔でインスタンス化して出現させる汎用コンポーネント。
 * 他のカスタムスクリプトには一切依存しません。
 */
@ccclass('Summoner')
export class Summoner extends Component {

    @property({
        type: Prefab,
        tooltip: '召喚する敵のプレハブ。未設定の場合は召喚されません。'
    })
    public enemyPrefab: Prefab | null = null;

    @property({
        type: Node,
        tooltip: '召喚された敵をぶら下げる親ノード。未設定の場合はこのノードの親ノードを使用します。'
    })
    public spawnParent: Node | null = null;

    @property({
        tooltip: 'true の場合、ゲーム開始と同時に召喚を開始します。'
    })
    public autoStart: boolean = true;

    @property({
        tooltip: '召喚を開始するまでの遅延時間(秒)。'
    })
    public initialDelay: number = 0.0;

    @property({
        tooltip: '召喚間隔の最小値(秒)。maxInterval と合わせてランダムな間隔を決定します。'
    })
    public minInterval: number = 2.0;

    @property({
        tooltip: '召喚間隔の最大値(秒)。minInterval より小さい場合は自動で入れ替えます。'
    })
    public maxInterval: number = 4.0;

    @property({
        tooltip: '同時に存在できる召喚済み敵の最大数。0 以下の場合は上限なし。'
    })
    public maxAlive: number = 5;

    @property({
        tooltip: '召喚位置のランダム半径。0 の場合は完全に同じ位置に出現します。'
    })
    public randomOffsetRadius: number = 0.0;

    @property({
        tooltip: 'true の場合、召喚された敵の回転を召喚者ノードの回転に合わせます。'
    })
    public alignToSummonerRotation: boolean = true;

    @property({
        tooltip: 'true にすると召喚処理のログをコンソールに出力します。'
    })
    public debugLog: boolean = false;

    /** 現在生存している召喚済み敵の数 */
    private _aliveCount: number = 0;

    /** 現在召喚がアクティブかどうか */
    private _isSummoning: boolean = false;

    /** 次の召喚までの残り時間(秒) */
    private _timeToNextSpawn: number = 0;

    onLoad() {
        // minInterval と maxInterval の関係を補正
        if (this.minInterval <= 0) {
            this.minInterval = 0.1;
        }
        if (this.maxInterval <= 0) {
            this.maxInterval = this.minInterval;
        }
        if (this.minInterval > this.maxInterval) {
            const tmp = this.minInterval;
            this.minInterval = this.maxInterval;
            this.maxInterval = tmp;
        }

        if (!this.enemyPrefab) {
            console.error('[Summoner] enemyPrefab が設定されていません。このコンポーネントは動作しません。', this.node);
        }

        // spawnParent が未設定の場合は、このノードの親を使う
        if (!this.spawnParent) {
            this.spawnParent = this.node.parent;
        }
        if (!this.spawnParent) {
            console.error('[Summoner] spawnParent が設定されておらず、このノードにも親がありません。生成先がないため召喚できません。', this.node);
        }
    }

    start() {
        if (this.autoStart) {
            this.startSummon();
        }
    }

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

        if (!this.enemyPrefab) {
            // 毎フレームエラーを出すと煩雑なので、一度だけエラーを出したい場合はフラグ管理するのもあり。
            return;
        }

        // 上限チェック(maxAlive <= 0 の場合は上限なし)
        if (this.maxAlive > 0 && this._aliveCount >= this.maxAlive) {
            return;
        }

        this._timeToNextSpawn -= deltaTime;
        if (this._timeToNextSpawn <= 0) {
            this.spawnOne();
            // 次回の召喚時間を再設定
            this._timeToNextSpawn = this.getRandomInterval();
        }
    }

    /**
     * 召喚を開始する(外部からも呼び出し可能)。
     */
    public startSummon(): void {
        if (this._isSummoning) {
            return;
        }
        if (!this.enemyPrefab) {
            console.error('[Summoner] enemyPrefab が設定されていないため、召喚を開始できません。', this.node);
            return;
        }
        if (!this.spawnParent) {
            console.error('[Summoner] spawnParent が設定されていないため、召喚を開始できません。', this.node);
            return;
        }

        this._isSummoning = true;
        this._timeToNextSpawn = this.initialDelay > 0 ? this.initialDelay : this.getRandomInterval();

        if (this.debugLog) {
            console.log('[Summoner] 召喚を開始しました。初回まで', this._timeToNextSpawn.toFixed(2), '秒', this.node.name);
        }
    }

    /**
     * 召喚を停止する(外部からも呼び出し可能)。
     */
    public stopSummon(): void {
        if (!this._isSummoning) {
            return;
        }
        this._isSummoning = false;
        if (this.debugLog) {
            console.log('[Summoner] 召喚を停止しました。', this.node.name);
        }
    }

    /**
     * ランダムな召喚間隔を返す。
     */
    private getRandomInterval(): number {
        if (this.minInterval === this.maxInterval) {
            return this.minInterval;
        }
        return math.lerp(this.minInterval, this.maxInterval, Math.random());
    }

    /**
     * 敵を 1 体召喚する。
     */
    private spawnOne(): void {
        if (!this.enemyPrefab || !this.spawnParent) {
            return;
        }

        // 生成位置を決定
        const worldPos = new Vec3();
        this.node.getWorldPosition(worldPos);

        if (this.randomOffsetRadius > 0) {
            const angle = Math.random() * Math.PI * 2;
            const radius = Math.random() * this.randomOffsetRadius;
            worldPos.x += Math.cos(angle) * radius;
            worldPos.y += Math.sin(angle) * radius;
        }

        // プレハブからインスタンスを生成
        const enemyNode = instantiate(this.enemyPrefab);

        // 親ノードに追加
        this.spawnParent.addChild(enemyNode);

        // ワールド座標を設定
        enemyNode.setWorldPosition(worldPos);

        // 回転を揃えるオプション
        if (this.alignToSummonerRotation) {
            const worldRot = this.node.getWorldRotation();
            enemyNode.setWorldRotation(worldRot);
        }

        this._aliveCount++;

        if (this.debugLog) {
            console.log('[Summoner] 敵を召喚しました。現在の生存数:', this._aliveCount, 'ノード名:', enemyNode.name);
        }

        // 敵ノードが破棄されたときにカウントを減らすためのフック
        // onDestroy は Component にしかないため、一時的な内部コンポーネントを追加して管理する。
        const tracker = enemyNode.addComponent(SummonedTracker);
        tracker.ownerSummoner = this;
    }

    /**
     * SummonedTracker から呼ばれるコールバック。
     * 召喚された敵が破棄されたときに生存数を減らす。
     */
    public notifySummonedDestroyed(): void {
        this._aliveCount = Math.max(0, this._aliveCount - 1);
        if (this.debugLog) {
            console.log('[Summoner] 召喚された敵が破棄されました。現在の生存数:', this._aliveCount);
        }
    }

    onDisable() {
        // ノードが無効化されたら召喚を止める
        this.stopSummon();
    }

    onDestroy() {
        // ノード破棄時も安全のため停止
        this.stopSummon();
    }
}

/**
 * SummonedTracker
 * Summoner が生成した敵ノードに自動的に付与される内部用コンポーネント。
 * 敵ノードが破棄されたときに Summoner に通知してカウントを調整する。
 *
 * ※外部から直接使う必要はありません。
 */
@ccclass('SummonedTracker')
class SummonedTracker extends Component {

    public ownerSummoner: Summoner | null = null;

    onDestroy() {
        if (this.ownerSummoner) {
            this.ownerSummoner.notifySummonedDestroyed();
        }
    }
}

コードの主要ポイント解説

  • onLoad
    • minIntervalmaxInterval の値を補正し、異常値(負数や逆転)を防いでいます。
    • enemyPrefab が設定されていない場合や、spawnParent を決定できない場合に console.error でエラーログを出します。
  • start
    • autoStarttrue のときだけ startSummon() を呼び出し、自動的に召喚を開始します。
  • update(deltaTime)
    • _isSummoningtrue のときだけ動作します。
    • maxAlive で同時出現数の上限をチェックし、上限に達していれば召喚をスキップします。
    • _timeToNextSpawn を減算し、0 以下になったら spawnOne() を呼び出して敵を生成し、次の間隔を再設定します。
  • spawnOne()
    • 自分自身のワールド座標を基準に、randomOffsetRadius でランダムな位置を計算します。
    • instantiate(enemyPrefab) で敵ノードを生成し、spawnParentaddChild します。
    • alignToSummonerRotationtrue の場合、召喚者と同じ回転を設定します。
    • 生成した敵ノードに SummonedTracker を付与し、敵が破棄されたときに notifySummonedDestroyed() が呼ばれるようにしています。
  • SummonedTracker
    • このコンポーネントは Summoner 内部専用です。ユーザーが意識して設定する必要はありません。
    • 敵ノードが destroy() されたときに onDestroy() が呼ばれ、紐づいている Summoner に「1 体減った」ことを通知します。

使用手順と動作確認

1. 敵プレハブの準備

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などで、敵用のノードを作成します。
  2. 必要であれば、敵用のスクリプトやアニメーション、Collider などを追加してください(任意)。
  3. 作成した敵ノードを Assets パネル にドラッグ&ドロップして Prefab にします。
    例: EnemyBasic.prefab

2. Summoner.ts スクリプトの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択し、ファイル名を Summoner.ts にします。
  2. 自動生成されたコードをすべて削除し、本記事の TypeScriptコードの実装 セクションのコードを丸ごと貼り付けて保存します。

3. 召喚ポイント用ノードの作成とアタッチ

  1. Hierarchy パネルで右クリック → Create → Empty Node を選択し、ノード名を EnemySpawnPoint などに変更します。
  2. このノードを敵を出現させたい位置に移動させます。
  3. EnemySpawnPoint ノードを選択し、Inspector の Add Component ボタンをクリックします。
  4. Custom → Summoner を選択してコンポーネントを追加します。

4. Summoner のプロパティ設定

  1. Enemy Prefab に、先ほど作成した EnemyBasic.prefab をドラッグ&ドロップします。
  2. Spawn Parent は、特にこだわりがなければ空欄のままで構いません。
    空欄の場合、EnemySpawnPoint の親ノードに敵がぶら下がります。
    敵だけをまとめて管理したい場合は、Hierarchy で Enemies ノードを作成し、そこを Spawn Parent に指定してください。
  3. Auto Start: とりあえず true のままにしておきます。
  4. Initial Delay: 例として 1.0 秒に設定すると、ゲーム開始から 1 秒後に最初の敵が出現します。
  5. Min Interval: 例として 2.0 秒。
  6. Max Interval: 例として 4.0 秒。
    この場合、2〜4 秒のランダムな間隔で敵が次々と出現します。
  7. Max Alive: 例として 5 に設定します。
    シーン上に 5 体以上の敵が存在する場合は、新たな敵は生成されません。
    敵を destroy() するなどして減らすと、再び召喚が再開されます。
  8. Random Offset Radius: 例として 1.5 に設定します。
    召喚ポイントの周囲 1.5 ユニット以内のランダムな位置に敵が出現するようになります。
  9. Align To Summoner Rotation: 敵の向きを召喚ポイントの向きに合わせたい場合は true のままにします。
  10. Debug Log: 動作確認のために true にしておくと、コンソールに召喚ログが表示されてわかりやすいです。

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

  1. メインカメラが敵出現位置を映していることを確認します。
  2. エディタ右上の Play ボタン(ブラウザプレビュー)をクリックします。
  3. ゲーム開始から Initial Delay 秒後に、EnemySpawnPoint の周囲に敵が出現し始めることを確認してください。
  4. Debug Logtrue にしている場合、ブラウザのコンソールに
    [Summoner] 敵を召喚しました。現在の生存数: ...
    といったログが出力されているはずです。
  5. 敵ノードを何らかの方法で destroy() すると、生存数が減り、再び召喚が行われることも確認してみてください。

6. 応用的な使い方の例

  • 複数の召喚ポイントを配置する
    • EnemySpawnPoint ノードを複製して、マップのあちこちに配置します。
    • それぞれの SummonerInitial DelayMin/Max Interval を変えることで、場所ごとに違うテンポで敵が湧くようにできます。
  • 敵の種類を変える
    • 別の敵プレハブ(例: EnemyFast.prefab)を作成し、別の SummonerEnemy Prefab に設定します。
    • 1 つのマップに「遅い雑魚」「素早い雑魚」などを混在させることが簡単にできます。
  • スクリプトから召喚の開始/停止を制御する
    • autoStart = false にしておき、他のコンポーネントから startSummon() / stopSummon() を呼び出すことで、特定のイベント(ボス出現後など)に合わせて召喚を開始・停止できます。
    • このときも Summoner 自体は外部に依存していない ため、どのようなゲーム構造にも組み込みやすくなっています。

まとめ

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

  • 敵プレハブと出現間隔、上限数をインスペクタで指定するだけで使える。
  • 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結している。
  • 召喚位置や向き、ランダムオフセット、ログ出力など、実運用に必要なオプションを備えている。

という特徴を持つ、汎用性の高い召喚ポイント実装です。

マップ上に複数配置するだけで「このエリアでは 2〜4 秒おきに 3 体まで」「こっちのエリアでは 5〜8 秒おきに 10 体まで」といったパターンを簡単に作れるため、ステージ設計やバランス調整の効率が大きく向上します。

このコンポーネントをベースに、例えば「時間経過で召喚速度が上がる」「プレイヤーの距離に応じて召喚をオン/オフする」といった拡張も、すべて Summoner 内部のロジック追加だけで完結させることができます。
まずは本記事のコードをそのまま導入して、シンプルな「雑魚敵の湧きポイント」として活用してみてください。