181 lines
5.1 KiB
GDScript
181 lines
5.1 KiB
GDScript
## AvatarCatalog - Asset catalog management.
|
|
##
|
|
## Manages the mapping of asset IDs to their metadata and resource paths.
|
|
## Loads the catalog manifest from disk and provides lookup by ID and slot.
|
|
##
|
|
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
|
|
## the format specified in avatar.md §7.2.
|
|
|
|
class_name AvatarCatalog
|
|
extends RefCounted
|
|
|
|
|
|
## Emitted when the catalog is loaded successfully.
|
|
signal catalog_loaded
|
|
|
|
## Emitted when the catalog fails to load.
|
|
signal catalog_load_failed(error_message: String)
|
|
|
|
## Catalog manifest structure:
|
|
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
|
|
var _catalog: Dictionary = {}
|
|
var _loaded: bool = false
|
|
var _catalog_path: String = "res://assets/avatars/catalog.json"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Load the catalog from disk. Call this before any lookups.
|
|
func load_catalog(path: String = _catalog_path) -> Error:
|
|
_catalog_path = path
|
|
_catalog = {}
|
|
|
|
var file = FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
var err = FileAccess.get_open_error()
|
|
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
|
|
return err
|
|
|
|
var json_text: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var result: Variant = JSON.parse_string(json_text)
|
|
if result == null or not result is Dictionary:
|
|
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
|
|
return ERR_PARSE_ERROR
|
|
|
|
var json: Dictionary = result as Dictionary
|
|
|
|
if not json.has("assets"):
|
|
catalog_load_failed.emit("Catalog missing 'assets' key")
|
|
return ERR_INVALID_DATA
|
|
|
|
_catalog = json["assets"]
|
|
_loaded = true
|
|
catalog_loaded.emit()
|
|
return OK
|
|
|
|
|
|
## Check if the catalog has been loaded.
|
|
func is_loaded() -> bool:
|
|
return _loaded
|
|
|
|
|
|
## Resolve an asset by its ID.
|
|
## Returns a Dictionary with keys: type, slot, path, version, etc.
|
|
## Returns null if the asset ID is not found.
|
|
func resolve(id: String) -> Dictionary:
|
|
if not _loaded:
|
|
return {}
|
|
return _catalog.get(id, {})
|
|
|
|
|
|
## Check if an asset ID exists in the catalog.
|
|
func has_asset(id: String) -> bool:
|
|
if not _loaded:
|
|
return false
|
|
return _catalog.has(id)
|
|
|
|
|
|
## Get all assets that belong to a given slot.
|
|
## Returns an array of asset ID strings.
|
|
func get_assets_by_slot(slot: String) -> Array[String]:
|
|
if not _loaded:
|
|
return []
|
|
var result: Array[String] = []
|
|
for id in _catalog:
|
|
var asset: Dictionary = _catalog[id]
|
|
if asset.get("slot") == slot:
|
|
result.append(id)
|
|
return result
|
|
|
|
|
|
## Get all asset IDs in the catalog.
|
|
func get_all_ids() -> Array[String]:
|
|
if not _loaded:
|
|
return []
|
|
return _catalog.keys()
|
|
|
|
|
|
## Validate a loadout against the catalog.
|
|
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
|
|
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
|
|
var errors: Array[String] = []
|
|
|
|
# Check base_body
|
|
if loadout.base_body.is_empty():
|
|
errors.append("Loadout missing base_body")
|
|
elif not has_asset(loadout.base_body):
|
|
errors.append("Unknown base_body asset: %s" % loadout.base_body)
|
|
else:
|
|
var base: Dictionary = resolve(loadout.base_body)
|
|
if base.get("type") != "base_body":
|
|
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
|
|
|
|
# Check each slot
|
|
for slot_id in loadout.slots:
|
|
var slot_value = loadout.slots[slot_id]
|
|
|
|
if slot_value == null:
|
|
continue
|
|
|
|
if slot_id == "accessory":
|
|
# Accessory is an array
|
|
if not slot_value is Array:
|
|
errors.append("Slot '%s' must be an array" % slot_id)
|
|
continue
|
|
for item_id in slot_value:
|
|
if not has_asset(item_id):
|
|
errors.append("Unknown accessory asset: %s" % item_id)
|
|
else:
|
|
var asset: Dictionary = resolve(item_id)
|
|
if asset.get("slot") != slot_id:
|
|
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
|
|
item_id, asset.get("slot"), slot_id
|
|
])
|
|
else:
|
|
# Exclusive slot - single value
|
|
if not slot_value is String:
|
|
errors.append("Slot '%s' must be a string or null" % slot_id)
|
|
continue
|
|
if not has_asset(slot_value):
|
|
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
|
|
else:
|
|
var asset: Dictionary = resolve(slot_value)
|
|
if asset.get("slot") != slot_id:
|
|
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
|
|
slot_value, asset.get("slot"), slot_id
|
|
])
|
|
|
|
return {
|
|
"valid": errors.is_empty(),
|
|
"errors": errors
|
|
}
|
|
|
|
|
|
## Compute a hash of the loadout for change detection.
|
|
## Returns a uint32 hash.
|
|
static func compute_loadout_hash(loadout: AvatarLoadout) -> uint32:
|
|
var hasher = HashingContext.new()
|
|
hasher.start(HashingContext.HASH_SHA256)
|
|
|
|
hasher.update(loadout.base_body.to_utf8_buffer())
|
|
for slot_id in loadout.slots:
|
|
var slot_value = loadout.slots[slot_id]
|
|
hasher.update(slot_id.to_utf8_buffer())
|
|
if slot_value is Array:
|
|
for item in slot_value:
|
|
hasher.update(item.to_utf8_buffer())
|
|
elif slot_value is String:
|
|
hasher.update(slot_value.to_utf8_buffer())
|
|
|
|
var hash_bytes = hasher.finish()
|
|
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
|
|
|
|
|
|
## Get the catalog path.
|
|
func get_catalog_path() -> String:
|
|
return _catalog_path
|