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