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
+81 -9
View File
@@ -1,7 +1,12 @@
extends Node3D
class_name FPSCameraRig
## FPS camera rig — handles mouse look, head bob, FOV kick, wall-run tilt.
## FPS camera rig — mouse look, head bob, FOV kick, wall-run tilt, plus
## movement-feel feedback driven by MovementStateMachine events:
## - landing dip scaled by impact speed
## - pitch impulse (vault kick) with spring decay
## - smooth crouch/slide eye height (follows the capsule's smoothed height)
## - slide roll tilt and dash FOV punch
## Attach as child of the CharacterBody3D player. Camera3D is a child of this node.
@export var sensitivity: float = 0.002
@@ -16,16 +21,50 @@ var _target_fov: float = 90.0
var _weapon_fov_override: float = -1.0
var _step_offset_y: float = 0.0
# Movement-feel feedback state
var _base_eye_y: float = 0.7
var _land_dip: float = 0.0 # current downward dip (springs back to 0)
var _pitch_impulse: float = 0.0 # extra camera pitch in radians, decays
var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
var _machine: MovementStateMachine = null
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
camera = get_node_or_null("Camera3D")
_base_eye_y = position.y
if camera and params:
camera.fov = SettingsManager.world_fov
_target_fov = SettingsManager.world_fov
# Explicitly enable callbacks — set_script() at runtime doesn't auto-register them
set_process_input(true)
set_process(true)
# The state machine may be added after us; hook events once the tree settles.
call_deferred("_connect_machine")
func _connect_machine() -> void:
var player := get_parent()
if not player:
return
var sm = player.get_node_or_null("MovementStateMachine")
if sm and sm is MovementStateMachine:
_machine = sm
if not sm.movement_event.is_connected(_on_movement_event):
sm.movement_event.connect(_on_movement_event)
func _on_movement_event(ev: String, data: Dictionary) -> void:
match ev:
"land":
var fall_speed: float = data.get("fall_speed", 0.0)
var scale_p: float = params.land_dip_scale if params else 0.012
var max_p: float = params.land_dip_max if params else 0.25
_land_dip = clampf(fall_speed * scale_p, 0.0, max_p)
"dash":
_dash_fov_kick = 15.0
"vault":
pass # pitch impulse is applied via add_pitch_impulse by the machine
func _input(event: InputEvent) -> void:
@@ -53,6 +92,14 @@ func _process(delta: float) -> void:
if not player:
return
if not _machine:
_connect_machine()
var state := ""
if _machine:
state = _machine.current_state
var is_sliding := state == "slide"
# ── FOV kick ──────────────────────────────────────────────────────────
_target_fov = SettingsManager.world_fov
var hspeed := Vector2(player.velocity.x, player.velocity.z).length()
@@ -60,16 +107,25 @@ func _process(delta: float) -> void:
# Smoothly scale FOV based on speed above walking speed
var speed_factor = clampf((hspeed - params.walk_speed) / (params.walk_speed * 0.5), 0.0, 1.0)
_target_fov = lerpf(SettingsManager.world_fov, SettingsManager.world_fov + 20.0, speed_factor)
# Dash punch decays on top
_dash_fov_kick = lerpf(_dash_fov_kick, 0.0, 1.0 - exp(-6.0 * delta))
_target_fov += _dash_fov_kick
if _weapon_fov_override > 0.0:
_target_fov = _weapon_fov_override
camera.fov = lerpf(camera.fov, _target_fov, 1.0 - exp(-params.fov_lerp_speed * delta))
# ── Crouch / slide eye height (follows the smoothed capsule) ──────────
var crouch_factor := 0.0
if _machine:
crouch_factor = _machine.get_crouch_factor()
var eye_drop: float = _machine.original_capsule_height * 0.5 if _machine else 0.9
var target_eye_y: float = _base_eye_y - crouch_factor * eye_drop
position.y = lerpf(position.y, target_eye_y, 1.0 - exp(-12.0 * delta))
# ── Head bob ──────────────────────────────────────────────────────────
var sm = player.get_node_or_null("MovementStateMachine")
var is_sliding = sm and sm.current_state == "slide"
if player.is_on_floor() and hspeed > 1.0 and not is_sliding:
_bob_timer += delta * params.head_bob_frequency * (hspeed / params.walk_speed)
var bob_y := sin(_bob_timer) * params.head_bob_amplitude
@@ -81,12 +137,25 @@ func _process(delta: float) -> void:
camera.position.y = lerp(camera.position.y, 0.0, 0.15)
camera.position.x = lerp(camera.position.x, 0.0, 0.15)
# ── Landing dip (springs back up) ─────────────────────────────────────
_land_dip = lerpf(_land_dip, 0.0, 1.0 - exp(-8.0 * delta))
camera.position.y -= _land_dip
# ── Step Smoothing ─────────────────────────────────────────────────────
_step_offset_y = lerpf(_step_offset_y, 0.0, 15.0 * delta)
camera.position.y += _step_offset_y
# ── Wall-run tilt ─────────────────────────────────────────────────────
_current_tilt = lerpf(_current_tilt, _target_tilt, 1.0 - exp(-params.wall_run_tilt_speed * delta))
# ── Pitch impulse (vault kick etc.), spring-decayed ───────────────────
_pitch_impulse = lerpf(_pitch_impulse, 0.0, 1.0 - exp(-7.0 * delta))
camera.rotation.x = _pitch_impulse
# ── Tilt: wall-run lean + slide roll ──────────────────────────────────
var tilt_target := _target_tilt
if is_sliding:
# Subtle roll in the slide's steering direction
var slide_roll: float = params.slide_tilt_angle
tilt_target += -_machine.input_dir.x * slide_roll if _machine else 0.0
_current_tilt = lerpf(_current_tilt, tilt_target, 1.0 - exp(-params.wall_run_tilt_speed * delta))
camera.rotation.z = deg_to_rad(_current_tilt)
## Called by the movement system to set wall-run tilt direction.
@@ -105,8 +174,11 @@ func set_weapon_fov(fov: float) -> void:
func clear_wall_tilt() -> void:
_target_tilt = 0.0
## Adds an offset to the camera so that when the player physics body snaps up a step,
## Kick the camera pitch (degrees); decays back smoothly. Used for vaults.
func add_pitch_impulse(degrees: float) -> void:
_pitch_impulse += deg_to_rad(degrees)
## Adds an offset to the camera so that when the player physics body snaps up a step,
## the camera interpolates smoothly instead of snapping.
func add_step_offset(offset_y: float) -> void:
_step_offset_y += offset_y