Files
settled-reach/client/ui/character_creation.gd
T
jpmschweitzerandClaude Opus 4.6 a2ef1c34cb fix(client): create TabContainer children programmatically
Godot 4.6 TabContainer silently destroys children defined in .tscn
during scene instantiation ("Parent path has vanished"). Moving tab
shell creation to _ready() fixes the right-side settings panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:41:24 +01:00

1509 lines
54 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name CharacterCreation
extends Control
## #705: Character creation screen.
## Live 3D preview via SubViewport + 5-tab customisation panel (Body/Head/Hair/Clothing/Accessories).
## Emits creation_confirmed(descriptor) on start, creation_cancelled on back.
##
## Game flow: main_menu → character_select (archetype) → character_creation → main.tscn
## D-146 (tile-scale preview, heavy zoom), D-155 (cardinal rotation only),
## D-158 (frontal -5° camera default), D-159 (11 body types), D-165 (color picker palette)
signal creation_confirmed(descriptor: CharacterVisualDescriptor)
signal creation_cancelled
# --- Color palette ---
const BG_COLOR := Color(0.03, 0.03, 0.06, 1.0)
const PANEL_BG := Color(0.05, 0.05, 0.08, 1.0)
const BORDER_COLOR := Color(0.12, 0.15, 0.20, 1.0)
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
const TEXT_DIM := Color(0.53, 0.56, 0.63, 1.0)
const HIGHLIGHT_COLOR := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
const ITEM_NORMAL_BG := Color(0.07, 0.07, 0.10, 1.0)
const ITEM_SELECTED_BG := Color(0.10, 0.13, 0.18, 1.0)
const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER — selection border
# --- Camera presets (D-158) ---
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
# Negative values = tilt downward (camera looks toward ground).
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
const CAM_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
const CAM_DISTANCE: float = 2.4
const CAM_TARGET_HEIGHT: float = 0.9
# --- Rotation (D-155: cardinal only, Q=counter-clockwise, E=clockwise) ---
const CARDINAL_DIRS := ["south", "west", "north", "east"]
# --- Body type grid layout (D-159) ---
# Female row: ThinF, AverageF, MuscularF, HeavyF, TeenF
# Male row: ThinM, AverageM, MuscularM, HeavyM, TeenM
# Child: Child
const BODY_FEMALE_ROW := [
CharacterVisualDescriptor.BodyType.THIN_F,
CharacterVisualDescriptor.BodyType.AVERAGE_F,
CharacterVisualDescriptor.BodyType.MUSCULAR_F,
CharacterVisualDescriptor.BodyType.HEAVY_F,
CharacterVisualDescriptor.BodyType.TEEN_F,
]
const BODY_MALE_ROW := [
CharacterVisualDescriptor.BodyType.THIN_M,
CharacterVisualDescriptor.BodyType.AVERAGE_M,
CharacterVisualDescriptor.BodyType.MUSCULAR_M,
CharacterVisualDescriptor.BodyType.HEAVY_M,
CharacterVisualDescriptor.BodyType.TEEN_M,
]
const BODY_TYPE_LABELS: Dictionary = {
CharacterVisualDescriptor.BodyType.THIN_F: "Thin",
CharacterVisualDescriptor.BodyType.AVERAGE_F: "Average",
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "Muscular",
CharacterVisualDescriptor.BodyType.HEAVY_F: "Heavy",
CharacterVisualDescriptor.BodyType.TEEN_F: "Teen",
CharacterVisualDescriptor.BodyType.THIN_M: "Thin",
CharacterVisualDescriptor.BodyType.AVERAGE_M: "Average",
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "Muscular",
CharacterVisualDescriptor.BodyType.HEAVY_M: "Heavy",
CharacterVisualDescriptor.BodyType.TEEN_M: "Teen",
CharacterVisualDescriptor.BodyType.CHILD: "Child",
}
# --- Hair / facial hair / eyebrows ---
const FACIAL_HAIR_OPTIONS := ["", "beard", "moustache", "mutton_chops"]
const FACIAL_HAIR_LABELS := ["None", "Beard", "Moustache", "Mutton Chops"]
const EYEBROW_OPTIONS := ["regular", "female", "teen", "thick"]
const EYEBROW_LABELS := ["Regular", "Female", "Teen", "Thick"]
# --- Clothing slots ---
const CLOTHING_SLOTS := ["torso", "legs", "feet", "hands"]
const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
const ACCESSORY_SLOTS := [
"hat", "goggles", "mask", "backpack", "belt",
"wrist_l", "wrist_r", "earring_l", "earring_r", "necklace",
]
const ACCESSORY_SLOT_LABELS := [
"Hat", "Goggles", "Mask", "Backpack", "Belt",
"Wrist L", "Wrist R", "Earring L", "Earring R", "Necklace",
]
# --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) ---
# Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
const PALETTE_COLORS: Array[Array] = [
# Row 1: Reds H=0° S=35%
["#3d1d1d", "#522727", "#673131", "#7f3d3d", "#974848", "#af5959", "#bc7575", "#c99090", "#d7acac"],
# Row 2: Greens H=150° S=28%
["#213a2d", "#2c4e3d", "#37614c", "#43785e", "#508f70", "#62a684", "#7cb599", "#96c4ad", "#b0d2c1"],
# Row 3: Blues H=215° S=35%
["#1d2b3d", "#273952", "#314867", "#3d587f", "#486997", "#597daf", "#7593bc", "#90a8c9", "#acbed7"],
# Row 4: Purples H=275° S=28%
["#30213a", "#402c4e", "#503761", "#624378", "#75508f", "#8a62a6", "#9d7cb5", "#b196c4", "#c4b0d2"],
# Row 5: Browns H=28° S=35%
["#3d2c1d", "#523b27", "#674a31", "#7f5c3d", "#976d48", "#af8159", "#bc9675", "#c9ab90", "#d7c0ac"],
# Row 6: Neutral grays
["#000000", "#1f1f1f", "#3f3f3f", "#5f5f5f", "#7f7f7f", "#9f9f9f", "#bfbfbf", "#dfdfdf", "#ffffff"],
]
const PALETTE_COLS := 9
const RECENT_SLOTS := 9
# --- @onready references to .tscn nodes ---
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
@onready var _tab_container: TabContainer = $Layout/TabPanel/TabContainer
@onready var _footer_back: Button = $Footer/BackBtn
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
@onready var _footer_start: Button = $Footer/StartBtn
@onready var _modal_root: Control = $ColorPickerModal
# --- Descriptor and preview state ---
var _descriptor: CharacterVisualDescriptor
var _char_visual: CharacterVisual = null
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
# --- Tab active slot state ---
var _active_clothing_slot: String = "torso"
var _active_accessory_slot: String = "hat"
# --- Body/skin tone buttons shared across Body + Head tabs ---
var _body_type_btns: Dictionary = {} # BodyType int -> Button
var _body_skin_btns: Array[Button] = [] # 9 buttons in Body tab skin dock
var _head_skin_btns: Array[Button] = [] # 9 buttons in Head tab skin dock
# --- Head / hair buttons ---
var _head_item_btns: Dictionary = {} # head_id -> Button
var _hair_item_btns: Dictionary = {} # hair_id -> Button
var _facial_hair_btns: Array[Button] = []
var _eyebrow_btns: Array[Button] = []
# --- Clothing/accessory grid content per slot ---
var _clothing_grids: Dictionary = {} # slot -> GridContainer
var _clothing_slot_btns: Array[Button] = []
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
var _accessory_grids: Dictionary = {} # slot -> GridContainer
var _accessory_slot_btns: Array[Button] = []
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
# --- Color dock swatch references ---
var _hair_primary_swatch: Button = null
var _hair_highlight_swatch: Button = null
var _eyebrow_tint_swatch: Button = null
var _facial_hair_tint_swatch: Button = null
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
# Note: hair highlight is always auto-derived (display-only swatch, no override).
var _eyebrow_tint_auto: bool = true
var _facial_hair_tint_auto: bool = true
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
var _clothing_accent_auto: Dictionary = {} # slot -> bool
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
# --- Color picker modal state ---
var _modal_callback: Callable
var _modal_original_color: Color = Color.WHITE
var _modal_preview_swatch: Button = null # the swatch button being edited
var _modal_hex_input: LineEdit = null
var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches
var _recent_colors: Array[Color] = [] # persisted recent custom colors
var _modal_recent_btns: Array[Button] = []
# --- Dock container references (set during tab build; avoid fragile child-index traversal) ---
var _clothing_dock_container: Control = null
var _accessory_dock_container: Control = null
# --- Cached asset scan results (populated once during tab build, reused by randomize) ---
var _cached_hair_ids: Array = []
var _cached_head_ids: Array = []
# --- Per-tab search text ---
var _tab_search: Array[String] = ["", "", "", "", ""] # one per tab index
## Per-tab grid container for search filtering (index = tab index 04).
## Null for tabs without a primary grid (Body uses HBoxRows; Clothing/Accessories use slot grids).
var _tab_grids: Array[GridContainer] = [null, null, null, null, null]
# =============================================================================
# Lifecycle
# =============================================================================
func _ready() -> void:
_descriptor = CharacterVisualDescriptor.new()
_descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
_descriptor.skin_tone = 2 # light_olive — readable middle-ground default
_descriptor.eyebrow_id = "regular"
_descriptor.hair_id = "bob"
_descriptor.hair_tint = Color(0.55, 0.35, 0.20) # warm brown default
_char_visual = CharacterVisual.new()
_char_anchor.add_child(_char_visual)
_char_visual.load_descriptor(_descriptor)
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
_update_camera_angle()
_rotate_left_btn.pressed.connect(_on_rotate_left)
_rotate_right_btn.pressed.connect(_on_rotate_right)
_cam_angle_btn.pressed.connect(_on_cam_angle_toggle)
_footer_back.pressed.connect(_on_back)
_footer_randomize.pressed.connect(_on_randomize)
_footer_start.pressed.connect(_on_start)
_footer_back.text = UIStrings.get_text("character_creation.btn_back")
_footer_randomize.text = UIStrings.get_text("character_creation.btn_randomize")
_footer_start.text = UIStrings.get_text("character_creation.btn_start")
_rotate_left_btn.text = UIStrings.get_text("character_creation.btn_rotate_left")
_rotate_right_btn.text = UIStrings.get_text("character_creation.btn_rotate_right")
# Create tab shells programmatically (Godot 4.6 TabContainer requires runtime children)
var tab_names: Array[String] = ["Body", "Head", "Hair", "Clothing", "Accessories"]
for tab_name in tab_names:
var tab := Control.new()
tab.name = tab_name
_tab_container.add_child(tab)
_build_body_tab(_tab_container.get_child(0))
_build_head_tab(_tab_container.get_child(1))
_build_hair_tab(_tab_container.get_child(2))
_build_clothing_tab(_tab_container.get_child(3))
_build_accessories_tab(_tab_container.get_child(4))
_build_color_picker_modal()
_modal_root.visible = false
_update_cam_angle_label()
# =============================================================================
# Camera management (D-158)
# =============================================================================
func _update_camera_angle() -> void:
var pitch_deg := CAM_PITCHES[_cam_pitch_idx]
var pitch_rad := deg_to_rad(-pitch_deg) # negative = looking downward
var cam_z := CAM_DISTANCE * cos(pitch_rad)
var cam_y := CAM_TARGET_HEIGHT + CAM_DISTANCE * sin(pitch_rad)
_preview_camera.position = Vector3(0.0, cam_y, cam_z)
_preview_camera.look_at(Vector3(0.0, CAM_TARGET_HEIGHT, 0.0), Vector3.UP)
func _update_cam_angle_label() -> void:
# Label shows the NEXT angle the button will switch to
var next_idx := (_cam_pitch_idx + 1) % CAM_PITCHES.size()
_cam_angle_btn.text = CAM_PITCH_NAMES[next_idx]
func _on_cam_angle_toggle() -> void:
_cam_pitch_idx = (_cam_pitch_idx + 1) % CAM_PITCHES.size()
_update_camera_angle()
_update_cam_angle_label()
# =============================================================================
# Rotation (D-155: cardinal only, Q=CCW, E=CW)
# =============================================================================
func _on_rotate_left() -> void:
_facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size()
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
func _on_rotate_right() -> void:
_facing_idx = (_facing_idx - 1 + CARDINAL_DIRS.size()) % CARDINAL_DIRS.size()
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
# =============================================================================
# Preview refresh
# =============================================================================
func _refresh_preview() -> void:
if _char_visual and is_instance_valid(_char_visual):
_char_visual.load_descriptor(_descriptor)
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
# =============================================================================
# Tab: Body (Task #2)
# =============================================================================
func _build_body_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
var search := _make_search_bar(0)
vbox.add_child(search)
# --- Body type grid ---
var body_label := _make_section_label("Body Type")
vbox.add_child(body_label)
# Female row
var female_label := _make_row_label("F")
vbox.add_child(female_label)
var female_row := HBoxContainer.new()
female_row.add_theme_constant_override("separation", 4)
vbox.add_child(female_row)
for bt: int in BODY_FEMALE_ROW:
var btn := _make_body_type_btn(bt)
female_row.add_child(btn)
_body_type_btns[bt] = btn
# Male row
var male_label := _make_row_label("M")
vbox.add_child(male_label)
var male_row := HBoxContainer.new()
male_row.add_theme_constant_override("separation", 4)
vbox.add_child(male_row)
for bt: int in BODY_MALE_ROW:
var btn := _make_body_type_btn(bt)
male_row.add_child(btn)
_body_type_btns[bt] = btn
# Child row (no gender label — no gendered framing per spec)
var child_row := HBoxContainer.new()
child_row.add_theme_constant_override("separation", 4)
vbox.add_child(child_row)
var child_btn := _make_body_type_btn(CharacterVisualDescriptor.BodyType.CHILD)
child_row.add_child(child_btn)
_body_type_btns[CharacterVisualDescriptor.BodyType.CHILD] = child_btn
# Spacer
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
vbox.add_child(spacer)
# --- Skin tone dock ---
var skin_label := _make_section_label("Skin Tone")
vbox.add_child(skin_label)
var skin_dock := _build_skin_tone_dock()
vbox.add_child(skin_dock)
_body_skin_btns = _skin_btns_from_dock(skin_dock)
_update_body_type_btns()
_update_skin_tone_btns()
func _make_body_type_btn(bt: int) -> Button:
var btn := Button.new()
btn.text = BODY_TYPE_LABELS.get(bt, "?")
btn.custom_minimum_size = Vector2(72, 48)
btn.add_theme_color_override("font_color", TEXT_COLOR)
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
btn.pressed.connect(_on_body_type_selected.bind(bt))
return btn
func _on_body_type_selected(bt: int) -> void:
_descriptor.body_type = bt as CharacterVisualDescriptor.BodyType
_update_body_type_btns()
_refresh_preview()
func _update_body_type_btns() -> void:
for bt: int in _body_type_btns:
_set_item_selected(_body_type_btns[bt], bt == _descriptor.body_type)
# =============================================================================
# Tab: Head (Task #3)
# =============================================================================
func _build_head_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
var search := _make_search_bar(1)
vbox.add_child(search)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
vbox.add_child(scroll)
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 4)
grid.add_theme_constant_override("v_separation", 4)
scroll.add_child(grid)
_tab_grids[1] = grid
_cached_head_ids = _scan_asset_ids("res://assets/characters/heads/templates/", ".glb",
["head_001", "head_002", "head_003", "head_004"])
for hid in _cached_head_ids:
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(96, 80))
btn.pressed.connect(_on_head_selected.bind(hid))
grid.add_child(btn)
_head_item_btns[hid] = btn
# Skin tone dock (shared state with Body tab)
var skin_label := _make_section_label("Skin Tone")
vbox.add_child(skin_label)
var skin_dock := _build_skin_tone_dock()
vbox.add_child(skin_dock)
_head_skin_btns = _skin_btns_from_dock(skin_dock)
_update_head_btns()
_update_skin_tone_btns()
func _on_head_selected(head_id: String) -> void:
_descriptor.head_id = head_id
_update_head_btns()
_refresh_preview()
func _update_head_btns() -> void:
for hid: String in _head_item_btns:
_set_item_selected(_head_item_btns[hid], hid == _descriptor.head_id)
# =============================================================================
# Tab: Hair (Task #4)
# =============================================================================
func _build_hair_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
var search := _make_search_bar(2)
vbox.add_child(search)
# Hair style grid
var hair_label := _make_section_label("Hair Style")
vbox.add_child(hair_label)
var hair_scroll := ScrollContainer.new()
hair_scroll.custom_minimum_size = Vector2(0, 100)
vbox.add_child(hair_scroll)
var hair_grid := GridContainer.new()
hair_grid.columns = 3
hair_grid.add_theme_constant_override("h_separation", 4)
hair_grid.add_theme_constant_override("v_separation", 4)
hair_scroll.add_child(hair_grid)
_tab_grids[2] = hair_grid
_cached_hair_ids = _scan_asset_ids("res://assets/characters/hair/", ".glb",
["bald", "bob", "buzzed", "long"])
for hid in _cached_hair_ids:
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 64))
btn.pressed.connect(_on_hair_selected.bind(hid))
hair_grid.add_child(btn)
_hair_item_btns[hid] = btn
# Facial hair row
var fh_label := _make_section_label("Facial Hair")
vbox.add_child(fh_label)
var fh_row := HBoxContainer.new()
fh_row.add_theme_constant_override("separation", 4)
vbox.add_child(fh_row)
_facial_hair_btns.clear()
for i in FACIAL_HAIR_OPTIONS.size():
var btn := _make_grid_item_btn(FACIAL_HAIR_LABELS[i], Vector2(70, 40))
btn.pressed.connect(_on_facial_hair_selected.bind(FACIAL_HAIR_OPTIONS[i]))
fh_row.add_child(btn)
_facial_hair_btns.append(btn)
# Eyebrow row
var eb_label := _make_section_label("Eyebrows")
vbox.add_child(eb_label)
var eb_row := HBoxContainer.new()
eb_row.add_theme_constant_override("separation", 4)
vbox.add_child(eb_row)
_eyebrow_btns.clear()
for i in EYEBROW_OPTIONS.size():
var btn := _make_grid_item_btn(EYEBROW_LABELS[i], Vector2(70, 40))
btn.pressed.connect(_on_eyebrow_selected.bind(EYEBROW_OPTIONS[i]))
eb_row.add_child(btn)
_eyebrow_btns.append(btn)
# Color dock
var dock := _build_hair_color_dock()
vbox.add_child(dock)
_update_hair_btns()
_update_hair_color_dock()
func _on_hair_selected(hair_id: String) -> void:
_descriptor.hair_id = hair_id
_update_hair_btns()
_update_hair_color_dock()
_refresh_preview()
func _on_facial_hair_selected(fh_id: String) -> void:
_descriptor.facial_hair_id = fh_id
if _facial_hair_tint_auto:
_descriptor.facial_hair_tint = _descriptor.hair_tint
_update_hair_btns()
_update_hair_color_dock()
_refresh_preview()
func _on_eyebrow_selected(eb_id: String) -> void:
_descriptor.eyebrow_id = eb_id
if _eyebrow_tint_auto:
_descriptor.eyebrow_tint = _descriptor.hair_tint
_update_hair_btns()
_update_hair_color_dock()
_refresh_preview()
func _update_hair_btns() -> void:
for hid: String in _hair_item_btns:
_set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id)
for i in FACIAL_HAIR_OPTIONS.size():
_set_item_selected(_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id)
for i in EYEBROW_OPTIONS.size():
_set_item_selected(_eyebrow_btns[i], EYEBROW_OPTIONS[i] == _descriptor.eyebrow_id)
func _build_hair_color_dock() -> Control:
var dock := VBoxContainer.new()
dock.add_theme_constant_override("separation", 4)
var dock_label := _make_section_label("Hair Color")
dock.add_child(dock_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
dock.add_child(row)
_hair_primary_swatch = _make_color_swatch(Color(0.55, 0.35, 0.20), "Primary",
func(c): _on_hair_primary_changed(c))
row.add_child(_hair_primary_swatch)
# Highlight is auto-derived from primary — display only, no click handler
_hair_highlight_swatch = _make_display_swatch(_derive_hair_highlight(_descriptor.hair_tint),
"Highlight ●")
row.add_child(_hair_highlight_swatch)
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
func(c): _on_eyebrow_tint_changed(c))
row.add_child(_eyebrow_tint_swatch)
_facial_hair_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Facial ●",
func(c): _on_facial_hair_tint_changed(c))
row.add_child(_facial_hair_tint_swatch)
return dock
func _update_hair_color_dock() -> void:
if not _hair_primary_swatch:
return
_set_swatch_color(_hair_primary_swatch, _descriptor.hair_tint)
_set_swatch_color(_hair_highlight_swatch, _derive_hair_highlight(_descriptor.hair_tint))
var eb_tint := _descriptor.hair_tint if _eyebrow_tint_auto else _descriptor.eyebrow_tint
_set_swatch_color(_eyebrow_tint_swatch, eb_tint)
var fh_tint := _descriptor.hair_tint if _facial_hair_tint_auto else _descriptor.facial_hair_tint
_set_swatch_color(_facial_hair_tint_swatch, fh_tint)
func _on_hair_primary_changed(color: Color) -> void:
_descriptor.hair_tint = color
if _eyebrow_tint_auto:
_descriptor.eyebrow_tint = color
if _facial_hair_tint_auto:
_descriptor.facial_hair_tint = color
_update_hair_color_dock()
_refresh_preview()
func _on_eyebrow_tint_changed(color: Color) -> void:
_eyebrow_tint_auto = false
_descriptor.eyebrow_tint = color
_update_hair_color_dock()
_refresh_preview()
func _on_facial_hair_tint_changed(color: Color) -> void:
_facial_hair_tint_auto = false
_descriptor.facial_hair_tint = color
_update_hair_color_dock()
_refresh_preview()
# =============================================================================
# Tab: Clothing (Task #5)
# =============================================================================
func _build_clothing_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
# Slot selector
var slot_row := HBoxContainer.new()
slot_row.add_theme_constant_override("separation", 4)
vbox.add_child(slot_row)
_clothing_slot_btns.clear()
for i in CLOTHING_SLOTS.size():
var slot: String = CLOTHING_SLOTS[i]
var btn := _make_slot_btn(CLOTHING_SLOT_LABELS[i])
btn.pressed.connect(_on_clothing_slot_selected.bind(slot))
slot_row.add_child(btn)
_clothing_slot_btns.append(btn)
_clothing_secondary_auto[slot] = true
_clothing_accent_auto[slot] = true
var search := _make_search_bar(3)
vbox.add_child(search)
# Per-slot grids (only the active one is visible)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
vbox.add_child(scroll)
var scroll_vbox := VBoxContainer.new()
scroll.add_child(scroll_vbox)
_clothing_grids.clear()
_clothing_item_btns.clear()
for slot in CLOTHING_SLOTS:
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 4)
grid.add_theme_constant_override("v_separation", 4)
grid.visible = (slot == _active_clothing_slot)
scroll_vbox.add_child(grid)
_clothing_grids[slot] = grid
_clothing_item_btns[slot] = {}
_clothing_primary_swatches[slot] = null
_clothing_secondary_swatches[slot] = null
_clothing_accent_swatches[slot] = null
var ids := _get_clothing_ids_for_slot(slot)
for item_id in ids:
var btn := _make_grid_item_btn(item_id.replace("_", " ").capitalize(), Vector2(88, 64))
btn.pressed.connect(_on_clothing_item_selected.bind(slot, item_id))
grid.add_child(btn)
_clothing_item_btns[slot][item_id] = btn
# Color dock (per slot, rebuilt on slot change)
_clothing_dock_container = Control.new()
_clothing_dock_container.name = "ClothingColorDock"
_clothing_dock_container.custom_minimum_size = Vector2(0, 64)
_clothing_dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(_clothing_dock_container)
# _tab_grids[3] left null: clothing uses per-slot grids (search filters active slot via _clothing_grids)
_rebuild_clothing_color_dock(_clothing_dock_container)
_update_clothing_slot_btns()
_update_clothing_item_btns()
func _on_clothing_slot_selected(slot: String) -> void:
_active_clothing_slot = slot
for s in _clothing_grids:
_clothing_grids[s].visible = (s == slot)
_update_clothing_slot_btns()
# Rebuild color dock for newly selected slot
if _clothing_dock_container:
_rebuild_clothing_color_dock(_clothing_dock_container)
func _rebuild_clothing_color_dock(container: Control) -> void:
for child in container.get_children():
child.queue_free()
var row := HBoxContainer.new()
row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
row.add_theme_constant_override("separation", 8)
container.add_child(row)
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
var tints: Array = _descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
var p_swatch := _make_color_swatch(primary, "Primary",
func(c): _on_clothing_primary_changed(c))
row.add_child(p_swatch)
_clothing_primary_swatches[_active_clothing_slot] = p_swatch
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
func(c): _on_clothing_secondary_changed(c))
row.add_child(s_swatch)
_clothing_secondary_swatches[_active_clothing_slot] = s_swatch
var a_swatch := _make_color_swatch(accent, "Accent ●",
func(c): _on_clothing_accent_changed(c))
row.add_child(a_swatch)
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
func _on_clothing_item_selected(slot: String, item_id: String) -> void:
_descriptor.clothing_slots[slot] = item_id
# Reset tints to defaults for this item
_descriptor.clothing_tints[item_id] = [Color(0.7, 0.65, 0.6)]
_clothing_secondary_auto[slot] = true
_clothing_accent_auto[slot] = true
_update_clothing_item_btns()
# Rebuild color dock to show new item's defaults
if _clothing_dock_container:
_rebuild_clothing_color_dock(_clothing_dock_container)
_refresh_preview()
func _on_clothing_primary_changed(color: Color) -> void:
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
if item_id.is_empty():
return
var tints: Array = _descriptor.clothing_tints.get(item_id, []) as Array
if tints.is_empty():
tints = [color]
else:
tints[0] = color
_descriptor.clothing_tints[item_id] = tints
if _clothing_secondary_auto.get(_active_clothing_slot, true):
_set_swatch_color(_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color))
_refresh_preview()
func _on_clothing_secondary_changed(color: Color) -> void:
_clothing_secondary_auto[_active_clothing_slot] = false
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
if not item_id.is_empty():
var tints: Array = _descriptor.clothing_tints.get(item_id, [Color(0.7, 0.65, 0.6)]) as Array
while tints.size() < 2:
tints.append(color)
tints[1] = color
_descriptor.clothing_tints[item_id] = tints
_refresh_preview()
func _on_clothing_accent_changed(color: Color) -> void:
_clothing_accent_auto[_active_clothing_slot] = false
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
if not item_id.is_empty():
var tints: Array = _descriptor.clothing_tints.get(item_id, [Color(0.7, 0.65, 0.6)]) as Array
while tints.size() < 3:
tints.append(color)
tints[2] = color
_descriptor.clothing_tints[item_id] = tints
_refresh_preview()
func _update_clothing_slot_btns() -> void:
for i in CLOTHING_SLOTS.size():
var selected: bool = CLOTHING_SLOTS[i] == _active_clothing_slot
_set_item_selected(_clothing_slot_btns[i], selected)
func _update_clothing_item_btns() -> void:
for slot: String in _clothing_item_btns:
var active_item: String = str(_descriptor.clothing_slots.get(slot, ""))
for item_id in _clothing_item_btns[slot]:
_set_item_selected(_clothing_item_btns[slot][item_id], item_id == active_item)
# =============================================================================
# Tab: Accessories (Task #6)
# =============================================================================
func _build_accessories_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
# Slot selector (wrapping row — many slots)
var slot_flow := HFlowContainer.new()
slot_flow.add_theme_constant_override("h_separation", 4)
slot_flow.add_theme_constant_override("v_separation", 4)
vbox.add_child(slot_flow)
_accessory_slot_btns.clear()
for i in ACCESSORY_SLOTS.size():
var slot: String = ACCESSORY_SLOTS[i]
var btn := _make_slot_btn(ACCESSORY_SLOT_LABELS[i])
btn.pressed.connect(_on_accessory_slot_selected.bind(slot))
slot_flow.add_child(btn)
_accessory_slot_btns.append(btn)
_accessory_secondary_auto[slot] = true
var search := _make_search_bar(4)
vbox.add_child(search)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
vbox.add_child(scroll)
var scroll_vbox := VBoxContainer.new()
scroll.add_child(scroll_vbox)
_accessory_grids.clear()
_accessory_item_btns.clear()
for slot in ACCESSORY_SLOTS:
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 4)
grid.add_theme_constant_override("v_separation", 4)
grid.visible = (slot == _active_accessory_slot)
scroll_vbox.add_child(grid)
_accessory_grids[slot] = grid
_accessory_item_btns[slot] = {}
_accessory_primary_swatches[slot] = null
_accessory_secondary_swatches[slot] = null
var ids := _get_accessory_ids_for_slot(slot)
for item_id in ids:
var btn := _make_grid_item_btn(item_id.replace("_", " ").capitalize(), Vector2(88, 64))
btn.pressed.connect(_on_accessory_item_selected.bind(slot, item_id))
grid.add_child(btn)
_accessory_item_btns[slot][item_id] = btn
_accessory_dock_container = Control.new()
_accessory_dock_container.name = "AccessoryColorDock"
_accessory_dock_container.custom_minimum_size = Vector2(0, 56)
_accessory_dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(_accessory_dock_container)
# _tab_grids[4] left null: accessories uses per-slot grids (search filters active slot via _accessory_grids)
_rebuild_accessory_color_dock(_accessory_dock_container)
_update_accessory_slot_btns()
_update_accessory_item_btns()
func _on_accessory_slot_selected(slot: String) -> void:
_active_accessory_slot = slot
for s in _accessory_grids:
_accessory_grids[s].visible = (s == slot)
_update_accessory_slot_btns()
if _accessory_dock_container:
_rebuild_accessory_color_dock(_accessory_dock_container)
func _rebuild_accessory_color_dock(container: Control) -> void:
for child in container.get_children():
child.queue_free()
var row := HBoxContainer.new()
row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
row.add_theme_constant_override("separation", 8)
container.add_child(row)
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
var tints_arr: Array = _descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
var primary: Color = tints_arr[0] as Color if not tints_arr.is_empty() else Color.WHITE
var secondary: Color = _derive_secondary(primary)
var p_swatch := _make_color_swatch(primary, "Primary",
func(c): _on_accessory_primary_changed(c))
row.add_child(p_swatch)
_accessory_primary_swatches[_active_accessory_slot] = p_swatch
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
func(c): _on_accessory_secondary_changed(c))
row.add_child(s_swatch)
_accessory_secondary_swatches[_active_accessory_slot] = s_swatch
func _on_accessory_item_selected(slot: String, item_id: String) -> void:
_descriptor.accessory_slots[slot] = item_id
_descriptor.accessory_tints[item_id] = [Color.WHITE] # Array[Color]: Primary only to start
_accessory_secondary_auto[slot] = true
_update_accessory_item_btns()
if _accessory_dock_container:
_rebuild_accessory_color_dock(_accessory_dock_container)
_refresh_preview()
func _on_accessory_primary_changed(color: Color) -> void:
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
if not item_id.is_empty():
var existing: Array = _descriptor.accessory_tints.get(item_id, []) as Array
if existing.is_empty():
existing = [color]
else:
existing[0] = color
_descriptor.accessory_tints[item_id] = existing
_refresh_preview()
func _on_accessory_secondary_changed(color: Color) -> void:
_accessory_secondary_auto[_active_accessory_slot] = false
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
if not item_id.is_empty():
var tints: Array = _descriptor.accessory_tints.get(item_id, [Color.WHITE]) as Array
while tints.size() < 2:
tints.append(color)
tints[1] = color
_descriptor.accessory_tints[item_id] = tints
_refresh_preview()
func _update_accessory_slot_btns() -> void:
for i in ACCESSORY_SLOTS.size():
_set_item_selected(_accessory_slot_btns[i], ACCESSORY_SLOTS[i] == _active_accessory_slot)
func _update_accessory_item_btns() -> void:
for slot: String in _accessory_item_btns:
var active_item: String = str(_descriptor.accessory_slots.get(slot, ""))
for item_id in _accessory_item_btns[slot]:
_set_item_selected(_accessory_item_btns[slot][item_id], item_id == active_item)
# =============================================================================
# Color picker modal (Task #7) — D-165 palette
# =============================================================================
func _build_color_picker_modal() -> void:
var modal := _modal_root
var bg := ColorRect.new()
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.0, 0.0, 0.0, 0.7)
bg.mouse_filter = Control.MOUSE_FILTER_STOP
modal.add_child(bg)
var box := VBoxContainer.new()
box.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
box.custom_minimum_size = Vector2(360, 420)
box.position = Vector2(-180, -210)
box.add_theme_constant_override("separation", 8)
modal.add_child(box)
var box_bg := ColorRect.new()
box_bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
box_bg.color = PANEL_BG
box_bg.z_index = -1
box.add_child(box_bg)
var title := Label.new()
title.text = "Color"
title.add_theme_color_override("font_color", TEXT_COLOR)
title.add_theme_font_size_override("font_size", 14)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title)
# 54-swatch palette: 5 chromatic rows (9 each) + 1 neutral gray row
var palette_grid := GridContainer.new()
palette_grid.columns = PALETTE_COLS
palette_grid.add_theme_constant_override("h_separation", 3)
palette_grid.add_theme_constant_override("v_separation", 3)
box.add_child(palette_grid)
_modal_swatch_btns.clear()
# 6 rows × 9 cols per D-165 (5 chromatic + 1 neutral gray)
for row_hexes: Array in PALETTE_COLORS:
for hex: String in row_hexes:
var swatch := _make_palette_swatch(Color(hex))
palette_grid.add_child(swatch)
_modal_swatch_btns.append(swatch)
# Hex input row
var hex_row := HBoxContainer.new()
hex_row.add_theme_constant_override("separation", 4)
box.add_child(hex_row)
var hex_label := Label.new()
hex_label.text = "#"
hex_label.add_theme_color_override("font_color", TEXT_DIM)
hex_label.add_theme_font_size_override("font_size", 12)
hex_row.add_child(hex_label)
_modal_hex_input = LineEdit.new()
_modal_hex_input.placeholder_text = "RRGGBB"
_modal_hex_input.max_length = 6
_modal_hex_input.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND
_modal_hex_input.add_theme_color_override("font_color", TEXT_COLOR)
_modal_hex_input.add_theme_font_size_override("font_size", 12)
hex_row.add_child(_modal_hex_input)
var hex_apply := Button.new()
hex_apply.text = "Apply"
hex_apply.add_theme_color_override("font_color", HIGHLIGHT_COLOR)
hex_apply.pressed.connect(_on_modal_hex_apply)
hex_row.add_child(hex_apply)
# Recent colors
var recent_label := Label.new()
recent_label.text = "Recent"
recent_label.add_theme_color_override("font_color", TEXT_DIM)
recent_label.add_theme_font_size_override("font_size", 11)
box.add_child(recent_label)
var recent_row := HBoxContainer.new()
recent_row.add_theme_constant_override("separation", 3)
box.add_child(recent_row)
_modal_recent_btns.clear()
for _i in RECENT_SLOTS:
var s := _make_palette_swatch(Color(0.15, 0.15, 0.2))
recent_row.add_child(s)
_modal_recent_btns.append(s)
# Cancel / OK
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 8)
btn_row.alignment = BoxContainer.ALIGNMENT_END
box.add_child(btn_row)
var cancel_btn := Button.new()
cancel_btn.text = "Cancel"
cancel_btn.add_theme_color_override("font_color", TEXT_DIM)
cancel_btn.pressed.connect(_on_modal_cancel)
btn_row.add_child(cancel_btn)
var ok_btn := Button.new()
ok_btn.text = "OK"
ok_btn.add_theme_color_override("font_color", ACTIVE_COLOR)
ok_btn.pressed.connect(_on_modal_ok)
btn_row.add_child(ok_btn)
func _make_palette_swatch(color: Color) -> Button:
var btn := Button.new()
btn.custom_minimum_size = Vector2(28, 24)
btn.flat = true
var r := ColorRect.new()
r.color = color
r.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
r.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(r)
btn.pressed.connect(_on_modal_palette_swatch_pressed.bind(color, btn))
return btn
func _open_color_picker(current_color: Color, swatch_btn: Button, callback: Callable) -> void:
_modal_callback = callback
_modal_original_color = current_color
_modal_preview_swatch = swatch_btn
if _modal_hex_input:
_modal_hex_input.text = current_color.to_html(false).to_upper()
_update_modal_recent_btns()
_modal_root.visible = true
func _on_modal_palette_swatch_pressed(color: Color, _swatch: Button) -> void:
# Live preview on 3D character before OK
if _modal_preview_swatch:
_set_swatch_color(_modal_preview_swatch, color)
_modal_callback.call(color)
if _modal_hex_input:
_modal_hex_input.text = color.to_html(false).to_upper()
func _on_modal_hex_apply() -> void:
if not _modal_hex_input:
return
var hex := _modal_hex_input.text.strip_edges()
if hex.length() == 6 and hex.is_valid_html_color():
var color := Color("#" + hex)
_modal_callback.call(color)
if _modal_preview_swatch:
_set_swatch_color(_modal_preview_swatch, color)
func _on_modal_cancel() -> void:
# Revert to original color
_modal_callback.call(_modal_original_color)
if _modal_preview_swatch:
_set_swatch_color(_modal_preview_swatch, _modal_original_color)
_modal_root.visible = false
func _on_modal_ok() -> void:
# Add current preview color to recent list
if _modal_preview_swatch:
_push_recent_color(_get_swatch_color(_modal_preview_swatch))
_modal_root.visible = false
func _push_recent_color(color: Color) -> void:
if _recent_colors.has(color):
return
_recent_colors.insert(0, color)
if _recent_colors.size() > RECENT_SLOTS:
_recent_colors.resize(RECENT_SLOTS)
_update_modal_recent_btns()
func _update_modal_recent_btns() -> void:
for i in _modal_recent_btns.size():
var btn := _modal_recent_btns[i]
if i < _recent_colors.size():
var color := _recent_colors[i]
var r := btn.get_child(0) as ColorRect
if r:
r.color = color
for conn in btn.pressed.get_connections():
btn.pressed.disconnect(conn.callable)
btn.pressed.connect(_on_modal_palette_swatch_pressed.bind(color, btn))
# =============================================================================
# Input handling (Task #8 keyboard nav)
# =============================================================================
func _input(event: InputEvent) -> void:
if not visible:
return
if not event is InputEventKey or not event.pressed or event.is_echo():
return
var key := event as InputEventKey
# Color picker modal captures all input when open
if _modal_root.visible:
if key.keycode == KEY_ESCAPE:
_on_modal_cancel()
get_viewport().set_input_as_handled()
elif key.keycode == KEY_ENTER or key.keycode == KEY_KP_ENTER:
_on_modal_ok()
get_viewport().set_input_as_handled()
return
match key.keycode:
KEY_Q:
_on_rotate_left()
get_viewport().set_input_as_handled()
KEY_E:
_on_rotate_right()
get_viewport().set_input_as_handled()
KEY_R:
_on_randomize()
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER:
_on_start()
get_viewport().set_input_as_handled()
KEY_ESCAPE:
_on_back()
get_viewport().set_input_as_handled()
KEY_TAB:
var count := _tab_container.get_tab_count()
if key.shift_pressed:
_tab_container.current_tab = ((_tab_container.current_tab - 1) % count + count) % count
else:
_tab_container.current_tab = (_tab_container.current_tab + 1) % count
get_viewport().set_input_as_handled()
# =============================================================================
# Game flow (Task #8)
# =============================================================================
func _on_back() -> void:
creation_cancelled.emit()
func _on_start() -> void:
creation_confirmed.emit(_descriptor)
# =============================================================================
# Randomize
# =============================================================================
func _on_randomize() -> void:
var all_types := CharacterVisualDescriptor.BodyType.values()
_descriptor.body_type = all_types[randi() % all_types.size()]
_descriptor.skin_tone = randi() % CharacterVisual.SKIN_TONES.size()
# Pick random head and hair from cached scan results (populated during tab build)
if not _cached_head_ids.is_empty():
_descriptor.head_id = _cached_head_ids[randi() % _cached_head_ids.size()]
if not _cached_hair_ids.is_empty():
_descriptor.hair_id = _cached_hair_ids[randi() % _cached_hair_ids.size()]
_descriptor.hair_tint = Color.from_hsv(randf(), 0.4 + randf() * 0.3, 0.4 + randf() * 0.4)
# Random facial hair and eyebrows
_descriptor.facial_hair_id = FACIAL_HAIR_OPTIONS[randi() % FACIAL_HAIR_OPTIONS.size()]
_descriptor.eyebrow_id = EYEBROW_OPTIONS[randi() % EYEBROW_OPTIONS.size()]
# Sync auto tints
_eyebrow_tint_auto = true
_facial_hair_tint_auto = true
_descriptor.eyebrow_tint = _descriptor.hair_tint
_descriptor.facial_hair_tint = _descriptor.hair_tint
_update_body_type_btns()
_update_skin_tone_btns()
_update_hair_btns()
_update_hair_color_dock()
_update_clothing_item_btns()
_update_accessory_item_btns()
_refresh_preview()
# =============================================================================
# Shared helpers — skin tone dock
# =============================================================================
func _build_skin_tone_dock() -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
for i in CharacterVisual.SKIN_TONES.size():
var tone: Dictionary = CharacterVisual.SKIN_TONES[i]
var btn := Button.new()
btn.custom_minimum_size = Vector2(28, 28)
btn.flat = true
var r := ColorRect.new()
r.color = tone["lit"]
r.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
r.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(r)
btn.pressed.connect(_on_skin_tone_selected.bind(i))
row.add_child(btn)
return row
func _skin_btns_from_dock(dock: HBoxContainer) -> Array[Button]:
var result: Array[Button] = []
for child in dock.get_children():
if child is Button:
result.append(child as Button)
return result
func _on_skin_tone_selected(idx: int) -> void:
_descriptor.skin_tone = idx
_update_skin_tone_btns()
_refresh_preview()
func _update_skin_tone_btns() -> void:
var idx := _descriptor.skin_tone
for i in _body_skin_btns.size():
_set_item_selected(_body_skin_btns[i], i == idx)
for i in _head_skin_btns.size():
_set_item_selected(_head_skin_btns[i], i == idx)
# =============================================================================
# Shared helpers — color swatches
# =============================================================================
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
func _get_swatch_color(btn: Button) -> Color:
for child in btn.get_children():
if child is VBoxContainer:
for sub in (child as VBoxContainer).get_children():
if sub is ColorRect:
return (sub as ColorRect).color
if child is ColorRect:
return (child as ColorRect).color
return Color.WHITE
func _make_color_swatch(color: Color, label_text: String, callback: Callable) -> Button:
var btn := Button.new()
btn.custom_minimum_size = Vector2(56, 44)
btn.flat = false
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 2)
btn.add_child(vbox)
var swatch_rect := ColorRect.new()
swatch_rect.color = color
swatch_rect.custom_minimum_size = Vector2(0, 24)
swatch_rect.size_flags_horizontal = Control.SIZE_FILL
swatch_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(swatch_rect)
if not label_text.is_empty():
var lbl := Label.new()
lbl.text = label_text.replace(" ●", "")
lbl.add_theme_font_size_override("font_size", 9)
lbl.add_theme_color_override("font_color", TEXT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.clip_text = true
vbox.add_child(lbl)
# Use _get_swatch_color to read current color at click time (avoids stale closure).
btn.pressed.connect(func():
_open_color_picker(_get_swatch_color(btn), btn, callback)
)
return btn
## Build a non-interactive display swatch (auto-derived values — no click handler).
func _make_display_swatch(color: Color, label_text: String) -> Button:
var btn := Button.new()
btn.custom_minimum_size = Vector2(56, 44)
btn.flat = false
btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.focus_mode = Control.FOCUS_NONE
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 2)
btn.add_child(vbox)
var swatch_rect := ColorRect.new()
swatch_rect.color = color
swatch_rect.custom_minimum_size = Vector2(0, 24)
swatch_rect.size_flags_horizontal = Control.SIZE_FILL
swatch_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(swatch_rect)
if not label_text.is_empty():
var lbl := Label.new()
lbl.text = label_text.replace(" ●", "")
lbl.add_theme_font_size_override("font_size", 9)
lbl.add_theme_color_override("font_color", TEXT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.clip_text = true
vbox.add_child(lbl)
return btn
func _set_swatch_color(btn: Button, color: Color) -> void:
if not btn:
return
for child in btn.get_children():
if child is VBoxContainer:
for sub in (child as VBoxContainer).get_children():
if sub is ColorRect:
(sub as ColorRect).color = color
return
if child is ColorRect:
(child as ColorRect).color = color
return
# =============================================================================
# Shared helpers — layout builders
# =============================================================================
func _make_tab_vbox(tab: Control) -> VBoxContainer:
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 6)
tab.add_child(vbox)
return vbox
func _make_search_bar(tab_idx: int) -> LineEdit:
var search := LineEdit.new()
search.placeholder_text = UIStrings.get_text("character_creation.search_placeholder")
search.add_theme_color_override("font_color", TEXT_COLOR)
search.add_theme_font_size_override("font_size", 12)
search.text_changed.connect(func(q: String):
_tab_search[tab_idx] = q
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
_apply_search_filter(_tab_grids[tab_idx], q)
)
return search
func _make_section_label(text: String) -> Label:
var lbl := Label.new()
lbl.text = text.to_upper()
lbl.add_theme_color_override("font_color", TEXT_DIM)
lbl.add_theme_font_size_override("font_size", 10)
return lbl
func _make_row_label(text: String) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.add_theme_color_override("font_color", TEXT_DIM)
lbl.add_theme_font_size_override("font_size", 11)
return lbl
func _make_slot_btn(label: String) -> Button:
var btn := Button.new()
btn.text = label
btn.custom_minimum_size = Vector2(60, 28)
btn.add_theme_font_size_override("font_size", 11)
btn.add_theme_color_override("font_color", TEXT_COLOR)
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
return btn
func _make_grid_item_btn(label: String, min_size: Vector2) -> Button:
var btn := Button.new()
btn.text = label
btn.custom_minimum_size = min_size
btn.add_theme_font_size_override("font_size", 11)
btn.add_theme_color_override("font_color", TEXT_COLOR)
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
return btn
func _set_item_selected(btn: Button, selected: bool) -> void:
if not btn:
return
if selected:
btn.add_theme_color_override("font_color", HIGHLIGHT_COLOR)
btn.add_theme_stylebox_override("normal", _make_selected_stylebox())
else:
btn.add_theme_color_override("font_color", TEXT_COLOR)
btn.remove_theme_stylebox_override("normal")
func _make_selected_stylebox() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = ITEM_SELECTED_BG
style.border_color = ITEM_SELECTED_BORDER
style.border_width_left = 1
style.border_width_right = 1
style.border_width_top = 1
style.border_width_bottom = 1
return style
func _apply_search_filter(grid: GridContainer, query: String) -> void:
if not grid:
return
var lower_q := query.to_lower()
for child in grid.get_children():
if child is Button:
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
# =============================================================================
# Asset scanning helpers
# =============================================================================
## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/).
## Returns fallback list if the directory is absent or empty.
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
static func _scan_subdirs(dir_path: String, fallback: Array) -> Array:
var dir := DirAccess.open(dir_path)
if dir == null:
return fallback
var ids: Array = []
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
ids.append(name)
name = dir.get_next()
dir.list_dir_end()
return ids if not ids.is_empty() else fallback
## Scan a directory for .glb asset IDs. Returns fallback list if directory absent.
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array:
var dir := DirAccess.open(dir_path)
if dir == null:
return fallback
var ids: Array = []
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if not dir.current_is_dir() and name.ends_with(ext):
ids.append(name.get_basename())
name = dir.get_next()
dir.list_dir_end()
return ids if not ids.is_empty() else fallback
func _get_clothing_ids_for_slot(slot: String) -> Array:
# Clothing items are directories: clothing/{item_id}/{body_type}.glb
# Slot assignment uses item name prefixes (convention, not a metadata field).
var all_ids := _scan_subdirs("res://assets/characters/clothing/",
["coveralls_basic", "jacket_basic", "shirt_basic", "pants_basic", "boots_basic", "gloves_basic"])
match slot:
"torso": return all_ids.filter(func(id: String) -> bool: return id.begins_with("coverall") or id.begins_with("jacket") or id.begins_with("shirt") or id.begins_with("dress") or id.begins_with("uniform"))
"legs": return all_ids.filter(func(id: String) -> bool: return id.begins_with("pants") or id.begins_with("skirt"))
"feet": return all_ids.filter(func(id: String) -> bool: return id.begins_with("boot") or id.begins_with("shoe"))
"hands": return all_ids.filter(func(id: String) -> bool: return id.begins_with("glove"))
_: return all_ids
func _get_accessory_ids_for_slot(slot: String) -> Array:
# Accessory items are directories: accessories/{item_id}/{body_type}.glb
# Slot assignment uses item name prefixes (convention, not a metadata field).
var all_ids := _scan_subdirs("res://assets/characters/accessories/", [])
match slot:
"hat": return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
"goggles": return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
"mask": return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
"backpack": return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
"belt": return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
_: return all_ids
# =============================================================================
# Color derivation helpers
# =============================================================================
func _derive_hair_highlight(primary: Color) -> Color:
return primary.lightened(0.3)
func _derive_secondary(primary: Color) -> Color:
return primary.darkened(0.25)
func _derive_accent(primary: Color) -> Color:
return Color.from_hsv(fposmod(primary.h + 0.1, 1.0), primary.s * 0.5, primary.v)