Godot 4でステータス異常や継続ダメージを実装しようとすると、つい「Player を継承した PoisonPlayer」とか「Enemy を継承した PoisonEnemy」みたいにクラスを増やしがちですよね。あるいは、_process() の中に「毒ダメージ」「炎上ダメージ」「リジェネ」などを全部詰め込んで、だんだんとカオスな巨大スクリプトになっていく……というのもよくあるパターンです。
さらに、ノード階層の中に「PoisonTimer」「BurnTimer」「StatusManager」などをベタベタ生やしていくと、シーンツリーもどんどん深くなっていきます。
そこで今回は、「毒状態」だけを切り出したコンポーネント PoisonEffect を用意して、HealthManager を持っているノードなら誰にでもアタッチするだけで毒状態を付与できるようにしてみましょう。継承ではなく「合成(Composition)」でステータス異常を組み立てるスタイルですね。
【Godot 4】ペタッと貼るだけ継続ダメージ!「PoisonEffect」コンポーネント
このコンポーネントは、一定間隔で親ノードの HealthManager にダメージを通知するだけに責務を絞っています。
「毒の強さ」「ダメージ間隔」「毒の継続時間」などは @export で外から調整できるようにしておき、プレイヤーにも敵にも、動く罠にも同じコンポーネントを再利用できるようにします。
前提:シンプルな HealthManager の例
まず、PoisonEffect が呼び出す側として、最低限の HealthManager コンポーネント例を置いておきます。すでに自前の HP 管理コンポーネントがある場合は、apply_damage() などのインターフェース名を合わせてください。
# HealthManager.gd
class_name HealthManager
extends Node
## シンプルなHP管理コンポーネントの例
## 実プロジェクトでは死亡処理やUI更新などをここに追加していくとよいです
@export var max_health: float = 100.0:
set(value):
max_health = max(value, 1.0)
current_health = clamp(current_health, 0.0, max_health)
@export var current_health: float = 100.0
signal died
signal health_changed(new_health: float)
func _ready() -> void:
# 初期値をクランプしておく
current_health = clamp(current_health, 0.0, max_health)
func apply_damage(amount: float, source: Node = null) -> void:
## マイナス値は回復扱いにしてもよいですが、ここではダメージのみ想定
if amount <= 0.0:
return
current_health = clamp(current_health - amount, 0.0, max_health)
emit_signal("health_changed", current_health)
if current_health <= 0.0:
emit_signal("died")
PoisonEffect コンポーネント フルコード
# PoisonEffect.gd
class_name PoisonEffect
extends Node
## 親ノードにアタッチされた HealthManager に対して
## 一定時間ごとに継続ダメージを与えるコンポーネント。
##
## - HealthManager を持つノードにアタッチして使う
## - 有効/無効の切り替え、毒の強さ、継続時間などをエディタから調整可能
## - 「継承」ではなく、「HealthManager + PoisonEffect」を組み合わせるだけの構成にできる
## 1回あたりのダメージ量
@export_range(0.0, 9999.0, 0.1, "or_greater") var damage_per_tick: float = 5.0
## ダメージを与える間隔(秒)
@export_range(0.05, 60.0, 0.05, "or_greater") var tick_interval: float = 1.0
## 毒の継続時間(秒)
## 0 以下にすると「無限に続く毒」として扱う
@export_range(0.0, 9999.0, 0.1, "or_greater") var duration: float = 5.0
## シーン開始時から毒を有効にするか
@export var start_enabled: bool = true
## 毒の発生源(オプション)
## 例えば「誰が毒を付与したか」を HealthManager 側で参照したい時に使う
@export var damage_source: NodePath
## 内部状態
var _elapsed_time: float = 0.0 # 経過時間(継続時間判定用)
var _tick_accumulator: float = 0.0 # 経過時間(ダメージ間隔判定用)
var _is_active: bool = false # 現在毒が有効かどうか
var _health_manager: HealthManager = null
func _ready() -> void:
# 親ノードから HealthManager を探す
# 1. 直下の子ノードにあるか?(よくある構成)
# 2. 親自身が HealthManager を持っているか?
_health_manager = _find_health_manager()
if _health_manager == null:
push_warning("PoisonEffect: 親ノードに HealthManager が見つかりません。このコンポーネントは何も行いません。")
_is_active = start_enabled
_elapsed_time = 0.0
_tick_accumulator = 0.0
func _process(delta: float) -> void:
if not _is_active:
return
if _health_manager == null:
return
_elapsed_time += delta
_tick_accumulator += delta
# duration <= 0 の場合は「無限毒」として扱う
if duration > 0.0 and _elapsed_time >= duration:
# 最後に1回ダメージを入れたい場合はここで処理してもよい
_is_active = false
return
# tick_interval ごとにダメージを与える
if _tick_accumulator >= tick_interval:
_tick_accumulator -= tick_interval
_apply_poison_damage()
func _apply_poison_damage() -> void:
if _health_manager == null:
return
# damage_source の NodePath が設定されていればそれを解決して渡す
var source: Node = null
if damage_source != NodePath():
source = get_node_or_null(damage_source)
_health_manager.apply_damage(damage_per_tick, source)
## --- パブリックAPI: 外部から毒の開始/停止を制御する ---
func start(duration_override: float = -1.0) -> void:
## 毒を開始(または再開)する
## duration_override >= 0 なら、その値で duration を上書きして1回だけ有効にする
_is_active = true
_elapsed_time = 0.0
_tick_accumulator = 0.0
if duration_override >= 0.0:
duration = duration_override
func stop() -> void:
## 毒を即座に解除する
_is_active = false
func restart() -> void:
## タイマーをリセットして毒を再スタート
_is_active = true
_elapsed_time = 0.0
_tick_accumulator = 0.0
func is_active() -> bool:
## 現在毒が有効かどうかを返す
return _is_active
## --- 内部ヘルパー ---
func _find_health_manager() -> HealthManager:
# 1. 親ノードの子に HealthManager がいないか探す
var parent := get_parent()
if parent == null:
return null
for child in parent.get_children():
if child is HealthManager:
return child
# 2. 親ノード自身が HealthManager かどうか
if parent is HealthManager:
return parent
return null
使い方の手順
- HealthManager コンポーネントを用意する
上記のHealthManager.gdをres://scripts/components/HealthManager.gdなどに保存し、
HP を持たせたいプレイヤーや敵にアタッチします。 - PoisonEffect コンポーネントをシーンに追加する
継続ダメージを受ける対象(プレイヤーや敵)のシーンにPoisonEffect.gdを追加します。damage_per_tickやtick_interval、durationをエディタ上で調整しましょう。 - ノード構成の例:プレイヤーに毒状態を付与
例えば 2D のプレイヤーであれば、こんな構成にできます。Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── HealthManager (Node)
└── PoisonEffect (Node)
- Player 本体は移動や入力処理だけに集中
- HealthManager は HP とダメージ処理だけ
- PoisonEffect は毒の継続ダメージだけ
というふうに、責務ごとにコンポーネントを分けられます。
- ゲーム中に毒を付与・解除する
例えば、毒沼に触れた時だけ毒状態にしたい場合、毒沼側からプレイヤーの PoisonEffect を呼び出します。
具体例1:毒沼に入っている間だけ毒を受ける
毒沼のシーン構成例:
PoisonSwamp (Area2D) ├── CollisionShape2D └── Sprite2D
毒沼スクリプト例:
# PoisonSwamp.gd
extends Area2D
@export var poison_duration: float = 3.0
func _on_body_entered(body: Node) -> void:
# プレイヤーや敵に PoisonEffect が付いていれば毒を開始
var poison := body.get_node_or_null("PoisonEffect")
if poison and poison is PoisonEffect:
poison.start(poison_duration)
func _on_body_exited(body: Node) -> void:
# 出たら毒を止めるパターン
var poison := body.get_node_or_null("PoisonEffect")
if poison and poison is PoisonEffect:
poison.stop()
この場合、プレイヤーのシーン構成は:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── HealthManager (Node) └── PoisonEffect (Node) ← ここにアタッチ
具体例2:敵の攻撃が命中したら一定時間だけ毒
敵の攻撃(弾など)から、命中した対象に毒を付与する例です。
EnemyProjectile (Area2D) ├── CollisionShape2D └── Sprite2D
# EnemyProjectile.gd
extends Area2D
@export var poison_duration: float = 5.0
@export var poison_damage_per_tick: float = 3.0
@export var poison_tick_interval: float = 1.0
func _on_body_entered(body: Node) -> void:
# 命中した対象に PoisonEffect があればパラメータを上書きして毒を付与
var poison := body.get_node_or_null("PoisonEffect")
if poison and poison is PoisonEffect:
poison.damage_per_tick = poison_damage_per_tick
poison.tick_interval = poison_tick_interval
poison.start(poison_duration)
queue_free() # 弾は消える
このように、「毒のロジック」はあくまで PoisonEffect コンポーネント側に集約し、
攻撃やギミック側は「いつ start() を呼ぶか」だけを考えればよくなります。
メリットと応用
- シーン構造がスッキリする
Player や Enemy を「毒持ちバージョン」「炎上バージョン」などに分岐させる必要がなく、
ベースとなるキャラシーンにHealthManagerとPoisonEffectを貼るだけで OK です。 - 責務が分離される
HP ロジックは HealthManager、毒ロジックは PoisonEffect、移動やAIは別スクリプト、と分けられるので、
どこを触れば何が変わるのかが明確になります。 - 使い回しが効く
プレイヤー、敵、ギミック(動く床やトラップ)など、
HealthManager を持っているノードなら全部同じ PoisonEffect を共有できます。 - 将来的な拡張が楽
「毒ダメージは防御力を無視する」「毒ダメージ中は緑色に点滅させる」などの仕様変更も、
基本的には PoisonEffect だけをいじれば済みます。
さらに、同じ思想で BurnEffect や RegenEffect を作っていけば、
「状態異常は全部コンポーネントで管理」という綺麗な構造に育てていけますね。
改造案:スタックする毒(重ねがけでダメージ増加)
例えば、同じ敵から連続で毒を食らうと「毒レベル」が上がってダメージが増える、という仕様にしたい場合は、
PoisonEffect に「スタック数」を持たせるのが簡単です。
# PoisonEffect.gd の一部に追加する例
@export var max_stacks: int = 5
var _stacks: int = 1
func add_stack(extra_duration: float = 0.0) -> void:
## 毒のスタックを1つ増やす(上限あり)
_stacks = clamp(_stacks + 1, 1, max_stacks)
## 毒が無効なら有効化
if not _is_active:
start()
## スタック時に継続時間を延長したい場合
if extra_duration > 0.0:
duration += extra_duration
func _apply_poison_damage() -> void:
if _health_manager == null:
return
var source: Node = null
if damage_source != NodePath():
source = get_node_or_null(damage_source)
# スタック数に応じてダメージを増加させる
var total_damage := damage_per_tick * float(_stacks)
_health_manager.apply_damage(total_damage, source)
こうしておけば、攻撃側からは poison.add_stack() を呼ぶだけで「毒の重ねがけ」が表現できます。
状態異常の仕様がどんどん増えても、それぞれのコンポーネントを足したり差し替えたりするだけで対応できるのが、合成ベース設計の気持ちいいところですね。
