Two fidelity gaps closed: - Reload/throw/shoot/hit used to REPLACE the whole-body clip, so reloading mid-slide snapped the character upright on standing legs. The model now runs a runtime AnimationTree (clips -> Transition -> TimeScale -> OneShot) whose one-shot layer is FILTERED to upper-body bones: the arms play the action while the legs keep sliding/running/falling. Land stays full-body. The rifle hold releases exactly while the one-shot node reports active. - Grapple no longer plays the library's horizontal-swim clip. The zip pose is procedural: Fall as the airborne base, the spine pivots to fly along the line to the actual anchor point (capped so overhead shots don't fold the body), the head sights the anchor, legs trail behind, the FREE left hand reaches up the rope — and the right hand keeps holding the rifle. The anchor point feeds in from the controller for local and remote players (synced_grapple_point). - anim_capture: deterministic open-ground teleport + two new regression shots (reload-while-sliding, grapple zip toward a real anchor). Co-Authored-By: Claude Fable 5 <[email protected]>
141 lines
4.8 KiB
GDScript
141 lines
4.8 KiB
GDScript
extends SceneTree
|
|
|
|
## Headless smoke test for the player spawn + skin + animation pipeline.
|
|
## Boots the test level as a singleplayer match, spawns the local player,
|
|
## ticks frames, and verifies the visual model came up for both the
|
|
## procedural skin and a GLB skin.
|
|
##
|
|
## Run: godot --headless --path . -s res://debug/spawn_smoke_test.gd
|
|
|
|
var _failures: Array = []
|
|
|
|
|
|
func _init() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
await process_frame
|
|
await process_frame
|
|
|
|
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
|
_check(skin_mgr != null, "SkinManager autoload exists")
|
|
var audio_mgr = root.get_node_or_null("/root/AudioManager")
|
|
_check(audio_mgr != null, "AudioManager autoload exists")
|
|
if audio_mgr:
|
|
_check(audio_mgr.has_sound("ak47_fire"), "AudioManager auto-registered ak47_fire")
|
|
|
|
# Pick the GLB skin if its model file is present, else default.
|
|
var glb_skin_id := ""
|
|
if skin_mgr:
|
|
for id in skin_mgr.get_skin_ids():
|
|
var s = skin_mgr.get_skin(id)
|
|
if s.model_path != "" and ResourceLoader.exists(s.model_path):
|
|
glb_skin_id = id
|
|
break
|
|
|
|
await _test_spawn_with_skin("default", false)
|
|
if glb_skin_id != "":
|
|
await _test_spawn_with_skin(glb_skin_id, true)
|
|
else:
|
|
print("NOTE: no GLB skin available, skipped skinned model test")
|
|
|
|
print("\n=== SPAWN SMOKE SUMMARY ===")
|
|
print("Failures: %d" % _failures.size())
|
|
for f in _failures:
|
|
print("FAIL: ", f)
|
|
quit(0 if _failures.is_empty() else 1)
|
|
|
|
|
|
func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
|
print("\n--- Spawn test with skin '%s' ---" % skin_id)
|
|
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
|
if skin_mgr:
|
|
skin_mgr.set_active_skin(skin_id)
|
|
|
|
var nm = root.get_node_or_null("/root/NetworkManager")
|
|
if not _check(nm != null, "NetworkManager autoload exists"):
|
|
return
|
|
nm.start_singleplayer_match("Deathmatch")
|
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
|
|
|
# Let the level build and the player spawn + settle.
|
|
for i in 30:
|
|
await process_frame
|
|
|
|
var level = current_scene
|
|
if not _check(level != null, "level scene loaded"):
|
|
return
|
|
var player = _find_player(level)
|
|
if not _check(player != null, "player '1' spawned"):
|
|
return
|
|
|
|
_check(player.get_node_or_null("MovementStateMachine") != null, "state machine present")
|
|
var visual = player.get_visual_model() if player.has_method("get_visual_model") else null
|
|
_check(visual != null, "visual model present")
|
|
|
|
if expect_skinned:
|
|
var skinned = player.get_node_or_null("SkinnedModel")
|
|
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
|
_check(skinned.loaded, "GLB model loaded")
|
|
_check(skinned.skeleton != null, "skeleton found in GLB")
|
|
# The mesh must be bound to the skeleton, else it renders its bind
|
|
# pose (T-pose) while the skeleton animates invisibly.
|
|
var bound := false
|
|
if skinned.skeleton:
|
|
for mi in skinned.find_children("*", "MeshInstance3D", true, false):
|
|
if mi.skin != null and mi.get_node_or_null(mi.skeleton) == skinned.skeleton:
|
|
bound = true
|
|
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
|
|
if skinned.animation_player:
|
|
# The AnimationTree drives playback now; the raw player is idle.
|
|
_check(skinned._anim_tree != null and skinned._anim_tree.active,
|
|
"animation tree active")
|
|
print(" clips resolved: ", skinned._resolved_clips)
|
|
# Drive some movement states through the same API the game uses.
|
|
for state in ["ground", "air", "slide", "wall_run", "dash"]:
|
|
skinned.update_state(state, 9.0, false)
|
|
await process_frame
|
|
_check(true, "state cycling did not crash")
|
|
|
|
# Third-person toggle: camera swap + owner model visibility.
|
|
if player.has_method("set_third_person"):
|
|
var tp_cam = player.get_node_or_null(
|
|
"HeadPivot/OrbitPivot/ShoulderOffset/ThirdPersonBoom/ThirdPersonCamera")
|
|
_check(tp_cam != null, "third-person camera boom created")
|
|
player.set_third_person(true)
|
|
await process_frame
|
|
_check(player.third_person, "toggled into third person")
|
|
if tp_cam:
|
|
_check(tp_cam.current, "third-person camera is current when toggled on")
|
|
player.set_third_person(false)
|
|
await process_frame
|
|
_check(not player.third_person, "toggled back to first person")
|
|
else:
|
|
var humanoid = player.get_node_or_null("HumanoidModel")
|
|
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
|
|
|
|
# Simulate a few physics frames of idle play.
|
|
for i in 30:
|
|
await physics_frame
|
|
_check(is_instance_valid(player), "player survived 30 physics frames")
|
|
|
|
|
|
func _find_player(node: Node) -> Node:
|
|
if node is CharacterBody3D and node.name == "1" and node.has_method("get_visual_model"):
|
|
return node
|
|
for child in node.get_children():
|
|
var found := _find_player(child)
|
|
if found:
|
|
return found
|
|
return null
|
|
|
|
|
|
func _check(ok: bool, msg: String) -> bool:
|
|
if ok:
|
|
print(" OK: ", msg)
|
|
else:
|
|
print(" FAIL: ", msg)
|
|
_failures.append(msg)
|
|
return ok
|