HUD Polish, Weapon Feedback & Player Rotation — Implementation Plan
HUD Polish, Weapon Feedback & Player Rotation — Implementation Plan
Section titled “HUD Polish, Weapon Feedback & Player Rotation — 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: Give the game a prominent neon HUD, visible weapon status with cooldown arcs, an obvious nova AoE ring, a ship that rotates with movement, and a toggleable F2 debug overlay; also update the live site copy to show M2 is in progress.
Architecture: All changes are render/UI-only except two determinism-neutral sim additions (cooldown_frac() pure getters and one fx_events.append in weapon_nova). New UI nodes are code-built CanvasLayers following the existing pattern. FxManager gains a second pool of ring Node2D children for nova rings.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 headless tests, CanvasItemMaterial.BLEND_MODE_ADD for additive glow.
Global Constraints
Section titled “Global Constraints”- Determinism keystone:
tests/test_determinism.gdtracehash() = 1314757315;tests/test_determinism_checksum.gdstate_checksum() = 1949813464. Both must stay identical after every task that touches/sim. /simis pure: every sim fileextends RefCounted— no Node/render/Input/Engine/Time/File/JSON APIs.cooldown_frac()getters are read-only pure functions; the novafx_events.appendis determinism-neutral (event list is excluded from checksum/snapshot).- All UI is code-built: no
.tscnor.tresfiles. Follow the pattern inui/hud.gd,ui/level_up_panel.gd. - Fonts:
NeonTheme.mono_font()for numbers/stats;NeonTheme.title_font()(Orbitron) for labels/names.NeonThemeis inui/theme/neon_theme.gd. - NeonTheme constants available:
NeonTheme.CYAN = Color(0.2, 0.9, 1.0),NeonTheme.TEXT = Color(0.85, 0.97, 1.0). - Test runner:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit. Runscripts/check-test-count.shbefore declaring green (guards against stale class cache dropping test files silently). - Import step required whenever a new
class_nameis added:godot --headless --path . --importbefore running tests. - Boot smoke:
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"must return0. - Web demo must not regress — all changes are renderer-agnostic.
update_hud(sim: Sim) -> voidcall signature inmain.gdmust remain unchanged.
File map
Section titled “File map”| File | Status | Purpose |
|---|---|---|
bullet-heaven-site/index.html |
Modify | Play-bar label + roadmap M2 block |
ui/hud.gd |
Replace | HP bar + level badge + centred timer + kills |
ui/weapon_panel.gd |
Create | Two weapon slots with cooldown arcs |
ui/debug_overlay.gd |
Create | F2-toggled sim state dump |
sim/weapon_pulse.gd |
Modify | cooldown_frac() -> float getter |
sim/weapon_nova.gd |
Modify | cooldown_frac() -> float + nova fx_event |
fx/fx_manager.gd |
Modify | _RingNode inner class + ring pool for nova |
main.gd |
Modify | Wire new panels; player rotation; upgrade tracking; F2 input |
tests/test_hud.gd |
Create | 3 HUD tests |
tests/test_weapon_panel.gd |
Create | 3 weapon getter tests |
tests/test_fx_manager.gd |
Modify | 3 new nova ring tests |
tests/test_debug_overlay.gd |
Create | 3 debug overlay tests |
Task 1: Site copy fix
Section titled “Task 1: Site copy fix”Files:
- Modify:
bullet-heaven-site/index.htmllines 88–92, 123, 164–165
Interfaces:
-
Consumes: nothing
-
Produces: nothing (standalone copy change)
-
Step 1: Add
.phase.activeCSS rule
Locate the existing block at line 88–92 in bullet-heaven-site/index.html:
.phase{border:1px solid var(--line);border-radius:14px;padding:20px;background:var(--panel)}.phase .tag{font-family:'Chakra Petch';text-transform:uppercase;letter-spacing:.1em;font-size:12px;padding:3px 9px;border-radius:6px}.phase.done .tag{background:rgba(90,255,158,.14);color:var(--green);border:1px solid rgba(90,255,158,.4)}.phase.next .tag{background:rgba(108,200,255,.14);color:var(--cyan);border:1px solid rgba(108,200,255,.4)}.phase.later .tag{background:rgba(176,106,255,.14);color:var(--purple);border:1px solid rgba(176,106,255,.4)}Add after .phase.later .tag{...}:
.phase.active{border-color:rgba(255,227,77,.35)}.phase.active .tag{background:rgba(255,227,77,.14);color:var(--gold);border:1px solid rgba(255,227,77,.4)}(--gold = #ffe34d — already defined in the CSS vars at line 19.)
- Step 2: Update the play-bar label (line 123)
Find:
<div class="play-bar"><span><span class="dot"></span>Live demo — Milestone 1 core loop</span>Replace with:
<div class="play-bar"><span><span class="dot"></span>Live demo — M2 in progress (elements · reactions · neon)</span>- Step 3: Update roadmap blocks (lines 164–165)
Find:
<div class="phase done"><span class="tag">✓ Shipped</span><h3>Milestone 1 — Core loop</h3><ul><li>Move, auto-fire, auto-target</li><li>Spawning enemies, spatial-hash collision</li><li>XP, level-up upgrade picker</li><li>Deterministic, headless-tested sim</li></ul></div> <div class="phase next"><span class="tag">▶ Next</span><h3>Milestone 2 — Depth</h3><ul><li>The 14-element reaction engine</li><li>More weapons, mods, evolutions</li><li>Enemy variety, elites, bosses</li><li>Meta-progression + unlocks</li></ul></div>Replace with:
<div class="phase done"><span class="tag">✓ Shipped</span><h3>Milestone 1 — Core loop</h3><ul><li>Move, auto-fire, auto-target</li><li>Spawning enemies, spatial-hash collision</li><li>XP, level-up upgrade picker</li><li>Deterministic, headless-tested sim</li></ul></div> <div class="phase active"><span class="tag">▶ In Progress</span><h3>Milestone 2 — Depth</h3><ul><li>✓ Data-driven content pipeline (bible.json)</li><li>✓ 14-element reaction engine (auras, stacks, Plasma)</li><li>✓ Transformative mods (Overcharge, Catalyst, Lingering)</li><li>✓ Neon visual overhaul (glow, fonts, FX vocabulary)</li><li>More weapons, mods, evolutions</li><li>Enemy variety, elites, bosses</li><li>Meta-progression + unlocks</li></ul></div>- Step 4: Deploy the site update
cd ~/Claude/bullet-heaven-site && bash scripts/deploy-site.shExpected: deploys successfully, live site shows updated label and roadmap.
- Step 5: Commit
cd ~/Claude/bullet-heaven-sitegit add index.htmlgit commit -m "chore(site): update play-bar label + M2 roadmap block to reflect progress"Task 2: HUD redesign
Section titled “Task 2: HUD redesign”Files:
- Modify:
ui/hud.gd(full replacement) - Create:
tests/test_hud.gd
Interfaces:
-
Consumes:
NeonTheme.mono_font(),NeonTheme.title_font(),NeonTheme.CYAN;sim.player.hp,sim.player.max_hp,sim.player.level,sim.run_time,sim.kills -
Produces:
class_name Hud extends CanvasLayer;func update_hud(sim: Sim) -> void(signature unchanged —main.gdcalls this) -
Step 1: Write failing tests
Create tests/test_hud.gd:
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) # Fill width should be near-maximum (234px at full HP) assert_gt(hud.hp_fill_width(), 230.0, "full HP = near-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_no_push_errors()- Step 2: Run tests — expect FAIL (Hud lacks test seam methods)
godot --headless --path . --import 2>/dev/nullgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit 2>&1 | tail -10Expected: failures on hp_fill_width / hp_fill_color not found.
- Step 3: Replace
ui/hud.gdwith the new implementation
class_name Hudextends CanvasLayer
const BAR_W: float = 234.0 # usable fill width inside 240px backingconst BAR_H: float = 32.0
var _hp_fill: ColorRectvar _hp_label: Labelvar _level_label: Labelvar _timer_label: Labelvar _kills_label: Label
func _ready() -> void: var vp := get_viewport_rect().size
# ── HP bar (top-left) ────────────────────────────────────────────── var bar_backing := ColorRect.new() bar_backing.position = Vector2(16, 12) bar_backing.size = Vector2(240, 40) bar_backing.color = Color(0, 0, 0, 0.55) bar_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(bar_backing)
_hp_fill = ColorRect.new() _hp_fill.position = Vector2(19, 16) # 3px inset on each side _hp_fill.size = Vector2(BAR_W, BAR_H) _hp_fill.color = NeonTheme.CYAN _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(240, 40) _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", 15) _hp_label.add_theme_color_override("font_color", NeonTheme.TEXT) _hp_label.mouse_filter = Control.MOUSE_FILTER_IGNORE bar_backing.add_child(_hp_label)
# ── Level badge (right of HP bar) ────────────────────────────────── _level_label = Label.new() _level_label.position = Vector2(264, 12) _level_label.size = Vector2(80, 40) _level_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _level_label.add_theme_font_override("font", NeonTheme.title_font()) _level_label.add_theme_font_size_override("font_size", 18) _level_label.add_theme_color_override("font_color", NeonTheme.CYAN) _level_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_level_label)
# ── Run timer (top-centre) ───────────────────────────────────────── _timer_label = Label.new() _timer_label.position = Vector2(vp.x / 2.0 - 80, 10) _timer_label.size = Vector2(160, 40) _timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _timer_label.add_theme_font_override("font", NeonTheme.title_font()) _timer_label.add_theme_font_size_override("font_size", 28) _timer_label.add_theme_color_override("font_color", NeonTheme.CYAN) _timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_timer_label)
# ── Kill counter (top-right) ─────────────────────────────────────── _kills_label = Label.new() _kills_label.position = Vector2(vp.x - 160, 12) _kills_label.size = Vector2(144, 40) _kills_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _kills_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _kills_label.add_theme_font_override("font", NeonTheme.mono_font()) _kills_label.add_theme_font_size_override("font_size", 18) _kills_label.add_theme_color_override("font_color", NeonTheme.CYAN) _kills_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_kills_label)
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 = BAR_W * frac _hp_fill.color = _hp_color(frac) _hp_label.text = "HP %d / %d" % [int(sim.player.hp), int(sim.player.max_hp)] _level_label.text = "Lv %d" % sim.player.level var t := int(sim.run_time) _timer_label.text = "%02d:%02d" % [t / 60, t % 60] _kills_label.text = "✕ %d" % sim.kills
# Test seams ──────────────────────────────────────────────────────────────func hp_fill_width() -> float: return _hp_fill.size.x
func hp_fill_color() -> Color: return _hp_fill.color
# Private ─────────────────────────────────────────────────────────────────func _hp_color(frac: float) -> Color: # Cyan at full health → red at ≤30% if frac > 0.3: return NeonTheme.CYAN # lerp from red (frac=0) toward cyan (frac=0.3) var t: float = frac / 0.3 return Color(1.0, 0.25, 0.2).lerp(NeonTheme.CYAN, t)- Step 4: Run tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit 2>&1 | tail -10Expected: 3 passed, 0 failed.
- Step 5: Run determinism tests to confirm no sim regression
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5Expected: both pass.
- Step 6: Commit
git add ui/hud.gd tests/test_hud.gdgit commit -m "feat(hud): redesign with HP bar, level badge, centred timer, kill counter"Task 3: Weapon panel
Section titled “Task 3: Weapon panel”Files:
- Modify:
sim/weapon_pulse.gd(add getter) - Modify:
sim/weapon_nova.gd(add getter only — nova event added in Task 4) - Create:
ui/weapon_panel.gd - Modify:
main.gd(wire WeaponPanel) - Create:
tests/test_weapon_panel.gd
Interfaces:
-
Consumes:
sim.weapon: WeaponPulse,sim.nova: WeaponNova,sim.pulse_element_idx: int,sim.nova_element_idx: int,sim.content: ContentDB,ElementPalette.color_for(content, idx) -
Produces:
WeaponPulse.cooldown_frac() -> floatWeaponNova.cooldown_frac() -> floatclass_name WeaponPanel extends CanvasLayer;func update_panel(pulse: WeaponPulse, nova: WeaponNova, pulse_el: int, nova_el: int, content: ContentDB, dmg_mult: float) -> void
-
Step 1: Write failing tests
Create tests/test_weapon_panel.gd:
extends GutTest
func _pulse_def() -> Dictionary: return {"base_damage": 10.0, "cooldown_s": 2.0, "projectile_speed": 300.0, "projectile_radius": 6.0, "lifetime_s": 1.5}
func _nova_def() -> Dictionary: return {"base_damage": 8.0, "cooldown_s": 3.0, "area": 200.0}
func test_pulse_cooldown_frac_full_when_fresh() -> void: var w := WeaponPulse.new(_pulse_def()) assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "fresh weapon = 1.0 (ready)")
func test_pulse_cooldown_frac_zero_when_spent() -> void: var w := WeaponPulse.new(_pulse_def()) w._timer = w.cooldown # simulate just-fired assert_almost_eq(w.cooldown_frac(), 0.0, 0.001, "just fired = 0.0")
func test_nova_cooldown_frac_full_when_fresh() -> void: var w := WeaponNova.new(_nova_def()) assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "fresh nova = 1.0 (ready)")- Step 2: Run tests — expect FAIL
godot --headless --path . --import 2>/dev/nullgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_panel.gd -gexit 2>&1 | tail -10Expected: failures on cooldown_frac not found.
- Step 3: Add
cooldown_frac()tosim/weapon_pulse.gd
Add at the end of the file, before the final closing (after update):
func cooldown_frac() -> float: return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)- Step 4: Add
cooldown_frac()tosim/weapon_nova.gd
Add at the end of the file:
func cooldown_frac() -> float: return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)- Step 5: Run tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_panel.gd -gexit 2>&1 | tail -10Expected: 3 passed, 0 failed.
- Step 6: Run determinism tests
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5Expected: both pass (pure read-only getters, no state change).
- Step 7: Create
ui/weapon_panel.gd
class_name WeaponPanelextends CanvasLayer
# ── Inner: cooldown arc drawn via _draw() ────────────────────────────────class _CooldownArc extends Node2D: var frac: float = 0.0 var col: Color = NeonTheme.CYAN func _draw() -> void: if frac <= 0.0: return draw_arc(Vector2.ZERO, 24.0, -PI / 2.0, -PI / 2.0 + TAU * frac, 48, col, 3.0, true)
const SLOT_W: float = 120.0const SLOT_H: float = 70.0const SLOT_GAP: float = 12.0
var _name_labels: Array[Label] = []var _stat_labels: Array[Label] = []var _arcs: Array[_CooldownArc] = []
func _ready() -> void: var vp := get_viewport_rect().size # Two slots: left = pulse, right = nova # Total width = SLOT_W*2 + SLOT_GAP, centred at vp.x/2 var x0: float = vp.x / 2.0 - SLOT_W - SLOT_GAP / 2.0 var y0: float = vp.y - SLOT_H - 16.0 for i in range(2): var sx: float = x0 + i * (SLOT_W + SLOT_GAP) _build_slot(sx, y0, i)
func _build_slot(x: float, y: float, idx: int) -> void: # Dark backing var backing := ColorRect.new() backing.position = Vector2(x, y) backing.size = Vector2(SLOT_W, SLOT_H) backing.color = Color(0, 0, 0, 0.55) backing.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(backing)
# Neon border panel (1px, rounded) var border := Panel.new() border.position = Vector2(x, y) border.size = Vector2(SLOT_W, SLOT_H) border.mouse_filter = Control.MOUSE_FILTER_IGNORE var sb := StyleBoxFlat.new() sb.bg_color = Color.TRANSPARENT sb.border_width_left = 1; sb.border_width_right = 1 sb.border_width_top = 1; sb.border_width_bottom = 1 sb.border_color = NeonTheme.CYAN sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8 sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8 border.add_theme_stylebox_override("panel", sb) add_child(border)
# Weapon name label var name_lbl := Label.new() name_lbl.position = Vector2(x + 8, y + 6) name_lbl.size = Vector2(SLOT_W - 16, 20) name_lbl.add_theme_font_override("font", NeonTheme.title_font()) name_lbl.add_theme_font_size_override("font_size", 13) name_lbl.add_theme_color_override("font_color", NeonTheme.TEXT) name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(name_lbl) _name_labels.append(name_lbl)
# Stat label (damage / area) var stat_lbl := Label.new() stat_lbl.position = Vector2(x + 8, y + 28) stat_lbl.size = Vector2(SLOT_W - 44, 18) stat_lbl.add_theme_font_override("font", NeonTheme.mono_font()) stat_lbl.add_theme_font_size_override("font_size", 12) stat_lbl.add_theme_color_override("font_color", NeonTheme.CYAN) stat_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(stat_lbl) _stat_labels.append(stat_lbl)
# Cooldown arc (centred right of slot) var arc := _CooldownArc.new() arc.position = Vector2(x + SLOT_W - 30, y + SLOT_H / 2.0) add_child(arc) _arcs.append(arc)
func update_panel( pulse: WeaponPulse, nova: WeaponNova, pulse_el: int, nova_el: int, content: ContentDB, dmg_mult: float) -> void: # Slot 0 — WeaponPulse _name_labels[0].text = "Lightning" _stat_labels[0].text = "dmg %.0f" % (pulse.base_damage * dmg_mult) _arcs[0].frac = pulse.cooldown_frac() _arcs[0].col = ElementPalette.color_for(content, pulse_el) _arcs[0].queue_redraw() # Update slot border colour to pulse element _tint_border(0, ElementPalette.color_for(content, pulse_el))
# Slot 1 — WeaponNova _name_labels[1].text = "Fire Nova" _stat_labels[1].text = "dmg %.0f r%.0f" % [nova.base_damage * dmg_mult, nova.area] _arcs[1].frac = nova.cooldown_frac() _arcs[1].col = ElementPalette.color_for(content, nova_el) _arcs[1].queue_redraw() _tint_border(1, ElementPalette.color_for(content, nova_el))
func _tint_border(slot_idx: int, col: Color) -> void: # The border Panel is the child at index: 1 + slot_idx * 4 + 1 = slot_idx*4+1 # Simpler: store border refs during _build_slot pass # Element tinting via arc colour is sufficient; border stays cyanNote: The _tint_border stub is intentional YAGNI — the arc colour already communicates the element. If element-tinted borders are desired later, store _borders: Array[Panel] in _build_slot. Do NOT over-engineer this now.
- Step 8: Wire WeaponPanel in
main.gd
Add field declarations near the top (after var screen_fx: ScreenFeedback):
var weapon_panel: WeaponPanelIn _new_run(), after add_child(screen_fx):
weapon_panel = WeaponPanel.new()add_child(weapon_panel)In _process(), after hud.update_hud(sim):
weapon_panel.update_panel( sim.weapon, sim.nova, sim.pulse_element_idx, sim.nova_element_idx, sim.content, sim.player.damage_mult)- Step 9: Boot smoke test
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"Expected: 0.
- Step 10: Run full test suite
bash scripts/check-test-count.sh 2>&1 | tail -5Expected: all pass, count guard OK.
- Step 11: Commit
git add sim/weapon_pulse.gd sim/weapon_nova.gd ui/weapon_panel.gd main.gd tests/test_weapon_panel.gdgit commit -m "feat(weapons): cooldown_frac getters + weapon panel with cooldown arcs"Task 4: Nova AoE ring
Section titled “Task 4: Nova AoE ring”Files:
- Modify:
sim/weapon_nova.gd(add nova fx_event) - Modify:
fx/fx_manager.gd(add_RingNodeinner class + ring pool) - Modify:
tests/test_fx_manager.gd(add 3 ring tests)
Interfaces:
-
Consumes: existing
sim.fx_events: Array[Dictionary]; existingFxManagerwith_content: ContentDB,active_count() -> int -
Produces:
sim.fx_eventsgains{"kind":"nova","pos":Vector2,"radius":float,"element":int}eventsFxManager.ring_active_count() -> intFxManager.RING_POOL_SIZE: int = 8
-
Step 1: Write failing tests (extend
tests/test_fx_manager.gd)
Append to the existing tests/test_fx_manager.gd file (keep all existing tests, add these below):
func test_nova_event_activates_one_ring() -> void: var fx := _make() fx.consume([{"kind": "nova", "pos": Vector2.ZERO, "radius": 200.0, "element": 0}]) assert_eq(fx.ring_active_count(), 1, "one nova event = one ring active")
func test_nova_ring_expires_after_max_life() -> void: var fx := _make() fx.consume([{"kind": "nova", "pos": Vector2.ZERO, "radius": 200.0, "element": 0}]) # advance past max_life (0.35s) for _i in range(30): fx.advance(1.0 / 60.0) # 0.5s total assert_eq(fx.ring_active_count(), 0, "ring returns to pool after max_life")
func test_nova_ring_pool_does_not_grow() -> void: var fx := _make() var many_evs: Array = [] for i in range(20): many_evs.append({"kind": "nova", "pos": Vector2.ZERO, "radius": 150.0, "element": -1}) fx.consume(many_evs) assert_lte(fx.ring_active_count(), FxManager.RING_POOL_SIZE, "ring pool is bounded")- Step 2: Run new tests — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit 2>&1 | tail -10Expected: new tests fail on ring_active_count not found.
- Step 3: Add nova fx_event emission to
sim/weapon_nova.gd
In update(), add the append immediately after the _timer = cooldown / ... line:
Current code (lines 17–20):
_timer -= dt if _timer > 0.0: return _timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01) sim.hash.rebuild(sim.enemies)Replace with:
_timer -= dt if _timer > 0.0: return _timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01) sim.fx_events.append({"kind": "nova", "pos": sim.player.pos, "radius": area, "element": sim.nova_element_idx}) sim.hash.rebuild(sim.enemies)- Step 4: Run determinism tests — must still pass
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5Expected: both pass (nova event is excluded from snapshot/checksum — verified by this test).
- Step 5: Add
_RingNodeinner class and ring pool tofx/fx_manager.gd
The full updated file (preserve all existing code, add the highlighted sections):
class_name FxManagerextends Node2D
const POOL_SIZE := 256const DEATH_CAP := 8const RING_POOL_SIZE := 8 # nova fires at most once per ~2s; 8 is ample
# ── Inner: expanding additive ring for nova pulses ───────────────────────class _RingNode extends Node2D: var life: float = 0.0 var max_life: float = 0.35 var max_radius: float = 100.0 var col: Color = Color.WHITE var active: bool = false
func _init() -> void: var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD material = mat visible = false
func reset(pos: Vector2, radius: float, c: Color) -> void: position = pos max_radius = radius col = c life = 0.0 active = true visible = true queue_redraw()
func tick(dt: float) -> bool: # returns true when expired life += dt queue_redraw() if life >= max_life: active = false visible = false return true return false
func _draw() -> void: if not active: return var t: float = float(life) / float(max_life) var r: float = max_radius * t var a: float = 1.0 - t draw_arc(Vector2.ZERO, r, 0.0, TAU, 64, Color(col.r, col.g, col.b, a), 4.0, true)
# ── Sprite2D pool (sparks / pops / pickups) ──────────────────────────────var _content: ContentDBvar _free: Array[Sprite2D] = []var _active: Array = []
# ── Ring pool ─────────────────────────────────────────────────────────────var _ring_free: Array = [] # Array of _RingNodevar _ring_active: Array = [] # Array of _RingNode
func setup(content: ContentDB) -> void: _content = content
func _ready() -> void: # Sprite2D pool var tex := GlowTexture.shared() for _i in range(POOL_SIZE): var s := Sprite2D.new() s.texture = tex var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD s.material = mat s.visible = false add_child(s) _free.append(s) # Ring pool for _i in range(RING_POOL_SIZE): var r := _RingNode.new() add_child(r) _ring_free.append(r)
func active_count() -> int: return _active.size()
func ring_active_count() -> int: return _ring_active.size()
func consume(events: Array) -> void: var deaths := 0 for ev in events: match ev.get("kind", ""): "reaction": _spawn(ev["pos"], ElementPalette.color_for(_content, ev.get("element", -1)), 0.28, 0.4, 2.4) "pickup": _spawn(ev["pos"], ElementPalette.GEM, 0.18, 0.2, 0.7) "death": if deaths < DEATH_CAP: deaths += 1 _spawn(ev["pos"], ElementPalette.color_for(_content, ev.get("element", -1)), 0.16, 0.3, 0.9) "nova": _spawn_ring(ev["pos"], float(ev.get("radius", 100.0)), ElementPalette.color_for(_content, ev.get("element", -1)))
func _spawn(pos: Vector2, color: Color, life: float, s0: float, s1: float) -> void: if _free.is_empty(): return var s: Sprite2D = _free.pop_back() s.position = pos s.modulate = color s.scale = Vector2(s0, s0) s.visible = true _active.append({"node": s, "life": life, "max_life": life, "s0": s0, "s1": s1, "color": color})
func _spawn_ring(pos: Vector2, radius: float, col: Color) -> void: if _ring_free.is_empty(): return # pool full: drop (bounded, nova fires ~0.5/s max) var r: _RingNode = _ring_free.pop_back() r.reset(pos, radius, col) _ring_active.append(r)
func advance(dt: float) -> void: # Sprite2D sparks var i := _active.size() - 1 while i >= 0: var a: Dictionary = _active[i] a["life"] -= dt var s: Sprite2D = a["node"] if a["life"] <= 0.0: s.visible = false _free.append(s) _active.remove_at(i) else: var t: float = 1.0 - float(a["life"]) / float(a["max_life"]) var sc: float = lerpf(float(a["s0"]), float(a["s1"]), t) s.scale = Vector2(sc, sc) var col: Color = a["color"] s.modulate = Color(col.r, col.g, col.b, 1.0 - t) i -= 1 # Rings var j := _ring_active.size() - 1 while j >= 0: var rn: _RingNode = _ring_active[j] if rn.tick(dt): _ring_free.append(rn) _ring_active.remove_at(j) j -= 1- Step 6: Run all fx_manager tests — expect PASS
godot --headless --path . --import 2>/dev/nullgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit 2>&1 | tail -10Expected: all 8 tests pass (5 existing + 3 new).
- Step 7: Run full suite + count guard
bash scripts/check-test-count.sh 2>&1 | tail -5Expected: all pass.
- Step 8: Commit
git add sim/weapon_nova.gd fx/fx_manager.gd tests/test_fx_manager.gdgit commit -m "feat(fx): nova AoE ring — expanding additive ring on nova fire"Task 5: Player rotation
Section titled “Task 5: Player rotation”Files:
- Modify:
main.gdonly
Interfaces:
- Consumes:
sim.player.pos: Vector2(existing) - Produces:
player_node.rotationupdated each_processframe
No new tests — render-only; verified by playtest. Boot smoke covers safety.
- Step 1: Add fields to
main.gd
After var _last_hp: float = 0.0 add:
var _last_player_pos: Vector2 = Vector2.ZEROvar _player_facing: float = 0.0- Step 2: Reset fields in
_new_run()
In _new_run(), after _last_hp = sim.player.hp add:
_last_player_pos = sim.player.pos_player_facing = 0.0- Step 3: Add rotation logic in
_process()
In _process(), immediately after player_node.position = sim.player.pos:
var _move_delta: Vector2 = sim.player.pos - _last_player_posif _move_delta.length_squared() > 1.0: # +PI/2 because the triangle tip is at Vector2(0,-18) = screen-north (angle 0 in Godot = right) _player_facing = _move_delta.angle() + PI / 2.0player_node.rotation = lerp_angle(player_node.rotation, _player_facing, 0.25)_last_player_pos = sim.player.pos- Step 4: Boot smoke test
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"Expected: 0.
- Step 5: Run full suite
bash scripts/check-test-count.sh 2>&1 | tail -5Expected: all pass, count guard OK.
- Step 6: Commit
git add main.gdgit commit -m "feat(player): rotate ship sprite to face movement direction"Task 6: Debug overlay
Section titled “Task 6: Debug overlay”Files:
- Create:
ui/debug_overlay.gd - Modify:
main.gd(wire overlay, track applied upgrades, handle F2 input) - Create:
tests/test_debug_overlay.gd
Interfaces:
-
Consumes:
sim: Sim,sim.player,sim.enemies.count,sim.projectiles.count,sim.gems.count,sim.enemies.aura_element: PackedInt32Array,sim.fx_events: Array[Dictionary],sim.content: ContentDB -
Produces:
class_name DebugOverlay extends CanvasLayerfunc toggle() -> voidfunc update_overlay(sim: Sim, applied: Array[String]) -> void
-
Step 1: Write failing tests
Create tests/test_debug_overlay.gd:
extends GutTest
func test_starts_hidden() -> void: var overlay := DebugOverlay.new() add_child_autofree(overlay) assert_false(overlay.visible, "debug overlay hidden on start")
func test_toggle_twice_returns_to_hidden() -> void: var overlay := DebugOverlay.new() add_child_autofree(overlay) overlay.toggle() overlay.toggle() assert_false(overlay.visible, "double-toggle = hidden")
func test_update_overlay_produces_text() -> void: var overlay := DebugOverlay.new() add_child_autofree(overlay) overlay.toggle() # make visible so update runs var sim := Sim.new(1, SimContentFixture.db()) overlay.update_overlay(sim, ["damage", "fire-rate"]) assert_gt(overlay.label_text().length(), 20, "overlay produces non-trivial text") assert_no_push_errors()- Step 2: Run tests — expect FAIL
godot --headless --path . --import 2>/dev/nullgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_overlay.gd -gexit 2>&1 | tail -10Expected: FAIL (DebugOverlay not found).
- Step 3: Create
ui/debug_overlay.gd
class_name DebugOverlayextends CanvasLayer
const PANEL_W: float = 310.0const PANEL_H: float = 270.0const MARGIN: float = 12.0
var _label: Label
func _ready() -> void: layer = 128 visible = false
var vp := get_viewport_rect().size
var panel := Panel.new() panel.position = Vector2(vp.x - PANEL_W - MARGIN, vp.y - PANEL_H - MARGIN) panel.size = Vector2(PANEL_W, PANEL_H) panel.mouse_filter = Control.MOUSE_FILTER_IGNORE var sb := StyleBoxFlat.new() sb.bg_color = Color(0, 0, 0, 0.72) sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8 sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8 panel.add_theme_stylebox_override("panel", sb) add_child(panel)
_label = Label.new() _label.position = Vector2(10, 8) _label.size = Vector2(PANEL_W - 20, PANEL_H - 16) _label.add_theme_font_override("font", NeonTheme.mono_font()) _label.add_theme_font_size_override("font_size", 13) _label.add_theme_color_override("font_color", Color(0.7, 1.0, 0.7)) _label.autowrap_mode = TextServer.AUTOWRAP_OFF _label.mouse_filter = Control.MOUSE_FILTER_IGNORE panel.add_child(_label)
func toggle() -> void: visible = !visible
func update_overlay(sim: Sim, applied: Array[String]) -> void: var p := sim.player # Aura breakdown var aura_counts: Dictionary = {} for i in range(sim.enemies.count): var el: int = sim.enemies.aura_element[i] aura_counts[el] = aura_counts.get(el, 0) + 1 var aura_lines := "" for el in aura_counts: var ename: String = "none" if el >= 0: ename = sim.content.element_at(el).get("id", "?") aura_lines += " %s×%d\n" % [ename, aura_counts[el]] if aura_lines == "": aura_lines = " (none)\n"
_label.text = ( "=== DEBUG (F2) ===\n" + "pos %.0f, %.0f\n" % [p.pos.x, p.pos.y] + "hp %.1f / %.1f\n" % [p.hp, p.max_hp] + "lv %d xp %.1f/%.1f\n" % [p.level, p.xp, p.xp_to_next] + "dmg× %.2f fr× %.2f\n" % [p.damage_mult, p.fire_rate_mult] + "enemy %d proj %d gem %d\n" % [sim.enemies.count, sim.projectiles.count, sim.gems.count] + "auras:\n" + aura_lines + "fx/tk %d\n" % sim.fx_events.size() + "upgr: " + (", ".join(applied) if applied.size() > 0 else "none") )
# Test seamfunc label_text() -> String: return _label.text if _label != null else ""- Step 4: Run tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_overlay.gd -gexit 2>&1 | tail -10Expected: 3 passed, 0 failed.
- Step 5: Wire DebugOverlay + upgrade tracking in
main.gd
Field declarations — add after var weapon_panel: WeaponPanel:
var debug_overlay: DebugOverlayvar _applied_upgrades: Array[String] = []In _new_run() — add after add_child(weapon_panel):
debug_overlay = DebugOverlay.new()add_child(debug_overlay)_applied_upgrades = []In _process() — add after weapon_panel.update_panel(...):
if debug_overlay.visible: debug_overlay.update_overlay(sim, _applied_upgrades)Add _input() function (new function, add after _on_upgrade_chosen):
func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if event.physical_keycode == KEY_F2: debug_overlay.toggle()In _on_upgrade_chosen() — add _applied_upgrades.append(id) as the first line:
func _on_upgrade_chosen(id: String) -> void: _applied_upgrades.append(id) sim.apply_upgrade(id) level_up.hide_panel() if sim.pending_levelups > 0: _open_levelup() else: _paused_for_levelup = false- Step 6: Run full suite + count guard
bash scripts/check-test-count.sh 2>&1 | tail -5Expected: all pass, count guard OK (should now be 42 test scripts, 147 tests or similar — trust the count guard).
- Step 7: Boot smoke test
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"Expected: 0.
- Step 8: Run determinism tests one final time
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5Expected: 1314757315 / 1949813464 — unchanged.
- Step 9: Commit
git add ui/debug_overlay.gd main.gd tests/test_debug_overlay.gdgit commit -m "feat(debug): F2 toggle overlay with sim state; track applied upgrades"Final gate
Section titled “Final gate”After all 6 tasks, before handing off:
# Full suite with count guardbash scripts/check-test-count.sh 2>&1 | tail -8
# Boot smokegodot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"
# Determinism (both)godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -3godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -3Then deploy the updated web demo:
bash scripts/deploy-demo.sh