141 lines
5.3 KiB
JavaScript
141 lines
5.3 KiB
JavaScript
/**
|
||
* redDwarf "red haze" — a thin, gritty red cast over the DARKNESS of space
|
||
* behind the stars.
|
||
*
|
||
* The darkness is the renderer's background (config backgroundColor), which
|
||
* a camera filter does NOT reach (the clear color bypasses the composite).
|
||
* So this is a CONTENT layer, not a filter: one large, canvas-generated
|
||
* gritty red-haze texture drawn at depth -1 (BEHIND the starfield's 0–2),
|
||
* recentered on the camera each frame so it always fills the view. The
|
||
* white stars and all the art sit in front and stay bright — only the void
|
||
* turns a dusty red.
|
||
*
|
||
* The texture (generated once, seeded per system) is: a dark warm-red base
|
||
* + a few large soft blotches (the "haze") + fine per-pixel grain (the
|
||
* "grit"). It is a plain sprite, so it is Canvas-safe and can never touch
|
||
* the HUD (it is world-space, behind everything, independent of the UI split).
|
||
*
|
||
* Config: data/systems.json → types.redDwarf.effect.haze.
|
||
*/
|
||
import Phaser from '../vendor/phaser.js';
|
||
import { canvasTexture } from '../utils/Textures.js';
|
||
import { hexToRgb01 } from './SystemEffectsMath.js';
|
||
|
||
const DEPTH = -1; // behind the starfield (0–2) — the very back
|
||
const MARGIN = 320; // the haze extends this far past the viewport (covers drift)
|
||
|
||
/** A seeded 32-bit PRNG (mulberry32) so a system's haze is stable per run. */
|
||
function makeRng(seedNum) {
|
||
let a = (seedNum >>> 0) || 0x9e3779b9;
|
||
return () => {
|
||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Paint one haze tile (SIZE×SIZE): dark base + soft blotches + fine grain.
|
||
* All from the config; deterministic under `rng`.
|
||
*/
|
||
function paintHaze(ctx, size, cfg, rng) {
|
||
const [br, bg, bb] = hexToRgb01(cfg.base ?? '#160b09');
|
||
const [tr, tg, tb] = hexToRgb01(cfg.tint ?? '#c0392b');
|
||
const to255 = (v) => Math.max(0, Math.min(255, Math.round(v * 255)));
|
||
|
||
// 1. dark warm-red base (this IS the "darkness", so it reads as space).
|
||
ctx.fillStyle = `rgb(${to255(br)}, ${to255(bg)}, ${to255(bb)})`;
|
||
ctx.fillRect(0, 0, size, size);
|
||
|
||
// 2. soft atmospheric blotches — the "haze" (large, low-contrast).
|
||
const blobs = Math.max(3, Math.round(cfg.blobs ?? 9));
|
||
const [sMin, sMax] = Array.isArray(cfg.scale) ? cfg.scale : [0.18, 0.42];
|
||
for (let i = 0; i < blobs; i++) {
|
||
const x = rng() * size;
|
||
const y = rng() * size;
|
||
const r = size * (sMin + (sMax - sMin) * rng());
|
||
const warm = 0.55 + 0.45 * rng();
|
||
const a = 0.10 + 0.16 * rng();
|
||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||
g.addColorStop(0, `rgba(${to255(tr * warm)}, ${to255(tg * warm)}, ${to255(tb * warm)}, ${a.toFixed(3)})`);
|
||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
// 3. fine grain — the "grit" (per-pixel, warm-biased).
|
||
const amp = Math.max(0, cfg.grain ?? 20);
|
||
if (amp > 0) {
|
||
const img = ctx.getImageData(0, 0, size, size);
|
||
const d = img.data;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const n = (rng() - 0.5) * 2 * amp;
|
||
d[i] = Math.max(0, Math.min(255, d[i] + n));
|
||
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n * 0.62));
|
||
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n * 0.46));
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The red-haze layer (a single full-screen sprite behind the stars).
|
||
*
|
||
* @param {object} scene Phaser scene (add / camera / scale / textures)
|
||
* @param {object} cfg data/systems.json → effect.haze
|
||
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||
*/
|
||
export class RedHaze {
|
||
constructor(scene, cfg, fx = {}) {
|
||
this.scene = scene;
|
||
this.cfg = cfg ?? {};
|
||
this.fx = fx ?? {};
|
||
this.sp = null;
|
||
this.tSec = 0;
|
||
}
|
||
|
||
/** Build the texture + sprite (idempotent). */
|
||
create() {
|
||
const scene = this.scene;
|
||
if (this.sp) return;
|
||
const SIZE = 1024;
|
||
// Fixed seed: the haze is a shared, subtle background, so it is
|
||
// consistent across systems and runs (the texture is cached by key).
|
||
const texKey = canvasTexture(scene, 'fx-redhaze', SIZE, SIZE, (ctx) => {
|
||
paintHaze(ctx, SIZE, this.cfg, makeRng(0x51ab1234));
|
||
});
|
||
const w = scene.scale.width;
|
||
const h = scene.scale.height;
|
||
const cam = scene.cameras.main;
|
||
// One large sprite, viewport + margin, centered on the camera.
|
||
this.sp = scene.add
|
||
.image(cam.scrollX + w / 2, cam.scrollY + h / 2, texKey)
|
||
.setDepth(DEPTH)
|
||
.setAlpha(this.cfg.alpha ?? 0.9)
|
||
.setScale((w + 2 * MARGIN) / SIZE, (h + 2 * MARGIN) / SIZE);
|
||
}
|
||
|
||
/** Per-frame: keep the haze centered on the camera (with a slow drift). */
|
||
update(nowMs, dtMs) {
|
||
if (!this.sp) return;
|
||
this.tSec += (dtMs || 0) / 1000;
|
||
const cam = this.scene.cameras.main;
|
||
const w = this.scene.scale.width;
|
||
const h = this.scene.scale.height;
|
||
const t = this.tSec;
|
||
// A very slow drift so the haze feels alive (amplitude < MARGIN, so it
|
||
// always still covers the view).
|
||
const dx = Math.sin(t * 0.05 + this.fx.phase * 6.28) * 120;
|
||
const dy = Math.cos(t * 0.04 + this.fx.drift * 6.28) * 120;
|
||
this.sp.x = cam.scrollX + w / 2 + dx;
|
||
this.sp.y = cam.scrollY + h / 2 + dy;
|
||
}
|
||
|
||
/** Tear down. */
|
||
destroy() {
|
||
this.sp?.destroy();
|
||
this.sp = null;
|
||
}
|
||
}
|