【Cocos Creator 3.8】BossPhase(形態変化)の実装:アタッチするだけで「HPが半分を切ったら見た目・当たり判定・攻撃パターンを切り替える」汎用スクリプト

ボス戦で「HPが減ると第2形態に変身する」演出はよく使われますが、毎回ロジックを書くのは面倒です。この記事では、任意のボスノードにアタッチするだけで

  • HPの管理(現在値と最大値)
  • HPがしきい値を下回ったときの一度きりの形態変化
  • 形態ごとの見た目(SpriteFrame)やコリジョンサイズ、攻撃パターン用ノードの有効/無効切り替え

をまとめて制御できる BossPhase コンポーネントを実装します。外部のGameManagerやシングルトンに一切依存せず、インスペクタの設定だけで完結するように設計します。


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

このコンポーネントの目的は、

  • HPのしきい値(例:最大HPの50%)を下回った瞬間に、一度だけボスの形態を切り替えること
  • 形態ごとに「見た目・当たり判定・攻撃パターン用ノード」をインスペクタから簡単に切り替えられること
  • 他のスクリプトに依存せず、このコンポーネント単体で「HP管理+形態変化」が完結していること

外部依存をなくすため、以下のような方針で設計します。

  • HPの操作(ダメージ・回復)は パブリックメソッドapplyDamage, heal 等)として提供し、他のスクリプトから呼び出せるようにするが、それらのスクリプトがなくても、インスペクタで初期HPを設定しておけばエディタ上のテストも可能。
  • 形態ごとの「見た目」「当たり判定」「攻撃パターン」は、すべてインスペクタのプロパティ経由で差し替えできるようにする。
  • Sprite や Collider などの標準コンポーネントは、getComponent で取得を試み、見つからなければ console.error で警告を出す防御的実装にする。

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

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

  • 最大HP(maxHp: number)
    • 初期の最大HP。
    • ゲーム開始時の現在HPはこの値で初期化される。
  • 現在HPの初期値(startHp: number)
    • ゲーム開始時の現在HP。
    • 0 <= startHp <= maxHp でなければ、自動的に maxHp に補正する。
  • 形態変化のしきい値タイプ(useRatioThreshold: boolean)
    • true: 最大HPに対する割合でしきい値を設定(例:0.5 = HPが50%を下回ったら変化)。
    • false: 絶対値(HPの数値)でしきい値を設定。
  • しきい値(thresholdRatio: number / thresholdHp: number)
    • useRatioThreshold = true のとき:最大HPに対する割合(0〜1)。例:0.5。
    • useRatioThreshold = false のとき:絶対HP値。例:50。
  • 現在形態のインデックス(currentPhaseIndex: number)
    • 0: 第1形態、1: 第2形態。
    • エディタ上でのプレビュー用に、手動で切り替えられる(ゲーム開始時にこの値が適用される)。
  • 第1形態の SpriteFrame(phase1Sprite: SpriteFrame)
    • ボスの第1形態の見た目。
    • アタッチ先ノードに Sprite コンポーネントがある場合にのみ使用。
  • 第2形態の SpriteFrame(phase2Sprite: SpriteFrame)
    • ボスの第2形態の見た目。
  • 第1形態の当たり判定サイズ(phase1ColliderSize: Vec2)
    • BoxCollider2D を想定したサイズ(幅・高さ)。
    • アタッチ先ノードに BoxCollider2D がある場合にのみ適用。
  • 第2形態の当たり判定サイズ(phase2ColliderSize: Vec2)
    • 同上、第2形態用。
  • 第1形態で有効にする攻撃ノード(phase1AttackNodes: Node[])
    • 第1形態のとき active = true にするノード群。
    • 第2形態のときは active = false にされる。
    • 弾発射パターンや近接攻撃用の子ノードなどを登録する想定。
  • 第2形態で有効にする攻撃ノード(phase2AttackNodes: Node[])
    • 第2形態のとき active = true にするノード群。
    • 第1形態のときは active = false にされる。
  • 死亡時に自動でノードを非アクティブにする(deactivateOnDeath: boolean)
    • HPが0以下になったとき、アタッチ先ノードの activefalse にするかどうか。
  • ログ出力の有効/無効(enableDebugLog: boolean)
    • 形態変化やダメージ適用時に console.log を出すかどうか。
    • 開発中のデバッグ用。リリース時はオフにできる。

これらを使って、「HPがしきい値を下回った瞬間に、第2形態に切り替えて、その後は戻らない」 という一方通行の形態変化を実現します。


TypeScriptコードの実装


