feat(audio): Update AudioManager to support streaming audio assets, add audio.json config for new sound effects, and include unit tests for playback and loading.

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-28 23:03:07 -07:00
parent 811e27dfa7
commit 8049e7ec7c
3 changed files with 554 additions and 138 deletions

View file

@ -1,11 +1,6 @@
{
"schema_version": 2,
"sfx": {
"_silent": {
"streams": [],
"description": "Sentinel terminator for fallback chains. play_sfx exits silently when the chain reaches this entry."
},
"turn_started": {
"stream": "audio/sfx/turn_started.ogg",
"volume_db": -6.0,
@ -34,13 +29,37 @@
"stream": "audio/sfx/unit_killed.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Generic combat death thud + metal — categorical fallback target."
"description": "Neutral combat death thud \u2014 plays when neither side is the local human (AI vs AI, AI vs wild)."
},
"unit_defeated": {
"stream": "audio/sfx/unit_defeated.ogg",
"volume_db": -4.0,
"bus": "SFX",
"description": "Somber wood thud \u2014 local human's unit died. Layered on top of the species death sound."
},
"unit_victorious": {
"stream": "audio/sfx/unit_victorious.ogg",
"volume_db": -4.0,
"bus": "SFX",
"description": "Bright metallic ting \u2014 local human scored a kill. Layered on top of the species death sound."
},
"wonder_built": {
"stream": "audio/sfx/wonder_built.ogg",
"volume_db": -3.0,
"bus": "SFX",
"description": "Reverberant choral swell announcing a completed wonder."
"description": "Generic wonder-built cue \u2014 used when neither own/rival variant exists in the manifest."
},
"wonder_built.own": {
"stream": "audio/sfx/wonder_built_own.ogg",
"volume_db": -2.0,
"bus": "SFX",
"description": "Triumphant brass fanfare when YOUR wonder completes (~5s, full ensemble)."
},
"wonder_built.rival": {
"stream": "audio/sfx/wonder_built_rival.ogg",
"volume_db": -8.0,
"bus": "SFX",
"description": "Distant ominous bell when a rival wonder completes (short, restrained, somewhere else in the world)."
},
"era_advanced": {
"stream": "audio/sfx/era_advanced.ogg",
@ -52,7 +71,7 @@
"stream": "audio/sfx/combat_hit.ogg",
"volume_db": -8.0,
"bus": "SFX",
"description": "Generic combat impact — categorical fallback target."
"description": "Generic combat impact \u2014 endpoint of the categorical ladder for unit hit events."
},
"unit_moved": {
"stream": "audio/sfx/unit_moved.ogg",
@ -60,77 +79,72 @@
"bus": "SFX",
"description": "Soft footstep tick on unit movement (throttled)."
},
"defeat_stinger": {
"stream": "audio/sfx/defeat_stinger.ogg",
"volume_db": -3.0,
"bus": "SFX",
"description": "Somber descending stinger on defeat / player_eliminated."
},
"victory_fanfare": {
"stream": "audio/sfx/victory_fanfare.ogg",
"volume_db": -2.0,
"bus": "SFX",
"description": "Full brass fanfare on victory_achieved."
},
"combat_started": {
"stream": "audio/sfx/combat/combat_started.ogg",
"volume_db": -6.0,
"bus": "SFX",
"fallback": "combat_hit",
"description": "Brief war-horn before first blow lands."
},
"unit_promoted": {
"stream": "audio/sfx/unit_promoted.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "tech_researched",
"description": "Bright brass flourish on unit promotion."
},
"city_grew": {
"stream": "audio/sfx/city/city_grew.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "city_founded",
"description": "Gentle bell on city population growth."
},
"city_starved": {
"stream": "audio/sfx/city/city_starved.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Hollow wind on starvation; silent fallback so missing assets don't ride the city_founded sound."
"description": "Hollow wind on starvation."
},
"golden_age_swell": {
"stream": "audio/sfx/golden_age_swell.ogg",
"volume_db": -3.0,
"bus": "SFX",
"fallback": "era_advanced",
"description": "Slow brass crescendo on golden-age start."
},
"border_expanded": {
"stream": "audio/sfx/border_expanded.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Soft brass on border-tile claim."
},
"research_start": {
"stream": "audio/sfx/research_start.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Soft tap on research-start; deliberately quiet."
},
"culture_researched": {
"stream": "audio/sfx/culture_researched.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "tech_researched",
"description": "Drum + chant when a cultural tradition completes."
},
"wild_spawn": {
"stream": "audio/sfx/fauna/spawn.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Brush rustle when a wild creature emerges from a lair."
},
"unit.melee.attack": {
"streams": [
"audio/sfx/units/melee/attack_01.ogg",
@ -140,8 +154,7 @@
"volume_db": -5.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Generic melee swing — random variant + ±5% pitch."
"description": "Generic melee swing \u2014 random variant + \u00b15% pitch."
},
"unit.melee.hit": {
"streams": [
@ -151,8 +164,7 @@
],
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.04,
"fallback": "combat_hit"
"pitch_jitter": 0.04
},
"unit.melee.death": {
"streams": [
@ -161,10 +173,8 @@
],
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.03,
"fallback": "unit_killed"
"pitch_jitter": 0.03
},
"unit.ranged.attack": {
"streams": [
"audio/sfx/units/ranged/fire_01.ogg",
@ -173,7 +183,6 @@
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Bowstring + arrow release."
},
"unit.ranged.hit": {
@ -184,16 +193,15 @@
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.03,
"fallback": "combat_hit",
"description": "Arrow thunk into target."
},
"unit.ranged.death": {
"streams": ["audio/sfx/units/ranged/death_01.ogg"],
"streams": [
"audio/sfx/units/ranged/death_01.ogg"
],
"volume_db": -7.0,
"bus": "SFX",
"fallback": "unit_killed"
"bus": "SFX"
},
"unit.siege.attack": {
"streams": [
"audio/sfx/units/siege/bombard_01.ogg",
@ -202,51 +210,41 @@
"volume_db": -3.0,
"bus": "SFX",
"pitch_jitter": 0.02,
"fallback": "combat_hit",
"description": "Deep concussive boom — siege engine fire."
"description": "Deep concussive boom \u2014 siege engine fire."
},
"unit.civilian.death": {
"stream": "audio/sfx/units/civilian/death.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "unit_killed"
"bus": "SFX"
},
"building.civic.complete": {
"stream": "audio/sfx/buildings/build_complete_civic.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Low ceremonial bell on civic-building completion."
},
"building.production.complete": {
"stream": "audio/sfx/buildings/build_complete_prod.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Anvil ring on production-building completion."
},
"building.military.complete": {
"stream": "audio/sfx/buildings/build_complete_mil.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "wonder_built",
"description": "Drum + horn on military-building completion."
},
"building.defense.complete": {
"stream": "audio/sfx/buildings/build_complete_def.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "building.production.complete",
"description": "Stone-slab thump on wall / fortification completion."
},
"fauna.predator.spawn": {
"stream": "audio/sfx/fauna/predator_spawn.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "wild_spawn"
"bus": "SFX"
},
"fauna.predator.attack": {
"streams": [
@ -256,7 +254,6 @@
"volume_db": -6.0,
"bus": "SFX",
"pitch_jitter": 0.05,
"fallback": "combat_hit",
"description": "Snarl + impact for wolves / bears."
},
"fauna.predator.hit": {
@ -266,85 +263,215 @@
],
"volume_db": -7.0,
"bus": "SFX",
"pitch_jitter": 0.04,
"fallback": "combat_hit"
"pitch_jitter": 0.04
},
"fauna.predator.death": {
"streams": ["audio/sfx/fauna/predator_death.ogg"],
"streams": [
"audio/sfx/fauna/predator_death.ogg"
],
"volume_db": -7.0,
"bus": "SFX",
"fallback": "unit_killed"
"bus": "SFX"
},
"fauna.apex_predator.spawn": {
"stream": "audio/sfx/fauna/apex_roar.ogg",
"volume_db": -4.0,
"bus": "SFX",
"fallback": "fauna.predator.spawn"
"bus": "SFX"
},
"fauna.apex_predator.attack": {
"stream": "audio/sfx/fauna/apex_attack.ogg",
"volume_db": -3.0,
"bus": "SFX",
"fallback": "fauna.predator.attack"
"bus": "SFX"
},
"fauna.apex_predator.death": {
"stream": "audio/sfx/fauna/apex_death.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "fauna.predator.death"
"bus": "SFX"
},
"fauna.herbivore.spawn": {
"stream": "audio/sfx/fauna/herbivore_call.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "wild_spawn",
"description": "Distant deer / elk call."
},
"fauna.herbivore.death": {
"stream": "audio/sfx/fauna/herbivore_death.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "unit_killed"
"bus": "SFX"
},
"weather.storm": {
"stream": "audio/sfx/weather/storm.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Distant thunder cue when a storm event applies."
},
"weather.blizzard": {
"stream": "audio/sfx/weather/blizzard.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "weather.storm"
"bus": "SFX"
},
"weather.heat_wave": {
"stream": "audio/sfx/weather/heat_wave.ogg",
"volume_db": -10.0,
"bus": "SFX",
"fallback": "_silent"
"bus": "SFX"
},
"weather.drought": {
"stream": "audio/sfx/weather/drought.ogg",
"volume_db": -12.0,
"bus": "SFX",
"fallback": "weather.heat_wave"
"bus": "SFX"
},
"weather.tornado": {
"stream": "audio/sfx/weather/tornado.ogg",
"volume_db": -5.0,
"bus": "SFX",
"fallback": "weather.storm"
"bus": "SFX"
},
"weather.hurricane": {
"stream": "audio/sfx/weather/hurricane.ogg",
"volume_db": -5.0,
"bus": "SFX"
},
"unit.melee.spawn": {
"stream": "audio/sfx/units/melee_spawn.ogg",
"volume_db": -8.0,
"bus": "SFX",
"fallback": "weather.storm"
"description": "Muffled wood thump — a melee unit musters into play."
},
"unit.ranged.spawn": {
"stream": "audio/sfx/units/ranged_spawn.ogg",
"volume_db": -8.0,
"bus": "SFX",
"description": "Quick wood tap — a ranged unit takes position."
},
"unit.siege.hit": {
"stream": "audio/sfx/units/siege_hit.ogg",
"volume_db": -5.0,
"bus": "SFX",
"description": "Strained plank creak — siege engine takes damage."
},
"unit.siege.death": {
"stream": "audio/sfx/units/siege_death.ogg",
"volume_db": -4.0,
"bus": "SFX",
"description": "Heavy plate collapse — siege engine destroyed."
},
"unit.support.attack": {
"stream": "audio/sfx/units/support_attack.ogg",
"volume_db": -7.0,
"bus": "SFX",
"description": "Light shove — support unit's contributory action."
},
"unit.support.hit": {
"stream": "audio/sfx/units/support_hit.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Soft body impact — support unit takes damage."
},
"unit.support.death": {
"stream": "audio/sfx/units/support_death.ogg",
"volume_db": -5.0,
"bus": "SFX",
"description": "Soft heavy fall — support unit killed."
},
"fauna.apex_predator.hit": {
"stream": "audio/sfx/fauna/apex_hit.ogg",
"volume_db": -5.0,
"bus": "SFX",
"description": "Pained snarl — apex predator takes damage."
},
"fauna.herbivore.attack": {
"stream": "audio/sfx/fauna/herbivore_attack.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Panicked headbutt grunt — cornered herbivore charges."
},
"fauna.herbivore.hit": {
"stream": "audio/sfx/fauna/herbivore_hit.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Bleat of pain — herbivore wounded."
},
"fauna.omnivore.spawn": {
"stream": "audio/sfx/fauna/omnivore_spawn.ogg",
"volume_db": -8.0,
"bus": "SFX",
"description": "Inquisitive chitter — omnivore enters the map."
},
"fauna.omnivore.attack": {
"stream": "audio/sfx/fauna/omnivore_attack.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Aggressive bark — omnivore lunges."
},
"fauna.omnivore.hit": {
"stream": "audio/sfx/fauna/omnivore_hit.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Yelp — omnivore takes damage."
},
"fauna.omnivore.death": {
"stream": "audio/sfx/fauna/omnivore_death.ogg",
"volume_db": -5.0,
"bus": "SFX",
"description": "Final roar trailing off — omnivore killed."
},
"building.culture.complete": {
"stream": "audio/sfx/buildings/culture_complete.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Resonant wood-stop — culture building completed."
},
"building.diplomacy.complete": {
"stream": "audio/sfx/buildings/diplomacy_complete.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Brass-plate ring — diplomacy building completed."
},
"building.infrastructure.complete": {
"stream": "audio/sfx/buildings/infrastructure_complete.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Stone settle — infrastructure building completed."
},
"building.research.complete": {
"stream": "audio/sfx/buildings/research_complete.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Light scholarly tap — research building completed."
},
"building.complete": {
"stream": "audio/sfx/buildings/infrastructure_complete.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Generic stone settle — kind-only fallback for any building category."
},
"complete": {
"stream": "audio/sfx/buildings/infrastructure_complete.ogg",
"volume_db": -7.0,
"bus": "SFX",
"description": "Bare-kind fallback — last-resort for any 'complete' event."
},
"attack": {
"stream": "audio/sfx/generic/attack.ogg",
"volume_db": -7.0,
"bus": "SFX",
"description": "Generic attack swing — last-resort fallback for unclassified entities."
},
"hit": {
"stream": "audio/sfx/generic/hit.ogg",
"volume_db": -7.0,
"bus": "SFX",
"description": "Generic impact — last-resort fallback for unclassified entities."
},
"death": {
"stream": "audio/sfx/generic/death.ogg",
"volume_db": -6.0,
"bus": "SFX",
"description": "Generic fall thud — last-resort fallback for unclassified entities."
},
"spawn": {
"stream": "audio/sfx/generic/spawn.ogg",
"volume_db": -9.0,
"bus": "SFX",
"description": "Brush rustle — last-resort fallback for entity spawn events."
}
},
"music": {
@ -355,7 +482,10 @@
"volume_db": -10.0,
"bus": "Music",
"loop": true,
"era_range": [1, 2],
"era_range": [
1,
2
],
"mood": "ambient",
"description": "Sparse horn drones + low drums. Era 1-2 ambient."
},
@ -365,7 +495,10 @@
"volume_db": -10.0,
"bus": "Music",
"loop": true,
"era_range": [3, 4],
"era_range": [
3,
4
],
"mood": "ambient",
"description": "Forge-rhythm percussion + strings. Era 3-4 ambient."
},
@ -375,7 +508,10 @@
"volume_db": -10.0,
"bus": "Music",
"loop": true,
"era_range": [5, 6],
"era_range": [
5,
6
],
"mood": "tension",
"description": "Measured march + choir. Era 5-6 ambient."
},
@ -385,7 +521,10 @@
"volume_db": -10.0,
"bus": "Music",
"loop": true,
"era_range": [7, 8],
"era_range": [
7,
8
],
"mood": "tension",
"description": "Industrial pulse + low brass. Era 7-8 ambient."
},
@ -395,7 +534,10 @@
"volume_db": -10.0,
"bus": "Music",
"loop": true,
"era_range": [9, 10],
"era_range": [
9,
10
],
"mood": "climactic",
"description": "Orchestral peak + full choir. Era 9-10 ambient."
},
@ -418,9 +560,142 @@
"era_range": null,
"mood": "triumph",
"description": "Victory screen music, plays once on victory_achieved."
},
{
"id": "defeat",
"stream": "audio/music/defeat.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "lament",
"description": "Defeat screen music, plays once when the human player is eliminated."
},
{
"id": "victory_domination_a",
"stream": "audio/music/victory_domination_a.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Domination victory variant A \u2014 Junkala 'Preparing For Battle'."
},
{
"id": "victory_domination_b",
"stream": "audio/music/victory_domination_b.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Domination variant B \u2014 Junkala 'Encounter With The Witches'."
},
{
"id": "victory_domination_c",
"stream": "audio/music/victory_domination_c.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Domination variant C \u2014 Junkala 'Army Approaching'."
},
{
"id": "victory_culture_a",
"stream": "audio/music/victory_culture_a.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Cultural victory A \u2014 Junkala Calm 'A Place I Call Home', wistful + settled."
},
{
"id": "victory_culture_b",
"stream": "audio/music/victory_culture_b.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Cultural victory B \u2014 Junkala Calm 'Childhood Friends'."
},
{
"id": "victory_culture_c",
"stream": "audio/music/victory_culture_c.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Cultural victory C \u2014 Junkala Calm 'Summer Memories'."
},
{
"id": "victory_science_a",
"stream": "audio/music/victory_science_a.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Science victory A \u2014 Junkala Exploration 'Tha'el Mines', discovery vibe."
},
{
"id": "victory_science_b",
"stream": "audio/music/victory_science_b.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Science victory B \u2014 Junkala Exploration 'Tropical Island', mystery."
},
{
"id": "victory_economic_a",
"stream": "audio/music/victory_economic_a.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Economic victory A \u2014 Junkala Exploration 'Grasslands', pastoral wealth."
},
{
"id": "victory_economic_b",
"stream": "audio/music/victory_economic_b.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "triumph",
"description": "Economic victory B \u2014 Junkala Exploration 'Prairie Nights'."
}
],
"crossfade_seconds": 2.0,
"default_track_id": "overworld_awakening"
"default_track_id": "overworld_awakening",
"victory_pool": {
"domination": [
"victory_domination_a",
"victory_domination_b",
"victory_domination_c"
],
"culture": [
"victory_culture_a",
"victory_culture_b",
"victory_culture_c"
],
"science": [
"victory_science_a",
"victory_science_b"
],
"economic": [
"victory_economic_a",
"victory_economic_b"
],
"score": [
"victory"
]
}
}
}
}

