/** * Engine flare + exhaust — the ship's two side-pod engines (frame 0 of * data/ship.json → texture, art facing north) light up the moment the ship * starts moving and throw a short, quickly-fading exhaust trail behind * them. When the throttle cuts (coasting, arrival) the burn dies within a * couple of frames and the stragglers fade out in under half a second. * * What you see (all additive; depth 9 — under the ship (10), above the * tether (6), so the glow spills out from under the pods): * - NOZZLE GLOW — a soft light under each engine, brightening with the * burn level; * - EXHAUST — a stream of small puffs streaming out behind each * engine, opposite the ship's current direction of motion. The * puffs live in the emitter's local space (this build drags them * with the emitter, verified with dev/particle-probe.html) and their * velocity is exactly the (speedX, speedY) ops — so aiming those * components opposite the heading is what streams the trail astern; * the trail length is puffs-speed × lifespan, independent of ship * speed; * - IGNITION FLARE — starting from a standstill: a quick burst of bright * puffs plus a momentary bloom at each nozzle, then the burn settles. * * The burn level follows the throttle actually being applied * (ship.body.acceleration vs ship.thrust), so braking into a target dims * the exhaust automatically, and the Shift+click throttle lock lights it * up full. It is driven from GameScene.update() (the scene's only clock, * see the v4 quirk noted there) and knows the scene's sleep/wake contract * (landing: sleep() cleans up; the display list survives a jump's scene * restart, so create-side code destroys any previous instance first). * * Config: data/ship.json → thrustFx. */ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { canvasTexture } from '../utils/Textures.js'; import { toColor } from '../utils/Color.js'; import { Ship } from '../entities/Ship.js'; const TEX_KEY = 'fx-thrust-soft'; const DEPTH = 9; // under the ship (10), above the tether (6) const TEX = 64; // soft-dot texture size (px) // Engine nozzle centers in the art's frame space (256×256 frame, art // facing north — measured on assets/images/ships-player.png, frame 0): // the exhaust fan at the foot of each side pod. For the built-in dart // fallback (it faces east, `size` px wide) there is one tail point. const NOZZLES_SHEET = [ { lx: -78, ly: 73 }, // port pod { lx: 76, ly: 73 }, // starboard pod ]; /** A small, bright, soft dot. White, so a tint reads as its color. */ function drawSoftDot(ctx, size) { const c = size / 2; const g = ctx.createRadialGradient(c, c, 0, c, c, c); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.22, 'rgba(255,255,255,0.85)'); g.addColorStop(0.5, 'rgba(255,255,255,0.28)'); g.addColorStop(0.78, 'rgba(255,255,255,0.07)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); } /** Lerp two 0xRRGGBB colors (t in [0,1] → weight on b). */ function mix(a, b, t) { const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t); const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t); const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t); return (r << 16) | (g << 8) | bl; } export class EngineFlare { /** * @param {object} scene the space scene (GameScene) * @param {Ship} ship the ship whose engines to light */ constructor(scene, ship) { this.scene = scene; this.ship = ship; this.enabled = config.get('ship.thrustFx.enabled', true); this.coreColor = toColor(config.get('ship.thrustFx.coreColor', '#f2ffff'), 0xf2ffff); this.plumeColor = toColor( config.get('ship.thrustFx.plumeColor', config.get('ship.cockpitColor', '#41c7ff')), 0x41c7ff, ); // Burn state (see update()). this.burn = 0; // eased 0..1 throttle actually being applied this.flare = 0; // ignition pop, 1 → 0 over ~0.32 s this.burst = 0; // ignition velocity kick, 1 → 0 over ~0.12 s this.prevSpeed = 0; this.flashes = []; // { img, t0, dur, strength } this.emitters = []; this.glows = []; this._velSlot = null; // shared per-puff vector between the speedX/speedY ops if (!this.enabled) return; const tex = canvasTexture(scene, TEX_KEY, TEX, TEX, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h))); // Dart fallback: one tail point in the dart's frame (it faces east). const size = config.get('ship.size', 46); const hasSheet = !!config.get('ship.texture', '') && scene.textures.exists(Ship.TEXTURE_KEY); this.nozzles = hasSheet ? NOZZLES_SHEET : [{ lx: -size * 0.28, ly: 0 }]; for (const n of this.nozzles) { // The plume. Per-emission values are FUNCTIONS of the live burn // state (the emitter's function-ops evaluate them at fire time), // so the same emitter is subtle at low throttle and full at the // throttle lock — no per-frame config surgery needed. const e = scene.add.particles(0, 0, tex, { lifespan: { min: 220, max: 400 }, // THIS BUILD: particle velocity is taken from (speedX, speedY) // (with `radial: true` + an `angle` op, every puff still flew +x — // verified empirically), so the exhaust direction is computed as // components here, opposite the heading. `radial` is pinned to // false below as belt and braces. speedX: () => { this._velSlot = null; return this._emitVel().x; }, speedY: () => { const v = this._velSlot || (this._velSlot = this._emitVel()); this._velSlot = null; return v.y; }, scale: () => this._emitScale(), alpha: { start: 0.5, end: 0 }, // modest: the trail reads by density, not by bloom tint: () => this._emitTint(), blendMode: Phaser.BlendModes.ADD, emitting: false, maxAliveParticles: 60, }); e.radial = false; // belt and braces: velocity must stay (speedX, speedY) e.setDepth(DEPTH); this.emitters.push(e); // The nozzle glow (the "engines are lit" read on the art itself). const g = scene.add .image(0, 0, tex) .setDepth(DEPTH) .setTint(this.coreColor) .setBlendMode(Phaser.BlendModes.ADD) .setScale(0.3) .setAlpha(0) .setVisible(true); this.glows.push(g); } } // ------------------------------------------------------------------- // Per-emission values (function-ops: called at fire time) // ------------------------------------------------------------------- /** * One exhaust vector (px/s, emitter-local) per puff — opposite the * ship's heading, in a ±12° cone. * * speedX and speedY are two separate op calls for the SAME particle, * made back-to-back inside the emitter's fire() (speedX first, nothing * between), so speedX draws one vector into a shared slot and speedY * consumes it — one speed, one direction, no per-component * re-randomizing. The slot is cleared immediately after use: pooled * particles are recycled, so anything left over from a previous puff * must never leak into the next one. */ _emitVel() { const eff = Math.min(1, this.burn + this.flare * 0.4); const speed = (50 + 250 * eff) * (0.75 + 0.5 * Math.random()) * (1 + 1.2 * this.burst); const ang = this._heading() + Math.PI + (Math.random() - 0.5) * 0.42; // exhaust = opposite heading const v = { x: Math.cos(ang) * speed, y: Math.sin(ang) * speed }; this._velSlot = v; return v; } _emitScale() { const eff = Math.min(1, this.burn + this.flare * 0.4); return (0.07 + 0.13 * eff) * (0.7 + 0.6 * Math.random()); // ~7–17 px puffs (64 px dot) } _emitTint() { // Most puffs ride the plume color; a third run hot (core), and the // ignition flare pushes everything toward white. const t = Math.min(1, 0.1 + 0.45 * Math.random() + 0.3 * this.flare); return mix(this.plumeColor, this.coreColor, t); } // ------------------------------------------------------------------- // Geometry / state helpers // ------------------------------------------------------------------- /** Ship's actual heading (world rad) — direction of motion, or its facing at rest. */ _heading() { const s = this.ship; const b = s.body; if (b) { const vx = b.velocity.x; const vy = b.velocity.y; if (Math.hypot(vx, vy) > 10) return Math.atan2(vy, vx); } return s.rotation - (s.artOffset || 0); } /** World position of a nozzle (frame-local offset, through the ship's transform). */ _nozzleWorld(n) { const s = this.ship; const k = s.scaleX; const c = Math.cos(s.rotation); const sn = Math.sin(s.rotation); return { x: s.x + (n.lx * c - n.ly * sn) * k, y: s.y + (n.lx * sn + n.ly * c) * k, }; } /** * Ignition: the ship is starting to move from a standstill. Pop the * flare (full from dead stop, half when re-lighting at speed), fire a * burst of puffs at each nozzle, and bloom a flash sprite there. */ _ignite(strength, time) { this.flare = strength; this.burst = 1; for (let i = 0; i < this.nozzles.length; i++) { const p = this._nozzleWorld(this.nozzles[i]); const e = this.emitters[i]; e.setPosition(p.x, p.y); // explode() emits relative to the emitter e.frequency = 6; // (explode resets the flow; keep the flow sane) e.start(); e.explode(4 + Math.round(3 * strength), 0, 0); this._spawnFlash(p.x, p.y, strength, time); } } _spawnFlash(x, y, strength, time) { const img = this.scene.add .image(x, y, TEX_KEY) .setDepth(DEPTH) .setTint(this.coreColor) .setBlendMode(Phaser.BlendModes.ADD) .setScale(0.28) .setAlpha(0.5); this.flashes.push({ img, t0: time, dur: 340, strength }); } // ------------------------------------------------------------------- // Frame hook (GameScene.update — the scene's only clock) // ------------------------------------------------------------------- update(time, delta) { if (!this.enabled) return; const ship = this.ship; const dt = Math.min(delta, 64) / 1000; const body = ship.body; const speed = body ? Math.hypot(body.velocity.x, body.velocity.y) : 0; const accel = body ? Math.hypot(body.acceleration.x, body.acceleration.y) : 0; // Burn = the throttle actually being applied (0 = coast/idle). const target = Math.min(1, accel / ship.thrust); const wasAtRest = this.prevSpeed < 40 && this.burn < 0.08; if (target > 0.08 && wasAtRest) { this._ignite(this.prevSpeed < 15 ? 1 : 0.5, time); this.burn = Math.max(this.burn, 0.2); // the flare must read, even on a tiny tap } this.prevSpeed = speed; // Ease the burn: fast attack (the "flare-up" ramp); when the throttle // is cut the decay runs faster still — the trail is meant to be GONE // well under a second after the engines go quiet. const rate = target > this.burn ? 12 : target > 0 ? 7 : 14; this.burn += (target - this.burn) * Math.min(1, rate * dt); if (target <= 0 && this.burn < 0.04) this.burn = 0; // Flare + burst decay (frame-driven; no tween dependency). if (this.flare > 0) this.flare = Math.max(0, this.flare - dt / 0.32); if (this.burst > 0) this.burst = Math.max(0, this.burst - dt / 0.12); const eff = Math.min(1, this.burn + this.flare * 0.4); for (let i = 0; i < this.nozzles.length; i++) { const p = this._nozzleWorld(this.nozzles[i]); const e = this.emitters[i]; if (eff > 0.03) { e.frequency = Math.round(Phaser.Math.Clamp(34 - 22 * eff, 8, 45)); // ms between puffs (subtle stream) e.start(); } else { e.stop(); } e.setPosition(p.x, p.y); const g = this.glows[i]; g.setPosition(p.x, p.y); g.setAlpha(0.55 * eff); g.setScale(0.28 + 0.34 * eff + 0.26 * this.flare); // ~18–54 px bloom } // Ignition blooms (manual clock — the scene's tweens are not ours to lean on). for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; const u = (time - f.t0) / f.dur; if (u >= 1) { f.img.destroy(); this.flashes.splice(i, 1); continue; } const ease = 1 - (1 - u) * (1 - u); f.img.setScale(0.28 + ease * (0.45 + 0.25 * f.strength)); f.img.setAlpha((1 - u) * (0.3 + 0.2 * f.strength)); } } // ------------------------------------------------------------------- // Scene lifecycle // ------------------------------------------------------------------- /** Landing (scene sleep): nothing may survive frozen mid-glow. */ sleep() { if (!this.enabled) return; for (const e of this.emitters) { e.stop(); e.killAll(); } for (const g of this.glows) g.setAlpha(0); for (const f of this.flashes) f.img.destroy(); this.flashes.length = 0; this.burn = 0; this.flare = 0; this.burst = 0; } /** Idempotent teardown (safe over an already-destroyed scene display list). */ destroy() { if (!this.enabled) return; for (const e of this.emitters) { try { e.stop(); e.killAll(); e.destroy(); } catch { /* already gone */ } } for (const g of this.glows) { try { g.destroy(); } catch { /* already gone */ } } for (const f of this.flashes) { try { f.img.destroy(); } catch { /* already gone */ } } this.flashes.length = 0; this.emitters.length = 0; this.glows.length = 0; } }