orbit/js/scenes/MenuScene.js

546 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 { 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._-]$/;
const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?';
/**
* 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)
// ------------------------------------------------------------------
startDecode(value) {
this.decode = { value, t0: this.time.now, dur: 620 };
}
updateDecode(time) {
if (!this.decode || !this.seedText || this.dead) return;
const { value, t0, dur } = this.decode;
const u = (time - t0) / dur;
if (u >= 1) {
this.decode = null;
this.drawSeed();
return;
}
const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * value.length);
let out = '';
for (let i = 0; i < value.length; i++) {
out += i < reveal ? value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0];
}
this.seedText.setText(out);
}
// ------------------------------------------------------------------
// Input
// ------------------------------------------------------------------
onSeedKey(e) {
if (this.dead) return;
const key =
e && typeof e.key === 'string' && e.key.length > 0
? e.key
: 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();
}
}