80f24bfd01
checkpoint / checkpoint (push) Successful in 1m40s
- SpringArm3D was colliding with the player's own capsule (same layer) -> camera yanked up against the head. add_excluded_object(self) in _ready + gentler default pitch, shorter arm, lower pivot. - AvatarSystem.build_current_avatar() is now self-sufficient: loads the catalog itself and falls back to a bare default loadout if login didn't prime one. So the in-world player always shows an avatar, never the capsule, as long as the assets are packed (they are now). - New AvatarSystem.assemble_avatar(): builds a standalone avatar with a throwaway loader, touching no shared state -- so it's safe to call once for the local player AND once per remote peer. (load_avatar()'s shared loader reset()s and freed the previous avatar on every call.) - game.gd: remote players render as the assembled avatar too (shared default look, tinted per-peer, name tag) instead of a plain capsule. Client-side -- rebuild the exe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
323 lines
11 KiB
GDScript
323 lines
11 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
|
|
|
|
# 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 <scene>` 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.free()
|
|
return base
|