Main menu: cyberpunk UI overhaul + shared theme layer

Shared visual language (data/theme.json): Ethnocentric headers,
   Centauri body, neon cyan/magenta, cut-corner panels, and a shared
   CRT/glitch overlay (scanlines, grid, vignette, scan sweep, RGB-split
   bursts) used by both scenes.

   - Menu: glitch title with RGB pull-apart, neon bloom, console frame +
     status chrome, staggered boot, seed decode scramble, reroll/New
     Game glitch feedback
   - GameScene: theme fonts, fainter overlay, arrival burst
   - Color.toCss() for canvas text (v4 writes fillStyle raw)

   Phaser v4 (Giedi) fixes: manual TimeClock/TweenManager stepping in
   scene update(); scene.add.existing() for constructed GameObjects;
   input.hitTestPointer() instead of Pointer.over(). Documented in
   PROJECT_NOTES.

   Dev: cdpshot/cdp-eval CDP drivers (headless virtual-time kills rAF).

   Verified: menu→game transition, click-to-fly, seed flows, all
   dev/*.test.mjs suites, no console errors.
This commit is contained in:
Brian Fertig 2026-09-03 16:27:25 -06:00
parent 02d689e1bb
commit 6ba71620b4
20 changed files with 1405 additions and 167 deletions

View File

@ -63,12 +63,14 @@ orbit/
│ ├── manifest.json # which config files exist
│ ├── game.json # dimensions, colors, starfield, …
│ ├── menu.json # menu text, colors, button layout, seed panel
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ └── naming.json # syllable pools for names
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
│ ├── main.js # entry point: load config → boot Phaser
@ -76,8 +78,8 @@ orbit/
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── ui/ # MenuButton (reusable)
│ ├── visuals/ # Starfield (decorative)
│ ├── ui/ # MenuButton, GlitchText, CyberShape (reusable)
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser
└── docs/PROJECT_NOTES.md # ← project conventions: read this

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

View File

@ -1,6 +1,7 @@
{
"files": [
"game.json",
"theme.json",
"menu.json",
"ship.json",
"planets.json",

View File

@ -1,43 +1,54 @@
{
"title": "ORBIT",
"titleFontSize": 84,
"subtitle": "a procedural space rpg",
"titleFontSize": 118,
"titleLetterSpacing": 12,
"subtitle": "A PROCEDURAL SPACE RPG",
"subtitleFontSize": 20,
"fontFamily": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
"buttonFontSize": 24,
"colors": {
"title": "#e9edf8",
"titleGlow": "#2f6df6",
"subtitle": "#8fa0c9",
"buttonText": "#e9edf8",
"buttonBg": "#16203a",
"buttonHoverBg": "#24345c",
"buttonBorder": "#33456f",
"footer": "#54608a"
},
"subtitleLetterSpacing": 8,
"buttonFontSize": 26,
"buttons": {
"upper": true,
"newGame": {
"label": "New Game",
"position": { "x": 0.5, "y": 0.60 },
"fontSize": 24
"position": { "x": 0.5, "y": 0.585 },
"fontSize": 26,
"paddingX": 46,
"paddingY": 18
}
},
"seed": {
"label": "GALAXY SEED",
"position": { "x": 0.5, "y": 0.755 },
"labelFontSize": 12,
"fontSize": 24,
"fieldWidth": 300,
"fieldHeight": 46,
"rerollLabel": "reroll",
"labelFontSize": 13,
"fontSize": 25,
"fieldWidth": 320,
"fieldHeight": 52,
"rerollLabel": "Reroll",
"rerollFontSize": 14,
"colors": {
"label": "#54608a",
"value": "#e9edf8",
"fieldBg": "#0b1226",
"border": "#2a3a63",
"activeBorder": "#41c7ff",
"hint": "#54608a"
"label": "#5c74a8",
"value": "#eaf6ff",
"fieldBg": "#070d1a",
"border": "#22405f",
"activeBorder": "#00e5ff",
"hint": "#5c74a8"
}
},
"colors": {
"title": "#eaf6ff",
"titleGlow": "#00e5ff",
"subtitle": "#7d92c4",
"buttonText": "#eaf6ff",
"buttonBg": "#0a1120",
"buttonHoverBg": "#13233f",
"buttonBorder": "#2aa9c9",
"footer": "#3d4c74"
},
"chrome": {
"topLeft": "ORBIT // MAIN CONSOLE",
"topRight": "SYS // ONLINE",
"bottomLeft": "ORBIT // v{version}",
"bottomRight": "RUNTIME // GIEDI 4.2.1",
"frameMargin": 10
}
}

42
data/theme.json Normal file
View File

@ -0,0 +1,42 @@
{
"$comment": "The game's visual language — shared by every scene's UI. Fonts: Ethnocentric = headers/titles, Centauri = body text & buttons. Palette: deep-space black + electric cyan + magenta (retro-futurism). Tuning for the CRT/glitch overlay lives here too (see js/visuals/CyberOverlay.js, js/ui/GlitchText.js).",
"fonts": {
"header": {
"family": "Ethnocentric",
"cssFallbacks": ["'Arial Black'", "'Impact'", "sans-serif"]
},
"body": {
"family": "Centauri",
"cssFallbacks": ["'Segoe UI'", "'Helvetica Neue'", "Arial", "sans-serif"]
}
},
"colors": {
"bg": "#04060d",
"panel": "#0a1120",
"ink": "#eaf6ff",
"dim": "#7d92c4",
"faint": "#3d4c74",
"neon": "#00e5ff",
"neon2": "#ff2d6f",
"amber": "#ffc94d"
},
"overlay": {
"scanline": { "pitch": 3, "alpha": 0.14 },
"grid": { "cell": 48, "alpha": 0.05 },
"vignette": { "alpha": 0.5 },
"sweep": { "alpha": 0.09, "duration": 9000, "band": 140 }
},
"glitch": {
"enabled": true,
"interval": [2200, 6800],
"duration": [110, 340],
"slices": [2, 6],
"maxOffset": 12
},
"gameScene": {
"overlay": true,
"scanlineAlpha": 0.1,
"vignetteAlpha": 0.45,
"burstOnEntry": true
}
}

38
dev/cdp-eval.mjs Normal file
View File

@ -0,0 +1,38 @@
// Evaluate an expression in a headless Chrome (CDP) page and print the result.
// Usage: node cdp-eval.mjs <url> <expr> [waitMs]
import { spawn } from 'node:child_process';
import fs from 'node:fs';
const [url, expr, waitMs = '6000'] = process.argv.slice(2);
const PORT = 9333 + Math.floor(Math.random() * 200);
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const udd = 'C:/Users/BRIAN~1.FER/AppData/Local/Temp/cdp-eval-' + process.pid + '-' + Date.now();
const chrome = spawn(CHROME, [
'--headless', `--remote-debugging-port=${PORT}`, `--user-data-dir=${udd}`,
'--no-first-run', '--disable-gpu', '--enable-unsafe-swiftshader', 'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
let v;
for (let i = 0; i < 100; i++) {
try { v = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(200); }
}
if (!v) throw new Error('CDP never came up');
let t;
try { t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); }
catch { t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); }
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
const send = (m, pa = {}) => new Promise((res, rej) => { const i = ++id; p.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: pa })); });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && p.has(x.id)) { const { res, rej } = p.get(x.id); p.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } };
await new Promise((r) => (ws.onopen = r));
await send('Runtime.enable');
await sleep(Number(waitMs));
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 400));
console.log(typeof r.result.value === 'object' ? JSON.stringify(r.result.value) : r.result.value);
ws.close();
} finally {
chrome.kill();
setTimeout(() => fs.rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), 1500);
}

118
dev/cdpshot.mjs Normal file
View File

@ -0,0 +1,118 @@
// Minimal CDP driver — real-time (no virtual-time budget), so
// requestAnimationFrame keeps ticking like in a normal browser session.
// (Headless Chrome with --virtual-time-budget stops rAF after a couple of
// frames — the old `chrome --headless --screenshot` path is broken for this
// game; this is not.)
//
// CLI: node cdpshot.mjs <url> <out.png> [waitMs] [w] [h]
// Module: import { CDP, launch } from './cdpshot.mjs'
import { spawn } from 'node:child_process';
import fs from 'node:fs';
const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Launch an isolated headless Chrome with CDP enabled.
* Returns { url, kill() } url = the webSocketDebuggerUrl of a new tab at
* `targetUrl` (blank if omitted). kill() terminates Chrome and cleans up
* its profile dir.
*/
export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() * 100), width = 1280, height = 720, waitMs = 4000) {
const userDataDir = `C:/Users/BRIAN~1.FER/AppData/Local/Temp/cdp-prof-${process.pid}-${port}`;
fs.rmSync(userDataDir, { recursive: true, force: true });
const chrome = spawn(CHROME, [
'--headless',
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--enable-unsafe-swiftshader',
'--hide-scrollbars',
`--window-size=${width},${height}`,
'about:blank',
], { stdio: 'ignore' });
for (let i = 0; i < waitMs / 100; i++) {
try {
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
if (r.ok) break;
} catch { /* not up yet */ }
await sleep(100);
}
// A fresh tab at the target URL (PUT = newer Chrome, POST = older).
const newUrl = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(targetUrl || 'about:blank')}`;
let target;
try {
target = await (await fetch(newUrl, { method: 'PUT' })).json();
} catch {
target = await (await fetch(newUrl, { method: 'POST' })).json();
}
const kill = () => {
chrome.kill();
// Chrome may still hold profile files for a moment after kill; retry politely.
(async () => {
for (let i = 0; i < 12; i++) {
try { fs.rmSync(userDataDir, { recursive: true, force: true }); return; } catch { await sleep(250); }
}
})();
};
return { url: target.webSocketDebuggerUrl, kill };
}
/** Connect a CDP session to an already-running page. */
export class CDP {
constructor(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.id = 0;
this.pending = new Map();
this.listeners = new Map();
this.ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
} else if (msg.method && this.listeners.has(msg.method)) {
for (const fn of this.listeners.get(msg.method)) fn(msg.params);
}
};
}
open() { return new Promise((r, j) => { this.ws.onopen = r; this.ws.onerror = j; }); }
send(method, params = {}) {
return new Promise((resolve, reject) => {
const mid = ++this.id;
this.pending.set(mid, { resolve, reject });
this.ws.send(JSON.stringify({ id: mid, method, params }));
});
}
on(method, fn) {
if (!this.listeners.has(method)) this.listeners.set(method, []);
this.listeners.get(method).push(fn);
}
close() { try { this.ws.close(); } catch { /* already closed */ } }
}
// ---- CLI entry ------------------------------------------------------
if (process.argv[1] && /cdpshot\.mjs$/.test(process.argv[1])) {
const [url, out, waitMs = '7000', W = '1280', H = '720'] = process.argv.slice(2);
const { url: wsUrl, kill } = await launch(url, undefined, Number(W), Number(H));
const cdp = new CDP(wsUrl);
try {
await cdp.open();
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Page.navigate', { url });
await sleep(Number(waitMs));
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(out, Buffer.from(shot.data, 'base64'));
console.log('saved', out);
} finally {
cdp.close();
kill();
}
}

View File

@ -151,7 +151,24 @@ unclaimed". Model & seams:
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
- App code is v4-specific where v4 changed things (e.g.
`Phaser.Math.Angle.RotateTo`, `banner: false`, `Clamp(value, min, max)`).
- To upgrade: replace the vendored file + note the version here.
- **v4 quirks that bite (verified against this build, Sept 2026):**
- The engine calls a scene's `update(time, delta)` directly but does **not**
step the scene's `TimeClock` or `TweenManager` (the v3 scene-events
PRE_UPDATE/UPDATE plumbing is not fired in the v4 loop). Each scene must
call `this.time.update(time, delta)` and `this.tweens.update()` at the top
of `update()`, or `delayedCall`/`addEvent`/tweens silently never run.
(Done in `MenuScene.update` / `GameScene.update`.)
- A GameObject constructed with `new` (e.g. our `Container` subclasses in
`js/ui/`) is **not** added to the scene display list — the `scene.add.*`
factories do that. Call `scene.add.existing(this)` in the constructor
(Ship/Planet already do; GlitchText/MenuButton now do too).
- Text colors go straight to the canvas: v4 writes `fillStyle =
style.color`, so a **numeric** color is an invalid fillStyle and the text
silently renders **black**. Text styles and `setColor()` must get CSS
strings — use `toCss()` from `js/utils/Color.js` (keep `toColor()` for
Graphics/shape APIs).
- To upgrade: replace the vendored file + note the version here (and re-check
the quirks above — they may go away).
## Roadmap (working list, intentionally rough)

View File

@ -5,6 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orbit</title>
<style>
/* Game typefaces (see data/theme.json — Ethnocentric = headers, Centauri = body/buttons).
Loaded before first draw in js/main.js so canvas text uses them from frame one. */
@font-face {
font-family: 'Ethnocentric';
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Centauri';
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
html, body {
margin: 0;
padding: 0;

View File

@ -13,9 +13,31 @@ import { GameScene } from './scenes/GameScene.js';
* 3. Build the Phaser game config and attach the scenes
* 4. Boot
*/
/**
* Wait for the web fonts (data/theme.json) to be ready before the first
* draw, so canvas text uses Ethnocentric/Centauri from frame one.
* Hard timeout so boot can never hang on a flaky asset we just fall
* back to the stack's system fonts in that case.
*/
async function awaitFonts(timeoutMs = 2000) {
const fonts = globalThis.document?.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const families = ['header', 'body']
.map((k) => config.get(`theme.fonts.${k}.family`))
.filter((f) => typeof f === 'string' && f.length > 0);
if (families.length === 0) return;
const jobs = families.map((fam) => fonts.load(`16px "${fam}"`).catch(() => {}));
const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
await Promise.race([Promise.allSettled(jobs), timeout]);
}
async function boot() {
const data = await ConfigLoader.load();
config.init(data);
console.info('orbit — config loaded', Object.keys(data).join(', '));
await awaitFonts();
console.info('orbit — fonts ready (or timed out)');
const gameConfig = createGameConfig();
gameConfig.scene = [MenuScene, GameScene];

View File

@ -1,14 +1,18 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { Starfield } from '../visuals/Starfield.js';
import { CyberOverlay } from '../visuals/CyberOverlay.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
/**
* The game world (v0.3: the home planet the player's Terran world
@ -39,6 +43,14 @@ export class GameScene extends Phaser.Scene {
this.cameraFollowShip = config.get('game.camera.followShip', true);
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
// The shared cyberpunk dressing (scanlines, vignette, occasional
// glitch burst) — the same overlay the menu uses, dialed down.
this.overlay = this.createThemeOverlay();
if (this.overlay && config.get('theme.gameScene.burstOnEntry', true)) {
// One arrival glitch when we cut in from the menu.
this.time.delayedCall(350, () => this.overlay?.trigger(320, 0.8));
}
// We are, after all, in a system. Show the player which one. (This
// also establishes the galaxy — and its seed — for what follows.)
this.createSystemHud();
@ -76,9 +88,10 @@ export class GameScene extends Phaser.Scene {
// Hint
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
fontFamily: FONT_FALLBACK,
fontFamily: BODY_FONT(),
fontSize: '14px',
color: '#54608a',
letterSpacing: 1,
})
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
@ -120,7 +133,8 @@ export class GameScene extends Phaser.Scene {
const current = this.galaxy.currentSystem();
const content = this.galaxy.ensureContent(current.id);
const report = formatSystemReport(content);
const fam = FONT_FALLBACK;
const fam = BODY_FONT();
const famHeader = HEADER_FONT();
let y = 14;
const line = (text, style) => {
@ -133,21 +147,54 @@ export class GameScene extends Phaser.Scene {
};
line(report.title, {
fontFamily: fam,
fontFamily: famHeader,
fontSize: '17px',
fontStyle: 'bold',
color: toColor(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
// v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js).
color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
letterSpacing: 2,
});
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9' });
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9', letterSpacing: 1 });
y += 2;
for (const s of report.settlements) {
line(s.text, { fontFamily: fam, fontSize: '13px', color: toColor(s.color, 0x8fa0c9) });
line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') });
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
}
/**
* The shared CRT/glitch overlay for this scene (data/theme.json
* gameScene + overlay/glitch). Dialed down vs. the menu: fainter
* scanlines, lighter vignette dressing, not the star of the show.
*/
createThemeOverlay() {
const theme = config.section('theme', {});
const gs = config.section('theme.gameScene', {});
if (gs.overlay === false) return null;
const overlay = theme.overlay ?? {};
return new CyberOverlay(this, {
scanline: {
pitch: overlay.scanline?.pitch ?? 3,
alpha: gs.scanlineAlpha ?? overlay.scanline?.alpha ?? 0.1,
},
grid: overlay.grid
? { cell: overlay.grid.cell ?? 48, alpha: (overlay.grid.alpha ?? 0.05) * 0.6 }
: undefined,
vignette: overlay.vignette
? { alpha: gs.vignetteAlpha ?? overlay.vignette.alpha ?? 0.4 }
: undefined,
sweep: overlay.sweep,
glitch: theme.glitch,
});
}
update(_time, delta) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
// TweenManager — drive them here or delayedCall/addEvent/tweens never
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
this.time.update(_time, delta);
this.tweens.update();
this.overlay?.update(_time, delta);
this.ship.update(_time, delta);
// The home world is solid: the ship may come within the clearance in
// data/planets.json of its rim, but never closer (or through it).
@ -206,5 +253,6 @@ export class GameScene extends Phaser.Scene {
shutdown() {
this.starfield?.destroy();
this.overlay?.destroy();
}
}

View File

@ -1,27 +1,38 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { canvasTexture } from '../utils/Textures.js';
import { MenuButton } from '../ui/MenuButton.js';
import { GlitchText } from '../ui/GlitchText.js';
import { CyberOverlay } from '../visuals/CyberOverlay.js';
import { CyberShape } from '../ui/CyberShape.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { Galaxy } from '../galaxy/Galaxy.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const MONO = "'Cascadia Mono', 'Consolas', 'Menlo', monospace";
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
const SEED_MAX = 32;
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?';
/**
* Main menu. All text/colors/layout come from data/menu.json.
* Main menu the cyberpunk face of the game.
*
* All copy/layout comes from data/menu.json, the visual language from
* data/theme.json: Ethnocentric headers, Centauri body text, neon
* cyan/magenta on deep space, a cut-corner panel UI, and the shared
* CyberOverlay (scanlines, grid, vignette, scan sweep, RGB glitch
* bursts) that the whole game uses at the UI level.
*
* The Galaxy Seed panel is where a new universe is chosen:
* - the seed is displayed, editable (click it, type; Backspace; Enter to
* commit), and rerollable;
* - the same seed always builds the same galaxy (Galaxy.create), and the
* panel shows what that seed contains before you commit;
* - "New Game" builds the galaxy roster from the seed and hands it to the
* rest of the game via the shared registry.
* - the seed is displayed, editable (click it, type; Backspace; Enter
* to commit), and rerollable it "decodes" in with a scramble;
* - the same seed always builds the same galaxy (Galaxy.create), and
* the panel shows what that seed contains before you commit;
* - "New Game" builds the galaxy roster from the seed, glitches out,
* and hands it to the rest of the game via the shared registry.
*/
export class MenuScene extends Phaser.Scene {
constructor() {
@ -30,54 +41,165 @@ export class MenuScene extends Phaser.Scene {
create() {
const menu = config.section('menu', {});
const fontFamily = menu.fontFamily ?? FONT_FALLBACK;
const theme = config.section('theme', {});
const colors = menu.colors ?? {};
const { width, height } = this.scale;
const cx = width / 2;
const chrome = menu.chrome ?? {};
const headerFont = fontStack('header', FONT_FALLBACK);
const bodyFont = fontStack('body', FONT_FALLBACK);
const neon = themeColor('neon', 0x00e5ff);
const neon2 = themeColor('neon2', 0xff2d6f);
const ink = themeColor('ink', 0xeaf6ff);
const dim = themeColor('dim', 0x7d92c4);
const faint = themeColor('faint', 0x3d4c74);
// Title
this.add
.text(cx, height * 0.34, menu.title ?? 'ORBIT', {
fontFamily,
fontSize: `${menu.titleFontSize ?? 84}px`,
fontStyle: 'bold',
color: colors.title ?? '#e9edf8',
const { width: w, height: h } = this.scale;
const cx = w / 2;
this.dead = false;
this.decode = null;
// ---- CRT / glitch overlay (scanlines, grid, vignette, sweep, bursts)
this.overlay = new CyberOverlay(this, {
scanline: theme.overlay?.scanline,
grid: theme.overlay?.grid,
vignette: theme.overlay?.vignette,
sweep: theme.overlay?.sweep,
glitch: theme.glitch,
});
// ---- corner-bracket console frame
const margin = chrome.frameMargin ?? 10;
CyberShape.frame(this, cx, h / 2, w - margin * 2, h - margin * 2, {
color: neon,
alpha: 0.32,
length: 26,
lineWidth: 2,
});
// ---- title: RGB-split glitch text with a soft neon bloom behind it
const titleY = h * (menu.titlePositionY ?? 0.30);
this.title = new GlitchText(
this,
cx,
titleY,
menu.title ?? 'ORBIT',
{
fontFamily: headerFont,
fontSize: `${menu.titleFontSize ?? 118}px`,
color: toCss(colors.title ?? ink),
letterSpacing: menu.titleLetterSpacing ?? 12,
shadow: {
color: colors.titleGlow ?? '#2f6df6',
blur: 28,
color: toCss(colors.titleGlow ?? neon),
blur: 30,
offsetX: 0,
offsetY: 0,
},
})
.setOrigin(0.5);
},
{ redColor: '#ff2d6f', cyanColor: '#00e5ff', baseOffset: 1.6, burstOffset: 12, ghostAlpha: 0.5 },
);
this.overlay.onGlitch((level) => this.title.setBurst(level));
this.title.start(this.time.now);
// Subtitle
this.add
.text(cx, height * 0.46, menu.subtitle ?? '', {
fontFamily,
fontSize: `${menu.subtitleFontSize ?? 20}px`,
color: colors.subtitle ?? '#8fa0c9',
})
.setOrigin(0.5);
// New Game
const btn = menu.buttons?.newGame ?? {};
new MenuButton(
// Title bloom: two offset radial glows (cyan core, magenta fringe).
this.titleBloom = new TitleBloom(
this,
(btn.position?.x ?? 0.5) * width,
(btn.position?.y ?? 0.60) * height,
btn.label ?? 'New Game',
() => this.startNewGame(),
btn,
cx,
titleY,
config.get('theme.colors.neon', '#00e5ff'),
config.get('theme.colors.neon2', '#ff2d6f'),
);
// Galaxy seed panel
this.createSeedPanel(menu, fontFamily, cx, height);
// ---- subtitle + rule
this.subtitle = this.add
.text(cx, titleY + 62, menu.subtitle ?? '', {
fontFamily: bodyFont,
fontSize: `${menu.subtitleFontSize ?? 20}px`,
color: toCss(colors.subtitle ?? dim),
letterSpacing: menu.subtitleLetterSpacing ?? 8,
})
.setOrigin(0.5)
.setAlpha(0);
const rule = this.add.graphics().setDepth(1);
rule.lineStyle(2, neon, 0.55);
rule.lineBetween(cx - 70, titleY + 96, cx + 70, titleY + 96);
rule.fillStyle(neon2, 0.9);
rule.fillRect(cx - 2, titleY + 94, 4, 4);
this.rule = rule.setAlpha(0);
// Global input: typing goes to the seed field; a click elsewhere blurs it.
// ---- New Game
const btn = menu.buttons?.newGame ?? {};
this.newGameBtn = new MenuButton(
this,
(btn.position?.x ?? 0.5) * w,
(btn.position?.y ?? 0.585) * h,
btn.label ?? 'New Game',
() => this.startNewGame(),
{
fontSize: btn.fontSize ?? 26,
paddingX: btn.paddingX ?? 46,
paddingY: btn.paddingY ?? 18,
},
);
this.newGameBtn.setAlpha(0);
// ---- Galaxy seed panel
this.createSeedPanel(menu, headerFont, bodyFont, cx, h);
const panelFade = this.seedIntroTargets;
for (const t of panelFade) t.setAlpha(0);
this.rerollBtn.setAlpha(0);
// ---- console chrome (status lines + pulsing dot)
const version = config.get('game.version', '');
this.add.text(26, 16, chrome.topLeft ?? 'ORBIT // MAIN CONSOLE', {
fontFamily: bodyFont,
fontSize: '11px',
color: toCss(faint),
letterSpacing: 2,
}).setOrigin(0, 0);
const tr = chrome.topRight ?? 'SYS // ONLINE';
const trText = this.add.text(w - 26, 16, tr, {
fontFamily: bodyFont,
fontSize: '11px',
color: toCss(dim),
letterSpacing: 2,
}).setOrigin(1, 0);
this.statusDot = this.add.circle(w - 26 - trText.width - 14, 21, 3, neon);
this.add.text(26, h - 16, (chrome.bottomLeft ?? 'ORBIT // v{version}').replace('{version}', version), {
fontFamily: bodyFont,
fontSize: '11px',
color: toCss(faint),
letterSpacing: 2,
}).setOrigin(0, 0.5);
this.add.text(w - 26, h - 16, chrome.bottomRight ?? 'RUNTIME // GIEDI 4.2.1', {
fontFamily: bodyFont,
fontSize: '11px',
color: toCss(faint),
letterSpacing: 2,
}).setOrigin(1, 0.5);
// ---- intro sequence: title flickers in, console assembles,
// the seed decodes, and one signature glitch fires.
this.newGameBtn.y += 12;
this.intro(180, () => {
this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' });
});
this.intro(340, () => {
this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' });
});
this.intro(500, () => {
const targets = [...this.seedIntroTargets, this.rerollBtn];
this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' });
});
this.intro(760, () => this.startDecode(this.seedValue));
this.intro(1050, () => this.overlay.trigger(320, 0.95));
// ---- input: typing goes to the seed field; a click elsewhere blurs it
this.input.keyboard.on('keydown', (e) => this.onSeedKey(e));
this.input.on('pointerdown', (pointer) => {
if (this.seedFieldHit && pointer.over(this.seedFieldHit)) {
// v4 (Giedi) quirk: no Pointer.over() — the scene input plugin's
// hitTestPointer() returns the hit game objects (or []).
const hits = this.input.hitTestPointer ? this.input.hitTestPointer(pointer) : [];
if (hits && hits.indexOf(this.seedPanel) !== -1) {
this.setSeedFocus(true);
} else if (this.seedFocused) {
this.setSeedFocus(false);
@ -87,101 +209,137 @@ export class MenuScene extends Phaser.Scene {
delay: 430,
loop: true,
callback: () => {
if (this.dead || this.decode) return;
if (this.seedFocused) {
this.cursorOn = !this.cursorOn;
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
}
},
});
}
// Version footer
this.add
.text(width - 16, height - 14, config.get('game.version', ''), {
fontFamily,
fontSize: '12px',
color: colors.footer ?? '#54608a',
})
.setOrigin(1, 0.5);
/** A scene-safe delayedCall: no-ops once we're shut down / cutting away. */
intro(ms, fn) {
this.time.delayedCall(ms, () => {
if (!this.dead) fn();
});
}
// ------------------------------------------------------------------
// Galaxy seed panel
// ------------------------------------------------------------------
createSeedPanel(menu, fontFamily, cx, height) {
createSeedPanel(menu, headerFont, bodyFont, cx, height) {
const cfg = menu.seed ?? {};
const colors = cfg.colors ?? {};
const fieldW = cfg.fieldWidth ?? 300;
const fieldH = cfg.fieldHeight ?? 46;
const neon = themeColor('neon', 0x00e5ff);
const fieldW = cfg.fieldWidth ?? 320;
const fieldH = cfg.fieldHeight ?? 52;
const notch = Math.min(14, fieldH * 0.3);
const cy = (cfg.position?.y ?? 0.755) * height;
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
this.seedFocused = false;
this.cursorOn = true;
this.seedIntroTargets = [];
// Label
this.add
.text(cx, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
fontFamily,
fontSize: `${cfg.labelFontSize ?? 12}px`,
color: colors.label ?? '#54608a',
// Label: neon arrow + dim caps
const arrow = this.add
.text(cx - fieldW / 2 - 30, cy - fieldH / 2 - 10, '\u25b8', {
fontFamily: bodyFont,
fontSize: '13px',
color: toCss(neon),
})
.setOrigin(0, 1);
const label = this.add
.text(cx - fieldW / 2 - 18, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
fontFamily: headerFont,
fontSize: `${cfg.labelFontSize ?? 13}px`,
color: toCss(colors.label ?? '#5c74a8'),
letterSpacing: 3,
})
.setOrigin(0.5, 1);
.setOrigin(0, 1);
// Field (click to edit)
this.seedFieldHit = this.add
.rectangle(cx, cy, fieldW, fieldH, toColor(colors.fieldBg ?? '#0b1226'), 1)
.setStrokeStyle(1, toColor(colors.border ?? '#2a3a63'), 0.9);
this.seedFieldHit.setInteractive({ useHandCursor: true });
this.seedFieldHit.on('pointerdown', () => this.setSeedFocus(true));
// Field — a cut-corner panel; click to edit.
this.seedPanel = this.add.graphics().setPosition(cx, cy);
this.seedPanel.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(-fieldW / 2, -fieldH / 2, fieldW, fieldH),
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
this.seedPanel.on('pointerdown', () => this.setSeedFocus(true));
this.seedText = this.add
.text(cx - fieldW / 2 + 14, cy, '', {
fontFamily: MONO,
fontSize: `${cfg.fontSize ?? 24}px`,
color: colors.value ?? '#e9edf8',
.text(cx - fieldW / 2 + 18, cy, '', {
fontFamily: headerFont,
fontSize: `${cfg.fontSize ?? 25}px`,
color: toCss(colors.value ?? '#eaf6ff'),
letterSpacing: 4,
})
.setOrigin(0, 0.5);
// Reroll
new MenuButton(
this.rerollBtn = new MenuButton(
this,
cx + fieldW / 2 + 26,
cx + fieldW / 2 + 30,
cy,
cfg.rerollLabel ?? 'reroll',
() => {
if (this.dead) return;
this.seedValue = Rng.randomSeedString(8);
this.setSeedFocus(false);
this.overlay.trigger(180, 0.5);
this.startDecode(this.seedValue);
},
{
fontSize: cfg.rerollFontSize ?? 14,
paddingX: 18,
paddingY: 10,
paddingX: 16,
paddingY: 9,
upper: true,
},
);
// Hint: what this seed will build (galaxy name + scale), live-updated.
this.seedHint = this.add
.text(cx, cy + fieldH / 2 + 12, '', {
fontFamily,
.text(cx, cy + fieldH / 2 + 14, '', {
fontFamily: bodyFont,
fontSize: '12px',
color: colors.hint ?? '#54608a',
color: toCss(colors.hint ?? '#54608a'),
letterSpacing: 1,
})
.setOrigin(0.5, 0);
this.seedPanelFieldW = fieldW;
this.seedPanelFieldH = fieldH;
this.seedPanelNotch = notch;
this.seedPanelColors = colors;
this.seedPanelNeon = neon;
this.seedIntroTargets = [arrow, label, this.seedPanel, this.seedText, this.seedHint];
this.drawSeed();
}
drawSeed() {
if (!this.seedText || this.seedDead) return;
this.seedText.setText(this.seedValue + (this.seedFocused ? (this.cursorOn ? ' \u258c' : ' ') : ''));
if (!this.seedText || this.dead) return;
const cfg = config.get('menu.seed.colors', {});
const border = this.seedFocused
? toColor(cfg.activeBorder ?? '#41c7ff')
: toColor(cfg.border ?? '#2a3a63');
this.seedFieldHit.setStrokeStyle(1, border, this.seedFocused ? 1 : 0.9);
this.seedText.setText(
this.seedValue + (this.seedFocused && !this.decode ? (this.cursorOn ? ' \u258c' : ' ') : ''),
);
// Repaint the field panel: focused = neon edge + glow.
const active = this.seedFocused;
this.seedPanel.clear();
CyberShape.draw(this.seedPanel, this.seedPanelFieldW, this.seedPanelFieldH, {
notch: this.seedPanelNotch,
fill: toColor(cfg.fieldBg ?? '#070d1a'),
fillAlpha: 0.88,
stroke: toColor(active ? cfg.activeBorder ?? '#00e5ff' : cfg.border ?? '#22405f'),
strokeAlpha: active ? 1 : 0.85,
lineWidth: 1.5,
glow: active ? this.seedPanelNeon : undefined,
glowAlpha: 0.28,
});
const count = config.get('galaxy.systemCount', 0);
const archetypes = Object.keys(config.get('systems.types', {})).length;
@ -194,12 +352,43 @@ export class MenuScene extends Phaser.Scene {
}
setSeedFocus(focused) {
if (this.dead) return;
this.seedFocused = !!focused;
this.cursorOn = true;
this.drawSeed();
}
// ------------------------------------------------------------------
// Seed "decode" scramble (boot + reroll)
// ------------------------------------------------------------------
startDecode(value) {
this.decode = { value, t0: this.time.now, dur: 620 };
}
updateDecode(time) {
if (!this.decode || !this.seedText || this.dead) return;
const { value, t0, dur } = this.decode;
const u = (time - t0) / dur;
if (u >= 1) {
this.decode = null;
this.drawSeed();
return;
}
const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * value.length);
let out = '';
for (let i = 0; i < value.length; i++) {
out += i < reveal ? value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0];
}
this.seedText.setText(out);
}
// ------------------------------------------------------------------
// Input
// ------------------------------------------------------------------
onSeedKey(e) {
if (this.dead) return;
const key =
e && typeof e.key === 'string' && e.key.length > 0
? e.key
@ -230,38 +419,67 @@ export class MenuScene extends Phaser.Scene {
// ------------------------------------------------------------------
startNewGame() {
if (this.dead) return;
let seed = this.seedValue.trim();
if (!seed) {
seed = Rng.randomSeedString(8);
this.seedValue = seed;
this.drawSeed();
}
try {
this.dead = true; // no further input while we cut away
this.setSeedFocus(false);
this.drawSeed();
const galaxy = Galaxy.create(seed);
this.registry.set('galaxy', galaxy);
this.registry.set('seed', seed);
this.saveSeed(seed);
console.info(`orbit \u2014 the galaxy of ${galaxy.name} (seed ${seed})`, galaxy.summary());
this.scene.start('GameScene');
// Glitch out, then cut to the game.
this.overlay.trigger(240, 1);
this.title.setBurst(1);
this.time.delayedCall(200, () => this.scene.start('GameScene'));
} catch (err) {
console.error(err);
this.dead = false; // allow retrying
const msg = this.add
.text(this.scale.width / 2, this.scale.height - 64, `could not build the galaxy: ${err.message}`, {
fontFamily: FONT_FALLBACK,
.text(this.scale.width / 2, this.scale.height - 64, `// signal lost: could not build the galaxy — ${err.message}`, {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: '14px',
color: '#ff9b9b',
color: toCss(themeColor('neon2', 0xff9b9b)),
letterSpacing: 1,
})
.setOrigin(0.5);
this.time.delayedCall(4000, () => msg.destroy());
}
}
/** Guard for post-shutdown input events (e.g. a click that also fired
* "New Game" on the same frame). */
/** Per-frame: overlay, title glitch, status dot, seed decode. */
update(time, delta) {
// Phaser v4 (Giedi) quirk: the engine calls the scene's update() but never
// steps the scene's TimeClock or TweenManager (the scene-events
// PRE_UPDATE/UPDATE hooks in lib/phaser.min.js are never fired in the v4
// loop). We drive them here, or delayedCall/addEvent/tweens never run.
// Verified headless Sept 2026: without this, time.now freezes and tweens
// never complete. Step *before* the dead guard so the cutaway's delayedCall
// still fires while we're tearing down.
this.time.update(time, delta);
this.tweens.update();
if (this.dead) return;
this.overlay.update(time, delta);
this.title.update(time, delta);
this.updateDecode(time);
if (this.statusDot) {
this.statusDot.setAlpha(0.35 + 0.5 * (0.5 + 0.5 * Math.sin(time * 0.004)));
}
}
shutdown() {
this.seedDead = true;
this.dead = true;
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
this.titleBloom?.destroy();
this.overlay?.destroy();
this.title?.destroy();
}
loadSavedSeed() {
@ -281,3 +499,47 @@ export class MenuScene extends Phaser.Scene {
}
}
}
/**
* The soft neon bloom behind the title: a cyan radial glow with a
* fainter magenta fringe offset to the side cheap, additive, and it
* makes the RGB-split title read as light rather than ink.
*/
class TitleBloom {
constructor(scene, x, y, cyanHex, magentaHex) {
const radial = (key, hex) =>
canvasTexture(scene, key, 256, 256, (ctx) => {
const n = parseInt(hex.replace('#', ''), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
const grad = ctx.createRadialGradient(128, 128, 8, 128, 128, 126);
grad.addColorStop(0, `rgba(${r},${g},${b},0.28)`);
grad.addColorStop(0.55, `rgba(${r},${g},${b},0.09)`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 256);
});
radial('__orbit_bloom_cyan', cyanHex);
radial('__orbit_bloom_magenta', magentaHex);
this.main = scene
.add.image(x - 30, y, '__orbit_bloom_cyan')
.setOrigin(0.5)
.setDisplaySize(1150, 480)
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(-2);
this.fringe = scene
.add.image(x + 120, y + 8, '__orbit_bloom_magenta')
.setOrigin(0.5)
.setDisplaySize(900, 380)
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(-2);
}
destroy() {
this.main?.destroy();
this.fringe?.destroy();
}
}

104
js/ui/CyberShape.js Normal file
View File

@ -0,0 +1,104 @@
/**
* The angular "cut-corner" shapes of the cyberpunk UI: rectangles with
* two opposing corners sliced off (top-left + bottom-right), drawn with
* a Graphics so they can be hit-tested with a plain rectangle.
*
* Static helpers no state, no scene bookkeeping:
*
* CyberShape.draw(panel, 320, 52, { fill: 0x0a1120, stroke: 0x00e5ff, ... });
* CyberShape.frame(scene, w/2, h/2, w, h, { color: neon, alpha: 0.35 });
*/
export class CyberShape {
/**
* The cut-corner polygon, centered on (0,0). `notch` is the size of
* each corner slice (clamped so it can never eat the whole shape).
*/
static points(w, h, notch) {
const n = Math.max(0, Math.min(notch ?? 0, w / 2, h / 2));
const x = w / 2;
const y = h / 2;
return [
{ x: -x + n, y: -y },
{ x, y: -y },
{ x, y: y - n },
{ x: x - n, y },
{ x: -x, y },
{ x: -x, y: -y + n },
];
}
/**
* Draw a cut-corner panel into an existing Graphics (at its origin).
*
* o: {
* notch, // corner slice size (px); default min(14, h*0.28)
* fill, fillAlpha, // panel fill (color int)
* stroke, strokeAlpha, // edge (color int)
* lineWidth, // edge width
* glow, glowAlpha, // optional soft outer pass (color int)
* }
* Omit `fill`/`stroke` to draw only the part you gave.
*/
static draw(g, w, h, o = {}) {
const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28));
if (o.fill !== undefined) {
g.fillStyle(o.fill, o.fillAlpha ?? 1);
g.fillPoints(pts, true);
}
if (o.stroke !== undefined) {
g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
g.strokePoints(pts, true);
}
if (o.glow !== undefined) {
g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.25);
g.strokePoints(pts, true);
}
}
/**
* A new cut-corner panel Graphics centered at (x, y) in the scene.
* Same options as draw(). Returns the Graphics.
*/
static panel(scene, x, y, w, h, o = {}) {
const g = scene.add.graphics().setPosition(x, y);
CyberShape.draw(g, w, h, o);
return g;
}
/**
* HUD corner brackets + edge ticks framing a rect centered at (x, y)
* the viewfinder chrome around the console.
*
* o: { color, alpha, length, lineWidth, ticks }
*/
static frame(scene, x, y, w, h, o = {}) {
const color = o.color ?? 0x00e5ff;
const alpha = o.alpha ?? 0.5;
const length = o.length ?? 26;
const lw = o.lineWidth ?? 2;
const g = scene.add.graphics().setPosition(x, y);
g.lineStyle(lw, color, alpha);
const hw = w / 2;
const hh = h / 2;
const corner = (cx, cy, sx, sy) => {
g.lineBetween(cx, cy, cx, cy + sy * length);
g.lineBetween(cx, cy, cx + sx * length, cy);
};
corner(-hw, -hh, 1, 1);
corner(hw, -hh, -1, 1);
corner(-hw, hh, 1, -1);
corner(hw, hh, -1, -1);
if (o.ticks !== false) {
g.lineStyle(lw, color, alpha * 0.7);
const t = 7;
g.lineBetween(0, -hh, 0, -hh + t);
g.lineBetween(0, hh, 0, hh - t);
g.lineBetween(-hw, 0, -hw + t, 0);
g.lineBetween(hw, 0, hw - t, 0);
}
return g;
}
}

