658 lines
29 KiB
JavaScript
658 lines
29 KiB
JavaScript
// 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;
|
||
}
|