View file

@ -4,19 +4,17 @@ extends Node
## (Music + SFX) plus a small SFX player pool for overlap.
##
## Wiring is purely on EventBus signals — game systems never call this node
## directly. Absence of a stream file is non-fatal: the manifest entry is
## cached and playback is a silent no-op until the asset lands.
## directly. **Fail-loud policy**: if a manifest entry is missing or its
## stream file fails to load, AudioManager plays nothing AND emits
## `EventBus.audio_asset_missing(key, reason)` plus `push_warning`. There is
## no `_silent` sentinel and no per-entry `fallback` chain — design contract
## is "ship the asset or hear nothing". Each missing key warns once per
## session to avoid log spam.
const SFX_POOL_SIZE: int = 6
const AUDIO_DATA_PATH_FMT: String = "res://public/games/%s/data/audio.json"
const SILENT_DB: float = -60.0
const UNIT_MOVED_THROTTLE_MSEC: int = 100
## Bottom of the fallback chain. An entry whose `streams: []` is empty is
## treated as the no-op terminator — `play_sfx` exits silently when reached.
const SILENT_KEY: String = "_silent"
## Defensive cap on fallback hops to guarantee termination even if data
## somehow forms a cycle.
const MAX_FALLBACK_HOPS: int = 8
var _loaded: bool = false
var _theme_id: String = ""
@ -24,6 +22,11 @@ var _sfx_events: Dictionary = {}
var _music_tracks: Dictionary = {}
var _music_default_id: String = ""
var _crossfade_seconds: float = 2.0
## Per-victory-condition track pool. Keys are victory_type strings the engine
## emits (`domination` / `culture` / `science` / `economic` / `score`); each
## value is an array of track IDs from `_music_tracks`. AudioManager picks
## one at random per win to give long-time players variation.
var _victory_pool: Dictionary = {}
var _sfx_pool: Array[AudioStreamPlayer] = []
var _sfx_cursor: int = 0
@ -40,6 +43,10 @@ var _entity_category_cache: Dictionary = {}
## RNG used for streams[] random pick + pitch_jitter. Default-seeded; audio
## jitter does not need a deterministic / replayable seed.
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
## Set of SFX keys that have already triggered a missing-asset warning this
## session — combat fires unit.melee.hit hundreds of times, so we warn once
## then go quiet for that key.
var _warned_missing: Dictionary = {}
func _ready() -> void:
@ -90,30 +97,34 @@ func load_theme(theme_id: String) -> void:
if id.is_empty():
continue
_music_tracks[id] = track
_victory_pool = (music.get("victory_pool", {}) as Dictionary).duplicate()
_loaded = true
func play_sfx(event_key: String) -> void:
## Public API (also called on EventBus signals). Silent no-op when missing.
## Walks the manifest's per-entry `fallback` chain until a stream resolves
## or the chain bottoms out at the `_silent` sentinel.
if not _loaded:
## Public API (also called on EventBus signals). Fail-loud: if the key
## isn't in the manifest or its stream can't load, emit
## `audio_asset_missing` (once per key) and play nothing. No fallback walk.
if not _loaded or event_key.is_empty():
return
var key: String = event_key
for _hop: int in range(MAX_FALLBACK_HOPS):
if key.is_empty() or key == SILENT_KEY:
return
if not _sfx_events.has(key):
return
var entry: Dictionary = _sfx_events[key]
var stream: AudioStream = _resolve_entry_stream(entry)
if stream != null:
_play_stream(stream, entry)
return
var fallback: String = String(entry.get("fallback", ""))
if fallback.is_empty():
return
key = fallback
if not _sfx_events.has(event_key):
_warn_missing(event_key, "key not in manifest")
return
var entry: Dictionary = _sfx_events[event_key]
var stream: AudioStream = _resolve_entry_stream(entry)
if stream == null:
_warn_missing(event_key, "stream(s) failed to load")
return
_play_stream(stream, entry)
func _warn_missing(key: String, reason: String) -> void:
if _warned_missing.has(key):
return
_warned_missing[key] = true
push_warning("[audio] missing asset for sfx key '%s'%s" % [key, reason])
if EventBus.has_signal("audio_asset_missing"):
EventBus.audio_asset_missing.emit(key, reason)
## Public API contextual to a specific game entity (unit / building / wild
@ -199,6 +210,7 @@ func _connect_event_bus() -> void:
EventBus.culture_researched.connect(_on_culture_researched)
EventBus.wild_creature_spawned.connect(_on_wild_creature_spawned)
EventBus.weather_event_applied.connect(_on_weather_event)
EventBus.player_eliminated.connect(_on_player_eliminated)
func _build_music_players() -> void:
@ -257,14 +269,13 @@ func _load_stream(relative_path: String) -> AudioStream:
## Resolve a stream from an SFX manifest entry. Prefers `streams[]` (random
## variant pick), falls back to legacy single `stream`. Returns null when no
## variant loads — caller is then expected to walk the entry's `fallback`
## chain. Empty `streams: []` is the `_silent` sentinel signal.
## variant pick), falls back to single `stream`. Returns null when no
## variant loads — caller emits a missing-asset warning.
func _resolve_entry_stream(entry: Dictionary) -> AudioStream:
if entry.has("streams"):
var raw: Array = entry["streams"] as Array
if raw.is_empty():
return null # _silent sentinel — explicit silence, not a missing asset
return null
# Try a random variant first, then walk the rest in order so one
# missing file does not silence the entry as long as another exists.
var indices: PackedInt32Array = []
@ -369,6 +380,8 @@ func _unit_combat_class(unit: Dictionary) -> String:
var unit_type: String = String(unit.get("unit_type", ""))
if unit_type == "civilian":
return "civilian"
if unit_type == "support":
return "support"
var attack_type: String = String(unit.get("attack_type", ""))
if attack_type == "siege":
return "siege"
@ -400,7 +413,9 @@ func _fauna_class(creature: Dictionary) -> String:
return "herbivore"
if tag == "omnivore":
return "omnivore"
return ""
# Default unclassified wilds to predator so the resolver never yields a
# malformed `fauna..attack` chain segment. Closure test pins this.
return "predator"
## Extract a unit_id from a payload that EventBus signals carry as
@ -439,16 +454,70 @@ func _on_tech_researched(_tech_id: String, _player_index: int) -> void:
play_sfx("tech_researched")
func _on_unit_destroyed(unit: Variant, _killer: Variant) -> void:
func _on_unit_destroyed(unit: Variant, killer: Variant) -> void:
# Two layers, conceptually distinct:
# 1. Species death sound (wolf yelp, dwarf grunt) — neutral, plays for
# everyone regardless of who owned the unit.
# 2. Perspective sting — `unit_defeated` when the local human's unit died,
# `unit_victorious` when the local human did the killing. Generic
# `unit_killed` when neither side is the human (e.g. AI vs AI, AI vs
# wild). The two stings are mutually exclusive: a human killing one of
# their own units only fires `unit_defeated` (loss outweighs).
var unit_id: String = _unit_id_of(unit)
if not unit_id.is_empty():
play_for_entity(unit_id, "death")
var victim_human: bool = _is_human_owner(unit)
var killer_human: bool = _is_human_owner(killer)
if victim_human:
play_sfx("unit_defeated")
elif killer_human:
play_sfx("unit_victorious")
else:
play_sfx("unit_killed")
func _is_human_owner(holder: Variant) -> bool:
if holder == null:
return false
if not ("owner" in holder):
return false
var idx: int = int(holder.get("owner"))
if idx < 0 or idx >= GameState.players.size():
return false
var player: RefCounted = GameState.players[idx] as RefCounted
if player == null or not ("is_human" in player):
return false
return bool(player.get("is_human"))
func _on_wonder_built(_wonder_id: String, player_index: int) -> void:
# Own vs rival branching: hearing a triumphant fanfare when a rival
# civilisation builds a wonder is wrong. Pick `wonder_built.own` for
# the human player, `wonder_built.rival` for everyone else, and fall
# back to the generic `wonder_built` key if neither variant is in the
# manifest (so a half-shipped pack still produces a cue).
play_sfx_for_owner("wonder_built", player_index)
## Play an event SFX with owner-aware variant selection. The candidate
## chain is:
## <key>.own — when player_index belongs to the local human
## <key>.rival — when player_index is any non-human (including AI / wild)
## <key> — generic key, used when neither variant exists in the manifest
## Each candidate is played via `play_sfx`, which fails loud (no per-entry
## fallback walk) — so a missing variant warns once instead of silently
## degrading to a different sound.
func play_sfx_for_owner(key: String, player_index: int) -> void:
var suffix: String = "rival"
if player_index >= 0 and player_index < GameState.players.size():
var player: RefCounted = GameState.players[player_index] as RefCounted
if player != null and "is_human" in player and bool(player.get("is_human")):
suffix = "own"
var variant_key: String = "%s.%s" % [key, suffix]
if _sfx_events.has(variant_key):
play_sfx(variant_key)
return
play_sfx("unit_killed")
func _on_wonder_built(_wonder_id: String, _player_index: int) -> void:
play_sfx("wonder_built")
play_sfx(key)
func _on_era_changed(new_era: int, _player_index: int) -> void:
@ -532,9 +601,38 @@ func _on_unit_moved(_unit: Variant, _from: Vector2i, _to: Vector2i) -> void:
play_sfx("unit_moved")
func _on_victory_achieved(_player_index: int, _victory_type: String) -> void:
func _on_victory_achieved(_player_index: int, victory_type: String) -> void:
play_sfx("victory_fanfare")
play_music("victory")
play_music(_pick_victory_track(victory_type))
## Pick a music track id for the given victory type. Looks the type up in
## `_victory_pool`; if multiple track ids are listed, picks one at random
## so a player who triggers the same victory across multiple games hears
## variation. Falls back to the manifest's "victory" track id when the
## type is unmapped, then to default_track_id.
func _pick_victory_track(victory_type: String) -> String:
if _victory_pool.has(victory_type) and _victory_pool[victory_type] is Array:
var pool: Array = _victory_pool[victory_type] as Array
if pool.size() > 0:
return String(pool[_rng.randi_range(0, pool.size() - 1)])
if _music_tracks.has("victory"):
return "victory"
return _music_default_id
## Defeat is the human-player counterpart of victory_achieved. The signal
## fires for any eliminated player; we only swap to defeat audio when the
## eliminated player is the local human, otherwise the listener gets
## defeat music for an AI's loss which is wrong.
func _on_player_eliminated(player_index: int) -> void:
if player_index < 0 or player_index >= GameState.players.size():
return
var player: RefCounted = GameState.players[player_index] as RefCounted
if player == null or not ("is_human" in player) or not bool(player.get("is_human")):
return
play_sfx("defeat_stinger")
play_music("defeat")
func _music_track_for_era(era: int) -> String:

