Skip to content

Shop Carousel + Hologram Corner Brackets Implementation Plan

Shop Carousel + Hologram Corner Brackets Implementation Plan

Section titled “Shop Carousel + Hologram Corner Brackets 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: Replace ui/meta_shop_panel.gd’s grid-based card layout and 2D navigation with a centered, 3-visible-cards-at-a-time carousel styled as Hologram Corner Brackets, for every view the panel renders (root categories, Drones sub-categories, every upgrade list).

Architecture: Port the validated tools/shop_lab/shop_lab.gd prototype’s carousel math (dynamic viewport-relative centering, item-index-tagged slot matching, live-follow drag, tween-based transitions) into the real panel, wiring it to the panel’s existing data calls (ShopCategories, MetaState, _current_upgrade_defs()) instead of the lab’s fake data. The grid (GridContainer, _columns/_columns_for/_card_width, the row/column _sel math in _input()) is deleted, not layered alongside the carousel.

Tech Stack: Godot 4.6.3 GDScript, GUT 9.6.0 for tests.

  • CENTER_SCALE = 1.85, SIDE_SCALE = 0.62, SIDE_ALPHA = 0.4, SIDE_GAP = 26.0, CENTER_Y_FRAC = 0.566, ANIM_DURATION = 0.3, TRAVEL_FRACTION = 0.6, DRAG_COMMIT_FRACTION = 0.22 — exact values from the validated lab prototype, carried over verbatim.
  • Centering is computed fresh every render from get_viewport().get_visible_rect().size — never a fixed design-space constant (this is the fix for the real-iPhone “not quite center” bug found in the lab).
  • No change to ShopCategories, MetaState, any def: Dictionary field, _close_btn, _go_back(), NeonBackdrop, or the start menu.
  • Up/down gamepad navigation is dropped entirely, not repurposed.
  • This is a render/UI-only change (no /sim files touched) — the pinned determinism baseline (snapshot_string().hash()=2730172591, state_checksum()=4075578713) must hold by construction; re-verify after every task anyway.
  • Full suite currently: 189 scripts / 1347 tests, all green. Re-run bash scripts/check-test-count.sh after every task — it fails loud if GUT silently drops a script (stale class cache / parse error), which a plain “tests passed” can hide.
  • Boot-check after every task: godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR" must be empty.

ui/meta_shop_panel.gd (630 lines currently) is a class_name MetaShopPanel extends CanvasLayer. Its current relevant state: _grid: GridContainer, _cards: Array[Button], _columns: int, _sel: int, _last_nav_ms: int. _render(keep: int) rebuilds _header + _grid from scratch for the current _view, then calls _finish_view(keep) which restores selection and plays a staggered entrance tween. _make_card/ _make_upgrade_card/_set_card_text/_set_root_card_content build card content; _activate(id) handles confirm (drill-in / buy / equip-decoy); _go_back() handles back/close. _input() handles confirm/back/directional nav. MenuNav (ui/menu_nav.gd) is a RefCounted with static is_up/is_down/is_left/is_right(event) -> bool.

The lab prototype this plan ports from lives at tools/shop_lab/shop_lab.gd — read it directly if a task’s code below needs more surrounding context than what’s quoted.


Task 1: Hologram Corner Brackets card style

Section titled “Task 1: Hologram Corner Brackets card style”

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: tests/test_meta_shop_panel.gd

Interfaces:

  • Produces: MetaShopPanel._HologramBrackets (inner class, extends Control, custom _draw()), MetaShopPanel._make_card(id, accent, bright, card_w, card_h) -> Button (same signature as today, new internal styling).
  • Consumes: nothing new from other tasks (this task is visual-only, grid untouched).

This task ONLY swaps the card’s visual container — grid layout, _sel, navigation are all untouched here, so it’s reviewable in isolation before the bigger structural change in Task 2.

  • Step 1: Write the failing test

Add to tests/test_meta_shop_panel.gd (near the existing card-fill tests — grep for test_card_fill_is_translucent_glass_not_opaque to find that section):

func test_cards_use_hologram_corner_brackets_not_a_filled_box() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var card: Button = p._cards[0]
# A hologram card has no StyleBoxFlat fill override (or a near-fully-transparent one) --
# the corner brackets are drawn by a child Control's _draw(), not a background stylebox.
var brackets: Control = null
for c in card.get_children():
if c is Control and c.has_method("_draw"):
brackets = c
break
assert_not_null(brackets, "card has a brackets-drawing child control")
p.hide_panel()
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: FAIL — test_cards_use_hologram_corner_brackets_not_a_filled_box fails because today’s cards only have Label/icon children, no brackets-drawing Control.

  • Step 3: Add the _HologramBrackets inner class

In ui/meta_shop_panel.gd, add near the bottom of the file (after _card_box, which stays — _close_btn still uses it):

# Hologram Corner Brackets (2026-07-02): no fill, no full-rectangle border -- four L-shaped
# corner brackets in the card's accent colour plus a very faint full-card wash so the
# clickable area still reads as a shape. Direct, unmodified port of tools/shop_lab/
# shop_lab.gd's _style_hologram/_HologramBrackets (validated across several rounds of
# live iPhone testing before Chris picked this as the shop's visual direction).
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.18
var col := Color(accent.r, accent.g, accent.b, 0.9)
# top-left
draw_line(Vector2(0, arm), Vector2.ZERO, col, 2.0)
draw_line(Vector2.ZERO, Vector2(arm, 0), col, 2.0)
# top-right
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)
# bottom-left
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)
# bottom-right
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)
draw_rect(Rect2(Vector2.ZERO, s), Color(accent.r, accent.g, accent.b, 0.04), true)
  • Step 4: Replace _make_card’s styling

