【Cocos Creator 3.8】GroundPound(ヒップドロップ)の実装:アタッチするだけで「空中で急降下&着地衝撃波ダメージ」を実現する汎用スクリプト

この記事では、任意のキャラクターノードにアタッチするだけで

  • 空中で「下入力」した瞬間に急降下(ヒップドロップ)
  • 地面に着地した瞬間に周囲へ衝撃波判定(攻撃)

を行える汎用コンポーネント GroundPound を実装します。

このコンポーネントは、

  • 2Dアクションゲームのプレイヤーキャラ
  • ボス敵のジャンプ攻撃
  • ギミック用の落下オブジェクト

などに、そのままアタッチして使えるように設計します。外部の GameManager やカスタムシングルトンには一切依存せず、すべてインスペクタの設定だけで完結します。


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

1. 前提と想定環境

  • 使用エンジン: Cocos Creator 3.8.7
  • 言語: TypeScript
  • 物理挙動: 2D物理 (RigidBody2D) を前提
  • 接地判定: Collider2DPhysicsSystem2D の接触イベントを使用

GroundPound は、以下の標準コンポーネントがノードに付いていることを前提に動作します。

  • RigidBody2D(必須:落下速度の制御に使用)
  • Collider2D(任意:接地判定に使用。別ノードの足元コライダーでも可)

これらが存在しない場合、コンポーネント内で getComponent を試み、見つからなければエラーログを出して機能を無効化します。

2. GroundPound の機能要件

  1. 入力による発動
    • 空中にいるときに「下入力」が押されたらヒップドロップ開始。
    • 入力は KeyCode.ARROW_DOWN と、任意で S キーに対応。
    • モバイルなどキーボードがない場合に備え、triggerGroundPound() メソッドを公開し、
      UI ボタンなどからも呼べるようにする。
  2. 急降下挙動
    • 発動した瞬間に、RigidBody2D の垂直速度を強制的に下向きへ設定。
    • 落下中は isGroundPounding 状態フラグを維持。
  3. 着地検知
    • 2D物理の接触イベント(onBeginContact)で「地面」と衝突した瞬間を検知。
    • 「地面」の判定方法は、インスペクタで指定する
      groundGroup(Physics2D グループ)に所属しているかどうかで判断。
  4. 衝撃波判定
    • 着地した瞬間に、ノードの周囲に円形の攻撃範囲(衝撃波)を生成。
    • 衝撃波は目に見えるエフェクトではなく、「当たった対象に通知するための一時的な判定」として実装。
    • 衝撃波に触れた対象の onGroundPoundHit(power: number) というメソッドを自動的に呼び出す。
      (対象側がこのメソッドを実装していればダメージ処理などが可能)
    • 衝撃波は shockwaveDuration 秒後に自動で消滅。
  5. 再発動制御
    • 一度着地して衝撃波を出したら、再びジャンプして空中にいる状態になるまで発動できない。

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

