269 lines
8.6 KiB
JavaScript
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(w / 2, 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(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(-24, bandY, w + 48, 14 + Math.random() * 22);
|
|
bars.fillStyle(0x00e5ff, 0.22);
|
|
bars.fillRect(-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);
|
|
}
|
|
}
|