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
SmokeParticlepool (SmokeFX._particles) capped atMAX(150 mobile / 400 desktop). - Three lazy-baked stamp canvases:
_smokeStamp,_flashStamp,_glowStamp. - Two offscreen compositing canvases sized to the main canvas:
_cvBot/_cxBotfor the bottom layer and_cvTop/_cxTopfor 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-atoplinear-gradient tint, final composite to the main context at fixed opacity.
READS FROM
camerafrom../coreforcamera.zoom.isMobile()from../core/device-capabilities(at module init) to chooseMAX.Camera.toS(x, y)from../renderingfor 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
CanvasRenderingContext2Dpassed intodrawBottom/drawTop/draw: bottom layer composited at 0.75 alpha, top layer at 0.40 alpha, flash particles drawn directly withlighterblending. - Mutates
mainCtxsave/restore around composites and flash draws; resetsglobalCompositeOperationandglobalAlphaback 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 —
drawBottomanddrawTopearly-return whendocumentis 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
createRadialGradientper particle; only once per stamp at first use.
Signals
- No event bus subscriptions. Callers invoke
smoke,smokeCloud, orexplosiondirectly. The engine’s render loop callsupdate(dt)once per frame anddrawBottom/drawTopat the appropriate Z slots (or the legacydrawwhich 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 offradius.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 callsdrawBottomthendrawTopback-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 usedrawImage— the stated reason being a mobile bottleneck of ~400 gradients/frame collapsing to zero. - Layer separation: each particle stores its
layer;drawBottom/drawTopfilter by layer in tight inner loops, also short-circuiting via ahasSmoke/hasTopSmoke/hasFlashpre-scan that skips offscreen-canvas work when nothing is queued. - Three-phase composite per layer: (1) additive
lighterblend of stamps into the offscreen canvas, (2)source-atoptint via a diagonal linear gradient (dark slate for bottom, near-white for top), (3)source-overcomposite 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 fromdrawTopwithlighterblending —layeris effectively ignored for flashes (filtered byisFlashfirst). - 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.MAXper push — once full, the remaining iterations of a single spawn call are skipped without error. - Offscreen canvas resizing:
_ensureCvrecreates the canvas (and re-fetches its context) whenever requested dimensions differ from current — there is no graceful resize, the old buffer is dropped.