import { _decorator, Component, Node, Sprite, SpriteFrame, BoxCollider2D, Vec2, CCFloat, CCInteger } from 'cc';
const { ccclass, property } = _decorator;

/**
 * BossPhase
 * - HPを管理し、しきい値を下回ったときに第2形態へ一度だけ変化させるコンポーネント。
 * - 他のスクリプトに依存せず、インスペクタから見た目や当たり判定、攻撃ノードを設定して使える。
 */
@ccclass('BossPhase')
export class BossPhase extends Component {

    // === HP関連 ===
    @property({
        type: CCFloat,
        tooltip: '最大HP。ゲーム開始時に currentHp をこの値で初期化します。'
    })
    public maxHp: number = 100;

    @property({
        type: CCFloat,
        tooltip: '開始時の現在HP。0〜maxHp の範囲外の場合は maxHp で補正されます。'
    })
    public startHp: number = 100;

    @property({
        tooltip: 'true: 最大HPに対する割合でしきい値を指定します(0〜1)。false: 絶対HP値で指定します。'
    })
    public useRatioThreshold: boolean = true;

    @property({
        type: CCFloat,
        tooltip: 'useRatioThreshold が true のとき有効。HPが maxHp * thresholdRatio を下回ったら第2形態に変化します(例: 0.5)。'
    })
    public thresholdRatio: number = 0.5;

    @property({
        type: CCFloat,
        tooltip: 'useRatioThreshold が false のとき有効。HPが thresholdHp を下回ったら第2形態に変化します。'
    })
    public thresholdHp: number = 50;

    @property({
        type: CCInteger,
        tooltip: '現在の形態インデックス。0 = 第1形態, 1 = 第2形態。エディタ上のプレビュー用としても利用できます。'
    })
    public currentPhaseIndex: number = 0;

    @property({
        tooltip: 'HPが0以下になったとき、このノードを自動的に非アクティブにします。'
    })
    public deactivateOnDeath: boolean = true;

    @property({
        tooltip: 'ダメージ適用・形態変化などのログをコンソールに出力します。'
    })
    public enableDebugLog: boolean = true;

    // === 見た目(Sprite)関連 ===
    @property({
        type: SpriteFrame,
        tooltip: '第1形態のSpriteFrame。アタッチ先ノードにSpriteコンポーネントがある場合に使用されます。'
    })
    public phase1Sprite: SpriteFrame | null = null;

    @property({
        type: SpriteFrame,
        tooltip: '第2形態のSpriteFrame。アタッチ先ノードにSpriteコンポーネントがある場合に使用されます。'
    })
    public phase2Sprite: SpriteFrame | null = null;

    // === 当たり判定(BoxCollider2D)関連 ===
    @property({
        type: Vec2,
        tooltip: '第1形態のBoxCollider2Dサイズ(幅, 高さ)。BoxCollider2Dがある場合にのみ適用されます。'
    })
    public phase1ColliderSize: Vec2 = new Vec2(100, 100);

    @property({
        type: Vec2,
        tooltip: '第2形態のBoxCollider2Dサイズ(幅, 高さ)。BoxCollider2Dがある場合にのみ適用されます。'
    })
    public phase2ColliderSize: Vec2 = new Vec2(150, 150);

    // === 攻撃パターン用ノードの有効/無効切り替え ===
    @property({
        type: [Node],
        tooltip: '第1形態で有効にする攻撃パターン用ノード群。第2形態では自動的に非アクティブになります。'
    })
    public phase1AttackNodes: Node[] = [];

    @property({
        type: [Node],
        tooltip: '第2形態で有効にする攻撃パターン用ノード群。第1形態では自動的に非アクティブになります。'
    })
    public phase2AttackNodes: Node[] = [];

    // === 内部状態 ===
    private _currentHp: number = 0;
    private _hasPhaseChanged: boolean = false;
    private _sprite: Sprite | null = null;
    private _collider: BoxCollider2D | null = null;

    /**
     * 現在HP(読み取り専用プロパティ)
     */
    public get currentHp(): number {
        return this._currentHp;
    }

