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

  • SonarRing interface — per-ring world position, RGB color components, timer, maxTimer, startRadius, endRadius, startAlpha, lineW, and optional arcStart/arcEnd for partial arcs.
  • rings: SonarRing[] — module-local pool of all live rings.
  • MAX_RINGS cap — used by spawn() to bound the per-frame draw cost (the cap is not enforced for shockwave/shockwaveArc).
  • SonarRings public singleton — spawn, shockwave, shockwaveArc, update, draw, clear, count getter.
  • Hex-to-RGB parsing at spawn time (each method reads chars 1–6 from the colorHex string and falls back to 128 on NaN).

READS FROM

  • Camera.toS(x, y) from ../rendering/camera — world-to-screen projection of each ring’s center inside draw.
  • camera from ../corecamera.zoom is read inside draw to 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 CanvasRenderingContext2Dsave/restore, sets globalCompositeOperation = 'lighter', then strokes each ring with an rgba() strokeStyle and computed lineWidth.
  • The module-local rings array — spawn/shockwave/shockwaveArc push entries; update splices expired entries; clear truncates 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) and draw(ctx) are driven by bridge.ts.
  • Does not auto-spawn on weapon fire — weapons.ts and other consumers must call spawn explicitly.
  • Does not enforce MAX_RINGS for shockwave or shockwaveArc — only spawn returns early when the pool is at the cap.
  • Does not validate colorHex format — any string is sliced; non-hex characters resolve to NaN and the || 128 fallback substitutes mid-gray per component.
  • Does not respect screen-space culling — rings outside the viewport are still projected and stroked (only sr <= 0 and alpha <= 0.01 short-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; spawn no-ops past this.
  • spawn ring geometry: startRadius = hullRadius * 0.8, endRadius = hullRadius * maxScale, startAlpha = 0.35, lineW = 0 (signals “use default thin ring”).
  • shockwave ring geometry: caller-supplied startR/endR/lineWidth, startAlpha = 0.7.
  • shockwaveArc ring geometry: caller-supplied startR/endR/lineWidth/arcStart/arcEnd, startAlpha = 0.9.
  • Suggested input ranges from JSDoc — spawn maxScale 1.6–2.0 and lifetime 0.08–0.14 s; shockwave lifetime 0.5–1.5 s and lineWidth 3–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 the rgba() 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 by MAX_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 than spawn. Not bounded by MAX_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, decrements timer, splices entries with timer <= 0. Called once per frame from bridge.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 from bridge.ts.
  • SonarRings.count (getter) — live ring count, for debug overlay.

Pattern notes

  • Per-ring progress: t = 1 - timer / maxTimer; radius is linearly interpolated startRadius → endRadius over t; alpha is linearly faded startAlpha → 0 over t.
  • The draw pass short-circuits with an early return when rings.length === 0, avoiding the save/globalCompositeOperation/restore overhead on idle frames.
  • Per-ring draw also short-circuits when alpha <= 0.01 or sr <= 0 (after zoom scaling) — both checks happen before beginPath so no canvas state changes for invisible rings.
  • Partial-arc rings are distinguished by arcStart !== undefined && arcEnd !== undefined; otherwise draw issues a full arc(..., 0, Math.PI * 2).
  • Two stroke-width regimes — lineW === 0 (default ring from spawn) draws at a fixed 2 world-unit equivalent; lineW > 0 (shockwaves) uses the caller’s value. Both pass through Math.max(1, ...) to guarantee at least 1 screen pixel at low zoom.
  • Expired-ring removal uses descending-index iteration with splice so the array is mutated in place without skipping survivors.
  • Color is parsed once at spawn time and stored as three numeric components; draw rebuilds the rgba() 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.