ダメージ数字のポップアップって、つい「プレイヤーシーンの子ノードとしてLabelを置いて、アニメーションはAnimationPlayerで…」みたいに、その場しのぎで実装しがちですよね。
でもそれを敵・プレイヤー・オブジェクトごとにコピペしていくと、

  • シーン階層がどんどん深くなる
  • 似たようなLabel+AnimationPlayerが大量発生する
  • 微調整したいときに「全部のシーンを開いて修正」する羽目になる

継承で共通化しようとしても、「敵もプレイヤーもダメージを受けるけど、共通の親クラスを作ると他の責務まで混ざる…」みたいなツラさもあります。

そこで今回は、「どのシーンにも後付けでポン付けできる」コンポーネントとして、DamagePopup を用意してみましょう。
攻撃を食らったオブジェクトの位置から、ふわっと数字が跳ねて消えるアレを、1コンポーネント+1プレハブシーンで完結させます。

【Godot 4】ダメージ演出をコンポーネント化!「DamagePopup」コンポーネント

今回の構成はざっくりこんな感じです。

  • DamagePopup.gd … 「ダメージ数字を生成する側」のコンポーネント
  • DamagePopupLabel.tscn … 実際に表示・アニメーションするラベルのシーン

DamagePopupコンポーネントは、敵やプレイヤーにアタッチしておき、
show_damage(value) を呼ぶだけで、頭上にポップアップを出してくれる仕組みです。


DamagePopup.gd(コンポーネント本体)


extends Node
class_name DamagePopup
## ダメージ数字を頭上にポップアップ表示するコンポーネント
## 任意のノードにアタッチして使います

@export var popup_scene: PackedScene
## 実際に表示されるダメージラベルのシーン
## 通常は DamagePopupLabel.tscn を指定します

@export var y_offset: float = -24.0
## ポップアップを表示する基準位置のオフセット(親ノードのローカル座標系)
## マイナスにすると「頭上」に表示されます

@export var random_spread: float = 8.0
## 左右方向にランダムで散らす量(ふわっとばらけさせる)

@export var use_global_position: bool = true
## true: 親ノードのグローバル座標を基準に表示
## false: 親ノードのローカル座標を基準に表示(UIレイヤーなどで使うとき用)

@export var parent_for_popup: NodePath
## 生成したポップアップをぶら下げるノード
## 空の場合は、親ノードの最上位のシーンルートに自動でぶら下げます
## (画面全体に対して表示したいときに便利)

func _ready() -> void:
    if popup_scene == null:
        push_warning("DamagePopup: popup_scene が設定されていません。インスペクタで DamagePopupLabel.tscn を指定してください。")


func show_damage(amount: int, is_critical: bool = false) -> void:
    ## ダメージを表示するメインAPI
    ## amount: 表示するダメージ値
    ## is_critical: クリティカルヒットなど、強調したいときに true

    if popup_scene == null:
        push_warning("DamagePopup: popup_scene が未設定のため、ポップアップを生成できません。")
        return

    var popup := popup_scene.instantiate()
    if not popup:
        return

    # ダメージ値を渡す(DamagePopupLabel側で受け取って表示する想定)
    if popup.has_method("setup"):
        popup.call("setup", amount, is_critical)
    elif popup.has_variable("value"):
        popup.value = amount

    # どこにぶら下げるか決める
    var root := _get_popup_parent()
    if root == null:
        push_warning("DamagePopup: 表示先の親ノードを取得できませんでした。")
        return

    root.add_child(popup)

    # 表示位置を計算
    var base_pos: Vector2
    if use_global_position and owner and owner is Node2D:
        base_pos = (owner as Node2D).global_position
    elif owner and owner is Node2D:
        base_pos = (owner as Node2D).position
    else:
        base_pos = Vector2.ZERO

    var final_pos := base_pos + Vector2(
        randf_range(-random_spread, random_spread),
        y_offset
    )

    if popup is Node2D:
        if use_global_position:
            (popup as Node2D).global_position = final_pos
        else:
            (popup as Node2D).position = final_pos
    elif popup is Control:
        # UIレイヤーに出す場合など
        (popup as Control).position = final_pos