    onLoad() {
        // 必要なコンポーネントを取得
        this._sprite = this.getComponent(Sprite);
        if (!this._sprite) {
            console.warn('[BossPhase] Sprite コンポーネントが見つかりません。見た目の切り替えは行われません。ノードに Sprite を追加すると有効になります。');
        }

        this._collider = this.getComponent(BoxCollider2D);
        if (!this._collider) {
            console.warn('[BossPhase] BoxCollider2D コンポーネントが見つかりません。コリジョンサイズの切り替えは行われません。ノードに BoxCollider2D を追加すると有効になります。');
        }

        // HP初期化
        if (this.maxHp <= 0) {
            console.error('[BossPhase] maxHp が 0 以下です。1 に補正します。');
            this.maxHp = 1;
        }

        // startHp の範囲補正
        if (this.startHp <= 0 || this.startHp > this.maxHp) {
            if (this.enableDebugLog) {
                console.log(`[BossPhase] startHp(${this.startHp}) が 0〜maxHp(${this.maxHp}) の範囲外のため、maxHp に補正します。`);
            }
            this.startHp = this.maxHp;
        }
        this._currentHp = this.startHp;

        // 形態変化フラグ初期化
        this._hasPhaseChanged = (this.currentPhaseIndex >= 1);

        // 開始時点の形態を適用
        this.applyPhaseVisuals(this.currentPhaseIndex);
    }

    start() {
        // start では特に処理は行わないが、将来的な拡張を考慮してメソッドを残しておく
    }

    update(deltaTime: number) {
        // このコンポーネントでは update 内での自動ダメージ処理は行わない。
        // HPの変化は外部から applyDamage / heal を呼んでもらう想定。
        // ただし、エディタ上で currentPhaseIndex を変更したときの反映などを行いたい場合はここでハンドリングできる。
    }

    /**
     * ダメージを適用する。
     * @param amount 与えるダメージ量(正の値)
     */
    public applyDamage(amount: number): void {
        if (amount <= 0) {
            return;
        }

        const prevHp = this._currentHp;
        this._currentHp -= amount;
        if (this._currentHp < 0) {
            this._currentHp = 0;
        }

        if (this.enableDebugLog) {
            console.log(`[BossPhase] ダメージ適用: -${amount} (HP: ${prevHp} → ${this._currentHp})`);
        }

        // 死亡チェック
        if (this._currentHp <= 0) {
            this.onDeath();
            return;
        }

        // 形態変化チェック
        this.checkPhaseChange(prevHp, this._currentHp);
    }

    /**
     * 回復を適用する。
     * @param amount 回復量(正の値)
     */
    public heal(amount: number): void {
        if (amount <= 0) {
            return;
        }

        const prevHp = this._currentHp;
        this._currentHp += amount;
        if (this._currentHp > this.maxHp) {
            this._currentHp = this.maxHp;
        }

        if (this.enableDebugLog) {
            console.log(`[BossPhase] 回復適用: +${amount} (HP: ${prevHp} → ${this._currentHp})`);
        }
        // 回復では形態を戻さない(第2形態に入ったら固定)
    }

    /**
     * 形態変化のしきい値を取得する(HP値)。
     */
    private getThresholdHp(): number {
        if (this.useRatioThreshold) {
            // 比率からHP値に変換
            let ratio = this.thresholdRatio;
            if (ratio <= 0) {
                ratio = 0.01;
            } else if (ratio >= 1) {
                ratio = 0.99;
            }
            return this.maxHp * ratio;
        } else {
            // 絶対値
            if (this.thresholdHp <= 0) {
                return 1;
            }
            if (this.thresholdHp >= this.maxHp) {
                return this.maxHp - 1;
            }
            return this.thresholdHp;
        }
    }

    /**
     * HPの変化に応じて形態変化をチェックする。
     */
    private checkPhaseChange(prevHp: number, currentHp: number): void {
        if (this._hasPhaseChanged) {
            return; // すでに第2形態に移行済み
        }

        const threshold = this.getThresholdHp();

        // 「しきい値を上回っていた状態」から「しきい値未満の状態」に変化した瞬間だけ反応
        if (prevHp >= threshold && currentHp < threshold) {
            this.changeToPhase(1);
        }
    }

    /**
     * 指定した形態に切り替える。
     * @param phaseIndex 0 = 第1形態, 1 = 第2形態
     */
    private changeToPhase(phaseIndex: number): void {
        if (phaseIndex === this.currentPhaseIndex) {
            return;
        }

        this.currentPhaseIndex = phaseIndex;
        this.applyPhaseVisuals(phaseIndex);

        if (phaseIndex >= 1) {
            this._hasPhaseChanged = true;
        }

        if (this.enableDebugLog) {
            console.log(`[BossPhase] 形態変化: 第${phaseIndex + 1}形態へ移行しました。`);
        }
    }

