PURPOSE
Colored expanding ring VFX that “pulse out” of the ship hull on every weapon fire, plus larger dramatic shockwave rings (full circles and partial arcs) for events like respawn pulse and shield break shatter. Pure VFX: spawns, advances, draws, and clears a bounded pool of ring instances. No gameplay side-effects.
OWNS
SonarRinginterface — per-ring world position, RGB color components,timer,maxTimer,startRadius,endRadius,startAlpha,lineW, and optionalarcStart/arcEndfor partial arcs.rings: SonarRing[]— module-local pool of all live rings.MAX_RINGScap — used byspawn()to bound the per-frame draw cost (the cap is not enforced forshockwave/shockwaveArc).SonarRingspublic singleton —spawn,shockwave,shockwaveArc,update,draw,clear,countgetter.- Hex-to-RGB parsing at spawn time (each method reads chars 1–6 from the
colorHexstring and falls back to128onNaN).
READS FROM
Camera.toS(x, y)from../rendering/camera— world-to-screen projection of each ring’s center insidedraw.camerafrom../core—camera.zoomis read insidedrawto scale ring radius and line width into screen units.- Method arguments only — all gameplay state (ship position, hull radius, weapon color, lifetime) is passed by callers. There are no store/global reads.
PUSHES TO
- The supplied
CanvasRenderingContext2D—save/restore, setsglobalCompositeOperation = 'lighter', then strokes each ring with anrgba()strokeStyleand computedlineWidth. - The module-local
ringsarray —spawn/shockwave/shockwaveArcpush entries;updatesplices expired entries;cleartruncates to length zero. - Nothing else — no events, no telemetry, no audio, no store mutation.
DOES NOT
- Does not damage, push, or otherwise affect entities — rings are visual-only.
- Does not own its own ticker —
update(dt)anddraw(ctx)are driven bybridge.ts. - Does not auto-spawn on weapon fire —
weapons.tsand other consumers must callspawnexplicitly. - Does not enforce
MAX_RINGSforshockwaveorshockwaveArc— onlyspawnreturns early when the pool is at the cap. - Does not validate
colorHexformat — any string is sliced; non-hex characters resolve toNaNand the|| 128fallback substitutes mid-gray per component. - Does not respect screen-space culling — rings outside the viewport are still projected and stroked (only
sr <= 0andalpha <= 0.01short-circuit a ring’s draw). - Does not pool or reuse ring objects — each spawn allocates a new object literal; expired rings are
splice-removed.
Signals
MAX_RINGS = 24— hard cap;spawnno-ops past this.spawnring geometry:startRadius = hullRadius * 0.8,endRadius = hullRadius * maxScale,startAlpha = 0.35,lineW = 0(signals “use default thin ring”).shockwavering geometry: caller-suppliedstartR/endR/lineWidth,startAlpha = 0.7.shockwaveArcring geometry: caller-suppliedstartR/endR/lineWidth/arcStart/arcEnd,startAlpha = 0.9.- Suggested input ranges from JSDoc —
spawnmaxScale1.6–2.0 andlifetime0.08–0.14 s;shockwavelifetime0.5–1.5 s andlineWidth3–6 world units. - Default stroke width when
lineW == 0:max(1, 2 * camera.zoom)screen px. - Override stroke width when
lineW > 0:max(1, lineW * camera.zoom)screen px. - Alpha is rendered with
toFixed(3)precision in thergba()string. - Composite mode for the whole draw pass:
'lighter'(additive).
Entry points
SonarRings.spawn(x, y, hullRadius, maxScale, colorHex, lifetime)— short, thin, additive ring emanating from the ship hull on weapon fire. Bounded byMAX_RINGS; silently no-ops at the cap.SonarRings.shockwave(x, y, startR, endR, colorHex, lifetime, lineWidth)— full-circle shockwave; thicker, brighter (alpha 0.7), longer-lived thanspawn. Not bounded byMAX_RINGS.SonarRings.shockwaveArc(x, y, startR, endR, colorHex, lifetime, lineWidth, arcStart, arcEnd)— partial-arc shockwave; brightest start alpha (0.9). Multiple arcs are intended to burst together (shield-break shatter).SonarRings.update(dt)— reverse-iterates the pool, decrementstimer, splices entries withtimer <= 0. Called once per frame frombridge.ts.SonarRings.draw(ctx)— strokes every live ring. Intended to render after the shield bottom and before the ship sprite so rings appear to emanate from the hull.SonarRings.clear()— empties the pool. Called on run reset and arena resets frombridge.ts.SonarRings.count(getter) — live ring count, for debug overlay.
Pattern notes
- Per-ring progress:
t = 1 - timer / maxTimer; radius is linearly interpolatedstartRadius → endRadiusovert; alpha is linearly fadedstartAlpha → 0overt. - The
drawpass short-circuits with an earlyreturnwhenrings.length === 0, avoiding thesave/globalCompositeOperation/restoreoverhead on idle frames. - Per-ring draw also short-circuits when
alpha <= 0.01orsr <= 0(after zoom scaling) — both checks happen beforebeginPathso no canvas state changes for invisible rings. - Partial-arc rings are distinguished by
arcStart !== undefined && arcEnd !== undefined; otherwisedrawissues a fullarc(..., 0, Math.PI * 2). - Two stroke-width regimes —
lineW === 0(default ring fromspawn) draws at a fixed 2 world-unit equivalent;lineW > 0(shockwaves) uses the caller’s value. Both pass throughMath.max(1, ...)to guarantee at least 1 screen pixel at low zoom. - Expired-ring removal uses descending-index iteration with
spliceso the array is mutated in place without skipping survivors. - Color is parsed once at spawn time and stored as three numeric components;
drawrebuilds thergba()string each frame from those numbers plus the per-frame alpha. - The pool is a singleton module-level array — there is only one ring system per process;
clear()resets it on run boundaries.