feat: implement player movement controller and state machine with dash, slide, and wall-run mechanics

This commit is contained in:
DottsGit
2026-06-03 21:55:10 -04:00
parent 5726aa1043
commit da277e9d35
14 changed files with 1092 additions and 329 deletions
+115 -17
View File
@@ -3,7 +3,6 @@ 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)
@@ -13,13 +12,19 @@ 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_sprint: bool = false
var input_crouch: bool = false
var input_dash: bool = false
# ── State tracking ────────────────────────────────────────────────────────────
var wall_normal: 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
@@ -28,6 +33,10 @@ 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
func _ready() -> void:
@@ -44,52 +53,73 @@ func _physics_process(delta: float) -> void:
if on_ground:
coyote_timer = params.coyote_time
else:
coyote_timer -= delta
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 -= delta
jump_buffer_time = maxf(jump_buffer_time - delta, 0.0)
# Update chain timer
if chain_timer > 0.0:
chain_timer -= delta
else:
chain_count = 0
current_chain_bonus = 0.0
if chain_timer <= 0.0:
chain_count = 0
current_chain_bonus = 0.0
# 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)
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)
# 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
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
for key in states:
var st = states[key]
if key == new_state_name:
st.enter(data)
else:
st.exit()
var new_state = states[new_state_name]
if new_state.has_method("enter"):
new_state.enter(data)
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)
chain_count = mini(chain_count + 1, 10)
else:
chain_count = 1
chain_timer = params.chain_window
current_chain_bonus = min(
current_chain_bonus = minf(
float(chain_count) * params.chain_bonus_per_success,
params.chain_bonus_cap
)
@@ -101,3 +131,71 @@ func register_chain_mechanic(_mechanic_name: String) -> void:
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 n: Vector3 = hit.get("normal", Vector3.ZERO)
# Wall must be roughly vertical (normal mostly horizontal)
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
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 n: Vector3 = hit.get("normal", Vector3.ZERO)
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
wall_side = side_data["side"]
return wall_normal
wall_normal = Vector3.ZERO
wall_side = 0.0
return Vector3.ZERO
func _apply_crouch(crouched: bool) -> void:
var shape_node = 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
var head = player.get_node_or_null("HeadPivot")
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