【Cocos Creator 3.8】DamagePopup の実装:アタッチするだけで「ダメージ数字がふわっと跳ねて消える」演出を実現する汎用スクリプト

敵やプレイヤーにダメージを与えたとき、頭上にダメージ数値がふわっと表示されて消える演出は、ゲームの手触りを一気に良くします。この記事では、任意のノードにアタッチするだけで、そのノードの位置からダメージ数字をポップアップ表示できる汎用コンポーネント DamagePopup を実装します。

外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結するように設計します。インスペクタからフォントや色、移動距離、時間などを調整できるため、様々なゲームにそのまま流用できます。


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

全体像

DamagePopup は、アタッチされたノードの位置にダメージ数字用の Label ノードを一時的に生成し、

  • 指定時間かけて上方向に移動(ふわっと浮く)
  • スケールやアルファ値をアニメーション(縮む・フェードアウト)
  • アニメーション後に自動で削除

という動きを行います。

ダメージ発生時には、DamagePopup コンポーネントの showDamage(value: number) メソッドを呼ぶだけで済みます。
このメソッドは public として公開するので、攻撃処理側のスクリプトから簡単に呼び出せます。

外部依存をなくすための設計

  • フォントや色などの見た目は、インスペクタのプロパティで設定可能にする。
  • ポップアップ用ノードは 動的に生成し、Prefab を前提にしない。
  • Canvas や他ノードへの参照は 任意とし、未設定時は「自分と同じ親ノード」に生成する。
  • 必須コンポーネント(Label など)はコード側で addComponent するため、エディタで事前に追加する必要はない。
  • 設定ミスや未設定に対しては console.warn / console.error でログを出し、防御的に動作する。

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

以下のプロパティを用意し、全て @property でインスペクタから調整できるようにします。

  • 表示系
    • font: BitmapFont | TrueTypeFont | null
      表示に使用するフォントアセット。未指定の場合は Label のデフォルトフォントを使用。
    • fontSize: number
      ダメージ数字のフォントサイズ。例: 32。
    • textColor: Color
      ダメージ数字の色。例: 赤っぽい色。
    • outlineColor: Color
      アウトラインの色。アウトラインを使わない場合でも設定しておく。
    • useOutline: boolean
      アウトライン(LabelOutline コンポーネント)を付与するかどうか。
    • outlineWidth: number
      アウトラインの太さ(ピクセル)。
  • アニメーション系
    • duration: number
      ポップアップの寿命(秒)。この時間をかけて移動&フェードし、最後に削除。
    • moveDistance: number
      上方向への移動距離(ローカル座標の Y)。例: 50。
    • startScale: number
      表示開始時のスケール。例: 1.0。
    • endScale: number
      表示終了時のスケール。例: 0.5。
    • startOpacity: number
      開始時の不透明度(0〜255)。例: 255。
    • endOpacity: number
      終了時の不透明度(0〜255)。例: 0。
    • useRandomOffset: boolean
      表示位置にランダムなオフセットを加えるかどうか。
    • randomOffsetRadius: number
      ランダムオフセットの半径。例: 10。
  • 生成位置系
    • parentForPopup: Node | null
      生成したダメージ数字ノードをぶら下げる親ノード。未設定の場合は「この DamagePopup がアタッチされたノードの親」を使用。
    • localOffset: Vec3
      アタッチノードからのローカルオフセット。例: (0, 50, 0) で少し頭上に表示。
  • 数値フォーマット系
    • prefix: string
      ダメージ値の前に付ける文字列。例: "-" で「-100」のように表示。
    • suffix: string
      ダメージ値の後ろに付ける文字列。例: " HP"
    • useInteger: boolean
      小数を四捨五入して整数表示するかどうか。

TypeScriptコードの実装

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


