diff --git a/assets/images/originals/arid-planet-03.png b/assets/images/originals/arid-planet-03.png new file mode 100644 index 0000000..b38f8b0 Binary files /dev/null and b/assets/images/originals/arid-planet-03.png differ diff --git a/data/ship.json b/data/ship.json index ce5214f..177cf14 100644 --- a/data/ship.json +++ b/data/ship.json @@ -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, diff --git a/dev/engine-flare-test.mjs b/dev/engine-flare-test.mjs new file mode 100644 index 0000000..06966ee --- /dev/null +++ b/dev/engine-flare-test.mjs @@ -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 +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); +} diff --git a/dev/particle-probe.html b/dev/particle-probe.html new file mode 100644 index 0000000..8eb4e2f --- /dev/null +++ b/dev/particle-probe.html @@ -0,0 +1,68 @@ + + + +particle probe + + + + + diff --git a/dev/particle-probe.mjs b/dev/particle-probe.mjs new file mode 100644 index 0000000..fc54ad5 --- /dev/null +++ b/dev/particle-probe.mjs @@ -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 [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); +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 48d0b7c..eb61d23 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/visuals/EngineFlare.js b/js/visuals/EngineFlare.js new file mode 100644 index 0000000..ef7c88d --- /dev/null +++ b/js/visuals/EngineFlare.js @@ -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; + } +}