Files
Bullosseum/tests/gameplay_test.gd
T
2026-05-25 20:51:48 +03:00

260 lines
8.3 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 = []
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)
# Let physics, AI, and IK warm up.
await create_timer(0.5).timeout
# 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)
else:
_note("ragdoll check skipped — no matadors found in scene")
_finish()
# ── 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)