tests & can charge up thrust

This commit is contained in:
2026-05-25 20:51:48 +03:00
parent bfb026c7ae
commit 6abf5355c2
10 changed files with 1022 additions and 15 deletions
+259
View File
@@ -0,0 +1,259 @@
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)