Route the bull's hits through the typed DamageEvent pipeline

Hitbox now builds a DamageEvent (cause + hit position + shaped knock-up) and
calls the bull's new receive_damage(), instead of two stringly Object.call()s.
receive_damage adapts over the existing pip/knock-up logic so behaviour is
identical -- the characterization test drives both the string and typed paths
and asserts the same outcomes. Wire fx/damage/harness tests into run_tests.sh
+ CLAUDE.md. All hitbox guard tests green (stab, bear hitbox, ram, charge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
This commit is contained in:
2026-09-05 00:17:04 +03:00
parent 973db7d8b8
commit 70274f1773
6 changed files with 68 additions and 5 deletions
+3
View File
@@ -108,6 +108,9 @@ bash run_tests.sh
# Individual tests:
gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
godot --headless --script tests/harness_selftest.gd # shared test_helpers: seeded RNG + approx + tally, ~1 s
godot --headless --script tests/damage_pipeline_test.gd # bull's hit reception (take_sword_hit + typed receive_damage/DamageEvent), ~3 s
godot --headless --script tests/fx_gallery_test.gd # every registered Fx effect spawns + self-frees, ~2 s
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: ragdoll build / sim-start / blood / mass hit, ~90 s
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
+19
View File
@@ -41,3 +41,22 @@ static func make(kind: Cause, at: Vector3, src: Node = null) -> DamageEvent:
## ("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
+11 -5
View File
@@ -80,11 +80,17 @@ func _apply_hit(body: Node3D) -> void:
return
_hit_this_swing[body] = true
_did_hit = true
body.call(&"take_sword_hit", damage_cause)
if knock_up > 0.0:
var v := _knock_up_for(body)
if v > 0.0:
body.call(&"apply_knock_up", v)
if body.has_method(&"receive_damage"):
var event := DamageEvent.make(DamageEvent.cause_from(damage_cause), body.global_position, self)
if knock_up > 0.0:
event.knock_up = _knock_up_for(body)
body.call(&"receive_damage", event)
else:
body.call(&"take_sword_hit", damage_cause)
if knock_up > 0.0:
var v := _knock_up_for(body)
if v > 0.0:
body.call(&"apply_knock_up", v)
# Scale the pop by how far the body is from this hitbox's origin (planar). See the export docs.
+9
View File
@@ -1222,6 +1222,15 @@ func apply_knock_up(v: float) -> void:
velocity.y = maxf(velocity.y, v)
# Typed entry point for the unified hit pipeline: a Hitbox (or any attacker) hands the bull a
# DamageEvent instead of calling take_sword_hit directly. A thin adapter over the existing pip /
# knock-up logic, so behaviour is unchanged while callers migrate off the stringly path.
func receive_damage(event: DamageEvent) -> void:
take_sword_hit(event.cause_name(), event.hit_position)
if event.knock_up > 0.0:
apply_knock_up(event.knock_up)
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
# entry point, biased outward from the bull's centre (so it reads as bursting from the
# hide) with a touch of lift before gravity drags it down.
+8
View File
@@ -18,6 +18,14 @@ echo ""
echo "=== Logic tests ==="
"$GODOT" --headless --script tests/logic_test.gd
echo ""
echo "=== Damage pipeline (bull's hit reception through DamageEvent) ==="
"$GODOT" --headless --script tests/damage_pipeline_test.gd
echo ""
echo "=== FX gallery smoke (every registered Fx effect spawns + frees) ==="
"$GODOT" --headless --script tests/fx_gallery_test.gd
echo ""
echo "=== Console focus tests ==="
"$GODOT" --headless --script tests/console_focus_test.gd
+18
View File
@@ -87,6 +87,24 @@ func _run() -> void:
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()