diff --git a/characters/humanoid_model.gd b/characters/humanoid_model.gd index ce32b74..fc97c7a 100644 --- a/characters/humanoid_model.gd +++ b/characters/humanoid_model.gd @@ -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) \ @@ -457,6 +462,15 @@ func _process(delta: float) -> void: t_upper_arm_r_rot.x = -0.5 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 @@ -485,6 +499,20 @@ func _process(delta: float) -> void: t_upper_arm_r_rot.z = -0.5 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) @@ -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( diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index c28460c..d7c9df8 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -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 diff --git a/debug/motion_variation_check.gd b/debug/motion_variation_check.gd new file mode 100644 index 0000000..ff5034b --- /dev/null +++ b/debug/motion_variation_check.gd @@ -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) diff --git a/debug/motion_variation_check.gd.uid b/debug/motion_variation_check.gd.uid new file mode 100644 index 0000000..544b72a --- /dev/null +++ b/debug/motion_variation_check.gd.uid @@ -0,0 +1 @@ +uid://yxud8rrfs1wc diff --git a/debug/test_level_builder.gd b/debug/test_level_builder.gd index 34dde97..89fc228 100644 --- a/debug/test_level_builder.gd +++ b/debug/test_level_builder.gd @@ -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") diff --git a/debug/titanfall_motion_capture.gd b/debug/titanfall_motion_capture.gd index a157882..76e41a1 100644 --- a/debug/titanfall_motion_capture.gd +++ b/debug/titanfall_motion_capture.gd @@ -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, } diff --git a/debug/traversal_animation_check.gd b/debug/traversal_animation_check.gd index b69593e..a4e01d6 100644 --- a/debug/traversal_animation_check.gd +++ b/debug/traversal_animation_check.gd @@ -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) diff --git a/movement/movement_params.gd b/movement/movement_params.gd index e4bd008..d3a6fb0 100644 --- a/movement/movement_params.gd +++ b/movement/movement_params.gd @@ -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 diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 4b34caa..d06878f 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -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, ) diff --git a/movement/states/state_ground.gd b/movement/states/state_ground.gd index 3d28426..1613094 100644 --- a/movement/states/state_ground.gd +++ b/movement/states/state_ground.gd @@ -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) diff --git a/movement/tests/test_fsm_runner.gd b/movement/tests/test_fsm_runner.gd index 37ed294..c0ebf21 100644 --- a/movement/tests/test_fsm_runner.gd +++ b/movement/tests/test_fsm_runner.gd @@ -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 diff --git a/scenes/maps/level_runtime.gd b/scenes/maps/level_runtime.gd index 17fea14..0bbd519 100644 --- a/scenes/maps/level_runtime.gd +++ b/scenes/maps/level_runtime.gd @@ -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") diff --git a/scenes/maps/procedural_arena/procedural_arena_runtime.gd b/scenes/maps/procedural_arena/procedural_arena_runtime.gd index c1473f5..e26fedd 100644 --- a/scenes/maps/procedural_arena/procedural_arena_runtime.gd +++ b/scenes/maps/procedural_arena/procedural_arena_runtime.gd @@ -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")