orbit/js/scenes/MenuScene.js

284 lines
8.3 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { MenuButton } from '../ui/MenuButton.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 MONO = "'Cascadia Mono', 'Consolas', 'Menlo', monospace";
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
const SEED_MAX = 32;
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
/**
* Main menu. All text/colors/layout come from data/menu.json.
*
* 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;
* - 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 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 fontFamily = menu.fontFamily ?? FONT_FALLBACK;
const colors = menu.colors ?? {};
const { width, height } = this.scale;
const cx = width / 2;
// Title
this.add
.text(cx, height * 0.34, menu.title ?? 'ORBIT', {
fontFamily,
fontSize: `${menu.titleFontSize ?? 84}px`,
fontStyle: 'bold',
color: colors.title ?? '#e9edf8',
shadow: {
color: colors.titleGlow ?? '#2f6df6',
blur: 28,
offsetX: 0,
offsetY: 0,
},
})
.setOrigin(0.5);
// Subtitle
this.add
.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,
(btn.position?.x ?? 0.5) * width,
(btn.position?.y ?? 0.60) * height,
btn.label ?? 'New Game',
() => this.startNewGame(),
btn,
);
// Galaxy seed panel
this.createSeedPanel(menu, fontFamily, cx, height);
// Global 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) => {
if (this.seedFieldHit && pointer.over(this.seedFieldHit)) {
this.setSeedFocus(true);
} else if (this.seedFocused) {
this.setSeedFocus(false);
}
});
this.cursorTimer = this.time.addEvent({
delay: 430,
loop: true,
callback: () => {
if (this.seedFocused) {
this.cursorOn = !this.cursorOn;
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
}
},
});
// Version footer
this.add
.text(width - 16, height - 14, config.get('game.version', ''), {
fontFamily,
fontSize: '12px',
color: colors.footer ?? '#54608a',
})
.setOrigin(1, 0.5);
}
// ------------------------------------------------------------------
// Galaxy seed panel
// ------------------------------------------------------------------
createSeedPanel(menu, fontFamily, cx, height) {
const cfg = menu.seed ?? {};
const colors = cfg.colors ?? {};
const fieldW = cfg.fieldWidth ?? 300;
const fieldH = cfg.fieldHeight ?? 46;
const cy = (cfg.position?.y ?? 0.755) * height;
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
this.seedFocused = false;
this.cursorOn = true;
// Label
this.add
.text(cx, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
fontFamily,
fontSize: `${cfg.labelFontSize ?? 12}px`,
color: colors.label ?? '#54608a',
letterSpacing: 3,
})
.setOrigin(0.5, 1);
// Field (click to edit)
this.seedFieldHit = this.add
.rectangle(cx, cy, fieldW, fieldH, toColor(colors.fieldBg ?? '#0b1226'), 1)
.setStrokeStyle(1, toColor(colors.border ?? '#2a3a63'), 0.9);
this.seedFieldHit.setInteractive({ useHandCursor: true });
this.seedFieldHit.on('pointerdown', () => this.setSeedFocus(true));
this.seedText = this.add
.text(cx - fieldW / 2 + 14, cy, '', {
fontFamily: MONO,
fontSize: `${cfg.fontSize ?? 24}px`,
color: colors.value ?? '#e9edf8',
})
.setOrigin(0, 0.5);
// Reroll
new MenuButton(
this,
cx + fieldW / 2 + 26,
cy,
cfg.rerollLabel ?? 'reroll',
() => {
this.seedValue = Rng.randomSeedString(8);
this.setSeedFocus(false);
},
{
fontSize: cfg.rerollFontSize ?? 14,
paddingX: 18,
paddingY: 10,
},
);
// Hint: what this seed will build (galaxy name + scale), live-updated.
this.seedHint = this.add
.text(cx, cy + fieldH / 2 + 12, '', {
fontFamily,
fontSize: '12px',
color: colors.hint ?? '#54608a',
})
.setOrigin(0.5, 0);
this.drawSeed();
}
drawSeed() {
if (!this.seedText || this.seedDead) return;
this.seedText.setText(this.seedValue + (this.seedFocused ? (this.cursorOn ? ' \u258c' : ' ') : ''));
const cfg = config.get('menu.seed.colors', {});
const border = this.seedFocused
? toColor(cfg.activeBorder ?? '#41c7ff')
: toColor(cfg.border ?? '#2a3a63');
this.seedFieldHit.setStrokeStyle(1, border, this.seedFocused ? 1 : 0.9);
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) {
this.seedFocused = !!focused;
this.cursorOn = true;
this.drawSeed();
}
onSeedKey(e) {
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() {
let seed = this.seedValue.trim();
if (!seed) {
seed = Rng.randomSeedString(8);
this.seedValue = seed;
this.drawSeed();
}
try {
this.setSeedFocus(false);
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());
this.scene.start('GameScene');
} catch (err) {
console.error(err);
const msg = this.add
.text(this.scale.width / 2, this.scale.height - 64, `could not build the galaxy: ${err.message}`, {
fontFamily: FONT_FALLBACK,
fontSize: '14px',
color: '#ff9b9b',
})
.setOrigin(0.5);
this.time.delayedCall(4000, () => msg.destroy());
}
}
/** Guard for post-shutdown input events (e.g. a click that also fired
* "New Game" on the same frame). */
shutdown() {
this.seedDead = true;
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
}
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 */
}
}
}