Combat overhaul: charge/gore, bear ram, matador taunt reactions

Bull charge damage:
- Tunnel-proof swept detection (prev→now segment) for matador gore and bear
  ram, so a 66 m/s dash no longer skips clean over the thin HitArea.
- Horns-first cone (bull_gore_arc): running into an enemy only wounds it when
  charging roughly at it, not a sideways brush.
- Dash is a committed lunge: bypasses the horns cone AND the survival roll, so a
  dash connect is a guaranteed kill. Cruise charges keep a speed-scaled roll
  (mat_charge_pierce). Roll ability defers to its own pop (is_rolling guard).

Bear:
- Horns-first swept ram detection (edge-triggered: one charge = one wound);
  a tunnelling or point-blank charge now lands where body_entered wouldn't.
- Overhead smash lane extends faster (0.1s) and further (20 m).
- Leap slam launches the bull from anywhere in the circle (ring_frac 0).
- Removed stray-quote syntax errors that broke the script.

Matador:
- Collider-driven stab (BladeHitbox) with recovery beat + taunt-after-hit so it
  stops machine-gunning the bull.
- A taunting matador answers a charge: spear from range, sword up close.

Spear projectile masks the BULL layer so throws connect.

Tests: matador_charge/stab/spear/taunt_react, bear_ram/hitbox/fx (+ unified
_fake_bull stand-in). Trimmed verbose comments.

Note: bear claw-emitter/death work is still WIP (3 bear_boss_test failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 00:59:23 +03:00
parent 4efc6763f0
commit 5c1e881a53
39 changed files with 1555 additions and 376 deletions
+121
View File
@@ -0,0 +1,121 @@
extends SceneTree
## Bear attack-collider test. The colliders + their motion live in BearAttackHitboxes.tscn: the
## leap slam is a ground circle (cylinder) whose "leap_slam" clip pops it open; the overhead smash
## is a forward box lane whose "smash_travel" clip grows it out from the bear. bear.gd fires the
## clip, arms the knock-up shaping, and sweeps while it runs. This asserts: a bull anywhere in the
## leap circle is mauled AND launched by default (bear_leap_ring_frac 0), while raising ring_frac
## carves a grounded inner zone; the smash lane catches bulls near AND far as the front travels,
## throws the further one higher, and spares anything off the lane.
## Run: godot --headless --script res://tests/bear_hitbox_test.gd
var _pass := 0
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _ok(c: bool, m: String) -> void:
if c:
_pass += 1
print(" PASS: ", m)
else:
_fail += 1
print(" FAIL: ", m)
func _make_bull(pos: Vector3) -> CharacterBody3D:
var b := CharacterBody3D.new()
b.collision_layer = 2 # PhysicsLayers.BULL — what the hitboxes mask
b.collision_mask = 0
b.add_to_group(&"player")
b.set_script(load("res://tests/_fake_bull.gd"))
var cs := CollisionShape3D.new()
var shape := SphereShape3D.new()
shape.radius = 0.6
cs.shape = shape
b.add_child(cs)
root.add_child(b)
b.global_position = pos
return b
# Drive one attack collider clip to completion, sweeping each physics frame like _physics_process.
func _drive(bear: Node, fire: String) -> void:
bear.call(fire)
for i in 60:
bear.call("_tick_attack_hitbox")
await physics_frame
if bear.get("_active_hitbox") == null:
break
func _run() -> void:
var bear := (load("res://Bear.tscn") as PackedScene).instantiate() as Node3D
root.add_child(bear)
bear.set_physics_process(false) # freeze the AI; we fire the colliders ourselves
await physics_frame
var leap_hb := bear.find_child("LeapSlamHitbox", true, false) as Area3D
var smash_hb := bear.find_child("SmashHitbox", true, false) as Area3D
_ok(leap_hb != null, "leap slam cylinder hitbox exists (from BearAttackHitboxes.tscn)")
_ok(smash_hb != null, "overhead smash lane hitbox exists (from BearAttackHitboxes.tscn)")
if leap_hb == null or smash_hb == null:
_finish()
return
var dp := root.get_node("/root/DP")
var slam_r: float = (leap_hb.get_node("CollisionShape3D").shape as CylinderShape3D).radius
var lane: float = (smash_hb.get_node("CollisionShape3D").shape as BoxShape3D).size.z
# ── Leap slam: by default (bear_leap_ring_frac 0) the bull is launched from ANYWHERE in the
# circle — no safe inner spot. Off the circle is still spared.
dp.call("set_value", "bear_leap_ring_frac", 0.0)
var center := _make_bull(Vector3(0.0, 0.5, 0.0))
var midring := _make_bull(Vector3(slam_r * 0.5, 0.5, 0.0))
var outside := _make_bull(Vector3(slam_r + 3.0, 0.5, 0.0))
await physics_frame
await _drive(bear, "_fire_leap_hitbox")
_ok(center.hits >= 1 and center.knocked_up > 0.0, "bull at the circle center is mauled AND launched")
_ok(midring.hits >= 1 and midring.knocked_up > 0.0, "bull mid-circle is mauled AND launched")
_ok(outside.hits == 0, "bull outside the slam radius is spared")
center.queue_free()
midring.queue_free()
outside.queue_free()
await physics_frame
# Raising ring_frac restores a grounded inner zone that only mauls (the slider still works).
dp.call("set_value", "bear_leap_ring_frac", 0.75)
var frac: float = dp.f("bear_leap_ring_frac")
var inner := _make_bull(Vector3(slam_r * (frac * 0.5), 0.5, 0.0))
var ring := _make_bull(Vector3(slam_r * (frac + (1.0 - frac) * 0.5), 0.5, 0.0))
await physics_frame
await _drive(bear, "_fire_leap_hitbox")
_ok(inner.hits >= 1 and inner.knocked_up == 0.0, "raised ring_frac keeps the inner circle grounded")
_ok(ring.knocked_up > 0.0, "raised ring_frac still launches the outer ring")
inner.queue_free()
ring.queue_free()
dp.call("set_value", "bear_leap_ring_frac", 0.0)
await physics_frame
# ── Overhead smash: the lane's leading edge travels the whole way, so bulls near AND far are
# both caught as the front passes; the further one is thrown higher. Off-lane is spared.
var near := _make_bull(Vector3(0.0, 0.5, lane * 0.2))
var far := _make_bull(Vector3(0.0, 0.5, lane * 0.8))
var beside := _make_bull(Vector3(lane * 0.5, 0.5, lane * 0.5))
var behind := _make_bull(Vector3(0.0, 0.5, -3.0))
await physics_frame
await _drive(bear, "_begin_smash_hitbox")
_ok(near.hits >= 1, "bull near the bear is caught as the shockwave starts")
_ok(far.hits >= 1, "bull far down the lane is caught when the front reaches it")
_ok(far.knocked_up > near.knocked_up, "the further hit is thrown higher")
_ok(beside.hits == 0, "bull beside the lane is spared (a lane, not a circle)")
_ok(behind.hits == 0, "bull behind the bear is spared")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)