GroundPound コンポーネントのプロパティと役割は以下の通りです。

  • enableKeyboardInput: boolean
    • デフォルト: true
    • 説明: キーボードの下キー(↓ または S)でヒップドロップを発動できるかどうか。
    • UI ボタンなどから triggerGroundPound() を呼ぶだけで使いたい場合は false にする。
  • downKeys: KeyCode[]
    • デフォルト: [KeyCode.ARROW_DOWN, KeyCode.KEY_S]
    • 説明: ヒップドロップ発動に使うキーの一覧。
  • fallVelocity: number
    • デフォルト: -2000
    • 説明: ヒップドロップ発動時に設定する垂直速度(マイナスが下向き)。絶対値を大きくすると急降下が速くなる。
  • minFallTime: number
    • デフォルト: 0.05
    • 説明: 発動から着地と判定するまでの最小経過時間。あまりにすぐ地面に触れた場合(例えば壁ずりなど)に誤検知を防ぐ。
  • shockwaveRadius: number
    • デフォルト: 3(物理世界座標の単位)
    • 説明: 着地時に生成する衝撃波の半径。この範囲内の Collider2D へ攻撃判定が届く。
  • shockwaveDuration: number
    • デフォルト: 0.1
    • 説明: 衝撃波コライダーが存在する時間。短くするほど「一瞬だけ当たり判定が出る」イメージになる。
  • shockwavePower: number
    • デフォルト: 1
    • 説明: 衝撃波の強さ。触れた相手の onGroundPoundHit(power) に渡される値。
  • shockwaveGroup: number
    • デフォルト: 0(Default グループを想定)
    • 説明: 衝撃波コライダーが所属する Physics2D グループ ID。敵専用グループなどに分けたい場合に変更。
  • groundGroup: number
    • デフォルト: 1(例: Ground グループ)
    • 説明: 「地面」とみなす Physics2D グループ ID。このグループに属するコライダーと接触したときに「着地」と判定する。
  • debugLog: boolean
    • デフォルト: false
    • 説明: true にすると、発動・着地・衝撃波ヒットなどのログを Console に詳細表示する。

これらのプロパティだけで、他のスクリプトに依存せず、GroundPound 単体で完結した挙動を行います。


TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    RigidBody2D,
    Collider2D,
    Contact2DType,
    IPhysics2DContact,
    PhysicsSystem2D,
    CircleCollider2D,
    Vec2,
    KeyCode,
    input,
    Input,
    EventKeyboard,
    sys,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * GroundPound
 * 空中で下入力すると急降下し、着地時に周囲へ一時的な衝撃波コライダーを生成して
 * 当たった対象の onGroundPoundHit(power: number) を呼び出す汎用コンポーネント。
 */
@ccclass('GroundPound')
export class GroundPound extends Component {

    @property({
        tooltip: 'キーボード入力でヒップドロップを発動できるかどうか。\n' +
            'false にすると、triggerGroundPound() を外部(UI ボタンなど)から呼び出して発動させます。',
    })
    public enableKeyboardInput: boolean = true;

    @property({
        type: [KeyCode],
        tooltip: 'ヒップドロップ発動に使用するキーの一覧。\n' +
            'デフォルトは ↓ キー と S キー。',
    })
    public downKeys: KeyCode[] = [KeyCode.ARROW_DOWN, KeyCode.KEY_S];

    @property({
        tooltip: 'ヒップドロップ発動時に設定する垂直速度(マイナスが下向き)。\n' +
            '絶対値を大きくすると急降下が速くなります。',
    })
    public fallVelocity: number = -2000;

    @property({
        tooltip: '発動から着地と判定するまでの最小時間(秒)。\n' +
            'あまりにすぐ接触した場合の誤検知を防ぐために使用します。',
        min: 0,
    })
    public minFallTime: number = 0.05;

    @property({
        tooltip: '着地時に生成する衝撃波の半径(物理世界座標の単位)。\n' +
            'この範囲内の Collider2D に攻撃判定が届きます。',
        min: 0.1,
    })
    public shockwaveRadius: number = 3;

    @property({
        tooltip: '衝撃波コライダーが存在する時間(秒)。\n' +
            '短いほど「一瞬だけ当たり判定が出る」イメージになります。',
        min: 0.01,
    })
    public shockwaveDuration: number = 0.1;

    @property({
        tooltip: '衝撃波の強さ。触れた相手の onGroundPoundHit(power) に渡される値です。',
    })
    public shockwavePower: number = 1;

    @property({
        tooltip: '衝撃波コライダーが所属する Physics2D グループ ID。\n' +
            '敵専用グループなどに分けたい場合に変更します。',
    })
    public shockwaveGroup: number = 0;

    @property({
        tooltip: '「地面」とみなす Physics2D グループ ID。\n' +
            'このグループに属するコライダーと接触したときに着地と判定します。',
    })
    public groundGroup: number = 1;

