This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+177 -52
View File
@@ -39,6 +39,7 @@ var footstep_player: AudioStreamPlayer
var footstep_streams: Array = []
var land_player: AudioStreamPlayer
var _remote_footstep_timer: float = 0.0
var _slide_surface_event: String = ""
# Networked one-shot animation actions (reload/throw): the owner bumps the
# sequence; remote peers play the named action when it changes.
@@ -50,6 +51,7 @@ var _was_reloading: bool = false
## own third-person model (remote shooters go through server_play_fire_effects).
var _last_local_ammo: int = -1
var dash_player: AudioStreamPlayer
var _jet_vfx: PlayerJetVFX = null
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer
var slide_player: AudioStreamPlayer
@@ -146,6 +148,7 @@ func _ready() -> void:
camera.add_child(flashlight)
_setup_audio()
_setup_jet_vfx()
_setup_grapple()
_setup_grenade_trajectory()
@@ -215,7 +218,13 @@ func set_third_person(on: bool) -> void:
return
third_person = on
if is_instance_valid(_tp_camera):
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
# Tool-script regression runners compile this controller before autoload
# singletons register, so a direct `SettingsManager` identifier makes the
# whole movement state machine fail to compile. Resolve the same singleton
# at runtime; level scenes and `-s` tests now share one safe path.
var settings = get_tree().root.get_node_or_null("SettingsManager")
if settings:
_tp_camera.fov = settings.world_fov
_tp_camera.current = on
if is_instance_valid(camera):
camera.current = not on
@@ -223,6 +232,8 @@ func set_third_person(on: bool) -> void:
var visual = get_visual_model()
if visual and visual.has_method("set_owner_visible"):
visual.set_owner_visible(on)
if is_instance_valid(_jet_vfx):
_jet_vfx.set_render_enabled(on)
# Hide the first-person weapon viewmodel in third person (it would float in
# the middle of the screen); the third-person weapon on the model shows.
if is_instance_valid(camera):
@@ -260,6 +271,21 @@ func get_visual_model() -> Node3D:
return skinned_model
return get_node_or_null("HumanoidModel")
func _setup_jet_vfx() -> void:
_jet_vfx = PlayerJetVFX.new()
_jet_vfx.name = "JetVFX"
add_child(_jet_vfx)
_jet_vfx.set_render_enabled(not is_multiplayer_authority() or third_person)
func _update_jet_vfx(state: String, move_dir: Vector2) -> void:
if not is_instance_valid(_jet_vfx):
return
_jet_vfx.set_visual_model(get_visual_model())
_jet_vfx.set_render_enabled(not is_multiplayer_authority() or third_person)
_jet_vfx.set_dash(state == "dash", move_dir)
## Swap the visual model to match a skin id. GLB skins replace the procedural
## model entirely; color skins tint the procedural model.
func _apply_skin_model(skin_id: String) -> void:
@@ -306,88 +332,150 @@ func _hide_remote_weapons() -> void:
func _setup_audio() -> void:
footstep_player = AudioStreamPlayer.new()
footstep_player.bus = "SFX"
for path in ["res://assets/sounds/footstep.wav", "res://assets/sounds/footstep_01.wav",
"res://assets/sounds/footstep_02.wav", "res://assets/sounds/footstep_03.wav",
"res://assets/sounds/footstep_04.wav"]:
if ResourceLoader.exists(path):
footstep_streams.append(load(path))
footstep_player.stream = footstep_streams[0] if not footstep_streams.is_empty() else null
_configure_audio_player(footstep_player, "footstep_concrete")
add_child(footstep_player)
land_player = AudioStreamPlayer.new()
land_player.bus = "SFX"
land_player.stream = load("res://assets/sounds/land.wav")
_configure_audio_player(land_player, "land")
add_child(land_player)
jump_player = AudioStreamPlayer.new()
jump_player.bus = "SFX"
jump_player.stream = load("res://assets/sounds/jump.wav")
_configure_audio_player(jump_player, "jump")
add_child(jump_player)
double_jump_player = AudioStreamPlayer.new()
double_jump_player.bus = "SFX"
jump_player.bus = "SFX"
double_jump_player.stream = load("res://assets/sounds/double_jump.wav")
_configure_audio_player(double_jump_player, "double_jump")
add_child(double_jump_player)
vault_player = AudioStreamPlayer.new()
vault_player.bus = "SFX"
vault_player.stream = load("res://assets/sounds/vault.wav")
_configure_audio_player(vault_player, "vault")
add_child(vault_player)
dash_player = AudioStreamPlayer.new()
dash_player.bus = "SFX"
dash_player.stream = load("res://assets/sounds/dash.wav")
dash_player.volume_db = -5.0
_configure_audio_player(dash_player, "dash")
add_child(dash_player)
hit_player = AudioStreamPlayer.new()
hit_player.bus = "SFX"
hit_player.stream = load("res://assets/sounds/hit_confirm.wav")
_configure_audio_player(hit_player, "hit_confirm")
hit_player.max_polyphony = 8
add_child(hit_player)
slide_player = AudioStreamPlayer.new()
slide_player.bus = "SFX"
slide_player.stream = load("res://assets/sounds/slide.wav")
slide_player.volume_db = -10.0
_configure_audio_player(slide_player, "movement_slide_concrete")
add_child(slide_player)
wallrun_player = AudioStreamPlayer.new()
wallrun_player.bus = "SFX"
wallrun_player.stream = load("res://assets/sounds/wallrun.wav")
wallrun_player.volume_db = -10.0
_configure_audio_player(wallrun_player, "wallrun")
add_child(wallrun_player)
wind_player = AudioStreamPlayer.new()
wind_player.bus = "Wind"
wind_player.stream = load("res://assets/sounds/wind.wav")
_configure_audio_player(wind_player, "wind")
wind_player.volume_db = -80.0 # Start silent
add_child(wind_player)
wind_player.play()
grapple_shoot_player = AudioStreamPlayer.new()
grapple_shoot_player.bus = "SFX"
grapple_shoot_player.stream = load("res://assets/sounds/dash.wav")
grapple_shoot_player.volume_db = -5.0
grapple_shoot_player.pitch_scale = 1.5
_configure_audio_player(grapple_shoot_player, "grapple_launch")
add_child(grapple_shoot_player)
grapple_latch_player = AudioStreamPlayer.new()
grapple_latch_player.bus = "SFX"
grapple_latch_player.stream = load("res://assets/sounds/hit_confirm.wav")
grapple_latch_player.pitch_scale = 0.8
_configure_audio_player(grapple_latch_player, "grapple_latch")
add_child(grapple_latch_player)
grapple_swing_player = AudioStreamPlayer.new()
grapple_swing_player.bus = "SFX"
grapple_swing_player.stream = load("res://assets/sounds/wind.wav")
grapple_swing_player.pitch_scale = 1.5
_configure_audio_player(grapple_swing_player, "grapple_reel")
grapple_swing_player.volume_db = -80.0
add_child(grapple_swing_player)
grapple_swing_player.play()
func _configure_audio_player(player: Node, event: String,
fallback_path: String = "", volume_db_offset: float = 0.0) -> void:
# See set_third_person(): direct autoload identifiers are unavailable while
# `-s` regression scripts compile this controller's dependency graph.
var audio_manager = get_tree().root.get_node_or_null("AudioManager")
if audio_manager:
audio_manager.configure_player(
player, event, fallback_path, volume_db_offset)
## Material under/alongside the controller, using the same collider metadata as
## gunshot occlusion. Builders already identify wood/metal/glass/brick, so
## movement audio stays correct without map-specific node lists.
func acoustic_surface(direction: Vector3 = Vector3.DOWN,
distance: float = 1.8) -> String:
if not is_inside_tree() or direction.length_squared() < 0.001:
return "concrete"
var start := global_position + Vector3.UP * 0.12
var query := PhysicsRayQueryParameters3D.create(
start, start + direction.normalized() * distance, 1)
query.exclude = [get_rid()]
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if hit.is_empty():
return "concrete"
return Acoustics.classify(hit.collider)
func _surface_family(material: String) -> String:
match material:
"metal":
return "metal"
"wood":
return "wood"
"glass":
return "glass"
_:
# Brick, stone, sand and untagged structural surfaces all use the
# concrete family; each still varies sample and pitch.
return "concrete"
func surface_event(prefix: String, direction: Vector3 = Vector3.DOWN,
distance: float = 1.8) -> String:
return "%s_%s" % [prefix, _surface_family(
acoustic_surface(direction, distance))]
func play_footstep(speed: float) -> void:
if footstep_player == null:
return
var event := surface_event("footstep")
var speed_mix := clampf((speed - 1.0) / 10.0, 0.0, 1.0)
_configure_audio_player(footstep_player, event, "",
lerpf(-3.0, 0.5, speed_mix))
footstep_player.pitch_scale = lerpf(0.96, 1.045, speed_mix) \
* randf_range(0.985, 1.015)
footstep_player.play()
func update_slide_audio(speed: float) -> void:
if slide_player == null:
return
var event := surface_event("movement_slide")
if event != _slide_surface_event:
var was_playing := slide_player.playing
_configure_audio_player(slide_player, event)
_slide_surface_event = event
if was_playing:
slide_player.play()
var intensity := clampf((speed - 4.0) / 16.0, 0.0, 1.0)
slide_player.volume_db = lerpf(-24.0, -13.0, intensity)
slide_player.pitch_scale = lerpf(0.94, 1.02, intensity)
func update_wallrun_audio(speed: float, wall_normal: Vector3) -> void:
if wallrun_player == null:
return
var intensity := clampf((speed - 4.0) / 16.0, 0.0, 1.0)
# The same warm friction bed works on every wall; material changes its
# resonance subtly without ever turning metal walls into a piercing whine.
var material := acoustic_surface(-wall_normal, 1.3)
var material_pitch := 1.035 if material == "metal" else (
0.94 if material == "wood" else 1.0)
wallrun_player.volume_db = lerpf(-24.0, -13.0, intensity)
wallrun_player.pitch_scale = lerpf(0.95, 1.02, intensity) * material_pitch
func _setup_speedlines() -> void:
_speedlines = ColorRect.new()
_speedlines.name = "SpeedLines"
@@ -646,7 +734,8 @@ func server_play_explosion(pos: Vector3, radius: float) -> void:
func rpc_play_explosion(pos: Vector3, radius: float) -> void:
if is_multiplayer_authority(): return
ExplosionVFX.spawn(get_tree(), pos, radius)
var pitch := clampf(1.06 - (radius - 2.5) * 0.035, 0.76, 1.12)
ExplosionVFX.spawn(get_tree(), pos, radius, pitch)
@rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
@@ -900,8 +989,12 @@ func _physics_process(_delta: float) -> void:
machine.input_grapple_just_pressed = input_grapple_just
var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
var wind_factor = clampf((speed - 12.0) / 24.0, 0.0, 1.0)
var wind_target := -70.0 if wind_factor <= 0.001 \
else lerpf(-42.0, -15.0, pow(wind_factor, 0.82))
wind_player.volume_db = lerpf(wind_player.volume_db, wind_target,
1.0 - exp(-4.5 * _delta))
wind_player.pitch_scale = lerpf(1.0, 1.24, wind_factor)
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
@@ -927,7 +1020,13 @@ func _physics_process(_delta: float) -> void:
# Update grapple rope visuals (runs on all clients for smooth interpolation)
if (synced_movement_state == "grapple" or synced_is_grapple_shooting) and camera:
grapple_rope.visible = true
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var start_pos = camera.global_position \
+ camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var grapple_visual := get_visual_model()
if synced_movement_state == "grapple" and grapple_visual \
and grapple_visual.has_method(
"get_grapple_hand_world_position"):
start_pos = grapple_visual.get_grapple_hand_world_position()
var end_pos = synced_grapple_point
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
@@ -943,9 +1042,13 @@ func _physics_process(_delta: float) -> void:
grapple_rope.visible = false
if synced_movement_state == "grapple" and velocity.length() > 5.0:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
var grapple_factor := clampf((velocity.length() - 5.0) / 18.0, 0.0, 1.0)
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db,
lerpf(-28.0, -15.0, grapple_factor), 1.0 - exp(-8.0 * _delta))
grapple_swing_player.pitch_scale = lerpf(0.92, 1.08, grapple_factor)
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db,
-80.0, 1.0 - exp(-12.0 * _delta))
# Local player's aim-down-sights state (drives the model's weapon raise).
synced_is_ads = _read_ads()
@@ -954,16 +1057,21 @@ func _physics_process(_delta: float) -> void:
var visual = get_visual_model()
if visual:
var h_speed = Vector2(velocity.x, velocity.z).length()
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
# Direction must be present before update_state drives the BlendSpace;
# otherwise the first movement frame still samples last frame's Idle.
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_wall_run_motion"):
visual.set_wall_run_motion(velocity)
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing, synced_dance_index)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
visual.set_grapple_target(synced_grapple_point, velocity)
_update_jet_vfx(sm.current_state, _local_move_dir())
# Publish state for remote peers
synced_movement_state = sm.current_state
@@ -1005,6 +1113,10 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play()
elif ev == "dash":
_speedline_burst = 1.0
elif ev == "double_jump":
_trigger_action("double_jump")
if is_instance_valid(_jet_vfx):
_jet_vfx.burst_double_jump()
elif ev == "land":
if land_player:
var heavy: bool = data.get("heavy", false)
@@ -1039,16 +1151,24 @@ func _process(delta: float) -> void:
var visual = get_visual_model()
if visual:
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_wall_run_motion"):
visual.set_wall_run_motion(synced_velocity)
visual.update_state(
synced_movement_state,
synced_movement_speed,
synced_is_crouching,
)
_update_jet_vfx(synced_movement_state, _local_move_dir())
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing, synced_dance_index)
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
visual.set_grapple_target(
synced_grapple_point, synced_velocity)
# Upper body follows the owner's synced camera pitch
if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x)
@@ -1056,6 +1176,8 @@ func _process(delta: float) -> void:
if visual.has_method("play_action") and synced_action_seq != _last_action_seq:
_last_action_seq = synced_action_seq
visual.play_action(synced_action)
if synced_action == "double_jump" and is_instance_valid(_jet_vfx):
_jet_vfx.burst_double_jump()
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)
@@ -1068,8 +1190,11 @@ func _process(delta: float) -> void:
_remote_footstep_timer -= delta
if _remote_footstep_timer <= 0.0:
var am = get_tree().root.get_node_or_null("AudioManager")
if am and am.has_sound("footstep"):
am.play_3d("footstep", global_position + Vector3(0, -0.8, 0), -8.0)
var event := surface_event("footstep")
if am and am.has_sound(event):
am.play_3d(event, global_position + Vector3(0, -0.8, 0),
-4.0, lerpf(0.96, 1.04,
clampf(synced_movement_speed / 14.0, 0.0, 1.0)))
_remote_footstep_timer = maxf(0.2, 3.0 / synced_movement_speed)
else:
_remote_footstep_timer = 0.0