// 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 '../../ui/Button.js'; import { empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost, } from './VegaLogic.js'; import { attitudeOf, moodOf, powerOf, canNegotiate, } from './VegaDiplomacy.js'; import { leaderOffers } from './VegaLeaders.js'; import { makeSpeciesPortrait } from './VegaArt.js'; import { openAudienceScreen } from './VegaAudience.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. `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, 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; /** 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, '✕', () => { 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(), }; } /** 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)}%`); }, }; } // -------------------------------------------------------------------------- // 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); const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', () => { shell.destroy(); openAudienceScreen(scene, rules, state, e, other.idx, art, 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', () => 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)); 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`, () => { 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)); 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', 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', () => { unassignLeader(rules, state, e, l.leaderId); refresh(); }, { width: 118, height: 32, fontSize: 14, variant: 'ghost' })); } y += 44; } 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', () => { layer.destroy(); onClose?.(); }, { width: 220, height: 52 }); layer.add(b); return layer; }