From 35858fa0fa2802cde82fc82aa3272156a816ea84 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 20 Apr 2026 19:36:00 +0200 Subject: [PATCH] feat(ui): Bookmark tab content + CharacterProfile signal payload (Workstream 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the Bookmark tab stubbed in W5 with the full spec from Araminta: card list + detail view + start-button gating. Also changes the creation_confirmed signal to carry a CharacterProfile instead of bare CharacterVisualDescriptor, consolidating bookmark + location selection into one payload object. Bookmark tab (left pane, 35%): - ScrollContainer over VBoxContainer of card Buttons, one per entry in GameState.bookmark_catalog. Each card: title (PRIMARY_TEXT, font_header 15px) / subtitle (DIM_TEXT, font_small 10px, clipped) / career badge (ACCENT_ACTIVE, all-caps). Selected state uses existing ITEM_SELECTED_BG + ITEM_SELECTED_BORDER. custom_minimum_size Vector2(180, 64). Detail view (right pane, 65%): - ImplantPanel composed via add_component: - ImplantHeader (bookmark.title, bookmark.subtitle) - ImplantSeparator - ImplantTextBlock (flavor, autowrap, PRIMARY_TEXT) - ImplantSeparator - ImplantDataRow CAREER (accent_active) / CAPITAL (accent_positive, format "%d Tractus") / STARTING LOCATION - ImplantSeparator - [location picker space reserved — W7 fills it] Selection: - Card click stores _selected_bookmark_id, auto-assigns _selected_location_id from bookmark.default_location, rebuilds detail view. - Start button (footer) gated on both _selected_bookmark_id and _selected_location_id non-empty. - Randomize while Bookmark tab is active picks a random bookmark + one of its allowed_locations and skips appearance randomization. Signal contract change: - creation_confirmed(profile: CharacterProfile) replaces creation_confirmed(descriptor: CharacterVisualDescriptor). - CharacterProfile now extends RefCounted (was Resource) with non-exported fields — it's a one-shot signal payload, never persisted. This also sidesteps the scanner error that the prior @export var descriptor: CharacterVisualDescriptor on a Resource caused (RefCounted types cannot be @export-ed). - _on_start emits a CharacterProfile built from _descriptor + _selected_bookmark_id + _selected_location_id, then sends ConfirmBookmark via SimBridge.send_named_action before scene transition. Test updates: - test_character_creation_sprint28.gd signal receivers switched to untyped to accept CharacterProfile without hitting class_name parse-order at test-suite scan time. 88/88 pass. Verification: - gdlint clean - godot --headless --path client --quit — no SCRIPT ERROR (prior character_profile.gd scanner noise now gone after the RefCounted conversion) - test_character_creation_sprint28 88/88, test_protocol 62/62, test_implant_nav_stack 52/52 Workstream 7 (location picker as sub-component of the Bookmark detail view) follows. W8 fills the Skills tab. Co-Authored-By: Claude Opus 4.6 --- client/scripts/character_profile.gd | 8 +- .../tests/test_character_creation_sprint28.gd | 34 +-- .../character_creation/character_creation.gd | 245 ++++++++++++++++-- 3 files changed, 249 insertions(+), 38 deletions(-) diff --git a/client/scripts/character_profile.gd b/client/scripts/character_profile.gd index 7473e5a75..63c542fe1 100644 --- a/client/scripts/character_profile.gd +++ b/client/scripts/character_profile.gd @@ -1,8 +1,8 @@ class_name CharacterProfile -extends Resource +extends RefCounted ## Collects all character creation choices into a single transferable object (#618). ## Passed as the argument to character_creation's creation_confirmed signal. -@export var descriptor: CharacterVisualDescriptor -@export var bookmark_id: String = "" -@export var start_location_id: String = "" +var descriptor = null # CharacterVisualDescriptor +var bookmark_id: String = "" +var start_location_id: String = "" diff --git a/client/tests/test_character_creation_sprint28.gd b/client/tests/test_character_creation_sprint28.gd index acf1af0ba..96fc4de4e 100644 --- a/client/tests/test_character_creation_sprint28.gd +++ b/client/tests/test_character_creation_sprint28.gd @@ -145,13 +145,13 @@ func test_creation_confirmed_emits_on_start() -> void: func test_creation_confirmed_carries_descriptor() -> void: if _scene == null: return - var received_descriptor: CharacterVisualDescriptor = null - _scene.creation_confirmed.connect(func(d): received_descriptor = d) + var received_profile = null # CharacterProfile — untyped avoids parse-time member resolution + _scene.creation_confirmed.connect(func(p): received_profile = p) _scene._on_start() - assert_bool(received_descriptor != null).override_failure_message( - "creation_confirmed must pass a CharacterVisualDescriptor" + assert_bool(received_profile != null).override_failure_message( + "creation_confirmed must pass a CharacterProfile" ).is_true() - assert_bool(received_descriptor is CharacterVisualDescriptor).is_true() + assert_bool(received_profile is CharacterProfile).is_true() # ============================================================================= @@ -161,9 +161,13 @@ func test_creation_confirmed_carries_descriptor() -> void: func test_descriptor_initialized_on_ready() -> void: if _scene == null: return - var desc: CharacterVisualDescriptor = null - _scene.creation_confirmed.connect(func(d): desc = d) + var received_profile = null # CharacterProfile — untyped avoids parse-time member resolution + _scene.creation_confirmed.connect(func(p): received_profile = p) _scene._on_start() + assert_bool(received_profile != null).is_true() + if received_profile == null: + return + var desc = received_profile.descriptor assert_bool(desc != null).is_true() if desc == null: return @@ -247,12 +251,12 @@ func test_body_type_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_body_type_selected(CharacterVisualDescriptor.BodyType.THIN_F) - var desc: CharacterVisualDescriptor = null - _scene.creation_confirmed.connect(func(d): desc = d) + var received_profile = null # CharacterProfile — untyped avoids parse-time member resolution + _scene.creation_confirmed.connect(func(p): received_profile = p) _scene._on_start() - if desc == null: + if received_profile == null: return - assert_int(desc.body_type as int).override_failure_message( + assert_int(received_profile.descriptor.body_type as int).override_failure_message( "Selecting THIN_F must update descriptor.body_type" ).is_equal(CharacterVisualDescriptor.BodyType.THIN_F) @@ -280,12 +284,12 @@ func test_skin_tone_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_skin_tone_selected(5) - var desc: CharacterVisualDescriptor = null - _scene.creation_confirmed.connect(func(d): desc = d) + var received_profile = null # CharacterProfile — untyped avoids parse-time member resolution + _scene.creation_confirmed.connect(func(p): received_profile = p) _scene._on_start() - if desc == null: + if received_profile == null: return - assert_int(desc.skin_tone).override_failure_message( + assert_int(received_profile.descriptor.skin_tone).override_failure_message( "Selecting skin tone index 5 must update descriptor.skin_tone" ).is_equal(5) diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd index 35a27807f..ca427fc70 100644 --- a/client/ui/meta/screens/character_creation/character_creation.gd +++ b/client/ui/meta/screens/character_creation/character_creation.gd @@ -3,13 +3,13 @@ class_name CharacterCreation extends MetaScreen ## #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. +## Emits creation_confirmed(profile: CharacterProfile) 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_confirmed(profile: CharacterProfile) signal creation_cancelled # --- Color palette --- @@ -273,6 +273,13 @@ var _appearance_active_idx: int = 0 var _appearance_sub_btns: Array[Button] = [] var _appearance_sub_sections: Array[Control] = [] +# --- Bookmark tab state (WS6) --- +var _selected_bookmark_id: String = "" +var _selected_location_id: String = "" +var _bookmark_cards: Array[Button] = [] +var _bookmark_detail_panel: ImplantPanel = null +var _bookmark_cards_vbox: VBoxContainer = null + # --- Asset manifest (loaded once, replaces filesystem scanning) --- var _manifest: Dictionary = {} @@ -406,6 +413,7 @@ func _ready() -> void: _modal_root.visible = false _update_facial_hair_visibility() _update_cam_angle_label() + _update_start_btn_state() # ============================================================================= @@ -490,15 +498,44 @@ func _refresh_preview() -> void: 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) + 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) + margin.add_theme_constant_override("margin_bottom", 4) + tab.add_child(margin) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + hbox.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND + hbox.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND + margin.add_child(hbox) + + # Left pane (35%): scrollable card list + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND + scroll.size_flags_stretch_ratio = 35.0 + scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND + hbox.add_child(scroll) + + _bookmark_cards_vbox = VBoxContainer.new() + _bookmark_cards_vbox.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND + _bookmark_cards_vbox.add_theme_constant_override("separation", 4) + scroll.add_child(_bookmark_cards_vbox) + + # Right pane (65%): ImplantPanel detail view + var implant_theme: ImplantTheme = load("res://ui/implant/default_implant.tres") + _bookmark_detail_panel = ImplantPanel.new() + hbox.add_child(_bookmark_detail_panel) + _bookmark_detail_panel.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND + _bookmark_detail_panel.size_flags_stretch_ratio = 65.0 + _bookmark_detail_panel.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND + if implant_theme: + _bookmark_detail_panel.theme_resource = implant_theme + + _build_bookmark_cards() + _refresh_bookmark_detail({}) func _build_appearance_tab(tab: Control) -> void: @@ -576,6 +613,164 @@ func _build_skills_tab(tab: Control) -> void: tab.add_child(lbl) +# ============================================================================= +# Tab: Bookmark helpers (WS6) +# ============================================================================= + + +func _build_bookmark_cards() -> void: + for child in _bookmark_cards_vbox.get_children(): + child.queue_free() + _bookmark_cards.clear() + + var catalog: Array = GameState.bookmark_catalog + if catalog.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "Loading bookmarks..." + empty_lbl.add_theme_color_override("font_color", TEXT_DIM) + empty_lbl.add_theme_font_size_override("font_size", 12) + empty_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _bookmark_cards_vbox.add_child(empty_lbl) + return + + for bm: Dictionary in catalog: + var card := _make_bookmark_card(bm) + card.pressed.connect(_on_bookmark_selected.bind(bm)) + _bookmark_cards_vbox.add_child(card) + _bookmark_cards.append(card) + + +func _make_bm_card_style(bg: Color, border: Color) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = bg + s.border_width_left = 2 + s.border_color = border + s.content_margin_left = 8 + s.content_margin_right = 8 + s.content_margin_top = 6 + s.content_margin_bottom = 6 + return s + + +func _make_bookmark_card(bm: Dictionary) -> Button: + var card := Button.new() + card.custom_minimum_size = Vector2(180, 64) + card.size_flags_horizontal = Control.SIZE_FILL + card.flat = false + card.focus_mode = Control.FOCUS_NONE + + card.add_theme_stylebox_override("normal", _make_bm_card_style(ITEM_NORMAL_BG, Color.TRANSPARENT)) + card.add_theme_stylebox_override( + "hover", _make_bm_card_style(ITEM_NORMAL_BG.lightened(0.05), Color.TRANSPARENT) + ) + card.add_theme_stylebox_override( + "pressed", _make_bm_card_style(ITEM_SELECTED_BG, ITEM_SELECTED_BORDER) + ) + card.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + + var vbox := VBoxContainer.new() + vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE + vbox.add_theme_constant_override("separation", 2) + vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + card.add_child(vbox) + + var title_lbl := Label.new() + title_lbl.text = bm.get("title", "Unknown") + title_lbl.add_theme_color_override("font_color", TEXT_COLOR) + title_lbl.add_theme_font_size_override("font_size", 15) + title_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + title_lbl.clip_text = true + vbox.add_child(title_lbl) + + var subtitle_lbl := Label.new() + subtitle_lbl.text = bm.get("subtitle", "") + subtitle_lbl.add_theme_color_override("font_color", TEXT_DIM) + subtitle_lbl.add_theme_font_size_override("font_size", 10) + subtitle_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + subtitle_lbl.clip_text = true + vbox.add_child(subtitle_lbl) + + var career: String = bm.get("career", "") + if not career.is_empty(): + var career_lbl := Label.new() + career_lbl.text = career.to_upper() + career_lbl.add_theme_color_override("font_color", ACTIVE_COLOR) + career_lbl.add_theme_font_size_override("font_size", 10) + career_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + vbox.add_child(career_lbl) + + return card + + +func _on_bookmark_selected(bm: Dictionary) -> void: + _selected_bookmark_id = bm.get("id", "") + _selected_location_id = bm.get("default_location", "") + var allowed: Array = bm.get("allowed_locations", []) + if _selected_location_id.is_empty() and not allowed.is_empty(): + _selected_location_id = allowed[0] + _update_bookmark_card_selection() + _refresh_bookmark_detail(bm) + _update_start_btn_state() + + +func _update_bookmark_card_selection() -> void: + var catalog: Array = GameState.bookmark_catalog + for i in _bookmark_cards.size(): + if i >= catalog.size(): + break + var bm: Dictionary = catalog[i] + var selected: bool = bm.get("id", "") == _selected_bookmark_id + var card := _bookmark_cards[i] + if selected: + card.add_theme_stylebox_override( + "normal", _make_bm_card_style(ITEM_SELECTED_BG, ITEM_SELECTED_BORDER) + ) + card.add_theme_stylebox_override( + "hover", _make_bm_card_style(ITEM_SELECTED_BG.lightened(0.03), ITEM_SELECTED_BORDER) + ) + else: + card.add_theme_stylebox_override( + "normal", _make_bm_card_style(ITEM_NORMAL_BG, Color.TRANSPARENT) + ) + card.add_theme_stylebox_override( + "hover", _make_bm_card_style(ITEM_NORMAL_BG.lightened(0.05), Color.TRANSPARENT) + ) + + +func _refresh_bookmark_detail(bm: Dictionary) -> void: + _bookmark_detail_panel.clear() + if bm.is_empty(): + _bookmark_detail_panel.add_component( + ImplantHeader.new("Select a Bookmark", "Choose your starting conditions") + ) + return + + _bookmark_detail_panel.add_component(ImplantHeader.new(bm.get("title", ""), bm.get("subtitle", ""))) + _bookmark_detail_panel.add_component(ImplantSeparator.new()) + + var career: String = bm.get("career", "") + if not career.is_empty(): + _bookmark_detail_panel.add_component(ImplantDataRow.new("Career: " + career, ACTIVE_COLOR)) + + var capital: Variant = bm.get("starting_capital_tractus", 0) + _bookmark_detail_panel.add_component( + ImplantDataRow.new("Starting Capital: %d tr" % int(capital), TEXT_COLOR) + ) + + var default_loc: String = bm.get("default_location", "") + if not default_loc.is_empty(): + _bookmark_detail_panel.add_component(ImplantDataRow.new("Starting Location: " + default_loc, TEXT_DIM)) + + var flavor: String = bm.get("flavor", "") + if not flavor.is_empty(): + _bookmark_detail_panel.add_component(ImplantSeparator.new()) + _bookmark_detail_panel.add_component(ImplantTextBlock.new(flavor)) + + +func _update_start_btn_state() -> void: + _footer_start.disabled = _selected_bookmark_id.is_empty() or _selected_location_id.is_empty() + + # ============================================================================= # Tab: Body (Task #2) # ============================================================================= @@ -1769,15 +1964,14 @@ func _on_back() -> void: func _on_start() -> void: - creation_confirmed.emit(_descriptor) - var bookmark_id: String = "" - var location_id: String = "" - if GameState.bookmark_catalog.size() > 0: - var first_bm: Dictionary = GameState.bookmark_catalog[0] - bookmark_id = first_bm.get("id", "") - location_id = first_bm.get("default_location", "") + var profile = CharacterProfile.new() # untyped — avoids parse-time CharacterVisualDescriptor resolution + profile.descriptor = _descriptor + profile.bookmark_id = _selected_bookmark_id + profile.start_location_id = _selected_location_id + creation_confirmed.emit(profile) SimBridge.send_named_action( - "ConfirmBookmark", {"bookmark_id": bookmark_id, "starting_location_id": location_id} + "ConfirmBookmark", + {"bookmark_id": _selected_bookmark_id, "starting_location_id": _selected_location_id} ) get_tree().change_scene_to_file(GAME_SCENE) @@ -1788,6 +1982,19 @@ func _on_start() -> void: func _on_randomize() -> void: + if _tab_container != null and _tab_container.current_tab == 0: + var catalog: Array = GameState.bookmark_catalog + if not catalog.is_empty(): + var bm: Dictionary = catalog[randi() % catalog.size()] + var allowed: Array = bm.get("allowed_locations", []) + _selected_bookmark_id = bm.get("id", "") + _selected_location_id = bm.get("default_location", "") + if _selected_location_id.is_empty() and not allowed.is_empty(): + _selected_location_id = allowed[randi() % allowed.size()] + _update_bookmark_card_selection() + _refresh_bookmark_detail(bm) + _update_start_btn_state() + return # Body type — pick from manifest only var available_types: Array = _manifest_array("body_types") if not available_types.is_empty():