Replace the current _make_card (lines 384-395 today):

func _make_card(id: String, accent: Color, bright: bool, card_w: float = 300.0, card_h: float = CARD_H) -> Button:
var card: Button = Button.new()
card.set_meta("id", id)
card.focus_mode = Control.FOCUS_ALL
card.custom_minimum_size = Vector2(card_w, card_h)
var fill: float = 0.18 if bright else 0.06
card.add_theme_stylebox_override("normal", _card_box(accent, fill))
card.add_theme_stylebox_override("hover", _card_box(accent, fill + 0.1))
card.add_theme_stylebox_override("pressed", _card_box(accent, fill + 0.16))
card.add_theme_stylebox_override("focus", _card_box(accent, fill + 0.12))
card.pressed.connect(func() -> void: _activate(id))
return card

with:

func _make_card(id: String, accent: Color, bright: bool, card_w: float = 300.0, card_h: float = CARD_H) -> Button:
var card: Button = Button.new()
card.set_meta("id", 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)
card.pressed.connect(func() -> void: _activate(id))
return card

bright is now unused by this function (it used to pick a stronger/weaker fill alpha — hologram cards don’t have a fill to vary). Leave the parameter in place; every call site still passes it, and removing it would touch every call site for no behavioral gain in this task. A later cleanup task in this plan (Task 4) removes the parameter once we’ve confirmed nothing needs it.

  • Step 5: Delete the two tests this style change makes obsolete

Running the suite now would show test_card_fill_is_translucent_glass_not_opaque and test_card_box_still_keeps_its_neon_border_and_glow_shadow newly failing — both assert properties of the old _card_box-based StyleBoxFlat fill (border color, shadow, fill alpha), none of which the hologram card has any more. Find and delete both test functions entirely from tests/test_meta_shop_panel.gd now, in this task, rather than leaving the suite red across a task boundary.

  • Step 6: Fix the child-index shift in test_root_cards_have_a_category_icon

_make_card now adds a _HologramBrackets child to every card BEFORE _set_root_card_content/_set_card_text add their icon/name/count children — any test that reads card.get_children()[N] expecting the icon at a specific index needs that index bumped by one. Find test_root_cards_have_a_category_icon in tests/test_meta_shop_panel.gd, read its get_children()[...] access, and update the index to account for the new leading _HologramBrackets child. Verify the corrected index is right by running just this test (Step 7) rather than guessing.

  • Step 7: Run all of test_meta_shop_panel.gd to verify it’s fully green

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: PASS — test_cards_use_hologram_corner_brackets_not_a_filled_box passes, the two obsolete tests are gone (not failing), and test_root_cards_have_a_category_icon passes with its corrected child index.

  • Step 8: Run the full suite + boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"

Expected: count guard passes at 189 scripts, all green; no SCRIPT ERROR output.

  • Step 9: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git commit -m "feat(shop): Hologram Corner Brackets card style
Ports tools/shop_lab/shop_lab.gd's validated _HologramBrackets look into
the real shop panel -- no fill, four L-shaped corner brackets in the
card's accent colour, backdrop visible straight through. Grid layout
and navigation are untouched in this task; Task 2 replaces those."

Section titled “Task 2: Carousel core rendering (static, all views)”

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: tests/test_meta_shop_panel.gd

Interfaces:

  • Consumes: _make_card from Task 1 (hologram styling), _HologramBrackets (unchanged).
  • Produces: MetaShopPanel._carousel_root: Control, MetaShopPanel._items: Array (the current view’s raw item list — categories, sub-category names, or def dicts), MetaShopPanel._carousel_index: int (replaces _sel), _viewport_size() -> Vector2, _center() -> Vector2, _slot_side_offset() -> float, _compute_slots(center_i: int, side_offset: float) -> Array, _place_card(card: Button, idx: int, x_off: float, scl: float) -> Vector2, _render_carousel(anim_direction: int = 0) -> void (instant-only in this task — anim_direction param exists now so Task 3 can add behavior without changing the signature every call site already uses). _cards: Array[Button] STAYS (now populated from _carousel_root.get_children() after each render, still used by _activate’s buy-flash lookup and by test_meta_shop.gd’s count assertions — see Step 6).

This is the largest task: it deletes the grid and wires every view (root, Drones, upgrade lists) through the carousel instead. No animation yet — paging is an instant re-render (anim_direction stays 0 everywhere in this task); Task 3 adds the tween.

  • Step 1: Write the failing tests

Replace the grid-specific tests in tests/test_meta_shop_panel.gd. First, locate and DELETE these test functions entirely (grep for each name): test_columns_scale_with_count, test_columns_for_four_cards_is_one_full_row, test_columns_for_seven_avoids_a_lone_orphan_row, test_root_view_uses_a_centered_2x2_grid_of_square_icon_tiles, test_non_root_views_shrink_center_the_grid_too, test_sparse_category_cards_are_width_capped_not_stretched_huge, test_nav_gutter_reclaims_width_for_long_category_names, test_drones_subcategory_cards_use_the_nav_gutter_not_the_upgrade_gutter, test_focused_card_draws_above_its_neighbours, test_category_view_card_width_fits_viewport.

