Add level-up marquee beat for combat level landings

- New js/ui/LevelUpMarquee.js: a self-contained full-screen beat (dim, old-level shatter, digit-by-digit decode, lock-in FX, neon shutter peel) driven by hand-stepped timeline with no tweens; coalesces multi-level grants into one "+N LEVELS" beat and buffers mid-beat level-ups so none are dropped
- GameScene wires the marquee to XpManager's 'levelup' event, gates ship/combat/research/build/discovery ticks on its active flag (same pause contract as comms ack), tears it down on shutdown, and exposes a ?combat dev `marquee(from,to)` preview hook
- Add headless Node test (dev/marquee.test.mjs) covering trigger/timeline/reap, coalesce, buffer, teardown, and the FIRMWARE-MAXED cap branch
- Add CDP screenshot harness (dev/marquee-shot.mjs) plus committed dev/shots/ evidence frames for each beat phase via a new debugSeek/debugRelease latch
This commit is contained in:
Brian Fertig 2026-09-19 18:42:21 -06:00
parent 2e9b1ace5a
commit ec1ad155d6
11 changed files with 1346 additions and 6 deletions

184
dev/marquee-shot.mjs Normal file
View File

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

266
dev/marquee.test.mjs Normal file
View File

@ -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');

BIN
dev/shots/marquee-lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
dev/shots/marquee-max.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
dev/shots/marquee-old.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
dev/shots/marquee-peel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
dev/shots/marquee-sync.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -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
}
/**

869
js/ui/LevelUpMarquee.js Normal file
View File

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