Add typed DamageEvent hit pipeline

A typed DamageEvent contract (systems/combat/damage_event.gd) as a single
entry point for every attacker, plus a characterization test locking the bull's
damage reception. Attacker/receiver wiring rides along with the moved actor
scripts in the reorg commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
2026-09-08 14:55:46 +03:00
parent 565c132848
commit 9458085abb
4 changed files with 195 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
class_name DamageEvent
extends RefCounted
## The one typed payload for every hit in the game — a bull goring a matador, a bear slam popping
## the bull, a thrown spear landing. It replaces the scattered, stringly Object.call(&"take_sword_hit",
## "gored") paths: an emitter (a Hitbox) fills one of these and hands it to the target's
## receive_damage(event). One shape to read, log and type-check, instead of four call conventions.
##
## It serves both victim models the game already has: the bull, which dies on one clean hit
## (lethal + cause + hit_position), and an enemy, which is launched/ragdolled by an impulse
## (knockback_dir · knockback_strength + knock_up). A given hit just leaves the fields it doesn't
## use at their defaults.
enum Cause { GENERIC, GORED, MAULED, THROWN, SLAMMED, KICKED, DASHED }
## Who dealt the hit — for attribution and for ignoring self-overlap. May be null.
var source: Node = null
## What kind of hit; drives the victim's reaction (blood/vignette/FX) and any future resistances.
var cause: Cause = Cause.GENERIC
## World contact point, so impact FX spawn where the hit actually landed.
var hit_position: Vector3 = Vector3.ZERO
## Planar push direction (need not be normalised). Vector3.ZERO = no horizontal knockback.
var knockback_dir: Vector3 = Vector3.ZERO
## Impulse magnitude behind knockback_dir — the enemy launch/ragdoll strength.
var knockback_strength: float = 0.0
## Upward launch in m/s (an overhead smash pop, a leap-slam outer ring). 0 = a grounded hit.
var knock_up: float = 0.0
## One clean hit ends the run for the bull; most enemy-dealt hits are lethal to it. Enemies that
## are merely launched (not killed) by the bull's abilities set this false.
var lethal: bool = true
static func make(kind: Cause, at: Vector3, src: Node = null) -> DamageEvent:
var e := DamageEvent.new()
e.cause = kind
e.hit_position = at
e.source = src
return e
## The single entry point every attacker uses to wound a target: build the typed event and hand it
## to receive_damage(). Falls back to the legacy string call for any target not yet migrated, so a
## caller never has to branch on which contract the victim speaks. `knock_up_v` is the already-shaped
## pop (0 = grounded); callers that scale it by distance compute it before calling.
static func deal(target: Node, kind: Cause, at: Vector3, src: Node = null, knock_up_v: float = 0.0) -> void:
if target == null:
return
if target.has_method(&"receive_damage"):
var event := make(kind, at, src)
event.knock_up = knock_up_v
target.call(&"receive_damage", event)
return
if target.has_method(&"take_sword_hit"):
target.call(&"take_sword_hit", make(kind, at, src).cause_name(), at)
if knock_up_v > 0.0 and target.has_method(&"apply_knock_up"):
target.call(&"apply_knock_up", knock_up_v)
## Lower-case name of the cause, for logs and for the bull's existing cause-keyed reactions
## ("gored" / "mauled" / "thrown") during the migration off string causes.
func cause_name() -> String:
return String(Cause.keys()[cause]).to_lower()
## Maps a legacy cause string onto the enum, so a Hitbox's exported damage_cause round-trips
## exactly (gored/mauled/thrown are the only ones in use). Unknown strings fall back to GENERIC.
static func cause_from(text: String) -> Cause:
match text.to_lower():
"gored":
return Cause.GORED
"mauled":
return Cause.MAULED
"thrown":
return Cause.THROWN
"slammed":
return Cause.SLAMMED
"kicked":
return Cause.KICKED
"dashed":
return Cause.DASHED
return Cause.GENERIC
+1
View File
@@ -0,0 +1 @@
uid://cwnr3ggsrii76
+113
View File
@@ -0,0 +1,113 @@
extends SceneTree
## Characterization test for the bull's damage reception. It pins the OBSERVABLE behaviour of
## take_sword_hit / apply_knock_up — pip loss, i-frame absorption, a single tagged death, and
## upward-only knock-up — before the WS2 slice reroutes them through DamageEvent, so the rewire can
## prove it changed nothing. When receive_damage(DamageEvent) lands, add a block that drives it and
## asserts the identical outcomes.
## Run: godot --headless --script tests/damage_pipeline_test.gd
var _pass := 0
var _fail := 0
var _died_count := 0
var _last_cause := ""
var _last_hp := -1
func _init() -> void:
_run.call_deferred()
func _ok(cond: bool, msg: String) -> void:
if cond:
_pass += 1
print(" PASS: ", msg)
else:
_fail += 1
print(" FAIL: ", msg)
func _on_died(cause: String) -> void:
_died_count += 1
_last_cause = cause
func _on_health(current: int, _max_hp: int) -> void:
_last_hp = current
func _run() -> void:
(root.get_node("/root/Run") as Node).call("ensure_run")
var scene: Node = load("res://levels/game_shell.tscn").instantiate()
root.add_child(scene)
current_scene = scene
await create_timer(0.8).timeout
var players := get_nodes_in_group(&"player")
_ok(not players.is_empty(), "bull present in the shell")
if players.is_empty():
return _finish()
var bull: Node = players[0]
bull.connect(&"died", _on_died)
bull.connect(&"health_changed", _on_health)
# Everything below runs synchronously (no await), so no physics frame or enemy turn interleaves —
# the bull's state changes only from the calls under test.
bull.set(&"_max_hp", 3)
bull.set(&"_hp", 3)
bull.set(&"_dead", false)
bull.set(&"_hit_iframes", 0.0)
bull.set(&"velocity", Vector3.ZERO)
bull.call(&"take_sword_hit", "gored")
_ok(int(bull.get(&"_hp")) == 2, "a clean hit removes one pip (3→2)")
_ok(_last_hp == 2, "health_changed reports the new pip count")
bull.call(&"take_sword_hit", "gored")
_ok(int(bull.get(&"_hp")) == 2, "i-frames absorb a second hit in the same window")
bull.set(&"_hit_iframes", 0.0)
bull.call(&"take_sword_hit", "gored")
_ok(int(bull.get(&"_hp")) == 1, "a hit after i-frames removes another pip (2→1)")
bull.set(&"_hit_iframes", 0.0)
bull.call(&"take_sword_hit", "gored")
_ok(int(bull.get(&"_hp")) <= 0, "the last pip drops the bull")
_ok(_died_count == 1, "died emits exactly once")
_ok(_last_cause == "gored", "died carries the hit's cause")
bull.set(&"_hit_iframes", 0.0)
bull.call(&"take_sword_hit", "thrown")
_ok(_died_count == 1, "a downed bull takes no further hits (death latches)")
bull.set(&"velocity", Vector3.ZERO)
bull.call(&"apply_knock_up", 5.0)
_ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 5.0), "knock_up raises velocity.y to the pop")
bull.call(&"apply_knock_up", 3.0)
_ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 5.0), "a smaller knock_up never lowers it")
bull.call(&"apply_knock_up", 8.0)
_ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 8.0), "a larger knock_up raises it further")
# The typed pipeline (Hitbox → DamageEvent → receive_damage) must produce identical outcomes to
# the string path it now wraps.
_last_hp = -1
bull.set(&"_hp", 3)
bull.set(&"_dead", false)
bull.set(&"_hit_iframes", 0.0)
bull.set(&"velocity", Vector3.ZERO)
bull.call(&"receive_damage", DamageEvent.make(DamageEvent.Cause.MAULED, Vector3.ZERO))
_ok(int(bull.get(&"_hp")) == 2, "receive_damage removes a pip like take_sword_hit")
_ok(_last_hp == 2, "receive_damage emits health_changed with the new pip count")
bull.set(&"_hit_iframes", 0.0)
bull.set(&"velocity", Vector3.ZERO)
var popping := DamageEvent.make(DamageEvent.Cause.SLAMMED, Vector3.ZERO)
popping.knock_up = 6.0
bull.call(&"receive_damage", popping)
_ok(is_equal_approx(float((bull.get(&"velocity") as Vector3).y), 6.0), "receive_damage applies the event's knock_up")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://cocj1seaa6a1