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:
parent
811e27dfa7
commit
8049e7ec7c
3 changed files with 554 additions and 138 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue