Files
Papay-Shooter/movement/movement_state_machine.gd
2026-08-10 12:17:59 -04:00

440 lines
16 KiB
GDScript

extends Node
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)
var current_state: String = ""
var states: Dictionary = {}
var player: CharacterBody3D
var params: MovementParams
# ── Input (set by controller each physics tick) ───────────────────────────────
var input_dir: Vector2 = Vector2.ZERO
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
# Grapple state
var grapple_point: Vector3 = Vector3.ZERO
var grapple_length: float = 0.0
var grapple_travel_time: float = 0.0
var grapple_shoot_time: float = 0.0
var is_grapple_shooting: bool = false
# ── State tracking ────────────────────────────────────────────────────────────
var wall_normal: Vector3 = Vector3.ZERO
var wall_contact_point: Vector3 = Vector3.ZERO
var wall_side: float = 0.0 # -1 left, +1 right, 0 none
var on_ground: bool = false
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_charges: int = -1 # -1 = initialize from params on first tick
var dash_recharge_timer: float = 0.0
var current_jump_count: int = 0
var chain_timer: float = 0.0
var chain_count: int = 0
var current_chain_bonus: float = 0.0
var original_capsule_height: float = 1.8
var slide_cooldown_timer: float = 0.0 # Prevents instant slide re-entry
var wall_cooldown_timer: float = 0.0 # Prevents instant re-attachment after wall jump
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
var grapple_state = Node.new()
grapple_state.name = "state_grapple"
grapple_state.set_script(load("res://movement/states/state_grapple.gd"))
add_child(grapple_state)
# Ensure wall climb state is injected
var wall_climb_state = Node.new()
wall_climb_state.name = "state_wall_climb"
wall_climb_state.set_script(load("res://movement/states/state_wall_climb.gd"))
add_child(wall_climb_state)
for child in get_children():
if child is Node and child.name.begins_with("state_"):
states[child.name.replace("state_", "")] = child
child.machine = self
if current_state.is_empty() and not states.is_empty():
switch_to(states.keys()[0])
func _physics_process(delta: float) -> void:
# Client-authoritative movement: only the peer that OWNS this player
# simulates it. Everyone else interpolates the synced transform.
if player and not player.is_multiplayer_authority(): return
# Update coyote time
if on_ground:
coyote_timer = params.coyote_time
else:
coyote_timer = maxf(coyote_timer - delta, 0.0)
# Update jump buffer
if input_jump_just_pressed:
jump_buffer_time = params.jump_buffer
else:
jump_buffer_time = maxf(jump_buffer_time - delta, 0.0)
# Update timers
jump_cooldown_timer = maxf(jump_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)
# Dash charges: regain one per dash_cooldown while below max
if dash_charges < 0:
dash_charges = params.dash_charges
if dash_charges < params.dash_charges:
dash_recharge_timer -= delta
if dash_recharge_timer <= 0.0:
dash_charges += 1
dash_recharge_timer = params.dash_cooldown if dash_charges < params.dash_charges else 0.0
# Update chain timer
if chain_timer > 0.0 and on_ground:
chain_timer -= delta
if chain_timer <= 0.0:
chain_count = 0
current_chain_bonus = 0.0
# Global grapple check: if just pressed, raycast from camera
if input_grapple_just_pressed and current_state != "grapple" and not is_grapple_shooting:
_try_start_grapple()
if is_grapple_shooting:
if not input_grapple:
is_grapple_shooting = false
else:
grapple_shoot_time += delta
if grapple_shoot_time >= grapple_travel_time:
is_grapple_shooting = false
movement_event.emit("grapple_latch", {})
switch_to("grapple")
# 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
if not states.has(current_state):
return
var state_node = states[current_state]
if state_node.has_method("update"):
state_node.update(delta)
func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
if new_state_name == current_state:
return
if not states.has(new_state_name):
push_warning("MovementStateMachine: state '%s' not found" % new_state_name)
return
var prev = current_state
# Exit only the current state
if states.has(prev):
var old_state = states[prev]
if old_state.has_method("exit"):
old_state.exit()
# Enter the new state
current_state = new_state_name
var new_state = states[new_state_name]
if new_state.has_method("enter"):
new_state.enter(data)
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
var camera: Camera3D = player.camera
var space_state := player.get_world_3d().direct_space_state
var origin := camera.global_position
var end := origin - camera.global_transform.basis.z * params.grapple_range
var ray := PhysicsRayQueryParameters3D.create(origin, end)
ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
grapple_point = hit.position
grapple_length = origin.distance_to(grapple_point)
grapple_travel_time = grapple_length / params.grapple_shoot_speed
grapple_shoot_time = 0.0
is_grapple_shooting = true
movement_event.emit("grapple_shoot", {})
func register_chain_mechanic(_mechanic_name: String) -> void:
if chain_timer > 0.0 and chain_count > 0:
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
else:
# 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
})
func get_effective_speed(base_speed: float) -> float:
return base_speed * (1.0 + current_chain_bonus)
## Utility: detect wall to left or right of player using horizontal raycasts.
## Returns the wall normal and sets wall_side. Returns Vector3.ZERO if no wall.
func detect_wall_horizontal() -> Vector3:
if not player:
return Vector3.ZERO
var origin := player.global_position + Vector3.UP * params.wall_ray_up_height
var right_dir := player.global_transform.basis.x.normalized()
var space_state := player.get_world_3d().direct_space_state
# Cast two rays: one left, one right
for side_data in [{"dir": right_dir, "side": 1.0}, {"dir": -right_dir, "side": -1.0}]:
var ray_end: Vector3 = origin + side_data["dir"] * params.wall_detect_distance
var ray := PhysicsRayQueryParameters3D.create(origin, ray_end)
ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO)
# Wall must be roughly vertical (normal mostly horizontal)
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
wall_contact_point = hit.get("position", origin)
wall_side = side_data["side"]
return wall_normal
# Also cast along movement direction for head-on walls
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
if hvel.length_squared() > 0.1:
var move_dir := hvel.normalized()
var perp := move_dir.cross(Vector3.UP).normalized()
for side_data in [{"dir": perp, "side": 1.0}, {"dir": -perp, "side": -1.0}]:
var ray_end: Vector3 = origin + side_data["dir"] * params.wall_detect_distance
var ray := PhysicsRayQueryParameters3D.create(origin, ray_end)
ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO)
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
wall_contact_point = hit.get("position", origin)
wall_side = side_data["side"]
return wall_normal
wall_normal = Vector3.ZERO
wall_contact_point = Vector3.ZERO
wall_side = 0.0
return Vector3.ZERO
## Utility: detect wall directly in front of the player.
## Useful for wall climbing and vaulting over short walls.
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)
ray_low.exclude = [player.get_rid()]
var hit_low := space_state.intersect_ray(ray_low)
# Mid ray (chest/head)
var origin_mid := player.global_position + Vector3.UP * params.wall_ray_up_height
var ray_mid := PhysicsRayQueryParameters3D.create(origin_mid, origin_mid + move_dir * dist)
ray_mid.exclude = [player.get_rid()]
var hit_mid := space_state.intersect_ray(ray_mid)
# If both lower rays miss, there is no wall directly in front
if hit_low.is_empty() and hit_mid.is_empty():
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
# Get the normal from the highest point we hit, or the lowest if we only hit low
var best_hit = hit_mid if not hit_mid.is_empty() else hit_low
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()}
## 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
break
if not shape_node:
return
var shape = shape_node.shape as CapsuleShape3D
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)
func can_dash() -> bool:
return dash_charges != 0 # -1 (uninitialized) counts as ready
## Spend one dash charge and start the recharge clock if it isn't running.
func consume_dash_charge() -> void:
if dash_charges < 0:
dash_charges = params.dash_charges
dash_charges = maxi(dash_charges - 1, 0)
if dash_recharge_timer <= 0.0:
dash_recharge_timer = params.dash_cooldown
## Seconds until the NEXT dash is available (0 when a charge is banked).
## HUDs also read get_dash_charges() to draw pips.
func get_dash_cooldown_remaining() -> float:
if dash_charges != 0:
return 0.0
return maxf(dash_recharge_timer, 0.0)
func get_dash_charges() -> int:
return dash_charges if dash_charges >= 0 else params.dash_charges