Weapon FX v2 — foundation + batch 1 implementation plan
Weapon FX v2 — Foundation + Batch 1 Implementation Plan
Section titled “Weapon FX v2 — Foundation + Batch 1 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: Batch-1 weapons get looks that grow with investment and change when the weapon evolves. The batch is Blaster (and all player bullets), Orbit Shards, Blade, Scythe and Nova. Crystal accents show which elements upgraded each weapon. The v1 looks stay behind an A/B switch and serve as the L3+ quality fallback. Structural guard tests make any future weapon fail the gate if its effect is invisible or doesn’t scale.
Architecture:
- Sim side. Every weapon
fx_eventgetswid/power/evolved/rungsfrom a pureWeaponVfxhelper. Player projectiles carry three render-onlyProjPoolcolumns:vfx,rungsandkind. Nothing entersstate_checksumand no RNG is drawn. - Render side. Each v2 look is one
WeaponFxLayer, aMultiMeshInstance2Dwith one shared shader, so each look costs one draw call. Per-instance data travels in COLOR and INSTANCE_CUSTOM. - Dispatch.
FxManagerstays a dispatcher and routes events to v2 looks whenv2_active()is true.main.gdswaps between the legacy orbit sprites /proj_rendererandOrbitShardsLook/PlayerBoltRenderer.
Tech Stack: Godot 4.7, typed GDScript, canvas_item shaders with #include, GUT, and the bh-vfx-iteration harness tiers.
Spec: docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md (approved as written). Read it before any task.
Global Constraints
Section titled “Global Constraints”- Visual only. Sim/balance, sound and mechanics are unchanged, including Scar Tissue’s event kind. Enemy/boss effects stay visual only and are out of scope (Chris, 2026-09-29). Enemy element reactions are batch 6 with their own design pass.
tests/test_determinism_survival.gdmust pass unchanged. Any movement is a bug in this work, not a re-pin. Never runbh-determinism-repinfor this plan./simstays pureRefCounted. It gets no Node, render, Engine or Time APIs, and no newrngdraws.fx_eventsstays out ofstate_checksumandsnapshot_string.- Power curve:
power = 1 - pow(0.72, invested), whereinvested = weapon_levels[wid] + popcount(rungs). A weapon never reaches 1.0. - Grow budget: a power-1 look is at most 1.5× the power-0 look in size or thickness. Interiors stay clear so enemies inside an effect remain readable. The element tint shows in every look.
- The evolved form takes the evolve rung’s element (
WeaponThresholds.TABLEkind == "evolve"). - Crystal motifs are built against a generic
(p, mask, edge, strength, t, seed)shape interface, so batch 6 (enemies) can reuse them. - Quality: v2 looks run at L0–L2 (
QualityManager.weapon_fx_v2_on(l) = l <= 2). L3+ uses the legacy looks. - Weapon feedback stays essential. Nova, slash and
blaster_firemuzzle are never juice-gated.bolt_impactsparks are juice. - The HDR recipe matches Plasma Nova’s:
blend_add, unshaded, with hot parts above 1.0 so the existing bloom (glow_hdr_threshold 1.0) picks them up. Colour uniforms use: source_color. Per-instance tint is packed raw, the same asSwarmRenderer. - No
cp/rsyncintoplatform/tvos/, which holds symlinks. scripts/gate.shrunsgit checkout -- project.godot, so commit intentionalproject.godotedits first. After every windowed Godot run,git diff --stat project.godotthengit checkout -- project.godot.- Commits use
git commit -F <file>. The message ends with:Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01FSXXmmsKp5hKdWgW9LjD34 - Two developers share the repo. Before merging to
main, rungit fetch origin && git log HEAD..origin/main --onelineand rebase if needed. Thengit push origin mainright after the merge.
Commands used throughout
Section titled “Commands used throughout”# after adding any new class_name or shader/include (else "Identifier not declared"):godot --headless --path . --import# one test script (-gtest does NOT narrow the run; -gselect does):godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit# judge by the GUT summary ("Passing Tests"/"Failing Tests"), NOT the exit code — headless teardown# SIGABRTs (exit 134) even on a clean run.# determinism (run after every sim-touching task):godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexit# full gate (import + suite + test-count guard + tutorial trace + boot smoke):scripts/gate.shscripts/check-test-count.sh counts tests/test_*.gd, so each new test file raises the expected count. Commit new test files in the same commit as their code, and let the gate’s count guard see them.
File map
Section titled “File map”| File | Status | Responsibility |
|---|---|---|
sim/weapon_vfx.gd |
new | WeaponVfx: invested/power/rungs/evolved/stamp, plus the bullet encode/decode |
sim/proj_pool.gd |
modify | render-only columns vfx, rungs, kind, plus kind_for(ctx) |
sim/weapon_*.gd (13 files), sim/sim.gd, sim/elemental_system.gd |
modify | stamp every emit; bullets pass vfx/rungs; splits inherit; bolt_impact |
fx/weapons/weapon_fx_layer.gd |
new | WeaponFxLayer: the one-draw-call MultiMesh base for every v2 look |
fx/weapons/fx_noise.gdshaderinc |
new (extracted) | hash21, vnoise, fbm shared by all weapon shaders |
fx/weapons/plasma_nova.gdshader |
moved | Plasma Nova shader (pure git mv plus include) |
fx/weapons/plasma_nova_node.gd |
new (moved) | PlasmaNovaNode (+ embers), the v1 nova path moved out of FxManager |
fx/weapons/element_accents.gdshaderinc |
new | crystal accent motifs (accent_motif, accents_all, evolve_accent) |
fx/weapons/element_accents.gd |
new | ElementAccents: GDScript twin (ids, code()), kept in sync by guard test |
fx/weapons/blade_slash_look.gd + .gdshader |
new | Blade look (+ Tempest), real arc |
fx/weapons/scythe_reap_look.gd + .gdshader |
new | Scythe look (+ Reaping) |
fx/weapons/nova_burst_look.gd + .gdshader |
new | Nova v2 (+ Supernova); Scar Tissue discharge scaled by its own power |
fx/weapons/orbit_shards_look.gd + .gdshader |
new | Orbit shards + trail + link ring (+ Event Horizon), all pilots |
fx/weapons/player_bolt_renderer.gd + player_bolt.gdshader |
new | all player bullets (+ Auto-Cannon twin cores) |
fx/weapons/blaster_flash_look.gd + .gdshader |
new | muzzle flash + Auto-Cannon impact sparks |
fx/fx_manager.gd |
modify | V2_LOOKS, v2_active(), look layers, dispatch; legacy slash honours arc |
render/visual_look.gd |
modify | weapon_fx_v2 switch (later: fold plasma_nova) |
render/quality_manager.gd |
modify | weapon_fx_v2_on(l); _apply sets fx_layer.v2_quality_ok |
ui/debug_settings_panel.gd |
modify | “Weapon FX v2 (A/B)” row |
main.gd |
modify | orbit look / bolt renderer wiring against legacy |
tools/weapon_vfx/{compare,ingame,motifs}.gd + .tscn |
new | tier-1/3 harnesses and motif grid |
tools/perf_probe/perf_probe.gd |
modify | --weapons= --evolve --v2= --level= |
tests/test_weapon_vfx.gd, tests/test_weapon_fx_guards.gd, tests/test_proj_pool_vfx.gd, tests/test_weapon_fx_layer.gd, tests/test_weapon_fx_looks.gd |
new | unit + structural guards |
Custom-data layouts (INSTANCE_CUSTOM, four float32 values; COLOR.rgb is the element tint and COLOR.a is look-defined):
| Look | COLOR.a | custom.x | custom.y | custom.z | custom.w |
|---|---|---|---|---|---|
| Blade / Scythe | life t | power + 2*evolved |
accent slot | arc (rad) | seed |
| Nova | life t | power + 2*evolved |
accent slot | seed | 0 |
| Orbit ring (1 per pilot) | spin_norm | power + 2*evolved |
accent slot | phase (rad) | shard count |
| Orbit shard | 1 | power + 2*evolved |
accent slot | seed k | -1 |
| Player bolt | 1 | vfx (power, +1 if evolved) |
accent slot | ProjPool kind | remaining life |
| Blaster flash | life t | power + 2*evolved |
accent slot | seed | mode (0 muzzle, 1 impact) |
Evolved decodes as x >= 1.5 for looks and vfx >= 1.0 for bolts. The accent code is an int, 2 bits per element id at bits 2*(id-1) (ids 1–10) and the evolve id at bits 20–23. It never rides per-instance (Task 7 fix round 1): the gl_compatibility (web) renderer stores MultiMesh COLOR and INSTANCE_CUSTOM as HALF floats (max 65504, 11-bit mantissa), so custom.y holds layer.accent_slot(code) (0–15, slot 0 = no accents / table full) and the shader recovers the code with accent_code_for_slot(custom.y) from the layer’s accent_codes int uniform. Every per-instance value must survive half precision: seeds are integers < 2048 or fractions in [0, 1); WeaponVfx.power is capped at POWER_MAX so an unevolved bolt vfx never rounds up to 1.0. Guard 6 round-trips these through encode_half/decode_half.
Known 2D gotcha: the UV y-axis of a QuadMesh in a MultiMeshInstance2D can come out flipped relative to world +y. Every shader below maps p = (UV - 0.5) * 2.0. If a grid shows a look mirrored (for example the blade sweeping the wrong way), flip p.y in that shader. Don’t touch the transform packing, because the layer test pins it.
Task 1: WeaponVfx — the power/evolution data helper
Section titled “Task 1: WeaponVfx — the power/evolution data helper”Files:
- Create:
sim/weapon_vfx.gd - Test:
tests/test_weapon_vfx.gd
Interfaces:
-
Consumes:
PilotArsenal.weapon_levels: Dictionary,PilotArsenal.thresholds_done: Dictionary(keys"wid:i"),PilotArsenal.weapon_by_id: Dictionary,PilotArsenal.aim: WeaponAim,WeaponThresholds.rules_for(wid) -> Array,weapon.evolved: bool. -
Produces (all
static):WeaponVfx.POWER_BASE := 0.72weapon(arsenal: PilotArsenal, wid: String) -> Objectrungs(arsenal, wid) -> int(bit i set ⇔thresholds_done["wid:i"])invested(arsenal, wid) -> intpower(arsenal, wid) -> floatis_evolved(arsenal, wid) -> boolstamp(ev: Dictionary, arsenal, wid) -> Dictionary(mutates and returnsev; addswid,power,evolved,rungs)bullet_vfx(arsenal, wid) -> float(power + 1.0 if evolved)stamp_bullet(ev: Dictionary, wid: String, vfx_v: float, rungs_v: int) -> Dictionary
-
Step 1: Write the failing test
tests/test_weapon_vfx.gd:
extends GutTest
# Weapon FX v2 (spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md): WeaponVfx is the ONE# place that turns a pilot's investment in a weapon into render inputs. Level-up picks and crystal# rungs BOTH count (weapon_levels alone misses every crystal rung).
func _content() -> ContentDB: return ContentLoader.load_from_path("res://data/bible.json")
func _arsenal() -> PilotArsenal: return PilotArsenal.new(_content())
func test_fresh_weapon_has_zero_power() -> void: var a := _arsenal() assert_eq(WeaponVfx.invested(a, "blade"), 0) assert_almost_eq(WeaponVfx.power(a, "blade"), 0.0, 1e-6) assert_false(WeaponVfx.is_evolved(a, "blade")) assert_eq(WeaponVfx.rungs(a, "blade"), 0)
func test_levels_and_crystal_rungs_both_count() -> void: var a := _arsenal() a.weapon_levels["blade"] = 2 a.thresholds_done["blade:1"] = true assert_eq(WeaponVfx.rungs(a, "blade"), 0b010, "rung 1 only") assert_eq(WeaponVfx.invested(a, "blade"), 3, "2 picks + 1 rung") assert_almost_eq(WeaponVfx.power(a, "blade"), 1.0 - pow(0.72, 3.0), 1e-6)
func test_power_curve_is_monotonic_and_never_reaches_one() -> void: var a := _arsenal() var prev := -1.0 for n in range(0, 25): a.weapon_levels["nova"] = n var p := WeaponVfx.power(a, "nova") assert_gt(p, prev, "strictly increasing at n=%d" % n) assert_lt(p, 1.0, "never reaches 1.0") prev = p
func test_evolved_reads_the_weapon_flag() -> void: var a := _arsenal() a.blade.evolve() assert_true(WeaponVfx.is_evolved(a, "blade"))
func test_aim_resolves_to_the_arsenal_aim_weapon() -> void: var a := _arsenal() assert_same(WeaponVfx.weapon(a, "aim"), a.aim) a.aim.evolve() assert_true(WeaponVfx.is_evolved(a, "aim"))
func test_unknown_weapon_is_zero_not_a_crash() -> void: var a := _arsenal() assert_almost_eq(WeaponVfx.power(a, "no_such_weapon"), 0.0, 1e-6) assert_false(WeaponVfx.is_evolved(a, "no_such_weapon")) assert_eq(WeaponVfx.rungs(a, "no_such_weapon"), 0)
func test_stamp_adds_the_four_fields_in_place() -> void: var a := _arsenal() a.weapon_levels["nova"] = 1 var ev := {"kind": "nova"} var out := WeaponVfx.stamp(ev, a, "nova") assert_true(is_same(out, ev), "stamps in place and returns the same dict") assert_eq(out["wid"], "nova") assert_almost_eq(float(out["power"]), 0.28, 1e-5) assert_eq(out["evolved"], false) assert_eq(out["rungs"], 0)
func test_rungs_are_per_pilot() -> void: var sim := Sim.new(1, _content()) var p2 := sim.add_pilot() p2.arsenal.thresholds_done["orbit:0"] = true assert_eq(WeaponVfx.rungs(p2.arsenal, "orbit"), 1) assert_eq(WeaponVfx.rungs(sim.player.arsenal, "orbit"), 0, "P1 unaffected by P2's rung")
func test_bullet_vfx_encodes_evolved_as_plus_one() -> void: var a := _arsenal() a.weapon_levels["blaster"] = 2 var p := WeaponVfx.power(a, "blaster") assert_almost_eq(WeaponVfx.bullet_vfx(a, "blaster"), p, 1e-6) a.blaster.evolve() assert_almost_eq(WeaponVfx.bullet_vfx(a, "blaster"), p + 1.0, 1e-6)
func test_stamp_bullet_decodes_the_column_values() -> void: var ev := WeaponVfx.stamp_bullet({"kind": "burst"}, "aim", 1.25, 3) assert_eq(ev["wid"], "aim") assert_true(ev["evolved"]) assert_almost_eq(float(ev["power"]), 0.25, 1e-6) assert_eq(ev["rungs"], 3) var ev2 := WeaponVfx.stamp_bullet({"kind": "burst"}, "blaster", 0.4, 0) assert_false(ev2["evolved"]) assert_almost_eq(float(ev2["power"]), 0.4, 1e-6)- Step 2: Run to verify it fails
Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit
Expected: parse error / Identifier "WeaponVfx" not declared.
- Step 3: Implement
sim/weapon_vfx.gd
class_name WeaponVfxextends RefCounted
# Weapon FX v2 (spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md). Turns how far a pilot# has invested in one weapon into render inputs, and stamps them onto that weapon's fx_events.# Pure READ of arsenal state: never writes sim state, never draws an rng, and fx_events are excluded# from state_checksum/snapshot_string -- so nothing here can move the determinism baseline.# invested = level-up picks (weapon_levels) + crystal rungs reached (thresholds_done); weapon_levels# alone misses every crystal rung (they don't increment it).
const POWER_BASE := 0.72 # power = 1 - POWER_BASE^invested: 0 fresh, ~0.5 after 2, ~0.8 after 5, never 1
static func weapon(arsenal: PilotArsenal, wid: String) -> Object: if wid == "aim": return arsenal.aim return arsenal.weapon_by_id.get(wid)
static func rungs(arsenal: PilotArsenal, wid: String) -> int: var mask := 0 var n := WeaponThresholds.rules_for(wid).size() for i in range(n): if arsenal.thresholds_done.has("%s:%d" % [wid, i]): mask |= 1 << i return mask
static func invested(arsenal: PilotArsenal, wid: String) -> int: var m := rungs(arsenal, wid) var bits := 0 while m != 0: bits += m & 1 m >>= 1 return int(arsenal.weapon_levels.get(wid, 0)) + bits
static func power(arsenal: PilotArsenal, wid: String) -> float: return 1.0 - pow(POWER_BASE, float(invested(arsenal, wid)))
static func is_evolved(arsenal: PilotArsenal, wid: String) -> bool: var w := weapon(arsenal, wid) return w != null and bool(w.get("evolved"))
# Stamps ev IN PLACE (so emit sites stay one line) and returns it.static func stamp(ev: Dictionary, arsenal: PilotArsenal, wid: String) -> Dictionary: ev["wid"] = wid ev["power"] = power(arsenal, wid) ev["evolved"] = is_evolved(arsenal, wid) ev["rungs"] = rungs(arsenal, wid) return ev
# Per-bullet encoding for ProjPool.vfx: power in [0,1), +1.0 when the firing weapon is evolved.static func bullet_vfx(arsenal: PilotArsenal, wid: String) -> float: return power(arsenal, wid) + (1.0 if is_evolved(arsenal, wid) else 0.0)
# Stamps an event raised by a BULLET (burst splash, impact) from its ProjPool columns -- by the time# it hits, the firing pilot isn't known, so the values were captured at add_proj.static func stamp_bullet(ev: Dictionary, wid: String, vfx_v: float, rungs_v: int) -> Dictionary: var evolved := vfx_v >= 1.0 ev["wid"] = wid ev["power"] = vfx_v - 1.0 if evolved else vfx_v ev["evolved"] = evolved ev["rungs"] = rungs_v return ev- Step 4: Run to verify it passes
Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexit
Expected: all tests in test_weapon_vfx.gd pass. If test_aim_resolves... fails because WeaponAim.evolve() takes arguments, read sim/weapon_aim.gd, call it with its real signature, and note the change in the commit body.
- Step 5: Commit
git add sim/weapon_vfx.gd sim/weapon_vfx.gd.uid tests/test_weapon_vfx.gd tests/test_weapon_vfx.gd.uidprintf '%s\n' "feat(fx-v2): WeaponVfx power/evolution helper" "" \ "invested = level-up picks + crystal rungs; power = 1 - 0.72^invested. Pure read, render-only." "" \ "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" \ "Claude-Session: https://claude.ai/code/session_01FSXXmmsKp5hKdWgW9LjD34" > /tmp/msg.txtgit commit -F /tmp/msg.txt(Use the session scratchpad instead of /tmp if one is available. The same commit recipe applies to every later task; only the title and body change.)
Task 2: ProjPool render-only columns + bullets carry their weapon’s power
Section titled “Task 2: ProjPool render-only columns + bullets carry their weapon’s power”Files:
- Modify:
sim/proj_pool.gd(whole file, shown below) - Modify:
sim/weapon_blaster.gd:43-45,sim/weapon_scatter.gd:51-53,sim/weapon_momentum_core.gd:141-143,sim/weapon_turret.gd:61-63,sim/sim.gd:2416-2418(_fire_aim),sim/elemental_system.gd:156-158and:159-179(spawn_split) - Test:
tests/test_proj_pool_vfx.gd
Interfaces:
-
Consumes:
WeaponVfx.bullet_vfx(arsenal, wid) -> float,WeaponVfx.rungs(arsenal, wid) -> int(Task 1). -
Produces:
ProjPool.vfx: PackedFloat32Array,ProjPool.rungs: PackedByteArray,ProjPool.kind: PackedByteArrayProjPool.KIND_OTHER=0, KIND_BLASTER=1, KIND_SCATTER=2, KIND_TURRET=3, KIND_MOMENTUM=4, KIND_AIM=5static ProjPool.kind_for(ctx: String) -> intadd_proj(..., ctx := "unattributed", vfx_v := 0.0, rungs_v := 0) -> int(two new trailing params)ElementalSystem.spawn_split(sim, pos, vel, radius, dmg, el, count, hit_enemy_radius := 0.0, vfx_v := 0.0, rungs_v := 0)
-
Step 1: Write the failing test
tests/test_proj_pool_vfx.gd:
extends GutTest
# Weapon FX v2: player bullets carry render-only vfx/rungs/kind columns (never checksummed) so the# PlayerBoltRenderer can grow/transform each bullet by its OWN weapon's investment.
func _content() -> ContentDB: return ContentLoader.load_from_path("res://data/bible.json")
func test_add_proj_defaults_are_zero() -> void: var pool := ProjPool.new(8) var i := pool.add_proj(Vector2.ZERO, Vector2.RIGHT, 4.0, 1.0, 10.0) assert_almost_eq(pool.vfx[i], 0.0, 1e-6) assert_eq(pool.rungs[i], 0) assert_eq(pool.kind[i], ProjPool.KIND_OTHER)
func test_kind_is_derived_from_context() -> void: assert_eq(ProjPool.kind_for("blaster"), ProjPool.KIND_BLASTER) assert_eq(ProjPool.kind_for("scatter"), ProjPool.KIND_SCATTER) assert_eq(ProjPool.kind_for("turret"), ProjPool.KIND_TURRET) assert_eq(ProjPool.kind_for("momentum_core"), ProjPool.KIND_MOMENTUM) assert_eq(ProjPool.kind_for("aim"), ProjPool.KIND_AIM) assert_eq(ProjPool.kind_for("unattributed"), ProjPool.KIND_OTHER)
func test_swap_remove_keeps_columns_aligned() -> void: var pool := ProjPool.new(8) pool.add_proj(Vector2(1, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "blaster", 0.3, 1) pool.add_proj(Vector2(2, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "scatter", 0.6, 2) pool.add_proj(Vector2(3, 0), Vector2.RIGHT, 4.0, 1.0, 1.0, -1, 0, 0, 0.0, 0.0, "aim", 1.5, 3) pool.remove_at(0) # last (aim) swaps into slot 0 assert_eq(pool.pos[0], Vector2(3, 0)) assert_almost_eq(pool.vfx[0], 1.5, 1e-6) assert_eq(pool.rungs[0], 3) assert_eq(pool.kind[0], ProjPool.KIND_AIM) assert_almost_eq(pool.vfx[1], 0.6, 1e-6) assert_eq(pool.kind[1], ProjPool.KIND_SCATTER)
func test_blaster_bullets_carry_the_blaster_investment() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.player.arsenal.weapon_levels["blaster"] = 3 sim.player.arsenal.blaster.evolve() sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 1000.0) sim.damage_context = "blaster" for _i in range(120): sim.player.arsenal.blaster.update(sim, sim.player, Sim_Const.DT) if sim.projectiles.count > 0: break assert_gt(sim.projectiles.count, 0, "blaster fired") var want := WeaponVfx.bullet_vfx(sim.player.arsenal, "blaster") assert_gt(want, 1.0, "evolved -> +1 encoding") assert_almost_eq(sim.projectiles.vfx[0], want, 1e-5) assert_eq(sim.projectiles.kind[0], ProjPool.KIND_BLASTER)
func test_split_children_inherit_vfx_and_rungs() -> void: var sim := Sim.new(1, _content()) sim.damage_context = "blaster" sim.elemental_system.spawn_split(sim, Vector2.ZERO, Vector2(300, 0), 4.0, 5.0, -1, 3, 10.0, 1.4, 2) assert_eq(sim.projectiles.count, 3) for i in range(3): assert_almost_eq(sim.projectiles.vfx[i], 1.4, 1e-6) assert_eq(sim.projectiles.rungs[i], 2) assert_eq(sim.projectiles.kind[i], ProjPool.KIND_BLASTER)- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_proj_pool_vfx -gexit
Expected: FAIL. vfx/kind are not members of ProjPool, and add_proj gets too many arguments.
- Step 3: Implement the columns in
sim/proj_pool.gd
Add the fields after var context: PackedStringArray:
# Weapon FX v2 -- RENDER-ONLY (never read into state_checksum/snapshot_string, never read by any sim# logic). vfx = the firing weapon's power (+1.0 if evolved), rungs = its crystal-rung bitmask, kind =# which bullet look to draw (derived from ctx). Captured at add_proj because by impact time the firing# pilot isn't known.const KIND_OTHER := 0const KIND_BLASTER := 1const KIND_SCATTER := 2const KIND_TURRET := 3const KIND_MOMENTUM := 4const KIND_AIM := 5var vfx: PackedFloat32Arrayvar rungs: PackedByteArrayvar kind: PackedByteArray
static func kind_for(ctx: String) -> int: match ctx: "blaster": return KIND_BLASTER "scatter": return KIND_SCATTER "turret": return KIND_TURRET "momentum_core": return KIND_MOMENTUM "aim": return KIND_AIM return KIND_OTHERIn _init, after context.resize(cap):
vfx.resize(cap) rungs.resize(cap) kind.resize(cap)Replace add_proj with:
func add_proj(p: Vector2, v: Vector2, r: float, lifetime: float, dmg: float, el: int = -1, pierce_v: int = 0, split_v: int = 0, knockback_v: float = 0.0, burst_radius_v: float = 0.0, ctx: String = "unattributed", vfx_v: float = 0.0, rungs_v: int = 0) -> int: var i := super.add(p, v, r, lifetime) if i != -1: damage[i] = dmg element_idx[i] = el pierce[i] = pierce_v split[i] = split_v knockback[i] = knockback_v burst_radius[i] = burst_radius_v context[i] = ctx vfx[i] = vfx_v rungs[i] = rungs_v kind[i] = kind_for(ctx) return iIn remove_at, inside if i != last: after context[i] = context[last]:
vfx[i] = vfx[last] rungs[i] = rungs[last] kind[i] = kind[last]- Step 4: Pass vfx/rungs at the four weapon call sites and the aim shot
Each site appends two arguments after sim.damage_context (or damage_context in sim.gd). The rest of each call is unchanged. The wid in each row below was checked against the file name and damage_context (sim.gd sets damage_context = wid around each weapon_by_id[wid].update).
| File:line | Append after the ctx argument |
|---|---|
sim/weapon_blaster.gd:45 |
, WeaponVfx.bullet_vfx(pilot.arsenal, "blaster"), WeaponVfx.rungs(pilot.arsenal, "blaster") |
sim/weapon_scatter.gd:53 |
, WeaponVfx.bullet_vfx(pilot.arsenal, "scatter"), WeaponVfx.rungs(pilot.arsenal, "scatter") |
sim/weapon_momentum_core.gd:143 |
, WeaponVfx.bullet_vfx(pilot.arsenal, "momentum_core"), WeaponVfx.rungs(pilot.arsenal, "momentum_core") |
sim/weapon_turret.gd:63 |
, WeaponVfx.bullet_vfx(pilot.arsenal, "turret"), WeaponVfx.rungs(pilot.arsenal, "turret") |
sim/sim.gd:2418 (_fire_aim) |
, WeaponVfx.bullet_vfx(pilot.arsenal, "aim"), WeaponVfx.rungs(pilot.arsenal, "aim") |
Hoist the two values out of the per-shot loop in blaster, scatter and momentum_core; the investment doesn’t change mid-volley. Blaster example:
var bvfx := WeaponVfx.bullet_vfx(pilot.arsenal, "blaster") var brungs := WeaponVfx.rungs(pilot.arsenal, "blaster") for k in range(n): var off: float = 0.0 if n == 1 else (float(k) - float(n - 1) * 0.5) * SPREAD var a := base_a + off sim.projectiles.add_proj(pilot.pos, Vector2(cos(a), sin(a)) * proj_speed, proj_radius, proj_lifetime, PlayerCrit.roll(sim, pilot, dmg), element_idx, sim.mods.projectile_pierce, sim.mods.projectile_split, 0.0, 0.0, sim.damage_context, bvfx, brungs)Scatter and momentum_core follow the same shape with their own wid literal. The single-shot turret and aim sites call the helpers inline.
- Step 5: Splits inherit
sim/elemental_system.gd. Change the spawn_split signature and its add_proj call:
func spawn_split(sim: Sim, pos: Vector2, vel: Vector2, radius: float, dmg: float, el: int, count: int, hit_enemy_radius: float = 0.0, vfx_v: float = 0.0, rungs_v: int = 0) -> void: sim.projectiles.add_proj(spawn_pos, dir * spd, radius, SPLIT_LIFETIME, dmg, el, 1, 0, 0.0, 0.0, sim.damage_context, vfx_v, rungs_v) # children pierce once (no re-split); inherit the parent's context + lookAt the call in resolve_collisions (currently lines 156–158):
spawn_split(sim, sim.projectiles.pos[pi], sim.projectiles.vel[pi], sim.projectiles.radius[pi], dmg * frac, el, spl, sim.enemies.radius[hit_ei], sim.projectiles.vfx[pi], sim.projectiles.rungs[pi])- Step 6: Run the new tests, the pool/weapon suites and determinism
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_proj_pool_vfx -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_blaster -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexitExpected: all pass. The test_determinism* scripts pass unchanged. If a hash moves, stop. The only legal cause would be a sim read of the new columns, and there must be none (/usr/bin/grep -n "\.vfx\[\|\.rungs\[\|\.kind\[" sim/ should show only proj_pool.gd and elemental_system.gd split/stamp reads).
- Step 7: Commit —
feat(fx-v2): player bullets carry render-only vfx/rungs/kind; splits inherit
Task 3: Stamp every weapon event + the structural guard tests
Section titled “Task 3: Stamp every weapon event + the structural guard tests”Files:
- Modify (one line each, exact new lines below):
sim/weapon_beam.gd:47,sim/weapon_blaster.gd:46,sim/weapon_chalice.gd:31,sim/weapon_chronowell.gd:98,sim/weapon_melee.gd:48,sim/weapon_momentum_core.gd:146,sim/weapon_nova.gd:27,sim/weapon_pulse.gd:50,sim/weapon_railgun.gd:84,sim/weapon_scar_tissue.gd:63,84,sim/weapon_scatter.gd:54,sim/weapon_scythe.gd:51,sim/weapon_thunder_wake.gd:126,sim/weapon_turret.gd:64,sim/weapon_wildfire.gd:47,55,62-66,96,114,122-129,sim/sim.gd:2419,sim/elemental_system.gd:106-127 - Test:
tests/test_weapon_fx_guards.gd(guards 1–4; guards 5–6 are added in Tasks 4–5)
Interfaces:
-
Consumes:
WeaponVfx.stamp,WeaponVfx.stamp_bullet(Task 1);ProjPool.vfx/rungs/kind/KIND_BLASTER(Task 2). -
Produces:
- Every weapon fx_event carries
wid: String,power: float,evolved: bool,rungs: int. blaster_firegainsdir: Vector2andelement: int.- New render-only event
{"kind": "bolt_impact", "pos", "dir", "element", + stamp}, emitted when an evolved blaster bullet hits. WeaponWildfire._ignite(sim, pilot, ei, depth)and_step_burn_zones(sim, pilot, dt)(pilot threaded through).
- Every weapon fx_event carries
-
Step 1: Write the failing guard tests
tests/test_weapon_fx_guards.gd:
extends GutTest
# Weapon FX v2 STRUCTURAL GUARDS (memory bullet-heaven-weapon-fx-v2). These exist so a weapon added# later via skill bh-add-content FAILS THE GATE if its effect is invisible or doesn't scale:# 1. every weapon fx_event is stamped (source scan: one-line appends only, by design);# 2. the batch-1 weapons' real events carry the stamp at runtime;# 3. evolving ANY weapon changes its stamp (so its event differs from the unevolved one);# 4. every weapon-local zone array has a renderer that reads it (or is a known, queued bug);# 5. (Task 4) every weapon id has a v2 look or is explicitly pending with a batch;# 6. (Task 5) the accent-motif ids agree between the shader include and its GDScript twin.
const EXCLUDED_FILES := ["weapon_vfx.gd", "weapon_thresholds.gd", "weapon_evolutions.gd"]const WID_FOR_FILE := {"weapon_melee.gd": "blade"} # everything else: strip "weapon_" and ".gd"
# Weapon-local zone arrays that are drawn through an accessor rather than by name.const RENDER_HOOKS := {"weapon_turret.gd:_turrets": "turret_positions"}# Known-invisible zone arrays = spec bugs 3/4, fixed by batch 3. Listed here so the guard stays# green until then -- and fails the moment a NEW invisible array appears.const KNOWN_INVISIBLE := { "weapon_wildfire.gd:_burn_zones": "batch 3 (spec bug 3)", "weapon_thunder_wake.gd:_zones": "batch 3 (spec bug 4)",}
func _content() -> ContentDB: return ContentLoader.load_from_path("res://data/bible.json")
func _read(path: String) -> String: return FileAccess.get_file_as_string(path)
func _weapon_files() -> Array[String]: var out: Array[String] = [] for f in DirAccess.get_files_at("res://sim"): if f.begins_with("weapon_") and f.ends_with(".gd") and not EXCLUDED_FILES.has(f): out.append(f) return out
func _wid_for(file: String) -> String: return WID_FOR_FILE.get(file, file.trim_prefix("weapon_").trim_suffix(".gd"))
# ── Guard 1: every fx_events.append in a weapon file is stamped with its own wid ──func test_every_weapon_fx_event_is_stamped_with_its_own_wid() -> void: var checked := 0 for f in _weapon_files(): var wid := _wid_for(f) var lines := _read("res://sim/" + f).split("\n") for n in range(lines.size()): var line: String = lines[n] if not line.contains("fx_events.append("): continue checked += 1 assert_true(line.contains("WeaponVfx.stamp("), "%s:%d fx_event must be stamped: sim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, \"%s\")) -- on ONE line" % [f, n + 1, wid]) assert_true(line.contains("\"%s\")" % wid), "%s:%d stamp must name this file's weapon id \"%s\"" % [f, n + 1, wid]) assert_gt(checked, 15, "scanned the real emit sites (sanity: the scan isn't silently empty)")
func test_aim_and_bullet_events_are_stamped() -> void: for line in _read("res://sim/sim.gd").split("\n"): if line.contains("\"kind\": \"aim_fire\""): assert_true(line.contains("WeaponVfx.stamp(") and line.contains("\"aim\")"), "aim_fire stamped as aim") var es := _read("res://sim/elemental_system.gd") var found := 0 for line in es.split("\n"): if line.contains("\"kind\": \"burst\"") or line.contains("\"kind\": \"bolt_impact\""): found += 1 assert_true(line.contains("WeaponVfx.stamp_bullet("), "bullet-raised events stamped from ProjPool columns: " + line.strip_edges()) assert_eq(found, 2, "burst + bolt_impact emit sites present")
# ── Guard 2: batch-1 weapons' REAL events carry the stamp ──func _fire_until(sim: Sim, w: Object, kind: String) -> Dictionary: for _i in range(900): sim.fx_events.clear() w.call("update", sim, sim.player, Sim_Const.DT) for ev in sim.fx_events: if String(ev.get("kind", "")) == kind: return ev return {}
func _sim_with_target() -> Sim: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 14.0, 1.0e9) return sim
func _assert_stamped(ev: Dictionary, wid: String) -> void: assert_false(ev.is_empty(), wid + " emitted its event") for k in ["wid", "power", "evolved", "rungs"]: assert_true(ev.has(k), "%s event has %s" % [wid, k]) assert_eq(ev.get("wid"), wid)
func test_batch1_events_carry_the_stamp() -> void: var cases := {"blade": "slash", "scythe": "slash", "nova": "nova", "blaster": "blaster_fire"} for wid in cases: var sim := _sim_with_target() var w: Object = sim.player.arsenal.weapon_by_id.get(wid) assert_not_null(w, wid + " exists in bible content") sim.player.arsenal.weapon_levels[wid] = 2 var ev := _fire_until(sim, w, cases[wid]) _assert_stamped(ev, wid) assert_almost_eq(float(ev.get("power", -1.0)), 1.0 - pow(0.72, 2.0), 1e-5, wid + " power reflects investment")
func test_scar_tissue_discharge_nova_is_stamped_as_scar_tissue() -> void: var sim := _sim_with_target() var st: WeaponScarTissue = sim.player.arsenal.scar_tissue assert_not_null(st) var ev := {} for _i in range(60): sim.fx_events.clear() st.on_player_hit(sim, sim.player, 5.0) for e in sim.fx_events: if String(e.get("kind", "")) == "nova": ev = e if not ev.is_empty(): break _assert_stamped(ev, "scar_tissue")
func test_aim_fire_is_stamped() -> void: var sim := _sim_with_target() var inp := InputState.new() inp.aim_dir = Vector2.RIGHT sim.fx_events.clear() sim._fire_aim(sim.player, inp, Sim_Const.DT) var ev := {} for e in sim.fx_events: if String(e.get("kind", "")) == "aim_fire": ev = e _assert_stamped(ev, "aim")
# ── Guard 3: evolving ANY weapon changes its stamp ──func test_every_weapon_stamp_differs_once_evolved() -> void: var a := PilotArsenal.new(_content()) var wids: Array = a.weapon_by_id.keys() wids.append("aim") var checked := 0 for wid in wids: var w: Object = WeaponVfx.weapon(a, wid) if w == null: continue checked += 1 var before := WeaponVfx.stamp({"kind": "probe"}, a, wid) w.call("evolve") var after := WeaponVfx.stamp({"kind": "probe"}, a, wid) assert_false(before["evolved"], wid + " starts unevolved") assert_true(after["evolved"], wid + ".evolve() flips the stamped flag") assert_ne(before, after, wid + ": an evolved weapon's event differs from an unevolved one") assert_gt(checked, 12, "covered the real roster")
# ── Guard 4: weapon-local zone arrays must have a renderer ──func _render_sources() -> String: var src := _read("res://main.gd") for dir in ["res://render", "res://fx", "res://fx/weapons"]: if not DirAccess.dir_exists_absolute(dir): continue for f in DirAccess.get_files_at(dir): if f.ends_with(".gd"): src += "\n" + _read(dir + "/" + f) return src
func test_every_weapon_zone_array_has_a_renderer() -> void: var re := RegEx.new() re.compile("(?m)^var\\s+(\\w+)\\s*:\\s*Array\\[Dictionary\\]") var render := _render_sources() var seen := {} for f in _weapon_files(): for m in re.search_all(_read("res://sim/" + f)): var key := "%s:%s" % [f, m.get_string(1)] seen[key] = true if KNOWN_INVISIBLE.has(key): continue var hook: String = RENDER_HOOKS.get(key, m.get_string(1)) var word := RegEx.new() word.compile("\\b" + hook + "\\b") assert_not_null(word.search(render), "%s is a weapon-local zone array nothing renders -- draw it (render/, fx/ or main.gd) or it's an invisible mechanic (spec bugs 3/4)" % key) for key in KNOWN_INVISIBLE: assert_true(seen.has(key), "%s no longer exists -- remove it from KNOWN_INVISIBLE" % key)- Step 2: Run to verify the guards fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexit
Expected: the guard 1, 2 and aim/bullet tests FAIL with “must be stamped”. Guards 3 and 4 already pass: guard 3 exercises WeaponVfx directly, and guard 4 sees turret wired plus the two known-invisible arrays. That is correct; they are regression guards.
- Step 3: Stamp every emit (exact replacement lines)
Replace each line. Indentation is unchanged, and so is every key already on the line; only the WeaponVfx.stamp( wrapper is new. The one exception is blaster_fire, which also gains dir and element.
# sim/weapon_beam.gd:47 sim.fx_events.append(WeaponVfx.stamp({"kind": "beam", "pos": pilot.pos, "dir": dir, "length": beam_range, "width": width, "element": pilot.arsenal.beam_element_idx}, pilot.arsenal, "beam"))# sim/weapon_blaster.gd:46 sim.fx_events.append(WeaponVfx.stamp({"kind": "blaster_fire", "pos": pilot.pos, "dir": Vector2(cos(base_a), sin(base_a)), "element": element_idx}, pilot.arsenal, "blaster")) # muzzle flash (v2) + audio cue; render channel, not in checksums# sim/weapon_chalice.gd:31 sim.fx_events.append(WeaponVfx.stamp({"kind": "chalice_cast", "pos": pilot.pos, "radius": radius, "element": pilot.arsenal.chalice_element_idx}, pilot.arsenal, "chalice"))# sim/weapon_chronowell.gd:98 sim.fx_events.append(WeaponVfx.stamp({"kind": "chronowell_deploy", "pos": center, "radius": BUBBLE_RADIUS}, pilot.arsenal, "chronowell"))# sim/weapon_melee.gd:48 sim.fx_events.append(WeaponVfx.stamp({"kind": "slash", "pos": pilot.pos, "dir": dir, "range": range, "arc": arc_deg, "element": pilot.arsenal.blade_element_idx}, pilot.arsenal, "blade"))# sim/weapon_momentum_core.gd:146 sim.fx_events.append(WeaponVfx.stamp({"kind": "momentum_charged_fire", "pos": pilot.pos, "dir": base_a}, pilot.arsenal, "momentum_core"))# sim/weapon_nova.gd:27 sim.fx_events.append(WeaponVfx.stamp({"kind": "nova", "pos": pilot.pos, "radius": area, "element": pilot.arsenal.nova_element_idx}, pilot.arsenal, "nova"))# sim/weapon_pulse.gd:50 sim.fx_events.append(WeaponVfx.stamp({"kind": "bolt", "from": from, "to": tpos, "element": pilot.arsenal.pulse_element_idx}, pilot.arsenal, "pulse"))# sim/weapon_railgun.gd:84 sim.fx_events.append(WeaponVfx.stamp({"kind": "railgun_lance", "pos": pilot.pos, "dir": dir, "length": lance_range, "width": width, "element": element_idx}, pilot.arsenal, "railgun"))# sim/weapon_scar_tissue.gd:63 sim.fx_events.append(WeaponVfx.stamp({"kind": "scar_tissue_stack", "pos": pilot.pos, "element": element_idx}, pilot.arsenal, "scar_tissue"))# sim/weapon_scar_tissue.gd:84 sim.fx_events.append(WeaponVfx.stamp({"kind": "nova", "pos": pilot.pos, "radius": radius, "element": element_idx}, pilot.arsenal, "scar_tissue"))# sim/weapon_scatter.gd:54 sim.fx_events.append(WeaponVfx.stamp({"kind": "scatter_fire", "pos": pilot.pos, "dir": base}, pilot.arsenal, "scatter"))# sim/weapon_scythe.gd:51 sim.fx_events.append(WeaponVfx.stamp({"kind": "slash", "pos": pilot.pos, "dir": dir, "range": range, "arc": arc_deg, "element": pilot.arsenal.scythe_element_idx}, pilot.arsenal, "scythe"))# sim/weapon_thunder_wake.gd:126 sim.fx_events.append(WeaponVfx.stamp({"kind": "thunder_wake_strike", "pos": pos, "from": pilot.pos, "radius": strike_radius, "element": element_idx}, pilot.arsenal, "thunder_wake"))# sim/weapon_turret.gd:64 sim.fx_events.append(WeaponVfx.stamp({"kind": "turret_fire", "pos": from_pos}, pilot.arsenal, "turret"))# sim/weapon_wildfire.gd:96 sim.fx_events.append(WeaponVfx.stamp({"kind": "wildfire_pop", "pos": pos, "radius": hop_radius}, pilot.arsenal, "wildfire"))# sim/sim.gd:2419 fx_events.append(WeaponVfx.stamp({"kind": "aim_fire", "pos": pilot.pos}, pilot.arsenal, "aim")) # audio-only cue (render channel, not in checksums)base_a is already in scope at weapon_blaster.gd:46: it is declared at line 37, above the loop.
- Step 4: Thread
pilotthrough Wildfire soignitecan be stamped
sim/weapon_wildfire.gd. _ignite and _step_burn_zones currently have no pilot. Change them as follows (no behaviour change; the new param is only read by the stamp):
# update(), line 47: _step_burn_zones(sim, pilot, dt)# update(), line 55: _ignite(sim, pilot, ei, 1)# signature, line 62, and its emit, line 66:func _ignite(sim: Sim, pilot: PlayerState, ei: int, depth: int) -> void: ... sim.fx_events.append(WeaponVfx.stamp({"kind": "ignite", "pos": sim.enemies.pos[ei], "element": element_idx}, pilot.arsenal, "wildfire"))# _detonate() (has pilot), line 114: _ignite(sim, pilot, hi, depth + 1)# _step_burn_zones signature, line 122, and its call, line 129:func _step_burn_zones(sim: Sim, pilot: PlayerState, dt: float) -> void: ... _ignite(sim, pilot, ei, FIRESTORM_CHAIN_LEN) # keeps re-triggering the persistent-zone behaviorVerify there are no other callers: /usr/bin/grep -rn "_ignite(\|_step_burn_zones(" sim tests tools should list only the lines above.
- Step 5: Stamp the bullet-raised events + add
bolt_impact
sim/elemental_system.gd, in resolve_collisions. Right after apply_element(sim, hit_ei, el):
# Weapon FX v2 (render-only, excluded from checksums like every fx_event): an evolved # Blaster (Auto-Cannon) bullet throws impact sparks. Read before any remove_at repoints [pi]. var pvfx: float = sim.projectiles.vfx[pi] if pvfx >= 1.0 and sim.projectiles.kind[pi] == ProjPool.KIND_BLASTER: sim.fx_events.append(WeaponVfx.stamp_bullet({"kind": "bolt_impact", "pos": ppos, "dir": sim.projectiles.vel[pi].normalized(), "element": el}, sim.projectiles.context[pi], pvfx, sim.projectiles.rungs[pi]))Replace the burst line (currently 127):
sim.fx_events.append(WeaponVfx.stamp_bullet({"kind": "burst", "pos": ppos, "radius": br, "element": el}, sim.projectiles.context[pi], sim.projectiles.vfx[pi], sim.projectiles.rungs[pi]))- Step 6: Run the guards, the touched weapon suites and determinism
Run:
godot --headless --path . --importgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexitfor t in test_weapon_blaster test_weapon_melee test_weapon_scythe test_weapon_nova test_weapon_orbit; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=$t -gexit 2>&1 | grep -E "Passing|Failing"; done/usr/bin/grep -rln "wildfire\|scar_tissue\|thunder_wake" tests/*.gd | xargs -n1 basename | sed 's/\.gd$//' | while read t; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=$t -gexit 2>&1 | grep -E "Failing Tests"; donegodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism -gexitExpected: all pass, with determinism unchanged. The audio manager keys only on kind, so the extra keys are inert. grep -n "blaster_fire\|slash" audio/audio_manager.gd confirms it doesn’t compare whole dicts.
- Step 7: Commit —
feat(fx-v2): stamp every weapon fx_event + structural guard tests
Task 4: The v2 switch, quality tier, Debug row, FxManager dispatch scaffolding + look-registry guard
Section titled “Task 4: The v2 switch, quality tier, Debug row, FxManager dispatch scaffolding + look-registry guard”Files:
- Modify:
render/visual_look.gd(append a static var) - Modify:
render/quality_manager.gd:125-137(tier func),:151-157(_apply) - Modify:
ui/debug_settings_panel.gd:148-149,:251,:321 - Modify:
fx/fx_manager.gd(new consts/vars/funcs, listed below) - Modify:
tests/test_fx_manager.gd:50(child-count assertion) - Test:
tests/test_weapon_fx_guards.gd(guard 5),tests/test_weapon_fx_looks.gd(new)
Interfaces:
-
Consumes: nothing new.
-
Produces:
VisualLook.weapon_fx_v2: bool(default false while batch 1 is in development; Task 16 flips it)static QualityManager.weapon_fx_v2_on(l: int) -> bool(l <= 2)FxManager.V2_LOOKS: Dictionary(wid → look class name; starts{})FxManager.v2_quality_ok: bool,FxManager.v2_active() -> bool,FxManager.v2_layer_count() -> intFxManager._add_v2_layer(layer: Node, wids: Array) -> void(later tasks register their looks through it, per wid)FxManager.v2_look(wid: String)(untyped return: the look layer or null; callers invoke itsspawn_event())- Debug toggle id
"weapon_fx_v2"
-
Step 1: Write the failing tests
Append to tests/test_weapon_fx_guards.gd:
# ── Guard 5: every weapon id has a v2 look, or is explicitly pending with the batch that owns it ──# When a batch lands a look: add the wid to FxManager.V2_LOOKS and DELETE it here. A new weapon added# via bh-add-content must be added to one or the other -- the test fails until it is.const PENDING_V2_LOOKS := { "blaster": "batch 1 (this plan, Task 13)", "orbit": "batch 1 (this plan, Task 12)", "blade": "batch 1 (this plan, Task 9)", "scythe": "batch 1 (this plan, Task 10)", "nova": "batch 1 (this plan, Task 11)", "beam": "batch 2", "railgun": "batch 2", "pulse": "batch 3", "thunder_wake": "batch 3", "wildfire": "batch 3", "turret": "batch 4", "scatter": "batch 4", "momentum_core": "batch 4", "aim": "batch 4", "chalice": "batch 5", "chronowell": "batch 5", "scar_tissue": "batch 5",}
func test_every_weapon_has_a_v2_look_or_is_pending() -> void: var a := PilotArsenal.new(_content()) var wids: Array = a.weapon_by_id.keys() wids.append("aim") for wid in wids: var has_look := FxManager.V2_LOOKS.has(wid) var pending := PENDING_V2_LOOKS.has(wid) assert_true(has_look or pending, "%s has no v2 look and isn't listed as pending -- add it to FxManager.V2_LOOKS or PENDING_V2_LOOKS" % wid) assert_false(has_look and pending, "%s has a v2 look now -- remove it from PENDING_V2_LOOKS" % wid) for wid in PENDING_V2_LOOKS: assert_true(wids.has(wid), "%s is pending but no longer a weapon -- remove it" % wid)Create tests/test_weapon_fx_looks.gd:
extends GutTest
# Weapon FX v2 render side: the A/B switch, the quality fallback, and (Tasks 9-13) each batch-1 look.# Drives the REAL FxManager.consume() with the real stamped event shapes the sim emits.
var _was_v2 := falsevar _was_plasma := true
func before_each() -> void: _was_v2 = VisualLook.weapon_fx_v2 _was_plasma = VisualLook.plasma_nova
func after_each() -> void: VisualLook.weapon_fx_v2 = _was_v2 VisualLook.plasma_nova = _was_plasma
func _make() -> FxManager: var fx := FxManager.new() fx.setup(SimContentFixture.db()) add_child_autofree(fx) return fx
func test_v2_active_needs_switch_and_quality() -> void: var fx := _make() VisualLook.weapon_fx_v2 = true fx.v2_quality_ok = true assert_true(fx.v2_active()) fx.v2_quality_ok = false assert_false(fx.v2_active(), "L3+ falls back to legacy looks") VisualLook.weapon_fx_v2 = false fx.v2_quality_ok = true assert_false(fx.v2_active(), "switch off = legacy")
func test_quality_tier_boundary() -> void: assert_true(QualityManager.weapon_fx_v2_on(0)) assert_true(QualityManager.weapon_fx_v2_on(2)) assert_false(QualityManager.weapon_fx_v2_on(3))
func test_quality_manager_pushes_the_tier_to_fx() -> void: var fx := _make() var qm := QualityManager.new() autofree(qm) qm.bind(null, [], fx, null, null, null, null) qm.auto = false qm.set_level(3) assert_false(fx.v2_quality_ok, "L3 -> legacy") qm.set_level(1) assert_true(fx.v2_quality_ok, "L1 -> v2")
func test_debug_row_flips_the_switch() -> void: var qm := QualityManager.new() autofree(qm) VisualLook.weapon_fx_v2 = false DebugSettingsPanel.apply_graphics_toggle("weapon_fx_v2", true, qm) assert_true(VisualLook.weapon_fx_v2) DebugSettingsPanel.apply_graphics_toggle("weapon_fx_v2", false, qm) assert_false(VisualLook.weapon_fx_v2)Before writing test_quality_manager_pushes_the_tier_to_fx, run /usr/bin/grep -n "func bind" -A4 render/quality_manager.gd and pass exactly as many nulls as bind has parameters after p_fx. The main.gd call at line 1284 has 7 arguments. If bind reads a member of a null argument, pass Node.new() placeholders via autofree() instead. Also check set_level’s real name and signature in the same file.
- Step 2: Run to verify failures
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_looks -gexit (and -gselect=test_weapon_fx_guards).
Expected: FAIL because weapon_fx_v2 / v2_active / V2_LOOKS / weapon_fx_v2_on don’t exist yet.
- Step 3: Implement
render/visual_look.gd, appended after plasma_nova:
# Weapon FX v2 (docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md): per-weapon looks that grow# with investment, transform on evolution and show the crystals that upgraded them. true = FxManager /# main.gd draw the v2 looks for every wid in FxManager.V2_LOOKS (quality L0-L2 only; L3+ always falls# back to legacy); false = the legacy looks exactly as before. Read every frame / at spawn time, so the# Debug Settings "Weapon FX v2 (A/B)" row shows on the next shot. Default false until Chris signs off# batch 1 (plan Task 16 flips it and folds plasma_nova into it).static var weapon_fx_v2 := falserender/quality_manager.gd, after juice_on:
static func weapon_fx_v2_on(l: int) -> bool: return l <= 2 # per-weapon v2 looks; L3+ = legacy looks (cheaper)In _apply(), inside if is_instance_valid(fx_layer): after the reaction_cap line:
fx_layer.v2_quality_ok = weapon_fx_v2_on(level) # weapon FX v2 at L0-L2 onlyui/debug_settings_panel.gd. Add a match arm after "plasma_nova"::
"weapon_fx_v2": VisualLook.weapon_fx_v2 = enabled # Weapon FX v2 looks vs legacy; shows on the next shotAdd after line 251:
_graphics_state["weapon_fx_v2"] = VisualLook.weapon_fx_v2Add after line 321:
_rows.append(_graphics_row("Weapon FX v2 (A/B)", "weapon_fx_v2"))fx/fx_manager.gd. Add after const PLASMA_NOVA_SHADER_PATH:
# Weapon FX v2: wid -> the look class that draws it when v2_active(). A wid NOT listed here keeps its# legacy look even with v2 on. tests/test_weapon_fx_guards.gd requires every weapon id to be here or# in its PENDING_V2_LOOKS (with the batch that owns it).const V2_LOOKS := {}After var reaction_cap := REACTION_CAP:
var v2_quality_ok := true # QualityManager: weapon_fx_v2_on(level) -- false at L3+var _v2_layers: Array[Node] = []
func v2_active() -> bool: return VisualLook.weapon_fx_v2 and v2_quality_ok
func v2_layer_count() -> int: return _v2_layers.size()
var _v2_by_wid := {} # wid -> the look layer drawing it (event looks FxManager owns)
func _add_v2_layer(layer: Node, wids: Array) -> void: add_child(layer) _v2_layers.append(layer) for w in wids: _v2_by_wid[w] = layer
func v2_look(wid: String): # untyped on purpose: callers invoke the look's spawn_event()/spawn_impact() return _v2_by_wid.get(wid)At the end of advance(dt):
# Weapon FX v2 event looks (persistent looks live in main.gd and sync themselves) for layer in _v2_layers: layer.advance(dt)tests/test_fx_manager.gd:50. Append + fx.v2_layer_count() to the expected child count, before the message string:
assert_eq(fx.get_child_count(), FxManager.POOL_SIZE + FxManager.RING_POOL_SIZE + FxManager.BEAM_POOL_SIZE + FxManager.BOLT_POOL_SIZE + FxManager.SLASH_POOL_SIZE + FxManager.LABEL_POOL_SIZE + FxManager.NOVA_POOL_SIZE + fx.v2_layer_count(), "pool size constant (reuse, no leak)")- Step 4: Run to verify passes
Run: -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager, -gselect=test_debug_settings, -gselect=test_quality_manager (use the commands block above).
Expected: all pass.
- Step 5: Commit —
feat(fx-v2): weapon_fx_v2 switch, L0-L2 quality tier, Debug row, look registry guard
Task 5: Move Plasma Nova out of FxManager + extract the shared noise include
Section titled “Task 5: Move Plasma Nova out of FxManager + extract the shared noise include”This is a pure relocation with no visual change, and it gives later tasks a home. fx/weapons/ becomes the directory for every weapon look.
Files:
- Move:
fx/plasma_nova.gdshader→fx/weapons/plasma_nova.gdshader(git mv; the.uidtravels with it) - Create:
fx/weapons/fx_noise.gdshaderinc - Create:
fx/weapons/plasma_nova_node.gd(the_PlasmaNovaNode+_EmberNodecode, moved) - Modify:
fx/fx_manager.gd:17(path),:91-189(delete the two inner classes),:417-418,:469,:725,:758(type names) - Modify:
tests/test_plasma_nova.gd:4(comment path only)
Interfaces:
-
Consumes: —
-
Produces:
class_name PlasmaNovaNode extends Node2DwithLIFE,EXTENT,_init(mat, tex, glow),reset(pos, radius, c, rng),progress(),tick(dt) -> bool. It keeps exactly the old API, soFxManager.active_novas()callers still work.res://fx/weapons/fx_noise.gdshaderincdefineshash21(vec2),vnoise(vec2),fbm(vec2)behind an include guard.FxManager.PLASMA_NOVA_SHADER_PATH == "res://fx/weapons/plasma_nova.gdshader".
-
Step 1: Capture the before image
NOVA_OUT=/tmp/fxv2_before godot --path . res://tools/nova_vfx/compare.tscn --rendering-method mobilegit diff --stat project.godot; git checkout -- project.godotExpected: /tmp/fxv2_before/nova_compare.png exists. Use the scratchpad path if you have one.
- Step 2: Move and extract
mkdir -p fx/weaponsgit mv fx/plasma_nova.gdshader fx/weapons/plasma_nova.gdshadergit mv fx/plasma_nova.gdshader.uid fx/weapons/plasma_nova.gdshader.uidCreate fx/weapons/fx_noise.gdshaderinc. The function bodies are byte-for-byte what plasma_nova.gdshader had:
// Shared value noise for weapon FX v2 shaders (extracted verbatim from plasma_nova.gdshader).#ifndef FX_NOISE_INC#define FX_NOISE_INC
float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y);}
float vnoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash21(i); float b = hash21(i + vec2(1.0, 0.0)); float c = hash21(i + vec2(0.0, 1.0)); float d = hash21(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}
float fbm(vec2 p) { float v = 0.0; float amp = 0.5; for (int k = 0; k < 3; k++) { v += amp * vnoise(p); p = p * 2.03 + vec2(11.7, 3.1); amp *= 0.5; } return v; // ~0..0.875}
#endifIn fx/weapons/plasma_nova.gdshader, delete the three functions (hash21 through the end of fbm) and put this in their place, after the void vertex() block:
#include "res://fx/weapons/fx_noise.gdshaderinc"Change the header comment’s FxManager._PlasmaNovaNode to PlasmaNovaNode (fx/weapons/plasma_nova_node.gd).
Create fx/weapons/plasma_nova_node.gd. This is the two inner classes moved verbatim. Only the class names and the ember type change:
class_name PlasmaNovaNodeextends Node2D
# Plasma Nova burst (2026-09-27; moved out of FxManager for weapon FX v2, 2026-09-29 -- unchanged).# One shader quad (fx/weapons/plasma_nova.gdshader) + a child of ember sparks. All bursts share ONE# material; per-burst tint + progress ride the draw colour. This is the v1 (legacy) Nova look.
const LIFE := 0.46const EXTENT := 1.22 # must match the shader's EXTENTvar life: float = 0.0var max_radius: float = 100.0var col: Color = Color.WHITEvar active: bool = falsevar embers: Embersvar quad_tex: Texture2D
func _init(mat: ShaderMaterial, tex: Texture2D, glow: Texture2D) -> void: material = mat quad_tex = tex visible = false embers = Embers.new(glow) add_child(embers)
func reset(pos: Vector2, radius: float, c: Color, rng: RandomNumberGenerator) -> void: position = pos rotation = rng.randf() * TAU # vary the noise orientation per burst max_radius = radius col = c life = 0.0 active = true visible = true embers.reset(radius, rng) queue_redraw()
func progress() -> float: return clampf(life / LIFE, 0.0, 1.0)
func tick(dt: float) -> bool: # returns true when expired life += dt if life >= LIFE: active = false visible = false return true embers.set_progress(progress()) queue_redraw() return false
func _draw() -> void: if not active: return var half := max_radius * EXTENT draw_texture_rect(quad_tex, Rect2(-half, -half, half * 2.0, half * 2.0), false, Color(col.r, col.g, col.b, progress()))
# Short-lived additive ember sparks thrown off the Plasma Nova front. Positions are a closed-form# function of progress (no per-ember state to integrate), drawn with the shared soft GlowTexture.class Embers extends Node2D: const COUNT := 14 var glow: Texture2D var t: float = 0.0 var radius: float = 100.0 var angles := PackedFloat32Array() var speeds := PackedFloat32Array() var sizes := PackedFloat32Array()
func _init(g: Texture2D) -> void: glow = g var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD material = mat angles.resize(COUNT) speeds.resize(COUNT) sizes.resize(COUNT)
func reset(r: float, rng: RandomNumberGenerator) -> void: radius = r t = 0.0 for i in range(COUNT): angles[i] = (float(i) + rng.randf() * 0.8) / float(COUNT) * TAU speeds[i] = rng.randf_range(0.85, 1.35) sizes[i] = rng.randf_range(3.0, 6.5) queue_redraw()
func set_progress(p: float) -> void: t = p queue_redraw()
func _draw() -> void: var front := 1.0 - pow(1.0 - t, 2.4) var a := 1.0 - t # Hot yellow-white while fresh, cooling to orange then magenta as they fly out. var hot := Color(1.0, 0.85, 0.45).lerp(Color(1.0, 0.35, 0.1), clampf(t * 1.6, 0.0, 1.0)) var c := hot.lerp(Color(0.95, 0.2, 0.7), clampf(t * 1.6 - 0.6, 0.0, 1.0)) for i in range(COUNT): var rr := radius * front * speeds[i] var p := Vector2(cos(angles[i]), sin(angles[i])) * rr var s := sizes[i] * (1.0 - 0.5 * t) draw_texture_rect(glow, Rect2(p - Vector2(s, s) * 2.0, Vector2(s, s) * 4.0), false, Color(c.r * 1.6, c.g * 1.6, c.b * 1.6, a)) draw_texture_rect(glow, Rect2(p - Vector2(s, s) * 0.6, Vector2(s, s) * 1.2), false, Color(1.6, 1.5, 1.3, a))Before deleting the originals in fx/fx_manager.gd, run diff <(sed -n '94,189p' fx/fx_manager.gd | sed 's/^\t//') <(sed -n '9,99p' fx/weapons/plasma_nova_node.gd). The only differences allowed are the class-name lines, _EmberNode→Embers, and the comment header. Then:
-
delete the
_PlasmaNovaNodeand_EmberNodeinner classes (lines 91–189, including their leading comment); -
set
const PLASMA_NOVA_SHADER_PATH := "res://fx/weapons/plasma_nova.gdshader"; -
change
# Array of _PlasmaNovaNodecomments,_PlasmaNovaNode.new(and bothvar ...: _PlasmaNovaNodeannotations toPlasmaNovaNode. -
Step 3: Import, run the nova suites, capture after
godot --headless --path . --import 2>&1 | grep -iE "error|include" | headgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_plasma_nova -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_fx_manager -gexitNOVA_OUT=/tmp/fxv2_after godot --path . res://tools/nova_vfx/compare.tscn --rendering-method mobilegit diff --stat project.godot; git checkout -- project.godotExpected: no import/shader errors, and both suites pass. Read both PNGs with the Read tool. The shader uses TIME, so the flame noise differs frame to frame. The fireball’s size, colour ramp, shell/core structure and ember count must match. A black or missing burst means the include failed to resolve.
- Step 4: Commit —
refactor(fx): move Plasma Nova to fx/weapons/ + shared fx_noise include (no visual change)
Task 6: WeaponFxLayer — the one-draw-call base for every v2 look
Section titled “Task 6: WeaponFxLayer — the one-draw-call base for every v2 look”Files:
- Create:
fx/weapons/weapon_fx_layer.gd - Test:
tests/test_weapon_fx_layer.gd
Interfaces:
-
Consumes: —
-
Produces:
class_name WeaponFxLayer extends MultiMeshInstance2Dwithconst STRIDE := 16var capacity: int_init(shader: Shader, cap: int)ensure_capacity(n: int) -> void(grows to the next power of two ≥ n; never shrinks)- persistent looks:
begin(),put(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4) -> bool(false when full),commit() - event looks:
spawn(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4, life_s: float) -> bool(false when full: bounded drop),advance(dt: float)(ages, expires, rebuilds, commits; COLOR.a = life progress 0..1),active_count() -> int,clear() debug_buffer() -> PackedFloat32Array(tests only; the headless dummy renderer can’t read MultiMesh buffers back)materialis aShaderMaterialshared by every instance.
-
Step 1: Write the failing test
tests/test_weapon_fx_layer.gd:
extends GutTest
# WeaponFxLayer: every v2 look is ONE MultiMesh draw call. Per instance: TRANSFORM_2D (rotation, scale =# half-extent px on a 2x2 quad) + COLOR + INSTANCE_CUSTOM, packed stride 16 -- the same layout the# production ZoneRenderer web layer uses: (x.x, y.x, 0, origin.x, x.y, y.y, 0, origin.y, rgba, custom).
func _shader() -> Shader: var s := Shader.new() s.code = "shader_type canvas_item;\nvoid fragment() { COLOR = vec4(1.0); }" return s
func _layer(cap: int = 4) -> WeaponFxLayer: var l := WeaponFxLayer.new(_shader(), cap) add_child_autofree(l) return l
func test_multimesh_is_set_up_for_colors_and_custom_data() -> void: var l := _layer() assert_eq(l.multimesh.transform_format, MultiMesh.TRANSFORM_2D) assert_true(l.multimesh.use_colors) assert_true(l.multimesh.use_custom_data) assert_true(l.material is ShaderMaterial) assert_eq((l.multimesh.mesh as QuadMesh).size, Vector2(2, 2), "unit quad: scale == half-extent px")
func test_put_packs_stride_16() -> void: var l := _layer() l.begin() l.put(Vector2(10, 20), PI * 0.5, Vector2(3, 5), Color(0.1, 0.2, 0.3, 0.4), Vector4(1, 2, 3, 4)) l.commit() var b := l.debug_buffer() var want := [0.0, -5.0, 0.0, 10.0, 3.0, 0.0, 0.0, 20.0, 0.1, 0.2, 0.3, 0.4, 1.0, 2.0, 3.0, 4.0] for k in range(16): assert_almost_eq(b[k], want[k], 1e-5, "float %d" % k) assert_eq(l.multimesh.visible_instance_count, 1) assert_true(l.visible)
func test_empty_commit_hides_the_layer() -> void: var l := _layer() l.begin() l.commit() assert_false(l.visible, "nothing to draw -> no draw call")
func test_put_refuses_past_capacity() -> void: var l := _layer(2) l.begin() assert_true(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO)) assert_true(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO)) assert_false(l.put(Vector2.ZERO, 0.0, Vector2.ONE, Color.WHITE, Vector4.ZERO)) l.commit() assert_eq(l.multimesh.visible_instance_count, 2)
func test_ensure_capacity_grows_to_pow2() -> void: var l := _layer(4) l.ensure_capacity(37) assert_eq(l.capacity, 64) assert_eq(l.multimesh.instance_count, 64) l.ensure_capacity(10) assert_eq(l.capacity, 64, "never shrinks")
func test_spawn_is_bounded_and_expires() -> void: var l := _layer(3) for _i in range(5): l.spawn(Vector2.ZERO, 0.0, Vector2.ONE * 10.0, Color.RED, Vector4.ZERO, 0.2) assert_eq(l.active_count(), 3, "bounded: extra spawns drop") l.advance(0.1) assert_eq(l.active_count(), 3) assert_almost_eq(l.debug_buffer()[11], 0.5, 1e-4, "COLOR.a = life progress") l.advance(0.15) assert_eq(l.active_count(), 0, "expired items leave") assert_false(l.visible)- Step 2: Run to verify it fails
Run: godot --headless --path . --import then -gselect=test_weapon_fx_layer.
Expected: WeaponFxLayer not declared.
- Step 3: Implement
fx/weapons/weapon_fx_layer.gd
class_name WeaponFxLayerextends MultiMeshInstance2D
# Weapon FX v2 base: ONE look = ONE of these = ONE draw call, however many instances. Every instance is# a unit 2x2 quad whose transform scale is the look's half-extent in px, so the shader's# p = (UV - 0.5) * 2.0 spans [-1, 1] over the quad. Per instance the shader gets COLOR (element tint rgb,# a = look-defined -- life progress for event looks) and INSTANCE_CUSTOM (4 look-defined float32s; see# the layout table in docs/superpowers/plans/2026-09-29-weapon-fx-v2-foundation-batch1.md).# Buffer layout (stride 16) matches the production ZoneRenderer web layer:# (x.x, y.x, 0, origin.x, x.y, y.y, 0, origin.y, r, g, b, a, cx, cy, cz, cw)# Tint is packed raw (like SwarmRenderer.set_instance_color) -- HDR values > 1 are fine.
const STRIDE := 16
var capacity: int = 0var _buf := PackedFloat32Array()var _n: int = 0# Event looks: [pos, rot, half_size, tint, custom, life, max_life] per item (arrays, no per-item nodes).var _items: Array = []
func _init(shader: Shader, cap: int) -> void: var quad := QuadMesh.new() quad.size = Vector2(2.0, 2.0) var mm := MultiMesh.new() # Formats must be set while instance_count == 0. mm.transform_format = MultiMesh.TRANSFORM_2D mm.use_colors = true mm.use_custom_data = true mm.mesh = quad multimesh = mm var mat := ShaderMaterial.new() mat.shader = shader material = mat visible = false ensure_capacity(maxi(cap, 1))
func ensure_capacity(n: int) -> void: if n <= capacity: return var c := 1 while c < n: c <<= 1 capacity = c multimesh.instance_count = capacity _buf.resize(capacity * STRIDE)
func begin() -> void: _n = 0
func put(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4) -> bool: if _n >= capacity: return false var c := cos(rot) var s := sin(rot) var o := _n * STRIDE _buf[o] = c * half_size.x _buf[o + 1] = -s * half_size.y _buf[o + 2] = 0.0 _buf[o + 3] = pos.x _buf[o + 4] = s * half_size.x _buf[o + 5] = c * half_size.y _buf[o + 6] = 0.0 _buf[o + 7] = pos.y _buf[o + 8] = tint.r _buf[o + 9] = tint.g _buf[o + 10] = tint.b _buf[o + 11] = tint.a _buf[o + 12] = custom.x _buf[o + 13] = custom.y _buf[o + 14] = custom.z _buf[o + 15] = custom.w _n += 1 return true
func commit() -> void: if _n == 0: multimesh.visible_instance_count = 0 visible = false return multimesh.buffer = _buf multimesh.visible_instance_count = _n visible = true
func spawn(pos: Vector2, rot: float, half_size: Vector2, tint: Color, custom: Vector4, life_s: float) -> bool: if _items.size() >= capacity: return false # bounded: drop, like every FxManager pool _items.append([pos, rot, half_size, tint, custom, 0.0, maxf(life_s, 0.001)]) _rebuild() return true
func advance(dt: float) -> void: if _items.is_empty() and not visible: return var i := _items.size() - 1 while i >= 0: var it: Array = _items[i] it[5] = float(it[5]) + dt if float(it[5]) >= float(it[6]): _items.remove_at(i) i -= 1 _rebuild()
func _rebuild() -> void: begin() for it in _items: var tint: Color = it[3] tint.a = clampf(float(it[5]) / float(it[6]), 0.0, 1.0) put(it[0], it[1], it[2], tint, it[4]) commit()
func active_count() -> int: return _items.size()
func clear() -> void: _items.clear() begin() commit()
func debug_buffer() -> PackedFloat32Array: return _buf- Step 4: Run to verify it passes
Run: godot --headless --path . --import then -gselect=test_weapon_fx_layer.
Expected: all tests in the script pass.
- Step 5: Commit —
feat(fx-v2): WeaponFxLayer one-draw-call MultiMesh base
Task 7: Crystal accent motifs — shader include, GDScript twin, sync guard, motif grid
Section titled “Task 7: Crystal accent motifs — shader include, GDScript twin, sync guard, motif grid”Files:
- Create:
fx/weapons/element_accents.gdshaderinc - Create:
fx/weapons/element_accents.gd - Create:
tools/weapon_vfx/harness.gd,tools/weapon_vfx/motifs.gd,tools/weapon_vfx/motifs.tscn,tools/weapon_vfx/motif_probe.gdshader - Modify:
.gitignore(addtools/weapon_vfx/out/) - Test:
tests/test_weapon_fx_guards.gd(guard 6),tests/test_weapon_vfx.gd(code packing)
Interfaces:
-
Consumes:
WeaponThresholds.rules_for(wid),fx_noise.gdshaderinc(Task 5),WeaponFxLayer(Task 6). -
Produces:
ElementAccents.IDS: Dictionary(element → 1..10),ElementAccents.BUILT: Dictionary(elements with a real motif),ElementAccents.EVOLVE_SHIFT := 20static ElementAccents.code(wid: String, rungs_mask: int, evolved: bool) -> floatstatic ElementAccents.evolve_element(wid: String) -> String- GLSL:
vec4 accent_motif(int id, vec2 p, float mask, float edge, float s, float t, float seed, float now)(rgb is added;.amultiplies the host colour, which is how void darkens under additive blending);vec4 accents_all(float code, vec2 p, float mask, float edge, float t, float seed, float now);int evolve_accent(float code). The generic shape inputs (mask,edge) are what batch 6 reuses on enemies. WeaponVfxHarness(static helpers used by every harness):make_stage(host: Node, size: Vector2i) -> Node2D,dots(root, centre),label(root, pos, text),save(root, file_name) -> String
-
Step 1: Write the failing tests
Append to tests/test_weapon_fx_guards.gd:
# ── Guard 6: accent ids agree between the shader include and its GDScript twin ──const BATCH1_ELEMENTS := ["kinetic", "cold", "lightning", "blood", "fire", "void", "light"]
func test_accent_ids_match_the_shader_include() -> void: var src := _read("res://fx/weapons/element_accents.gdshaderinc") var re := RegEx.new() re.compile("const int ACC_([A-Z]+) = (\\d+);") var shader_ids := {} for m in re.search_all(src): shader_ids[m.get_string(1).to_lower()] = int(m.get_string(2)) assert_eq(shader_ids, ElementAccents.IDS, "fx/weapons/element_accents.gdshaderinc ACC_* == ElementAccents.IDS") for el in ElementAccents.BUILT: assert_true(src.contains("if (id == ACC_%s)" % el.to_upper()), "BUILT element %s has a motif branch" % el)
func test_every_threshold_element_has_an_accent_id() -> void: for wid in WeaponThresholds.TABLE: for r in WeaponThresholds.rules_for(wid): assert_true(ElementAccents.IDS.has(String(r["element"])), "%s rung element %s needs an accent id" % [wid, r["element"]])
func test_batch1_elements_have_real_motifs() -> void: for el in BATCH1_ELEMENTS: assert_true(ElementAccents.BUILT.has(el), el + " motif is built (batch 1)")Append to tests/test_weapon_vfx.gd:
func test_accent_code_packs_levels_and_evolve_element() -> void: # orbit rungs: cold, lightning, cold, void-evolve assert_eq(ElementAccents.code("orbit", 0, false), 0.0) var c := int(ElementAccents.code("orbit", 0b0111, false)) assert_eq((c >> (2 * (ElementAccents.IDS["cold"] - 1))) & 3, 2, "two cold rungs") assert_eq((c >> (2 * (ElementAccents.IDS["lightning"] - 1))) & 3, 1) assert_eq((c >> ElementAccents.EVOLVE_SHIFT) & 15, 0, "not evolved") var ce := int(ElementAccents.code("orbit", 0b1111, true)) assert_eq((ce >> ElementAccents.EVOLVE_SHIFT) & 15, ElementAccents.IDS["void"], "Event Horizon takes void")
func test_level_up_evolve_without_the_rung_still_takes_the_evolve_element() -> void: var c := int(ElementAccents.code("nova", 0, true)) assert_eq((c >> ElementAccents.EVOLVE_SHIFT) & 15, ElementAccents.IDS["light"], "Supernova = light even via the level-up path")
func test_accent_code_is_exact_in_float32() -> void: var f := PackedFloat32Array([ElementAccents.code("orbit", 0b1111, true)]) assert_eq(int(f[0]), int(ElementAccents.code("orbit", 0b1111, true)), "< 2^24 so float32 carries it exactly")- Step 2: Run to verify failures
Run: -gselect=test_weapon_fx_guards and -gselect=test_weapon_vfx.
Expected: ElementAccents is not declared, and the include file is missing.
- Step 3: Implement the GDScript twin
fx/weapons/element_accents.gd
class_name ElementAccentsextends RefCounted
# Weapon FX v2 crystal accents (spec § Crystal accents): a weapon's look carries a motif for each# element that upgraded it, and its evolved form takes the evolve rung's element. This is the GDScript# twin of fx/weapons/element_accents.gdshaderinc -- the ids MUST match (tests/test_weapon_fx_guards.gd# parses the include). Packing (a float, exact < 2^24): 2 bits of level (0..3) per id at bits# 2*(id-1) (ids 1..10 -> bits 0..19); the evolve element's id at bits 20..23.
const IDS := { "fire": 1, "cold": 2, "lightning": 3, "blood": 4, "void": 5, "light": 6, "kinetic": 7, "poison": 8, "aether": 9, "time": 10,}# Elements with a real motif. The rest return a no-op until the batch that needs them.const BUILT := {"fire": true, "cold": true, "lightning": true, "blood": true, "void": true, "light": true, "kinetic": true}const EVOLVE_SHIFT := 20
static func evolve_element(wid: String) -> String: for r in WeaponThresholds.rules_for(wid): if String(r["kind"]) == "evolve": return String(r["element"]) return ""
static func code(wid: String, rungs_mask: int, evolved: bool) -> float: var levels := {} var rules := WeaponThresholds.rules_for(wid) for i in range(rules.size()): var r: Dictionary = rules[i] if String(r["kind"]) == "evolve": continue if (rungs_mask & (1 << i)) == 0: continue var id: int = IDS.get(String(r["element"]), 0) if id == 0: continue levels[id] = mini(int(levels.get(id, 0)) + 1, 3) var c := 0 for id in levels: c |= int(levels[id]) << (2 * (int(id) - 1)) # Evolved via the crystal rung OR the level-up evolve path: either way the form takes the # TABLE's evolve element (spec: "the evolved form takes the evolve rung's element"). if evolved: var ev_id: int = IDS.get(evolve_element(wid), 0) c |= ev_id << EVOLVE_SHIFT return float(c)- Step 4: Implement the shader include
fx/weapons/element_accents.gdshaderinc
// -- ACC_* ids MUST equal ElementAccents.IDS (tests/test_weapon_fx_guards.gd parses this file).// Written against a GENERIC shape so batch 6 can put the same motifs on enemies:// p look-local coords (~[-1,1]); mask 0..1 = host shape coverage; edge 0..1 = closeness to the// host's leading edge / rim; s = strength 0..1; t = host life progress; seed per instance; now = TIME.// Returns rgb to ADD to the host and .a to MULTIPLY the host colour by (void < 1 bites it dark --// additive blending can't draw black, so darkening = dimming the host's own glow).#ifndef ELEMENT_ACCENTS_INC#define ELEMENT_ACCENTS_INC#include "res://fx/weapons/fx_noise.gdshaderinc"
const int ACC_FIRE = 1;const int ACC_COLD = 2;const int ACC_LIGHTNING = 3;const int ACC_BLOOD = 4;const int ACC_VOID = 5;const int ACC_LIGHT = 6;const int ACC_KINETIC = 7;const int ACC_POISON = 8;const int ACC_AETHER = 9;const int ACC_TIME = 10;
vec4 accent_motif(int id, vec2 p, float mask, float edge, float s, float t, float seed, float now) { if (s <= 0.0 || mask <= 0.0) { return vec4(0.0, 0.0, 0.0, 1.0); } float tt = now + seed * 17.0; if (id == ACC_FIRE) { // embers rising off the edge float n = fbm(p * 6.0 + vec2(0.0, -tt * 3.0)); float e = smoothstep(0.52, 0.78, n) * (0.35 + 0.65 * edge); return vec4(vec3(1.0, 0.45, 0.08) * e * 2.2 * s * mask, 1.0); } if (id == ACC_COLD) { // frost rime: crystalline facets along the rim, pale blue vec2 q = p * 9.0; float f = abs(fract(q.x + q.y * 0.5) - 0.5) + abs(fract(q.y - q.x * 0.5) - 0.5); float rime = smoothstep(0.25, 0.05, f) * (0.3 + 0.7 * edge); return vec4(vec3(0.55, 0.85, 1.0) * rime * 1.6 * s * mask, 1.0); } if (id == ACC_LIGHTNING) { // crackle: thin filaments that re-strike ~14x/s, only on some strikes float strike = floor(tt * 14.0); float n = vnoise(p * 7.0 + vec2(strike * 3.1, seed * 5.0)); float arc = smoothstep(0.035, 0.0, abs(n - 0.5)) * step(0.35, hash21(vec2(strike, seed))); return vec4(vec3(0.75, 0.85, 1.0) * arc * 2.6 * s * mask, 1.0); } if (id == ACC_BLOOD) { // droplets: dark-red beads trailing the edge vec2 cell = floor(p * 8.0); vec2 f = fract(p * 8.0) - 0.5; float r = hash21(cell + seed); float drop = smoothstep(0.22, 0.12, length(f + vec2(0.0, (r - 0.5) * 0.4))) * step(0.7, r); return vec4(vec3(0.9, 0.05, 0.12) * drop * 1.4 * s * mask * (0.4 + 0.6 * edge), 1.0); } if (id == ACC_VOID) { // void bite: drifting dark holes eat the body, a thin violet rim around each hole float n = fbm(p * 5.0 + vec2(tt * 0.6, -tt * 0.4)); float hole = smoothstep(0.5, 0.62, n) * (1.0 - edge * 0.5); float rim = smoothstep(0.46, 0.5, n) * (1.0 - smoothstep(0.5, 0.54, n)); return vec4(vec3(0.55, 0.2, 1.0) * rim * 1.5 * s * mask, 1.0 - 0.85 * hole * s * mask); } if (id == ACC_LIGHT) { // radiance: slow god-rays + a white bloom on the edge float a = atan(p.y, p.x); float rays = pow(0.5 + 0.5 * cos(a * 12.0 + tt * 1.5), 8.0); return vec4(vec3(1.0, 0.97, 0.85) * (rays * 0.8 + edge * 0.6) * 1.5 * s * mask, 1.0); } if (id == ACC_KINETIC) { // shock chevrons: bright bands sliding along +x (the travel / swing axis) float band = pow(0.5 + 0.5 * cos(p.x * 10.0 - abs(p.y) * 6.0 - tt * 22.0), 10.0); return vec4(vec3(1.0, 0.95, 0.8) * band * 1.2 * s * mask * (0.4 + 0.6 * edge), 1.0); } // ACC_POISON / ACC_AETHER / ACC_TIME: built with the batches that use them (ElementAccents.BUILT). return vec4(0.0, 0.0, 0.0, 1.0);}
float acc_strength(int lvl) { return lvl == 1 ? 0.5 : (lvl == 2 ? 0.75 : 0.9);}
vec4 accents_all(float code, vec2 p, float mask, float edge, float t, float seed, float now) { int c = int(code + 0.5); vec3 add = vec3(0.0); float dim = 1.0; for (int id = 1; id <= 10; id++) { int lvl = (c >> (2 * (id - 1))) & 3; if (lvl > 0) { vec4 m = accent_motif(id, p, mask, edge, acc_strength(lvl), t, seed + float(id), now); add += m.rgb; dim *= m.a; } } return vec4(add, dim);}
int evolve_accent(float code) { return (int(code + 0.5) >> 20) & 15;}#endif- Step 5: Shared harness helpers
tools/weapon_vfx/harness.gd
class_name WeaponVfxHarnessextends RefCounted
# Shared bits for the weapon FX v2 harnesses (bh-vfx-iteration tier 1). Every harness renders into a# fixed-size HDR SubViewport under a copy of main.gd's Metal WorldEnvironment -- NEVER judge a glow# look without it. Run harnesses WINDOWED (headless can't read back shader/glow output), then# `git checkout -- project.godot`.
static func make_stage(host: Node, size: Vector2i) -> Node2D: var vp := SubViewport.new() vp.size = size vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS vp.use_hdr_2d = true host.add_child(vp) var root := Node2D.new() vp.add_child(root) # Mirrors main.gd's HDR branch (grep `glow_hdr_threshold` in main.gd if these drift). RenderingServer.set_default_clear_color(Color(0.01, 0.012, 0.02)) var env := Environment.new() env.background_mode = Environment.BG_CANVAS env.glow_enabled = true env.glow_blend_mode = Environment.GLOW_BLEND_MODE_ADDITIVE env.glow_intensity = 1.15 env.glow_strength = 1.0 env.glow_bloom = 0.05 env.glow_hdr_threshold = 1.0 env.glow_hdr_scale = 1.5 env.adjustment_enabled = true env.adjustment_brightness = 1.0 env.adjustment_contrast = 1.08 env.adjustment_saturation = 1.14 var we := WorldEnvironment.new() we.environment = env root.add_child(we) return root
# A few neon enemy-sized dots so a look is judged against something, not a void.static func dots(root: Node2D, centre: Vector2, spread: float = 60.0) -> void: var cols := [Color(0.3, 0.9, 1.0), Color(1.0, 0.3, 0.8), Color(0.5, 1.0, 0.4)] for i in range(9): var s := Sprite2D.new() s.texture = GlowTexture.shared() var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD s.material = mat var ang := float(i) * 2.39 s.position = centre + Vector2(cos(ang), sin(ang)) * (spread + 15.0 * float(i)) s.scale = Vector2(0.45, 0.45) s.modulate = cols[i % 3] * 1.4 root.add_child(s)
static func label(root: Node2D, pos: Vector2, text: String) -> void: var l := Label.new() l.text = text l.position = pos l.add_theme_font_size_override("font_size", 16) root.add_child(l)
# Waits for the glow to settle, saves $WEAPON_VFX_OUT/<file_name> (default tools/weapon_vfx/out/).static func save(root: Node2D, file_name: String) -> String: for _i in range(6): await RenderingServer.frame_post_draw var img := root.get_viewport().get_texture().get_image() var dir := OS.get_environment("WEAPON_VFX_OUT") if dir == "": dir = ProjectSettings.globalize_path("res://tools/weapon_vfx/out") DirAccess.make_dir_recursive_absolute(dir) var path := "%s/%s" % [dir, file_name] img.save_png(path) print("[weapon_vfx] saved %s" % path) return path- Step 6: Motif grid
tools/weapon_vfx/motif_probe.gdshader,motifs.gd,motifs.tscn
tools/weapon_vfx/motif_probe.gdshader. This is a neutral host (a soft ring) that shows one motif per instance:
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"// Motif grid probe: a dim neutral ring host + ONE accent motif. custom = (id, strength, seed, 0).varying vec4 v_cus;void vertex() { v_cus = INSTANCE_CUSTOM; }void fragment() { vec2 p = (UV - 0.5) * 2.0; float d = length(p); float ring = exp(-pow((d - 0.7) / 0.16, 2.0)); float edge = exp(-pow((d - 0.82) / 0.05, 2.0)); vec3 host = vec3(0.35, 0.6, 1.0) * ring * 0.8; vec4 m = accent_motif(int(v_cus.x + 0.5), p, ring, edge, v_cus.y, 0.5, v_cus.z, TIME); COLOR = vec4(host * m.a + m.rgb, 1.0);}tools/weapon_vfx/motifs.gd:
extends Node
# Weapon FX v2 motif grid: columns = the 7 batch-1 accent motifs, rows = rung strength (1/2/3 rungs of# that element). Judge each motif reads as its element at a glance, and that 1 rung is subtle but# visible. Output: $WEAPON_VFX_OUT/motifs.png (default tools/weapon_vfx/out/)# godot --path . res://tools/weapon_vfx/motifs.tscn --rendering-method mobile ; git checkout -- project.godot
const CELL := 220const ORDER := ["kinetic", "cold", "lightning", "blood", "fire", "void", "light"]const STRENGTHS := [0.5, 0.75, 0.9]
func _ready() -> void: var root := WeaponVfxHarness.make_stage(self, Vector2i(CELL * ORDER.size(), CELL * STRENGTHS.size() + 30)) var layer := WeaponFxLayer.new(load("res://tools/weapon_vfx/motif_probe.gdshader") as Shader, 32) root.add_child(layer) layer.begin() for col in range(ORDER.size()): WeaponVfxHarness.label(root, Vector2(CELL * col + 8, 4), ORDER[col]) for row in range(STRENGTHS.size()): var c := Vector2(CELL * col + CELL * 0.5, 30 + CELL * row + CELL * 0.5) layer.put(c, 0.0, Vector2.ONE * CELL * 0.45, Color.WHITE, Vector4(ElementAccents.IDS[ORDER[col]], STRENGTHS[row], float(col * 3 + row), 0.0)) layer.commit() await WeaponVfxHarness.save(root, "motifs.png") get_tree().quit()tools/weapon_vfx/motifs.tscn:
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/weapon_vfx/motifs.gd" id="1_mot"]
[node name="WeaponVfxMotifs" type="Node"]script = ExtResource("1_mot")Append to .gitignore:
# weapon-fx-v2 harness render output (regenerable)tools/weapon_vfx/out/- Step 7: Run tests + render the grid
godot --headless --path . --import 2>&1 | grep -iE "error" | headgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_fx_guards -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_vfx -gexitgodot --path . res://tools/weapon_vfx/motifs.tscn --rendering-method mobilegit diff --stat project.godot; git checkout -- project.godotExpected: the tests pass and tools/weapon_vfx/out/motifs.png exists. Read it with the Read tool. All 7 columns should show a distinct motif over the ring, and the void column should visibly dim the ring. If a column is empty, check the Godot output for shader compile errors. This is the first shader to use int bit ops and nested includes, so errors surface here first. Don’t tune looks yet: Chris reviews the motifs together with the weapon grids in Task 16.
- Step 8: Commit —
feat(fx-v2): crystal accent motifs (7 batch-1 elements) + GDScript twin + motif grid
Task 8: Weapon comparison + in-game harnesses
Section titled “Task 8: Weapon comparison + in-game harnesses”Files:
- Create:
tools/weapon_vfx/compare.gd,tools/weapon_vfx/compare.tscn,tools/weapon_vfx/ingame.gd,tools/weapon_vfx/ingame.tscn
Interfaces:
-
Consumes:
WeaponVfxHarness(Task 7),ElementAccents.code,WeaponThresholds.rules_for,FxManager.consume/advance/v2_quality_ok,VisualLook.weapon_fx_v2, main.gd’s_on_mode_chosen,apply_dev_command,quality_manager,fx_layer,camera. -
Produces:
WEAPON=<wid> godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobilewritescompare_<wid>.png. Rows: v1, p0 (power 0), one row per crystal rung in TABLE order (cumulative), evolved. Columns are frames through the effect’s life.WEAPON=<wid> V2=0|1 EVOLVE=0|1 LEVELS=<n>withingame.tscnwritesingame_<wid>_<v1|v2>.png. Looks from Tasks 9–13 plug into the_cell()dispatch below. Until a look exists, its v2 rows render the legacy path, which is expected. -
Step 1: Write
tools/weapon_vfx/compare.gd
extends Node
# Weapon FX v2 comparison grid (bh-vfx-iteration tier 1). One PNG per weapon:# rows = v1 (legacy) | p0 (power 0) | each crystal rung reached, in TABLE order (cumulative) | evolved# columns = frames through the effect's life (event looks) or orbit phases / bullet ages (persistent)# Everything goes through the REAL render classes with the real stamped event shapes. Chris eyeballs# every grid before merge (spec § Verification).# WEAPON=blade godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobile# git checkout -- project.godot # output: $WEAPON_VFX_OUT/compare_<wid>.png
const CELL := 400const TIMES := [0.04, 0.10, 0.18, 0.30] # seconds into an event lookconst REACH := {"blade": 150.0, "scythe": 170.0, "nova": 180.0, "orbit": 120.0, "blaster": 0.0}
var _content: ContentDBvar _wid := "blade"
func _ready() -> void: _wid = OS.get_environment("WEAPON") if OS.get_environment("WEAPON") != "" else "blade" _content = ContentLoader.load_from_path("res://data/bible.json") var rows := _rows() var root := WeaponVfxHarness.make_stage(self, Vector2i(CELL * TIMES.size() + 120, CELL * rows.size())) var was := VisualLook.weapon_fx_v2 for r in range(rows.size()): var row: Dictionary = rows[r] WeaponVfxHarness.label(root, Vector2(6, CELL * r + 6), row["name"]) for c in range(TIMES.size()): var centre := Vector2(120 + CELL * c + CELL * 0.5, CELL * r + CELL * 0.5) WeaponVfxHarness.dots(root, centre) VisualLook.weapon_fx_v2 = row["v2"] _cell(root, centre, row, c) VisualLook.weapon_fx_v2 = was await WeaponVfxHarness.save(root, "compare_%s.png" % _wid) get_tree().quit()
# Rows: v1, p0, rung 1..k (cumulative mask, invested = rungs reached), evolved (all rungs + 3 picks).func _rows() -> Array: var out := [{"name": "v1", "v2": false, "power": 0.0, "rungs": 0, "evolved": false}, {"name": "p0", "v2": true, "power": 0.0, "rungs": 0, "evolved": false}] var rules := WeaponThresholds.rules_for(_wid) var mask := 0 var n := 0 for i in range(rules.size()): if String(rules[i]["kind"]) == "evolve": continue mask |= 1 << i n += 1 out.append({"name": "+%s" % rules[i]["element"], "v2": true, "power": 1.0 - pow(WeaponVfx.POWER_BASE, float(n)), "rungs": mask, "evolved": false}) var all_mask := (1 << rules.size()) - 1 out.append({"name": "EVOLVED", "v2": true, "power": 1.0 - pow(WeaponVfx.POWER_BASE, float(rules.size() + 3)), "rungs": all_mask, "evolved": true}) return out
func _element() -> int: return _content.element_index(String(_content.weapon(_wid).get("element", "")))
func _stamp(ev: Dictionary, row: Dictionary) -> Dictionary: ev["wid"] = _wid ev["power"] = row["power"] ev["evolved"] = row["evolved"] ev["rungs"] = row["rungs"] return ev
func _fx(root: Node2D) -> FxManager: var fx := FxManager.new() fx.setup(_content) root.add_child(fx) fx.v2_quality_ok = true return fx
func _advance(fx: FxManager, t: float) -> void: for _i in range(int(round(t * 60.0))): fx.advance(1.0 / 60.0) fx.set_process(false)
func _cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void: var t: float = TIMES[col] match _wid: "blade", "scythe": var fx := _fx(root) var arc := 360.0 if row["evolved"] else 120.0 fx.consume([_stamp({"kind": "slash", "pos": centre, "dir": Vector2.RIGHT, "range": REACH[_wid], "arc": arc, "element": _element()}, row)]) _advance(fx, t) "nova": var fx := _fx(root) fx.consume([_stamp({"kind": "nova", "pos": centre, "radius": REACH["nova"], "element": _element()}, row)]) _advance(fx, t) "orbit": _orbit_cell(root, centre, row, col) "blaster": _blaster_cell(root, centre, row, col)
# Persistent looks: columns vary the orbit phase / bullet age instead of event time.func _orbit_cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void: var n := 6 if row["evolved"] else 3 + int(row["rungs"] & 1) var phase := float(col) * 0.35 var positions: Array[Vector2] = [] for k in range(n): var a := phase + float(k) * TAU / float(n) positions.append(centre + Vector2(cos(a), sin(a)) * REACH["orbit"]) if not row["v2"]: for p in positions: var sh := Sprite2D.new() sh.texture = GlowTexture.shared() var m := CanvasItemMaterial.new() m.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD sh.material = m sh.modulate = ElementPalette.color_for(_content, _element()) sh.scale = Vector2(0.6, 0.6) sh.position = p root.add_child(sh) return var look := OrbitShardsLook.new() root.add_child(look) look.sync_entries([{"center": centre, "positions": positions, "radius": REACH["orbit"], "spin_norm": 0.5, "tint": ElementPalette.color_for(_content, _element()), "power": row["power"], "evolved": row["evolved"], "code": ElementAccents.code("orbit", row["rungs"], row["evolved"])}])
func _blaster_cell(root: Node2D, centre: Vector2, row: Dictionary, col: int) -> void: var pool := ProjPool.new(8) var vfx: float = row["power"] + (1.0 if row["evolved"] else 0.0) for k in range(3): pool.add_proj(centre + Vector2(-90.0 + 70.0 * k, -40.0), Vector2(600, 0), 4.0, 0.2 + 0.25 * col, 10.0, _element(), 0, 0, 0.0, 0.0, "blaster", vfx, row["rungs"]) var cols := PackedColorArray() for _k in range(pool.count): cols.append(ElementPalette.color_for(_content, _element())) if not row["v2"]: var sr := SwarmRenderer.new() sr.configure(6.0, Color.WHITE) sr.set_bullet_shape(18.0, 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT) root.add_child(sr) sr.sync(pool, cols) return var br := PlayerBoltRenderer.new() root.add_child(br) br.sync_bolts(pool, cols, Rect2(-1e6, -1e6, 2e6, 2e6)) # muzzle flash (col 0) and Auto-Cannon impact sparks (evolved, later columns) in the same cell var fx := _fx(root) fx.enabled = true var evs := [_stamp({"kind": "blaster_fire", "pos": centre + Vector2(-140, 40), "dir": Vector2.RIGHT, "element": _element()}, row)] if row["evolved"]: evs.append(_stamp({"kind": "bolt_impact", "pos": centre + Vector2(120, 40), "dir": Vector2.RIGHT, "element": _element()}, row)) fx.consume(evs) _advance(fx, 0.02 + 0.03 * col)OrbitShardsLook and PlayerBoltRenderer don’t exist until Tasks 12–13, so the script won’t parse until then. To keep the harness runnable for Tasks 9–11, create compare.gd in this task with the "orbit" and "blaster" arms of _cell() commented out. Add _orbit_cell and _blaster_cell in Tasks 12 and 13 respectively. The final text is exactly as above.
tools/weapon_vfx/compare.tscn:
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/weapon_vfx/compare.gd" id="1_cmp"]
[node name="WeaponVfxCompare" type="Node"]script = ExtResource("1_cmp")- Step 2: Write
tools/weapon_vfx/ingame.gd
extends Node
# Weapon FX v2 in-game capture (bh-vfx-iteration tier 3): the REAL main.tscn, a busy survival horde,# the chosen weapon forced active with the requested investment, captured after it has fired a while.# WEAPON=blade V2=1 EVOLVE=0 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscn# git checkout -- project.godot # output: $WEAPON_VFX_OUT/ingame_<wid>_<v1|v2><_evo>.png
const SIZE := Vector2i(1920, 1080)
var vp: SubViewportvar main
func _ready() -> void: var wid := OS.get_environment("WEAPON") if OS.get_environment("WEAPON") != "" else "blade" VisualLook.weapon_fx_v2 = OS.get_environment("V2") != "0" var evolve := OS.get_environment("EVOLVE") == "1" var levels := int(OS.get_environment("LEVELS")) if OS.get_environment("LEVELS") != "" else 3 vp = SubViewport.new() vp.size = SIZE vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS vp.handle_input_locally = false add_child(vp) main = preload("res://main.tscn").instantiate() vp.add_child(main) await get_tree().process_frame await get_tree().process_frame main._on_mode_chosen("survival") var s = main.sim s.player.dev_invuln = true s.dev_suppress_spawns = true main.quality_manager.auto = false main.quality_manager.set_level(0) for k in main.codex_db.TYPE_KEYS: if k != "": main.meta.mark_seen(k) var ars: PilotArsenal = s.player.arsenal if not ars.active_weapon_ids.has(wid): ars.active_weapon_ids.append(wid) ars.weapon_levels[wid] = levels var rules := WeaponThresholds.rules_for(wid) for i in range(rules.size()): if String(rules[i]["kind"]) != "evolve" or evolve: ars.thresholds_done["%s:%d" % [wid, i]] = true if evolve: WeaponVfx.weapon(ars, wid).call("evolve") main.apply_dev_command({"kind": "clear"}) for spec in [["swarmer", 70], ["tank", 8], ["shooter", 10]]: main.apply_dev_command({"kind": "spawn_preset", "type": spec[0], "count": spec[1], "placement": "random"}) main.camera.zoom = Vector2(1.0, 1.0) for _i in range(150): await get_tree().process_frame await RenderingServer.frame_post_draw var img := vp.get_texture().get_image() var dir := OS.get_environment("WEAPON_VFX_OUT") if dir == "": dir = ProjectSettings.globalize_path("res://tools/weapon_vfx/out") DirAccess.make_dir_recursive_absolute(dir) var path := "%s/ingame_%s_%s%s.png" % [dir, wid, "v2" if VisualLook.weapon_fx_v2 else "v1", "_evo" if evolve else ""] img.save_png(path) print("[weapon_vfx] saved %s" % path) get_tree().quit()This writes thresholds_done directly and skips the rung’s mechanical apply_mod. That is intentional: the harness shows the look for a given investment, and gameplay numbers are not under test. The rungs’ apply_mod would change reach/arc, which the evolve call already covers for the transform.
tools/weapon_vfx/ingame.tscn: the same 4-line scene shape as compare.tscn, with path="res://tools/weapon_vfx/ingame.gd", id="1_ing" and node name="WeaponVfxIngame".
- Step 3: Smoke-run both on today’s (legacy) looks
godot --headless --path . --importWEAPON=nova godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobileWEAPON=blade V2=0 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscngit diff --stat project.godot; git checkout -- project.godotExpected: compare_nova.png has 5 rows (v1, p0, +fire, +fire, EVOLVED) × 4 columns. All rows currently show the Plasma Nova, because no v2 look exists yet. ingame_blade_v1.png shows the horde with the blade active. Read both PNGs.
- Step 4: Commit —
feat(fx-v2): weapon compare grid + in-game capture harnesses
Task 9: Blade look (fixes bug 1) + the FxManager v2 dispatch path
Section titled “Task 9: Blade look (fixes bug 1) + the FxManager v2 dispatch path”Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).
Files:
- Create:
fx/weapons/blade_slash_look.gd,fx/weapons/blade_slash.gdshader - Modify:
fx/fx_manager.gd:V2_LOOKS,_ready(register),consume"slash"arm (lines 522–528),_SlashNode(lines 299–345) +_spawn_slash(lines 685–690) for the legacy arc fix,advance(tick the v2 layers), a newactive_slashes()accessor - Modify:
tests/test_weapon_fx_guards.gd(delete"blade"fromPENDING_V2_LOOKS) - Test:
tests/test_weapon_fx_looks.gd
Interfaces:
-
Consumes:
WeaponFxLayer(Task 6) incl.accent_slot(code) -> float/accent_reset()(Task 7 fix 804fa4e),ElementAccents.code(Task 7), the stampedslashevent (Task 3),FxManager._add_v2_layer(layer, wids),v2_look(wid),v2_active()(Task 4). -
Produces:
class_name BladeSlashLook extends WeaponFxLayer, withconst EXTENT := 1.15,const CAP := 12,spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool. Every v2 event look implements this samespawn_eventsignature.FxManager.active_slashes() -> Array- Legacy
_SlashNode.reset(pos, dir, r, c, span_rad)now uses the event’s arc.
-
Step 1: Write the failing tests
Append to tests/test_weapon_fx_looks.gd:
func _slash_ev(wid: String, arc: float, evolved: bool = false, power: float = 0.5, rungs: int = 0) -> Dictionary: return {"kind": "slash", "pos": Vector2(10, 0), "dir": Vector2.RIGHT, "range": 150.0, "arc": arc, "element": 0, "wid": wid, "power": power, "evolved": evolved, "rungs": rungs}
func test_every_v2_look_is_registered_with_its_class() -> void: var fx := _make() var main_src := FileAccess.get_file_as_string("res://main.gd") for wid in FxManager.V2_LOOKS: var cls: String = FxManager.V2_LOOKS[wid] if cls.begins_with("main:"): assert_true(main_src.contains(cls.trim_prefix("main:")), "%s's look %s is wired in main.gd" % [wid, cls]) else: var look = fx.v2_look(wid) assert_not_null(look, wid + " look registered in FxManager") assert_eq(look.get_script().get_global_name(), cls)
func test_blade_v2_draws_the_real_arc_bug1() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.consume([_slash_ev("blade", 360.0, true, 0.5, 0b011)]) var look: WeaponFxLayer = fx.v2_look("blade") assert_eq(look.active_count(), 1) assert_eq(fx.slash_active_count(), 0, "v2 on -> no legacy crescent") var b := look.debug_buffer() assert_almost_eq(b[12], 2.5, 1e-5, "custom.x = power + 2*evolved") assert_eq(b[13], 1.0, "custom.y = accent SLOT (first code on a fresh layer -> slot 1), never the raw code") assert_eq(look.accent_codes()[int(b[13])], ElementAccents.code("blade", 0b011, true), "the slot resolves to the code") assert_almost_eq(b[14], TAU, 1e-4, "custom.z = the event's REAL arc -- 360 deg draws the full ring (bug 1)")
func test_blade_look_quad_matches_the_real_reach() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.consume([_slash_ev("blade", 120.0)]) var b: PackedFloat32Array = fx.v2_look("blade").debug_buffer() assert_almost_eq(Vector2(b[0], b[4]).length(), 150.0 * BladeSlashLook.EXTENT, 1e-3, "half-extent = reach * EXTENT (d == 1 is the hit edge)")
func test_legacy_crescent_honours_arc_too() -> void: VisualLook.weapon_fx_v2 = false var fx := _make() fx.consume([_slash_ev("blade", 360.0)]) assert_eq(fx.slash_active_count(), 1) assert_almost_eq(float(fx.active_slashes()[0].span), TAU, 1e-4, "the L3+ fallback no longer draws a ~97 deg crescent")
func test_quality_fallback_uses_the_legacy_crescent() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.v2_quality_ok = false fx.consume([_slash_ev("blade", 120.0)]) assert_eq(fx.slash_active_count(), 1) assert_eq(fx.v2_look("blade").active_count(), 0)
func test_blade_look_is_bounded_and_expires() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() var many: Array = [] for _i in range(40): many.append(_slash_ev("blade", 120.0)) fx.consume(many) assert_lte(fx.v2_look("blade").active_count(), BladeSlashLook.CAP) for _i in range(60): fx.advance(1.0 / 60.0) assert_eq(fx.v2_look("blade").active_count(), 0, "no leak")
func test_slash_is_weapon_feedback_even_at_l5() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.enabled = false fx.consume([_slash_ev("blade", 120.0)]) assert_eq(fx.v2_look("blade").active_count(), 1)- Step 2: Run to verify failures
Run: -gselect=test_weapon_fx_looks.
Expected: v2_look / BladeSlashLook not found.
- Step 3: Write
fx/weapons/blade_slash_look.gd
class_name BladeSlashLookextends WeaponFxLayer
# Blade v2 (spec § Batch 1): a shader crescent swept across the event's REAL arc (bug 1: the legacy# node hardcoded ~97 deg) with a white-hot leading edge and an element-coloured smear. Power: thicker,# longer-lived smear, an afterimage crescent, more sparks (<= 1.5x). Evolved (Tempest): a full-360# whirl of spinning blade echoes + wind streaks, carrying the evolve rung's element (void).
const SHADER_PATH := "res://fx/weapons/blade_slash.gdshader"const CAP := 12const EXTENT := 1.15 # quad half-size in reach units -- keep in sync with the shader's EXTENTconst LIFE := 0.20const LIFE_GROW := 0.35 # the smear lingers up to 35% longer at power 1const LIFE_EVOLVED := 0.34
func _init() -> void: super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool: var power := float(ev.get("power", 0.0)) var evolved := bool(ev.get("evolved", false)) var dir: Vector2 = ev.get("dir", Vector2.RIGHT) var reach := float(ev.get("range", 160.0)) var slot := accent_slot(ElementAccents.code("blade", int(ev.get("rungs", 0)), evolved)) # half-float safe (Task 7 fix) var life := LIFE_EVOLVED if evolved else LIFE * (1.0 + LIFE_GROW * power) return spawn(ev["pos"], dir.angle(), Vector2.ONE * reach * EXTENT, tint, Vector4(power + (2.0 if evolved else 0.0), slot, deg_to_rad(float(ev.get("arc", 120.0))), seed), life)- Step 4: Write
fx/weapons/blade_slash.gdshader
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Blade v2 (weapon FX v2 batch 1). One instance per swing on a 2x2 quad scaled to reach * EXTENT;// local +x = swing direction, d == 1 is exactly the blade's reach (the hit edge).// COLOR = (element tint, a = life t). INSTANCE_CUSTOM = (power + 2*evolved, accent slot, arc rad, seed).// Power (<= 1.5x): thicker, longer smear, an afterimage crescent, more sparks. Evolved (Tempest): three// blade echoes spinning round the full circle + wind streaks + the evolve element's motif.// The interior stays clear (enemies inside the swing stay readable). Hot parts > 1.0 feed the HDR bloom.
const float EXTENT = 1.15; // keep in sync with BladeSlashLook.EXTENT
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
void fragment() { float t = clamp(v_col.a, 0.0, 1.0); bool evolved = v_cus.x >= 1.5; float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0); float half_arc = clamp(v_cus.z, 0.1, TAU) * 0.5; float seed = v_cus.w; vec2 p = (UV - 0.5) * 2.0 * EXTENT; float d = length(p); if (d > EXTENT) { discard; } float a = atan(p.y, p.x); // 0 = swing direction float in_arc = step(abs(a), half_arc); // half_arc >= PI (a 360 swing) covers every angle
// Sweep: the leading edge crosses the arc over the first 60% of life; the smear decays behind it. float lead = mix(-half_arc, half_arc, smoothstep(0.0, 0.6, t)); float behind = lead - a; // > 0 = already swept float trail = max(half_arc * mix(0.30, 0.45, power), 0.05); float smear = in_arc * step(0.0, behind) * exp(-behind / trail); float band_w = mix(0.16, 0.24, power); // 1.5x at full power float band = exp(-pow((d - 0.86) / band_w, 2.0)); float clear_in = smoothstep(0.25, 0.55, d); float body = smear * band * clear_in; float lead_edge = in_arc * exp(-pow(behind / 0.07, 2.0)) * band;
vec3 col = v_col.rgb * body * 1.3 + vec3(1.0, 0.98, 0.92) * lead_edge * 2.2;
// Afterimage crescent (power): a fainter copy lagging behind, slightly inside the reach. float ai = smoothstep(0.3, 0.8, power); float behind2 = behind - half_arc * 0.35; float echo = in_arc * step(0.0, behind2) * exp(-behind2 / 0.25) * exp(-pow((d - 0.72) / band_w, 2.0)); col += v_col.rgb * echo * 0.6 * ai;
// Sparks thrown off the edge as the lead passes (3 at power 0, up to 8). float nsp = 3.0 + floor(power * 5.0); for (int k = 0; k < 8; k++) { if (float(k) >= nsp) { break; } float r1 = hash21(vec2(float(k), seed)); float r2 = hash21(vec2(seed, float(k) + 7.0)); float sa = mix(-half_arc, half_arc, r1); float born = (sa + half_arc) / (2.0 * half_arc) * 0.6; float age = clamp((t - born) / 0.4, 0.0, 1.0); if (age <= 0.0 || age >= 1.0) { continue; } vec2 q = p - vec2(cos(sa), sin(sa)) * (0.86 + age * (0.15 + 0.2 * r2)); col += vec3(1.0, 0.9, 0.7) * exp(-dot(q, q) / 0.0009) * (1.0 - age) * 2.0; }
// Tempest (evolved): three blade echoes spinning round the full circle + wind streaks. if (evolved) { float spin = t * 9.0; for (int e = 0; e < 3; e++) { float bh = mod(spin + float(e) * TAU / 3.0 - a, TAU); // angle behind echo e's tip col += v_col.rgb * exp(-bh / 0.7) * band * clear_in * 0.9; col += vec3(1.0) * exp(-pow(bh / 0.05, 2.0)) * band * 1.4; } float streak = smoothstep(0.72, 0.9, vnoise(vec2(a * 6.0 - t * 10.0, d * 3.0))); streak *= smoothstep(0.4, 0.9, d) * (1.0 - smoothstep(1.0, 1.12, d)); col += vec3(0.8, 0.9, 1.0) * streak * 0.8; }
// Crystal accents on the blade body; the evolved form adds its evolve element at full strength. float mask = clamp(body + lead_edge, 0.0, 1.0); float edge = clamp(lead_edge + (evolved ? band * 0.5 : 0.0), 0.0, 1.0); int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, edge, t, seed, TIME); col = col * acc.a + acc.rgb; if (evolved) { vec4 em = accent_motif(evolve_accent(acode), p, clamp(mask + band * clear_in, 0.0, 1.0), edge, 1.0, t, seed + 3.0, TIME); col = col * em.a + em.rgb; } COLOR = vec4(col * pow(1.0 - t, 1.4), 1.0);}- Step 5: FxManager — registry, dispatch, legacy arc fix, tick
V2_LOOKS becomes:
const V2_LOOKS := {"blade": "BladeSlashLook"}At the end of _ready():
# Weapon FX v2 looks (one MultiMesh draw call each; idle = hidden = zero draws). _add_v2_layer(BladeSlashLook.new(), ["blade"])Replace the "slash": arm in consume:
"slash": # Melee swing. v2: the weapon's own look (real arc, grows with power, transforms when # evolved). Legacy: the bright crescent node, now honouring the event's arc too (bug 1). var slpos: Vector2 = ev["pos"] var sldir: Vector2 = ev.get("dir", Vector2.RIGHT) var slr: float = float(ev.get("range", 160.0)) var slcol := ElementPalette.color_for(_content, ev.get("element", -1)) var slwid := String(ev.get("wid", "blade")) if v2_active() and V2_LOOKS.has(slwid): v2_look(slwid).spawn_event(ev, slcol, _fx_rng.randf()) else: _spawn_slash(slpos, sldir, slr, slcol, deg_to_rad(float(ev.get("arc", 97.4))))Legacy node: change _SlashNode.reset’s signature to func reset(pos: Vector2, dir: Vector2, r: float, c: Color, span_rad: float) -> void: and add span = clampf(span_rad, 0.1, TAU) as its first line. Change _spawn_slash to func _spawn_slash(pos: Vector2, dir: Vector2, r: float, col: Color, span_rad: float = 1.7) -> void: with sl.reset(pos, dir, r, col, span_rad). Add:
func active_slashes() -> Array: return _slash_activetests/test_weapon_fx_guards.gd: delete the "blade": ... line from PENDING_V2_LOOKS.
Accent slot tables (Task 7 fix 804fa4e): no explicit accent_reset() call is needed for FxManager-owned event looks. main._new_run() frees and rebuilds fx_layer (it is not in the persistent-children list), and WeaponFxLayer._init calls accent_reset(), so every run starts with an empty table. Within one run an event look sees at most rungs + 2 distinct codes for its own wid (rungs only accumulate), well under the 15 free slots. Don’t add a mid-run reset: live effects would read code 0 until they expire.
- Step 6: Run the tests
Run: godot --headless --path . --import, then -gselect=test_weapon_fx_looks, -gselect=test_fx_manager, -gselect=test_weapon_fx_guards, -gselect=test_plasma_nova.
Expected: all pass. test_fx_manager’s child count now includes 1 v2 layer.
- Step 7: Render the grid + in-game shots
WEAPON=blade godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobileWEAPON=blade V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscnWEAPON=blade V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscngit diff --stat project.godot; git checkout -- project.godotRead each PNG and check these items. Iterate on shader constants until every one holds (the bh-vfx-iteration loop):
-
v1 row: legacy crescent. EVOLVED row: a full 360° whirl, not a crescent.
-
The sweep runs across the frames in each row, and the lead edge is white-hot.
-
p0 → +blood → +fire: visibly thicker/longer each step, but never more than ~1.5× p0. The blood droplets then fire embers appear.
-
The interior stays dark, with enemy dots readable through the swing.
-
If the sweep runs the wrong way or is mirrored, flip
p.y(see the 2D gotcha at the top). -
Step 8: Commit —
feat(fx-v2): Blade look (real arc, grows with power, Tempest whirl) + v2 dispatch; legacy crescent honours arc
Task 10: Scythe look
Section titled “Task 10: Scythe look”Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).
Files:
- Create:
fx/weapons/scythe_reap_look.gd,fx/weapons/scythe_reap.gdshader - Modify:
fx/fx_manager.gd(V2_LOOKS,_ready),tests/test_weapon_fx_guards.gd(delete"scythe"from pending) - Test:
tests/test_weapon_fx_looks.gd
Interfaces:
-
Consumes: as Task 9. The
slasharm already routes byev.wid, so no dispatch change is needed. -
Produces:
class_name ScytheReapLook extends WeaponFxLayerwithEXTENT := 1.15,CAP := 8,spawn_event(ev, tint, seed) -> bool. -
Step 1: Write the failing tests (append to
tests/test_weapon_fx_looks.gd)
func test_scythe_routes_to_its_own_look() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.consume([_slash_ev("scythe", 360.0, true, 0.3, 0b011)]) assert_eq(fx.v2_look("scythe").active_count(), 1) assert_eq(fx.v2_look("blade").active_count(), 0, "a scythe swing is not a blade swing") var b: PackedFloat32Array = fx.v2_look("scythe").debug_buffer() assert_almost_eq(b[14], TAU, 1e-4, "Reaping draws the full ring (bug 1)") assert_eq(fx.v2_look("scythe").accent_codes()[int(b[13])], ElementAccents.code("scythe", 0b011, true), "custom.y = slot -> code")
func test_scythe_look_is_bounded_and_expires() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() var many: Array = [] for _i in range(30): many.append(_slash_ev("scythe", 120.0)) fx.consume(many) assert_lte(fx.v2_look("scythe").active_count(), ScytheReapLook.CAP) for _i in range(90): fx.advance(1.0 / 60.0) assert_eq(fx.v2_look("scythe").active_count(), 0)-
Step 2: Run
-gselect=test_weapon_fx_looks. Expected: FAIL, becausev2_look("scythe")is null. -
Step 3: Write
fx/weapons/scythe_reap_look.gd
class_name ScytheReapLookextends WeaponFxLayer
# Scythe v2 (spec § Batch 1): a heavier violet sweep with a ghost-green razor edge; render-side wisps# drift from the rim toward the pilot inside the swept sector (spec: acceptable for batch 1 even though# they don't originate from real hits). Evolved (Reaping): an eclipse ring + spiral wisps falling# inward, carrying the evolve rung's element (blood).
const SHADER_PATH := "res://fx/weapons/scythe_reap.gdshader"const CAP := 8const EXTENT := 1.15 # keep in sync with the shader's EXTENTconst LIFE := 0.30const LIFE_GROW := 0.35const LIFE_EVOLVED := 0.45
func _init() -> void: super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool: var power := float(ev.get("power", 0.0)) var evolved := bool(ev.get("evolved", false)) var dir: Vector2 = ev.get("dir", Vector2.RIGHT) var reach := float(ev.get("range", 160.0)) var slot := accent_slot(ElementAccents.code("scythe", int(ev.get("rungs", 0)), evolved)) # half-float safe (Task 7 fix) var life := LIFE_EVOLVED if evolved else LIFE * (1.0 + LIFE_GROW * power) return spawn(ev["pos"], dir.angle(), Vector2.ONE * reach * EXTENT, tint, Vector4(power + (2.0 if evolved else 0.0), slot, deg_to_rad(float(ev.get("arc", 120.0))), seed), life)- Step 4: Write
fx/weapons/scythe_reap.gdshader
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Scythe v2 (weapon FX v2 batch 1). Same instance contract as blade_slash.gdshader:// COLOR = (element tint, a = life t), INSTANCE_CUSTOM = (power + 2*evolved, accent slot, arc rad, seed),// quad = reach * EXTENT, +x = swing direction, d == 1 = the hit edge.// Violet body (element tint blended in) + ghost-green razor edge + wisps drifting inward inside the sector.// Evolved (Reaping): eclipse corona on the reach circle + three spiral arms falling inward.
const float EXTENT = 1.15; // keep in sync with ScytheReapLook.EXTENT
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
void fragment() { float t = clamp(v_col.a, 0.0, 1.0); bool evolved = v_cus.x >= 1.5; float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0); float half_arc = clamp(v_cus.z, 0.1, TAU) * 0.5; float seed = v_cus.w; vec2 p = (UV - 0.5) * 2.0 * EXTENT; float d = length(p); if (d > EXTENT) { discard; } float a = atan(p.y, p.x); float in_arc = step(abs(a), half_arc);
// Heavier, slower sweep than the blade. float lead = mix(-half_arc, half_arc, smoothstep(0.0, 0.5, t)); float behind = lead - a; float smear = in_arc * step(0.0, behind) * exp(-behind / max(half_arc * mix(0.45, 0.68, power), 0.05)); float band_w = mix(0.14, 0.21, power); // 1.5x at full power float band = exp(-pow((d - 0.88) / band_w, 2.0)); float razor = exp(-pow((d - 0.98) / 0.03, 2.0)); float clear_in = smoothstep(0.2, 0.5, d); vec3 violet = mix(vec3(0.45, 0.12, 0.85), v_col.rgb, 0.35); vec3 ghost = vec3(0.45, 1.0, 0.7); float body = smear * band * clear_in; vec3 col = violet * body * 1.25 + ghost * smear * razor * 1.9;
// Wisps: soft ghost-green blobs drifting from the rim toward the pilot inside the swept sector. float nw = 4.0 + floor(power * 4.0); for (int k = 0; k < 8; k++) { if (float(k) >= nw) { break; } float r1 = hash21(vec2(float(k) * 1.7, seed)); float r2 = hash21(vec2(seed + 3.1, float(k))); float wa = mix(-half_arc, half_arc, r1); float age = clamp(t * 1.4 - r2 * 0.4, 0.0, 1.0); if (age <= 0.0) { continue; } vec2 q = p - vec2(cos(wa), sin(wa)) * mix(0.92, 0.35, age); col += ghost * exp(-dot(q, q) / mix(0.004, 0.012, age)) * (1.0 - age) * 0.9; }
float corona = 0.0; if (evolved) { // Reaping: an eclipse corona on the reach circle + three spiral arms falling inward. corona = exp(-pow((d - 0.95) / 0.05, 2.0)) * (0.7 + 0.6 * fbm(vec2(a * 4.0, TIME * 1.5))); col += mix(ghost, v_col.rgb, 0.3) * corona * 1.6 * (1.0 - t * 0.6); float arm = pow(0.5 + 0.5 * cos(3.0 * a + 9.0 * d - t * 14.0), 12.0); arm *= smoothstep(0.3, 0.9, d) * (1.0 - smoothstep(0.95, 1.05, d)); col += violet * arm * 1.1; }
float mask = clamp(body + razor * smear + corona * 0.5, 0.0, 1.0); float edge = clamp(razor * in_arc + corona, 0.0, 1.0); int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, edge, t, seed, TIME); col = col * acc.a + acc.rgb; if (evolved) { vec4 em = accent_motif(evolve_accent(acode), p, clamp(mask + band * clear_in, 0.0, 1.0), edge, 1.0, t, seed + 3.0, TIME); col = col * em.a + em.rgb; } COLOR = vec4(col * pow(1.0 - t, 1.2), 1.0);}- Step 5: Register
V2_LOOKS gets "scythe": "ScytheReapLook". In _ready, add _add_v2_layer(ScytheReapLook.new(), ["scythe"]). In the guards file, delete "scythe" from PENDING_V2_LOOKS.
- Step 6: Tests + grid
Run --import, then -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager. Then run WEAPON=scythe compare and WEAPON=scythe V2=1 EVOLVE=1 LEVELS=5 ingame, and restore project.godot. Check against the grid:
-
The body reads violet with a ghost-green edge, and the tint is still visible.
-
Wisps move inward across the frames.
-
+blood adds droplets, and +void bites dark holes.
-
EVOLVED shows the eclipse ring plus spiral arms, covering 360°.
-
Step 7: Commit —
feat(fx-v2): Scythe look (ghost edge, inward wisps, Reaping eclipse)
Task 11: Nova v2 look (+ Supernova; Scar Tissue discharge scaled by its own power)
Section titled “Task 11: Nova v2 look (+ Supernova; Scar Tissue discharge scaled by its own power)”Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).
Files:
- Create:
fx/weapons/nova_burst_look.gd,fx/weapons/nova_burst.gdshader - Modify:
fx/fx_manager.gd(V2_LOOKS,_ready, the"nova"arm at lines 529–534),tests/test_weapon_fx_guards.gd(delete"nova"from pending) - Test:
tests/test_weapon_fx_looks.gd
Interfaces:
-
Consumes:
fx_noise/ accents includes; stampednovaevents from bothweapon_nova.gd(widnova) andweapon_scar_tissue.gd(widscar_tissue). -
Produces:
class_name NovaBurstLook extends WeaponFxLayerwithEXTENT := 1.35,CAP := 8,LIFE := 0.46,LIFE_EVOLVED := 0.70,spawn_event(ev, tint, seed) -> bool. For widscar_tissueit draws the Nova look at Scar Tissue’s own power, never evolved, with no accents (spec: “keeps the Nova look, scaled by its own power”). -
Step 1: Write the failing tests (append)
func _nova_ev(wid: String, power: float, evolved: bool, rungs: int = 0) -> Dictionary: return {"kind": "nova", "pos": Vector2(40, -20), "radius": 180.0, "element": 0, "wid": wid, "power": power, "evolved": evolved, "rungs": rungs}
func test_nova_v2_replaces_the_plasma_node() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.consume([_nova_ev("nova", 0.6, true, 0b011)]) assert_eq(fx.v2_look("nova").active_count(), 1) assert_eq(fx.nova_active_count(), 0, "no v1 plasma node") assert_eq(fx.ring_active_count(), 0, "no legacy ring") var b: PackedFloat32Array = fx.v2_look("nova").debug_buffer() assert_almost_eq(b[12], 2.6, 1e-5) assert_eq(fx.v2_look("nova").accent_codes()[int(b[13])], ElementAccents.code("nova", 0b011, true), "custom.y = slot -> code")
func test_scar_tissue_discharge_is_nova_look_at_its_own_power() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.consume([_nova_ev("scar_tissue", 0.4, true, 0b111)]) var b: PackedFloat32Array = fx.v2_look("nova").debug_buffer() assert_almost_eq(b[12], 0.4, 1e-5, "Scar Tissue's own power, never the Supernova transform") assert_almost_eq(b[13], 0.0, 1e-5, "slot 0: no Nova crystal accents on Scar Tissue's discharge")
func test_nova_v2_off_keeps_the_plasma_node() -> void: VisualLook.weapon_fx_v2 = false VisualLook.plasma_nova = true var fx := _make() fx.consume([_nova_ev("nova", 0.6, false)]) assert_eq(fx.nova_active_count(), 1) assert_eq(fx.v2_look("nova").active_count(), 0)
func test_nova_v2_is_essential_and_expires() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.enabled = false fx.consume([_nova_ev("nova", 0.0, true)]) assert_eq(fx.v2_look("nova").active_count(), 1, "weapon feedback survives L5") for _i in range(60): fx.advance(1.0 / 60.0) assert_eq(fx.v2_look("nova").active_count(), 0, "even the lingering Supernova expires within 1s")-
Step 2: Run
-gselect=test_weapon_fx_looks. Expected: FAIL. -
Step 3: Write
fx/weapons/nova_burst_look.gd
class_name NovaBurstLookextends WeaponFxLayer
# Nova v2 (spec § Batch 1): the Plasma Nova fireball, grown by power -- denser body, more embers,# longer flame tongues (<= 1.5x) -- and on evolution a Supernova: blue-white core, a second delayed# shock ring and a lingering corona, carrying the evolve rung's element (light).# Scar Tissue's discharge reuses the "nova" event: it gets this look at Scar Tissue's OWN power, never# evolved and without Nova's crystal accents, until batch 5 gives it its own look.
const SHADER_PATH := "res://fx/weapons/nova_burst.gdshader"const CAP := 8const EXTENT := 1.35 # quad half-size in AoE radii -- keep in sync with the shader's EXTENTconst LIFE := 0.46 # == PlasmaNovaNode.LIFEconst LIFE_EVOLVED := 0.70 # the Supernova corona lingers
func _init() -> void: super(load(SHADER_PATH) as Shader, CAP)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool: var own := String(ev.get("wid", "nova")) == "nova" var power := float(ev.get("power", 0.0)) var evolved := own and bool(ev.get("evolved", false)) var slot: float = accent_slot(ElementAccents.code("nova", int(ev.get("rungs", 0)), evolved)) if own else 0.0 # slot 0 = no accents var r := float(ev.get("radius", 100.0)) return spawn(ev["pos"], seed * TAU, Vector2.ONE * r * EXTENT, tint, Vector4(power + (2.0 if evolved else 0.0), slot, seed, 0.0), LIFE_EVOLVED if evolved else LIFE)- Step 4: Write
fx/weapons/nova_burst.gdshader
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Nova v2 (weapon FX v2 batch 1). The Plasma Nova recipe (fx/weapons/plasma_nova.gdshader) with power// growth and a Supernova transform, as ONE MultiMesh instance per burst (embers drawn in-shader).// COLOR = (element tint, a = life t). INSTANCE_CUSTOM = (power + 2*evolved, accent slot, seed, 0).// Quad = radius * EXTENT, so d == 1 is exactly the damage edge. Rotation is random per burst (seed).
const float EXTENT = 1.35; // keep in sync with NovaBurstLook.EXTENT
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
// Heat 0..1 -> violet (tinted by element) .. magenta .. orange .. yellow .. white (== plasma_nova).vec3 heat_ramp(float h, vec3 tint) { vec3 violet = mix(vec3(0.42, 0.10, 1.0), tint, 0.30); vec3 c = mix(violet, vec3(1.0, 0.14, 0.62), smoothstep(0.02, 0.30, h)); c = mix(c, vec3(1.0, 0.40, 0.06), smoothstep(0.30, 0.55, h)); c = mix(c, vec3(1.0, 0.80, 0.28), smoothstep(0.55, 0.80, h)); return mix(c, vec3(1.0, 0.97, 0.88), smoothstep(0.80, 1.0, h));}
// Supernova: deep blue (tinted) .. blue .. cyan-white .. white.vec3 super_ramp(float h, vec3 tint) { vec3 deep = mix(vec3(0.20, 0.25, 1.0), tint, 0.25); vec3 c = mix(deep, vec3(0.35, 0.65, 1.0), smoothstep(0.02, 0.35, h)); c = mix(c, vec3(0.70, 0.92, 1.0), smoothstep(0.35, 0.70, h)); return mix(c, vec3(1.0), smoothstep(0.70, 1.0, h));}
void fragment() { float t = clamp(v_col.a, 0.0, 1.0); bool evolved = v_cus.x >= 1.5; float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0); float seed = v_cus.z; vec2 p = (UV - 0.5) * 2.0 * EXTENT; float d = length(p); if (d > EXTENT) { discard; } vec2 dir = p / max(d, 1e-4); float front = 1.0 - pow(1.0 - t, 2.4);
vec2 flow = vec2(TIME * 1.9, -TIME * 1.4) + seed * 11.0; float n_edge = fbm(dir * 5.5 + flow); float n_body = fbm(p * 4.2 - dir * TIME * 2.6 + vec2(0.0, TIME * 0.7) + seed * 7.0); float edge = front * (0.94 + (n_edge - 0.44) * 0.20); float x = d - edge; float n_tongue = fbm(dir * 9.0 + vec2(-x * 4.0 + TIME * 2.5, TIME * 0.9) + seed * 3.0);
float shell_w = mix(0.07, 0.20, t); float tongue_w = 0.012 + mix(0.20, 0.30, power) * pow(n_tongue, 2.2); // tongues 1.5x at power 1 float dens = x < 0.0 ? exp(x / shell_w) : exp(-x / tongue_w); dens *= 0.25 + 1.5 * smoothstep(mix(0.15, 0.08, power), 0.75, n_body); // denser body with power
float core_t = 1.0 - smoothstep(0.0, 0.45, t); float core = exp(-d * d / (0.02 + 0.22 * t)) * core_t * core_t; float cool = 1.0 - 0.85 * t; float inside = x < 0.0 ? 1.0 : 0.35; float h = clamp(max(core * 1.1, dens * cool * inside * (0.75 + 0.35 * n_body)), 0.0, 1.0); vec3 ramp = evolved ? super_ramp(h, v_col.rgb) : heat_ramp(h, v_col.rgb); float fade = pow(1.0 - t, 1.3); vec3 col = ramp * (dens * fade * 1.25 + core * 1.6) * (0.85 + 0.9 * h);
// Embers: 14 at power 0 (== the v1 Embers node), up to 24 at power 1. Closed-form in progress. float n_emb = 14.0 + floor(10.0 * power); vec3 hot = mix(vec3(1.0, 0.85, 0.45), vec3(1.0, 0.35, 0.1), clamp(t * 1.6, 0.0, 1.0)); vec3 ember_c = evolved ? vec3(0.75, 0.9, 1.0) : mix(hot, vec3(0.95, 0.2, 0.7), clamp(t * 1.6 - 0.6, 0.0, 1.0)); for (int k = 0; k < 24; k++) { if (float(k) >= n_emb) { break; } float ang = (float(k) + hash21(vec2(float(k), seed)) * 0.8) / n_emb * TAU; float spd = mix(0.85, 1.35, hash21(vec2(seed, float(k) + 3.0))); float sz = mix(0.018, 0.036, hash21(vec2(float(k) + 9.0, seed))) * (1.0 - 0.5 * t); vec2 q = p - vec2(cos(ang), sin(ang)) * front * spd; float r2 = dot(q, q); col += (ember_c * 1.6 * exp(-r2 / (sz * sz)) + vec3(1.6, 1.5, 1.3) * exp(-r2 / (sz * sz * 0.09))) * (1.0 - t); }
float corona = 0.0; if (evolved) { // Supernova: a second, delayed shock ring + a corona that lingers on the damage edge. float t2 = clamp((t - 0.22) / 0.78, 0.0, 1.0); float front2 = (1.0 - pow(1.0 - t2, 2.4)) * 1.05; col += vec3(0.8, 0.92, 1.0) * exp(-pow((d - front2) / 0.035, 2.0)) * step(0.001, t2) * (1.0 - t2) * 2.0; corona = exp(-pow((d - 1.0) / 0.09, 2.0)) * smoothstep(0.25, 0.55, t) * (1.0 - t) * (0.6 + 0.6 * n_edge); col += mix(vec3(0.6, 0.8, 1.0), v_col.rgb, 0.3) * corona * 1.4; }
float mask = clamp(dens * fade + corona, 0.0, 1.0); float rim = clamp(exp(-x * x / 0.004), 0.0, 1.0); int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, rim, t, seed, TIME); col = col * acc.a + acc.rgb; if (evolved) { vec4 em = accent_motif(evolve_accent(acode), p, mask, max(rim, corona), 1.0, t, seed + 3.0, TIME); col = col * em.a + em.rgb; } COLOR = vec4(col, 1.0);}- Step 5: Dispatch + register
Replace the "nova": arm:
"nova": var ncol := ElementPalette.color_for(_content, ev.get("element", -1)) if v2_active() and V2_LOOKS.has("nova"): v2_look("nova").spawn_event(ev, ncol, _fx_rng.randf()) # Nova + Scar Tissue discharge elif VisualLook.plasma_nova: _spawn_plasma_nova(ev["pos"], float(ev.get("radius", 100.0)), ncol) else: _spawn_ring(ev["pos"], float(ev.get("radius", 100.0)), ncol)V2_LOOKS gets "nova": "NovaBurstLook". _ready: _add_v2_layer(NovaBurstLook.new(), ["nova"]). Delete "nova" from PENDING_V2_LOOKS. Scar Tissue stays pending for batch 5: its own look is still to come.
- Step 6: Tests + grid
Run the --import / test_weapon_fx_looks / test_plasma_nova / test_fx_manager / test_weapon_fx_guards suites. Then run WEAPON=nova compare, WEAPON=nova V2=1 EVOLVE=1 LEVELS=5 ingame and WEAPON=nova V2=0 ingame, and restore project.godot. Check the grid:
-
p0 ≈ the v1 Plasma Nova. It must not look worse; this is the look Chris approved on 2026-09-27.
-
The +fire rows get denser with longer tongues and more embers, within 1.5×.
-
EVOLVED is blue-white with a visible second ring and a corona that is still lit in the last column.
-
The interior clears fast, and enemy dots inside stay readable.
-
Step 7: Commit —
feat(fx-v2): Nova look (power-grown fireball, Supernova); Scar Tissue discharge at its own power
Task 12: Orbit Shards look (all pilots) + main.gd wiring
Section titled “Task 12: Orbit Shards look (all pilots) + main.gd wiring”Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).
Files:
- Create:
fx/weapons/orbit_shards_look.gd,fx/weapons/orbit_shards.gdshader - Modify:
main.gd(var decl near line 242, setup after the orbit-shard block at ~1095–1109, sync block at ~1810–1816),fx/fx_manager.gd(V2_LOOKS),tests/test_weapon_fx_guards.gd(delete"orbit"from pending),tools/weapon_vfx/compare.gd(enable the"orbit"arm +_orbit_cellfrom Task 8) - Test:
tests/test_weapon_fx_looks.gd
Interfaces:
-
Consumes:
WeaponOrbit.positions: Array[Vector2],.orbit_radius,.spin_deg;PilotArsenal.orbit,.orbit_element_idx;Sim.pilots,Sim.content;WeaponVfx.power / rungs / is_evolved;ElementAccents.code;WeaponFxLayer.begin/put/commit/clear/ensure_capacity/accent_slot/accent_reset. -
Produces:
class_name OrbitShardsLook extends WeaponFxLayerwithRING_EXTENT := 1.3,SHARD_HALF := 14.0,SHARD_GROW := 0.5,EVOLVED_HALF := 20.0,SPIN_REF := 270.0static entries_for_sim(sim: Sim) -> Arrayof{center, positions, radius, spin_norm, tint, power, evolved, code}(code= the raw int accent code;sync_entriesturns it into a slot)sync_entries(entries: Array) -> voidFxManager.V2_LOOKS["orbit"] == "main:OrbitShardsLook"
-
Step 1: Write the failing tests (append)
func _orbit_sim() -> Sim: var sim := Sim.new(1, ContentLoader.load_from_path("res://data/bible.json")) sim.player.pos = Vector2(100, 100) sim.player.arsenal.orbit.update(sim, sim.player, Sim_Const.DT) return sim
func test_orbit_entries_follow_the_real_weapon() -> void: var sim := _orbit_sim() var es := OrbitShardsLook.entries_for_sim(sim) assert_eq(es.size(), 1) assert_eq((es[0]["positions"] as Array).size(), sim.player.arsenal.orbit.positions.size()) assert_almost_eq(float(es[0]["radius"]), sim.player.arsenal.orbit.orbit_radius, 1e-4) assert_false(es[0]["evolved"])
func test_orbit_entries_cover_every_pilot() -> void: var sim := _orbit_sim() var p2 := sim.add_pilot() p2.arsenal.orbit.update(sim, p2, Sim_Const.DT) assert_eq(OrbitShardsLook.entries_for_sim(sim).size(), 2, "co-op pilots get their own ring (the legacy sprites only drew P1)")
func test_orbit_look_packs_ring_then_one_instance_per_shard() -> void: var sim := Sim.new(1, ContentLoader.load_from_path("res://data/bible.json")) sim.player.arsenal.orbit.evolve() sim.player.arsenal.orbit.update(sim, sim.player, Sim_Const.DT) var look := OrbitShardsLook.new() add_child_autofree(look) look.sync_entries(OrbitShardsLook.entries_for_sim(sim)) var n := sim.player.arsenal.orbit.positions.size() assert_eq(look.multimesh.visible_instance_count, 1 + n) var b := look.debug_buffer() assert_almost_eq(b[15], float(n), 1e-5, "ring: custom.w = shard count") assert_almost_eq(b[WeaponFxLayer.STRIDE + 15], -1.0, 1e-5, "shard: custom.w = -1") assert_gte(b[12], 2.0, "evolved -> power + 2") var code := int(OrbitShardsLook.entries_for_sim(sim)[0]["code"]) assert_eq(look.accent_codes()[int(b[13])], code, "ring custom.y = slot -> the orbit accent code") assert_eq(b[WeaponFxLayer.STRIDE + 13], b[13], "shards share the ring's slot")
func test_orbit_look_idle_draws_nothing() -> void: var look := OrbitShardsLook.new() add_child_autofree(look) look.sync_entries([]) assert_false(look.visible)-
Step 2: Run
-gselect=test_weapon_fx_looks. Expected: FAIL (OrbitShardsLooknot declared). -
Step 3: Write
fx/weapons/orbit_shards_look.gd
class_name OrbitShardsLookextends WeaponFxLayer
# Orbit Shards v2 (spec § Batch 1): faceted crystals with a glint + a motion-blur arc trail behind# each shard; a faint link ring appears with power; Event Horizon (evolved) = singularities + a lensing# ring, carrying the evolve rung's element (void). Persistent (not an fx_event look): main.gd calls# sync_entries(entries_for_sim(sim)) every frame while v2 is active. ONE layer for every pilot: per# pilot one RING instance (trails/link/lens) + one SHARD instance per shard -- one draw call total.
const SHADER_PATH := "res://fx/weapons/orbit_shards.gdshader"const RING_EXTENT := 1.3 # ring quad half-size in orbit radii -- keep in sync with the shaderconst SHARD_HALF := 14.0 # px at power 0 (~ the legacy GlowTexture sprite at scale 0.6)const SHARD_GROW := 0.5 # 1.5x at power 1 (grow budget)const EVOLVED_HALF := 20.0const SPIN_REF := 270.0 # spin_deg that reads as "full speed" for the trail length
func _init() -> void: super(load(SHADER_PATH) as Shader, 16)
static func entries_for_sim(sim: Sim) -> Array: var out := [] for pilot in sim.pilots: var ars: PilotArsenal = pilot.arsenal var o: WeaponOrbit = ars.orbit if o == null or o.positions.is_empty(): continue var evolved := WeaponVfx.is_evolved(ars, "orbit") out.append({"center": pilot.pos, "positions": o.positions, "radius": o.orbit_radius, "spin_norm": clampf(o.spin_deg / SPIN_REF, 0.0, 1.0), "tint": ElementPalette.color_for(sim.content, ars.orbit_element_idx), "power": WeaponVfx.power(ars, "orbit"), "evolved": evolved, "code": ElementAccents.code("orbit", WeaponVfx.rungs(ars, "orbit"), evolved)}) return out
func sync_entries(entries: Array) -> void: var need := 0 for e in entries: need += 1 + (e["positions"] as Array).size() ensure_capacity(need) accent_reset() # persistent look: every instance is re-put each frame, so re-slotting per sync never overflows begin() for e in entries: var c: Vector2 = e["center"] var pos: Array = e["positions"] var evolved: bool = e["evolved"] var power: float = e["power"] var pe := power + (2.0 if evolved else 0.0) var tint: Color = e["tint"] var slot := accent_slot(int(e["code"])) var phase := 0.0 if pos.is_empty() else ((pos[0] as Vector2) - c).angle() put(c, 0.0, Vector2.ONE * float(e["radius"]) * RING_EXTENT, Color(tint.r, tint.g, tint.b, float(e["spin_norm"])), Vector4(pe, slot, phase, float(pos.size()))) var half := EVOLVED_HALF if evolved else SHARD_HALF * (1.0 + SHARD_GROW * power) for k in range(pos.size()): var sp: Vector2 = pos[k] put(sp, (sp - c).angle() + PI * 0.5, Vector2.ONE * half, Color(tint.r, tint.g, tint.b, 1.0), Vector4(pe, slot, float(k), -1.0)) commit()- Step 4: Write
fx/weapons/orbit_shards.gdshader
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Orbit Shards v2 (weapon FX v2 batch 1). ONE layer, two instance kinds (INSTANCE_CUSTOM.w):// RING (w = shard count >= 1): one per pilot, quad = orbit_radius * RING_EXTENT, so d == 1 is the// orbit; COLOR.a = spin_norm; custom = (power + 2*evolved, accent slot, phase of shard 0, n).// Draws each shard's motion-blur arc trail, the power link ring, and Event Horizon's lens.// SHARD (w = -1): quad = shard half-size, rotated so +x is the orbit tangent; custom.z = shard index.// Faceted crystal + glint; evolved = a singularity (dark core, hot accretion rim).
const float RING_EXTENT = 1.3; // keep in sync with OrbitShardsLook.RING_EXTENT
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
void fragment() { bool evolved = v_cus.x >= 1.5; float power = clamp(evolved ? v_cus.x - 2.0 : v_cus.x, 0.0, 1.0); vec3 col = vec3(0.0); if (v_cus.w >= 0.5) { // ── RING ── vec2 p = (UV - 0.5) * 2.0 * RING_EXTENT; float d = length(p); if (d > RING_EXTENT) { discard; } float a = atan(p.y, p.x); float n = v_cus.w; float radial = exp(-pow((d - 1.0) / mix(0.05, 0.075, power), 2.0)); // 1.5x at power 1 float trail_len = mix(0.35, 0.55, power) * (0.6 + 0.8 * v_col.a); float tr = 0.0; for (int k = 0; k < 6; k++) { if (float(k) >= n) { break; } float bh = mod(v_cus.z + float(k) * TAU / n - a, TAU); // angle behind shard k tr += exp(-bh / trail_len); } col = v_col.rgb * tr * radial * 1.1; col += v_col.rgb * smoothstep(0.35, 0.8, power) * exp(-pow((d - 1.0) / 0.015, 2.0)) * 0.6; // link ring float mask = clamp(tr * radial, 0.0, 1.0); if (evolved) { // Event Horizon: a shimmering lensing band on the orbit float lens = exp(-pow((d - 1.0) / 0.14, 2.0)); float shimmer = 0.3 + 0.7 * fbm(vec2(a * 6.0 - TIME * 2.0, d * 4.0)); col += mix(vec3(0.55, 0.2, 1.0), v_col.rgb, 0.3) * lens * shimmer * 0.7; mask = clamp(mask + lens * 0.5, 0.0, 1.0); } int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, radial, 0.5, 0.0, TIME); col = col * acc.a + acc.rgb; if (evolved) { vec4 em = accent_motif(evolve_accent(acode), p, mask, radial, 1.0, 0.5, 1.0, TIME); col = col * em.a + em.rgb; } } else { // ── SHARD ── vec2 p = (UV - 0.5) * 2.0; float seed = v_cus.z; float mask; if (!evolved) { float dia = abs(p.x) + abs(p.y) * 1.6; // diamond, long axis along the orbit tangent float body = smoothstep(1.0, 0.85, dia); float facet = 0.55 + 0.35 * step(0.0, p.x * 0.7 + p.y) + 0.25 * step(0.0, p.y); float core = exp(-dot(p, p) / 0.05); float glint = pow(max(0.0, 1.0 - length(p - vec2(-0.25, -0.2)) * 4.0), 3.0) * (0.5 + 0.5 * sin(TIME * 6.0 + seed * 10.0)); float halo = exp(-dot(p, p) / 0.35) * 0.35; col = v_col.rgb * (body * facet * 1.3 + halo) + vec3(1.0) * (core * 1.2 + glint * 2.0) * body; mask = body; } else { // Event Horizon singularity: no core fill (reads dark) ringed by a hot accretion rim float r = length(p); float rim = exp(-pow((r - 0.5) / 0.08, 2.0)); float swirl = 0.5 + 0.5 * sin(atan(p.y, p.x) * 3.0 - TIME * 8.0 + r * 10.0); float disk = exp(-pow((r - 0.72) / 0.12, 2.0)) * swirl; col = vec3(1.0, 0.95, 1.0) * rim * 1.8 + mix(vec3(0.55, 0.2, 1.0), v_col.rgb, 0.4) * disk * 1.2; mask = clamp(rim + disk, 0.0, 1.0); } int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, mask, 0.5, seed, TIME); col = col * acc.a + acc.rgb; } COLOR = vec4(col, 1.0);}- Step 5: Wire main.gd
Var, after var orbit_shards: Array[Sprite2D] = []:
var orbit_look: OrbitShardsLook # weapon FX v2 orbit look (all pilots); swapped against orbit_shards per frameSetup, right after the orbit_shards creation block (after its closing orbit_shards.append(sh) loop):
# Weapon FX v2 orbit look: every pilot's shards + trails in ONE draw call. Drawn instead of the # orbit_shards sprites while fx_layer.v2_active() (see the sync block). orbit_look = OrbitShardsLook.new() add_child(orbit_look)Sync. Replace the if sim.orbit: shard block with:
var orbit_v2 := fx_layer.v2_active() and FxManager.V2_LOOKS.has("orbit") if orbit_v2: orbit_look.sync_entries(OrbitShardsLook.entries_for_sim(sim)) else: orbit_look.clear() if sim.orbit: for k in range(orbit_shards.size()): if not orbit_v2 and k < sim.orbit.positions.size(): orbit_shards[k].position = sim.orbit.positions[k] orbit_shards[k].visible = true else: orbit_shards[k].visible = falsefx/fx_manager.gd: V2_LOOKS gets "orbit": "main:OrbitShardsLook". Delete "orbit" from PENDING_V2_LOOKS. Enable the "orbit" arm and _orbit_cell in tools/weapon_vfx/compare.gd (text as in Task 8).
- Step 6: Tests + grid
Run --import, -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_main, -gselect=test_weapon_orbit. Then:
WEAPON=orbit godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobileWEAPON=orbit V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscnWEAPON=orbit V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscngit diff --stat project.godot; git checkout -- project.godotCheck:
-
Shards read as crystals, not blobs.
-
Each trail runs behind its shard. If the trails lead, the spin direction is flipped: negate
ain the ring branch. -
The link ring appears from about +lightning onward, and cold rime shows on the +cold rows.
-
EVOLVED shows 6 dark-cored singularities and a shimmering lens band.
-
In-game, the shards sit exactly on the real hit positions, compared against the v1 sprite shot.
-
Step 7: Commit —
feat(fx-v2): Orbit Shards look (crystals, trails, link ring, Event Horizon) for every pilot
Task 13: Player bullets (PlayerBoltRenderer), Blaster muzzle flash, Auto-Cannon impact sparks
Section titled “Task 13: Player bullets (PlayerBoltRenderer), Blaster muzzle flash, Auto-Cannon impact sparks”Amended 2026-09-29 after Task 7 fix (804fa4e): accent codes ride a per-layer slot uniform, not INSTANCE_CUSTOM (half-float safety on gl_compatibility).
Files:
- Create:
fx/weapons/player_bolt_renderer.gd,fx/weapons/player_bolt.gdshader,fx/weapons/blaster_flash_look.gd,fx/weapons/blaster_flash.gdshader - Modify:
main.gd(var decl near line 35, setup after line 1068, sync at line 1806),fx/fx_manager.gd(ESSENTIAL_KINDS,V2_LOOKS,_ready, two newconsumearms),tests/test_weapon_fx_guards.gd(delete"blaster"from pending),tools/weapon_vfx/compare.gd(enable the"blaster"arm +_blaster_cell) - Test:
tests/test_weapon_fx_looks.gd
Interfaces:
-
Consumes:
ProjPool.vfx/rungs/kind/context/data/pos/vel(Task 2), theblaster_fire(+dir,element) andbolt_impactevents (Task 3),main._proj_colors() -> PackedColorArray,main._visible_world_rect() -> Rect2. -
Produces:
class_name PlayerBoltRenderer extends WeaponFxLayerwithBASE_LEN := 18.0,BASE_WID := 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT,LEN_GROW := 0.5,PAD := 3.0,sync_bolts(pool: ProjPool, colors: PackedColorArray, cull: Rect2) -> voidclass_name BlasterFlashLook extends WeaponFxLayerwithspawn_event(ev, tint, seed) -> bool(muzzle) andspawn_impact(ev, tint, seed) -> boolFxManager.V2_LOOKS["blaster"] == "BlasterFlashLook". The bullets are main-drawn, pinned by the test below.
-
Step 1: Write the failing tests (append)
func _one_bolt_pool(vfx: float, rungs: int, ctx: String = "blaster") -> ProjPool: var pool := ProjPool.new(8) pool.add_proj(Vector2(5, 5), Vector2(300, 0), 4.0, 0.7, 1.0, 0, 0, 0, 0.0, 0.0, ctx, vfx, rungs) return pool
func _bolts() -> PlayerBoltRenderer: var br := PlayerBoltRenderer.new() add_child_autofree(br) return br
const BIG := Rect2(-1.0e5, -1.0e5, 2.0e5, 2.0e5)
func test_bolts_pack_vfx_code_kind_and_life() -> void: var br := _bolts() br.sync_bolts(_one_bolt_pool(1.3, 0b01), PackedColorArray([Color.RED]), BIG) var b := br.debug_buffer() assert_almost_eq(b[12], 1.3, 1e-5) assert_eq(br.accent_codes()[int(b[13])], ElementAccents.code("blaster", 0b01, true), "custom.y = slot -> code") assert_eq(int(b[14]), ProjPool.KIND_BLASTER) assert_almost_eq(b[15], 0.7, 1e-4, "remaining life mod 1 (noise seed, half-float safe)")
func test_bolts_cull_offscreen() -> void: var pool := _one_bolt_pool(0.0, 0) pool.add_proj(Vector2(5000, 5000), Vector2.RIGHT, 4.0, 1.0, 1.0) var br := _bolts() br.sync_bolts(pool, PackedColorArray([Color.RED, Color.RED]), Rect2(0, 0, 100, 100)) assert_eq(br.multimesh.visible_instance_count, 1)
func test_bolt_growth_stays_inside_the_budget() -> void: var br := _bolts() br.sync_bolts(_one_bolt_pool(0.0, 0), PackedColorArray([Color.RED]), BIG) var hx0 := br.debug_buffer()[0] - PlayerBoltRenderer.PAD br.sync_bolts(_one_bolt_pool(0.999, 0), PackedColorArray([Color.RED]), BIG) var hx1 := br.debug_buffer()[0] - PlayerBoltRenderer.PAD assert_gt(hx1, hx0, "grows with power") assert_lte(hx1 / hx0, 1.5 + 1e-4, "<= 1.5x grow budget")
func test_bolt_capacity_grows_with_the_pool() -> void: var pool := ProjPool.new(512) var cols := PackedColorArray() for i in range(300): pool.add_proj(Vector2(i, 0), Vector2.RIGHT, 4.0, 1.0, 1.0) cols.append(Color.WHITE) var br := _bolts() br.sync_bolts(pool, cols, BIG) assert_gte(br.capacity, 300) assert_eq(br.multimesh.visible_instance_count, 300)
func test_main_draws_bullets_with_the_bolt_renderer() -> void: var src := FileAccess.get_file_as_string("res://main.gd") assert_true(src.contains("PlayerBoltRenderer.new()"), "main.gd builds the v2 bullet renderer") assert_true(src.contains("bolt_renderer.sync_bolts("), "and syncs it")
func _blaster_ev(kind: String, evolved: bool) -> Dictionary: return {"kind": kind, "pos": Vector2.ZERO, "dir": Vector2.RIGHT, "element": 0, "wid": "blaster", "power": 0.5, "evolved": evolved, "rungs": 0}
func test_muzzle_flash_is_essential_weapon_feedback() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.enabled = false fx.consume([_blaster_ev("blaster_fire", false)]) assert_eq(fx.v2_look("blaster").active_count(), 1) assert_almost_eq(fx.v2_look("blaster").debug_buffer()[15], 0.0, 1e-6, "mode 0 = muzzle")
func test_impact_sparks_are_juice() -> void: VisualLook.weapon_fx_v2 = true var fx := _make() fx.enabled = false fx.consume([_blaster_ev("bolt_impact", true)]) assert_eq(fx.v2_look("blaster").active_count(), 0, "L5 drops impact sparks") fx.enabled = true fx.consume([_blaster_ev("bolt_impact", true)]) assert_eq(fx.v2_look("blaster").active_count(), 1) assert_almost_eq(fx.v2_look("blaster").debug_buffer()[15], 1.0, 1e-6, "mode 1 = impact")
func test_v2_off_blaster_events_draw_nothing_new() -> void: VisualLook.weapon_fx_v2 = false var fx := _make() fx.consume([_blaster_ev("blaster_fire", true), _blaster_ev("bolt_impact", true)]) assert_eq(fx.v2_look("blaster").active_count(), 0, "legacy had no muzzle flash / impact sparks")-
Step 2: Run
-gselect=test_weapon_fx_looks. Expected: FAIL. -
Step 3: Write
fx/weapons/player_bolt_renderer.gd
class_name PlayerBoltRendererextends WeaponFxLayer
# Player bullets v2 (spec § Batch 1, "Blaster (and all bullets)"): every player bullet as a tapered# plasma bolt that grows with ITS OWN weapon's power (ProjPool.vfx, captured at add_proj), with the# firing weapon's crystal accents; evolved -> Auto-Cannon twin spiralling cores. Replaces proj_renderer# while fx_layer.v2_active() (main.gd swaps them per frame). Per-kind shapes for scatter/turret/# momentum/aim come in batch 4 -- kind rides in custom.z already (aim keeps a plain dot under the lance).
const SHADER_PATH := "res://fx/weapons/player_bolt.gdshader"const BASE_LEN := 18.0 # == main.PLAYER_BULLET_LENconst BASE_WID := 2.5 * SwarmRenderer.NOVA_BULLET_WIDTH_MULT # == main's Nova-look bullet widthconst LEN_GROW := 0.5 # tail 1.5x at power 1 (grow budget)const PAD := 3.0 # quad room for the glow sheathconst EVOLVED_WID := 1.6 # twin cores need a wider quad
var _codes := {} # ctx -> {rungs * 2 + evolved: accent code}; a handful of entries, reused every frame# (the code -> SLOT mapping is rebuilt every sync: accent_reset() then accent_slot(), see sync_bolts)
func _init() -> void: super(load(SHADER_PATH) as Shader, 256)
func sync_bolts(pool: ProjPool, colors: PackedColorArray, cull: Rect2) -> void: ensure_capacity(pool.count) accent_reset() # persistent look: every bolt is re-put each frame, so slots only cover live codes begin() for i in range(pool.count): var pos: Vector2 = pool.pos[i] if not cull.has_point(pos): continue var v: Vector2 = pool.vel[i] var vfx: float = pool.vfx[i] var evolved := vfx >= 1.0 var power := vfx - 1.0 if evolved else vfx var half := Vector2(BASE_LEN * 0.5 * (1.0 + LEN_GROW * power) + PAD, BASE_WID * (EVOLVED_WID if evolved else 1.0) + PAD) put(pos, v.angle() if v.length_squared() > 0.0001 else 0.0, half, colors[i], Vector4(vfx, _slot(pool.context[i], pool.rungs[i], evolved), float(pool.kind[i]), fposmod(pool.data[i], 1.0))) commit()
func _slot(ctx: String, rungs_v: int, evolved: bool) -> float: if not _codes.has(ctx): _codes[ctx] = {} var per: Dictionary = _codes[ctx] var key := rungs_v * 2 + (1 if evolved else 0) if not per.has(key): per[key] = ElementAccents.code(ctx, rungs_v, evolved) return accent_slot(int(per[key]))- Step 4: Write
fx/weapons/player_bolt.gdshader
shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Player bullets v2 (weapon FX v2 batch 1). One instance per bullet, +x = travel, quad half-size =// (length/2 grown by power + PAD, width + PAD), so p spans the quad in [-1, 1] on both axes.// COLOR = element tint (raw, a unused). INSTANCE_CUSTOM = (vfx = power + 1 if evolved, accent slot,// ProjPool kind, remaining life mod 1 -> per-bullet noise seed).// Tapered plasma bolt: white-hot head, element sheath, tail thinning out. Evolved (Auto-Cannon): twin// cores spiralling round the travel axis. Kind 5 (Rift Bolt / aim) stays a plain round dot -- the aim// lance overlay draws on top of it (batch 4 owns the Rift Bolt look).
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
void fragment() { vec2 p = (UV - 0.5) * 2.0; vec3 tint = v_col.rgb; int kind = int(v_cus.z + 0.5); if (kind == 5) { float r = length(p); vec3 dot_col = mix(tint, vec3(1.0), 0.6) * smoothstep(0.55, 0.2, r) * 1.4 + tint * exp(-r * r / 0.3) * 0.5; COLOR = vec4(dot_col, 1.0); } else { bool evolved = v_cus.x >= 1.0; float seed = v_cus.w * 7.3; float head = 0.62; float tail = -0.95; float along = clamp((head - p.x) / (head - tail), 0.0, 1.0); // 0 at the head, 1 at the tail float taper = mix(0.55, 0.05, along); float dy = abs(p.y); float inside_x = step(tail, p.x) * smoothstep(head + 0.3, head, p.x); float core = smoothstep(taper * 0.55, 0.0, dy) * inside_x; float sheath = exp(-dy * dy / (taper * taper * 1.2 + 0.01)) * inside_x; float head_glow = exp(-(pow(p.x - head, 2.0) / 0.03 + dy * dy / 0.2)); vec3 col = mix(tint, vec3(1.0), 0.75) * core * 1.7 + tint * sheath * 0.9 + vec3(1.0, 0.97, 0.9) * head_glow * 1.3; float mask = clamp(core + sheath * 0.5, 0.0, 1.0); if (evolved) { float off = 0.38 * sin(p.x * 9.0 - TIME * 26.0 + seed); float c2 = (smoothstep(0.16, 0.0, abs(p.y - off)) + smoothstep(0.16, 0.0, abs(p.y + off))) * inside_x; col += mix(tint, vec3(1.0), 0.5) * c2 * 1.3 * (1.0 - along * 0.6); mask = clamp(mask + c2 * 0.5, 0.0, 1.0); } int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, clamp(core, 0.0, 1.0), 0.5, seed, TIME); col = col * acc.a + acc.rgb; if (evolved) { vec4 em = accent_motif(evolve_accent(acode), p, mask, clamp(core, 0.0, 1.0), 1.0, 0.5, seed + 3.0, TIME); col = col * em.a + em.rgb; } COLOR = vec4(col, 1.0); }}- Step 5: Write
fx/weapons/blaster_flash_look.gdandblaster_flash.gdshader
class_name BlasterFlashLookextends WeaponFxLayer
# Blaster v2 event looks (spec § Batch 1): a muzzle flash on every "blaster_fire" (essential weapon# feedback) and, for the evolved Auto-Cannon, impact sparks on every "bolt_impact" (juice). One layer,# mode in custom.w (0 muzzle, 1 impact). Grows with power (<= 1.5x); evolved = twin-barrel flash.
const SHADER_PATH := "res://fx/weapons/blaster_flash.gdshader"const CAP := 32const MUZZLE_HALF := 16.0const MUZZLE_GROW := 0.5const MUZZLE_LIFE := 0.07const MUZZLE_OFFSET := 12.0 # px ahead of the pilot centre (the ship's nose)const IMPACT_HALF := 26.0const IMPACT_LIFE := 0.18
func _init() -> void: super(load(SHADER_PATH) as Shader, CAP)
func _custom(ev: Dictionary, seed: float, mode: float) -> Vector4: var evolved := bool(ev.get("evolved", false)) return Vector4(float(ev.get("power", 0.0)) + (2.0 if evolved else 0.0), accent_slot(ElementAccents.code("blaster", int(ev.get("rungs", 0)), evolved)), seed, mode) # half-float safe (Task 7 fix)
func spawn_event(ev: Dictionary, tint: Color, seed: float) -> bool: var dir: Vector2 = ev.get("dir", Vector2.RIGHT) var power := float(ev.get("power", 0.0)) return spawn(ev["pos"] + dir * MUZZLE_OFFSET, dir.angle(), Vector2.ONE * MUZZLE_HALF * (1.0 + MUZZLE_GROW * power), tint, _custom(ev, seed, 0.0), MUZZLE_LIFE)
func spawn_impact(ev: Dictionary, tint: Color, seed: float) -> bool: var dir: Vector2 = ev.get("dir", Vector2.RIGHT) return spawn(ev["pos"], dir.angle(), Vector2.ONE * IMPACT_HALF, tint, _custom(ev, seed, 1.0), IMPACT_LIFE)shader_type canvas_item;render_mode blend_add, unshaded;#include "res://fx/weapons/element_accents.gdshaderinc"
// Blaster v2 flashes (weapon FX v2 batch 1). COLOR = (tint, a = life t). INSTANCE_CUSTOM =// (power + 2*evolved, accent slot, seed, mode). +x = the shot direction.// mode 0 muzzle: a short hot cone from the nose (twin cones when evolved).// mode 1 impact (Auto-Cannon only): 6 spark streaks fanning +-60 deg along the shot + a hit flash.
varying vec4 v_col;varying vec4 v_cus;
void vertex() { v_col = COLOR; v_cus = INSTANCE_CUSTOM;}
float seg_dist(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a; vec2 ba = b - a; float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0); return length(pa - ba * h);}
void fragment() { float t = clamp(v_col.a, 0.0, 1.0); bool evolved = v_cus.x >= 1.5; float seed = v_cus.z; vec2 p = (UV - 0.5) * 2.0; vec3 tint = v_col.rgb; vec3 col = vec3(0.0); float mask = 0.0; if (v_cus.w < 0.5) { vec2 q = vec2(p.x + 0.7, p.y); // cone origin near the back of the quad float off = evolved ? 0.3 : 0.0; for (int b = 0; b < 2; b++) { if (b == 1 && !evolved) { break; } vec2 qb = vec2(q.x, q.y - off * (b == 0 ? 1.0 : -1.0)); float cone = step(0.0, qb.x) * smoothstep(1.4, 0.0, qb.x) * smoothstep(0.1 + qb.x * 0.45, 0.0, abs(qb.y)); col += mix(tint, vec3(1.0), 0.5) * cone * 1.6; mask += cone; } col += vec3(1.0, 0.97, 0.9) * exp(-dot(q, q) / 0.04) * 2.4; col *= 1.0 - t; } else { for (int k = 0; k < 6; k++) { float h1 = hash21(vec2(float(k), seed)); float ang = mix(-1.05, 1.05, (float(k) + h1 * 0.6) / 6.0); float spd = mix(0.6, 1.0, hash21(vec2(seed, float(k) + 5.0))); vec2 dir = vec2(cos(ang), sin(ang)); float r0 = t * spd; float dist = seg_dist(p, dir * max(r0 - 0.25, 0.0), dir * r0); float s = exp(-dist * dist / 0.002); col += mix(tint, vec3(1.0), 0.6) * s * 2.0 * (1.0 - t); mask += s; } col += vec3(1.0, 0.95, 0.85) * exp(-dot(p, p) / 0.03) * (1.0 - smoothstep(0.0, 0.3, t)) * 2.0; } mask = clamp(mask, 0.0, 1.0); int acode = accent_code_for_slot(v_cus.y); // custom.y is a SLOT, not the code vec4 acc = accents_all(acode, p, mask, mask, t, seed, TIME); col = col * acc.a + acc.rgb; COLOR = vec4(col, 1.0);}- Step 6: FxManager + main.gd wiring
fx/fx_manager.gd:
- Add
"blaster_fire": truetoESSENTIAL_KINDS. The muzzle flash is weapon feedback. With v2 off,blaster_firehas no visual arm, so this changes nothing there. V2_LOOKSgets"blaster": "BlasterFlashLook"._ready:_add_v2_layer(BlasterFlashLook.new(), ["blaster"]).- New
consumearms, next to"burst":
"blaster_fire": # v2 muzzle flash (weapon feedback). Legacy had none -- the event was audio-only. if v2_active() and V2_LOOKS.has("blaster"): v2_look("blaster").spawn_event(ev, ElementPalette.color_for(_content, ev.get("element", -1)), _fx_rng.randf()) "bolt_impact": # v2 Auto-Cannon impact sparks -- disposable juice (off at L5, counts against the frame cap). if enabled and v2_active() and V2_LOOKS.has("blaster"): v2_look("blaster").spawn_impact(ev, ElementPalette.color_for(_content, ev.get("element", -1)), _fx_rng.randf())main.gd. Var, after var proj_renderer: SwarmRenderer:
var bolt_renderer: PlayerBoltRenderer # weapon FX v2 player bullets; swapped against proj_renderer per frameSetup, right after add_child(proj_renderer) (line 1068) and before the aim-lance overlay, so the lance still draws on top:
# Weapon FX v2: every player bullet as a plasma bolt grown by its own weapon's power (Auto-Cannon # twin cores when evolved). Drawn instead of proj_renderer while fx_layer.v2_active(). bolt_renderer = PlayerBoltRenderer.new() add_child(bolt_renderer)Sync. Replace proj_renderer.sync(sim.projectiles, _proj_colors(), null, cull) with:
var bolts_v2 := fx_layer.v2_active() and FxManager.V2_LOOKS.has("blaster") proj_renderer.visible = not bolts_v2 # hides its halo/hot-core children too (visible cascades) if bolts_v2: bolt_renderer.sync_bolts(sim.projectiles, _proj_colors(), cull) else: bolt_renderer.clear() proj_renderer.sync(sim.projectiles, _proj_colors(), null, cull)Check that nothing else sets proj_renderer.visible: /usr/bin/grep -n "proj_renderer.visible" main.gd render/*.gd should list only the new line. Then delete "blaster" from PENDING_V2_LOOKS and enable the "blaster" arm and _blaster_cell in tools/weapon_vfx/compare.gd.
- Step 7: Tests, determinism, grid
Run --import, -gselect=test_weapon_fx_looks, -gselect=test_weapon_fx_guards, -gselect=test_fx_manager, -gselect=test_main, -gselect=test_swarm_renderer, -gselect=test_determinism. Then:
WEAPON=blaster godot --path . res://tools/weapon_vfx/compare.tscn --rendering-method mobileWEAPON=blaster V2=1 LEVELS=3 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscnWEAPON=blaster V2=1 EVOLVE=1 LEVELS=5 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscnWEAPON=blaster V2=0 godot --path . --rendering-method mobile res://tools/weapon_vfx/ingame.tscngit diff --stat project.godot; git checkout -- project.godotCheck:
-
p0 bolts read at least as clearly as v1 at gameplay zoom.
-
Tails lengthen with each +kinetic row, and kinetic chevrons appear.
-
EVOLVED shows two cores twisting, void holes, impact sparks at the hit side, and a twin muzzle flash.
-
In-game with a busy horde, bullets never read as enemy shots (Chris’s recurring readability concern).
-
The Rift Bolt lance still sits on its shots (
WEAPON=blasterwith the aim stick isn’t driven by the harness; check on device in Task 16). -
Step 8: Commit —
feat(fx-v2): player bolt renderer (grows per weapon, Auto-Cannon twin cores), muzzle flash, impact sparks
Task 14: Perf comparison (v1 vs v2, all batch-1 weapons evolved, L0) + the L3 fallback
Section titled “Task 14: Perf comparison (v1 vs v2, all batch-1 weapons evolved, L0) + the L3 fallback”Files:
- Modify:
tools/perf_probe/perf_probe.gd(flags + GPU/CPU timing) - Create:
docs/research/2026-09-29-weapon-fx-v2-batch1-perf.md(results; needs atitle:frontmatter line for the docs site)
Interfaces:
-
Consumes:
VisualLook.weapon_fx_v2,WeaponVfx.weapon,main.quality_manager,main.sim.player.arsenal. -
Produces: new perf_probe flags
--weapons=a,b,c --evolve --v2=0|1 --level=N --seed=N, printing[perf_probe] ... TOTAL draws=… gpu_ms=… cpu_ms=…. -
Step 1: Add the flags
In _ready(), add to the var block: var weapons: PackedStringArray = [], var evolve := false, var v2 := -1, var level := -1, var run_seed := -1. Then add to the arg loop:
elif a.begins_with("--weapons="): weapons = a.substr(10).split(",", false) elif a == "--evolve": evolve = true elif a.begins_with("--v2="): v2 = int(a.substr(5)) elif a.begins_with("--level="): level = int(a.substr(8)) elif a.begins_with("--seed="): run_seed = int(a.substr(7))Right before main._on_mode_chosen(_mode):
if v2 >= 0: VisualLook.weapon_fx_v2 = v2 == 1 if run_seed >= 0: seed(run_seed) # main picks run_seed = randi(): seeding the global RNG pins the run (same spawns v1 vs v2)Right after main.sim.player.dev_invuln = true:
if level >= 0: main.quality_manager.auto = false main.quality_manager.set_level(level) var ars: PilotArsenal = main.sim.player.arsenal for wid in weapons: if WeaponVfx.weapon(ars, wid) == null: push_error("[perf_probe] unknown weapon %s" % wid) continue if not ars.active_weapon_ids.has(wid): ars.active_weapon_ids.append(wid) ars.weapon_levels[wid] = 5 if evolve: WeaponVfx.weapon(ars, wid).call("evolve") RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)Add a timing sampler, and print it after the existing TOTAL line:
func _sample_times() -> Vector2: var rid := get_viewport().get_viewport_rid() var cpu := 0.0 var gpu := 0.0 for i in SAMPLE_FRAMES: await get_tree().process_frame cpu += RenderingServer.viewport_get_measured_render_time_cpu(rid) gpu += RenderingServer.viewport_get_measured_render_time_gpu(rid) return Vector2(cpu, gpu) / float(SAMPLE_FRAMES) var times := await _sample_times() print("[perf_probe] v2=%s level=%d weapons=%s evolve=%s gpu_ms=%.2f cpu_ms=%.2f" % [VisualLook.weapon_fx_v2, main.quality_manager.level, ",".join(weapons), evolve, times.y, times.x])Also update the header comment’s flag list.
- Step 2: Run the matrix (windowed, foreground, 3 runs each; compare medians)
W=--weapons=blaster,orbit,blade,scythe,novafor v in 0 1; do for i in 1 2 3; do godot --path . --rendering-method mobile --always-on-top res://tools/perf_probe/perf_probe.tscn -- \ --mode=survival $W --evolve --v2=$v --level=0 --seed=1234 --no-breakdown 2>&1 | grep "\[perf_probe\]"done; done# L3 fallback: v2 on, but the tier must hand back the legacy looksgodot --path . --rendering-method mobile --always-on-top res://tools/perf_probe/perf_probe.tscn -- \ --mode=survival $W --evolve --v2=1 --level=3 --seed=1234 --no-breakdown --shot=reports/fxv2/l3.png 2>&1 | grep "\[perf_probe\]"git diff --quiet project.godot || git checkout -- project.godotPass criteria (spec § Verification: “no regression beyond noise vs v1 on the same seed”):
- Draws: v2 ≤ v1 + 5. Each v2 look is one draw call, and idle looks are hidden.
- gpu_ms: v2 median ≤ v1 median × 1.10, or within the run-to-run spread of the v1 runs, whichever is larger.
- L3: the draw count and
l3.pngmatch a v1 run. There are no v2 bolts, and the orbit crystals are sprites. Read the PNG.
If gpu_ms regresses, cut the cost in this order, re-measuring after each step, and record which step fixed it:
player_bolt.gdshaderaccents (skipaccents_allwhenv_cus.y == 0). This is the most-instanced look.- The ember loop count in
nova_burst.gdshader. - The
fbminorbit_shards.gdshader’s ring branch.
- Step 3: Write the results doc
docs/research/2026-09-29-weapon-fx-v2-batch1-perf.md:
---title: Weapon FX v2 batch 1 — perf comparison---# Weapon FX v2 batch 1 — perf comparison (2026-09-29)
Setup: survival, seed 1234, blaster+orbit+blade+scythe+nova all evolved, quality L0, MacBook Pro M5,`--rendering-method mobile`, perf_probe `--no-breakdown`, 3 runs each (median reported).
| Config | draws | gpu_ms | cpu_ms ||---|---|---|---|| v1 (legacy looks) | <fill from run> | <fill> | <fill> || v2 | <fill> | <fill> | <fill> || v2 at L3 (fallback) | <fill> | <fill> | <fill> |
Verdict: <pass/fail against the plan's criteria + any cost cut applied>.Every <fill…> above is a slot for measured output from Step 2, not a plan placeholder. Commit the doc only once every slot holds a real number.
- Step 4: Commit —
perf(fx-v2): probe flags + batch-1 v1/v2/L3 comparison
Task 15: Docs, release queue, bh-add-content skill, memory
Section titled “Task 15: Docs, release queue, bh-add-content skill, memory”Files:
-
Modify:
docs/architecture/rendering-and-performance.md(§ Plasma Nova at lines 75–81 → a new § Weapon FX v2) -
Modify:
docs/architecture/weapons-and-buildcraft.md(a “Visuals” note in the weapon-wiring section) -
Modify:
docs/RELEASE-1.0.1.md§3b (line 61) -
Modify:
.claude/skills/bh-add-content/SKILL.md(gitignored, so edit in place; it is not committed) -
Modify:
.claude/skills/bh-vfx-iteration/SKILL.md(harness paths) -
Modify: memory
bullet-heaven-weapon-fx-v2.md -
Step 1:
rendering-and-performance.md
Replace the Plasma Nova section with a “Weapon FX v2” section covering these points (it’s the current source of truth, not a changelog):
-
Data.
WeaponVfxstampswid/power/evolved/rungson every weapon fx_event.power = 1 - 0.72^(level picks + crystal rungs). Bullets carryProjPool.vfx/rungs/kind, which are render-only and never checksummed. -
Render. Each look is a
WeaponFxLayer(one MultiMesh, one draw call, stride-16 buffer). The per-look COLOR/INSTANCE_CUSTOM layouts are in the plan’s table; copy that table in. -
Dispatch.
FxManager.V2_LOOKSmaps wid → look.v2_active() = VisualLook.weapon_fx_v2 and QualityManager.weapon_fx_v2_on(level)(L0–L2). The persistent looks (OrbitShardsLook,PlayerBoltRenderer) live in main.gd and swap per frame. -
Accents.
fx/weapons/element_accents.gdshaderincand theElementAccentstwin, with the packing and the generic(p, mask, edge, s, t, seed, now)contract that batch 6 reuses for enemies. -
Plasma Nova.
PlasmaNovaNodeinfx/weapons/is the v1 / L3+ nova. -
Harnesses.
tools/weapon_vfx/{compare,ingame,motifs}. -
Guards.
tests/test_weapon_fx_guards.gdchecks guards 1–6. Say what each guard catches. -
Gotchas. The QuadMesh UV-y flip note. Use
: source_colorfor colour uniforms. Nested#includeneeds#ifndefguards. -
Step 2:
weapons-and-buildcraft.md
Add under the weapon-wiring notes:
Visuals (weapon FX v2): every
fx_events.appendin a weapon must besim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, "<wid>"))on one line, and new weapons go inFxManager.V2_LOOKSortests/test_weapon_fx_guards.gdPENDING_V2_LOOKS. The gate fails otherwise. Seedocs/architecture/rendering-and-performance.md§ Weapon FX v2.
- Step 3:
docs/RELEASE-1.0.1.md§3b line 61
Tick the “Weapon FX v2 — foundation + batch 1” entry with ✅ DONE (<merge sha>) only in Task 16 after merge. In this task, append — plan docs/superpowers/plans/2026-09-29-weapon-fx-v2-foundation-batch1.md, implementation on branch <branch>.
- Step 4: Skills (local, gitignored)
In .claude/skills/bh-add-content/SKILL.md, add a checklist step in the weapon section:
“Stamp every fx_event:
sim.fx_events.append(WeaponVfx.stamp({...}, pilot.arsenal, "<wid>")), one line. Bullets passWeaponVfx.bullet_vfx(pilot.arsenal, "<wid>"), WeaponVfx.rungs(pilot.arsenal, "<wid>")after the ctx arg ofadd_proj. Add the wid toPENDING_V2_LOOKSintests/test_weapon_fx_guards.gdwith the batch that will give it a look, or build its look (FxManager.V2_LOOKS). Any weapon-localArray[Dictionary]zone needs a renderer.tests/test_weapon_fx_guards.gdenforces all of this.”
In .claude/skills/bh-vfx-iteration/SKILL.md, point the tier-1/3 examples at tools/weapon_vfx/compare.gd (WEAPON=<wid>) and ingame.gd. Keep the existing gotchas.
- Step 5: Memory
Update /Users/chris/.claude/projects/-Users-chris-Claude-bullet-heaven/memory/bullet-heaven-weapon-fx-v2.md:
-
Next step becomes “awaiting Chris grid/device sign-off (plan Task 16)”. Once Task 16 is done, it becomes “batch 2 design pass (Beam + Railgun)”.
-
Record: “Enemy effects stay visual only — Chris, 2026-09-29 (resolves the open question)”.
-
Update the
MEMORY.mdpointer line hook to match. -
Step 6: Commit (the docs only; skills and memory are outside git) —
docs(fx-v2): architecture, buildcraft wiring rule, release queue note
Task 16: ⛔ STOP — Chris signs off, then flip the default, fold plasma_nova, gate, merge, push
Section titled “Task 16: ⛔ STOP — Chris signs off, then flip the default, fold plasma_nova, gate, merge, push”Files:
- Modify:
render/visual_look.gd,fx/fx_manager.gd(nova arm),ui/debug_settings_panel.gd,tests/test_plasma_nova.gd,tests/test_fx_manager.gd,docs/RELEASE-1.0.1.md - Delete:
tools/nova_vfx/(superseded bytools/weapon_vfx/WEAPON=nova, whose v1 row is the Plasma Nova)
Interfaces:
-
Consumes: everything above.
-
Produces:
VisualLook.weapon_fx_v2 := true.VisualLook.plasma_novano longer exists. With v2 off (or at L3+), nova =PlasmaNovaNode, and the legacy nova ring is gone from the nova arm (the ring primitive stays forburst/reactions). -
Step 1: ⛔ STOP — present for sign-off and wait
Send Chris the PNGs with SendUserFile:
- the five
compare_<wid>.pnggrids; motifs.png;- the
ingame_<wid>_{v1,v2,v2_evo}.pngsets; - the Task 14 perf table.
For each weapon, say in one line what changed per row. Ask him to toggle Debug Settings → “Weapon FX v2 (A/B)” in the Dev app (scripts/dev/install-dev.sh). Device installs are on Chris’s own devices, so get his same-day OK before installing. Do not continue past this step until Chris approves each weapon. A weapon he rejects goes back to its task’s shader iteration loop. A weapon he defers leaves V2_LOOKS and returns to PENDING_V2_LOOKS, and the rest proceeds.
- Step 2: Flip the default + fold
plasma_nova(after approval)
render/visual_look.gd: delete the plasma_nova var and its comment block. Set static var weapon_fx_v2 := true, and change its comment’s last sentence to:
Default true since Chris signed off batch 1 on
; false (and quality L3+) = the legacy looks, where the Nova is the Plasma Nova fireball (PlasmaNovaNode).
fx/fx_manager.gd, "nova" arm:
"nova": var ncol := ElementPalette.color_for(_content, ev.get("element", -1)) if v2_active() and V2_LOOKS.has("nova"): v2_look("nova").spawn_event(ev, ncol, _fx_rng.randf()) # Nova + Scar Tissue discharge else: _spawn_plasma_nova(ev["pos"], float(ev.get("radius", 100.0)), ncol) # v1 / L3+ lookui/debug_settings_panel.gd: delete the "plasma_nova" match arm, _graphics_state["plasma_nova"] and the "Plasma Nova (A/B)" row.
tests/test_plasma_nova.gd:
- It now tests the v1/L3+ nova. Change
_wasto save and restoreVisualLook.weapon_fx_v2, and inbefore_eachsetVisualLook.weapon_fx_v2 = false. - Delete
test_plasma_nova_is_the_default_look, the plasma-off→ring test (line ~39), andtest_debug_row_flips_plasma_nova_switch. - In the remaining tests, replace every
VisualLook.plasma_nova = trueline with nothing. - Add:
func test_weapon_fx_v2_is_the_default() -> void: var src := FileAccess.get_file_as_string("res://render/visual_look.gd") assert_string_contains(src, "static var weapon_fx_v2 := true", "v2 ships as default after sign-off") assert_false(src.contains("static var plasma_nova"), "plasma_nova folded into weapon_fx_v2")tests/test_fx_manager.gd: it’s the legacy-look suite, so save and restore VisualLook.weapon_fx_v2 and set it false in before_each, replacing _plasma_was. The four legacy nova-ring tests become plasma-node tests:
test_nova_event_activates_one_ring→ assertfx.nova_active_count() == 1;test_nova_ring_expires_after_max_life→ advance 0.5s (>PlasmaNovaNode.LIFE0.46) and assertnova_active_count() == 0;test_nova_ring_pool_does_not_grow→ assertnova_active_count() <= FxManager.NOVA_POOL_SIZE;test_disabled_still_spawns_nova_ring→ assertnova_active_count() == 1.
Rename each test to say “plasma” instead of “ring”, and delete the VisualLook.plasma_nova = false lines.
Delete tools/nova_vfx/ with git rm -r tools/nova_vfx. Then update every reference:
/usr/bin/grep -rn "nova_vfx\|VisualLook.plasma_nova\|\"plasma_nova\"\|Plasma Nova (A/B)" --include='*.gd' --include='*.md' --include='*.tscn' . | grep -v "^./docs/superpowers/\|^./docs/research/\|^./docs/RELEASE-0.1\|^./.godot/"This must return nothing. The historical specs, plans, research docs and closed release notes keep their text. Fix docs/architecture/rendering-and-performance.md and the bh-vfx-iteration skill if they appear.
- Step 3: Full gate
git fetch origin && git log HEAD..origin/main --oneline # rebase first if anything landedscripts/gate.shExpected: the gate is green, the test-count guard reflects the 5 new test files, and test_determinism_survival passes unchanged.
Now that the default is v2, a test that asserts on a legacy renderer (proj_renderer, the orbit sprites, slash_active_count, ring_active_count for nova) will fail. The fix is always the same: save VisualLook.weapon_fx_v2 in that test’s before_each, set it false, and restore it in after_each. Never loosen the assertion.
- Step 4: Device check
With Chris’s same-day OK, run scripts/dev/install-dev.sh to the Dev app. Chris plays 2 runs on Apple TV and iPhone.
- Confirm the Rift Bolt lance still sits on its shots.
- Confirm co-op isn’t broken by the orbit look. Co-op is gated off; P1 only is the check.
- Confirm the L3 fallback: drop the tier in Debug Settings and the looks swap to v1.
Record any issue in docs/RELEASE-1.0.1.md before fixing it (session-start rule).
- Step 5: Tick the queue, commit, merge, push
In docs/RELEASE-1.0.1.md §3b line 61, mark “Weapon FX v2 — foundation + batch 1” ✅ DONE (<sha>). Commit with feat(fx-v2): weapon FX v2 default on; plasma_nova folded in (batch 1 signed off by Chris). Merge to main, then git push origin main immediately after the merge. Finally, update memory bullet-heaven-weapon-fx-v2 to “batch 1 shipped; next: batch 2 design pass (Beam + Railgun, bug 2)”.
Self-review (against the spec)
Section titled “Self-review (against the spec)”| Spec requirement | Task |
|---|---|
WeaponVfx.invested/power/stamp, levels + thresholds counted, curve |
1 |
every weapon event carries wid/power/evolved(/rungs) |
3 (guards 1–2) |
ProjPool vfx/rungs/kind, trailing add_proj arg, splits inherit, swap-remove aligned |
2 |
rungs bitmask matches thresholds_done per pilot |
1 (test_rungs_are_per_pilot) |
looks under fx/weapons/, one class + shader each; FxManager stays a dispatcher; _PlasmaNovaNode moved first |
5, 9–13 |
PlayerBoltRenderer replaces the player SwarmRenderer when v2 is on |
13 |
| grow ≤ 1.5×, interiors clear, tint everywhere | shaders 9–13 (bolt budget pinned by test in 13); grids |
| quality fallback: v2 at L0–L2, legacy at L3+ | 4 (tier + test), 14 (L3 check) |
VisualLook.weapon_fx_v2 + Debug row; plasma_nova folds in |
4, 16 |
| crystal accents include + GDScript twin; batch-1 motifs; evolved takes the evolve element; generic shape mask for batch 6 | 7 |
| Blaster / Orbit / Blade (bug 1) / Scythe / Nova + Scar Tissue at its own power | 13 / 12 / 9 / 10 / 11 |
slash look honours arc (360° full ring) |
9 (v2 + legacy fallback) |
| pools bounded, no leaks; v2 off = legacy node types | 9–11, 13 |
| determinism unchanged | 2, 3, 13, 16 |
| compare grid (rows p0 / each rung / evolved + v1, frames, real HDR env); ingame harness | 8 (+ arms in 12, 13) |
| Chris eyeballs every grid before merge; Dev-app device check | 16 (STOP) |
| perf soak all evolved at L0 vs v1; L3 fallback | 14 |
| gate green | 16 |
| memory guard tests: stamped events / evolved differs / zone arrays rendered (so bh-add-content fails the gate if invisible or non-scaling) | 3 (guards 1–4), 4 (guard 5 look registry), 7 (guard 6 accent sync), 15 (skill text) |
| out of scope: sound, sim/balance, enemy/boss FX (visual only, Chris 2026-09-29), evolution moment | Global Constraints |
Execution handoff
Section titled “Execution handoff”User decisions (Chris, 2026-09-29):
- The spec
docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.mdis approved as written. - Enemy effects stay visual only. Crystal accents on enemies (batch 6) never change sim or balance. This resolves the spec’s open question.
- Scope is the foundation plus batch 1 (Blaster, Orbit, Blade, Scythe, Nova + Scar Tissue). Batches 2–6 each get their own plan later.
Authorised:
- All code, test and doc work in Tasks 1–15 on a feature branch or worktree.
- Commits.
- Windowed Godot harness runs on the Mac. Afterwards, run
git checkout -- project.godot.
Needs a same-day OK from Chris:
- The Task 16 STOP (grid and device sign-off).
- Any
scripts/dev/install-dev.shdevice install. - Anything that touches the store or TestFlight. That is out of scope; don’t run
release.sh. - Pushes to
mainfollow the project rule:git push origin mainafter every local merge. The merge itself only happens after the Task 16 sign-off.
Done outside this plan: nothing. Only the spec and this plan are committed.
Open questions: none blocking. Per-weapon aesthetic calls are Chris’s at the Task 16 STOP. A rejected look loops back to its task. A deferred look moves back to PENDING_V2_LOOKS.
Env gotchas:
- Run
godot --headless --path . --importafter any newclass_name, or GUT reports “not declared”. - Windowed runs rewrite
project.godot.scripts/gate.shrunsgit checkout -- project.godot, so commit any intentional edit first. - Bash
grepis ugrep and honours.gitignore. Use/usr/bin/grepfor the guard-list checks. .claude/skillsis gitignored. Task 15’s skill edits happen in the main checkout, not in a worktree that gets deleted.- Code-writing subagents: use
bh-implementerwithmodel: "opus". Check their claims yourself (memorybullet-heaven-subagent-model-haiku). - Toby may push to
main. Before merging, rungit fetchand rebase.
Exact next action: create a worktree or branch weapon-fx-v2-batch1 from main, then start Task 1 (sim/weapon_vfx.gd + tests/test_weapon_vfx.gd).