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:
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cwnr3ggsrii76
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cocj1seaa6a1
|
||||||
Reference in New Issue
Block a user