import { _decorator, Component, Node, Label, Color, Vec3, tween, UIOpacity, LabelOutline, BitmapFont, TrueTypeFont, isValid } from 'cc';
const { ccclass, property } = _decorator;

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

    // =========================
    // 表示系
    // =========================

    @property({
        type: BitmapFont,
        tooltip: 'ダメージ数字に使用する BitmapFont。未指定の場合はデフォルトフォントを使用します。'
    })
    public bitmapFont: BitmapFont | null = null;

    @property({
        type: TrueTypeFont,
        tooltip: 'ダメージ数字に使用する TrueTypeFont。BitmapFont が指定されている場合はそちらが優先されます。'
    })
    public ttfFont: TrueTypeFont | null = null;

    @property({
        tooltip: 'ダメージ数字のフォントサイズ'
    })
    public fontSize: number = 32;

    @property({
        tooltip: 'ダメージ数字の色'
    })
    public textColor: Color = new Color(255, 80, 80, 255);

    @property({
        tooltip: 'アウトラインを使用するかどうか'
    })
    public useOutline: boolean = true;

    @property({
        tooltip: 'アウトラインの色'
    })
    public outlineColor: Color = new Color(0, 0, 0, 255);

    @property({
        tooltip: 'アウトラインの太さ(ピクセル)'
    })
    public outlineWidth: number = 2;

    // =========================
    // アニメーション系
    // =========================

    @property({
        tooltip: 'ポップアップの表示時間(秒)。この時間をかけて移動・フェードアウトします。'
    })
    public duration: number = 0.6;

    @property({
        tooltip: '上方向への移動距離(ローカル座標のY)'
    })
    public moveDistance: number = 50;

    @property({
        tooltip: '開始時のスケール'
    })
    public startScale: number = 1.0;

    @property({
        tooltip: '終了時のスケール'
    })
    public endScale: number = 0.5;

    @property({
        tooltip: '開始時の不透明度(0〜255)'
    })
    public startOpacity: number = 255;

    @property({
        tooltip: '終了時の不透明度(0〜255)'
    })
    public endOpacity: number = 0;

    @property({
        tooltip: '表示位置にランダムオフセットを加えるかどうか'
    })
    public useRandomOffset: boolean = true;

    @property({
        tooltip: 'ランダムオフセットの半径(ピクセル)。useRandomOffset が true のときのみ有効です。'
    })
    public randomOffsetRadius: number = 10;

    // =========================
    // 生成位置系
    // =========================

    @property({
        type: Node,
        tooltip: 'ダメージ数字ノードをぶら下げる親ノード。未設定の場合は、このコンポーネントがアタッチされたノードの親を使用します。'
    })
    public parentForPopup: Node | null = null;

    @property({
        tooltip: 'アタッチノードからのローカルオフセット位置(x, y, z)'
    })
    public localOffset: Vec3 = new Vec3(0, 50, 0);

    // =========================
    // 数値フォーマット系
    // =========================

    @property({
        tooltip: 'ダメージ値の前に付ける文字列(例: "-" で -100 のように表示)'
    })
    public prefix: string = '-';

    @property({
        tooltip: 'ダメージ値の後ろに付ける文字列(例: " HP")'
    })
    public suffix: string = '';

    @property({
        tooltip: '小数を四捨五入して整数表示するかどうか'
    })
    public useInteger: boolean = true;

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

    onLoad() {
        // 必要な親ノードの存在チェック
        if (!this.parentForPopup) {
            if (!this.node.parent) {
                console.error('[DamagePopup] 親ノードが存在しません。parentForPopup をインスペクタで設定するか、このノードを何かの子として配置してください。');
            } else {
                // デフォルトで自分の親を使用
                this.parentForPopup = this.node.parent;
            }
        }

        // duration の防御的補正
        if (this.duration <= 0) {
            console.warn('[DamagePopup] duration が 0 以下です。0.5 秒に補正します。');
            this.duration = 0.5;
        }

        if (this.startOpacity < 0) this.startOpacity = 0;
        if (this.startOpacity > 255) this.startOpacity = 255;
        if (this.endOpacity < 0) this.endOpacity = 0;
        if (this.endOpacity > 255) this.endOpacity = 255;
    }

    start() {
        // 特に初期処理は不要だが、将来の拡張用にメソッドを残しておく
    }

    // update は使用しない(tween ベースでアニメーションする)

    // =========================
    // 公開API
    // =========================

    /**
     * ダメージ値をポップアップ表示します。
     * 攻撃処理側のスクリプトから呼び出してください。
     * 例: this.damagePopup.showDamage(100);
     */
    public showDamage(value: number) {
        if (!isValid(this.node)) {
            console.warn('[DamagePopup] このコンポーネントのノードは既に破棄されています。');
            return;
        }

        // 親ノードの最終確認
        let parent = this.parentForPopup;
        if (!parent) {
            parent = this.node.parent;
        }
        if (!parent) {
            console.error('[DamagePopup] ダメージポップアップを生成する親ノードが見つかりません。');
            return;
        }

        // =========================
        // ノード生成
        // =========================

        const popupNode = new Node('DamagePopupLabel');
        parent.addChild(popupNode);

        // 位置の決定(アタッチノードのローカル座標 + オフセット)
        const basePos = this.node.getPosition().clone().add(this.localOffset);

        let finalPos = basePos.clone();
        if (this.useRandomOffset && this.randomOffsetRadius > 0) {
            const angle = Math.random() * Math.PI * 2;
            const radius = Math.random() * this.randomOffsetRadius;
            const offsetX = Math.cos(angle) * radius;
            const offsetY = Math.sin(angle) * radius;
            finalPos.x += offsetX;
            finalPos.y += offsetY;
        }
        popupNode.setPosition(finalPos);

        // スケール初期値
        popupNode.setScale(this.startScale, this.startScale, this.startScale);

        // Label コンポーネント追加
        const label = popupNode.addComponent(Label);
        label.fontSize = this.fontSize;
        label.color = this.textColor.clone();

        // フォント設定(BitmapFont が優先、その次に TTF)
        if (this.bitmapFont) {
            label.font = this.bitmapFont;
        } else if (this.ttfFont) {
            label.font = this.ttfFont;
        }

        // アウトライン設定
        if (this.useOutline) {
            const outline = popupNode.addComponent(LabelOutline);
            outline.color = this.outlineColor.clone();
            outline.width = this.outlineWidth;
        }

        // 数値のフォーマット
        let displayValue = value;
        if (this.useInteger) {
            displayValue = Math.round(displayValue);
        }
        label.string = `${this.prefix}${displayValue}${this.suffix}`;

        // 不透明度制御用コンポーネント
        const uiOpacity = popupNode.addComponent(UIOpacity);
        uiOpacity.opacity = this.startOpacity;

        // =========================
        // アニメーション(tween)
        // =========================

        const targetPos = finalPos.clone();
        targetPos.y += this.moveDistance;

        tween(popupNode)
            .to(
                this.duration,
                {
                    position: targetPos,
                    scale: new Vec3(this.endScale, this.endScale, this.endScale)
                }
            )
            .call(() => {
                // ノードが既に破棄されていないか確認してから削除
                if (isValid(popupNode)) {
                    popupNode.destroy();
                }
            })
            .start();

        tween(uiOpacity)
            .to(
                this.duration,
                { opacity: this.endOpacity }
            )
            .start();
    }
}

