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); } }