orbit/js/visuals/EmberField.js

173 lines
6.4 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.

/**
* 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.150.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],
twinkle = null,
} = this.cfg;
// Optional twinkle (the habitable fireflies): each spark's alpha pulses
// on its own phase — depth 0 = steady (redDwarf embers, unchanged).
const twRate = twinkle?.rate ?? null;
const twDepth = twinkle?.depth ?? null;
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 baseAlpha = range(alpha[0], alpha[1]);
const sp = scene.add
.image(bx, by, texKey)
.setDepth(DEPTH)
.setTint(this.color)
.setAlpha(baseAlpha)
.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]),
baseAlpha,
twRate: twRate ? range(twRate[0], twRate[1]) : 0,
twDepth: twDepth ? range(twDepth[0], twDepth[1]) : 0,
twPhase: rand() * Math.PI * 2,
});
}
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);
// Twinkle (optional): the alpha pulses on its own phase — a firefly
// blinking in the grass, not a steady spark (depth 0 = steady).
if (e.twDepth > 0) {
const t = (nowMs || 0) / 1000;
const wave = 0.5 * (1 + Math.sin(t * Math.PI * 2 * e.twRate + e.twPhase));
e.sp.alpha = e.baseAlpha * (1 - e.twDepth + e.twDepth * wave);
}
}
}
/** Tear down. */
destroy() {
for (const e of this.sprites) e.sp.destroy();
this.sprites = [];
}
}