1642 lines
71 KiB
JavaScript
1642 lines
71 KiB
JavaScript
// 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);
|
||
}
|
||
}
|