358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|