orbit/js/ui/GlitchText.js

106 lines
4.0 KiB
JavaScript

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