orbit/js/visuals/VoidBase.js

147 lines
5.7 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.

/**
* VoidBase — a full-screen "darkness" content layer (the binary's two
* lights paint on top of it; see the grade's two-light lift + breath).
*
* WHY A CONTENT LAYER: the void of space is the renderer's background
* (config backgroundColor), which a camera filter does NOT reach — the
* clear color bypasses the composite pass. So the two-color wash and the
* slow breath (both GRADE shader effects) need a world-space surface to
* act on. This is that surface: one large, canvas-generated, gritty,
* near-black sprite at depth -1 (BEHIND the starfield's 02), recentered
* on the camera each frame so it always fills the view.
*
* The base is NEUTRAL (not pre-tinted) on purpose: the grade's two-light
* lift (amber side A / azure side B, across the seeded axis) does the
* coloring, and its slow breath trades the two sides' brightness. Keeping
* the base neutral means the axis + breath stay fully in the shader
* (per-system, animated) and this layer is just the textured dark ground.
*
* Plain sprite → Canvas-safe, and it can never touch the HUD (world-space,
* behind everything, independent of the UI-camera split).
*
* Config: data/systems.json → effect.void { base, alpha, grain, blobs, scale }.
*/
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 void extends this far past the viewport (covers drift)
/** A seeded 32-bit PRNG (mulberry32) so the grain/blobs are stable. */
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 ^ (a >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Paint one void tile (SIZE×SIZE): near-black neutral base + a few large
* soft, very low-contrast depth blobs + fine per-pixel grain (the "grit").
* All from the config; deterministic under `rng`.
*/
function paintVoid(ctx, size, cfg, rng) {
const [br, bg, bb] = hexToRgb01(cfg.base ?? '#0b0d14');
const to255 = (v) => Math.max(0, Math.min(255, Math.round(v * 255)));
// 1. near-black neutral 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 depth blotches — large, neutral, very low contrast.
// They give the void a little structure so the lift has something to
// grip (a perfectly flat base would look like a solid fill).
const blobs = Math.max(0, Math.round(cfg.blobs ?? 6));
const [sMin, sMax] = Array.isArray(cfg.scale) ? cfg.scale : [0.2, 0.5];
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.7 + 0.5 * rng(); // slight brightness variation
const a = 0.04 + 0.05 * rng();
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${to255(br * warm)}, ${to255(bg * warm)}, ${to255(bb * 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, neutral).
const amp = Math.max(0, cfg.grain ?? 10);
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));
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n));
}
ctx.putImageData(img, 0, 0);
}
}
/**
* The void-base 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.void
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
*/
export class VoidBase {
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 void is a shared, subtle background, so it is
// consistent across systems and runs (the texture is cached by key).
const texKey = canvasTexture(scene, 'fx-voidbase', SIZE, SIZE, (ctx) => {
paintVoid(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 ?? 1.0)
.setScale((w + 2 * MARGIN) / SIZE, (h + 2 * MARGIN) / SIZE);
}
/** Per-frame: keep the void 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 void 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;
}
}