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

434 lines
18 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 '../../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;
}