105
js/ui/GlitchText.js Normal file
View File

@ -0,0 +1,105 @@
import Phaser from '../vendor/phaser.js';
import { toCss } from '../utils/Color.js';
/**
* A header title with the RGB pull-apart of the glitch theme: the main
* text plus a magenta-red ghost offset left and a cyan ghost offset
* right, both additive-blended so the fringes glow on the dark bg.
*
* Motion model:
* - IDLE a slow shimmer keeps the split alive (a ~1.5 px wobble);
* - BURST setBurst(level 0..1) pushes the ghosts apart and adds
* high-frequency jitter; the level decays on its own (~90 ms).
* Usually driven by CyberOverlay's glitch bursts:
* overlay.onGlitch((level) => title.setBurst(level));
* - BOOT start(time) plays the flicker-in once.
*
* The scene must call update(time, delta) from its own update loop
* (Phaser v4 does not auto-update plain objects).
*/
export class GlitchText extends Phaser.GameObjects.Container {
constructor(scene, x, y, text, style, o = {}) {
super(scene, x, y);
this.level = 0; // current burst level 0..1 (decays in update)
this.baseOffset = o.baseOffset ?? 1.5; // px of idle RGB split
this.burstOffset = o.burstOffset ?? 12; // extra px of split at level 1
this.ghostAlpha = o.ghostAlpha ?? 0.55;
this.bootT0 = null;
this.bootDur = o.bootDuration ?? 650;
const main = scene.add.text(0, 0, text, style).setOrigin(0.5);
const ghostStyle = { ...style, shadow: undefined };
// v4 quirk: canvas fillStyle needs CSS strings — normalize (toCss is
// a pass-through for strings, see utils/Color.js).
const redColor = toCss(o.redColor ?? '#ff2d6f');
const cyanColor = toCss(o.cyanColor ?? '#00e5ff');
const ghostRed = scene
.add.text(0, 0, text, { ...ghostStyle, color: redColor })
.setOrigin(0.5)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
const ghostCyan = scene
.add.text(0, 0, text, { ...ghostStyle, color: cyanColor })
.setOrigin(0.5)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
this.main = main;
this.ghostRed = ghostRed;
this.ghostCyan = ghostCyan;
// ghosts first, main on top
this.add([ghostRed, ghostCyan, main]);
this.setSize(main.width, main.height);
// Phaser v4: a directly-constructed GameObject is NOT added to the scene's
// display list (the scene.add.* factories do that) — register it here or it
// never renders. Verified Sept 2026 against lib/phaser.min.js (4.2.1 Giedi).
this.scene.add.existing(this);
}
/** Feed a burst level (0..1); it decays automatically. */
setBurst(level) {
this.level = Math.max(this.level, Math.min(1, Math.max(0, level ?? 0)));
}
/** Arm the boot flicker (time = scene time, e.g. this.time.now). */
start(time) {
this.bootT0 = time;
}
update(time, delta) {
// Boot flicker: a few hard on/off frames, then settle.
if (this.bootT0 !== null) {
const u = (time - this.bootT0) / this.bootDur;
if (u >= 1) {
this.bootT0 = null;
this.main.setAlpha(1);
this.setScale(1);
} else if (u >= 0) {
this.main.setAlpha(Math.sin(u * 70) > -0.35 ? 1 : 0.08);
this.setScale(1.06 - 0.06 * Math.min(1, u * 1.6));
}
}
// Burst level decays (~90 ms time constant).
this.level *= Math.exp(-(delta / 90));
if (this.level < 0.002) this.level = 0;
const t = time * 0.001;
// Idle shimmer + burst jitter (deterministic pseudo-noise from sines).
const idle = 0.65 + 0.35 * Math.sin(t * 1.9);
const off = this.baseOffset * idle + this.level * this.burstOffset;
const jx = this.level * 6 * Math.sin(time * 0.053) * Math.sin(time * 0.021 + 1.7);
const jy = this.level * 4 * Math.sin(time * 0.083 + 0.4);
this.ghostRed.setPosition(-off + jx, jy * 0.5);
this.ghostCyan.setPosition(off * 0.85, -off * 0.3 + jy);
const flick = 0.6 + 0.4 * Math.sin(t * 3.3);
const alpha = Math.min(1, this.ghostAlpha * flick + this.level * 0.5);
this.ghostRed.setAlpha(alpha);
this.ghostCyan.setAlpha(alpha);
}
}

View File

@ -1,57 +1,172 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* Reusable text button with hover state.
* The cyberpunk text button: a cut-corner panel (CyberShape), a neon
* edge, a light-streak sweep on hover, an RGB-split ghost on the label
* while hovered, and a white flash + scale punch on click.
*
* Colors/sizes come from data/menu.json (menu.colors + per-button overrides),
* so adding a new button elsewhere is a one-liner:
* Drop-in for the old button same constructor:
*
* new MenuButton(scene, x, y, 'Save', () => ..., { bgColor: '#334' });
* new MenuButton(scene, x, y, 'New Game', () => ..., { fontSize: 14 });
*
* Colors/sizes come from data/menu.json (menu.colors + per-button
* overrides) with the palette in data/theme.json underneath.
*/
export class MenuButton extends Phaser.GameObjects.Container {
constructor(scene, x, y, label, onClick, overrides = {}) {
constructor(scene, x, y, label, onClick, o = {}) {
super(scene, x, y);
const colors = config.section('menu.colors', {});
const fontFamily = overrides.fontFamily ?? config.get('menu.fontFamily', FONT_FALLBACK);
const fontSize = overrides.fontSize ?? config.get('menu.buttonFontSize', 22);
const paddingX = overrides.paddingX ?? 32;
const paddingY = overrides.paddingY ?? 16;
const menuColors = config.section('menu.colors', {});
const fontFamily = o.fontFamily ?? fontStack('body', FONT_FALLBACK);
const fontSize = o.fontSize ?? config.get('menu.buttonFontSize', 22);
const paddingX = o.paddingX ?? 34;
const paddingY = o.paddingY ?? 14;
const upper = o.upper ?? config.get('menu.buttons.upper', true);
const letterSpacing = o.letterSpacing ?? 2;
const baseColor = toColor(overrides.bgColor ?? colors.buttonBg ?? '#16203a');
const hoverColor = toColor(overrides.hoverColor ?? colors.buttonHoverBg ?? '#24345c');
const border = toColor(colors.buttonBorder ?? '#33456f');
const textColor = overrides.textColor ?? colors.buttonText ?? '#e9edf8';
const fill = toColor(o.bgColor ?? menuColors.buttonBg ?? '#0a1120');
const hoverFill = toColor(o.hoverColor ?? menuColors.buttonHoverBg ?? '#13233f');
const stroke = toColor(menuColors.buttonBorder ?? '#2aa9c9');
const neon = themeColor('neon', 0x00e5ff);
const neon2 = themeColor('neon2', 0xff2d6f);
const ink = toColor(o.textColor ?? menuColors.buttonText ?? '#eaf6ff');
const text = scene.add.text(0, 0, label, {
// v4 quirk: Text styles go straight to canvas fillStyle — must be CSS
// strings, or the text renders black (see toCss in utils/Color.js).
const neonCss = toCss(neon);
const neon2Css = toCss(neon2);
const inkCss = toCss(ink);
const labelText = upper ? label.toUpperCase() : label;
const textStyle = {
fontFamily,
fontSize: `${fontSize}px`,
fontStyle: '600',
color: textColor,
color: inkCss,
letterSpacing,
};
const w0 = scene.add.text(0, 0, labelText, textStyle);
const textW = w0.width;
const textH = w0.height;
w0.destroy();
const width = textW + paddingX * 2;
const height = textH + paddingY * 2;
const notch = Math.min(12, height * 0.3);
// Panel (redrawn on state changes — that's the whole styling system).
this.panel = scene.add.graphics().setPosition(0, 0);
// RGB-split ghosts behind the label (additive), revealed on hover.
this.ghostCyan = scene.add.text(0, 0, labelText, { ...textStyle, color: neonCss })
.setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD);
this.ghostMagenta = scene.add.text(0, 0, labelText, { ...textStyle, color: neon2Css })
.setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD);
// Light streak that sweeps across the panel on hover.
this.sweep = scene.add.rectangle(0, 0, 26, Math.max(6, height - 10), neon, 0).setOrigin(0.5);
this.labelText = scene.add.text(0, 0, labelText, textStyle).setOrigin(0.5);
this.add([this.panel, this.ghostCyan, this.ghostMagenta, this.sweep, this.labelText]); this.setSize(width, height);
// Phaser v4: a directly-constructed GameObject is NOT added to the scene's
// display list (the scene.add.* factories do that) — register it here or it
// never renders. Verified Sept 2026 against lib/phaser.min.js (4.2.1 Giedi).
this.scene.add.existing(this);
this.style = { width, height, notch, fill, hoverFill, stroke, neon, neon2, ink, inkCss, labelText, textStyle };
this.hoverOn = false;
this.pressing = false;
this._sweepTween = null;
this.paint('base');
// Hit-test the whole rect with an explicit area (independent of the
// Graphics' draw state, so repainting never breaks interaction).
this.panel.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
const width = text.width + paddingX * 2;
const height = text.height + paddingY * 2;
const bg = scene
.add.rectangle(0, 0, width, height, baseColor, 1)
.setOrigin(0.5)
.setStrokeStyle(1, border, 0.9);
this.add([bg, text]);
this.setSize(width, height);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(hoverColor, 1));
bg.on('pointerout', () => bg.setFillStyle(baseColor, 1));
bg.on('pointerdown', () => {
this.panel.on('pointerover', () => this.hover(true));
this.panel.on('pointerout', () => this.hover(false));
this.panel.on('pointerdown', () => {
this.press();
if (typeof onClick === 'function') onClick(this);
});
}
scene.add.existing(this);
/** Repaint the panel for a visual state: 'base' | 'hover'. */
paint(state) {
const s = this.style;
const hover = state === 'hover';
this.panel.clear();
CyberShape.draw(this.panel, s.width, s.height, {
notch: s.notch,
fill: hover ? s.hoverFill : s.fill,
fillAlpha: hover ? 0.92 : 0.72,
stroke: hover ? s.neon : s.stroke,
strokeAlpha: hover ? 1 : 0.8,
lineWidth: 1.5,
glow: hover ? s.neon : undefined,
glowAlpha: 0.22,
});
this.ghostCyan.setAlpha(hover ? 0.8 : 0).setPosition(-2.2, 0);
this.ghostMagenta.setAlpha(hover ? 0.8 : 0).setPosition(2.2, 0);
// v4: Text.setColor() stores the value verbatim for the canvas fillStyle —
// CSS string only.
this.labelText.setColor(hover ? '#ffffff' : s.inkCss);
}
hover(on) {
this.hoverOn = on;
this.paint(on ? 'hover' : 'base');
if (this._sweepTween) {
this._sweepTween.remove();
this._sweepTween = null;
}
if (on) {
const half = this.style.width / 2 - 16;
this.sweep.setX(-half).setAlpha(0.4);
this._sweepTween = this.scene.tweens.add({
targets: this.sweep,
x: half,
duration: 380,
ease: 'Sine.easeOut',
onComplete: () => this.sweep.setAlpha(0),
});
} else {
this.sweep.setAlpha(0);
}
}
/** Click feedback: white flash + scale punch, then restore. */
press() {
if (this.pressing) return;
this.pressing = true;
const s = this.style;
this.panel.clear();
CyberShape.draw(this.panel, s.width, s.height, {
notch: s.notch,
fill: 0xdff6ff,
fillAlpha: 0.92,
stroke: 0xffffff,
strokeAlpha: 1,
lineWidth: 1.5,
});
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
this.scene.time.delayedCall(120, () => {
this.pressing = false;
if (this.active !== false) this.paint(this.hoverOn ? 'hover' : 'base');
});
}
}

View File

@ -14,3 +14,17 @@ export function toColor(value, fallback = 0xffffff) {
}
return fallback;
}
/**
* Converts a color (number or CSS string) to a '#rrggbb' CSS string.
*
* Phaser v4 (4.2.1 Giedi) quirk: Text styles are written straight to the
* canvas `fillStyle = style.color` so a numeric color (e.g. 0xeaf6ff)
* is an *invalid* fillStyle and the text silently renders black. Canvas-
* backed text (add.text / TextStyle) must therefore always get CSS strings;
* keep toColor for Graphics/shape APIs that want numbers.
*/
export function toCss(value, fallback = '#ffffff') {
const n = toColor(value, toColor(fallback));
return '#' + (n >>> 0).toString(16).padStart(6, '0');
}

19
js/utils/Textures.js Normal file
View File

@ -0,0 +1,19 @@
/**
* Generate small canvas textures (scanlines, vignette, glow, ) and
* register them with the scene's TextureManager.
*
* Cached by key: a second call with the same key reuses the existing
* texture, so multiple overlays/scenes share one.
*
* canvasTexture(scene, '__glow_cyan', 256, 256, (ctx, w, h) => { ... });
*/
export function canvasTexture(scene, key, w, h, draw) {
if (scene.textures.exists(key)) return key;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w));
canvas.height = Math.max(1, Math.round(h));
const ctx = canvas.getContext('2d');
draw(ctx, canvas.width, canvas.height);
scene.textures.addCanvas(key, canvas);
return key;
}

