refactor(audio): ♻️ Split audio system into modular components: AudioManager, AudioLoader, and AudioResolver for better separation of concerns
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5e92fb313d
commit
2decf846a3
3 changed files with 289 additions and 227 deletions
129
src/game/engine/src/audio/audio_loader.gd
Normal file
129
src/game/engine/src/audio/audio_loader.gd
Normal file
|
|
@ -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
|
||||
139
src/game/engine/src/audio/audio_resolver.gd
Normal file
139
src/game/engine/src/audio/audio_resolver.gd
Normal file
|
|
@ -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:
|
||||
##
|
||||
## <entity_id>.<event_kind> — bespoke per-entity cue
|
||||
## <kind>.<sub>.<event_kind> — 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 ""
|
||||
|
|
@ -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 <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
|
||||
## 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. <event_kind> 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
|
||||
# `<kind>.<sub>.<event_kind>`. The kind-only and bare fallbacks
|
||||
# (`<kind>.<event_kind>`, `<event_kind>`) 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue