From 9458085abb186cb29118efb17389216643bb598b Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Tue, 8 Sep 2026 14:55:46 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj --- systems/combat/damage_event.gd | 80 ++++++++++++++++++++ systems/combat/damage_event.gd.uid | 1 + tests/damage_pipeline_test.gd | 113 +++++++++++++++++++++++++++++ tests/damage_pipeline_test.gd.uid | 1 + 4 files changed, 195 insertions(+) create mode 100644 systems/combat/damage_event.gd create mode 100644 systems/combat/damage_event.gd.uid create mode 100644 tests/damage_pipeline_test.gd create mode 100644 tests/damage_pipeline_test.gd.uid diff --git a/systems/combat/damage_event.gd b/systems/combat/damage_event.gd new file mode 100644 index 0000000..8df68f6 --- /dev/null +++ b/systems/combat/damage_event.gd @@ -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 diff --git a/systems/combat/damage_event.gd.uid b/systems/combat/damage_event.gd.uid new file mode 100644 index 0000000..900e46e --- /dev/null +++ b/systems/combat/damage_event.gd.uid @@ -0,0 +1 @@ +uid://cwnr3ggsrii76 diff --git a/tests/damage_pipeline_test.gd b/tests/damage_pipeline_test.gd new file mode 100644 index 0000000..1a85b20 --- /dev/null +++ b/tests/damage_pipeline_test.gd @@ -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) diff --git a/tests/damage_pipeline_test.gd.uid b/tests/damage_pipeline_test.gd.uid new file mode 100644 index 0000000..5a77d51 --- /dev/null +++ b/tests/damage_pipeline_test.gd.uid @@ -0,0 +1 @@ +uid://cocj1seaa6a1