Per-Enemy Threat Radar Mist Implementation Plan
Per-Enemy Threat Radar Mist Implementation Plan
Section titled “Per-Enemy Threat Radar Mist Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the 8-sector-blob ThreatRadar with a per-enemy screen-edge light plus a
downres-SubViewport mist layer, so dense enemy clusters visibly merge into soft wide glow while
isolated enemies stay small distinct dots.
Architecture: Extract ThreatMap’s existing per-enemy weight formula into its own function
(shared by Ace’s unchanged decision-making and the new radar). ThreatRadar sweeps every enemy
once per frame into a pure build_light_data() function, then uploads that data to two
MultiMeshInstance2D layers — one drawn directly (sharp small dots), one rendered into a
low-resolution SubViewport and displayed back upscaled with bilinear filtering (the blur that
produces genuine merging), both reusing SwarmRenderer’s existing MultiMesh + GlowTexture +
additive-blend pattern.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0.
Global Constraints
Section titled “Global Constraints”- No changes to
/sim— this is entirelyai/threat_map.gd(one behavior-preserving extraction) andui/threat_radar.gd(full internal rewrite). No determinism re-pin expected; verify the pinned assertion intests/test_determinism_survival.gdis unchanged at the end. - No changes to
SkilledAutoPlayer’s decision-making (compute_move_for/wants_dash/compute_aim_for, all consumers ofThreatMap.compute()/best()) — already tuned and fixed earlier tonight (the engage-vs-flee regression). The Task 1 extraction must be behavior- preserving: same formula, same results, just factored into its own function. - No changes to
main.gd—ThreatRadar’s external interface (update_radar(sim, pilot), thevisibleself-repair, the field declaration, the persistence/hide wiring) is completely unchanged; only the class’s internals change. - Reuse
render/glow_texture.gd’sGlowTexture.shared()— do not create a new texture asset. - Full
bh-dev-chunk-style gate at the end: full GUT suite,scripts/check-test-count.sh, a headless boot smoke (godot --headless --path . --quit-after 120, grep forSCRIPT ERROR), a real visual capture via the_shot.gdharness before considering the render task done.
Task 1: ThreatMap.enemy_weight() — extract the shared per-enemy formula
Section titled “Task 1: ThreatMap.enemy_weight() — extract the shared per-enemy formula”Files:
- Modify:
ai/threat_map.gd - Test:
tests/test_threat_map.gd
Interfaces:
-
Produces:
ThreatMap.enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float— the exact formula currently inlined incompute()’s loop (base 1.0 +ELITE_BONUSif elite +BOSS_BONUSif boss-type + HP-fraction ×HP_LIVE_WEIGHT, all × the Gaussian distance falloff usingDIST_SIGMA).compute()itself is modified to call this instead of inlining the formula — behavior must be byte-identical (same constants, same math), verified by the existing test suite passing unchanged. -
Step 1: Write the failing test for the extracted function
Add to tests/test_threat_map.gd (after the existing _only_nonzero_slice helper):
func test_enemy_weight_matches_the_base_plus_full_hp_formula() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) # full HP at spawn var w := ThreatMap.enemy_weight(sim, sim.player, 0) assert_almost_eq(w, 2.0 * _DIST_100_50_FALLOFF, 0.0001, "1.0 base + 1.0 full-HP fraction, no elite/boss bonus, distance-discounted -- same formula compute() already tests")
func test_enemy_weight_matches_the_elite_bonus_formula() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) sim.enemies.is_elite[0] = 1 var w := ThreatMap.enemy_weight(sim, sim.player, 0) assert_almost_eq(w, 4.0 * _DIST_100_50_FALLOFF, 0.0001)
func test_enemy_weight_decays_with_distance() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(1500, 0), Vector2.ZERO, 14.0, 20.0) var near_w := ThreatMap.enemy_weight(sim, sim.player, 0) var far_w := ThreatMap.enemy_weight(sim, sim.player, 1) assert_gt(near_w, far_w, "an otherwise-identical enemy weighs less the farther away it is")(_DIST_100_50_FALLOFF already exists in this file from the earlier distance-falloff work — reuse
it, don’t redefine it.)
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit
Expected: FAIL — enemy_weight does not exist on ThreatMap yet.
- Step 3: Extract the function in
ai/threat_map.gd
Find this exact block (the current compute() function):
static func compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary]: var slices: Array[Dictionary] = [] var slice_width := TAU / float(sectors) for s in range(sectors): slices.append({ "score": 0.0, "angle": (float(s) + 0.5) * slice_width, # slice CENTER angle, for edge projection "target_pos": Vector2.ZERO, "has_target": false, "_best_weight": 0.0, }) for i in range(sim.enemies.count): var d: Vector2 = sim.enemies.pos[i] - pilot.pos var dist: float = d.length() var ang: float = fposmod(d.angle(), TAU) var idx: int = clampi(int(ang / slice_width), 0, sectors - 1) var weight := 1.0 if sim.enemies.is_elite[i] == 1: weight += ELITE_BONUS if sim.boss_rotation.is_boss_type(sim.enemies.type_id[i]): weight += BOSS_BONUS var max_hp: float = maxf(sim.enemies.max_hp[i], 1.0) weight += (sim.enemies.data[i] / max_hp) * HP_LIVE_WEIGHT weight *= exp(-(dist * dist) / (DIST_SIGMA * DIST_SIGMA)) var slice: Dictionary = slices[idx] slice["score"] = float(slice["score"]) + weight if weight > float(slice["_best_weight"]): slice["_best_weight"] = weight slice["target_pos"] = sim.enemies.pos[i] slice["has_target"] = true return slicesReplace with:
# Per-enemy danger weight: count + elite + boss + remaining-HP-fraction, discounted by distance# (Gaussian falloff, DIST_SIGMA). Shared by compute()'s sector accumulation (below) and# ui/threat_radar.gd's per-enemy light rendering -- one formula, two consumers.static func enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float: var dist: float = sim.enemies.pos[i].distance_to(pilot.pos) var weight := 1.0 if sim.enemies.is_elite[i] == 1: weight += ELITE_BONUS if sim.boss_rotation.is_boss_type(sim.enemies.type_id[i]): weight += BOSS_BONUS var max_hp: float = maxf(sim.enemies.max_hp[i], 1.0) weight += (sim.enemies.data[i] / max_hp) * HP_LIVE_WEIGHT weight *= exp(-(dist * dist) / (DIST_SIGMA * DIST_SIGMA)) return weight
static func compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary]: var slices: Array[Dictionary] = [] var slice_width := TAU / float(sectors) for s in range(sectors): slices.append({ "score": 0.0, "angle": (float(s) + 0.5) * slice_width, # slice CENTER angle, for edge projection "target_pos": Vector2.ZERO, "has_target": false, "_best_weight": 0.0, }) for i in range(sim.enemies.count): var d: Vector2 = sim.enemies.pos[i] - pilot.pos var ang: float = fposmod(d.angle(), TAU) var idx: int = clampi(int(ang / slice_width), 0, sectors - 1) var weight := enemy_weight(sim, pilot, i) var slice: Dictionary = slices[idx] slice["score"] = float(slice["score"]) + weight if weight > float(slice["_best_weight"]): slice["_best_weight"] = weight slice["target_pos"] = sim.enemies.pos[i] slice["has_target"] = true return slices- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit
Expected: PASS — all tests green, INCLUDING every pre-existing test in this file (the extraction
must not change compute()’s observable behavior at all).
- Step 5: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as before this task, plus the 3 new tests. Also re-run
test_skilled_auto_player and test_threat_radar specifically (both consume ThreatMap) to
confirm zero behavior change:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
- Step 6: Commit
git add ai/threat_map.gd tests/test_threat_map.gdgit commit -m "refactor(ai): extract ThreatMap.enemy_weight(), shared by compute() and the new per-enemy radar"Task 2: ThreatRadar.build_light_data() — pure per-enemy sweep
Section titled “Task 2: ThreatRadar.build_light_data() — pure per-enemy sweep”Files:
- Modify:
ui/threat_radar.gd - Test:
tests/test_threat_radar.gd
Interfaces:
- Consumes:
ThreatMap.enemy_weight(sim, pilot, i)(Task 1). - Produces:
ThreatRadar.SCORE_NORMALIZE: float(const,8.0, unchanged from the sector version — a single enemy’s own weight at/above this now renders at full intensity, which is meaningfully reachable for one elite/boss up close).ThreatRadar.build_light_data(sim: Sim, pilot: PlayerState, vp: Vector2, margin: float, mist_scale: float) -> Dictionary— pure, static, no Node/viewport access. Returns{"sharp": Array[Dictionary], "mist": Array[Dictionary]}, each entry{"pos": Vector2, "alpha": float};mistentries havepospre-multiplied bymist_scale.ThreatRadar.edge_point(dir, vp, margin) -> Vector2(existing, unchanged, reused bybuild_light_data).
This task REPLACES the sector-based update_radar()/blob-drawing internals with the new pure
sweep, but does NOT yet touch the actual rendering nodes (_ready(), the _Radar inner class,
MultiMesh) — that’s Task 3. For this task, delete the old _Radar inner class, _ready()
body, and the old update_radar()/blob_count() entirely; the file temporarily has no working
_ready()/update_radar() (Task 3 adds them back) — this is fine, nothing else in the repo calls
ThreatRadar methods except main.gd at runtime, which isn’t exercised by this task’s tests.
Files still create: tests/test_threat_radar.gd keeps its existing edge_point() geometry
tests (test_edge_point_due_east/south/west/north, test_edge_point_diagonal_stays_within_box)
verbatim — do not touch those 5 tests. Everything below # ── update_radar() in that file is
being replaced.
- Step 1: Write the failing tests
In tests/test_threat_radar.gd, replace everything from the # ── update_radar() comment to the
end of the file with:
# ── build_light_data() — pure per-enemy sweep, no Node/viewport access ────────────────────
const _MIST_SCALE := 0.18
func test_build_light_data_empty_arena_is_empty() -> void: var sim := Sim.new(1, _content()) var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) assert_eq((data["sharp"] as Array).size(), 0) assert_eq((data["mist"] as Array).size(), 0)
func test_build_light_data_one_enemy_produces_one_light_in_each_layer() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) assert_eq((data["sharp"] as Array).size(), 1) assert_eq((data["mist"] as Array).size(), 1)
func test_build_light_data_mist_position_is_the_sharp_position_scaled() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) var sharp_pos: Vector2 = (data["sharp"] as Array)[0]["pos"] var mist_pos: Vector2 = (data["mist"] as Array)[0]["pos"] assert_almost_eq(mist_pos.x, sharp_pos.x * _MIST_SCALE, 0.01) assert_almost_eq(mist_pos.y, sharp_pos.y * _MIST_SCALE, 0.01)
func test_build_light_data_alpha_matches_normalized_enemy_weight() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) var expected := clampf(ThreatMap.enemy_weight(sim, sim.player, 0) / ThreatRadar.SCORE_NORMALIZE, 0.0, 1.0) var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) assert_almost_eq((data["sharp"] as Array)[0]["alpha"], expected, 0.0001)
func test_build_light_data_elite_has_higher_alpha_than_plain_at_the_same_distance() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(0, 100), Vector2.ZERO, 14.0, 20.0) sim.enemies.is_elite[1] = 1 var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) var lights: Array = data["sharp"] # both enemies are at distance 100 -- find each by its x/y position sign to compare alphas. var plain_alpha := 0.0 var elite_alpha := 0.0 for l in lights: var p: Vector2 = l["pos"] if absf(p.x - ThreatRadar.edge_point(Vector2.RIGHT, VP, ThreatRadar.MARGIN).x) < 1.0: plain_alpha = l["alpha"] if absf(p.y - ThreatRadar.edge_point(Vector2.DOWN, VP, ThreatRadar.MARGIN).y) < 1.0: elite_alpha = l["alpha"] assert_gt(elite_alpha, plain_alpha, "the elite enemy's light is brighter than the plain one at the same distance")
func test_build_light_data_negligible_far_enemy_is_excluded() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(2000, 0), Vector2.ZERO, 14.0, 20.0) # arena-edge distance, near-zero weight var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) assert_eq((data["sharp"] as Array).size(), 0, "negligible weight -- not worth a light or a MultiMesh slot")
func test_build_light_data_two_enemies_produce_two_lights() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(-100, 0), Vector2.ZERO, 14.0, 20.0) var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE) assert_eq((data["sharp"] as Array).size(), 2)- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: FAIL — build_light_data and SCORE_NORMALIZE don’t exist yet in this shape (the file
still has the old sector-based implementation).
- Step 3: Replace
ui/threat_radar.gd’s content from the const block down through the oldupdate_radar()/blob_count()
Replace the WHOLE file with:
class_name ThreatRadarextends CanvasLayer
# An always-on screen-edge glow toward danger, one light PER ENEMY (not per sector), with a# density-based "mist" layer so clustered enemies visibly merge into a soft wide glow while# isolated enemies stay small distinct dots. REWRITTEN 2026-07-17 (Chris, after playing the# original 8-sector-blob version): "make every enemy have a radar threat light and an average# light effect that connects above them all so if there are tons in one area there will be a# gentle wide mist above all the tiny lights." See# docs/superpowers/specs/2026-07-17-per-enemy-threat-radar-mist-design.md for the full research# + architecture. Rendering nodes are wired up in a later task -- this task only carries the pure# geometry/data functions (edge_point, build_light_data), testable with no Node/viewport at all.
const MARGIN: float = 24.0const SCORE_NORMALIZE: float = 8.0 # a single enemy's own weight at/above this renders at full intensityconst RADIUS_MIN: float = 5.0const RADIUS_MAX: float = 14.0const MAX_ALPHA: float = 0.5const DANGER_COLOR := Color(1.0, 0.35, 0.1) # distinct from the low-HP vignette's Color(0.7, 0.06, 0.06)const MIST_RESOLUTION_SCALE: float = 0.18 # deliberately far lower than nebula_bg.gd's 0.5 -- the blur IS the point hereconst MAX_LIGHTS: int = 400 # generous headroom above this game's own peak_enemies telemetry (200-345)
# Pure: given a direction and viewport size, the point on the inset edge box along that ray from# screen-centre. Same ray-box math the original sector version used.static func edge_point(dir: Vector2, vp: Vector2, margin: float) -> Vector2: var half := Vector2(vp.x * 0.5 - margin, vp.y * 0.5 - margin) var tx: float = (half.x / absf(dir.x)) if absf(dir.x) > 0.0001 else INF var ty: float = (half.y / absf(dir.y)) if absf(dir.y) > 0.0001 else INF return vp * 0.5 + dir * minf(tx, ty)
# Pure: sweeps every enemy once, returns the per-instance data both render layers need. Skips# enemies whose weight is negligible (alpha <= 0.001) -- nothing meaningful to draw or upload.# "mist" entries carry the SAME light, position pre-scaled by mist_scale (the low-res SubViewport# the mist layer renders into has a proportionally smaller coordinate space).static func build_light_data(sim: Sim, pilot: PlayerState, vp: Vector2, margin: float, mist_scale: float) -> Dictionary: var sharp: Array[Dictionary] = [] var mist: Array[Dictionary] = [] for i in range(sim.enemies.count): var d: Vector2 = sim.enemies.pos[i] - pilot.pos if d.length() < 0.001: continue var dir := d.normalized() var weight := ThreatMap.enemy_weight(sim, pilot, i) var alpha := clampf(weight / SCORE_NORMALIZE, 0.0, 1.0) if alpha <= 0.001: continue var pos := edge_point(dir, vp, margin) sharp.append({"pos": pos, "alpha": alpha}) mist.append({"pos": pos * mist_scale, "alpha": alpha}) return {"sharp": sharp, "mist": mist}- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: PASS — all 12 tests green (5 pre-existing edge_point tests + 7 new build_light_data
tests). The old test_no_blobs_when_arena_empty/test_one_blob_when_one_enemy_present/
test_update_radar_self_repairs_visibility tests are gone (deleted in Step 1) — Task 3 adds their
replacements once the render nodes exist again.
- Step 5: Commit
git add ui/threat_radar.gd tests/test_threat_radar.gdgit commit -m "refactor(hud): ThreatRadar.build_light_data() -- pure per-enemy sweep, no rendering yet"Task 3: Wire up the two MultiMesh render layers + SubViewport mist
Section titled “Task 3: Wire up the two MultiMesh render layers + SubViewport mist”Files:
- Modify:
ui/threat_radar.gd - Test:
tests/test_threat_radar.gd
Interfaces:
-
Consumes:
ThreatRadar.build_light_data()(Task 2),render/glow_texture.gd’sGlowTexture.shared() -> Texture2D(existing, unchanged). -
Produces:
ThreatRadar.update_radar(sim: Sim, pilot: PlayerState) -> void(same external signaturemain.gdalready calls — no caller-side changes anywhere).ThreatRadar.light_count() -> int(test seam, replaces the oldblob_count()). -
Step 1: Write the failing tests
Append to tests/test_threat_radar.gd. NOTE: _radar() and _ready_radar() helpers already
exist earlier in this file (defined before the “Pure edge geometry” section, so untouched by
Task 2’s replacement of everything from # ── update_radar() onward) — do NOT redefine them here,
just use them directly (redefining would be a duplicate-function error):
# ── update_radar() — node-level behaviour ──────────────────────────────────────────────────
func test_no_lights_when_arena_empty() -> void: var sim := Sim.new(1, _content()) var r := await _ready_radar() r.update_radar(sim, sim.player) assert_eq(r.light_count(), 0, "no enemies -> nothing to light up")
func test_one_light_when_one_enemy_present() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) var r := await _ready_radar() r.update_radar(sim, sim.player) assert_eq(r.light_count(), 1)
func test_update_radar_self_repairs_visibility() -> void: var sim := Sim.new(1, _content()) var r := await _ready_radar() r.visible = false r.update_radar(sim, sim.player) assert_true(r.visible, "update_radar always re-shows the radar, mirroring BossEdgeIndicator's self-repair")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: FAIL — update_radar/light_count don’t exist yet (Task 2 left the file without
rendering nodes).
- Step 3: Append the rendering nodes and orchestration to
ui/threat_radar.gd
Add this to the end of the file (after build_light_data):
var _sharp_mm: MultiMeshInstance2Dvar _mist_viewport: SubViewportvar _mist_mm: MultiMeshInstance2Dvar _mist_display: TextureRect
func _ready() -> void: layer = 5 # same tier as Hud/BossEdgeIndicator
_sharp_mm = MultiMeshInstance2D.new() _sharp_mm.multimesh = _make_multimesh() _sharp_mm.texture = GlowTexture.shared() var sharp_mat := CanvasItemMaterial.new() sharp_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD _sharp_mm.material = sharp_mat add_child(_sharp_mm)
_mist_viewport = SubViewport.new() _mist_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS _mist_viewport.transparent_bg = true _mist_mm = MultiMeshInstance2D.new() _mist_mm.multimesh = _make_multimesh() _mist_mm.texture = GlowTexture.shared() var mist_mat := CanvasItemMaterial.new() mist_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD _mist_mm.material = mist_mat _mist_viewport.add_child(_mist_mm) add_child(_mist_viewport)
_mist_display = TextureRect.new() _mist_display.set_anchors_preset(Control.PRESET_FULL_RECT) _mist_display.mouse_filter = Control.MOUSE_FILTER_IGNORE _mist_display.stretch_mode = TextureRect.STRETCH_SCALE _mist_display.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR # the upscale IS the blur var display_mat := CanvasItemMaterial.new() display_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD _mist_display.material = display_mat _mist_display.texture = _mist_viewport.get_texture() add_child(_mist_display)
static func _make_multimesh() -> MultiMesh: var quad := QuadMesh.new() quad.size = Vector2(RADIUS_MAX * 2.0, RADIUS_MAX * 2.0) var mm := MultiMesh.new() mm.mesh = quad mm.transform_format = MultiMesh.TRANSFORM_2D mm.use_colors = true mm.instance_count = MAX_LIGHTS mm.visible_instance_count = 0 return mm
func update_radar(sim: Sim, pilot: PlayerState) -> void: visible = true var vp := get_viewport().get_visible_rect().size var mist_size := Vector2i((vp * MIST_RESOLUTION_SCALE).round()) if _mist_viewport.size != mist_size: _mist_viewport.size = mist_size var data := build_light_data(sim, pilot, vp, MARGIN, MIST_RESOLUTION_SCALE) _upload(_sharp_mm.multimesh, data["sharp"]) _upload(_mist_mm.multimesh, data["mist"])
func _upload(mm: MultiMesh, lights: Array) -> void: var n: int = mini(lights.size(), MAX_LIGHTS) for i in range(n): var light: Dictionary = lights[i] var pos: Vector2 = light["pos"] var alpha: float = light["alpha"] var r: float = lerpf(RADIUS_MIN, RADIUS_MAX, alpha) / RADIUS_MAX mm.set_instance_transform_2d(i, Transform2D(Vector2(r, 0.0), Vector2(0.0, r), pos)) mm.set_instance_color(i, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, alpha * MAX_ALPHA)) mm.visible_instance_count = n
# Test seam.func light_count() -> int: return _sharp_mm.multimesh.visible_instance_count if _sharp_mm != null else 0- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: PASS — all 15 tests green (5 edge_point + 7 build_light_data + 3 new node-level).
- Step 5: Verify with a real capture — do NOT skip this step
Write a temporary capture script _shot.gd in the repo root:
extends SceneTree
func _init() -> void: await process_frame var bg := ColorRect.new() bg.color = Color(0.03, 0.03, 0.06) bg.set_anchors_preset(Control.PRESET_FULL_RECT) root.add_child(bg)
var defs: ContentDB = ContentLoader.load_from_path("res://data/bible.json") var sim: Sim = Sim.new(1, defs) sim.player.pos = Vector2.ZERO # A loose scatter (should stay small distinct dots) plus a genuinely dense cluster # (should visibly merge into wide mist) plus one far, near-zero-weight straggler. sim.enemies.add(Vector2(150, 20), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(-300, 100), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(0, -250), Vector2.ZERO, 14.0, 20.0) sim.enemies.is_elite[2] = 1 for i in range(25): var a := float(i) / 25.0 * 0.6 - 0.3 sim.enemies.add(Vector2(200, 200) + Vector2(cos(a), sin(a)) * 30.0, Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(1900, 0), Vector2.ZERO, 14.0, 20.0)
var radar: ThreatRadar = ThreatRadar.new() root.add_child(radar) await process_frame await process_frame radar.update_radar(sim, sim.player) await process_frame await process_frame var img: Image = root.get_texture().get_image() img.save_png("/tmp/per_enemy_radar_shot.png") print("SHOT_SAVED") quit()Run NON-headless (headless returns blank textures): godot --path . -s res://_shot.gd
Expected stdout: SHOT_SAVED. Then delete the temp script: rm -f _shot.gd _shot.gd.uid.
Look at /tmp/per_enemy_radar_shot.png (Read tool). Confirm, by eye: the 3 loose/isolated enemies
(including the elite, which should read brighter/larger than the plain ones) show as small
distinct dots; the 25-enemy dense cluster shows as one wide, soft, genuinely blurred glow region
— NOT 25 visible overlapping circles with hard edges; the far straggler is barely or not visible.
If the cluster still reads as distinct overlapping dots rather than merged mist, MIST_RESOLUTION_SCALE
needs to go lower (more blur) — adjust the constant, re-run this step, and do not proceed to Step 6
until the capture genuinely shows merged mist, not just brighter dots. This is a visual effect;
numeric test passes are not sufficient proof by themselves (see bullet-heaven-ui-look memory’s
“looks plausible isn’t proof” lesson) — the capture is the actual acceptance check for this task.
- Step 6: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as after Task 2, plus 3 new tests.
- Step 7: Commit
git add ui/threat_radar.gd tests/test_threat_radar.gdgit commit -m "feat(hud): per-enemy threat radar with a downres-SubViewport mist merge layer"(If Step 5’s capture required adjusting MIST_RESOLUTION_SCALE, include that in this same commit
— it’s part of making the task’s actual deliverable correct, not a separate change.)
Task 4: Full-feature verification
Section titled “Task 4: Full-feature verification”Files: none (verification only).
Interfaces: none.
- Step 1: Full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all scripts run, 0 failing, same 4 pre-existing risky/pending.
- Step 2: Count guard
Run: bash scripts/check-test-count.sh
Expected: test-count guard OK (240 scripts — no new test files were created, only existing ones
extended in this plan).
- Step 3: Boot smoke
Run: godot --headless --path . --quit-after 120
Expected: no SCRIPT ERROR lines (leaked-RID/teardown noise is expected and pre-existing).
- Step 4: Determinism baseline unaffected
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS with the SAME pinned hash/checksum as before this plan (this feature never touches
/sim).
- Step 5: Soak sanity check
Run: bash scripts/soak.sh --runs=4 --ticks=3600 --ships=cobalt --coop=solo --player=skilled --seed0=7000
Expected: clean JSON report, no invariant failures, no anomalies. (This exercises
ThreatMap.enemy_weight()/compute() through SkilledAutoPlayer’s real decision paths, catching
any accidental behavior change from the Task 1 extraction that the unit tests didn’t.)
- Step 6: Export a fresh macOS build and hand off
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.appgit diff --stat project.godotExpected: export succeeds; project.godot diff is empty. Then open builds/macos/BulletHeaven.app
so Chris can watch the new radar live.
- Step 7: Push
git push origin main