Files
settled-reach/client/ui/character_creation.gd
T
2026-04-05 11:09:10 +02:00

2041 lines
68 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.
# 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 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]
# --- 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 <path> 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)