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
@@ -3,6 +3,15 @@ class_name MovementStateMachine
|
||||
|
||||
## Generic state machine for player movement.
|
||||
## Each state is a Node child; the machine switches between them.
|
||||
##
|
||||
## The machine owns cross-state concerns so individual states stay small:
|
||||
## - input snapshot (written by the controller each tick)
|
||||
## - jump buffering / coyote time / jump cooldown
|
||||
## - crouch capsule resizing (smoothly lerped, single owner)
|
||||
## - dash cooldown (per-instance, not shared between players)
|
||||
## - grapple hook raycast + travel
|
||||
## - wall detection helpers
|
||||
## - chain-bonus bookkeeping
|
||||
|
||||
signal state_changed(from_state: String, to_state: String)
|
||||
signal movement_event(event_name: String, data: Dictionary)
|
||||
@@ -19,6 +28,7 @@ var wish_dir_world: Vector3 = Vector3.ZERO
|
||||
var input_jump_pressed: bool = false
|
||||
var input_jump_just_pressed: bool = false
|
||||
var input_crouch: bool = false
|
||||
var input_sprint: bool = false
|
||||
var input_dash: bool = false
|
||||
var input_grapple: bool = false
|
||||
var input_grapple_just_pressed: bool = false
|
||||
@@ -38,6 +48,7 @@ var last_ground_time: float = 0.0
|
||||
var jump_buffer_time: float = 0.0
|
||||
var coyote_timer: float = 0.0
|
||||
var jump_cooldown_timer: float = 0.0
|
||||
var dash_cooldown_timer: float = 0.0
|
||||
var current_jump_count: int = 0
|
||||
var chain_timer: float = 0.0
|
||||
var chain_count: int = 0
|
||||
@@ -48,6 +59,9 @@ var wall_cooldown_timer: float = 0.0 # Prevents instant re-attachment after wa
|
||||
var is_crouched: bool = false
|
||||
var can_wall_climb: bool = true
|
||||
|
||||
# Smooth capsule crouch: the machine is the single owner of capsule height.
|
||||
var _capsule_current_height: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Ensure grapple state is injected
|
||||
@@ -86,10 +100,12 @@ func _physics_process(delta: float) -> void:
|
||||
jump_buffer_time = params.jump_buffer
|
||||
else:
|
||||
jump_buffer_time = maxf(jump_buffer_time - delta, 0.0)
|
||||
|
||||
# Update jump cooldown
|
||||
if jump_cooldown_timer > 0.0:
|
||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Update timers
|
||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||
dash_cooldown_timer = maxf(dash_cooldown_timer - delta, 0.0)
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Update chain timer
|
||||
if chain_timer > 0.0 and on_ground:
|
||||
@@ -112,21 +128,9 @@ func _physics_process(delta: float) -> void:
|
||||
movement_event.emit("grapple_latch", {})
|
||||
switch_to("grapple")
|
||||
|
||||
# Manage global crouch state
|
||||
var want_crouch = input_crouch
|
||||
if current_state == "slide":
|
||||
want_crouch = true
|
||||
if want_crouch != is_crouched:
|
||||
is_crouched = want_crouch
|
||||
_apply_crouch(is_crouched)
|
||||
|
||||
# Update slide cooldown
|
||||
if slide_cooldown_timer > 0.0:
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Update wall cooldown
|
||||
if wall_cooldown_timer > 0.0:
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
# Manage global crouch state (slide keeps the capsule low)
|
||||
is_crouched = input_crouch or current_state == "slide"
|
||||
_update_crouch_capsule(delta)
|
||||
|
||||
if current_state.is_empty():
|
||||
return
|
||||
@@ -157,6 +161,33 @@ func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
|
||||
state_changed.emit(prev, new_state_name)
|
||||
|
||||
|
||||
## Called by states when the player touches down. Emits the landing event so
|
||||
## camera/audio/animation can react proportionally to impact speed.
|
||||
func notify_landed(fall_speed: float) -> void:
|
||||
on_ground = true
|
||||
current_jump_count = 0
|
||||
can_wall_climb = true
|
||||
if fall_speed > params.land_soft_speed:
|
||||
movement_event.emit("land", {
|
||||
"fall_speed": fall_speed,
|
||||
"heavy": fall_speed >= params.land_heavy_speed,
|
||||
})
|
||||
|
||||
|
||||
## Shared jump executed from ground-like states. Keeps horizontal momentum.
|
||||
func do_jump(extra_boost: float = 0.0) -> void:
|
||||
player.velocity.y = params.jump_velocity + extra_boost
|
||||
current_jump_count = 1
|
||||
on_ground = false
|
||||
coyote_timer = 0.0
|
||||
jump_buffer_time = 0.0
|
||||
jump_cooldown_timer = params.jump_cooldown
|
||||
register_chain_mechanic("jump")
|
||||
movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
|
||||
|
||||
func _try_start_grapple() -> void:
|
||||
if not player or not player.camera:
|
||||
return
|
||||
@@ -170,9 +201,7 @@ func _try_start_grapple() -> void:
|
||||
if not hit.is_empty():
|
||||
grapple_point = hit.position
|
||||
grapple_length = origin.distance_to(grapple_point)
|
||||
|
||||
# Travel at 45 m/s
|
||||
grapple_travel_time = grapple_length / 45
|
||||
grapple_travel_time = grapple_length / params.grapple_shoot_speed
|
||||
grapple_shoot_time = 0.0
|
||||
is_grapple_shooting = true
|
||||
movement_event.emit("grapple_shoot", {})
|
||||
@@ -183,12 +212,12 @@ func register_chain_mechanic(_mechanic_name: String) -> void:
|
||||
chain_count += 1
|
||||
else:
|
||||
chain_count = 1
|
||||
|
||||
|
||||
# Shrink the chain window as the chain gets longer to make it increasingly punishing
|
||||
# Drops by 0.05 seconds per successful chain, down to a minimum of 0.4 seconds
|
||||
var current_window = maxf(0.4, params.chain_window - (float(chain_count) * 0.05))
|
||||
chain_timer = current_window
|
||||
|
||||
|
||||
var raw_bonus = float(chain_count) * params.chain_bonus_per_success
|
||||
if raw_bonus <= params.chain_bonus_cap:
|
||||
current_chain_bonus = raw_bonus
|
||||
@@ -196,7 +225,7 @@ func register_chain_mechanic(_mechanic_name: String) -> void:
|
||||
# Soft cap: diminishing returns past the cap
|
||||
var over_bonus = raw_bonus - params.chain_bonus_cap
|
||||
current_chain_bonus = params.chain_bonus_cap + (over_bonus / (1.0 + over_bonus * 3.0))
|
||||
|
||||
|
||||
movement_event.emit("chain_updated", {
|
||||
"count": chain_count,
|
||||
"bonus": current_chain_bonus
|
||||
@@ -262,17 +291,17 @@ func detect_wall_horizontal() -> Vector3:
|
||||
func detect_wall_forward() -> Dictionary:
|
||||
if not player:
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
|
||||
|
||||
|
||||
var move_dir := -player.global_transform.basis.z
|
||||
move_dir.y = 0.0
|
||||
if move_dir.length_squared() > 0.01:
|
||||
move_dir = move_dir.normalized()
|
||||
else:
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
|
||||
|
||||
|
||||
var space_state := player.get_world_3d().direct_space_state
|
||||
var dist := 1.2 # Increased to reliably detect walls at an angle
|
||||
|
||||
|
||||
# Lower ray (feet/knees)
|
||||
var origin_low := player.global_position + Vector3.UP * params.wall_ray_down_height
|
||||
var ray_low := PhysicsRayQueryParameters3D.create(origin_low, origin_low + move_dir * dist)
|
||||
@@ -294,22 +323,44 @@ func detect_wall_forward() -> Dictionary:
|
||||
var collider = best_hit.get("collider")
|
||||
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
|
||||
|
||||
|
||||
var normal: Vector3 = best_hit.get("normal", Vector3.ZERO)
|
||||
if abs(normal.y) >= 0.3:
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false} # Not a vertical wall
|
||||
|
||||
|
||||
# Check if wall is short (ledge check) using a top ray
|
||||
var origin_high := player.global_position + Vector3.UP * (params.wall_ray_up_height + params.wall_climb_vault_height_check)
|
||||
var ray_high := PhysicsRayQueryParameters3D.create(origin_high, origin_high + move_dir * dist)
|
||||
ray_high.exclude = [player.get_rid()]
|
||||
var hit_high := space_state.intersect_ray(ray_high)
|
||||
|
||||
|
||||
return {"hit": true, "normal": normal.normalized(), "is_short": hit_high.is_empty()}
|
||||
|
||||
|
||||
func _apply_crouch(crouched: bool) -> void:
|
||||
var shape_node = null
|
||||
## Perform an instant vault over a short wall: forward+up impulse, camera kick
|
||||
## via the rig, cooldown so we don't immediately re-detect the same wall.
|
||||
func do_vault() -> void:
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
movement_event.emit("vault", {})
|
||||
var rig = player.get_node_or_null("HeadPivot")
|
||||
if rig and rig.has_method("add_pitch_impulse"):
|
||||
rig.add_pitch_impulse(6.0)
|
||||
wall_cooldown_timer = 0.3
|
||||
register_chain_mechanic("vault")
|
||||
|
||||
|
||||
## Smoothly lerp the collision capsule toward the crouch/stand height and keep
|
||||
## the capsule's bottom anchored so shrinking doesn't lift the player off the
|
||||
## floor. Standing back up is blocked while there's no headroom.
|
||||
func _update_crouch_capsule(delta: float) -> void:
|
||||
var shape_node: CollisionShape3D = null
|
||||
for child in player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
shape_node = child
|
||||
@@ -317,19 +368,38 @@ func _apply_crouch(crouched: bool) -> void:
|
||||
if not shape_node:
|
||||
return
|
||||
var shape = shape_node.shape as CapsuleShape3D
|
||||
var head = player.get_node_or_null("HeadPivot")
|
||||
if _capsule_current_height <= 0.0:
|
||||
_capsule_current_height = shape.height
|
||||
|
||||
var target_height := original_capsule_height * (0.5 if is_crouched else 1.0)
|
||||
|
||||
# Don't stand up into a ceiling
|
||||
if not is_crouched and target_height > _capsule_current_height + 0.01:
|
||||
var space_state := player.get_world_3d().direct_space_state
|
||||
var from := player.global_position + Vector3.UP * (_capsule_current_height * 0.5)
|
||||
var to := player.global_position + Vector3.UP * (original_capsule_height * 0.55)
|
||||
var ray := PhysicsRayQueryParameters3D.create(from, to)
|
||||
ray.exclude = [player.get_rid()]
|
||||
if not space_state.intersect_ray(ray).is_empty():
|
||||
target_height = _capsule_current_height # hold until clear
|
||||
|
||||
var t := 1.0 - exp(-params.crouch_transition_speed * delta)
|
||||
_capsule_current_height = lerpf(_capsule_current_height, target_height, t)
|
||||
if absf(_capsule_current_height - target_height) < 0.005:
|
||||
_capsule_current_height = target_height
|
||||
|
||||
shape.height = _capsule_current_height
|
||||
# Keep feet planted: offset the shape down by half the height loss.
|
||||
shape_node.position.y = -(original_capsule_height - _capsule_current_height) * 0.5
|
||||
|
||||
|
||||
## 0 (standing) → 1 (fully crouched); used by the camera rig for eye height.
|
||||
func get_crouch_factor() -> float:
|
||||
if original_capsule_height <= 0.0 or _capsule_current_height <= 0.0:
|
||||
return 0.0
|
||||
return clampf((original_capsule_height - _capsule_current_height)
|
||||
/ (original_capsule_height * 0.5), 0.0, 1.0)
|
||||
|
||||
if crouched:
|
||||
shape.height = original_capsule_height * 0.5
|
||||
shape_node.position.y = -original_capsule_height * 0.25
|
||||
if head:
|
||||
head.position.y = 0.7 - (original_capsule_height * 0.5)
|
||||
else:
|
||||
shape.height = original_capsule_height
|
||||
shape_node.position.y = 0.0
|
||||
if head:
|
||||
head.position.y = 0.7
|
||||
|
||||
func get_dash_cooldown_remaining() -> float:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
return maxf(0.0, params.dash_cooldown - (now - StateDash._last_dash_time))
|
||||
return dash_cooldown_timer
|
||||
|
||||
Reference in New Issue
Block a user