engine/vfx/thruster.ts

PURPOSE

Renders the player ship’s engine fire jet — a single tapered flame drawn behind the ship in the world, under it in the draw order. Color and intensity are driven by the ship’s heat relative to its ideal heat rating: low heat reads yellow/white, ideal heat reads red-orange at full size, overheat slides through deep red into an “angry” dark red as the flame slightly chokes. Cold-flame color is also tinted by the active ship’s rarity. The page covers four stacked additive cones (outer glow, main flame, hot core, second offset flame layer) plus a fixed ring buffer of tiny fire-spark particles drifting along the flame body. No smoke is emitted — the older regular-and-overheat smoke streams were removed so the field reads as a clean fire jet at all heat levels.

OWNS

  • MAX_FIRE_SPARKS fixed-size ring buffer fireSparks: FireSpark[] of {alive, x, y, vx, vy, life, maxLife, size, r, g, b} slots, all pre-allocated at module load to avoid per-frame allocation.
  • Module-level spark-spawn accumulator _sparkAccum (carries fractional spawn budget across frames, hard-clamped to 3).
  • Module-level _rarityRGB: [number, number, number] holding the cold-flame tint for the currently active ship rarity (default deep yellow [255,200,60]).
  • Tuning constants for flame geometry (FLAME_REACH = 0.85, FLAME_OVERSHOOT = 0.15, FLAME_WIDTH_BASE = 0.24), glow shell (GLOW_WIDTH_MULT = 1.5, GLOW_ALPHA = 0.18), hot core (CORE_LEN_FRAC = 0.45, CORE_WIDTH_FRAC = 0.5), flicker (FLICKER_SPEED = 12 Hz, FLICKER_AMP = 0.12), and spark spawn rate (FIRE_SPARK_RATE_THRUST = 35 per second).
  • Heat-to-color palette stops: outer cones (COL_COLD, COL_WARM, COL_IDEAL, COL_OVERHEAT, COL_MAX_HEAT) and inner cores (CORE_COLD, CORE_IDEAL, CORE_OVERHEAT).
  • Per-rarity flame-color table (common, uncommon, rare, epic) used by setThrusterRarityColor.
  • An unused vestigial constant FLAME_REACH_IDLE kept for future idle-flame work (the function currently returns immediately when not thrusting).
  • Private helpers lerpColor, rgbStr, and _spawnFireSpark (free-slot scan with oldest-spark recycle fallback).

READS FROM

  • ../rendering/cameraCamera.toS(worldX, worldY) to project the flame base, tip, core tip, and each spark from world space to screen space.
  • ../core/statecamera.zoom to scale flame widths and spark sizes; game.time to drive the second flame layer’s independent flicker oscillator.
  • Caller-provided arguments only for ship state: world position (sx, sy), angle, collision radius, heat, idealHeat, thrusting flag, time, dt, ship velocity (shipVx, shipVy), and visual shieldRadius.

PUSHES TO

  • The 2D canvas context ctx passed in by the caller. All four flame cones and every spark are drawn with globalCompositeOperation = 'lighter' (additive blending) inside a single save()/restore() pair so the composite mode never leaks.
  • The internal fireSparks ring buffer (mutates alive, position, velocity, life, size, and RGB on spawn; mutates position/velocity/life on update; clears alive when life expires or on resetThruster).
  • The internal _rarityRGB state when setThrusterRarityColor is invoked at run start.

DOES NOT

  • Does not mutate the ship, the camera, game state, or any other game system. Pure draw + private particle state.
  • Does not emit smoke, soot, exhaust trails, or overheat black-smoke streams — those layers were removed and the legacy updateThrusterSmoke name is retained only for caller-side import stability.
  • Does not render anything when thrusting is false; alive sparks continue to fade naturally via the next updateThrusterSmoke tick, but no new geometry or sparks are drawn or spawned that frame.
  • Does not allocate per frame — sparks live in a pre-sized ring buffer and the spawn helper either claims a dead slot or recycles the spark with the lowest remaining life.
  • Does not adapt the flame to the ship sprite, hull shape, or weapon loadout — geometry is derived purely from radius and shieldRadius.
  • Does not handle multiple thrusters, side jets, or boost effects; only one rear jet per call.
  • Does not own its own requestAnimationFrame/update loop, sound, screen-shake, or post-processing.

Signals

  • setThrusterRarityColor(rarity: string): void — Sets the cold-flame tint for the run from a fixed {common, uncommon, rare, epic} → RGB table; unknown rarities fall back to the default deep yellow.
  • updateThrusterSmoke(dt: number): void — Advances all alive sparks: decrements life, kills expired sparks, integrates x/y by vx/vy, and applies an exponential-decay drag of 0.5^dt per axis so sparks slow naturally.
  • drawThruster(ctx, sx, sy, angle, radius, heat, idealHeat, thrusting, time, dt, shipVx, shipVy, shieldRadius): void — Renders the flame stack for one ship and spawns fire sparks for this frame. Early-returns when thrusting is false.
  • resetThruster(): void — Marks every spark slot dead and zeroes the spawn accumulator; called at the start of each run.

