796 lines
30 KiB
JavaScript
796 lines
30 KiB
JavaScript
// 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 */ }
|
||
}
|
||
}
|