Files
modbison/scripts/avatar/avatar_catalog.gd
coder-bot dcaaf96d81 WIP checkpoint: fix static registry, uint32 type, GLTF API calls, ARRAY_BONES, missing slot handler
- avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every
  caller uses AvatarSlotRegistry.method() on the class, never an instance,
  so the old instance _init() populating them was never invoked); moved
  registry build to static func _static_init()
- avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int
  (GDScript has no fixed-width uint type)
- avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real
  append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't
  exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) ->
  Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can
  never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh);
  fixed _merge_node_into_avatar to recurse into a Skeleton3D's children
  (skinned meshes are commonly children of the skeleton in a GLB export,
  the old code returned before ever reaching them)
- avatar_customization_ui.gd: added the missing _on_slot_button_pressed
  handler (referenced by a button connect, never defined -- just calls
  the existing _select_slot())

Verified: godot --headless --import + --quit is fully clean, and the
avatar_customization.tscn scene now instantiates successfully.

Remaining, scoped separately: avatar_visualizer.gd calls a
AvatarLoader.load_slot() static async API and SlotItem/SlotResult types
that don't exist -- AvatarLoader's real API (matching the project's own
avatar.md spec: one canonical skeleton, attachments retargeted onto it)
is instance-based load_base_body()/load_attachment(). AvatarVisualizer
needs rewriting to use that real API; it's the one out of step with spec,
not AvatarLoader.
2026-08-18 14:47:08 +00:00

182 lines
5.3 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 32-bit hash value (GDScript has no fixed-width uint type — int
## is 64-bit signed, the value is just constrained to 32 bits of range).
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
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