Add engine flare and exhaust trail to ship

- New `js/visuals/EngineFlare.js` drives nozzle glow, ignition burst, and a short fading exhaust plume from the two side-pod engines, driven by the ship's live throttle so braking dims it and Shift+click lights it up full
- Configurable via new `thrustFx` block in `data/ship.json` (enabled flag, core/plume colors)
- Hooked into GameScene lifecycle: created per scene restart, updated from the scene clock, cleaned up on landing sleep and scene destroy so nothing survives frozen mid-glow
- Add dev harnesses for visual verification: `dev/engine-flare-test.mjs` (headless Chromium CDP walk through idle → ignite → burn → coast → settled with screenshots) and `dev/particle-probe.{html,mjs}` to confirm particle velocity follows the emitter's heading
- Add new arid planet art asset
This commit is contained in:
Brian Fertig 2026-09-13 17:43:14 -06:00
parent cedee9f58e
commit 5408616bbf
7 changed files with 683 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -8,6 +8,12 @@
"size": 46,
"color": "#dfe7ff",
"cockpitColor": "#41c7ff",
"thrustFx": {
"_comment": "Engine flare + exhaust (js/visuals/EngineFlare.js) — the animated ignition when the ship starts moving, and the short trail that fades out behind the two side-pod engines. enabled=false turns the whole effect off (no code runs). coreColor = the hot center (ignition bloom + the brightest puffs); plumeColor = the body of the exhaust, defaults to the cockpit's cyan.",
"enabled": true,
"coreColor": "#f2ffff",
"plumeColor": "#41c7ff"
},
"scale": 1,
"thrust": 900,
"maxSpeed": 480,

181
dev/engine-flare-test.mjs Normal file
View File

