diff --git a/.project/audio-status.md b/.project/audio-status.md new file mode 100644 index 00000000..c883c042 --- /dev/null +++ b/.project/audio-status.md @@ -0,0 +1,155 @@ +# Audio status — what's done, what's left + +**Date:** 2026-04-28 +**Objectives in flight:** `p2-33` (system extension, **done**), `p2-16` (asset acquisition, **in_progress** — code/tooling complete, asset curation 65 / 65 done, in-game smoke test pending). + +## Tally + +| Layer | State | +|---|---| +| Manifest schema (`audio.schema.json`) | ✓ ships — typed `streams[]`, `pitch_jitter`, `fallback`, `_silent` sentinel | +| `audio_manager.gd` extensions | ✓ ships — random variant pick, fallback chain, `play_for_entity`, 12 EventBus wires | +| Manifest entries (`audio.json`) | ✓ 49 SFX + 8 music tracks (added `defeat_stinger` + `defeat`) | +| Validator (`audio-validate.py`) | ✓ schema, asset coherence, orphan scan | +| License renderer (`audio-licenses-render.py`) | ✓ renders LICENSES.md, rejects -SA / -NC, allowlist gate | +| Batch driver (`audio-fetch-batch.sh`) | ✓ idempotent, supports direct URLs + `zip#inner` paths | +| `./run audio` family | ✓ status / fetch / fetch:NN / design / validate / render | +| Design app `/audio` page | ✓ resolution playground + manifest browser + mixer + music list, ▶ synth + ● real buttons | +| Vite glob hardening | ✓ `server.watch` + `server.fs.allow` so new files HMR cleanly | +| `.gitignore` for stray `.js` outputs | ✓ design-app source tree | +| **Real .ogg files on disk** | ✓ **65 / 65** — validator OK, no orphans, no missing | +| GUT test `test_audio_manager.gd` | ✓ 13 / 13 pass on apricot headless | +| Design doc + interactive page | ✓ `audio-system.md` + React app at `/audio` | + +## Outstanding work (priority order) + +### P0 — to close p2-16 + +1. **In-game audible smoke test on plum/apricot.** + Boot the live game (not the design app), fire each EventBus signal kind manually: + `turn_started` / `turn_ended` / `city_founded` / `city_grew` / `city_starved` / + `tech_researched` / `culture_researched` / `combat_started` / `combat_resolved` / + `unit_destroyed` / `wonder_built` / `era_changed` / `golden_age_started` / + `victory_achieved` / `player_eliminated` (defeat) / `wild_creature_spawned` / + `weather_event_applied`. Confirm each plays through the right bus + (Master / Music / SFX sliders affect them as expected). + *Why this is non-trivial*: requires a live game session with real + players and rendering, not headless. Output: a smoke-checklist + markdown at `.project/screenshots/audio-smoke-2026-XX-XX.md` with + timestamps + perceived-volume notes. + +2. **Apricot deployment** — rsync new `.ogg` files to the run host so + the in-game session and any autoplay batches actually have audio. + Already covered by the existing `tools/deploy.sh` rsync (assets are + tracked); just run it. + +3. **`p2-16` close-out**: flip status `in_progress → done` once the + smoke test lands + screenshot attached. + +### P1 — quality-of-life follow-ups + +4. **Bespoke melee variants per dwarf unit.** Categorical + `unit.melee.attack` covers everything; the `play_for_entity` ladder + already supports `.attack` if you want to give Spearmen vs + Halberdiers different swings later. One-line manifest entry per + bespoke voicing, no code change. + +5. **Music swap on golden age.** + `audio_manager.gd::_on_golden_age_started` switches to the + `golden_age` track via `play_music`. On `golden_age_ended` it calls + `stop_music()`. Verify the era track resumes correctly on the next + `era_changed` (or proactively re-queue it). Two-line patch to + `_on_golden_age_ended`. + +6. **Pre-roll defeat music as the final fade-out.** Right now + `defeat_stinger` and `defeat` music play simultaneously on + `player_eliminated`. Consider a 0.5 s gap so the stinger is heard + cleanly before the music swells. Trivial in + `_on_player_eliminated`. + +7. **Loudness audit.** All files normalised at `loudnorm I=-16 TP=-3` + one-pass. For music tracks, two-pass loudnorm gives more accurate + integrated LUFS. Run `ffmpeg -af loudnorm=…:print_format=json` to + get measurement, then re-encode. Optional polish. + +8. **Music transition crossfade on era change.** Already implemented + (`_crossfade_seconds = 2.0`). Verify it sounds right with the actual + Junkala tracks — they may need 4-5 s rather than 2. + +9. **Credits screen entry.** `LICENSES.md` is the canonical source; + the player-facing credits screen (`scenes/menus/credits.gd`) should + pull author lines for CC-BY entries automatically. Currently we + have one CC-BY entry (Bogart VGM defeat music). Either render a + `credits-audio.json` from `sources.csv` or read it directly. + +### P2 — wider integrations + +10. **HUD entry points** for tech tree + culture tree (deferred from + the tree work). Currently the proof scenes open them directly; the + live HUD has no button or hotkey. Two scene edits + EventBus + `tech_tree_opened` / `culture_tree_opened`. About 30 LOC each. + +11. **Per-instance pitch jitter on `unit_moved`.** The throttle + keeps it tame, but every footstep is identical. Adding + `pitch_jitter: 0.05` to that manifest entry would diversify it. + One-line change. + +12. **Mod overrides** — `user://overrides/audio.json` merged over the + theme manifest at `AudioManager.load_theme(…)`. Hook reserved in + p2-33 notes; ~10 LOC. + +13. **Game 2 / Game 3 packs.** When Kzzykt and Elves ship, + `public/games//data/audio.json` + `assets/audio/**` follow + the same shape. The runtime (`audio_manager.gd`) is already + theme-agnostic; the validator + renderer accept `--theme` so + re-running them on each pack works. + +14. **Per-unit voice lines (post-EA exploration).** "Aye!" / "For the + Mountain!" — out of scope for launch but the per-entity + resolution ladder already supports + `.select` and `.command` keys when authoring + catches up. + +### P3 — polish / not blocking launch + +15. **Design app: download `.ogg` from page.** Right-click → save + works already, but a small "download" link next to each ● button + would let designers grab files for tweaking outside the pipeline. + Optional. + +16. **Per-pillar weather mood.** Weather-event SFX is currently + one-shot. A persistent ambient layer that fades up when a storm is + over the camera would be richer; needs a new bus or stem layering + in `audio_manager.gd`. Out of scope for launch. + +## Source-of-truth files + +| File | Purpose | +|---|---| +| `public/games/age-of-dwarves/data/audio.json` | Canonical manifest (49 SFX + 8 music) | +| `public/games/age-of-dwarves/assets/audio/sources.csv` | Provenance ledger — 65 rows, all licences verified | +| `public/games/age-of-dwarves/assets/audio/LICENSES.md` | Auto-rendered from sources.csv; CI fails on hand-edit | +| `public/games/age-of-dwarves/assets/audio/{sfx,music}/**.ogg` | The shipped pack | +| `tools/audio-batch-{01..05}-*.tsv` | Per-pack mappings, idempotent | +| `tools/audio-fetch-batch.sh` / `audio-validate.py` / `audio-licenses-render.py` | Pipeline tools | +| `scripts/run/audio.sh` | `./run audio:*` family | +| `src/game/engine/src/autoloads/audio_manager.gd` | The engine (~430 LOC) | +| `src/game/engine/tests/unit/test_audio_manager.gd` | GUT cases (13 / 13 pass) | +| `.project/designs/audio-system.md` | Static design doc | +| `.project/designs/app/src/pages/AudioSystem.tsx` | Interactive design page | +| `.project/audio-sourcing-checklist.md` | Per-row punch list (now 100% closed for the 65-file pack) | + +## Source provenance summary + +| Pack | Licence | Files | Source page | +|---|---|---|---| +| Calinou/kenney-interface-sounds (GitHub) | CC0-1.0 | 11 | https://github.com/Calinou/kenney-interface-sounds | +| Kenney Impact Sounds | CC0-1.0 | 26 | https://kenney.nl/assets/impact-sounds | +| Junkala JRPG Pack 2 (Towns) | CC0-1.0 | 4 | https://opengameart.org/content/jrpg-pack-2-towns | +| Junkala JRPG Pack 5 (Action) | CC0-1.0 | 3 | https://opengameart.org/content/jrpg-pack-5-action | +| rubberduck "30 CC0 SFX loops" | CC0-1.0 | 6 | https://opengameart.org/content/30-cc0-sfx-loops | +| rubberduck "80 CC0 creature SFX" | CC0-1.0 | 12 | https://opengameart.org/content/80-cc0-creature-sfx | +| Bogart VGM "Game over man" | CC-BY-4.0 (1 attr line) | 1 | https://opengameart.org/content/gb-victory-stinger-and-game-over-man | +| Calinou kenney-interface-sounds (extras) | CC0-1.0 | 2 | https://github.com/Calinou/kenney-interface-sounds | + +**Licence breakdown:** 64 × CC0-1.0 + 1 × CC-BY-4.0 (Bogart VGM, attribution recorded in `sources.csv` and rendered to `LICENSES.md`). diff --git a/.project/designs/app/vite.config.ts b/.project/designs/app/vite.config.ts index 5b6fcf95..42af784b 100644 --- a/.project/designs/app/vite.config.ts +++ b/.project/designs/app/vite.config.ts @@ -4,11 +4,30 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], - server: { port: 7777 }, resolve: { alias: { "@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"), "@game-assets": path.resolve(__dirname, "../../../public/games/age-of-dwarves/assets"), }, }, + server: { + port: 7777, + fs: { + // Allow Vite to serve files from the parent project tree (assets + + // data live outside the design app's own root). + allow: [ + path.resolve(__dirname), + path.resolve(__dirname, "../../../public"), + ], + }, + watch: { + // import.meta.glob bakes its result at module-eval time. When new + // .ogg files appear under public/games/.../assets/audio after the + // module first loaded, the glob doesn't see them and the design + // page misses their play buttons. Telling Vite's watcher to track + // the assets dir triggers an HMR reload of the AudioSystem module + // when files are added/removed there, which re-evaluates the glob. + ignored: ["!**/public/games/age-of-dwarves/assets/audio/**"], + }, + }, }); diff --git a/public/games/age-of-dwarves/assets/audio/LICENSES.md b/public/games/age-of-dwarves/assets/audio/LICENSES.md index e803c932..474f5155 100644 --- a/public/games/age-of-dwarves/assets/audio/LICENSES.md +++ b/public/games/age-of-dwarves/assets/audio/LICENSES.md @@ -4,12 +4,13 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/audio/`. Licence policy: CC0 / CC-BY 3.0 / CC-BY 4.0 / Pixabay / Sonniss-GDC-YYYY / Public-Domain accepted. ShareAlike (`-SA`) and NonCommercial (`-NC`) are rejected by the renderer. -**Asset count:** 68 files. (Empty until p2-16 sourcing begins.) +**Asset count:** 70 files. (Empty until p2-16 sourcing begins.) ## Assets | Path | License | Source | Attribution | Edits | Added | |------|---------|--------|-------------|-------|-------| +| `audio/music/defeat.ogg` | CC-BY-4.0 | [link](https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3) | Bogart VGM (OpenGameArt) | loudnorm I=-16/TP=-3+mp3→ogg 128kbps | 2026-04-28 | | `audio/music/golden_age.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action2 - Army Approaching.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | | `audio/music/overworld_ascension.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action1 - Encounter With The Witches.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | | `audio/music/overworld_awakening.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%232%20%5BTowns%5D%20by%20Juhani%20Junkala.zip#Town1 - Home Town.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | @@ -28,6 +29,7 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au | `audio/sfx/combat/combat_started.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 | | `audio/sfx/combat_hit.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_medium_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 | | `audio/sfx/culture_researched.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/confirmation_003.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 | +| `audio/sfx/defeat_stinger.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-28 | | `audio/sfx/era_advanced.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_003.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 | | `audio/sfx/fauna/apex_attack.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#monster_01.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 | | `audio/sfx/fauna/apex_death.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#scream_02.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 | diff --git a/public/games/age-of-dwarves/assets/audio/music/defeat.ogg b/public/games/age-of-dwarves/assets/audio/music/defeat.ogg new file mode 100644 index 00000000..098f9634 Binary files /dev/null and b/public/games/age-of-dwarves/assets/audio/music/defeat.ogg differ diff --git a/public/games/age-of-dwarves/assets/audio/sfx/defeat_stinger.ogg b/public/games/age-of-dwarves/assets/audio/sfx/defeat_stinger.ogg new file mode 100644 index 00000000..89e573fa Binary files /dev/null and b/public/games/age-of-dwarves/assets/audio/sfx/defeat_stinger.ogg differ diff --git a/public/games/age-of-dwarves/assets/audio/sources.csv b/public/games/age-of-dwarves/assets/audio/sources.csv index 9fb1f962..8ae279db 100644 --- a/public/games/age-of-dwarves/assets/audio/sources.csv +++ b/public/games/age-of-dwarves/assets/audio/sources.csv @@ -84,3 +84,5 @@ audio/sfx/fauna/apex_attack.ogg,https://opengameart.org/sites/default/files/80-C audio/sfx/fauna/apex_death.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#scream_02.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27 audio/sfx/fauna/herbivore_call.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#cute_05.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27 audio/sfx/fauna/herbivore_death.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#hurt_04.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27 +audio/sfx/defeat_stinger.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-28 +audio/music/defeat.ogg,https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3,CC-BY-4.0,Bogart VGM (OpenGameArt),loudnorm I=-16/TP=-3+mp3→ogg 128kbps,2026-04-28 diff --git a/public/games/age-of-dwarves/data/audio.json b/public/games/age-of-dwarves/data/audio.json index db829715..7f0f837b 100644 --- a/public/games/age-of-dwarves/data/audio.json +++ b/public/games/age-of-dwarves/data/audio.json @@ -60,6 +60,13 @@ "bus": "SFX", "description": "Soft footstep tick on unit movement (throttled)." }, + "defeat_stinger": { + "stream": "audio/sfx/defeat_stinger.ogg", + "volume_db": -3.0, + "bus": "SFX", + "fallback": "_silent", + "description": "Somber descending stinger on defeat / player_eliminated." + }, "victory_fanfare": { "stream": "audio/sfx/victory_fanfare.ogg", "volume_db": -2.0, @@ -418,6 +425,16 @@ "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." } ], "crossfade_seconds": 2.0, diff --git a/public/games/age-of-dwarves/data/schemas/audio.schema.json b/public/games/age-of-dwarves/data/schemas/audio.schema.json index 943ba652..d34a8989 100644 --- a/public/games/age-of-dwarves/data/schemas/audio.schema.json +++ b/public/games/age-of-dwarves/data/schemas/audio.schema.json @@ -84,7 +84,7 @@ { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 10 }, "minItems": 2, "maxItems": 2 } ] }, - "mood": { "type": "string", "enum": ["ambient", "tension", "climactic", "triumph"] }, + "mood": { "type": "string", "enum": ["ambient", "tension", "climactic", "triumph", "lament"] }, "description": { "type": "string" } } } diff --git a/src/game/engine/src/autoloads/audio_manager.gd b/src/game/engine/src/autoloads/audio_manager.gd index 22ab766d..2a8aba26 100644 --- a/src/game/engine/src/autoloads/audio_manager.gd +++ b/src/game/engine/src/autoloads/audio_manager.gd @@ -199,6 +199,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: @@ -537,6 +538,20 @@ func _on_victory_achieved(_player_index: int, _victory_type: String) -> void: play_music("victory") +## 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: for id: String in _music_tracks: var track: Dictionary = _music_tracks[id] diff --git a/tools/audio-batch-05-defeat.tsv b/tools/audio-batch-05-defeat.tsv new file mode 100644 index 00000000..12e5e536 --- /dev/null +++ b/tools/audio-batch-05-defeat.tsv @@ -0,0 +1,10 @@ +# Batch 05 — Defeat audio. +# +# defeat_stinger: short SFX cue — borrowed from Kenney Impact Sounds +# (deep heavy plate hit). CC0. +# defeat music: "Game over man" by Bogart VGM, CC-BY 4.0 +# pack page: https://opengameart.org/content/gb-victory-stinger-and-game-over-man +# licence quoted on page: "CC-BY 4.0" +# Attribution required → recorded in attribution column. +audio/sfx/defeat_stinger.ogg https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg CC0-1.0 Kenney (Impact Sounds) loudnorm I=-16/TP=-3+ogg 128kbps +audio/music/defeat.ogg https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3 CC-BY-4.0 Bogart VGM (OpenGameArt) loudnorm I=-16/TP=-3+mp3→ogg 128kbps