Then add these new tests (anywhere in the file, grouped together with a # ---- Carousel rendering ---- comment for clarity):

func test_root_view_shows_at_most_three_cards_centered_bigger() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
assert_lte(p._cards.size(), 3, "carousel never renders more than 3 cards at once")
var center: Button = p._cards[p._carousel_index]
assert_almost_eq(center.scale.x, MetaShopPanel.CENTER_SCALE, 0.001, "center card is scaled up")
p.hide_panel()
func test_side_cards_are_smaller_and_dimmer_than_center() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
assert_gte(p._cards.size(), 2, "root has enough categories for at least one side card")
for i in p._cards.size():
if i == p._carousel_index:
continue
assert_almost_eq(p._cards[i].scale.x, MetaShopPanel.SIDE_SCALE, 0.001)
assert_almost_eq(p._cards[i].modulate.a, MetaShopPanel.SIDE_ALPHA, 0.001)
p.hide_panel()
func test_every_rendered_card_is_tagged_with_its_item_index() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
for c in p._cards:
assert_true(c.has_meta("item_idx"), "every carousel card is tagged for transition matching")
p.hide_panel()
func test_carousel_is_centered_on_the_actual_viewport_not_a_fixed_constant() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var center: Button = p._cards[p._carousel_index]
var vp_w: float = p.get_viewport().get_visible_rect().size.x
var card_center_x: float = center.position.x + center.custom_minimum_size.x * 0.5
assert_almost_eq(card_center_x, vp_w * 0.5, 1.0, "center card sits on the true viewport center")
p.hide_panel()
func test_drones_category_shows_subcategory_tiles_carousel() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
p._show_category("Drones")
var subcats := ShopCategories.drone_subcategories_present(content.meta_upgrades())
assert_eq(p._items.size(), subcats.size() + 1, "Drones sub-category tiles + Back, in the full item list")
assert_lte(p._cards.size(), 3, "still only renders up to 3 at once")
p.hide_panel()
func test_arsenal_category_with_few_items_shows_only_what_exists() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
p._show_category("Arsenal")
var n := ShopCategories.in_category(content.meta_upgrades(), "Arsenal").size()
assert_eq(p._items.size(), n + 1, "Arsenal's upgrades + Back")
# Arsenal has 1 upgrade + Back = 2 items -> center + exactly one side, never a duplicate.
if p._items.size() == 2:
assert_eq(p._cards.size(), 2, "2-item view shows center + one side, not both duplicated")
p.hide_panel()
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: FAIL — _carousel_index, _items, CENTER_SCALE etc. don’t exist yet; the deleted tests are gone so they no longer run (that’s correct, not a failure to fix).

  • Step 3: Delete grid state and add carousel state

Replace these lines near the top of ui/meta_shop_panel.gd (lines 20-61 today):

const CARD_H: float = 96.0
const CARD_GAP: int = 10
const MARGIN_H: int = 20 # left/right margin around the grid
const NAV_DEBOUNCE_MS: int = 200
const BACK_ID := "__back__"
# Root category cards (2026-07-02): square icon tiles in a centered 2x2 grid. Went through two
# rounds -- first a full-width stretched row, then a centered 1x4 vertical column of the same
# wide list-style cards (Chris: "space the menu buttons out better... center them... 1x4
# vertical") -- but a follow-up screenshot asked for square/vertical cards with an icon and an
# "overall better layout", so the root view now gets its OWN content layout (_set_root_card_
# content) instead of reusing the wide name+subtitle+"▶" row style _set_card_text draws for
# every other view.
const ROOT_CARD_SIZE: float = 220.0
const ROOT_ICON_SCALE: float = 2.2 # ShopIcons draws at 32x32; scaled up for a hero icon
# Navigation cards (root categories + Drones sub-categories) only ever show a single "▶" chevron
# as their right_text -- the upgrade cards' 124px gutter is sized for variable-length cost/state
# strings ("9999 gold", "SELECT"), wildly oversized for one glyph. Chris: "in the drone menu the
# text is still being lost" -- e.g. "Interceptor"/"Disruptor" were clipping into that reserved
# space. Reclaiming it fixes the clipping without touching font size or column count.
const NAV_GUTTER: float = 34.0
const NAV_RIGHT_W: float = 24.0
# Deeper views (Drones sub-category tiles, upgrade lists) previously stretched cards to fill the
# FULL row width and top-anchored the grid -- a sparse row (e.g. Arsenal's 1 upgrade + Back) drew
# two ~600px-wide cards pinned to the top with a huge empty area below (Chris: "check the deeper
# menus of the drones, see if we can center everything on the screen more rather than going from
# top left"). Capping card width + shrink-centering the grid (see _card_width and the Drones/else
# branches of _render) makes a sparse row read as a modest, centered block instead.
const LIST_CARD_MAX_W: float = 380.0
const GOLD := Color(1.0, 0.84, 0.3)
const STEEL := Color(0.55, 0.8, 1.0)
const GLASS_ALPHA := 0.58 # 2026-07-02 "glassy ambient" restyle: translucent card fill so the
# NeonBackdrop's glow blobs read through (was 0.97, near-opaque)
var _backdrop: NeonBackdrop
var _header: VBoxContainer
var _grid: GridContainer
var _close_btn: Button # always-visible touch-safe close/back (top-left) — see the header note
var _cards: Array[Button] = []
var _columns: int = 3
var _sel: int = 0
var _last_nav_ms: int = 0

with:

const CARD_H: float = 96.0
const NAV_DEBOUNCE_MS: int = 200
const BACK_ID := "__back__"
const ROOT_CARD_SIZE: float = 220.0
const ROOT_ICON_SCALE: float = 2.2 # ShopIcons draws at 32x32; scaled up for a hero icon
const LIST_CARD_W: float = 300.0 # a fixed local width for wide-row cards -- the carousel
# never needs to fit N cards side by side, so there is no
# column-count-driven width calc to do any more.
const LIST_CARD_MAX_W: float = 380.0 # kept as the upper visual bound content-builders can lean on
# NAV_GUTTER/NAV_RIGHT_W: KEPT (not a carousel concern) -- navigation cards (Drones sub-category
# tiles) only ever show a single "▶" right_text glyph, and LIST_CARD_W is unchanged by the
# carousel, so the earlier text-clipping fix (the 124px default gutter, sized for cost/state
# strings, wasted space and clipped names like "Interceptor"/"Disruptor") is still needed here.
const NAV_GUTTER: float = 34.0
const NAV_RIGHT_W: float = 24.0
const GOLD := Color(1.0, 0.84, 0.3)
const STEEL := Color(0.55, 0.8, 1.0)
const GLASS_ALPHA := 0.58 # 2026-07-02 "glassy ambient" restyle: translucent card fill so the
# NeonBackdrop's glow blobs read through (was 0.97, near-opaque).
# KEPT (not a carousel concern) -- still used by _card_box(), which
# is still load-bearing for _close_btn (see Step 8's note below).
# Carousel layout (2026-07-02): ports tools/shop_lab/shop_lab.gd's validated constants
# verbatim. CENTER_Y_FRAC positions the vertical center as a fraction of the actual
# viewport height (not a fixed pixel value) -- Chris found the lab's fixed CENTER_X/
# CENTER_Y measurably off-center on a real iPhone, whose aspect ratio differs from the
# desktop test window those constants were tuned against. Computing from the real
# viewport every render fixes it on any device/orientation.
const CENTER_Y_FRAC := 0.566
const CENTER_SCALE := 1.85
const SIDE_SCALE := 0.62
const SIDE_ALPHA := 0.4
const SIDE_GAP := 26.0
const ANIM_DURATION := 0.3
const TRAVEL_FRACTION := 0.6
const DRAG_COMMIT_FRACTION := 0.22
var _backdrop: NeonBackdrop
var _header: VBoxContainer
var _carousel_root: Control
var _close_btn: Button # always-visible touch-safe close/back (top-left) — see the header note
var _cards: Array[Button] = [] # currently-rendered carousel cards (<=3), NOT the full item list
var _items: Array = [] # the current view's FULL item list (categories / sub-cat names / defs)
var _carousel_index: int = 0 # index into _items of whichever item is centered
var _last_nav_ms: int = 0
  • Step 4: Replace _ready()’s grid setup with the carousel overlay

Replace lines 76-99 (the root/_header/margin/_grid setup) with:

var root := VBoxContainer.new()
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_theme_constant_override("separation", 6)
add_child(root)
_header = VBoxContainer.new()
_header.add_theme_constant_override("separation", 4)
_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root.add_child(_header)
# A full-window OVERLAY, NOT nested in the `root` header VBoxContainer -- _center() below
# returns absolute viewport coordinates, so card positions must be computed relative to
# the window's own (0,0), not to wherever the header's rendered height happens to place
# its origin (the exact bug the lab prototype hit and fixed the same way).
_carousel_root = Control.new()
add_child(_carousel_root)
_carousel_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_carousel_root.mouse_filter = Control.MOUSE_FILTER_IGNORE

(The margin/_grid block that followed is deleted entirely — there is no more GridContainer.)

  • Step 5: Add the viewport/centering/slot helpers

Add these new methods (a good spot is right after _columns_for — which Step 6 deletes — so just add these where that function was):

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:
var card_w: float = ROOT_CARD_SIZE if _view == "root" else LIST_CARD_W
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()
var final_x: float = center.x + x_off - card_size.x * 0.5
var final_y: float = center.y - card_size.y * 0.5
return Vector2(final_x, final_y)
  • Step 6: Delete _columns_for and _card_width

Delete the _columns_for(n: int) -> int function (lines 122-141 today) and the _card_width(cols: int) -> float function (lines 260-268 today) entirely. Nothing else in this task calls them once Step 7 lands.

  • Step 7: Rewrite _render to build the item list + carousel, for every view

Replace the whole _render(keep: int) function (lines 192-256 today — everything from func _render(keep: int) -> void: through the closing of the else: branch, right before _finish_view(keep) is called) with:

func _render(keep: int) -> void:
for c in _header.get_children():
c.queue_free()
_close_btn.text = "✕ CLOSE" if _view == "root" else "◀ BACK"
if _view == "root":
_build_header("SHOP")
_items = ShopCategories.present(_defs)
elif _view == "Drones":
_build_header("DRONES", _cat_color("Drones"))
_items = ShopCategories.drone_subcategories_present(_defs)
_items.append(BACK_ID)
else:
_build_header(_view_title().to_upper(), _cat_color(_view_root_category()))
_items = _current_upgrade_defs().duplicate()
_items.append(BACK_ID)
_carousel_index = clampi(keep, 0, maxi(_items.size() - 1, 0))
_render_carousel(0)
# Builds one item (category name / sub-category name / BACK_ID / an upgrade def) into a
# card, for whichever _view is current.
func _build_item_card(item) -> Button:
if item == BACK_ID:
var card_w: float = ROOT_CARD_SIZE if _view == "root" else LIST_CARD_W
var back := _make_card(BACK_ID, STEEL, true, card_w)
_set_card_text(back, "◀ BACK", "", STEEL, "", "")
return back
if _view == "root":
var cat: String = item
var n := ShopCategories.in_category(_defs, cat).size()
var col := _cat_color(cat)
var tile := _make_card(cat, col, true, ROOT_CARD_SIZE, ROOT_CARD_SIZE)
_set_root_card_content(tile, cat, _cat_icon(cat), col, n)
return tile
if _view == "Drones":
var subcat: String = item
var n := ShopCategories.in_drone_subcategory(_defs, subcat).size()
var col := _cat_color("Drones")
var tile := _make_card(subcat, col, true, LIST_CARD_W)
# NAV_GUTTER/NAV_RIGHT_W (not the 124px default) -- this card's right_text is always a
# single "▶" glyph, same reasoning as the pre-carousel grid code this replaces. LIST_CARD_W
# doesn't change under the carousel, so the earlier text-clipping fix ("Interceptor"/
# "Disruptor" cut off against the oversized default gutter) still applies here -- dropping
# these two args here would silently regress that fix.
_set_card_text(tile, subcat, "%d upgrade%s" % [n, "" if n == 1 else "s"], col, "▶", "",
NAV_GUTTER, NAV_RIGHT_W)
return tile
# Any other view: item is an upgrade def dict.
return _make_upgrade_card(item, LIST_CARD_W)
# anim_direction: 0 = instant cut (view changes: root/category/back/style switch). +1/-1 =
# animated paging, added in Task 3 -- this task only ever calls it with 0.
func _render_carousel(anim_direction: int = 0) -> void:
for c in _carousel_root.get_children():
c.queue_free()
_cards.clear()
var n := _items.size()
if n == 0:
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 item = _items[idx]
var card := _build_item_card(item)
_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()
_finish_view()

_finish_view no longer takes a keep parameter (selection restoration is now handled by _render’s _carousel_index = clampi(keep, ...) line before calling _render_carousel) — update its signature and body:

func _finish_view() -> void:
if _cards.is_empty():
return
# Staggered entrance animation (scale + fade in per card), adapted to each card's own
# target scale/alpha instead of a uniform 1.0 -- the carousel's center/side cards have
# different targets.
for i: int in _cards.size():
var card: Button = _cards[i]
var target_scale: Vector2 = card.scale
var target_alpha: float = card.modulate.a
card.modulate.a = 0.0
card.scale = target_scale * 0.85
var tw: Tween = create_tween()
tw.tween_interval(i * 0.04)
tw.tween_property(card, "modulate:a", target_alpha, 0.18)
tw.parallel().tween_property(card, "scale", target_scale, 0.18)

Update every call site of _render/_finish_view/_rebuild_current accordingly: _show_root() and _show_category(cat) already call _render(0) — unchanged. _rebuild_current() currently reads _render(_sel) — change to _render(_carousel_index).

  • Step 8: Update _activate, _input, and the buy-flash lookup for the new state names

_activate(id: String) currently drills in / buys by scanning _defs directly for category/Drones-drill-in cases (unaffected — those don’t touch _cards/_sel), but the buy-flash block scans _cards by "id" meta — that still works unchanged since _make_card still sets card.set_meta("id", id). No change needed there.

Replace the _input directional-nav block (from # ── Directional nav ── to the end of the function, lines 563-591 today):

# ── Directional nav (left/right steps the carousel one item; up/down is dropped --
# a carousel has no second row to move to) ────────────────────────────────────
var fwd: bool = MenuNav.is_right(event)
var back_dir: bool = MenuNav.is_left(event)
if not (fwd or back_dir):
return
var now: int = 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()
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)

Delete _focus_card(idx: int) entirely (lines 599-607 today) — its z_index/scale-bump affordance is superseded by the carousel’s own center/side scale+alpha difference.

Note: _card_box/GLASS_ALPHA stay exactly as-is — they’re still load-bearing for _close_btn, which this task doesn’t touch. The two obsolete card-fill tests and the test_root_cards_have_a_category_icon child-index fix were already handled back in Task 1 (Steps 5-6), since Task 1 is what made them obsolete — nothing left to do for either here.

  • Step 9: Update test_meta_shop.gd’s two count assertions

test_root_shows_one_tile_per_present_category currently asserts p._cards.size() == ShopCategories.present(...).size() — under the carousel this is wrong (_cards is capped at 3). Replace with an assertion against _items:

func test_root_shows_one_tile_per_present_category() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new() # fresh, zero gold → no save path touched
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
_open(p, meta, content.meta_upgrades())
assert_eq(p._items.size(), ShopCategories.present(content.meta_upgrades()).size(),
"root view's full item list has one entry per non-empty category")
p.hide_panel()

Same fix for test_drilling_into_category_shows_its_cards_plus_back:

func test_drilling_into_category_shows_its_cards_plus_back() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
_open(p, meta, content.meta_upgrades())
p._show_category("Pilot")
var n := ShopCategories.in_category(content.meta_upgrades(), "Pilot").size()
assert_eq(p._items.size(), n + 1, "category view's full item list is its cards + a Back card")
p.hide_panel()
  • Step 10: Run all tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop.gd -gexit Expected: PASS on both files.

  • Step 11: Run the full suite, determinism, and boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"

Expected: all green, determinism baseline unchanged (2730172591/4075578713), no SCRIPT ERROR output.

  • Step 12: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd tests/test_meta_shop.gd
git commit -m "feat(shop): replace grid rendering with a carousel for every view
Deletes GridContainer/_columns/_columns_for/_card_width and the
row/column _sel math -- root categories, Drones sub-categories, and
every upgrade list now render through a centered, at-most-3-visible
carousel (item-index-tagged cards, dynamic viewport-relative
centering) instead. No paging animation yet (Task 3); gamepad
left/right already steps the carousel, up/down is dropped entirely."

Task 3: Animated transitions (paging, tap-side-jump)

Section titled “Task 3: Animated transitions (paging, tap-side-jump)”

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: tests/test_meta_shop_panel.gd

Interfaces:

  • Consumes: _render_carousel(anim_direction), _compute_slots, _place_card, _carousel_step, _items, _cards, _carousel_index from Task 2.

  • Produces: _render_carousel now actually animates when anim_direction != 0; _jump_to(idx: int) -> void (tap a side card jumps straight to it, animated).

  • Step 1: Write the failing tests

func test_paging_creates_a_tween_and_settles_on_the_new_center() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var before := p._carousel_index
p._carousel_step(1)
assert_eq(p._carousel_index, (before + 1) % p._items.size(), "index advances immediately")
await get_tree().create_timer(MetaShopPanel.ANIM_DURATION + 0.1).timeout
assert_lte(p._cards.size(), 3, "settles back to at most 3 rendered cards")
var center: Button = null
for c in p._cards:
if int(c.get_meta("item_idx")) == p._carousel_index:
center = c
assert_not_null(center, "the new center item is actually rendered after settling")
assert_almost_eq(center.scale.x, MetaShopPanel.CENTER_SCALE, 0.001)
p.hide_panel()
func test_tapping_a_side_card_jumps_to_it() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var side_idx: int = -1
for c in p._cards:
var idx: int = int(c.get_meta("item_idx"))
if idx != p._carousel_index:
side_idx = idx
break
assert_true(side_idx >= 0, "root has a side card to tap")
p._jump_to(side_idx)
assert_eq(p._carousel_index, side_idx, "jumping to a side card centers it")
p.hide_panel()
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: FAIL — _jump_to doesn’t exist yet; _render_carousel doesn’t animate.

  • Step 3: Add the animated transition to _render_carousel

⚠️ CORRECTED before dispatch — read this before the code below. _make_card (from Task 1, ui/meta_shop_panel.gd) unconditionally does card.pressed.connect(func() -> void: _activate(id)) for EVERY card it builds, center or side. The original version of Step 4 below then ADDED a second pressed connection (_jump_to(idx)) for non-center cards, on top of that existing one — Godot fires every connected callable on a signal, so tapping a dimmed side card would have triggered BOTH _jump_to (recenter it) AND _activate(id) (buy the upgrade / drill into the category / go back) in the same tap. For an upgrade card specifically, that means an unintended purchase from what’s supposed to be a pure browsing gesture — confirmed by grepping every _make_card/_make_upgrade_card call site: all four route through this same slot loop, and no test anywhere simulates a card’s own pressed signal to indirectly exercise _activate (existing tests all call p._activate(id) directly), so removing the auto-connect and re-wiring explicitly here is safe and breaks nothing.

Fix: remove _make_card’s auto-connect (it now takes an on_press: Callable instead of deciding unconditionally), and wire _activate vs. _jump_to explicitly per-slot — this is what Step 4’s own prose (“connect each non-center card’s pressed signal to jump to it instead of relying on the generic _activate routing”) already intended; the code below now actually does that, rather than firing both.

In ui/meta_shop_panel.gd’s _make_card (from Task 1), replace:

card.pressed.connect(func() -> void: _activate(id))
return card

with:

# Card content (center = _activate, side = _jump_to) is wired by the caller (the
# _render_carousel slot loop), which is the only place that knows whether THIS
# particular render of the card is the centered one -- see Task 3's correction note.
return card

Replace the _render_carousel body from Task 2 with (the _build_item_card/slot-loop structure stays the same shape, now branching on anim_direction):

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 item = _items[idx]
var card := _build_item_card(item)
_carousel_root.add_child(card)
var final_pos := _place_card(card, idx, x_off, scl)
_cards.append(card)
# Exactly one handler per card: the centered card activates on tap/confirm; every side
# card jumps to re-center instead. Wired here (not inside _make_card) because only this
# loop knows, at render time, which slot a given card landed in.
if is_center:
card.grab_focus.call_deferred()
card.pressed.connect(func() -> void: _activate(String(card.get_meta("id", ""))))
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)
if anim_direction == 0:
_finish_view()

