diff --git a/dev/marquee-shot.mjs b/dev/marquee-shot.mjs new file mode 100644 index 0000000..424e7a7 --- /dev/null +++ b/dev/marquee-shot.mjs @@ -0,0 +1,184 @@ +/** + * Dev-only CDP screenshot harness for the LEVEL-UP MARQUEE — drives the + * real game (index.html?combat) and plays the marquee's beats through the + * production component (GameScene.levelMarquee, js/ui/LevelUpMarquee.js), + * capturing one labeled frame per beat phase so the full sequence is on + * record. + * + * node dev/marquee-shot.mjs [outDir] + * + * Requires the dev server (http://127.0.0.1:8080) and a headless + * Chromium-CDP browser on 127.0.0.1:9333 (same as dev/cdp-shot.mjs): + * /opt/brave.com/brave/brave --headless --disable-gpu \ + * --remote-debugging-port=9333 about:blank + * + * Outputs (committed as evidence under the given outDir, default + * dev/shots/) — the beat plays 7 → 9 (+2 LEVELS), then 78 → 80 (the + * FIRMWARE-MAXED branch): + * marquee-old.png — the old level booted in (decode line live) + * marquee-shatter.png — corruption: slices + the shatter into shards + * marquee-scramble.png — the new level's digit slots cycling/locking + * marquee-lock.png — the lock-in: flash, shockwave, sparks + * marquee-sync.png — the hold: SYNC COMPLETE ▸ +2 LEVELS + armor + * marquee-peel.png — the neon shutter peeling the frame away + * marquee-max.png — the cap: FIRMWARE MAXED ▸ LEVEL 80 + */ +const fs = await import('node:fs'); +const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; +const BASE = process.env.BASE ?? 'http://127.0.0.1:8080'; +const OUT = process.argv[2] ?? 'dev/shots'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + fs.mkdirSync(OUT, { recursive: true }); + + async function openPage(url) { + let target; + try { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + } catch { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); + ws.onmessage = (m) => { + const x = JSON.parse(m.data); + if (x.id && pending.has(x.id)) { + const { res, rej } = pending.get(x.id); + pending.delete(x.id); + x.error ? rej(new Error(x.error.message)) : res(x.result); + } + }; + await send('Runtime.enable'); + await send('Page.enable'); + await send('Network.enable'); + await send('Network.setCacheDisabled', { cacheDisabled: true }); + await send('Page.navigate', { url }); + const ev = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; + }; + const waitTrue = async (expr, timeoutMs = 45000) => { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + const v = await ev(expr).catch(() => null); + if (v) return v; + await sleep(250); + } + throw new Error('timed out waiting for: ' + expr.slice(0, 60)); + }; + const shot = async (path, delayMs = 90) => { + await sleep(delayMs); // let the last paint land + const r = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path, Buffer.from(r.data, 'base64')); + console.log(` shot: ${path}`); + }; + const close = async () => { + await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); + ws.close(); + }; + return { ev, waitTrue, shot, close, target }; + } + + const p = await openPage(`${BASE}/index.html?combat`); + try { + await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)'); + await p.ev('window.game.scene.getScene("MenuScene").startNewGame()'); + await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)'); + // The new-run tutorial comms own the screen (ack pause) — clear the + // chain through the hub's own finish path so the world behind the + // marquee is the live game, not a comm panel. + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + const h = g.comms; + if (!h) return true; + if (h.current) { try { h._finish(); } catch (e) {} } + if (Array.isArray(h.queue)) h.queue.length = 0; + h._setPaused(false); + try { h.box?.close(); } catch (e) {} + h.box = null; + return true; + })()`); + // The camera follows the ship; wait until it is centered on it. + await p.waitTrue(`(() => { + const g = window.game.scene.getScene('GameScene'); + const cam = g.cameras.main; + const w = g.scale.width, h = g.scale.height; + const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2; + const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2; + return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24; + })()`, 8000).catch(() => null); + await p.ev('document.fonts.ready.then(() => true)'); + await sleep(400); // the pack settles; the sim ticks + + // Play the beat through the production component (the exact path a + // real kill's XP grant drives — the ledger's event → this.play()), + // then capture each phase with debugSeek() so the shot lands on the + // exact beat moment (no wall-clock racing; the scene keeps stepping). + // Timeline (two-digit level): old 180–950, corrupt 950, scramble 1250, + // locks 1580/1720, lock-in 1810, peel 2630, end 2960. + const seek = async (ms) => { + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + if (g.levelMarquee.active) g.levelMarquee.debugSeek(${ms}); + return g.levelMarquee.active; + })()`); + await sleep(150); // the latch is synchronous — let the paint land + }; + const release = async () => { + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + if (g.levelMarquee.active) g.levelMarquee.debugRelease(); + return true; + })()`); + }; + + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + g.levelMarquee.play({ from: 7, to: 9, levelsGained: 2 }); + return g.levelMarquee.active; + })()`); + + await seek(700); // old level booted in, the decode line live + await p.shot(`${OUT}/marquee-old.png`); + await seek(1020); // the corruption burst + shatter (shards mid-fall) + await p.shot(`${OUT}/marquee-shatter.png`); + await seek(1560); // the digit slots cycling, the first lock landing + await p.shot(`${OUT}/marquee-scramble.png`); + await seek(1820); // the lock-in: flash + shockwave + sparks + await p.shot(`${OUT}/marquee-lock.png`); + await seek(2250); // the hold: SYNC COMPLETE ▸ +2 LEVELS + the armor line + await p.shot(`${OUT}/marquee-sync.png`); + await seek(2720); // the neon shutter peeling the frame away + await p.shot(`${OUT}/marquee-peel.png`); + await release(); + await p.waitTrue('!window.game.scene.getScene("GameScene").levelMarquee.active', 8000); + + // The cap branch: FIRMWARE MAXED ▸ LEVEL 80. + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + g.levelMarquee.play({ from: 78, to: 80, levelsGained: 2 }); + return g.levelMarquee.active; + })()`); + await seek(2200); // the lock-in + the MAXED line up + await p.shot(`${OUT}/marquee-max.png`); + await release(); + // Let the beat finish (no beat left running when we close). + await p.waitTrue('!window.game.scene.getScene("GameScene").levelMarquee.active', 8000); + } finally { + await p.close(); + } + + console.log('marquee screenshots: done'); +} + +main().catch((err) => { console.error('marquee-shot failed:', err.message); process.exit(1); }); diff --git a/dev/marquee.test.mjs b/dev/marquee.test.mjs new file mode 100644 index 0000000..7d74894 --- /dev/null +++ b/dev/marquee.test.mjs @@ -0,0 +1,266 @@ +/** + * LevelUpMarquee test (dev tool, run with Node — no browser needed): + * + * node dev/marquee.test.mjs + * + * The level-up marquee's BEAT CONTRACT (js/ui/LevelUpMarquee.js): + * + * - TRIGGER — the ledger's 'levelup' event plays the beat immediately + * (the pause contract the scene's update gates on). + * - TIMELINE — the beat runs dim → old-level → corruption → scramble → + * lock-in → hold → shutter peel, finishes on its own, and + * reaps EVERY transient it spawned (shards, rings, sparks, + * slices — nothing leaks into the scene). + * - COALESCE — one grant crossing N levels fires one 'levelup' per + * level in the same tick → the marquee plays ONE beat + * (lowest → highest, "+N LEVELS"), never N overlapping + * ones. + * - BUFFER — a level-up landing MID-BEAT is buffered and plays right + * after the live one (the queue never drops a level). + * - TEARDOWN — destroy() ends the beat and every created object dies + * with the scene (a scene mid-shutdown can't wedge it). + * + * The phaser stub (dev/phaser-stub.mjs) provides the Container + * GlitchText extends; a fake Scene below provides the add.* factories + + * time + textures (a `document` shim lets canvasTexture run headless). + * SFX + cameras are absent on purpose — the marquee must no-op both + * (a camera-less scene still gets the full beat). + */ + +process.env.NODE_ENV = 'dev'; + +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const url = (p) => pathToFileURL(join(__dirname, p)).href; + +// --- Load the real config (data/*.json) into the config singleton ------ +const { config } = await import(url('../js/config/Config.js')); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +// --- A `document` shim: canvasTexture needs createElement + a 2D ctx ---- +const gradient = () => ({ addColorStop() {} }); +globalThis.document ??= { + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + fillRect() {}, + strokeRect() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + arc() {}, + stroke() {}, + fill() {}, + createLinearGradient: gradient, + createRadialGradient: gradient, + }), + }), +}; + +// --------------------------------------------------------------------------- +// A headless Scene: the add.* factories + time + textures the marquee builds +// on. Every created object is recorded (the leak + teardown asserts). +// SFX, cameras, and the cache are DELIBERATELY absent — the marquee must +// no-op them all (a headless scene still gets the full beat). +// --------------------------------------------------------------------------- +function chainable(o) { + for (const m of [ + 'setOrigin', 'setVisible', 'setScrollFactor', 'setDepth', + 'setTint', 'setBlendMode', 'setAngle', 'setStrokeStyle', + 'setRotation', 'setDisplaySize', 'setStroke', 'fillStyle', 'fillRect', + 'beginPath', 'moveTo', 'lineTo', 'stroke', 'fill', + ]) { + o[m] = (..._a) => o; + } + o.setAlpha = (a) => { o.alpha = Number(a); return o; }; + o.setScale = (v) => { o.scale = Number(v); return o; }; + o.setX = (x) => { o.x = x; return o; }; + o.setY = (y) => { o.y = y; return o; }; + o.setPosition = (x, y) => { o.x = x; o.y = y; return o; }; + o.setSize = (w, h) => { o.width = w; o.height = h; return o; }; + o.destroy = () => { o.destroyed = true; }; + return o; +} + +function fakeText(str) { + const t = chainable({ kind: 'text', text: String(str) }); + const measure = () => { t.width = Math.max(12, String(t.text).length * 14); t.height = 24; }; + measure(); + t.setText = (s) => { t.text = String(s); measure(); return t; }; + return t; +} + +function makeScene() { + const scene = { + scale: { width: 1280, height: 720 }, + time: { now: 0 }, + created: [], + _textures: new Set(), + }; + scene.textures = { + exists: (k) => scene._textures.has(k), + addCanvas: (k, c) => { scene._textures.add(k); return c; }, + }; + scene.add = { + text: (_x, _y, str) => { const t = fakeText(str); scene.created.push(t); return t; }, + image: () => { const o = chainable({ kind: 'image' }); scene.created.push(o); return o; }, + graphics: () => { const o = chainable({ kind: 'graphics' }); scene.created.push(o); return o; }, + existing: (o) => o, + }; + return scene; +} + +const { LevelUpMarquee } = await import(url('../js/ui/LevelUpMarquee.js')); +const { XpManager } = await import(url('../js/combat/XpManager.js')); + +/** + * Step the beat forward (16 ms frames) until `done()` holds or the budget + * runs out — the scene's clock + the marquee's update are the ONLY time + * source, exactly as GameScene drives them. + */ +function step(m, scene, done, budgetMs = 20000) { + let ms = 0; + while (!done() && ms < budgetMs) { + scene.time.now += 16; + m.update(scene.time.now, 16); + ms += 16; + } + if (!done()) throw new Error('step budget exhausted — the beat never finished'); +} + +const settled = (m) => + m.active === false && m._frags.length === 0 && m._rings.length === 0 && + m._sparks.length === 0 && m._slices.length === 0 && m._beat.length === 0; + +// --------------------------------------------------------------------------- +// 1 — TRIGGER + TIMELINE + REAP (one level crossed, the full beat plays +// out, finishes, and leaves the scene exactly as it found it) +// --------------------------------------------------------------------------- +{ + const scene = makeScene(); + const done = []; + const m = new LevelUpMarquee(scene, { onDone: (b) => done.push(b) }); + const xp = new XpManager(); + m.bind(xp); + assert.equal(m.active, false, 'idle until a level lands'); + assert.equal(scene._textures.size, 0, 'no cost until the first beat (lazy build)'); + + xp.grant(60, 'test'); // level 1 costs 50 (data/progression.json) → crosses + assert.equal(m.active, true, "the ledger's 'levelup' plays the beat immediately"); + assert.deepEqual(m.ev, { from: 0, to: 1, levelsGained: 1 }); + assert.ok(scene._textures.has('__marquee_beam'), 'the static layer built on first beat'); + + // Mid-beat invariants: active, the old board is up, transients exist. + step(m, scene, () => scene.time.now - m.t0 >= 1100, 1500); + assert.equal(m.active, true, 'still playing mid-beat'); + assert.ok(m._frags.length > 0, 'the old level shattered into shards'); + + step(m, scene, () => settled(m), 20000); + assert.equal(done.length, 1, 'onDone fired once'); + assert.deepEqual(done[0], { from: 0, to: 1, levelsGained: 1 }); + assert.equal(m.dim.alpha, 0, 'the dim lifted (the world is back)'); + console.log('ok 1 — trigger, timeline, reap, onDone'); +} + +// --------------------------------------------------------------------------- +// 2 — COALESCE (one grant crossing two levels → ONE beat, 0 → 2) +// --------------------------------------------------------------------------- +{ + const scene = makeScene(); + const m = new LevelUpMarquee(scene); + const xp = new XpManager(); + m.bind(xp); + + xp.grant(600, 'test'); // 50 (L1) + 200 (L2) = 250; next cost 450 > 350 left → level 2 + assert.equal(m.active, true, 'the first levelup of the grant plays immediately'); + assert.deepEqual(m.pending, { from: 0, to: 2, levelsGained: 2 }, + 'the rest of the grant folded onto the beat ledger (0 → 2, +2)'); + + // The live beat finishes → the coalesced beat plays (0 → 2, the full span). + step(m, scene, () => m.active === false || (m.ev.to === 2 && m.pending === null), 20000); + assert.equal(m.active, true, 'the coalesced beat plays right after the first'); + assert.deepEqual(m.ev, { from: 0, to: 2, levelsGained: 2 }, 'the beat reads the grant span'); + assert.match(m._decodeNew.value, /SYNC COMPLETE ▸ \+2 LEVELS/, 'the subline says +2 LEVELS'); + + step(m, scene, () => settled(m), 20000); + console.log('ok 2 — multi-level grant coalesces onto one beat'); +} + +// --------------------------------------------------------------------------- +// 3 — BUFFER (a level landing mid-beat plays right after — never dropped) +// --------------------------------------------------------------------------- +{ + const scene = makeScene(); + const m = new LevelUpMarquee(scene); + m.play({ from: 5, to: 6 }); + assert.equal(m.active, true, 'manual play arms the beat'); + step(m, scene, () => scene.time.now - m.t0 >= 300, 500); + + m.play({ from: 6, to: 7 }); // lands mid-beat + assert.equal(m.active, true, 'the live beat is undisturbed'); + assert.deepEqual(m.pending, { from: 5, to: 7, levelsGained: 2 }, + 'the late level folded onto the beat ledger (5 → 7)'); + + step(m, scene, () => m.active === false || (m.ev.to === 7 && m.pending === null), 20000); + assert.equal(m.active, true, 'the buffered beat plays right after the first'); + assert.equal(m.ev.to, 7, 'it shows the buffered level'); + assert.equal(m.pending, null, 'the queue drained'); + + step(m, scene, () => settled(m), 20000); + assert.equal(m.active, false, '… and the scene settles'); + console.log('ok 3 — mid-beat level-ups buffer and play in order'); +} + +// --------------------------------------------------------------------------- +// 4 — TEARDOWN (destroy() ends the beat; every created object dies) +// --------------------------------------------------------------------------- +{ + const scene = makeScene(); + const m = new LevelUpMarquee(scene); + m.play({ from: 7, to: 9 }); + step(m, scene, () => scene.time.now - m.t0 >= 1200, 1500); // mid-corruption + assert.equal(m.active, true); + const statics = m._o.slice(); + const beat = m._beat.slice(); + m.destroy(); + assert.equal(m.active, false, 'destroy ends the beat'); + assert.ok(statics.every((o) => o.destroyed), 'the static layer destroyed'); + assert.ok(beat.every((o) => o.destroyed), 'the live beat transients destroyed'); + m.update(scene.time.now + 100, 16); // a dead marquee is a no-op + console.log('ok 4 — teardown is total'); +} + +// --------------------------------------------------------------------------- +// 5 — MAX (the cap lands as the beat's final state — the MAXED line) +// --------------------------------------------------------------------------- +{ + const scene = makeScene(); + const m = new LevelUpMarquee(scene); + const xp = new XpManager(); + m.bind(xp); + // Jump the ledger to one below the cap, then cross it (the event's `to` + // is the max → the beat takes the FIRMWARE-MAXED branch). + xp.level = xp.maxLevel - 1; + xp.grant(xp.xpForLevel(xp.maxLevel), 'test'); + assert.equal(m.active, true); + assert.equal(m.ev.to, xp.maxLevel, 'the cap level is the beat target'); + assert.match(m._decodeNew.value, /FIRMWARE MAXED/, 'the max line is the decode target'); + step(m, scene, () => settled(m), 20000); + console.log('ok 5 — the cap level plays the MAXED line'); +} + +console.log('\nmarquee: all checks passed'); diff --git a/dev/shots/marquee-lock.png b/dev/shots/marquee-lock.png new file mode 100644 index 0000000..936ad33 Binary files /dev/null and b/dev/shots/marquee-lock.png differ diff --git a/dev/shots/marquee-max.png b/dev/shots/marquee-max.png new file mode 100644 index 0000000..d72ef4f Binary files /dev/null and b/dev/shots/marquee-max.png differ diff --git a/dev/shots/marquee-old.png b/dev/shots/marquee-old.png new file mode 100644 index 0000000..6981dcd Binary files /dev/null and b/dev/shots/marquee-old.png differ diff --git a/dev/shots/marquee-peel.png b/dev/shots/marquee-peel.png new file mode 100644 index 0000000..10c4504 Binary files /dev/null and b/dev/shots/marquee-peel.png differ diff --git a/dev/shots/marquee-scramble.png b/dev/shots/marquee-scramble.png new file mode 100644 index 0000000..e9c6ba4 Binary files /dev/null and b/dev/shots/marquee-scramble.png differ diff --git a/dev/shots/marquee-shatter.png b/dev/shots/marquee-shatter.png new file mode 100644 index 0000000..592fc28 Binary files /dev/null and b/dev/shots/marquee-shatter.png differ diff --git a/dev/shots/marquee-sync.png b/dev/shots/marquee-sync.png new file mode 100644 index 0000000..0ab9bcb Binary files /dev/null and b/dev/shots/marquee-sync.png differ diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 312572f..3efcd9a 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -35,6 +35,7 @@ import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; import { CombatBar } from '../ui/CombatBar.js'; +import { LevelUpMarquee } from '../ui/LevelUpMarquee.js'; import { CombatSystem } from '../combat/CombatSystem.js'; import { XpManager } from '../combat/XpManager.js'; import { CombatHud } from '../combat/CombatHud.js'; @@ -398,6 +399,7 @@ export class GameScene extends Phaser.Scene { this.stopGameMusic(); this.setMiningLoop(false); // the hum can't outlive the scene this.systemEffects?.release(); // the effect can't outlive its scene + this.levelMarquee?.destroy(); // the marquee can't outlive its scene }); this.events.on('sleep', () => { this.stopGameMusic(); // no space hum on a world's surface @@ -1017,6 +1019,16 @@ export class GameScene extends Phaser.Scene { if (a.hullHit > 0) this.combatHud.flash('hull'); }, }); + // THE LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js) — the big beat on a + // combat level landing: the world freezes and dims, the old level + // shatters into falling shards, the new one decodes in digit by + // digit, and a neon shutter peels the frame away. Subscribed to the + // ledger's 'levelup' event (the armor bump + the level-landing voice + // still ride CombatSystem's own subscription); the scene's update() + // gates the ship, the combat, and the time-based ticks on its + // `active` — the same freeze contract as the comms' ack pause. + this.levelMarquee = new LevelUpMarquee(this); + this.levelMarquee.bind(this.combat.xp); // ?combat boot (the tuning sandbox): the hostile pack around the // ship. The main game's combat arrives with the quest's ambush // (the wave run above) — the boot just adds the practice pack. @@ -1080,6 +1092,10 @@ export class GameScene extends Phaser.Scene { // curve / the level-up beat (source 'dev' — the ledger tags it). xp: () => this.combat?.xp?.stats() ?? null, grantXp: (n = 100) => this.combat?.xp?.grant(n, 'dev') ?? null, + // The LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js): preview the + // full beat without a kill (from → to; +N LEVELS for a jump). + marquee: (from = 7, to = 9) => + (this.levelMarquee?.play({ from, to, levelsGained: Math.max(1, to - from) }) ?? null), // WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a // script (a combat.waves.scripts NAME or a raw wave list), watch // it, cancel it. The quest's own hook is live (the 'Pirate @@ -2418,6 +2434,7 @@ export class GameScene extends Phaser.Scene { this.time.update(_time, delta); this.tweens.update(); this.comms?.pump(_time); // comms: queue → play (and its pause contract) + this.levelMarquee?.update(_time, delta); // the level-up beat (drives its own timeline) // The ACK pause contract (the CommsHub froze the action): the ship // holds station, the research/build ticks wait (their deadlines are // time-based — a project that finishes while the box holds completes @@ -2426,16 +2443,20 @@ export class GameScene extends Phaser.Scene { // breathing — starfield, tether, the comm's own clip — only the // player's action is paused. const _commsPaused = this.comms?.paused === true; + // The level-up marquee's freeze (js/ui/LevelUpMarquee.js): the beat + // owns the screen, so the action holds while it plays — the same + // rule as the ack pause, one frame of the world standing still. + const _frozen = _commsPaused || this.levelMarquee?.active === true; // The deck goes to STANDBY while an 'ack' comm holds (the pause // contract's face): the slots dim and stop hovering/pressing, so the // pulsing ACKNOWLEDGE button is the only live control on screen. // setEnabled is a no-op when the state is unchanged (per-frame free), // and re-arms the frame the comm ends. - this.actionBar?.setEnabled(!_commsPaused); + this.actionBar?.setEnabled(!_frozen); this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV) this.questTracker?.update(_time); // the tracker: live checklist + priority - if (!_commsPaused) this.ship.update(_time, delta); + if (!_frozen) this.ship.update(_time, delta); // COMBAT (the ?combat demo): the pack's AI + lances, the ship's // auto-fire, the tracers, the rams, the player's armor — frozen by an @@ -2447,7 +2468,7 @@ export class GameScene extends Phaser.Scene { this._combatSettleFrames--; if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0; } - if (!_commsPaused) { + if (!_frozen) { this.combat.update(_time, delta); this.combatBar?.setStats(this.combat); this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash) @@ -2466,7 +2487,7 @@ export class GameScene extends Phaser.Scene { // completion and already unlocked it — the scene applies effects/SFX). // Paused by an ACK comm (above) — the tick resumes after, and the // completed project's effects land then (the toast, the deck bar). - const _researchDone = !_commsPaused && this.researchState ? this.researchState.tick(_time) : null; + const _researchDone = !_frozen && this.researchState ? this.researchState.tick(_time) : null; if (_researchDone?.length) { for (const d of _researchDone) this._completeResearch(d.category, d.id); } @@ -2483,7 +2504,7 @@ export class GameScene extends Phaser.Scene { // player took off mid-build (game-loop clock — see beginBuild); when // its deadline passes HERE we apply the effect (the world change is // world state: the tether range outlives the stay). - if (this.buildState && !_commsPaused) { + if (this.buildState && !_frozen) { const _buildDone = this.buildState.tick(this.game.loop.now); if (_buildDone.length) { for (const c of _buildDone) this.completeBuild(c.planet, c.build); @@ -2547,7 +2568,7 @@ export class GameScene extends Phaser.Scene { this._maybeWitnessCapitalReturn(); this._drawPatrolMarkers(_time); // the active patrol's beacons (the route's system only) this.updatePatrol(); // the check-ins (the compass + the beacons see the fresh ledger this frame) - if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view + if (!_frozen) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } /** diff --git a/js/ui/LevelUpMarquee.js b/js/ui/LevelUpMarquee.js new file mode 100644 index 0000000..493c78f --- /dev/null +++ b/js/ui/LevelUpMarquee.js @@ -0,0 +1,869 @@ +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; + c.lockAt = this.t0 + 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); + } + } + } +}