feat(ui): 3-tab restructure of character creation (Workstream 5)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:11:46 +02:00
co-authored by Claude Opus 4.6
parent 2104348000
commit 256703e9b7
@@ -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