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:
parent
cedee9f58e
commit
5408616bbf
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue