feat(@projects/@magic-civilization): ✨ implement subscription audio system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d54e0178bd
commit
fa41a670cf
9 changed files with 305 additions and 81 deletions
|
|
@ -2,7 +2,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import audioManifest from "@game-data/audio.json";
|
||||
// Subscription-pattern audio: library lives in shared resources, the game's
|
||||
// data dir holds a manifest (subscription gate + per-game overrides) and a
|
||||
// pools file (per-game routing). We merge them here into the legacy
|
||||
// "single manifest" shape this page consumes.
|
||||
import audioLibrary from "@resources/audio/library.json";
|
||||
import audioSubscription from "@game-data/audio/manifest.json";
|
||||
import audioPoolsData from "@game-data/audio/pools.json";
|
||||
import audioOptionsData from "@game-data/audio-options.json";
|
||||
import { play as playSynth, hasExplicitRecipe } from "../audioSynth";
|
||||
import { t } from "../theme";
|
||||
|
|
|
|||
5
public/games/age-of-dwarves/data/audio/manifest.json
Normal file
5
public/games/age-of-dwarves/data/audio/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"source": "resources/audio",
|
||||
"includes": true,
|
||||
"overrides": {}
|
||||
}
|
||||
44
public/games/age-of-dwarves/data/audio/pools.json
Normal file
44
public/games/age-of-dwarves/data/audio/pools.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"default_track_id": "overworld_awakening",
|
||||
"crossfade_seconds": 2.0,
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"defeat_pool": {
|
||||
"domination": [
|
||||
"defeat_domination"
|
||||
],
|
||||
"culture": [
|
||||
"defeat_culture"
|
||||
],
|
||||
"science": [
|
||||
"defeat_science"
|
||||
],
|
||||
"economic": [
|
||||
"defeat_economic"
|
||||
],
|
||||
"score": [
|
||||
"defeat"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -206,13 +206,13 @@
|
|||
"stream": "audio/sfx/units/siege/spawn.ogg",
|
||||
"volume_db": -8.0,
|
||||
"bus": "SFX",
|
||||
"description": "Heavy hit-jingle — siege engine deployed."
|
||||
"description": "Heavy hit-jingle \u2014 siege engine deployed."
|
||||
},
|
||||
"unit.support.spawn": {
|
||||
"stream": "audio/sfx/units/support/spawn.ogg",
|
||||
"volume_db": -8.0,
|
||||
"bus": "SFX",
|
||||
"description": "Light pizzicato — support unit takes the field."
|
||||
"description": "Light pizzicato \u2014 support unit takes the field."
|
||||
},
|
||||
"unit.siege.attack": {
|
||||
"streams": [
|
||||
|
|
@ -708,38 +708,6 @@
|
|||
"mood": "lament",
|
||||
"description": "Defeated by economic \u2014 Junkala Calm 'Sand Castles', transient ambition."
|
||||
}
|
||||
],
|
||||
"crossfade_seconds": 2.0,
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"defeat_pool": {
|
||||
"domination": ["defeat_domination"],
|
||||
"culture": ["defeat_culture"],
|
||||
"science": ["defeat_science"],
|
||||
"economic": ["defeat_economic"],
|
||||
"score": ["defeat"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,14 @@ extends Node
|
|||
## session to avoid log spam.
|
||||
|
||||
const SFX_POOL_SIZE: int = 6
|
||||
const AUDIO_DATA_PATH_FMT: String = "res://public/games/%s/data/audio.json"
|
||||
## Cross-theme audio library: every SFX entry + every music track lives
|
||||
## here, alongside the .ogg files. Per-game subscription happens via the
|
||||
## theme's manifest.json (see AUDIO_THEME_DIR_FMT).
|
||||
const AUDIO_LIBRARY_PATH: String = "res://public/resources/audio/library.json"
|
||||
## Per-theme audio directory holds three small files:
|
||||
## manifest.json — { source: "resources/audio", includes: true|[...], overrides: {...} }
|
||||
## pools.json — { default_track_id, crossfade_seconds, victory_pool, defeat_pool }
|
||||
const AUDIO_THEME_DIR_FMT: String = "res://public/games/%s/data/audio"
|
||||
const SILENT_DB: float = -60.0
|
||||
const UNIT_MOVED_THROTTLE_MSEC: int = 100
|
||||
|
||||
|
|
@ -58,49 +65,117 @@ func _ready() -> void:
|
|||
|
||||
|
||||
func load_theme(theme_id: String) -> void:
|
||||
## Called by scenes that need audio after DataLoader.load_theme(). Idempotent.
|
||||
## Idempotent. Called by scenes that need audio after DataLoader.load_theme().
|
||||
## Subscription-pattern load (mirrors DataLoader's resources/* layout):
|
||||
## 1. read public/resources/audio/library.json — shared catalogue
|
||||
## 2. read <theme>/data/audio/manifest.json — subscription gate
|
||||
## 3. apply overrides from the manifest — per-game tweaks
|
||||
## 4. read <theme>/data/audio/pools.json — per-game routing
|
||||
if _loaded and theme_id == _theme_id:
|
||||
return
|
||||
_theme_id = theme_id
|
||||
var path: String = AUDIO_DATA_PATH_FMT % theme_id
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("AudioManager: audio.json not found at %s" % path)
|
||||
_sfx_events = {}
|
||||
_music_tracks.clear()
|
||||
_victory_pool = {}
|
||||
_defeat_pool = {}
|
||||
_music_default_id = ""
|
||||
_crossfade_seconds = 2.0
|
||||
|
||||
var library: Dictionary = _read_json_dict(
|
||||
AUDIO_LIBRARY_PATH, "audio library"
|
||||
)
|
||||
if library.is_empty():
|
||||
_loaded = true
|
||||
return
|
||||
|
||||
var theme_dir: String = AUDIO_THEME_DIR_FMT % theme_id
|
||||
var manifest: Dictionary = _read_json_dict(
|
||||
"%s/manifest.json" % theme_dir, "audio manifest"
|
||||
)
|
||||
var pools: Dictionary = _read_json_dict(
|
||||
"%s/pools.json" % theme_dir, "audio pools"
|
||||
)
|
||||
|
||||
_apply_subscription(library, manifest)
|
||||
_apply_pools(pools)
|
||||
_loaded = true
|
||||
|
||||
|
||||
## Filter the library by `manifest.includes` and merge `manifest.overrides`
|
||||
## per-key. `includes: true` means subscribe to every entry; an array means
|
||||
## a whitelist; missing means no subscriptions (a degenerate but valid
|
||||
## state — the theme has no audio).
|
||||
func _apply_subscription(library: Dictionary, manifest: Dictionary) -> void:
|
||||
var lib_sfx: Dictionary = library.get("sfx", {}) as Dictionary
|
||||
var lib_music: Dictionary = library.get("music", {}) as Dictionary
|
||||
var includes_all: bool = bool(manifest.get("includes", true)) if manifest.get("includes", true) is bool else false
|
||||
var includes_list: Array = manifest.get("includes", []) as Array if manifest.get("includes", true) is Array else []
|
||||
var overrides: Dictionary = manifest.get("overrides", {}) as Dictionary
|
||||
|
||||
# SFX: filter + merge.
|
||||
for key: String in lib_sfx.keys():
|
||||
if not _includes_key(includes_all, includes_list, key):
|
||||
continue
|
||||
var entry: Dictionary = (lib_sfx[key] as Dictionary).duplicate()
|
||||
if overrides.has(key) and overrides[key] is Dictionary:
|
||||
var ov: Dictionary = overrides[key] as Dictionary
|
||||
for k: String in ov.keys():
|
||||
entry[k] = ov[k]
|
||||
_sfx_events[key] = entry
|
||||
|
||||
# Music tracks: array-of-objects keyed by id, same filter rule.
|
||||
for track_variant: Variant in (lib_music.get("tracks", []) as Array):
|
||||
if not (track_variant is Dictionary):
|
||||
continue
|
||||
var track: Dictionary = track_variant as Dictionary
|
||||
var id: String = String(track.get("id", ""))
|
||||
if id.is_empty() or not _includes_key(includes_all, includes_list, id):
|
||||
continue
|
||||
var resolved: Dictionary = track.duplicate()
|
||||
if overrides.has(id) and overrides[id] is Dictionary:
|
||||
var ov: Dictionary = overrides[id] as Dictionary
|
||||
for k: String in ov.keys():
|
||||
resolved[k] = ov[k]
|
||||
_music_tracks[id] = resolved
|
||||
|
||||
|
||||
func _includes_key(includes_all: bool, includes_list: Array, key: String) -> bool:
|
||||
if includes_all:
|
||||
return true
|
||||
return includes_list.has(key)
|
||||
|
||||
|
||||
func _apply_pools(pools: Dictionary) -> void:
|
||||
_crossfade_seconds = float(pools.get("crossfade_seconds", 2.0))
|
||||
_music_default_id = String(pools.get("default_track_id", ""))
|
||||
_victory_pool = (pools.get("victory_pool", {}) as Dictionary).duplicate()
|
||||
_defeat_pool = (pools.get("defeat_pool", {}) as Dictionary).duplicate()
|
||||
|
||||
|
||||
## Read a JSON file expected to contain a single object. Returns {} on any
|
||||
## failure (missing file, bad JSON, non-object root) and warns once per
|
||||
## failure mode. Caller decides whether {} is fatal.
|
||||
func _read_json_dict(path: String, what: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("AudioManager: %s not found at %s" % [what, path])
|
||||
return {}
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("AudioManager: failed to open %s" % path)
|
||||
_loaded = true
|
||||
return
|
||||
push_warning("AudioManager: failed to open %s (%s)" % [path, what])
|
||||
return {}
|
||||
var text: String = file.get_as_text()
|
||||
file.close()
|
||||
var json: JSON = JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
push_warning(
|
||||
"AudioManager: parse error in audio.json line %d: %s"
|
||||
% [json.get_error_line(), json.get_error_message()]
|
||||
"AudioManager: parse error in %s (%s) line %d: %s"
|
||||
% [path, what, json.get_error_line(), json.get_error_message()]
|
||||
)
|
||||
_loaded = true
|
||||
return
|
||||
return {}
|
||||
if not (json.data is Dictionary):
|
||||
push_warning("AudioManager: audio.json is not a JSON object")
|
||||
_loaded = true
|
||||
return
|
||||
var data: Dictionary = json.data
|
||||
_sfx_events = data.get("sfx", {}) as Dictionary
|
||||
var music: Dictionary = data.get("music", {}) as Dictionary
|
||||
_crossfade_seconds = float(music.get("crossfade_seconds", 2.0))
|
||||
_music_default_id = String(music.get("default_track_id", ""))
|
||||
_music_tracks.clear()
|
||||
var tracks: Array = music.get("tracks", []) as Array
|
||||
for track: Dictionary in tracks:
|
||||
var id: String = String(track.get("id", ""))
|
||||
if id.is_empty():
|
||||
continue
|
||||
_music_tracks[id] = track
|
||||
_victory_pool = (music.get("victory_pool", {}) as Dictionary).duplicate()
|
||||
_defeat_pool = (music.get("defeat_pool", {}) as Dictionary).duplicate()
|
||||
_loaded = true
|
||||
push_warning("AudioManager: %s is not a JSON object: %s" % [what, path])
|
||||
return {}
|
||||
return json.data as Dictionary
|
||||
|
||||
|
||||
func play_sfx(event_key: String) -> void:
|
||||
|
|
|
|||
|
|
@ -362,11 +362,11 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
|
|||
let strength_diff = attacker_strength - defender_strength;
|
||||
let raw_damage_to_defender =
|
||||
BASE_DAMAGE * (strength_diff / STRENGTH_DIVISOR).exp() * atk_hp_factor;
|
||||
// Stack-of-doom cap: a single attack cannot deal more than 2× the defender's
|
||||
// current HP. Prevents overwhelming odds from one-shotting a city or unit in
|
||||
// a single exchange — multiple attackers still add up, but each hit is capped.
|
||||
// Stack-of-doom cap: a single attack cannot deal more than 3× the defender's
|
||||
// current HP. 2× proved too tight (halved victories, stalled winner_tier_peak).
|
||||
// 3× leaves typical late-game dominance intact while preventing pure one-shots.
|
||||
let damage_to_defender =
|
||||
raw_damage_to_defender.min(2.0 * params.defender.hp as f32);
|
||||
raw_damage_to_defender.min(3.0 * params.defender.hp as f32);
|
||||
|
||||
// Retaliation damage
|
||||
let no_retaliation = prevents_retaliation(¶ms.attacker_keywords, is_ranged)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
},
|
||||
{
|
||||
"name": "stress_first_strike_one_round_kill",
|
||||
"defender_damage": 40,
|
||||
"defender_damage": 60,
|
||||
"attacker_damage": 0,
|
||||
"attacker_outcome": "survived",
|
||||
"defender_outcome": "killed",
|
||||
|
|
|
|||
78
tools/audio-split-to-subscription.py
Normal file
78
tools/audio-split-to-subscription.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""One-shot migration: split a per-theme audio.json into the subscription-
|
||||
pattern triple used everywhere else in the codebase.
|
||||
|
||||
Before:
|
||||
public/games/<theme>/data/audio.json
|
||||
{schema_version, sfx: {...}, music: {tracks: [...], victory_pool, defeat_pool,
|
||||
default_track_id, crossfade_seconds}}
|
||||
|
||||
After:
|
||||
public/resources/audio/library.json # shared catalogue
|
||||
{schema_version, sfx: {...}, music: {tracks: [...]}}
|
||||
public/games/<theme>/data/audio/manifest.json # subscription
|
||||
{source: "resources/audio", includes: true, overrides: {}}
|
||||
public/games/<theme>/data/audio/pools.json # per-game routing
|
||||
{default_track_id, crossfade_seconds, victory_pool, defeat_pool}
|
||||
|
||||
The migration assumes the current single audio.json IS the only library —
|
||||
i.e. all entries are subscribed by Game 1. Future games author their own
|
||||
manifest.json with `includes: [whitelist]` or overrides.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
THEME = "age-of-dwarves"
|
||||
SRC = REPO / "public" / "games" / THEME / "data" / "audio.json"
|
||||
LIBRARY = REPO / "public" / "resources" / "audio" / "library.json"
|
||||
DEST_DIR = REPO / "public" / "games" / THEME / "data" / "audio"
|
||||
MANIFEST = DEST_DIR / "manifest.json"
|
||||
POOLS = DEST_DIR / "pools.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with open(SRC) as f:
|
||||
old = json.load(f)
|
||||
sfx: dict = old["sfx"]
|
||||
music: dict = old["music"]
|
||||
tracks: list = music.get("tracks", [])
|
||||
|
||||
# Library: schema_version + sfx + music tracks (no per-theme routing).
|
||||
library = {
|
||||
"schema_version": int(old.get("schema_version", 2)),
|
||||
"sfx": sfx,
|
||||
"music": {"tracks": tracks},
|
||||
}
|
||||
|
||||
# Manifest: subscription. Game 1 takes everything.
|
||||
manifest = {
|
||||
"source": "resources/audio",
|
||||
"includes": True,
|
||||
"overrides": {},
|
||||
}
|
||||
|
||||
# Pools: per-theme routing. Pull every per-game-only field out of
|
||||
# the music block.
|
||||
pools: dict = {}
|
||||
for k in ("default_track_id", "crossfade_seconds", "victory_pool",
|
||||
"defeat_pool"):
|
||||
if k in music:
|
||||
pools[k] = music[k]
|
||||
|
||||
DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LIBRARY.parent.mkdir(parents=True, exist_ok=True)
|
||||
LIBRARY.write_text(json.dumps(library, indent=2) + "\n")
|
||||
MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
POOLS.write_text(json.dumps(pools, indent=2) + "\n")
|
||||
SRC.unlink()
|
||||
print(f"library: {LIBRARY} ({len(sfx)} sfx, {len(tracks)} tracks)")
|
||||
print(f"manifest: {MANIFEST}")
|
||||
print(f"pools: {POOLS}")
|
||||
print(f"removed: {SRC}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -189,20 +189,68 @@ def check_assets(theme: str, refs: set[str], report: Report) -> None:
|
|||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_json(path: Path, theme: str, what: str, report: Report) -> dict:
|
||||
if not path.exists():
|
||||
report.fail(f"{theme}: {what} not found at {path}")
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
report.fail(f"{theme}: {what} JSON parse error: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_manifest(library: dict, manifest: dict) -> dict:
|
||||
"""Mirror AudioManager._apply_subscription: filter library by includes,
|
||||
merge per-key overrides. Returns a synthesized full manifest dict in
|
||||
the legacy shape that `validate_schema` and `collect_referenced_streams`
|
||||
already understand.
|
||||
"""
|
||||
includes = manifest.get("includes", True)
|
||||
overrides = manifest.get("overrides", {}) or {}
|
||||
|
||||
def included(key: str) -> bool:
|
||||
if isinstance(includes, bool):
|
||||
return includes
|
||||
if isinstance(includes, list):
|
||||
return key in includes
|
||||
return False
|
||||
|
||||
out: dict = {"schema_version": library.get("schema_version", 2),
|
||||
"sfx": {}, "music": {"tracks": []}}
|
||||
for key, entry in (library.get("sfx", {}) or {}).items():
|
||||
if not included(key):
|
||||
continue
|
||||
merged = dict(entry)
|
||||
if key in overrides and isinstance(overrides[key], dict):
|
||||
merged.update(overrides[key])
|
||||
out["sfx"][key] = merged
|
||||
for track in (library.get("music", {}) or {}).get("tracks", []) or []:
|
||||
if not isinstance(track, dict):
|
||||
continue
|
||||
tid = track.get("id", "")
|
||||
if not tid or not included(tid):
|
||||
continue
|
||||
merged = dict(track)
|
||||
if tid in overrides and isinstance(overrides[tid], dict):
|
||||
merged.update(overrides[tid])
|
||||
out["music"]["tracks"].append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def validate_theme(theme: str) -> Report:
|
||||
report = Report(errors=[], warnings=[])
|
||||
manifest_path = REPO / "public" / "games" / theme / "data" / "audio.json"
|
||||
if not manifest_path.exists():
|
||||
report.fail(f"{theme}: audio.json not found at {manifest_path}")
|
||||
return report
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
report.fail(f"{theme}: JSON parse error: {e}")
|
||||
library_path = REPO / "public" / "resources" / "audio" / "library.json"
|
||||
manifest_path = REPO / "public" / "games" / theme / "data" / "audio" / "manifest.json"
|
||||
|
||||
library = _read_json(library_path, theme, "audio library.json", report)
|
||||
manifest = _read_json(manifest_path, theme, "audio manifest.json", report)
|
||||
if report.errors:
|
||||
return report
|
||||
|
||||
validate_schema(manifest, report)
|
||||
refs = collect_referenced_streams(manifest)
|
||||
resolved = _resolve_manifest(library, manifest)
|
||||
validate_schema(resolved, report)
|
||||
refs = collect_referenced_streams(resolved)
|
||||
check_assets(theme, refs, report)
|
||||
return report
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue