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