    @property({
        tooltip: 'デバッグ用ログを有効にします。true にすると発動・着地・ヒットなどを Console に表示します。',
    })
    public debugLog: boolean = false;

    // 内部状態
    private _rigidBody: RigidBody2D | null = null;
    private _collider: Collider2D | null = null;

    private _isGroundPounding: boolean = false;
    private _canGroundPound: boolean = true;
    private _isGrounded: boolean = false;
    private _fallElapsed: number = 0;

    onLoad() {
        // 物理コンポーネントの取得
        this._rigidBody = this.getComponent(RigidBody2D);
        if (!this._rigidBody) {
            console.error('[GroundPound] RigidBody2D がアタッチされていません。' +
                'ヒップドロップを使用するノードには RigidBody2D を追加してください。');
        }

        this._collider = this.getComponent(Collider2D);
        if (!this._collider) {
            console.warn('[GroundPound] Collider2D がアタッチされていません。' +
                '接地判定は別ノードのコライダーで行っている場合は問題ありませんが、\n' +
                'このノード自身で接地判定を行いたい場合は Collider2D を追加してください。');
        }

        // キーボード入力の登録
        if (this.enableKeyboardInput && sys.isBrowser) {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        }

        // コライダーイベント登録(このノードに Collider2D がある場合のみ)
        if (this._collider) {
            this._collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            this._collider.on(Contact2DType.END_CONTACT, this._onEndContact, this);
        }
    }

    start() {
        // 初期接地状態の推定(必要なら)
        this._isGrounded = false;
    }

    onDestroy() {
        if (this.enableKeyboardInput && sys.isBrowser) {
            input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        }

        if (this._collider) {
            this._collider.off(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this);
            this._collider.off(Contact2DType.END_CONTACT, this._onEndContact, this);
        }
    }

    update(deltaTime: number) {
        // 落下中の経過時間を計測
        if (this._isGroundPounding) {
            this._fallElapsed += deltaTime;
        }

        // 空中状態の簡易判定:接地していなければ「空中」
        if (!this._isGrounded) {
            this._canGroundPound = true;
        }
    }

    /**
     * 外部(UI ボタンなど)から呼び出してヒップドロップを発動させるためのメソッド。
     */
    public triggerGroundPound(): void {
        if (!this._rigidBody) {
            console.error('[GroundPound] RigidBody2D がないためヒップドロップを開始できません。');
            return;
        }

        // すでに発動中、または地面にいる場合は無視
        if (this._isGroundPounding || this._isGrounded || !this._canGroundPound) {
            if (this.debugLog) {
                console.log('[GroundPound] 発動条件を満たしていないため無視されました。');
            }
            return;
        }

        this._isGroundPounding = true;
        this._fallElapsed = 0;

        // 垂直速度を強制的に下方向へ
        const v = this._rigidBody.linearVelocity;
        v.y = this.fallVelocity;
        this._rigidBody.linearVelocity = v;

        if (this.debugLog) {
            console.log('[GroundPound] ヒップドロップ開始。fallVelocity =', this.fallVelocity);
        }
    }

    // キーボード入力ハンドラ
    private _onKeyDown(event: EventKeyboard) {
        if (!this.enableKeyboardInput) {
            return;
        }
        const keyCode = event.keyCode;
        if (this.downKeys.indexOf(keyCode) !== -1) {
            this.triggerGroundPound();
        }
    }

