Godotで「敵がダメージを受けたときに血が飛び散る演出」を作ろうとすると、ありがちな実装はこんな感じですよね。
- 敵シーンの中に
GPUParticles2DやSprite2Dを直接仕込む - 敵のスクリプトから
particles.emitting = trueや「血痕ノードを生成して貼り付ける」ロジックを書く - 敵の種類が増えるたびに、コピペ地獄 or 継承ツリーが伸びていく
さらに、「血しぶきはパーティクル」「床や壁に残る血痕はSprite」と役割が分かれているせいで、
- どのシーンに血痕を付けるロジックを書いたのか分からなくなる
- 敵だけでなく、プレイヤーや壊れるオブジェクトにも血痕を付けたくなったときに再利用しづらい
こういう「演出系の処理」をキャラクター本体のスクリプトにベタ書きすると、すぐに肥大化して保守しづらくなります。
そこで今回は、どのノードにもポン付けできるコンポーネントとして、被弾時の血しぶき&血痕デカールをまとめて扱える「BloodSplatter」コンポーネントを用意してみましょう。
【Godot 4】血しぶきも血痕もまるっとお任せ!「BloodSplatter」コンポーネント
このコンポーネントをアタッチしておけば、
- ダメージを受けた瞬間にパーティクルで血を飛ばす
- 飛んだ先の壁や床に、自動で血痕デカール(Sprite)を貼り付ける
- 血痕は一定時間後にフェードアウト or 自動削除
といった処理を、どのキャラクターシーンにも簡単に再利用できます。
継承ではなく「合成(コンポーネント)」で演出を足していくスタイルですね。
GDScriptフルコード:BloodSplatter.gd
extends Node2D
class_name BloodSplatter
"""
被弾時に血のパーティクルを散らし、壁や床に血痕デカールを貼り付けるコンポーネント。
【想定用途】
- 敵やプレイヤーなど「ダメージを受けるオブジェクト」にアタッチ
- 外部から `spawn_blood_splatter()` を呼び出して使用
【実装方針】
- パーティクル: CPUParticles2D をコードで生成 / 再利用
- デカール: Sprite2D をRayCast2Dでヒットした位置に生成
"""
# =========================
# エディタから調整可能なパラメータ
# =========================
@export_category("Particles")
@export var particle_lifetime: float = 0.5:
set(value):
particle_lifetime = max(value, 0.05)
@export var particle_amount: int = 40:
set(value):
particle_amount = max(value, 1)
@export var particle_spread_deg: float = 70.0:
set(value):
particle_spread_deg = clampf(value, 0.0, 180.0)
@export var particle_speed_min: float = 150.0
@export var particle_speed_max: float = 350.0
@export var particle_color: Color = Color(0.6, 0.0, 0.0) # 濃い赤
@export var particle_random_color_variation: float = 0.15
@export var particle_gravity: Vector2 = Vector2(0, 900.0)
@export_category("Decal (血痕)")
@export var decal_texture: Texture2D
@export var decal_scale_min: float = 0.4
@export var decal_scale_max: float = 1.0
@export var decal_random_rotation: bool = true
@export var decal_lifetime: float = 10.0 # 0以下なら永続
@export var decal_fade_time: float = 2.0 # フェードアウトにかける時間
@export var decal_z_index: int = -1 # 床やキャラの下に表示したい場合は負の値に
@export_category("Raycast / 衝突設定")
@export var collision_mask: int = 1:
"""
血痕を付ける対象のコリジョンレイヤー。
例: 壁・床が "World" レイヤー(1) にあるなら 1 を指定。
"""
@export var max_decal_distance: float = 200.0:
set(value):
max_decal_distance = max(1.0, value)
@export_category("その他")
@export var auto_cleanup_particles: bool = true:
"""
true: パーティクルの寿命が尽きたら自動的にQueueFreeする
false: 再利用用に残す(カスタムプールを作る場合など)
"""
# =========================
# 内部用変数
# =========================
var _particle_node: CPUParticles2D
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.randomize()
_init_particles()
# パーティクルノードを初期化
func _init_particles() -> void:
_particle_node = CPUParticles2D.new()
add_child(_particle_node)
_particle_node.name = "BloodParticles"
# パーティクル設定
_particle_node.lifetime = particle_lifetime
_particle_node.one_shot = true
_particle_node.amount = particle_amount
_particle_node.emitting = false
_particle_node.local_coords = true
# ベース色
var mat := ParticleProcessMaterial.new()
mat.gravity = particle_gravity
mat.initial_velocity_min = particle_speed_min
mat.initial_velocity_max = particle_speed_max
mat.spread = deg_to_rad(particle_spread_deg)
mat.color = particle_color
mat.color_random = particle_random_color_variation
_particle_node.process_material = mat
# =========================
# 外部から呼び出すAPI
# =========================
## 血しぶきと血痕を生成するメイン関数
## @param world_position: 被弾したワールド座標
## @param hit_normal: 被弾面の法線ベクトル(例: 壁なら (1,0) や (-1,0)、床なら (0,-1))
## 不明な場合は Vector2.ZERO を渡してもOK(パーティクルはランダム方向)
func spawn_blood_splatter(world_position: Vector2, hit_normal: Vector2 = Vector2.ZERO) -> void:
if not is_inside_tree():
return
# パーティクルを発生させる
_spawn_particles(world_position, hit_normal)
# 血痕デカールを壁や床に貼り付ける
if decal_texture:
_spawn_decal(world_position, hit_normal)
# =========================
# パーティクル生成ロジック
# =========================
func _spawn_particles(world_position: Vector2, hit_normal: Vector2) -> void:
if _particle_node == null:
_init_particles()
# 親のローカル座標系に変換
var local_pos := to_local(world_position)
_particle_node.global_position = world_position
# 飛び散る向きをざっくり決める
var mat := _particle_node.process_material as ParticleProcessMaterial
if hit_normal != Vector2.ZERO:
# 法線と反対方向に飛ぶ(=表面から外側へ)
var dir := -hit_normal.normalized()
mat.direction = dir
else:
# ランダムな方向
var angle := _rng.randf_range(0.0, TAU)
mat.direction = Vector2.RIGHT.rotated(angle)
# パラメータを反映
_particle_node.lifetime = particle_lifetime
_particle_node.amount = particle_amount
_particle_node.emitting = false
_particle_node.restart()
_particle_node.emitting = true
# 自動クリーンアップ
if auto_cleanup_particles:
var timer := get_tree().create_timer(particle_lifetime + 0.1)
timer.timeout.connect(func():
if is_instance_valid(_particle_node):
_particle_node.queue_free()
)
# =========================
# デカール生成ロジック
# =========================
func _spawn_decal(world_position: Vector2, hit_normal: Vector2) -> void:
# RayCastの方向と長さを決める
var cast_from := world_position
var cast_to: Vector2
if hit_normal != Vector2.ZERO:
# 法線方向に少し戻って貼り付け位置を探す
cast_to = cast_from + hit_normal.normalized() * max_decal_distance
else:
# 法線情報がない場合は、下方向に床を探す(よくあるケース)
cast_to = cast_from + Vector2.DOWN * max_decal_distance
var space_state := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(cast_from, cast_to)
query.collision_mask = collision_mask
var result := space_state.intersect_ray(query)
if result.is_empty():
return
var hit_pos: Vector2 = result.position
var hit_n: Vector2 = result.normal
var decal := Sprite2D.new()
decal.texture = decal_texture
decal.centered = true
decal.global_position = hit_pos
decal.z_index = decal_z_index
# スケールをランダムに
var scale_val := _rng.randf_range(decal_scale_min, decal_scale_max)
decal.scale = Vector2.ONE * scale_val
# 回転をランダム or 法線に合わせる
if decal_random_rotation:
decal.rotation = _rng.randf_range(0.0, TAU)
else:
# 法線と直交する向きに軽く回転させる(2Dでは見た目の差は小さい)
decal.rotation = hit_n.angle() + PI * 0.5
# デカールはワールド直下に置くと管理しやすい
var world_root := get_tree().current_scene
if world_root:
world_root.add_child(decal)
else:
# フォールバック: 自分の親に付ける
get_parent().add_child(decal)
# ライフタイム管理
if decal_lifetime > 0.0:
_fade_and_free_decal(decal)
# デカールをフェードアウトさせてから削除
func _fade_and_free_decal(decal: Sprite2D) -> void:
# フェード時間が0以下なら、ライフタイム後に即削除
if decal_fade_time <= 0.0:
var timer := get_tree().create_timer(decal_lifetime)
timer.timeout.connect(func():
if is_instance_valid(decal):
decal.queue_free()
)
return
# Tweenでアルファ値を徐々に下げる
var tween := create_tween()
tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
# フェード開始まで待つ
tween.tween_interval(decal_lifetime)
# アルファ値を 1 → 0 へ
var start_color := decal.modulate
var end_color := start_color
end_color.a = 0.0
tween.tween_property(decal, "modulate", end_color, decal_fade_time)
# 完了後に削除
tween.finished.connect(func():
if is_instance_valid(decal):
decal.queue_free()
)
使い方の手順
ここでは「敵がダメージを受けたときに血しぶき&血痕を出す」例で説明します。
プレイヤーにも、壊れる樽にも、同じコンポーネントをポン付けできるのがポイントです。
手順①:BloodSplatterスクリプトを用意する
- 上記の
BloodSplatter.gdをプロジェクト内(例:res://components/BloodSplatter.gd)に保存します。 - Godotエディタで開くと、スクリプトクラスとして認識されるので、ノードにアタッチしやすくなります。
手順②:敵シーンにコンポーネントとしてアタッチ
敵シーンの構成例(2Dの場合):
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── BloodSplatter (Node2D) ← このノードに BloodSplatter.gd をアタッチ
- Enemyシーンを開き、子ノードとして
Node2Dを追加します。 - そのNode2Dに
BloodSplatter.gdをアタッチします。 - インスペクタから以下を設定します:
Decal / decal_texture: 血痕用のテクスチャ(PNG)Raycast / collision_mask: 壁や床のレイヤー(例:1)- 必要に応じて、パーティクルの色や量、デカールの寿命などを調整
手順③:ダメージ処理から呼び出す
敵のスクリプト(例:Enemy.gd)で、ダメージを受けたタイミングで spawn_blood_splatter() を呼び出します。
extends CharacterBody2D
@onready var blood_splatter: BloodSplatter = $BloodSplatter
var health: int = 100
func apply_damage(amount: int, hit_position: Vector2, hit_normal: Vector2 = Vector2.ZERO) -> void:
health -= amount
# 血しぶき&血痕を発生させる
if blood_splatter:
blood_splatter.spawn_blood_splatter(hit_position, hit_normal)
if health <= 0:
die()
func die() -> void:
queue_free()
hit_position: 弾丸や攻撃が当たったワールド座標hit_normal: 衝突した面の法線。弾丸側でintersect_rayの結果から渡すとより自然な飛び散りになります。
プレイヤーに使う場合も同様で、プレイヤーシーンに BloodSplatter ノードを足して、ダメージ処理で呼ぶだけです。
手順④:弾丸側から法線を渡す例
壁に当たった方向に応じて、血しぶきの向きを自然にしたい場合、弾丸側でRayCastの結果から法線を取得して渡します。
extends Area2D
@export var speed: float = 800.0
var velocity: Vector2
func _physics_process(delta: float) -> void:
position += velocity * delta
func _on_body_entered(body: Node) -> void:
# ぶつかった相手が BloodSplatter を持っているか確認
if body.has_node("BloodSplatter"):
var blood: BloodSplatter = body.get_node("BloodSplatter")
# Ray を飛ばして衝突位置と法線を取得(簡易版)
var from := global_position
var to := global_position + velocity.normalized() * 10.0
var space_state := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(from, to)
var result := space_state.intersect_ray(query)
var hit_pos := result.get("position", global_position)
var hit_normal := result.get("normal", Vector2.ZERO)
blood.spawn_blood_splatter(hit_pos, hit_normal)
queue_free()
これで、弾丸がどの角度から当たっても、それっぽい血しぶき&血痕が発生するようになります。
メリットと応用
この「BloodSplatter」コンポーネントを使うことで、
- 敵・プレイヤー・オブジェクトのスクリプトから「演出ロジック」を追い出せる
- 「ダメージを受けたらコンポーネントに通知する」だけで済むので、メインロジックがスッキリする
- 血の量や色、デカールの寿命などを インスペクタから調整可能 なので、レベルデザイナやアーティストもいじりやすい
- 「敵Aは血がドバドバ、敵Bは控えめ」など、シーンごとのバリエーションも簡単に作れる
特に、Godot標準の流儀だと「敵シーンの中にパーティクルを埋め込む→敵ごとに設定を変える」というパターンになりがちですが、
コンポーネントとして分離しておくと、
- 敵シーンのノード階層が肥大化しにくい
- 「血演出」だけを別プロジェクトに持っていくのも簡単
というメリットがあります。継承ツリーを増やさずに、演出を「後付け」できるのが良いですね。
改造案:出血レベルでパーティクル量を変える
例えば、ダメージ量に応じて血の量を増減させたい場合は、こんなラッパー関数を追加すると便利です。
# BloodSplatter.gd 内に追加する例
func spawn_blood_with_intensity(world_position: Vector2, hit_normal: Vector2, intensity: float) -> void:
# intensity: 0.0 ~ 1.0 を想定(0で最小、1で最大)
intensity = clampf(intensity, 0.0, 1.0)
var original_amount := particle_amount
var original_lifetime := particle_lifetime
# ダメージが大きいほど量と寿命を増やす
particle_amount = int(lerpf(original_amount * 0.3, original_amount * 2.0, intensity))
particle_lifetime = lerpf(original_lifetime * 0.5, original_lifetime * 1.5, intensity)
spawn_blood_splatter(world_position, hit_normal)
# 元の値に戻しておく
particle_amount = original_amount
particle_lifetime = original_lifetime
呼び出し側では、
var intensity := clampf(damage / 100.0, 0.0, 1.0)
blood_splatter.spawn_blood_with_intensity(hit_position, hit_normal, intensity)
のように使えば、「強い攻撃ほど血が多く飛び散る」といった表現も簡単に実現できます。
このあたりをさらに発展させて、「出血タイプ別にテクスチャを変える」「毒ダメージは緑色にする」など、
コンポーネントを中心に演出を組み立てていくと、継承に頼らない柔軟な設計になっていきますね。




