jump ani fixing and transition animations

This commit is contained in:
Nicholas Butzke
2026-08-10 12:50:00 -04:00
parent 833e936bcd
commit 0ce626ad42
13 changed files with 359 additions and 35 deletions
+32 -4
View File
@@ -250,7 +250,12 @@ func _apply_color(col: Color) -> void:
func update_state(state: String, speed: float, is_crouching: bool = false) -> void: func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
var previous_speed := movement_speed var previous_speed := movement_speed
var slowdown := previous_speed - speed var slowdown := previous_speed - speed
if state in ["ground", "idle"]: if state == "stop" and current_state != "stop":
_brake_left = BRAKE_DURATION
_brake_strength = clampf(maxf(previous_speed, speed) / 10.0, 0.55, 1.0)
if absf(_locomotion_fwd) > 0.15:
_brake_fwd = signf(_locomotion_fwd)
elif state in ["ground", "idle"]:
if speed > 4.5 and slowdown < 0.05: if speed > 4.5 and slowdown < 0.05:
_brake_armed = true _brake_armed = true
if _brake_armed and previous_speed > 4.5 and slowdown > 0.12: if _brake_armed and previous_speed > 4.5 and slowdown > 0.12:
@@ -259,7 +264,7 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_brake_armed = false _brake_armed = false
if absf(_locomotion_fwd) > 0.15: if absf(_locomotion_fwd) > 0.15:
_brake_fwd = signf(_locomotion_fwd) _brake_fwd = signf(_locomotion_fwd)
elif state not in ["ground", "idle"]: elif state not in ["ground", "idle", "stop"]:
_brake_left = 0.0 _brake_left = 0.0
_brake_armed = true _brake_armed = true
current_state = state current_state = state
@@ -378,7 +383,7 @@ func _set_shadows_recursive(node: Node) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
var brake_target := 0.0 var brake_target := 0.0
if _brake_left > 0.0 and current_state in ["ground", "idle"]: if _brake_left > 0.0 and current_state in ["ground", "idle", "stop"]:
_brake_left = maxf(0.0, _brake_left - delta) _brake_left = maxf(0.0, _brake_left - delta)
var progress := 1.0 - _brake_left / BRAKE_DURATION var progress := 1.0 - _brake_left / BRAKE_DURATION
var envelope := smoothstep(0.0, 1.0, progress / 0.30) \ var envelope := smoothstep(0.0, 1.0, progress / 0.30) \
@@ -458,6 +463,15 @@ func _process(delta: float) -> void:
t_lower_arm_l_rot.x = -1.2 t_lower_arm_l_rot.x = -1.2
t_lower_arm_r_rot.x = -1.2 t_lower_arm_r_rot.x = -1.2
"stop":
# Neutral base under the catching-step overlay below. Keeping the run
# cycle out of this state prevents residual speed from accelerating it.
t_root_rot.x = deg_to_rad(8.0)
t_thigh_r_rot.x = deg_to_rad(-24.0)
t_calf_r_rot.x = deg_to_rad(12.0)
t_thigh_l_rot.x = deg_to_rad(8.0)
t_calf_l_rot.x = deg_to_rad(14.0)
"slide": "slide":
t_root_pos.y = -0.6 t_root_pos.y = -0.6
t_root_rot.x = deg_to_rad(-45) # Lean back t_root_rot.x = deg_to_rad(-45) # Lean back
@@ -486,6 +500,20 @@ func _process(delta: float) -> void:
t_lower_arm_l_rot.x = -0.5 t_lower_arm_l_rot.x = -0.5
t_lower_arm_r_rot.x = -0.5 t_lower_arm_r_rot.x = -0.5
"air_alt":
# Mirror the simple fallback silhouette to match the alternating
# offline-authored variants used by skinned motion-lab characters.
t_thigh_l_rot.x = 0.2
t_thigh_r_rot.x = -0.4
t_calf_l_rot.x = 0.1
t_calf_r_rot.x = 0.5
t_upper_arm_l_rot.x = -0.8
t_upper_arm_r_rot.x = -0.8
t_upper_arm_l_rot.z = -0.5
t_upper_arm_r_rot.z = 0.5
t_lower_arm_l_rot.x = -0.5
t_lower_arm_r_rot.x = -0.5
"wall_run": "wall_run":
t_root_rot.z = deg_to_rad(-15) t_root_rot.z = deg_to_rad(-15)
var cycle = sin(_anim_time * 25.0) var cycle = sin(_anim_time * 25.0)
@@ -549,7 +577,7 @@ func _process(delta: float) -> void:
# Blend a single catching step over the locomotion result. It rises quickly # Blend a single catching step over the locomotion result. It rises quickly
# and releases over the same span as the run-to-idle crossfade, preserving # and releases over the same span as the run-to-idle crossfade, preserving
# momentum without freezing the whole mannequin in an exaggerated plant. # momentum without freezing the whole mannequin in an exaggerated plant.
if current_state in ["ground", "idle"] and _brake_weight > 0.001: if current_state in ["ground", "idle", "stop"] and _brake_weight > 0.001:
var travel_sign := -1.0 if _brake_fwd < 0.0 else 1.0 var travel_sign := -1.0 if _brake_fwd < 0.0 else 1.0
t_root_pos.y = lerpf(t_root_pos.y, -0.025, _brake_weight) t_root_pos.y = lerpf(t_root_pos.y, -0.025, _brake_weight)
t_root_rot.x = lerp_angle( t_root_rot.x = lerp_angle(
+61 -17
View File
@@ -48,8 +48,11 @@ const CLIP_FALLBACKS := {
"Run": ["Run", "Walk", "Idle"], "Run": ["Run", "Walk", "Idle"],
"Sprint": ["Sprint", "Run", "Walk", "Idle"], "Sprint": ["Sprint", "Run", "Walk", "Idle"],
"Jump": ["Jump", "Fall", "Idle"], "Jump": ["Jump", "Fall", "Idle"],
"JumpAlt": ["JumpAlt", "Jump", "Fall", "Idle"],
"Fall": ["Fall", "Jump", "Idle"], "Fall": ["Fall", "Jump", "Idle"],
"FallAlt": ["FallAlt", "Fall", "Jump", "Idle"],
"Land": ["Land", "Idle"], "Land": ["Land", "Idle"],
"Stop": ["Stop", "Land", "Idle"],
"Crouch": ["CrouchIdle", "Crouch", "Idle"], "Crouch": ["CrouchIdle", "Crouch", "Idle"],
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"], "CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
"SlideStart": ["SlideStart", "Slide"], "SlideStart": ["SlideStart", "Slide"],
@@ -85,7 +88,7 @@ const CLIP_FALLBACKS := {
"Throw": ["Throw", "Hit"], "Throw": ["Throw", "Hit"],
} }
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch", const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "FallAlt", "Crouch",
"CrouchIdle", "CrouchWalk", "Slide", "WallRunLeft", "WallRunRight", "CrouchIdle", "CrouchWalk", "Slide", "WallRunLeft", "WallRunRight",
"WallCling", "Grapple", "WallCling", "Grapple",
"StrafeWalkForward", "StrafeWalkBackward", "StrafeWalkLeft", "StrafeWalkRight", "StrafeWalkForward", "StrafeWalkBackward", "StrafeWalkLeft", "StrafeWalkRight",
@@ -99,17 +102,13 @@ const BLEND_TIME := 0.36
## drifts across their thresholds, and that is where hard cuts were most ## drifts across their thresholds, and that is where hard cuts were most
## visible. ## visible.
const BLEND_TIMES := { const BLEND_TIMES := {
"Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28, "Dash": 0.20, "Jump": 0.12, "JumpAlt": 0.12,
# SlideStart has a distinct authored crouch pose; blending into it from "Fall": 0.12, "FallAlt": 0.12, "Hit": 0.14, "Land": 0.28,
# airborne/standing can send the lower body through a ground-plane flip. "Stop": 0.12, "SlideStart": 0.12, "Slide": 0.30, "Death": 0.34,
"SlideStart": 0.0, "Slide": 0.30, "Death": 0.34,
"Throw": 0.20, "PistolReload": 0.30, "Throw": 0.20, "PistolReload": 0.30,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40, "Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32, "CrouchIdle": 0.40, "CrouchWalk": 0.40,
# The two authored wall performances start in distinct side poses. A full "WallRunLeft": 0.12, "WallRunRight": 0.12,
# body crossfade from Fall/ground interpolates the hips and thighs through
# an upside-down midpoint, so hand off directly to the authored first pose.
"WallRunLeft": 0.0, "WallRunRight": 0.0,
"WallCling": 0.32, "Grapple": 0.32, "WallCling": 0.32, "Grapple": 0.32,
} }
const GROUND_STATE_BLEND := 0.30 const GROUND_STATE_BLEND := 0.30
@@ -135,6 +134,7 @@ var _wall_surface_normal_world := Vector3.ZERO
var _wall_surface_point_world := Vector3.ZERO var _wall_surface_point_world := Vector3.ZERO
var _motion_yaw := 0.0 var _motion_yaw := 0.0
var _motion_was_wall_run := false var _motion_was_wall_run := false
var _stop_entry_speed := 0.0
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = "" var _current_clip: String = ""
@@ -234,6 +234,7 @@ func load_model(path: String) -> void:
_wall_surface_point_world = Vector3.ZERO _wall_surface_point_world = Vector3.ZERO
_motion_yaw = 0.0 _motion_yaw = 0.0
_motion_was_wall_run = false _motion_was_wall_run = false
_stop_entry_speed = 0.0
_loco_blend_target = Vector2.ZERO _loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO _loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0 _loco_scale_target = 1.0
@@ -857,7 +858,7 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_prev_state = state _prev_state = state
return return
# A heavy landing plays the Land one-shot before locomotion resumes. # A heavy landing plays the Land one-shot before locomotion resumes.
if state in ["ground", "idle"] and _prev_state == "air" \ if state in ["ground", "idle"] and _prev_state in ["air", "air_alt"] \
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"): and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
_oneshot_lock = 0.25 _oneshot_lock = 0.25
_play_clip("Land") _play_clip("Land")
@@ -866,6 +867,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_prev_state = state _prev_state = state
if state != "slide": if state != "slide":
_slide_start_remaining = 0.0 _slide_start_remaining = 0.0
if state == "stop" and previous_state != "stop":
_stop_entry_speed = speed
elif state != "stop":
_stop_entry_speed = 0.0
var clip := "Idle" var clip := "Idle"
match state: match state:
@@ -878,6 +883,13 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
"air": "air":
# Rising = jump, falling = the fall loop. # Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall" clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
"air_alt":
# Offline-mirrored source clips swap the leading knee and arm while
# preserving the exact Titanfall timing and silhouette.
clip = "JumpAlt" if _vertical_speed() > 0.5 else "FallAlt"
"stop":
clip = "Stop"
_loco_scale_target = 1.0
"slide": "slide":
if previous_state != "slide": if previous_state != "slide":
_slide_start_remaining = _animation_length_if_distinct( _slide_start_remaining = _animation_length_if_distinct(
@@ -910,10 +922,11 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Death" clip = "Death"
if clip != "": if clip != "":
# A wall jump hands the full body from a side performance to Jump/Fall. var blend_override := -1.0
# Interpolating those two poses can put the thighs through the horizontal if state in ["wall_run", "slide", "air", "air_alt", "stop"] \
# midpoint, which reads as a delayed leg flip on the jump frame. or exiting_wall_run:
_play_clip(clip, false, 0.0 if exiting_wall_run else -1.0) blend_override = _speed_scaled_transition(speed)
_play_clip(clip, false, blend_override)
if _pose_mod: if _pose_mod:
_pose_mod.state = state _pose_mod.state = state
@@ -927,6 +940,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
var scale := 1.0 var scale := 1.0
if clip in ["SlideStart", "Slide"]: if clip in ["SlideStart", "Slide"]:
scale = _slide_playback_scale(speed) scale = _slide_playback_scale(speed)
elif clip == "Stop":
scale = _stop_playback_scale(_stop_entry_speed)
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]: elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
# Grapple is held, and the wall clips are compact propulsion loops. # Grapple is held, and the wall clips are compact propulsion loops.
# Neither should become a frantic treadmill at high traversal speed. # Neither should become a frantic treadmill at high traversal speed.
@@ -1187,6 +1202,20 @@ func _slide_playback_scale(speed: float) -> float:
return clampf(speed / maxf(run_anim_reference_speed, 0.01), 0.75, 1.8) return clampf(speed / maxf(run_anim_reference_speed, 0.01), 0.75, 1.8)
func _stop_playback_scale(entry_speed: float) -> float:
# Finish the weight-catching step while the physical stopping glide still
# has momentum. Faster entries compress the one-shot without hard-cutting it.
return clampf(entry_speed / maxf(run_anim_reference_speed, 0.01), 1.0, 2.2)
func _speed_scaled_transition(speed: float) -> float:
# High-speed traversal needs a quicker silhouette handoff, but never a
# zero-frame snap. At ordinary run speed this is ~0.12 s; near the movement
# cap it approaches 0.07 s.
var speed_factor := clampf((speed - 3.0) / 15.0, 0.0, 1.0)
return lerpf(0.18, 0.07, speed_factor)
## Pick the cloth solver's detail level from how far the camera is. ## Pick the cloth solver's detail level from how far the camera is.
## ##
## Re-checked a few times a second rather than every frame: the answer changes ## Re-checked a few times a second rather than every frame: the answer changes
@@ -1248,8 +1277,23 @@ func _play_ground_locomotion(speed: float, crouched: bool) -> void:
if _current_state_node != state_node: if _current_state_node != state_node:
var exiting_wall_run := _current_state_node in [ var exiting_wall_run := _current_state_node in [
"WallRunLeft", "WallRunRight"] "WallRunLeft", "WallRunRight"]
_state_trans.xfade_time = 0.0 if exiting_wall_run \ var exiting_stop: bool = \
else GROUND_STATE_BLEND _current_state_node == String(_resolved_clips.get("Stop", ""))
if exiting_wall_run:
_state_trans.xfade_time = _speed_scaled_transition(speed)
elif exiting_stop:
# The stop one-shot has already absorbed the residual momentum. Enter
# the Idle point directly under the crossfade; carrying Sprint's stale
# BlendSpace coordinate forward would create a final fast run cycle.
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
_loco_scale_visual = 1.0
_anim_tree.set("parameters/loco_blend/blend_position", Vector2.ZERO)
_anim_tree.set("parameters/loco_scale/scale", 1.0)
_state_trans.xfade_time = 0.18
else:
_state_trans.xfade_time = GROUND_STATE_BLEND
_anim_tree.set("parameters/state/transition_request", state_node) _anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node _current_state_node = state_node
+156
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://yxud8rrfs1wc
+1
View File
@@ -277,6 +277,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":rotation") client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation") client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_movement_state") 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_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")
+41
View File
@@ -81,6 +81,40 @@ func _run() -> void:
_snap("post_right_20") _snap("post_right_20")
await _drive("ground", 0.0, 70) await _drive("ground", 0.0, 70)
_snap("post_right_idle") _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() quit()
@@ -132,6 +166,7 @@ func _pose_metrics() -> Dictionary:
max_torso_joint_angle = joint_angle max_torso_joint_angle = joint_angle
max_torso_joint = _model.skeleton.get_bone_name(chain[index]) max_torso_joint = _model.skeleton.get_bone_name(chain[index])
var max_leg_horizontal_ratio := 0.0 var max_leg_horizontal_ratio := 0.0
var knee_rise := {"L": 0.0, "R": 0.0}
for side in ["L", "R"]: for side in ["L", "R"]:
var thigh := _model._role_bone("thigh." + side, var thigh := _model._role_bone("thigh." + side,
["thigh." + side, "Left thigh" if side == "L" else "Right thigh"]) ["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 \ var segment := _model.skeleton.get_bone_global_pose(shin).origin \
- _model.skeleton.get_bone_global_pose(thigh).origin - _model.skeleton.get_bone_global_pose(thigh).origin
segment = _model.skeleton.global_transform.basis * segment segment = _model.skeleton.global_transform.basis * segment
knee_rise[side] = segment.y
if segment.length() > 0.0001: if segment.length() > 0.0001:
max_leg_horizontal_ratio = maxf(max_leg_horizontal_ratio, max_leg_horizontal_ratio = maxf(max_leg_horizontal_ratio,
Vector2(segment.x, segment.z).length() / segment.length()) 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_angle": max_torso_joint_angle,
"torso_joint": max_torso_joint, "torso_joint": max_torso_joint,
"leg_horizontal_ratio": max_leg_horizontal_ratio, "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,
} }
+7 -5
View File
@@ -82,9 +82,11 @@ func _run() -> void:
for side in [-1.0, 1.0]: for side in [-1.0, 1.0]:
var entry_leg_rise := await _wall_entry_leg_rise( var entry_leg_rise := await _wall_entry_leg_rise(
model, side, thigh_l, thigh_r, shin_l, shin_r) model, side, thigh_l, thigh_r, shin_l, shin_r)
_expect(entry_leg_rise < 0.05, # A real crossfade may raise the leading knee slightly; reject only
"wall-run entry keeps both legs below the pelvis on side %+.0f " # the old near-vertical inversion, not the authored wall-run stride.
% side) _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_side(-1.0)
model.set_wall_surface(Vector3.RIGHT, Vector3(-0.4, 0.0, 0.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) var slide_leg_r := _world_bone_segment(model, thigh_r, shin_r)
slide_entry_rise = maxf(slide_entry_rise, slide_entry_rise = maxf(slide_entry_rise,
maxf(slide_leg_l.y, slide_leg_r.y)) maxf(slide_leg_l.y, slide_leg_r.y))
_expect(slide_entry_rise < 0.05, _expect(slide_entry_rise < 0.20,
"slide entry keeps both legs below the pelvis") "slide entry avoids an inverted leg (%.3f m)" % slide_entry_rise)
model.update_state("ground", 0.0, false) model.update_state("ground", 0.0, false)
await process_frame await process_frame
model.update_state("slide", 4.0, true) model.update_state("slide", 4.0, true)
+4 -1
View File
@@ -6,7 +6,10 @@ class_name MovementParams
@export var crouch_speed: float = 4.5 @export var crouch_speed: float = 4.5
@export var ground_friction: float = 10.0 @export var ground_friction: float = 10.0
@export var ground_acceleration: float = 60.0 @export var ground_acceleration: float = 60.0
@export var ground_deceleration: float = 45.0 # Releasing movement carries roughly one metre of momentum from full speed.
# The old 45/s response erased half the velocity in a single rendered frame,
# leaving no physical time for an authored stopping step to read.
@export var ground_deceleration: float = 9.0
# ── Jump / Air ──────────────────────────────────────────────────────────────── # ── Jump / Air ────────────────────────────────────────────────────────────────
@export var jump_velocity: float = 8.5 @export var jump_velocity: float = 8.5
+24 -3
View File
@@ -83,6 +83,9 @@ var grapple_rope: MeshInstance3D
# broadcasts position/velocity; remote peers interpolate toward it (see # broadcasts position/velocity; remote peers interpolate toward it (see
# _process). The server stays authoritative for health, shield, and kills. # _process). The server stays authoritative for health, shield, and kills.
var synced_movement_state: String = "idle" var synced_movement_state: String = "idle"
## Presentation-only state. Physics/network gameplay still uses
## synced_movement_state; this adds stop and alternating-air variants.
var synced_animation_state: String = "idle"
var synced_movement_speed: float = 0.0 var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO var synced_position: Vector3 = Vector3.ZERO
@@ -91,6 +94,7 @@ var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-glide bank) var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-glide bank)
var synced_wall_normal: Vector3 = Vector3.ZERO var synced_wall_normal: Vector3 = Vector3.ZERO
var synced_wall_contact_point: Vector3 = Vector3.ZERO var synced_wall_contact_point: Vector3 = Vector3.ZERO
var _airborne_alternate := false
var synced_helmet_closed: bool = false var synced_helmet_closed: bool = false
var synced_is_dancing: bool = false # dance emote (B), shown on the model var synced_is_dancing: bool = false # dance emote (B), shown on the model
## Which of the five routines in DanceRoutines is playing. Replicated, so other ## Which of the five routines in DanceRoutines is playing. Replicated, so other
@@ -1063,9 +1067,10 @@ func _physics_process(_delta: float) -> void:
synced_is_ads = _read_ads() synced_is_ads = _read_ads()
# Drive the visual model (skinned GLB or procedural) from local state # Drive the visual model (skinned GLB or procedural) from local state
var h_speed = Vector2(velocity.x, velocity.z).length()
var animation_state := _visual_animation_state(sm.current_state, h_speed)
var visual = get_visual_model() var visual = get_visual_model()
if visual: if visual:
var h_speed = Vector2(velocity.x, velocity.z).length()
if visual.has_method("set_locomotion"): if visual.has_method("set_locomotion"):
var d := _local_move_dir() var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0) visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
@@ -1081,7 +1086,7 @@ func _physics_process(_delta: float) -> void:
visual.set_wall_run_motion(velocity) visual.set_wall_run_motion(velocity)
if visual.has_method("set_helmet_closed"): if visual.has_method("set_helmet_closed"):
visual.set_helmet_closed(synced_helmet_closed) visual.set_helmet_closed(synced_helmet_closed)
visual.update_state(sm.current_state, h_speed, sm.input_crouch) visual.update_state(animation_state, h_speed, sm.input_crouch)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing, synced_dance_index) visual.set_dancing(synced_is_dancing, synced_dance_index)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple": if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
@@ -1090,6 +1095,7 @@ func _physics_process(_delta: float) -> void:
# Publish state for remote peers # Publish state for remote peers
synced_movement_state = sm.current_state synced_movement_state = sm.current_state
synced_animation_state = animation_state
synced_movement_speed = Vector2(velocity.x, velocity.z).length() synced_movement_speed = Vector2(velocity.x, velocity.z).length()
synced_is_crouching = sm.input_crouch synced_is_crouching = sm.input_crouch
synced_wall_side = sm.wall_side synced_wall_side = sm.wall_side
@@ -1109,6 +1115,17 @@ func _local_move_dir() -> Vector2:
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed) return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
func _visual_animation_state(state: String, horizontal_speed: float) -> String:
if state == "air":
return "air_alt" if _airborne_alternate else "air"
if state == "ground" and is_instance_valid(_machine) \
and not _machine.input_crouch \
and _machine.input_dir.length_squared() < 0.01 \
and horizontal_speed > 0.6:
return "stop"
return state
## Whether the local player's active weapon is aiming down sights. ## Whether the local player's active weapon is aiming down sights.
func _read_ads() -> bool: func _read_ads() -> bool:
if not is_instance_valid(camera): if not is_instance_valid(camera):
@@ -1130,6 +1147,10 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play() grapple_latch_player.play()
elif ev == "dash": elif ev == "dash":
_speedline_burst = 1.0 _speedline_burst = 1.0
elif ev == "jump":
# Alternate the complete authored airborne silhouette on successive
# ground jumps so bunny hops do not always lead with the same knee.
_airborne_alternate = not _airborne_alternate
elif ev == "double_jump": elif ev == "double_jump":
_trigger_action("double_jump") _trigger_action("double_jump")
if is_instance_valid(_jet_vfx): if is_instance_valid(_jet_vfx):
@@ -1183,7 +1204,7 @@ func _process(delta: float) -> void:
if visual.has_method("set_helmet_closed"): if visual.has_method("set_helmet_closed"):
visual.set_helmet_closed(synced_helmet_closed) visual.set_helmet_closed(synced_helmet_closed)
visual.update_state( visual.update_state(
synced_movement_state, synced_animation_state,
synced_movement_speed, synced_movement_speed,
synced_is_crouching, synced_is_crouching,
) )
+10 -5
View File
@@ -52,11 +52,8 @@ func update(delta: float) -> void:
# ── Horizontal movement (exponential interpolation for smoothness) ─── # ── Horizontal movement (exponential interpolation for smoothness) ───
var hvel := Vector3(vel.x, 0.0, vel.z) var hvel := Vector3(vel.x, 0.0, vel.z)
var target_vel := wish_dir * effective_speed var target_vel := wish_dir * effective_speed
hvel = _next_horizontal_velocity(
if wish_dir.length_squared() > 0.0: hvel, target_vel, wish_dir.length_squared() > 0.0, delta)
hvel = hvel.lerp(target_vel, 1.0 - exp(-params.ground_acceleration * delta))
else:
hvel = hvel.lerp(Vector3.ZERO, 1.0 - exp(-params.ground_deceleration * delta))
vel.x = hvel.x vel.x = hvel.x
vel.z = hvel.z vel.z = hvel.z
@@ -127,6 +124,14 @@ func update(delta: float) -> void:
return return
func _next_horizontal_velocity(current: Vector3, target: Vector3,
has_input: bool, delta: float) -> Vector3:
var response := params.ground_acceleration \
if has_input else params.ground_deceleration
return current.lerp(target if has_input else Vector3.ZERO,
1.0 - exp(-response * delta))
func _try_step_up(player: CharacterBody3D, wish_dir: Vector3, hvel: Vector3) -> void: func _try_step_up(player: CharacterBody3D, wish_dir: Vector3, hvel: Vector3) -> void:
for i in range(player.get_slide_collision_count()): for i in range(player.get_slide_collision_count()):
var col = player.get_slide_collision(i) var col = player.get_slide_collision(i)
+20
View File
@@ -41,6 +41,7 @@ func run_all() -> void:
"test_dash_adds_speed_to_forward_momentum", "test_dash_adds_speed_to_forward_momentum",
"test_chain_bonus_soft_caps", "test_chain_bonus_soft_caps",
"test_notify_landed_emits_scaled_event", "test_notify_landed_emits_scaled_event",
"test_ground_release_has_short_stopping_glide",
"test_slide_entry_boosts_and_keeps_direction", "test_slide_entry_boosts_and_keeps_direction",
"test_grapple_state_registered_by_machine", "test_grapple_state_registered_by_machine",
] ]
@@ -239,6 +240,25 @@ func test_notify_landed_emits_scaled_event() -> void:
_expect(sm.current_jump_count == 0, "landing resets jump count") _expect(sm.current_jump_count == 0, "landing resets jump count")
func test_ground_release_has_short_stopping_glide() -> void:
var ground = sm.states["ground"]
var velocity := Vector3(0.0, 0.0, -params.walk_speed)
var distance := 0.0
var first_frame_speed := 0.0
for frame in 90:
velocity = ground._next_horizontal_velocity(
velocity, Vector3.ZERO, false, 1.0 / 60.0)
if frame == 0:
first_frame_speed = velocity.length()
distance += velocity.length() / 60.0
if velocity.length() <= 0.6:
break
_expect(first_frame_speed > params.walk_speed * 0.80,
"release keeps most momentum on its first frame")
_expect(distance > 0.75 and distance < 1.5,
"release produces a short stopping glide (%.2f m)" % distance)
func test_slide_entry_boosts_and_keeps_direction() -> void: func test_slide_entry_boosts_and_keeps_direction() -> void:
sm.switch_to("ground") sm.switch_to("ground")
fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed
+1
View File
@@ -149,6 +149,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":rotation") client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation") client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_movement_state") 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_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")
@@ -82,6 +82,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":rotation") client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation") client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_movement_state") 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_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")