@ -0,0 +1,181 @@
// Visual test for the engine flare (js/visuals/EngineFlare.js).
// Loads the real game in headless Chromium, starts GameScene, drives the
// ship through idle -> ignition -> full burn -> coast -> stopped, capturing
// a screenshot + fx state at each beat.
//
// node dev/engine-flare-test.mjs <outDir>
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const outDir = process.argv[2] || '/tmp/efx';
fs.mkdirSync(outDir, { recursive: true });
const PORT = 9450 + Math.floor(Math.random() * 90);
const HOME = process.env.HOME || os.homedir();
const CHROME = [
path.join(HOME, '.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'),
path.join(HOME, '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome'),
].find((p) => fs.existsSync(p));
if (!CHROME) throw new Error('no chromium');
const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'efx-'));
const chrome = spawn(CHROME, [
'--headless', `--remote-debugging-port=${PORT}`, `--user-data-dir=${udd}`,
'--no-first-run', '--disable-gpu', '--enable-unsafe-swiftshader', '--no-sandbox',
'--window-size=960,600', 'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
let v;
for (let i = 0; i < 100; i++) {
try { v = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(200); }
}
if (!v) throw new Error('CDP never came up');
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent('http://127.0.0.1:8123/index.html')}`, { method: 'PUT' })).json();
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
const send = (m, pa = {}) => new Promise((res, rej) => { const i = ++id; p.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: pa })); });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && p.has(x.id)) { const { res, rej } = p.get(x.id); p.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } };
await new Promise((r) => (ws.onopen = r));
await send('Page.enable');
await send('Runtime.enable');
await send('Network.enable');
await send('Network.setCacheDisabled', { cacheDisabled: true });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text).slice(0, 500));
return r.result?.value;
};
const shot = async (name) => {
const s = await send('Page.captureScreenshot', { format: 'png' });
const f = path.join(outDir, name + '.png');
fs.writeFileSync(f, Buffer.from(s.data, 'base64'));
return f;
};
// Wait for game boot + GameScene ready.
const ready = async (expr, ms = 30000) => {
const t0 = Date.now();
while (Date.now() - t0 < ms) {
const r = await evaluate(expr).catch(() => null);
if (r) return r;
await sleep(250);
}
throw new Error('timeout waiting: ' + expr);
};
await ready('window.game && window.game.isRunning');
// Start the game scene directly (skip the menu).
await evaluate('(() => { window.game.scene.start("GameScene"); return "started"; })()');
await ready('(() => { const s = window.game.scene.getScene("GameScene"); return s && s.ship && s.engineFx ? { burn: s.engineFx.burn, nozzles: s.engineFx.nozzles?.length } : null; })()', 30000);
// Keep the test deterministic: an auto-playing 'ack' comm pauses the
// action contract (ship.stop()) mid-sequence. Quiet the comms hub —
// the effect under test (engine flare) does not depend on it.
await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); if (s.comms) { s.comms._down = true; s.comms._setPaused(false); } return "ok"; })()');
const fx = () => `(() => {
const s = window.game.scene.getScene("GameScene");
const f = s.engineFx; const sh = s.ship;
const alive = f.emitters.reduce((n, e) => n + (e.alive ? e.alive.length : 0), 0);
return {
burn: +f.burn.toFixed(3), flare: +f.flare.toFixed(3), burst: +f.burst.toFixed(3),
alive, speed: +Math.hypot(sh.body.velocity.x, sh.body.velocity.y).toFixed(1),
state: sh.state,
accel: +Math.hypot(sh.body.acceleration.x, sh.body.acceleration.y).toFixed(1),
};
})()`;
const base = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()');
console.log('ship at', base);
// Some seeds spawn the ship touching the tether/a solid (it is then braked
// on contact — real game behavior). Clear that first so the thrust test
// can run: if a constraint touches, step the ship out of it.
const clear = await evaluate(`(() => {
const s = window.game.scene.getScene("GameScene");
const sh = s.ship;
const touches = () => !!(s.tetherField.constrainShip(sh) || s.solids.some((o) => o.constrainShip(sh, sh.radius)));
if (!touches()) return { ok: true, wasTouching: false };
// Step the ship out of whatever touches it: away from the nearest
// tether anchor / solid center.
let best = null;
for (const t of s.tetherField.tethers) {
const d = Math.hypot(sh.x - t.x, sh.y - t.y);
if (d < t.radius + 40) best = { x: t.x, y: t.y };
}
if (!best) {
let min = Infinity;
for (const o of s.solids) {
const d = Math.hypot(sh.x - o.x, sh.y - o.y);
if (d < min) { min = d; best = { x: o.x, y: o.y }; }
}
}
for (let i = 0; i < 20 && touches(); i++) {
const d = Math.hypot(sh.x - best.x, sh.y - best.y) || 1;
sh.setPosition(sh.x + ((sh.x - best.x) / d) * 80, sh.y + ((sh.y - best.y) / d) * 80);
sh.body.velocity.set(0, 0);
sh.body.acceleration.set(0, 0);
}
return { ok: !touches(), wasTouching: true, pos: [Math.round(sh.x), Math.round(sh.y)] };
})()`);
if (!clear.ok) throw new Error('could not clear obstacle: ' + JSON.stringify(clear));
if (clear.wasTouching) console.log('cleared obstacle at', clear.pos);
const pos = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()');
Object.assign(base, pos);
// 0) idle
await sleep(500);
console.log('idle ', JSON.stringify(await evaluate(fx())));
await shot('0-idle');
// 1) ignition: full throttle lock. The spawn seed decides where the
// tether rim and solids are, so RAYCAST candidate headings against
// the real geometry (the ship must stay inside the tether union and
// clear of solid worlds) and use the first clear corridor.
const corridor = await evaluate(`(() => {
const s = window.game.scene.getScene("GameScene");
const sh = s.ship;
const tethers = s.tetherField.tethers;
const insideUnion = (x, y) => tethers.some((t) => { const dx = x - t.x, dy = y - t.y; return dx * dx + dy * dy <= (t.radius + 1) * (t.radius + 1); });
const clearOfSolids = (x, y) => s.solids.every((o) => Math.hypot(o.x - x, o.y - y) > (o.radius ?? 30) + sh.radius + 80);
for (let a = 0; a < 360; a += 10) {
const rad = a * Math.PI / 180;
let good = true;
for (let d = 150; d <= 750; d += 150) {
const x = sh.x + Math.cos(rad) * d, y = sh.y + Math.sin(rad) * d;
if (!insideUnion(x, y) || !clearOfSolids(x, y)) { good = false; break; }
}
if (good) return { ang: a, tx: Math.round(sh.x + Math.cos(rad) * 900), ty: Math.round(sh.y + Math.sin(rad) * 900) };
}
return null;
})()`);
if (!corridor) throw new Error('no clear flight corridor found');
console.log('corridor ang=' + corridor.ang + ' target', corridor.tx, corridor.ty);
await evaluate(`(() => { const s = window.game.scene.getScene("GameScene"); s.ship.thrustToward(${corridor.tx}, ${corridor.ty}); return "ok"; })()`);
await sleep(130);
console.log('ignite ', JSON.stringify(await evaluate(fx())));
await shot('1-ignite');
// 2) full burn (throttle lock holds)
await sleep(700);
console.log('burn ', JSON.stringify(await evaluate(fx())));
await shot('2-burn');
// 3) coast: cut throttle -> burn decays, trail fades
await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); s.ship.stop(); return s.ship.state; })()');
await sleep(160);
console.log('coast ', JSON.stringify(await evaluate(fx())));
await shot('3-coast');
// 4) settled: nothing lit, no live particles
await sleep(900);
console.log('done ', JSON.stringify(await evaluate(fx())));
await shot('4-settled');
ws.close();
} finally {
chrome.kill();
setTimeout(() => fs.rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), 1500);
}

68
dev/particle-probe.html Normal file
View File

@ -0,0 +1,68 @@
<!-- Probe: do live particles stay in WORLD space when the emitter moves?
Served via dev server; driven by particle-probe.mjs. Results in window.__result -->
<!doctype html>
<html>
<head><meta charset="utf-8"><title>particle probe</title></head>
<body style="margin:0;background:#222">
<script src="/lib/phaser.min.js"></script>
<script>
const make = () => {
const g = document.createElement('canvas');
g.width = 32; g.height = 32;
const c = g.getContext('2d');
const gr = c.createRadialGradient(16, 16, 0, 16, 16, 16);
gr.addColorStop(0, 'rgba(255,255,255,1)');
gr.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = gr; c.fillRect(0, 0, 32, 32);
return g;
};
class Probe extends Phaser.Scene {
create() {
this.textures.addCanvas('dot', make());
this.ship = this.add.rectangle(100, 180, 24, 24, 0xffcc00);
this.em = this.add.particles(100, 180, 'dot', {
speedX: 100, // magnitude
angle: 0, // +x direction (radial true)
lifespan: 2000,
alpha: { start: 1, end: 0.3 },
scale: 0.8,
frequency: 50,
emitting: true,
});
this.em.setDepth(1);
this.ship.setDepth(2);
this.frames = 0;
}
update() {
this.frames++;
if (this.frames === 120) {
// snapshot BEFORE the move
const snap = (list) => list.map((p) => ({ x: p.x, y: p.y, wx: p.worldPosition ? p.worldPosition.x : null }));
const before = snap(this.em.alive);
// "ship moves": move the emitter (and ship) +300px in +x
this.em.setPosition(400, 180);
this.ship.setPosition(400, 180);
this.__before = before;
}
if (this.frames === 130) {
const snap = (list) => list.map((p) => ({ x: p.x, y: p.y, wx: p.worldPosition ? p.worldPosition.x : null }));
const after = snap(this.em.alive);
window.__result = { before: this.__before, after, emitter: { x: this.em.x, y: this.em.y } };
}
if (this.frames === 140) {
window.__shot = this.game.renderer.snapshot ? this.game.renderer.snapshot() : null;
window.__done = true;
}
}
}
new Phaser.Game({
type: Phaser.CANVAS,
width: 640, height: 360,
backgroundColor: '#101418',
scene: Probe,
});
</script>
</body>
</html>

59
dev/particle-probe.mjs Normal file
View File

@ -0,0 +1,59 @@
// Drive headless Chromium via CDP: load a page, wait for window.__done,
// print window.__result (and save a renderer snapshot if present).
// Usage: node particle-probe.mjs <url> [waitMs]
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const [url, waitMs = '15000'] = process.argv.slice(2);
const PORT = 9400 + Math.floor(Math.random() * 100);
const HOME = process.env.HOME || os.homedir();
const CHROME = [
path.join(HOME, '.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'),
path.join(HOME, '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome'),
].find((p) => fs.existsSync(p));
if (!CHROME) throw new Error('no chromium found');
const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'cdp-probe-'));
const chrome = spawn(CHROME, [
'--headless', `--remote-debugging-port=${PORT}`, `--user-data-dir=${udd}`,
'--no-first-run', '--disable-gpu', '--enable-unsafe-swiftshader', '--no-sandbox',
'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
let v;
for (let i = 0; i < 100; i++) {
try { v = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(200); }
}
if (!v) throw new Error('CDP never came up');
let t;
try { t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); }
catch { t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); }
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
const send = (m, pa = {}) => new Promise((res, rej) => { const i = ++id; p.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: pa })); });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && p.has(x.id)) { const { res, rej } = p.get(x.id); p.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } };
await new Promise((r) => (ws.onopen = r));
await send('Runtime.enable');
const t0 = Date.now();
let done = false;
while (Date.now() - t0 < Number(waitMs)) {
await sleep(250);
const r = await send('Runtime.evaluate', { expression: 'window.__done === true', returnByValue: true });
if (r.result && r.result.value === true) { done = true; break; }
}
const r = await send('Runtime.evaluate', { expression: 'JSON.stringify(window.__result)', returnByValue: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 400));
console.log('DONE:', done);
console.log('RESULT:', r.result && r.result.value);
const s = await send('Runtime.evaluate', { expression: 'document.querySelector("canvas").toDataURL("image/png")', returnByValue: true });
if (s.result && s.result.value) {
fs.writeFileSync('/tmp/particle-probe.png', Buffer.from(s.result.value.split(',')[1], 'base64'));
console.log('SHOT: /tmp/particle-probe.png');
}
ws.close();
} finally {
chrome.kill();
setTimeout(() => fs.rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), 1500);
}

View File

@ -21,6 +21,7 @@ import { Starfield } from '../visuals/Starfield.js';
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
import { NebulaLightning } from '../visuals/NebulaLightning.js';
import { EmberField } from '../visuals/EmberField.js';
import { EngineFlare } from '../visuals/EngineFlare.js';
import { WandererStar } from '../visuals/WandererStar.js';
import { RedHaze } from '../visuals/RedHaze.js';
import { VoidBase } from '../visuals/VoidBase.js';
@ -349,6 +350,7 @@ export class GameScene extends Phaser.Scene {
this.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface
this.setMiningLoop(false); // …nor the mining hum
this.engineFx?.sleep(); // no ghost exhaust waiting on the pad
});
this.events.on('wake', () => {
this.startGameMusic(); // back in space — the shuffle starts fresh
@ -675,6 +677,14 @@ export class GameScene extends Phaser.Scene {
}
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
// ENGINE FX — flare-up + exhaust trail on the ship's side pods
// (js/visuals/EngineFlare.js). Created per create() because a jump
// restarts this scene in place: the display list (emitters, glows)
// is destroyed with it, while our ref survives — same contract as
// the effect layers below. It reads the ship's throttle per frame
// (update) and cleans up on landing (sleep).
this.engineFx?.destroy();
this.engineFx = new EngineFlare(this, this.ship);
// WORLD PREVIEW (?type=…): the ship starts beside the TARGET world
// (this._previewEntity — the planet/station the pick named), not
// the home world — the point is to land on it and judge its clips
@ -2049,6 +2059,7 @@ export class GameScene extends Phaser.Scene {
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
this.questTracker?.update(_time); // the tracker: live checklist + priority
if (!_commsPaused) this.ship.update(_time, delta);
this.engineFx?.update(_time, delta); // burn fades even through a pause
// Session time (saved with the game) — capped so a backgrounded tab
// can't fast-forward it.
this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100);
@ -5617,6 +5628,7 @@ export class GameScene extends Phaser.Scene {
this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.engineFx?.destroy(); // the exhaust can't outlive the scene
this.nebula?.destroy();
this.haze?.destroy();
this.voidBase?.destroy();

357
js/visuals/EngineFlare.js Normal file
View File

@ -0,0 +1,357 @@
/**
* 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;
}
}