Liquid-glass UI — Plan 1: core (glass component, level-up, stats sheet, dud filter, pause)
Liquid-glass UI — Plan 1 (core) Implementation Plan
Section titled “Liquid-glass UI — Plan 1 (core) 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: Ship the shared liquid-glass component, the redesigned level-up (bottom strip of 3 glass cards), the Y ship-stats sheet with live card preview, the dud-crystal-card filter, and the glass pause menu.
Architecture: One GlassPanel container draws a single shader-backed surface behind its content (frosted variant samples the mipmapped screen texture; clear variant doesn’t). StatsSheet is a pure-data-plus-view Control reused by the level-up panel and the pause menu. The old level-up panel is kept intact as LegacyLevelUpPanel behind VisualLook.glass_ui, both deriving from a tiny LevelUpBase so main.gd holds either.
Tech Stack: Godot 4.7 typed GDScript, canvas_item shaders, GUT 9.7.1.
Spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md
Plan 2 (separate, written after this lands): start menu, results, dimension choice, shop, ship config, bestiary, dialogue box, HUD docks — each a layout pass on GlassPanel.
Global Constraints
Section titled “Global Constraints”- Old look one flag away:
VisualLook.glass_ui(static var, defaulttrue);falsemust render every touched screen exactly as before. /simstays pure (no Node/render APIs). UI only reads sim state; the only sim change in this plan is the dud-crystal filter insim/upgrade_system.gd.- Frost (screen-texture sampling) only on screens shown while the sim is paused. Never on the HUD.
- Base viewport is 1280×720 (
canvas_itemsstretch). Sizes below are base units. Card name font 24, card line font 17 (≥ spec’s 28px/20px at 1080p). - No DPS delta anywhere. DPS shows
effective_dps(sim)only. - Co-op: the level-up panel and its sheet obey
_gate_device/_gate_p1exactly as today. - Gate for every commit:
bash scripts/check-test-count.sh > /tmp/gate.log 2>&1; tail -1 /tmp/gate.log | grep -q 'guard OK'must pass beforegit commit; thengit pull --rebase --autostash && git push. - After any windowed godot run:
git diff --quiet project.godot || git checkout -- project.godot. - ui-sweep:
zsh scripts/ui-sweep.sh(neverbash). Bless only after eyeballing every PNG intools/ui_sweep/out/. - Run a single test script with
-gselect=<script_name_without_.gd>, e.g.godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_glass_panel -gexit. - Determinism: a survival re-pin is approved by Chris for the dud filter only (spec §2), in its own commit, and only if
test_determinism_survivalactually fails.
Task 1: GlassPanel component + VisualLook.glass_ui
Section titled “Task 1: GlassPanel component + VisualLook.glass_ui”Files:
- Modify:
render/visual_look.gd(append the flag) - Create:
ui/theme/glass_common.gdshaderinc,ui/theme/glass_frost.gdshader,ui/theme/glass_clear.gdshader,ui/theme/glass_panel.gd - Test:
tests/test_glass_panel.gd
Interfaces:
-
Produces:
class_name GlassPanel extends Containerwithaccent: Color,focus: float(0..1),frosted: bool,dim: bool,margin: float(content padding, default 14), constsGLOW_PAD := 18.0,RADIUS := 12.0,FROST_SHADER,CLEAR_SHADER;func backdrop() -> ColorRect(the internal surface, for tests).VisualLook.glass_ui: bool. -
Step 1: Write the failing test —
tests/test_glass_panel.gd
extends GutTest
func _panel(frosted: bool) -> GlassPanel: var p := GlassPanel.new() p.frosted = frosted add_child_autofree(p) return p
func test_frosted_uses_frost_shader_and_clear_does_not() -> void: var f := _panel(true) var c := _panel(false) await get_tree().process_frame assert_eq((f.backdrop().material as ShaderMaterial).shader, GlassPanel.FROST_SHADER) assert_eq((c.backdrop().material as ShaderMaterial).shader, GlassPanel.CLEAR_SHADER) f.frosted = false assert_eq((f.backdrop().material as ShaderMaterial).shader, GlassPanel.CLEAR_SHADER, "live switch")
func test_setters_push_shader_params() -> void: var p := _panel(false) p.accent = Color(1, 0, 0) p.focus = 0.5 p.dim = true var m := p.backdrop().material as ShaderMaterial assert_eq(m.get_shader_parameter("accent"), Color(1, 0, 0)) assert_almost_eq(float(m.get_shader_parameter("focus")), 0.5, 0.001) assert_almost_eq(float(m.get_shader_parameter("dim")), 1.0, 0.001)
func test_focus_is_clamped() -> void: var p := _panel(false) p.focus = 3.0 assert_almost_eq(p.focus, 1.0, 0.001)
func test_layout_pads_content_and_grows_backdrop_by_glow_pad() -> void: var p := _panel(false) var lbl := Label.new() lbl.text = "hello" p.add_child(lbl) p.size = Vector2(300, 120) await get_tree().process_frame await get_tree().process_frame assert_eq(lbl.position, Vector2(p.margin, p.margin), "content inset by margin") assert_eq(lbl.size, Vector2(300 - 2 * p.margin, 120 - 2 * p.margin)) assert_eq(p.backdrop().position, Vector2(-GlassPanel.GLOW_PAD, -GlassPanel.GLOW_PAD)) assert_eq(p.backdrop().size, Vector2(300, 120) + Vector2.ONE * GlassPanel.GLOW_PAD * 2.0) var m := p.backdrop().material as ShaderMaterial assert_eq(m.get_shader_parameter("rect_size"), Vector2(300, 120))
func test_backdrop_is_internal_not_a_child() -> void: var p := _panel(true) await get_tree().process_frame assert_eq(p.get_child_count(), 0, "screens iterating children never see the backdrop") assert_true(p.backdrop().get_parent() == p)
func test_minimum_size_includes_margins() -> void: var p := _panel(false) var box := Control.new() box.custom_minimum_size = Vector2(100, 40) p.add_child(box) assert_eq(p.get_combined_minimum_size(), Vector2(100, 40) + Vector2.ONE * p.margin * 2.0)- Step 2: Run it — expect FAIL (“Could not find type GlassPanel”).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_glass_panel -gexit
- Step 3: Implement
Append to render/visual_look.gd:
# Liquid-glass UI (docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md, the 4th# freeze exception). false = the Nova-plate UI exactly as it was on 2026-09-23. Read when a screen is# built, so flipping it takes effect on the next open, not mid-screen.static var glass_ui := trueui/theme/glass_common.gdshaderinc:
// Shared liquid-glass surface. The backdrop quad is the panel grown by `pad` px on every side so the// focus glow can bleed outside the panel; UV spans that whole quad.uniform vec4 accent : source_color = vec4(0.2, 0.9, 1.0, 1.0);uniform float focus = 0.0;uniform float dim = 0.0;uniform vec2 rect_size = vec2(100.0, 100.0);uniform float pad = 18.0;uniform float radius = 12.0;
float glass_sd(vec2 p, vec2 half_size, float r) { vec2 q = abs(p) - half_size + vec2(r); return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;}
// base: what shows through (blurred screen, or a dark plate). body_a: surface alpha inside.vec4 glass_surface(vec2 uv, vec3 base, float body_a) { vec2 full = rect_size + vec2(pad * 2.0); vec2 p = uv * full - full * 0.5; float d = glass_sd(p, rect_size * 0.5, radius); float inside = 1.0 - smoothstep(-0.8, 0.8, d); float t = clamp((p.y + rect_size.y * 0.5) / max(rect_size.y, 1.0), 0.0, 1.0); // 0 top, 1 bottom vec3 pale = mix(accent.rgb, vec3(1.0), 0.7); vec3 body = mix(base, vec3(1.0), mix(0.13, 0.035, t)); // top-lit sheen body = mix(body, pale, 0.10 * focus); body *= 1.0 - 0.18 * smoothstep(0.65, 1.0, t); // soft bottom shade body *= mix(1.0, 0.55, dim); float rim = 1.0 - smoothstep(0.0, 1.3, abs(d + 0.7)); body += pale * rim * (0.22 + 0.4 * focus) * (1.0 - 0.6 * dim); body += vec3(rim * (1.0 - t) * 0.25); // bright top edge float g = exp(-max(d, 0.0) / (pad * 0.35)) * (1.0 - inside) * focus * (1.0 - dim); vec4 glow = vec4(accent.rgb, g * 0.55); return mix(glow, vec4(body, body_a), inside);}ui/theme/glass_frost.gdshader:
shader_type canvas_item;#include "res://ui/theme/glass_common.gdshaderinc"// Frosted: the game frame behind, read from the screen texture's mip chain (one copy + mip build per// frame, no multi-pass blur). Paused screens only — see GlassPanel.uniform sampler2D screen_tex : hint_screen_texture, filter_linear_mipmap;uniform float blur_lod = 3.5;
void fragment() { vec3 s = textureLod(screen_tex, SCREEN_UV, blur_lod).rgb; float l = dot(s, vec3(0.299, 0.587, 0.114)); s = mix(vec3(l), s, 1.35) * 0.55; // a little more saturated, darkened so text reads COLOR = glass_surface(UV, s, 1.0);}ui/theme/glass_clear.gdshader:
shader_type canvas_item;#include "res://ui/theme/glass_common.gdshaderinc"// Unfrosted glass for the live HUD: never touches the screen texture (no copy, no mips).void fragment() { COLOR = glass_surface(UV, vec3(0.03, 0.04, 0.07), 0.72);}ui/theme/glass_panel.gd:
class_name GlassPanelextends Container
# Liquid-glass surface (spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md §1).# Every glass box in the UI is one of these — screens set accent/focus/frosted/dim and never build# their own StyleBoxFlat, so the look can't drift screen to screen.# The surface is an INTERNAL ColorRect (not returned by get_children()) grown by GLOW_PAD on every side# so the focus glow bleeds outside the panel. Content children are laid out inset by `margin`.
const FROST_SHADER := preload("res://ui/theme/glass_frost.gdshader")const CLEAR_SHADER := preload("res://ui/theme/glass_clear.gdshader")const GLOW_PAD := 18.0const RADIUS := 12.0
var accent: Color = Tokens.ACCENT: set(v): accent = v _push()var focus: float = 0.0: set(v): focus = clampf(v, 0.0, 1.0) _push()var frosted: bool = false: set(v): frosted = v if _mat != null: _mat.shader = FROST_SHADER if v else CLEAR_SHADERvar dim: bool = false: set(v): dim = v _push()var margin: float = 14.0: set(v): margin = v update_minimum_size() queue_sort()
var _bg: ColorRectvar _mat: ShaderMaterial
func _init() -> void: _mat = ShaderMaterial.new() _mat.shader = FROST_SHADER if frosted else CLEAR_SHADER _bg = ColorRect.new() _bg.color = Color.WHITE _bg.mouse_filter = Control.MOUSE_FILTER_IGNORE _bg.material = _mat add_child(_bg, false, Node.INTERNAL_MODE_FRONT) _push()
func backdrop() -> ColorRect: return _bg
func _push() -> void: if _mat == null: return _mat.set_shader_parameter("accent", accent) _mat.set_shader_parameter("focus", focus) _mat.set_shader_parameter("dim", 1.0 if dim else 0.0) _mat.set_shader_parameter("pad", GLOW_PAD) _mat.set_shader_parameter("radius", RADIUS) _mat.set_shader_parameter("rect_size", size)
func _get_minimum_size() -> Vector2: var m := Vector2.ZERO for c in get_children(): if c is Control and (c as Control).visible: m = m.max((c as Control).get_combined_minimum_size()) return m + Vector2.ONE * margin * 2.0
func _notification(what: int) -> void: if what == NOTIFICATION_SORT_CHILDREN: var inner := Rect2(Vector2.ONE * margin, size - Vector2.ONE * margin * 2.0) for c in get_children(): if c is Control: fit_child_in_rect(c as Control, inner) _bg.position = -Vector2.ONE * GLOW_PAD _bg.size = size + Vector2.ONE * GLOW_PAD * 2.0 _push()- Step 4: Run the test — expect PASS. If
test_layout_pads_content…fails on sizing, check that the panel is not inside another container overridingsize(it isn’t in the test). - Step 5: Visual check. Write a throwaway scene (not committed) at
/tmpis not loadable by Godot, so put it attools/glass_preview/glass_preview.gd+.tscn(committed, dev tool): aNode2Dscene that draws a few coloured circles, then aCanvasLayerwith threeGlassPanels (frosted, focus 0 / 0.5 / 1, accents cyan/violet/gold) each holding aLabel, and after 20 frames savesget_viewport().get_texture().get_image()toOS.get_environment("GLASS_OUT")(defaultres://tools/glass_preview/out.png), then quits:
extends Node2D# Dev tool: renders GlassPanel variants over a busy background for eyeballing (2026-09-24).# GLASS_OUT=/abs/out.png godot --path . res://tools/glass_preview/glass_preview.tscnfunc _ready() -> void: var layer := CanvasLayer.new() add_child(layer) var accents := [Tokens.ACCENT, Color(0.6, 0.35, 1.0), Tokens.GOLD] for i in 3: var p := GlassPanel.new() p.frosted = true p.accent = accents[i] p.focus = i * 0.5 p.position = Vector2(140 + i * 360, 420) p.size = Vector2(320, 150) var l := Label.new() l.text = "Penetrator\nPierce 1 → 3" l.add_theme_font_override("font", NeonTheme.title_font()) l.add_theme_font_size_override("font_size", 24) p.add_child(l) layer.add_child(p) for f in 20: await get_tree().process_frame await RenderingServer.frame_post_draw var out := OS.get_environment("GLASS_OUT") if out == "": out = ProjectSettings.globalize_path("res://tools/glass_preview/out.png") get_viewport().get_texture().get_image().save_png(out) get_tree().quit()
func _draw() -> void: for i in 40: var c := Color.from_hsv(fmod(i * 0.137, 1.0), 0.8, 1.0) draw_circle(Vector2(fmod(i * 197.0, 1280.0), fmod(i * 131.0, 720.0)), 30.0 + (i % 5) * 12.0, c).tscn: a single root Node2D with the script attached ([gd_scene format=3] / [ext_resource type="Script" path="res://tools/glass_preview/glass_preview.gd" id="1"] / [node name="GlassPreview" type="Node2D"] / script = ExtResource("1")). Add tools/glass_preview/out.png to .gitignore.
Run windowed: GLASS_OUT=$PWD/scratch_glass.png godot --path . res://tools/glass_preview/glass_preview.tscn --rendering-method mobile, restore project.godot, open the PNG. Expected: blurred circles through each panel, a bright top edge, no glow on the left panel, a visible accent glow on the right one. If the blur is missing (sharp circles), the renderer isn’t generating screen mips; raise nothing, report it — the fallback is still a legible tinted panel.
- Step 6: Gate + commit —
feat(ui): GlassPanel liquid-glass component + VisualLook.glass_ui
Task 2: Dud crystal cards never offered
Section titled “Task 2: Dud crystal cards never offered”Files:
- Modify:
sim/upgrade_system.gd(survival branch ofroll_upgrade_choices, lines ~111-131; add two funcs afterroll_crystal_grant) - Test:
tests/test_dud_crystal_filter.gd
Interfaces:
-
Produces:
func is_dud_crystal(sim: Sim, pilot: PlayerState, id: String) -> bool,func roll_live_crystal_grant(sim: Sim, pilot: PlayerState) -> String(returns""when every retry was a dud),const CRYSTAL_REROLLS := 4. -
Step 1: Failing test —
tests/test_dud_crystal_filter.gd
extends GutTest
# A crystal grant that crosses no weapon rung AND builds towards none is a dud ("no effect") —# the one dud card still reaching players (RELEASE-0.1 §4). Everything goes through the real# upgrade_effects()/roll path; nothing is hand-set.
func _sim() -> Sim: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() return sim
func test_grant_touching_no_owned_weapon_is_a_dud() -> void: var sim := _sim() assert_false(sim.player.arsenal.active_weapon_ids.has("orbit"), "precondition: no cold weapon owned") assert_true(sim.upgrade_system.is_dud_crystal(sim, sim.player, "crystals:cold=1"))
func test_grant_building_towards_a_rung_is_not_a_dud() -> void: var sim := _sim() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") # cold shard @5 -> 1 cold = progress 1/5 assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, "crystals:cold=1"))
func test_non_crystal_ids_are_never_duds() -> void: var sim := _sim() assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, "weapon:nova"))
func test_live_grant_is_never_a_dud() -> void: var sim := _sim() for i in 200: var id := sim.upgrade_system.roll_live_crystal_grant(sim, sim.player) if id != "": assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, id), "rolled %s" % id)
func test_level_up_offers_never_contain_a_dud() -> void: var sim := _sim() for i in 150: var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) assert_between(ids.size(), 1, 3) for id in ids: assert_false(sim.upgrade_system.is_dud_crystal(sim, sim.player, id), "offered dud %s" % id)-
Step 2: Run — expect FAIL (
is_dud_crystalnot found). -
Step 3: Implement. After
roll_crystal_grantinsim/upgrade_system.gdadd:
# Retries before giving up on a live crystal grant (each retry is one more roll_crystal_grant draw# from upgrade_rng, so a dud-free roll consumes exactly the draws it always did — determinism holds# for every offer that never hit a dud).const CRYSTAL_REROLLS := 4
# A crystal grant that crosses no weapon rung AND builds towards none: the "no effect" card# (liquid-glass spec §2, RELEASE-0.1 §4). A grant that is one rung short still shows progress, so# it is NOT a dud.func is_dud_crystal(sim: Sim, pilot: PlayerState, id: String) -> bool: if not id.begins_with("crystals:"): return false var eff := upgrade_effects(sim, pilot, id) return bool(eff.get("dead", false)) and (eff.get("progress", []) as Array).is_empty()
# roll_crystal_grant, re-rolled past duds. "" when every retry was a dud (caller drops the slot).func roll_live_crystal_grant(sim: Sim, pilot: PlayerState) -> String: for _i in CRYSTAL_REROLLS: var id := roll_crystal_grant(sim) if not is_dud_crystal(sim, pilot, id): return id return ""In the survival branch of roll_upgrade_choices, replace:
if sim.upgrade_rng.randf() < CRYSTAL_GRANT_CHANCE: out.append(roll_crystal_grant(sim)) if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE: out.append(roll_crystal_grant(sim))with:
if sim.upgrade_rng.randf() < CRYSTAL_GRANT_CHANCE: var g1 := roll_live_crystal_grant(sim, pilot) if g1 != "": out.append(g1) if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE: var g2 := roll_live_crystal_grant(sim, pilot) if g2 != "": out.append(g2)and the pad loop:
while out.size() < n: out.append(roll_crystal_grant(sim))with:
while out.size() < n: var g3 := roll_live_crystal_grant(sim, pilot) if g3 == "": break # fewer cards beats a dud card (spec §2) out.append(g3)- Step 4: Run the new test — PASS. Then run
-gselect=test_determinism_survivaland-gselect=test_determinism_coop.- Both pass → no re-pin; note “no re-pin needed” in the commit body.
- One fails → re-pin ONLY the failing file’s asserted hash/checksum to the new printed values, in a separate commit
test(determinism): re-pin survival baseline for the dud-crystal filter (approved, spec §2).
- Step 5: Soak —
scripts/soak.sh --runs=6 --ticks=18000 --ships=cobalt,aurum --coop=bothmust report no invariant failures. - Step 6: Tick RELEASE-0.1.md — in §4 find the dud “no effect” level-up card item and append
✅ DONE (<commit>). - Step 7: Gate + commit —
fix(upgrades): never offer a dud crystal card
Task 3: StatsSheet data model
Section titled “Task 3: StatsSheet data model”Pure row builders first (unit-testable without a scene), view in Task 4.
Files:
- Create:
ui/stats_sheet.gd(data half only in this task) - Test:
tests/test_stats_sheet_rows.gd
Interfaces:
-
Consumes:
upgrade_system.ship_stat_preview(sim, pilot, id) -> {field, now, after}(or{}),upgrade_system.parse_crystal_spec(sim, spec) -> Dictionary(element→amount),upgrade_system.effective_dps(sim) -> float,WeaponThresholds.rules_for(wid),sim.content.weapon(wid),sim.max_weapon_slots,sim.crystals.counts. -
Produces (all
static):StatsSheet.ship_rows(sim, pilot, preview_id: String) -> Array[Dictionary]— each{field, name, value: String, after: String ("" = unchanged), frac: float (-1 = no bar)}, fields in ordermax_hp, armor, speed, fire_rate_mult, pickup_radius, dps.StatsSheet.crystal_rows(sim, pilot, preview_id) -> Array[Dictionary]— each{element, have: int, after: int, next: int (0 = no rung above)}, one per element held OR granted by the previewed card, sorted by element name.StatsSheet.weapon_rows(sim, pilot, preview_id) -> Array[Dictionary]— each{wid, name, evolved: bool, ghost: bool, empty: bool, flag: String}; owned weapons first (inactive_weapon_idsorder), then a ghost row for aweapon:Xpreview, then empty slots up tosim.max_weapon_slots.
-
Step 1: Failing test —
tests/test_stats_sheet_rows.gd
extends GutTest
func _sim() -> Sim: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() return sim
func _row(rows: Array, key: String, val: String) -> Dictionary: for r: Dictionary in rows: if String(r.get(key, "")) == val: return r return {}
func test_ship_rows_show_current_values_and_no_after_without_preview() -> void: var sim := _sim() var rows := StatsSheet.ship_rows(sim, sim.player, "") assert_eq(rows.size(), 6) assert_eq(String(rows[0]["field"]), "max_hp") for r: Dictionary in rows: assert_eq(String(r["after"]), "", "%s unchanged" % r["field"])
func test_stat_card_preview_sets_after_on_its_row_only() -> void: var sim := _sim() var id := "" for u in sim.content.upgrades(): # first real stat upgrade that ship_stat_preview understands if not sim.upgrade_system.ship_stat_preview(sim, sim.player, String(u["id"])).is_empty(): id = String(u["id"]) break assert_ne(id, "", "fixture has a previewable stat upgrade") var pv := sim.upgrade_system.ship_stat_preview(sim, sim.player, id) var rows := StatsSheet.ship_rows(sim, sim.player, id) for r: Dictionary in rows: if String(r["field"]) == String(pv["field"]): assert_ne(String(r["after"]), "", "previewed row shows an after value") else: assert_eq(String(r["after"]), "", "%s untouched" % r["field"])
func test_crystal_preview_adds_the_granted_element_with_after_total() -> void: var sim := _sim() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") # cold rung at 5 var rows := StatsSheet.crystal_rows(sim, sim.player, "crystals:cold=1") var cold := _row(rows, "element", "cold") assert_false(cold.is_empty(), "granted element appears even at 0 held") assert_eq(int(cold["have"]), 0) assert_eq(int(cold["after"]), 1) assert_eq(int(cold["next"]), 5, "next rung = orbit's cold 5")
func test_crystal_rows_without_preview_have_after_equal_have() -> void: var sim := _sim() sim.crystals.counts["void"] = 7 var rows := StatsSheet.crystal_rows(sim, sim.player, "") var v := _row(rows, "element", "void") assert_eq(int(v["have"]), 7) assert_eq(int(v["after"]), 7)
func test_weapon_rows_list_owned_then_ghost_then_empty_slots() -> void: var sim := _sim() var owned := sim.player.arsenal.active_weapon_ids.size() var rows := StatsSheet.weapon_rows(sim, sim.player, "weapon:nova") assert_eq(rows.size(), sim.max_weapon_slots, "one row per slot") assert_true(bool(rows[owned]["ghost"]), "the previewed weapon ghosts into the first free slot") assert_eq(String(rows[owned]["wid"]), "nova") for i in range(owned + 1, rows.size()): assert_true(bool(rows[i]["empty"]))
func test_crystal_card_flags_the_weapon_whose_rung_it_crosses() -> void: var sim := _sim() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") var rows := StatsSheet.weapon_rows(sim, sim.player, "crystals:cold=5") var orbit := _row(rows, "wid", "orbit") assert_ne(String(orbit["flag"]), "", "orbit's cold rung is crossed -> flagged")-
Step 2: Run — FAIL (
StatsSheetnot found). -
Step 3: Implement
ui/stats_sheet.gd(data half):
class_name StatsSheetextends Control
# Ship-stats sheet (spec §3): opened with Y over the level-up cards (live preview of the focused card)# and from the pause menu (read-only). Row builders are static + pure so they're tested without a# scene; the view (Task 4) only renders what they return. Reads sim state only.
const SHIP_FIELDS: Array[Array] = [ ["max_hp", "Hull"], ["armor", "Armor"], ["speed", "Speed"], ["fire_rate_mult", "Fire rate"], ["pickup_radius", "Pickup"], ["dps", "DPS"],]
static func _fmt(field: String, pilot: PlayerState, v: float) -> String: match field: "max_hp": return "%.0f / %.0f" % [pilot.hp, v] "fire_rate_mult": return "%.2f×" % v _: return "%.0f" % v
static func ship_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]: var pv: Dictionary = {} if preview_id == "" else sim.upgrade_system.ship_stat_preview(sim, pilot, preview_id) var out: Array[Dictionary] = [] for f: Array in SHIP_FIELDS: var field: String = f[0] var row := {"field": field, "name": f[1], "after": "", "frac": -1.0} if field == "dps": row["value"] = "%.0f" % sim.upgrade_system.effective_dps(sim) # current only, no delta (spec) else: row["value"] = _fmt(field, pilot, float(pilot.get(field))) if String(pv.get("field", "")) == field: row["after"] = _fmt(field, pilot, float(pv["after"])) if field == "max_hp": row["frac"] = clampf(pilot.hp / maxf(pilot.max_hp, 1.0), 0.0, 1.0) out.append(row) return out
static func _bundle(sim: Sim, preview_id: String) -> Dictionary: if not preview_id.begins_with("crystals:"): return {} return sim.upgrade_system.parse_crystal_spec(sim, preview_id.substr("crystals:".length()))
static func _owned_plus_aim(pilot: PlayerState) -> Array: return pilot.arsenal.active_weapon_ids + ["aim"] # "aim" is always on but not in active ids
static func crystal_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]: var bundle := _bundle(sim, preview_id) var els: Array[String] = [] for el: Variant in sim.crystals.counts: if int(sim.crystals.counts[el]) > 0: els.append(String(el)) for el: Variant in bundle: if not els.has(String(el)): els.append(String(el)) els.sort() var out: Array[Dictionary] = [] for el in els: var have := int(sim.crystals.counts.get(el, 0)) var after := have + int(bundle.get(el, 0)) var nxt := 0 for wid: Variant in _owned_plus_aim(pilot): for rule: Dictionary in WeaponThresholds.rules_for(String(wid)): var amt := int(rule["amount"]) if String(rule["element"]) == el and amt > have and (nxt == 0 or amt < nxt): nxt = amt out.append({"element": el, "have": have, "after": after, "next": nxt}) return out
static func weapon_rows(sim: Sim, pilot: PlayerState, preview_id: String) -> Array[Dictionary]: var bundle := _bundle(sim, preview_id) var after: Dictionary = sim.crystals.counts.duplicate() for el: Variant in bundle: after[el] = int(after.get(el, 0)) + int(bundle[el]) var out: Array[Dictionary] = [] for wid: Variant in pilot.arsenal.active_weapon_ids: var w: Variant = pilot.arsenal.weapon_by_id.get(wid) var flag := "" if not bundle.is_empty(): var before_met := WeaponThresholds.rules_met(String(wid), sim.crystals.counts) var after_met := WeaponThresholds.rules_met(String(wid), after) var rules := WeaponThresholds.rules_for(String(wid)) for i in after_met: if not before_met.has(i): flag = "★ EVOLVE" if String(rules[i]["kind"]) == "evolve" else "▲ " + String(rules[i]["kind"]).to_upper() out.append({"wid": String(wid), "name": String(sim.content.weapon(String(wid)).get("name", wid)), "evolved": w != null and bool(w.evolved), "ghost": false, "empty": false, "flag": flag}) if preview_id.begins_with("weapon:"): var nw := preview_id.substr("weapon:".length()) out.append({"wid": nw, "name": String(sim.content.weapon(nw).get("name", nw)), "evolved": false, "ghost": true, "empty": false, "flag": "NEW"}) while out.size() < sim.max_weapon_slots: out.append({"wid": "", "name": "Empty slot", "evolved": false, "ghost": false, "empty": true, "flag": ""}) return out- Step 4: Run — PASS. If
test_weapon_rows…fails because the fixture sim already fills every slot, the precondition is wrong for this fixture: assertowned < sim.max_weapon_slotsfirst and pick the ghost index from the data. - Step 5: Gate + commit —
feat(ui): StatsSheet row model (ship, crystals, weapons, live preview)
Task 4: StatsSheet view
Section titled “Task 4: StatsSheet view”Files:
- Modify:
ui/stats_sheet.gd(add the view half) - Test:
tests/test_stats_sheet_view.gd
Interfaces:
-
Consumes: Task 3 row builders,
GlassPanel,WeaponDetailView.build(dict) -> Control,upgrade_system.weapon_detail(sim, pilot, wid) -> Dictionary. -
Produces:
func bind(sim: Sim, pilot: PlayerState) -> void,func set_preview(id: String) -> void,func open() -> void,func close() -> void,func is_open() -> bool,func move_weapon(step: int) -> void,var weapon_sel: int,var read_only: bool(pause-menu mode: no preview), signalclosed. -
Step 1: Failing test —
tests/test_stats_sheet_view.gd
extends GutTest
func _sheet() -> Array: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") var sheet := StatsSheet.new() add_child_autofree(sheet) sheet.bind(sim, sim.player) return [sim, sheet]
func test_open_close_toggles_visibility() -> void: var r := _sheet() var sheet: StatsSheet = r[1] assert_false(sheet.is_open()) sheet.open() assert_true(sheet.is_open()) assert_true(sheet.visible) sheet.close() assert_false(sheet.is_open())
func test_preview_marks_changed_labels() -> void: var r := _sheet() var sheet: StatsSheet = r[1] sheet.open() sheet.set_preview("crystals:cold=5") await get_tree().process_frame assert_true(sheet.text_dump().contains("0 → 5") or sheet.text_dump().contains("0→5") or sheet.text_dump().contains("→ 5"), "cold bar shows the after total: %s" % sheet.text_dump())
func test_read_only_ignores_preview() -> void: var r := _sheet() var sheet: StatsSheet = r[1] sheet.read_only = true sheet.set_preview("crystals:cold=5") assert_false(sheet.text_dump().contains("→ 5"))
func test_move_weapon_wraps_over_owned_weapons() -> void: var r := _sheet() var sim: Sim = r[0] var sheet: StatsSheet = r[1] var n := sim.player.arsenal.active_weapon_ids.size() sheet.open() sheet.move_weapon(-1) assert_eq(sheet.weapon_sel, n - 1, "wraps backwards") sheet.move_weapon(1) assert_eq(sheet.weapon_sel, 0)-
Step 2: Run — FAIL.
-
Step 3: Implement — add to
ui/stats_sheet.gd:
signal closed
const WIDTH_FRAC := 0.42const HEIGHT_FRAC := 0.58const SLIDE_S := 0.18const GOOD := Color(0.4, 1.0, 0.6)const EVOLVE := Color(1.0, 0.75, 0.25)const ELEMENT_COLORS := { "fire": Color(1.0, 0.55, 0.3), "cold": Color(0.5, 0.88, 1.0), "lightning": Color(1.0, 0.92, 0.35), "void": Color(0.68, 0.45, 1.0), "blood": Color(1.0, 0.3, 0.4), "light": Color(1.0, 0.97, 0.8), "kinetic": Color(0.75, 0.82, 0.9), "aether": Color(0.55, 1.0, 0.85),}
var read_only := falsevar weapon_sel := 0var _sim: Simvar _pilot: PlayerStatevar _preview := ""var _open := falsevar _glass: GlassPanelvar _body: HBoxContainervar _tween: Tween
func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE set_anchors_preset(Control.PRESET_FULL_RECT) _glass = GlassPanel.new() _glass.frosted = true _glass.focus = 0.35 _glass.margin = 18.0 add_child(_glass) _body = HBoxContainer.new() _body.add_theme_constant_override("separation", 22) _glass.add_child(_body) visible = false
func bind(sim: Sim, pilot: PlayerState) -> void: _sim = sim _pilot = pilot weapon_sel = 0 _preview = "" _rebuild()
func set_preview(id: String) -> void: if read_only: return _preview = id _rebuild()
func is_open() -> bool: return _open
func open() -> void: _open = true visible = true _layout() if _tween != null: _tween.kill() _tween = create_tween() _glass.position.x = -_glass.size.x _tween.tween_property(_glass, "position:x", 24.0, SLIDE_S).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
func close() -> void: if not _open: return _open = false visible = false closed.emit()
func move_weapon(step: int) -> void: if _pilot == null: return var n := _pilot.arsenal.active_weapon_ids.size() if n == 0: return weapon_sel = wrapi(weapon_sel + step, 0, n) _rebuild()
# Plain-text dump of every Label, for tests.func text_dump() -> String: var s := "" for l in _all_labels(_body): s += l.text + "\n" return s
func _all_labels(n: Node) -> Array[Label]: var out: Array[Label] = [] for c in n.get_children(): if c is Label: out.append(c) out.append_array(_all_labels(c)) return out
func _layout() -> void: var vp := get_viewport_rect().size if is_inside_tree() else Vector2(1280, 720) _glass.size = Vector2(vp.x * WIDTH_FRAC, vp.y * HEIGHT_FRAC) _glass.position = Vector2(24, 24)
func _label(text: String, size: int, col: Color, title := false) -> Label: var l := Label.new() l.text = text l.add_theme_font_override("font", NeonTheme.title_font() if title else NeonTheme.mono_font()) l.add_theme_font_size_override("font_size", size) l.add_theme_color_override("font_color", col) return l
func _bar(frac: float, ghost: float, col: Color) -> Control: var bar := Control.new() bar.custom_minimum_size = Vector2(0, 5) bar.draw.connect(func() -> void: var w := bar.size.x bar.draw_rect(Rect2(0, 0, w, 5), Color(1, 1, 1, 0.10)) if ghost > frac: bar.draw_rect(Rect2(0, 0, w * clampf(ghost, 0, 1), 5), Color(GOOD.r, GOOD.g, GOOD.b, 0.45)) bar.draw_rect(Rect2(0, 0, w * clampf(frac, 0, 1), 5), col)) return bar
func _rebuild() -> void: if _body == null or _sim == null: return for c in _body.get_children(): c.queue_free() var pv := "" if read_only else _preview var left := VBoxContainer.new() left.size_flags_horizontal = Control.SIZE_EXPAND_FILL left.add_theme_constant_override("separation", 3) _body.add_child(left) left.add_child(_label("SHIP", 13, Tokens.ACCENT, true)) for r: Dictionary in ship_rows(_sim, _pilot, pv): var row := HBoxContainer.new() var name_l := _label(String(r["name"]), 15, Tokens.TEXT_DIM) name_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(name_l) var changed := String(r["after"]) != "" row.add_child(_label(String(r["value"]) + ((" → " + String(r["after"])) if changed else ""), 15, GOOD if changed else (Tokens.GOLD if String(r["field"]) == "dps" else Tokens.TEXT))) left.add_child(row) if float(r["frac"]) >= 0.0: left.add_child(_bar(float(r["frac"]), -1.0, Tokens.HP)) var crows := crystal_rows(_sim, _pilot, pv) if not crows.is_empty(): left.add_child(_label("CRYSTALS", 13, Tokens.ACCENT, true)) for c: Dictionary in crows: var el := String(c["element"]) var col: Color = ELEMENT_COLORS.get(el, Tokens.TEXT) var nxt := int(c["next"]) var have := int(c["have"]) var aft := int(c["after"]) var txt := "%s %d" % [el.to_upper(), have] if aft != have: txt += " → %d" % aft if nxt > 0: txt += " / %d" % nxt left.add_child(_label(txt, 14, GOOD if aft != have else col)) if nxt > 0: left.add_child(_bar(float(have) / nxt, float(aft) / nxt, col)) var right := VBoxContainer.new() right.size_flags_horizontal = Control.SIZE_EXPAND_FILL right.add_theme_constant_override("separation", 4) _body.add_child(right) right.add_child(_label("WEAPONS", 13, Tokens.ACCENT, true)) var rows := weapon_rows(_sim, _pilot, pv) for i in rows.size(): var w: Dictionary = rows[i] var col := Tokens.TEXT_DIM if bool(w["empty"]) else (EVOLVE if bool(w["evolved"]) else Tokens.TEXT) if bool(w["ghost"]) or String(w["flag"]) != "": col = GOOD var mark := "▸ " if (i == weapon_sel and not bool(w["empty"]) and not bool(w["ghost"])) else " " var txt := mark + String(w["name"]) if String(w["flag"]) != "": txt += " " + String(w["flag"]) right.add_child(_label(txt, 15, col)) var owned := _pilot.arsenal.active_weapon_ids if weapon_sel < owned.size(): var detail := WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _pilot, String(owned[weapon_sel]))) right.add_child(detail) _layout()- Step 4: Run — PASS. The preview test matches the crystal line format
COLD 0 → 5 / 5; if it fails, printtext_dump()and fix the format string, not the assertion’s intent (the after total must be visible). - Step 5: Gate + commit —
feat(ui): StatsSheet glass view with live preview + weapon path
Task 5: LevelUpBase + keep the old panel as LegacyLevelUpPanel
Section titled “Task 5: LevelUpBase + keep the old panel as LegacyLevelUpPanel”Files:
- Create:
ui/level_up_base.gd - Create:
ui/level_up_panel_legacy.gd(verbatim copy of today’sui/level_up_panel.gd, class renamed) - Move:
tests/test_level_up_panel.gd→tests/test_level_up_panel_legacy.gd(everyLevelUpPanel.new()→LegacyLevelUpPanel.new()) - Modify:
main.gd:44andmain.gd:1053
Interfaces:
-
Produces:
class_name LevelUpBase extends CanvasLayerwithsignal chosen(id: String),var audio: AudioManager,func show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> voidandfunc hide_panel() -> void(bothpush_error+ no-op in the base).LegacyLevelUpPanel extends LevelUpBase.main.level_up: LevelUpBase. -
Step 1:
ui/level_up_base.gd:
class_name LevelUpBaseextends CanvasLayer
# Shared contract for the two level-up panels (liquid-glass LevelUpPanel and the pre-2026-09-24# LegacyLevelUpPanel, picked by VisualLook.glass_ui in main.gd) so main holds either one.
signal chosen(id: String)
var audio: AudioManager
func show_for(_sim: Sim, _pilot: PlayerState, _ids: Array, _allowed_device: int = -1, _pilot_idx: int = 0) -> void: push_error("LevelUpBase.show_for must be overridden")
func hide_panel() -> void: push_error("LevelUpBase.hide_panel must be overridden")- Step 2:
git mv ui/level_up_panel.gd ui/level_up_panel_legacy.gdthen in it:class_name LevelUpPanel→class_name LegacyLevelUpPanel,extends CanvasLayer→extends LevelUpBase, delete its ownsignal chosen(id: String)line and itsvar audio: AudioManagerline (both now inherited). Remove the stale.uidrename issue by runninggodot --headless --path . --importonce. - Step 3:
git mv tests/test_level_up_panel.gd tests/test_level_up_panel_legacy.gd;sed -i '' 's/LevelUpPanel.new()/LegacyLevelUpPanel.new()/' tests/test_level_up_panel_legacy.gd. Add at the top of every test body nothing else — the legacy panel doesn’t readVisualLook.glass_ui. - Step 4: Create a temporary
ui/level_up_panel.gdso the project still parses until Task 6 replaces it:
class_name LevelUpPanelextends LegacyLevelUpPanel# Temporary shim (Task 5 of the liquid-glass core plan); replaced by the glass panel in Task 6.- Step 5:
main.gd:44var level_up: LevelUpPanel→var level_up: LevelUpBase;main.gd:1053level_up = LevelUpPanel.new()→level_up = LevelUpPanel.new() if VisualLook.glass_ui else LegacyLevelUpPanel.new(). - Step 6: Gate (suite must be fully green: the legacy tests now run against
LegacyLevelUpPanel) + commit —refactor(ui): LevelUpBase; keep the pre-glass panel as LegacyLevelUpPanel
Task 6: Glass level-up panel (layout C) with the stats sheet
Section titled “Task 6: Glass level-up panel (layout C) with the stats sheet”Files:
- Replace:
ui/level_up_panel.gd(overwrite the Task 5 shim) - Test:
tests/test_level_up_panel.gd(new)
Interfaces:
- Consumes:
LevelUpBase,GlassPanel,StatsSheet(Tasks 1, 3, 4),upgrade_system.rank_upgrades,upgrade_system.upgrade_effects,Platform.is_touch(),MenuNav.is_left/right/up/down. - Produces:
class_name LevelUpPanel extends LevelUpBase; test-visible fields_cards: Array[GlassPanel],_sel: int,_sheet: StatsSheet,_header: Label,_gate_device: int,_gate_p1: bool; methods_event_allowed(event) -> bool,_move_sel(step),toggle_sheet(),card_text(i: int) -> String.
Layout (1280×720 base): a full-rect ColorRect gradient dim (shader-free: a TextureRect with a GradientTexture2D, transparent at y=0.40 → Color(0,0.01,0.03,0.88) at y=0.72 and below); header Label centred at y≈ 0.47·H (LEVEL %d · CHOOSE ONE, co-op: PLAYER %d · LEVEL %d); an HBoxContainer anchored bottom with 5% side margins, bottom offset 64, height 170, separation 2% of width, holding up to 3 GlassPanel cards (frosted, SIZE_EXPAND_FILL, first card stretch ratio 1.1); a hint Label centred at bottom offset 26; the StatsSheet added last (above the cards). Card accents: crystal Color(0.6,0.35,1.0), weapon Tokens.GOLD, stat/mod Tokens.ACCENT. A card holds a VBoxContainer: kind label (13, accent, title font, letter-spaced via uppercase), name (24, title font, white), up to 3 change lines (17, mono): each target: detail in Color(0.4,1,0.6) (EVOLVE lines Color(1,0.75,0.25)), or for a dead crystal card its first two progress lines %s %d/%d → %s in Tokens.TEXT_DIM. The top-ranked card (index 0) gets a BEST pill: a small GlassPanel (clear, accent cyan, focus 1, margin 4) with a 11px label, anchored top-right of the card with offset (-10, -10).
Focus visuals: the focused card focus = 1.0, scale tween to 1.03 (pivot = card centre), others focus = 0.15, scale 1.0.
Input (in _input, after the device gate, same debounce NAV_DEBOUNCE_MS := 200):
-
left/right AND up/down →
_move_sel(±1)(left/up = -1). -
confirm (
ui_accept/JOY_BUTTON_A) →chosen.emit(id). -
Y (
JOY_BUTTON_Y) / Tab (KEY_TAB) →toggle_sheet(); B (JOY_BUTTON_B) / Esc-less:KEY_BACKSPACE→ close the sheet if open (never closes the panel). -
shoulders (
JOY_BUTTON_LEFT_SHOULDER/RIGHT_SHOULDER) orKEY_PAGEUP/KEY_PAGEDOWN→_sheet.move_weapon(∓1/±1)when the sheet is open. -
every handled event →
get_viewport().set_input_as_handled(). -
touch: a
STATSButton(only whenPlatform.is_touch()), bottom-right, callstoggle_sheet(). -
On focus change:
_sheet.set_preview(id)(the sheet re-renders even while closed so opening is instant). -
Hint text:
"Ⓐ CHOOSE Ⓨ SHIP STATS"joypad,"ENTER CHOOSE TAB SHIP STATS"whenPlatform.has_mouse()and no joypads (Input.get_connected_joypads().is_empty()), empty string on touch. -
Entry animation on
show_for: each card starts atmodulate.a = 0,position.y += 40relative, tweens in over 0.22s with 0.06s stagger. -
Mouse: card
gui_inputclick → select + confirm; P2-gated panels set every card’smouse_filter = MOUSE_FILTER_IGNORE(same rule as legacy). -
Step 1: Failing test —
tests/test_level_up_panel.gd
extends GutTest
func _open(ids: Array, device := -1, pilot_idx := 0, coop := false) -> Array: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") if coop: sim.add_pilot() var panel := LevelUpPanel.new() add_child_autofree(panel) await get_tree().process_frame var pilot: PlayerState = sim.player2 if pilot_idx == 1 else sim.player panel.show_for(sim, pilot, ids, device, pilot_idx) await get_tree().process_frame return [sim, panel]
func test_one_glass_card_per_choice_best_first() -> void: var r: Array = await _open(["crystals:cold=1", "crystals:void=18", "weapon:nova"]) var panel: LevelUpPanel = r[1] assert_eq(panel._cards.size(), 3) assert_true(panel._cards[0] is GlassPanel) assert_eq(String(panel._cards[0].get_meta("id", "")), "crystals:void=18", "ranked best first") assert_true(panel.card_text(0).contains("BEST"), "only the top card carries the BEST pill") assert_false(panel.card_text(1).contains("BEST"))
func test_card_carries_its_own_changes_or_progress() -> void: var r: Array = await _open(["crystals:cold=1"]) var panel: LevelUpPanel = r[1] assert_true(panel.card_text(0).contains("1/5"), "dead-but-building crystal shows progress: %s" % panel.card_text(0))
func test_moving_focus_updates_the_sheet_preview() -> void: var r: Array = await _open(["weapon:nova", "crystals:cold=5"]) var panel: LevelUpPanel = r[1] panel.toggle_sheet() assert_true(panel._sheet.is_open()) var first := panel._sheet.text_dump() panel._move_sel(1) await get_tree().process_frame assert_ne(panel._sheet.text_dump(), first, "focusing another card re-renders the sheet") panel.toggle_sheet() assert_false(panel._sheet.is_open())
func test_left_right_and_up_down_both_move_focus() -> void: var r: Array = await _open(["armor", "weapon:nova", "crystals:cold=5"]) var panel: LevelUpPanel = r[1] panel._move_sel(1) assert_eq(panel._sel, 1) panel._move_sel(-1) panel._move_sel(-1) assert_eq(panel._sel, 2, "wraps")
func test_single_player_header_has_no_pilot_prefix() -> void: var r: Array = await _open(["armor"]) var panel: LevelUpPanel = r[1] assert_false(panel._header.text.contains("PLAYER")) assert_eq(panel._gate_device, -1)
func test_coop_p2_header_and_device_gate() -> void: var r: Array = await _open(["armor"], 7, 1, true) var panel: LevelUpPanel = r[1] assert_true(panel._header.text.begins_with("PLAYER 2")) var own := InputEventJoypadButton.new(); own.device = 7; own.button_index = JOY_BUTTON_A var other := InputEventJoypadButton.new(); other.device = 0; other.button_index = JOY_BUTTON_A var kbd := InputEventKey.new(); kbd.keycode = KEY_ENTER assert_true(panel._event_allowed(own)) assert_false(panel._event_allowed(other)) assert_false(panel._event_allowed(kbd), "keyboard never drives a P2 panel") for c in panel._cards: assert_eq(c.mouse_filter, Control.MOUSE_FILTER_IGNORE, "P2 cards ignore mouse")
func test_p1_coop_panel_allows_keyboard_and_mouse() -> void: var r: Array = await _open(["armor"], 3, 0, true) var panel: LevelUpPanel = r[1] var kbd := InputEventKey.new(); kbd.keycode = KEY_ENTER assert_true(panel._event_allowed(kbd)) for c in panel._cards: assert_ne(c.mouse_filter, Control.MOUSE_FILTER_IGNORE)
func test_confirm_emits_chosen_for_focused_card() -> void: var r: Array = await _open(["armor", "weapon:nova"]) var panel: LevelUpPanel = r[1] watch_signals(panel) panel._move_sel(1) var a := InputEventJoypadButton.new(); a.button_index = JOY_BUTTON_A; a.pressed = true panel._input(a) assert_signal_emitted_with_parameters(panel, "chosen", [String(panel._cards[1].get_meta("id"))])
func test_y_toggles_sheet_and_b_only_closes_it() -> void: var r: Array = await _open(["armor"]) var panel: LevelUpPanel = r[1] var y := InputEventJoypadButton.new(); y.button_index = JOY_BUTTON_Y; y.pressed = true panel._input(y) assert_true(panel._sheet.is_open()) var b := InputEventJoypadButton.new(); b.button_index = JOY_BUTTON_B; b.pressed = true panel._input(b) assert_false(panel._sheet.is_open()) assert_true(panel.visible, "B never closes the level-up panel itself")-
Step 2: Run — FAIL (shim has no
_sheet/card_text). -
Step 3: Implement
ui/level_up_panel.gd:
class_name LevelUpPanelextends LevelUpBase
# Liquid-glass level-up (spec §2, layout C — Chris, 2026-09-24): three glass cards in a strip along# the bottom, the frozen arena readable above, each card carrying its own changes. Y opens the# StatsSheet, which previews the focused card live. The pre-glass panel is LegacyLevelUpPanel.
const NAV_DEBOUNCE_MS := 200const ACCENT_CRYSTAL := Color(0.6, 0.35, 1.0)const GOOD := Color(0.4, 1.0, 0.6)const EVOLVE := Color(1.0, 0.75, 0.25)const STRIP_H := 170.0const STRIP_BOTTOM := 64.0
var _cards: Array[GlassPanel] = []var _sel := 0var _last_nav_ms := 0var _sheet: StatsSheetvar _header: Labelvar _hint: Labelvar _strip: HBoxContainervar _stats_btn: Buttonvar _sim: Simvar _pilot: PlayerStatevar _effects: Dictionary = {}var _gate_device := -1var _gate_p1 := true
func _ready() -> void: layer = 10 var dim := TextureRect.new() var grad := Gradient.new() grad.set_offset(0, 0.40); grad.set_color(0, Color(0, 0.01, 0.03, 0.0)) grad.set_offset(1, 0.72); grad.set_color(1, Color(0, 0.01, 0.03, 0.88)) var gt := GradientTexture2D.new() gt.gradient = grad gt.fill_from = Vector2(0, 0) gt.fill_to = Vector2(0, 1) dim.texture = gt dim.stretch_mode = TextureRect.STRETCH_SCALE dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.mouse_filter = Control.MOUSE_FILTER_STOP add_child(dim)
_header = Label.new() _header.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _header.grow_horizontal = Control.GROW_DIRECTION_BOTH _header.offset_top = -(STRIP_BOTTOM + STRIP_H + 64) _header.offset_bottom = -(STRIP_BOTTOM + STRIP_H + 24) _header.add_theme_font_override("font", NeonTheme.title_font()) _header.add_theme_font_size_override("font_size", 26) _header.add_theme_color_override("font_color", Color(0.88, 0.97, 1.0)) _header.add_theme_color_override("font_outline_color", Color(0.2, 0.9, 1.0, 0.35)) _header.add_theme_constant_override("outline_size", 6) add_child(_header)
_strip = HBoxContainer.new() _strip.anchor_left = 0.05 _strip.anchor_right = 0.95 _strip.anchor_top = 1.0 _strip.anchor_bottom = 1.0 _strip.offset_top = -(STRIP_BOTTOM + STRIP_H) _strip.offset_bottom = -STRIP_BOTTOM _strip.add_theme_constant_override("separation", 26) add_child(_strip)
_hint = Label.new() _hint.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _hint.grow_horizontal = Control.GROW_DIRECTION_BOTH _hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _hint.offset_top = -40 _hint.offset_bottom = -18 _hint.add_theme_font_override("font", NeonTheme.mono_font()) _hint.add_theme_font_size_override("font_size", 14) _hint.add_theme_color_override("font_color", Color(0.72, 0.8, 0.9)) add_child(_hint)
if Platform.is_touch(): _stats_btn = Button.new() _stats_btn.text = "STATS" _stats_btn.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) _stats_btn.offset_left = -130 _stats_btn.offset_top = -52 _stats_btn.offset_right = -24 _stats_btn.offset_bottom = -14 _stats_btn.theme = NeonTheme.get_theme() _stats_btn.pressed.connect(toggle_sheet) add_child(_stats_btn)
_sheet = StatsSheet.new() add_child(_sheet) hide_panel()
func show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> void: _sim = sim _pilot = pilot _gate_device = allowed_device _gate_p1 = pilot_idx == 0 for c in _strip.get_children(): c.queue_free() _cards.clear() _effects.clear() var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, pilot, ids) for id in ranked: _effects[id] = sim.upgrade_system.upgrade_effects(sim, pilot, id) var coop := sim.pilots.size() > 1 _header.text = ("PLAYER %d · LEVEL %d" % [pilot_idx + 1, pilot.level]) if coop \ else "LEVEL %d · CHOOSE ONE" % pilot.level _header.add_theme_color_override("font_color", (Color(1.0, 0.75, 0.2) if _gate_p1 else Color(0.55, 0.85, 1.0)) if coop else Color(0.88, 0.97, 1.0)) for i in ranked.size(): var card := _make_card(ranked[i], i) _strip.add_child(card) _cards.append(card) _hint.text = _hint_text() _sheet.close() _sheet.read_only = false _sheet.bind(sim, pilot) visible = true _last_nav_ms = 0 _sel = 0 _focus(0) _animate_in()
func hide_panel() -> void: visible = false if _sheet != null: _sheet.close() _cards.clear() _sim = null _pilot = null _gate_device = -1 _gate_p1 = true
func toggle_sheet() -> void: if _sheet.is_open(): _sheet.close() else: _sheet.open() _ui_nav()
func card_text(i: int) -> String: var s := "" var stack: Array[Node] = [_cards[i]] while not stack.is_empty(): var n: Node = stack.pop_back() if n is Label: s += (n as Label).text + "\n" stack.append_array(n.get_children(true)) # include INTERNAL children (the BEST pill is one) return s
func _hint_text() -> String: if Platform.is_touch(): return "" if Input.get_connected_joypads().is_empty() and Platform.has_mouse(): return "ENTER CHOOSE TAB SHIP STATS" return "Ⓐ CHOOSE Ⓨ SHIP STATS"
func _accent(kind: String) -> Color: match kind: "crystal": return ACCENT_CRYSTAL "weapon": return Tokens.GOLD _: return Tokens.ACCENT
func _kind_label(id: String, kind: String) -> String: match kind: "crystal": return "CRYSTALS" "weapon": return "NEW WEAPON" "mod": return "MOD" if id.begins_with("wm:"): return id.split(":")[1].to_upper() + " MOD" return "SHIP"
func _lbl(text: String, size: int, col: Color, title := false) -> Label: var l := Label.new() l.text = text l.mouse_filter = Control.MOUSE_FILTER_IGNORE l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART l.add_theme_font_override("font", NeonTheme.title_font() if title else NeonTheme.mono_font()) l.add_theme_font_size_override("font_size", size) l.add_theme_color_override("font_color", col) return l
func _make_card(id: String, rank: int) -> GlassPanel: var eff: Dictionary = _effects.get(id, {}) var kind := String(eff.get("kind", "stat")) var accent := _accent(kind) var card := GlassPanel.new() card.frosted = true card.accent = accent card.margin = 16.0 card.size_flags_horizontal = Control.SIZE_EXPAND_FILL card.size_flags_stretch_ratio = 1.1 if rank == 0 else 1.0 card.set_meta("id", id) card.mouse_filter = Control.MOUSE_FILTER_IGNORE if (_gate_device != -1 and not _gate_p1) else Control.MOUSE_FILTER_STOP card.gui_input.connect(func(e: InputEvent) -> void: if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: _focus(_cards.find(card)) _confirm()) var box := VBoxContainer.new() box.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_theme_constant_override("separation", 4) card.add_child(box) box.add_child(_lbl(_kind_label(id, kind), 13, accent, true)) box.add_child(_lbl(String(eff.get("headline", id)), 24, Color.WHITE, true)) var changes: Array = eff.get("changes", []) if bool(eff.get("dead", false)) or changes.is_empty(): var progress: Array = eff.get("progress", []) for p: Dictionary in progress.slice(0, 2): box.add_child(_lbl("%s %d/%d → %s" % [String(p.get("target", "")).capitalize(), int(p.get("have", 0)), int(p.get("need", 0)), String(p.get("effect", ""))], 17, Tokens.TEXT_DIM)) else: for ch: Dictionary in changes.slice(0, 3): var ev := bool(ch.get("evolve", false)) box.add_child(_lbl("%s: %s" % [String(ch.get("target", "")).capitalize(), String(ch.get("detail", ""))], 17, EVOLVE if ev else GOOD)) if rank == 0 and _cards.is_empty() and _effects.size() > 1: var pill := GlassPanel.new() pill.accent = Tokens.ACCENT pill.focus = 1.0 pill.margin = 4.0 pill.add_child(_lbl("BEST", 11, Color(0.01, 0.08, 0.12), true)) pill.set_anchors_preset(Control.PRESET_TOP_RIGHT) pill.position = Vector2(-10, -10) card.add_child(pill, false, Node.INTERNAL_MODE_BACK) # internal: not laid out by the card return card
func _focus(i: int) -> void: if _cards.is_empty(): return _sel = clampi(i, 0, _cards.size() - 1) for k in _cards.size(): var c := _cards[k] c.focus = 1.0 if k == _sel else 0.15 c.pivot_offset = c.size * 0.5 c.scale = Vector2.ONE * (1.03 if k == _sel else 1.0) _sheet.set_preview(String(_cards[_sel].get_meta("id", "")))
func _move_sel(step: int) -> void: if _cards.is_empty(): return _focus(wrapi(_sel + step, 0, _cards.size())) _ui_nav()
func _confirm() -> void: if _cards.is_empty(): return _ui_nav() chosen.emit(String(_cards[_sel].get_meta("id", "")))
func _animate_in() -> void: for k in _cards.size(): var c := _cards[k] c.modulate.a = 0.0 var tw := create_tween() tw.tween_interval(0.06 * k) tw.tween_property(c, "modulate:a", 1.0, 0.22)
func _event_allowed(event: InputEvent) -> bool: if event is InputEventJoypadButton or event is InputEventJoypadMotion: return event.device == _gate_device return _gate_p1
func _input(event: InputEvent) -> void: if not visible or _cards.is_empty(): return if _gate_device != -1 and not _event_allowed(event): return var jb := event as InputEventJoypadButton var key := event as InputEventKey var jpress := jb != null and jb.pressed var kpress := key != null and key.pressed and not key.echo if (jpress and jb.button_index == JOY_BUTTON_Y) or (kpress and key.keycode == KEY_TAB): toggle_sheet() get_viewport().set_input_as_handled() return if (jpress and jb.button_index == JOY_BUTTON_B) or (kpress and key.keycode == KEY_BACKSPACE): if _sheet.is_open(): _sheet.close() get_viewport().set_input_as_handled() return if _sheet.is_open() and ((jpress and jb.button_index in [JOY_BUTTON_LEFT_SHOULDER, JOY_BUTTON_RIGHT_SHOULDER]) \ or (kpress and key.keycode in [KEY_PAGEUP, KEY_PAGEDOWN])): var fwd := (jpress and jb.button_index == JOY_BUTTON_RIGHT_SHOULDER) or (kpress and key.keycode == KEY_PAGEDOWN) _sheet.move_weapon(1 if fwd else -1) get_viewport().set_input_as_handled() return var confirm := event.is_action_pressed("ui_accept") or (jpress and jb.button_index == JOY_BUTTON_A) if confirm: _confirm() get_viewport().set_input_as_handled() return var fwd_nav := MenuNav.is_right(event) or MenuNav.is_down(event) var back_nav := MenuNav.is_left(event) or MenuNav.is_up(event) if not (fwd_nav or back_nav): return var now := Time.get_ticks_msec() if now - _last_nav_ms >= NAV_DEBOUNCE_MS: _last_nav_ms = now _move_sel(1 if fwd_nav else -1) get_viewport().set_input_as_handled()
func _ui_nav() -> void: if audio != null: audio.ui_nav()Notes for the implementer:
-
_confirmcalls_ui_nav()because the legacy_ui_select()also calledaudio.ui_nav()— same sound. -
The BEST pill check
_cards.is_empty()is true while building card 0 (it’s appended after_make_cardreturns)._effects.size() > 1hides the pill when only one card is offered. -
The BEST pill is an INTERNAL child so
GlassPanel’s sort doesn’t stretch it; set itssize = pill.get_combined_minimum_size()after adding, and anchor byposition = Vector2(card.size.x - pill.size.x + 10, -10)inside the card’sresizedsignal. -
Step 4: Run — PASS, then the full gate (legacy tests must still pass).
-
Step 5: ui-sweep.
zsh scripts/ui-sweep.sh; thelevel_upcapture will differ (expected). Opentools/ui_sweep/out/level_up.pngand check: three cards bottom-third, arena visible above, card text not clipped, BEST pill on card 1, hint line readable. Only then copy it totools/ui_sweep/baseline/level_up.png. Add a new sweep screenlevel_up_statsintools/ui_sweep/sweep.gd’sscreenslist right afterlevel_up:
["level_up_stats", func() -> void: await _stage_level_up(); main.level_up.toggle_sheet()],and in _reset_between treat "level_up_stats" exactly like "level_up" (add it to that match arm). Eyeball out/level_up_stats.png (sheet top-left, cards live below, preview lines green) and bless it.
- Step 6: Restore project.godot, gate + commit —
feat(ui): liquid-glass level-up (bottom strip) with Y ship-stats sheet
Task 7: Glass pause menu + read-only stats sheet
Section titled “Task 7: Glass pause menu + read-only stats sheet”Files:
- Modify:
ui/pause_menu.gd - Modify:
main.gd(connect the new signal) - Test:
tests/test_pause_menu.gd(append)
Interfaces:
-
Consumes:
GlassPanel,StatsSheet. -
Produces:
signal stats_requestedonPauseMenuis NOT needed — the sheet lives inside the pause menu. New:var _sheet: StatsSheet,func bind_stats(sim: Sim, pilot: PlayerState) -> void(main calls it when opening the pause menu), aShip statsbutton second in the list. -
Step 1: Failing tests — append to
tests/test_pause_menu.gd:
func test_glass_pause_has_ship_stats_second() -> void: var menu := PauseMenu.new() add_child_autofree(menu) await get_tree().process_frame assert_eq(menu._buttons[1].text, "Ship stats")
func test_ship_stats_opens_read_only_sheet_and_b_closes_it() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() var menu := PauseMenu.new() add_child_autofree(menu) await get_tree().process_frame menu.bind_stats(sim, sim.player) menu._buttons[1].emit_signal("pressed") assert_true(menu._sheet.is_open()) assert_true(menu._sheet.read_only) var b := InputEventJoypadButton.new(); b.button_index = JOY_BUTTON_B; b.pressed = true menu._input(b) assert_false(menu._sheet.is_open()) assert_true(menu.visible, "B closes the sheet, not the pause menu")(If PauseMenu’s class name differs, read the top of ui/pause_menu.gd and use it.)
- Step 2: Run — FAIL.
- Step 3: Implement in
ui/pause_menu.gd:- Add
var _sheet: StatsSheet. - In
_ready(), whenVisualLook.glass_ui: replace theCenterContainer’s directboxparent with aGlassPanel(frosted, accentTokens.ACCENT, focus 0.2, margin 28) holdingbox; set the dimColorRectcolour toColor(0.0, 0.01, 0.03, 0.55)(the frost does the separating now); title font size 44; buttonscustom_minimum_size = Vector2(380, 48). - Insert after Resume:
box.add_child(_make_btn("Ship stats", func() -> void: _open_stats(), Color(0.55, 0.85, 1.0))). - Rename the
Back to Start Menubutton text toQuit to menu. - Dev-only buttons (Arm Remote Control, Debug Settings) stay behind
BuildConfig.dev_tools()as now, but getmodulate.a = 0.6; the_code_labelmoves below the buttons (above the build label) so the header is title + time only. - After the box:
_sheet = StatsSheet.new(); _sheet.read_only = true; add_child(_sheet). func bind_stats(sim: Sim, pilot: PlayerState) -> void: _sheet.bind(sim, pilot).func _open_stats() -> void: _sheet.open().- In
_btn_box, whenVisualLook.glass_ui, return aStyleBoxEmptyfor every state and instead wrap each button: simplest compliant approach — keep the Button but set its styleboxes toStyleBoxEmptyand add aGlassPanel(clear, not frosted, accent = the button’s accent, margin 0) as an INTERNAL front child of the Button sized to it onresized; onfocus_entered/focus_exitedset that panel’sfocusto 1.0 / 0.0. Put this in a helper_glass_skin(b: Button, accent: Color)called from_make_btnwhenVisualLook.glass_ui. - In
_input, first thing after the visibility check: if_sheet != null and _sheet.is_open(): B / Y / Backspace / Tab close the sheet; shoulders / PageUp / PageDown call_sheet.move_weapon; everything else (joypad/key) is consumed; return. - The Remote-control
set_codetext stays as is.
- Add
- Step 4: In
main.gd, find where the pause menu is shown (_toggle_pause) and, right after it sets the run time (pause_menu.set_run_time(...)), addpause_menu.bind_stats(sim, sim.player). - Step 5: Run — PASS; full gate.
- Step 6: ui-sweep — eyeball
out/pause.png(glass column, title + time only in the header, Ship stats second) and bless. - Step 7: Commit —
feat(ui): glass pause menu with read-only ship stats
Task 8: Debug toggle, docs, on-device check
Section titled “Task 8: Debug toggle, docs, on-device check”Files:
-
Modify:
ui/debug_settings_panel.gd(aglass_uirow besidenova_look) -
Modify:
docs/RELEASE-0.1.md,docs/architecture/rendering-and-performance.md,CLAUDE.md(Current status, one condensed bullet, update in place) -
Step 1: In
debug_settings_panel.gd, find the"nova_look"toggle row definition (search fornova_look— it appears in the rows list, inapply_graphics_toggle, and in the_graphics_stateseeding). Add a"glass_ui"row next to each: labelLiquid-glass UI,apply_graphics_togglecase"glass_ui": VisualLook.glass_ui = enabled # takes effect next time a screen opens, seeding_graphics_state["glass_ui"] = VisualLook.glass_ui. Main’s level-up panel is built once at boot, so note in the row label comment that the level-up swap needs an app restart. -
Step 2:
docs/architecture/rendering-and-performance.md: a short “Liquid-glass UI” section — GlassPanel, frost vs clear, the paused-only rule, the screen-copy-once behaviour, where the shaders live. -
Step 3: On-device frame check. Build to the Living Room Apple TV via the
bh-deployskill (BUILD bump), open a level-up with the sheet open, leave it 30s, then query telemetry for that build’s samples while paused (bh-telemetryskill). Record frame time inRELEASE-0.1.md’s 4th-exception entry. Paused frames carry no swarm cost, so anything under the 45 fps target on this screen is a real finding to report to Chris, not to tune silently. -
Step 4: Update the 4th-exception entry in
RELEASE-0.1.mdwith the commits, and CLAUDE.md’s Current status (one bullet, in place). -
Step 5: Gate + commit + push —
docs: liquid-glass UI core shipped (Plan 1)
Self-review notes (plan author)
Section titled “Self-review notes (plan author)”- Spec coverage: §1 → Task 1; §2 layout + dud filter → Tasks 2, 5, 6; §3 → Tasks 3, 4, 6, 7; §4 pause → Task 7 (other screens → Plan 2, stated in the header); §5 perf → Task 1 (clear variant), Task 8 (device check); §6 testing → every task; §7 → Task 8.
- “Multiple frosted panels share one screen copy”: Godot copies the screen once for the first screen-reading item drawn and reuses it for later ones in the same frame, so no
BackBufferCopyis added. If Task 1’s preview shows a later panel NOT blurring earlier panels’ content, that’s this behaviour and is intended. - Interface names cross-checked:
StatsSheet.ship_rows/crystal_rows/weapon_rows/bind/set_preview/open/close/is_open/move_weapon/text_dump/read_only/weapon_sel,GlassPanel.accent/focus/frosted/dim/margin/backdrop/GLOW_PAD/FROST_SHADER/CLEAR_SHADER,LevelUpPanel._cards/_sel/_sheet/_header/toggle_sheet/card_text/_move_sel/_event_allowed,upgrade_system.is_dud_crystal/roll_live_crystal_grant.