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
|
|
@ -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); });
|
||||
|
|
@ -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');
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||