orbit/js/visuals/EngineFlare.js

358 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
}