orbit/js/visuals/CyberOverlay.js

269 lines
8.6 KiB
JavaScript

import { canvasTexture } from '../utils/Textures.js';
const T = {
scan: '__cyber_scan',
grid: '__cyber_grid',
vignette: '__cyber_vignette',
sweep: '__cyber_sweep',
};
/**
* The shared cyberpunk/CRT dressing of the UI: a faint tech grid,
* scanlines, a slow scan-sweep band, a vignette — and periodic RGB
* glitch bursts (full-width slice bars plus a level signal that glitch
* text listens to and blows its RGB split apart).
*
* Purely decorative: non-interactive, screen-fixed (scrollFactor 0),
* drawn above everything (depth 5000+).
*
* A scene owns one instance and drives it from its own update loop
* (Phaser v4 does not auto-update plain objects):
*
* this.overlay = new CyberOverlay(this, cfg);
* this.overlay.onGlitch((level) => this.title.setBurst(level));
* update(time, delta) { this.overlay.update(time, delta); }
* shutdown() { this.overlay.destroy(); }
*
* cfg shape (all optional — sensible defaults fill the gaps; the menu
* and game scenes build their cfg from data/theme.json):
* {
* depth: 5000,
* scanline: { pitch: 3, alpha: 0.14 },
* grid: { cell: 48, alpha: 0.05 },
* vignette: { alpha: 0.5 },
* sweep: { alpha: 0.09, duration: 9000, band: 140 },
* glitch: { enabled: true, interval: [2200, 6800], duration: [110, 340],
* slices: [2, 6] },
* }
*/
export class CyberOverlay {
constructor(scene, cfg = {}) {
this.scene = scene;
this.cfg = {
depth: 5000,
scanline: { pitch: 3, alpha: 0.14, ...(cfg.scanline ?? {}) },
grid: { cell: 48, alpha: 0.05, ...(cfg.grid ?? {}) },
vignette: { alpha: 0.5, ...(cfg.vignette ?? {}) },
sweep: { alpha: 0.09, duration: 9000, band: 140, ...(cfg.sweep ?? {}) },
glitch: {
enabled: true,
interval: [2200, 6800],
duration: [110, 340],
slices: [2, 6],
...(cfg.glitch ?? {}),
},
};
const { width: w, height: h } = scene.scale;
const d = this.cfg.depth;
// ---- generated textures (cached by key, shared across overlays) ----
const pitch = Math.max(2, Math.round(this.cfg.scanline.pitch));
canvasTexture(scene, T.scan, 1, pitch, (ctx) => {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, pitch - 1, 1, 1); // one dark line every `pitch` rows
});
const cell = Math.max(16, Math.round(this.cfg.grid.cell));
canvasTexture(scene, T.grid, cell, cell, (ctx) => {
ctx.strokeStyle = 'rgba(0,229,255,0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cell - 0.5, 0);
ctx.lineTo(cell - 0.5, cell);
ctx.moveTo(0, cell - 0.5);
ctx.lineTo(cell, cell - 0.5);
ctx.stroke();
});
canvasTexture(scene, T.vignette, 256, 256, (ctx) => {
const g = ctx.createRadialGradient(128, 128, 60, 128, 128, 182);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.72, 'rgba(0,0,0,0.22)');
g.addColorStop(1, 'rgba(0,0,0,0.85)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 256, 256);
});
canvasTexture(scene, T.sweep, 64, 160, (ctx) => {
const g = ctx.createLinearGradient(0, 0, 0, 160);
g.addColorStop(0, 'rgba(0,229,255,0)');
g.addColorStop(0.5, 'rgba(0,229,255,0.55)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 160);
});
// ---- layers (order = depth: grid < scanlines < sweep < vignette < slices)
this.grid = scene
.add.tileSprite(w / 2, h / 2, w, h, T.grid)
.setScrollFactor(0)
.setDepth(d)
.setAlpha(this.cfg.grid.alpha);
this.scanlines = scene
.add.tileSprite(w / 2, h / 2, w, h, T.scan)
.setScrollFactor(0)
.setDepth(d + 1)
.setAlpha(this.cfg.scanline.alpha);
const band = Math.max(20, Math.round(this.cfg.sweep.band));
this.sweepBand = band;
this.sweep = scene
.add.image(0, 0, T.sweep)
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(d + 2)
.setAlpha(this.cfg.sweep.alpha)
.setDisplaySize(w, band);
this.vignette = scene
.add.image(w / 2, h / 2, T.vignette)
.setScrollFactor(0)
.setDepth(d + 3)
.setAlpha(this.cfg.vignette.alpha);
// ---- glitch state
this.depthSlices = d + 4;
this.burstT0 = null;
this.burstDur = 0;
this.burstIntensity = 1;
this.nextBurstAt = null; // set on first update (~0.9 s after scene start)
this.lastTime = null;
this.listeners = [];
this.slices = []; // { g, die }
}
/**
* Subscribe to glitch bursts: fn(level 0..1, time).
* @returns {() => void} an unsubscribe function
*/
onGlitch(fn) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((f) => f !== fn);
};
}
/** Fire a glitch burst right now (e.g. on a button click / scene entry). */
trigger(duration = 220, intensity = 1) {
const now = this.lastTime ?? this.scene.time.now;
this.burstT0 = now;
this.burstDur = Math.max(1, duration);
this.burstIntensity = intensity;
this.nextBurstAt = now + this.randRange(this.cfg.glitch.interval) + this.randRange(this.cfg.glitch.duration);
this.spawnSlices();
this.notify(intensity, now);
}
/** Drive the sweep + glitch lifecycle. Call once per frame. */
update(time, delta) {
this.lastTime = time;
const { width: w, height: h } = this.scene.scale;
// Scan sweep: a bright band drifting top → bottom, forever.
const s = this.cfg.sweep;
if (s && s.duration > 0 && this.sweep) {
const p = ((time % s.duration) / s.duration + 1) % 1;
this.sweep.setY(this.sweepBand / 2 + p * (h - this.sweepBand));
}
// Glitch bursts: schedule → fire → decay.
const g = this.cfg.glitch;
if (g && g.enabled !== false) {
if (this.nextBurstAt === null) {
this.nextBurstAt = time + 800 + Math.random() * 700; // first one early
}
if (this.burstT0 !== null) {
const el = time - this.burstT0;
if (el >= this.burstDur) {
this.burstT0 = null; // burst over
} else {
const level = (1 - el / this.burstDur) * this.burstIntensity;
this.notify(Math.max(0, Math.min(1, level)), time);
}
} else if (time >= this.nextBurstAt) {
this.burstT0 = time;
this.burstDur = this.randRange(g.duration);
this.burstIntensity = 0.8 + Math.random() * 0.4;
this.nextBurstAt = time + this.burstDur + this.randRange(g.interval);
this.spawnSlices();
this.notify(1, time);
}
}
// Reap expired slice bars.
if (this.slices.length > 0) {
this.slices = this.slices.filter((s) => {
if (time >= s.die) {
s.g.destroy();
return false;
}
return true;
});
}
}
/**
* The "pull-apart" itself: a handful of thin full-width bars in neon
* cyan / magenta / white / bg, each shifted a few px sideways —
* classic signal-loss slicing across the frame.
*/
spawnSlices() {
const { width: w, height: h } = this.scene.scale;
const g = this.cfg.glitch.slices;
const n = Math.round(this.range(g[0] ?? 2, g[1] ?? 6));
const palette = [0x00e5ff, 0xff2d6f, 0xeaf6ff, 0x04060d];
const bars = this.scene.add.graphics().setScrollFactor(0).setDepth(this.depthSlices);
for (let i = 0; i < n; i++) {
const y = Math.random() * h;
const barH = 1 + Math.random() * 13;
const dx = (Math.random() * 2 - 1) * 14;
const color = palette[(Math.random() * palette.length) | 0];
const alpha = 0.06 + Math.random() * 0.18;
bars.fillStyle(color, alpha);
bars.fillRect(-w / 2 + dx - 24, y, w + 48, barH);
}
// One wider "displacement band" so the burst reads at a glance.
const bandY = Math.random() * h;
bars.fillStyle(0x04060d, 0.55);
bars.fillRect(-w / 2 - 24, bandY, w + 48, 14 + Math.random() * 22);
bars.fillStyle(0x00e5ff, 0.22);
bars.fillRect(-w / 2 - 24, bandY - 2, w + 48, 2);
const die = (this.lastTime ?? this.scene.time.now) + this.burstDur + 60;
this.slices.push({ g: bars, die });
}
notify(level, time) {
for (const fn of this.listeners) {
try {
fn(level, time);
} catch (err) {
console.error('[overlay] glitch listener failed', err);
}
}
}
destroy() {
this.listeners.length = 0;
for (const s of this.slices) s.g.destroy();
this.slices.length = 0;
this.grid?.destroy();
this.scanlines?.destroy();
this.sweep?.destroy();
this.vignette?.destroy();
this.grid = this.scanlines = this.sweep = this.vignette = null;
}
randRange([lo, hi]) {
if (!Array.isArray(lo)) return lo;
return lo + Math.random() * (hi - lo);
}
range(lo, hi) {
return lo + Math.random() * (hi - lo);
}
}