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.
Global Constraints
Section titled “Global Constraints”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, anydef: Dictionaryfield,_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
/simfiles 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.shafter 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.
Context for every task
Section titled “Context for every task”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
_HologramBracketsinner 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 cardwith:
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 cardbright 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.gdto 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
bash scripts/check-test-count.shgodot --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
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): Hologram Corner Brackets card style
Ports tools/shop_lab/shop_lab.gd's validated _HologramBrackets look intothe real shop panel -- no fill, four L-shaped corner brackets in thecard's accent colour, backdrop visible straight through. Grid layoutand navigation are untouched in this task; Task 2 replaces those."Task 2: Carousel core rendering (static, all views)
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_cardfrom 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_directionparam 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 bytest_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.0const CARD_GAP: int = 10const MARGIN_H: int = 20 # left/right margin around the gridconst NAV_DEBOUNCE_MS: int = 200const 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.0const 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.0const 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: NeonBackdropvar _header: VBoxContainervar _grid: GridContainervar _close_btn: Button # always-visible touch-safe close/back (top-left) — see the header notevar _cards: Array[Button] = []var _columns: int = 3var _sel: int = 0var _last_nav_ms: int = 0with:
const CARD_H: float = 96.0const NAV_DEBOUNCE_MS: int = 200const BACK_ID := "__back__"const ROOT_CARD_SIZE: float = 220.0const ROOT_ICON_SCALE: float = 2.2 # ShopIcons draws at 32x32; scaled up for a hero iconconst 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.0const 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.566const CENTER_SCALE := 1.85const SIDE_SCALE := 0.62const SIDE_ALPHA := 0.4const SIDE_GAP := 26.0const ANIM_DURATION := 0.3const TRAVEL_FRACTION := 0.6const DRAG_COMMIT_FRACTION := 0.22
var _backdrop: NeonBackdropvar _header: VBoxContainervar _carousel_root: Controlvar _close_btn: Button # always-visible touch-safe close/back (top-left) — see the header notevar _cards: Array[Button] = [] # currently-rendered carousel cards (<=3), NOT the full item listvar _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 centeredvar _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_forand_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
_renderto 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
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --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
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gd tests/test_meta_shop.gdgit commit -m "feat(shop): replace grid rendering with a carousel for every view
Deletes GridContainer/_columns/_columns_for/_card_width and therow/column _sel math -- root categories, Drones sub-categories, andevery upgrade list now render through a centered, at-most-3-visiblecarousel (item-index-tagged cards, dynamic viewport-relativecentering) instead. No paging animation yet (Task 3); gamepadleft/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_indexfrom Task 2. -
Produces:
_render_carouselnow actually animates whenanim_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 cardwith:
# 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 cardReplace 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
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 7: Commit
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): animated carousel paging + tap-side-to-jump
Gamepad left/right and tapping a dimmed side card now both play thetween-based slide+cross-fade transition (cards matched across thetransition by item_idx, not by tracking which physical node playswhich visual role) instead of an instant cut."Task 4: Touch/mouse live-follow drag
Section titled “Task 4: Touch/mouse live-follow drag”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_cardfrom 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_cardinstead 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 := falsevar _drag_start_x := 0.0var _drag_side_offset := 0.0var _drag_incoming_dir := 0Add 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
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 7: Commit
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): live-follow touch/mouse-drag paging
Ports tools/shop_lab/shop_lab.gd's validated live-follow drag (cardstrack the finger continuously, a 4th incoming card fades inproportional to drag progress, release either commits pastDRAG_COMMIT_FRACTION or springs back) into the real shop panel, wiredto 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
bash scripts/check-test-count.shExpected: 189+ scripts (some net-new from Tasks 1-4), all green.
- Step 2: Run both determinism tests explicitly
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitExpected: PASS, baseline snapshot_string().hash()=2730172591,
state_checksum()=4075578713 unchanged.
- Step 3: Boot smoke test
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
git add sim/constants.gdgit 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_textgains one new optional parameter,right_is_buy: bool = false;_make_upgrade_card’s affordable-and-not-maxed branch passesright_is_buy = trueand 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_buyto_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_cardpass 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 yetThen 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
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --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
git add ui/meta_shop_panel.gd tests/test_meta_shop_panel.gdgit commit -m "feat(shop): explicit BUY affordance on affordable upgrade cards
Chris, mid-session: 'we could add a buy button.' The whole card isalready the single tap/confirm target (Task 3's fix keeps it thatway, deliberately not adding a second clickable region) -- this taskonly makes that existing tap read as an obvious purchase action:affordable-and-not-maxed upgrades now show 'BUY · N gold' with asmall pill background instead of a bare price, so the player isn'tleft inferring what tapping the card will do. Unaffordable andmaxed/owned states are unchanged."