(The entrance stagger in _finish_view only makes sense for a fresh view-enter, not a paging transition — that’s why it’s now only called in the anim_direction == 0 branch.)

  • Step 4: Add _jump_to
# 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))

The pressed wiring for both the center (_activate) and side (_jump_to) cases is already added directly in Step 3’s slot loop above (see the correction note there) — there is no separate wiring step here.

  • Step 5: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: PASS.

  • Step 6: Run the full suite, determinism, and boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 7: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git commit -m "feat(shop): animated carousel paging + tap-side-to-jump
Gamepad left/right and tapping a dimmed side card now both play the
tween-based slide+cross-fade transition (cards matched across the
transition by item_idx, not by tracking which physical node plays
which visual role) instead of an instant cut."

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: tests/test_meta_shop_panel.gd

Interfaces:

  • Consumes: _compute_slots, _place_card, _slot_side_offset, _carousel_index, _items, _build_item_card from Tasks 2-3.
  • Produces: _begin_drag(x: float), _update_drag(x: float), _end_drag(x: float), _complete_drag(direction: int), _cancel_drag() — direct ports of the lab’s verified drag logic, wired to the panel’s real _items/_build_item_card instead of the lab’s fake ones.

This is the last visual/interaction piece: live-follow drag on touch/mouse, matching what Chris already validated on his phone in tools/shop_lab/.

  • Step 1: Write the failing tests
