diff --git a/public/games/age-of-dwarves/data/audio.json b/public/games/age-of-dwarves/data/audio.json index db829715..fbdbcc71 100644 --- a/public/games/age-of-dwarves/data/audio.json +++ b/public/games/age-of-dwarves/data/audio.json @@ -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" + ] + } } -} +} \ No newline at end of file diff --git a/src/game/engine/src/autoloads/audio_manager.gd b/src/game/engine/src/autoloads/audio_manager.gd index 22ab766d..28464402 100644 --- a/src/game/engine/src/autoloads/audio_manager.gd +++ b/src/game/engine/src/autoloads/audio_manager.gd @@ -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: +## .own — when player_index belongs to the local human +## .rival — when player_index is any non-human (including AI / wild) +## — 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: diff --git a/src/game/engine/tests/unit/test_audio_manager.gd b/src/game/engine/tests/unit/test_audio_manager.gd index 6dd87296..90c7b08a 100644 --- a/src/game/engine/tests/unit/test_audio_manager.gd +++ b/src/game/engine/tests/unit/test_audio_manager.gd @@ -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)