## AvatarSystem - Global avatar orchestrator (autoload singleton). ## ## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout. ## Provides a single entry point for the rest of the game to: ## - Load the asset catalog. ## - Load a player's avatar from a loadout. ## - Equip / unequip items and persist the resulting loadout. ## ## Registered as an autoload singleton named "AvatarSystem" in project.godot. extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the catalog has finished loading. signal catalog_loaded ## Emitted when the catalog fails to load. signal catalog_load_failed(error_message: String) ## Emitted when the avatar has been fully assembled and is ready to render. signal avatar_ready(avatar_node: Node3D) ## Emitted when the loadout changes (after equip/unequip). signal loadout_changed(loadout: AvatarLoadout) # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## The asset catalog (id -> metadata + path). var catalog: AvatarCatalog = null ## The loader used to import GLB assets and assemble the avatar. var loader: AvatarLoader = null ## The current player's loadout. var current_loadout: AvatarLoadout = null ## The assembled avatar node (null until avatar_ready). var avatar_node: Node3D = null ## Whether the catalog has been loaded. var is_catalog_loaded: bool = false ## Whether an avatar is currently assembled. var is_avatar_ready: bool = false ## Default catalog path. const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json" # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: catalog = AvatarCatalog.new() loader = AvatarLoader.new() loader.init_loader(catalog) add_child(loader) ## Load the asset catalog. Call this once at startup (or on demand). func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error: var err := catalog.load_catalog(path) if err == OK: is_catalog_loaded = true catalog_loaded.emit() else: catalog_load_failed.emit("Failed to load catalog: %s" % err) return err # --------------------------------------------------------------------------- # Avatar assembly # --------------------------------------------------------------------------- ## Load and assemble an avatar from a loadout. ## Returns the assembled avatar Node3D on success, or null on failure. func load_avatar(loadout: AvatarLoadout) -> Node3D: if not is_catalog_loaded: push_error("AvatarSystem: catalog not loaded; call load_catalog() first") return null if loadout == null: push_error("AvatarSystem: loadout is null") return null # Validate the loadout against the catalog. var validation := catalog.validate_loadout(loadout) if not validation.valid: push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors)) return null current_loadout = loadout # Reset any previous avatar. loader.reset() # Load the base body. var base_body_id := loadout.base_body var base_node := loader.load_base_body(base_body_id) if base_node == null: push_error("AvatarSystem: failed to load base body '%s'" % base_body_id) return null # Load each equipped attachment. for slot_id in AvatarSlotRegistry.SLOT_IDS: var slot_value = loadout.slots.get(slot_id) if slot_id == "accessory": # Accessory is an array of asset ids. if slot_value is Array: for asset_id in slot_value: if asset_id is String and not (asset_id as String).is_empty(): loader.load_attachment(slot_id, asset_id) else: # Exclusive slot: single asset id or null. if slot_value is String and not (slot_value as String).is_empty(): loader.load_attachment(slot_id, slot_value) # Hide base meshes for equipped body-part slots. for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS: var slot_value = loadout.slots.get(slot_id) if slot_value is String and not (slot_value as String).is_empty(): loader.set_base_mesh_visible(slot_id, false) avatar_node = loader.avatar_node is_avatar_ready = avatar_node != null if is_avatar_ready: avatar_ready.emit(avatar_node) return avatar_node ## Equip an item into a slot and rebuild the avatar. ## Returns true on success. func equip(slot_id: String, asset_id: String) -> bool: if current_loadout == null: push_error("AvatarSystem: no loadout loaded") return false if not AvatarSlotRegistry.has_slot(slot_id): push_error("AvatarSystem: unknown slot '%s'" % slot_id) return false if not catalog.has_asset(asset_id): push_error("AvatarSystem: unknown asset '%s'" % asset_id) return false var asset := catalog.resolve(asset_id) if asset.get("slot") != slot_id: push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [ asset_id, asset.get("slot"), slot_id]) return false if slot_id == "accessory": if not current_loadout.add_accessory(asset_id): push_error("AvatarSystem: could not add accessory '%s'" % asset_id) return false else: current_loadout.set_equipment(slot_id, asset_id) loadout_changed.emit(current_loadout) return true ## Unequip an item from a slot and rebuild the avatar. ## Returns true on success. func unequip(slot_id: String, asset_id: String = "") -> bool: if current_loadout == null: push_error("AvatarSystem: no loadout loaded") return false if not AvatarSlotRegistry.has_slot(slot_id): push_error("AvatarSystem: unknown slot '%s'" % slot_id) return false if slot_id == "accessory": if asset_id.is_empty(): push_error("AvatarSystem: accessory unequip requires an asset id") return false if not current_loadout.remove_accessory(asset_id): push_error("AvatarSystem: accessory '%s' not equipped" % asset_id) return false else: current_loadout.set_equipment(slot_id, "") loadout_changed.emit(current_loadout) return true ## Serialize the current loadout to JSON. func save_loadout() -> String: if current_loadout == null: return "" return current_loadout.to_json() ## Load a loadout from a JSON string. func load_loadout_from_json(json_string: String) -> AvatarLoadout: var loadout := AvatarLoadout.from_json(json_string) if not loadout.validate(): push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors)) return null return loadout ## Get the list of available assets for a given slot. func get_assets_for_slot(slot_id: String) -> Array[String]: return catalog.get_assets_by_slot(slot_id) ## Get the display name for an asset id. func get_asset_display_name(asset_id: String) -> String: var asset := catalog.resolve(asset_id) return asset.get("display_name", asset_id)