ダメージを受けたときに「フワッ」と数字が出る演出、つけたくなりますよね。
でも素直に実装しようとすると…

  • プレイヤー用のダメージポップアップ
  • 敵A用のダメージポップアップ
  • ボス用のダメージポップアップ

…と、それぞれのシーンに似たようなラベルやアニメーションを仕込んで、_on_damage()の中にゴリゴリ処理を書きがちです。
さらに継承でまとめようとすると、「敵の共通親シーン」とか「ダメージ表示付きプレイヤー基底クラス」とか、ツリーもクラス階層もどんどん重くなってしまいます。

そこで今回は、「ダメージを受けたときに、親の頭上に数字テキストを出してフワッと浮かせる」だけに責務を絞ったコンポーネント
「FloatingText (ポップアップ)」 を作っていきましょう。

どのノードにもペタッと貼れる「合成(Composition)」スタイルで実装しておけば、
プレイヤーでも敵でも動く床でも、ダメージっぽい数値を出したいものには何でもアタッチできるようになります。


【Godot 4】ダメージ数字をフワッと出す!「FloatingText」コンポーネント

このコンポーネントは、

  • 親ノードの位置を基準に
  • 数値テキストを少し上にオフセットして表示し
  • 上方向に移動しながらフェードアウトして自動削除

という「よくあるダメージポップアップ」を、どのノードにもアタッチできる形で提供します。
ダメージ側のコードは floating_text.show_number(damage_amount) を呼ぶだけ、というシンプル設計です。


フルコード:FloatingText.gd


extends Node2D
class_name FloatingText
## 親ノードの頭上に、数値テキストをポップアップ表示するコンポーネント。
## どのノードにもアタッチできるよう、Node2D を継承しています。

## =========================
## エディタから設定できるパラメータ
## =========================

@export_group("表示位置")
## 親ノードからの相対オフセット(頭上に出したいので、Yはマイナスが基本)
@export var offset: Vector2 = Vector2(0, -32.0)

## テキストをランダムに散らす範囲(画面上のブレ)
@export var random_spread: Vector2 = Vector2(8.0, 4.0)

@export_group("アニメーション")
## 浮かび上がる方向と速度(ピクセル/秒)
@export var float_velocity: Vector2 = Vector2(0, -40.0)

## 1つのポップアップが生きている時間(秒)
@export_range(0.1, 5.0, 0.1, "or_greater")
var lifetime: float = 0.8

## ポップアップがスケール変化するかどうか
@export var use_scale_animation: bool = true

## スケールの開始値と終了値
@export var start_scale: Vector2 = Vector2(1.2, 1.2)
@export var end_scale: Vector2 = Vector2(0.8, 0.8)

@export_group("見た目")
## 使用するフォント(未指定ならデフォルトフォントを使用)
@export var font: Font

## フォントサイズ
@export_range(8, 128, 1, "or_greater")
var font_size: int = 24

## テキストの色
@export var color: Color = Color(1, 0.3, 0.3, 1.0)

## クリティカル時などに使う特別な色
@export var critical_color: Color = Color(1, 1, 0.3, 1.0)

## 影の色(完全に透明にすると影なし)
@export var shadow_color: Color = Color(0, 0, 0, 0.5)

## 影のオフセット
@export var shadow_offset: Vector2 = Vector2(1, 1)

@export_group("その他")
## ローカル空間で表示するか、ワールド空間で表示するか
## - true: 親ノードと一緒に動く(ローカル座標)
## - false: ビューポート直下に配置して、画面上の座標で固定表示
@export var use_local_space: bool = true

## 小数を丸めるか(true: 整数に丸める)
@export var round_numbers: bool = true


## =========================
## 内部用のシーンリソース(Labelベースの1枚ポップアップ)
## =========================

## 実際に表示される1つ分のポップアップを表す内部クラス
class PopupLabel:
    var label: Label
    var time: float = 0.0
    var lifetime: float
    var start_scale: Vector2
    var end_scale: Vector2
    var velocity: Vector2
    var use_scale_animation: bool

    func _init(label: Label, lifetime: float, start_scale: Vector2, end_scale: Vector2, velocity: Vector2, use_scale_animation: bool) -> void:
        self.label = label
        self.lifetime = lifetime
        self.start_scale = start_scale
        self.end_scale = end_scale
        self.velocity = velocity
        self.use_scale_animation = use_scale_animation