func _get_popup_parent() -> Node:
    # 明示的に指定されていればそれを使う
    if parent_for_popup != NodePath():
        var node := get_node_or_null(parent_for_popup)
        if node:
            return node

    # owner の最上位シーンルートを探して使う
    var current := owner
    while current and current.get_parent():
        current = current.get_parent()
    return current

DamagePopupLabel.gd(表示用ラベル)

こちらは DamagePopupLabel.tscn にアタッチするスクリプトです。
Node2D + Label という、かなりシンプルな構成を想定しています。


extends Node2D
class_name DamagePopupLabel
## 実際に画面上に表示されるダメージ数字
## ふわっと上に移動してフェードアウトしたら自動で消えます

@export var lifetime: float = 0.6
## 表示してから消えるまでの時間(秒)

@export var rise_distance: float = 24.0
## 上方向にどれくらい持ち上がるか

@export var start_scale: float = 1.0
@export var end_scale: float = 0.8
## 開始時と終了時のスケール(少し縮んで消える感じ)

@export var normal_color: Color = Color(1, 1, 1)
@export var critical_color: Color = Color(1, 0.3, 0.3)
## 通常ダメージとクリティカル時の色

@onready var label: Label = $Label

var _elapsed: float = 0.0
var _start_position: Vector2
var _is_critical: bool = false

func _ready() -> void:
    _start_position = position
    if label == null:
        push_warning("DamagePopupLabel: 子ノードに Label が見つかりません。シーン構成を確認してください。")


func setup(amount: int, is_critical: bool = false) -> void:
    ## DamagePopup から呼ばれる初期化メソッド
    _is_critical = is_critical
    if label:
        label.text = str(amount)
        label.modulate = critical_color if is_critical else normal_color

    # クリティカル時はちょっと大きめにしてみる
    if is_critical:
        scale = Vector2.ONE * (start_scale * 1.2)
    else:
        scale = Vector2.ONE * start_scale


func _process(delta: float) -> void:
    _elapsed += delta
    var t := clampf(_elapsed / lifetime, 0.0, 1.0)

    # ふわっと上方向へ移動(イージング付き)
    var ease_t := _ease_out_quad(t)
    position = _start_position + Vector2(0, -rise_distance * ease_t)

    # スケールとフェードアウト
    var current_scale := lerp(start_scale, end_scale, ease_t)
    scale = Vector2.ONE * current_scale

    if label:
        var alpha := 1.0 - t
        var c := label.modulate
        c.a = alpha
        label.modulate = c

    # 寿命が尽きたら自動で消える
    if t >= 1.0:
        queue_free()


func _ease_out_quad(x: float) -> float:
    # 簡単なイージング関数(0〜1)
    return 1.0 - (1.0 - x) * (1.0 - x)

※シーン構成例(DamagePopupLabel.tscn)はこんな感じを想定しています:

DamagePopupLabel (Node2D)
 └── Label (Label)

使い方の手順

  1. DamagePopupLabel.tscn を作る

1. 新規シーンを作成し、Node2D をルートにします。
2. 子に Label を追加します。
3. ルートの Node2D に DamagePopupLabel.gd をアタッチします。
4. シーンを DamagePopupLabel.tscn という名前で保存します。

DamagePopupLabel (Node2D)
 └── Label (Label)
  1. DamagePopup.gd をコンポーネントとして用意

1. 任意の場所(例: res://components/)に DamagePopup.gd を作成します。
2. 上記コードをコピペして保存します。

  1. プレイヤーや敵シーンにアタッチする

例: プレイヤーにダメージポップアップを付ける場合:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── DamagePopup (Node)  <-- このノードに DamagePopup.gd をアタッチ

手順:

  • Player シーンを開く
  • Player の子として Node を1つ追加し、名前を DamagePopup に変更
  • そのノードに DamagePopup.gd をアタッチ
  • インスペクタの popup_sceneDamagePopupLabel.tscn を指定

敵キャラにも同じようにアタッチできます:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── DamagePopup (Node)
  1. ダメージを受けたタイミングで show_damage を呼ぶ

プレイヤー側のスクリプト例:


extends CharacterBody2D

@onready var damage_popup: DamagePopup = $DamagePopup

var hp: int = 100

func apply_damage(amount: int, is_critical: bool = false) -> void:
    hp -= amount
    hp = max(hp, 0)

    # ダメージ数字を表示
    if damage_popup:
        damage_popup.show_damage(amount, is_critical)

    if hp <= 0:
        die()


func die() -> void:
    queue_free()

敵側も同じノリでOKです:


extends CharacterBody2D

@onready var damage_popup: DamagePopup = $DamagePopup

var hp: int = 50

func hit_by_player(attack_power: int) -> void:
    var is_critical := randf() < 0.2
    var dmg := attack_power
    if is_critical:
        dmg = int(attack_power * 1.5)

    hp -= dmg
    if damage_popup:
        damage_popup.show_damage(dmg, is_critical)

    if hp <= 0:
        queue_free()

「動く床がダメージを与えるトラップ」なんかにも、同じコンポーネントをそのまま使えます:

SpikeFloor (Area2D)
 ├── CollisionShape2D
 ├── Sprite2D
 └── DamagePopup (Node)

メリットと応用

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

  • どのシーンにも後付けでダメージ演出を足せる
    → 既存の Player / Enemy / Trap に「Node + スクリプトを1つ」追加するだけ。
  • シーン構造がスッキリする
    → 各シーンにバラバラの AnimationPlayer や Label を持たせず、
    ダメージ演出は DamagePopupLabel.tscn に集約。
  • 演出の調整が一箇所で済む
    → ふわっと上がる距離・時間・色・フォントなどを1シーンで管理できるので、
    「やっぱりもうちょいゆっくり消したいな…」というときも即反映できます。
  • コンポーネント方式なので責務が分離される
    → Player や Enemy のスクリプトは「ダメージ計算」だけに集中でき、
    「どう表示するか」は DamagePopup に丸投げできます。

さらに、DamagePopupuse_global_positionparent_for_popup を持っているので、

  • ワールド空間(Node2D)に直接表示する
  • UIレイヤー(CanvasLayer / Control)に表示する

といった切り替えも簡単です。
大きめのゲームになってくると、「UIは全部CanvasLayerで統一したい」みたいな要件が出てくるので、このあたりもコンポーネントで吸収しておくと後から楽になりますね。

改造案:ヒール(回復)にも流用する

ダメージだけでなく、回復量も緑色でポップアップさせたい場合、
DamagePopupLabel にこんな関数を追加して、setup()の代わりに呼ぶのもアリです。


func setup_heal(amount: int) -> void:
    ## 回復用の表示設定(緑色で、少しゆっくりめに)
    _is_critical = false
    if label:
        label.text = "+" + str(amount)
        label.modulate = Color(0.3, 1.0, 0.3) # 緑系

    lifetime = 0.8
    rise_distance = 20.0
    start_scale = 0.9
    end_scale = 1.1
    scale = Vector2.ONE * start_scale

あとは DamagePopup 側から popup.call("setup_heal", heal_amount) を呼ぶようにすれば、
同じコンポーネントで「ダメージ」と「回復」の両方の演出をまかなえます。

継承で「DamagePopupBase → DamageHealPopup → DamageDamagePopup…」と増やしていくより、
こうやって 1コンポーネント+複数の初期化メソッド で合成していく方が、Godotでも長期的にメンテしやすい構成になりますね。