    // 接触開始イベント
    private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        // 地面グループとの接触かどうか
        if (otherCollider.group === this.groundGroup) {
            this._isGrounded = true;

            // ヒップドロップ中で、かつ最低落下時間を満たしていれば着地と判定
            if (this._isGroundPounding && this._fallElapsed >= this.minFallTime) {
                if (this.debugLog) {
                    console.log('[GroundPound] 着地検知。衝撃波を生成します。');
                }
                this._createShockwave();
            }

            // 状態リセット
            this._isGroundPounding = false;
            this._canGroundPound = false;
        }
    }

    // 接触終了イベント
    private _onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        if (otherCollider.group === this.groundGroup) {
            // 地面から離れたので空中状態
            this._isGrounded = false;
            if (this.debugLog) {
                console.log('[GroundPound] 地面から離れました。');
            }
        }
    }

    /**
     * 衝撃波コライダーを一時的に生成し、周囲の Collider2D にヒット通知を行う。
     */
    private _createShockwave(): void {
        const worldPos = this.node.worldPosition;

        // PhysicsSystem2D のオーバーラップクエリを使って、円範囲内のコライダーを取得
        const hits: Collider2D[] = [];
        const physics = PhysicsSystem2D.instance;

        if (!physics) {
            console.error('[GroundPound] PhysicsSystem2D が利用できません。2D 物理を有効にしてください。');
            return;
        }

        // 円形クエリ
        const center = new Vec2(worldPos.x, worldPos.y);
        physics.testAABB(center, new Vec2(center.x, center.y)); // ダミー呼び出し(エディタでの初期化対策用)

        physics.testShape(
            {
                type: 'circle',
                position: center,
                radius: this.shockwaveRadius,
                group: this.shockwaveGroup,
            } as any,
            (collider: Collider2D) => {
                hits.push(collider);
                return true;
            }
        );

        if (this.debugLog) {
            console.log('[GroundPound] 衝撃波ヒット数:', hits.length);
        }

        // ヒットしたコライダーに対して onGroundPoundHit を呼び出す
        for (const col of hits) {
            const targetNode = col.node;
            const anyCompList = targetNode.getComponents(Component);

            for (const comp of anyCompList) {
                const anyComp = comp as any;
                if (typeof anyComp.onGroundPoundHit === 'function') {
                    try {
                        anyComp.onGroundPoundHit(this.shockwavePower);
                        if (this.debugLog) {
                            console.log('[GroundPound] onGroundPoundHit を呼び出しました: node =',
                                targetNode.name, 'power =', this.shockwavePower);
                        }
                    } catch (e) {
                        console.error('[GroundPound] onGroundPoundHit 呼び出し中にエラーが発生しました:', e);
                    }
                }
            }
        }

        // 衝撃波用の見えない一時コライダーを本当に生成しておきたい場合は、
        // 下記のように CircleCollider2D を生成して一定時間後に破棄することもできます。
        // ただし、上記 testShape によるクエリだけで十分なケースが多いため、
        // 実際のゲームではどちらか一方を採用してください。

        // this._spawnTemporaryShockwaveCollider();
    }

    /**
     * (オプション)実際に一時的な CircleCollider2D ノードを生成するサンプル。
     * デフォルトでは使用していません。必要な場合は _createShockwave から呼び出してください。
     */
    private _spawnTemporaryShockwaveCollider(): void {
        const shockwaveNode = new Node('GroundPoundShockwave');
        shockwaveNode.setWorldPosition(this.node.worldPosition);

        const collider = shockwaveNode.addComponent(CircleCollider2D);
        collider.radius = this.shockwaveRadius;
        collider.group = this.shockwaveGroup;
        collider.sensor = true; // センサーにして当たり判定のみ

        // 衝撃波が他のオブジェクトに触れたときの処理
        collider.on(Contact2DType.BEGIN_CONTACT, (self: Collider2D, other: Collider2D) => {
            const targetNode = other.node;
            const comps = targetNode.getComponents(Component);
            for (const comp of comps) {
                const anyComp = comp as any;
                if (typeof anyComp.onGroundPoundHit === 'function') {
                    anyComp.onGroundPoundHit(this.shockwavePower);
                    if (this.debugLog) {
                        console.log('[GroundPound] (temp collider) onGroundPoundHit 呼び出し:', targetNode.name);
                    }
                }
            }
        });

        this.node.scene?.addChild(shockwaveNode);

        // 一定時間後に自動削除
        this.scheduleOnce(() => {
            shockwaveNode.destroy();
        }, this.shockwaveDuration);
    }
}

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

  • onLoad
    • RigidBody2DCollider2DgetComponent で取得し、防御的に存在チェック。
    • キーボード入力(input.on(KEY_DOWN))や、コライダーの接触イベントを登録。
  • update
    • ヒップドロップ中の経過時間を計測(_fallElapsed)。
    • 接地していない間は _canGroundPound を true にして、空中なら再発動可能にする。
  • triggerGroundPound()
    • 外部公開メソッド。UI ボタンなどからも呼び出せる。
    • 地面にいる/すでに発動中/_canGroundPound が false の場合は無視。
    • RigidBody2D.linearVelocity.yfallVelocity に強制設定して急降下開始。
  • _onBeginContact / _onEndContact
    • otherCollider.group === groundGroup を条件に「地面」との接触を判定。
    • ヒップドロップ中かつ minFallTime を超えていれば _createShockwave() を呼び出し。
    • 着地後は _isGroundPounding を false、_canGroundPound を false にして、再び空中に出るまで再発動不可。
  • _createShockwave()
    • PhysicsSystem2DtestShape(円クエリ)を使って、shockwaveRadius 内の Collider2D を収集。
    • ヒットしたノード上の全コンポーネントを走査し、onGroundPoundHit(power) メソッドを持っていれば呼び出す。
    • これにより、攻撃対象側は「GroundPound に依存せず」、任意のスクリプトに onGroundPoundHit を実装するだけで連携できる。
  • _spawnTemporaryShockwaveCollider()(オプション)
    • 実際に CircleCollider2D を持つ一時ノードを生成し、一定時間だけ物理世界に存在させる例。
    • 上記 _createShockwave のクエリ方式とどちらか一方を採用する設計を推奨。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択。
  2. ファイル名を GroundPound.ts にします。
  3. 自動生成された中身をすべて削除し、本記事の GroundPound コードを貼り付けて保存します。

