## 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 # Sibling avatar scripts are referenced via preload rather than by their global # class_name so this autoload parses correctly from a bare clone -- a headless # `godot ` run does not rebuild the global class registry (only an editor # scan / `--import` does), and this script is parsed at startup before any such # scan could have happened. See docs/architecture/avatar.md. const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd") const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd") const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd") const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd") # --------------------------------------------------------------------------- # 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) # --------------------------------------------------------------------------- # Local-player convenience API # # The rest of the game (the main menu, the in-world player) only needs three # things: load the catalog once, hand us the player's loadout (as the dict the # backend returns), and ask for an assembled avatar node to drop into a scene. # --------------------------------------------------------------------------- ## True once a loadout has been handed to us (from the backend or a default). func has_loadout() -> bool: return current_loadout != null ## Ensure the catalog is loaded (idempotent). Safe to await repeatedly. func ensure_catalog() -> bool: if is_catalog_loaded: return true return load_catalog() == OK ## Set the current player's loadout from the dict shape the backend's ## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed). ## Returns true if it validated and was stored. func set_loadout_from_dict(loadout_dict: Dictionary) -> bool: var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict)) if not lo.validate(): push_warning("AvatarSystem: rejecting invalid loadout dict: %s" % str(lo.validation_errors)) return false current_loadout = lo loadout_changed.emit(current_loadout) return true ## Assemble a fresh avatar Node3D from the current loadout, ready to parent ## into a scene (e.g. onto the in-world player). Returns null if there's no ## loadout or the catalog isn't loaded or assembly failed. func build_current_avatar() -> Node3D: # Self-sufficient: load the catalog if needed, and fall back to a bare # default loadout when login didn't prime one -- the player should always # get *an* avatar, never a placeholder capsule, as long as the assets exist. if not ensure_catalog(): push_warning("AvatarSystem: catalog unavailable; cannot build avatar") return null if current_loadout == null: var bases := get_assets_for_slot("base_body") if bases.is_empty(): return null set_loadout_from_dict({"base_body": bases[0], "slots": {}}) return assemble_avatar(current_loadout) ## Assemble a standalone avatar node from [param loadout] using a throwaway ## loader -- unlike load_avatar(), this touches no shared state, so it's safe ## to call many times (local player + one per remote peer). Returns null only ## if the assets are genuinely missing. func assemble_avatar(loadout) -> Node3D: if loadout == null or not is_catalog_loaded: return null var validation := catalog.validate_loadout(loadout) if not validation.valid: push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors)) return null var l = AvatarLoader.new() l.init_loader(catalog) var base := l.load_base_body(loadout.base_body) if base == null: l.free() return null for slot_id in AvatarSlotRegistry.SLOT_IDS: var v = loadout.slots.get(slot_id) if slot_id == "accessory": if v is Array: for aid in v: if aid is String and not (aid as String).is_empty(): l.load_attachment(slot_id, aid) elif v is String and not (v as String).is_empty(): l.load_attachment(slot_id, v) for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS: var v = loadout.slots.get(slot_id) if v is String and not (v as String).is_empty(): l.set_base_mesh_visible(slot_id, false) l.apply_skin_tone(loadout.skin_tone) l.free() return base