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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user