Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage #53
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user