From 256703e9b7d5197041e6af0f2c621a24b7c8416d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 20 Apr 2026 18:11:46 +0200 Subject: [PATCH] feat(ui): 3-tab restructure of character creation (Workstream 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the character creation TabContainer from 8 flat tabs (Body / Head / Hair / Clothing / Accessories / Debug plus the two being-added Skills / Bookmark) into 4 top-level tabs per Araminta's revised spec: Bookmark, Appearance, Skills, Debug. The existing five appearance sub-tabs (Body, Head, Hair, Clothing, Accessories) now live inside the Appearance tab as a horizontal segmented sub-navigation using the existing `_make_slot_btn()` pattern — consistent with the Clothing/Accessories slot row vocabulary. Selected sub-section uses existing ITEM_SELECTED_BG / BORDER styling. Structural changes: - New APPEARANCE_SUB_NAMES const lists the five sub-sections. - Renamed _tab_search → _appearance_search, _tab_grids → _appearance_grids. Scope changed from "top-level tabs" to "Appearance sub-sections" but index 0..4 semantics preserved. - Added _appearance_active_idx, _appearance_sub_btns, _appearance_sub_sections state. - _ready() builds exactly 4 top-level tabs; tab builders invoked explicitly per index. - New _build_bookmark_tab / _build_skills_tab render TEXT_DIM placeholder labels ("Bookmark content lands in Workstream 6", etc.) — actual content in W6/W8. - _build_appearance_tab constructs the sub-nav strip and stacks all 5 sub-sections up front with visibility-toggle swap (_on_appearance_sub_selected). Comment explains the up-front build choice and the free-and-rebuild fallback if performance regresses. - Existing _build_body_tab / _head / _hair / _clothing / _accessories / _debug remain unchanged — they now receive Appearance sub-section Controls as their tab argument instead of top-level tabs. _make_tab_vbox anchors full-rect in both parent contexts, so layout is preserved. Verification: - gdlint clean - godot --headless --path client --quit — no SCRIPT ERROR - test_protocol 62/62, test_client_p3 24/24, test_ui_framework_sprint15 54/54, test_implant_nav_stack 52/52, test_implant_registry 42/42, test_implant_app_lifecycle 36/36 Workstream 6 (Bookmark tab content: card list + detail view + location picker per Araminta's spec) lands next. W7 (location picker as a sub-component of Bookmark tab) follows. W8 (Skills stub content) is last. Hoshe's parallel Task #21 (test hygiene triage) commits separately. Co-Authored-By: Claude Opus 4.6 --- .../character_creation/character_creation.gd | 156 +++++++++++++----- 1 file changed, 117 insertions(+), 39 deletions(-) diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd index 9d7746eaf..35a27807f 100644 --- a/client/ui/meta/screens/character_creation/character_creation.gd +++ b/client/ui/meta/screens/character_creation/character_creation.gd @@ -190,6 +190,7 @@ const MAIN_MENU_SCENE := "res://scenes/main_menu.tscn" const GAME_SCENE := "res://scenes/main.tscn" const MANIFEST_PATH := "res://assets/characters/manifest.json" +const APPEARANCE_SUB_NAMES := ["Body", "Head", "Hair", "Clothing", "Accessories"] const SCREENSHOT_DIR := "user://screenshots/" const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"] @@ -261,11 +262,16 @@ var _accessory_dock_container: Control = null 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] +# --- Appearance sub-section search text and grid references --- +var _appearance_search: Array[String] = ["", "", "", "", ""] # one per sub-section (Body=0 … Accessories=4) +## Per-sub-section grid container for search filtering (index = sub-section 0–4). +## Null for sub-sections without a primary grid (Body uses HBoxRows; Clothing/Accessories use slot grids). +var _appearance_grids: Array[GridContainer] = [null, null, null, null, null] + +# --- Appearance sub-navigation state (WS5) --- +var _appearance_active_idx: int = 0 +var _appearance_sub_btns: Array[Button] = [] +var _appearance_sub_sections: Array[Control] = [] # --- Asset manifest (loaded once, replaces filesystem scanning) --- var _manifest: Dictionary = {} @@ -352,31 +358,10 @@ func _ready() -> void: _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: + # Fixed 4-tab top-level structure: Bookmark / Appearance / Skills / Debug + for tab_name: String in ["Bookmark", "Appearance", "Skills", "Debug"]: var tab := Control.new() - tab.name = tb["name"] + tab.name = tab_name _tab_container.add_child(tab) _descriptor = CharacterVisualDescriptor.new() @@ -412,9 +397,10 @@ func _ready() -> void: _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_bookmark_tab(_tab_container.get_child(0)) + _build_appearance_tab(_tab_container.get_child(1)) + _build_skills_tab(_tab_container.get_child(2)) + _build_debug_tab(_tab_container.get_child(3)) _build_color_picker_modal() _modal_root.visible = false @@ -498,6 +484,98 @@ func _refresh_preview() -> void: _char_visual.set_facing(CARDINAL_DIRS[_facing_idx]) +# ============================================================================= +# Tabs: Bookmark / Appearance / Skills — top-level structure (WS5) +# ============================================================================= + + +func _build_bookmark_tab(tab: Control) -> void: + var lbl := Label.new() + lbl.text = "Bookmark content lands in Workstream 6." + lbl.add_theme_color_override("font_color", TEXT_DIM) + lbl.add_theme_font_size_override("font_size", 12) + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + lbl.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + tab.add_child(lbl) + + +func _build_appearance_tab(tab: Control) -> void: + 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", 4) + margin.add_child(vbox) + + # Segmented control — one button per sub-section, using _make_slot_btn style. + var subnav := HBoxContainer.new() + subnav.add_theme_constant_override("separation", 4) + vbox.add_child(subnav) + + _appearance_sub_btns.clear() + for i in APPEARANCE_SUB_NAMES.size(): + var btn := _make_slot_btn(APPEARANCE_SUB_NAMES[i]) + btn.pressed.connect(_on_appearance_sub_selected.bind(i)) + subnav.add_child(btn) + _appearance_sub_btns.append(btn) + + # Sub-section area — stacked Controls, only one visible at a time. + # Build all 5 up front to avoid rebuild cost on switch. + # If initial build is slow on low-end hardware, switch to free-and-rebuild on switch. + var sub_area := Control.new() + sub_area.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND + sub_area.size_flags_horizontal = Control.SIZE_FILL + vbox.add_child(sub_area) + + _appearance_sub_sections.clear() + var builders: Array[Callable] = [ + _build_body_tab, + _build_head_tab, + _build_hair_tab, + _build_clothing_tab, + _build_accessories_tab, + ] + for i in builders.size(): + var section := Control.new() + section.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + section.visible = (i == 0) + sub_area.add_child(section) + builders[i].call(section) + _appearance_sub_sections.append(section) + + _update_appearance_sub_btns() + + +func _on_appearance_sub_selected(idx: int) -> void: + _appearance_active_idx = idx + for i in _appearance_sub_sections.size(): + _appearance_sub_sections[i].visible = (i == idx) + _update_appearance_sub_btns() + + +func _update_appearance_sub_btns() -> void: + for i in _appearance_sub_btns.size(): + _set_item_selected(_appearance_sub_btns[i], i == _appearance_active_idx) + + +func _build_skills_tab(tab: Control) -> void: + var lbl := Label.new() + lbl.text = "Skills content lands in Workstream 8." + lbl.add_theme_color_override("font_color", TEXT_DIM) + lbl.add_theme_font_size_override("font_size", 12) + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + lbl.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + tab.add_child(lbl) + + # ============================================================================= # Tab: Body (Task #2) # ============================================================================= @@ -641,7 +719,7 @@ func _build_head_tab(tab: Control) -> void: grid.add_theme_constant_override("h_separation", 4) grid.add_theme_constant_override("v_separation", 4) scroll.add_child(grid) - _tab_grids[1] = grid + _appearance_grids[1] = grid _cached_head_ids = _manifest_array("heads") for hid in _cached_head_ids: @@ -917,7 +995,7 @@ func _build_clothing_tab(tab: Control) -> void: _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) + # _appearance_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() @@ -1121,7 +1199,7 @@ func _build_accessories_tab(tab: Control) -> void: _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) + # _appearance_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() @@ -1917,14 +1995,14 @@ func _make_search_bar(tab_idx: int) -> LineEdit: 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 + _appearance_search[tab_idx] = q + # Sub-sections 3/4 filter the active slot's grid; others use _appearance_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) + elif tab_idx < _appearance_grids.size() and _appearance_grids[tab_idx] != null: + _apply_search_filter(_appearance_grids[tab_idx], q) ) return search