540 lines
18 KiB
JavaScript
540 lines
18 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
|
import { config } from '../config/Config.js';
|
|
import { toColor, toCss } from '../utils/Color.js';
|
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
|
import { canvasTexture } from '../utils/Textures.js';
|
|
import { 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 { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js';
|
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
|
|
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
|
|
const SEED_MAX = 32;
|
|
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
|
|
|
|
/**
|
|
* 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 — 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() {
|
|
super({ key: 'MenuScene' });
|
|
}
|
|
|
|
create() {
|
|
const menu = config.section('menu', {});
|
|
const theme = config.section('theme', {});
|
|
const colors = menu.colors ?? {};
|
|
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);
|
|
|
|
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: toCss(colors.titleGlow ?? neon),
|
|
blur: 30,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
},
|
|
},
|
|
{ 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);
|
|
|
|
// Title bloom: two offset radial glows (cyan core, magenta fringe).
|
|
this.titleBloom = new TitleBloom(
|
|
this,
|
|
cx,
|
|
titleY,
|
|
config.get('theme.colors.neon', '#00e5ff'),
|
|
config.get('theme.colors.neon2', '#ff2d6f'),
|
|
);
|
|
|
|
// ---- 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);
|
|
|
|
// ---- 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) => {
|
|
// 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);
|
|
}
|
|
});
|
|
this.cursorTimer = this.time.addEvent({
|
|
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' : ' '));
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 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, headerFont, bodyFont, cx, height) {
|
|
const cfg = menu.seed ?? {};
|
|
const colors = cfg.colors ?? {};
|
|
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: 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, 1);
|
|
|
|
// 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 + 18, cy, '', {
|
|
fontFamily: headerFont,
|
|
fontSize: `${cfg.fontSize ?? 25}px`,
|
|
color: toCss(colors.value ?? '#eaf6ff'),
|
|
letterSpacing: 4,
|
|
})
|
|
.setOrigin(0, 0.5);
|
|
|
|
// Reroll
|
|
this.rerollBtn = new MenuButton(
|
|
this,
|
|
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: 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 + 14, '', {
|
|
fontFamily: bodyFont,
|
|
fontSize: '12px',
|
|
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.dead) return;
|
|
const cfg = config.get('menu.seed.colors', {});
|
|
|
|
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;
|
|
let hint = `${Number(count).toLocaleString('en-US')} systems \u00b7 ${archetypes} archetypes \u00b7 same seed \u2192 same galaxy`;
|
|
if (this.seedValue.trim()) {
|
|
const gname = NameGenerator.galaxy(Rng.derive(this.seedValue.trim(), 'galaxy', 'name'));
|
|
hint = `the galaxy of ${gname} \u00b7 ${hint}`;
|
|
}
|
|
this.seedHint.setText(hint);
|
|
}
|
|
|
|
setSeedFocus(focused) {
|
|
if (this.dead) return;
|
|
this.seedFocused = !!focused;
|
|
this.cursorOn = true;
|
|
this.drawSeed();
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Seed "decode" scramble (boot + reroll) — the shared console effect
|
|
// (js/utils/Decode.js), the one the system dossier HUD uses too.
|
|
// ------------------------------------------------------------------
|
|
|
|
startDecode(value) {
|
|
this.decode = new ScrambleDecode(value, this.time.now, DECODE_DURATION);
|
|
}
|
|
|
|
updateDecode(time) {
|
|
if (!this.decode || !this.seedText || this.dead) return;
|
|
if (this.decode.finished(time)) {
|
|
this.decode = null;
|
|
this.drawSeed();
|
|
return;
|
|
}
|
|
this.seedText.setText(this.decode.display(time));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Input
|
|
// ------------------------------------------------------------------
|
|
|
|
onSeedKey(e) {
|
|
if (this.dead) return;
|
|
const key =
|
|
e && typeof e.key === 'string' && e.key.length > 0
|
|
? e.key
|
|
: e?.keyCode === 13 ? 'Enter'
|
|
: e?.keyCode === 8 ? 'Backspace'
|
|
: '';
|
|
|
|
if (key === 'Enter' || key === 'Escape') {
|
|
this.setSeedFocus(false);
|
|
return;
|
|
}
|
|
if (key === 'Backspace') {
|
|
if (this.seedFocused) {
|
|
this.seedValue = this.seedValue.slice(0, -1);
|
|
this.drawSeed();
|
|
}
|
|
return;
|
|
}
|
|
if (SEED_CHAR.test(key)) {
|
|
this.setSeedFocus(true);
|
|
if (this.seedValue.length < SEED_MAX) {
|
|
this.seedValue += key;
|
|
this.drawSeed();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
startNewGame() {
|
|
if (this.dead) return;
|
|
let seed = this.seedValue.trim();
|
|
if (!seed) {
|
|
seed = Rng.randomSeedString(8);
|
|
this.seedValue = seed;
|
|
}
|
|
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());
|
|
|
|
// 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, `// signal lost: could not build the galaxy — ${err.message}`, {
|
|
fontFamily: fontStack('body', FONT_FALLBACK),
|
|
fontSize: '14px',
|
|
color: toCss(themeColor('neon2', 0xff9b9b)),
|
|
letterSpacing: 1,
|
|
})
|
|
.setOrigin(0.5);
|
|
this.time.delayedCall(4000, () => msg.destroy());
|
|
}
|
|
}
|
|
|
|
/** 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.dead = true;
|
|
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
|
|
this.titleBloom?.destroy();
|
|
this.overlay?.destroy();
|
|
this.title?.destroy();
|
|
}
|
|
|
|
loadSavedSeed() {
|
|
try {
|
|
const v = globalThis.localStorage?.getItem?.(SEED_STORAGE_KEY);
|
|
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
saveSeed(seed) {
|
|
try {
|
|
globalThis.localStorage?.setItem?.(SEED_STORAGE_KEY, seed);
|
|
} catch {
|
|
/* private mode / no storage — fine */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|