Unified Menu Carousel — design
Unified Menu Carousel — design
Section titled “Unified Menu Carousel — design”Fold the start menu’s remaining footer buttons (Ship, Shop, Enemies) into the same hologram-bracket carousel the mode picker already uses, so every top-level menu choice is navigated identically — and, in doing so, separate what the menu contains from how it’s drawn, so a future switch to a different visual style touches one layer, not both.
Context
Section titled “Context”ui/start_menu.gd (from the 2026-07-05 Start Menu Carousel work) currently shows a
hologram-bracket carousel of mode cards (Tutorial/Story/Survival/Crystals, collapsed to
just Survival while V01_CRYSTALS_ONLY = true), followed by a VBoxContainer column of
static footer Buttons: “Ship” (always), “Shop”/“Enemies” (once has_progressed), and
“Remote Control” (dev builds only, BuildConfig.dev_tools()). Confirming a carousel card
calls _choose(mode) — hardcoded to “launch this mode,” read via
_cards[0].get_meta("mode"). The footer buttons are separate nodes with their own
pressed signal connections (ship_config_requested.emit(), shop_requested.emit(),
bestiary_requested.emit()), entirely outside the carousel’s _items/_cards machinery.
Non-goals
Section titled “Non-goals”- Remote Control stays a separate dev-only footer button below the carousel — it’s a developer tool, not a real player-facing menu choice, so it doesn’t need the same hologram-card treatment or need to displace real menu items in the carousel’s card count.
- No change to
ShopCategories,MetaState,ShipBonuses,ui/meta_shop_panel.gd, or any upgrade/loadout data model. - No change to
ShipConfigPanel’s own ship carousel (paging/confirm/touch, shipped 2026-07-05/06) — reached the same way, via a “Ship” item’son_confirm. - No change to which modes exist or
V01_CRYSTALS_ONLY’s gating of them — only how many navigation levels exist above them (see Design §2) and how the centered card’s confirm action is looked up (Design §3). - Not building a generic, speculative “menu system” usable by arbitrary future screens
beyond this one carousel — the content/view split below is scoped to
StartMenuspecifically, matching this codebase’s existing bias toward one-off ports over speculative shared abstractions (the same reasoning the original carousel design used to justify not extracting a shared carousel class from the shop/mode/ship ports).
Design
Section titled “Design”1. Content model — MenuItem, a plain Dictionary
Section titled “1. Content model — MenuItem, a plain Dictionary”A menu item is {id: String, label: String, desc: String, color: Color, on_confirm: Callable}. on_confirm takes no arguments and returns nothing — it’s a closure the
content layer defines (bind whatever it needs, e.g. func() -> void: _choose("crystals")
or func() -> void: ship_config_requested.emit()). The carousel view (hologram
brackets, slot math, drag, tween, MenuNav stepping — all of _render_carousel,
_make_card (renamed from _make_mode_card), _compute_slots, _place_card,
_begin_drag/_update_drag/_end_drag, _carousel_step) never inspects id or branches
on item type. It reads label/desc/color to draw a card and calls
item.on_confirm.call() when that card is confirmed. This is the actual separation this
design is for: swapping the view layer later (a grid, a different card style, whatever)
means rewriting the render/layout functions against the same Array[Dictionary] shape,
not touching what the menu contains or what each entry does.
_items’s element type changes from the current [id, label, desc, color] Array-of-Arrays
to Array[Dictionary] of this shape. _make_card’s only remaining per-item logic is
reading label/desc/color/an id-derived accent-dot styling already keyed off
color — no behavioral branching.
2. Two navigation levels: top-level menu, and a “Play” sub-carousel of modes
Section titled “2. Two navigation levels: top-level menu, and a “Play” sub-carousel of modes”The top-level list (_build_top_level_items() -> Array[Dictionary]) is: Play (always),
Ship (always), Shop (has_progressed), Enemies (has_progressed) — same
has_progressed gating the footer buttons use today, just relocated into which items
exist rather than which buttons are built.
- Ship / Shop / Enemies:
on_confirmis exactly today’s footer-button behavior —func() -> void: _ui_select(); ship_config_requested.emit()(etc.) — emitting the same signalsmain.gdalready listens for. These items never drill into a sub-carousel. - Play:
on_confirmis_activate_play(). This builds the mode list (today’smodesarray — Tutorial/Story/Survival/Crystals, or just the one Survival/Crystals entry underV01_CRYSTALS_ONLY) asArray[Dictionary]in the sameMenuItemshape, each entry’son_confirmcalling_choose(mode_id)exactly as today. If that list has more than one entry,_activate_play()swaps_itemsto it, remembers the top-level list to return to (_parent_items: Array[Dictionary], empty when at the top level), and re-renders centered on the first mode. If it has exactly one entry (today’sV01_CRYSTALS_ONLYstate),_activate_play()skips the swap entirely and calls that single mode’son_confirm.call()directly — Play launches Survival immediately, no extra confirm press, matching today’s actual play-feel. Once more modes ship (V01_CRYSTALS_ONLYflips), Play’s behavior changes automatically to “drill in,” no code change needed at that point.
_parent_items.is_empty() is exactly the signal used everywhere navigation needs to know
which level is active (whether Back does anything, whether the reveal/stagger timing on
first open applies — see §3).
3. Confirm dispatch and Back navigation
Section titled “3. Confirm dispatch and Back navigation”_input()’s confirm branch changes from _choose(String(_cards[0].get_meta("mode"))) to
reading the whole item back off the card (_cards[0].get_meta("item"), the Dictionary
itself) and calling (_cards[0].get_meta("item") as Dictionary)["on_confirm"].call().
A new Back/Cancel handler (ui_cancel/JOY_BUTTON_B/Esc) is added to _input(), active
only when not _parent_items.is_empty(). _carousel_index means different things in the
two lists (an index into the mode list vs. an index into the top-level list), so popping
back must not just swap _items and leave the old index in place — it has to explicitly
re-resolve which slot “Play” occupies in the restored list: swap _items back to
_parent_items, clear _parent_items, set _carousel_index to
_parent_items.find_custom(func(i): return i["id"] == "play") (this screen only ever has
one drill-in point, so there’s no need for a general breadcrumb stack — just the single
parent-list slot and a lookup by id, not a remembered numeric index that could point at
the wrong item once the list underneath it has changed), then re-render. At the top level
(_parent_items.is_empty()), Back/Cancel is a no-op (nowhere to go — this is the root
screen, same as today).
4. Navigation summary
Section titled “4. Navigation summary”| Level | Confirm | Left/Right | Back/Cancel |
|---|---|---|---|
| Top level (Play/Ship/Shop/Enemies) | that item’s on_confirm |
steps the top-level carousel | no-op (root screen) |
| Mode sub-carousel (drilled in via Play, only reachable once >1 mode exists) | _choose(mode_id) |
steps the mode carousel | pops back to top level, centered on Play |
Ship carousel (ShipConfigPanel, unchanged) |
persist meta.selected_ship, stay open |
pages to next/prev ship | close() (unchanged) |
Testing
Section titled “Testing”Kept, unmodified: every carousel-mechanics test from the 2026-07-05 plan (slot
math/scale/alpha, drag commit/cancel, reveal-fade target-alpha, MenuNav stepping) — the
view layer’s mechanics don’t change, only what feeds it. ShipConfigPanel’s own suite is
untouched (not in scope here).
Removed/rewritten: test_shop_entry_emits_shop_requested and its siblings (currently
finding the Shop/Ship/Enemies footer button by walking box’s children and pressing it)
— these become carousel-navigation tests instead: step/confirm to the Shop/Ship/Enemies
card and confirm it, asserting the right signal fires.
New:
- Top-level list shape: exactly
[Play, Ship]whenhas_progressed == false;[Play, Ship, Shop, Enemies]oncehas_progressed == true. - Confirming Play with exactly one mode available launches it directly (
mode_chosenfires with the right id, no sub-carousel ever renders,_parent_itemsstays empty). - Confirming Play with more than one mode available drills into the mode sub-carousel
(
_itemsbecomes the mode list,_parent_itemsbecomes the top-level list, centered on the first mode) — exercised by temporarily flippingV01_CRYSTALS_ONLYin the test the same way any existing test in this file already does for that flag, if that’s the established pattern (checktests/test_start_menu_modes.gd), otherwise construct the mode list directly and call_activate_play(). - From the mode sub-carousel, confirming a mode calls
_choose(mode_id)— unchanged behavior, new path to it. - From the mode sub-carousel, Back/Cancel returns to the top level, re-centered on Play, and a second Back/Cancel at the top level is a no-op.
- Confirming Ship/Shop/Enemies cards fires the right signal (
ship_config_requested/shop_requested/bestiary_requested) and does NOT touch_parent_items. - Remote Control is NOT present anywhere in
_items/_cardsat either level, and its footer button still works exactly as today (unchanged code path).
Rollout
Section titled “Rollout”ui/start_menu.gd-only change, no /sim files touched — the pinned determinism baseline
holds by construction; verify per the standard bh-dev-chunk ritual anyway. This changes
a well-worn, high-traffic screen (the very first thing every player sees), so include a
real-device check (Mac export at minimum, bh-deploy to the Apple TV if this session’s
cadence continues) — specifically confirm the Play → mode-sub-carousel → Back round trip
feels right on a real remote/controller, not just headlessly.