440 lines
16 KiB
GDScript
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
|