diff --git a/assets/avatars/catalog.json b/assets/avatars/catalog.json new file mode 100644 index 0000000..e69e50b --- /dev/null +++ b/assets/avatars/catalog.json @@ -0,0 +1,115 @@ +{ + "assets": { + "base_human_male_01": { + "type": "base_body", + "slot": "base_body", + "path": "res://assets/avatars/base_human_male_01.glb", + "version": "1.0.0", + "display_name": "Human Male Base" + }, + "head_cyber_01": { + "type": "body_part", + "slot": "head", + "path": "res://assets/avatars/head_cyber_01.glb", + "version": "1.0.0", + "display_name": "Cyber Head", + "mount_bone": "Head" + }, + "hair_mohawk_01": { + "type": "attachment", + "slot": "hair", + "path": "res://assets/avatars/hair_mohawk_01.glb", + "version": "1.0.0", + "display_name": "Mohawk", + "mount_bone": "HeadTop" + }, + "hair_ponytail_01": { + "type": "attachment", + "slot": "hair", + "path": "res://assets/avatars/hair_ponytail_01.glb", + "version": "1.0.0", + "display_name": "Ponytail", + "mount_bone": "HeadTop" + }, + "face_glasses_01": { + "type": "attachment", + "slot": "face", + "path": "res://assets/avatars/face_glasses_01.glb", + "version": "1.0.0", + "display_name": "Glasses", + "mount_bone": "Jaw" + }, + "chest_bandit_01": { + "type": "attachment", + "slot": "chest", + "path": "res://assets/avatars/chest_bandit_01.glb", + "version": "1.0.0", + "display_name": "Bandit Shirt", + "mount_bone": "Chest" + }, + "back_backpack_01": { + "type": "attachment", + "slot": "back", + "path": "res://assets/avatars/back_backpack_01.glb", + "version": "1.0.0", + "display_name": "Backpack", + "mount_bone": "Chest" + }, + "pants_cargo_01": { + "type": "attachment", + "slot": "pants", + "path": "res://assets/avatars/pants_cargo_01.glb", + "version": "1.0.0", + "display_name": "Cargo Pants", + "mount_bone": "Hips" + }, + "left_hand_glove_01": { + "type": "attachment", + "slot": "left_hand", + "path": "res://assets/avatars/left_hand_glove_01.glb", + "version": "1.0.0", + "display_name": "Left Glove", + "mount_bone": "LeftHand" + }, + "right_hand_glove_01": { + "type": "attachment", + "slot": "right_hand", + "path": "res://assets/avatars/right_hand_glove_01.glb", + "version": "1.0.0", + "display_name": "Right Glove", + "mount_bone": "RightHand" + }, + "left_foot_boot_01": { + "type": "attachment", + "slot": "left_foot", + "path": "res://assets/avatars/left_foot_boot_01.glb", + "version": "1.0.0", + "display_name": "Left Boot", + "mount_bone": "LeftFoot" + }, + "right_foot_boot_01": { + "type": "attachment", + "slot": "right_foot", + "path": "res://assets/avatars/right_foot_boot_01.glb", + "version": "1.0.0", + "display_name": "Right Boot", + "mount_bone": "RightFoot" + }, + "accessory_belt_01": { + "type": "attachment", + "slot": "accessory", + "path": "res://assets/avatars/accessory_belt_01.glb", + "version": "1.0.0", + "display_name": "Belt", + "mount_bone": "Root" + }, + "accessory_necklace_01": { + "type": "attachment", + "slot": "accessory", + "path": "res://assets/avatars/accessory_necklace_01.glb", + "version": "1.0.0", + "display_name": "Necklace", + "mount_bone": "Root" + } + } +} diff --git a/scripts/avatar/avatar_system.gd b/scripts/avatar/avatar_system.gd new file mode 100644 index 0000000..74e8bd0 --- /dev/null +++ b/scripts/avatar/avatar_system.gd @@ -0,0 +1,222 @@ +## 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)