func test_committed_drag_advances_the_centered_item() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var before := p._carousel_index
var side_offset: float = p._slot_side_offset()
p._begin_drag(640.0)
p._update_drag(640.0 - side_offset * (MetaShopPanel.DRAG_COMMIT_FRACTION + 0.1))
p._end_drag(640.0 - side_offset * (MetaShopPanel.DRAG_COMMIT_FRACTION + 0.1))
assert_eq(p._carousel_index, (before + 1) % p._items.size(), "a big-enough left-drag commits to next")
p.hide_panel()
func test_short_drag_springs_back_without_changing_the_center() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
var before := p._carousel_index
p._begin_drag(640.0)
p._update_drag(650.0) # tiny drag, well under the commit threshold
p._end_drag(650.0)
assert_eq(p._carousel_index, before, "a small drag springs back, no page change")
p.hide_panel()
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: FAIL — _begin_drag/_update_drag/_end_drag don’t exist yet.

  • Step 3: Add drag state and the drag lifecycle methods

Add near the other carousel state vars (from Task 2’s Step 3):

var _drag_active := false
var _drag_start_x := 0.0
var _drag_side_offset := 0.0
var _drag_incoming_dir := 0

Add these methods (a good spot is right after _jump_to):

func _begin_drag(x: float) -> void:
if _items.size() < 2 or _drag_active:
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
if _drag_incoming_dir == 0 and absf(dx) > 10.0 and _items.size() >= 3:
_drag_incoming_dir = -1 if dx > 0 else 1
var incoming_idx: int = (_carousel_index + _drag_incoming_dir + _items.size()) % _items.size()
var incoming_x_off: float = _drag_side_offset * _drag_incoming_dir
var card := _build_item_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)
card.set_meta("drag_incoming_alpha", SIDE_ALPHA)
card.position.y = final_pos.y
card.pressed.connect(func() -> void: _jump_to(incoming_idx))
# Deliberately NOT appended to _cards here -- it's a transient mid-gesture visual,
# not yet part of the settled carousel state. _complete_drag() is what promotes it
# into _cards (by matching item_idx), and _cancel_drag() frees it without ever
# having added it -- so _cards never holds a reference to a node that gets freed
# out from under 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"))
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()
func _complete_drag(direction: int) -> void:
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 = {}
for slot in target_slots:
matched[int(slot[0])] = [slot[1], slot[2], slot[3]]
_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 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)
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)
func _cancel_drag() -> void:
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)
  • Step 4: Wire touch/mouse events into _input

