fertig-classic-games/src/games/mastervega/MasterOfVegaGame.js

1642 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Master of Vega — the Phaser scene.
//
// The engine (VegaLogic) is headless; this scene drives it and renders through
// VegaStarMap. AI empires play via VegaAI. The scene owns three things and
// nothing else: the setup screen, the HUD, and the turn driver.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { TextInput } from '../../ui/TextInput.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { VegaMusic } from './VegaMusic.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js';
import { compileRules, turnToYear } from './VegaRules.js';
import {
ensureSheets, makeSpeciesPortrait, sizeSpeciesPortrait, speciesSpeechClip, UI_SPEECH,
} from './VegaArt.js';
import * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js';
import VegaStarMap from './VegaStarMap.js';
import VegaSidePanel from './VegaSidePanel.js';
import VegaFx from './VegaFx.js';
import { openSystemView } from './VegaSystemView.js';
// V2 (formerly ?movsim-only) is the real game's combat view now — see
// VegaLogic.js's createBattle/runBattle import comment.
import { openCombatViewV2 } from './VegaCombatViewV2.js';
import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
openSaveScreen, openLoadScreen, showVictoryOverlay, openFormationPicker,
openAttackNotice,
} from './VegaScreens.js';
import { openColoniesScreen } from './VegaColoniesScreen.js';
import { openResearchScreen } from './VegaResearchScreen.js';
import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import * as Gnn from './VegaGnn.js';
import { openGnnScreen } from './VegaGnnScreen.js';
import { openBombardPopup } from './VegaBombardScreen.js';
import { openCouncilSessionScreen } from './VegaCouncilSession.js';
import { openAudienceScreen } from './VegaAudience.js';
import { playIntroVideo } from './VegaIntroVideo.js';
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
import { VegaTutorial } from './VegaTutorial.js';
import { validateTutorialData } from './VegaTutorialData.js';
const SAVE_KEY = 'mastervega-save';
// 10 manual slots, independent of the single SAVE_KEY auto-save above (which
// exists purely to power the landing screen's "Resume Game" button). Each
// slot is one localStorage entry: { meta: {...for display...}, raw: the
// engine's own serialize() string }, so loading a slot is just a
// deserialize() call away from the exact same state shape "Resume Game" uses.
const SAVE_SLOT_COUNT = 10;
const saveSlotKey = (i) => `mastervega-save-slot-${i}`;
// The Empire button's own leading open/closed indicator — swapped by
// open/closeEmpireMenu, same ▸/▾ convention VegaTurnReportScreen.js uses
// per-row, applied here to a button label instead. The button's label is
// one centered Phaser Text (VegaButton.js has no separate icon slot), so
// the only way to nudge the triangle further from "Empire" without a new
// component is padding the string itself — the extra spaces push the
// triangle left and "Empire" right by equal amounts as the whole centered
// block widens.
const EMPIRE_CLOSED_LABEL = '▸ Empire';
const EMPIRE_OPEN_LABEL = '▾ Empire';
export default class MasterOfVegaGame extends Phaser.Scene {
constructor() { super('MasterOfVegaGame'); }
init(data) {
this.roomData = data ?? {};
this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
// Set by the game menu's Load screen: a state deserialized from a save
// slot, handed across a full scene restart (see openLoadScreen below)
// rather than swapped into a still-running scene, so the map/HUD/fx from
// the game being left behind get torn down the same proven way Quit to
// Arcade already relies on instead of a bespoke in-place teardown.
this.pendingSavedState = data?.savedState ?? null;
this.modalOpen = false;
// Set true by VegaTutorial for the one step that teaches pan/zoom — lets
// the star map's own drag/wheel handlers through despite modalOpen, while
// onStarClick/onFleetClick (and every other modalOpen-gated control)
// still check modalOpen directly and stay frozen. See blockWheel/
// blockPointer below.
this.tutorialFreePan = false;
this.busy = false;
// Empire indices with a freshly-claimed contact waiting for their
// full-screen Audience — see runAudienceQueue().
this.pendingAudiences = [];
}
create() {
this.cameras.main.setBackgroundColor('#03060d');
this.rules = compileRules(this.cache.json.get('mastervega-rules'));
const artwork = this.cache.json.get('mastervega-artwork') ?? { sheets: {} };
const { keys, procedural } = ensureSheets(this, this.rules, artwork);
this.art = keys;
if (procedural.length) {
console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`);
}
// Guided-tutorial script (data/mastervega-tutorial.json). A malformed file
// must never take the game down with it — log and disable the feature.
this.tutorialData = this.cache.json.get('mastervega-tutorial') ?? null;
if (this.tutorialData) {
const { ok, errors } = validateTutorialData(this.tutorialData);
if (!ok) {
console.warn('[MasterOfVega] tutorial disabled — invalid mastervega-tutorial.json:', errors);
this.tutorialData = null;
}
}
try {
this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music'));
} catch (err) { /* music is optional */ }
this.events.once('shutdown', () => this.teardown());
// A Load-slot resume drops straight into a running game and never sees a
// landing screen at all, so it skips the intro too — only a fresh entry
// plays it, right before showLanding(). The menu track waits until
// whichever of those actually runs: the intro clip carries its own
// soundtrack, and starting the menu theme underneath it would just be two
// tracks fighting for the same speakers.
if (this.pendingSavedState) {
this.music?.setCategory('menu');
this.beginGame(null, this.pendingSavedState);
} else {
playIntroVideo(this, () => {
this.music?.setCategory('menu');
this.showLanding();
});
}
}
// Wrap a menu handler so it clicks. Every button and card on the front-end
// screens goes through this, so the cue can never drift out of sync with what
// is actually clickable.
uiClick(fn) {
return (...args) => {
playSound(this, SFX.VEGA_SELECT);
fn?.(...args);
};
}
teardown() {
this.tutorial?.destroy();
this.tutorial = null;
resetSpeechQueue();
this.panel?.destroy();
this.map?.destroy();
this.fx?.destroy();
this.music?.destroy?.();
}
// -------------------------------------------------------------- backdrop
// Shared by the landing screen and the setup screen. `dim` is how hard to
// darken the artwork: the landing screen wants to show it off, the setup
// screen has to keep ten species cards legible on top of it.
paintBackdrop(layer, dim) {
if (this.textures.exists('vega-menu-bg')) {
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg');
bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
layer.add(bg);
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, dim).setOrigin(0, 0));
} else {
// The whole game runs with zero art files; the menus are no exception.
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0));
}
}
// Draw the title logo scaled to CONTAIN a box, never stretched — the source
// is a large title card and its aspect has to survive.
paintTitle(layer, cx, cy, boxW, boxH, fallbackSize) {
if (this.textures.exists('vega-menu-title')) {
const t = this.add.image(cx, cy, 'vega-menu-title').setOrigin(0.5);
const src = this.textures.get('vega-menu-title').getSourceImage();
t.setScale(Math.min(boxW / src.width, boxH / src.height));
layer.add(t);
return t;
}
const t = this.add.text(cx, cy, 'MASTER OF VEGA', {
fontFamily: FONT, fontSize: `${fallbackSize}px`, color: '#cfe8ff', align: 'center',
}).setOrigin(0.5);
layer.add(t);
return t;
}
// A periodic light sweep over the title logo. A soft white band is masked to
// the title's own alpha (so it only lights the lettering, never the backdrop)
// and travels left → right on a loop. Returns the shine object so the caller
// can stop it if the landing layer is torn down.
paintTitleShine(layer, title) {
if (!this.textures.exists('vega-menu-title')) return null;
const w = title.displayWidth;
const h = title.displayHeight;
if (!w || !h) return null;
const bandW = Math.max(140, Math.round(w * 0.3));
const bandH = Math.ceil(h);
// A horizontal gradient: transparent → bright white → transparent.
if (!this.textures.exists('vega-title-shine')) {
const g = this.make.graphics({ add: false });
const steps = 48;
for (let i = 0; i < steps; i += 1) {
const t = i / (steps - 1);
const a = Math.sin(t * Math.PI);
g.fillStyle(0xffffff, a * 0.5);
g.fillRect(i * (bandW / steps), 0, Math.ceil(bandW / steps) + 1, bandH);
}
g.generateTexture('vega-title-shine', bandW, bandH);
g.destroy();
}
const shine = this.add.image(title.x, title.y, 'vega-title-shine')
.setBlendMode(Phaser.BlendModes.ADD);
shine.setMask(title.createBitmapMask());
layer.add(shine);
// Start fully off the left edge, sweep across, then loop after a pause.
const travel = w + bandW * 2;
const startX = title.x - travel / 2;
const endX = title.x + travel / 2;
shine.x = startX;
this.tweens.add({
targets: shine,
x: endX,
duration: 1500,
ease: 'Sine.easeInOut',
delay: 2200,
repeat: -1,
});
return shine;
}
// --------------------------------------------------------------- landing
showLanding() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.landingLayer = layer;
this.paintBackdrop(layer, 0.34);
// Logo large on the left, breathing gently.
const title = this.paintTitle(layer, 1400, 300, 900, 560, 76);
this.tweens.add({
targets: title,
scale: title.scaleX * 1.04,
duration: 2800,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
// A light sweep over the logo, masked to the lettering, on a loop.
this.paintTitleShine(layer, title);
// Buttons well below it, over on the right.
const bx = 1420;
let by = 640;
const gap = 104;
const newGame = new Button(this, bx, by, 'New Game', this.uiClick(() => {
layer.destroy();
this.showSetup();
}), { width: 420, height: 78, fontSize: 30 });
layer.add(newGame);
by += gap;
const saved = this.readSave();
const resume = new Button(this, bx, by, 'Resume Game', this.uiClick(() => {
if (!saved) return;
layer.destroy();
this.beginGame(null, saved);
}), { width: 420, height: 78, fontSize: 30 });
// setEnabled(false) both dims it and drops its input, so a missing save
// reads as unavailable rather than as a button that silently does nothing.
if (!saved) resume.setEnabled(false);
layer.add(resume);
by += gap;
const quit = new Button(this, bx, by, 'Return to Arcade', this.uiClick(() => {
this.scene.start('GameMenu');
}), { width: 420, height: 78, fontSize: 30 });
layer.add(quit);
}
// ------------------------------------------------------- species detail
/**
* Grow a species card into a centred pop-over with full-size art and
* readable type, then commit or discard the choice.
*
* The portrait is REPARENTED out of the card rather than a second one being
* created. Two Phaser Video objects on one cached video is not a
* configuration worth betting on — if they share an element, destroying the
* pop-over's copy would kill the card's — and moving the one that already
* exists sidesteps the question entirely while halving the decoding.
*/
openSpeciesDetail(parentLayer, spec, card, portrait, onSelect, nameInput = null) {
if (this.speciesDetailOpen) return;
this.speciesDetailOpen = true;
// The colony-name field is a real DOM <input>, which paints in its own
// layer above the canvas (z-index, styles.css) regardless of this popup's
// Phaser depth — a Phaser depth/z-index bump can't pull it back underneath.
// Hidden for the popup's whole lifetime so it can never float on top of it.
if (nameInput) nameInput.el.style.visibility = 'hidden';
const CARD_PORTRAIT = { x: 52, y: 62, size: 84 };
const PANEL_W = 860;
const PANEL_H = 640;
const halfW = PANEL_W / 2;
const halfH = PANEL_H / 2;
const accent = Phaser.Display.Color.HexStringToColor(spec.color).color;
const veil = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0)
.setOrigin(0, 0).setInteractive();
parentLayer.add(veil);
this.tweens.add({ targets: veil, fillAlpha: 0.78, duration: 200 });
// Built at full size, then scaled down to the card and grown — so the type
// is rendered crisp at its final size instead of being a magnified
// thumbnail.
const pop = this.add.container(card.x + CARD_PORTRAIT.x, card.y + CARD_PORTRAIT.y);
pop.setScale(0.18).setAlpha(0.35);
parentLayer.add(pop);
const panel = this.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, 0x0b1220, 0.97).setOrigin(0, 0);
panel.setStrokeStyle(2, accent, 0.85);
pop.add(panel);
const ticks = this.add.graphics();
ticks.lineStyle(3, accent, 0.9);
const t = 30;
for (const [tx, ty, dx, dy] of [
[-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1], [-halfW, halfH, 1, -1], [halfW, halfH, -1, -1],
]) {
ticks.lineBetween(tx, ty, tx + dx * t, ty);
ticks.lineBetween(tx, ty, tx, ty + dy * t);
}
pop.add(ticks);
// The portrait moves house, at its full 256px.
card.remove(portrait);
portrait.setPosition(-halfW + 168, -halfH + 188);
sizeSpeciesPortrait(portrait, 256);
pop.add(portrait);
const colX = -100;
const colW = PANEL_W - 40 - (colX + halfW);
let y = -halfH + 46;
pop.add(this.add.text(colX, y, spec.name, {
fontFamily: FONT, fontSize: '44px', color: spec.color,
}));
y += 62;
const heading = (label) => {
pop.add(this.add.text(colX, y, label, {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 24;
};
const bullets = (lines, colour) => {
for (const line of lines) {
const item = this.add.text(colX, y, `· ${line}`, {
fontFamily: FONT, fontSize: '20px', color: colour, wordWrap: { width: colW },
});
pop.add(item);
y += item.height + 6;
}
y += 10;
};
heading('STRENGTHS');
bullets(spec.strengths, '#7fd8a0');
heading('WEAKNESSES');
bullets(spec.weaknesses, '#e08a8a');
pop.add(this.add.text(-halfW + 40, halfH - 220, spec.desc, {
fontFamily: FONT, fontSize: '19px', color: '#a8c4e0',
wordWrap: { width: PANEL_W - 80 }, lineSpacing: 4,
}));
pop.add(this.add.text(-halfW + 40, halfH - 128, `Homeworld: ${this.rules.planetTypes[spec.homeworld].name}`, {
fontFamily: FONT, fontSize: '17px', color: '#7f97b3',
}));
// --- close, either way
let closing = false;
const close = (commit) => {
if (closing) return;
closing = true;
if (commit) onSelect?.();
// The window is going away, so the voice goes with it.
resetSpeechQueue();
this.tweens.add({ targets: veil, fillAlpha: 0, duration: 180 });
this.tweens.add({
targets: pop,
x: card.x + CARD_PORTRAIT.x,
y: card.y + CARD_PORTRAIT.y,
scale: 0.18,
alpha: 0.25,
duration: 200,
ease: 'Cubic.easeIn',
onComplete: () => {
// Hand the portrait back BEFORE the pop-over is destroyed, or it
// would be destroyed along with it and the card left empty.
pop.remove(portrait);
portrait.setPosition(CARD_PORTRAIT.x, CARD_PORTRAIT.y);
sizeSpeciesPortrait(portrait, CARD_PORTRAIT.size);
card.add(portrait);
pop.destroy();
veil.destroy();
this.speciesDetailOpen = false;
if (nameInput) nameInput.el.style.visibility = '';
},
});
};
pop.add(new Button(this, -160, halfH - 62, 'Select Species', this.uiClick(() => close(true)),
{ width: 300, height: 62, fontSize: 24 }));
pop.add(new Button(this, 170, halfH - 62, 'Cancel', this.uiClick(() => close(false)),
{ width: 220, height: 62, fontSize: 24, variant: 'ghost' }));
veil.on('pointerup', this.uiClick(() => close(false)));
this.tweens.add({
targets: pop,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
});
// The species introduces itself. resetQueue first so opening a second card
// cuts the first one off instead of letting two voices talk over each
// other, and `force` so the line plays even if the queue was full.
resetSpeechQueue();
enqueueSpeech(speciesSpeechClip(spec.id), null, { force: true });
}
// ---------------------------------------------------------------- setup
showSetup() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.setupLayer = layer;
this.paintBackdrop(layer, 0.62);
this.paintTitle(layer, GAME_WIDTH / 2, 74, 760, 132, 58);
layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', {
fontFamily: FONT, fontSize: '20px', color: '#7f97b3',
}).setOrigin(0.5));
// Spoken greeting on arrival. resetQueue first so stepping back to the
// landing screen and returning does not stack a second copy on top of a
// line that is still running.
resetSpeechQueue();
enqueueSpeech(UI_SPEECH.chooseSpecies, null, { force: true });
const choice = {
speciesId: null,
sizeId: 'medium',
shapeId: 'spiral',
difficultyId: 'normal',
empires: 4,
homeColonyName: '',
};
// Declared up here so the species cards (built next) and the name field
// (built after the option rows) can both reach them — both close over
// these rather than each other, since either can flip readiness.
let nameInput = null;
const refreshStart = () => {
const ready = !!choice.speciesId && choice.homeColonyName.trim().length > 0;
start.setEnabled(ready);
};
// --- species picker
const cols = 5;
const cardW = 300;
const cardH = 190;
const gridX = (GAME_WIDTH - cols * cardW) / 2;
const cards = [];
this.rules.speciesList.forEach((spec, i) => {
const cx = gridX + (i % cols) * cardW;
const cy = 182 + Math.floor(i / cols) * cardH;
const card = this.add.container(cx, cy);
const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0);
bg.setStrokeStyle(1.5, 0x24405f, 1);
card.add(bg);
const portrait = makeSpeciesPortrait(this, this.rules, this.art, spec.id, 52, 62, 84);
card.add(portrait);
card.add(this.add.text(104, 16, spec.name, {
fontFamily: FONT, fontSize: '22px', color: spec.color,
}));
card.add(this.add.text(104, 46, spec.strengths.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#7fd8a0', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(104, 100, spec.weaknesses.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#e08a8a', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(14, 150, spec.desc, {
fontFamily: FONT, fontSize: '11px', color: '#6f8aa3', wordWrap: { width: cardW - 40 },
}));
// The hit zone is a top-left rectangle of its own, never the container —
// a Container's hit area is always centred on its origin.
const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
zone.on('pointerup', this.uiClick(() => {
this.openSpeciesDetail(layer, spec, card, portrait, () => {
choice.speciesId = spec.id;
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
// Re-seed the name field with this species' own flavour every time —
// a name typed for a previously-picked species would read oddly on
// this one, and the player can still overwrite it. Randomized so it
// is not always the same first name off the bank; Math.random is
// fine here, same as the rival-species shuffle below — no game
// state exists yet for this to desync.
const defaultName = spec.colonyNames[Math.floor(Math.random() * spec.colonyNames.length)];
choice.homeColonyName = defaultName;
if (nameInput) nameInput.value = defaultName;
refreshStart();
}, nameInput);
}));
card.add(zone);
layer.add(card);
cards.push({ bg, spec });
});
// --- option rows
const optTooltip = new Tooltip(this, { depth: D.detail });
const optY = 600;
const mkRow = (label, y, options, key, format = (o) => o.name) => {
layer.add(this.add.text(GAME_WIDTH / 2 - 620, y, label, {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const buttons = [];
options.forEach((opt, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), this.uiClick(() => {
choice[key] = opt.id ?? opt;
for (const other of buttons) other.setActive(false);
b.setActive(true);
}), { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true);
if (opt.desc) {
optTooltip.attachTo(b, () => ({ title: opt.name, lines: [{ text: opt.desc }] }));
}
buttons.push(b);
layer.add(b);
});
};
mkRow('Galaxy', optY, this.rules.galaxySizeList, 'sizeId');
mkRow('Shape', optY + 62, this.rules.galaxyShapeList, 'shapeId');
mkRow('Difficulty', optY + 124, this.rules.difficultyList, 'difficultyId');
layer.add(this.add.text(GAME_WIDTH / 2 - 620, optY + 186, 'Empires', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const empButtons = [];
[2, 3, 4, 5, 6].forEach((n, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, this.uiClick(() => {
choice.empires = n;
for (const other of empButtons) other.setActive(false);
b.setActive(true);
}), { width: 195, height: 46, fontSize: 19 });
if (n === choice.empires) b.setActive(true);
empButtons.push(b);
layer.add(b);
});
layer.add(this.add.text(GAME_WIDTH / 2 - 620, optY + 248, 'Home Colony', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
nameInput = new TextInput(this, GAME_WIDTH / 2 + 145, optY + 248, {
width: 1035, height: 44, value: choice.homeColonyName, maxLength: 28, autocomplete: 'off',
placeholder: 'Select a species above',
});
nameInput.on('input', () => {
choice.homeColonyName = nameInput.value;
refreshStart();
});
const start = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90, 'Begin', this.uiClick(() => {
// Belt-and-braces: setEnabled(false) already blocks pointer input while
// either condition fails, but this is the actual gate.
if (!choice.speciesId || !choice.homeColonyName.trim()) { refreshStart(); return; }
choice.homeColonyName = choice.homeColonyName.trim();
nameInput.destroy();
optTooltip.destroy();
layer.destroy();
this.beginGame(choice);
}), { width: 260, height: 64, fontSize: 30 });
start.setEnabled(false);
layer.add(start);
// Resuming lives on the landing screen now, so this only steps back to it.
const back = new Button(this, 120, 60, '← Back', this.uiClick(() => {
nameInput.destroy();
optTooltip.destroy();
layer.destroy();
this.showLanding();
}), { width: 170, height: 48, fontSize: 20, variant: 'ghost' });
layer.add(back);
}
// ----------------------------------------------------------------- game
beginGame(choice, savedState = null) {
if (savedState) {
this.state = savedState;
} else {
const cap = this.rules.galaxySizes[choice.sizeId].maxEmpires;
const count = Math.min(choice.empires, cap);
// The human's species first, then distinct rivals drawn from the rest.
const pool = this.rules.speciesList
.map((s) => s.id)
.filter((id) => id !== choice.speciesId);
// Math.random is fine here — determinism starts at the engine seed below.
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
const speciesIds = [choice.speciesId, ...pool.slice(0, count - 1)];
this.state = Logic.createGame(this.rules, {
sizeId: choice.sizeId,
shapeId: choice.shapeId,
difficultyId: choice.difficultyId,
seed: (Math.random() * 1e9) | 0,
speciesIds,
humanIndex: 0,
homeColonyName: choice.homeColonyName,
});
}
this.state.rules = this.rules;
this.music?.setCategory('peace');
this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1);
this.fx = new VegaFx(this, this.fxLayer);
this.map = new VegaStarMap(this, this.rules, this.state, this.art, {
onStarClick: (idx) => this.onStarClick(idx),
onFleetClick: (fleet) => this.onFleetClick(fleet),
onEmptyClick: () => this.clearSelection(),
blockWheel: () => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen,
// A modal blocks the map outright; so does the panel's ship detail
// pop-over, which veils the whole screen without being a modal.
// Otherwise only the side panel's own footprint does (so dragging a
// slider there doesn't pan the galaxy underneath it). The old
// `!this.modalOpen && ...` form always short-circuited to false while a
// modal was open, which let drags pan the star map right through the
// System View window. `tutorialFreePan` punches a narrow exception
// through modalOpen for pan/zoom only — onStarClick/onFleetClick are
// untouched, so clicking a star or fleet is still fully frozen.
blockPointer: (p) => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen
|| !!this.panel?.containsPoint(p.x, p.y),
});
// The command panel is the only place a selection is acted on; the scene
// just decides what is selected and hands it over.
this.panel = new VegaSidePanel(this, this.rules, this.state, this.art, {
viewerIdx: this.state.humanIndex,
onViewSystem: (idx) => this.openSystemViewFor(idx),
onSelectFleet: (fleet) => this.onFleetClick(fleet),
onAcceptOrder: (fleet, toStar, ships) => this.confirmOrder(fleet, toStar, ships),
onCancelOrder: () => { this.map?.clearRoutePreview(); this.panel.showFleet(this.selectedFleet); },
onSelectionLost: () => { this.selectedFleet = null; this.map?.setSelectedStar(-1); },
onClose: () => this.clearSelection(),
});
this.buildHud();
this.refreshHud();
// A brand-new game (never a resumed/loaded one) opens with the guided
// tutorial. Runs every new game — there is no "seen" flag — and is also
// re-triggerable from the ☰ menu.
if (!savedState) this.startTutorial({ replay: false });
}
// ------------------------------------------------------------- tutorial
startTutorial({ replay = false } = {}) {
if (this.tutorial || !this.tutorialData || !this.map || !this.panel) return;
if (replay && !this.canReplayTutorial()) return;
const emp = this.state.empires[this.state.humanIndex];
const species = this.rules.species[emp?.speciesId]?.plural
?? this.rules.species[emp?.speciesId]?.name ?? 'your people';
this.tutorial = new VegaTutorial(this, {
data: this.tutorialData,
vars: { species },
onFinish: () => { this.tutorial = null; },
});
this.tutorial.start();
}
/** The tutorial's fleet callout needs the untouched starting fleet in orbit
* at the homeworld — once it has moved or split there is nothing to point
* at, so the ☰ replay entry greys out. */
canReplayTutorial() {
if (!this.tutorialData || !this.map || !this.panel || !this.state) return false;
const emp = this.state.empires[this.state.humanIndex];
if (!emp) return false;
return this.state.fleets.some((f) => f.empireIdx === this.state.humanIndex
&& f.starIdx === emp.homeStar && f.toStar < 0);
}
// ------------------------------------------------------------------ HUD
buildHud() {
const hud = this.add.container(0, 0).setDepth(D.hud);
this.hud = hud;
const bar = this.add.rectangle(0, 0, GAME_WIDTH, 62, 0x06101c, 0.92).setOrigin(0, 0);
bar.setStrokeStyle(1, 0x6fc4ff, 0.35);
hud.add(bar);
this.hudText = this.add.text(24, 18, '', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff' });
hud.add(this.hudText);
// Research/Diplomacy/Leaders/Council used to be four separate HUD
// buttons; consolidated into one "Empire" dropdown (openEmpireMenu,
// near the game menu below) to leave room in the bar for more empire-
// management buttons later without the row running out of width —
// Colonies (VegaColoniesScreen.js) is that next button. Parked
// immediately left of End Turn, same
// width, so the two read as a pair — the leading ▸/▾ (EMPIRE_CLOSED_LABEL/
// EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's
// own open/closed indicator, not the same glyph VegaTurnReportScreen.js
// uses per-row, just the same visual convention.
this.empireBtn = new Button(this, GAME_WIDTH - 340, 31, EMPIRE_CLOSED_LABEL,
this.uiClick(() => this.toggleEmpireMenu()), { width: 190, height: 42, fontSize: 18 });
hud.add(this.empireBtn);
// GNN — immediately left of Empire, same row. Label gains a bullet
// (refreshHud) whenever a story is waiting so there's a visible cue even
// between auto-opens (e.g. after a Load, or if the player dismissed the
// last broadcast mid-flow via ESC).
this.gnnBtn = new Button(this, GAME_WIDTH - 550, 31, 'GNN',
this.uiClick(() => this.openGnnOnDemand()), { width: 170, height: 42, fontSize: 18, variant: 'ghost' });
hud.add(this.gnnBtn);
// Not this.uiClick — onEndTurn plays its own vega-endturn cue (shared with
// combat's Next round button) rather than the generic click, and a no-op
// press while busy/modal should stay silent.
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
{ width: 190, height: 46, fontSize: 20, scheme: 'magenta' });
hud.add(this.endTurnBtn);
// --- status log, bottom left
//
// Bottom-anchored (origin 0,1) rather than pinned at a fixed top-left y:
// a top anchor meant a full 8-line log grew DOWN into the ☰ button below
// it, while a short log left the anchor point looking arbitrary. Anchoring
// the bottom edge just above the button (same "22 + gap" geometry as the
// ☰ button's own popover a few lines down) means the log grows upward
// instead and can never overlap it, at any line count.
const LOG_BOTTOM_Y = (GAME_HEIGHT - 40) - 22 - 16;
this.logLines = [];
this.logText = this.add.text(24, LOG_BOTTOM_Y, '', {
fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', lineSpacing: 3,
}).setOrigin(0, 1);
hud.add(this.logText);
// Worst-case top edge of the log once it grows to its own 8-line cap
// (log(), below) — refreshOfferNotifications() stacks its cards starting
// just above THIS, not the button, so a full log and a held offer can't
// collide either now that the log's top edge actually moves.
this.logTopY = LOG_BOTTOM_Y - 8 * 24;
// Held peace/alliance/trade-agreement offers from an AI empire get an
// actionable card (portrait + Seek Audience button) stacked just above
// the log, not just the "seek an audience" ticker line — see
// refreshOfferNotifications(). One card per empire currently holding an
// offer for the human, rebuilt from state.empires[human].pendingOffers
// every refreshAll() so it tracks accept/reject/expiry automatically.
this.offerCardLayer = this.add.container(0, 0);
hud.add(this.offerCardLayer);
const menuBtn = new Button(this, 56, GAME_HEIGHT - 40, '☰', this.uiClick(() => this.toggleGameMenu()),
{ width: 56, height: 44, fontSize: 24, variant: 'ghost' });
hud.add(menuBtn);
}
// -------------------------------------------------------------- game menu
/** ☰ toggles a small popover directly above it: Return to Main Menu, Save,
* Load (lit only once a slot exists), Quit to Arcade. Gated by modalOpen
* like every other HUD button so it can't stack on top of Research/
* Diplomacy/Council/Leaders. */
toggleGameMenu() {
if (this.modalOpen) return;
if (this.gameMenuLayer) { this.closeGameMenu(); return; }
this.openGameMenu();
}
openGameMenu() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.gameMenuLayer = layer;
// Click-anywhere-else-to-dismiss, same mechanism VegaScreens.js's list
// pickers use for their own veil.
const catcher = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
.setOrigin(0, 0).setInteractive();
catcher.on('pointerup', () => this.closeGameMenu());
layer.add(catcher);
const items = [
['Return to Main Menu', () => this.returnToMainMenu()],
['Save', () => this.openSaveMenu()],
['Load', () => this.openLoadMenu(), !this.hasAnySaveSlot()],
['Replay tutorial', () => this.startTutorial({ replay: true }), !this.canReplayTutorial()],
['Quit to Arcade', () => this.quitToArcade()],
];
const BTN_W = 260;
const BTN_H = 46;
const GAP = 10;
const PAD = 16;
const panelH = PAD * 2 + items.length * BTN_H + (items.length - 1) * GAP;
const panelCx = 56 + BTN_W / 2 - 12;
const panelBottomY = (GAME_HEIGHT - 40) - 22 - 12; // just above the ☰ button
const panelCy = panelBottomY - panelH / 2;
layer.add(this.add.rectangle(panelCx, panelCy, BTN_W + PAD * 2, panelH, 0x0b1220, 0.97)
.setStrokeStyle(1.5, 0x6fc4ff, 0.55));
let by = panelCy - panelH / 2 + PAD + BTN_H / 2;
for (const [label, fn, disabled] of items) {
const btn = new Button(this, panelCx, by, label, this.uiClick(() => { this.closeGameMenu(); fn(); }),
{ width: BTN_W, height: BTN_H, fontSize: 16, variant: 'ghost' });
if (disabled) btn.setEnabled(false);
layer.add(btn);
by += BTN_H + GAP;
}
}
closeGameMenu() {
this.gameMenuLayer?.destroy();
this.gameMenuLayer = null;
}
// -------------------------------------------------------------- empire menu
/** Empire toggles a small popover dropping down from directly below it:
* Research, Diplomacy, Leaders, Colonies, Council, in that order (Brian's
* ask). Same shape as the game menu above — click-anywhere-to-dismiss
* veil, ghost-variant item buttons — just anchored under the HUD bar
* instead of above the ☰ button. Gated by modalOpen like every other HUD
* button. */
toggleEmpireMenu() {
if (this.modalOpen) return;
if (this.empireMenuLayer) { this.closeEmpireMenu(); return; }
this.openEmpireMenu();
}
openEmpireMenu() {
this.empireBtn.setLabel(EMPIRE_OPEN_LABEL);
const layer = this.add.container(0, 0).setDepth(D.modal);
this.empireMenuLayer = layer;
const catcher = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
.setOrigin(0, 0).setInteractive();
catcher.on('pointerup', () => this.closeEmpireMenu());
layer.add(catcher);
const items = [
['Research', () => this.openModal((done) =>
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done))],
['Diplomacy', () => this.openModal((done) =>
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshAll()))],
['Leaders', () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud()))],
['Colonies', () => this.openModal((done) =>
openColoniesScreen(this, this.rules, this.state, this.state.humanIndex, this.art, {
onChanged: () => this.refreshAll(),
onClose: done,
}))],
];
// The Council only earns a spot in this menu once it has actually
// convened for the first time — before that, `lastResult` is still null
// (VegaLogic.js's createGame) and the button would just open onto
// openCouncilScreen's "The Council has not yet convened" empty state
// (Brian's ask, 2026-08-14).
if (this.state.council.lastResult) {
items.push(['Council', () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done))]);
}
const BTN_W = 200;
const BTN_H = 42;
const GAP = 8;
const PAD = 14;
const panelH = PAD * 2 + items.length * BTN_H + (items.length - 1) * GAP;
// Left-aligned under the (190-wide) Empire button, regardless of the
// dropdown panel's own width — reads empireBtn's actual width rather
// than a second hardcoded constant that could drift if either changes.
const panelCx = this.empireBtn.x - this.empireBtn.options.width / 2 + BTN_W / 2;
const panelTopY = 31 + 21 + 12; // just below the Empire button (centred at y=31, height 42)
const panelCy = panelTopY + panelH / 2;
layer.add(this.add.rectangle(panelCx, panelCy, BTN_W + PAD * 2, panelH, 0x0b1220, 0.97)
.setStrokeStyle(1.5, 0x6fc4ff, 0.55));
let by = panelCy - panelH / 2 + PAD + BTN_H / 2;
for (const [label, fn] of items) {
const btn = new Button(this, panelCx, by, label, this.uiClick(() => { this.closeEmpireMenu(); fn(); }),
{ width: BTN_W, height: BTN_H, fontSize: 16, variant: 'ghost' });
layer.add(btn);
by += BTN_H + GAP;
}
}
closeEmpireMenu() {
this.empireBtn.setLabel(EMPIRE_CLOSED_LABEL);
this.empireMenuLayer?.destroy();
this.empireMenuLayer = null;
}
// Both destinations auto-save to the single "Resume Game" slot on the way
// out, same safety net the old ← Menu button always had — independent of,
// and in addition to, the 10 manual slots below.
returnToMainMenu() {
this.writeSave();
this.scene.start('MasterOfVegaGame', { game: this.gameDef });
}
quitToArcade() {
this.writeSave();
this.scene.start('GameMenu');
}
openSaveMenu() {
this.openModal((done) => openSaveScreen(this, this.rules, this.state, {
getSlots: () => this.allSaveSlotMeta(),
onSave: (i) => this.writeSaveSlot(i),
}, done));
}
openLoadMenu() {
this.openModal((done) => openLoadScreen(this, this.rules, this.state, {
getSlots: () => this.allSaveSlotMeta(),
// A successful load restarts the whole scene (see init()'s
// pendingSavedState) rather than swapping this.state under the
// running map/HUD — the same proven teardown path Quit to Arcade
// already relies on, so `done` (the modal-close callback) is
// intentionally left uncalled: the scene it would resume is gone.
onLoad: (i) => {
const loaded = this.loadSaveSlot(i);
if (!loaded) return;
this.scene.start('MasterOfVegaGame', { game: this.gameDef, savedState: loaded });
},
onDelete: (i) => this.deleteSaveSlot(i),
}, done));
}
refreshHud() {
const emp = this.state.empires[this.state.humanIndex];
if (!emp) return;
const cols = Logic.empireColonies(this.state, emp.idx);
this.hudText.setText(
`${emp.name} · Year ${turnToYear(this.state.turn)} · `
+ `${cols.length} colonies · ${Math.round(emp.totalPop)} population · `
+ `${Math.round(emp.bc)} BC (${emp.lastIncome >= 0 ? '+' : ''}${Math.round(emp.lastIncome ?? 0)}) · `
+ `${emp.techsKnown} technologies`,
);
// Unread cue — visible even between auto-opens (e.g. right after a Load,
// or if the player ESC'd out of a broadcast mid-flow).
const hasNews = Gnn.pendingGnnStories(this.rules, this.state).length > 0;
this.gnnBtn?.setLabel(hasNews ? 'GNN •' : 'GNN');
}
log(line) {
this.logLines.push(line);
if (this.logLines.length > 8) this.logLines.shift();
this.logText?.setText(this.logLines.join('\n'));
}
// One card per empire currently holding a peace/alliance/trade-agreement
// offer for the human — a species portrait on the left, a Seek Audience
// button on the right, stacked upward from just above the status log.
// Fully rebuilt from live state every call, so a card simply stops
// appearing the moment its offer is accepted, rejected, or expires —
// no separate dismiss action or lifecycle to manage.
refreshOfferNotifications() {
this.offerCardLayer?.removeAll(true);
if (!this.state) return;
const human = this.state.empires[this.state.humanIndex];
if (!human) return;
const W = 340;
const H = 64;
const GAP = 10;
const X = 24;
const PORTRAIT = 48;
// canNegotiate here is a belt-and-suspenders guard, not the primary fix:
// runDiplomacyTurn (VegaDiplomacy.js) no longer lets a diplomacy-
// incapable species (Lithox) queue a pendingOffer in the first place, but
// this keeps a save written before that fix from resurrecting a stray
// "seeks an audience" card with a permanently-disabled button.
const otherIdxs = Object.keys(human.pendingOffers)
.map(Number)
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive
&& canNegotiate(this.rules, this.state, this.state.humanIndex, idx));
otherIdxs.forEach((otherIdx, row) => {
const other = this.state.empires[otherIdx];
const cy = this.logTopY - 16 - row * (H + GAP) - H / 2;
const card = this.add.container(X + W / 2, cy);
card.add(this.add.rectangle(0, 0, W, H, 0x0b1220, 0.92)
.setStrokeStyle(1.5, Phaser.Display.Color.HexStringToColor(other.color).color, 0.6));
const px = -W / 2 + 12 + PORTRAIT / 2;
card.add(makeSpeciesPortrait(this, this.rules, this.art, other.speciesId, px, 0, PORTRAIT));
card.add(this.add.text(px + PORTRAIT / 2 + 12, 0, `The ${other.name}\nseek an audience.`, {
fontFamily: FONT, fontSize: '13px', color: '#cfe0f2', lineSpacing: 2,
wordWrap: { width: 108 },
}).setOrigin(0, 0.5));
const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx);
const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', this.uiClick(() => {
this.openModal((done) => {
// Same per-race ducking runAudienceQueue does for an AI-initiated
// audience — only drop back to peace on close if nothing else is
// queued up behind this one.
this.music?.setDiplomacy(other.speciesId);
openAudienceScreen(
this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
if (!this.pendingAudiences.length) this.music?.setDiplomacy(null);
done();
},
() => this.refreshAll(),
);
});
}), { width: 140, height: 40, fontSize: 14 });
if (!canSeek) btn.setEnabled(false);
card.add(btn);
this.offerCardLayer.add(card);
});
}
refreshAll() {
this.map?.refresh();
this.panel?.refresh();
this.refreshHud();
this.refreshOfferNotifications();
}
// On-demand GNN — same modalOpen gate + openModal((done) => ...) shape as
// every Empire-menu entry above. Opens even with nothing new to report:
// VegaGnnScreen.js falls back to a random rankings page and lets Prev walk
// back into state.gnn.history, so it's never a dead click.
openGnnOnDemand() {
if (this.modalOpen) return;
this.openModal((done) => openGnnScreen(this, this.rules, this.state, this.art, { onClose: done }));
}
// -------------------------------------------------------------- modals
openModal(factory) {
if (this.modalOpen) return;
this.modalOpen = true;
// Arm a click guard so the modal's own close button does not fall through
// to the star map underneath it.
factory(() => {
this.time.delayedCall(60, () => {
this.modalOpen = false;
// Opportunistic drain: makes every ordinary modal-close in the game a
// safe place for a contact claimed mid-modal (colonize into a shared
// system, say) to finally surface, without touching each call site.
this.runAudienceQueue();
});
this.refreshAll();
});
}
// Manages modalOpen directly rather than going through openModal() itself:
// openModal's own done() defers `modalOpen = false` by 60ms
// (this.time.delayedCall above), so a synchronous recursive call from
// inside that same closure would see modalOpen still true and silently
// no-op — stalling the queue until some unrelated openModal call happened
// to drain it. Managing the flag here also chains back-to-back audiences
// with no artificial gap between them.
runAudienceQueue(onDone) {
if (this.modalOpen) { onDone?.(); return; }
if (!this.pendingAudiences.length) { onDone?.(); return; }
const otherIdx = this.pendingAudiences.shift();
this.modalOpen = true;
this.music?.setDiplomacy(this.state.empires[otherIdx].speciesId);
openAudienceScreen(this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
this.modalOpen = false;
// Only drop back to peace once the queue is empty — a next entry here
// recurses straight into another setDiplomacy() below, so back-to-back
// audiences never blip through the peacetime pool in between.
if (!this.pendingAudiences.length) this.music?.setDiplomacy(null);
this.refreshAll();
this.runAudienceQueue(onDone);
}, () => this.refreshAll());
}
// Clicking a star means one of two things, and which one depends entirely on
// whether a fleet of ours is selected: with a fleet in hand the star is a
// destination and the panel asks for confirmation, otherwise it is just
// something to look at.
onStarClick(idx) {
if (this.modalOpen || this.busy) return;
// Clicking the system the fleet is already sitting in is not a move order —
// it is a request to look at the place, so the selection is dropped and the
// star's own panel comes up (which lists the fleet, one click from getting
// it back). Clicking anywhere else with a fleet in hand quotes a route: the
// fleet keeps its own ring and a flowing dashed line marks the course
// instead of ringing the destination star.
const sf = this.selectedFleet;
const docked = sf && sf.starIdx >= 0 && sf.starIdx !== idx;
// Hyperspace Communications' redirectInFlight effect (Brian's ask,
// 2026-08-14) opens the same "quote a route" flow for a fleet already
// under way — anchored on its CURRENT destination, the one real star
// still available mid-flight (VegaLogic.js's sendFleet does the same).
const redirectable = sf && sf.starIdx < 0 && sf.toStar >= 0 && sf.toStar !== idx
&& Logic.empireComponents(this.rules, this.state, sf.empireIdx).redirectInFlight;
if (sf && this.state.fleets.includes(sf) && (docked || redirectable)) {
playSound(this, SFX.VEGA_VIEW);
this.map.setRoutePreview(docked ? sf.starIdx : sf.toStar, idx);
this.panel.showOrder(idx);
return;
}
playSound(this, SFX.VEGA_STAR);
this.map.setSelectedStar(idx);
this.selectedFleet = null;
this.panel.showStar(idx);
}
onFleetClick(fleet) {
if (this.modalOpen || this.busy) return;
// Someone else's fleet is not something we can give orders to, but the
// system it is sitting in is — clicking it reads as clicking that system,
// which with a fleet in hand is how an attack gets ordered. That takes
// priority over sensor detail below: a fleet already selected for a move
// order must still route through onStarClick's "quote a route" flow
// (same condition onStarClick itself uses), not get swallowed by the
// read-only scan panel.
if (fleet.empireIdx !== this.state.humanIndex) {
const ordering = this.selectedFleet && this.state.fleets.includes(this.selectedFleet)
&& this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== fleet.starIdx;
// Detected on sensors (Battle Scanner and friends' scanRange effect,
// Brian's ask, 2026-08-14): the same fleet panel our own fleets use,
// just read-only (VegaSidePanel.js's buildFleet branches on
// ownership). Outside scan range, or mid-order, a rival fleet marker
// still just reads as "click the system," same as before scanRange
// existed.
if (!ordering && Logic.fleetInScanRange(this.rules, this.state, this.state.humanIndex, fleet)) {
playSound(this, SFX.VEGA_UNIT);
this.panel.showFleet(fleet);
return;
}
if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx);
return;
}
playSound(this, SFX.VEGA_UNIT);
this.selectedFleet = fleet;
this.map.setSelectedFleet(fleet);
this.panel.showFleet(fleet);
}
clearSelection() {
if (this.modalOpen) return;
this.selectedFleet = null;
this.map?.setSelectedStar(-1);
this.panel?.hide();
}
openSystemViewFor(idx) {
const emp = this.state.empires[this.state.humanIndex];
// The system view stays closed until a scout has actually arrived; the
// panel is what an unexplored star has to say for itself.
if (emp && !emp.explored[idx]) return;
this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, {
viewerIdx: this.state.humanIndex,
onChanged: () => {
// Founding a colony onto a system another empire already shares can
// trigger contact without any fleet move — claim it here, but don't
// drain the queue: the system view (and the colony-founding vignette
// it is about to layer on top of, outside openModal) owns the screen
// right now. It surfaces passively once that whole chain eventually
// closes, via openModal's drain hook above.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
this.refreshAll();
},
onClose: done,
}));
}
// The panel's Accept. `ships` is the task force the player dialled in — the
// ships left out stay behind as a fleet of their own.
confirmOrder(fleet, toStar, ships) {
const dest = this.state.galaxy.stars[toStar];
const sent = Logic.sendDetachment(this.rules, this.state, fleet, toStar, ships);
if (!sent) {
this.log('Out of fuel range. Research propulsion, or plant a colony closer.');
return;
}
const eta = Logic.fleetEta(this.rules, this.state, sent);
this.log(`Fleet away — ${dest.name} in ${eta} turn${eta === 1 ? '' : 's'}.`);
playSound(this, SFX.VEGA_WARP);
this.selectedFleet = null;
this.panel.hide();
this.map.setSelectedStar(toStar);
this.refreshAll();
}
// ----------------------------------------------------------- turn driver
onEndTurn() {
if (this.busy || this.modalOpen) return;
playSound(this, SFX.VEGA_ENDTURN);
this.busy = true;
this.endTurnBtn.setEnabled(false);
this.writeSave();
// Move first, then hand any battle the player is involved in to the
// tactical view before the engine resolves the rest. endEmpireTurn is told
// not to move again.
Logic.moveFleetsFor(this.rules, this.state, this.state.humanIndex);
// A fleet arriving into a shared system pops its Audience screen right
// away — before battles or the AI turn loop run, i.e. before anything
// else about this turn resolves. Meet before you fight.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
this.runAudienceQueue(() => {
this.playPlayerBattles(() => {
Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex,
{ skipMove: true, deferBattlesFor: this.state.humanIndex });
this.runToHumanTurn();
});
});
}
// Fight every battle the human is currently in, one at a time on the
// tactical screen — whoever attacked whom. Each is prepared by the engine,
// driven tick by tick by the view, and its outcome handed straight back,
// so a battle the player fights here and an AI-vs-AI one auto-resolved by
// resolveCombats() still go through exactly the same prepareBattleAt/
// applyBattleOutcome code, just with a screen to watch (and, when the
// human has a fleet in this fight, a formation choice — see the
// humanIsDefender/humanHasFleetHere gate below). Re-derives
// Logic.pendingBattlesFor fresh every call (a no-op if nothing's pending),
// so it's safe to call from more than one place: onEndTurn() after the
// human's own move (`attacked` left false — the human chose this fight, so
// they choose the formation, UNLESS they're purely defending with no fleet
// of their own present), AND runToHumanTurn()'s AI loop after every AI
// empire's move (`attacked: true` — see below), since endEmpireTurn is told
// to defer any battle touching the human rather than auto-resolve it
// (Brian's ask, 2026-08-14) — this is what actually shows it.
playPlayerBattles(done, { attacked = false } = {}) {
const me = this.state.humanIndex;
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
if (!pending.length) { done(); return; }
const next = (i) => {
if (i >= pending.length) { this.refreshAll(); done(); return; }
const { starIdx, other } = pending[i];
this.map?.panToStar(starIdx, 260);
this.modalOpen = true;
const startBattle = (humanFormation) => {
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other, { humanFormation });
if (!prepared) { this.modalOpen = false; next(i + 1); return; }
this.music?.setCategory('combat');
openCombatViewV2(this, this.rules, prepared.battle, this.art, {
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender',
onDone: (result) => {
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
this.modalOpen = false;
this.music?.setCategory('peace');
this.refreshAll();
next(i + 1);
},
});
};
// A defending human with no fleet in orbit — only planetary defenses
// fighting — has no formation to choose a doctrine FOR, so asking is a
// pointless prompt in front of a fight they have zero ships in (Brian's
// ask, 2026-08-14). Routed into the same no-prompt path `attacked:true`
// already uses, regardless of which call site found this battle.
const colony = Logic.colonyAt(this.state, starIdx);
const humanIsDefender = colony?.empireIdx === me;
const humanHasFleetHere = Logic.fleetsAt(this.state, starIdx)
.some((f) => f.empireIdx === me && f.ships.length);
if (attacked || (humanIsDefender && !humanHasFleetHere)) {
// The formation picker is skipped here (Brian's ask, 2026-08-14):
// VegaFormations.js's own header comment says a chosen strategy
// doesn't change how the battle plays out yet, so asking the player
// to pick one before a fight they didn't start is a pointless extra
// click, not a real decision. Just say who's attacking and where,
// then start the same prepared battle with both sides silently
// randomised — prepareBattleAt's default when humanFormation is null,
// same as any AI-vs-AI fight already gets.
openAttackNotice(this, {
attackerName: this.state.empires[other].name,
starName: this.state.galaxy.stars[starIdx].name,
}, () => startBattle(null));
} else {
// Forced (closable: false) — there's no sensible "cancel" once fleets
// are already committed to this fight. Only the human's own side is
// ever asked; the AI opponent always picks silently (Logic
// .prepareBattleAt's own comment).
openFormationPicker(this, startBattle, { closable: false });
}
};
next(0);
}
// Resolve instantly, animate afterwards. Every AI empire is played out
// synchronously, then the interesting events are replayed for the player.
runToHumanTurn() {
const step = () => {
// The Council Session ceremony pre-empts everything, including a
// decisive-victory state.over below — it can fire mid-AI-turn-stepping
// (VegaLogic.js's runCouncil runs inside endEmpireTurn's turn-wrap, on
// whichever empire's turn happens to close out the calendar turn), and
// a council win that ends the game still deserves its ceremony instead
// of jumping straight to the victory overlay. Manages modalOpen
// directly rather than through this.openModal() — same reasoning as
// runAudienceQueue()'s own comment: a synchronous recursive call back
// into step() from inside openModal's own done() would see modalOpen
// still true (its 60ms clear hasn't fired yet) and silently no-op.
if (this.state.council.pendingSession) {
this.state.council.pendingSession = false;
this.modalOpen = true;
openCouncilSessionScreen(this, this.rules, this.state, this.art, () => {
this.modalOpen = false;
this.refreshAll();
step();
});
return;
}
if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
// Colony Focus autopilot: same relative placement as runAITurn is for
// an AI empire below — right after this turn's production has been
// spent, so whatever it queues here is what NEXT turn's
// beginEmpireTurn will consume.
Logic.autoQueueColonies(this.rules, this.state, this.state.humanIndex);
// Advisor re-analysis: may push an 'advisorRecommendation' event into
// state.events, picked up below by processTurnEvents() same as any
// other notable event.
Logic.checkAdvisorRecommendations(this.rules, this.state, this.state.humanIndex);
// Contact discovered during an AI empire's turn surfaces the moment
// control returns to the human — before the routine turn report, so
// "contact made" always reads as the bigger beat.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
const finish = () => {
this.busy = false;
this.endTurnBtn.setEnabled(true);
};
this.runAudienceQueue(() => {
const notable = this.processTurnEvents();
// GNN has its own event set (VegaGnn.js's GNN_STORY_TYPES) that
// isn't a subset of the Turn Report's NOTABLE_TYPES — `lastColony`
// has no Turn Report presence at all, and `spyCaught` is
// deliberately ticker-only there — so a turn can have zero
// `notable` rows and still owe the player a GNN broadcast.
const gnnPending = Gnn.pendingGnnStories(this.rules, this.state).length > 0;
if (notable.length || gnnPending) {
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
// modalOpen and refreshes; finish() on top of that re-arms the End
// Turn button, which openModal has no reason to touch on its own.
// Any research-branch choice prompts run first, each opened
// DIRECTLY (not via this.openModal — modalOpen is already true
// for the duration of this whole outer call, same reasoning as
// the turn report's own "View Star System" button), then the
// turn report (if there's anything notable to show), then GNN
// (if there's a pending story — re-checked at call time since a
// research choice can itself grant a headline tech), then
// done()+finish() exactly once at the end.
const pendingChoices = this.pendingResearchChoices(notable);
this.openModal((done) => {
const finishAll = () => { done(); finish(); };
const openGnnIfPending = () => {
if (Gnn.pendingGnnStories(this.rules, this.state).length) {
openGnnScreen(this, this.rules, this.state, this.art, { onClose: finishAll });
} else {
finishAll();
}
};
// AI-initiated bombardments/invasions against the player's own
// colonies get their own rich popup (VegaBombardScreen.js)
// here, one per event, shown BEFORE the turn report (Brian's
// ask, 2026-08-14: the attack itself is the bigger beat, so it
// leads rather than getting buried after the report). Player-
// initiated attacks already got this popup synchronously the
// moment they were clicked (VegaSystemView.js), so only events
// the human did NOT initiate and where the human is the one
// being attacked qualify — an AI-vs-AI bombardment still gets
// its ordinary turn-report row (it's in NOTABLE_TYPES) but
// never this popup.
const me = this.state.humanIndex;
const attackEvents = notable.filter((ev) => ev.empire !== me && (
(ev.type === 'bombard' && ev.target === me)
|| (ev.type === 'captured' && ev.from === me)
|| (ev.type === 'invasionFailed' && ev.defenderIdx === me)
));
const attackPopupOpts = (ev) => {
if (ev.type === 'bombard') {
return { kind: 'bombard', attackerIdx: ev.empire, defenderIdx: ev.target, starIdx: ev.starIdx, result: ev };
}
const defenderIdx = ev.type === 'captured' ? ev.from : ev.defenderIdx;
return {
kind: 'invade', attackerIdx: ev.empire, defenderIdx, starIdx: ev.starIdx,
result: {
captured: ev.type === 'captured', attackersLeft: ev.attackersLeft,
defendersLeft: ev.defendersLeft, troops: ev.troops, planetTypeId: ev.planetTypeId,
},
};
};
const openReport = () => {
if (notable.length) {
openTurnReportScreen(this, this.rules, this.state, notable, openGnnIfPending);
} else {
openGnnIfPending();
}
};
const showAttackPopups = (queue) => {
if (!queue.length) { openReport(); return; }
const [ev, ...rest] = queue;
openBombardPopup(this, this.rules, this.state, attackPopupOpts(ev), () => showAttackPopups(rest));
};
const runChoices = (queue) => {
if (!queue.length) { showAttackPopups(attackEvents); return; }
const [next, ...rest] = queue;
openResearchChoiceScreen(this, this.rules, this.state, this.state.humanIndex, this.art,
next.field, next.choices, next.completedTechId, () => runChoices(rest));
};
runChoices(pendingChoices);
});
} else {
this.refreshAll();
finish();
}
});
return;
}
const e = this.state.current;
Logic.beginEmpireTurn(this.rules, this.state, e);
runAITurn(this.rules, this.state, e);
Logic.endEmpireTurn(this.rules, this.state, e, { deferBattlesFor: this.state.humanIndex });
// This AI empire's own move can bring it into contact with the human —
// same interactive tactical view the human's own movement already gets
// (Brian's ask, 2026-08-14), `attacked: true` so it's a one-button
// "you're under attack" notice instead of the formation picker (that
// choice is the human's to make when THEY start a fight, not something
// to ask for on defense). playPlayerBattles re-derives
// Logic.pendingBattlesFor fresh every call and returns immediately when
// nothing is pending, so this costs nothing on the (common) turn where
// no battle happened, and steps the human straight into the tactical
// view the instant one did — before the loop moves on to the next
// empire, so fights never stack up unresolved across turns.
this.playPlayerBattles(() => this.time.delayedCall(60, step), { attacked: true });
};
step();
}
// techDone events this turn, human-owned, from actual research (not trade
// or espionage — grantTech's techDone events always carry a `source`),
// deduped by field, where the field's NEW frontier is ambiguous. Order
// matches turn/event order. Each entry carries the completed tech's id too,
// so the choice prompt can name what just finished.
pendingResearchChoices(notable) {
const seen = new Set();
const out = [];
for (const ev of notable) {
if (ev.type !== 'techDone' || ev.empire !== this.state.humanIndex || ev.source !== 'research') continue;
const field = this.rules.techs[ev.techId].field;
if (seen.has(field)) continue;
seen.add(field);
const choices = Logic.openResearchChoices(this.rules, this.state, this.state.humanIndex, field);
if (choices.length > 1) out.push({ field, choices, completedTechId: ev.techId });
}
return out;
}
// Turn engine events into log lines, map pings, and — for the events worth
// interrupting the player for — rows in the "New Turn" popup. `announced`
// marks a record consumed, since beginEmpireTurn trims rather than clears
// the event list. Returns the events the popup should show this turn.
processTurnEvents() {
const me = this.state.humanIndex;
const notable = [];
for (const ev of this.state.events) {
if (ev.announced) continue;
ev.announced = true;
if (!isRelevantToHuman(ev, me)) continue;
if (NOTABLE_TYPES.has(ev.type)) {
notable.push(ev);
continue;
}
const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null;
const name = (i) => this.state.empires[i]?.name ?? '?';
if (ev.type === 'refit' && ev.empire === me) {
this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`);
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
} else if (ev.type === 'offerReceived' && ev.other === me) {
// A held peace/alliance offer — not urgent enough to interrupt with a
// popup, but discoverable next time Diplomacy is opened.
this.log(`The ${name(ev.empire)} seek an audience.`);
} else if (ev.type === 'populationDelivered') {
this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`);
} else if (ev.type === 'gift' && ev.other === me) {
this.log(`The ${name(ev.empire)} send a gift of ${ev.tierId === 'lavish' ? 'considerable' : ev.tierId === 'generous' ? 'notable' : 'modest'} value.`);
} else if (ev.type === 'fleetComplaint' && ev.other === me) {
// The FIRST violation of an incident force-opens the Audience screen
// instead (claimFleetComplaints, called before this) and shares its
// `announced` flag, so this branch only ever sees turn 2+ of an
// ongoing intrusion — or a first violation for a diplomacy-incapable
// colony owner that can never get a popup at all.
this.log(`${name(ev.empire)} ${ev.dwell > 4 ? 'are furious about' : 'are unhappy about'} your fleet lingering at ${star?.name}.`);
} else if (ev.type === 'techStolen' && ev.target === me) {
this.log(`The ${name(ev.empire)} have stolen ${this.rules.techs[ev.techId]?.name} technology from us.`);
} else if (ev.type === 'spyCaught' && ev.empire === me) {
this.log(`Our spies were caught ${ev.mission === 'sabotage' ? 'attempting sabotage' : 'operating'} against the ${name(ev.target)}.`);
} else if (ev.type === 'sabotage' && ev.target === me) {
this.log(`The ${name(ev.empire)} have sabotaged our colony at ${star?.name}.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
}
}
return notable;
}
finishGame() {
this.busy = true;
this.clearSave();
showVictoryOverlay(this, this.rules, this.state, () => this.scene.start('GameMenu'));
}
update(time, delta) {
this.map?.update(time, delta);
this.tutorial?.update?.(time, delta);
}
// ------------------------------------------------------------ save/load
writeSave() {
try {
if (this.state && !this.state.over) {
window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
}
} catch (err) { /* storage may be unavailable */ }
}
readSave() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
return raw ? Logic.deserialize(raw) : null;
} catch (err) { return null; }
}
clearSave() {
try { window.localStorage.removeItem(SAVE_KEY); } catch (err) { /* ignore */ }
}
// ------------------------------------------------------- manual save slots
writeSaveSlot(i) {
try {
const emp = this.state.empires[this.state.humanIndex];
const meta = {
savedAt: Date.now(),
turn: this.state.turn,
year: turnToYear(this.state.turn),
empireName: emp?.name ?? 'Unknown',
speciesName: this.rules.species[emp?.speciesId]?.name ?? '?',
};
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
return true;
} catch (err) { return false; }
}
readSaveSlotMeta(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
return raw ? (JSON.parse(raw).meta ?? null) : null;
} catch (err) { return null; }
}
loadSaveSlot(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
if (!raw) return null;
return Logic.deserialize(JSON.parse(raw).raw);
} catch (err) { return null; }
}
deleteSaveSlot(i) {
try { window.localStorage.removeItem(saveSlotKey(i)); } catch (err) { /* ignore */ }
}
allSaveSlotMeta() {
const out = [];
for (let i = 0; i < SAVE_SLOT_COUNT; i += 1) out.push(this.readSaveSlotMeta(i));
return out;
}
hasAnySaveSlot() {
return this.allSaveSlotMeta().some(Boolean);
}
}