【Cocos Creator 3.8】MineLayer の実装:アタッチするだけで「踏むと爆発する地雷エリア」を自動生成する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「足元に地雷(Area2D)を設置し、踏まれたら爆発して一定時間で消える」動きを実現する汎用コンポーネント MineLayer を実装します。
プレイヤーや敵キャラなど「地雷をばらまくキャラ」にこのコンポーネントを付けるだけで、一定間隔で足元に爆発判定エリアを生成できるようになります。


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

1. 機能要件の整理

  • このコンポーネントをアタッチしたノード(例: プレイヤー)の足元に「地雷ノード」を生成する。
  • 地雷は 2D物理のトリガー判定Collider2D)を用いて、「誰かが踏んだ」ことを検出する。
  • 踏まれたら爆発状態になり、爆発エフェクトを表示しつつ、一定時間後に自動で消滅する。
  • 地雷の見た目(Sprite)・爆発エフェクト(Sprite / アニメーション)・コリジョン形状・サイズなどは、インスペクタのプロパティで調整可能にする。
  • 一定間隔で自動的に地雷を設置する「オート設置モード」と、スクリプトから placeMine() を呼ぶことで設置する「手動設置モード」の両方に対応する。
  • 外部のカスタムスクリプトには一切依存せず、このコンポーネント単体で完結させる。

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

  • 地雷ノードは コード内で新規生成し、必要なコンポーネント(Sprite, CircleCollider2D, RigidBody2Dなど)を addComponent で自前で追加する。
  • 爆発演出も、別の「ExplosionManager」などには頼らず、地雷ノード自身が見た目を切り替え、一定時間後に自壊する。
  • 「誰が踏んだか」を判定するためにタグや専用スクリプトには依存せず、onBeginContact で「何かが触れたら爆発」というシンプルな仕様にする。

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

以下のプロパティを用意し、全て @property で Inspector から編集できるようにします。

  • autoPlace (boolean)
    • 地雷を自動設置するかどうか。
    • true: 一定間隔で足元に地雷を自動生成。
    • false: 自動では生成しない。placeMine() を他スクリプトから呼ぶことで設置(本記事では主に自動モードを説明)。
  • placeInterval (number)
    • 自動設置モード時の、地雷を設置する間隔(秒)。
    • 例: 1.5 にすると、1.5秒ごとに足元に新しい地雷を生成。
  • maxMines (number)
    • 同時に存在できる地雷の最大数。
    • 0 以下を指定した場合は「無制限」とみなす。
  • mineOffsetY (number)
    • 設置位置の Y オフセット。キャラの足元に合わせて微調整するための値。
    • 例: -20 にすると、キャラの原点より少し下に地雷を設置。
  • mineRadius (number)
    • 地雷の当たり判定の半径(CircleCollider2D.radius)。
    • 例: 20 〜 40 くらいで調整。
  • mineSpriteFrame (SpriteFrame)
    • 地雷待機中の見た目に使うスプライト。
    • 未設定の場合は簡易な警告ログを出すが、Sprite 自体は空のまま生成される。
  • explosionSpriteFrame (SpriteFrame)
    • 爆発時の見た目に使うスプライト。
    • 未設定の場合は、爆発しても見た目は変わらない。
  • mineLifetime (number)
    • 踏まれなかった場合でも、この秒数経過後に自動消滅する。
    • 0 以下で「時間経過では消えない」。
  • explosionDuration (number)
    • 爆発状態を維持する時間(秒)。この時間経過後に地雷ノードを破棄。
  • debugLog (boolean)
    • 地雷設置・爆発・破棄などのログを console.log に出すかどうか。

TypeScriptコードの実装

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