Add at the top of _input(event: InputEvent), before the existing confirm/back/nav checks (so a drag in progress doesn’t also trigger the panel’s other input handling — guard on _drag_active where relevant):

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

(Left-mouse-button press/release already flows through ui_accept-style confirm detection for click-to-activate on desktop — do NOT also route InputEventMouseButton through _begin_drag/_end_drag, since that would break clicking a card directly with the mouse. Touch and gamepad are this panel’s real targets; the lab’s mouse-drag desktop-testing convenience is not needed here since the real panel already works with mouse clicks via Button.pressed.)

  • Step 5: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: PASS.

  • Step 6: Run the full suite, determinism, and boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 7: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git commit -m "feat(shop): live-follow touch/mouse-drag paging
Ports tools/shop_lab/shop_lab.gd's validated live-follow drag (cards
track the finger continuously, a 4th incoming card fades in
proportional to drag progress, release either commits past
DRAG_COMMIT_FRACTION or springs back) into the real shop panel, wired
to real category/upgrade data instead of the lab's fake items."

Task 5: Full verification + real-device deploy

Section titled “Task 5: Full verification + real-device deploy”

Files: none (verification only)

Interfaces: none — this task confirms Tasks 1-4 work together correctly on a real device before considering the feature done.

  • Step 1: Run the complete suite one more time