コードのポイント解説

  • onLoad
    • parentForPopup が未設定なら、自分の親ノードを自動的に使用します。
    • duration が 0 以下の場合は 0.5 秒に補正し、ログで警告します。
    • 不透明度が 0〜255 の範囲に収まるように補正します。
  • showDamage(value: number)
    • 攻撃処理側から呼び出す公開APIです。
    • ポップアップ用のノードを生成し、LabelLabelOutlineUIOpacity をその場で追加します。
    • 位置は「アタッチノードのローカル座標 + localOffset + ランダムオフセット」で決定します。
    • tween を使って、位置・スケール・不透明度を duration 秒かけて変化させます。
    • アニメーション終了後にポップアップノードを destroy() してクリーンアップします。
  • update を使わない設計
    • アニメーションは tween に任せるため、update 関数は不要です。
    • これにより、コンポーネント自体のオーバーヘッドを減らし、シンプルな設計になります。

使用手順と動作確認

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

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

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

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選び、テスト用のキャラクターノードを作成します。
    例: ノード名を Enemy にする。
  2. Canvas 配下に配置されていることを確認します(2D UI として表示する場合)。

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

  1. Hierarchy で先ほど作成した Enemy ノードを選択します。
  2. Inspector パネルの下部で Add Component ボタンをクリックします。
  3. Custom → DamagePopup を選択してアタッチします。

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

