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