Files
modbison/scripts/avatar/avatar_system.gd

223 lines
6.7 KiB
GDScript

## 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)