Merge pull request 'feat: implement movement state machine architecture and add various sound assets' (#15) from feat/11-adding-wall-climbing-and-valuting-fundamentals into main

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-06-07 13:13:37 -07:00
30 changed files with 372 additions and 13 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -465,4 +465,26 @@ for i in range(int(sample_rate * flight_duration)):
swarm_flight_samples.append(math.tanh(raw * 2.0)) swarm_flight_samples.append(math.tanh(raw * 2.0))
write_wav('assets/sounds/swarm_flight.wav', swarm_flight_samples) write_wav('assets/sounds/swarm_flight.wav', swarm_flight_samples)
# 23. Vault Sound (Quick airy whoosh + clothing ruffle)
vault_duration = 0.3
vault_samples = []
prev_v = 0.0
for i in range(int(sample_rate * vault_duration)):
t = i / sample_rate
noise = random.uniform(-1.0, 1.0)
# Airy whoosh
alpha = 0.08
filtered = prev_v + alpha * (noise - prev_v)
prev_v = filtered
whoosh_env = math.sin(t * math.pi / vault_duration)
whoosh = filtered * whoosh_env * 0.6
# Ruffle/grab sound
ruffle_env = math.exp(-t * 30.0)
ruffle = noise * ruffle_env * 0.2
vault_samples.append(whoosh + ruffle)
write_wav('assets/sounds/vault.wav', vault_samples)
print("Generated all sounds!") print("Generated all sounds!")
+8
View File
@@ -49,6 +49,14 @@ class_name MovementParams
@export var wall_ray_up_height: float = 0.6 @export var wall_ray_up_height: float = 0.6
@export var wall_ray_down_height: float = -0.3 @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 ─────────────────────────────────────────────────────────────────── # ── Jumping ───────────────────────────────────────────────────────────────────
@export var jump_cooldown: float = 0.27 @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")) grapple_state.set_script(load("res://movement/states/state_grapple.gd"))
add_child(grapple_state) 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(): for child in get_children():
if child is Node and child.name.begins_with("state_"): if child is Node and child.name.begins_with("state_"):
states[child.name.replace("state_", "")] = child states[child.name.replace("state_", "")] = child
@@ -214,6 +220,9 @@ func detect_wall_horizontal() -> Vector3:
ray.exclude = [player.get_rid()] ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray) var hit := space_state.intersect_ray(ray)
if not hit.is_empty(): if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController:
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO) var n: Vector3 = hit.get("normal", Vector3.ZERO)
# Wall must be roughly vertical (normal mostly horizontal) # Wall must be roughly vertical (normal mostly horizontal)
if absf(n.y) < 0.3 and n.length_squared() > 0.0: 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()] ray.exclude = [player.get_rid()]
var hit := space_state.intersect_ray(ray) var hit := space_state.intersect_ray(ray)
if not hit.is_empty(): if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController:
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO) var n: Vector3 = hit.get("normal", Vector3.ZERO)
if absf(n.y) < 0.3 and n.length_squared() > 0.0: if absf(n.y) < 0.3 and n.length_squared() > 0.0:
wall_normal = n.normalized() wall_normal = n.normalized()
@@ -242,6 +254,62 @@ func detect_wall_horizontal() -> Vector3:
wall_side = 0.0 wall_side = 0.0
return Vector3.ZERO 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: func _apply_crouch(crouched: bool) -> void:
var shape_node = null var shape_node = null
+6
View File
@@ -37,6 +37,7 @@ var wallrun_player: AudioStreamPlayer
var wind_player: AudioStreamPlayer var wind_player: AudioStreamPlayer
var jump_player: AudioStreamPlayer var jump_player: AudioStreamPlayer
var double_jump_player: AudioStreamPlayer var double_jump_player: AudioStreamPlayer
var vault_player: AudioStreamPlayer
var grapple_shoot_player: AudioStreamPlayer var grapple_shoot_player: AudioStreamPlayer
var grapple_latch_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") double_jump_player.stream = load("res://assets/sounds/double_jump.wav")
add_child(double_jump_player) 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 = AudioStreamPlayer.new()
dash_player.bus = "SFX" dash_player.bus = "SFX"
dash_player.stream = load("res://assets/sounds/dash.wav") 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() player.double_jump_player.play()
return return
# ── Wall run transition ─────────────────────────────────────────────── # ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
var wall_n := machine.detect_wall_horizontal() if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0:
if wall_n != Vector3.ZERO and machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0: var fwd_wall = machine.detect_wall_forward()
machine.switch_to("wall_run") if fwd_wall.hit:
return 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 ────────────────────────────────────────────────────────────── # ── Dash ──────────────────────────────────────────────────────────────
if machine.input_dash: if machine.input_dash:
+83 -6
View File
@@ -90,6 +90,45 @@ func update(delta: float) -> void:
player.velocity = vel player.velocity = vel
player.move_and_slide() 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() _update_capsule_height()
@@ -116,12 +155,50 @@ func update(delta: float) -> void:
machine.switch_to("air") machine.switch_to("air")
return return
# ── Wall run check ──────────────────────────────────────────────────── # ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
var wall_n := machine.detect_wall_horizontal() if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
if wall_n != Vector3.ZERO and machine.input_dir.length() > 0.1: var fwd_wall = machine.detect_wall_forward()
if not player.is_on_floor(): if fwd_wall.hit:
machine.switch_to("wall_run") if fwd_wall.is_short:
return # 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 ────────────────────────────────────────────────────────────── # ── Dash ──────────────────────────────────────────────────────────────
if machine.input_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.x *= pow(params.slide_friction, delta * 10.0)
vel.z *= 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) ────────────────────────────────────────────── # ── Gravity (for slopes) ──────────────────────────────────────────────
if not player.is_on_floor(): if not player.is_on_floor():
vel.y -= params.gravity * delta 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 elapsed = 0.0
machine.register_chain_mechanic("wall_run") machine.register_chain_mechanic("wall_run")
# Cap the upward momentum so they don't fly up the wall, # Cap the upward momentum so they don't fly up the wall,
# but preserve some if they just jumped onto it. # and prevent negative y momentum so the wall "catches" them.
machine.player.velocity.y = minf(machine.player.velocity.y, 1.5) machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, 1.5)
machine.on_ground = false machine.on_ground = false
_last_vel = machine.player.velocity _last_vel = machine.player.velocity