jump ani fixing and transition animations
This commit is contained in:
@@ -250,7 +250,12 @@ func _apply_color(col: Color) -> void:
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
var previous_speed := movement_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:
|
||||
_brake_armed = true
|
||||
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
|
||||
if absf(_locomotion_fwd) > 0.15:
|
||||
_brake_fwd = signf(_locomotion_fwd)
|
||||
elif state not in ["ground", "idle"]:
|
||||
elif state not in ["ground", "idle", "stop"]:
|
||||
_brake_left = 0.0
|
||||
_brake_armed = true
|
||||
current_state = state
|
||||
@@ -378,7 +383,7 @@ func _set_shadows_recursive(node: Node) -> void:
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
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)
|
||||
var progress := 1.0 - _brake_left / BRAKE_DURATION
|
||||
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_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":
|
||||
t_root_pos.y = -0.6
|
||||
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_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":
|
||||
t_root_rot.z = deg_to_rad(-15)
|
||||
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
|
||||
# and releases over the same span as the run-to-idle crossfade, preserving
|
||||
# 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
|
||||
t_root_pos.y = lerpf(t_root_pos.y, -0.025, _brake_weight)
|
||||
t_root_rot.x = lerp_angle(
|
||||
|
||||
@@ -48,8 +48,11 @@ const CLIP_FALLBACKS := {
|
||||
"Run": ["Run", "Walk", "Idle"],
|
||||
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
|
||||
"Jump": ["Jump", "Fall", "Idle"],
|
||||
"JumpAlt": ["JumpAlt", "Jump", "Fall", "Idle"],
|
||||
"Fall": ["Fall", "Jump", "Idle"],
|
||||
"FallAlt": ["FallAlt", "Fall", "Jump", "Idle"],
|
||||
"Land": ["Land", "Idle"],
|
||||
"Stop": ["Stop", "Land", "Idle"],
|
||||
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
|
||||
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
|
||||
"SlideStart": ["SlideStart", "Slide"],
|
||||
@@ -85,7 +88,7 @@ const CLIP_FALLBACKS := {
|
||||
"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",
|
||||
"WallCling", "Grapple",
|
||||
"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
|
||||
## visible.
|
||||
const BLEND_TIMES := {
|
||||
"Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28,
|
||||
# SlideStart has a distinct authored crouch pose; blending into it from
|
||||
# airborne/standing can send the lower body through a ground-plane flip.
|
||||
"SlideStart": 0.0, "Slide": 0.30, "Death": 0.34,
|
||||
"Dash": 0.20, "Jump": 0.12, "JumpAlt": 0.12,
|
||||
"Fall": 0.12, "FallAlt": 0.12, "Hit": 0.14, "Land": 0.28,
|
||||
"Stop": 0.12, "SlideStart": 0.12, "Slide": 0.30, "Death": 0.34,
|
||||
"Throw": 0.20, "PistolReload": 0.30,
|
||||
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
|
||||
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32,
|
||||
# The two authored wall performances start in distinct side poses. A full
|
||||
# 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,
|
||||
"CrouchIdle": 0.40, "CrouchWalk": 0.40,
|
||||
"WallRunLeft": 0.12, "WallRunRight": 0.12,
|
||||
"WallCling": 0.32, "Grapple": 0.32,
|
||||
}
|
||||
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 _motion_yaw := 0.0
|
||||
var _motion_was_wall_run := false
|
||||
var _stop_entry_speed := 0.0
|
||||
|
||||
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
|
||||
var _current_clip: String = ""
|
||||
@@ -234,6 +234,7 @@ func load_model(path: String) -> void:
|
||||
_wall_surface_point_world = Vector3.ZERO
|
||||
_motion_yaw = 0.0
|
||||
_motion_was_wall_run = false
|
||||
_stop_entry_speed = 0.0
|
||||
_loco_blend_target = Vector2.ZERO
|
||||
_loco_blend_visual = Vector2.ZERO
|
||||
_loco_scale_target = 1.0
|
||||
@@ -857,7 +858,7 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
_prev_state = state
|
||||
return
|
||||
# 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"):
|
||||
_oneshot_lock = 0.25
|
||||
_play_clip("Land")
|
||||
@@ -866,6 +867,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
_prev_state = state
|
||||
if state != "slide":
|
||||
_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"
|
||||
match state:
|
||||
@@ -878,6 +883,13 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
"air":
|
||||
# Rising = jump, falling = the fall loop.
|
||||
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":
|
||||
if previous_state != "slide":
|
||||
_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"
|
||||
|
||||
if clip != "":
|
||||
# A wall jump hands the full body from a side performance to Jump/Fall.
|
||||
# Interpolating those two poses can put the thighs through the horizontal
|
||||
# midpoint, which reads as a delayed leg flip on the jump frame.
|
||||
_play_clip(clip, false, 0.0 if exiting_wall_run else -1.0)
|
||||
var blend_override := -1.0
|
||||
if state in ["wall_run", "slide", "air", "air_alt", "stop"] \
|
||||
or exiting_wall_run:
|
||||
blend_override = _speed_scaled_transition(speed)
|
||||
_play_clip(clip, false, blend_override)
|
||||
|
||||
if _pose_mod:
|
||||
_pose_mod.state = state
|
||||
@@ -927,6 +940,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
var scale := 1.0
|
||||
if clip in ["SlideStart", "Slide"]:
|
||||
scale = _slide_playback_scale(speed)
|
||||
elif clip == "Stop":
|
||||
scale = _stop_playback_scale(_stop_entry_speed)
|
||||
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
|
||||
# Grapple is held, and the wall clips are compact propulsion loops.
|
||||
# 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)
|
||||
|
||||
|
||||
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.
|
||||
##
|
||||
## 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:
|
||||
var exiting_wall_run := _current_state_node in [
|
||||
"WallRunLeft", "WallRunRight"]
|
||||
_state_trans.xfade_time = 0.0 if exiting_wall_run \
|
||||
else GROUND_STATE_BLEND
|
||||
var exiting_stop: bool = \
|
||||
_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)
|
||||
_current_state_node = state_node
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,10 @@ class_name MovementParams
|
||||
@export var crouch_speed: float = 4.5
|
||||
@export var ground_friction: float = 10.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 ────────────────────────────────────────────────────────────────
|
||||
@export var jump_velocity: float = 8.5
|
||||
|
||||
@@ -83,6 +83,9 @@ var grapple_rope: MeshInstance3D
|
||||
# broadcasts position/velocity; remote peers interpolate toward it (see
|
||||
# _process). The server stays authoritative for health, shield, and kills.
|
||||
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_is_crouching: bool = false
|
||||
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_normal: Vector3 = Vector3.ZERO
|
||||
var synced_wall_contact_point: Vector3 = Vector3.ZERO
|
||||
var _airborne_alternate := false
|
||||
var synced_helmet_closed: bool = false
|
||||
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
|
||||
@@ -1063,9 +1067,10 @@ func _physics_process(_delta: float) -> void:
|
||||
synced_is_ads = _read_ads()
|
||||
|
||||
# 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()
|
||||
if visual:
|
||||
var h_speed = Vector2(velocity.x, velocity.z).length()
|
||||
if visual.has_method("set_locomotion"):
|
||||
var d := _local_move_dir()
|
||||
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)
|
||||
if visual.has_method("set_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"):
|
||||
visual.set_dancing(synced_is_dancing, synced_dance_index)
|
||||
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
|
||||
synced_movement_state = sm.current_state
|
||||
synced_animation_state = animation_state
|
||||
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
|
||||
synced_is_crouching = sm.input_crouch
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
func _read_ads() -> bool:
|
||||
if not is_instance_valid(camera):
|
||||
@@ -1130,6 +1147,10 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
grapple_latch_player.play()
|
||||
elif ev == "dash":
|
||||
_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":
|
||||
_trigger_action("double_jump")
|
||||
if is_instance_valid(_jet_vfx):
|
||||
@@ -1183,7 +1204,7 @@ func _process(delta: float) -> void:
|
||||
if visual.has_method("set_helmet_closed"):
|
||||
visual.set_helmet_closed(synced_helmet_closed)
|
||||
visual.update_state(
|
||||
synced_movement_state,
|
||||
synced_animation_state,
|
||||
synced_movement_speed,
|
||||
synced_is_crouching,
|
||||
)
|
||||
|
||||
@@ -52,11 +52,8 @@ func update(delta: float) -> void:
|
||||
# ── Horizontal movement (exponential interpolation for smoothness) ───
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var target_vel := wish_dir * effective_speed
|
||||
|
||||
if wish_dir.length_squared() > 0.0:
|
||||
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))
|
||||
hvel = _next_horizontal_velocity(
|
||||
hvel, target_vel, wish_dir.length_squared() > 0.0, delta)
|
||||
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
@@ -127,6 +124,14 @@ func update(delta: float) -> void:
|
||||
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:
|
||||
for i in range(player.get_slide_collision_count()):
|
||||
var col = player.get_slide_collision(i)
|
||||
|
||||
@@ -41,6 +41,7 @@ func run_all() -> void:
|
||||
"test_dash_adds_speed_to_forward_momentum",
|
||||
"test_chain_bonus_soft_caps",
|
||||
"test_notify_landed_emits_scaled_event",
|
||||
"test_ground_release_has_short_stopping_glide",
|
||||
"test_slide_entry_boosts_and_keeps_direction",
|
||||
"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")
|
||||
|
||||
|
||||
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:
|
||||
sm.switch_to("ground")
|
||||
fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed
|
||||
|
||||
@@ -149,6 +149,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")
|
||||
|
||||
@@ -82,6 +82,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")
|
||||
|
||||
Reference in New Issue
Block a user