orbit/js/visuals/RedHaze.js

141 lines
5.3 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 "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 02),
* 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 (02) — 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;
}
}