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:
parent
02d689e1bb
commit
6ba71620b4
|
|
@ -63,12 +63,14 @@ orbit/
|
||||||
│ ├── manifest.json # which config files exist
|
│ ├── manifest.json # which config files exist
|
||||||
│ ├── game.json # dimensions, colors, starfield, …
|
│ ├── game.json # dimensions, colors, starfield, …
|
||||||
│ ├── menu.json # menu text, colors, button layout, seed panel
|
│ ├── 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, …
|
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
|
||||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
||||||
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
||||||
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
||||||
│ └── naming.json # syllable pools for names
|
│ └── naming.json # syllable pools for names
|
||||||
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
|
├── 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)
|
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── main.js # entry point: load config → boot Phaser
|
│ ├── main.js # entry point: load config → boot Phaser
|
||||||
|
|
@ -76,8 +78,8 @@ orbit/
|
||||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||||
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
|
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
|
||||||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
|
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
|
||||||
│ ├── ui/ # MenuButton (reusable)
|
│ ├── ui/ # MenuButton, GlitchText, CyberShape (reusable)
|
||||||
│ ├── visuals/ # Starfield (decorative)
|
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
|
||||||
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
||||||
│ └── vendor/ # shim to the vendored Phaser
|
│ └── vendor/ # shim to the vendored Phaser
|
||||||
└── docs/PROJECT_NOTES.md # ← project conventions: read this
|
└── 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 |
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"game.json",
|
"game.json",
|
||||||
|
"theme.json",
|
||||||
"menu.json",
|
"menu.json",
|
||||||
"ship.json",
|
"ship.json",
|
||||||
"planets.json",
|
"planets.json",
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,54 @@
|
||||||
{
|
{
|
||||||
"title": "ORBIT",
|
"title": "ORBIT",
|
||||||
"titleFontSize": 84,
|
"titleFontSize": 118,
|
||||||
"subtitle": "a procedural space rpg",
|
"titleLetterSpacing": 12,
|
||||||
|
"subtitle": "A PROCEDURAL SPACE RPG",
|
||||||
"subtitleFontSize": 20,
|
"subtitleFontSize": 20,
|
||||||
"fontFamily": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
|
"subtitleLetterSpacing": 8,
|
||||||
"buttonFontSize": 24,
|
"buttonFontSize": 26,
|
||||||
"colors": {
|
|
||||||
"title": "#e9edf8",
|
|
||||||
"titleGlow": "#2f6df6",
|
|
||||||
"subtitle": "#8fa0c9",
|
|
||||||
"buttonText": "#e9edf8",
|
|
||||||
"buttonBg": "#16203a",
|
|
||||||
"buttonHoverBg": "#24345c",
|
|
||||||
"buttonBorder": "#33456f",
|
|
||||||
"footer": "#54608a"
|
|
||||||
},
|
|
||||||
"buttons": {
|
"buttons": {
|
||||||
|
"upper": true,
|
||||||
"newGame": {
|
"newGame": {
|
||||||
"label": "New Game",
|
"label": "New Game",
|
||||||
"position": { "x": 0.5, "y": 0.60 },
|
"position": { "x": 0.5, "y": 0.585 },
|
||||||
"fontSize": 24
|
"fontSize": 26,
|
||||||
|
"paddingX": 46,
|
||||||
|
"paddingY": 18
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"seed": {
|
"seed": {
|
||||||
"label": "GALAXY SEED",
|
"label": "GALAXY SEED",
|
||||||
"position": { "x": 0.5, "y": 0.755 },
|
"position": { "x": 0.5, "y": 0.755 },
|
||||||
"labelFontSize": 12,
|
"labelFontSize": 13,
|
||||||
"fontSize": 24,
|
"fontSize": 25,
|
||||||
"fieldWidth": 300,
|
"fieldWidth": 320,
|
||||||
"fieldHeight": 46,
|
"fieldHeight": 52,
|
||||||
"rerollLabel": "reroll",
|
"rerollLabel": "Reroll",
|
||||||
"rerollFontSize": 14,
|
"rerollFontSize": 14,
|
||||||
"colors": {
|
"colors": {
|
||||||
"label": "#54608a",
|
"label": "#5c74a8",
|
||||||
"value": "#e9edf8",
|
"value": "#eaf6ff",
|
||||||
"fieldBg": "#0b1226",
|
"fieldBg": "#070d1a",
|
||||||
"border": "#2a3a63",
|
"border": "#22405f",
|
||||||
"activeBorder": "#41c7ff",
|
"activeBorder": "#00e5ff",
|
||||||
"hint": "#54608a"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -151,7 +151,24 @@ unclaimed". Model & seams:
|
||||||
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
||||||
- App code is v4-specific where v4 changed things (e.g.
|
- App code is v4-specific where v4 changed things (e.g.
|
||||||
`Phaser.Math.Angle.RotateTo`, `banner: false`, `Clamp(value, min, max)`).
|
`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)
|
## Roadmap (working list, intentionally rough)
|
||||||
|
|
||||||
|
|
|
||||||
16
index.html
16
index.html
|
|
@ -5,6 +5,22 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Orbit</title>
|
<title>Orbit</title>
|
||||||
<style>
|
<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 {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
22
js/main.js
22
js/main.js
|
|
@ -13,9 +13,31 @@ import { GameScene } from './scenes/GameScene.js';
|
||||||
* 3. Build the Phaser game config and attach the scenes
|
* 3. Build the Phaser game config and attach the scenes
|
||||||
* 4. Boot
|
* 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() {
|
async function boot() {
|
||||||
const data = await ConfigLoader.load();
|
const data = await ConfigLoader.load();
|
||||||
config.init(data);
|
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();
|
const gameConfig = createGameConfig();
|
||||||
gameConfig.scene = [MenuScene, GameScene];
|
gameConfig.scene = [MenuScene, GameScene];
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.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 { Rng } from '../utils/Rng.js';
|
||||||
import { Galaxy } from '../galaxy/Galaxy.js';
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||||||
import { Ship } from '../entities/Ship.js';
|
import { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
|
import { CyberOverlay } from '../visuals/CyberOverlay.js';
|
||||||
|
|
||||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
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 —
|
* 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.cameraFollowShip = config.get('game.camera.followShip', true);
|
||||||
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
|
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
|
// We are, after all, in a system. Show the player which one. (This
|
||||||
// also establishes the galaxy — and its seed — for what follows.)
|
// also establishes the galaxy — and its seed — for what follows.)
|
||||||
this.createSystemHud();
|
this.createSystemHud();
|
||||||
|
|
@ -76,9 +88,10 @@ export class GameScene extends Phaser.Scene {
|
||||||
// Hint
|
// Hint
|
||||||
this.hint = this.add
|
this.hint = this.add
|
||||||
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
||||||
fontFamily: FONT_FALLBACK,
|
fontFamily: BODY_FONT(),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#54608a',
|
color: '#54608a',
|
||||||
|
letterSpacing: 1,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
.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 current = this.galaxy.currentSystem();
|
||||||
const content = this.galaxy.ensureContent(current.id);
|
const content = this.galaxy.ensureContent(current.id);
|
||||||
const report = formatSystemReport(content);
|
const report = formatSystemReport(content);
|
||||||
const fam = FONT_FALLBACK;
|
const fam = BODY_FONT();
|
||||||
|
const famHeader = HEADER_FONT();
|
||||||
|
|
||||||
let y = 14;
|
let y = 14;
|
||||||
const line = (text, style) => {
|
const line = (text, style) => {
|
||||||
|
|
@ -133,21 +147,54 @@ export class GameScene extends Phaser.Scene {
|
||||||
};
|
};
|
||||||
|
|
||||||
line(report.title, {
|
line(report.title, {
|
||||||
fontFamily: fam,
|
fontFamily: famHeader,
|
||||||
fontSize: '17px',
|
fontSize: '17px',
|
||||||
fontStyle: 'bold',
|
// v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js).
|
||||||
color: toColor(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
|
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;
|
y += 2;
|
||||||
for (const s of report.settlements) {
|
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(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
|
||||||
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
|
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) {
|
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);
|
this.ship.update(_time, delta);
|
||||||
// The home world is solid: the ship may come within the clearance in
|
// 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).
|
// data/planets.json of its rim, but never closer (or through it).
|
||||||
|
|
@ -206,5 +253,6 @@ export class GameScene extends Phaser.Scene {
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
|
this.overlay?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,38 @@
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.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 { 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 { Rng } from '../utils/Rng.js';
|
||||||
import { NameGenerator } from '../utils/NameGenerator.js';
|
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||||
import { Galaxy } from '../galaxy/Galaxy.js';
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
||||||
|
|
||||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
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_STORAGE_KEY = 'orbit.galaxySeed';
|
||||||
const SEED_MAX = 32;
|
const SEED_MAX = 32;
|
||||||
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
|
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 Galaxy Seed panel is where a new universe is chosen:
|
||||||
* - the seed is displayed, editable (click it, type; Backspace; Enter to
|
* - the seed is displayed, editable (click it, type; Backspace; Enter
|
||||||
* commit), and rerollable;
|
* to commit), and rerollable — it "decodes" in with a scramble;
|
||||||
* - the same seed always builds the same galaxy (Galaxy.create), and the
|
* - the same seed always builds the same galaxy (Galaxy.create), and
|
||||||
* panel shows what that seed contains before you commit;
|
* the panel shows what that seed contains before you commit;
|
||||||
* - "New Game" builds the galaxy roster from the seed and hands it to the
|
* - "New Game" builds the galaxy roster from the seed, glitches out,
|
||||||
* rest of the game via the shared registry.
|
* and hands it to the rest of the game via the shared registry.
|
||||||
*/
|
*/
|
||||||
export class MenuScene extends Phaser.Scene {
|
export class MenuScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -30,54 +41,165 @@ export class MenuScene extends Phaser.Scene {
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const menu = config.section('menu', {});
|
const menu = config.section('menu', {});
|
||||||
const fontFamily = menu.fontFamily ?? FONT_FALLBACK;
|
const theme = config.section('theme', {});
|
||||||
const colors = menu.colors ?? {};
|
const colors = menu.colors ?? {};
|
||||||
const { width, height } = this.scale;
|
const chrome = menu.chrome ?? {};
|
||||||
const cx = width / 2;
|
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
|
const { width: w, height: h } = this.scale;
|
||||||
this.add
|
const cx = w / 2;
|
||||||
.text(cx, height * 0.34, menu.title ?? 'ORBIT', {
|
|
||||||
fontFamily,
|
this.dead = false;
|
||||||
fontSize: `${menu.titleFontSize ?? 84}px`,
|
this.decode = null;
|
||||||
fontStyle: 'bold',
|
|
||||||
color: colors.title ?? '#e9edf8',
|
// ---- 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: {
|
shadow: {
|
||||||
color: colors.titleGlow ?? '#2f6df6',
|
color: toCss(colors.titleGlow ?? neon),
|
||||||
blur: 28,
|
blur: 30,
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 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
|
// Title bloom: two offset radial glows (cyan core, magenta fringe).
|
||||||
this.add
|
this.titleBloom = new TitleBloom(
|
||||||
.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(
|
|
||||||
this,
|
this,
|
||||||
(btn.position?.x ?? 0.5) * width,
|
cx,
|
||||||
(btn.position?.y ?? 0.60) * height,
|
titleY,
|
||||||
btn.label ?? 'New Game',
|
config.get('theme.colors.neon', '#00e5ff'),
|
||||||
() => this.startNewGame(),
|
config.get('theme.colors.neon2', '#ff2d6f'),
|
||||||
btn,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Galaxy seed panel
|
// ---- subtitle + rule
|
||||||
this.createSeedPanel(menu, fontFamily, cx, height);
|
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.keyboard.on('keydown', (e) => this.onSeedKey(e));
|
||||||
this.input.on('pointerdown', (pointer) => {
|
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);
|
this.setSeedFocus(true);
|
||||||
} else if (this.seedFocused) {
|
} else if (this.seedFocused) {
|
||||||
this.setSeedFocus(false);
|
this.setSeedFocus(false);
|
||||||
|
|
@ -87,101 +209,137 @@ export class MenuScene extends Phaser.Scene {
|
||||||
delay: 430,
|
delay: 430,
|
||||||
loop: true,
|
loop: true,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
if (this.dead || this.decode) return;
|
||||||
if (this.seedFocused) {
|
if (this.seedFocused) {
|
||||||
this.cursorOn = !this.cursorOn;
|
this.cursorOn = !this.cursorOn;
|
||||||
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
|
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Version footer
|
/** A scene-safe delayedCall: no-ops once we're shut down / cutting away. */
|
||||||
this.add
|
intro(ms, fn) {
|
||||||
.text(width - 16, height - 14, config.get('game.version', ''), {
|
this.time.delayedCall(ms, () => {
|
||||||
fontFamily,
|
if (!this.dead) fn();
|
||||||
fontSize: '12px',
|
});
|
||||||
color: colors.footer ?? '#54608a',
|
|
||||||
})
|
|
||||||
.setOrigin(1, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Galaxy seed panel
|
// Galaxy seed panel
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
createSeedPanel(menu, fontFamily, cx, height) {
|
createSeedPanel(menu, headerFont, bodyFont, cx, height) {
|
||||||
const cfg = menu.seed ?? {};
|
const cfg = menu.seed ?? {};
|
||||||
const colors = cfg.colors ?? {};
|
const colors = cfg.colors ?? {};
|
||||||
const fieldW = cfg.fieldWidth ?? 300;
|
const neon = themeColor('neon', 0x00e5ff);
|
||||||
const fieldH = cfg.fieldHeight ?? 46;
|
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;
|
const cy = (cfg.position?.y ?? 0.755) * height;
|
||||||
|
|
||||||
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
|
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
|
||||||
this.seedFocused = false;
|
this.seedFocused = false;
|
||||||
this.cursorOn = true;
|
this.cursorOn = true;
|
||||||
|
this.seedIntroTargets = [];
|
||||||
|
|
||||||
// Label
|
// Label: neon arrow + dim caps
|
||||||
this.add
|
const arrow = this.add
|
||||||
.text(cx, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
|
.text(cx - fieldW / 2 - 30, cy - fieldH / 2 - 10, '\u25b8', {
|
||||||
fontFamily,
|
fontFamily: bodyFont,
|
||||||
fontSize: `${cfg.labelFontSize ?? 12}px`,
|
fontSize: '13px',
|
||||||
color: colors.label ?? '#54608a',
|
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,
|
letterSpacing: 3,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5, 1);
|
.setOrigin(0, 1);
|
||||||
|
|
||||||
// Field (click to edit)
|
// Field — a cut-corner panel; click to edit.
|
||||||
this.seedFieldHit = this.add
|
this.seedPanel = this.add.graphics().setPosition(cx, cy);
|
||||||
.rectangle(cx, cy, fieldW, fieldH, toColor(colors.fieldBg ?? '#0b1226'), 1)
|
this.seedPanel.setInteractive({
|
||||||
.setStrokeStyle(1, toColor(colors.border ?? '#2a3a63'), 0.9);
|
useHandCursor: true,
|
||||||
this.seedFieldHit.setInteractive({ useHandCursor: true });
|
hitArea: new Phaser.Geom.Rectangle(-fieldW / 2, -fieldH / 2, fieldW, fieldH),
|
||||||
this.seedFieldHit.on('pointerdown', () => this.setSeedFocus(true));
|
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||||
|
});
|
||||||
|
this.seedPanel.on('pointerdown', () => this.setSeedFocus(true));
|
||||||
|
|
||||||
this.seedText = this.add
|
this.seedText = this.add
|
||||||
.text(cx - fieldW / 2 + 14, cy, '', {
|
.text(cx - fieldW / 2 + 18, cy, '', {
|
||||||
fontFamily: MONO,
|
fontFamily: headerFont,
|
||||||
fontSize: `${cfg.fontSize ?? 24}px`,
|
fontSize: `${cfg.fontSize ?? 25}px`,
|
||||||
color: colors.value ?? '#e9edf8',
|
color: toCss(colors.value ?? '#eaf6ff'),
|
||||||
|
letterSpacing: 4,
|
||||||
})
|
})
|
||||||
.setOrigin(0, 0.5);
|
.setOrigin(0, 0.5);
|
||||||
|
|
||||||
// Reroll
|
// Reroll
|
||||||
new MenuButton(
|
this.rerollBtn = new MenuButton(
|
||||||
this,
|
this,
|
||||||
cx + fieldW / 2 + 26,
|
cx + fieldW / 2 + 30,
|
||||||
cy,
|
cy,
|
||||||
cfg.rerollLabel ?? 'reroll',
|
cfg.rerollLabel ?? 'reroll',
|
||||||
() => {
|
() => {
|
||||||
|
if (this.dead) return;
|
||||||
this.seedValue = Rng.randomSeedString(8);
|
this.seedValue = Rng.randomSeedString(8);
|
||||||
this.setSeedFocus(false);
|
this.setSeedFocus(false);
|
||||||
|
this.overlay.trigger(180, 0.5);
|
||||||
|
this.startDecode(this.seedValue);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fontSize: cfg.rerollFontSize ?? 14,
|
fontSize: cfg.rerollFontSize ?? 14,
|
||||||
paddingX: 18,
|
paddingX: 16,
|
||||||
paddingY: 10,
|
paddingY: 9,
|
||||||
|
upper: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hint: what this seed will build (galaxy name + scale), live-updated.
|
// Hint: what this seed will build (galaxy name + scale), live-updated.
|
||||||
this.seedHint = this.add
|
this.seedHint = this.add
|
||||||
.text(cx, cy + fieldH / 2 + 12, '', {
|
.text(cx, cy + fieldH / 2 + 14, '', {
|
||||||
fontFamily,
|
fontFamily: bodyFont,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: colors.hint ?? '#54608a',
|
color: toCss(colors.hint ?? '#54608a'),
|
||||||
|
letterSpacing: 1,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5, 0);
|
.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();
|
this.drawSeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSeed() {
|
drawSeed() {
|
||||||
if (!this.seedText || this.seedDead) return;
|
if (!this.seedText || this.dead) return;
|
||||||
this.seedText.setText(this.seedValue + (this.seedFocused ? (this.cursorOn ? ' \u258c' : ' ') : ''));
|
|
||||||
|
|
||||||
const cfg = config.get('menu.seed.colors', {});
|
const cfg = config.get('menu.seed.colors', {});
|
||||||
const border = this.seedFocused
|
|
||||||
? toColor(cfg.activeBorder ?? '#41c7ff')
|
this.seedText.setText(
|
||||||
: toColor(cfg.border ?? '#2a3a63');
|
this.seedValue + (this.seedFocused && !this.decode ? (this.cursorOn ? ' \u258c' : ' ') : ''),
|
||||||
this.seedFieldHit.setStrokeStyle(1, border, this.seedFocused ? 1 : 0.9);
|
);
|
||||||
|
|
||||||
|
// 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 count = config.get('galaxy.systemCount', 0);
|
||||||
const archetypes = Object.keys(config.get('systems.types', {})).length;
|
const archetypes = Object.keys(config.get('systems.types', {})).length;
|
||||||
|
|
@ -194,12 +352,43 @@ export class MenuScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
setSeedFocus(focused) {
|
setSeedFocus(focused) {
|
||||||
|
if (this.dead) return;
|
||||||
this.seedFocused = !!focused;
|
this.seedFocused = !!focused;
|
||||||
this.cursorOn = true;
|
this.cursorOn = true;
|
||||||
this.drawSeed();
|
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) {
|
onSeedKey(e) {
|
||||||
|
if (this.dead) return;
|
||||||
const key =
|
const key =
|
||||||
e && typeof e.key === 'string' && e.key.length > 0
|
e && typeof e.key === 'string' && e.key.length > 0
|
||||||
? e.key
|
? e.key
|
||||||
|
|
@ -230,38 +419,67 @@ export class MenuScene extends Phaser.Scene {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
startNewGame() {
|
startNewGame() {
|
||||||
|
if (this.dead) return;
|
||||||
let seed = this.seedValue.trim();
|
let seed = this.seedValue.trim();
|
||||||
if (!seed) {
|
if (!seed) {
|
||||||
seed = Rng.randomSeedString(8);
|
seed = Rng.randomSeedString(8);
|
||||||
this.seedValue = seed;
|
this.seedValue = seed;
|
||||||
this.drawSeed();
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
this.dead = true; // no further input while we cut away
|
||||||
this.setSeedFocus(false);
|
this.setSeedFocus(false);
|
||||||
|
this.drawSeed();
|
||||||
const galaxy = Galaxy.create(seed);
|
const galaxy = Galaxy.create(seed);
|
||||||
this.registry.set('galaxy', galaxy);
|
this.registry.set('galaxy', galaxy);
|
||||||
this.registry.set('seed', seed);
|
this.registry.set('seed', seed);
|
||||||
this.saveSeed(seed);
|
this.saveSeed(seed);
|
||||||
console.info(`orbit \u2014 the galaxy of ${galaxy.name} (seed ${seed})`, galaxy.summary());
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
this.dead = false; // allow retrying
|
||||||
const msg = this.add
|
const msg = this.add
|
||||||
.text(this.scale.width / 2, this.scale.height - 64, `could not build the galaxy: ${err.message}`, {
|
.text(this.scale.width / 2, this.scale.height - 64, `// signal lost: could not build the galaxy — ${err.message}`, {
|
||||||
fontFamily: FONT_FALLBACK,
|
fontFamily: fontStack('body', FONT_FALLBACK),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#ff9b9b',
|
color: toCss(themeColor('neon2', 0xff9b9b)),
|
||||||
|
letterSpacing: 1,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
this.time.delayedCall(4000, () => msg.destroy());
|
this.time.delayedCall(4000, () => msg.destroy());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Guard for post-shutdown input events (e.g. a click that also fired
|
/** Per-frame: overlay, title glitch, status dot, seed decode. */
|
||||||
* "New Game" on the same frame). */
|
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() {
|
shutdown() {
|
||||||
this.seedDead = true;
|
this.dead = true;
|
||||||
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
|
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
|
||||||
|
this.titleBloom?.destroy();
|
||||||
|
this.overlay?.destroy();
|
||||||
|
this.title?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSavedSeed() {
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,57 +1,172 @@
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.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";
|
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),
|
* Drop-in for the old button — same constructor:
|
||||||
* so adding a new button elsewhere is a one-liner:
|
|
||||||
*
|
*
|
||||||
* 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 {
|
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);
|
super(scene, x, y);
|
||||||
|
|
||||||
const colors = config.section('menu.colors', {});
|
const menuColors = config.section('menu.colors', {});
|
||||||
const fontFamily = overrides.fontFamily ?? config.get('menu.fontFamily', FONT_FALLBACK);
|
const fontFamily = o.fontFamily ?? fontStack('body', FONT_FALLBACK);
|
||||||
const fontSize = overrides.fontSize ?? config.get('menu.buttonFontSize', 22);
|
const fontSize = o.fontSize ?? config.get('menu.buttonFontSize', 22);
|
||||||
const paddingX = overrides.paddingX ?? 32;
|
const paddingX = o.paddingX ?? 34;
|
||||||
const paddingY = overrides.paddingY ?? 16;
|
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 fill = toColor(o.bgColor ?? menuColors.buttonBg ?? '#0a1120');
|
||||||
const hoverColor = toColor(overrides.hoverColor ?? colors.buttonHoverBg ?? '#24345c');
|
const hoverFill = toColor(o.hoverColor ?? menuColors.buttonHoverBg ?? '#13233f');
|
||||||
const border = toColor(colors.buttonBorder ?? '#33456f');
|
const stroke = toColor(menuColors.buttonBorder ?? '#2aa9c9');
|
||||||
const textColor = overrides.textColor ?? colors.buttonText ?? '#e9edf8';
|
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,
|
fontFamily,
|
||||||
fontSize: `${fontSize}px`,
|
fontSize: `${fontSize}px`,
|
||||||
fontStyle: '600',
|
color: inkCss,
|
||||||
color: textColor,
|
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),
|
||||||
});
|
});
|
||||||
|
this.panel.on('pointerover', () => this.hover(true));
|
||||||
const width = text.width + paddingX * 2;
|
this.panel.on('pointerout', () => this.hover(false));
|
||||||
const height = text.height + paddingY * 2;
|
this.panel.on('pointerdown', () => {
|
||||||
|
this.press();
|
||||||
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', () => {
|
|
||||||
if (typeof onClick === 'function') onClick(this);
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,17 @@ export function toColor(value, fallback = 0xffffff) {
|
||||||
}
|
}
|
||||||
return fallback;
|
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');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue