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