1321 lines
43 KiB
GDScript
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_foot, left_hand)
|
|
## 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_foot", "left_hand"]
|
|
},
|
|
{
|
|
"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()
|