feat: implement movement state machine architecture and add various sound assets
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdri08bsr5muh
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user