    /**
     * 指定した形態の見た目・当たり判定・攻撃ノード有効状態を適用する。
     */
    private applyPhaseVisuals(phaseIndex: number): void {
        // Sprite の切り替え
        if (this._sprite) {
            if (phaseIndex === 0 && this.phase1Sprite) {
                this._sprite.spriteFrame = this.phase1Sprite;
            } else if (phaseIndex === 1 && this.phase2Sprite) {
                this._sprite.spriteFrame = this.phase2Sprite;
            }
        }

        // Collider サイズの切り替え
        if (this._collider) {
            if (phaseIndex === 0) {
                this._collider.size.set(this.phase1ColliderSize.x, this.phase1ColliderSize.y);
            } else if (phaseIndex === 1) {
                this._collider.size.set(this.phase2ColliderSize.x, this.phase2ColliderSize.y);
            }
            this._collider.apply();
        }

        // 攻撃ノードの有効/無効切り替え
        const phase1Active = (phaseIndex === 0);
        const phase2Active = (phaseIndex === 1);

        this.phase1AttackNodes.forEach(node => {
            if (node) {
                node.active = phase1Active;
            }
        });

        this.phase2AttackNodes.forEach(node => {
            if (node) {
                node.active = phase2Active;
            }
        });
    }

    /**
     * HPが0以下になったときに呼ばれる。
     */
    private onDeath(): void {
        if (this.enableDebugLog) {
            console.log('[BossPhase] HPが0になりました。死亡処理を実行します。');
        }

        if (this.deactivateOnDeath) {
            this.node.active = false;
        }
        // ここでアニメーション再生やエフェクトを追加したい場合は、
        // 別のコンポーネントで this.node の active 状態や currentHp を監視して実装できます。
    }
}

コードの主要部分の解説

  • onLoad
    • SpriteBoxCollider2DgetComponent で取得し、なければ console.warn で警告します(防御的実装)。
    • HP関連の初期値(maxHp, startHp)を補正しつつ _currentHp をセット。
    • currentPhaseIndex に応じて applyPhaseVisuals を呼び、開始時の形態の見た目・当たり判定・攻撃ノード状態を適用します。
  • applyDamage
    • 引数 amount を HP から減算し、0 未満にならないようにクランプします。
    • HPが0以下になったら onDeath を呼びます。
    • それ以外の場合は checkPhaseChange を呼んで、第2形態への移行条件を満たしていないか確認します。
  • checkPhaseChange
    • getThresholdHp でしきい値(HP値)を取得します。
    • prevHp >= threshold かつ currentHp < threshold のときだけ、changeToPhase(1) を呼びます。
    • _hasPhaseChanged フラグで、第2形態への移行は一度きりに制限しています。
  • applyPhaseVisuals
    • 形態インデックスに応じて SpriteFrame を切り替え。
    • BoxCollider2D.sizephase1ColliderSize / phase2ColliderSize に変更し、apply() を呼んで反映。
    • phase1AttackNodes / phase2AttackNodesactive を切り替えて、攻撃パターンを変更します。

