feat: implement movement state machine architecture and add various sound assets

This commit is contained in:
DottsGit
2026-06-07 16:13:01 -04:00
parent 064bc90f53
commit 22ea918e6d
30 changed files with 372 additions and 13 deletions
+8
View File
@@ -49,6 +49,14 @@ class_name MovementParams
@export var wall_ray_up_height: float = 0.6
@export var wall_ray_down_height: float = -0.3
# ── Wall Climb ────────────────────────────────────────────────────────────────
@export var wall_climb_speed: float = 6.0
@export var wall_climb_duration: float = 3.0
@export var wall_climb_max_angle: float = 55.0
@export var wall_climb_vault_forward: float = 16.0
@export var wall_climb_vault_up: float = 14.0
@export var wall_climb_vault_height_check: float = 1.0
# ── Jumping ───────────────────────────────────────────────────────────────────
@export var jump_cooldown: float = 0.27
+68
View File
@@ -55,6 +55,12 @@ func _ready() -> void:
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
@@ -214,6 +220,9 @@ func detect_wall_horizontal() -> Vector3:
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:
continue
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:
@@ -232,6 +241,9 @@ func detect_wall_horizontal() -> Vector3:
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:
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO)
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized()
@@ -242,6 +254,62 @@ func detect_wall_horizontal() -> Vector3:
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 hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var move_dir := Vector3.ZERO
if hvel.length_squared() > 0.1:
move_dir = hvel.normalized()
else:
move_dir = -player.global_transform.basis.z
move_dir.y = 0.0
if move_dir.length_squared() > 0.01:
move_dir = move_dir.normalized()
if move_dir.length_squared() < 0.1:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var space_state := player.get_world_3d().direct_space_state
var dist := params.wall_detect_distance
# 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
if best_hit.get("collider") is PlayerMovementController:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var normal: Vector3 = best_hit.get("normal", Vector3.ZERO)
if absf(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()}
func _apply_crouch(crouched: bool) -> void:
var shape_node = null
+6
View File
@@ -37,6 +37,7 @@ var wallrun_player: AudioStreamPlayer
var wind_player: AudioStreamPlayer
var jump_player: AudioStreamPlayer
var double_jump_player: AudioStreamPlayer
var vault_player: AudioStreamPlayer
var grapple_shoot_player: AudioStreamPlayer
var grapple_latch_player: AudioStreamPlayer
@@ -123,6 +124,11 @@ func _setup_audio() -> void:
double_jump_player.stream = load("res://assets/sounds/double_jump.wav")
add_child(double_jump_player)
vault_player = AudioStreamPlayer.new()
vault_player.bus = "SFX"
vault_player.stream = load("res://assets/sounds/vault.wav")
add_child(vault_player)
dash_player = AudioStreamPlayer.new()
dash_player.bus = "SFX"
dash_player.stream = load("res://assets/sounds/dash.wav")
+42 -5
View File
@@ -100,11 +100,48 @@ func update(delta: float) -> void:
player.double_jump_player.play()
return
# ── Wall run transition ───────────────────────────────────────────────
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO and machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0:
machine.switch_to("wall_run")
return
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0:
var fwd_wall = machine.detect_wall_forward()
if fwd_wall.hit:
if fwd_wall.is_short:
# Instant Vault
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()
# Quick camera animation (tilt up and forward bob)
var camera = player.camera
if camera:
var tween = player.create_tween()
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
machine.wall_cooldown_timer = 0.3
return
else:
# Check if moving directly into wall and looking generally towards it
var look_dir := -player.global_transform.basis.z
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
if looking_towards and moving_towards:
machine.wall_normal = fwd_wall.normal
machine.switch_to("wall_climb")
return
# Fallback to wall run
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO:
machine.switch_to("wall_run")
return
# ── Dash ──────────────────────────────────────────────────────────────
if machine.input_dash:
+83 -6
View File
@@ -90,6 +90,45 @@ func update(delta: float) -> void:
player.velocity = vel
player.move_and_slide()
# ── Stair Stepping ────────────────────────────────────────────────────
if wish_dir.length_squared() > 0.01 and not machine.input_crouch:
for i in range(player.get_slide_collision_count()):
var col = player.get_slide_collision(i)
if absf(col.get_normal().y) < 0.3: # Hit a wall
var max_step_height = 0.5
var space_state = player.get_world_3d().direct_space_state
var feet_y = player.global_position.y - (machine.original_capsule_height / 2.0)
# Raycast down from above the step
var fwd = wish_dir.normalized()
var ray_start = player.global_position + fwd * 0.5
ray_start.y = feet_y + max_step_height + 0.1
var ray_end = ray_start - Vector3.UP * (max_step_height + 0.2)
var ray = PhysicsRayQueryParameters3D.create(ray_start, ray_end)
ray.exclude = [player.get_rid()]
var hit = space_state.intersect_ray(ray)
if not hit.is_empty() and hit.normal.y > 0.7:
var step_height = hit.position.y - feet_y
if step_height > 0.01 and step_height <= max_step_height:
# Check if we have headroom to move up
var head_ray_start = player.global_position
head_ray_start.y += (machine.original_capsule_height / 2.0)
var head_ray_end = head_ray_start + Vector3.UP * (step_height + 0.1)
var head_ray = PhysicsRayQueryParameters3D.create(head_ray_start, head_ray_end)
head_ray.exclude = [player.get_rid()]
if space_state.intersect_ray(head_ray).is_empty():
# We can safely step up
player.global_position.y += step_height + 0.01
# Push slightly forward to get onto the step
player.global_position += fwd * 0.1
# Restore horizontal velocity that was lost from hitting the wall
player.velocity.x = hvel.x
player.velocity.z = hvel.z
break
_update_capsule_height()
@@ -116,12 +155,50 @@ func update(delta: float) -> void:
machine.switch_to("air")
return
# ── Wall run check ────────────────────────────────────────────────────
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO and machine.input_dir.length() > 0.1:
if not player.is_on_floor():
machine.switch_to("wall_run")
return
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
var fwd_wall = machine.detect_wall_forward()
if fwd_wall.hit:
if fwd_wall.is_short:
# Instant Vault
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()
# Quick camera animation (tilt up and forward bob)
var camera = player.camera
if camera:
var tween = player.create_tween()
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
machine.wall_cooldown_timer = 0.3
machine.switch_to("air")
return
else:
# Check if moving directly into wall and looking generally towards it
var look_dir := -player.global_transform.basis.z
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
if looking_towards and moving_towards:
machine.wall_normal = fwd_wall.normal
machine.switch_to("wall_climb")
return
# Fallback to wall run
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO:
if not player.is_on_floor():
machine.switch_to("wall_run")
return
# ── Dash ──────────────────────────────────────────────────────────────
if machine.input_dash:
+16
View File
@@ -64,6 +64,22 @@ func update(delta: float) -> void:
vel.x *= pow(params.slide_friction, delta * 10.0)
vel.z *= pow(params.slide_friction, delta * 10.0)
# ── Allow slight steering ─────────────────────────────────────────────
var wish_dir := machine.wish_dir_world
if wish_dir.length_squared() > 0.01:
wish_dir = wish_dir.normalized()
var hvel := Vector3(vel.x, 0.0, vel.z)
var current_speed := hvel.length()
# Slowly bend velocity towards wish_dir
var steer_speed = 3.0 * delta
var new_hvel = hvel.lerp(wish_dir * current_speed, steer_speed)
vel.x = new_hvel.x
vel.z = new_hvel.z
if new_hvel.length_squared() > 0.01:
_slide_direction = new_hvel.normalized()
# ── Gravity (for slopes) ──────────────────────────────────────────────
if not player.is_on_floor():
vel.y -= params.gravity * delta
+124
View File
@@ -0,0 +1,124 @@
extends Node
class_name StateWallClimb
var machine: MovementStateMachine
var params: MovementParams:
get: return machine.params
var elapsed: float = 0.0
func enter(_data: Dictionary = {}) -> void:
elapsed = 0.0
machine.register_chain_mechanic("wall_climb")
machine.on_ground = false
if machine.player.wallrun_player and not machine.player.wallrun_player.playing:
machine.player.wallrun_player.play()
# Give an initial upward boost
var player := machine.player
player.velocity.y = params.wall_climb_speed
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
player.velocity.x = hvel.x * 0.5
player.velocity.z = hvel.z * 0.5
func exit() -> void:
if machine.player.wallrun_player:
machine.player.wallrun_player.stop()
func update(delta: float) -> void:
elapsed += delta
var player := machine.player
if player.wallrun_player and not player.wallrun_player.playing:
player.wallrun_player.play()
# ── Time limit ────────────────────────────────────────────────────────
if elapsed > params.wall_climb_duration:
_detach()
return
# ── Look/Move away to transition to wall run ──────────────────────────────
var look_dir := -player.global_transform.basis.z
var wish_dir := machine.wish_dir_world
var looking_towards := look_dir.dot(-machine.wall_normal) > 0.0
var moving_sideways := false
if wish_dir.length_squared() > 0.01:
moving_sideways = wish_dir.dot(-machine.wall_normal) <= cos(deg_to_rad(params.wall_climb_max_angle))
if not looking_towards or moving_sideways:
# We are looking away or moving sideways, transition to wall run
# The wall run state will figure out tangent and side
var wall_n = machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO:
machine.switch_to("wall_run")
return
else:
_detach()
return
# ── Ledge Vault Check ─────────────────────────────────────────────────
var fwd_wall = machine.detect_wall_forward()
if not fwd_wall.hit:
# The forward lower ray missed, we fell off the wall completely
_detach()
return
if fwd_wall.is_short:
# The upper ray missed, meaning we reached the top of the wall!
_vault()
return
# ── Movement ──────────────────────────────────────────────────────────
# Slow down over time
var speed_factor = maxf(0.0, 1.0 - (elapsed / params.wall_climb_duration))
player.velocity.y = params.wall_climb_speed * speed_factor
# Push slightly into the wall to maintain contact
player.velocity -= machine.wall_normal * 2.0
player.move_and_slide()
# ── Hit floor during wall climb (should be rare) ──────────────────────
if player.is_on_floor():
machine.on_ground = true
machine.wall_normal = Vector3.ZERO
machine.wall_side = 0.0
machine.current_jump_count = 0
machine.switch_to("ground")
func _detach() -> void:
machine.wall_normal = Vector3.ZERO
machine.wall_side = 0.0
machine.wall_cooldown_timer = 0.3
machine.switch_to("air")
func _vault() -> void:
var player := machine.player
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()
# Impulse
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()
# Quick camera animation (tilt up and forward bob)
var camera = player.camera
if camera:
var tween = player.create_tween()
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
machine.wall_normal = Vector3.ZERO
machine.wall_side = 0.0
machine.wall_cooldown_timer = 0.3
machine.switch_to("air")
+1
View File
@@ -0,0 +1 @@
uid://bdri08bsr5muh
+2 -2
View File
@@ -16,8 +16,8 @@ func enter(_data: Dictionary = {}) -> void:
elapsed = 0.0
machine.register_chain_mechanic("wall_run")
# Cap the upward momentum so they don't fly up the wall,
# but preserve some if they just jumped onto it.
machine.player.velocity.y = minf(machine.player.velocity.y, 1.5)
# and prevent negative y momentum so the wall "catches" them.
machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, 1.5)
machine.on_ground = false
_last_vel = machine.player.velocity