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:
co-authored by
Claude Fable 5
parent
c4a538a469
commit
0ba14a9469
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user