New Acoustics system (globals/acoustics.gd): - Occlusion: rays from listener to source collect up to 3 occluding bodies; each surface's material adds transmission loss and lowers the lowpass cutoff. A gunshot through wood stays warm (-8dB, 1.4kHz), brick muffles hard (-15dB, 600Hz), concrete harder (-18dB, 450Hz), metal keeps more highs (-11dB, 1kHz), glass barely muffles (-5dB, 2.6kHz). Applied via each AudioStreamPlayer3D's attenuation filter. - Early reflections: loud sounds (gunfire, explosions) probe 8 directions for nearby walls; each close surface plays a quiet, delayed (path/343ms), material-filtered copy from the mirror point — shots audibly slap back off structures so players can triangulate. Sub-30ms echoes are skipped (they perceptually merge with the direct sound). - Surface classification: builder-set acoustic_material meta -> node-name keywords -> metallic-material inference -> concrete default; cached. Integration: - AudioManager.play_3d applies occlusion at fire time and re-filters all playing positional sounds every 0.12s (walking behind a wall mid-sound audibly muffles it); flight loops opt in via "acoustic_occluded" group - Remote players' gunshots were entirely SILENT — rpc_play_fire_effects now plays the right weapon's shot at the shooter's position, occluded and reflected like everything else - Remote players' footsteps were also silent — now positional + occluded, cadence scaled to their speed - Test level tagged: brick arena walls, wooden platforms/obstacles, metal wall-run corridor/rails/dash platforms; dust2 sandstone reads as brick Tests: debug/acoustics_test.gd (12 checks: per-material loss ordering, multi-wall stacking, reflection delay/filter, name classification) — all pass; movement 11/11; smoke 0 failures. Co-Authored-By: Claude Fable 5 <[email protected]>
265 lines
9.1 KiB
GDScript
265 lines
9.1 KiB
GDScript
extends Node
|
|
|
|
## Autoload: central sound playback with variation + pitch randomization.
|
|
##
|
|
## Why: identical samples played back-to-back sound robotic. Real games ship
|
|
## 3-5 variations per event and randomize pitch a few percent per shot. This
|
|
## manager makes that the default for every sound in the game.
|
|
##
|
|
## Usage:
|
|
## AudioManager.play_3d("ak47_fire", muzzle_global_pos)
|
|
## AudioManager.play_ui("hit_confirm")
|
|
## AudioManager.register_sound("footstep_metal", [
|
|
## "res://assets/sounds/footstep_metal_01.wav",
|
|
## "res://assets/sounds/footstep_metal_02.wav",
|
|
## ], "Footsteps")
|
|
##
|
|
## Sounds are auto-registered from assets/sounds/*.wav on startup (the file
|
|
## stem is the sound id). Drop variations next to the original with _01/_02/…
|
|
## 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.
|
|
|
|
const SOUNDS_DIR := "res://assets/sounds"
|
|
const POOL_SIZE_3D := 32
|
|
const POOL_SIZE_2D := 8
|
|
|
|
# id -> { "streams": Array[AudioStream], "bus": String, "pitch_var": float, "volume_db": float }
|
|
var _library: Dictionary = {}
|
|
var _pool_3d: Array[AudioStreamPlayer3D] = []
|
|
var _pool_2d: Array[AudioStreamPlayer] = []
|
|
var _next_3d: int = 0
|
|
var _next_2d: int = 0
|
|
|
|
# Default routing/tuning per id prefix (applied during auto-registration).
|
|
const BUS_HINTS := {
|
|
"footstep": "Footsteps",
|
|
"fire": "Weapons",
|
|
"reload": "Weapons",
|
|
}
|
|
|
|
## 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",
|
|
"Plasma Gun": "plasma_fire", "Rocket Launcher": "rocket_fire",
|
|
"Rocket Swarm": "swarm_fire", "Mortar": "mortar_fire",
|
|
"Knife": "knife_swing",
|
|
}
|
|
|
|
## Sounds loud enough to slap back off nearby walls (early reflections).
|
|
const OCCLUSION_TICK := 0.12
|
|
var _occlusion_accum := 0.0
|
|
|
|
|
|
func _ready() -> void:
|
|
_setup_buses()
|
|
_build_pools()
|
|
_auto_register_sounds()
|
|
print("AudioManager: %d sounds registered" % _library.size())
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
# Re-filter playing positional sounds as the listener/source move so
|
|
# walking behind a wall audibly muffles loops and long tails too.
|
|
_occlusion_accum += delta
|
|
if _occlusion_accum < OCCLUSION_TICK:
|
|
return
|
|
_occlusion_accum = 0.0
|
|
var cam := get_viewport().get_camera_3d()
|
|
if cam == null:
|
|
return
|
|
var listener := cam.global_position
|
|
for p in _pool_3d:
|
|
if p.playing and not p.get_meta("is_reflection", false):
|
|
Acoustics.apply_occlusion(p, Acoustics.occlusion(
|
|
cam.get_world_3d(), listener, p.global_position))
|
|
# External looping players (projectile flight sounds, etc.) opt in by
|
|
# joining this group.
|
|
for node in get_tree().get_nodes_in_group("acoustic_occluded"):
|
|
if node is AudioStreamPlayer3D and node.playing and node.is_inside_tree():
|
|
Acoustics.apply_occlusion(node, Acoustics.occlusion(
|
|
cam.get_world_3d(), listener, node.global_position))
|
|
|
|
|
|
func weapon_sound_id(weapon_name: String) -> String:
|
|
return WEAPON_SOUND_IDS.get(weapon_name, "")
|
|
|
|
|
|
# ── Bus layout ────────────────────────────────────────────────────────────────
|
|
|
|
func _setup_buses() -> void:
|
|
_ensure_bus("SFX", "Master")
|
|
_ensure_bus("Weapons", "SFX")
|
|
_ensure_bus("Footsteps", "SFX")
|
|
_ensure_bus("UI", "SFX")
|
|
_ensure_bus("Music", "Master")
|
|
|
|
|
|
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)
|
|
|
|
|
|
func set_bus_volume_linear(bus_name: String, linear: float) -> void:
|
|
var idx := AudioServer.get_bus_index(bus_name)
|
|
if idx != -1:
|
|
AudioServer.set_bus_volume_db(idx, linear_to_db(clampf(linear, 0.0001, 1.0)))
|
|
|
|
|
|
# ── Registration ──────────────────────────────────────────────────────────────
|
|
|
|
func register_sound(id: String, paths: Array, bus: String = "SFX",
|
|
pitch_var: float = 0.06, volume_db: float = 0.0) -> void:
|
|
var streams: Array = []
|
|
for p in paths:
|
|
if ResourceLoader.exists(p):
|
|
var s = load(p)
|
|
if s:
|
|
streams.append(s)
|
|
if streams.is_empty():
|
|
return
|
|
_library[id] = {
|
|
"streams": streams,
|
|
"bus": bus,
|
|
"pitch_var": pitch_var,
|
|
"volume_db": volume_db,
|
|
}
|
|
|
|
|
|
func _auto_register_sounds() -> void:
|
|
var dir := DirAccess.open(SOUNDS_DIR)
|
|
if not dir:
|
|
return
|
|
# Group files: "footstep_01.wav" and "footstep.wav" both map to "footstep".
|
|
var groups: Dictionary = {}
|
|
for fname in dir.get_files():
|
|
if not (fname.ends_with(".wav") or fname.ends_with(".ogg") or fname.ends_with(".mp3")):
|
|
continue
|
|
var stem := fname.get_basename()
|
|
var base := stem
|
|
var parts := stem.rsplit("_", true, 1)
|
|
if parts.size() == 2 and parts[1].is_valid_int():
|
|
base = parts[0]
|
|
if not groups.has(base):
|
|
groups[base] = []
|
|
groups[base].append(SOUNDS_DIR + "/" + fname)
|
|
|
|
for id in groups:
|
|
var bus := "SFX"
|
|
for hint in BUS_HINTS:
|
|
if id.findn(hint) != -1:
|
|
bus = BUS_HINTS[hint]
|
|
break
|
|
register_sound(id, groups[id], bus)
|
|
|
|
|
|
func has_sound(id: String) -> bool:
|
|
return _library.has(id)
|
|
|
|
|
|
## Build an AudioStreamRandomizer for a registered id: every play() picks a
|
|
## random variation with random pitch. For nodes that own their own player
|
|
## (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)
|
|
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"]:
|
|
r.add_stream(-1, s)
|
|
return r
|
|
|
|
|
|
# ── Playback ──────────────────────────────────────────────────────────────────
|
|
|
|
func _build_pools() -> void:
|
|
for i in POOL_SIZE_3D:
|
|
var p := AudioStreamPlayer3D.new()
|
|
p.name = "Pool3D_%d" % i
|
|
p.max_distance = 80.0
|
|
p.unit_size = 8.0
|
|
add_child(p)
|
|
_pool_3d.append(p)
|
|
for i in POOL_SIZE_2D:
|
|
var p := AudioStreamPlayer.new()
|
|
p.name = "Pool2D_%d" % i
|
|
add_child(p)
|
|
_pool_2d.append(p)
|
|
|
|
|
|
## Play a positional sound with material-aware occlusion; loud sounds also
|
|
## spawn early reflections off nearby surfaces so they can be located.
|
|
## 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)
|
|
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.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"])
|
|
player.global_position = global_pos
|
|
player.set_meta("is_reflection", false)
|
|
var cam := get_viewport().get_camera_3d()
|
|
if cam:
|
|
var occ := Acoustics.occlusion(cam.get_world_3d(), cam.global_position, global_pos)
|
|
Acoustics.apply_occlusion(player, occ)
|
|
if _is_loud(id):
|
|
_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 })
|
|
player.play()
|
|
return player
|
|
|
|
|
|
func _is_loud(id: String) -> bool:
|
|
return id.findn("fire") != -1 or id.findn("explosion") != -1
|
|
|
|
|
|
## Delayed, filtered copies of a loud sound from nearby wall mirror points.
|
|
func _spawn_reflections(stream: AudioStream, source: Vector3, cam: Camera3D,
|
|
entry: Dictionary, volume_db_offset: float) -> void:
|
|
var refls := Acoustics.reflections(cam.get_world_3d(), source, cam.global_position)
|
|
for r in refls:
|
|
get_tree().create_timer(r.delay).timeout.connect(func():
|
|
var p := _pool_3d[_next_3d]
|
|
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
|
|
p.stream = stream
|
|
p.bus = entry["bus"]
|
|
p.volume_db = entry["volume_db"] + volume_db_offset + r.db
|
|
p.pitch_scale = randf_range(0.97, 1.0)
|
|
p.global_position = r.point
|
|
p.set_meta("is_reflection", true)
|
|
p.attenuation_filter_cutoff_hz = r.cutoff
|
|
p.attenuation_filter_db = Acoustics.DEFAULT_FILTER_DB
|
|
p.play()
|
|
)
|
|
|
|
|
|
## 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)
|
|
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.volume_db = entry["volume_db"] + volume_db_offset
|
|
player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
|
player.play()
|
|
return player
|