2. テスト用ノードの作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択し、プレイヤー用ノードを作成します。
    • 名前は例として Player とします。
  2. Player ノードを選択し、Inspector で以下のコンポーネントを追加します。
    1. Add Component → Physics 2D → RigidBody2D
      • Type: Dynamic
      • 重力スケールなどは必要に応じて調整。
    2. Add Component → Physics 2D → BoxCollider2D(または CircleCollider2D)
      • キャラクターの当たり判定サイズに合わせて調整してください。
  3. 同じく Inspector で Add Component → Custom → GroundPound を選択し、Player にアタッチします。

3. 地面(Ground)ノードの用意

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、地面用ノードを作成します。
    • 名前は例として Ground とします。
  2. Ground ノードの Transform を調整して、画面下部に横長の足場を配置します。
  3. Ground ノードに以下のコンポーネントを追加します。
    1. Add Component → Physics 2D → RigidBody2D
      • Type: Static(静的にして動かない床にします)
    2. Add Component → Physics 2D → BoxCollider2D
      • 床のサイズに合わせてコライダーを調整します。
  4. Physics 2D のグループ設定で、GroundGround グループ(例: ID=1)に設定します。
    • メニューの Project → Project Settings → Physics 2D → Group からグループ名と ID を確認・設定できます。

4. GroundPound プロパティの設定

