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