feat(ui): Bookmark tab content + CharacterProfile signal payload (Workstream 6)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:36:00 +02:00
co-authored by Claude Opus 4.6
parent 88202ad679
commit 35858fa0fa
3 changed files with 249 additions and 38 deletions
+4 -4
View File
@@ -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 = ""
@@ -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)
@@ -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():