Enemy ノードの Inspector に表示された DamagePopup の各プロパティを設定します。

  • 表示系
    • Bitmap Font / TrueType Font: 任意のフォントアセットがあればドラッグ&ドロップします。なければ空のままで構いません。
    • Font Size: 32 など好みのサイズに設定します。
    • Text Color: 赤系(例: R=255, G=80, B=80, A=255)に設定するとダメージっぽくなります。
    • Use Outline: チェックを入れます。
    • Outline Color: 黒(R=0, G=0, B=0, A=255)を推奨。
    • Outline Width: 2 程度。
  • アニメーション系
    • Duration: 0.60.8 秒程度。
    • Move Distance: 50
    • Start Scale: 1.0
    • End Scale: 0.5
    • Start Opacity: 255
    • End Opacity: 0
    • Use Random Offset: チェックを入れると、毎回少し違う位置に表示されます。
    • Random Offset Radius: 10
  • 生成位置系
    • Parent For Popup: 空のままにすると、Enemy の親ノード(通常は Canvas)が使用されます。
      別のノード配下に出したい場合は、そのノードをドラッグ&ドロップします。
    • Local Offset: (0, 50, 0) など、Enemy の少し上に出るように調整します。
  • 数値フォーマット系
    • Prefix: "-" としておくと「-100」のような表記になります。
    • Suffix: 必要に応じて " HP" などを設定。
    • Use Integer: 通常はチェックを入れて整数表示にします。

5. ダメージ表示テスト用スクリプトから呼び出す

実際には攻撃処理スクリプトから showDamage を呼び出しますが、ここでは動作確認のための簡単なテストスクリプト例を示します。


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

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

    @property({ type: DamagePopup, tooltip: 'テスト対象の DamagePopup コンポーネント' })
    public damagePopup: DamagePopup | null = null;

    start() {
        if (!this.damagePopup) {
            // 同じノードにアタッチされている DamagePopup を自動取得
            this.damagePopup = this.getComponent(DamagePopup);
        }

        if (!this.damagePopup) {
            console.error('[DamagePopupTester] DamagePopup コンポーネントが見つかりません。Inspector で設定するか、同じノードにアタッチしてください。');
        }

        // キーボード入力を監視(エディタのプレビューで使用)
        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.damagePopup) {
            return;
        }

        // スペースキーでテスト
        if (event.keyCode === KeyCode.SPACE) {
            const randomDamage = Math.floor(Math.random() * 50) + 50; // 50〜99
            this.damagePopup.showDamage(randomDamage);
        }
    }
}

テスト手順:

  1. Assets で DamagePopupTester.ts を作成し、上記コードを貼り付けます。
  2. Hierarchy で Enemy ノードを選択し、Add Component → Custom → DamagePopupTester を追加します。
  3. Inspector の DamagePopupTester.damagePopup に、同じノードにアタッチされている DamagePopup をドラッグ&ドロップするか、空のままにして自動取得に任せます。
  4. シーンを再生し、ゲームビュー上で スペースキー を押すと、Enemy の頭上にランダムなダメージ数字がポップアップ表示されます。

まとめ

この記事では、DamagePopup コンポーネントを実装し、任意のノードにアタッチするだけで「ダメージ数字がふわっと跳ねて消える」演出を行えるようにしました。

  • 外部の GameManager やシングルトンに一切依存しない完全に独立したコンポーネントである。
  • フォント、色、移動距離、時間、スケール、ランダムオフセットなどをすべてインスペクタから調整可能
  • showDamage(value: number) を呼ぶだけで、どこからでも簡単にダメージ表示ができる。
  • Label / LabelOutline / UIOpacity をコード内で追加するため、事前にノード構造を用意する必要がない。

このコンポーネントをベースに、クリティカルヒット用の色変更や、回転・バウンド・パーティクルとの連携などを追加していけば、演出の幅をさらに広げることができます。
ダメージ表示は多くのゲームで頻出する機能なので、今回のような汎用コンポーネントとして切り出しておくと、プロジェクトをまたいで再利用でき、開発効率が大きく向上します。