Files
Bullosseum/tests/gameplay_test.gd
T

483 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
## Gameplay regression test — runs the full scene, captures a motion frame strip,
## and asserts that physics/IK stays within sane bounds.
##
## Run (headless, assertions only — screenshots will be blank):
## godot --headless --script tests/gameplay_test.gd
##
## Run (with display for real screenshots):
## godot --script tests/gameplay_test.gd
##
## Outputs: tests/output/gameplay/*.png + tests/output/gameplay/report.txt
## Exit code 0 = all assertions passed, 1 = any failure.
const OUTPUT_DIR := "res://tests/output/gameplay"
# Tail bone names mirrored from bull_legs.gd — update both if rig changes.
const TAIL_BONES: Array[StringName] = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
&"tail_5", &"tail_6", &"tail_tip_1", &"tail_tip_2",
]
var _passed: int = 0
var _failed: int = 0
var _report: PackedStringArray = []
var _matador_scene: PackedScene = null
# Loaded lazily (not preloaded): as a --script SceneTree entry, a top-level
# preload of a matador scene compiles matador.gd before the DP autoload binds.
func _mat_scene() -> PackedScene:
if _matador_scene == null:
_matador_scene = load("res://Matador.tscn")
return _matador_scene
func _init() -> void:
_run.call_deferred()
func _run() -> void:
DirAccess.make_dir_recursive_absolute(OUTPUT_DIR)
var scene_res := load("res://scene.tscn")
if not scene_res:
push_error("gameplay_test: failed to load scene.tscn")
quit(1)
return
var scene: Node = scene_res.instantiate()
root.add_child(scene)
current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene
# Let physics, AI, and IK warm up.
await create_timer(0.5).timeout
# The matador is lethal on contact now, so a wandering one could end the match
# during the non-combat checks below. Park the bull on a temp pad far out in the
# void, well out of any matador's reach (the roll tests use this spot too).
_isolate_bull(scene)
_check_bull_animation()
await _check_overlays()
# The overlay check needed live matadors; now remove them. The aggressive matador
# pursues the bull, and the ragdoll check spawns its own fresh one, so none should
# be roaming during the capture / tail / roll phases.
for m: Node in get_nodes_in_group(&"matador"):
m.queue_free()
await physics_frame
# Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle).
for i in range(5):
_capture_frame("motion_%02d.png" % i)
await create_timer(0.4).timeout
# After ~2.5 s the tail Verlet chain and IK should be stable.
_check_tail_integrity()
# Roll first, in open space, before any matador dies (a kill ends the match).
await _check_roll_ability(scene)
await create_timer(0.1).timeout # let the roll pop-test's throwaway matador free
# Ragdoll check on a FRESH matador spawned at the origin (the isolated bull is far
# away, so this one can't reach it). Not wired to the spawner, so its death won't
# trip the win screen. Ragdoll it immediately and check the bones don't explode.
var mat_scene := _mat_scene()
if mat_scene != null:
var mat: Node3D = mat_scene.instantiate()
scene.add_child(mat)
mat.global_position = Vector3(0.0, 1.0, 0.0)
await create_timer(0.3).timeout
var start_pos: Vector3 = mat.global_position
mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0)
_capture_frame("ragdoll_trigger.png")
await create_timer(0.8).timeout
_capture_frame("ragdoll_result.png")
_check_ragdoll_sanity(mat, start_pos)
else:
_note("ragdoll check skipped — Matador.tscn missing")
# Last — a bull hit raises the lose screen and pauses the tree.
await _check_game_over_wiring(scene)
_finish()
# Drop a small static floor at (-45, -45) and stand the bull on it — 60+ m from the
# arena, so no matador can close the gap within the test window.
func _isolate_bull(scene: Node) -> void:
var pad := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(20, 1, 20)
cs.shape = box
pad.add_child(cs)
scene.add_child(pad)
pad.global_position = Vector3(-45, -0.5, -45) # top surface at y = 0
var players := get_nodes_in_group(&"player")
if not players.is_empty():
(players[0] as Node3D).global_position = Vector3(-45, 1, -45)
# ── Roll ability (Rammus Powerball) ────────────────────────────────────────────
# Two behaviours: the speed ramps up the longer the ball rolls (base → max), capped
# at roll_max_speed with no wall-boost overshoot; and ramming a matador pops the ball
# (the roll ends). Rolled in empty space so it doesn't touch the real match matadors.
func _check_roll_ability(scene: Node) -> void:
print("\n-- check_roll_ability --")
var dp: Node = root.get_node_or_null("/root/DP")
var players := get_nodes_in_group(&"player")
if dp == null or players.is_empty():
_note("roll check: DP / player missing")
return
var player: Node = players[0]
var ap: AnimationPlayer = _find_anim_player(player)
_assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)")
var restore_dur: float = dp.f("roll_duration")
var restore_ramp: float = dp.f("roll_rampup_time")
dp.set_value("roll_duration", 1.2)
dp.set_value("roll_rampup_time", 0.7)
player.global_position = Vector3(-45, 1, -45) # empty void, away from the arena
player.velocity = Vector3.ZERO
player.cube_guy.rotation.y = 0.0
player.ability_cd[3] = 0.0
player._activate_roll()
_assert_true(player._active_ability == 3, "roll activates in slot 3")
if ap != null:
_assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip")
var early := -1.0
var peak := 0.0
for n in 30:
await create_timer(0.03).timeout
var spd := Vector2(player.velocity.x, player.velocity.z).length()
if n == 1:
early = spd
peak = maxf(peak, spd)
_assert_true(peak > early + 5.0, "roll speed ramps up over time (Powerball)")
_assert_true(peak <= dp.f("roll_max_speed") + 2.0, "roll speed caps at roll_max_speed (no wall boost)")
dp.set_value("roll_duration", restore_dur)
dp.set_value("roll_rampup_time", restore_ramp)
await _check_roll_pop(scene, player)
# Roll into a throwaway matador (not one of the match spawns, so its death doesn't
# end the game) and confirm the ball pops: the roll ability ends on contact.
func _check_roll_pop(scene: Node, player: Node) -> void:
var mat_scene := _mat_scene()
if mat_scene == null:
_note("roll pop: Matador.tscn missing")
return
var mat: Node3D = mat_scene.instantiate()
scene.add_child(mat)
mat.global_position = Vector3(-45, 1, -38) # ~7 m ahead of the bull along +Z
await create_timer(0.2).timeout
player.global_position = Vector3(-45, 1, -45)
player.velocity = Vector3.ZERO
player.cube_guy.rotation.y = 0.0 # face +Z, toward the matador
player.ability_cd[3] = 0.0
player._activate_roll()
var popped := false
for _n in 45:
await create_timer(0.03).timeout
if player._active_ability != 3:
popped = true
break
_assert_true(popped, "roll pops (ends) on ramming a matador")
if is_instance_valid(mat):
mat.queue_free()
# ── Bull animation ────────────────────────────────────────────────────────────
# The bull sits idle in this test (no input), so it must be playing its looping
# IDLE clip — guards the idle wiring and the KICK clip rename in player.gd.
func _check_bull_animation() -> void:
print("\n-- check_bull_animation --")
var players := get_nodes_in_group(&"player")
if players.is_empty():
_note("bull anim check: no player in scene")
return
var ap: AnimationPlayer = _find_anim_player(players[0])
if ap == null:
_note("bull anim check: no AnimationPlayer under player")
return
_assert_true(ap.has_animation(&"Armature|IDLE"), "bull has IDLE clip")
_assert_true(ap.has_animation(&"Armature|KICK"), "bull has KICK clip (not stale FOOTKICK)")
_assert_true(ap.current_animation == &"Armature|IDLE", "bull plays IDLE at rest")
if ap.has_animation(&"Armature|IDLE"):
_assert_true(ap.get_animation(&"Armature|IDLE").loop_mode == Animation.LOOP_LINEAR,
"bull IDLE clip loops")
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node
for child in node.get_children():
var r := _find_anim_player(child)
if r != null:
return r
return null
# ── Win / lose wiring ─────────────────────────────────────────────────────────
# One clean sword hit must end the run in a loss, and the signal chain must be in
# place for a win (spawner.all_defeated → HUD). Runs last: it pauses the tree.
func _check_game_over_wiring(scene: Node) -> void:
print("\n-- check_game_over_wiring --")
var hud: Node = _find_hud(scene)
var players := get_nodes_in_group(&"player")
if hud == null or players.is_empty():
_note("game over check: HUD / player missing")
return
var player: Node = players[0]
_assert_true(player.has_signal(&"died"), "player exposes a died signal")
var spawners := get_nodes_in_group(&"matador_spawn")
_assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"),
"spawner exposes an all_defeated signal (win route)")
player.take_sword_hit()
await create_timer(0.05).timeout
_assert_true(hud._game_over, "a single sword hit raises the game-over screen")
_assert_true(not hud._result_win, "a bull hit is a loss, not a win")
func _find_hud(node: Node) -> Node:
if node is CanvasLayer and node.has_method(&"_show_game_over"):
return node
for child in node.get_children():
var r := _find_hud(child)
if r != null:
return r
return null
# ── Debug overlays ────────────────────────────────────────────────────────────
# Turn on every DebugDraw overlay against the live scene, confirm it builds, then
# turn them off and confirm it tears down — guards the reconcile bookkeeping.
func _check_overlays() -> void:
print("\n-- check_overlays --")
var dp: Node = root.get_node_or_null("/root/DP")
var draw: Node = root.get_node_or_null("/root/DebugDraw")
if dp == null or draw == null:
_note("overlay check: DP / DebugDraw autoload missing")
return
var flags := [
"show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones",
"show_animation_name", "show_wireframe", "show_grid", "show_axes",
]
for flag: String in flags:
dp.set_value(flag, true)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.size() > 0, "overlay builds matador state labels")
_assert_true(draw._overlays.size() > 0, "overlay builds collision meshes")
_assert_true(draw._anim_labels.size() > 0, "overlay builds animation-name labels")
_assert_true(draw._stats_layer != null and draw._stats_layer.visible, "stats overlay visible")
_assert_true(not draw._stats_label.text.is_empty(), "stats overlay composes text")
for flag: String in flags:
dp.set_value(flag, false)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.is_empty(), "overlay clears state labels when off")
_assert_true(draw._overlays.is_empty(), "overlay clears collision meshes when off")
_assert_true(draw._anim_labels.is_empty(), "overlay clears animation-name labels when off")
# ── Tail integrity ────────────────────────────────────────────────────────────
func _check_tail_integrity() -> void:
print("\n-- check_tail_integrity --")
var players := get_nodes_in_group(&"player")
if players.is_empty():
_note("tail check: no player in scene")
return
# Prefer reading from the live Verlet chain in the legs node.
var legs: Node = _find_legs_node(players[0])
if legs:
_check_tail_verlet(legs)
return
# Fallback: sample bone global poses from Skeleton3D.
var skel: Skeleton3D = _find_skeleton(players[0]) as Skeleton3D
if not skel:
_note("tail check: no Skeleton3D found under player")
return
_check_tail_skeleton(skel)
func _check_tail_verlet(legs: Node) -> void:
var chain: Array = legs._tail_world
if chain.size() < 2:
_note("tail Verlet: chain has fewer than 2 nodes — skipping")
return
for i in range(chain.size()):
var p: Vector3 = chain[i]
_assert_true(p.is_finite(), "tail Verlet node %d is finite" % i)
for i in range(1, chain.size()):
var dist: float = (chain[i] as Vector3).distance_to(chain[i - 1])
_assert_true(dist > 0.005,
"tail Verlet segment %d%d not collapsed (%.4f m)" % [i - 1, i, dist])
var span: float = (chain[0] as Vector3).distance_to(chain[-1])
_assert_true(span > 0.05,
"tail chain root-to-tip span non-zero (%.3f m)" % span)
# Verify no two nodes are at exactly the same position (bunching symptom).
for i in range(1, chain.size()):
for j in range(i + 1, chain.size()):
var d: float = (chain[i] as Vector3).distance_to(chain[j])
_assert_true(d > 0.001,
"tail nodes %d and %d are distinct (%.4f m apart)" % [i, j, d])
_note("tail Verlet: %d nodes, span %.3f m" % [chain.size(), span])
func _check_tail_skeleton(skel: Skeleton3D) -> void:
var positions: Array[Vector3] = []
for bone_name: StringName in TAIL_BONES:
var idx: int = skel.find_bone(bone_name)
if idx == -1:
continue
positions.append(skel.to_global(skel.get_bone_global_pose(idx).origin))
if positions.size() < 2:
_note("tail skeleton: fewer than 2 bones found")
return
for i in range(positions.size()):
_assert_true(positions[i].is_finite(), "tail bone %d finite" % i)
for i in range(1, positions.size()):
var dist: float = positions[i].distance_to(positions[i - 1])
_assert_true(dist > 0.005,
"tail bone segment %d%d not collapsed (%.4f m)" % [i - 1, i, dist])
_note("tail skeleton: %d bones checked" % positions.size())
# ── Ragdoll sanity ────────────────────────────────────────────────────────────
func _check_ragdoll_sanity(matador: Node3D, start_pos: Vector3) -> void:
print("\n-- check_ragdoll_sanity --")
var sim: Node = _find_class_recursive(matador, "PhysicalBoneSimulator3D")
if not sim:
_note("ragdoll: no PhysicalBoneSimulator3D — check skipped")
return
var checked := 0
var all_at_origin := true
for child in sim.get_children():
if not (child is PhysicalBone3D):
continue
var bone := child as PhysicalBone3D
var bp: Vector3 = bone.global_position
checked += 1
if bp.distance_to(Vector3.ZERO) > 0.1:
all_at_origin = false
_assert_true(bp.is_finite(),
"ragdoll '%s' position finite" % bone.bone_name)
if bp.is_finite():
var dist: float = bp.distance_to(start_pos)
# 6 m: impulse + 0.8 s of movement should stay inside this.
# Regression guard: bodies stuck at world origin fail (spawn is 10-20 m away).
_assert_true(dist < 6.0,
"ragdoll '%s' near spawn (%.1f m)" % [bone.bone_name, dist])
_assert_true(bp.y > -1.0,
"ragdoll '%s' above floor (y = %.2f)" % [bone.bone_name, bp.y])
_assert_true(bp.y < 3.0,
"ragdoll '%s' settled below 3 m (y = %.2f)" % [bone.bone_name, bp.y])
if checked == 0:
_note("ragdoll: simulator has no PhysicalBone3D children")
elif all_at_origin:
_note("ragdoll: all bones at world origin — physics may not have simulated (headless?)")
else:
_note("ragdoll: %d bones checked" % checked)
# ── Frame capture ─────────────────────────────────────────────────────────────
func _capture_frame(filename: String) -> void:
var img: Image = root.get_viewport().get_texture().get_image()
if img == null:
_note("frame capture failed: %s" % filename)
return
var path := OUTPUT_DIR + "/" + filename
img.save_png(path)
_note("captured %s (%d×%d)" % [filename, img.get_width(), img.get_height()])
# ── Scene helpers ─────────────────────────────────────────────────────────────
func _find_legs_node(player: Node) -> Node:
for child in player.get_children():
var s: Script = child.get_script() as Script
if s and s.resource_path.ends_with("bull_legs.gd"):
return child
return null
func _find_skeleton(node: Node) -> Node:
if node is Skeleton3D:
return node
for child in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_class_recursive(node: Node, class_name_str: String) -> Node:
if node.get_class() == class_name_str:
return node
for child in node.get_children():
var r := _find_class_recursive(child, class_name_str)
if r:
return r
return null
# ── Assertions & report ───────────────────────────────────────────────────────
func _assert_true(condition: bool, desc: String) -> void:
if condition:
_passed += 1
print(" PASS: %s" % desc)
_report.append("PASS: " + desc)
else:
_failed += 1
print(" FAIL: %s" % desc)
_report.append("FAIL: " + desc)
func _note(msg: String) -> void:
print(" NOTE: %s" % msg)
_report.append("NOTE: " + msg)
func _finish() -> void:
var f := FileAccess.open(OUTPUT_DIR + "/report.txt", FileAccess.WRITE)
if f:
f.store_string("\n".join(Array(_report)))
f.close()
print("")
print("=".repeat(60))
print("GAMEPLAY TESTS: %d passed, %d failed" % [_passed, _failed])
print("Frame strip: %s" % OUTPUT_DIR)
print("=".repeat(60))
quit(1 if _failed > 0 else 0)