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 { playSfxOn } from '../utils/Sfx.js'; import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { SaveManager } from '../save/SaveManager.js'; import { prepareLoad, resetRunState } from '../save/SaveData.js'; import { SavePanel } from '../ui/SavePanel.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. * * Continue resumes the NEWEST save in the localStorage bank (the one the * player last saved — SaveManager.latest()): it stages the restore the * same way the Load Game pop-up does and cuts straight into the game. * Grayed out, like Load Game, while the bank is empty. */ export class MenuScene extends Phaser.Scene { constructor() { super({ key: 'MenuScene' }); } preload() { // Sound effects (data/sfx.json → enabled) — the menu's buttons and // the save pop-up's decode ticks need their voices queued here (the // menu runs before GameScene, so its load is what makes them exist // on first use). Skipped entirely when the master switch is off — // no load cost, no files fetched. if (config.get('sfx.enabled', true)) { this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3')); this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3')); this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3')); this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3')); this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3')); this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3')); this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3')); this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3')); } // Music (data/music.json → enabled): the menu's loop (the menu runs // before GameScene, so its load is what makes it exist on first use). if (config.get('music.enabled', true)) { const menuTrack = config.get('music.menu'); if (typeof menuTrack === 'string') { this.load.audio(musicKey('music.menu'), menuTrack); } } } /** * Play one of the configured sound effects (data/sfx.json) — the * shared voice (js/utils/Sfx.js); silently no-ops when SFX are * disabled or the asset isn't loaded. */ playSfx(name) { playSfxOn(this, name); } create() { playMusicOn(this, 'music.menu'); // the menu hum (loop) — starts here, dies on shutdown // v4 (Giedi) quirk: scene transitions stop us via sys.shutdown and emit // the 'shutdown' EVENT — they never call our shutdown() method — so the // hum's stop is hooked on the event (the method stays for game destroy). this.events.once('shutdown', () => stopMusicOn(this, 'music.menu')); 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); // ---- the save bank (localStorage) — Continue and Load Game both read it this.saveManager = new SaveManager(); const hasSaves = this.saveManager.hasAny(); // ---- Continue — resume the newest save (SaveManager.latest). The // one-click door back into the game the player was last playing — // after leaving from the menu bar, after a browser refresh. Grayed // out while the bank is empty — the feature is visible, the door is // locked (same convention as Load Game). const cb = menu.buttons?.continue ?? {}; this.continueBtn = new MenuButton( this, (cb.position?.x ?? 0.5) * w, (cb.position?.y ?? 0.52) * h, cb.label ?? 'Continue', () => this.continueGame(), { fontSize: cb.fontSize ?? 26, paddingX: cb.paddingX ?? 46, paddingY: cb.paddingY ?? 18, }, ); this.continueBtn.setAlpha(0); if (!hasSaves) this.continueBtn.setDisabled(true); // ---- New Game const btn = menu.buttons?.newGame ?? {}; this.newGameBtn = new MenuButton( this, (btn.position?.x ?? 0.5) * w, (btn.position?.y ?? 0.635) * h, btn.label ?? 'New Game', () => this.startNewGame(), { fontSize: btn.fontSize ?? 26, paddingX: btn.paddingX ?? 46, paddingY: btn.paddingY ?? 18, }, ); this.newGameBtn.setAlpha(0); // ---- Load Game — the full slot bank (LOAD mode only; js/ui/SavePanel.js) const lb = menu.buttons?.loadGame ?? {}; this.loadGameBtn = new MenuButton( this, (lb.position?.x ?? 0.5) * w, (lb.position?.y ?? 0.735) * h, lb.label ?? 'Load Game', () => this.openLoadPanel(), { fontSize: lb.fontSize ?? 17, paddingX: lb.paddingX ?? 32, paddingY: lb.paddingY ?? 11, textColor: menu.colors?.buttonText ?? '#eaf6ff', borderColor: toCss('#7d92c4'), }, ); this.loadGameBtn.setAlpha(0); if (!hasSaves) this.loadGameBtn.setDisabled(true); // ---- 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 buttons rise in priority order, the seed decodes, and // one signature glitch fires. this.continueBtn.y += 12; 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.continueBtn, alpha: 1, y: this.continueBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); this.intro(460, () => { this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); this.intro(580, () => { this.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' }); }); this.intro(620, () => { const targets = [...this.seedIntroTargets, this.rerollBtn]; this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' }); }); this.intro(880, () => this.startDecode(this.seedValue)); this.intro(1150, () => 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)); // ESC with the load pop-up open closes the topmost thing first. this.input.keyboard.on('keydown-ESC', () => this.escLoadPanel()); 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.855) * 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 — parked fully OUTSIDE the field: the old fixed +30 center // offset let the button's left edge bite into the field's right edge. 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, }, ); this.rerollBtn.setX(cx + fieldW / 2 + (cfg.rerollGap ?? 18) + this.rerollBtn.width / 2); // 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(); // A fresh run must not inherit the previous one's discovery state or // a half-staged restore (js/save/SaveData.js). resetRunState(this.registry); 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()); } } // ------------------------------------------------------------------ // Continue — resume the newest save in the bank // ------------------------------------------------------------------ /** * The menu's Continue button: pick the bank's newest save (by savedAt, * SaveManager.latest), stage the restore in the shared registry, and cut * to the game. From the menu there is no live game to replace, so — * unlike the in-game Load flow — no confirmation beat: one click, in. */ continueGame() { if (this.dead) return; const latest = this.saveManager.latest(); if (!latest) return; try { this.dead = true; // no further input while we cut away this.setSeedFocus(false); this.drawSeed(); // Stage the restore (galaxy from seed + run state) exactly like the // Load Game pop-up does — GameScene.create() picks it up. prepareLoad(this.registry, latest.record); // Keep the seed panel in step with the galaxy we just staged, so the // next menu visit shows the seed Continue loaded. this.saveSeed(String(latest.record.seed).trim()); console.info(`orbit — resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot}, seed ${latest.record.seed})`); // The same glitch-out cut as New 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 resume the save — ${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()); } } // ------------------------------------------------------------------ // Load Game — the same slot bank, from the menu side // ------------------------------------------------------------------ /** * The menu's Load Game button: the 10-slot pop-up in LOAD mode (no * captureState needed here — a menu load only reads the bank and stages * the restore in the registry; GameScene.create() consumes it). */ openLoadPanel() { if (this.dead || !this.saveManager.hasAny()) return; if (!this.savePanel) { this.savePanel = new SavePanel(this, { onLoadComplete: () => { if (this.dead) return; this.dead = true; // no further input while we cut away // The same glitch-out cut as New Game. this.overlay?.trigger(240, 1); this.title?.setBurst(1); this.time.delayedCall(200, () => this.scene.start('GameScene')); }, }); } this.savePanel.show('load'); } /** ESC with the load pop-up open: topmost thing first (confirm → panel). */ escLoadPanel() { if (!this.savePanel) return; if (this.savePanel.confirm.isOpen) { this.savePanel.confirm.cancel(); return; } if (this.savePanel.isOpen) this.savePanel.close(); } /** 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); this.savePanel?.update(time); // the load pop-up (folds, decodes, confirm) if (this.statusDot) { this.statusDot.setAlpha(0.35 + 0.5 * (0.5 + 0.5 * Math.sin(time * 0.004))); } } shutdown() { this.dead = true; stopMusicOn(this, 'music.menu'); // the menu hum can't outlive the scene if (this.cursorTimer) this.time.removeEvent(this.cursorTimer); this.titleBloom?.destroy(); this.overlay?.destroy(); this.title?.destroy(); this.savePanel?.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(); } }