Terminal window
bash scripts/check-test-count.sh

Expected: 189+ scripts (some net-new from Tasks 1-4), all green.

  • Step 2: Run both determinism tests explicitly
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit

Expected: PASS, baseline snapshot_string().hash()=2730172591, state_checksum()=4075578713 unchanged.

  • Step 3: Boot smoke test
Terminal window
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: empty output.

  • Step 4: Deploy to Chris’s iPhone for real-device verification

Follow the bh-deploy skill’s section C2 (build + install to Chris’s iPhone via direct devicectl, NOT TestFlight — this is a fast-iteration playtest, matching how the tools/shop_lab/ prototype itself was verified throughout its development). Bump Sim_Const.BUILD in sim/constants.gd first. Sync to ~/Claude/bullet-heaven-tvos, verify there too (count guard + boot check), then build/install/launch.

  • Step 5: Report to Chris for hands-on confirmation

This panel shipped from a lab prototype Chris himself called “a bit glitchy” — do not mark this done from a headless pass alone. After the device install, tell Chris it’s ready to test on his phone and wait for his confirmation (or bug reports) before considering the carousel port complete.

  • Step 6: Commit the build-number bump
Terminal window
git add sim/constants.gd
git commit -m "chore: bump BUILD for shop carousel real-device verification"

Task 6: Explicit “BUY” affordance on affordable upgrade cards

Section titled “Task 6: Explicit “BUY” affordance on affordable upgrade cards”

Added after Task 3, per Chris’s own follow-up ask mid-session (“we could add a buy button”) — not part of the original spec, scoped here as a small, self-contained addition rather than reopening the spec/plan cycle. Chris’s own words when asked to clarify scope: confirmed as a straightforward yes to adding a visible buy affordance.

Design decision (made here, not in the original spec): rather than a literal SEPARATE clickable button nested inside the card (which would need its own signal wiring, and this plan’s Task 3 correction just fixed a real double-fire bug from two handlers landing on one card) the fix is: the WHOLE card is already the single click target (_activate, wired once per card by Task 3) — this task only changes what the right-side label SAYS and how it LOOKS when the shown upgrade is actually purchasable, so the existing tap affordance reads as an obvious “Buy” action instead of a bare price. No new signal, no new clickable region — zero risk of reintroducing the class of bug Task 3 just fixed.

Files:

  • Modify: ui/meta_shop_panel.gd
  • Test: tests/test_meta_shop_panel.gd

Interfaces:

  • Consumes: _make_upgrade_card, _set_card_text (both from Task 2/earlier, still present and unchanged in shape by Tasks 1-5).

  • Produces: _set_card_text gains one new optional parameter, right_is_buy: bool = false; _make_upgrade_card’s affordable-and-not-maxed branch passes right_is_buy = true and a "BUY · %d gold"-style label instead of a bare price.

  • Step 1: Write the failing tests

