181ba6ae02
checkpoint / checkpoint (push) Successful in 1m45s
- The preview is now a turntable: drag it to rotate the avatar and see all sides; it slowly auto-spins after ~2.5s of no input. Starts facing the camera so you see the face. "drag to rotate" hint under the viewport. - The big grey void to the right of the controls is gone -- the preview panel now expands to fill the leftover width; the controls dock to the right edge at a fixed width. - Camera reframed to fit the whole ~2 m body; softer preview lights. - Placeholder avatar: torso/arms/legs now share one 0.46 m depth so a limb always covers the torso slice behind it -- kills the bright "cyan seam" (it was a lit sliver of the torso side peeking past the thinner arm). Verified headless (boot, 2p multiplayer, avatar smoke source+pack, fresh-clone) + eyeballed the customization screen and the avatar seams in rendered screenshots. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
565 lines
18 KiB
GDScript
565 lines
18 KiB
GDScript
## AvatarCustomizationUI
|
|
##
|
|
## The in-game avatar customization screen. Lets the player:
|
|
## - See a live preview of their avatar.
|
|
## - Browse and equip/unequip items per slot.
|
|
## - Adjust skin tone.
|
|
## - Save / load loadouts.
|
|
##
|
|
## The UI is built programmatically so it works without a pre-authored .tscn.
|
|
## It is a Control node that can be added to any scene.
|
|
|
|
class_name AvatarCustomizationUI
|
|
extends Control
|
|
|
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
|
const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd")
|
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Emitted when the player closes the customization screen.
|
|
signal closed
|
|
|
|
## Emitted when the loadout changes (after equip/unequip/skin-tone).
|
|
signal loadout_changed(loadout: AvatarLoadout)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exports / config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Path to the asset catalog manifest.
|
|
@export var catalog_path: String = "res://assets/avatars/catalog.json"
|
|
|
|
## The default base body to use when none is equipped.
|
|
@export var default_base_body: String = "base_human_male_01"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## The current loadout being edited.
|
|
var loadout: AvatarLoadout = null
|
|
|
|
## The AvatarSystem singleton (set in _ready).
|
|
var _system: Node = null
|
|
|
|
## The catalog (owned by AvatarSystem).
|
|
var _catalog: AvatarCatalog = null
|
|
|
|
## The loader (owned by AvatarSystem).
|
|
var _loader: AvatarLoader = null
|
|
|
|
## The visualizer that renders the preview.
|
|
var _visualizer: AvatarVisualizer = null
|
|
|
|
## The preview viewport container.
|
|
var _preview_container: SubViewportContainer = null
|
|
|
|
## The preview viewport.
|
|
var _preview_viewport: SubViewport = null
|
|
|
|
## The preview camera.
|
|
var _preview_camera: Camera3D = null
|
|
|
|
## The preview avatar root.
|
|
var _preview_root: Node3D = null
|
|
|
|
## Currently selected slot id.
|
|
var _selected_slot: String = ""
|
|
|
|
## Turntable: the preview avatar's Y rotation (radians), whether the player is
|
|
## currently dragging it, and how long since they last touched it (drives the
|
|
## slow idle auto-spin). Starts facing the camera.
|
|
var _turntable_yaw: float = PI
|
|
var _turntable_dragging: bool = false
|
|
var _turntable_idle: float = 0.0
|
|
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
|
|
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
|
|
const _TURNTABLE_DRAG_SPEED := 0.01
|
|
|
|
## UI node references.
|
|
var _slot_list: VBoxContainer = null
|
|
var _item_list: VBoxContainer = null
|
|
var _slot_label: Label = null
|
|
var _item_label: Label = null
|
|
var _skin_tone_slider: HSlider = null
|
|
var _status_label: Label = null
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
# Grab the AvatarSystem autoload.
|
|
_system = get_node_or_null("/root/AvatarSystem")
|
|
if _system == null:
|
|
push_error("AvatarCustomizationUI: AvatarSystem autoload not found")
|
|
return
|
|
|
|
_catalog = _system.catalog
|
|
_loader = _system.loader
|
|
|
|
# Load the catalog if not already loaded.
|
|
if _catalog != null and not _catalog.is_loaded():
|
|
_catalog.load_catalog(catalog_path)
|
|
|
|
# Build the UI.
|
|
_build_ui()
|
|
|
|
# Seed from the player's live loadout (set at login) if there is one, so
|
|
# the screen opens showing what they're actually wearing; otherwise start
|
|
# from a bare default.
|
|
if _system != null and _system.current_loadout != null:
|
|
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
|
|
if loadout == null or not loadout.validate():
|
|
loadout = AvatarLoadout.new()
|
|
loadout.base_body = default_base_body
|
|
|
|
# Initialize the visualizer.
|
|
_visualizer = AvatarVisualizer.new()
|
|
|
|
# Populate the slot list.
|
|
_populate_slots()
|
|
|
|
# Select the first slot.
|
|
if AvatarSlotRegistry.SLOT_IDS.size() > 0:
|
|
_select_slot(AvatarSlotRegistry.SLOT_IDS[0])
|
|
|
|
# Refresh the preview.
|
|
_refresh_preview()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UI construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Build the entire UI tree programmatically.
|
|
func _build_ui() -> void:
|
|
# Root layout: horizontal split (preview | controls).
|
|
var root_hbox := HBoxContainer.new()
|
|
root_hbox.name = "RootLayout"
|
|
root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
root_hbox.add_theme_constant_override("separation", 16)
|
|
add_child(root_hbox)
|
|
|
|
# --- Left: preview (takes all the leftover width) ---
|
|
var preview_panel := PanelContainer.new()
|
|
preview_panel.name = "PreviewPanel"
|
|
preview_panel.custom_minimum_size = Vector2(420, 0)
|
|
preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
root_hbox.add_child(preview_panel)
|
|
|
|
var preview_margin := MarginContainer.new()
|
|
preview_margin.add_theme_constant_override("margin_left", 8)
|
|
preview_margin.add_theme_constant_override("margin_right", 8)
|
|
preview_margin.add_theme_constant_override("margin_top", 8)
|
|
preview_margin.add_theme_constant_override("margin_bottom", 8)
|
|
preview_panel.add_child(preview_margin)
|
|
|
|
var preview_vbox := VBoxContainer.new()
|
|
preview_vbox.add_theme_constant_override("separation", 6)
|
|
preview_margin.add_child(preview_vbox)
|
|
|
|
_preview_container = SubViewportContainer.new()
|
|
_preview_container.name = "PreviewViewportContainer"
|
|
_preview_container.stretch = true
|
|
_preview_container.custom_minimum_size = Vector2(400, 520)
|
|
_preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
_preview_container.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
_preview_container.gui_input.connect(_on_preview_gui_input)
|
|
preview_vbox.add_child(_preview_container)
|
|
|
|
var rotate_hint := Label.new()
|
|
rotate_hint.text = "drag to rotate"
|
|
rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
|
|
rotate_hint.add_theme_font_size_override("font_size", 12)
|
|
preview_vbox.add_child(rotate_hint)
|
|
|
|
_preview_viewport = SubViewport.new()
|
|
_preview_viewport.name = "PreviewViewport"
|
|
_preview_viewport.size = Vector2i(400, 520)
|
|
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
_preview_viewport.transparent_bg = true
|
|
# Its own 3D world -- a SubViewport that shares the (Control-only) main
|
|
# world has no scenario/environment set up and renders nothing.
|
|
_preview_viewport.own_world_3d = true
|
|
_preview_container.add_child(_preview_viewport)
|
|
|
|
# Environment: ambient fill so the model reads even from a bad light angle.
|
|
var world_env := WorldEnvironment.new()
|
|
world_env.name = "PreviewEnv"
|
|
var env := Environment.new()
|
|
env.background_mode = Environment.BG_COLOR
|
|
env.background_color = Color(0.10, 0.11, 0.14)
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
env.ambient_light_color = Color(0.62, 0.64, 0.7)
|
|
env.ambient_light_energy = 0.7
|
|
world_env.environment = env
|
|
_preview_viewport.add_child(world_env)
|
|
|
|
# Camera for the preview -- framed to fit the full ~2 m avatar.
|
|
_preview_camera = Camera3D.new()
|
|
_preview_camera.name = "PreviewCamera"
|
|
# look_at needs a global transform, which only exists once in the tree.
|
|
_preview_camera.look_at_from_position(
|
|
Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP)
|
|
_preview_camera.current = true
|
|
_preview_viewport.add_child(_preview_camera)
|
|
|
|
# Avatar root inside the viewport.
|
|
_preview_root = Node3D.new()
|
|
_preview_root.name = "AvatarPreviewRoot"
|
|
_preview_viewport.add_child(_preview_root)
|
|
|
|
# Key + fill lights so the avatar is clearly visible.
|
|
var key := DirectionalLight3D.new()
|
|
key.name = "PreviewKeyLight"
|
|
key.rotation_degrees = Vector3(-35, 35, 0)
|
|
key.light_energy = 1.0
|
|
_preview_viewport.add_child(key)
|
|
var fill := DirectionalLight3D.new()
|
|
fill.name = "PreviewFillLight"
|
|
fill.rotation_degrees = Vector3(-20, -140, 0)
|
|
fill.light_energy = 0.35
|
|
_preview_viewport.add_child(fill)
|
|
|
|
# --- Right: controls ---
|
|
var controls_panel := PanelContainer.new()
|
|
controls_panel.name = "ControlsPanel"
|
|
controls_panel.custom_minimum_size = Vector2(520, 0)
|
|
root_hbox.add_child(controls_panel)
|
|
|
|
var controls_margin := MarginContainer.new()
|
|
controls_margin.add_theme_constant_override("margin_left", 12)
|
|
controls_margin.add_theme_constant_override("margin_right", 12)
|
|
controls_margin.add_theme_constant_override("margin_top", 12)
|
|
controls_margin.add_theme_constant_override("margin_bottom", 12)
|
|
controls_panel.add_child(controls_margin)
|
|
|
|
var controls_vbox := VBoxContainer.new()
|
|
controls_vbox.name = "Controls"
|
|
controls_vbox.add_theme_constant_override("separation", 8)
|
|
controls_margin.add_child(controls_vbox)
|
|
|
|
# Title.
|
|
var title := Label.new()
|
|
title.text = "Avatar Customization"
|
|
title.add_theme_font_size_override("font_size", 24)
|
|
controls_vbox.add_child(title)
|
|
|
|
# Slot selector.
|
|
var slot_header := Label.new()
|
|
slot_header.text = "Slots"
|
|
slot_header.add_theme_font_size_override("font_size", 16)
|
|
controls_vbox.add_child(slot_header)
|
|
|
|
var slot_scroll := ScrollContainer.new()
|
|
slot_scroll.custom_minimum_size = Vector2(0, 160)
|
|
controls_vbox.add_child(slot_scroll)
|
|
|
|
_slot_list = VBoxContainer.new()
|
|
_slot_list.name = "SlotList"
|
|
_slot_list.add_theme_constant_override("separation", 4)
|
|
slot_scroll.add_child(_slot_list)
|
|
|
|
# Item selector for the selected slot.
|
|
var item_header := Label.new()
|
|
item_header.text = "Items"
|
|
item_header.add_theme_font_size_override("font_size", 16)
|
|
controls_vbox.add_child(item_header)
|
|
|
|
var item_scroll := ScrollContainer.new()
|
|
item_scroll.custom_minimum_size = Vector2(0, 200)
|
|
controls_vbox.add_child(item_scroll)
|
|
|
|
_item_list = VBoxContainer.new()
|
|
_item_list.name = "ItemList"
|
|
_item_list.add_theme_constant_override("separation", 4)
|
|
item_scroll.add_child(_item_list)
|
|
|
|
# Skin tone control.
|
|
var skin_header := Label.new()
|
|
skin_header.text = "Skin Tone"
|
|
skin_header.add_theme_font_size_override("font_size", 16)
|
|
controls_vbox.add_child(skin_header)
|
|
|
|
_skin_tone_slider = HSlider.new()
|
|
_skin_tone_slider.name = "SkinToneSlider"
|
|
_skin_tone_slider.min_value = 0.0
|
|
_skin_tone_slider.max_value = 1.0
|
|
_skin_tone_slider.step = 0.01
|
|
_skin_tone_slider.value = 0.5
|
|
_skin_tone_slider.custom_minimum_size = Vector2(0, 24)
|
|
_skin_tone_slider.value_changed.connect(_on_skin_tone_changed)
|
|
controls_vbox.add_child(_skin_tone_slider)
|
|
|
|
# Status / error label.
|
|
_status_label = Label.new()
|
|
_status_label.name = "StatusLabel"
|
|
_status_label.text = ""
|
|
_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
|
|
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
controls_vbox.add_child(_status_label)
|
|
|
|
# Action buttons.
|
|
var button_row := HBoxContainer.new()
|
|
button_row.add_theme_constant_override("separation", 8)
|
|
controls_vbox.add_child(button_row)
|
|
|
|
var save_btn := Button.new()
|
|
save_btn.text = "Save Loadout"
|
|
save_btn.pressed.connect(_on_save_pressed)
|
|
button_row.add_child(save_btn)
|
|
|
|
var load_btn := Button.new()
|
|
load_btn.text = "Load Loadout"
|
|
load_btn.pressed.connect(_on_load_pressed)
|
|
button_row.add_child(load_btn)
|
|
|
|
var close_btn := Button.new()
|
|
close_btn.text = "Close"
|
|
close_btn.pressed.connect(_on_close_pressed)
|
|
button_row.add_child(close_btn)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot / item population
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Populate the slot list with buttons for each slot.
|
|
func _populate_slots() -> void:
|
|
if _slot_list == null:
|
|
return
|
|
|
|
# Clear existing children.
|
|
for child in _slot_list.get_children():
|
|
child.queue_free()
|
|
|
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
|
var btn := Button.new()
|
|
btn.name = "Slot_%s" % slot_id
|
|
btn.text = slot_id
|
|
btn.toggle_mode = true
|
|
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
|
|
_slot_list.add_child(btn)
|
|
|
|
|
|
## Populate the item list for the currently selected slot.
|
|
func _populate_items() -> void:
|
|
if _item_list == null or _selected_slot.is_empty():
|
|
return
|
|
|
|
# Clear existing children.
|
|
for child in _item_list.get_children():
|
|
child.queue_free()
|
|
|
|
if _catalog == null:
|
|
return
|
|
|
|
# Get assets for this slot.
|
|
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
|
|
|
|
# "None" button to unequip.
|
|
var none_btn := Button.new()
|
|
none_btn.text = "(none)"
|
|
none_btn.pressed.connect(_on_equip_pressed.bind(""))
|
|
_item_list.add_child(none_btn)
|
|
|
|
for asset_id in assets:
|
|
var btn := Button.new()
|
|
btn.text = asset_id
|
|
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
|
|
_item_list.add_child(btn)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Handle a slot button press.
|
|
func _on_slot_button_pressed(slot_id: String) -> void:
|
|
_select_slot(slot_id)
|
|
|
|
|
|
## Select a slot and refresh the item list.
|
|
func _select_slot(slot_id: String) -> void:
|
|
_selected_slot = slot_id
|
|
|
|
# Update button toggle states.
|
|
if _slot_list != null:
|
|
for child in _slot_list.get_children():
|
|
if child is Button:
|
|
var btn := child as Button
|
|
btn.button_pressed = (btn.name == "Slot_%s" % slot_id)
|
|
|
|
_populate_items()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Equip / unequip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Handle an equip/unequip action for the selected slot.
|
|
func _on_equip_pressed(asset_id: String) -> void:
|
|
if loadout == null or _selected_slot.is_empty():
|
|
return
|
|
|
|
if _selected_slot == "accessory":
|
|
if asset_id.is_empty():
|
|
# Clear all accessories.
|
|
loadout.slots["accessory"] = []
|
|
else:
|
|
loadout.add_accessory(asset_id)
|
|
else:
|
|
loadout.set_equipment(_selected_slot, asset_id)
|
|
|
|
_refresh_preview()
|
|
loadout_changed.emit(loadout)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Turntable (drag to rotate the preview, slow auto-spin when idle)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _process(delta: float) -> void:
|
|
if _preview_root == null:
|
|
return
|
|
if _turntable_dragging:
|
|
_turntable_idle = 0.0
|
|
else:
|
|
_turntable_idle += delta
|
|
if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY:
|
|
_turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED
|
|
_preview_root.rotation.y = _turntable_yaw
|
|
|
|
|
|
func _on_preview_gui_input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
|
_turntable_dragging = event.pressed
|
|
_turntable_idle = 0.0
|
|
elif event is InputEventMouseMotion and _turntable_dragging:
|
|
_turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED
|
|
_turntable_idle = 0.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Rebuild the avatar preview from the current loadout.
|
|
func _refresh_preview() -> void:
|
|
if _visualizer == null or _preview_root == null:
|
|
return
|
|
|
|
# Clear the preview root.
|
|
for child in _preview_root.get_children():
|
|
child.queue_free()
|
|
|
|
# Validate the loadout.
|
|
if loadout == null or loadout.base_body.is_empty():
|
|
_status_label.text = "No base body selected."
|
|
return
|
|
|
|
# Build the render tree.
|
|
_visualizer.load_loadout(loadout, _preview_root)
|
|
|
|
# Show any validation errors.
|
|
if not loadout.validation_errors.is_empty():
|
|
_status_label.text = "\n".join(loadout.validation_errors)
|
|
else:
|
|
_status_label.text = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skin tone
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_skin_tone_changed(value: float) -> void:
|
|
if loadout == null:
|
|
return
|
|
# Map slider 0..1 to a skin tone color range.
|
|
var t := value
|
|
var skin := Color(
|
|
0.6 + 0.3 * t, # R
|
|
0.4 + 0.2 * t, # G
|
|
0.3 + 0.15 * t, # B
|
|
1.0
|
|
)
|
|
loadout.skin_tone = skin
|
|
_refresh_preview()
|
|
loadout_changed.emit(loadout)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Save / load
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_save_pressed() -> void:
|
|
if loadout == null:
|
|
return
|
|
|
|
# Local cache (offline fallback / fast reopen).
|
|
var json := loadout.to_json()
|
|
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
|
if file:
|
|
file.store_string(json)
|
|
file.close()
|
|
|
|
# Apply to the live in-world avatar immediately.
|
|
if _system != null:
|
|
_system.set_loadout_from_dict(JSON.parse_string(json))
|
|
|
|
# Persist to the backend if we're signed in.
|
|
var api := get_node_or_null("/root/ApiClient")
|
|
if api != null and api.has_token():
|
|
_status_label.text = "Saving…"
|
|
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
|
|
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
|
|
else:
|
|
_status_label.text = "Saved locally (not signed in)."
|
|
|
|
|
|
func _on_load_pressed() -> void:
|
|
if not FileAccess.file_exists("user://loadout.json"):
|
|
_status_label.text = "No saved loadout found."
|
|
return
|
|
|
|
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
|
|
if file == null:
|
|
_status_label.text = "Failed to open saved loadout."
|
|
return
|
|
|
|
var json := file.get_as_text()
|
|
file.close()
|
|
|
|
var loaded := AvatarLoadout.from_json(json)
|
|
if not loaded.validation_errors.is_empty():
|
|
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
|
|
return
|
|
|
|
loadout = loaded
|
|
_refresh_preview()
|
|
loadout_changed.emit(loadout)
|
|
_status_label.text = "Loadout loaded."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Close
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_close_pressed() -> void:
|
|
closed.emit()
|
|
queue_free()
|