big
This commit is contained in:
+243
-22
@@ -19,8 +19,9 @@ extends Node
|
||||
## suffixes and they are picked up as one id with random selection:
|
||||
## footstep.wav, footstep_01.wav, footstep_02.wav -> id "footstep" (3 vars)
|
||||
##
|
||||
## Buses: Master <- SFX <- {Weapons, Footsteps, UI}, Master <- Music.
|
||||
## See docs/SOUND_DESIGN.md for where to get good source audio.
|
||||
## Buses: Master <- SFX <- {Weapons, Explosions, Footsteps, Movement, UI,
|
||||
## Wind}, Master <- Music. The master hard limiter is a safety net, not a
|
||||
## loudness tool: checked-in assets are already mastered with headroom.
|
||||
|
||||
const SOUNDS_DIR := "res://assets/sounds"
|
||||
const POOL_SIZE_3D := 32
|
||||
@@ -32,19 +33,115 @@ var _pool_3d: Array[AudioStreamPlayer3D] = []
|
||||
var _pool_2d: Array[AudioStreamPlayer] = []
|
||||
var _next_3d: int = 0
|
||||
var _next_2d: int = 0
|
||||
var _last_ui_play_ms: Dictionary = {}
|
||||
var _last_variant: Dictionary = {}
|
||||
|
||||
# Default routing/tuning per id prefix (applied during auto-registration).
|
||||
const BUS_HINTS := {
|
||||
"footstep": "Footsteps",
|
||||
"explosion": "Explosions",
|
||||
"movement": "Movement",
|
||||
"projectile": "Movement",
|
||||
"ui_": "UI",
|
||||
"weapon": "Weapons",
|
||||
"fire": "Weapons",
|
||||
"reload": "Weapons",
|
||||
}
|
||||
|
||||
## Old gameplay ids remain API-compatible while resolving to the remastered
|
||||
## event families. Keeping aliases here avoids sound-path knowledge spreading
|
||||
## back into every weapon and HUD script.
|
||||
const PREMIUM_ALIASES := {
|
||||
"ak47_fire": "weapon_ak47",
|
||||
"m4_fire": "weapon_m4",
|
||||
"mp7_fire": "weapon_mp7",
|
||||
"dmr_fire": "weapon_dmr",
|
||||
"awp_fire": "weapon_awp",
|
||||
"shotgun_fire": "weapon_shotgun",
|
||||
"nailgun_fire": "weapon_nailgun",
|
||||
"plasma_fire": "weapon_plasma",
|
||||
"rocket_fire": "weapon_rocket",
|
||||
"swarm_fire": "weapon_swarm",
|
||||
"mortar_fire": "weapon_mortar",
|
||||
"knife_swing": "weapon_knife",
|
||||
"shotgun_reload": "weapon_reload",
|
||||
"explosion": "explosion_deep",
|
||||
"bullet_impact": "impact_bullet",
|
||||
"footstep": "footstep_concrete",
|
||||
"wind": "movement_wind",
|
||||
"slide": "movement_slide_concrete",
|
||||
"wallrun": "movement_wallrun",
|
||||
"dash": "movement_dash",
|
||||
"jump": "movement_jump",
|
||||
"double_jump": "movement_double_jump",
|
||||
"vault": "movement_vault",
|
||||
"land": "movement_land",
|
||||
"grapple_launch": "movement_grapple_launch",
|
||||
"grapple_latch": "movement_grapple_latch",
|
||||
"grapple_reel": "movement_grapple_reel",
|
||||
"rocket_flight": "projectile_rocket_loop",
|
||||
"swarm_flight": "projectile_swarm_loop",
|
||||
"hit_confirm": "ui_confirm_premium",
|
||||
"kill_confirm": "ui_kill_premium",
|
||||
"ui_hover": "ui_hover_premium",
|
||||
"ui_click": "ui_click_premium",
|
||||
"ui_confirm": "ui_confirm_premium",
|
||||
"ui_error": "ui_error_premium",
|
||||
"ui_equip": "ui_equip_premium",
|
||||
}
|
||||
|
||||
## Mix intent belongs beside event registration. Source files have controlled
|
||||
## peaks; these values establish hierarchy and variation without per-call magic
|
||||
## numbers. Loops opt into sample-accurate looping when loaded.
|
||||
const EVENT_TUNING := {
|
||||
"weapon_ak47": {"bus": "Weapons", "pitch": 0.025, "db": -3.0},
|
||||
"weapon_m4": {"bus": "Weapons", "pitch": 0.022, "db": -3.5},
|
||||
"weapon_mp7": {"bus": "Weapons", "pitch": 0.028, "db": -5.0},
|
||||
"weapon_dmr": {"bus": "Weapons", "pitch": 0.018, "db": -2.5},
|
||||
"weapon_awp": {"bus": "Weapons", "pitch": 0.015, "db": -1.5},
|
||||
"weapon_shotgun": {"bus": "Weapons", "pitch": 0.020, "db": -2.0},
|
||||
"weapon_nailgun": {"bus": "Weapons", "pitch": 0.025, "db": -4.0},
|
||||
"weapon_plasma": {"bus": "Weapons", "pitch": 0.025, "db": -4.0},
|
||||
"weapon_rocket": {"bus": "Weapons", "pitch": 0.018, "db": -3.0},
|
||||
"weapon_swarm": {"bus": "Weapons", "pitch": 0.025, "db": -4.0},
|
||||
"weapon_mortar": {"bus": "Weapons", "pitch": 0.015, "db": -2.0},
|
||||
"weapon_knife": {"bus": "Weapons", "pitch": 0.035, "db": -5.0},
|
||||
"weapon_reload": {"bus": "Weapons", "pitch": 0.018, "db": -7.0},
|
||||
"explosion_deep": {"bus": "Explosions", "pitch": 0.018, "db": 0.5},
|
||||
"impact_bullet": {"bus": "Impacts", "pitch": 0.045, "db": -5.0},
|
||||
"footstep_concrete": {"bus": "Footsteps", "pitch": 0.035, "db": -4.0},
|
||||
"footstep_metal": {"bus": "Footsteps", "pitch": 0.030, "db": -5.0},
|
||||
"footstep_wood": {"bus": "Footsteps", "pitch": 0.040, "db": -4.0},
|
||||
"footstep_glass": {"bus": "Footsteps", "pitch": 0.025, "db": -6.0},
|
||||
"movement_wind": {"bus": "Wind", "pitch": 0.0, "db": 0.0, "loop": true},
|
||||
"movement_slide_concrete": {"bus": "Movement", "pitch": 0.0, "db": 0.0, "loop": true},
|
||||
"movement_slide_metal": {"bus": "Movement", "pitch": 0.0, "db": -1.0, "loop": true},
|
||||
"movement_slide_wood": {"bus": "Movement", "pitch": 0.0, "db": 0.0, "loop": true},
|
||||
"movement_slide_glass": {"bus": "Movement", "pitch": 0.0, "db": -2.0, "loop": true},
|
||||
"movement_wallrun": {"bus": "Movement", "pitch": 0.0, "db": -1.0, "loop": true},
|
||||
"movement_grapple_reel": {"bus": "Movement", "pitch": 0.0, "db": -4.0, "loop": true},
|
||||
"movement_grapple_launch": {"bus": "Movement", "pitch": 0.010, "db": -3.0},
|
||||
"movement_grapple_latch": {"bus": "Movement", "pitch": 0.008, "db": -3.0},
|
||||
"movement_dash": {"bus": "Movement", "pitch": 0.015, "db": -2.5},
|
||||
"movement_jump": {"bus": "Movement", "pitch": 0.010, "db": -3.5},
|
||||
"movement_double_jump": {"bus": "Movement", "pitch": 0.010, "db": -3.2},
|
||||
"movement_vault": {"bus": "Movement", "pitch": 0.025, "db": -6.0},
|
||||
"movement_land": {"bus": "Movement", "pitch": 0.025, "db": -4.0},
|
||||
"projectile_rocket_loop": {"bus": "Movement", "pitch": 0.015, "db": -4.0, "loop": true},
|
||||
"projectile_swarm_loop": {"bus": "Movement", "pitch": 0.020, "db": -6.0, "loop": true},
|
||||
"ui_hover_premium": {"bus": "UI", "pitch": 0.008, "db": -5.0, "cooldown": 55},
|
||||
"ui_click_premium": {"bus": "UI", "pitch": 0.006, "db": -3.5, "cooldown": 30},
|
||||
"ui_confirm_premium": {"bus": "UI", "pitch": 0.005, "db": -3.0},
|
||||
"ui_kill_premium": {"bus": "UI", "pitch": 0.005, "db": -1.5},
|
||||
"ui_error_premium": {"bus": "UI", "pitch": 0.005, "db": -3.0},
|
||||
"ui_equip_premium": {"bus": "UI", "pitch": 0.006, "db": -3.0},
|
||||
}
|
||||
|
||||
## weapon_name (as broadcast in fire RPCs) -> fire sound id.
|
||||
const WEAPON_SOUND_IDS := {
|
||||
"AK-47": "ak47_fire", "M4": "m4_fire", "MP7": "mp7_fire",
|
||||
"DMR": "dmr_fire", "AWP": "awp_fire",
|
||||
"Double Barrel Shotgun": "shotgun_fire", "Nail Gun": "nailgun_fire",
|
||||
"Double Barrel": "shotgun_fire", "Nail Gun": "nailgun_fire",
|
||||
"Plasma Gun": "plasma_fire", "Rocket Launcher": "rocket_fire",
|
||||
"Rocket Swarm": "swarm_fire", "Mortar": "mortar_fire",
|
||||
"Knife": "knife_swing",
|
||||
@@ -94,18 +191,65 @@ func weapon_sound_id(weapon_name: String) -> String:
|
||||
func _setup_buses() -> void:
|
||||
_ensure_bus("SFX", "Master")
|
||||
_ensure_bus("Weapons", "SFX")
|
||||
_ensure_bus("Explosions", "SFX")
|
||||
_ensure_bus("Impacts", "SFX")
|
||||
_ensure_bus("Footsteps", "SFX")
|
||||
_ensure_bus("Movement", "SFX")
|
||||
_ensure_bus("Ambience", "SFX")
|
||||
_ensure_bus("UI", "SFX")
|
||||
_ensure_bus("Music", "Master")
|
||||
_ensure_bus("Wind", "SFX")
|
||||
_install_mix_effects()
|
||||
|
||||
|
||||
func _ensure_bus(bus_name: String, send_to: String) -> void:
|
||||
if AudioServer.get_bus_index(bus_name) != -1:
|
||||
return
|
||||
var idx := AudioServer.bus_count
|
||||
AudioServer.add_bus(idx)
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
AudioServer.set_bus_send(idx, send_to)
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx == -1:
|
||||
idx = AudioServer.bus_count
|
||||
AudioServer.add_bus(idx)
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
if bus_name != "Master":
|
||||
AudioServer.set_bus_send(idx, send_to)
|
||||
|
||||
|
||||
func _install_mix_effects() -> void:
|
||||
var master := AudioServer.get_bus_index("Master")
|
||||
if master >= 0 and not _bus_has_effect(master, "AudioEffectHardLimiter"):
|
||||
var limiter := AudioEffectHardLimiter.new()
|
||||
limiter.ceiling_db = -1.0
|
||||
limiter.pre_gain_db = 0.0
|
||||
limiter.release = 0.10
|
||||
AudioServer.add_bus_effect(master, limiter)
|
||||
|
||||
var weapons := AudioServer.get_bus_index("Weapons")
|
||||
if weapons >= 0 and not _bus_has_effect(weapons, "AudioEffectCompressor"):
|
||||
var punch := AudioEffectCompressor.new()
|
||||
punch.threshold = -9.0
|
||||
punch.ratio = 2.0
|
||||
punch.attack_us = 1800.0
|
||||
punch.release_ms = 95.0
|
||||
punch.gain = 0.5
|
||||
punch.mix = 0.42
|
||||
AudioServer.add_bus_effect(weapons, punch)
|
||||
|
||||
var explosions := AudioServer.get_bus_index("Explosions")
|
||||
if explosions >= 0 and not _bus_has_effect(explosions, "AudioEffectCompressor"):
|
||||
var depth := AudioEffectCompressor.new()
|
||||
depth.threshold = -10.0
|
||||
depth.ratio = 1.8
|
||||
depth.attack_us = 2000.0
|
||||
depth.release_ms = 240.0
|
||||
depth.gain = 0.5
|
||||
depth.mix = 0.35
|
||||
AudioServer.add_bus_effect(explosions, depth)
|
||||
|
||||
|
||||
func _bus_has_effect(bus: int, effect_class: String) -> bool:
|
||||
for i in AudioServer.get_bus_effect_count(bus):
|
||||
var effect := AudioServer.get_bus_effect(bus, i)
|
||||
if effect and effect.is_class(effect_class):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func set_bus_volume_linear(bus_name: String, linear: float) -> void:
|
||||
@@ -158,11 +302,20 @@ func _auto_register_sounds() -> void:
|
||||
if id.findn(hint) != -1:
|
||||
bus = BUS_HINTS[hint]
|
||||
break
|
||||
register_sound(id, groups[id], bus)
|
||||
var tuning: Dictionary = EVENT_TUNING.get(id, {})
|
||||
register_sound(id, groups[id],
|
||||
str(tuning.get("bus", bus)),
|
||||
float(tuning.get("pitch", 0.045)),
|
||||
float(tuning.get("db", 0.0)))
|
||||
|
||||
|
||||
func has_sound(id: String) -> bool:
|
||||
return _library.has(id)
|
||||
return _library.has(_resolve_id(id))
|
||||
|
||||
|
||||
func _resolve_id(id: String) -> String:
|
||||
var premium: String = str(PREMIUM_ALIASES.get(id, id))
|
||||
return premium if _library.has(premium) else id
|
||||
|
||||
|
||||
## Build an AudioStreamRandomizer for a registered id: every play() picks a
|
||||
@@ -170,16 +323,67 @@ func has_sound(id: String) -> bool:
|
||||
## (weapons) instead of going through the pools. Falls back to a plain load
|
||||
## of fallback_path if the id isn't registered.
|
||||
func stream_for(id: String, fallback_path: String = "") -> AudioStream:
|
||||
var entry = _library.get(id)
|
||||
var resolved := _resolve_id(id)
|
||||
var entry = _library.get(resolved)
|
||||
if entry == null:
|
||||
return load(fallback_path) if fallback_path != "" and ResourceLoader.exists(fallback_path) else null
|
||||
var r := AudioStreamRandomizer.new()
|
||||
r.random_pitch = 1.0 + entry["pitch_var"]
|
||||
for s in entry["streams"]:
|
||||
if bool(EVENT_TUNING.get(resolved, {}).get("loop", false)):
|
||||
_enable_loop(s)
|
||||
r.add_stream(-1, s)
|
||||
return r
|
||||
|
||||
|
||||
## Configure an owned AudioStreamPlayer/AudioStreamPlayer3D from the same event
|
||||
## metadata used by pooled playback. This erases the old per-weapon gain/bus
|
||||
## lottery while letting weapons keep their existing player nodes.
|
||||
func configure_player(player: Node, id: String, fallback_path: String = "",
|
||||
volume_db_offset: float = 0.0) -> void:
|
||||
if player == null:
|
||||
return
|
||||
var resolved := _resolve_id(id)
|
||||
var entry = _library.get(resolved)
|
||||
player.set("stream", stream_for(id, fallback_path))
|
||||
if entry:
|
||||
player.set("bus", entry["bus"])
|
||||
player.set("volume_db", float(entry["volume_db"]) + volume_db_offset)
|
||||
if player is AudioStreamPlayer3D:
|
||||
var spatial := player as AudioStreamPlayer3D
|
||||
spatial.unit_size = 7.0
|
||||
spatial.max_distance = 85.0
|
||||
# Automatic fire must not amputate the body/tail of the previous shot.
|
||||
# Godot defaults to one voice per player, which was making every rifle
|
||||
# turn into a dry click train under sustained fire.
|
||||
spatial.max_polyphony = 8 if resolved.begins_with("weapon_") \
|
||||
and resolved != "weapon_reload" else 1
|
||||
elif player is AudioStreamPlayer:
|
||||
(player as AudioStreamPlayer).max_polyphony = 4
|
||||
|
||||
|
||||
func _enable_loop(stream: AudioStream) -> void:
|
||||
if stream is AudioStreamWAV:
|
||||
var wav := stream as AudioStreamWAV
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
wav.loop_begin = 0
|
||||
wav.loop_end = maxi(1, roundi(wav.get_length() * wav.mix_rate))
|
||||
elif stream is AudioStreamOggVorbis:
|
||||
(stream as AudioStreamOggVorbis).loop = true
|
||||
|
||||
|
||||
func _pick_stream(id: String, entry: Dictionary) -> AudioStream:
|
||||
var streams: Array = entry["streams"]
|
||||
if streams.size() <= 1:
|
||||
return streams[0]
|
||||
var previous := int(_last_variant.get(id, -1))
|
||||
var pick := randi_range(0, streams.size() - 2)
|
||||
if pick >= previous:
|
||||
pick += 1
|
||||
_last_variant[id] = pick
|
||||
return streams[pick]
|
||||
|
||||
|
||||
# ── Playback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _build_pools() -> void:
|
||||
@@ -202,12 +406,13 @@ func _build_pools() -> void:
|
||||
## Safe to call every frame — players are pooled.
|
||||
func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
|
||||
pitch_scale: float = 1.0) -> AudioStreamPlayer3D:
|
||||
var entry = _library.get(id)
|
||||
var resolved := _resolve_id(id)
|
||||
var entry = _library.get(resolved)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_3d[_next_3d]
|
||||
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.stream = _pick_stream(resolved, entry)
|
||||
player.bus = entry["bus"]
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
@@ -217,7 +422,7 @@ func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
|
||||
if cam:
|
||||
var occ := Acoustics.occlusion(cam.get_world_3d(), cam.global_position, global_pos)
|
||||
Acoustics.apply_occlusion(player, occ)
|
||||
if _is_loud(id):
|
||||
if _is_loud(resolved):
|
||||
_spawn_reflections(player.stream, global_pos, cam, entry, volume_db_offset)
|
||||
else:
|
||||
Acoustics.apply_occlusion(player, { "db": 0.0, "cutoff": Acoustics.OPEN_CUTOFF, "surfaces": 0 })
|
||||
@@ -226,7 +431,7 @@ func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
|
||||
|
||||
|
||||
func _is_loud(id: String) -> bool:
|
||||
return id.findn("fire") != -1 or id.findn("explosion") != -1
|
||||
return id.begins_with("weapon_") or id.begins_with("explosion")
|
||||
|
||||
|
||||
## Delayed, filtered copies of a loud sound from nearby wall mirror points.
|
||||
@@ -249,16 +454,32 @@ func _spawn_reflections(stream: AudioStream, source: Vector3, cam: Camera3D,
|
||||
)
|
||||
|
||||
|
||||
## Play a non-positional sound (UI clicks, local confirms).
|
||||
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
|
||||
var entry = _library.get(id)
|
||||
## Play a non-positional event on its authored bus. Local movement feedback
|
||||
## uses this too; play_ui adds hover/click rate limiting on top.
|
||||
func play_local(id: String, volume_db_offset: float = 0.0,
|
||||
pitch_scale: float = 1.0) -> AudioStreamPlayer:
|
||||
var resolved := _resolve_id(id)
|
||||
var entry = _library.get(resolved)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_2d[_next_2d]
|
||||
_next_2d = (_next_2d + 1) % POOL_SIZE_2D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.bus = "UI"
|
||||
player.stream = _pick_stream(resolved, entry)
|
||||
player.bus = entry["bus"]
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.pitch_scale = pitch_scale * randf_range(
|
||||
1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.play()
|
||||
return player
|
||||
|
||||
|
||||
## Play a non-positional UI sound. A small per-event cooldown stops a pointer
|
||||
## sweeping across a dense menu from turning gentle hovers into a grating buzz.
|
||||
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
|
||||
var resolved := _resolve_id(id)
|
||||
var cooldown := int(EVENT_TUNING.get(resolved, {}).get("cooldown", 0))
|
||||
var now := Time.get_ticks_msec()
|
||||
if cooldown > 0 and now - int(_last_ui_play_ms.get(resolved, -cooldown)) < cooldown:
|
||||
return null
|
||||
_last_ui_play_ms[resolved] = now
|
||||
return play_local(id, volume_db_offset)
|
||||
|
||||
Reference in New Issue
Block a user