feat: overhaul movement for flow — momentum-preserving states, smooth crouch, landing feel

- Quake-style air-strafe accelerate (real speed gain from strafing; external
  speed from dash/rockets never clamped)
- Wall run: keeps entry momentum (incl. upward carry), accelerates along the
  wall instead of hard-setting velocity, gravity fades in over the run
- Slide: slope physics (gravity projected along floor accelerates downhill),
  flat-ground decel instead of multiplicative friction, entry boost, direct
  slide->wallrun and slide->dash transitions
- Dash: cooldown 10s -> 2s, per-player (was a static shared across instances),
  momentum fully kept on exit, FOV punch event
- Grapple: taut pendulum with active rope control (W reels in, S pays out),
  release keeps swing energy
- Wall climb: carries upward momentum in, jump-off kick, shared vault helper
- Crouch capsule: single owner in the machine, smoothly lerped, ceiling check;
  camera eye height follows via crouch factor (no more head snapping)
- Landing: machine emits 'land' events scaled by impact; camera dip + pitched
  down thud; footsteps get pitch variation
- Camera rig: event-driven (land dip, dash FOV kick, vault pitch impulse),
  slide roll tilt
- Model: Jump vs Fall split by vertical velocity, Land one-shot on heavy
  landings, wall-run body lean (synced to remotes via synced_wall_side)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-17 11:58:14 -04:00
co-authored by Claude Fable 5
parent c4a538a469
commit 0ba14a9469
16 changed files with 653 additions and 493 deletions
+50 -2
View File
@@ -35,6 +35,7 @@ const CLIP_FALLBACKS := {
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
"Jump": ["Jump", "Fall", "Idle"],
"Fall": ["Fall", "Jump", "Idle"],
"Land": ["Land", "Idle"],
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
@@ -65,10 +66,12 @@ var _pose_mod: ShooterPoseModifier
var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _target_wall: float = 0.0
var _cur_strafe: float = 0.0
var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
var _cur_wall: float = 0.0
const POSE_SMOOTH := 10.0
@@ -184,12 +187,30 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
# ── Animation state ───────────────────────────────────────────────────────────
var _prev_state: String = ""
var _land_lock: float = 0.0 # seconds left where the Land one-shot owns playback
## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
if not loaded or not animation_player:
return
# A heavy landing plays the Land one-shot before locomotion resumes.
if _land_lock > 0.0:
_land_lock -= get_process_delta_time()
if _land_lock > 0.0 and state in ["ground", "idle"]:
_prev_state = state
return
if state in ["ground", "idle"] and _prev_state == "air" \
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
_land_lock = 0.25
_play_clip("Land")
_prev_state = state
return
_prev_state = state
var clip := "Idle"
match state:
"ground", "idle":
@@ -202,8 +223,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
elif speed > 0.5:
clip = "Walk"
"air":
# Composed, weapon-ready airborne — not a flailing fall.
clip = "Jump"
# Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
"slide":
clip = "Slide"
"wall_run":
@@ -243,6 +264,12 @@ func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none.
## Drives a whole-body lean into the wall.
func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
func _process(delta: float) -> void:
if not _pose_mod:
return
@@ -252,10 +279,13 @@ func _process(delta: float) -> void:
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, t)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.ads = _cur_ads
_pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall
func _play_clip(canonical: String) -> void:
@@ -268,6 +298,14 @@ func _play_clip(canonical: String) -> void:
_current_clip = clip_name
## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float:
var p := get_parent()
if p is CharacterBody3D:
return p.velocity.y
return 0.0
# ── Third-person weapon ───────────────────────────────────────────────────────
## Attach a weapon (by weapon script path) to the right hand bone so other
@@ -356,6 +394,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var fwd: float = 0.0 # -1 back .. +1 forward
var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
var state: String = "idle"
var weapon_held: bool = false
@@ -392,6 +431,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_lean(skel)
if slide > 0.01:
_apply_slide(skel)
if absf(wall) > 0.01:
_apply_wall_lean(skel)
if weapon_held:
_apply_weapon(skel)
@@ -406,6 +447,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Wall run: roll the torso into the wall (+wall = wall on the right).
func _apply_wall_lean(skel: Skeleton3D) -> void:
var roll := Quaternion(Vector3(0, 0, 1), wall * 0.35)
var per := Quaternion.IDENTITY.slerp(roll, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Slide: lean the whole torso back, then pitch the head up to look forward.
func _apply_slide(skel: Skeleton3D) -> void:
var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide)