408 lines
15 KiB
GDScript
408 lines
15 KiB
GDScript
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)
|