Files
modbison/scripts/avatar/avatar_customization_ui.gd
T

1321 lines
43 KiB
GDScript

## AvatarCustomizationUI
##
## The in-game avatar customization screen. Lets the player:
## - See a live 3D preview of their avatar with turntable rotation and camera zoom/framing.
## - Browse category tabs: Body & Head, Clothing, Accessories, Skin & Colors.
## - Browse and equip/unequip friendly-named items per slot.
## - Fine-tune skin tone with presets, shade slider, and custom RGB sliders.
## - View live Buffalo Chips balance and automatic save status.
## - Auto-save on exit (persisting to user://loadout.json, AvatarSystem, and backend).
##
## Built programmatically as a full-screen Control node.
class_name AvatarCustomizationUI
extends Control
# Preloaded dependencies.
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")
const EconomyApiClient := preload("res://scripts/economy_api_client.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the player backs out or closes the screen.
signal closed
## Emitted when the player clicks the back button or presses ESC.
signal back_requested
## Emitted when the loadout changes (after equip/unequip/skin-tone).
signal loadout_changed(loadout: AvatarLoadout)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Category tabs definition per Lead Architect blueprint:
## Tab 0: "Body & Head" (slots: base_body, head, hair, face)
## Tab 1: "Clothing" (slots: chest, pants, torso, left_hand, right_hand, left_foot, right_foot)
## Tab 2: "Accessories" (slots: back, accessory)
## Tab 3: "Skin & Colors" (skin tone presets, shading slider, custom RGB sliders)
const CATEGORIES: Array[Dictionary] = [
{
"name": "Body & Head",
"slots": ["base_body", "head", "hair", "face"]
},
{
"name": "Clothing",
"slots": ["chest", "pants", "torso", "left_hand", "right_hand", "left_foot", "right_foot"]
},
{
"name": "Accessories",
"slots": ["back", "accessory"]
},
{
"name": "Skin & Colors",
"slots": []
}
]
## Camera framing presets:
## "Full Body" (dist 3.5, height 0.98)
## "Head" (dist 1.6, height 1.62)
## "Torso" (dist 2.2, height 1.12)
## "Feet" (dist 1.8, height 0.35)
const CAMERA_PRESETS: Dictionary = {
"Full Body": {"dist": 3.5, "height": 0.98},
"Head": {"dist": 1.6, "height": 1.62},
"Torso": {"dist": 2.2, "height": 1.12},
"Feet": {"dist": 1.8, "height": 0.35},
}
## Skin: base swatches. First swatch is default yellow.
const SKIN_SWATCHES: Array[Color] = [
Color(0.96, 0.80, 0.19), # yellow (default)
Color(0.95, 0.78, 0.66), # pale
Color(0.83, 0.62, 0.46), # tan
Color(0.55, 0.38, 0.27), # brown
Color(0.33, 0.21, 0.15), # deep brown
Color(0.62, 0.62, 0.64), # grey
Color(0.78, 0.24, 0.24), # red
Color(0.30, 0.62, 0.34), # green
Color(0.26, 0.46, 0.76), # blue
Color(0.62, 0.36, 0.72), # purple
]
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
const _TURNTABLE_DRAG_SPEED := 0.01
const CAMERA_MIN_DIST := 1.5
const CAMERA_MAX_DIST := 4.5
const CAMERA_ZOOM_STEP := 0.2
# ---------------------------------------------------------------------------
# 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 or local fallback).
var _catalog: AvatarCatalog = null
## The loader (owned by AvatarSystem or local fallback).
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
## Camera framing parameters.
var _camera_distance: float = 3.5
var _camera_target_height: float = 0.98
var camera_distance: float:
get:
return _camera_distance
var camera_target_height: float:
get:
return _camera_target_height
## Currently selected category index.
var _current_category_idx: int = 0
## Currently selected slot id.
var _selected_slot: String = "base_body"
## Current skin base color before shading.
var _skin_base: Color = SKIN_SWATCHES[0]
## Turntable state.
var _turntable_yaw: float = PI
var _turntable_dragging: bool = false
var _turntable_idle: float = 0.0
## UI node references.
var _back_button: Button = null
var _title_label: Label = null
var _wallet_label: Label = null
var _status_pill: Label = null
var _zoom_slider: HSlider = null
var _category_tab_bar: TabBar = null
var _items_section: VBoxContainer = null
var _skin_section: VBoxContainer = null
var _slot_list: HBoxContainer = null
var _item_list: VBoxContainer = null
var _skin_tone_slider: HSlider = null
var _status_label: Label = null
var _slider_r: HSlider = null
var _slider_g: HSlider = null
var _slider_b: HSlider = null
var _label_r: Label = null
var _label_g: Label = null
var _label_b: Label = null
var _skin_preview: ColorRect = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Grab the AvatarSystem autoload or instantiate fallback.
_system = get_node_or_null("/root/AvatarSystem")
if _system != null:
_catalog = _system.catalog
_loader = _system.loader
else:
_catalog = AvatarCatalog.new()
_loader = AvatarLoader.new()
# Load the catalog if not already loaded.
if _catalog != null and not _catalog.is_loaded():
_catalog.load_catalog(catalog_path)
# Build the UI tree.
_build_ui()
# Seed from the player's live loadout (set at login) if there is one.
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
# Sync skin tone if already present in loadout.
if loadout.skin_tone != Color.TRANSPARENT:
_skin_base = loadout.skin_tone
_sync_skin_controls()
# Initialize visualizer.
_visualizer = AvatarVisualizer.new()
# Initialize category tabs to Tab 0: "Body & Head".
_on_category_tab_changed(0)
# Initial camera framing.
_update_camera_transform()
# Refresh the 3D preview.
_refresh_preview()
# Fetch live Buffalo Chips balance.
_fetch_wallet_balance()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
save_and_exit()
# ---------------------------------------------------------------------------
# UI Construction
# ---------------------------------------------------------------------------
func _build_ui() -> void:
# Top Level Layout: vertical split (HeaderBar | ContentArea).
var main_vbox := VBoxContainer.new()
main_vbox.name = "MainLayout"
main_vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
main_vbox.add_theme_constant_override("separation", 0)
add_child(main_vbox)
# --- Header Bar ---
var header_panel := PanelContainer.new()
header_panel.name = "HeaderBar"
var header_sb := StyleBoxFlat.new()
header_sb.bg_color = Color(0.08, 0.09, 0.12, 0.98)
header_sb.set_content_margin_all(10)
header_sb.content_margin_left = 16
header_sb.content_margin_right = 16
header_panel.add_theme_stylebox_override("panel", header_sb)
main_vbox.add_child(header_panel)
var header_hbox := HBoxContainer.new()
header_hbox.name = "HeaderHBox"
header_hbox.add_theme_constant_override("separation", 16)
header_panel.add_child(header_hbox)
# Back button: < Back [ESC] (custom_minimum_size Vector2(120, 38))
_back_button = Button.new()
_back_button.name = "BackButton"
_back_button.text = "< Back [ESC]"
_back_button.custom_minimum_size = Vector2(120, 38)
_back_button.pressed.connect(_on_back_pressed)
header_hbox.add_child(_back_button)
# Title Label: Avatar Studio (font_size 24+, centered or expand fill)
_title_label = Label.new()
_title_label.name = "TitleLabel"
_title_label.text = "Avatar Studio"
_title_label.add_theme_font_size_override("font_size", 24)
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_hbox.add_child(_title_label)
# Header Right container
var header_right := HBoxContainer.new()
header_right.name = "HeaderRight"
header_right.add_theme_constant_override("separation", 14)
header_hbox.add_child(header_right)
# Status pill label: All changes saved
var status_pill_panel := PanelContainer.new()
status_pill_panel.name = "StatusPillPanel"
var pill_sb := StyleBoxFlat.new()
pill_sb.bg_color = Color(0.12, 0.22, 0.15, 0.9)
pill_sb.border_color = Color(0.25, 0.75, 0.4, 0.6)
pill_sb.set_border_width_all(1)
pill_sb.set_corner_radius_all(12)
pill_sb.content_margin_left = 12
pill_sb.content_margin_right = 12
pill_sb.content_margin_top = 4
pill_sb.content_margin_bottom = 4
status_pill_panel.add_theme_stylebox_override("panel", pill_sb)
header_right.add_child(status_pill_panel)
_status_pill = Label.new()
_status_pill.name = "StatusPill"
_status_pill.text = "All changes saved"
_status_pill.add_theme_color_override("font_color", Color(0.4, 0.95, 0.6))
_status_pill.add_theme_font_size_override("font_size", 13)
status_pill_panel.add_child(_status_pill)
# Live Buffalo Chips balance label
_wallet_label = Label.new()
_wallet_label.name = "WalletBalanceLabel"
_wallet_label.text = "Wallet: 0 Buffalo Chips"
_wallet_label.add_theme_font_size_override("font_size", 14)
_wallet_label.add_theme_color_override("font_color", Color(0.96, 0.82, 0.3))
header_right.add_child(_wallet_label)
# Content Area: Margins around horizontal split (preview | controls).
var content_margin := MarginContainer.new()
content_margin.name = "ContentMargin"
content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_margin.add_theme_constant_override("margin_left", 12)
content_margin.add_theme_constant_override("margin_right", 12)
content_margin.add_theme_constant_override("margin_top", 12)
content_margin.add_theme_constant_override("margin_bottom", 12)
main_vbox.add_child(content_margin)
var root_hbox := HBoxContainer.new()
root_hbox.name = "RootLayout"
root_hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
root_hbox.add_theme_constant_override("separation", 16)
content_margin.add_child(root_hbox)
# --- Left: preview panel (SubViewport 3D with turntable & camera controls) ---
var preview_panel := PanelContainer.new()
preview_panel.name = "PreviewPanel"
preview_panel.custom_minimum_size = Vector2(430, 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, 480)
_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 • scroll wheel to zoom"
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)
# --- Camera Controls Bar ---
var cam_bar := VBoxContainer.new()
cam_bar.name = "CameraControlsBar"
cam_bar.add_theme_constant_override("separation", 4)
preview_vbox.add_child(cam_bar)
# Preset buttons row: "Full Body", "Head", "Torso", "Feet"
var preset_row := HBoxContainer.new()
preset_row.name = "PresetRow"
preset_row.alignment = BoxContainer.ALIGNMENT_CENTER
preset_row.add_theme_constant_override("separation", 6)
cam_bar.add_child(preset_row)
for preset_name in ["Full Body", "Head", "Torso", "Feet"]:
var pbtn := Button.new()
pbtn.name = "Preset_%s" % preset_name.replace(" ", "")
pbtn.text = preset_name
pbtn.custom_minimum_size = Vector2(82, 28)
pbtn.add_theme_font_size_override("font_size", 12)
pbtn.pressed.connect(frame_camera_preset.bind(preset_name))
preset_row.add_child(pbtn)
# Zoom controls row: [-] Slider [+]
var zoom_row := HBoxContainer.new()
zoom_row.name = "ZoomRow"
zoom_row.alignment = BoxContainer.ALIGNMENT_CENTER
zoom_row.add_theme_constant_override("separation", 8)
cam_bar.add_child(zoom_row)
var zoom_lbl := Label.new()
zoom_lbl.text = "Zoom:"
zoom_lbl.add_theme_font_size_override("font_size", 12)
zoom_row.add_child(zoom_lbl)
var zoom_out_btn := Button.new()
zoom_out_btn.name = "ZoomOutBtn"
zoom_out_btn.text = "-"
zoom_out_btn.custom_minimum_size = Vector2(28, 24)
zoom_out_btn.pressed.connect(func(): set_camera_zoom(_camera_distance + CAMERA_ZOOM_STEP))
zoom_row.add_child(zoom_out_btn)
_zoom_slider = HSlider.new()
_zoom_slider.name = "CameraZoomSlider"
_zoom_slider.min_value = CAMERA_MIN_DIST
_zoom_slider.max_value = CAMERA_MAX_DIST
_zoom_slider.step = 0.05
_zoom_slider.value = _camera_distance
_zoom_slider.custom_minimum_size = Vector2(160, 20)
_zoom_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_zoom_slider.value_changed.connect(_on_zoom_slider_changed)
zoom_row.add_child(_zoom_slider)
var zoom_in_btn := Button.new()
zoom_in_btn.name = "ZoomInBtn"
zoom_in_btn.text = "+"
zoom_in_btn.custom_minimum_size = Vector2(28, 24)
zoom_in_btn.pressed.connect(func(): set_camera_zoom(_camera_distance - CAMERA_ZOOM_STEP))
zoom_row.add_child(zoom_in_btn)
# 3D Viewport Setup.
_preview_viewport = SubViewport.new()
_preview_viewport.name = "PreviewViewport"
_preview_viewport.size = Vector2i(400, 480)
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_preview_viewport.transparent_bg = true
_preview_viewport.own_world_3d = true
_preview_container.add_child(_preview_viewport)
# 3D Environment.
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)
# Preview Camera.
_preview_camera = Camera3D.new()
_preview_camera.name = "PreviewCamera"
_preview_camera.current = true
_preview_viewport.add_child(_preview_camera)
# Preview Avatar Root.
_preview_root = Node3D.new()
_preview_root.name = "AvatarPreviewRoot"
_preview_viewport.add_child(_preview_root)
# Directional Lights.
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 Panel ---
var controls_panel := PanelContainer.new()
controls_panel.name = "ControlsPanel"
controls_panel.custom_minimum_size = Vector2(540, 0)
controls_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root_hbox.add_child(controls_panel)
var controls_margin := MarginContainer.new()
controls_margin.add_theme_constant_override("margin_left", 14)
controls_margin.add_theme_constant_override("margin_right", 14)
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", 10)
controls_margin.add_child(controls_vbox)
# Category Tabs
_category_tab_bar = TabBar.new()
_category_tab_bar.name = "CategoryTabBar"
_category_tab_bar.custom_minimum_size = Vector2(0, 38)
for cat in CATEGORIES:
_category_tab_bar.add_tab(cat["name"])
_category_tab_bar.tab_changed.connect(_on_category_tab_changed)
controls_vbox.add_child(_category_tab_bar)
# --- Items Section (Tabs 0, 1, 2) ---
_items_section = VBoxContainer.new()
_items_section.name = "ItemsSection"
_items_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
_items_section.add_theme_constant_override("separation", 8)
controls_vbox.add_child(_items_section)
# Slots header
var slot_header := Label.new()
slot_header.name = "SlotHeader"
slot_header.text = "Select Slot"
slot_header.add_theme_font_size_override("font_size", 15)
_items_section.add_child(slot_header)
var slot_scroll := ScrollContainer.new()
slot_scroll.custom_minimum_size = Vector2(0, 48)
slot_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
slot_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_items_section.add_child(slot_scroll)
_slot_list = HBoxContainer.new()
_slot_list.name = "SlotList"
_slot_list.add_theme_constant_override("separation", 6)
slot_scroll.add_child(_slot_list)
# Items header
var item_header := Label.new()
item_header.name = "ItemHeader"
item_header.text = "Available Items"
item_header.add_theme_font_size_override("font_size", 15)
_items_section.add_child(item_header)
var item_scroll := ScrollContainer.new()
item_scroll.name = "ItemScroll"
item_scroll.custom_minimum_size = Vector2(0, 240)
item_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_items_section.add_child(item_scroll)
_item_list = VBoxContainer.new()
_item_list.name = "ItemList"
_item_list.add_theme_constant_override("separation", 6)
_item_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
item_scroll.add_child(_item_list)
# --- Skin & Colors Section (Tab 3) ---
_skin_section = VBoxContainer.new()
_skin_section.name = "SkinSection"
_skin_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
_skin_section.add_theme_constant_override("separation", 10)
_skin_section.visible = false
controls_vbox.add_child(_skin_section)
# Presets
var preset_header := Label.new()
preset_header.text = "Skin Tone Presets"
preset_header.add_theme_font_size_override("font_size", 15)
_skin_section.add_child(preset_header)
var swatch_grid := GridContainer.new()
swatch_grid.name = "SwatchGrid"
swatch_grid.columns = 5
swatch_grid.add_theme_constant_override("h_separation", 6)
swatch_grid.add_theme_constant_override("v_separation", 6)
_skin_section.add_child(swatch_grid)
for c in SKIN_SWATCHES:
swatch_grid.add_child(_make_swatch(c))
# Shading
var shade_header := Label.new()
shade_header.text = "Shading (Dark to Light)"
shade_header.add_theme_font_size_override("font_size", 15)
_skin_section.add_child(shade_header)
_skin_tone_slider = HSlider.new()
_skin_tone_slider.name = "ShadeSlider"
_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)
_skin_section.add_child(_skin_tone_slider)
# Custom RGB Sliders
var rgb_header := Label.new()
rgb_header.text = "Custom RGB Color"
rgb_header.add_theme_font_size_override("font_size", 15)
_skin_section.add_child(rgb_header)
var rgb_container := VBoxContainer.new()
rgb_container.name = "RGBContainer"
rgb_container.add_theme_constant_override("separation", 6)
_skin_section.add_child(rgb_container)
# Red
var r_row := HBoxContainer.new()
r_row.add_theme_constant_override("separation", 10)
var r_name := Label.new()
r_name.text = "Red:"
r_name.custom_minimum_size = Vector2(50, 0)
r_row.add_child(r_name)
_slider_r = HSlider.new()
_slider_r.name = "SliderR"
_slider_r.min_value = 0.0
_slider_r.max_value = 1.0
_slider_r.step = 0.01
_slider_r.value = _skin_base.r
_slider_r.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_slider_r.value_changed.connect(_on_rgb_slider_changed)
r_row.add_child(_slider_r)
_label_r = Label.new()
_label_r.name = "LabelR"
_label_r.custom_minimum_size = Vector2(60, 0)
_label_r.text = "R: %d%%" % int(_skin_base.r * 100)
r_row.add_child(_label_r)
rgb_container.add_child(r_row)
# Green
var g_row := HBoxContainer.new()
g_row.add_theme_constant_override("separation", 10)
var g_name := Label.new()
g_name.text = "Green:"
g_name.custom_minimum_size = Vector2(50, 0)
g_row.add_child(g_name)
_slider_g = HSlider.new()
_slider_g.name = "SliderG"
_slider_g.min_value = 0.0
_slider_g.max_value = 1.0
_slider_g.step = 0.01
_slider_g.value = _skin_base.g
_slider_g.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_slider_g.value_changed.connect(_on_rgb_slider_changed)
g_row.add_child(_slider_g)
_label_g = Label.new()
_label_g.name = "LabelG"
_label_g.custom_minimum_size = Vector2(60, 0)
_label_g.text = "G: %d%%" % int(_skin_base.g * 100)
g_row.add_child(_label_g)
rgb_container.add_child(g_row)
# Blue
var b_row := HBoxContainer.new()
b_row.add_theme_constant_override("separation", 10)
var b_name := Label.new()
b_name.text = "Blue:"
b_name.custom_minimum_size = Vector2(50, 0)
b_row.add_child(b_name)
_slider_b = HSlider.new()
_slider_b.name = "SliderB"
_slider_b.min_value = 0.0
_slider_b.max_value = 1.0
_slider_b.step = 0.01
_slider_b.value = _skin_base.b
_slider_b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_slider_b.value_changed.connect(_on_rgb_slider_changed)
b_row.add_child(_slider_b)
_label_b = Label.new()
_label_b.name = "LabelB"
_label_b.custom_minimum_size = Vector2(60, 0)
_label_b.text = "B: %d%%" % int(_skin_base.b * 100)
b_row.add_child(_label_b)
rgb_container.add_child(b_row)
# Swatch preview
var preview_row := HBoxContainer.new()
preview_row.add_theme_constant_override("separation", 10)
var prev_title := Label.new()
prev_title.text = "Preview Color:"
preview_row.add_child(prev_title)
_skin_preview = ColorRect.new()
_skin_preview.name = "SkinColorPreview"
_skin_preview.custom_minimum_size = Vector2(50, 26)
_skin_preview.color = _skin_base
preview_row.add_child(_skin_preview)
_skin_section.add_child(preview_row)
# Spacer
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
_skin_section.add_child(spacer)
# --- Footer: Status & Actions ---
_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)
var button_row := HBoxContainer.new()
button_row.add_theme_constant_override("separation", 10)
controls_vbox.add_child(button_row)
var save_btn := Button.new()
save_btn.name = "SaveButton"
save_btn.text = "Save Loadout"
save_btn.custom_minimum_size = Vector2(110, 36)
save_btn.pressed.connect(_on_save_pressed)
button_row.add_child(save_btn)
var load_btn := Button.new()
load_btn.name = "LoadButton"
load_btn.text = "Load Loadout"
load_btn.custom_minimum_size = Vector2(110, 36)
load_btn.pressed.connect(_on_load_pressed)
button_row.add_child(load_btn)
# ---------------------------------------------------------------------------
# Category & Slot Population
# ---------------------------------------------------------------------------
func _on_category_tab_changed(tab_idx: int) -> void:
_current_category_idx = tab_idx
if _category_tab_bar != null and _category_tab_bar.current_tab != tab_idx:
_category_tab_bar.current_tab = tab_idx
if tab_idx < 3:
if _items_section != null:
_items_section.visible = true
if _skin_section != null:
_skin_section.visible = false
_populate_slots_for_category(tab_idx)
var cat_slots: Array = CATEGORIES[tab_idx]["slots"]
if cat_slots.size() > 0:
if not (cat_slots.has(_selected_slot)):
_selected_slot = cat_slots[0]
# Update toggle states on slot buttons
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" % _selected_slot)
_populate_items()
else:
if _items_section != null:
_items_section.visible = false
if _skin_section != null:
_skin_section.visible = true
_sync_skin_controls()
## Populate slot buttons for the active category.
func _populate_slots_for_category(cat_idx: int) -> void:
if _slot_list == null:
return
for child in _slot_list.get_children():
_slot_list.remove_child(child)
child.queue_free()
if cat_idx < 0 or cat_idx >= CATEGORIES.size():
return
var slots: Array = CATEGORIES[cat_idx]["slots"]
for slot_id in slots:
var btn := Button.new()
btn.name = "Slot_%s" % slot_id
btn.text = _format_slot_name(slot_id)
btn.toggle_mode = true
btn.button_pressed = (slot_id == _selected_slot)
btn.custom_minimum_size = Vector2(100, 34)
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
_slot_list.add_child(btn)
## Populate the slot list (alias for compatibility).
func _populate_slots() -> void:
_populate_slots_for_category(_current_category_idx)
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
# Switch category if the target slot belongs to a different tab.
var target_cat := -1
for i in range(CATEGORIES.size()):
if CATEGORIES[i]["slots"].has(slot_id):
target_cat = i
break
if target_cat != -1 and target_cat != _current_category_idx:
_current_category_idx = target_cat
if _category_tab_bar != null:
_category_tab_bar.current_tab = target_cat
if _items_section != null:
_items_section.visible = true
if _skin_section != null:
_skin_section.visible = false
_populate_slots_for_category(target_cat)
# Update toggle states on slot buttons.
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()
func _format_slot_name(slot_id: String) -> String:
match slot_id:
"base_body": return "Base Body"
"left_foot": return "Left Foot"
"right_foot": return "Right Foot"
"left_hand": return "Left Hand"
"right_hand": return "Right Hand"
_: return slot_id.capitalize()
# ---------------------------------------------------------------------------
# Item Population & Human-Friendly Names
# ---------------------------------------------------------------------------
## Populate the item list for the currently selected slot.
func _populate_items() -> void:
if _item_list == null or _selected_slot.is_empty():
return
for child in _item_list.get_children():
_item_list.remove_child(child)
child.queue_free()
if _catalog == null:
return
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
if _selected_slot == "base_body" and assets.is_empty() and not default_base_body.is_empty():
assets.append(default_base_body)
# Determine if "(None)" is equipped for this slot.
var is_none_equipped: bool = false
if loadout != null:
if _selected_slot == "base_body":
is_none_equipped = loadout.base_body.is_empty()
elif _selected_slot == "accessory":
var accs: Array = loadout.slots.get("accessory", [])
is_none_equipped = accs.is_empty()
else:
is_none_equipped = loadout.get_equipment(_selected_slot).is_empty()
else:
is_none_equipped = true
# (None) / Unequip option.
var none_btn := Button.new()
none_btn.name = "Item_none"
none_btn.custom_minimum_size = Vector2(0, 44)
none_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
none_btn.text = "(None)" if not is_none_equipped else "(None) ✓ EQUIPPED"
none_btn.set_meta("asset_id", "")
none_btn.set_meta("display_name", "(None)")
none_btn.set_meta("is_equipped", is_none_equipped)
_style_item_button(none_btn, is_none_equipped)
if is_none_equipped:
var badge := Label.new()
badge.name = "EquippedBadge"
badge.text = "✓ EQUIPPED"
badge.add_theme_color_override("font_color", Color(0.3, 0.9, 0.5))
none_btn.add_child(badge)
none_btn.pressed.connect(_on_equip_pressed.bind(""))
_item_list.add_child(none_btn)
# Catalog items with friendly names and equipped badges.
for asset_id in assets:
var dname := get_friendly_item_name(asset_id)
var item_equipped: bool = false
if loadout != null:
if _selected_slot == "base_body":
item_equipped = (loadout.base_body == asset_id)
elif _selected_slot == "accessory":
item_equipped = loadout.has_accessory(asset_id)
else:
item_equipped = (loadout.get_equipment(_selected_slot) == asset_id)
var btn := Button.new()
btn.name = "Item_%s" % asset_id
btn.custom_minimum_size = Vector2(0, 44)
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.text = dname if not item_equipped else "%s ✓ EQUIPPED" % dname
btn.set_meta("asset_id", asset_id)
btn.set_meta("display_name", dname)
btn.set_meta("is_equipped", item_equipped)
_style_item_button(btn, item_equipped)
if item_equipped:
var badge := Label.new()
badge.name = "EquippedBadge"
badge.text = "✓ EQUIPPED"
badge.add_theme_color_override("font_color", Color(0.3, 0.9, 0.5))
btn.add_child(badge)
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
_item_list.add_child(btn)
## Look up the display name from the catalog, falling back to a capitalized ID.
func get_friendly_item_name(asset_id: String) -> String:
if asset_id.is_empty():
return "(None)"
var item: Dictionary = _catalog.resolve(asset_id) if _catalog != null else {}
var dname := str(item.get("display_name", "")).strip_edges()
return dname if not dname.is_empty() else asset_id.capitalize()
func _style_item_button(btn: Button, is_equipped: bool) -> void:
var sb_normal := StyleBoxFlat.new()
sb_normal.set_corner_radius_all(6)
sb_normal.set_content_margin_all(8)
if is_equipped:
sb_normal.bg_color = Color(0.16, 0.24, 0.18, 0.95)
sb_normal.border_color = Color(0.25, 0.85, 0.45)
sb_normal.set_border_width_all(2)
else:
sb_normal.bg_color = Color(0.12, 0.14, 0.18, 0.85)
sb_normal.border_color = Color(0.24, 0.26, 0.32)
sb_normal.set_border_width_all(1)
btn.add_theme_stylebox_override("normal", sb_normal)
var sb_hover := sb_normal.duplicate() as StyleBoxFlat
if is_equipped:
sb_hover.border_color = Color(0.4, 0.95, 0.6)
sb_hover.bg_color = Color(0.20, 0.28, 0.22, 1.0)
else:
sb_hover.border_color = Color(0.5, 0.6, 0.7)
sb_hover.bg_color = Color(0.18, 0.20, 0.26, 1.0)
btn.add_theme_stylebox_override("hover", sb_hover)
var sb_pressed := sb_normal.duplicate() as StyleBoxFlat
sb_pressed.border_color = Color(0.3, 0.85, 0.5)
btn.add_theme_stylebox_override("pressed", sb_pressed)
# ---------------------------------------------------------------------------
# Equip / Unequip
# ---------------------------------------------------------------------------
func _on_equip_pressed(asset_id: String) -> void:
if loadout == null or _selected_slot.is_empty():
return
if _selected_slot == "base_body":
if not asset_id.is_empty():
loadout.base_body = asset_id
else:
loadout.base_body = default_base_body
elif _selected_slot == "accessory":
if asset_id.is_empty():
loadout.slots["accessory"] = []
else:
if loadout.has_accessory(asset_id):
loadout.remove_accessory(asset_id)
else:
loadout.add_accessory(asset_id)
else:
if asset_id.is_empty():
loadout.set_equipment(_selected_slot, "")
else:
if loadout.get_equipment(_selected_slot) == asset_id:
loadout.set_equipment(_selected_slot, "")
else:
loadout.set_equipment(_selected_slot, asset_id)
_refresh_preview()
_populate_items()
_mark_saved_status()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Camera Zoom & Framing Presets
# ---------------------------------------------------------------------------
## Set preview camera zoom distance, clamped between CAMERA_MIN_DIST and CAMERA_MAX_DIST.
func set_camera_zoom(dist: float) -> void:
_camera_distance = clampf(dist, CAMERA_MIN_DIST, CAMERA_MAX_DIST)
if _zoom_slider != null:
_zoom_slider.set_value_no_signal(_camera_distance)
_update_camera_transform()
## Frame the camera using a named preset ("Full Body", "Head", "Torso", "Feet").
func frame_camera_preset(preset_name: String) -> void:
var matched_key := ""
for k in CAMERA_PRESETS:
if str(k).to_lower() == preset_name.to_lower():
matched_key = k
break
if matched_key.is_empty():
push_warning("AvatarCustomizationUI: Unknown camera preset '%s'" % preset_name)
return
var data: Dictionary = CAMERA_PRESETS[matched_key]
_camera_distance = float(data["dist"])
_camera_target_height = float(data["height"])
if _zoom_slider != null:
_zoom_slider.set_value_no_signal(_camera_distance)
_update_camera_transform()
func _on_zoom_slider_changed(value: float) -> void:
set_camera_zoom(value)
func _update_camera_transform() -> void:
if _preview_camera == null:
return
_preview_camera.look_at_from_position(
Vector3(0, _camera_target_height + 0.07, _camera_distance),
Vector3(0, _camera_target_height, 0),
Vector3.UP
)
# ---------------------------------------------------------------------------
# Turntable & Input
# ---------------------------------------------------------------------------
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.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
set_camera_zoom(_camera_distance - CAMERA_ZOOM_STEP)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
set_camera_zoom(_camera_distance + CAMERA_ZOOM_STEP)
elif event.button_index == MOUSE_BUTTON_LEFT:
_turntable_dragging = true
_turntable_idle = 0.0
elif event is InputEventMouseButton and not event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
_turntable_dragging = false
_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
# ---------------------------------------------------------------------------
func _refresh_preview() -> void:
if _visualizer == null or _preview_root == null:
return
for child in _preview_root.get_children():
_preview_root.remove_child(child)
child.queue_free()
if loadout == null or loadout.base_body.is_empty():
if _status_label != null:
_status_label.text = "No base body selected."
return
_visualizer.load_loadout(loadout, _preview_root)
if not loadout.validation_errors.is_empty():
if _status_label != null:
_status_label.text = "\n".join(loadout.validation_errors)
else:
if _status_label != null:
_status_label.text = ""
# ---------------------------------------------------------------------------
# Skin & Colors
# ---------------------------------------------------------------------------
func _make_swatch(color: Color) -> Button:
var b := Button.new()
b.custom_minimum_size = Vector2(44, 32)
b.tooltip_text = "#" + color.to_html(false)
for state in ["normal", "hover", "pressed", "focus", "disabled"]:
var sb := StyleBoxFlat.new()
sb.bg_color = color
sb.set_corner_radius_all(4)
if state == "hover" or state == "pressed" or state == "focus":
sb.set_border_width_all(2)
sb.border_color = Color.WHITE
b.add_theme_stylebox_override(state, sb)
b.pressed.connect(_on_skin_swatch_pressed.bind(color))
return b
func _on_skin_swatch_pressed(color: Color) -> void:
_skin_base = color
if _skin_tone_slider != null:
_skin_tone_slider.set_value_no_signal(0.5)
_sync_skin_controls()
_apply_skin_colour()
func _on_skin_tone_changed(_value: float) -> void:
_apply_skin_colour()
func _on_rgb_slider_changed(_value: float) -> void:
if _slider_r == null or _slider_g == null or _slider_b == null:
return
_skin_base = Color(_slider_r.value, _slider_g.value, _slider_b.value, 1.0)
_apply_skin_colour()
func _sync_skin_controls() -> void:
var col := _skin_base
if _slider_r != null:
_slider_r.set_value_no_signal(col.r)
if _slider_g != null:
_slider_g.set_value_no_signal(col.g)
if _slider_b != null:
_slider_b.set_value_no_signal(col.b)
if _label_r != null:
_label_r.text = "R: %d%%" % int(col.r * 100)
if _label_g != null:
_label_g.text = "G: %d%%" % int(col.g * 100)
if _label_b != null:
_label_b.text = "B: %d%%" % int(col.b * 100)
if _skin_preview != null:
_skin_preview.color = col
func _apply_skin_colour() -> void:
if loadout == null:
return
var shade: float = _skin_tone_slider.value if _skin_tone_slider != null else 0.5
var col := _skin_base
if shade < 0.5:
col = _skin_base.lerp(Color.BLACK, (0.5 - shade) * 1.7)
elif shade > 0.5:
col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3)
col.a = 1.0
loadout.skin_tone = col
if _label_r != null:
_label_r.text = "R: %d%%" % int(col.r * 100)
if _label_g != null:
_label_g.text = "G: %d%%" % int(col.g * 100)
if _label_b != null:
_label_b.text = "B: %d%%" % int(col.b * 100)
if _skin_preview != null:
_skin_preview.color = col
_refresh_preview()
_mark_saved_status()
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Wallet & Economy
# ---------------------------------------------------------------------------
func _fetch_wallet_balance() -> void:
if _wallet_label == null:
return
_wallet_label.text = "Wallet: 0 Buffalo Chips"
var api := get_node_or_null("/root/ApiClient")
if api == null or not api.has_method("request"):
return
if api.has_method("has_token") and not api.has_token():
return
var res: Dictionary = await EconomyApiClient.get_wallet(api)
if res.get("ok", false) and res.has("data") and res["data"] is Dictionary:
var bal: int = int(res["data"].get("balance", 0))
_wallet_label.text = "Wallet: %d Buffalo Chips" % bal
else:
_wallet_label.text = "Wallet: 0 Buffalo Chips"
func update_wallet_balance(balance: int) -> void:
if _wallet_label != null:
_wallet_label.text = "Wallet: %d Buffalo Chips" % balance
# ---------------------------------------------------------------------------
# Auto-Save & Exit
# ---------------------------------------------------------------------------
func _mark_saved_status() -> void:
if _status_pill != null:
_status_pill.text = "All changes saved"
## Save loadout to local cache, sync to AvatarSystem, persist to backend, play chime, and exit.
func save_and_exit() -> void:
if _status_pill != null:
_status_pill.text = "Saving changes..."
if loadout != null:
var dict: Dictionary = loadout.to_dict()
var json := loadout.to_json()
# Write loadout JSON to user://loadout.json
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
# Sync to AvatarSystem autoload
if _system != null and _system.has_method("set_loadout_from_dict"):
_system.set_loadout_from_dict(dict)
# Persist to backend if signed in
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_method("has_token") and api.has_token() and api.has_method("save_avatar_loadout"):
await api.save_avatar_loadout(dict)
# Play chime SFX via AudioManager
var audio := get_node_or_null("/root/AudioManager")
if audio != null and audio.has_method("play_chime"):
audio.play_chime()
if _status_pill != null:
_status_pill.text = "All changes saved"
back_requested.emit()
closed.emit()
func _on_save_pressed() -> void:
if loadout == null:
return
var json := loadout.to_json()
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
if _system != null:
_system.set_loadout_from_dict(JSON.parse_string(json))
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_method("has_token") and api.has_token():
if _status_pill != null:
_status_pill.text = "Saving…"
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
if _status_pill != null:
_status_pill.text = "All changes saved" if res.get("ok", false) else "Saved locally"
if not res.get("ok", false):
_status_label.text = "Backend save error: %s" % res.get("detail", "error")
else:
if _status_pill != null:
_status_pill.text = "All changes saved"
_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()
_populate_items()
if loadout.skin_tone != Color.TRANSPARENT:
_skin_base = loadout.skin_tone
_sync_skin_controls()
_mark_saved_status()
loadout_changed.emit(loadout)
_status_label.text = "Loadout loaded."
# ---------------------------------------------------------------------------
# Navigation / Back
# ---------------------------------------------------------------------------
func _on_back_pressed() -> void:
await save_and_exit()
func _on_close_pressed() -> void:
await save_and_exit()
queue_free()