extends Node ## AudioManager — Phase 18 + atmospheric audio (Phase 18 follow-up). ## ## Owns three AudioServer buses (Master, Music, SFX), a small catalog of music ## loops + one-shot SFX, and a single AudioStreamPlayer for the music director. ## SFX play through pooled short-lived AudioStreamPlayers. ## ## Music swaps on Clock.phase_changed (day/dusk/night/dawn). SFX fire on ## EventBus signals (combat, storyteller stings) and direct calls from Tree / ## Rock entities for chop / mine completion sounds. ## ## Atmospheric audio (Phase 18 follow-up): ## - Rain ambient loop: persistent AudioStreamPlayer on SFX bus, fades in/out ## on weather_changed. Routed to SFX bus (no new bus) so it respects the ## existing SFX slider and application-pause muting via Master bus mute. ## - Storm thunder sting: one-shot on WEATHER_RAIN → WEATHER_STORM transition. ## - Raid warning sting: one-shot on wolf_spawned. ## ## Volume sliders in SettingsMenu drive set_*_db() at the Master/Music/SFX bus ## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus ## loss) to match the existing "no background simulation" rule. const BUS_MASTER: StringName = &"Master" const BUS_MUSIC: StringName = &"Music" const BUS_SFX: StringName = &"SFX" # Slider range is 0.0..1.0 (linear); we map to dB on the bus. const SILENT_DB: float = -80.0 # How long the chop/mine SFX cooldown is to avoid spam when many entities work # simultaneously. Measured in milliseconds. const SFX_THROTTLE_MS: int = 80 ## Catalog: StringName → AudioStream. Loaded lazily on first access; once a ## key resolves we cache the stream so the second play is a Dictionary lookup. const SFX_FILES: Dictionary = { &"ui_click": "res://audio/sfx/ui_click.ogg", &"ui_confirm": "res://audio/sfx/ui_confirm.ogg", &"chop_tick": "res://audio/sfx/chop_tick.ogg", &"tree_fell": "res://audio/sfx/tree_fell.ogg", &"mine_tick": "res://audio/sfx/mine_tick.ogg", &"combat_hit": "res://audio/sfx/combat_hit.ogg", # Atmospheric — Phase 18 follow-up. # rain_ambient: Cozy Melodies Pack 6 (ElvGames Ultimate Farming RPG Tier 3) &"rain_ambient": "res://audio/sfx/rain_ambient.ogg", # thunder_sting: Magic and Spells 6 (ElvGames Ultimate Farming RPG Tier 3) &"thunder_sting": "res://audio/sfx/thunder_sting.ogg", # raid_warning: Sword Sound Effects Pack 1 (ElvGames Ultimate Farming RPG Tier 3) # Sword unsheathe sting used as danger signal; no dedicated alarm/horn in bundle. &"raid_warning": "res://audio/sfx/raid_warning.ogg", } const MUSIC_FILES: Dictionary = { &"day_loop": "res://audio/music/day_loop.ogg", &"night_loop": "res://audio/music/night_loop.ogg", } # Loaded streams. Populated on demand. var _sfx_streams: Dictionary = {} var _music_streams: Dictionary = {} # Single persistent music player. Stream is swapped on phase change. var _music_player: AudioStreamPlayer = null var _current_music_key: StringName = &"" # Per-SFX cooldowns to avoid spam. Last-played epoch ms keyed by SFX key. var _sfx_last_played_ms: Dictionary = {} # Cached slider values (linear 0..1). Applied to buses on _ready and on # settings change. Public so SettingsMenu can rebind after creation. var master_linear: float = 1.0 var music_linear: float = 1.0 var sfx_linear: float = 1.0 # Atmospheric — rain ambient loop player. Persistent; stream stays loaded. # Routed to SFX bus so it inherits the SFX slider and app-pause muting. var _rain_player: AudioStreamPlayer = null # Tween used for the rain fade-out (clear/cold_snap). Cancelled on re-rain. var _rain_tween: Tween = null # Track previous weather to detect RAIN → STORM transition for thunder sting. var _prev_weather: StringName = &"" # ── lifecycle ─────────────────────────────────────────────────────────────── func _ready() -> void: _ensure_buses() # Music director: track stays on a single persistent player; stream swaps. _music_player = AudioStreamPlayer.new() _music_player.bus = BUS_MUSIC _music_player.name = "MusicPlayer" add_child(_music_player) # Rain ambient: persistent looping player on SFX bus. Starts silent; weather # signal activates it. Routing to SFX bus means app-pause muting (Master bus) # covers it automatically — no extra NOTIFICATION handling needed. _rain_player = AudioStreamPlayer.new() _rain_player.bus = BUS_SFX _rain_player.name = "RainAmbientPlayer" _rain_player.volume_db = 0.0 add_child(_rain_player) # SFX → world event wiring. Keep this list short and obvious. if EventBus.has_signal("pawn_took_damage"): EventBus.pawn_took_damage.connect(_on_pawn_took_damage) if EventBus.has_signal("storyteller_event_fired"): EventBus.storyteller_event_fired.connect(_on_storyteller_event_fired) if EventBus.has_signal("alert_added"): EventBus.alert_added.connect(_on_alert_added) if EventBus.has_signal("weather_changed"): EventBus.weather_changed.connect(_on_weather_changed) if EventBus.has_signal("wolf_spawned"): EventBus.wolf_spawned.connect(_on_wolf_spawned) # Music director: swap day/night loops as Clock.phase_changed fires. if Clock.has_signal("phase_changed"): Clock.phase_changed.connect(_on_clock_phase_changed) # Kick off the right initial track based on current phase, with no fade. var initial_phase: StringName = &"day" if Clock.has_method("current_phase"): initial_phase = Clock.current_phase() _apply_phase_to_music(initial_phase) # Sync rain loop to current weather in case we loaded mid-rain (e.g. after # save/load). Weather autoload is ready before Audio in the autoload list; # safe to query here. if Weather.has_method("is_raining") and Weather.is_raining(): _prev_weather = Weather.current_weather _start_rain_loop() Audit.log("audio", "AudioManager ready (master/music/sfx buses online + atmospheric)") ## NOTIFICATION_APPLICATION_PAUSED fires when the app is suspended (mobile ## background, Steam Deck sleep). Mute Master so any in-flight SFX go silent; ## resume on focus. func _notification(what: int) -> void: match what: NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT: AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), true) NOTIFICATION_APPLICATION_RESUMED, NOTIFICATION_APPLICATION_FOCUS_IN: AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), false) # ── public API ────────────────────────────────────────────────────────────── ## Play a one-shot SFX by catalog key. Quietly no-ops on unknown keys. ## Rate-limits by key so chop/mine tick spam doesn't clip the audio mixer. func play_sfx(key: StringName) -> void: if not SFX_FILES.has(key): return var now: int = Time.get_ticks_msec() var last: int = int(_sfx_last_played_ms.get(key, -SFX_THROTTLE_MS)) if now - last < SFX_THROTTLE_MS: return _sfx_last_played_ms[key] = now var stream: AudioStream = _load_sfx(key) if stream == null: return var p := AudioStreamPlayer.new() p.bus = BUS_SFX p.stream = stream add_child(p) p.finished.connect(p.queue_free) p.play() ## Switch the music track. `key` resolves through MUSIC_FILES. Same key as ## currently playing is a no-op so phase ticks don't restart the loop. func play_music(key: StringName) -> void: if key == _current_music_key: return if not MUSIC_FILES.has(key): return var stream: AudioStream = _load_music(key) if stream == null: return _current_music_key = key _music_player.stream = stream _music_player.play() ## Slider → bus volume helpers. Linear 0..1; 0 mutes the bus. func set_master_linear(linear: float) -> void: master_linear = clampf(linear, 0.0, 1.0) _apply_bus_db(BUS_MASTER, master_linear) func set_music_linear(linear: float) -> void: music_linear = clampf(linear, 0.0, 1.0) _apply_bus_db(BUS_MUSIC, music_linear) func set_sfx_linear(linear: float) -> void: sfx_linear = clampf(linear, 0.0, 1.0) _apply_bus_db(BUS_SFX, sfx_linear) # ── internal ──────────────────────────────────────────────────────────────── func _ensure_buses() -> void: # Godot ships with a single "Master" bus. We add Music + SFX as children # routed to Master, so the master slider scales everything together. _add_bus_if_missing(BUS_MUSIC, BUS_MASTER) _add_bus_if_missing(BUS_SFX, BUS_MASTER) func _add_bus_if_missing(bus_name: StringName, send_to: StringName) -> void: if AudioServer.get_bus_index(bus_name) != -1: return var idx: int = AudioServer.bus_count AudioServer.add_bus(idx) AudioServer.set_bus_name(idx, bus_name) AudioServer.set_bus_send(idx, send_to) func _bus_idx(bus_name: StringName) -> int: return AudioServer.get_bus_index(bus_name) func _apply_bus_db(bus_name: StringName, linear: float) -> void: var idx: int = _bus_idx(bus_name) if idx < 0: return var db: float = linear_to_db(linear) if linear > 0.0 else SILENT_DB AudioServer.set_bus_volume_db(idx, db) func _load_sfx(key: StringName) -> AudioStream: if _sfx_streams.has(key): return _sfx_streams[key] var path: String = String(SFX_FILES[key]) var stream: AudioStream = load(path) if stream != null: _sfx_streams[key] = stream return stream func _load_music(key: StringName) -> AudioStream: if _music_streams.has(key): return _music_streams[key] var path: String = String(MUSIC_FILES[key]) var stream: AudioStream = load(path) if stream != null: # Loopable variants of the source tracks are pre-cut; tell the import # layer to loop in case the .import wasn't flagged. if stream is AudioStreamOggVorbis: stream.loop = true _music_streams[key] = stream return stream func _apply_phase_to_music(phase: StringName) -> void: # Day / dawn / dusk all share the day loop; only night gets the night loop. # Phase 20 may split dusk into a distinct track once we have one. var key: StringName = &"night_loop" if phase == &"night" else &"day_loop" play_music(key) # ── signal handlers ───────────────────────────────────────────────────────── func _on_pawn_took_damage(_pawn, _amount: float) -> void: play_sfx(&"combat_hit") func _on_storyteller_event_fired(_event) -> void: play_sfx(&"ui_confirm") func _on_alert_added(_severity: StringName, _text: String, _focus_tile: Vector2i) -> void: play_sfx(&"ui_click") func _on_clock_phase_changed(new_phase: StringName) -> void: _apply_phase_to_music(new_phase) func _on_weather_changed(weather: StringName) -> void: var prev: StringName = _prev_weather _prev_weather = weather match weather: &"rain", &"storm": # Cancel any pending fade-out tween. if _rain_tween != null and _rain_tween.is_valid(): _rain_tween.kill() _rain_tween = null _start_rain_loop() # Thunder sting only on a direct transition into storm. if weather == &"storm" and prev != &"storm": play_sfx(&"thunder_sting") _: # WEATHER_CLEAR or WEATHER_COLD_SNAP — fade out rain loop. _stop_rain_loop_fade() func _on_wolf_spawned(wolves: Array) -> void: if wolves.size() > 0: play_sfx(&"raid_warning") # ── rain ambient helpers ───────────────────────────────────────────────────── func _start_rain_loop() -> void: # Load the stream once and reuse across transitions. var stream: AudioStream = _load_sfx(&"rain_ambient") if stream == null: return if _rain_player.stream != stream: _rain_player.stream = stream if stream is AudioStreamOggVorbis: stream.loop = true _rain_player.volume_db = 0.0 if not _rain_player.playing: _rain_player.play() func _stop_rain_loop_fade() -> void: if not _rain_player.playing: return # 0.5 s linear fade from current volume to SILENT_DB, then stop. if _rain_tween != null and _rain_tween.is_valid(): _rain_tween.kill() _rain_tween = create_tween() _rain_tween.tween_property(_rain_player, "volume_db", SILENT_DB, 0.5) _rain_tween.tween_callback(_rain_player.stop)