Files
Bullosseum/tests/bear_hitbox_test.gd
T
richard 7b6a141767 Reorganize the codebase into role-based folders (snake_case)
Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.

Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.

Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 00:04:41 +03:00

122 lines
4.8 KiB
GDScript

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://actors/bear/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)