HUD Elegance Pass Implementation Plan
HUD Elegance Pass Implementation Plan
Section titled “HUD Elegance Pass 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: Reduce the live-gameplay HUD to survival-critical info by default, escalate visual weight with actual urgency, and replace the warp ability icon with a diegetic ship-thruster indicator.
Architecture: Six sequential tasks, each touching a small, well-scoped set of files:
remove dead code (the superseded weapon-info overlay), consolidate the top-center HP block
down to HP-only with urgency-driven prominence, relocate level display to two existing
screens, add a lightweight level-up celebration via the existing FxManager/fx_events
render pipeline, make the ship’s own thruster show warp readiness, and gate the weapon/drone
docks behind a single toggle. All render/UI-side — no /sim changes, so determinism is
unaffected by construction throughout.
Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 (headless test runner).
Global Constraints
Section titled “Global Constraints”- Render/UI-side only — no task in this plan touches
/sim. Re-verifytests/test_determinism_checksum.gdafter each task as a sanity check (expected: no change). - Follow
DESIGN.md(project root) for any new visual element: near-black translucent panels, restrained 1–2px accent borders,NeonTheme.CYANas the default accent,NeonTheme.title_font()/mono_font()for labels/numbers. - The EVE-style drone combat rework raised during design discussion is explicitly OUT OF SCOPE for this plan — decoy/drone simulation behavior does not change here, only its HUD visibility.
- Anchored
Controls viaset_anchors_preset— never hardcoded pixel positions assuming a specific window size (existing project convention). - Bump
Sim_Const.BUILDonly if/when this ships to a device — not required mid-plan.
Task 1: Remove the superseded weapon-info overlay
Section titled “Task 1: Remove the superseded weapon-info overlay”Files:
- Delete:
ui/weapon_info_overlay.gd - Delete:
tests/test_weapon_info.gd - Modify:
main.gd
Interfaces:
-
Produces:
Y(controller) andV(keyboard) are free of any binding after this task — Task 6 binds them to the new tactical-HUD toggle. -
Step 1: Confirm current test baseline
bash scripts/check-test-count.shExpected: all green, note the script/test counts to compare against after this task’s
removals (both counts should drop by exactly 1 script; test count drops by however many
tests were in test_weapon_info.gd).
- Step 2: Delete the overlay class and its test file
rm ui/weapon_info_overlay.gd ui/weapon_info_overlay.gd.uid tests/test_weapon_info.gd tests/test_weapon_info.gd.uid(the .uid files are Godot’s per-resource sidecar files; remove them alongside their .gd
if present — ls ui/*.uid tests/*.uid first if unsure which exist).
- Step 3: Remove every reference in
main.gd
Remove the field declaration (currently near line 85):
var weapon_info: WeaponInfoOverlay # in-play weapon details (Y button / V), freezes the sim while openRemove its instantiation (currently near line 798):
weapon_info = WeaponInfoOverlay.new() add_child(weapon_info)Remove the _toggle_weapon_info() function entirely (currently near line 1452):
# Toggle the in-play weapon-details overlay. Only during an active run (not on the# menu / level-up / pause / game-over screens); the sim freezes while it's open.func _toggle_weapon_info() -> void: if sim == null or sim.game_over or _paused_for_menu or _paused_for_levelup or _story_won: return weapon_info.toggle(sim)Remove its two _input() bindings (currently near lines 1421 and 1433-1435):
# Y button: toggle the in-play weapon-details overlay (freezes the sim while open). if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y: _toggle_weapon_info() returnand
if event.physical_keycode == KEY_V: _toggle_weapon_info() returnRemove weapon_info.is_open() from the three stacking-guard conditions it appears in
(_physics_process’s _frozen check, _check_codex_encounters, _check_teaser_event —
search the file for weapon_info.is_open(), there are exactly 3 occurrences). Each is an
or-chained boolean condition; delete just the or weapon_info.is_open() clause from each,
leaving the rest of the condition intact. Example (the _physics_process one):
# Before: var _frozen := sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \ or weapon_info.is_open() or codex.is_open() or boss_teaser.is_open() or _warping # After: var _frozen := sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \ or codex.is_open() or boss_teaser.is_open() or _warping- Step 4: Verify no references remain
grep -rn "weapon_info\|WeaponInfoOverlay\|_toggle_weapon_info" main.gdExpected: no output (empty).
- Step 5: Full suite + boot check
godot --headless --path . --importbash scripts/check-test-count.shgodot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"Expected: count guard passes with exactly one fewer script than before Step 1, boot check empty output.
- Step 6: Commit
git add -A ui/weapon_info_overlay.gd tests/test_weapon_info.gd main.gdgit commit -m "chore: remove weapon-info overlay, superseded by Ship Configuration"Task 2: HP bar becomes the sole top-center readout
Section titled “Task 2: HP bar becomes the sole top-center readout”Files:
- Modify:
ui/hud.gd - Modify:
tests/test_hud.gd
Interfaces:
-
Produces:
Hud.HP_BACKING_W: float(244.0) — the new HP block’s total width, replacingXP_ROW_W’s role as the centering dimension.Hud._hp_prominence(frac: float) -> float(private, but exercised viaupdate_hud()’s effect on_hp_group.modulate.a— expose a test seamfunc hp_group_alpha() -> float: return _hp_group.modulate.a). -
Consumes: nothing from other tasks.
-
Step 1: Write the failing tests
Replace the entire content of tests/test_hud.gd with this (removes the now-invalid
XP/level tests, updates the HP-width test for the new BAR_W, adds prominence tests):
extends GutTest
func _make_sim() -> Sim: return Sim.new(1, SimContentFixture.db())
func test_update_hud_full_hp_fill_is_max() -> void: var hud := Hud.new() add_child_autofree(hud) var sim := _make_sim() sim.player.hp = sim.player.max_hp hud.update_hud(sim) assert_almost_eq(hud.hp_fill_width(), Hud.BAR_W, 1.0, "full HP = max fill width")
func test_update_hud_low_hp_fill_color_is_reddish() -> void: var hud := Hud.new() add_child_autofree(hud) var sim := _make_sim() sim.player.hp = sim.player.max_hp * 0.2 # 20% HP, below 30% threshold hud.update_hud(sim) var col := hud.hp_fill_color() assert_gt(col.r, col.g, "low HP fill is reddish (r > g)")
func test_update_hud_no_push_error() -> void: var hud := Hud.new() add_child_autofree(hud) hud.update_hud(_make_sim()) assert_push_error_count(0, "update_hud should not push any errors")
# ── HP bar moved top-left → top-center (2026-07-03 HUD polish pass) ────────────────────────
func test_hp_group_is_anchored_top_center_not_top_left() -> void: var hud := Hud.new() add_child_autofree(hud) assert_almost_eq(hud._hp_group.anchor_left, 0.5, 0.001, "HP block anchors to horizontal center") assert_almost_eq(hud._hp_group.anchor_right, 0.5, 0.001, "HP block anchors to horizontal center") assert_almost_eq(hud._hp_group.anchor_top, 0.0, 0.001, "HP block still anchors to the top edge")
func test_hp_group_is_horizontally_centered_on_its_own_width() -> void: var hud := Hud.new() add_child_autofree(hud) # Now that level/XP are gone, the HP block's width is just the bar backing's width # (Hud.HP_BACKING_W), not the old XP_ROW_W. assert_almost_eq(hud._hp_group.offset_left, -Hud.HP_BACKING_W * 0.5, 0.5, "HP block centers on its own width")
# ── Boss HP bar must never overlap the (now top-center) player HP block ────────────────────
func test_boss_bar_sits_below_the_player_hp_block_with_no_overlap() -> void: var hud := Hud.new() add_child_autofree(hud) # Player HP block's absolute bottom edge: hp_group.position.y + its local content height # (34px HP row, no XP row underneath anymore -- bottom at local y=34). var hp_block_bottom: float = hud._hp_group.position.y + 34.0 assert_gt(hud._boss_group.position.y, hp_block_bottom, "boss bar starts below the player HP block, not overlapping it")
# ── Kills/gold/DPS removed from the live HUD (moved to the results screen instead) ─────────
func test_kills_gold_dps_labels_no_longer_on_the_live_hud() -> void: var hud := Hud.new() add_child_autofree(hud) hud.update_hud(_make_sim()) for c in hud.get_children(): if c is Label: var t: String = (c as Label).text assert_eq(t.find("kills"), -1, "no live 'kills' readout on the HUD") assert_eq(t.find("gold"), -1, "no live 'gold' readout on the HUD") assert_eq(t.find("DPS"), -1, "no live 'DPS' readout on the HUD")
# ── Warp ability glyph — kept only as pure geometry (see hud.gd); the icon widget itself# moves to PlayerRenderer in a later task, so no glyph-rendering tests live here anymore.
# ── Level/XP removed from the live HUD entirely (2026-07-03 HUD elegance pass) ──────────────
func test_level_label_no_longer_exists_on_hud() -> void: var hud := Hud.new() add_child_autofree(hud) hud.update_hud(_make_sim()) for c in hud.get_children(): if c is Label: assert_eq((c as Label).text.begins_with("Lv "), false, "no 'Lv N' label on the live HUD")
# ── HP prominence scales with urgency: subtle at full HP, prominent as HP drops ─────────────
func test_hp_group_is_subtle_at_full_hp() -> void: var hud := Hud.new() add_child_autofree(hud) var sim := _make_sim() sim.player.hp = sim.player.max_hp hud.update_hud(sim) assert_lt(hud.hp_group_alpha(), 0.6, "full HP renders subtly, not at full opacity")
func test_hp_group_is_fully_prominent_at_low_hp() -> void: var hud := Hud.new() add_child_autofree(hud) var sim := _make_sim() sim.player.hp = sim.player.max_hp * 0.2 # below the 0.3 danger threshold hud.update_hud(sim) assert_almost_eq(hud.hp_group_alpha(), 1.0, 0.01, "low HP renders at full prominence")
func test_hp_group_prominence_ramps_between_thresholds() -> void: var hud := Hud.new() add_child_autofree(hud) var sim := _make_sim() sim.player.hp = sim.player.max_hp * 0.45 # midway in the 0.3-0.6 transitional band hud.update_hud(sim) var a := hud.hp_group_alpha() assert_gt(a, 0.45, "transitional HP is more prominent than the full-HP baseline") assert_lt(a, 1.0, "transitional HP is less prominent than the low-HP maximum")- Step 2: Run tests to verify they fail
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexitExpected: FAIL — Hud.HP_BACKING_W doesn’t exist yet, hp_group_alpha() doesn’t exist yet,
and the full-HP-fill test’s expected value assumes the new (not-yet-written) BAR_W.
- Step 3: Rewrite
ui/hud.gd’s HP block + remove level/XP
Replace the const block (currently lines 7-15) with:
const BAR_W: float = 230.0 # widened HP fill (was 170) -- more presence on screenconst BAR_H: float = 22.0const HP_BACKING_W: float = 244.0 # BAR_W + the same 6px/8px left/right inset as beforeconst HP_BACKING_H: float = 34.0const BOSS_BAR_W: float = 460.0 # was 680 — smaller, top-aligned readoutconst BOSS_BAR_H: float = 22.0 # was 30const BOSS_BAR_Y: float = 88.0 # below the top-center player HP block (y 22-56), see _ready()Replace the var block (currently lines 17-29) with:
var _hp_group: Control # the HP block; top-CENTER (2026-07-03, was top-left)var _hp_fill: Panelvar _hp_fill_sb: StyleBoxFlat # mutated in place each frame (no per-tick alloc) — the colour seamvar _hp_label: Labelvar _story_label: Labelvar _banner_label: Label # centered wave/boss macro-loop banner (survival/crystal)var _boss_group: Controlvar _boss_fill: Panelvar _boss_label: Label # shows the boss's NAME (WARDEN / OVERSEER / …), not a generic "BOSS"(_level_label, _xp_fill, _xp_fill_sb, _ability_bars are all removed — level/XP by
this task, _ability_bars in a later task in this same plan, not yet.)
Replace the HP block construction in _ready() (currently lines 34-99, everything from the
# ── HP bar + level comment through the end of the XP bar construction) with:
# ── HP bar (top-CENTER, anchored) ─────────────────────────────────── # Moved from top-left to top-center (2026-07-03, HUD polish pass) so the player's own vital # stat reads as the primary top-of-screen readout. Level/XP removed from the live HUD # entirely (2026-07-03, HUD elegance pass) — level now shows on the level-up choice panel # and Ship Configuration screen instead; a level-up fires a brief cheer (see FxManager) # rather than a persistent bar. The HP block is now just the bar itself, so it centers on # HP_BACKING_W alone. _hp_group = Control.new() _hp_group.set_anchors_preset(Control.PRESET_CENTER_TOP) _hp_group.position = Vector2(-HP_BACKING_W * 0.5, 22) _hp_group.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_hp_group)
var bar_backing := Panel.new() bar_backing.position = Vector2(0, 0) bar_backing.size = Vector2(HP_BACKING_W, HP_BACKING_H) bar_backing.add_theme_stylebox_override("panel", _bar_track_style(NeonTheme.CYAN)) bar_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE _hp_group.add_child(bar_backing)
_hp_fill = Panel.new() _hp_fill.position = Vector2(6, 6) _hp_fill.size = Vector2(BAR_W, BAR_H) _hp_fill_sb = _bar_fill_style(NeonTheme.CYAN) _hp_fill.add_theme_stylebox_override("panel", _hp_fill_sb) _hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE bar_backing.add_child(_hp_fill)
_hp_label = Label.new() _hp_label.position = Vector2(0, 0) _hp_label.size = Vector2(HP_BACKING_W, HP_BACKING_H) _hp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _hp_label.add_theme_font_override("font", NeonTheme.mono_font()) _hp_label.add_theme_font_size_override("font_size", 13) _hp_label.add_theme_color_override("font_color", NeonTheme.TEXT) _hp_label.mouse_filter = Control.MOUSE_FILTER_IGNORE bar_backing.add_child(_hp_label)
# Kills/gold/DPS were removed from the live HUD (2026-07-03, HUD polish pass) — they now # live on the results screen (ResultsPanel.show_results/show_victory), which is where a # run's final numbers actually matter; mid-run they were just top-right clutter. See # ui/results_panel.gd for the DPS readout added there in the same pass.Update the boss bar’s own comment (currently around lines 134-138) so the byte-offset math in the comment stays accurate — replace:
# ── Boss HP bar (top-centre, shown only while a boss is alive) ───── # Sits BELOW the player's own HP+level+XP block (moved there 2026-07-03 when the player HP # bar relocated from top-left to top-center) so the two never overlap when both are visible # mid-fight — the player bar's block runs from y=22 to y=71 (34px HP row + 11px XP row); the # boss bar starts at BOSS_BAR_Y=88, a clear 17px below that.with:
# ── Boss HP bar (top-centre, shown only while a boss is alive) ───── # Sits BELOW the player's own HP block (moved there 2026-07-03 when the player HP bar # relocated from top-left to top-center) so the two never overlap when both are visible # mid-fight — the player bar's block runs from y=22 to y=56 (34px HP row, no XP row # anymore); the boss bar starts at BOSS_BAR_Y=88, a clear 32px below that.Remove the _ability_bars field’s reference from the _ready() comment block above it is
NOT part of this task (leave the _AbilityBars/warp icon code exactly as it is for now — a
later task in this plan removes it). Do NOT touch that code in this task.
Replace update_hud() (currently lines 197-223) with:
func update_hud(sim: Sim) -> void: var frac: float = clampf(sim.player.hp / maxf(sim.player.max_hp, 1.0), 0.0, 1.0) _hp_fill.size.x = maxf(BAR_W * frac, 8.0 if frac > 0.0 else 0.0) # keep a rounded sliver visible var col := _hp_color(frac) _hp_fill_sb.bg_color = col _hp_fill_sb.shadow_color = Color(col.r, col.g, col.b, 0.5) # Low-HP danger pulse on the bar itself — a clear cue now that the screen red is gentler. _hp_fill.modulate.a = (0.65 + 0.35 * sin(sim.run_time * 9.0)) if frac < 0.3 else 1.0 # Whole-block prominence scales with urgency: subtle/quiet at full HP, ramping to full # visual weight as HP drops, using the SAME threshold shape as _hp_color so colour and # prominence escalate together (2026-07-03, HUD elegance pass). _hp_group.modulate.a = _hp_prominence(frac) _hp_label.text = "HP %d / %d" % [int(sim.player.hp), int(sim.player.max_hp)] if sim.story != null: _story_label.visible = true _story_label.text = _story_objective(sim) else: _story_label.visible = false # Wave/boss banner (survival/crystal; empty in story mode → hidden). var banner := sim.spawn_banner() var btext := String(banner.get("text", "")) if btext != "": var bsecs := int(banner.get("seconds", 0)) _banner_label.text = btext + (" %d" % bsecs if bsecs > 0 else "") _banner_label.visible = true else: _banner_label.visible = falseRemove the XP test-seam functions (xp_fill_width, xp_bar_max_width) from the “Test
seams” section, and add the new prominence seam. Replace:
func hp_fill_width() -> float: return _hp_fill.size.x
func hp_fill_color() -> Color: return _hp_fill_sb.bg_color
func xp_fill_width() -> float: return _xp_fill.size.x
func xp_bar_max_width() -> float: return XP_FILL_Wwith:
func hp_fill_width() -> float: return _hp_fill.size.x
func hp_fill_color() -> Color: return _hp_fill_sb.bg_color
func hp_group_alpha() -> float: return _hp_group.modulate.aAdd the new pure _hp_prominence function right after the existing _hp_color function:
# Urgency-driven prominence: subtle/quiet at full HP, ramping to full visual weight as HP# drops. Mirrors _hp_color's exact threshold shape (>0.6 healthy, 0.3-0.6 transitional,# <0.3 danger) so colour and prominence escalate together, not on separate curves.func _hp_prominence(frac: float) -> float: if frac > 0.6: return 0.45 if frac > 0.3: var t: float = (frac - 0.3) / 0.3 return lerpf(1.0, 0.45, t) return 1.0- Step 4: Run tests to verify they pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexitExpected: PASS, all tests green.
- Step 5: Full suite + determinism + boot check
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"Expected: full suite green, determinism baseline UNCHANGED, boot check empty output.
- Step 6: Commit
git add ui/hud.gd tests/test_hud.gdgit commit -m "feat(hud): HP bar becomes the sole top-center readout, prominence scales with urgency"Task 3: Show level number on the level-up panels + Ship Configuration
Section titled “Task 3: Show level number on the level-up panels + Ship Configuration”Files:
- Modify:
ui/level_up_panel.gd - Modify:
ui/crystals_levelup_panel.gd - Modify:
ui/ship_config_panel.gd - Modify:
main.gd - Modify:
tests/test_level_up_panel.gd(create if it doesn’t already exist — check first) - Modify:
tests/test_ship_config_panel.gd
Interfaces:
-
Produces:
LevelUpPanel.show_choices(choices: Array, level: int) -> void— signature change, one new required parameter. -
Consumes: nothing from earlier tasks.
-
Step 1: Check for an existing level-up panel test file
find . -iname "test_level_up_panel.gd" -not -path "*/.claude/*"If it exists, read it before writing Step 2’s tests so you extend rather than duplicate.
- Step 2: Write the failing tests
Add this test to tests/test_level_up_panel.gd (create the file with this content if it
doesn’t already exist; if it exists, add this as a new test function):
func test_show_choices_displays_the_current_level() -> void: var p := LevelUpPanel.new() add_child_autofree(p) p.show_choices([], 7) var found := false for c in p._box.get_children(): if c is Label and (c as Label).text.find("7") != -1: found = true break assert_true(found, "the level number appears somewhere in the panel")(If tests/test_level_up_panel.gd doesn’t exist yet, start it with extends GutTest on its
own first line before this function.)
Add this test to tests/test_ship_config_panel.gd (read the existing file first for its
helper functions/fixtures — likely a _make_sim()-style helper already exists; reuse it):
func test_open_config_shows_the_current_level() -> void: var p := ShipConfigPanel.new() add_child_autofree(p) var sim := Sim.new(1, SimContentFixture.db()) sim.player.level = 12 var meta := MetaState.new() p.open_config(meta, sim) assert_true(p._bonus_lbl.text.find("12") != -1, "the current level appears in the bonus/info label")- Step 3: Run tests to verify they fail
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_level_up_panel.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexitExpected: FAIL — show_choices doesn’t accept a level arg yet; the bonus label doesn’t
show a level yet.
- Step 4: Add the level parameter to
LevelUpPanel.show_choices
In ui/level_up_panel.gd, change the function signature and title text (currently lines
39-49):
func show_choices(choices: Array, level: int) -> void: for c in _box.get_children(): c.queue_free()
var title := Label.new() title.text = "LEVEL UP · Lv %d" % level title.add_theme_font_override("font", NeonTheme.title_font()) title.add_theme_font_size_override("font_size", 52) title.add_theme_color_override("font_color", NeonTheme.CYAN) title.add_theme_color_override("font_outline_color", Color(0.06, 0.45, 0.95, 0.85)) title.add_theme_constant_override("outline_size", 12)- Step 5: Update
CrystalsLevelUpPanel’s title to include the level
In ui/crystals_levelup_panel.gd, find the title construction (around line 141-148, inside
show_for(sim: Sim, ids: Array) — this function already receives sim, so read the level
directly, no signature change needed):
var title := Label.new() title.text = "LEVEL UP · Lv %d" % sim.player.level title.add_theme_font_override("font", NeonTheme.title_font()) title.add_theme_font_size_override("font_size", 38) title.add_theme_color_override("font_color", NeonTheme.CYAN) title.add_theme_color_override("font_outline_color", Color(0.1, 0.5, 0.9, 0.5)) title.add_theme_constant_override("outline_size", 8) _left.add_child(title)(only the title.text line changes — everything else in that block stays as-is.)
- Step 6: Show level on Ship Configuration
In ui/ship_config_panel.gd, update open_config (currently lines 82-101) — change the
_bonus_lbl.text line:
_bonus_lbl.text = ShipBonuses.label_for(ship_id) + (" · Lv %d" % sim.player.level if sim != null else "")(this is a one-line change to the existing _bonus_lbl.text = ShipBonuses.label_for(ship_id)
line — everything else in open_config stays the same.)
- Step 7: Update the one call site in
main.gd
Find level_up.show_choices(choices) inside _open_levelup() (currently near line 1252)
and change it to:
level_up.show_choices(choices, sim.player.level)- Step 8: Run tests to verify they pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_level_up_panel.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexitExpected: PASS.
- Step 9: Full suite + boot check
bash scripts/check-test-count.shgodot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"Expected: all green, boot check empty output.
- Step 10: Commit
git add ui/level_up_panel.gd ui/crystals_levelup_panel.gd ui/ship_config_panel.gd main.gd tests/test_level_up_panel.gd tests/test_ship_config_panel.gdgit commit -m "feat(ui): show current level on the level-up panels and Ship Configuration"Task 4: Level-up cheer FX
Section titled “Task 4: Level-up cheer FX”Files:
- Modify:
fx/fx_manager.gd - Modify:
main.gd - Modify:
tests/test_fx_manager.gd
Interfaces:
-
Produces:
FxManager.consume()handles a new event kind"level_up"(keys:pos: Vector2) — spawns a ring + a “LEVEL UP” label at that position, using the same_spawn_ring/_spawn_labelprimitives the existing"reaction"case already uses. -
Consumes: nothing from earlier tasks. Entirely additive to
FxManager. -
Step 1: Check the existing fx_manager test file’s structure
grep -n "func test_\|_spawn_ring\|_spawn_label\|reaction_active_count\|label_active_count\|ring_active_count" tests/test_fx_manager.gd | head -30Read enough of the surrounding test code to match its existing style (likely a _fx()
helper constructing a fresh FxManager, and an accessor like ring_active_count() or
similar already used to verify the "reaction" case spawns a ring — reuse whatever exists
rather than inventing a new counting mechanism).
- Step 2: Write the failing test
Add this test to tests/test_fx_manager.gd, adapting the exact assertion helper names to
whatever Step 1 found already exists for counting active rings/labels (the test below uses
placeholder names ring_active_count()/label_active_count() — replace with the real
accessor names from Step 1’s grep output before running):
func test_level_up_event_spawns_a_ring_and_a_label() -> void: var fx := FxManager.new() add_child_autofree(fx) fx.enabled = true fx.consume([{"kind": "level_up", "pos": Vector2(100, 100)}]) assert_gt(fx.ring_active_count(), 0, "a level-up spawns a celebratory ring") assert_gt(fx.label_active_count(), 0, "a level-up spawns a floating 'LEVEL UP' label")- Step 3: Run test to verify it fails
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexitExpected: FAIL — the "level_up" kind isn’t handled yet (no ring/label spawned).
- Step 4: Add the
"level_up"case toFxManager.consume()
In fx/fx_manager.gd, add a new case to the match ev.get("kind", ""): block inside
consume(). Place it in the “disposable juice” section (after the existing "pickup" case,
before "phase_flicker", following the exact same guard-then-spawn pattern the "reaction"
case already uses):
"level_up": # A brief celebratory moment at the instant of leveling, before the upgrade # choice panel opens (main._open_levelup) — NOT gated by `enabled`/juice caps, # since it's a rare, meaningful event (not spam like death/pickup sparks) and # should always show regardless of quality tier. _spawn_ring(ev["pos"], REACTION_BURST_RING, NeonTheme.CYAN) _spawn_label(ev["pos"], "LEVEL UP", NeonTheme.CYAN)- Step 5: Run test to verify it passes
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexitExpected: PASS.
- Step 6: Wire the trigger in
main._open_levelup()
In main.gd, _open_levelup() (currently starting near line 1235), add the FX trigger right
after the existing audio.level_up() call:
func _open_levelup() -> void: _paused_for_levelup = true if screen_fx != null: screen_fx.set_suppressed(true) # clear the red damage overlay so the panel reads clearly audio.level_up() if fx_layer != null: fx_layer.consume([{"kind": "level_up", "pos": player_node.position}]) # Always 3 choices per level-up (Chris: "change the upgrade to 3 per lvl" — crystals mode # previously got a 4th slot; unified with every other mode). var ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)(only the two new lines — if fx_layer != null: / fx_layer.consume(...) — are added; the
rest of the function is unchanged.)
- Step 7: Full suite + determinism + 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 120 2>&1 | grep "SCRIPT ERROR"Expected: all green, determinism baseline UNCHANGED (this never touches /sim — _open_levelup
is already a render-side function, and the new call only feeds FxManager, itself
render-side), boot check empty output.
- Step 8: Commit
git add fx/fx_manager.gd main.gd tests/test_fx_manager.gdgit commit -m "feat(fx): add a level-up cheer (ring + label burst) at the moment of leveling"Task 5: Warp goes diegetic (ship thruster) — remove the ability-bar warp icon
Section titled “Task 5: Warp goes diegetic (ship thruster) — remove the ability-bar warp icon”Files:
- Modify:
render/player_renderer.gd - Modify:
ui/hud.gd - Modify:
main.gd - Modify:
tests/test_player_renderer.gd - Modify:
tests/test_hud.gd
Interfaces:
-
Produces:
PlayerRenderer.update_visual(level: int, dt: float, dash_ready: bool = false) -> void— adds an optional third parameter (defaultfalse, so every existing call site not updated by this task keeps working unchanged).PlayerRenderer.thruster_alpha() -> float— new test seam. -
Consumes: nothing from earlier tasks.
-
Step 1: Write the failing tests
Add these tests to tests/test_player_renderer.gd (read the existing file first for its
exact instantiation pattern — likely var p := PlayerRenderer.new(); add_child_autofree(p),
matching what’s already used a few lines up in that file):
func test_thruster_is_dimmer_when_dash_not_ready() -> void: var p := PlayerRenderer.new() add_child_autofree(p) p.update_visual(1, 1.0 / 60.0, false) var dim_alpha := p.thruster_alpha() p.update_visual(1, 1.0 / 60.0, true) var bright_alpha := p.thruster_alpha() assert_gt(bright_alpha, dim_alpha, "thruster is brighter when warp/dash is ready than when it's on cooldown")
func test_thruster_alpha_defaults_to_not_ready_look() -> void: var p := PlayerRenderer.new() add_child_autofree(p) p.update_visual(1, 1.0 / 60.0) # dash_ready omitted -- must default to false, not crash assert_gte(p.thruster_alpha(), 0.0, "defaults safely with no dash_ready argument")Add this test to tests/test_hud.gd (append):
func test_ability_bars_no_longer_exist_on_hud() -> void: var hud := Hud.new() add_child_autofree(hud) assert_null(hud._ability_bars, "the warp ability icon moved to the ship's own thruster")- Step 2: Run tests to verify they fail
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexitExpected: FAIL — update_visual doesn’t accept a third argument yet, thruster_alpha()
doesn’t exist yet, _ability_bars still exists.
- Step 3: Add the diegetic thruster-ready state to
PlayerRenderer
In render/player_renderer.gd, add a new field near _thruster_phase (currently line 92):
var _thruster_accent: Color = Color(0.5, 0.85, 1.0, 0.7) # tier-driven base colour+alpha, recombined with the ready-state alpha each frameIn _rebuild(tier) (currently around line 222-223), replace:
# Thruster plume matches the tier hue. _thruster.modulate = Color(accent.r, accent.g, accent.b, 0.7)with:
# Thruster plume matches the tier hue. Base colour/alpha stored so update_visual can # recombine it with the warp-ready alpha multiplier every frame (2026-07-03, diegetic # warp indicator — replaces the old separate HUD ability icon). _thruster_accent = Color(accent.r, accent.g, accent.b, 0.7) _thruster.modulate = _thruster_accentReplace update_visual (currently lines 146-187) with:
# Called every render frame by main with the player's current level, frame delta, and# whether the warp/dash ability is currently off cooldown. dash_ready drives the thruster's# brightness/pulse so "warp ready" reads off the ship itself (2026-07-03, HUD elegance pass# — replaces the old separate ability-bar icon on the HUD).func update_visual(level: int, dt: float, dash_ready: bool = false) -> void: var tier := tier_for(level) if tier != _tier: var prev := _tier _tier = tier _rebuild(tier) if prev >= 0 and tier > prev: _flash_t = FLASH_LIFE # only on a real promotion, not the first build
# Orbit the accent orbs (independent of facing — reads as a spinning ring). _accent_orbit += dt * ACCENT_SPIN var ar: float = ACCENT_RADIUS[_tier] var show_accents := not low_detail for k in range(_accents.size()): var a := _accents[k] a.visible = show_accents if show_accents: var ang := _accent_orbit + float(k) / float(maxi(_accents.size(), 1)) * TAU a.position = Vector2(cos(ang), sin(ang)) * ar
# Thruster plume: brighter, bigger, faster pulse when warp is ready; dimmer and calmer # while on cooldown. The ready/not-ready distinction is the diegetic replacement for the # old HUD warp icon. _thruster_phase += dt * (16.0 if dash_ready else 9.0) var amp: float = 0.28 if dash_ready else 0.14 var pulse := THRUSTER_BASE * (1.0 + amp * sin(_thruster_phase)) * (0.8 + 0.12 * _tier) * (1.2 if dash_ready else 0.8) _thruster.scale = Vector2(pulse, pulse * 1.25) var ready_alpha_mult: float = 1.0 if dash_ready else 0.55 _thruster.modulate = Color(_thruster_accent.r, _thruster_accent.g, _thruster_accent.b, _thruster_accent.a * ready_alpha_mult)
# Engine-core breathing + canopy shimmer — the craft reads as 'alive' even when idle. _idle_phase += dt if _core != null: var beat: float = 0.82 + 0.18 * sin(_idle_phase * 4.5) _core.self_modulate = Color(beat, beat, beat) if _cockpit != null: _cockpit.modulate.a = 0.55 + 0.35 * (0.5 + 0.5 * sin(_idle_phase * 6.0 + 1.2))
# Ascension flash: expand + fade. if _flash_t > 0.0: _flash_t -= dt var ft: float = 1.0 - clampf(_flash_t / FLASH_LIFE, 0.0, 1.0) # 0 → 1 var fs: float = lerpf(0.6, 2.6, ft) _flash.scale = Vector2(fs, fs) _flash.modulate = Color(1.0, 1.0, 1.0, (1.0 - ft) * 0.9) else: _flash.modulate.a = 0.0Add the new test seam right after set_facing (currently line 277-279):
func thruster_alpha() -> float: return _thruster.modulate.a- Step 4: Remove
_AbilityBarsfromui/hud.gd
Remove the field declaration (currently line 29):
var _ability_bars: _AbilityBars # WARP/DRONE cooldown readout — non-touch only (touch shows it on the buttons)Remove its instantiation in _ready() (currently lines 180-189):
# WARP / DRONE cooldown readout — small EVE-module-style charge icons, back under the weapon # dock (top-left) rather than bottom-centre. Shown ONLY on non-touch (Apple TV / desktop); on # a touchscreen the WARP/DRONE buttons themselves show the cooldown, so this stays off. _ability_bars = _AbilityBars.new() _ability_bars.set_anchors_preset(Control.PRESET_TOP_LEFT) _ability_bars.size = Vector2(WeaponPanel.TILE_C, WeaponPanel.TILE_C) # just warp for now (drone module removed) _ability_bars.position = Vector2(24, 216) # under the drone dock (DroneDock.DOCK_ORIGIN 24,150 + its row + hint) _ability_bars.mouse_filter = Control.MOUSE_FILTER_IGNORE _ability_bars.visible = not Platform.is_touch() # show on controller platforms (ATV/desktop), not iOS touch add_child(_ability_bars)Remove set_ability_state (currently lines 191-195):
# Feed the WARP/DRONE ability cooldown state to the HUD indicator (non-touch). main calls this each# frame with the same values it feeds the touch buttons; the indicator only draws when visible.func set_ability_state(dash_frac: float, dash_ready: bool, decoy_frac: float, decoy_ready: bool, decoy_active: bool) -> void: if _ability_bars != null and _ability_bars.visible: _ability_bars.set_state(dash_frac, dash_ready, decoy_frac, decoy_ready, decoy_active)Remove the entire class _AbilityBars extends Control: block at the end of the file
(from the # WARP / DRONE cooldown indicator... comment through the final
_warp_glyph_points function’s closing line) — the whole class and everything in it goes
away. Note: Task 2 already ran before this task and shifted some line numbers earlier in the
file, so search for the literal comment/code text above rather than trusting exact line
numbers.
- Step 5: Update
main.gd’s call sites
Update the player_renderer.update_visual call (currently line 1093) to pass the warp-ready
signal:
player_renderer.update_visual(sim.player.level, delta, sim.player.dash_cd <= 0.0) # evolve the craft + show warp readinessRemove the hud.set_ability_state(...) call (currently line 1196) — the HUD no longer has
an ability indicator to feed:
hud.set_ability_state(dash_frac, dash_ready, decoy_frac, decoy_ready, decoy_active) # non-touch HUD readout(delete this line entirely; touch_controls.set_ability_state(...) on the line above it is
UNRELATED and must stay — touch’s own on-screen buttons still show their own cooldown state,
that isn’t changing).
- Step 6: Run tests to verify they pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexitExpected: PASS.
- Step 7: Full suite + determinism + boot check
godot --headless --path . --importbash 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 120 2>&1 | grep "SCRIPT ERROR"Expected: all green, determinism baseline UNCHANGED, boot check empty output.
- Step 8: Commit
git add render/player_renderer.gd ui/hud.gd main.gd tests/test_player_renderer.gd tests/test_hud.gdgit commit -m "feat(player): warp readiness shows on the ship's own thruster, remove the HUD ability icon"Task 6: Y-toggle for the tactical HUD (weapon dock + drone dock), plus spacing polish
Section titled “Task 6: Y-toggle for the tactical HUD (weapon dock + drone dock), plus spacing polish”Files:
- Modify:
main.gd - Modify:
ui/drone_dock.gd - Modify:
tests/test_main.gd(check the exact filename first — see Step 1)
Interfaces:
-
Produces:
main._tactical_hud_shown: bool,main._toggle_tactical_hud() -> void. -
Consumes:
Y(controller) andV(keyboard), both freed by Task 1. -
Step 1: Find the right test file for
main.gd-level behavior
find . -iname "test_main*.gd" -not -path "*/.claude/*"grep -rln "func _toggle_pause\|_paused_for_menu\|weapon_panel.visible" tests/*.gdUse whichever existing test file already exercises main.gd-level toggle behavior (e.g. how
_toggle_pause/_paused_for_menu is tested) as the home for this task’s new test, matching
its existing instantiation pattern for main.tscn/Main.
- Step 2: Write the failing test
Add this test to the file found in Step 1 (adapt the exact Main instantiation boilerplate
to match whatever pattern that file already uses for booting a full main scene headlessly):
func test_tactical_hud_starts_hidden_and_y_button_toggles_it() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("crystals") assert_false(m.weapon_panel.visible, "weapon dock is hidden by default") assert_false(m.drone_dock.visible, "drone dock is hidden by default") m._toggle_tactical_hud() assert_true(m.weapon_panel.visible, "toggling shows the weapon dock") assert_true(m.drone_dock.visible, "toggling shows the drone dock") m._toggle_tactical_hud() assert_false(m.weapon_panel.visible, "toggling again hides it") assert_false(m.drone_dock.visible, "toggling again hides it")- Step 3: Run test to verify it fails
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<the file from Step 1>.gd -gexitExpected: FAIL — _toggle_tactical_hud doesn’t exist yet.
- Step 4: Add the toggle state + function to
main.gd
Add a new field near the other _paused_for_*/mode-state fields (search for
var _paused_for_menu to find the right neighborhood and match its style):
var _tactical_hud_shown: bool = false # weapon/drone docks hidden by default; Y (or V) reveals themAdd the toggle function near _toggle_pause() (match that function’s location/style):
# Y (controller) / V (keyboard) toggles the weapon + drone dock visibility. Hidden by# default so the live HUD stays minimal; the player summons loadout info on demand# (2026-07-03, HUD elegance pass — Y/V were freed by removing the old weapon-info overlay).func _toggle_tactical_hud() -> void: _tactical_hud_shown = not _tactical_hud_shown weapon_panel.visible = _tactical_hud_shown drone_dock.visible = _tactical_hud_shown if audio != null: audio.ui_nav()In _input(), add the new bindings where the old weapon-info ones used to be (the same
spot Task 1 removed them from):
# Y button: toggle the tactical HUD (weapon + drone docks), hidden by default. if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y: _toggle_tactical_hud() returnAnd in the keyboard section of _input() (where KEY_V used to call _toggle_weapon_info,
removed by Task 1), add:
if event.physical_keycode == KEY_V: _toggle_tactical_hud() returnIn _new_run(), find the two lines (currently near lines 452-453):
weapon_panel.visible = true # may have been hidden by _return_to_menu() drone_dock.visible = trueand replace them with:
_tactical_hud_shown = false # hidden by default every fresh run weapon_panel.visible = false drone_dock.visible = false- Step 5: Apply a small spacing adjustment to
DroneDock
In ui/drone_dock.gd, change DOCK_ORIGIN (currently line 17):
const DOCK_ORIGIN := Vector2(24, 162) # more breathing room below the weapon dock (was 150) -- # 2026-07-03, HUD elegance pass: with the ability-bar row # removed, the weapon/drone docks are the only two groups # left here, so a slightly more generous gap between them # (was 68px between rows, now 80px) reads as two distinct # groupings rather than one continuous stack.- Step 6: Run tests to verify they pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<the file from Step 1>.gd -gexitExpected: PASS.
- Step 7: Full suite + determinism + 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 120 2>&1 | grep "SCRIPT ERROR"Expected: all green, both determinism baselines UNCHANGED, boot check empty output.
- Step 8: Manual editor playtest (not headless-verifiable)
Open the project in the Godot editor and press F5 (or godot --path .), start a Survival or
Crystals run, and confirm:
- The weapon dock and drone dock are NOT visible at run start.
- Pressing a controller’s Y button (or
Von keyboard) reveals both docks; pressing again hides them. - The HP bar at top-center is visibly wider than before, reads subtly at full HP, and becomes progressively more vivid/prominent as you take damage.
- No level number or XP bar appears anywhere on the live HUD.
- Leveling up shows a brief ring+“LEVEL UP” burst at the ship before the upgrade choice panel opens, and the choice panel’s title now includes the current level.
- The ship’s thruster plume is visibly dimmer most of the time and brightens/pulses faster right after using dash, then dims again while dash is on cooldown.
- Opening Ship Configuration (from the pause menu) shows the current level alongside the ship’s bonus text.
Report back what you saw before considering this task (and the whole plan) done — this is real render/input behavior a headless test cannot confirm.
- Step 9: Commit
git add main.gd ui/drone_dock.gd tests/<the file from Step 1>.gdgit commit -m "feat(hud): Y/V toggles the tactical HUD (weapon + drone docks), hidden by default"Self-Review Notes (for the plan author, already applied above)
Section titled “Self-Review Notes (for the plan author, already applied above)”- Spec coverage: every design-doc item has a task — dead-code removal (Task 1), HP bar width + prominence (Task 2), level/XP relocation to the level-up panels + Ship Config (Task 3), level-up cheer (Task 4), diegetic warp thruster (Task 5), Y-toggle group + layout spacing (Task 6). PRODUCT.md/DESIGN.md were already written and committed during brainstorming, not repeated here.
- Placeholder scan: no TBD/TODO. The only “decided during implementation” items from the design doc (exact HP bar width, exact spacing values) are now committed to concrete numbers in Tasks 2 and 6 respectively, chosen by reasoning about the existing layout rather than left open.
- Type consistency:
PlayerRenderer.update_visual(level: int, dt: float, dash_ready: bool = false)is used identically in Task 5’s own code and itsmain.gdcall site.LevelUpPanel.show_choices(choices: Array, level: int)matches its Task 3 call site.Hud.HP_BACKING_W/Hud._hp_prominence/Hud.hp_group_alpha()are introduced in Task 2 and not referenced elsewhere until Task 5’s own (separate, unrelated)_ability_barsremoval readshud._ability_barsdirectly — no cross-task name mismatches found. - Task ordering: Tasks 2 and 5 both edit
ui/hud.gd’s_ready(), in different, non-overlapping regions (Task 2: the HP block; Task 5: the_ability_barsblock) — safe to run sequentially. Task 6 depends on Task 1 having freed theY/Vbindings; Task 6 must not run before Task 1.