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

796 lines
30 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 — the Phaser scene.
//
// The engine (VegaLogic) is headless; this scene drives it and renders through
// VegaStarMap. AI empires play via VegaAI. The scene owns three things and
// nothing else: the setup screen, the HUD, and the turn driver.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js';
import { compileRules, turnToYear } from './VegaRules.js';
import {
ensureSheets, makeSpeciesPortrait, sizeSpeciesPortrait, speciesSpeechClip, UI_SPEECH,
} from './VegaArt.js';
import * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js';
import VegaStarMap from './VegaStarMap.js';
import VegaSidePanel from './VegaSidePanel.js';
import VegaFx from './VegaFx.js';
import { openSystemView } from './VegaSystemView.js';
import { openCombatView } from './VegaCombatView.js';
import {
FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
showVictoryOverlay,
} from './VegaScreens.js';
const SAVE_KEY = 'mastervega-save';
export default class MasterOfVegaGame extends Phaser.Scene {
constructor() { super('MasterOfVegaGame'); }
init(data) {
this.roomData = data ?? {};
// getGameSoundtrack() reads scene.gameDef.slug to pick the track list.
this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
this.modalOpen = false;
this.busy = false;
}
create() {
this.cameras.main.setBackgroundColor('#03060d');
this.rules = compileRules(this.cache.json.get('mastervega-rules'));
const artwork = this.cache.json.get('mastervega-artwork') ?? { sheets: {} };
const { keys, procedural } = ensureSheets(this, this.rules, artwork);
this.art = keys;
if (procedural.length) {
console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`);
}
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks?.length) this.music = new MusicPlayer(this, tracks, volume);
} catch (err) { /* music is optional */ }
this.events.once('shutdown', () => this.teardown());
this.showLanding();
}
// Wrap a menu handler so it clicks. Every button and card on the front-end
// screens goes through this, so the cue can never drift out of sync with what
// is actually clickable.
uiClick(fn) {
return (...args) => {
playSound(this, SFX.EIGHTBIT_ACTIVATE);
fn?.(...args);
};
}
teardown() {
resetSpeechQueue();
this.panel?.destroy();
this.map?.destroy();
this.fx?.destroy();
this.music?.destroy?.();
}
// -------------------------------------------------------------- backdrop
// Shared by the landing screen and the setup screen. `dim` is how hard to
// darken the artwork: the landing screen wants to show it off, the setup
// screen has to keep ten species cards legible on top of it.
paintBackdrop(layer, dim) {
if (this.textures.exists('vega-menu-bg')) {
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg');
bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
layer.add(bg);
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, dim).setOrigin(0, 0));
} else {
// The whole game runs with zero art files; the menus are no exception.
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0));
}
}
// Draw the title logo scaled to CONTAIN a box, never stretched — the source
// is a large title card and its aspect has to survive.
paintTitle(layer, cx, cy, boxW, boxH, fallbackSize) {
if (this.textures.exists('vega-menu-title')) {
const t = this.add.image(cx, cy, 'vega-menu-title').setOrigin(0.5);
const src = this.textures.get('vega-menu-title').getSourceImage();
t.setScale(Math.min(boxW / src.width, boxH / src.height));
layer.add(t);
return t;
}
const t = this.add.text(cx, cy, 'MASTER OF VEGA', {
fontFamily: FONT, fontSize: `${fallbackSize}px`, color: '#cfe8ff', align: 'center',
}).setOrigin(0.5);
layer.add(t);
return t;
}
// --------------------------------------------------------------- landing
showLanding() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.landingLayer = layer;
this.paintBackdrop(layer, 0.34);
// Logo large on the left.
this.paintTitle(layer, 580, 430, 900, 560, 76);
// Buttons well below it, over on the right.
const bx = 1420;
let by = 640;
const gap = 104;
const newGame = new Button(this, bx, by, 'New Game', this.uiClick(() => {
layer.destroy();
this.showSetup();
}), { width: 420, height: 78, fontSize: 30 });
layer.add(newGame);
by += gap;
const saved = this.readSave();
const resume = new Button(this, bx, by, 'Resume Game', this.uiClick(() => {
if (!saved) return;
layer.destroy();
this.beginGame(null, saved);
}), { width: 420, height: 78, fontSize: 30 });
// setEnabled(false) both dims it and drops its input, so a missing save
// reads as unavailable rather than as a button that silently does nothing.
if (!saved) resume.setEnabled(false);
layer.add(resume);
by += gap;
const quit = new Button(this, bx, by, 'Return to Arcade', this.uiClick(() => {
this.scene.start('GameMenu');
}), { width: 420, height: 78, fontSize: 30 });
layer.add(quit);
}
// ------------------------------------------------------- species detail
/**
* Grow a species card into a centred pop-over with full-size art and
* readable type, then commit or discard the choice.
*
* The portrait is REPARENTED out of the card rather than a second one being
* created. Two Phaser Video objects on one cached video is not a
* configuration worth betting on — if they share an element, destroying the
* pop-over's copy would kill the card's — and moving the one that already
* exists sidesteps the question entirely while halving the decoding.
*/
openSpeciesDetail(parentLayer, spec, card, portrait, onSelect) {
if (this.speciesDetailOpen) return;
this.speciesDetailOpen = true;
const CARD_PORTRAIT = { x: 52, y: 62, size: 84 };
const PANEL_W = 860;
const PANEL_H = 640;
const halfW = PANEL_W / 2;
const halfH = PANEL_H / 2;
const accent = Phaser.Display.Color.HexStringToColor(spec.color).color;
const veil = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0)
.setOrigin(0, 0).setInteractive();
parentLayer.add(veil);
this.tweens.add({ targets: veil, fillAlpha: 0.78, duration: 200 });
// Built at full size, then scaled down to the card and grown — so the type
// is rendered crisp at its final size instead of being a magnified
// thumbnail.
const pop = this.add.container(card.x + CARD_PORTRAIT.x, card.y + CARD_PORTRAIT.y);
pop.setScale(0.18).setAlpha(0.35);
parentLayer.add(pop);
const panel = this.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, 0x0b1220, 0.97).setOrigin(0, 0);
panel.setStrokeStyle(2, accent, 0.85);
pop.add(panel);
const ticks = this.add.graphics();
ticks.lineStyle(3, accent, 0.9);
const t = 30;
for (const [tx, ty, dx, dy] of [
[-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1], [-halfW, halfH, 1, -1], [halfW, halfH, -1, -1],
]) {
ticks.lineBetween(tx, ty, tx + dx * t, ty);
ticks.lineBetween(tx, ty, tx, ty + dy * t);
}
pop.add(ticks);
// The portrait moves house, at its full 256px.
card.remove(portrait);
portrait.setPosition(-halfW + 168, -halfH + 188);
sizeSpeciesPortrait(portrait, 256);
pop.add(portrait);
const colX = -100;
const colW = PANEL_W - 40 - (colX + halfW);
let y = -halfH + 46;
pop.add(this.add.text(colX, y, spec.name, {
fontFamily: FONT, fontSize: '44px', color: spec.color,
}));
y += 62;
const heading = (label) => {
pop.add(this.add.text(colX, y, label, {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 24;
};
const bullets = (lines, colour) => {
for (const line of lines) {
const item = this.add.text(colX, y, `· ${line}`, {
fontFamily: FONT, fontSize: '20px', color: colour, wordWrap: { width: colW },
});
pop.add(item);
y += item.height + 6;
}
y += 10;
};
heading('STRENGTHS');
bullets(spec.strengths, '#7fd8a0');
heading('WEAKNESSES');
bullets(spec.weaknesses, '#e08a8a');
pop.add(this.add.text(-halfW + 40, halfH - 220, spec.desc, {
fontFamily: FONT, fontSize: '19px', color: '#a8c4e0',
wordWrap: { width: PANEL_W - 80 }, lineSpacing: 4,
}));
pop.add(this.add.text(-halfW + 40, halfH - 128, `Homeworld: ${this.rules.planetTypes[spec.homeworld].name}`, {
fontFamily: FONT, fontSize: '17px', color: '#7f97b3',
}));
// --- close, either way
let closing = false;
const close = (commit) => {
if (closing) return;
closing = true;
if (commit) onSelect?.();
// The window is going away, so the voice goes with it.
resetSpeechQueue();
this.tweens.add({ targets: veil, fillAlpha: 0, duration: 180 });
this.tweens.add({
targets: pop,
x: card.x + CARD_PORTRAIT.x,
y: card.y + CARD_PORTRAIT.y,
scale: 0.18,
alpha: 0.25,
duration: 200,
ease: 'Cubic.easeIn',
onComplete: () => {
// Hand the portrait back BEFORE the pop-over is destroyed, or it
// would be destroyed along with it and the card left empty.
pop.remove(portrait);
portrait.setPosition(CARD_PORTRAIT.x, CARD_PORTRAIT.y);
sizeSpeciesPortrait(portrait, CARD_PORTRAIT.size);
card.add(portrait);
pop.destroy();
veil.destroy();
this.speciesDetailOpen = false;
},
});
};
pop.add(new Button(this, -160, halfH - 62, 'Select Species', this.uiClick(() => close(true)),
{ width: 300, height: 62, fontSize: 24 }));
pop.add(new Button(this, 170, halfH - 62, 'Cancel', this.uiClick(() => close(false)),
{ width: 220, height: 62, fontSize: 24, variant: 'ghost' }));
veil.on('pointerup', this.uiClick(() => close(false)));
this.tweens.add({
targets: pop,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
});
// The species introduces itself. resetQueue first so opening a second card
// cuts the first one off instead of letting two voices talk over each
// other, and `force` so the line plays even if the queue was full.
resetSpeechQueue();
enqueueSpeech(speciesSpeechClip(spec.id), null, { force: true });
}
// ---------------------------------------------------------------- setup
showSetup() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.setupLayer = layer;
this.paintBackdrop(layer, 0.62);
this.paintTitle(layer, GAME_WIDTH / 2, 74, 760, 132, 58);
layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', {
fontFamily: FONT, fontSize: '20px', color: '#7f97b3',
}).setOrigin(0.5));
// Spoken greeting on arrival. resetQueue first so stepping back to the
// landing screen and returning does not stack a second copy on top of a
// line that is still running.
resetSpeechQueue();
enqueueSpeech(UI_SPEECH.chooseSpecies, null, { force: true });
const choice = {
speciesId: 'human',
sizeId: 'medium',
shapeId: 'spiral',
difficultyId: 'normal',
empires: 4,
};
// --- species picker
const cols = 5;
const cardW = 300;
const cardH = 190;
const gridX = (GAME_WIDTH - cols * cardW) / 2;
const cards = [];
this.rules.speciesList.forEach((spec, i) => {
const cx = gridX + (i % cols) * cardW;
const cy = 182 + Math.floor(i / cols) * cardH;
const card = this.add.container(cx, cy);
const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0);
bg.setStrokeStyle(1.5, 0x24405f, 1);
card.add(bg);
const portrait = makeSpeciesPortrait(this, this.rules, this.art, spec.id, 52, 62, 84);
card.add(portrait);
card.add(this.add.text(104, 16, spec.name, {
fontFamily: FONT, fontSize: '22px', color: spec.color,
}));
card.add(this.add.text(104, 46, spec.strengths.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#7fd8a0', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(104, 100, spec.weaknesses.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#e08a8a', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(14, 150, spec.desc, {
fontFamily: FONT, fontSize: '11px', color: '#6f8aa3', wordWrap: { width: cardW - 40 },
}));
// The hit zone is a top-left rectangle of its own, never the container —
// a Container's hit area is always centred on its origin.
const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
zone.on('pointerup', this.uiClick(() => {
this.openSpeciesDetail(layer, spec, card, portrait, () => {
choice.speciesId = spec.id;
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
});
}));
card.add(zone);
layer.add(card);
cards.push({ bg, spec });
});
cards[0].bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
// --- option rows
const optY = 600;
const mkRow = (label, y, options, key, format = (o) => o.name) => {
layer.add(this.add.text(GAME_WIDTH / 2 - 620, y, label, {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const buttons = [];
options.forEach((opt, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), this.uiClick(() => {
choice[key] = opt.id ?? opt;
for (const other of buttons) other.setActive(false);
b.setActive(true);
}), { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true);
buttons.push(b);
layer.add(b);
});
};
mkRow('Galaxy', optY, this.rules.galaxySizeList, 'sizeId');
mkRow('Shape', optY + 62, this.rules.galaxyShapeList, 'shapeId');
mkRow('Difficulty', optY + 124, this.rules.difficultyList, 'difficultyId');
layer.add(this.add.text(GAME_WIDTH / 2 - 620, optY + 186, 'Empires', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const empButtons = [];
[2, 3, 4, 5, 6].forEach((n, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, this.uiClick(() => {
choice.empires = n;
for (const other of empButtons) other.setActive(false);
b.setActive(true);
}), { width: 195, height: 46, fontSize: 19 });
if (n === choice.empires) b.setActive(true);
empButtons.push(b);
layer.add(b);
});
const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', this.uiClick(() => {
layer.destroy();
this.beginGame(choice);
}), { width: 260, height: 64, fontSize: 30 });
layer.add(start);
// Resuming lives on the landing screen now, so this only steps back to it.
const back = new Button(this, 120, 60, '← Back', this.uiClick(() => {
layer.destroy();
this.showLanding();
}), { width: 170, height: 48, fontSize: 20, variant: 'ghost' });
layer.add(back);
}
// ----------------------------------------------------------------- game
beginGame(choice, savedState = null) {
if (savedState) {
this.state = savedState;
} else {
const cap = this.rules.galaxySizes[choice.sizeId].maxEmpires;
const count = Math.min(choice.empires, cap);
// The human's species first, then distinct rivals drawn from the rest.
const pool = this.rules.speciesList
.map((s) => s.id)
.filter((id) => id !== choice.speciesId);
// Math.random is fine here — determinism starts at the engine seed below.
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
const speciesIds = [choice.speciesId, ...pool.slice(0, count - 1)];
this.state = Logic.createGame(this.rules, {
sizeId: choice.sizeId,
shapeId: choice.shapeId,
difficultyId: choice.difficultyId,
seed: (Math.random() * 1e9) | 0,
speciesIds,
humanIndex: 0,
});
}
this.state.rules = this.rules;
this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1);
this.fx = new VegaFx(this, this.fxLayer);
this.map = new VegaStarMap(this, this.rules, this.state, this.art, {
onStarClick: (idx) => this.onStarClick(idx),
onFleetClick: (fleet) => this.onFleetClick(fleet),
onEmptyClick: () => this.clearSelection(),
blockWheel: () => this.modalOpen,
// A modal blocks the map outright; otherwise only the side panel's own
// footprint does (so dragging a slider there doesn't pan the galaxy
// underneath it). The old `!this.modalOpen && ...` form always
// short-circuited to false while a modal was open, which let drags
// pan the star map right through the System View window.
blockPointer: (p) => this.modalOpen || !!this.panel?.containsPoint(p.x, p.y),
});
// The command panel is the only place a selection is acted on; the scene
// just decides what is selected and hands it over.
this.panel = new VegaSidePanel(this, this.rules, this.state, this.art, {
viewerIdx: this.state.humanIndex,
onViewSystem: (idx) => this.openSystemViewFor(idx),
onSelectFleet: (fleet) => this.onFleetClick(fleet),
onAcceptOrder: (fleet, toStar, ships) => this.confirmOrder(fleet, toStar, ships),
onCancelOrder: () => { this.map?.clearRoutePreview(); this.panel.showFleet(this.selectedFleet); },
onSelectionLost: () => { this.selectedFleet = null; this.map?.setSelectedStar(-1); },
onClose: () => this.clearSelection(),
});
this.buildHud();
this.refreshHud();
this.log('The stars are yours to take.');
}
// ------------------------------------------------------------------ HUD
buildHud() {
const hud = this.add.container(0, 0).setDepth(D.hud);
this.hud = hud;
const bar = this.add.rectangle(0, 0, GAME_WIDTH, 62, 0x06101c, 0.92).setOrigin(0, 0);
bar.setStrokeStyle(1, 0x6fc4ff, 0.35);
hud.add(bar);
this.hudText = this.add.text(24, 18, '', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff' });
hud.add(this.hudText);
const mk = (label, x, fn) => {
const b = new Button(this, x, 31, label, fn, { width: 150, height: 42, fontSize: 18 });
hud.add(b);
return b;
};
mk('Research', GAME_WIDTH - 830, () => this.openModal((done) =>
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done)));
mk('Diplomacy', GAME_WIDTH - 670, () => this.openModal((done) =>
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshAll())));
mk('Council', GAME_WIDTH - 510, () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done)));
mk('Leaders', GAME_WIDTH - 350, () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud())));
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
{ width: 190, height: 46, fontSize: 20 });
hud.add(this.endTurnBtn);
// --- status log, bottom left
this.logLines = [];
this.logText = this.add.text(24, GAME_HEIGHT - 168, '', {
fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', lineSpacing: 3,
});
hud.add(this.logText);
const back = new Button(this, 96, GAME_HEIGHT - 40, '← Menu', () => {
this.writeSave();
this.scene.start('GameMenu');
}, { width: 150, height: 40, fontSize: 17, variant: 'ghost' });
hud.add(back);
}
refreshHud() {
const emp = this.state.empires[this.state.humanIndex];
if (!emp) return;
const cols = Logic.empireColonies(this.state, emp.idx);
this.hudText.setText(
`${emp.name} · Year ${turnToYear(this.state.turn)} · `
+ `${cols.length} colonies · ${Math.round(emp.totalPop)} population · `
+ `${Math.round(emp.bc)} BC (${emp.lastIncome >= 0 ? '+' : ''}${Math.round(emp.lastIncome ?? 0)}) · `
+ `${emp.techsKnown} technologies`,
);
}
log(line) {
this.logLines.push(line);
if (this.logLines.length > 8) this.logLines.shift();
this.logText?.setText(this.logLines.join('\n'));
}
refreshAll() {
this.map?.refresh();
this.panel?.refresh();
this.refreshHud();
}
// -------------------------------------------------------------- modals
openModal(factory) {
if (this.modalOpen) return;
this.modalOpen = true;
// Arm a click guard so the modal's own close button does not fall through
// to the star map underneath it.
factory(() => {
this.time.delayedCall(60, () => { this.modalOpen = false; });
this.refreshAll();
});
}
// Clicking a star means one of two things, and which one depends entirely on
// whether a fleet of ours is selected: with a fleet in hand the star is a
// destination and the panel asks for confirmation, otherwise it is just
// something to look at.
onStarClick(idx) {
if (this.modalOpen || this.busy) return;
// Clicking the system the fleet is already sitting in is not a move order —
// it is a request to look at the place, so the selection is dropped and the
// star's own panel comes up (which lists the fleet, one click from getting
// it back). Clicking anywhere else with a fleet in hand quotes a route: the
// fleet keeps its own ring and a flowing dashed line marks the course
// instead of ringing the destination star.
if (this.selectedFleet && this.state.fleets.includes(this.selectedFleet)
&& this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== idx) {
this.map.setRoutePreview(this.selectedFleet.starIdx, idx);
this.panel.showOrder(idx);
return;
}
this.map.setSelectedStar(idx);
this.selectedFleet = null;
this.panel.showStar(idx);
}
onFleetClick(fleet) {
if (this.modalOpen || this.busy) return;
// Someone else's fleet is not something we can give orders to, but the
// system it is sitting in is — clicking it reads as clicking that system,
// which with a fleet in hand is how an attack gets ordered.
if (fleet.empireIdx !== this.state.humanIndex) {
if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx);
return;
}
this.selectedFleet = fleet;
this.map.setSelectedFleet(fleet);
this.panel.showFleet(fleet);
}
clearSelection() {
if (this.modalOpen) return;
this.selectedFleet = null;
this.map?.setSelectedStar(-1);
this.panel?.hide();
}
openSystemViewFor(idx) {
const emp = this.state.empires[this.state.humanIndex];
// The system view stays closed until a scout has actually arrived; the
// panel is what an unexplored star has to say for itself.
if (emp && !emp.explored[idx]) return;
this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, {
viewerIdx: this.state.humanIndex,
onChanged: () => this.refreshAll(),
onClose: done,
}));
}
// The panel's Accept. `ships` is the task force the player dialled in — the
// ships left out stay behind as a fleet of their own.
confirmOrder(fleet, toStar, ships) {
const dest = this.state.galaxy.stars[toStar];
const sent = Logic.sendDetachment(this.rules, this.state, fleet, toStar, ships);
if (!sent) {
this.log('Out of fuel range. Research propulsion, or plant a colony closer.');
return;
}
const eta = Logic.fleetEta(this.rules, this.state, sent);
this.log(`Fleet away — ${dest.name} in ${eta} turn${eta === 1 ? '' : 's'}.`);
playSound(this, 'ta-rocket-1');
this.selectedFleet = null;
this.panel.hide();
this.map.setSelectedStar(toStar);
this.refreshAll();
}
// ----------------------------------------------------------- turn driver
onEndTurn() {
if (this.busy || this.modalOpen) return;
this.busy = true;
this.endTurnBtn.setEnabled(false);
this.writeSave();
// Move first, then hand any battle the player is involved in to the
// tactical view before the engine resolves the rest. endEmpireTurn is told
// not to move again.
Logic.moveFleetsFor(this.rules, this.state, this.state.humanIndex);
this.playPlayerBattles(() => {
Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, { skipMove: true });
this.runToHumanTurn();
});
}
// Fight the human's battles one at a time on the tactical screen. Each one is
// prepared by the engine, driven round by round by the view, and its outcome
// handed straight back — so a battle the player fights and one the AI
// auto-resolves go through exactly the same code.
playPlayerBattles(done) {
const me = this.state.humanIndex;
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
if (!pending.length) { done(); return; }
const next = (i) => {
if (i >= pending.length) { this.refreshAll(); done(); return; }
const { starIdx, other } = pending[i];
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other);
if (!prepared) { next(i + 1); return; }
this.map?.panToStar(starIdx, 260);
this.modalOpen = true;
openCombatView(this, this.rules, prepared.battle, this.art, {
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender',
onDone: (result) => {
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
this.modalOpen = false;
this.refreshAll();
next(i + 1);
},
});
};
next(0);
}
// Resolve instantly, animate afterwards. Every AI empire is played out
// synchronously, then the interesting events are replayed for the player.
runToHumanTurn() {
const step = () => {
if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
this.announceEvents();
this.refreshAll();
this.busy = false;
this.endTurnBtn.setEnabled(true);
return;
}
const e = this.state.current;
Logic.beginEmpireTurn(this.rules, this.state, e);
runAITurn(this.rules, this.state, e);
Logic.endEmpireTurn(this.rules, this.state, e);
this.time.delayedCall(60, step);
};
step();
}
// Turn engine events into log lines and map pings. `announced` marks a record
// consumed, since beginEmpireTurn trims rather than clears the event list.
announceEvents() {
const me = this.state.humanIndex;
for (const ev of this.state.events) {
if (ev.announced) continue;
ev.announced = true;
const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null;
const name = (i) => this.state.empires[i]?.name ?? '?';
if (ev.type === 'techDone' && ev.empire === me) {
this.log(`Researched ${this.rules.techs[ev.techId]?.name ?? ev.techId}.`);
} else if (ev.type === 'refit' && ev.empire === me) {
this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`);
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
} else if (ev.type === 'captured') {
this.log(`${name(ev.empire)} has taken ${star?.name} from ${name(ev.from)}.`);
} else if (ev.type === 'colonyDestroyed') {
this.log(`${star?.name} has been bombed out of existence by ${name(ev.empire)}.`);
} else if (ev.type === 'colonised' && ev.empire === me) {
this.log(`Colony founded at ${star?.name}.`);
} else if (ev.type === 'contact') {
this.log(`We have made contact with the ${name(ev.other === me ? ev.empire : ev.other)}.`);
} else if (ev.type === 'warDeclared') {
this.log(`${name(ev.empire)} declares war on ${name(ev.other)}.`);
} else if (ev.type === 'councilRefused') {
this.log(`${name(ev.empire)} refuses to submit to ${name(ev.winner)}. The Council is void.`);
} else if (ev.type === 'council' && ev.winner >= 0) {
this.log(`${name(ev.winner)} is elected High Guardian of the Galaxy.`);
} else if (ev.type === 'eliminated') {
this.log(`The ${name(ev.empire)} are no more.`);
}
}
}
finishGame() {
this.busy = true;
this.clearSave();
showVictoryOverlay(this, this.rules, this.state, () => this.scene.start('GameMenu'));
}
update(time, delta) {
this.map?.update(time, delta);
}
// ------------------------------------------------------------ save/load
writeSave() {
try {
if (this.state && !this.state.over) {
window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
}
} catch (err) { /* storage may be unavailable */ }
}
readSave() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
return raw ? Logic.deserialize(raw) : null;
} catch (err) { return null; }
}
clearSave() {
try { window.localStorage.removeItem(SAVE_KEY); } catch (err) { /* ignore */ }
}
}