155 lines
5.5 KiB
JavaScript
155 lines
5.5 KiB
JavaScript
/**
|
||
* redDwarf ember field — a slow drift of tiny warm sparks behind the art.
|
||
*
|
||
* Content layer (world-space sprites), NOT a filter: it works in the
|
||
* Canvas fallback and can never touch the HUD (it is a sprite at depth 3,
|
||
* like the starfield, so the camera split is irrelevant to it). The sparks
|
||
* are small, warm, additively blended (they glow like embers) and sit at a
|
||
* parallax BETWEEN the starfield (0.15–0.85) and the ship — dust in the
|
||
* air, closer than the stars, farther than you.
|
||
*
|
||
* The field FILLs the view and parallaxes like the starfield (the proven
|
||
* pattern in Starfield.js): embers start in a window around the CURRENT
|
||
* camera, each frame they shift by (1 − p) of the camera delta (so on
|
||
* screen they stream opposite to travel, near sparks faster than far
|
||
* ones) plus their own slow drift, and are wrapped back into the current
|
||
* view. That keeps them on screen no matter how far the ship flies.
|
||
*
|
||
* Config: data/systems.json → types.redDwarf.effect.particles.
|
||
* Node-testable color/shape math is inline (this file imports Phaser and
|
||
* is exercised in the browser via dev/server.mjs).
|
||
*/
|
||
import Phaser from '../vendor/phaser.js';
|
||
import { canvasTexture } from '../utils/Textures.js';
|
||
import { toColor } from '../utils/Color.js';
|
||
|
||
const DEPTH = 3; // behind the art (planets 5, ship 10), in front of starfield
|
||
const MARGIN = 80; // embers may start/exit this far past the viewport
|
||
|
||
/** A small, bright, soft dot — an ember. White so a tint reads as color. */
|
||
function drawSpark(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.35, 'rgba(255,255,255,0.85)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */
|
||
function wrapIn(v, left, span) {
|
||
return left + ((((v - left) % span) + span) % span);
|
||
}
|
||
|
||
/**
|
||
* The ember layer.
|
||
*
|
||
* @param {object} scene Phaser scene (add / camera / renderer / scale)
|
||
* @param {string} color ember color (CSS hex)
|
||
* @param {object} cfg data/systems.json → effect.particles
|
||
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||
*/
|
||
export class EmberField {
|
||
constructor(scene, color, cfg, fx = {}) {
|
||
this.scene = scene;
|
||
this.color = toColor(color, 0xff8a4a);
|
||
this.cfg = cfg ?? {};
|
||
this.fx = fx ?? {};
|
||
this.sprites = [];
|
||
this.lastScrollX = 0;
|
||
this.lastScrollY = 0;
|
||
}
|
||
|
||
/** Build the spark texture + one sprite per ember (idempotent). */
|
||
create() {
|
||
const scene = this.scene;
|
||
if (this.sprites.length) return;
|
||
const {
|
||
count = 40,
|
||
size = [2, 6],
|
||
alpha = [0.4, 0.9],
|
||
parallax = [0.25, 0.5],
|
||
speed = [8, 25],
|
||
} = this.cfg;
|
||
|
||
const texKey = canvasTexture(scene, 'fx-ember-spark', 32, 32, (ctx) => drawSpark(ctx, 32));
|
||
const w = scene.scale.width;
|
||
const h = scene.scale.height;
|
||
const cam = scene.cameras.main;
|
||
|
||
// Embers start in a window around the CURRENT camera view (like the
|
||
// starfield); update() keeps them wrapped into it as the camera flies.
|
||
const left = cam.scrollX - MARGIN;
|
||
const top = cam.scrollY - MARGIN;
|
||
|
||
// Seeded per-ember variation (xorshift32 from the system fx, so the
|
||
// same system looks the same every run).
|
||
let s = (Math.imul(0x9e3779b1, 2654435761) ^ Math.round((this.fx.angle ?? 0.5) * 1e9)) >>> 0;
|
||
const rand = () => {
|
||
s ^= s << 13; s >>>= 0;
|
||
s ^= s >> 17; s >>>= 0;
|
||
s ^= s << 5; s >>>= 0;
|
||
return (s >>> 0) / 4294967296;
|
||
};
|
||
const range = (a, b) => a + (b - a) * rand();
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const bx = left + rand() * (w + 2 * MARGIN);
|
||
const by = top + rand() * (h + 2 * MARGIN);
|
||
const ang = rand() * Math.PI * 2;
|
||
const spd = range(speed[0], speed[1]);
|
||
const sp = scene.add
|
||
.image(bx, by, texKey)
|
||
.setDepth(DEPTH)
|
||
.setTint(this.color)
|
||
.setAlpha(range(alpha[0], alpha[1]))
|
||
.setBlendMode(Phaser.BlendModes.ADD);
|
||
const sz = range(size[0], size[1]);
|
||
sp.setScale(sz / 16); // 32px texture, scale to ~size px
|
||
this.sprites.push({
|
||
sp,
|
||
vx: Math.cos(ang) * spd,
|
||
vy: Math.sin(ang) * spd,
|
||
p: range(parallax[0], parallax[1]),
|
||
});
|
||
}
|
||
this.lastScrollX = cam.scrollX;
|
||
this.lastScrollY = cam.scrollY;
|
||
}
|
||
|
||
/** Per-frame: parallax (opposite the camera, like the starfield) + drift. */
|
||
update(nowMs, dtMs) {
|
||
if (!this.sprites.length) return;
|
||
const dt = (dtMs || 0) / 1000;
|
||
const cam = this.scene.cameras.main;
|
||
const dx = cam.scrollX - this.lastScrollX;
|
||
const dy = cam.scrollY - this.lastScrollY;
|
||
this.lastScrollX = cam.scrollX;
|
||
this.lastScrollY = cam.scrollY;
|
||
|
||
const w = this.scene.scale.width;
|
||
const h = this.scene.scale.height;
|
||
const spanX = w + 2 * MARGIN;
|
||
const spanY = h + 2 * MARGIN;
|
||
const left = cam.scrollX - MARGIN;
|
||
const top = cam.scrollY - MARGIN;
|
||
|
||
for (const e of this.sprites) {
|
||
// Move by (1 − p) of the camera delta (→ on screen −p of it, opposite
|
||
// to travel, near sparks faster), plus the ember's own slow drift.
|
||
e.sp.x += dx * (1 - e.p) + e.vx * dt;
|
||
e.sp.y += dy * (1 - e.p) + e.vy * dt;
|
||
// Keep the field full: wrap into the current camera window.
|
||
e.sp.x = wrapIn(e.sp.x, left, spanX);
|
||
e.sp.y = wrapIn(e.sp.y, top, spanY);
|
||
}
|
||
}
|
||
|
||
/** Tear down. */
|
||
destroy() {
|
||
for (const e of this.sprites) e.sp.destroy();
|
||
this.sprites = [];
|
||
}
|
||
}
|