使用手順と動作確認

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

  1. エディタの Assets パネルで、スクリプトを置きたいフォルダ(例:assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. ファイル名を BossPhase.ts に変更します。
  4. 作成された BossPhase.ts をダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードを貼り付けます。
  5. 保存(Ctrl+S / Cmd+S)します。

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

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、テスト用のボスノードを作成します。
    • ノード名を TestBoss など、分かりやすい名前に変更しておきます。
  2. InspectorTestBoss ノードを選択し、以下を確認・設定します。
    • Sprite コンポーネントが自動で追加されているはずです。ここに第1形態用の画像を設定しておきます(後で phase1Sprite, phase2Sprite に差し替え可能)。
  3. 当たり判定を使いたい場合:
    • TestBoss ノードを選択した状態で、Add ComponentPhysics 2DBoxCollider2D を追加します。
    • サイズはとりあえずデフォルトのままで構いません(形態ごとのサイズは BossPhase のプロパティで上書きされます)。

3. BossPhase コンポーネントのアタッチ

  1. HierarchyTestBoss ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. メニューから CustomBossPhase を選択し、コンポーネントを追加します。

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

TestBoss ノードの BossPhase コンポーネントに、以下のように設定してみましょう。

  • maxHp: 100
  • startHp: 100
  • useRatioThreshold: ON(チェック)
  • thresholdRatio: 0.5(HPが50を下回ったら第2形態へ)
  • thresholdHp: 50(今回は ratio を使うので無視されます)
  • currentPhaseIndex: 0(第1形態から開始)
  • deactivateOnDeath: ON(HP0でノードを非アクティブに)
  • enableDebugLog: ON(挙動をログで確認するため)
  • phase1Sprite: 第1形態の画像(Assets からドラッグ&ドロップ)
  • phase2Sprite: 第2形態の画像(Assets からドラッグ&ドロップ)
  • phase1ColliderSize: (100, 100)
  • phase2ColliderSize: (150, 150)
  • phase1AttackNodes:
    • 第1形態専用の攻撃ノード(例:Phase1Shooter など)を Hierarchy 上で TestBoss の子として作成し、それらを配列にドラッグ&ドロップ。
  • phase2AttackNodes:
    • 第2形態専用の攻撃ノード(例:Phase2Laser など)を Hierarchy 上で TestBoss の子として作成し、それらを配列にドラッグ&ドロップ。

ポイント:

  • 第1形態開始時には phase1AttackNodes が自動的に active = truephase2AttackNodesactive = false になります。
  • HPがしきい値を下回った瞬間、第2形態へ移行し、Sprite・Colliderサイズ・攻撃ノードの active が切り替わります。

5. HP変化をテストする(簡易テスト)

このコンポーネントは、外部スクリプトから applyDamage を呼び出す設計ですが、ここでは簡単に動作確認する方法を2つ紹介します。

方法A:一時的なテスト用スクリプトでダメージを与える

  1. Assets パネルで右クリック → CreateTypeScript を選択し、BossPhaseTester.ts を作成します。
  2. 以下のような簡易テストコードを貼り付けます(このスクリプトは削除してもOKな一時的なものです)。

import { _decorator, Component, Node, input, Input, EventKeyboard, KeyCode } from 'cc';
import { BossPhase } from './BossPhase';
const { ccclass, property } = _decorator;

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

    @property({ type: BossPhase, tooltip: 'テスト対象の BossPhase コンポーネントをアサインします。' })
    public bossPhase: BossPhase | null = null;

    onLoad() {
        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    private onKeyDown(event: EventKeyboard) {
        if (!this.bossPhase) {
            return;
        }

        switch (event.keyCode) {
            case KeyCode.SPACE:
                // スペースキーで10ダメージ
                this.bossPhase.applyDamage(10);
                break;
        }
    }
}
  1. Hierarchy で適当なノード(例:Canvas)を選択し、Add ComponentCustomBossPhaseTester を追加します。
  2. Inspector で、BossPhaseTesterbossPhase プロパティに、TestBoss ノードの BossPhase コンポーネントをドラッグ&ドロップしてアサインします。
  3. Play ボタンでゲームを再生し、キーボードの Space キーを何度か押して HP を減らしていきます。
    • HPが50を下回った瞬間に、第2形態へ変化し、Sprite・Collider・攻撃ノードが切り替わることを確認してください。
    • コンソールに [BossPhase] のログが表示されていれば、HPと形態の変化も確認できます。

※この記事の主役は BossPhase コンポーネントであり、BossPhaseTester はあくまでテスト用の例です。最終的なゲームでは、任意の攻撃ロジックから applyDamage を呼び出せば同じように動作します。

方法B:Inspector から currentPhaseIndex を切り替えて見た目だけ確認

HP連動のテストが不要で、「見た目・Colliderサイズ・攻撃ノードの切り替えだけ確認したい」場合は、

  1. ゲームを再生せず、エディタ上で TestBoss ノードを選択します。
  2. BossPhasecurrentPhaseIndex0 / 1 に手動で切り替えてみます。
  3. その状態で一度シーンを再生 → 停止すると、onLoad 内で applyPhaseVisuals が呼ばれ、指定した形態の見た目が適用されます。

※エディタ上だけで即時に反映させたい場合は、update 内で currentPhaseIndex の変更を監視して applyPhaseVisuals を呼ぶ、といった拡張も可能です。


まとめ

この BossPhase コンポーネントを使うことで、

  • HP管理と「HPがしきい値を下回ったときの形態変化」ロジックを、1つのスクリプトに集約できる
  • Sprite・Colliderサイズ・攻撃パターン用ノードの切り替えを すべてインスペクタから設定できる
  • GameManager やシングルトンに依存せず、任意のボスノードにアタッチするだけで再利用できる

といったメリットがあります。

応用例としては、

  • しきい値を複数に増やし、第3形態・第4形態…と段階的に強くなるボス
  • 第2形態への移行時に、専用のエフェクトノードを一時的に active = true にする
  • UI側で currentHp / maxHp を参照してボスHPバーを表示する

などが考えられます。

まずはこの記事のコードをそのままプロジェクトに追加し、HPやしきい値、Sprite、攻撃ノードを差し替えながら、自分のゲームに合ったボス形態変化を組み立ててみてください。