func test_affordable_upgrade_shows_an_explicit_buy_label() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
meta.banked_gold = 999999 # affords everything
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
p._show_category("Pilot")
var center: Button = p._cards[p._carousel_index]
var found_buy_label := false
for c in center.get_children():
if c is Label and (c as Label).text.begins_with("BUY"):
found_buy_label = true
assert_true(found_buy_label, "an affordable upgrade's centered card says BUY, not just a bare price")
p.hide_panel()
func test_unaffordable_upgrade_does_not_say_buy() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new() # zero gold -> nothing affordable yet
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
p._show_category("Pilot")
var center: Button = p._cards[p._carousel_index]
for c in center.get_children():
if c is Label:
assert_false((c as Label).text.begins_with("BUY"),
"an unaffordable upgrade never shows the BUY label -- there's nothing to act on yet")
p.hide_panel()
func test_buy_label_has_a_pill_background_behind_it() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var meta := MetaState.new()
meta.banked_gold = 999999
var p := MetaShopPanel.new(); add_child_autofree(p); await get_tree().process_frame
p.open_shop(meta, content.meta_upgrades(), func() -> void: pass)
p._show_category("Pilot")
var center: Button = p._cards[p._carousel_index]
var found_pill := false
for c in center.get_children():
if c is Panel:
found_pill = true
assert_true(found_pill, "the BUY label has a visible pill background, reading as a real button")
p.hide_panel()
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: FAIL — no card currently ever says “BUY”, and no card has a Panel child.

  • Step 3: Add right_is_buy to _set_card_text (pill background)

In ui/meta_shop_panel.gd, replace _set_card_text’s signature and its right_text block:

func _set_card_text(card: Button, name_text: String, desc_text: String,
accent: Color, right_text: String = "", icon_key: String = "",
right_gutter: float = 124.0, right_w: float = 104.0) -> void:

with:

func _set_card_text(card: Button, name_text: String, desc_text: String,
accent: Color, right_text: String = "", icon_key: String = "",
right_gutter: float = 124.0, right_w: float = 104.0,
right_is_buy: bool = false) -> void:

and replace the existing if right_text != "": block (the last block in the function) with:

if right_text != "":
var right_pos := Vector2(cw - right_w - 12.0, 26.0)
var right_size := Vector2(right_w, 28.0)
if right_is_buy:
# A real, tappable-looking affordance -- the card itself is still the single
# click target (see Task 3's correction note); this is a cosmetic pill only,
# not a new interactive Control, so it can't reintroduce a double-fire bug.
var pill := Panel.new()
pill.position = right_pos - Vector2(6.0, 3.0)
pill.size = right_size + Vector2(12.0, 6.0)
pill.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sb := StyleBoxFlat.new()
sb.bg_color = Color(accent.r, accent.g, accent.b, 0.22)
sb.border_color = accent
sb.set_border_width_all(1)
sb.set_corner_radius_all(int(right_size.y * 0.5) + 3)
pill.add_theme_stylebox_override("panel", sb)
card.add_child(pill)
var right: Label = _label(right_text, NeonTheme.mono_font(), 17, accent)
right.position = right_pos
right.size = right_size
right.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
right.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(right)
  • Step 4: Make _make_upgrade_card pass the new label + flag

In _make_upgrade_card, replace:

var right: String = ""
if maxed:
if is_decoy:
right = "ACTIVE" if _meta.selected_decoy == target.substr(6) else "SELECT"
else:
right = "OWNED" if is_unlock else "MAX"
else:
right = "%d gold" % _meta.cost(def)

with:

var right: String = ""
var right_is_buy := false
if maxed:
if is_decoy:
right = "ACTIVE" if _meta.selected_decoy == target.substr(6) else "SELECT"
else:
right = "OWNED" if is_unlock else "MAX"
elif afford:
# Affordable and not yet owned/maxed -- the only state where tapping the (already
# single-click-target) card actually buys something right now, so say so explicitly
# instead of showing a bare price the player has to infer an action from.
right = "BUY · %d gold" % _meta.cost(def)
right_is_buy = true
else:
right = "%d gold" % _meta.cost(def) # priced, but nothing to act on yet

Then find this card’s _set_card_text(...) call (a few lines below, in the same function) and add right_is_buy as its final argument, matching whatever right_gutter/right_w args it already passes (grep the function to find the exact call — it should be the only _set_card_text call inside _make_upgrade_card).

  • Step 5: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit Expected: PASS.

  • Step 6: Run the full suite, determinism, and boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"

Expected: all green, determinism baseline unchanged (2730172591/4075578713), no SCRIPT ERROR output.

  • Step 7: Verify visually

Use this project’s established temporary-SubViewport-capture-then-delete convention (check marketing/capture/ for the pattern) to actually see: an affordable upgrade card’s centered view shows “BUY · N gold” with a visible pill background around it; an unaffordable one still shows a bare price with no pill; a maxed/owned one shows “OWNED”/“MAX”/“ACTIVE”/“SELECT” with no pill (unchanged from before this task). Delete the temp harness after.

  • Step 8: Commit
Terminal window
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd
git commit -m "feat(shop): explicit BUY affordance on affordable upgrade cards
Chris, mid-session: 'we could add a buy button.' The whole card is
already the single tap/confirm target (Task 3's fix keeps it that
way, deliberately not adding a second clickable region) -- this task
only makes that existing tap read as an obvious purchase action:
affordable-and-not-maxed upgrades now show 'BUY · N gold' with a
small pill background instead of a bare price, so the player isn't
left inferring what tapping the card will do. Unaffordable and
maxed/owned states are unchanged."