Player ノードを選択し、Inspector の GroundPound セクションを以下のように設定してみます。

  • Enable Keyboard Input: true
  • Down Keys: デフォルトのまま(ARROW_DOWN, S
  • Fall Velocity: -2000(急降下が速すぎる場合は -1200 などに下げる)
  • Min Fall Time: 0.05
  • Shockwave Radius: 3
  • Shockwave Duration: 0.1
  • Shockwave Power: 1
  • Shockwave Group: 0(Default グループ)
  • Ground Group: 1(Ground グループの ID に合わせる)
  • Debug Log: 動作確認中は true にしておくとログが見やすいです。

5. 衝撃波のヒット確認用ターゲットノード

衝撃波が当たったときに何か起こることを確認するため、簡単なターゲットノードを用意します。

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、敵用ノードを作成します。
    • 名前は例として DummyEnemy とします。
  2. DummyEnemyRigidBody2D(Dynamic)BoxCollider2D を追加します。
  3. Assets で Create → TypeScript を選び、DummyEnemy.ts を作成し、次のような簡単なスクリプトを貼り付けます。

import { _decorator, Component, Node, Color, Sprite } from 'cc';
const { ccclass, property } = _decorator;

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

    @property({ tooltip: 'ヒット時にログを表示するかどうか。' })
    public logOnHit: boolean = true;

    private _sprite: Sprite | null = null;

    onLoad() {
        this._sprite = this.getComponent(Sprite);
    }

    // GroundPound から呼ばれる想定のメソッド
    public onGroundPoundHit(power: number) {
        if (this.logOnHit) {
            console.log('[DummyEnemy] GroundPound にヒットしました。power =', power);
        }

        // 視覚的に分かりやすくするため、ヒット時に色を変える
        if (this._sprite) {
            this._sprite.color = new Color(255, 100, 100, 255);
        }
    }
}
  1. DummyEnemy ノードの Inspector で Add Component → Custom → DummyEnemy を追加します。
  2. DummyEnemy を地面の上に配置し、プレイヤーがヒップドロップしたときに衝撃波範囲に入るように位置を調整します。

6. 実行してテスト

  1. シーンを保存し、Preview(▶) ボタンでゲームを実行します。
  2. プレイヤーが画面上部にいて、重力で落下するのを確認します。
  3. 空中にいる間に ↓ キー または S キー を押すと、急に落下速度が上がり、ヒップドロップ状態になります。
  4. 地面に着地した瞬間に、コンソールに
    • [GroundPound] 着地検知。衝撃波を生成します。
    • 衝撃波が DummyEnemy に届いていれば、[DummyEnemy] GroundPound にヒットしました。power = 1

    といったログが表示され、DummyEnemy の色が変わります。

  5. 再びジャンプして空中に出るまではヒップドロップが再発動しないことも確認してください。

もし着地しても衝撃波が出ない場合は、次の点を確認してください。

  • Ground ノードの Physics2D グループ ID が GroundPound の Ground Group と一致しているか。
  • プレイヤーに RigidBody2D が正しくアタッチされているか。
  • プレイヤーの Collider2D が地面のコライダーと実際に接触しているか(位置関係)。

まとめ

本記事では、Cocos Creator 3.8 / TypeScript で、

  • 空中で下入力 → 急降下(ヒップドロップ)
  • 着地時に周囲へ衝撃波判定 → 当たった対象の onGroundPoundHit を自動呼び出し

という一連の挙動を、GroundPound コンポーネント単体で完結させる実装を行いました。

ポイントは次の通りです。

  • 必要な設定はすべてインスペクタの @property から行い、外部の GameManager やシングルトンに依存しない。
  • 接地判定は Physics2D のグループ ID によるフィルタリングで柔軟に制御。
  • 衝撃波のダメージ処理は「相手が onGroundPoundHit を実装していれば呼ぶ」という疎結合な設計にすることで、
    GroundPound 側はあくまで汎用的なイベント発行役に徹する。

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

  • 発動時・着地時にパーティクルエフェクトを再生
  • カメラシェイクを追加
  • 衝撃波の見た目用スプライトを表示

といった演出を重ねていけば、2Dアクションゲームのヒップドロップ演出を簡単かつ再利用性高く構築できます。

プロジェクトごとに GroundPound のパラメータだけを調整すれば、

  • 軽い小ジャンプ攻撃
  • 画面全体を揺らすボスの大ジャンプ攻撃

といったバリエーションも簡単に実現できるため、ぜひ汎用コンポーネントとして活用してみてください。