Files
Papay-Shooter/debug/motion_variation_check.gd
T

157 lines
5.7 KiB
GDScript

extends SceneTree
## Regression gate for speed-scaled traversal blends, authored stopping, and
## alternating mirrored airborne silhouettes in the Titanfall motion lab.
const DEFAULT_MODEL_PATH := \
"res://assets/characters/local_reference/titanfall_motion/mannequin.glb"
const SPEED := 13.0
var _failures: Array[String] = []
var _model: SkinnedPlayerModel
var _body: CharacterBody3D
var _model_path := DEFAULT_MODEL_PATH
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_model_path = args[0]
call_deferred("_run")
func _run() -> void:
_body = CharacterBody3D.new()
root.add_child(_body)
current_scene = _body
_model = SkinnedPlayerModel.new()
_model.model_path = _model_path
_body.add_child(_model)
for _frame in 60:
await process_frame
_expect(_model.loaded, "motion-lab mannequin loads")
if not _model.loaded:
_finish()
return
for clip in ["Stop", "JumpAlt", "FallAlt"]:
_expect(_model.clip_names_debug().has(clip),
"rebuilt motion pack contains " + clip)
# The same transition is deliberately quicker at high speed, while both
# retain enough time for the skeleton to interpolate rather than cut.
_body.velocity = Vector3(0.0, -2.0, -4.0)
await _drive("air", 4.0, 20)
_model.set_wall_side(-1.0)
await _drive("wall_run", 4.0, 1)
var slow_wall_blend := _model._state_trans.xfade_time
await _drive("ground", 0.0, 24)
_body.velocity = Vector3(0.0, -2.0, -18.0)
await _drive("air", 18.0, 20)
_model.set_wall_side(1.0)
await _drive("wall_run", 18.0, 1)
var fast_wall_blend := _model._state_trans.xfade_time
_expect(slow_wall_blend > 0.0 and fast_wall_blend > 0.0,
"wall transitions never hard-cut")
_expect(fast_wall_blend < slow_wall_blend,
"wall transition shortens with speed (%.3f -> %.3f s)" % [
slow_wall_blend, fast_wall_blend])
# Stop owns the full body while physical velocity decays. It must not leave
# the high-rate Sprint node active during the release glide.
_model.set_locomotion(0.0, 1.0, 0.0)
await _drive("ground", 11.0, 45)
await _drive("stop", 11.0, 1)
_expect(_model.current_clip_debug() == "Stop",
"release selects the authored Stop performance")
var stop_blend := _model._state_trans.xfade_time
var stop_scale := float(_model._anim_tree.get(
"parameters/loco_scale/scale"))
_expect(stop_blend > 0.0 and stop_blend < 0.19,
"stop enters through a quick nonzero transition (%.3f s)" % stop_blend)
_expect(stop_scale > 1.0,
"high-speed stop compresses source timing (%.2fx)" % stop_scale)
var physical_speed := 11.0
var stop_distance := 0.0
var stop_held := true
var deceleration := MovementParams.new().ground_deceleration
while physical_speed > 0.6:
physical_speed *= exp(-deceleration / 60.0)
stop_distance += physical_speed / 60.0
await _drive("stop", physical_speed, 1)
stop_held = stop_held and _model.current_clip_debug() == "Stop"
_expect(stop_held, "Stop remains active during residual movement")
await _drive("ground", 0.0, 14)
_expect(stop_distance > 0.75 and stop_distance < 1.5,
"physical stop carries a short distance (%.2f m)" % stop_distance)
_expect(_model.current_clip_debug() == "Idle",
"stop resolves into Idle instead of a fast run cycle")
_expect(_model.locomotion_effective_speed_debug() < 0.25,
"stale sprint playback speed is cleared after stopping")
# Sample both clips at the same point after their short crossfade. The signed
# knee lead must reverse, proving the alternate is a true mirrored source
# performance rather than another phase of the same pose.
await _drive("ground", 0.0, 30)
_body.velocity = Vector3(0.0, 8.5, -8.0)
await _drive("air", 8.0, 12)
var normal_lead := _knee_lead()
await _drive("ground", 0.0, 30)
_body.velocity = Vector3(0.0, 8.5, -8.0)
await _drive("air_alt", 8.0, 12)
var alternate_lead := _knee_lead()
_expect(absf(normal_lead) > 0.04 and absf(alternate_lead) > 0.04 \
and normal_lead * alternate_lead < 0.0,
"successive airborne clips swap the leading knee (%.3f / %.3f m)" % [
normal_lead, alternate_lead])
# Controller event wiring alternates the presentation state on successive
# ground jumps and leaves the physics movement state untouched.
var controller := PlayerMovementController.new()
controller._machine = MovementStateMachine.new()
controller._on_movement_event("jump", {})
var first_air := controller._visual_animation_state("air", 8.0)
controller._on_movement_event("jump", {})
var second_air := controller._visual_animation_state("air", 8.0)
_expect(first_air != second_air and first_air in ["air", "air_alt"] \
and second_air in ["air", "air_alt"],
"successive jump events alternate runtime airborne variants")
controller._machine.free()
controller.free()
_body.queue_free()
await process_frame
_finish()
func _drive(state: String, speed: float, frames: int) -> void:
for _frame in frames:
_model.update_state(state, speed, false)
await process_frame
func _knee_lead() -> float:
var rises := {}
for side in ["L", "R"]:
var thigh := _model._role_bone("thigh." + side,
["thigh." + side, "Left thigh" if side == "L" else "Right thigh"])
var shin := _model._role_bone("shin." + side,
["shin." + side, "Left shin" if side == "L" else "Right shin"])
var segment := _model.skeleton.get_bone_global_pose(shin).origin \
- _model.skeleton.get_bone_global_pose(thigh).origin
rises[side] = (_model.skeleton.global_transform.basis * segment).y
return float(rises["R"]) - float(rises["L"])
func _expect(ok: bool, description: String) -> void:
if ok:
print(" OK: ", description)
else:
_failures.append(description)
printerr(" FAIL: ", description)
func _finish() -> void:
print("\n=== MOTION VARIATION SUMMARY ===")
print("Failures: %d" % _failures.size())
quit(1 if not _failures.is_empty() else 0)