// Master of Vega — modal screens and the shared sci-fi chrome they are built // in. There is no repo-wide panel component (Civilization and Total Annihilation // both roll their own), so `modalShell` is this game's version: a holographic // frame with corner ticks, matching the clean bridge-console look rather than // the arcade CRT treatment. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from './VegaButton.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost, } from './VegaLogic.js'; import { attitudeOf, moodOf, powerOf, canNegotiate, declareWar, makePeace, } from './VegaDiplomacy.js'; import { leaderOffers } from './VegaLeaders.js'; import { makeSpeciesPortrait } from './VegaArt.js'; import { openAudienceScreen } from './VegaAudience.js'; import { FORMATION_STRATEGIES } from './VegaFormations.js'; export const FONT = '"Julius Sans One"'; // `colony` sits above `modal` so the colony screen can stack over the system // view that opened it without either having to be torn down. `detail` is the // ship pop-over, which opens from the star map's side panel AND from inside the // colony screen, so it has to clear both. `council` is the Council Session // ceremony (VegaCouncilSession.js) — it can fire mid-AI-turn-stepping, before // any other modal is open, so its exact rank relative to gnn/detail never // actually matters in practice; it sits next to gnn as the other full-screen // takeover. `intro` is the colony-founding vignette, which opens over the // system view it was triggered from and must cover everything except the // end-of-game overlay. export const D = { map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, detail: 76, intro: 78, toast: 80, }; /** * Orbit numerals. Worldgen rolls at most five planets, but * `guaranteeNearbyWorlds` can append more to a star that needed help, so the * list runs long and every caller still falls back to `orbit + 1`. */ export const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII']; const ACCENT = 0x6fc4ff; const PANEL = 0x0b1220; // Wrap a Button handler so every ordinary click across the game's screens // shares one cue. Anything that closes a window via the ✕ glyph or has its // own dedicated sound (build queueing, ship detail, new-turn) bypasses this. export function uiClick(scene, fn) { return (...args) => { playSound(scene, SFX.VEGA_SELECT); fn?.(...args); }; } /** The holographic frame every modal is built inside. */ export function modalShell(scene, title, onClose, { width = 1220, height = 800, closable = true } = {}) { const layer = scene.add.container(0, 0).setDepth(D.modal); const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.72) .setOrigin(0, 0).setInteractive(); layer.add(veil); const x = (GAME_WIDTH - width) / 2; const y = (GAME_HEIGHT - height) / 2; const panel = scene.add.rectangle(x, y, width, height, PANEL, 0.96).setOrigin(0, 0); panel.setStrokeStyle(1.5, ACCENT, 0.55); layer.add(panel); // Corner ticks — cheap, and they do most of the work of making a rectangle // read as a HUD element rather than a dialog box. const ticks = scene.add.graphics(); ticks.lineStyle(2.5, ACCENT, 0.9); const t = 26; for (const [cx, cy, dx, dy] of [ [x, y, 1, 1], [x + width, y, -1, 1], [x, y + height, 1, -1], [x + width, y + height, -1, -1], ]) { ticks.lineBetween(cx, cy, cx + dx * t, cy); ticks.lineBetween(cx, cy, cx, cy + dy * t); } layer.add(ticks); const head = scene.add.text(x + 30, y + 22, title.toUpperCase(), { fontFamily: FONT, fontSize: '30px', color: '#cfe8ff', }); layer.add(head); const rule = scene.add.rectangle(x + 30, y + 64, width - 60, 1, ACCENT, 0.4).setOrigin(0, 0); layer.add(rule); // Some screens (a forced choice prompt, say) opt out of the ✕ entirely — // closable defaults true so every existing caller is unaffected. if (closable) { const close = new Button(scene, x + width - 70, y + 40, '✕', () => { playSound(scene, SFX.VEGA_CLOSE); layer.destroy(); onClose?.(); }, { width: 46, height: 40, variant: 'ghost' }); layer.add(close); } return { layer, x, y, width, height, body: { x: x + 30, y: y + 84, w: width - 60, h: height - 120 }, add: (obj) => layer.add(obj), destroy: () => layer.destroy(), }; } /** * Click-to-enlarge a portrait thumbnail. Sits at D.detail so it clears the * Leaders modal (D.modal) without either having to be torn down; a click * anywhere (veil or portrait) closes it, same one-shot-overlay feel as * openListPicker. */ function openPortraitZoom(scene, art, frame, name) { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const box = scene.add.container(0, 0).setDepth(D.detail); const close = () => { playSound(scene, SFX.VEGA_CLOSE); box.destroy(); }; box.add(scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.82) .setOrigin(0.5).setInteractive({ useHandCursor: true }).on('pointerdown', close)); const size = 420; box.add(scene.add.rectangle(cx, cy, size + 20, size + 20, PANEL, 0.98).setStrokeStyle(2, ACCENT)); box.add(scene.add.image(cx, cy, art.leaders, frame).setDisplaySize(size, size) .setInteractive({ useHandCursor: true }).on('pointerdown', close)); if (name) { box.add(scene.add.text(cx, cy + size / 2 + 30, name, { fontFamily: FONT, fontSize: '20px', color: '#ffd88a', }).setOrigin(0.5)); } return box; } /** A labelled horizontal slider. Returns { container, setValue }. */ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) { const c = scene.add.container(x, y); const text = scene.add.text(0, 0, label, { fontFamily: FONT, fontSize: '17px', color: '#a8c4e0' }); c.add(text); const pct = scene.add.text(w, 0, `${Math.round(value * 100)}%`, { fontFamily: FONT, fontSize: '17px', color: '#e8f4ff', }).setOrigin(1, 0); c.add(pct); const trackY = 28; const track = scene.add.rectangle(0, trackY, w, 8, 0x1b2b42).setOrigin(0, 0.5); c.add(track); const fill = scene.add.rectangle(0, trackY, w * value, 8, colour).setOrigin(0, 0.5); c.add(fill); const knob = scene.add.circle(w * value, trackY, 9, 0xe8f4ff); c.add(knob); // The hit zone is a plain rectangle placed by its own top-left, NOT the // container — a Container's origin is locked at 0.5 and its hit area is // always centred, which is the classic way to get an unclickable widget here. const zone = scene.add.rectangle(0, trackY, w, 30, 0xffffff, 0.001) .setOrigin(0, 0.5).setInteractive({ useHandCursor: true, draggable: true }); c.add(zone); // Modal shells are containers sitting at the origin, so the slider // container's own x IS its world x and pointer maths needs no walk up the // parent chain. const apply = (px) => { const v = Phaser.Math.Clamp(px / w, 0, 1); fill.width = w * v; knob.x = w * v; pct.setText(`${Math.round(v * 100)}%`); onChange?.(v); }; zone.on('pointerdown', (p) => apply(p.x - c.x)); scene.input.setDraggable(zone); zone.on('drag', (p) => apply(p.x - c.x)); return { container: c, setValue(v) { fill.width = w * v; knob.x = w * v; pct.setText(`${Math.round(v * 100)}%`); }, }; } // -------------------------------------------------------------------------- /** * A simple two-card picker for the player's pre-battle formation strategy * (VegaFormations.js). The opposing side always picks silently — an unset * side falls back to VegaCombatV2.createBattle's own seeded-random choice — * this screen only ever asks about the player's own side. Shared by * VegaCombatSim.js's dev tool (closable, a "real quick" testing aid) and the * real pre-battle flow (MasterOfVegaGame.js's playPlayerBattles, forced — * `closable: false`, since there is no sensible "cancel" once fleets are * already committed to a fight this turn). */ export function openFormationPicker(scene, onPick, { closable = true } = {}) { const shell = modalShell(scene, 'Choose Formation Strategy', null, { width: 980, height: 420, closable }); const { body } = shell; shell.add(scene.add.text(body.x + body.w / 2, body.y, "Your fleet's tactical doctrine for this battle. The enemy fleet picks its own, silently.", { fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', align: 'center', }).setOrigin(0.5, 0)); const cardW = (body.w - 40) / 2; const cardY = body.y + body.h / 2 + 20; FORMATION_STRATEGIES.forEach((f, i) => { const cx = body.x + cardW / 2 + i * (cardW + 40); const card = scene.add.rectangle(cx, cardY, cardW, 160, PANEL, 0.7).setStrokeStyle(2, ACCENT, 0.9); shell.add(card); shell.add(scene.add.text(cx, cardY - 48, f.name, { fontFamily: FONT, fontSize: '22px', color: '#cfe8ff', }).setOrigin(0.5)); shell.add(scene.add.text(cx, cardY - 4, f.desc, { fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', align: 'center', wordWrap: { width: cardW - 40 }, }).setOrigin(0.5, 0)); const btn = new Button(scene, cx, cardY + 55, 'Choose', uiClick(scene, () => { shell.destroy(); onPick(f.id); }), { width: 160, height: 44, fontSize: 16 }); shell.add(btn); }); return shell; } // -------------------------------------------------------------------------- // Research has its own file, VegaResearchScreen.js — it grew past this // module's shared factory-function pattern the same way Audience and the // colony-founding vignette already had. export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged) { const shell = modalShell(scene, 'Diplomacy', onClose, { width: 1280, height: 780 }); const emp = state.empires[e]; const others = state.empires.filter((o) => o.alive && o.idx !== e && emp.contacted[o.idx]); if (!others.length) { shell.add(scene.add.text(shell.body.x, shell.body.y, 'You have not yet met another empire.', { fontFamily: FONT, fontSize: '20px', color: '#8fa8c0', })); return shell; } const rowH = Math.min(150, shell.body.h / others.length); others.forEach((other, i) => { const y = shell.body.y + i * rowH; const spec = rules.species[other.speciesId]; shell.add(makeSpeciesPortrait(scene, rules, art, other.speciesId, shell.body.x + 52, y + rowH / 2, 96)); const att = attitudeOf(state, other.idx, e); const treaty = emp.treaties[other.idx] ?? 'none'; const name = scene.add.text(shell.body.x + 120, y + 18, `${spec.name}`, { fontFamily: FONT, fontSize: '24px', color: other.color, }); shell.add(name); const status = scene.add.text(shell.body.x + 120, y + 52, `${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)}) · ` + `${empireColonies(state, other.idx).length} colonies · power ${Math.round(powerOf(rules, state, other.idx))}`, { fontFamily: FONT, fontSize: '16px', color: '#9fb6cc', }); shell.add(status); const bio = scene.add.text(shell.body.x + 120, y + 78, spec.desc, { fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: 620 }, }); shell.add(bio); const bx = shell.body.x + shell.body.w - 150; const canSeek = canNegotiate(rules, state, e, other.idx); // A diplomacy-incapable species (Lithox: traits.diplomacy <= -100, same // check canNegotiate itself makes) can never be negotiated with, so // "Seek Audience" would just sit permanently disabled — no way to ever // go to war OR back out of one. This replaces it with a direct, // unilateral toggle for exactly that case (Brian's ask, after finding // the player had no path to war with Lithox at all): declareWar/ // makePeace are the same functions the Audience screen's own Declare // War / accepted Sue for Peace already call, reused as-is rather than // inventing a parallel relationship state — this only changes WHO can // trigger them and how (instantly, no negotiation), not what they do. const incapable = (spec.traits.diplomacy ?? 0) <= -100; if (incapable) { const atWarNow = treaty === 'war'; const wb = new Button(scene, bx, y + rowH / 2 - 20, atWarNow ? 'Cease Hostilities' : 'Declare War', uiClick(scene, () => { if (atWarNow) makePeace(rules, state, e, other.idx); else declareWar(rules, state, e, other.idx); // Tear down and reopen THIS screen with the same onClose/onChanged // — not calling onClose itself — so the player stays on the // diplomacy list and immediately sees the updated stance/status // line, instead of the whole modal closing the way clicking Seek // Audience does. shell.destroy(); openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged); onChanged?.(); }), { width: 200, height: 40, variant: atWarNow ? 'ghost' : 'solid' }); shell.add(wb); } else { const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => { shell.destroy(); // Same per-race ducking runAudienceQueue does for an AI-initiated // audience (MasterOfVegaGame.js) — only drop back to peace on close if // nothing else is queued up behind this one. scene.music?.setDiplomacy(other.speciesId); openAudienceScreen(scene, rules, state, e, other.idx, art, () => { if (!scene.pendingAudiences?.length) scene.music?.setDiplomacy(null); onClose?.(); }, onChanged); }), { width: 200, height: 40 }); if (!canSeek) b.setEnabled(false); shell.add(b); } }); return shell; } // -------------------------------------------------------------------------- export function openCouncilScreen(scene, rules, state, onClose) { const shell = modalShell(scene, 'Galactic Council', onClose, { width: 900, height: 620 }); const r = state.council.lastResult; if (!r) { shell.add(scene.add.text(shell.body.x, shell.body.y, 'The Council has not yet convened.', { fontFamily: FONT, fontSize: '20px', color: '#8fa8c0', })); return shell; } let y = shell.body.y; const need = Math.ceil(r.totalPop * rules.council.winFraction); shell.add(scene.add.text(shell.body.x, y, `Session of ${2300 + r.turn} — ${need} votes of ${Math.round(r.totalPop)} needed`, { fontFamily: FONT, fontSize: '18px', color: '#9fb6cc', })); y += 46; for (const idx of r.candidates ?? []) { const emp = state.empires[idx]; const v = r.votes[idx] ?? 0; shell.add(scene.add.text(shell.body.x, y, emp.name, { fontFamily: FONT, fontSize: '24px', color: emp.color, })); const bar = scene.add.rectangle(shell.body.x, y + 34, shell.body.w, 16, 0x1b2b42).setOrigin(0, 0); shell.add(bar); const fill = scene.add.rectangle(shell.body.x, y + 34, shell.body.w * Phaser.Math.Clamp(v / Math.max(1, r.totalPop), 0, 1), 16, Phaser.Display.Color.HexStringToColor(emp.color).color).setOrigin(0, 0); shell.add(fill); shell.add(scene.add.text(shell.body.x + shell.body.w, y + 4, `${Math.round(v)}`, { fontFamily: FONT, fontSize: '20px', color: '#e8f4ff', }).setOrigin(1, 0)); y += 76; } shell.add(scene.add.text(shell.body.x, y, `Abstained: ${Math.round(r.abstained)}`, { fontFamily: FONT, fontSize: '17px', color: '#7f97b3', })); y += 40; const verdict = r.winner >= 0 ? `${state.empires[r.winner].name} is elected High Guardian of the Galaxy.` : (r.refused ? 'The defeated candidate REFUSES TO SUBMIT. The election is void, and the matter will be settled by war.' : 'No candidate reached the required majority. The Council adjourns.'); shell.add(scene.add.text(shell.body.x, y, verdict, { fontFamily: FONT, fontSize: '19px', color: r.winner >= 0 ? '#ffd88a' : '#e08a8a', wordWrap: { width: shell.body.w }, })); return shell; } // -------------------------------------------------------------------------- /** "3× Cruiser, 2× Frigate (at Vega Prime)" / "(en route to Vega Prime)". */ function fleetLabel(rules, state, f) { const ships = f.ships.filter((s) => s.count > 0) .map((s) => `${s.count}× ${rules.hulls[s.hullId]?.name}`).join(', ') || 'empty fleet'; const where = f.starIdx >= 0 ? `at ${state.galaxy.stars[f.starIdx]?.name}` : `en route to ${state.galaxy.stars[f.toStar]?.name ?? '?'}`; return `${ships} (${where})`; } /** A vertical list picker overlaid on `shell` — same mechanism as * VegaAudience.js's pickFromList, generalised to any {label, onPick} list * since this screen has no existing picker of its own. */ function openListPicker(scene, shell, title, items) { const bx = GAME_WIDTH / 2; const by = GAME_HEIGHT / 2; const box = scene.add.container(0, 0); shell.add(box); const h = Math.min(520, 100 + Math.max(items.length, 1) * 34); box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive()); box.add(scene.add.rectangle(bx, by, 460, h, PANEL, 0.98).setStrokeStyle(2, ACCENT)); box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5)); if (!items.length) { box.add(scene.add.text(bx, by, 'Nothing available.', { fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', }).setOrigin(0.5)); } items.slice(0, 12).forEach(([label, fn], i) => { const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, label, { fontFamily: FONT, fontSize: '16px', color: '#e8f4ff', }).setOrigin(0.5).setInteractive({ useHandCursor: true }); t.on('pointerover', () => t.setColor('#ffd88a')); t.on('pointerout', () => t.setColor('#e8f4ff')); t.on('pointerdown', () => { box.destroy(true); fn(); }); box.add(t); }); const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)), { width: 160, height: 40, fontSize: 16, variant: 'ghost' }); box.add(cancel); } export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged) { const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 }); const emp = state.empires[e]; shell.add(scene.add.text(shell.body.x, shell.body.y, 'AVAILABLE FOR HIRE', { fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', })); let y = shell.body.y + 34; for (const offer of leaderOffers(rules, state, e)) { const cost = leaderHireCost(rules, state, e, offer.id); const affordable = emp.bc >= cost; shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame) .setDisplaySize(60, 60).setInteractive({ useHandCursor: true }) .on('pointerdown', () => openPortraitZoom(scene, art, offer.portraitFrame, offer.name))); shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name} — ${offer.kind}`, { fontFamily: FONT, fontSize: '19px', color: '#e8f4ff', })); shell.add(scene.add.text(shell.body.x + 76, y + 30, offer.bio, { fontFamily: FONT, fontSize: '14px', color: '#7f97b3', wordWrap: { width: 640 }, })); shell.add(scene.add.text(shell.body.x + 76, y + 50, Object.entries(offer.skills).map(([k, v]) => `${k} ${v}`).join(' · '), { fontFamily: FONT, fontSize: '13px', color: '#7fd8a0', })); const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30, `Hire ${cost} BC`, uiClick(scene, () => { if (hireLeader(rules, state, e, offer.id)) { shell.destroy(); openLeaderScreen(scene, rules, state, e, art, onClose, onChanged); onChanged?.(); } }), { width: 190, height: 38, variant: affordable ? 'solid' : 'ghost' }); shell.add(b); y += 84; } y += 20; shell.add(scene.add.text(shell.body.x, y, 'IN YOUR SERVICE', { fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', })); y += 34; if (!emp.leaders.length) { shell.add(scene.add.text(shell.body.x, y, 'None. Leaders take a posting before they do anything.', { fontFamily: FONT, fontSize: '16px', color: '#6f8aa3', })); } for (const l of emp.leaders) { const def = rules.leaders[l.leaderId]; let where = 'unassigned'; if (l.assignKind === 'colony') { const c = empireColonies(state, e).find((c2) => c2.id === l.assignId); where = c ? `${c.name} (${state.galaxy.stars[c.starIdx]?.name})` : 'unassigned'; } else if (l.assignKind === 'fleet') { const f = empireFleets(state, e).find((f2) => f2.id === l.assignId); where = f ? fleetLabel(rules, state, f) : 'unassigned'; } shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame) .setDisplaySize(40, 40).setInteractive({ useHandCursor: true }) .on('pointerdown', () => openPortraitZoom(scene, art, def.portraitFrame, def.name))); shell.add(scene.add.text(shell.body.x + 56, y + 6, `${def.name} — ${def.kind} — ${where} — ${def.upkeep} BC/turn`, { fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', wordWrap: { width: shell.body.w - 320 }, })); // Posting picker: admins list colonies, captains list fleets. Re-opens // the whole screen on pick/unassign, same refresh pattern the Hire // button already uses above. const refresh = () => { shell.destroy(); openLeaderScreen(scene, rules, state, e, art, onClose, onChanged); onChanged?.(); }; const openPicker = () => { const targets = def.kind === 'admin' ? empireColonies(state, e).map((c) => [ `${c.name} (${state.galaxy.stars[c.starIdx]?.name})`, () => { assignLeader(rules, state, e, l.leaderId, 'colony', c.id); refresh(); }, ]) : empireFleets(state, e).filter((f) => f.starIdx >= 0 || f.toStar >= 0).map((f) => [ fleetLabel(rules, state, f), () => { assignLeader(rules, state, e, l.leaderId, 'fleet', f.id); refresh(); }, ]); openListPicker(scene, shell, def.kind === 'admin' ? 'POST TO WHICH COLONY?' : 'POST TO WHICH FLEET?', targets); }; shell.add(new Button(scene, shell.body.x + shell.body.w - 110, y + 20, l.assignKind ? 'Reassign' : 'Assign', uiClick(scene, openPicker), { width: 118, height: 32, fontSize: 14 })); if (l.assignKind) { shell.add(new Button(scene, shell.body.x + shell.body.w - 250, y + 20, 'Unassign', uiClick(scene, () => { unassignLeader(rules, state, e, l.leaderId); refresh(); }), { width: 118, height: 32, fontSize: 14, variant: 'ghost' })); } y += 44; } return shell; } // -------------------------------------------------------------------------- /** "Slot 3 — Ursaal Empire (Ursaal) — Year 2431, Turn 131 — 8/8/2026 3:45 PM". */ function saveSlotLabel(i, meta) { if (!meta) return `Slot ${i + 1} — empty`; const d = new Date(meta.savedAt); const when = `${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; return `Slot ${i + 1} — ${meta.empireName} (${meta.speciesName}) — Year ${meta.year}, Turn ${meta.turn} — ${when}`; } /** * Write the current game to one of 10 slots. `getSlots()` returns a * length-10 array of meta objects (or null for an empty slot), re-read on * every (re)render so a just-written slot shows up immediately. Overwriting * an occupied slot needs a confirm click first — `confirmIdx` carries that * transient state across the destroy+rebuild refresh, same pattern * openLeaderScreen uses for its own picker flows. */ export function openSaveScreen(scene, rules, state, { getSlots, onSave }, onClose, confirmIdx = -1) { const shell = modalShell(scene, 'Save Game', onClose, { width: 1000, height: 800 }); shell.add(scene.add.text(shell.body.x, shell.body.y, 'Choose a slot to save your current game to.', { fontFamily: FONT, fontSize: '15px', color: '#7f97b3', })); const refresh = (nextConfirmIdx = -1) => { shell.destroy(); openSaveScreen(scene, rules, state, { getSlots, onSave }, onClose, nextConfirmIdx); }; const ROW_H = 66; // Load's row (Load + Delete buttons) leaves less room than Save's (one // button) — sized to the tighter of the two so a long empire/species name // never runs under either screen's buttons. const LABEL_W = shell.body.w - 340; let y = shell.body.y + 34; getSlots().forEach((meta, i) => { shell.add(scene.add.text(shell.body.x, y + ROW_H / 2, saveSlotLabel(i, meta), { fontFamily: FONT, fontSize: '15px', color: meta ? '#c8dcf0' : '#5f7890', wordWrap: { width: LABEL_W }, lineSpacing: 2, }).setOrigin(0, 0.5)); if (confirmIdx === i) { shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Confirm Overwrite', uiClick(scene, () => { onSave(i); refresh(); }), { width: 200, height: 40, fontSize: 14 })); shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Cancel', uiClick(scene, () => refresh()), { width: 180, height: 40, fontSize: 14, variant: 'ghost' })); } else { shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, meta ? 'Overwrite' : 'Save', uiClick(scene, () => { if (meta) refresh(i); else { onSave(i); refresh(); } }), { width: 180, height: 40, fontSize: 15, variant: meta ? 'ghost' : 'solid' })); } y += ROW_H; }); return shell; } /** * Load or delete one of the 10 slots. Deleting needs the same confirm-click * as an overwrite in openSaveScreen; loading doesn't (it's not destructive * to anything but the current in-progress session, and the caller is about * to tear that down regardless — see MasterOfVegaGame.openLoadMenu). */ export function openLoadScreen(scene, rules, state, { getSlots, onLoad, onDelete }, onClose, confirmDeleteIdx = -1) { const shell = modalShell(scene, 'Load Game', onClose, { width: 1000, height: 800 }); shell.add(scene.add.text(shell.body.x, shell.body.y, 'Choose a saved game to load or delete.', { fontFamily: FONT, fontSize: '15px', color: '#7f97b3', })); const refresh = (nextConfirmDeleteIdx = -1) => { shell.destroy(); openLoadScreen(scene, rules, state, { getSlots, onLoad, onDelete }, onClose, nextConfirmDeleteIdx); }; const ROW_H = 66; const LABEL_W = shell.body.w - 340; let y = shell.body.y + 34; getSlots().forEach((meta, i) => { shell.add(scene.add.text(shell.body.x, y + ROW_H / 2, saveSlotLabel(i, meta), { fontFamily: FONT, fontSize: '15px', color: meta ? '#c8dcf0' : '#5f7890', wordWrap: { width: LABEL_W }, lineSpacing: 2, }).setOrigin(0, 0.5)); if (!meta) { y += ROW_H; return; } if (confirmDeleteIdx === i) { shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Confirm Delete', uiClick(scene, () => { onDelete(i); refresh(); }), { width: 200, height: 40, fontSize: 14 })); shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Cancel', uiClick(scene, () => refresh()), { width: 180, height: 40, fontSize: 14, variant: 'ghost' })); } else { shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Load', uiClick(scene, () => onLoad(i)), { width: 180, height: 40, fontSize: 15 })); shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Delete', uiClick(scene, () => refresh(i)), { width: 180, height: 40, fontSize: 15, variant: 'ghost' })); } y += ROW_H; }); return shell; } // -------------------------------------------------------------------------- export function showVictoryOverlay(scene, rules, state, onClose) { const layer = scene.add.container(0, 0).setDepth(D.toast); layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.9) .setOrigin(0, 0).setInteractive()); const winner = state.winnerIdx >= 0 ? state.empires[state.winnerIdx] : null; const human = state.humanIndex >= 0 ? state.empires[state.humanIndex] : null; const won = winner && human && winner.idx === human.idx; const title = won ? 'VICTORY' : 'DEFEAT'; layer.add(scene.add.text(GAME_WIDTH / 2, 300, title, { fontFamily: FONT, fontSize: '92px', color: won ? '#ffd88a' : '#e08a8a', }).setOrigin(0.5)); const kind = { conquest: 'by conquest — the galaxy holds no rival', council: 'by acclamation of the Galactic Council', timeout: 'by dominance when the age ended', }[state.victoryKind] ?? ''; layer.add(scene.add.text(GAME_WIDTH / 2, 400, winner ? `${winner.name} ${kind}` : 'The galaxy is empty.', { fontFamily: FONT, fontSize: '28px', color: '#cfe8ff', }).setOrigin(0.5)); layer.add(scene.add.text(GAME_WIDTH / 2, 470, `Year ${2300 + state.turn}`, { fontFamily: FONT, fontSize: '22px', color: '#7f97b3', }).setOrigin(0.5)); // Buttons in this repo draw centred, so they are positioned by their centre. const b = new Button(scene, GAME_WIDTH / 2, 560, 'Return to menu', uiClick(scene, () => { layer.destroy(); onClose?.(); }), { width: 220, height: 52 }); layer.add(b); return layer; }