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

659 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Master of Vega — 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,
zone, // exposed so callers can tooltip.attachTo(s.zone, ...)
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;
}