import {
    _decorator,
    Component,
    Node,
    Vec3,
    Sprite,
    SpriteFrame,
    UITransform,
    Color,
    RigidBody2D,
    ERigidBody2DType,
    CircleCollider2D,
    Contact2DType,
    IPhysics2DContact,
    director,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * MineLayer
 * 任意のノードにアタッチすると、足元に「踏むと爆発する地雷エリア」を設置できるコンポーネント。
 * 2D物理(Collider2D + RigidBody2D)を利用したトリガー判定で爆発を検出します。
 */
@ccclass('MineLayer')
export class MineLayer extends Component {

    // =======================
    // インスペクタ設定項目
    // =======================

    @property({
        tooltip: 'true にすると、自動的に一定間隔で足元に地雷を設置します。',
    })
    public autoPlace: boolean = true;

    @property({
        tooltip: '自動設置モード時の、地雷設置間隔(秒)。autoPlace = true のときのみ有効です。',
        min: 0.1,
    })
    public placeInterval: number = 1.5;

    @property({
        tooltip: '同時に存在できる地雷の最大数。0 以下で無制限。',
        min: 0,
    })
    public maxMines: number = 5;

    @property({
        tooltip: '地雷の設置位置の Y オフセット。負の値でキャラの少し下(足元)に配置されます。',
    })
    public mineOffsetY: number = -20;

    @property({
        tooltip: '地雷の当たり判定(CircleCollider2D)の半径。',
        min: 1,
    })
    public mineRadius: number = 25;

    @property({
        tooltip: '地雷待機中の見た目に使用する SpriteFrame。未設定でも動作しますが、見た目が表示されません。',
        type: SpriteFrame,
    })
    public mineSpriteFrame: SpriteFrame | null = null;

    @property({
        tooltip: '爆発時の見た目に使用する SpriteFrame。未設定の場合は見た目は変化しません。',
        type: SpriteFrame,
    })
    public explosionSpriteFrame: SpriteFrame | null = null;

    @property({
        tooltip: '踏まれなかった場合でも、この秒数経過後に自動で地雷を破棄します。0 以下で時間経過では消えません。',
        min: 0,
    })
    public mineLifetime: number = 5;

    @property({
        tooltip: '爆発状態を維持する時間(秒)。この時間後に地雷ノードを破棄します。',
        min: 0.05,
    })
    public explosionDuration: number = 0.3;

    @property({
        tooltip: 'true にすると、地雷設置・爆発・破棄のログをコンソールに出力します。',
    })
    public debugLog: boolean = false;

    // =======================
    // 内部状態
    // =======================

    private _timer: number = 0;
    private _activeMines: Set<Node> = new Set();

    // =======================
    // ライフサイクル
    // =======================

    onLoad() {
        // 特別な依存はないが、2D物理が有効かの簡易チェックとして PhysicsSystem2D の存在を確認してもよい。
        if (!director.getScene()) {
            console.error('[MineLayer] Scene が存在しません。正しいシーン上のノードにアタッチしてください。');
        }
    }

    start() {
        this._timer = 0;
        if (this.debugLog) {
            console.log('[MineLayer] start: autoPlace =', this.autoPlace);
        }
    }

    update(dt: number) {
        if (!this.autoPlace) {
            return;
        }

        // 最大地雷数に達している場合は新規設置しない
        if (this.maxMines > 0 && this._activeMines.size >= this.maxMines) {
            return;
        }

        this._timer += dt;
        if (this._timer >= this.placeInterval) {
            this._timer = 0;
            this.placeMine();
        }
    }

    // =======================
    // パブリック API
    // =======================

    /**
     * 即座に足元に地雷を1つ設置します。
     * autoPlace が false の場合でも利用できます。
     */
    public placeMine(): void {
        // 最大数制限チェック
        if (this.maxMines > 0 && this._activeMines.size >= this.maxMines) {
            if (this.debugLog) {
                console.log('[MineLayer] placeMine: maxMines に達しているため設置しません');
            }
            return;
        }

        const mineNode = new Node('Mine');
        const parent = this.node.parent ?? this.node.scene;

        if (!parent) {
            console.error('[MineLayer] 親ノードまたはシーンが見つからないため、地雷を設置できません。');
            return;
        }

        parent.addChild(mineNode);

        // 設置位置を計算(自身と同じ X/Z、Y はオフセット)
        const worldPos = this.node.worldPosition.clone();
        worldPos.y += this.mineOffsetY;
        mineNode.setWorldPosition(worldPos);

        // 見た目(Sprite)を追加
        const sprite = mineNode.addComponent(Sprite);
        if (this.mineSpriteFrame) {
            sprite.spriteFrame = this.mineSpriteFrame;
        } else {
            // 見た目が未設定の場合は警告のみ
            console.warn('[MineLayer] mineSpriteFrame が設定されていません。地雷の見た目が表示されません。');
        }
        sprite.color = Color.WHITE;

        // UITransform を設定(スプライトサイズに応じて自動でも良いが、ここでは半径の2倍を基準にする)
        const uiTransform = mineNode.addComponent(UITransform);
        const size = this.mineRadius * 2;
        uiTransform.setContentSize(size, size);

        // 物理ボディ(静的ボディ + センサー)を追加
        const body = mineNode.addComponent(RigidBody2D);
        body.type = ERigidBody2DType.Static;

        const collider = mineNode.addComponent(CircleCollider2D);
        collider.radius = this.mineRadius;
        collider.sensor = true; // センサーにすることで「めり込み」ではなくトリガー判定だけにする

        // コリジョンコールバックを登録
        collider.on(Contact2DType.BEGIN_CONTACT, this._onMineContact, this);

        // 地雷の寿命タイマーを管理するためのメタ情報をノードに保存
        const now = director.getTotalTime() / 1000; // 秒単位
        (mineNode as any)._mineData = {
            createdAt: now,
            exploded: false,
        };

        this._activeMines.add(mineNode);

        if (this.debugLog) {
            console.log('[MineLayer] 地雷を設置しました。activeMines =', this._activeMines.size);
        }

        // 毎フレーム監視する必要はないので、mineLifetime/爆発後の破棄は scheduleOnce で行う
        if (this.mineLifetime > 0) {
            this.scheduleOnce(() => {
                this._tryDestroyMine(mineNode, 'lifetime');
            }, this.mineLifetime);
        }
    }

    // =======================
    // 地雷のコールバック処理
    // =======================

    /**
     * 何かが地雷のセンサーに触れたときに呼ばれる。
     */
    private _onMineContact(
        selfCollider: CircleCollider2D,
        otherCollider: any,
        contact: IPhysics2DContact | null
    ) {
        const mineNode = selfCollider.node;
        const data = (mineNode as any)._mineData as { createdAt: number; exploded: boolean } | undefined;

        if (!data) {
            console.warn('[MineLayer] _onMineContact: mineData が見つかりません。');
            return;
        }

        if (data.exploded) {
            // すでに爆発処理中であれば何もしない
            return;
        }

        data.exploded = true;

        // 見た目を爆発用に切り替え
        const sprite = mineNode.getComponent(Sprite);
        if (this.explosionSpriteFrame && sprite) {
            sprite.spriteFrame = this.explosionSpriteFrame;
        } else {
            // 爆発用スプライトが無い場合は、色を赤くして「爆発っぽさ」を出す
            if (sprite) {
                sprite.color = Color.RED;
            }
        }

        // コリジョンを無効化して、二重で踏まれないようにする
        selfCollider.enabled = false;

        if (this.debugLog) {
            console.log('[MineLayer] 地雷が爆発しました。爆発を検知したコライダー:', otherCollider.node.name);
        }

        // explosionDuration 秒後にノードを破棄
        this.scheduleOnce(() => {
            this._tryDestroyMine(mineNode, 'explosion');
        }, this.explosionDuration);
    }

    /**
     * 地雷ノードを安全に破棄する。
     * すでに破棄されている場合や、アクティブリストに無い場合は何もしない。
     */
    private _tryDestroyMine(mineNode: Node, reason: 'lifetime' | 'explosion') {
        if (!mineNode.isValid) {
            return;
        }

        if (!this._activeMines.has(mineNode)) {
            // すでに別経路で削除済み
            return;
        }

        this._activeMines.delete(mineNode);

        if (this.debugLog) {
            console.log(`[MineLayer] 地雷を破棄します。理由: ${reason} / 残り activeMines =`, this._activeMines.size);
        }

        // コールバック登録を解除(念のため)
        const collider = mineNode.getComponent(CircleCollider2D);
        if (collider) {
            collider.off(Contact2DType.BEGIN_CONTACT, this._onMineContact, this);
        }

        mineNode.destroy();
    }

    // =======================
    // コンポーネント破棄時
    // =======================

    onDestroy() {
        // すべての地雷をクリーンアップ
        this._activeMines.forEach((mine) => {
            if (!mine.isValid) return;
            const collider = mine.getComponent(CircleCollider2D);
            if (collider) {
                collider.off(Contact2DType.BEGIN_CONTACT, this._onMineContact, this);
            }
            mine.destroy();
        });
        this._activeMines.clear();

        if (this.debugLog) {
            console.log('[MineLayer] onDestroy: すべての地雷を破棄しました。');
        }
    }
}

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

  • onLoad
    • 特別な初期化はしていませんが、シーンが存在するかの簡易チェックを行っています。
  • start
    • 内部タイマー _timer をリセットし、デバッグログが有効な場合に現在設定を出力します。
  • update
    • autoPlacetrue の場合のみ動作し、placeInterval ごとに placeMine() を呼び出します。
    • maxMines を超えている場合は新しい地雷を設置しません。
  • placeMine()
    • 足元の位置(mineOffsetY)に新しいノードを生成し、SpriteUITransformRigidBody2DCircleCollider2D を追加します。
    • CircleCollider2D.sensor = true として「トリガー判定のみ」にしています。
    • Contact2DType.BEGIN_CONTACT_onMineContact を登録し、誰かが踏んだ瞬間に爆発処理を行います。
    • mineLifetime > 0 の場合は scheduleOnce で寿命タイマーを設定し、時間経過で自動破棄します。
  • _onMineContact()
    • 爆発済みでないことを確認した上で、explosionSpriteFrame による見た目の切り替え、または色変更を行います。
    • コライダーを無効化して二重判定を防ぎます。
    • explosionDuration 後に _tryDestroyMine() を呼び出し、地雷ノードを破棄します。
  • _tryDestroyMine()
    • 内部の _activeMines 管理用 Set からノードを削除し、コールバックを解除した上で destroy() します。
  • onDestroy()
    • MineLayer コンポーネントが削除された際に、残っているすべての地雷ノードをクリーンアップします。

使用手順と動作確認

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

  1. エディタの Assets パネルで、地雷用スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. ファイル名を MineLayer.ts に変更します。
  4. 作成された MineLayer.ts をダブルクリックして開き、上記のコード全文を貼り付けて保存します。

2. テスト用シーンとノードの準備

  1. 2Dプロジェクト であることを確認します(2D物理を使うため)。
  2. Hierarchy パネルで右クリック → CreateUICanvas を作成(すでにある場合は不要)。
  3. Canvas の子として、テスト用のキャラノードを作成します。
    • Canvas を右クリック → CreateUISprite
    • 名前を Player などに変更します。
  4. Inspector で Player ノードを選択し、Add ComponentCustomMineLayer を選択してアタッチします。

3. プロパティの設定

Player ノードにアタッチされた MineLayer コンポーネントを選択し、Inspector で以下のように設定してみてください。

  • Auto Place: チェックを入れる(true)。
  • Place Interval: 1.0(1秒ごとに地雷を設置)。
  • Max Mines: 5(同時に5個まで)。
  • Mine Offset Y: -30(Player の少し下に設置)。
  • Mine Radius: 25
  • Mine Sprite Frame:
    • 事前に地雷用の画像(例: mine.png)を Assets にインポートしておきます。
    • その画像から作成された SpriteFrame をドラッグ&ドロップでここに設定します。
  • Explosion Sprite Frame:
    • 爆発用の画像(例: explosion.png)を SpriteFrame として用意し、同様に設定します。
  • Mine Lifetime: 5(5秒経っても踏まれなかったら自動で消える)。
  • Explosion Duration: 0.3(0.3秒だけ爆発表示)。
  • Debug Log: 必要に応じてチェック(true にするとログが出ます)。

4. 2D物理の有効化

このコンポーネントは 2D物理エンジン を利用するため、プロジェクト設定で Physics2D を有効にしておきます。

  1. メニューから ProjectProject Settings を開きます。
  2. 左側のカテゴリから Physics2D を選択します。
  3. 「Enable」や「Use 2D Physics」などの有効化チェックがあればオンにします(バージョンにより表記が多少異なります)。
  4. 重力などは今回は特に重要ではありませんが、デフォルトのままで問題ありません。

5. 動作確認

  1. シーンを保存します(Ctrl+S / Cmd+S)。
  2. Play ボタンを押してゲームを実行します。
  3. ゲーム開始後、Player ノードの足元に 1秒ごとに地雷が生成されていくのが確認できます。
  4. 地雷は 2D 物理のトリガー判定で「何かが重なった瞬間」に爆発します。
    • テスト用に、別のノードに RigidBody2D + Collider2D を付けて動かすと、踏んだ瞬間に爆発する様子が確認できます。
  5. Debug Log をオンにしている場合は、コンソールに
    • [MineLayer] 地雷を設置しました。
    • [MineLayer] 地雷が爆発しました。
    • [MineLayer] 地雷を破棄します。理由: explosion

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

6. 手動設置モードを試す

自動設置ではなく、任意のタイミングで地雷を設置したい場合は以下のようにします。

  1. MineLayer コンポーネントの Auto Place のチェックを外し、false にします。
  2. 別のスクリプト(例: プレイヤー操作スクリプト)から、以下のように呼び出します。
    
    const mineLayer = this.node.getComponent(MineLayer);
    if (mineLayer) {
        mineLayer.placeMine();
    }
    
  3. 例えば「スペースキーを押したら地雷を設置する」といった操作に簡単に対応できます。

まとめ

この MineLayer コンポーネントは、任意のノードにアタッチするだけで
「足元に踏むと爆発するエリアを設置する」挙動を完結させる、再利用性の高いスクリプトです。

  • 外部の GameManager や専用シングルトンに依存せず、この1ファイルだけで動作します。
  • 地雷の見た目・当たり判定・寿命・爆発時間・自動設置間隔・最大数などをすべて Inspector から調整可能にしているため、ゲームごとにバランスを取りやすくなっています。
  • 2D物理のトリガー判定を利用しているので、「誰が踏んだか」をタグや別スクリプトに頼らずに実装でき、シンプルな仕組みで動作します。

このコンポーネントをベースに、

  • 爆発時にダメージを与える(爆心地からの距離でダメージ計算)
  • アニメーションやパーティクルを再生する
  • 特定のレイヤー(プレイヤーだけ、敵だけ)にのみ反応する

といった機能を拡張していけば、さまざまなアクションゲームの地雷ギミックを簡単に実現できます。
まずは本記事の MineLayer をそのまま導入し、足元に地雷が並ぶ様子と爆発挙動を確認してみてください。