Three-agent fan-out (gdscript-refactor x3) closing deferred polish: - Stockpile chip filter UI: new StockpilePanel (layer 18, right-anchored, mirrors WorkbenchPanel). 5-priority segmented control + 21-chip 4-col filter grid using Item.ALL_TYPES; wildcard (empty accepted_types) shows all chips checked with 'All' hint, first explicit pick switches to explicit-list mode. Selection chain extended to pawn → workbench → stockpile with mutual exclusion. 12 ui.stockpile.* + 13 item.* keys. - DaySummaryCard: layer-19 modal auto-opens at dusk→night via day_ended, auto-pauses sim, shows day+season header, weather row, stats grid with green/yellow/red tension bar, Continue dismiss + backdrop tap. Settings 'Show end-of-day summary' toggle persists via GameState. - Atmospheric audio: rain ambient loop (Cozy Melodies Pack 6) on weather_changed rain/storm with 0.5s fade-out on clear; thunder sting (Magic and Spells 6) on rain→storm transition; raid warning sting (Sword Pack 1, 'blades drawn') on EventBus.wolf_spawned. All on SFX bus — inherits existing slider + suspend mute. Contracts pre-written before fan-out: EventBus.stockpile_selected / stockpile_deselected / wolf_spawned signals; WolfSpawner._trigger_raid + _on_request_wolf_spawn now emit wolf_spawned with the spawned array. MCP runtime verified: StockpilePanel opens with 21 chips, DaySummaryCard renders weather row + tension bar + auto-pause, rain_player.playing=true on weather_changed(rain), all three new SFX keys in Audio.SFX_FILES. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
GDScript
328 lines
12 KiB
GDScript
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)
|