PURPOSE

Dual-layer composited smoke FX with pooled particles for lingering smoke clouds, layered smoke bursts, and explosion flash + smoke combos. A bottom layer (dark, drawn below ships) and a top layer (light/fog, drawn above ships and shields) sandwich gameplay, with white-hot flash particles drawn additively on top. Pre-baked radial gradient stamps replace per-particle createRadialGradient to eliminate the dominant mobile Canvas 2D bottleneck.

OWNS

  • The SmokeParticle pool (SmokeFX._particles) capped at MAX (150 mobile / 400 desktop).
  • Three lazy-baked stamp canvases: _smokeStamp, _flashStamp, _glowStamp.
  • Two offscreen compositing canvases sized to the main canvas: _cvBot/_cxBot for the bottom layer and _cvTop/_cxTop for the top layer.
  • Particle physics: position integration, exponential drag, slight upward drift (hot air rises), lifetime decay, swap-remove cleanup.
  • Tinting/compositing pipeline: additive accumulation, source-atop linear-gradient tint, final composite to the main context at fixed opacity.

READS FROM

  • camera from ../core for camera.zoom.
  • isMobile() from ../core/device-capabilities (at module init) to choose MAX.
  • Camera.toS(x, y) from ../rendering for world-to-screen projection per particle.
  • Each particle’s own x, y, vx, vy, sz, shrink, a, l, ml, isFlash, layer.

PUSHES TO

  • The offscreen bottom/top canvases (cleared and rebuilt every draw call when their layer has particles).
  • The main CanvasRenderingContext2D passed into drawBottom/drawTop/draw: bottom layer composited at 0.75 alpha, top layer at 0.40 alpha, flash particles drawn directly with lighter blending.
  • Mutates mainCtx save/restore around composites and flash draws; resets globalCompositeOperation and globalAlpha back to defaults.

DOES NOT

  • Does not own any game-side entity, hit, damage, or audio. Purely visual.
  • Does not allocate particles beyond MAX; spawn loops bail when the pool is full (oldest survive, new spawns silently dropped).
  • Does not run in Node — drawBottom and drawTop early-return when document is undefined (test safety).
  • Does not splice or sort particles; uses swap-remove for O(1) deletion.
  • Does not cache or reuse the offscreen canvases across size changes — they are recreated if main canvas width/height changes.
  • Does not call createRadialGradient per particle; only once per stamp at first use.

Signals

  • No event bus subscriptions. Callers invoke smoke, smokeCloud, or explosion directly. The engine’s render loop calls update(dt) once per frame and drawBottom/drawTop at the appropriate Z slots (or the legacy draw which calls both).

Entry points

  • SmokeFX.smoke(wx, wy, opts?) — spawn a lingering cloud on one layer. Opts: count, spread, lifetime, size, alpha, layer (‘bottom’ default | ‘top’).
  • SmokeFX.smokeCloud(wx, wy, opts?) — spawn dark dense smoke on both layers simultaneously (bottom larger/heavier, top smaller/lighter).
  • SmokeFX.explosion(wx, wy, radius, opts?) — spawn a burst of expanding warm flash particles plus a two-layer smoke cloud sized off radius.
  • SmokeFX.update(dt) — tick every particle (position, drag, upward drift, lifetime, swap-remove dead particles).
  • SmokeFX.drawBottom(mainCtx) — render the bottom (dark) smoke layer.
  • SmokeFX.drawTop(mainCtx) — render the top (fog) smoke layer and flash particles.
  • SmokeFX.draw(mainCtx) — legacy combined entry that calls drawBottom then drawTop back-to-back.

Pattern notes

  • Pre-baked stamps: _bakeStamp (64px) and _bakeGlowStamp (96px, softer/wider falloff) produce radial-gradient circles once at first draw. All subsequent draws use drawImage — the stated reason being a mobile bottleneck of ~400 gradients/frame collapsing to zero.
  • Layer separation: each particle stores its layer; drawBottom/drawTop filter by layer in tight inner loops, also short-circuiting via a hasSmoke/hasTopSmoke/hasFlash pre-scan that skips offscreen-canvas work when nothing is queued.
  • Three-phase composite per layer: (1) additive lighter blend of stamps into the offscreen canvas, (2) source-atop tint via a diagonal linear gradient (dark slate for bottom, near-white for top), (3) source-over composite of the tinted offscreen onto main at a fixed alpha (0.75 bottom, 0.40 top).
  • Flash particles always carry layer: 'bottom' in their record but are drawn directly to main from drawTop with lighter blending — layer is effectively ignored for flashes (filtered by isFlash first).
  • Particles fade in (Math.min(1, lifeFrac * 6)) and fade out (Math.pow(p.l / p.ml, 0.7)), and skip draw entirely below 0.5px size or 0.01 alpha to cut fill-rate waste.
  • Bottom layer scales drawn size by 1.4x with a 0.7x shrink multiplier — wider, longer-lived glow bleed than the top layer’s tighter stamps.
  • Velocity model: high initial speed (30 + rand * spread * 2.5) with heavy frame-rate-normalized drag (Math.pow(0.75, dt * 60)) so particles burst out then settle quickly; constant 3 units/sec upward acceleration adds vertical drift.
  • Pool growth is gated inside the spawn loops by this._particles.length < this.MAX per push — once full, the remaining iterations of a single spawn call are skipped without error.
  • Offscreen canvas resizing: _ensureCv recreates the canvas (and re-fetches its context) whenever requested dimensions differ from current — there is no graceful resize, the old buffer is dropped.