orbit/js/ui/LevelUpMarquee.js

875 lines
30 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { canvasTexture } from '../utils/Textures.js';
import { ScrambleDecode } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { Fx } from '../combat/Fx.js';
import { ensureUiCameras } from '../visuals/UiCameras.js';
import { GlitchText } from './GlitchText.js';
/**
* THE LEVEL-UP MARQUEE — the big beat when the player's combat level
* lands (js/combat/XpManager.js's 'levelup' event): the world freezes
* and dims, the OLD level boots in as a glitching hologram, gets
* corrupted (glitch slices + it shatters into falling shards), the NEW
* level decodes in digit-by-digit (the slots cycle random values,
* locking right→left, each lock a flash + tick), the lock-in slams
* (white flash, camera kick, shockwave rings, sparks, the SYNC line +
* the armor reward), a neon SHUTTER then peels the frame away.
*
* Screen-fixed (scrollFactor 0), drawn above everything (depth 6000+ —
* above the menu's CyberOverlay at 5000). Self-contained: no scene
* beyond add.text/image/graphics, camera, scale, time, sound — and no
* tweens (the whole timeline is stepped by hand in update(), the
* js/combat/Fx.js idiom), so it plays identically wherever the scene
* drives it.
*
* The PAUSE CONTRACT (the same rule as the comms' ack pause, see
* js/scenes/GameScene.js's update): the marquee owns the screen, so the
* scene gates the ship, the combat, and the time-based ticks on
* `marquee.active` while the beat plays.
*
* Usage (the scene owns one instance, like CyberOverlay):
*
* this.levelMarquee = new LevelUpMarquee(this, { onDone });
* this.levelMarquee.bind(this.combat.xp); // the ledger's 'levelup'
* update(time, delta) { this.levelMarquee.update(time, delta); }
* shutdown() { this.levelMarquee.destroy(); }
*
* Multiple levels in ONE grant (a big kill / dev grant) fire one
* 'levelup' per level crossed in the same tick — the marquee coalesces
* them into one beat (from = the lowest, to = the highest, and the
* subline reads "+N LEVELS"). A level-up that lands MID-BEAT buffers
* and plays right after (the queue never drops a level).
*
* Voices (the existing data/sfx.json set — no new files):
* solar_flare_01 — the level landing (CombatSystem's own beat, t0)
* deconstruct — the old level's corruption
* construct — each digit slot locking (throttled)
* solar_flare_02 — the lock-in thump
*/
const DEPTH = 6000;
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const easeOutCubic = (u) => 1 - Math.pow(1 - clamp01(u), 3);
export class LevelUpMarquee {
/**
* @param {object} scene a Phaser scene (GameScene today)
* @param {object} [o] `onDone(beat)` — fired when a beat finishes
*/
constructor(scene, o = {}) {
this.scene = scene;
this.onDone = typeof o.onDone === 'function' ? o.onDone : null;
this.active = false;
this.t0 = 0; // the beat's start (scene time, ms)
this.el = -1; // elapsed beat time (updated in update())
this.T = null; // the beat's timeline (per-digit-count)
this.ev = null; // the current beat's payload {from, to, levelsGained}
this.pending = null; // level-ups that landed mid-beat (coalesced)
this._unsub = null;
this._built = false;
this._o = []; // the STATIC objects (ensureBuilt — destroy in one sweep)
this._beat = []; // the beat's TRANSIENT objects (boards, shards, …)
this._oldParts = []; // the old level's glyph columns (shatter seed)
this._newParts = []; // the new level's glyph columns
this._digitCols = []; // the cycling digit slots (lock schedule)
this._prefixParts = []; // the "LV" columns (fade in at the scramble)
this._frags = [];
this._rings = [];
this._sparks = [];
this._slices = [];
this._flags = new Set(); // the one-shot beat markers
this._sfxGate = 0; // the construct-tick throttle clock
this._holdOn = false; // dev: the beat is latched (the scene's updates are no-ops)
}
// ------------------------------------------------------------- the seam
/**
* Subscribe to an XpManager (its 'levelup' event drives the beats).
* @param {object} xp an XpManager
* @returns {() => void} the unsubscribe (idempotent)
*/
bind(xp) {
if (!xp || typeof xp.on !== 'function' || this._unsub) return () => {};
this._unsub = xp.on('levelup', (ev) => this.play(ev));
return () => this.unbind();
}
unbind() {
this._unsub?.();
this._unsub = null;
}
/**
* A combat level landed. Plays the beat (or folds behind the live one —
* one grant crossing N levels coalesces onto the beat's ledger: the
* ORIGINAL from, the highest to, "+N LEVELS" — the grant's full span,
* so the subline always matches the numbers on screen). A level-up
* that lands MID-BEAT buffers and plays right after (never dropped).
* @param {{from:number, to:number, levelsGained?:number}} ev
*/
play(ev = {}) {
const from = Math.max(0, Math.floor(Number(ev.from) || 0));
const to = Math.max(from + 1, Math.floor(Number(ev.to) || 0));
const beat = {
from,
to,
levelsGained: Math.max(1, to - from, Math.floor(Number(ev.levelsGained) || 0)),
};
if (this.active) {
// Fold onto the running beat's ledger (or the buffer's, once it
// exists) — the beat's original `from` survives, so a coalesced
// beat still reads the grant's full span (LV 00 → LV 03, +3).
this.pending = this._coalesce(this.pending ?? this.ev, beat);
return;
}
this._playNow(beat);
}
_coalesce(a, b) {
if (!a) return { ...b };
const from = Math.min(a.from, b.from);
const to = Math.max(a.to, b.to);
return {
from,
to,
// The full ledger span (a contiguous level-up run always equals the
// levels gained) — the subline stays true to the numbers on screen.
levelsGained: Math.max(a.levelsGained, b.levelsGained, to - from),
};
}
/** Drive the beat (call from the scene's update, once its time stepped). */
update(time, delta) {
if (!this.active) return;
if (this._holdOn) return; // dev hold (debugSeek) — the frame is latched
const s = this.scene;
const T = this.T;
const el = time - this.t0;
this.el = el;
const dt = Math.min(64, delta > 0 ? delta : 16) / 1000;
const { height: h } = s.scale;
// ---- the one-shot beats (each fires exactly once, in order) ----
if (el >= T.corrupt)
this._flag('corrupt', () => {
this._spawnSlices(1); // the signal-loss burst
this._spawnFrags(); // the old level shatters into falling shards
for (const p of this._oldParts) p.gt.setVisible(false);
playSfxOn(s, 'deconstruct'); // the collapse voice
});
if (el >= T.scramble)
this._flag('scramble', () => {
this._spawnSlices(0.5); // a lighter burst as the new level takes the stage
for (const c of this._digitCols) {
c.gt.setVisible(true); // the board is built hidden — the slots arm now
this._setChar(c.gt, String((Math.random() * 10) | 0));
}
for (const p of this._prefixParts) p.gt.setVisible(true); // "LV" joins them
});
this._digitCols.forEach((c, i) => {
if (el >= c.lockAt)
this._flag(`lock_${i}`, () => {
this._setChar(c.gt, c.final);
this._digitSparks(c);
if (time - this._sfxGate > 90) {
this._sfxGate = time; // the tick: one voice per lock, never a machine gun
playSfxOn(s, 'construct');
}
});
});
if (el >= T.lock) this._flag('lock', () => this._lockFx());
if (el >= T.peel) this._flag('peel', () => this._spawnSlices(0.7));
// ---- the continuous curves ----
// The dim (the world steps aside) — up at the start, lifted by the peel.
const dimA =
el < T.peel
? 0.92 * easeOutCubic(el / T.dimEnd)
: 0.92 * (1 - clamp01((el - T.peel) / (T.end - T.peel)));
this.dim.setAlpha(Math.max(0, dimA));
// The energy beam behind the number (rises, spikes at the corruption,
// flares at the lock-in, dies with the peel) — a wide, soft glow,
// not a pillar.
let ba;
if (el < T.corrupt) ba = 0.3 * easeOutCubic((el - T.oldIn) / 600);
else if (el < T.scramble) ba = 0.5 + 0.08 * Math.sin(el * 0.04);
else if (el < T.lock) ba = 0.42 + 0.05 * Math.sin(el * 0.033);
else if (el < T.peel) ba = Math.max(0.3, 0.9 - 0.6 * clamp01((el - T.lock) / 320));
else ba = Math.max(0, 0.3 * (1 - (el - T.peel) / (T.end - T.peel)));
this.beam.setAlpha(ba * (0.92 + 0.08 * Math.sin(el * 0.021)));
// The eyebrow label + the decode line (the compile line, then SYNC).
this.label.setAlpha(
el < T.oldIn
? 0
: el < T.peel
? easeOutCubic((el - T.oldIn) / 260)
: 1 - clamp01((el - T.peel) / (T.end - T.peel)),
);
const dec = el < T.lock ? this._decodeOld : this._decodeNew;
this.decodeLine.setAlpha(el < T.oldIn ? 0 : 1);
this.decodeLine.setText(dec.started(time) ? dec.display(time) : '');
// The reward line (the armor bump — the level's tangible gain).
const rewardA =
el < T.lock + 180
? 0
: el < T.peel
? easeOutCubic((el - T.lock - 180) / 220)
: 1 - clamp01((el - T.peel) / (T.end - T.peel));
this.reward.setAlpha(rewardA);
// The OLD level: the boot flicker + the idle RGB shimmer (GlitchText
// owns both), until the shatter.
if (el < T.corrupt) {
for (const p of this._oldParts) p.gt.update(time, delta);
}
// The NEW level: the digit slots cycle random values until their lock,
// then SNAP to the final digit with a scale/alpha flash.
if (el >= T.scramble && el < T.peel) {
const bucket = Math.floor(el / 55); // the cycle tick
for (const c of this._digitCols) {
if (el < c.lockAt)
this._digitCycle(c, el, bucket);
else {
const u = clamp01((el - c.lockAt) / 150);
c.gt.setScale(1 + 0.2 * (1 - u));
c.gt.setAlpha(0.6 + 0.4 * u);
}
c.gt.update(time, delta); // the RGB shimmer keeps each slot alive
}
// The "LV" prefix fades in as the scramble starts (its visibility is
// armed by the scramble flag — the board is built hidden).
const pa = el < T.scramble ? 0 : clamp01((el - T.scramble) / 200);
for (const p of this._prefixParts) p.gt.setAlpha(pa);
}
// The lock-in's white flash.
this.flash.setAlpha(el >= T.lock ? 0.5 * (1 - clamp01((el - T.lock) / 200)) : 0);
// The shutter peel: a dark band + a neon edge sweep the frame top→bottom.
if (el >= T.peel) {
const u = clamp01((el - T.peel) / (T.end - T.peel));
const y = -70 + u * (h + 140);
this.bandShutter.setY(y).setAlpha(0.5);
this.bandNeon.setY(y).setAlpha(0.8 * (1 - 0.3 * u));
} else {
this.bandShutter.setAlpha(0);
this.bandNeon.setAlpha(0);
}
// ---- the transient FX (stepped like js/combat/Fx.js) ----
this._stepFrags(el);
this._stepRings(el);
this._stepSparks(el, dt);
this._stepSlices(time);
if (el >= T.end) this._finish();
}
/** True while a beat plays (the scene's pause contract gates on this). */
get isActive() {
return this.active;
}
/**
* Dev-only: release a debugSeek hold (the beat resumes from the held
* moment and plays out to the end).
*/
debugRelease() {
this._holdFrames = 0;
this._holdOn = false;
}
/**
* Dev-only (screenshot harnesses): jump the beat to elapsed `ms` and
* LATCH it — one full update pass renders that exact moment
* synchronously (all the one-shot beats up to it fire, the transients
* spawn), then the hold is set so the scene's updates are no-ops. The
* capture cost (a slow headless paint) can't race the timeline, and no
* frame-cadence assumptions are needed.
* @param {number} ms the beat-elapsed time to land on
*/
debugSeek(ms) {
if (!this.active) return;
const t = this.scene.time.now;
this.t0 = t - Math.max(0, Number(ms) || 0);
// Re-anchor the decode lines to the NEW beat origin (they were set
// against the original play time in _playNow).
const T = this._times(this.ev.levelsGained);
this._decodeOld.t0 = this.t0 + 430;
this._decodeNew.t0 = this.t0 + T.lock - 90;
this._holdOn = false;
this.update(t, 16); // the pass at exactly el = ms (single-threaded: no interleaving)
this._holdOn = true;
}
/** Tear down (the scene's shutdown — the objects can't outlive it). */
destroy() {
this.unbind();
this.active = false;
this._holdOn = false;
this._clearBeat();
for (const o of this._o) {
try {
o.destroy();
} catch {
/* already gone (a scene mid-shutdown) — nothing to do */
}
}
this._o.length = 0;
this._built = false;
this.onDone = null;
}
// ------------------------------------------------------------- the beat
/** The timeline (ms) — the scramble's tail moves with the digit count. */
_times(digits) {
const T = { dimEnd: 180, oldIn: 180, tickerIn: 430, corrupt: 950, scramble: 1250 };
T.lockBase = T.scramble + 330;
T.lastLock = T.lockBase + (digits - 1) * 140; // locks right → left
T.lock = T.lastLock + 90;
T.peel = T.lock + 820; // the hold (the RGB shimmer keeps it alive)
T.end = T.peel + 330; // the shutter peel
return T;
}
_flag(name, fn) {
if (this._flags.has(name)) return;
this._flags.add(name);
fn();
}
_playNow(beat) {
const s = this.scene;
this.ensureBuilt();
this._clearBeat();
this._holdOn = false; // a new beat plays free
this.ev = beat;
this.pending = null;
const maxLevel = Math.max(1, Math.floor(Number(config.get('progression.maxLevel', 80)) || 80));
const isMax = beat.to >= maxLevel;
const n = beat.levelsGained;
const hull = Math.max(0, Math.floor(Number(config.get('progression.reward.hullPerLevel', 3)) || 0));
const shield = Math.max(0, Math.floor(Number(config.get('progression.reward.shieldPerLevel', 2)) || 0));
const syncLine = isMax
? `FIRMWARE MAXED ▸ LEVEL ${beat.to}`
: n > 1
? `SYNC COMPLETE ▸ +${n} LEVELS`
: 'SYNC COMPLETE';
const rewardLine = `HULL +${hull * n} ▸ SHIELDS +${shield * n}`;
const pad = (v) => String(Math.max(0, Math.floor(v))).padStart(2, '0');
const fromStr = `LV ${pad(beat.from)}`;
const toStr = `LV ${pad(beat.to)}`;
const hexLine =
beat.from === 0
? '0x00 ▸ FIRST FIRMWARE FLASH'
: `0x${pad(beat.from).toUpperCase()} ▸ COMPILING FIRMWARE`;
const digits = pad(beat.to).length;
this.T = this._times(digits);
this.t0 = s.time.now;
const cx = s.scale.width / 2;
const cy = s.scale.height / 2;
const size = Math.round(Math.min(190, Math.max(96, s.scale.height * 0.24)));
this._cy = cy;
this._size = size;
// The OLD level's board (boots in — GlitchText's flicker, then the
// idle shimmer; the shatter hides it and seeds the shards).
const oldBoard = this._makeBoard(fromStr, cy - 8, size);
this._oldParts = oldBoard.parts;
for (const p of oldBoard.parts) p.gt.start(this.t0 + this.T.oldIn);
// The NEW level's board (hidden until the scramble; the digit slots
// lock right → left, each with its own flash + tick).
const newBoard = this._makeBoard(toStr, cy - 8, size);
this._newParts = newBoard.parts;
this._digitCols = newBoard.parts.filter((p) => p.isDigit);
this._prefixParts = newBoard.parts.filter((p) => !p.isDigit);
const D = this._digitCols.length;
for (const p of newBoard.parts) p.gt.setVisible(false);
this._digitCols.forEach((c, i) => {
c.final = c.ch;
// ELAPSED ms since the beat start (update() compares it against
// el = time - t0) — NOT a scene-clock stamp: on a live session
// s.time.now is minutes in, and an absolute stamp would make
// `el >= c.lockAt` false for the whole beat, so the digit never
// locks and the slots keep cycling random values off the level.
c.lockAt = this.T.lockBase + (D - 1 - i) * 140; // i = from the left
c.bucket = -1;
c.phase = i * 1.7;
});
// The decode lines (the shared line below the number): the compile
// line in the old phase, the SYNC line from the lock-in.
this._decodeOld = new ScrambleDecode(hexLine, this.t0 + this.T.tickerIn, 520);
this._decodeNew = new ScrambleDecode(syncLine, this.t0 + this.T.lock + 60, 480);
this.decodeLine.setText('').setAlpha(0);
this.reward.setText(rewardLine).setAlpha(0);
this.label.setAlpha(0);
this.active = true;
}
_finish() {
this.active = false;
this._clearBeat();
for (const o of this._o) if (o?.setAlpha) o.setAlpha(0); // park the statics
const done = this.onDone;
const next = this.pending;
this.pending = null;
if (next) {
// A level landed mid-beat (or coalesced) — it plays right after.
this._playNow(next);
return;
}
if (done) {
try {
done(this.ev);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[marquee] onDone threw', err);
}
}
}
_clearBeat() {
for (const o of this._beat) {
try {
o.destroy();
} catch {
/* a scene mid-shutdown — the scene's teardown reaps it */
}
}
this._beat.length = 0;
this._oldParts.length = 0;
this._newParts.length = 0;
this._digitCols.length = 0;
this._prefixParts.length = 0;
this._frags.length = 0;
this._rings.length = 0;
this._sparks.length = 0;
for (const sl of this._slices) sl.g.destroy();
this._slices.length = 0;
this._flags.clear();
}
// --------------------------------------------------------------- statics
/** The static layer (built once, on the first beat — cost-free until then). */
ensureBuilt() {
if (this._built) return;
const s = this.scene;
const { width: w, height: h } = s.scale;
const bg = themeColor('bg', 0x04060d);
const dimC = themeColor('dim', 0x7d92c4);
const amber = themeColor('amber', 0xffc94d);
const bodyFont = fontStack('body');
const cx = w / 2;
const cy = h / 2;
// Shared generated textures (cached by key — one for every marquee).
canvasTexture(s, '__marquee_pixel', 4, 4, (ctx) => {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 4, 4);
});
canvasTexture(s, '__marquee_beam', 128, 8, (ctx, cw) => {
const g = ctx.createLinearGradient(0, 0, cw, 0);
g.addColorStop(0, 'rgba(0,229,255,0)');
g.addColorStop(0.5, 'rgba(0,229,255,0.8)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, cw, 8);
});
canvasTexture(s, '__marquee_band', 64, 96, (ctx, ch) => {
const g = ctx.createLinearGradient(0, 0, 0, ch);
g.addColorStop(0, 'rgba(0,229,255,0)');
g.addColorStop(0.5, 'rgba(190,255,255,0.9)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, ch);
});
this.dim = this._add(
s.add.image(cx, cy, '__marquee_pixel')
.setScrollFactor(0)
.setDepth(DEPTH)
.setTint(toColor(bg))
.setDisplaySize(w, h)
.setAlpha(0),
);
this.beam = this._add(
s.add.image(cx, cy, '__marquee_beam')
.setScrollFactor(0)
.setDepth(DEPTH + 1)
.setBlendMode(Phaser.BlendModes.ADD)
.setDisplaySize(Math.min(760, w * 0.62), Math.min(290, h * 0.42))
.setAlpha(0),
);
this.label = this._add(
s.add
.text(cx, cy - h * 0.235, 'COMBAT LEVEL', {
fontFamily: bodyFont,
fontSize: '21px',
color: toCss(dimC),
letterSpacing: 7,
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(DEPTH + 5)
.setAlpha(0),
);
this.decodeLine = this._add(
s.add
.text(cx, cy + h * 0.185, '', {
fontFamily: bodyFont,
fontSize: '21px',
color: toCss(dimC),
letterSpacing: 3,
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(DEPTH + 5)
.setAlpha(0),
);
this.reward = this._add(
s.add
.text(cx, cy + h * 0.185 + 34, '', {
fontFamily: bodyFont,
fontSize: '20px',
fontStyle: 'bold',
color: toCss(amber),
letterSpacing: 3,
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(DEPTH + 5)
.setAlpha(0),
);
this.flash = this._add(
s.add.image(cx, cy, '__marquee_pixel')
.setScrollFactor(0)
.setDepth(DEPTH + 9)
.setBlendMode(Phaser.BlendModes.ADD)
.setDisplaySize(w, h)
.setAlpha(0),
);
this.bandShutter = this._add(
s.add.image(cx, -80, '__marquee_pixel')
.setScrollFactor(0)
.setDepth(DEPTH + 10)
.setTint(toColor(bg))
.setDisplaySize(w + 64, 120)
.setAlpha(0),
);
this.bandNeon = this._add(
s.add.image(cx, -80, '__marquee_band')
.setScrollFactor(0)
.setDepth(DEPTH + 11)
.setBlendMode(Phaser.BlendModes.ADD)
.setDisplaySize(w + 64, 92)
.setAlpha(0),
);
this._built = true;
}
_add(o) {
this._o.push(o);
return o;
}
// ---------------------------------------------------------------- boards
/**
* The number board: "LV 07" as per-glyph GlitchText columns (each glyph
* carries its own RGB-split ghosts), centered on (cx, y). Digit glyphs
* sit in FIXED SLOTS (the widest digit's width) so the scramble's
* cycling values never jitter the layout.
*/
_makeBoard(str, y, size) {
const s = this.scene;
const neon = toCss(themeColor('neon', '#00e5ff'));
// The width probe uses the style WITHOUT the glow (a canvas shadow
// inflates the text's canvas — measuring with it would space the
// glyphs too far apart).
const tight = { fontFamily: fontStack('header'), fontSize: `${size}px`, color: neon };
const style = { ...tight, shadow: { color: neon, blur: 26, offsetX: 0, offsetY: 0 } };
const probe = (ch) => {
const t = s.add.text(0, 0, ch, tight);
const w = t.width;
t.destroy();
return w;
};
const spaceW = probe(' ');
const slotW = Math.max(...'0123456789'.split('').map((d) => probe(d)));
const widths = [...str].map((ch) => (ch === ' ' ? spaceW : /\d/.test(ch) ? slotW : probe(ch)));
const total = widths.reduce((a, b) => a + b, 0);
const cx = s.scale.width / 2;
const cursorX = cx - total / 2;
let cursor = cursorX;
const parts = [];
[...str].forEach((ch, i) => {
const w = widths[i];
if (ch !== ' ') {
const gt = new GlitchText(s, 0, y, ch, style, {
baseOffset: 1.5,
burstOffset: 14,
ghostAlpha: 0.5,
bootDuration: 650,
});
const x = cursor + w / 2;
// Above the dim (DEPTH) + the beam (DEPTH+1), below the FX — the
// board must stay lit while the world goes dark.
gt.setPosition(x, y).setDepth(DEPTH + 4);
parts.push({ ch, final: ch, gt, x, w, isDigit: /\d/.test(ch) });
this._beat.push(gt);
}
cursor += w;
});
return { parts, digits: parts.filter((p) => p.isDigit).length };
}
/** Swap a slot's glyph on all three layers (main + both RGB ghosts). */
_setChar(gt, ch) {
gt.main.setText(ch);
gt.ghostRed.setText(ch);
gt.ghostCyan.setText(ch);
}
/** One tick of the digit cycle (a new random value every ~55 ms). */
_digitCycle(c, el, bucket) {
if (bucket !== c.bucket) {
c.bucket = bucket;
this._setChar(c.gt, String((Math.random() * 10) | 0));
}
c.gt.setAlpha(0.7 + 0.18 * Math.sin(el * 0.05 + c.phase));
}
// -------------------------------------------------------------------- FX
/** The signal-loss burst (the CyberOverlay slice language, self-hosted). */
_spawnSlices(intensity = 1) {
const s = this.scene;
const { width: w, height: h } = s.scale;
const palette = [
toColor(0x00e5ff),
toColor(0xff2d6f),
toColor(0xeaf6ff),
toColor(0x04060d),
];
const g = s.add.graphics().setScrollFactor(0).setDepth(DEPTH + 8);
const n = 3 + ((Math.random() * 4) | 0);
for (let i = 0; i < n; i++) {
const y = Math.random() * h;
const barH = 1 + Math.random() * 12 * intensity;
const dx = (Math.random() * 2 - 1) * 16 * intensity;
g.fillStyle(palette[(Math.random() * palette.length) | 0], 0.08 + Math.random() * 0.2 * intensity);
g.fillRect(dx - 24, y, w + 48, barH);
}
// One wide "displacement band" so the burst reads at a glance.
const bandY = h * (0.2 + Math.random() * 0.6);
g.fillStyle(toColor(0x04060d), 0.5 * intensity);
g.fillRect(-24, bandY, w + 48, 12 + Math.random() * 18 * intensity);
g.fillStyle(toColor(0x00e5ff), 0.3 * intensity);
g.fillRect(-24, bandY - 2, w + 48, 2);
this._slices.push({ g, die: s.time.now + 340 });
}
/** The shatter: each old glyph breaks loose and falls (gravity + spin). */
_spawnFrags() {
const s = this.scene;
const colors = [
toCss(themeColor('neon', '#00e5ff')),
toCss(themeColor('neon2', '#ff2d6f')),
toCss(themeColor('ink', '#eaf6ff')),
];
const font = fontStack('header');
const size = Math.round(this._size * 0.92);
const y = this._cy - 8;
for (const p of this._oldParts) {
const t = s.add
.text(p.x, y, p.final, {
fontFamily: font,
fontSize: `${size}px`,
color: colors[(Math.random() * colors.length) | 0],
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(DEPTH + 6);
this._frags.push({
t,
x: p.x,
y,
vx: (Math.random() * 2 - 1) * 170,
vy: 60 + Math.random() * 240,
vr: (Math.random() * 2 - 1) * 4.5,
t0: this.el,
life: 560 + Math.random() * 180,
});
this._beat.push(t);
}
}
_stepFrags(el) {
const G = 1500; // the shards' gravity (px/s²)
for (let i = this._frags.length - 1; i >= 0; i--) {
const f = this._frags[i];
const ageMs = el - f.t0;
if (ageMs < 0) continue;
const u = ageMs / f.life;
if (u >= 1) {
f.t.destroy();
this._frags.splice(i, 1);
continue;
}
const a = ageMs / 1000;
f.t.setPosition(f.x + f.vx * a, f.y + f.vy * a + 0.5 * G * a * a);
f.t.setAngle(f.vr * a * 57.29);
f.t.setAlpha(0.95 * Math.pow(1 - u, 1.3));
}
}
/** A digit slot's lock: four small sparks off the glyph. */
_digitSparks(c) {
const s = this.scene;
const key = Fx.ensureDot(s);
const y = this._cy - 8;
const palette = [0x9ff3ff, 0x00e5ff, 0xff2d6f, 0xffffff];
for (let i = 0; i < 4; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 120 + Math.random() * 200;
const img = s.add
.image(c.x, y, key)
.setTint(toColor(palette[(Math.random() * palette.length) | 0]))
.setBlendMode(Phaser.BlendModes.ADD)
.setScrollFactor(0)
.setDepth(DEPTH + 7)
.setScale((2.5 + Math.random() * 3) / 64);
this._sparks.push({
img,
x: c.x,
y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
t0: this.el,
life: 300 + Math.random() * 200,
});
this._beat.push(img);
}
}
/**
* The lock-in: the white flash, the world-camera kick (the UI layer
* stays put — the split's whole point), the shockwave rings, the spark
* burst, and the thump voice.
*/
_lockFx() {
const s = this.scene;
const key = Fx.ensureDot(s);
const cx = s.scale.width / 2;
const cy = this._cy - 8;
const R = Math.min(s.scale.width, s.scale.height) * 0.55;
const mkRing = (color, life, to, delay) => {
const img = s.add
.image(cx, cy, key)
.setTint(toColor(color))
.setBlendMode(Phaser.BlendModes.ADD)
.setScrollFactor(0)
.setDepth(DEPTH + 7)
.setScale(6 / 64)
.setAlpha(0);
this._rings.push({ img, t0: this.el + delay, life, from: 6 / 64, to: to / 64 });
this._beat.push(img);
};
mkRing(themeColor('neon', 0x00e5ff), 460, R, 0);
mkRing(themeColor('neon2', 0xff2d6f), 540, R * 1.25, 90);
const palette = [0x9ff3ff, 0x00e5ff, 0xff2d6f, 0xffffff];
for (let i = 0; i < 26; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 240 + Math.random() * 340;
const img = s.add
.image(cx, cy, key)
.setTint(toColor(palette[(Math.random() * palette.length) | 0]))
.setBlendMode(Phaser.BlendModes.ADD)
.setScrollFactor(0)
.setDepth(DEPTH + 7)
.setScale((3 + Math.random() * 5) / 64);
this._sparks.push({
img,
x: cx,
y: cy,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
t0: this.el,
life: 380 + Math.random() * 320,
});
this._beat.push(img);
}
try {
// The kick hits the WORLD pass only (js/visuals/UiCameras.js) — the
// marquee is the UI layer and stays dead still while the world kicks.
ensureUiCameras(s);
s.cameras?.main?.shake?.(260, 0.013);
} catch {
/* a camera-less scene (headless dev) — the beat plays on anyway */
}
playSfxOn(s, 'solar_flare_02'); // the lock-in thump
}
_stepRings(el) {
for (let i = this._rings.length - 1; i >= 0; i--) {
const r = this._rings[i];
const u = (el - r.t0) / r.life;
if (u >= 1) {
r.img.destroy();
this._rings.splice(i, 1);
continue;
}
if (u < 0) continue;
const e = easeOutCubic(u);
r.img.setScale(r.from + (r.to - r.from) * e);
r.img.setAlpha(0.85 * (1 - u));
}
}
_stepSparks(el, dt) {
const drag = Math.exp(-2.8 * dt);
for (let i = this._sparks.length - 1; i >= 0; i--) {
const p = this._sparks[i];
const u = (el - p.t0) / p.life;
if (u >= 1) {
p.img.destroy();
this._sparks.splice(i, 1);
continue;
}
if (u < 0) continue;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= drag;
p.vy *= drag;
p.img.setPosition(p.x, p.y);
p.img.setAlpha(1 - u);
}
}
_stepSlices(time) {
for (let i = this._slices.length - 1; i >= 0; i--) {
if (time >= this._slices[i].die) {
this._slices[i].g.destroy();
this._slices.splice(i, 1);
}
}
}
}