36
js/utils/Theme.js Normal file
View File

@ -0,0 +1,36 @@
import { config } from '../config/Config.js';
import { toColor } from './Color.js';
/**
* Helpers for the shared visual theme (data/theme.json).
*
* The theme is the game's UI language fonts, palette, and CRT/glitch
* tuning so every scene reads from here instead of hardcoding looks:
*
* import { fontStack, themeColor } from '../utils/Theme.js';
* const titleStyle = { fontFamily: fontStack('header'), ... };
* const neon = themeColor('neon', 0x00e5ff);
*/
/**
* Canvas-ready CSS font stack for a theme font kind ('header' | 'body').
*
* fontStack('header') "'Ethnocentric', 'Arial Black', 'Impact', sans-serif"
*
* Falls back to a plain system stack if the theme section is missing
* (e.g. a config that predates theme.json).
*/
export function fontStack(kind = 'body') {
const f = config.get(`theme.fonts.${kind}`);
if (f && typeof f.family === 'string' && f.family.length > 0) {
const family = /[^a-z0-9-]/i.test(f.family) ? `'${f.family}'` : f.family;
const fallbacks = Array.isArray(f.cssFallbacks) ? f.cssFallbacks : [];
return [family, ...fallbacks, 'sans-serif'].join(', ');
}
return "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
}
/** A theme palette color as an int: themeColor('neon', 0x00e5ff) */
export function themeColor(name, fallback = 0xffffff) {
return toColor(config.get(`theme.colors.${name}`, undefined), fallback);
}

