feat(movement): add movement state machine with air/ground/wallrun/slide/dash/ground states

- MovementStateMachine: core state machine base
- MovementParams: export-rich tunable resource
- StateGround/StateAir: grounded & aeriel locomotion with coyote, buffered jump, bunny hop
- StateWallRun / StateWallCling: wall-based traversal with wall-jump off
- StateSlide: sprint-parallel slide with friction fallout
- StateDash: burst dash with momentum brake
- PlayerMovementController: entry point wiring to CharacterBody3D
- input_map.cfg: full action map reference file
- characters/player/player_base.tscn: placeholder scene
- movement/tests/test_fsm_runner.gd: 9-state unit test runner

Closes #1
This commit is contained in:
2026-06-02 22:36:00 -04:00
parent a4863a7b08
commit 202f67fa32
14 changed files with 993 additions and 6 deletions
+103
View File
@@ -0,0 +1,103 @@
extends Node
class_name MovementStateMachine
## Generic state machine for player movement.
## Each state is a Node child; the machine switches between them.
## States must call `self.transition_to(new_state_name)` to change states.
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
var input_dir: Vector2 = Vector2.ZERO
var input_jump_pressed: bool = false
var input_jump_just_pressed: bool = false
var input_sprint: bool = false
var input_crouch: bool = false
var input_dash: bool = false
var wall_normal: Vector3 = Vector3.ZERO
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 current_jump_count: int = 0
var chain_timer: float = 0.0
var chain_count: int = 0
var current_chain_bonus: float = 0.0
func _ready() -> void:
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:
# Update coyote time
if on_ground:
coyote_timer = params.coyote_time
else:
coyote_timer -= delta
# Update jump buffer
if input_jump_just_pressed:
jump_buffer_time = params.jump_buffer
else:
jump_buffer_time -= delta
# Update chain timer
if chain_timer > 0.0:
chain_timer -= delta
else:
chain_count = 0
current_chain_bonus = 0.0
if current_state.is_empty():
return
var state_node = states[current_state]
if state_node.has_method("update"):
state_node.update(delta)
# Apply gravity that was accumulated during state update, regardless of state.
# CharacterBody3D handles gravity automatically via move_and_slide but
# we tune it per-position here.
func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
if new_state_name == current_state:
return
var prev = current_state
current_state = new_state_name
for key in states:
var st = states[key]
if key == new_state_name:
st.enter(data)
else:
st.exit()
state_changed.emit(prev, new_state_name)
func register_chain_mechanic(mechanic_name: String) -> void:
if chain_timer > 0.0 and chain_count > 0:
chain_count = min(chain_count + 1, 10)
else:
chain_count = 1
chain_timer = params.chain_window
current_chain_bonus = min(
float(chain_count) * params.chain_bonus_per_success,
params.chain_bonus_cap
)
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)