Files
Bullosseum/tests/gameplay_test.gd
T

408 lines
15 KiB
GDScript
Raw Permalink 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 = []
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
_check_bull_animation()
await _check_overlays()
# 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()
# Trigger ragdoll on the first matador and check that bones don't explode.
var matadors := get_nodes_in_group(&"matador")
if matadors.size() > 0:
var mat: Node3D = matadors[0] as Node3D
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)
_check_score_wiring(scene)
else:
_note("ragdoll check skipped — no matadors found in scene")
# Last, so any matadors the roll bowls over don't perturb the score check above.
await _check_roll_ability(scene)
_finish()
# ── Roll ability ──────────────────────────────────────────────────────────────
# Roll into a synthetic wall and confirm the bank shot: the heading reflects and the
# speed boosts past roll_max_speed (the natural ramp caps at max, so any excess is a
# wall boost). A low roll_duration lets it end within the sample window.
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 wall := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(10, 5, 1)
cs.shape = box
wall.add_child(cs)
scene.add_child(wall)
wall.global_position = Vector3(0, 1, 8)
var restore_dur: float = dp.f("roll_duration")
dp.set_value("roll_duration", 0.6)
player.global_position = Vector3(0, 1, 0)
player.cube_guy.rotation.y = 0.0 # face +Z, into the wall
player.velocity = Vector3(0, 0, 40)
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 peak := 0.0
var reflected := false
for _n in 25:
await create_timer(0.03).timeout
peak = maxf(peak, Vector2(player.velocity.x, player.velocity.z).length())
if player._roll_dir.z < -0.2:
reflected = true
_assert_true(reflected, "roll ricochets its heading off the wall")
_assert_true(peak > dp.f("roll_max_speed") + 1.0, "wall ricochet boosts speed past roll_max_speed")
_assert_true(peak <= dp.f("roll_wall_cap") + 1.0, "ricochet boost stays under the cap")
if ap != null:
_assert_true(ap.current_animation == &"Armature|IDLE", "roll returns to IDLE when it ends")
dp.set_value("roll_duration", restore_dur)
wall.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
# ── Scoreboard wiring ─────────────────────────────────────────────────────────
# The ragdoll above is one matador kill; confirm it flowed matador → spawner →
# HUD and scored base × combo-1 = 100. Guards the whole kill→score signal chain.
func _check_score_wiring(scene: Node) -> void:
print("\n-- check_score_wiring --")
var hud: Node = _find_hud(scene)
if hud == null:
_note("score check: HUD not found in scene")
return
_assert_true(hud._score == 100, "one kill scores 100 (base × combo 1) — got %d" % hud._score)
_assert_true(hud._combo == 1, "one kill sets combo to 1 — got %d" % hud._combo)
func _find_hud(node: Node) -> Node:
if node is CanvasLayer and node.has_method(&"_on_matador_killed"):
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)