# gdlint:disable=max-file-lines 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 = 3.8 const CAM_TARGET_HEIGHT: float = 0.85 # --- 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 const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail) const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level) const CAM_ZOOM_STEP: float = 0.12 const MANIFEST_PATH := "res://assets/characters/manifest.json" const SCREENSHOT_DIR := "user://screenshots/" const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"] # --- 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°) var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed # --- 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 0–4). ## 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] # --- Asset manifest (loaded once, replaces filesystem scanning) --- var _manifest: Dictionary = {} # --- Debug tab state --- var _debug_toggles: Dictionary = {} # seg_name -> CheckButton # --- Screenshot / automated testing state --- var _screenshot_delay_frames: int = 5 # wait N frames for scene to render var _screenshot_pending: bool = false var _screenshot_frame_count: int = 0 var _quit_after_screenshot: bool = false var _screenshot_cardinals: bool = false var _screenshot_cardinal_idx: int = 0 # resolved in _ready() — @onready path fails when instantiated as child var _tab_container: TabContainer = null # --- @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 _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 # ============================================================================= # Lifecycle # ============================================================================= func _load_manifest() -> void: var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ) if file == null: push_warning( "CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH ) _manifest = {} return var parsed: Variant = JSON.parse_string(file.get_as_text()) file.close() if parsed is Dictionary: _manifest = parsed as Dictionary else: push_warning("CharacterCreation: manifest parse failed — using empty defaults") _manifest = {} func _manifest_array(key: String) -> Array: if _manifest.has(key): return _manifest[key] as Array return [] func _ready() -> void: _load_manifest() # Build tab panel programmatically — Godot 4.6 destroys TabContainer children # defined in .tscn when the scene is instantiated as a child of another scene. var layout: HBoxContainer = get_node_or_null("Layout") as HBoxContainer if layout == null: push_error("CharacterCreation: Layout node not found") return var tab_panel := VBoxContainer.new() tab_panel.name = "TabPanel" tab_panel.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND tab_panel.size_flags_stretch_ratio = 2.0 tab_panel.add_theme_constant_override("separation", 0) layout.add_child(tab_panel) _tab_container = TabContainer.new() _tab_container.name = "TabContainer" _tab_container.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND _tab_container.add_theme_font_size_override("font_size", 12) _tab_container.add_theme_color_override("font_color", Color(0.784, 0.816, 0.878, 1.0)) tab_panel.add_child(_tab_container) # Build tabs dynamically — only show tabs that have content in the manifest var tab_builders: Array[Dictionary] = [] tab_builders.append({"name": "Body", "build": _build_body_tab, "always": true}) var has_heads := not _manifest_array("heads").is_empty() if has_heads: tab_builders.append({"name": "Head", "build": _build_head_tab, "always": false}) var has_hair := not _manifest_array("hair").is_empty() if has_hair: tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false}) var clothing_data: Variant = _manifest.get("clothing", {}) var has_clothing: bool = ( clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty() ) if has_clothing: tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false}) var has_accessories := not _manifest_array("accessories").is_empty() if has_accessories: tab_builders.append( {"name": "Accessories", "build": _build_accessories_tab, "always": false} ) tab_builders.append({"name": "Debug", "build": _build_debug_tab, "always": true}) for tb in tab_builders: var tab := Control.new() tab.name = tb["name"] _tab_container.add_child(tab) _descriptor = CharacterVisualDescriptor.new() _descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M _descriptor.skin_tone = 2 # light_olive — readable middle-ground default _descriptor.eyebrow_id = "" # eyebrows from body segment, not separate asset _descriptor.hair_id = "bob" _descriptor.hair_tint = Color(0.55, 0.35, 0.20) # warm brown default _descriptor.eyebrow_tint = _descriptor.hair_tint _descriptor.facial_hair_tint = _descriptor.hair_tint _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() # Auto-capture screenshot when running standalone (not via MainMenu) if OS.has_feature("standalone") or get_parent() == get_tree().root: _load_test_config() # apply test config if --test-config passed _schedule_screenshot() _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") for i in tab_builders.size(): var builder: Callable = tab_builders[i]["build"] builder.call(_tab_container.get_child(i)) _build_color_picker_modal() _modal_root.visible = false _update_facial_hair_visibility() _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 dist := CAM_DISTANCE * _cam_zoom var cam_z := dist * cos(pitch_rad) var cam_y := CAM_TARGET_HEIGHT + dist * sin(pitch_rad) _preview_camera.position = Vector3(0.0, cam_y, cam_z) + _cam_zoom_offset var look_target := Vector3(0.0, CAM_TARGET_HEIGHT, 0.0) + _cam_zoom_offset _preview_camera.look_at(look_target, Vector3.UP) func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void: _cam_zoom = clampf(_cam_zoom + zoom_delta, CAM_ZOOM_MIN, CAM_ZOOM_MAX) if _cam_zoom >= 1.0: # At or beyond default — look at body center _cam_zoom_offset = Vector3.ZERO else: # Zoomed in — shift look target up toward head bone if _char_visual and _char_visual._skeleton: var head_idx := _char_visual._skeleton.find_bone("Head") if head_idx >= 0: var head_pos := ( _char_visual._skeleton.global_transform * _char_visual._skeleton.get_bone_global_pose(head_idx).origin ) # Blend from body center toward head as zoom increases var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom _cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend _update_camera_angle() 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) var available_types: Array = _manifest_array("body_types") for bt: int in BODY_FEMALE_ROW: var key: String = CharacterVisualDescriptor.BODY_TYPE_KEYS.get(bt, "") if not available_types.is_empty() and key not in available_types: continue 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 key: String = CharacterVisualDescriptor.BODY_TYPE_KEYS.get(bt, "") if not available_types.is_empty() and key not in available_types: continue 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 show_child: bool = available_types.is_empty() or "child" in available_types if show_child: 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) # --- Eye color --- var eye_label := _make_section_label("Eye Color") vbox.add_child(eye_label) var eye_row := HBoxContainer.new() eye_row.add_theme_constant_override("separation", 8) vbox.add_child(eye_row) var eye_swatch := _make_color_swatch( _descriptor.eye_color, "Iris", func(c: Color) -> void: _descriptor.eye_color = c _refresh_preview() ) eye_row.add_child(eye_swatch) _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 # Clear facial hair if switching to a body type that doesn't support it var key: String = _descriptor.body_type_key() if key.ends_with("_f") or key == "child" or key.begins_with("teen"): _descriptor.facial_hair_id = "" _update_body_type_btns() _update_facial_hair_visibility() _update_hair_btns() _refresh_preview() func _update_facial_hair_visibility() -> void: var key: String = _descriptor.body_type_key() var show_fh: bool = not key.ends_with("_f") and key != "child" and not key.begins_with("teen") var fh_label: Control = find_child("FacialHairLabel", true, false) var fh_row: Control = find_child("FacialHairRow", true, false) if fh_label: fh_label.visible = show_fh if fh_row: fh_row.visible = show_fh 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 = _manifest_array("heads") 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) # Eye color (shared with Body tab) var eye_label := _make_section_label("Eye Color") vbox.add_child(eye_label) var eye_row := HBoxContainer.new() eye_row.add_theme_constant_override("separation", 8) vbox.add_child(eye_row) var eye_swatch := _make_color_swatch( _descriptor.eye_color, "Iris", func(c: Color) -> void: _descriptor.eye_color = c _refresh_preview() ) eye_row.add_child(eye_swatch) _update_head_btns() _update_skin_tone_btns() func _on_head_selected(head_id: String) -> void: _descriptor.head_id = "" if _descriptor.head_id == head_id else 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 — takes 50% of available space var hair_label := _make_section_label("Hair Style") vbox.add_child(hair_label) var hair_grid := HFlowContainer.new() hair_grid.add_theme_constant_override("h_separation", 4) hair_grid.add_theme_constant_override("v_separation", 4) vbox.add_child(hair_grid) _cached_hair_ids = _manifest_array("hair") for hid in _cached_hair_ids: var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 50)) btn.pressed.connect(_on_hair_selected.bind(hid)) hair_grid.add_child(btn) _hair_item_btns[hid] = btn # Facial hair — takes remaining 50% (hidden for female/child/teen) var fh_label := _make_section_label("Facial Hair") fh_label.name = "FacialHairLabel" vbox.add_child(fh_label) var fh_row := HFlowContainer.new() fh_row.name = "FacialHairRow" fh_row.add_theme_constant_override("h_separation", 4) fh_row.add_theme_constant_override("v_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) # Spacer pushes color dock to bottom var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND vbox.add_child(spacer) # Color dock — docked at bottom (consistent with Body and Clothing tabs) 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 = "" if _descriptor.hair_id == hair_id else hair_id _update_hair_btns() _update_hair_color_dock() _refresh_preview() func _on_facial_hair_selected(fh_id: String) -> void: _descriptor.facial_hair_id = "" if _descriptor.facial_hair_id == fh_id else 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 = "" if _descriptor.eyebrow_id == eb_id else 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 ) # Eyebrow buttons removed — eyebrows come from body segment only 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) # #719 (Option B): highlight is auto-derived from primary — display-only, not editable. _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() var first_visible_slot: String = "" for i in CLOTHING_SLOTS.size(): var slot: String = CLOTHING_SLOTS[i] var ids := _get_clothing_ids_for_slot(slot) if ids.is_empty(): continue # skip empty slots if first_visible_slot.is_empty(): first_visible_slot = slot 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 if not first_visible_slot.is_empty(): _active_clothing_slot = first_visible_slot 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 _rebuild_clothing_slot_grids() -> void: # Repopulate clothing grids for current body type (items change per gender) for slot: String in _clothing_grids: var grid: GridContainer = _clothing_grids[slot] for child in grid.get_children(): child.queue_free() _clothing_item_btns[slot] = {} 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 _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: var current: String = str(_descriptor.clothing_slots.get(slot, "")) if current == item_id: # Toggle off — remove from slot _descriptor.clothing_slots.erase(slot) _descriptor.clothing_tints.erase(item_id) else: _descriptor.clothing_slots[slot] = item_id _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: # Slot buttons may be fewer than CLOTHING_SLOTS (empty slots are hidden) for btn in _clothing_slot_btns: # The slot name is stored in the button's pressed signal bindings — # match by checking if the button text matches the active slot label var is_active := false var idx := CLOTHING_SLOT_LABELS.find(btn.text) if idx >= 0 and idx < CLOTHING_SLOTS.size(): is_active = CLOTHING_SLOTS[idx] == _active_clothing_slot _set_item_selected(btn, is_active) 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: var current: String = str(_descriptor.accessory_slots.get(slot, "")) if current == item_id: _descriptor.accessory_slots.erase(slot) _descriptor.accessory_tints.erase(item_id) else: _descriptor.accessory_slots[slot] = item_id _descriptor.accessory_tints[item_id] = [Color.WHITE] _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) # ============================================================================= # ============================================================================= # Screenshot & automated testing # ============================================================================= func _schedule_screenshot() -> void: _screenshot_pending = true _screenshot_frame_count = 0 func _process(_delta: float) -> void: if _screenshot_pending: _screenshot_frame_count += 1 if _screenshot_frame_count >= _screenshot_delay_frames: _screenshot_pending = false _take_screenshot() func _take_screenshot(suffix: String = "") -> void: DirAccess.make_dir_recursive_absolute(SCREENSHOT_DIR) if _screenshot_cardinals: # Take screenshot for current cardinal, then advance var dir_name := CARDINAL_NAMES[_screenshot_cardinal_idx] _char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx]) suffix = dir_name var filename := ( "charcreator_%s_%s.png" % [_descriptor.body_type_key(), suffix if not suffix.is_empty() else "default"] ) var path := SCREENSHOT_DIR + filename var img := get_viewport().get_texture().get_image() img.save_png(path) var abs_path := ProjectSettings.globalize_path(path) print("SCREENSHOT: %s" % abs_path) if _screenshot_cardinals: _screenshot_cardinal_idx += 1 if _screenshot_cardinal_idx < CARDINAL_NAMES.size(): # More directions to capture _schedule_screenshot() return _screenshot_cardinals = false if _quit_after_screenshot: get_tree().quit() func _load_test_config() -> void: # Load a JSON test config from --test-config command line arg # Format: { "body_type": "muscular_m", "hair_id": "buzzed", "clothing": {"torso": "peasant_tunic"}, ... } var args := OS.get_cmdline_args() var config_idx := -1 for i in args.size(): if args[i] == "--test-config" and i + 1 < args.size(): config_idx = i + 1 break if config_idx < 0: return var file := FileAccess.open(args[config_idx], FileAccess.READ) if file == null: push_error("CharacterCreation: could not open test config: %s" % args[config_idx]) return var parsed: Variant = JSON.parse_string(file.get_as_text()) file.close() if not (parsed is Dictionary): push_error("CharacterCreation: test config is not a JSON object") return var cfg: Dictionary = parsed as Dictionary print("TEST CONFIG: %s" % str(cfg)) # Apply config to descriptor if cfg.has("body_type"): var key: String = cfg["body_type"] for bt: int in CharacterVisualDescriptor.BODY_TYPE_KEYS: if CharacterVisualDescriptor.BODY_TYPE_KEYS[bt] == key: _descriptor.body_type = bt as CharacterVisualDescriptor.BodyType break if cfg.has("hair_id"): _descriptor.hair_id = cfg["hair_id"] if cfg.has("facial_hair_id"): _descriptor.facial_hair_id = cfg["facial_hair_id"] if cfg.has("eyebrow_id"): _descriptor.eyebrow_id = cfg["eyebrow_id"] if cfg.has("skin_tone"): _descriptor.skin_tone = int(cfg["skin_tone"]) if cfg.has("hair_tint"): var t: Array = cfg["hair_tint"] _descriptor.hair_tint = Color(t[0], t[1], t[2]) if cfg.has("clothing"): var clothing: Dictionary = cfg["clothing"] for slot: String in clothing: _descriptor.clothing_slots[slot] = clothing[slot] # Auto-quit after screenshot (for CI/automated testing) if cfg.has("screenshot_delay_frames"): _screenshot_delay_frames = int(cfg["screenshot_delay_frames"]) if cfg.has("quit_after_screenshot"): _quit_after_screenshot = bool(cfg["quit_after_screenshot"]) # Reload the character with new descriptor _char_visual.load_descriptor(_descriptor) # Schedule 4 cardinal screenshots (south, east, north, west) _screenshot_cardinals = true _screenshot_cardinal_idx = 0 _schedule_screenshot() # ============================================================================= # Debug tab — segment visibility toggles # ============================================================================= func _build_debug_tab(tab: Control) -> void: var vbox := _make_tab_vbox(tab) var header := Label.new() header.text = "SEGMENT VISIBILITY" header.add_theme_font_size_override("font_size", 14) header.add_theme_color_override("font_color", Color(0.9, 0.7, 0.3)) vbox.add_child(header) var btn_row := HBoxContainer.new() btn_row.add_theme_constant_override("separation", 4) vbox.add_child(btn_row) var all_on := Button.new() all_on.text = "All ON" all_on.add_theme_font_size_override("font_size", 11) all_on.pressed.connect(_on_debug_all.bind(true)) btn_row.add_child(all_on) var all_off := Button.new() all_off.text = "All OFF" all_off.add_theme_font_size_override("font_size", 11) all_off.pressed.connect(_on_debug_all.bind(false)) btn_row.add_child(all_off) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND vbox.add_child(scroll) var list := VBoxContainer.new() list.add_theme_constant_override("separation", 2) scroll.add_child(list) _debug_toggles.clear() for seg_name in CharacterVisual.ALL_SEGMENTS: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) list.add_child(row) var toggle := CheckButton.new() toggle.button_pressed = seg_name not in CharacterVisual.HIDDEN_BY_DEFAULT toggle.text = seg_name toggle.add_theme_font_size_override("font_size", 11) toggle.add_theme_color_override("font_color", Color(0.8, 0.8, 0.85)) toggle.toggled.connect(_on_debug_toggle.bind(seg_name)) row.add_child(toggle) _debug_toggles[seg_name] = toggle # Also add toggles for clothing and outline var clothing_header := Label.new() clothing_header.text = "CLOTHING & OUTLINE" clothing_header.add_theme_font_size_override("font_size", 14) clothing_header.add_theme_color_override("font_color", Color(0.9, 0.7, 0.3)) list.add_child(clothing_header) var toggle_clothing := CheckButton.new() toggle_clothing.button_pressed = true toggle_clothing.text = "all clothing" toggle_clothing.add_theme_font_size_override("font_size", 11) toggle_clothing.toggled.connect(_on_debug_toggle_clothing) list.add_child(toggle_clothing) var toggle_outlines := CheckButton.new() toggle_outlines.button_pressed = true toggle_outlines.text = "outlines" toggle_outlines.add_theme_font_size_override("font_size", 11) toggle_outlines.toggled.connect(_on_debug_toggle_outlines) list.add_child(toggle_outlines) func _on_debug_all(on: bool) -> void: for seg_name: String in _debug_toggles: _debug_toggles[seg_name].button_pressed = on _on_debug_toggle(on, seg_name) func _on_debug_toggle(pressed: bool, seg_name: String) -> void: for mi in _char_visual._body_meshes: if mi.get_meta("segment", "") == seg_name: mi.visible = pressed print("DEBUG: ", seg_name, " visible=", pressed, " mesh=", mi.name) func _on_debug_toggle_clothing(pressed: bool) -> void: for mi in _char_visual._clothing_meshes: mi.visible = pressed func _on_debug_toggle_outlines(pressed: bool) -> void: for mi in _char_visual._outline_nodes: if is_instance_valid(mi): mi.visible = pressed # ============================================================================= # 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 # Scroll zoom — works over the preview panel if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.pressed: if mb.button_index == MOUSE_BUTTON_WHEEL_UP: _cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP) get_viewport().set_input_as_handled() return if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: _cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP) get_viewport().set_input_as_handled() 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: # Body type — pick from manifest only var available_types: Array = _manifest_array("body_types") if not available_types.is_empty(): var key: String = available_types[randi() % available_types.size()] for bt: int in CharacterVisualDescriptor.BODY_TYPE_KEYS: if CharacterVisualDescriptor.BODY_TYPE_KEYS[bt] == key: _descriptor.body_type = bt as CharacterVisualDescriptor.BodyType break _descriptor.skin_tone = randi() % CharacterVisual.SKIN_TONES.size() # Hair from manifest 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) # Facial hair — only for male non-teen body types var body_key: String = _descriptor.body_type_key() if not body_key.ends_with("_f") and not body_key.begins_with("teen") and body_key != "child": var fh_options: Array = _manifest_array("facial_hair") fh_options = [""] + fh_options # include "none" _descriptor.facial_hair_id = fh_options[randi() % fh_options.size()] else: _descriptor.facial_hair_id = "" # Eye color — random natural tones _descriptor.eye_color = Color.from_hsv( randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5 ) # 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_facial_hair_visibility() _update_hair_btns() _update_hair_color_dock() _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 margin := MarginContainer.new() margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_left", 8) margin.add_theme_constant_override("margin_right", 8) margin.add_theme_constant_override("margin_top", 4) tab.add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 6) margin.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) ) func _get_clothing_ids_for_slot(slot: String) -> Array: # Clothing items from manifest — slot assignment is explicit, not prefix-based. var clothing_data: Variant = _manifest.get("clothing", {}) if not (clothing_data is Dictionary): return [] var cd: Dictionary = clothing_data as Dictionary var result: Array = [] for item_id: String in cd.keys(): var item_info: Variant = cd[item_id] if item_info is Dictionary: var item_slot: String = (item_info as Dictionary).get("slot", "") as String if item_slot == slot: result.append(item_id) return result func _get_accessory_ids_for_slot(slot: String) -> Array: # Accessory items from manifest, filtered by slot name prefix convention. var all_ids: Array = _manifest_array("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 # gdlint:ignore = max-returns # ============================================================================= # 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)