157 lines
5.7 KiB
GDScript
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)
|