268
js/visuals/CyberOverlay.js Normal file
View File

@ -0,0 +1,268 @@
import { canvasTexture } from '../utils/Textures.js';
const T = {
scan: '__cyber_scan',
grid: '__cyber_grid',
vignette: '__cyber_vignette',
sweep: '__cyber_sweep',
};
/**
* The shared cyberpunk/CRT dressing of the UI: a faint tech grid,
* scanlines, a slow scan-sweep band, a vignette and periodic RGB
* glitch bursts (full-width slice bars plus a level signal that glitch
* text listens to and blows its RGB split apart).
*
* Purely decorative: non-interactive, screen-fixed (scrollFactor 0),
* drawn above everything (depth 5000+).
*
* A scene owns one instance and drives it from its own update loop
* (Phaser v4 does not auto-update plain objects):
*
* this.overlay = new CyberOverlay(this, cfg);
* this.overlay.onGlitch((level) => this.title.setBurst(level));
* update(time, delta) { this.overlay.update(time, delta); }
* shutdown() { this.overlay.destroy(); }
*
* cfg shape (all optional sensible defaults fill the gaps; the menu
* and game scenes build their cfg from data/theme.json):
* {
* depth: 5000,
* scanline: { pitch: 3, alpha: 0.14 },
* grid: { cell: 48, alpha: 0.05 },
* vignette: { alpha: 0.5 },
* sweep: { alpha: 0.09, duration: 9000, band: 140 },
* glitch: { enabled: true, interval: [2200, 6800], duration: [110, 340],
* slices: [2, 6] },
* }
*/
export class CyberOverlay {
constructor(scene, cfg = {}) {
this.scene = scene;
this.cfg = {
depth: 5000,
scanline: { pitch: 3, alpha: 0.14, ...(cfg.scanline ?? {}) },
grid: { cell: 48, alpha: 0.05, ...(cfg.grid ?? {}) },
vignette: { alpha: 0.5, ...(cfg.vignette ?? {}) },
sweep: { alpha: 0.09, duration: 9000, band: 140, ...(cfg.sweep ?? {}) },
glitch: {
enabled: true,
interval: [2200, 6800],
duration: [110, 340],
slices: [2, 6],
...(cfg.glitch ?? {}),
},
};
const { width: w, height: h } = scene.scale;
const d = this.cfg.depth;
// ---- generated textures (cached by key, shared across overlays) ----
const pitch = Math.max(2, Math.round(this.cfg.scanline.pitch));
canvasTexture(scene, T.scan, 1, pitch, (ctx) => {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, pitch - 1, 1, 1); // one dark line every `pitch` rows
});
const cell = Math.max(16, Math.round(this.cfg.grid.cell));
canvasTexture(scene, T.grid, cell, cell, (ctx) => {
ctx.strokeStyle = 'rgba(0,229,255,0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cell - 0.5, 0);
ctx.lineTo(cell - 0.5, cell);
ctx.moveTo(0, cell - 0.5);
ctx.lineTo(cell, cell - 0.5);
ctx.stroke();
});
canvasTexture(scene, T.vignette, 256, 256, (ctx) => {
const g = ctx.createRadialGradient(128, 128, 60, 128, 128, 182);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.72, 'rgba(0,0,0,0.22)');
g.addColorStop(1, 'rgba(0,0,0,0.85)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 256, 256);
});
canvasTexture(scene, T.sweep, 64, 160, (ctx) => {
const g = ctx.createLinearGradient(0, 0, 0, 160);
g.addColorStop(0, 'rgba(0,229,255,0)');
g.addColorStop(0.5, 'rgba(0,229,255,0.55)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 160);
});
// ---- layers (order = depth: grid < scanlines < sweep < vignette < slices)
this.grid = scene
.add.tileSprite(w / 2, h / 2, w, h, T.grid)
.setScrollFactor(0)
.setDepth(d)
.setAlpha(this.cfg.grid.alpha);
this.scanlines = scene
.add.tileSprite(w / 2, h / 2, w, h, T.scan)
.setScrollFactor(0)
.setDepth(d + 1)
.setAlpha(this.cfg.scanline.alpha);
const band = Math.max(20, Math.round(this.cfg.sweep.band));
this.sweepBand = band;
this.sweep = scene
.add.image(0, 0, T.sweep)
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(d + 2)
.setAlpha(this.cfg.sweep.alpha)
.setDisplaySize(w, band);
this.vignette = scene
.add.image(w / 2, h / 2, T.vignette)
.setScrollFactor(0)
.setDepth(d + 3)
.setAlpha(this.cfg.vignette.alpha);
// ---- glitch state
this.depthSlices = d + 4;
this.burstT0 = null;
this.burstDur = 0;
this.burstIntensity = 1;
this.nextBurstAt = null; // set on first update (~0.9 s after scene start)
this.lastTime = null;
this.listeners = [];
this.slices = []; // { g, die }
}
/**
* Subscribe to glitch bursts: fn(level 0..1, time).
* @returns {() => void} an unsubscribe function
*/
onGlitch(fn) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((f) => f !== fn);
};
}
/** Fire a glitch burst right now (e.g. on a button click / scene entry). */
trigger(duration = 220, intensity = 1) {
const now = this.lastTime ?? this.scene.time.now;
this.burstT0 = now;
this.burstDur = Math.max(1, duration);
this.burstIntensity = intensity;
this.nextBurstAt = now + this.randRange(this.cfg.glitch.interval) + this.randRange(this.cfg.glitch.duration);
this.spawnSlices();
this.notify(intensity, now);
}
/** Drive the sweep + glitch lifecycle. Call once per frame. */
update(time, delta) {
this.lastTime = time;
const { width: w, height: h } = this.scene.scale;
// Scan sweep: a bright band drifting top → bottom, forever.
const s = this.cfg.sweep;
if (s && s.duration > 0 && this.sweep) {
const p = ((time % s.duration) / s.duration + 1) % 1;
this.sweep.setY(this.sweepBand / 2 + p * (h - this.sweepBand));
}
// Glitch bursts: schedule → fire → decay.
const g = this.cfg.glitch;
if (g && g.enabled !== false) {
if (this.nextBurstAt === null) {
this.nextBurstAt = time + 800 + Math.random() * 700; // first one early
}
if (this.burstT0 !== null) {
const el = time - this.burstT0;
if (el >= this.burstDur) {
this.burstT0 = null; // burst over
} else {
const level = (1 - el / this.burstDur) * this.burstIntensity;
this.notify(Math.max(0, Math.min(1, level)), time);
}
} else if (time >= this.nextBurstAt) {
this.burstT0 = time;
this.burstDur = this.randRange(g.duration);
this.burstIntensity = 0.8 + Math.random() * 0.4;
this.nextBurstAt = time + this.burstDur + this.randRange(g.interval);
this.spawnSlices();
this.notify(1, time);
}
}
// Reap expired slice bars.
if (this.slices.length > 0) {
this.slices = this.slices.filter((s) => {
if (time >= s.die) {
s.g.destroy();
return false;
}
return true;
});
}
}
/**
* The "pull-apart" itself: a handful of thin full-width bars in neon
* cyan / magenta / white / bg, each shifted a few px sideways
* classic signal-loss slicing across the frame.
*/
spawnSlices() {
const { width: w, height: h } = this.scene.scale;
const g = this.cfg.glitch.slices;
const n = Math.round(this.range(g[0] ?? 2, g[1] ?? 6));
const palette = [0x00e5ff, 0xff2d6f, 0xeaf6ff, 0x04060d];
const bars = this.scene.add.graphics().setScrollFactor(0).setDepth(this.depthSlices);
for (let i = 0; i < n; i++) {
const y = Math.random() * h;
const barH = 1 + Math.random() * 13;
const dx = (Math.random() * 2 - 1) * 14;
const color = palette[(Math.random() * palette.length) | 0];
const alpha = 0.06 + Math.random() * 0.18;
bars.fillStyle(color, alpha);
bars.fillRect(-w / 2 + dx - 24, y, w + 48, barH);
}
// One wider "displacement band" so the burst reads at a glance.
const bandY = Math.random() * h;
bars.fillStyle(0x04060d, 0.55);
bars.fillRect(-w / 2 - 24, bandY, w + 48, 14 + Math.random() * 22);
bars.fillStyle(0x00e5ff, 0.22);
bars.fillRect(-w / 2 - 24, bandY - 2, w + 48, 2);
const die = (this.lastTime ?? this.scene.time.now) + this.burstDur + 60;
this.slices.push({ g: bars, die });
}
notify(level, time) {
for (const fn of this.listeners) {
try {
fn(level, time);
} catch (err) {
console.error('[overlay] glitch listener failed', err);
}
}
}
destroy() {
this.listeners.length = 0;
for (const s of this.slices) s.g.destroy();
this.slices.length = 0;
this.grid?.destroy();
this.scanlines?.destroy();
this.sweep?.destroy();
this.vignette?.destroy();
this.grid = this.scanlines = this.sweep = this.vignette = null;
}
randRange([lo, hi]) {
if (!Array.isArray(lo)) return lo;
return lo + Math.random() * (hi - lo);
}
range(lo, hi) {
return lo + Math.random() * (hi - lo);
}
}