jump ani fixing and transition animations
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://yxud8rrfs1wc
|
||||
@@ -277,6 +277,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_animation_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
|
||||
@@ -81,6 +81,40 @@ func _run() -> void:
|
||||
_snap("post_right_20")
|
||||
await _drive("ground", 0.0, 70)
|
||||
_snap("post_right_idle")
|
||||
|
||||
# Speed-scaled traversal entry: preserve three early frames so a hard cut is
|
||||
# visually obvious in review while the final wall pose remains unchanged.
|
||||
body.velocity = Vector3(0.0, -3.0, -SPEED)
|
||||
await _drive("air", SPEED, 20)
|
||||
_model.set_wall_side(1.0)
|
||||
_model.set_wall_glide_motion(Vector3(0.0, 0.0, -SPEED))
|
||||
await _drive("wall_run", SPEED, 1)
|
||||
_snap("wall_transition_01")
|
||||
await _drive("wall_run", SPEED, 3)
|
||||
_snap("wall_transition_04")
|
||||
await _drive("wall_run", SPEED, 6)
|
||||
_snap("wall_transition_10")
|
||||
|
||||
# The stopping one-shot is driven while speed physically decays instead of
|
||||
# leaving the high-rate Sprint cycle active until an instant zero.
|
||||
_model.set_locomotion(0.0, 1.0, 0.0)
|
||||
await _drive("ground", 11.0, 45)
|
||||
_snap("stop_00_run")
|
||||
for sample in [["stop_01", 10.0, 1], ["stop_06", 4.5, 5],
|
||||
["stop_14", 1.2, 8]]:
|
||||
await _drive("stop", float(sample[1]), int(sample[2]))
|
||||
_snap(String(sample[0]))
|
||||
await _drive("ground", 0.0, 18)
|
||||
_snap("stop_32_idle")
|
||||
|
||||
# Successive hops select source and offline-mirrored source performances.
|
||||
body.velocity = Vector3(0.0, 8.5, -8.0)
|
||||
await _drive("air", 8.0, 12)
|
||||
_snap("jump_normal")
|
||||
await _drive("ground", 0.0, 24)
|
||||
body.velocity = Vector3(0.0, 8.5, -8.0)
|
||||
await _drive("air_alt", 8.0, 12)
|
||||
_snap("jump_alternate")
|
||||
quit()
|
||||
|
||||
|
||||
@@ -132,6 +166,7 @@ func _pose_metrics() -> Dictionary:
|
||||
max_torso_joint_angle = joint_angle
|
||||
max_torso_joint = _model.skeleton.get_bone_name(chain[index])
|
||||
var max_leg_horizontal_ratio := 0.0
|
||||
var knee_rise := {"L": 0.0, "R": 0.0}
|
||||
for side in ["L", "R"]:
|
||||
var thigh := _model._role_bone("thigh." + side,
|
||||
["thigh." + side, "Left thigh" if side == "L" else "Right thigh"])
|
||||
@@ -142,6 +177,7 @@ func _pose_metrics() -> Dictionary:
|
||||
var segment := _model.skeleton.get_bone_global_pose(shin).origin \
|
||||
- _model.skeleton.get_bone_global_pose(thigh).origin
|
||||
segment = _model.skeleton.global_transform.basis * segment
|
||||
knee_rise[side] = segment.y
|
||||
if segment.length() > 0.0001:
|
||||
max_leg_horizontal_ratio = maxf(max_leg_horizontal_ratio,
|
||||
Vector2(segment.x, segment.z).length() / segment.length())
|
||||
@@ -149,4 +185,9 @@ func _pose_metrics() -> Dictionary:
|
||||
"torso_joint_angle": max_torso_joint_angle,
|
||||
"torso_joint": max_torso_joint,
|
||||
"leg_horizontal_ratio": max_leg_horizontal_ratio,
|
||||
"left_knee_segment_y": knee_rise["L"],
|
||||
"right_knee_segment_y": knee_rise["R"],
|
||||
"knee_lead": float(knee_rise["R"]) - float(knee_rise["L"]),
|
||||
"clip": _model.current_clip_debug(),
|
||||
"crossfade": _model._state_trans.xfade_time,
|
||||
}
|
||||
|
||||
@@ -82,9 +82,11 @@ func _run() -> void:
|
||||
for side in [-1.0, 1.0]:
|
||||
var entry_leg_rise := await _wall_entry_leg_rise(
|
||||
model, side, thigh_l, thigh_r, shin_l, shin_r)
|
||||
_expect(entry_leg_rise < 0.05,
|
||||
"wall-run entry keeps both legs below the pelvis on side %+.0f "
|
||||
% side)
|
||||
# A real crossfade may raise the leading knee slightly; reject only
|
||||
# the old near-vertical inversion, not the authored wall-run stride.
|
||||
_expect(entry_leg_rise < 0.20,
|
||||
"wall-run entry avoids an inverted leg on side %+.0f (%.3f m)"
|
||||
% [side, entry_leg_rise])
|
||||
|
||||
model.set_wall_side(-1.0)
|
||||
model.set_wall_surface(Vector3.RIGHT, Vector3(-0.4, 0.0, 0.0))
|
||||
@@ -182,8 +184,8 @@ func _run() -> void:
|
||||
var slide_leg_r := _world_bone_segment(model, thigh_r, shin_r)
|
||||
slide_entry_rise = maxf(slide_entry_rise,
|
||||
maxf(slide_leg_l.y, slide_leg_r.y))
|
||||
_expect(slide_entry_rise < 0.05,
|
||||
"slide entry keeps both legs below the pelvis")
|
||||
_expect(slide_entry_rise < 0.20,
|
||||
"slide entry avoids an inverted leg (%.3f m)" % slide_entry_rise)
|
||||
model.update_state("ground", 0.0, false)
|
||||
await process_frame
|
||||
model.update_state("slide", 4.0, true)
|
||||
|
||||
Reference in New Issue
Block a user