feat: implement player movement controller and state machine with dash, slide, and wall-run mechanics
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user