Entry points

  • Imported by engine/bridge.ts as import { drawThruster, updateThrusterSmoke, resetThruster, setThrusterRarityColor } from './vfx/thruster'.
  • resetThruster() is called from the run-start path in bridge.ts alongside the planet/context initialisation.
  • setThrusterRarityColor(shipRarity) is called once at run start in bridge.ts after the active ship hull and rarity are resolved.
  • updateThrusterSmoke(rawDt) is called every frame from the bridge update loop alongside other VFX systems (ExplosionFX.update, AoeExplosion.update, PlayerGlow.update).
  • drawThruster(...) is called from the bridge ship-render path before the sticker layer is blitted, so the flame appears under the ship. The angle passed in is the ship’s visual angle, falling back to targetAngle for hulls with rotates === false so the flame still tracks the aim direction.

Pattern notes

  • Single thrusting gate. thrusting === false short-circuits the entire draw and spawn pipeline. Alive sparks still fade out via the next updateThrusterSmoke call. The unused FLAME_REACH_IDLE constant is referenced as void FLAME_REACH_IDLE to suppress the unused-symbol warning while preserving it for future idle-flame work.
  • Heat normalisation. h = clamp(heat/100, 0, 1) and idealH = clamp(idealHeat/100, 0.1, 1) — the 0.1 floor on idealH keeps the cold/warm/ideal split well-defined when callers pass small or zero ideal-heat targets.
  • Heat-driven length curve. Below the ideal heat band the multiplier ramps 0.85 → 1.0 linearly over [0, idealH]. Above ideal it eases back from 1.0 → 0.9 over [idealH, 1], modelling the flame choking slightly at overheat rather than continuing to grow.
  • Two-sine flicker. Length flicker is 1 + sin(time*12)*0.12 + sin(time*12*1.7 + 2)*0.06, producing a non-periodic shimmer at ~12 Hz. The second flame cone runs an independent sin(game.time*18 + 2.0)*0.10 oscillator so the two layers beat against each other rather than pulsing in lockstep.
  • Width discipline. Heat width scales as 1 + h² (quadratic), but the final flame half-width is hard-clamped to the ship’s collision radius so the jet never visually exceeds the ship footprint.
  • Heat-banded color blend. The heat range is split into four bands at [0, 0.5·idealH], [0.5·idealH, idealH], [idealH, idealH + 0.5·(1−idealH)], [idealH + 0.5·(1−idealH), 1] and each band lerps its own outer/core color pair. The cold-flame band additionally lerps the outer color toward the rarity tint with weight (0.3 − h)/0.3 for h < 0.3, so the rarity color is dominant at idle and dissolves into the normal fire palette by the time the ship is mildly warm.
  • Geometry. Each cone is drawn as a triangle: two base vertices at ±halfWidth along the perpendicular at the ship centre, and one tip vertex along angle + π at flameLen. The hot core uses the same base point but a shorter tip at flameLen * CORE_LEN_FRAC with coreW = halfW * CORE_WIDTH_FRAC. All four cones use linear gradients from base to tip with the alpha taper baked into the gradient stops, so no per-pixel alpha math runs in script.
  • Additive composition. A single ctx.save() sets globalCompositeOperation = 'lighter' and is mirrored by ctx.restore() after the spark pass, guaranteeing the additive mode does not leak into subsequent draws even if cones or sparks throw.
  • Ring-buffer particles, no allocations. MAX_FIRE_SPARKS = 30 slots are allocated once at module load; _spawnFireSpark first walks the array for a dead slot, then falls back to recycling the slot with the lowest remaining life. The spawn accumulator carries the fractional spark budget across frames and is hard-clamped to 3 so a long pause cannot release a burst.
  • Spark placement. Sparks spawn at a random t ∈ [0.3, 0.9] along the flame, offset laterally by ±(1 − t) · 6 / 2 so the cloud tapers with the flame. Initial velocity combines sideways jitter (±20 per axis on the perpendicular), a small backward drift (+10 along the back axis), and 20% of the ship velocity, so sparks lag the ship realistically. Lifetime is 0.1–0.3 s and the spark color is the current outer-flame RGB brightened by +30 per channel and clamped to 255.
  • Spark draw. Each alive spark is drawn as a filled circle of screen radius max(0.5, size · zoom · (1 − 0.5·progress)) with alpha 1 − progress for a linear fade. Because the spark pass runs inside the additive save/restore, sparks composite over the flame as bright hot dots.
  • Legacy name retained. The per-frame update is still exported as updateThrusterSmoke to keep the import site in bridge.ts stable across the v5.164.3 smoke removal; the function body now only ticks fire sparks.