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(); } }