ダメージ数字のポップアップって、つい「プレイヤーシーンの子ノードとして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)
使い方の手順
- DamagePopupLabel.tscn を作る
1. 新規シーンを作成し、Node2D をルートにします。
2. 子に Label を追加します。
3. ルートの Node2D に DamagePopupLabel.gd をアタッチします。
4. シーンを DamagePopupLabel.tscn という名前で保存します。
DamagePopupLabel (Node2D) └── Label (Label)
- DamagePopup.gd をコンポーネントとして用意
1. 任意の場所(例: res://components/)に DamagePopup.gd を作成します。
2. 上記コードをコピペして保存します。
- プレイヤーや敵シーンにアタッチする
例: プレイヤーにダメージポップアップを付ける場合:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── DamagePopup (Node) <-- このノードに DamagePopup.gd をアタッチ
手順:
- Player シーンを開く
- Player の子として Node を1つ追加し、名前を
DamagePopupに変更 - そのノードに DamagePopup.gd をアタッチ
- インスペクタの
popup_sceneに DamagePopupLabel.tscn を指定
敵キャラにも同じようにアタッチできます:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── DamagePopup (Node)
- ダメージを受けたタイミングで 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 に丸投げできます。
さらに、DamagePopup は use_global_position と parent_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でも長期的にメンテしやすい構成になりますね。