View file

@ -151,23 +151,20 @@ func test_streams_array_pool_is_loaded_for_categorical_keys() -> void:
assert_true(jitter > 0.0 and jitter <= 0.5, "pitch_jitter must lie in (0.0, 0.5]")
func test_silent_sentinel_terminates_fallback_chain() -> void:
# An entry whose `streams: []` is empty terminates the fallback walk
# without error. city_starved → _silent in the live manifest.
assert_true(
AudioManager._sfx_events.has("_silent"),
"manifest must define a `_silent` sentinel for fallback termination"
)
var sentinel: Dictionary = AudioManager._sfx_events["_silent"]
assert_true(sentinel.has("streams"), "_silent must use empty streams[]")
assert_eq(
(sentinel["streams"] as Array).size(),
0,
"_silent.streams must be the empty array"
)
# Calling play_sfx on a chain that bottoms out at _silent must not crash.
AudioManager.play_sfx("city_starved")
assert_true(true, "play_sfx through a _silent fallback must be a no-op")
func test_missing_key_emits_audio_asset_missing_signal() -> void:
# Fail-loud: an unknown key fires `EventBus.audio_asset_missing(key, reason)`
# exactly once and pushes a warning. Re-calling with the same key is silent
# (already-warned dedupe).
var captured: Array[String] = []
var handler: Callable = func(k: String, _r: String) -> void: captured.append(k)
EventBus.audio_asset_missing.connect(handler)
# Reset dedupe so the test is order-independent.
AudioManager._warned_missing.clear()
AudioManager.play_sfx("definitely_not_a_real_key")
AudioManager.play_sfx("definitely_not_a_real_key")
EventBus.audio_asset_missing.disconnect(handler)
assert_eq(captured.size(), 1, "missing-key warning must fire exactly once per session")
assert_eq(captured[0], "definitely_not_a_real_key")
func test_play_for_entity_resolves_categorical_chain() -> void:
@ -235,3 +232,49 @@ func test_new_event_signals_are_connected() -> void:
found,
"AudioManager must be connected to EventBus.%s (p2-33)" % sig_name
)
# ── Closure: every resolver chain ends in an authored manifest entry ─────
func _assert_chain_resolves(entity_id: String, kind: String) -> void:
var keys: Array[String] = AudioManager._resolve_keys(entity_id, kind)
var found: bool = false
for k: String in keys:
if AudioManager._sfx_events.has(k):
found = true
break
assert_true(
found,
"no manifest entry for chain %s.%s -> %s" % [entity_id, kind, str(keys)]
)
func test_every_unit_resolution_chain_terminates_in_manifest() -> void:
var units: Dictionary = DataLoader.get_data("units") as Dictionary
assert_gt(units.size(), 0, "DataLoader must expose units")
for unit_id: String in units.keys():
for kind: String in ["attack", "hit", "death", "spawn"]:
_assert_chain_resolves(unit_id, kind)
func test_every_building_completion_chain_terminates_in_manifest() -> void:
var bldgs: Dictionary = DataLoader.get_data("buildings") as Dictionary
assert_gt(bldgs.size(), 0, "DataLoader must expose buildings")
for bldg_id: String in bldgs.keys():
_assert_chain_resolves(bldg_id, "complete")
func test_every_weather_kind_has_manifest_entry() -> void:
for kind: String in ["storm", "blizzard", "heat_wave", "drought", "hurricane", "tornado"]:
assert_true(
AudioManager._sfx_events.has("weather.%s" % kind),
"weather.%s missing" % kind
)
func test_bare_kind_keys_authored_for_unknown_entities() -> void:
# Unknown entity_id with no DataLoader registration falls all the way
# to the bare-kind bottom of the chain. These four keys must be authored.
for kind: String in ["attack", "hit", "death", "spawn"]:
_assert_chain_resolves("totally_unknown_entity_xyz", kind)