From 2decf846a3fd110d7e171bf52b85cf4082403d53 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 30 Apr 2026 01:33:32 -0700 Subject: [PATCH] =?UTF-8?q?refactor(audio):=20=E2=99=BB=EF=B8=8F=20Split?= =?UTF-8?q?=20audio=20system=20into=20modular=20components:=20AudioManager?= =?UTF-8?q?,=20AudioLoader,=20and=20AudioResolver=20for=20better=20separat?= =?UTF-8?q?ion=20of=20concerns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/src/audio/audio_loader.gd | 129 +++++++++ src/game/engine/src/audio/audio_resolver.gd | 139 ++++++++++ .../engine/src/autoloads/audio_manager.gd | 248 ++---------------- 3 files changed, 289 insertions(+), 227 deletions(-) create mode 100644 src/game/engine/src/audio/audio_loader.gd create mode 100644 src/game/engine/src/audio/audio_resolver.gd diff --git a/src/game/engine/src/audio/audio_loader.gd b/src/game/engine/src/audio/audio_loader.gd new file mode 100644 index 00000000..2b540427 --- /dev/null +++ b/src/game/engine/src/audio/audio_loader.gd @@ -0,0 +1,129 @@ +class_name AudioLoader +extends RefCounted +## Pure data transformation: read library + manifest + pools JSON files +## from disk, apply the subscription filter and per-key overrides, and +## return a resolved bundle. Has no Godot-tree side effects (no audio +## node creation, no signal subscription) — that's AudioManager's job. +## +## Mirror of TS-side `resolveManifest()` in design app's AudioSystem.tsx +## so both sides see the same resolved manifest given the same inputs. + +const SCHEMA_VERSION_DEFAULT: int = 2 + + +## Result of a successful load. AudioManager unpacks this into its +## instance vars. +class Bundle extends RefCounted: + var sfx_events: Dictionary = {} + var music_tracks: Dictionary = {} # id -> track dict + var music_default_id: String = "" + var crossfade_seconds: float = 2.0 + var victory_pool: Dictionary = {} + var defeat_pool: Dictionary = {} + var loaded: bool = false # false when library couldn't be read at all + + +## Read all three files and resolve. `library_path` is the cross-theme +## library; `theme_dir` is the per-theme directory (manifest.json + pools.json). +## Returns a Bundle; `bundle.loaded == false` when the library is missing +## (caller should warn and play silent). +static func load(library_path: String, theme_dir: String) -> Bundle: + var bundle: Bundle = Bundle.new() + var library: Dictionary = _read_json_dict(library_path, "audio library") + if library.is_empty(): + return bundle + 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(bundle, library, manifest) + _apply_pools(bundle, pools) + bundle.loaded = true + return bundle + + +## 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. +static func _apply_subscription( + bundle: Bundle, library: Dictionary, manifest: Dictionary +) -> void: + var lib_sfx: Dictionary = library.get("sfx", {}) as Dictionary + var lib_music: Dictionary = library.get("music", {}) as Dictionary + # JSON parse returns either bool true or an Array here; branch on type + # without storing the Variant in a typed local (project lint forbids + # Variant locals outside autoload signal boundaries). + 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 + + 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] + bundle.sfx_events[key] = entry + + for track: Dictionary in (lib_music.get("tracks", []) as Array): + 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] + bundle.music_tracks[id] = resolved + + +static func _includes_key( + includes_all: bool, includes_list: Array, key: String +) -> bool: + if includes_all: + return true + return includes_list.has(key) + + +static func _apply_pools(bundle: Bundle, pools: Dictionary) -> void: + bundle.crossfade_seconds = float(pools.get("crossfade_seconds", 2.0)) + bundle.music_default_id = String(pools.get("default_track_id", "")) + bundle.victory_pool = (pools.get("victory_pool", {}) as Dictionary).duplicate() + bundle.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. +static func _read_json_dict(path: String, what: String) -> Dictionary: + if not FileAccess.file_exists(path): + push_warning("AudioLoader: %s not found at %s" % [what, path]) + return {} + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("AudioLoader: 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( + "AudioLoader: parse error in %s (%s) line %d: %s" + % [path, what, json.get_error_line(), json.get_error_message()] + ) + return {} + if not (json.data is Dictionary): + push_warning("AudioLoader: %s is not a JSON object: %s" % [what, path]) + return {} + return json.data as Dictionary diff --git a/src/game/engine/src/audio/audio_resolver.gd b/src/game/engine/src/audio/audio_resolver.gd new file mode 100644 index 00000000..c3c42ba5 --- /dev/null +++ b/src/game/engine/src/audio/audio_resolver.gd @@ -0,0 +1,139 @@ +class_name AudioResolver +extends RefCounted +## Categorical resolution for entity-keyed audio events. +## +## Given an entity id ("paladin", "barracks", "dire_wolf_apex") and an +## event_kind ("attack", "complete", "spawn"), returns the chain of SFX +## manifest keys the AudioManager should try, in priority order: +## +## . — bespoke per-entity cue +## .. — categorical fallback +## +## `kind` is one of `unit` / `building` / `fauna` based on which DataLoader +## category the id belongs to. `sub` is the inferred combat class +## (melee/ranged/siege/civilian/support), the building category, or the +## fauna trophic class. +## +## State: caches the kind+sub lookup per entity_id (DataLoader walks once). +## Pure with respect to the manifest — does not know which keys are +## actually authored. AudioManager is responsible for the fail-loud check +## against `_sfx_events`. + +var _entity_category_cache: Dictionary = {} + + +## Build the candidate-key chain for `entity_id` × `event_kind`. +func resolve(entity_id: String, event_kind: String) -> Array[String]: + var keys: Array[String] = [] + keys.append("%s.%s" % [entity_id, event_kind]) + + var kind_and_sub: PackedStringArray = _entity_kind_and_sub(entity_id) + if kind_and_sub.size() == 2: + var kind: String = kind_and_sub[0] + var sub: String = kind_and_sub[1] + if not sub.is_empty(): + keys.append("%s.%s.%s" % [kind, sub, event_kind]) + + return keys + + +## Return [kind, sub_category] for an entity id, or empty if the id is +## not registered with DataLoader. Cached. +func _entity_kind_and_sub(entity_id: String) -> PackedStringArray: + if _entity_category_cache.has(entity_id): + return _entity_category_cache[entity_id] + var result: PackedStringArray = [] + + # Wilds first — wild creatures appear as units of `unit_type: "wild"` AND + # may live in the wilds registry. Route them through the fauna path so + # their roar / spawn / death sounds match. + var wilds: Dictionary = DataLoader.get_data("wilds") as Dictionary + if wilds.has(entity_id) and wilds[entity_id] is Dictionary: + var wild: Dictionary = wilds[entity_id] as Dictionary + if not wild.is_empty(): + result.append("fauna") + result.append(_fauna_class(wild)) + _entity_category_cache[entity_id] = result + return result + + var unit: Dictionary = DataLoader.get_unit(entity_id) as Dictionary + if not unit.is_empty(): + # `unit_type: "wild"` units that are not in the wilds registry still + # get fauna classification by trophic semantics from their `attributes`. + if String(unit.get("unit_type", "")) == "wild": + result.append("fauna") + result.append(_fauna_class(unit)) + _entity_category_cache[entity_id] = result + return result + result.append("unit") + result.append(_unit_combat_class(unit)) + _entity_category_cache[entity_id] = result + return result + + var bldg: Dictionary = DataLoader.get_building(entity_id) as Dictionary + if not bldg.is_empty(): + result.append("building") + result.append(String(bldg.get("category", ""))) + _entity_category_cache[entity_id] = result + return result + + _entity_category_cache[entity_id] = result + return result + + +## Coarse combat class derived from existing unit JSON fields. The data +## doesn't carry an explicit `combat_class`; we infer one for sound routing. +static 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" + var ranged: int = int(unit.get("ranged_attack", 0)) + if ranged > 0: + return "ranged" + # Default for military / mounted / other: melee. + return "melee" + + +## Fauna trophic class. Prefers an explicit `trophic_class` field; falls +## back to scanning `attributes` / `flags` for a recognised tier. Defaults +## to "predator" so the resolver never yields a malformed `fauna..attack` +## chain segment (closure test pins this). +static func _fauna_class(creature: Dictionary) -> String: + var explicit: String = String(creature.get("trophic_class", "")) + if not explicit.is_empty(): + return explicit + var tags: PackedStringArray = [] + for raw_v in (creature.get("attributes", []) as Array): + tags.append(String(raw_v)) + for raw_v in (creature.get("flags", []) as Array): + tags.append(String(raw_v)) + for tag: String in tags: + if tag == "apex_predator" or tag == "apex": + return "apex_predator" + if tag == "predator": + return "predator" + if tag == "herbivore": + return "herbivore" + if tag == "omnivore": + return "omnivore" + return "predator" + + +## Extract a unit_id from a payload that EventBus signals carry as +## `unit: Variant`. Tolerates RefCounted entities and Dictionary payloads. +static func unit_id_of(unit: Variant) -> String: + if unit == null: + return "" + if unit is RefCounted: + var rc: RefCounted = unit as RefCounted + if "unit_id" in rc: + return String(rc.get("unit_id")) + return "" + if unit is Dictionary: + return String((unit as Dictionary).get("unit_id", "")) + return "" diff --git a/src/game/engine/src/autoloads/audio_manager.gd b/src/game/engine/src/autoloads/audio_manager.gd index dc4cda5a..d5c44c10 100644 --- a/src/game/engine/src/autoloads/audio_manager.gd +++ b/src/game/engine/src/autoloads/audio_manager.gd @@ -11,6 +11,9 @@ extends Node ## is "ship the asset or hear nothing". Each missing key warns once per ## session to avoid log spam. +const AudioResolverScript: GDScript = preload("res://engine/src/audio/audio_resolver.gd") +const AudioLoaderScript: GDScript = preload("res://engine/src/audio/audio_loader.gd") + const SFX_POOL_SIZE: int = 6 ## Cross-theme audio library: every SFX entry + every music track lives ## here, alongside the .ogg files. Per-game subscription happens via the @@ -44,10 +47,10 @@ var _active_music_player: AudioStreamPlayer = null var _current_music_id: String = "" var _stream_cache: Dictionary = {} var _last_unit_moved_msec: int = 0 -## Cache of entity_id → category bucket ("unit.melee" / "building.civic" / -## "fauna.apex_predator" / "" if unknown). Avoids re-walking DataLoader on -## every play_for_entity call. -var _entity_category_cache: Dictionary = {} +## Categorical resolver: builds the candidate-key chain for an entity-keyed +## audio event. Owns its own DataLoader-cache. Stateless from AudioManager's +## perspective beyond construction. +var _resolver: RefCounted = AudioResolverScript.new() ## RNG used for streams[] random pick + pitch_jitter. Default-seeded; audio ## jitter does not need a deterministic / replayable seed. var _rng: RandomNumberGenerator = RandomNumberGenerator.new() @@ -66,118 +69,23 @@ func _ready() -> void: func load_theme(theme_id: String) -> void: ## 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 /data/audio/manifest.json — subscription gate - ## 3. apply overrides from the manifest — per-game tweaks - ## 4. read /data/audio/pools.json — per-game routing + ## Delegates to AudioLoader (pure data transformation) and unpacks the + ## resolved bundle into instance vars used by playback. if _loaded and theme_id == _theme_id: return _theme_id = theme_id - _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" + var bundle: RefCounted = AudioLoaderScript.load( + AUDIO_LIBRARY_PATH, AUDIO_THEME_DIR_FMT % theme_id ) - 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) + _sfx_events = bundle.sfx_events + _music_tracks = bundle.music_tracks + _music_default_id = bundle.music_default_id + _crossfade_seconds = bundle.crossfade_seconds + _victory_pool = bundle.victory_pool + _defeat_pool = bundle.defeat_pool _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 (%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 %s (%s) line %d: %s" - % [path, what, json.get_error_line(), json.get_error_message()] - ) - return {} - if not (json.data is Dictionary): - 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: ## Public API (also called on EventBus signals). Fail-loud: if the key ## isn't in the manifest or its stream can't load, emit @@ -410,128 +318,14 @@ func _play_stream(stream: AudioStream, entry: Dictionary) -> void: ## 4. generic ## `kind` is `unit` / `building` / `fauna` based on which DataLoader ## category the id resolves into. +# Categorical resolution lives in AudioResolver. Methods kept as thin +# delegates so existing callers and tests don't change shape. func _resolve_keys(entity_id: String, event_kind: String) -> Array[String]: - # Two-level chain: bespoke per-entity key, then categorical - # `..`. The kind-only and bare fallbacks - # (`.`, ``) were removed: they were - # unreachable once every concrete category had a manifest entry, - # and keeping them invited silent-fallback drift instead of - # fail-loud authoring discipline. - var keys: Array[String] = [] - keys.append("%s.%s" % [entity_id, event_kind]) - - var kind_and_sub: PackedStringArray = _entity_kind_and_sub(entity_id) - if kind_and_sub.size() == 2: - var kind: String = kind_and_sub[0] - var sub: String = kind_and_sub[1] - if not sub.is_empty(): - keys.append("%s.%s.%s" % [kind, sub, event_kind]) - - return keys + return _resolver.resolve(entity_id, event_kind) -## Return [kind, sub_category] for an entity id, e.g. -## ["unit", "melee"] / ["building", "production"] / ["fauna", "predator"] / -## [] when the id is not registered with DataLoader. -func _entity_kind_and_sub(entity_id: String) -> PackedStringArray: - if _entity_category_cache.has(entity_id): - return _entity_category_cache[entity_id] - var result: PackedStringArray = [] - - # Wilds first — wild creatures appear as units of `unit_type: "wild"` AND - # may live in the wilds registry. Route them through the fauna path so - # their roar / spawn / death sounds match. - var wilds: Dictionary = DataLoader.get_data("wilds") as Dictionary - if wilds.has(entity_id) and wilds[entity_id] is Dictionary: - var wild: Dictionary = wilds[entity_id] as Dictionary - if not wild.is_empty(): - result.append("fauna") - result.append(_fauna_class(wild)) - _entity_category_cache[entity_id] = result - return result - - var unit: Dictionary = DataLoader.get_unit(entity_id) as Dictionary - if not unit.is_empty(): - # `unit_type: "wild"` units that are not in the wilds registry still - # get fauna classification by trophic semantics from their `attributes`. - if String(unit.get("unit_type", "")) == "wild": - result.append("fauna") - result.append(_fauna_class(unit)) - _entity_category_cache[entity_id] = result - return result - result.append("unit") - result.append(_unit_combat_class(unit)) - _entity_category_cache[entity_id] = result - return result - - var bldg: Dictionary = DataLoader.get_building(entity_id) as Dictionary - if not bldg.is_empty(): - result.append("building") - result.append(String(bldg.get("category", ""))) - _entity_category_cache[entity_id] = result - return result - - _entity_category_cache[entity_id] = result - return result - - -## Coarse combat class derived from existing unit JSON fields. The data -## doesn't carry an explicit `combat_class`; we infer one for sound routing. -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" - var ranged: int = int(unit.get("ranged_attack", 0)) - if ranged > 0: - return "ranged" - # Default for military / mounted / other: melee. - return "melee" - - -## Fauna trophic class for sound routing. Prefers an explicit -## `trophic_class` field; falls back to scanning `attributes` / -## `flags` for a recognised tier. -func _fauna_class(creature: Dictionary) -> String: - var explicit: String = String(creature.get("trophic_class", "")) - if not explicit.is_empty(): - return explicit - var tags: PackedStringArray = [] - for raw_v in (creature.get("attributes", []) as Array): - tags.append(String(raw_v)) - for raw_v in (creature.get("flags", []) as Array): - tags.append(String(raw_v)) - for tag: String in tags: - if tag == "apex_predator" or tag == "apex": - return "apex_predator" - if tag == "predator": - return "predator" - if tag == "herbivore": - return "herbivore" - if tag == "omnivore": - return "omnivore" - # 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 -## `unit: Variant`. Tolerates RefCounted entities and Dictionary payloads. func _unit_id_of(unit: Variant) -> String: - if unit == null: - return "" - if unit is RefCounted: - var rc: RefCounted = unit as RefCounted - if "unit_id" in rc: - return String(rc.get("unit_id")) - return "" - if unit is Dictionary: - return String((unit as Dictionary).get("unit_id", "")) - return "" + return AudioResolverScript.unit_id_of(unit) # ---------------------------------------------------------------------------