## 現在生きている全ポップアップ
var _popups: Array[PopupLabel] = []


func _ready() -> void:
    ## use_local_space = false の場合は、ビューポート直下に自分を付け替える
    ## これにより、「カメラに追従しないUI的な表示」にも対応できます。
    if not use_local_space:
        var viewport := get_viewport()
        if viewport:
            var canvas_layer := CanvasLayer.new()
            canvas_layer.name = "%s_CanvasLayer" % name
            viewport.add_child(canvas_layer)
            canvas_layer.add_child(self)
            global_position = Vector2.ZERO

func _process(delta: float) -> void:
    ## 各ポップアップを更新
    for popup in _popups:
        popup.time += delta
        var t := clamp(popup.time / popup.lifetime, 0.0, 1.0)

        ## 位置更新(浮かび上がる)
        popup.label.position += popup.velocity * delta

        ## スケールアニメーション
        if popup.use_scale_animation:
            popup.label.scale = popup.start_scale.lerp(popup.end_scale, t)

        ## フェードアウト(アルファを時間に応じて減らす)
        var modulate := popup.label.modulate
        modulate.a = 1.0 - t
        popup.label.modulate = modulate

    ## 寿命の尽きたポップアップを削除
    for i in range(_popups.size() - 1, -1, -1):
        var popup := _popups[i]
        if popup.time >= popup.lifetime or not is_instance_valid(popup.label):
            if is_instance_valid(popup.label):
                popup.label.queue_free()
            _popups.remove_at(i)


## =========================
## 公開API
## =========================

## ダメージ値を表示するためのヘルパー
func show_damage(amount: float, is_critical: bool = false) -> void:
    var text := ""
    if round_numbers:
        text = str(int(round(amount)))
    else:
        text = str(amount)

    var col := is_critical ? critical_color : color
    show_text(text, col)


## 任意のテキストを表示する汎用API
func show_text(text: String, text_color: Color = color) -> void:
    ## ラベル生成
    var label := Label.new()
    label.text = text

    ## フォント設定
    if font:
        var theme := Theme.new()
        theme.set_font("font", "Label", font)
        theme.set_font_size("font_size", "Label", font_size)
        label.theme = theme
    else:
        label.add_theme_font_size_override("font_size", font_size)

    ## 色設定
    label.modulate = text_color

    ## 影っぽい表現:ShadowedLabel を使うのもアリですが、
    ## ここでは簡易的に2枚重ねで実装します。
    var container := Node2D.new()
    add_child(container)

    ## 影ラベル
    if shadow_color.a > 0.0:
        var shadow_label := Label.new()
        shadow_label.text = text
        if font:
            var shadow_theme := Theme.new()
            shadow_theme.set_font("font", "Label", font)
            shadow_theme.set_font_size("font_size", "Label", font_size)
            shadow_label.theme = shadow_theme
        else:
            shadow_label.add_theme_font_size_override("font_size", font_size)
        shadow_label.modulate = shadow_color
        shadow_label.position = shadow_offset
        container.add_child(shadow_label)

    ## 本体ラベル
    container.add_child(label)

    ## 初期位置:親ノードの位置 + オフセット + ランダムブレ
    var base_position: Vector2
    if use_local_space:
        ## 親のローカル座標系に追従
        base_position = to_local(get_parent().global_position)
    else:
        ## ワールド座標をスクリーン座標に変換したい場合は Camera2D から変換するなど、
        ## プロジェクトに応じて調整してください。ここでは簡易に global_position をそのまま使います。
        base_position = get_parent().global_position

    var random_offset := Vector2(
        randf_range(-random_spread.x, random_spread.x),
        randf_range(-random_spread.y, random_spread.y)
    )

    container.position = base_position + offset + random_offset

    ## スケール初期値
    if use_scale_animation:
        container.scale = start_scale

    ## PopupLabel として管理
    var popup := PopupLabel.new(
        container,
        lifetime,
        start_scale,
        end_scale,
        float_velocity,
        use_scale_animation
    )
    _popups.append(popup)

使い方の手順

ここでは、敵キャラクターの頭上にダメージ数字を出す例で説明します。
同じ要領でプレイヤーやオブジェクトにも簡単に流用できます。

手順①:コンポーネントスクリプトを用意する

  1. 上記の FloatingText.gd をプロジェクトのどこか(例:res://components/FloatingText.gd)に保存します。
  2. Godot エディタを再読み込みすると、class_name FloatingText により「FloatingText」がスクリプトクラスとして認識されます。

手順②:敵シーンにコンポーネントをアタッチする

例として、こんな敵シーンを想定します:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FloatingText (Node2D)  ← このノードに FloatingText.gd をアタッチ
  1. Enemy シーンを開く
  2. Enemy ノードの子として Node2D を追加し、名前を FloatingText に変更
  3. その Node2D に FloatingText.gd をアタッチ
  4. インスペクタから、offsetfont_size などを好みに合わせて調整

プレイヤーにも同じように貼りたい場合は:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FloatingText (Node2D)

とすればOKです。クラス継承は一切いじらず、「ダメージ表示したいノードにコンポーネントを足すだけ」の構造になります。

手順③:ダメージ処理からコンポーネントを呼び出す

敵のスクリプト(例:Enemy.gd)側では、ダメージを受けたタイミングで FloatingText コンポーネントを呼び出します。


extends CharacterBody2D

@onready var floating_text: FloatingText = $FloatingText

var hp: int = 100

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

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

    if hp <= 0:
        die()

func die() -> void:
    queue_free()

プレイヤー側も同じように、apply_damage()take_damage() の中で floating_text.show_damage() を呼ぶだけでOKです。

手順④:動く床やギミックにも使い回す

ダメージだけでなく、たとえば「スコア +10」「回復 +30」「毒 -5」など、数値を浮かばせたい場面はいくらでもあります。
その場合は show_text() を直接呼び出します。


# 例:コイン取得時に "+10" を表示するギミック
extends Area2D

@onready var floating_text: FloatingText = $FloatingText

func _on_body_entered(body: Node) -> void:
    if body.name == "Player":
        if floating_text:
            floating_text.show_text("+10", Color(1, 1, 0.3))
        queue_free()

シーン構成はこんな感じ:

Coin (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FloatingText (Node2D)

こうしておけば、「ポップアップ表示を持つコイン」「ポップアップ表示を持つ回復アイテム」など、
どんなシーンにも FloatingText ノードを1個足すだけで同じ仕組みを共有できます。


メリットと応用

この「FloatingText」コンポーネントを導入するメリットはかなり大きいです。

  • シーン構造がシンプル
    各キャラクターは「本体 + 見た目 + 当たり判定 + FloatingText」というフラットな構成で済みます。
    「ダメージ表示付きキャラクター基底クラス」みたいな継承ツリーを作る必要がありません。
  • 使い回しがしやすい
    プレイヤー、敵、ギミック、アイテムなど、どのシーンにも同じコンポーネントを貼るだけ。
    演出の調整もコンポーネント側で1回変えれば、全シーンに反映されます。
  • 責務が明確
    「FloatingText は “数値テキストを浮かばせる” だけ」が責務。
    ダメージ計算やHP管理は別コンポーネントや本体スクリプトに任せられるので、コードの見通しが良くなります。
  • レベルデザインが楽
    レベルデザイナーがシーンを組むとき、「この敵だけダメージ表示オフにしたい」なら FloatingText ノードを削除するだけ。
    コードをいじらなくても、シーン構造だけで演出の有無をコントロールできます。

合成(Composition)でコンポーネントを積み上げていくスタイルにしておくと、
「あとから別のゲームにも持っていく」「別プロジェクトのプレイヤーにも載せ替える」といった再利用も格段にやりやすくなります。

改造案:ダメージ量に応じてフォントサイズを変える

例えば「大ダメージは文字も大きくしたい」という場合、show_damage() を少し改造してみましょう。


func show_damage(amount: float, is_critical: bool = false) -> void:
    var text := round_numbers ? str(int(round(amount))) : str(amount)
    var col := is_critical ? critical_color : color

    # ダメージ量に応じてフォントサイズをスケーリング
    var base_size := font_size
    var scaled_size := int(base_size * clamp(amount / 50.0, 0.8, 2.0))

    # 一時的にフォントサイズを上書きしてから表示
    var original_size := font_size
    font_size = scaled_size
    show_text(text, col)
    font_size = original_size

このように、コンポーネント側に「演出ロジック」を閉じ込めておけば、
ゲーム全体の演出の方向性が変わっても、1つのスクリプトをいじるだけで一括調整できるのが嬉しいところですね。