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