import Phaser from '../vendor/phaser.js'; import { toCss } from '../utils/Color.js'; /** * A header title with the RGB pull-apart of the glitch theme: the main * text plus a magenta-red ghost offset left and a cyan ghost offset * right, both additive-blended so the fringes glow on the dark bg. * * Motion model: * - IDLE — a slow shimmer keeps the split alive (a ~1.5 px wobble); * - BURST — setBurst(level 0..1) pushes the ghosts apart and adds * high-frequency jitter; the level decays on its own (~90 ms). * Usually driven by CyberOverlay's glitch bursts: * overlay.onGlitch((level) => title.setBurst(level)); * - BOOT — start(time) plays the flicker-in once. * * The scene must call update(time, delta) from its own update loop * (Phaser v4 does not auto-update plain objects). */ export class GlitchText extends Phaser.GameObjects.Container { constructor(scene, x, y, text, style, o = {}) { super(scene, x, y); this.level = 0; // current burst level 0..1 (decays in update) this.baseOffset = o.baseOffset ?? 1.5; // px of idle RGB split this.burstOffset = o.burstOffset ?? 12; // extra px of split at level 1 this.ghostAlpha = o.ghostAlpha ?? 0.55; this.bootT0 = null; this.bootDur = o.bootDuration ?? 650; const main = scene.add.text(0, 0, text, style).setOrigin(0.5); const ghostStyle = { ...style, shadow: undefined }; // v4 quirk: canvas fillStyle needs CSS strings — normalize (toCss is // a pass-through for strings, see utils/Color.js). const redColor = toCss(o.redColor ?? '#ff2d6f'); const cyanColor = toCss(o.cyanColor ?? '#00e5ff'); const ghostRed = scene .add.text(0, 0, text, { ...ghostStyle, color: redColor }) .setOrigin(0.5) .setAlpha(0) .setBlendMode(Phaser.BlendModes.ADD); const ghostCyan = scene .add.text(0, 0, text, { ...ghostStyle, color: cyanColor }) .setOrigin(0.5) .setAlpha(0) .setBlendMode(Phaser.BlendModes.ADD); this.main = main; this.ghostRed = ghostRed; this.ghostCyan = ghostCyan; // ghosts first, main on top this.add([ghostRed, ghostCyan, main]); this.setSize(main.width, main.height); // Phaser v4: a directly-constructed GameObject is NOT added to the scene's // display list (the scene.add.* factories do that) — register it here or it // never renders. Verified Sept 2026 against lib/phaser.min.js (4.2.1 Giedi). this.scene.add.existing(this); } /** Feed a burst level (0..1); it decays automatically. */ setBurst(level) { this.level = Math.max(this.level, Math.min(1, Math.max(0, level ?? 0))); } /** Arm the boot flicker (time = scene time, e.g. this.time.now). */ start(time) { this.bootT0 = time; } update(time, delta) { // Boot flicker: a few hard on/off frames, then settle. if (this.bootT0 !== null) { const u = (time - this.bootT0) / this.bootDur; if (u >= 1) { this.bootT0 = null; this.main.setAlpha(1); this.setScale(1); } else if (u >= 0) { this.main.setAlpha(Math.sin(u * 70) > -0.35 ? 1 : 0.08); this.setScale(1.06 - 0.06 * Math.min(1, u * 1.6)); } } // Burst level decays (~90 ms time constant). this.level *= Math.exp(-(delta / 90)); if (this.level < 0.002) this.level = 0; const t = time * 0.001; // Idle shimmer + burst jitter (deterministic pseudo-noise from sines). const idle = 0.65 + 0.35 * Math.sin(t * 1.9); const off = this.baseOffset * idle + this.level * this.burstOffset; const jx = this.level * 6 * Math.sin(time * 0.053) * Math.sin(time * 0.021 + 1.7); const jy = this.level * 4 * Math.sin(time * 0.083 + 0.4); this.ghostRed.setPosition(-off + jx, jy * 0.5); this.ghostCyan.setPosition(off * 0.85, -off * 0.3 + jy); const flick = 0.6 + 0.4 * Math.sin(t * 3.3); const alpha = Math.min(1, this.ghostAlpha * flick + this.level * 0.5); this.ghostRed.setAlpha(alpha); this.ghostCyan.setAlpha(alpha); } }