// 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 , 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); } }