Start Menu Carousel Implementation Plan
Start Menu Carousel Implementation Plan
Section titled “Start Menu Carousel Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restyle the start menu’s mode picker as a hologram-bracket carousel (matching ui/meta_shop_panel.gd’s shipped pattern), remove the inline ship-picker row, and turn ui/ship_config_panel.gd’s single-ship display into a pre-run pageable carousel for choosing a ship.
Architecture: Port (don’t extract/share) the shop’s carousel pattern into ui/start_menu.gd for the mode list (full 3-up hologram-bracket carousel: center + dimmed side neighbors, drag + MenuNav step + confirm). ui/ship_config_panel.gd gets a simpler single-item paging carousel (no visible side neighbors — slide+crossfade the whole hero+weapon-ring composition), gated to sim == null (pre-run) only.
Tech Stack: Godot 4.6.3 / GDScript, GUT for tests.
Global Constraints
Section titled “Global Constraints”/sim/*is untouched by this plan — render/UI only, so the pinned determinism baseline (snapshot_string().hash()=4217109746,state_checksum()=2666143677) must hold by construction. Re-verify anyway per Task 9.- Follow
bh-dev-chunkritual for every task:godot --headless --path . --importafter any new inner class, boot-check (--quit-after 90 | grep "SCRIPT ERROR"must be empty), full suite (-gdir=res://tests -ginclude_subdirs -gexit) +bash scripts/check-test-count.sh. - GUT assertion methods are
assert_lte/assert_gte, notassert_le/assert_ge. - Commit after each task (one chunk = one commit), on
main, no--no-verify. - No change to
ShopCategories,MetaStatefields,ShipBonuses, ormeta_shop_panel.gditself.
Task 1: Mode carousel — Hologram Brackets card + slot math (ui/start_menu.gd)
Section titled “Task 1: Mode carousel — Hologram Brackets card + slot math (ui/start_menu.gd)”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd
Interfaces:
-
Produces:
StartMenu._HologramBrackets(inner class, same shape asMetaShopPanel._HologramBrackets),StartMenu._carousel_root: Control,StartMenu._items: Array(each entry a[id, title, desc, accent]array — the existingmodesarray shape),StartMenu._carousel_index: int,StartMenu._center() -> Vector2,StartMenu._slot_side_offset() -> float,StartMenu._compute_slots(center_i: int, side_offset: float) -> Array,StartMenu._place_card(card: Button, idx: int, x_off: float, scl: float) -> Vector2,StartMenu._make_mode_card(item: Array) -> Button(replaces_make_card). -
Consumes: nothing new from other tasks.
-
Step 1: Write the failing test
# Add to tests/test_start_menu.gdfunc test_mode_carousel_centers_one_card_when_only_one_mode() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame assert_eq(m._cards.size(), 1, "V01_CRYSTALS_ONLY=true -> a single centered mode card") assert_almost_eq(m._cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01) assert_almost_eq(m._cards[0].modulate.a, 1.0, 0.01)
func test_mode_carousel_shows_dimmed_side_neighbours_for_three_items() -> void: var m := StartMenu.new() m._items = [["a", "A", "desc a", Color.RED], ["b", "B", "desc b", Color.GREEN], ["c", "C", "desc c", Color.BLUE]] add_child_autofree(m) await get_tree().process_frame m._carousel_index = 0 m._render_carousel(0) assert_eq(m._cards.size(), 3, "center + 2 side neighbours") assert_almost_eq(m._cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01, "center card") assert_almost_eq(m._cards[1].scale.x, StartMenu.SIDE_SCALE, 0.01, "side card") assert_almost_eq(m._cards[1].modulate.a, StartMenu.SIDE_ALPHA, 0.01, "side card dimmed")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL — CENTER_SCALE/_render_carousel/etc. don’t exist yet.
- Step 3: Add carousel constants +
_HologramBrackets+ slot/placement math + card builder
In ui/start_menu.gd, add alongside the existing consts (near CARD_W/CARD_H):
# Carousel layout — ports ui/meta_shop_panel.gd's validated constants verbatim (see# docs/superpowers/specs/2026-07-02-shop-carousel-hologram-design.md). Computed fresh from# the real viewport every render, not a fixed design-space value (the shop carousel's fix# for a real-device off-center bug).const CENTER_Y_FRAC := 0.5const CENTER_SCALE := 1.4const SIDE_SCALE := 0.55const SIDE_ALPHA := 0.4const SIDE_GAP := 26.0const ANIM_DURATION := 0.3const TRAVEL_FRACTION := 0.6const DRAG_COMMIT_FRACTION := 0.22Add member vars alongside the existing ones:
var _carousel_root: Controlvar _items: Array = [] # [id, title, desc, accent] per modevar _carousel_index: int = 0Add these methods (near _make_card):
func _viewport_size() -> Vector2: return get_viewport().get_visible_rect().size
func _center() -> Vector2: var vp := _viewport_size() return Vector2(vp.x * 0.5, vp.y * CENTER_Y_FRAC)
func _slot_side_offset() -> float: return CARD_W * 0.5 * CENTER_SCALE + SIDE_GAP + CARD_W * SIDE_SCALE * 0.5
# [index, x_offset, scale, alpha, is_center] for whichever items are visible around center_i.func _compute_slots(center_i: int, side_offset: float) -> Array: var n := _items.size() var slots: Array = [[center_i, 0.0, CENTER_SCALE, 1.0, true]] if n >= 3: slots.append([(center_i - 1 + n) % n, -side_offset, SIDE_SCALE, SIDE_ALPHA, false]) slots.append([(center_i + 1) % n, side_offset, SIDE_SCALE, SIDE_ALPHA, false]) elif n == 2: slots.append([(center_i + 1) % n, side_offset, SIDE_SCALE, SIDE_ALPHA, false]) return slots
func _place_card(card: Button, idx: int, x_off: float, scl: float) -> Vector2: card.set_meta("item_idx", idx) var card_size: Vector2 = card.custom_minimum_size card.pivot_offset = card_size * 0.5 card.scale = Vector2(scl, scl) var center := _center() return Vector2(center.x + x_off - card_size.x * 0.5, center.y - card_size.y * 0.5)Add the hologram-bracket inner class (bottom of file, alongside _AccentDot):
# Hologram Corner Brackets — direct port of MetaShopPanel._HologramBrackets (see that file's# own comment for the validation history). Ported independently, not shared, per this# feature's design doc (docs/superpowers/specs/2026-07-05-start-menu-carousel-design.md).class _HologramBrackets extends Control: func _draw() -> void: var accent: Color = get_meta("accent", Color.CYAN) var s := size var arm := minf(s.x, s.y) * 0.12 var col := Color(accent.r, accent.g, accent.b, 0.9) draw_line(Vector2(0, arm), Vector2.ZERO, col, 2.0) draw_line(Vector2.ZERO, Vector2(arm, 0), col, 2.0) draw_line(Vector2(s.x - arm, 0), Vector2(s.x, 0), col, 2.0) draw_line(Vector2(s.x, 0), Vector2(s.x, arm), col, 2.0) draw_line(Vector2(0, s.y - arm), Vector2(0, s.y), col, 2.0) draw_line(Vector2(0, s.y), Vector2(arm, s.y), col, 2.0) draw_line(Vector2(s.x - arm, s.y), Vector2(s.x, s.y), col, 2.0) draw_line(Vector2(s.x, s.y), Vector2(s.x, s.y - arm), col, 2.0)Replace _make_card with a hologram-styled version that keeps the existing accent-dot +
title + description content:
func _make_mode_card(item: Array) -> Button: var id: String = item[0] var title: String = item[1] var desc: String = item[2] var accent: Color = item[3] var card := Button.new() card.set_meta("mode", id) card.focus_mode = Control.FOCUS_ALL card.custom_minimum_size = Vector2(CARD_W, CARD_H) card.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) card.add_theme_stylebox_override("hover", StyleBoxEmpty.new()) card.add_theme_stylebox_override("pressed", StyleBoxEmpty.new()) card.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) var brackets := _HologramBrackets.new() brackets.set_anchors_preset(Control.PRESET_FULL_RECT) brackets.mouse_filter = Control.MOUSE_FILTER_IGNORE brackets.set_meta("accent", accent) card.add_child(brackets)
var dot := _AccentDot.new() dot.set_meta("c", accent) dot.position = Vector2(26, CARD_H * 0.5) card.add_child(dot)
var name_lbl := Label.new() name_lbl.text = title name_lbl.position = Vector2(58, 13) name_lbl.add_theme_font_override("font", NeonTheme.title_font()) name_lbl.add_theme_font_size_override("font_size", 30) name_lbl.add_theme_color_override("font_color", accent) name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE card.add_child(name_lbl)
var desc_lbl := Label.new() desc_lbl.text = desc desc_lbl.position = Vector2(58, 50) desc_lbl.add_theme_font_override("font", NeonTheme.mono_font()) desc_lbl.add_theme_font_size_override("font_size", 15) desc_lbl.add_theme_color_override("font_color", Color(0.74, 0.8, 0.9)) desc_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE card.add_child(desc_lbl) return cardAdd _render_carousel (this task builds it without the drag/animation params — animation
direction wiring lands in Task 4; call it with anim_direction always 0 for now):
func _render_carousel(anim_direction: int = 0) -> void: for c in _carousel_root.get_children(): c.queue_free() _cards.clear() if _items.is_empty(): return var side_offset := _slot_side_offset() var slots := _compute_slots(_carousel_index, side_offset) for slot in slots: var idx: int = slot[0] var x_off: float = slot[1] var scl: float = slot[2] var a: float = slot[3] var is_center: bool = slot[4] var card := _make_mode_card(_items[idx]) _carousel_root.add_child(card) var final_pos := _place_card(card, idx, x_off, scl) card.position = final_pos card.modulate = Color(1, 1, 1, a) _cards.append(card) if is_center: card.grab_focus.call_deferred() card.pressed.connect(func() -> void: _ui_select(); _choose(String(card.get_meta("mode")))) else: card.pressed.connect(func() -> void: _jump_to(idx))Add a stub _jump_to (fleshed out with animation in Task 4, but must exist now so the
connection above compiles):
func _jump_to(idx: int) -> void: _carousel_index = idx _ui_nav() _render_carousel(0)Now wire this into _ready(): replace the existing mode-card loop —
var d := REVEAL_STAGGER * 2.0 for m in modes: var c := _make_card(m[0], m[1], m[2], m[3]) box.add_child(c) _reveal.append({"node": c, "delay": d}) d += REVEAL_STAGGER— with:
_items = modes _carousel_root = Control.new() _carousel_root.set_anchors_preset(Control.PRESET_FULL_RECT) _carousel_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_carousel_root) # sibling of `center`, NOT inside the VBox -- _center() below # returns absolute viewport coords, same reasoning as the shop's # _carousel_root (see meta_shop_panel.gd's own comment on this). _carousel_index = 0 _render_carousel(0) var d := REVEAL_STAGGER * 2.0 for card in _cards: _reveal.append({"node": card, "delay": d}) d += REVEAL_STAGGERRemove the now-unused _make_card function entirely (superseded by _make_mode_card).
- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: PASS (both new tests).
- Step 5: Full boot-check
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: import succeeds; grep produces no output.
- Step 6: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "feat(start-menu): hologram-bracket carousel card + slot math for the mode picker"Task 2: Mode carousel — navigation (step + confirm), ui/start_menu.gd
Section titled “Task 2: Mode carousel — navigation (step + confirm), ui/start_menu.gd”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd
Interfaces:
-
Consumes:
_carousel_index,_items,_render_carousel(anim_direction),_jump_to(idx)from Task 1. -
Produces:
StartMenu._carousel_step(direction: int) -> void. -
Step 1: Write the failing test
func test_carousel_step_advances_index_and_wraps() -> void: var m := StartMenu.new() m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]] add_child_autofree(m) await get_tree().process_frame m._carousel_index = 0 m._render_carousel(0) m._carousel_step(-1) assert_eq(m._carousel_index, 2, "stepping left from 0 wraps to the last item")
func test_confirm_activates_the_centered_mode() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame watch_signals(m) var confirm := InputEventAction.new() confirm.action = "ui_accept" confirm.pressed = true m._input(confirm) assert_signal_emitted_with_parameters(m, "mode_chosen", ["crystals"])- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL — _carousel_step doesn’t exist; _input still uses the old _sel grid logic so confirm activates the wrong thing (or errors on an empty _cards-index path).
- Step 3: Add
_carousel_stepand rewrite_input’s confirm/nav branches
func _carousel_step(direction: int) -> void: if _items.is_empty(): return _carousel_index = (_carousel_index + direction + _items.size()) % _items.size() _ui_nav() _render_carousel(direction)Replace the body of _input (the existing _sel-wrap grid logic) with:
func _input(event: InputEvent) -> void: if _cards.is_empty() or not visible: return var confirm := event.is_action_pressed("ui_accept") if not confirm and event is InputEventJoypadButton: confirm = event.pressed and event.button_index == JOY_BUTTON_A if confirm: _ui_select() # _cards[0] is always the centered card -- _compute_slots() puts the center slot first. var sel_mode := String(_cards[0].get_meta("mode", "survival")) if sel_mode == "remote": _on_remote_requested() elif sel_mode == "shop": shop_requested.emit() elif sel_mode == "bestiary": bestiary_requested.emit() elif sel_mode == "ship_config": ship_config_requested.emit() else: _choose(sel_mode) get_viewport().set_input_as_handled() return var fwd := MenuNav.is_right(event) var back_dir := MenuNav.is_left(event) if not (fwd or back_dir): return var now := Time.get_ticks_msec() if now - _last_nav_ms < NAV_DEBOUNCE_MS: get_viewport().set_input_as_handled() return _last_nav_ms = now _carousel_step(1 if fwd else -1) get_viewport().set_input_as_handled()Note: the footer buttons (Ship/Shop/Enemies/Remote) stay as regular Buttons connected to
their own .pressed signals (unchanged) — they are NOT part of _items/the carousel, so
_cards[0] (confirm target) only ever refers to the centered mode card. _sel and the old
up/down (MenuNav.is_down/is_up) handling are removed entirely — a carousel has no second
row, matching the shop carousel’s own “up/down dropped, not repurposed” precedent.
Remove the now-dead _sel member var and refocus()’s _cards[_sel] reference — replace
refocus() with:
func refocus() -> void: if not _cards.is_empty(): _cards[0].grab_focus.call_deferred()- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: PASS.
- Step 5: Full suite + boot-check
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: empty grep; full suite green; count guard passes.
- Step 6: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "feat(start-menu): carousel step navigation + confirm activates the centered mode"Task 3: Mode carousel — footer buttons + reveal wiring sanity, ui/start_menu.gd
Section titled “Task 3: Mode carousel — footer buttons + reveal wiring sanity, ui/start_menu.gd”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd
Interfaces:
- Consumes: everything from Tasks 1–2.
- Produces: nothing new — this task fixes footer-button focus interaction with the carousel (they must not be reachable via left/right, only via direct tap/click or a future Y/tab-style switch — for this plan, direct tap/click only, matching current behavior since footer buttons never were part of
_sel’s left/right cycle… actually they WERE part of_cards/_selbefore. This task makes that explicit and tested).
Context: before this change, footer buttons (_footer_btn) were appended to _cards and
were reachable by cycling _sel with left/right, same as mode cards. After Tasks 1–2, only
mode cards live in the carousel’s _cards/_carousel_indexated cycle — footer buttons are separate Button` nodes the player reaches only by direct tap/click, or standard Godot
focus-neighbor Tab-style traversal (unchanged default engine behavior, not custom code).
This task adds the test that locks that in, since Task 2 changed the behavior silently.
- Step 1: Write the failing test
func test_footer_buttons_are_not_part_of_the_mode_carousel() -> void: var m := StartMenu.new(); m.has_progressed = true add_child_autofree(m) await get_tree().process_frame for c in m._cards: assert_false(String(c.get_meta("mode", "")) in ["shop", "bestiary", "ship_config", "remote"], "footer buttons must not be centered/paged as if they were modes")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL if footer buttons still append to _cards (check _footer_btn’s body from
the original file — it currently does _cards.append(b)).
- Step 3: Stop appending footer buttons to
_cards
In _footer_btn, remove the _cards.append(b) line — footer buttons are plain buttons with
their own .pressed connections (already wired at each call site in _ready()), and don’t
need to be in _cards now that confirm/nav only look at the mode carousel.
- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: PASS.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "fix(start-menu): footer buttons no longer ride the mode carousel's card list"Task 4: Mode carousel — touch/mouse drag + tween transitions, ui/start_menu.gd
Section titled “Task 4: Mode carousel — touch/mouse drag + tween transitions, ui/start_menu.gd”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd
Interfaces:
- Consumes:
_render_carousel,_compute_slots,_place_card,_carousel_step,_jump_tofrom Tasks 1–2. - Produces:
StartMenu._begin_drag(x: float),StartMenu._update_drag(x: float),StartMenu._end_drag(x: float),StartMenu._complete_drag(direction: int),StartMenu._cancel_drag(), drag state vars_drag_active,_drag_start_x,_drag_side_offset,_drag_incoming_dir,_transitioning.
This ports MetaShopPanel’s validated drag implementation (ui/meta_shop_panel.gd), adapted
to _items/_make_mode_card/_choose instead of shop defs/_activate. The full adapted
code is given in Step 3 below.
- Step 1: Write the failing test
func test_drag_past_commit_threshold_advances_the_carousel() -> void: var m := StartMenu.new() m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE], ["d","D","d",Color.YELLOW]] add_child_autofree(m) await get_tree().process_frame m._carousel_index = 0 m._render_carousel(0) var side_offset := m._slot_side_offset() m._begin_drag(500.0) m._update_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION + 0.05)) m._end_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION + 0.05)) assert_eq(m._carousel_index, 1, "a leftward drag past the commit threshold advances to the next item")
func test_short_drag_springs_back_without_changing_index() -> void: var m := StartMenu.new() m._items = [["a","A","d",Color.RED], ["b","B","d",Color.GREEN], ["c","C","d",Color.BLUE]] add_child_autofree(m) await get_tree().process_frame m._carousel_index = 0 m._render_carousel(0) var side_offset := m._slot_side_offset() m._begin_drag(500.0) m._update_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION - 0.05)) m._end_drag(500.0 - side_offset * (StartMenu.DRAG_COMMIT_FRACTION - 0.05)) assert_eq(m._carousel_index, 0, "a short drag springs back, index unchanged")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL — _begin_drag/_update_drag/_end_drag don’t exist yet.
- Step 3: Port the drag implementation
Add the member vars (near _carousel_index):
var _drag_active := falsevar _drag_start_x := 0.0var _drag_side_offset := 0.0var _drag_incoming_dir := 0var _transitioning := falseReplace the Task 1 stub _jump_to with the real wraparound-aware version:
# Only the immediate left/right neighbours are ever tappable, so the direction is exactly# which way we stepped -- diff is +1/-1 except at the wraparound seam, which needs the# short way round (e.g. index 0 -> n-1 is a "previous" step, not n-1 forward steps).func _jump_to(idx: int) -> void: var n := _items.size() var diff: int = idx - _carousel_index if diff > n / 2: diff -= n elif diff < -n / 2: diff += n _carousel_index = idx _ui_nav() _render_carousel(signi(diff))Replace the Task 1 _render_carousel with the animation-aware version (adds enter/exit
tweens when anim_direction != 0; identical to the anim_direction == 0 path otherwise):
func _render_carousel(anim_direction: int = 0) -> void: var old_children := _carousel_root.get_children() var n := _items.size() if n == 0: for c in old_children: c.queue_free() _cards.clear() return
var side_offset := _slot_side_offset() var travel: float = side_offset * TRAVEL_FRACTION
if anim_direction != 0 and not old_children.is_empty(): var exit_dx: float = -travel * anim_direction for old_card in old_children: var tw := create_tween() tw.set_ease(Tween.EASE_IN) tw.set_trans(Tween.TRANS_CUBIC) tw.tween_property(old_card, "position:x", old_card.position.x + exit_dx, ANIM_DURATION) tw.parallel().tween_property(old_card, "modulate:a", 0.0, ANIM_DURATION * 0.8) tw.tween_callback(old_card.queue_free) else: for c in old_children: c.queue_free()
_cards.clear() var slots := _compute_slots(_carousel_index, side_offset)
for slot in slots: var idx: int = slot[0] var x_off: float = slot[1] var scl: float = slot[2] var a: float = slot[3] var is_center: bool = slot[4] var card := _make_mode_card(_items[idx]) _carousel_root.add_child(card) var final_pos := _place_card(card, idx, x_off, scl) _cards.append(card) if is_center: card.grab_focus.call_deferred() card.pressed.connect(func() -> void: _ui_select(); _choose(String(card.get_meta("mode")))) else: card.pressed.connect(func() -> void: _jump_to(idx))
if anim_direction != 0: var enter_dx: float = travel * anim_direction card.position = Vector2(final_pos.x + enter_dx, final_pos.y) card.modulate = Color(1, 1, 1, 0.0) var tw := create_tween() tw.set_ease(Tween.EASE_OUT) tw.set_trans(Tween.TRANS_CUBIC) tw.tween_property(card, "position:x", final_pos.x, ANIM_DURATION) tw.parallel().tween_property(card, "modulate:a", a, ANIM_DURATION) else: card.position = final_pos card.modulate = Color(1, 1, 1, a)Add _begin_drag/_update_drag:
func _begin_drag(x: float) -> void: if _items.size() < 2 or _drag_active or _transitioning: return _drag_active = true _drag_start_x = x _drag_incoming_dir = 0 _drag_side_offset = _slot_side_offset() for c in _carousel_root.get_children(): c.set_meta("drag_base_x", c.position.x)
func _update_drag(x: float) -> void: if not _drag_active: return var dx: float = x - _drag_start_x
# Dragging RIGHT (dx>0) reveals the PREVIOUS item (direction -1); dragging LEFT reveals NEXT (+1). if _drag_incoming_dir == 0 and absf(dx) > 10.0: _drag_incoming_dir = -1 if dx > 0 else 1
# Lazily build the 4th "incoming" card, one slot further out than the side card that's # about to become the new center: `new_index + direction`, where `new_index` is what # _complete_drag will also compute as the post-commit center. Gated to n>=4 (for n==3, # center+both neighbours already account for every item; for n==2 the "other" item is # already one of the 2 existing cards). if _items.size() >= 4: var new_index: int = (_carousel_index + _drag_incoming_dir + _items.size()) % _items.size() var incoming_idx: int = (new_index + _drag_incoming_dir + _items.size()) % _items.size() var incoming_x_off: float = _drag_side_offset * _drag_incoming_dir var card := _make_mode_card(_items[incoming_idx]) _carousel_root.add_child(card) var final_pos := _place_card(card, incoming_idx, incoming_x_off, SIDE_SCALE) card.modulate = Color(1, 1, 1, 0.0) card.set_meta("drag_base_x", final_pos.x - dx) # so base_x + dx == final_pos.x right now card.set_meta("drag_incoming_alpha", SIDE_ALPHA) card.position.y = final_pos.y card.pressed.connect(func() -> void: _jump_to(incoming_idx)) # NOT appended to _cards here -- it's a transient mid-gesture visual; _complete_drag # promotes it (by matching item_idx) or _cancel_drag frees it without ever adding it.
for c in _carousel_root.get_children(): if not c.has_meta("drag_base_x"): continue c.position.x = float(c.get_meta("drag_base_x")) + dx if c.has_meta("drag_incoming_alpha"): var t: float = clampf(absf(dx) / maxf(_drag_side_offset, 1.0), 0.0, 1.0) c.modulate.a = t * float(c.get_meta("drag_incoming_alpha"))Add _end_drag/_complete_drag/_cancel_drag:
func _end_drag(x: float) -> void: if not _drag_active: return _drag_active = false var dx: float = x - _drag_start_x var commit_threshold: float = _drag_side_offset * DRAG_COMMIT_FRACTION if _drag_incoming_dir != 0 and absf(dx) >= commit_threshold: _complete_drag(_drag_incoming_dir) else: _cancel_drag()
# Tween every currently-visible card from its live mid-drag position to its TRUE resting slot# for the new center index, matched by the item_idx each card was tagged with when placed# (not by role) -- robust regardless of exactly how far the drag got before release.func _complete_drag(direction: int) -> void: _transitioning = true var new_index: int = (_carousel_index + direction + _items.size()) % _items.size() _carousel_index = new_index _ui_nav() var side_offset := _slot_side_offset() var target_slots := _compute_slots(new_index, side_offset) var matched: Dictionary = {} # item_idx -> [x_off, scale, alpha, is_center] for slot in target_slots: matched[int(slot[0])] = [slot[1], slot[2], slot[3], slot[4]]
_cards.clear() for c in _carousel_root.get_children(): if not c.has_meta("item_idx"): c.queue_free() continue var idx: int = c.get_meta("item_idx") var tw := create_tween() tw.set_ease(Tween.EASE_OUT) tw.set_trans(Tween.TRANS_CUBIC) if matched.has(idx): var target: Array = matched[idx] var is_center: bool = target[3] var final_pos := _place_card(c, idx, target[0], target[1]) tw.tween_property(c, "position", final_pos, ANIM_DURATION) tw.parallel().tween_property(c, "modulate:a", target[2], ANIM_DURATION) _cards.append(c) # A card kept across a completed drag carries whatever `pressed` connection it got # from its LAST render -- its ROLE can change here (a side card can become the new # center, or vice versa), so that stale wiring must be replaced. for conn in c.pressed.get_connections(): c.pressed.disconnect(conn["callable"]) if is_center: c.grab_focus.call_deferred() c.pressed.connect(func() -> void: _choose(String(c.get_meta("mode")))) else: c.pressed.connect(func() -> void: _jump_to(idx)) c.remove_meta("drag_base_x") c.remove_meta("drag_incoming_alpha") else: var exit_dx: float = side_offset * TRAVEL_FRACTION * signi(direction) tw.tween_property(c, "position:x", c.position.x + exit_dx, ANIM_DURATION) tw.parallel().tween_property(c, "modulate:a", 0.0, ANIM_DURATION * 0.8) tw.tween_callback(c.queue_free)
# _cards[0] must be the actual center -- the confirm handler in _input reads _cards[0] # unconditionally. Re-sort since this loop follows scene-tree child order, not slot role. for i in _cards.size(): if int(_cards[i].get_meta("item_idx")) == new_index: var tmp: Button = _cards[0] _cards[0] = _cards[i] _cards[i] = tmp break
var reset_tw := create_tween() reset_tw.tween_interval(ANIM_DURATION) reset_tw.tween_callback(func() -> void: _transitioning = false)
func _cancel_drag() -> void: _transitioning = true for c in _carousel_root.get_children(): if not c.has_meta("drag_base_x"): continue var tw := create_tween() tw.set_ease(Tween.EASE_OUT) tw.set_trans(Tween.TRANS_CUBIC) if c.has_meta("drag_incoming_alpha"): tw.tween_property(c, "position:x", float(c.get_meta("drag_base_x")), ANIM_DURATION) tw.parallel().tween_property(c, "modulate:a", 0.0, ANIM_DURATION) tw.tween_callback(c.queue_free) else: tw.tween_property(c, "position:x", float(c.get_meta("drag_base_x")), ANIM_DURATION) c.remove_meta("drag_base_x")
var reset_tw := create_tween() reset_tw.tween_interval(ANIM_DURATION) reset_tw.tween_callback(func() -> void: _transitioning = false)Wire drag events into _input, inserted before the confirm/nav checks already there:
if event is InputEventScreenTouch: if event.index != 0: return if event.pressed: _begin_drag(event.position.x) else: _end_drag(event.position.x) return elif event is InputEventScreenDrag: if event.index == 0: _update_drag(event.position.x) return elif event is InputEventMouseMotion and _drag_active: _update_drag(event.position.x) return- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: PASS.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "feat(start-menu): live-follow drag for the mode carousel, ported from the shop"Task 5: Remove the inline ship-picker row (ui/start_menu.gd)
Section titled “Task 5: Remove the inline ship-picker row (ui/start_menu.gd)”Files:
- Modify:
ui/start_menu.gd - Test:
tests/test_start_menu.gd
Interfaces:
-
Consumes: nothing from prior tasks (independent removal).
-
Produces: nothing — pure deletion.
-
Step 1: Write the failing test
func test_no_ship_picker_tiles_on_the_start_menu() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame for c in m._cards: assert_false(String(c.get_meta("mode", "")).begins_with("ship:"), "the inline ship-tile row is removed -- ship choice lives in ShipConfigPanel now")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: FAIL — ship:-tagged cards still exist.
- Step 3: Delete the ship row
Remove from ui/start_menu.gd: the ship_spacer/ship_label/ship_row construction block
in _ready(), the for ship_id in ShipBonuses.ORDER: ... loop that builds _make_ship_button
tiles, the _make_ship_button function itself, _pick_ship, the _ship_buttons member var,
and the SHIP_TILE/SHIP_THUMB constants (no longer used anywhere in this file). Also
remove the sel_mode.begins_with("ship:") branch from _input (dead now that no ship:
card is ever built).
- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_start_menu -gexit
Expected: PASS.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/start_menu.gd tests/test_start_menu.gdgit commit -m "refactor(start-menu): remove the inline ship-picker row (moved into ShipConfigPanel)"Task 6: ShipConfigPanel — retain meta + pre-run flag, add ship-index browsing state
Section titled “Task 6: ShipConfigPanel — retain meta + pre-run flag, add ship-index browsing state”Files:
- Modify:
ui/ship_config_panel.gd - Test:
tests/test_ship_config_panel.gd
Interfaces:
- Produces:
ShipConfigPanel._meta: MetaState,ShipConfigPanel._pre_run: bool,ShipConfigPanel._ship_index: int,ShipConfigPanel._current_ship_id() -> String,ShipConfigPanel._rebuild_for_ship(ship_id: String, sim: Sim) -> void(extracted from the body ofopen_config). - Consumes:
ShipBonuses.ORDER,ShipBonuses.DEFAULT_SHIP.
open_config(meta, sim) currently rebuilds the display inline and discards meta/sim
afterward — nothing persists them for later input handling. This task retains what paging
needs, without changing the panel’s visible behavior yet (paging lands in Task 8).
- Step 1: Write the failing test
func test_pre_run_open_sets_pre_run_flag_and_ship_index() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() meta.selected_ship = "amethyst" p.open_config(meta, null) assert_true(p._pre_run, "sim == null -> pre-run mode") assert_eq(p._ship_index, ShipBonuses.ORDER.find("amethyst"), "ship index matches the equipped ship")
func test_mid_run_open_clears_pre_run_flag() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) p.open_config(MetaState.new(), sim) assert_false(p._pre_run, "sim != null -> mid-run, no paging")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: FAIL — _pre_run/_ship_index don’t exist yet.
- Step 3: Add the retained state and refactor
open_config
Add member vars (near _open):
var _meta: MetaStatevar _pre_run: bool = falsevar _ship_index: int = 0Replace open_config’s body with:
func open_config(meta: MetaState, sim: Sim) -> void: _open = true visible = true _meta = meta _pre_run = sim == null var ship_id := meta.selected_ship if meta != null else ShipBonuses.DEFAULT_SHIP _ship_index = maxi(ShipBonuses.ORDER.find(ship_id), 0) _rebuild_for_ship(ship_id, sim)
func _current_ship_id() -> String: return ShipBonuses.ORDER[_ship_index]
func _rebuild_for_ship(ship_id: String, sim: Sim) -> void: _title_lbl.text = ShipBonuses.name_for(ship_id).to_upper() _bonus_lbl.text = ShipBonuses.label_for(ship_id) + (" · Lv %d" % sim.player.level if sim != null else "") _ship_thumb.texture = load("res://render/ship_sprites/ship3d_%s.png" % ship_id)
for c in _weapon_cards: c.queue_free() _weapon_cards.clear()
var owned := sim.upgrade_system.active_weapon_views(sim) if sim != null else [{"glyph": "blade", "name": "Blade", "stat": ""}] var slot_count := sim.max_weapon_slots if sim != null else ShipBonuses.weapon_slots_for(ship_id) for i in range(slot_count): var card := _make_weapon_card(owned[i] if i < owned.size() else {}) _weapon_cards.append(card) _root.add_child(card)
_rebuild_drone_row(sim) _layout()(This is the same body open_config had before, just split so _rebuild_for_ship can be
called again later, from paging, without re-doing the _open/visible/_pre_run setup.)
- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: PASS — and all pre-existing tests in this file (listed in the design spec’s Testing
section) still pass unmodified, since _rebuild_for_ship’s body is byte-identical to the old
open_config body it was extracted from.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gdgit commit -m "refactor(ship-config): retain meta + pre-run flag, extract _rebuild_for_ship"Task 7: ShipConfigPanel — paging (browse) with slide+crossfade, confirm persists
Section titled “Task 7: ShipConfigPanel — paging (browse) with slide+crossfade, confirm persists”Files:
- Modify:
ui/ship_config_panel.gd - Test:
tests/test_ship_config_panel.gd
Interfaces:
- Consumes:
_pre_run,_ship_index,_current_ship_id(),_rebuild_for_ship()from Task 6. - Produces:
ShipConfigPanel._page_ship(direction: int) -> void,ShipConfigPanel._confirm_ship() -> void.
Per the design spec, this carousel shows exactly one ship at a time (no visible dimmed
side neighbors — there’s no room once the weapon ring is laid out) — paging is a
slide+crossfade of the whole _root composition, not a 3-up hologram layout. Persistence
happens on confirm, not on every page step (browsing doesn’t equip a ship until you commit).
- Step 1: Write the failing test
func test_page_ship_wraps_and_rebuilds_without_persisting() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() meta.selected_ship = ShipBonuses.ORDER[0] p.open_config(meta, null) p._page_ship(-1) assert_eq(p._ship_index, ShipBonuses.ORDER.size() - 1, "paging left from index 0 wraps to the last ship") assert_eq(meta.selected_ship, ShipBonuses.ORDER[0], "paging alone does not persist the selection") assert_eq(p._title_lbl.text, ShipBonuses.name_for(ShipBonuses.ORDER[ShipBonuses.ORDER.size() - 1]).to_upper(), "the display rebuilds for the newly-centered ship")
func test_confirm_ship_persists_when_owned() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() meta.selected_ship = ShipBonuses.ORDER[0] p.open_config(meta, null) p._page_ship(1) var expected := ShipBonuses.ORDER[1] if meta.owns_ship(expected): p._confirm_ship() assert_eq(meta.selected_ship, expected, "confirm persists the centered, owned ship")
func test_confirm_ship_does_not_persist_when_locked() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() var locked_idx := -1 for i in range(ShipBonuses.ORDER.size()): if not meta.owns_ship(ShipBonuses.ORDER[i]): locked_idx = i break if locked_idx == -1: pass_test("no locked ship in this MetaState fixture -- nothing to assert") return meta.selected_ship = ShipBonuses.ORDER[0] p.open_config(meta, null) p._ship_index = locked_idx p._confirm_ship() assert_eq(meta.selected_ship, ShipBonuses.ORDER[0], "confirming a locked ship is a no-op")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: FAIL — _page_ship/_confirm_ship don’t exist.
- Step 3: Implement paging + confirm
const PAGE_ANIM_DURATION := 0.25const PAGE_TRAVEL := 90.0
func _page_ship(direction: int) -> void: var n := ShipBonuses.ORDER.size() _ship_index = (_ship_index + direction + n) % n _ui_nav() var ship_id := _current_ship_id() # Slide+crossfade the whole hero+ring composition: fade the current content out while # sliding it further in the drag direction, rebuild for the new ship, then fade+slide in # from the opposite side. Matches ANIM_DURATION/TRANS_CUBIC used by the shop/mode carousels # for visual consistency, at a smaller travel distance (PAGE_TRAVEL) since this is a single # hero composition, not a multi-slot carousel. var out_tw := create_tween() out_tw.set_ease(Tween.EASE_IN) out_tw.set_trans(Tween.TRANS_CUBIC) out_tw.tween_property(_root, "position:x", _root.position.x - PAGE_TRAVEL * direction, PAGE_ANIM_DURATION) out_tw.parallel().tween_property(_root, "modulate:a", 0.0, PAGE_ANIM_DURATION) out_tw.tween_callback(func() -> void: _rebuild_for_ship(ship_id, null) _root.position.x += PAGE_TRAVEL * direction * 2.0 var in_tw := create_tween() in_tw.set_ease(Tween.EASE_OUT) in_tw.set_trans(Tween.TRANS_CUBIC) in_tw.tween_property(_root, "position:x", _root.position.x - PAGE_TRAVEL * direction, PAGE_ANIM_DURATION) in_tw.parallel().tween_property(_root, "modulate:a", 1.0, PAGE_ANIM_DURATION))
func _confirm_ship() -> void: var ship_id := _current_ship_id() if _meta == null or not _meta.owns_ship(ship_id): return _meta.selected_ship = ship_id MetaStore.save_state(_meta)Locked-ship dimming (ported from the deleted _make_ship_button’s locked treatment):
in _rebuild_for_ship, after setting _ship_thumb.texture, add:
_ship_thumb.modulate = Color(0.45, 0.45, 0.5) if (_meta != null and not _meta.owns_ship(ship_id)) else Color.WHITE- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: PASS. Note the tween-driven rebuild inside _page_ship runs on a callback, not
synchronously — the test above calls _page_ship and asserts immediately, which only works
because _rebuild_for_ship’s call sits behind the OUT tween’s tween_callback. If this
proves flaky in practice (tween timing in a headless test), simplify Task 7 by rebuilding
synchronously and only tweening the fade/slide as cosmetic polish — check the test’s real
behavior before assuming the async version is correct; adjust to synchronous-rebuild if the
tween-gated version doesn’t resolve within a single test frame.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gdgit commit -m "feat(ship-config): pre-run ship paging with slide+crossfade, confirm persists"Task 8: ShipConfigPanel — input wiring (left/right pages, confirm persists, gated pre-run)
Section titled “Task 8: ShipConfigPanel — input wiring (left/right pages, confirm persists, gated pre-run)”Files:
- Modify:
ui/ship_config_panel.gd - Test:
tests/test_ship_config_panel.gd
Interfaces:
-
Consumes:
_page_ship,_confirm_ship,_pre_runfrom Tasks 6–7. -
Produces: updated
ShipConfigPanel._input. -
Step 1: Write the failing test
func test_left_right_pages_ship_only_when_pre_run() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() meta.selected_ship = ShipBonuses.ORDER[0] p.open_config(meta, null) # MenuNav.is_right() checks event.is_action_pressed("ui_right") (also "move_right"/ # "aim_right" -- see ui/menu_nav.gd), so a synthetic InputEventAction is enough, same # pattern as the "ui_accept" confirm test just below. var right := InputEventAction.new() right.action = "ui_right" right.pressed = true p._input(right) assert_eq(p._ship_index, 1, "right pages to the next ship")
func test_left_right_is_a_noop_mid_run() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var sim := Sim.new(1, SimContentFixture.db()) p.open_config(MetaState.new(), sim) var before := p._ship_index var right := InputEventAction.new() right.action = "ui_right" right.pressed = true p._input(right) assert_eq(p._ship_index, before, "mid-run: left/right does nothing")
func test_confirm_persists_the_centered_ship_when_pre_run() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) await get_tree().process_frame var meta := MetaState.new() meta.selected_ship = ShipBonuses.ORDER[0] p.open_config(meta, null) if meta.owns_ship(ShipBonuses.ORDER[0]): var confirm := InputEventAction.new() confirm.action = "ui_accept" confirm.pressed = true p._input(confirm) assert_eq(meta.selected_ship, ShipBonuses.ORDER[0])- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: FAIL — _input doesn’t yet check _pre_run/call _page_ship/_confirm_ship.
- Step 3: Extend
_input
Insert into the existing _input (before the back check, since back/Esc must still work
regardless of _pre_run):
if _pre_run: var confirm := event.is_action_pressed("ui_accept") if not confirm and event is InputEventJoypadButton: var je: InputEventJoypadButton = event confirm = je.pressed and je.button_index == JOY_BUTTON_A if confirm: _ui_select() _confirm_ship() get_viewport().set_input_as_handled() return if MenuNav.is_right(event) or MenuNav.is_left(event): _page_ship(1 if MenuNav.is_right(event) else -1) get_viewport().set_input_as_handled() return(Placed as the first block inside _input, before the existing back check — ui_cancel/
JOY_BUTTON_B/Esc still falls through to the existing close() call unchanged in both
pre-run and mid-run cases.)
- Step 4: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_config_panel -gexit
Expected: PASS.
-
Step 5: Full suite + boot-check (same commands as Task 2 Step 5)
-
Step 6: Commit
git add ui/ship_config_panel.gd tests/test_ship_config_panel.gdgit commit -m "feat(ship-config): wire left/right paging + confirm-to-persist, gated pre-run"Task 9: Determinism, full-suite verification, and real-device check
Section titled “Task 9: Determinism, full-suite verification, and real-device check”Files: none (verification only).
- Step 1: Full suite + count guard
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: empty grep; “All tests passed” or only the 2 pre-existing unrelated risky tests;
count guard passes (matches tests/test_*.gd file count).
- Step 2: Determinism baseline
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_checksum -gexitExpected: PASS — this plan touches no /sim file, so the pinned baseline
(snapshot_string().hash()=4217109746, state_checksum()=2666143677) must hold unchanged.
- Step 3: Local macOS export + manual look
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.appopen builds/macos/BulletHeaven.appManually confirm: the mode carousel renders centered and correctly styled with only
“SURVIVAL” showing (V01_CRYSTALS_ONLY); the SHIP row is gone from the start menu; pressing
the “Ship” footer button opens a carousel-capable ShipConfigPanel that pages between all
7 ShipBonuses.ORDER hulls with left/right, shows LOCKED ships dimmed, and confirming an
owned ship sticks (re-open the panel and see it’s still centered on that ship next time).
- Step 4: Real-device check (Apple TV, matching this session’s established cadence)
Follow the bh-deploy skill’s tvOS section: bump Sim_Const.BUILD in sim/constants.gd,
verify the folded platform/tvos/ project (--import, boot-check, full suite matching root’s
script count), export-pack + xcodebuild + devicectl install/launch. Confirm on-device: the
carousel is legible/navigable via the Apple TV remote (D-pad emulation through MenuNav), and
the Siri Remote filter (already shipped, unrelated to this change) still keeps the actual
remote from moving anything.
- Step 5: Final commit
git add sim/constants.gdgit commit -m "chore: bump BUILD for the start menu carousel deploy"