fertig-classic-games/src/games/civilization/CivilizationGame.js

1793 lines
76 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.

// Civilization (Civ II-lite) — main Phaser scene.
//
// Phases: setup -> playing -> over. Setup picks a leader character (the
// standard opponents double as civilizations), world size, rival count and
// difficulty. The engine (CivilizationLogic) is headless; this scene drives it
// and renders through CivilizationMapView. AI rivals play via CivilizationAI.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { TextInput } from '../../ui/TextInput.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
import * as Logic from './CivilizationLogic.js';
import { runAITurn, respondToProposal } from './CivilizationAI.js';
import { requestValid, resolveRequest } from './CivilizationDiplomacy.js';
import { currentLeader, leaderTurnsLeft } from './CivilizationBarbarians.js';
import { RENOUNCE, GIFT_GOLD, GIFT_TECH, pickLine } from './CivilizationChat.js';
import { CivilizationMapView } from './CivilizationMapView.js';
import { openCityScreen } from './CivilizationCityScreen.js';
import {
openTechScreen, openDiplomacyScreen, openGovernmentScreen, openSpaceshipScreen, showVictoryOverlay,
} from './CivilizationScreens.js';
const FONT = '"Julius Sans One"';
const SAVE_KEY = 'civilization-save';
const D = { hud: 30, modal: 60, toast: 80 };
// Status log panel — bottom-right, 30px padding from the bottom/right edges.
const LOG_PAD = 30;
const LOG_W = 360;
const LOG_H = 360;
const LOG_HEADER_H = 34;
const LOG_X = GAME_WIDTH - LOG_PAD - LOG_W;
const LOG_Y = GAME_HEIGHT - LOG_PAD - LOG_H;
const LOG_MASK_X = LOG_X + 10;
const LOG_MASK_Y = LOG_Y + LOG_HEADER_H;
const LOG_MASK_W = LOG_W - 20;
const LOG_MASK_H = LOG_H - LOG_HEADER_H - 12;
const LOG_ENTRY_CAP = 60;
export default class CivilizationGame extends Phaser.Scene {
constructor() { super('CivilizationGame'); }
// Any window opening or closing flips `modalOpen`. Buttons fire their
// onClick on 'pointerup', so a click that closes a window (e.g. a modal's
// own Close/Cancel button) has already set modalOpen back to false by the
// time the scene-wide pointerup handler below runs its own check — without
// this guard that same mouse-up would fall through and register as a map
// click (moving the selected unit, etc). Flipping modalOpen either way arms
// the guard; the next pointerup (almost always the one in progress right
// now) consumes it and is swallowed, so the map only responds to a fresh
// click made after the mouse button has been released.
get modalOpen() { return this._modalOpen; }
set modalOpen(value) {
if (value !== this._modalOpen) this.clickGuard = true;
this._modalOpen = value;
}
init(data) {
this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' };
this.state = null;
this.view = null;
this.phase = 'setup';
this.modalOpen = false;
this.clickGuard = false;
this.busy = false;
this.endTurnFlashTween = null;
this.logEntries = [];
this.logScrollY = 0;
this.statusQueue = [];
this.pendingCityAttacks = [];
this.disembarkPending = null;
}
create() {
this.rules = compileRules(this.cache.json.get('civilization-rules'));
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0d12)
.setDepth(-10);
this.hudRoot = this.add.container(0, 0).setDepth(D.hud);
this.modalRoot = this.add.container(0, 0).setDepth(D.modal);
this.toastRoot = this.add.container(0, 0).setDepth(D.toast);
this.setupRoot = null;
this.toasts = [];
this.hotkeyList = 'UP,DOWN,LEFT,RIGHT,Q,E,Z,C,W,A,S,D,B,F,G,I,M,N,O,R,T,SPACE,ENTER';
this.keys = this.input.keyboard.addKeys(this.hotkeyList);
this.escHandler = () => this.onEscape();
this.input.keyboard.on('keydown-ESC', this.escHandler);
this.events.once('shutdown', () => {
this.input.keyboard.off('keydown-ESC', this.escHandler);
this.view?.destroy();
});
try {
const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.opponentsData = [];
fetch('data/opponents.json')
.then((r) => r.json())
.then((json) => { this.opponentsData = json.opponents ?? []; this.showSetup(); })
.catch(() => { this.opponentsData = []; this.showSetup(); });
}
onEscape() {
if (this.disembarkPending) { this.cancelDisembark(); return; }
if (this.modalOpen) return; // modals close themselves
if (this.phase === 'playing') this.openMenu();
else this.scene.start('GameMenu');
}
// ===========================================================================
// Setup phase
showSetup() {
this.phase = 'setup';
this.setupRoot?.destroy(true);
this.setupRoot = this.add.container(0, 0).setDepth(10);
const root = this.setupRoot;
const cx = GAME_WIDTH / 2;
// Background art is optional (assets/images/civilization/background.png,
// registered in src/data/assetManifest.js) — the scene's base dark fill
// from create() already covers the fallback if it isn't loaded, so this
// is simply skipped when missing rather than needing its own else-branch.
if (this.textures.exists('civilization-setup-bg')) {
root.add(this.add.image(cx, GAME_HEIGHT / 2, 'civilization-setup-bg'));
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55));
}
root.add(this.add.text(cx, 52, 'CIVILIZATION', {
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.accentHex,
}).setOrigin(0.5));
root.add(this.add.text(cx, 100, 'Build an empire to stand the test of time', {
fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5));
// --- leader pick grid
root.add(this.add.text(cx, 150, 'CHOOSE YOUR LEADER', {
fontFamily: FONT, fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5));
const leaders = this.opponentsData.length ? this.opponentsData
: Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 }));
this.setupLeaders = leaders;
this.pickedLeader = this.pickedLeader ?? Math.floor(Math.random() * leaders.length);
const perRow = 15;
const cell = 96;
const gridW = Math.min(leaders.length, perRow) * cell;
const gx = cx - gridW / 2 + cell / 2;
const gy = 214;
this.leaderMarks = [];
leaders.forEach((op, i) => {
const x = gx + (i % perRow) * cell;
const y = gy + Math.floor(i / perRow) * (cell + 18);
const ring = this.add.circle(x, y, 40, COLORS.panel)
.setStrokeStyle(3, i === this.pickedLeader ? COLORS.gold : COLORS.muted);
let face;
if (this.textures.exists('opponents')) {
face = this.add.image(x, y, 'opponents', op.spriteIndex ?? 0).setDisplaySize(72, 72);
const maskShape = this.make.graphics({ add: false });
maskShape.fillStyle(0xffffff);
maskShape.fillCircle(x, y, 36);
face.setMask(maskShape.createGeometryMask());
} else {
face = this.add.text(x, y, op.name[0], {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(0.5);
}
const label = this.add.text(x, y + 52, op.name, {
fontFamily: FONT, fontSize: '14px',
color: i === this.pickedLeader ? COLORS.goldHex : COLORS.mutedHex,
}).setOrigin(0.5);
ring.setInteractive({ useHandCursor: true });
ring.on('pointerdown', () => {
if (i === this.pickedLeader) return;
this.pickedLeader = i;
this.leaderMarks.forEach((m, j) => {
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
});
this.updateLeaderDetailPanel();
this.updateSetupPortrait({ playPick: true });
});
this.leaderMarks.push({ ring, label });
root.add([ring, face, label]);
});
const rowsUsed = Math.ceil(leaders.length / perRow);
let oy = gy + rowsUsed * (cell + 18) + 40;
// --- option rows
this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 5, difficultyId: 'prince' };
const mkRow = (label, options, current, onPick) => {
root.add(this.add.text(cx - 560, oy, label, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0.5));
const marks = [];
const startX = cx - 260;
options.forEach((opt, i) => {
const w = Math.max(90, opt.label.length * 13 + 30);
const x = startX + options.slice(0, i).reduce((a, o) => a + Math.max(90, o.label.length * 13 + 30) + 14, 0);
const rect = this.add.rectangle(x + w / 2, oy, w, 44, COLORS.panel)
.setStrokeStyle(2, opt.value === current ? COLORS.gold : COLORS.muted);
const txt = this.add.text(x + w / 2, oy, opt.label, {
fontFamily: FONT, fontSize: '19px',
color: opt.value === current ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5);
rect.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => {
onPick(opt.value);
marks.forEach((m, j) => {
m.rect.setStrokeStyle(2, options[j].value === opt.value ? COLORS.gold : COLORS.muted);
m.txt.setColor(options[j].value === opt.value ? COLORS.goldHex : COLORS.textHex);
});
});
marks.push({ rect, txt });
root.add([rect, txt]);
});
oy += 62;
};
mkRow('World Size',
this.rules.worldSizeList.map((w) => ({ label: `${w.name} (${w.cols}×${w.rows})`, value: w.id })),
this.setupOpts.sizeId, (v) => { this.setupOpts.sizeId = v; });
mkRow('Rival Civilizations',
[2, 3, 4, 5, 6, 7].map((n) => ({ label: `${n}`, value: n })),
this.setupOpts.opponents, (v) => { this.setupOpts.opponents = v; });
mkRow('Difficulty',
this.rules.difficultyList.map((d) => ({ label: d.name, value: d.id })),
this.setupOpts.difficultyId, (v) => { this.setupOpts.difficultyId = v; });
oy += 8;
const startBtn = new Button(this, cx - (this.hasSave() ? 160 : 0), oy + 20, 'BEGIN', () => {
this.startNewGame();
}, { width: 280, height: 64, fontSize: 30 });
root.add(startBtn);
if (this.hasSave()) {
const resumeBtn = new Button(this, cx + 160, oy + 20, 'RESUME GAME', () => {
this.resumeGame();
}, { width: 280, height: 64, fontSize: 26, variant: 'ghost' });
root.add(resumeBtn);
}
// --- leader details panel (below BEGIN/RESUME), updated live from the
// leader-pick ring handler above — see updateLeaderDetailPanel().
const panelY = oy + 20 + 32 + 24;
const panelW = 900;
const panelH = 150;
const panelBg = this.add.rectangle(cx, panelY + panelH / 2, panelW, panelH, COLORS.panel, 0.92)
.setStrokeStyle(2, COLORS.accent, 0.7);
const nameText = this.add.text(cx, panelY + 26, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5);
const traitText = this.add.text(cx, panelY + 66, '', {
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5);
const descText = this.add.text(cx, panelY + 100, '', {
fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
root.add([panelBg, nameText, traitText, descText]);
this.leaderDetailPanel = { nameText, traitText, descText };
this.updateLeaderDetailPanel();
// Video portrait of the currently-picked leader, in the empty margin
// left of the details panel — plays their idle loop; see
// updateSetupPortrait() for the pick-speech swap on selection change.
this.setupPortraitPos = { x: cx - panelW / 2 - 110, y: panelY + panelH / 2, radius: 85 };
this.updateSetupPortrait({ playPick: false });
}
// Reflects the currently-picked leader's starting-condition trait in the
// setup-screen details panel. Called once when the panel is first built,
// and again from the leader-pick ring's click handler — both share this
// one code path so there's no separate "initial" vs "on click" logic.
updateLeaderDetailPanel() {
if (!this.leaderDetailPanel) return;
const op = this.setupLeaders[this.pickedLeader];
const trait = this.rules.civTraits[op?.trait];
const { nameText, traitText, descText } = this.leaderDetailPanel;
nameText.setText(op?.name ?? '');
traitText.setText(trait ? trait.name : 'Balanced (no bonuses)');
const techNames = (op?.startingTechs ?? []).map((id) => this.rules.techs[id]?.name ?? id);
const lines = [];
if (trait?.description) lines.push(trait.description);
if (techNames.length) lines.push(`Starts knowing ${techNames.join(', ')}`);
descText.setText(lines.join(' • '));
}
// (Re)builds the setup screen's leader portrait for whoever is currently
// picked — createOpponentPortrait autoplays that leader's idle video
// immediately. When playPick is true (an actual selection change, not the
// initial random pick), also fires their one-line "pick" speech clip —
// deterministically, not through Portrait.js's playEmotion() 60%-chance
// gate (that gate exists to stop repeated in-match mood banter from
// getting spammy; a deliberate click should always be acknowledged).
updateSetupPortrait({ playPick }) {
this.setupPortrait?.destroy();
const op = this.setupLeaders[this.pickedLeader];
const { x, y, radius } = this.setupPortraitPos;
this.setupPortrait = createOpponentPortrait(this, op, x, y, radius, 11, { playIntro: false });
if (playPick) {
const clip = op?.speech?.pick?.[0];
if (clip) enqueueSpeech(clip);
}
}
hasSave() {
try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; }
}
startNewGame() {
const { sizeId, opponents, difficultyId } = this.setupOpts;
const pool = this.setupLeaders;
const me = pool[this.pickedLeader];
const rivals = pool.filter((_, i) => i !== this.pickedLeader);
// Shuffle rivals with Math.random (game determinism starts at engine seed).
for (let i = rivals.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[rivals[i], rivals[j]] = [rivals[j], rivals[i]];
}
const leaders = [me, ...rivals.slice(0, opponents)]
.map((op) => ({
id: op.id, name: op.name, trait: op.trait, startingTechs: op.startingTechs, citySheet: op.citySheet,
}));
const seed = (Date.now() % 1000000) + 1;
try {
this.state = Logic.createGame(this.rules, {
sizeId, seed, difficultyId, leaders, humanIndex: 0,
});
} catch (err) {
this.toast(`World generation failed — try again (${err.message})`);
return;
}
this.leaderPool = leaders;
this.beginPlaying();
}
resumeGame() {
try {
const state = Logic.deserialize(localStorage.getItem(SAVE_KEY));
if (!state) { this.toast('Saved game is from an old version'); localStorage.removeItem(SAVE_KEY); return; }
this.state = state;
this.beginPlaying(true);
} catch (_) {
this.toast('Could not load the save');
}
}
saveGame() {
try { localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); } catch (_) { /* full */ }
}
clearSave() {
try { localStorage.removeItem(SAVE_KEY); } catch (_) { /* noop */ }
}
// ===========================================================================
// Playing phase
beginPlaying(resumed = false) {
this.phase = 'playing';
this.setupRoot?.destroy(true);
this.setupRoot = null;
this.setupPortrait?.destroy();
this.setupPortrait = null;
this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, {
onCityClick: (city) => this.onCityClick(city),
onUnitClick: (unit) => this.onUnitClick(unit),
});
this.view.buildMinimap(16, GAME_HEIGHT - 260, 300);
this.buildHud();
this.bindPointer();
const human = this.state.civs[this.state.humanIndex];
const capital = Logic.civCities(this.state, human.id)[0];
const firstUnit = Logic.civUnits(this.state, human.id)[0];
const focus = capital ?? firstUnit;
if (focus) this.view.centerOn(focus.x, focus.y);
if (!resumed || this.state.current !== this.state.humanIndex) {
// Fresh games (and saves mid-AI-round) run up to the human turn.
this.runToHumanTurn();
} else {
this.startHumanTurn(false);
}
}
buildHud() {
this.hudRoot.removeAll(true);
const bar = this.add.rectangle(GAME_WIDTH / 2, 28, GAME_WIDTH, 56, COLORS.panel, 0.95)
.setStrokeStyle(1, COLORS.accent, 0.6);
this.hudRoot.add(bar);
this.hudText = this.add.text(20, 28, '', {
fontFamily: FONT, fontSize: '21px', color: COLORS.textHex,
}).setOrigin(0, 0.5);
this.hudRoot.add(this.hudText);
const mkBtn = (x, label, fn, w = 150) => {
const b = new Button(this, x, 28, label, fn, { width: w, height: 42, fontSize: 18 });
this.hudRoot.add(b);
return b;
};
mkBtn(GAME_WIDTH - 1010, 'GOVT', () => this.openGovt(), 130);
mkBtn(GAME_WIDTH - 860, 'TECH', () => this.openTech());
mkBtn(GAME_WIDTH - 700, 'DIPLOMACY', () => this.openDiplomacy(), 170);
mkBtn(GAME_WIDTH - 530, 'SPACESHIP', () => this.openSpaceship(), 170);
mkBtn(GAME_WIDTH - 370, 'MENU', () => this.openMenu(), 120);
this.endTurnBtn = new Button(this, GAME_WIDTH - 210, 28, 'END TURN', () => this.onEndTurn(),
{ width: 180, height: 42, fontSize: 18, bg: COLORS.gold, textColor: COLORS.textDarkHex });
this.hudRoot.add(this.endTurnBtn);
// Right-side unit/terrain panel.
this.unitPanel = this.add.container(GAME_WIDTH - 300, 80);
const panelBg = this.add.rectangle(0, 0, 284, 240, COLORS.panel, 0.92)
.setOrigin(0, 0).setStrokeStyle(2, COLORS.accent, 0.7);
this.unitPanelText = this.add.text(14, 14, '', {
fontFamily: FONT, fontSize: '17px', color: COLORS.textHex,
wordWrap: { width: 256 }, lineSpacing: 5,
});
this.unitPanel.add([panelBg, this.unitPanelText]);
this.hudRoot.add(this.unitPanel);
this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 24,
'Arrows/QEZC move · B found city · R road · I irrigate · M mine · O fortress · F fortify · Space skip · N next · Enter end turn',
{ fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5);
this.hudRoot.add(this.hintText);
this.refreshHud();
this.buildStatusLog();
}
// ---------------------------------------------------------------------------
// Status log (bottom-right) + queued "significant event" popups.
//
// logMessage() appends straight to the log (minor/operational feedback).
// announceStatus() queues a big centered popup with a CONTINUE button for
// significant events; clicking CONTINUE shrinks/flies the message into the
// log panel, then advances to the next queued popup (if any) — mirrors the
// shift-and-recurse queue presentAIProposals() already uses for AI dialogs.
buildStatusLog() {
const bg = this.add.graphics();
bg.fillStyle(COLORS.panel, 0.92);
bg.fillRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
bg.lineStyle(2, COLORS.accent, 0.7);
bg.strokeRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
this.hudRoot.add(bg);
const title = this.add.text(LOG_X + LOG_W / 2, LOG_Y + 18, 'STATUS LOG', {
fontFamily: 'Righteous', fontSize: '15px', color: COLORS.accentHex,
}).setOrigin(0.5);
this.hudRoot.add(title);
this.logScrollArea = this.add.container(LOG_MASK_X, LOG_MASK_Y);
this.hudRoot.add(this.logScrollArea);
const maskGfx = this.make.graphics({ add: false });
maskGfx.fillStyle(0xffffff);
maskGfx.fillRect(LOG_MASK_X, LOG_MASK_Y, LOG_MASK_W, LOG_MASK_H);
this.logScrollArea.setMask(maskGfx.createGeometryMask());
this.logPanelRect = {
x: LOG_X, y: LOG_Y, w: LOG_W, h: LOG_H,
};
}
appendLogEntry(msg) {
if (this.logEntries.length >= LOG_ENTRY_CAP) this.logEntries.shift().destroy();
const t = this.add.text(8, 0, msg, {
fontFamily: FONT, fontSize: '15px', color: COLORS.textHex,
wordWrap: { width: LOG_MASK_W - 16 }, lineSpacing: 3,
}).setOrigin(0, 0);
t.setAlpha(0);
this.logScrollArea.add(t);
this.logEntries.push(t);
this.relayoutLog();
this.tweens.add({ targets: t, alpha: 1, duration: 200 });
}
relayoutLog() {
let y = 0;
for (const e of this.logEntries) { e.setY(y); y += e.height + 8; }
this.logScrollY = Math.max(0, y - LOG_MASK_H);
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
}
logMessage(msg) {
this.appendLogEntry(msg);
}
// `onDismiss`, if given, runs instead of auto-advancing to the next queued
// popup once this one is dismissed (e.g. first-contact opens the diplomacy
// screen next) — that callback is then responsible for resuming the queue
// itself once IT closes (see openDiplomacy()'s onClose).
//
// `pre`, if given, is a cinematic step (e.g. pan the camera + replay a
// combat) that runs BEFORE the text popup appears — it receives a
// `proceed` callback to call once it's done. Used for city-attack/capture
// announcements, which should show the attack playing out before the
// outcome text, rather than the text popping up first.
//
// `extra`, if given, is a second button `{ label, onClick }` shown next to
// CONTINUE — dismisses the popup the same way, then calls `onClick`
// instead of advancing the queue (e.g. building-complete opens the city
// screen; that screen's own onClose is responsible for resuming the queue
// once it closes — see the buildingDone handler in announceEvents()).
announceStatus(msg, onDismiss, pre, extra) {
this.statusQueue.push({
msg, onDismiss, pre, extra,
});
if (!this.modalOpen) this.showNextStatus();
}
showNextStatus() {
const item = this.statusQueue.shift();
if (!item) return;
// Set modalOpen up front, even though the popup itself isn't built until
// `proceed()` runs — a `pre` cinematic (camera pan + combat replay)
// should block input exactly like the popup that follows it does.
this.modalOpen = true;
const {
msg, onDismiss, pre, extra,
} = item;
const proceed = () => this.showStatusPopup(msg, onDismiss, extra);
if (pre) pre(proceed); else proceed();
}
showStatusPopup(msg, onDismiss, extra) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
const panel = this.add.rectangle(cx, cy, 760, 280, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const txt = this.add.text(cx, cy - 30, msg, {
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
wordWrap: { width: 680 }, align: 'center',
}).setOrigin(0.5);
const buttons = [];
// Shared dismiss animation for whichever button is clicked — shrinks the
// message into the status log and fades the popup out — differing only
// in what runs afterward (`then`): resume the queue, or (for the extra
// button) do something else that's responsible for resuming it itself.
const dismiss = (then) => {
buttons.forEach((b) => b.disableInteractive());
// Detach without destroying — root sits at (0,0), so txt's numeric x/y
// already equal its on-screen position; Phaser re-adds a removed child
// straight to the scene's top-level display list, so it keeps rendering
// in place (and keeps respecting setDepth) once outside the container.
root.remove(txt, false);
txt.setDepth(D.modal + 1);
const t = this.logPanelRect;
this.tweens.add({
targets: txt,
x: t.x + t.w / 2,
y: t.y + t.h - 16,
scaleX: 15 / 32,
scaleY: 15 / 32,
duration: 450,
ease: 'Cubic.easeInOut',
onComplete: () => { txt.destroy(); this.appendLogEntry(msg); },
});
this.tweens.add({
targets: [dim, panel],
alpha: 0,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => root.destroy(true),
});
this.modalOpen = false;
then();
};
const btnX = extra ? cx - 120 : cx;
const btn = new Button(this, btnX, cy + 100, 'CONTINUE',
() => dismiss(() => { if (onDismiss) onDismiss(); else this.showNextStatus(); }),
{ width: 220, height: 56 });
buttons.push(btn);
root.add([dim, panel, txt, btn]);
if (extra) {
const extraBtn = new Button(this, cx + 120, cy + 100, extra.label,
() => dismiss(() => extra.onClick()),
{ width: 220, height: 56, variant: 'ghost' });
buttons.push(extraBtn);
root.add(extraBtn);
}
}
refreshHud() {
if (!this.state || this.phase !== 'playing') return;
const civ = this.state.civs[this.state.humanIndex];
const year = formatYear(turnToYear(this.state.turn, this.rules.yearCurve));
const gov = this.rules.governments[civ.government].name;
const research = civ.researching
? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}`
: '— pick research —';
this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`);
this.refreshWarlordBanner();
this.refreshUnitPanel();
}
// Persistent reminder that a Barbarian Leader is alive and worth gold, with
// the countdown to his escape. Tracks the LAST KNOWN position: it updates
// only on turns the human can actually see him, and goes dim when it's
// stale, so losing sight of the Warlord means hunting from the last sighting
// rather than getting a free tracker.
refreshWarlordBanner() {
const leader = currentLeader(this.state);
const turnsLeft = leaderTurnsLeft(this.state);
if (!leader || turnsLeft <= 0) {
this.warlordBanner?.destroy();
this.warlordBanner = null;
this.warlordLastSeen = null;
return;
}
const human = this.state.humanIndex;
const visible = Logic.isUnitVisibleTo(this.rules, this.state, leader, human)
&& this.view?.isTileVisible?.(leader.x, leader.y);
if (visible) this.warlordLastSeen = { x: leader.x, y: leader.y, turn: this.state.turn };
const seen = this.warlordLastSeen;
if (!seen) return; // sighted event hasn't landed yet
const label = `⚑ WARLORD AT LARGE — ${turnsLeft} turn${turnsLeft === 1 ? '' : 's'}`;
if (!this.warlordBanner) {
this.warlordBanner = this.add.text(GAME_WIDTH / 2, 56, label, {
fontFamily: FONT, fontSize: '17px', color: '#ffd24a',
backgroundColor: '#3f1d1dcc', padding: { x: 14, y: 6 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.warlordBanner.on('pointerdown', () => {
const at = this.warlordLastSeen;
if (at) this.view.panToTile(at.x, at.y);
});
this.hudRoot.add(this.warlordBanner);
// Recomputed per hover, not captured: the banner outlives many turns and
// the Warlord slips in and out of sight while it's on screen.
this.view?.tooltip?.attachTo(this.warlordBanner, () => {
const at = this.warlordLastSeen;
const nowVisible = at?.turn === this.state.turn;
return nowVisible
? 'A Barbarian Warlord is in sight. Cut down his escort, then corner him alone to claim the ransom.'
: `Warlord last seen on turn ${at?.turn ?? '?'}. Click to pan there — he has moved since.`;
});
}
this.warlordBanner.setText(label);
this.warlordBanner.setAlpha(visible ? 1 : 0.6);
}
refreshUnitPanel() {
const unit = this.selectedUnit();
if (!unit) {
this.unitPanelText.setText(this.busy ? 'Rivals are moving…' : 'No unit selected.\nClick a unit or press N.');
return;
}
const def = this.rules.units[unit.type];
const terr = Logic.terrainAt(this.rules, this.state.world, unit.x, unit.y);
const idx = Logic.tileIndex(this.state.world, unit.x, unit.y);
const spec = this.state.world.special[idx] >= 0
? this.rules.specialList[this.state.world.special[idx]].name : null;
const lines = [
`${def.name}${unit.vet ? ' (V)' : ''}`,
`A${def.attack} D${def.defense} HP ${unit.hp}/${def.hp}`,
`Moves: ${(unit.mp / 3).toFixed(unit.mp % 3 ? 1 : 0)}`,
`Terrain: ${terr.name}${spec ? ` (${spec})` : ''}`,
];
if (unit.order?.kind === 'work') {
lines.push(`Working: ${this.rules.improvements[unit.order.imp].name}`);
}
if (unit.fortified) lines.push('Fortified');
this.unitPanelText.setText(lines.join('\n'));
}
// ---------------------------------------------------------------------------
// Pointer + keyboard input
bindPointer() {
let dragStart = null;
let dragged = false;
this.input.on('pointerdown', (pointer) => {
if (this.modalOpen || this.phase !== 'playing') return;
this.view.panTween?.stop();
dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y };
dragged = false;
});
this.input.on('pointermove', (pointer) => {
if (!pointer.isDown || !dragStart || this.modalOpen) return;
const dx = pointer.x - dragStart.x;
const dy = pointer.y - dragStart.y;
if (Math.abs(dx) + Math.abs(dy) > 8) dragged = true;
if (dragged) {
this.view.root.x = dragStart.rx + dx;
this.view.root.y = dragStart.ry + dy;
this.view.clampPan();
}
});
this.input.on('pointerup', (pointer) => {
const wasDrag = dragged;
dragStart = null;
dragged = false;
if (this.clickGuard) { this.clickGuard = false; return; }
if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return;
if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands
// Clicks that landed on any interactive object (buttons, minimap, city
// banners) are theirs, not the map's. Non-selected unit markers are
// interactive too now (for hover tooltips) but are tagged 'hoverOnly'
// (see CivilizationMapView.drawUnit) since they aren't a real click
// target — a click there should still fall through to onTileClick.
if (this.input.hitTestPointer(pointer).some((o) => !o.getData('hoverOnly'))) return;
const tile = this.view.screenToTile(pointer.x, pointer.y);
if (tile) this.onTileClick(tile[0], tile[1]);
});
this.input.on('wheel', (pointer, objs, dx, dy) => {
if (this.modalOpen || this.phase !== 'playing') return;
const r = this.logPanelRect;
if (r && pointer.x >= r.x && pointer.x <= r.x + r.w && pointer.y >= r.y && pointer.y <= r.y + r.h) {
const contentH = this.logEntries.reduce((h, e) => h + e.height + 8, 0);
this.logScrollY = Phaser.Math.Clamp(this.logScrollY + dy * 0.5, 0, Math.max(0, contentH - LOG_MASK_H));
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
return;
}
this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y);
});
}
onTileClick(c, r) {
if (this.disembarkPending) { this.resolveDisembarkClick(c, r); return; }
const state = this.state;
const human = state.humanIndex;
const city = Logic.cityAt(state, c, r);
// Fortified units in a city are hidden on the map (see CivilizationMapView) —
// clicking the tile should open the city, not select an invisible unit.
const myUnits = Logic.unitsAt(state, c, r)
.filter((u) => u.civ === human && !(city && u.fortified));
const sel = this.selectedUnit();
// A movable selected unit clicking a different owned city is ambiguous —
// ask whether to move it in or just inspect the city.
if (city && city.civ === human && sel && sel.mp > 0 && state.current === human
&& (sel.x !== c || sel.y !== r)) {
this.confirmMoveOrInspectCity(sel, city, c, r);
return;
}
if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) {
// A unit is already active and the player clicked a DIFFERENT tile
// with one of their own units on it — ambiguous whether they meant to
// send the active unit there (joining that tile's stack) or take
// control of the unit they clicked, so ask instead of guessing.
if (sel && state.current === human) {
this.openUnitInteractionMenu(sel, myUnits, c, r);
return;
}
this.selectUnit(myUnits[0]);
return;
}
if (myUnits.length && sel && sel.x === c && sel.y === r) {
if (myUnits.length > 1 && state.current === human) {
this.openStackSwitchMenu(sel, myUnits);
return;
}
// Cycle the stack.
const i = myUnits.indexOf(sel);
this.selectUnit(myUnits[(i + 1) % myUnits.length]);
return;
}
if (city && city.civ === human && !myUnits.length) {
this.onCityClick(city);
return;
}
// Move the selected unit toward the clicked tile.
if (sel) this.moveTo(sel, c, r);
}
moveTo(unit, c, r) {
if (this.state.current !== this.state.humanIndex) return;
if (Logic.cheb(unit.x, unit.y, c, r) === 1) {
this.tryStep(unit, c - unit.x, r - unit.y, true);
return;
}
const path = Logic.findPath(this.rules, this.state, unit, c, r);
if (!path) { this.logMessage('No route there'); return; }
this.view.showPath(path);
this.walkPath(unit, path);
}
confirmMoveOrInspectCity(unit, city, c, r) {
const name = this.rules.units[unit.type].name;
this.confirmDialog(`Move ${name} into ${city.name}, or inspect the city?`,
() => this.moveTo(unit, c, r),
() => this.onCityClick(city),
'MOVE', 'INSPECT');
}
onCityClick(city) {
if (city.civ !== this.state.humanIndex || this.modalOpen) return;
this.modalOpen = true;
openCityScreen(this, this.rules, this.state, city, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
});
}
update() {
// Phaser Containers render children in list order, not by their .depth —
// .setDepth() alone (used throughout CivilizationMapView for units/cities/
// combat ghosts/selection ring) has no effect on draw order inside
// this.view.dynamic unless the list is explicitly re-sorted. Units and
// cities are added in arbitrary (state-array) order each refresh, and
// move/combat animations update depth continuously, so this has to run
// every frame — including mid-animation, hence it runs before the
// busy/modal early-return below.
this.view?.dynamic?.sort('depth');
if (this.phase !== 'playing' || this.modalOpen || this.busy) return;
// Edge-of-keyboard panning.
const k = this.keys;
const pan = 14;
if (k.W.isDown) this.view.panBy(0, pan);
if (k.S.isDown && !k.S.shiftKey) this.view.panBy(0, -pan);
if (k.A.isDown) this.view.panBy(pan, 0);
if (k.D.isDown) this.view.panBy(-pan, 0);
if (this.state?.current !== this.state?.humanIndex) return;
const sel = this.selectedUnit();
const just = (key) => Phaser.Input.Keyboard.JustDown(key);
if (just(k.N)) this.selectNextUnit();
if (just(k.ENTER)) this.onEndTurn();
if (!sel) return;
if (just(k.UP)) this.tryStep(sel, 0, -1);
else if (just(k.DOWN)) this.tryStep(sel, 0, 1);
else if (just(k.LEFT)) this.tryStep(sel, -1, 0);
else if (just(k.RIGHT)) this.tryStep(sel, 1, 0);
else if (just(k.Q)) this.tryStep(sel, -1, -1);
else if (just(k.E)) this.tryStep(sel, 1, -1);
else if (just(k.Z)) this.tryStep(sel, -1, 1);
else if (just(k.C)) this.tryStep(sel, 1, 1);
else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); }
else if (just(k.F)) this.tryFortify(sel);
else if (just(k.B)) this.tryFound(sel);
else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road');
else if (just(k.I)) this.tryWork(sel, 'irrigation');
else if (just(k.M)) this.tryWork(sel, 'mine');
else if (just(k.O)) this.tryWork(sel, 'fortress');
else if (just(k.T)) this.tryWork(sel, 'transform');
else if (just(k.G)) this.tryCaravan(sel);
}
canRail(unit) {
const bits = this.state.world.improvements[Logic.tileIndex(this.state.world, unit.x, unit.y)];
return (bits & Logic.IMP.ROAD) && this.state.civs[unit.civ].known.railroad;
}
selectedUnit() {
if (!this.state || this.selectedUnitId == null) return null;
const u = Logic.unitById(this.state, this.selectedUnitId);
if (!u || u.civ !== this.state.humanIndex) return null;
return u;
}
selectUnit(unit) {
this.cancelDisembark();
this.selectedUnitId = unit?.id ?? null;
this.view.selectedUnitId = this.selectedUnitId;
this.view.showPath(null);
if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y);
this.view.refresh();
this.refreshUnitPanel();
// Nothing left to move this turn — nudge the player toward End Turn.
const noneLeft = !unit && this.phase === 'playing' && !this.busy
&& this.state?.current === this.state?.humanIndex;
this.setEndTurnFlash(noneLeft);
}
setEndTurnFlash(on) {
if (on === !!this.endTurnFlashTween) return;
if (!on) {
this.endTurnFlashTween?.stop();
this.endTurnFlashTween = null;
this.endTurnBtn?.setAlpha(1);
return;
}
this.endTurnFlashTween = this.tweens.add({
targets: this.endTurnBtn,
alpha: 0.55,
duration: 550,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
selectNextUnit() {
const units = Logic.civUnits(this.state, this.state.humanIndex)
.filter((u) => u.mp > 0 && !u.fortified && !u.sentry && !u.order && !u.carriedBy
&& this.rules.units[u.type].domain !== 'project');
if (!units.length) { this.selectUnit(null); return; }
const curIdx = units.findIndex((u) => u.id === this.selectedUnitId);
const next = units[(curIdx + 1) % units.length];
this.selectUnit(next);
this.view.panToTile(next.x, next.y);
}
tryStep(unit, dx, dy, animate = false) {
if (this.state.current !== this.state.humanIndex) return;
const from = [unit.x, unit.y];
const out = unit.carriedBy
? Logic.disembark(this.rules, this.state, unit, dx, dy)
: Logic.tryMove(this.rules, this.state, unit, dx, dy);
if (out.result === 'blocked' && out.needsWar !== undefined) {
this.confirmWar(out.needsWar, () => {
Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar);
this.tryStep(unit, dx, dy, animate);
});
return;
}
if (out.result === 'invalid') return;
if (out.hut) this.toastHut(out.hut);
const finish = () => {
this.afterAction();
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
if (!this.state.units.includes(unit)) this.selectNextUnit();
};
const combatEvents = this.collectNewCombatEvents();
if (combatEvents.length) {
this.busy = true;
this.view.animateCombat(combatEvents, () => { this.busy = false; finish(); });
return;
}
const moved = unit.x !== from[0] || unit.y !== from[1];
if (!animate || !moved) { finish(); return; }
this.animateMoveThen([from, [unit.x, unit.y]], finish);
}
walkPath(unit, path) {
// Resolve the whole turn's worth of movement instantly (combat, huts,
// etc. all need the real logic), then replay the tiles actually crossed
// as a smooth glide so the player sees continuous motion, not a snap.
// A combat step always ends the walk — the rest of the original path
// no longer applies once a fight breaks out.
const visited = [[unit.x, unit.y]];
let i = 0;
let hutEvent = null;
while (i < path.length && unit.mp > 0 && this.state.units.includes(unit) && !this.state.over) {
const [nx, ny] = path[i];
i += 1;
const out = Logic.tryMove(this.rules, this.state, unit,
Math.sign(nx - unit.x), Math.sign(ny - unit.y));
if (out.result === 'invalid' || out.result === 'blocked' || out.result === 'combat') break;
if (out.hut) hutEvent = out.hut;
visited.push([unit.x, unit.y]);
}
if (hutEvent) this.toastHut(hutEvent);
const finish = () => {
this.view.showPath(null);
this.afterAction();
if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit();
};
const combatEvents = this.collectNewCombatEvents();
if (combatEvents.length) {
// Walk the peaceful leg of the path first, then play the clash.
this.animateMoveThen(visited, () => {
this.busy = true;
this.view.animateCombat(combatEvents, () => { this.busy = false; finish(); });
});
return;
}
this.animateMoveThen(visited, finish);
}
// Picks up any 'combat' events pushed since the last time this ran
// (marking them consumed so they never replay), for the caller to animate.
collectNewCombatEvents() {
const found = [];
for (const e of this.state.events) {
if (e.type === 'combat' && !e.animated) { e.animated = true; found.push(e); }
}
return found;
}
// Glides the selected unit's marker smoothly across `tiles`
// ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the
// destination, then invokes `finish`. Falls straight through to `finish`
// when there's nothing to animate (unit didn't actually move).
animateMoveThen(tiles, finish) {
if (tiles.length < 2) { finish(); return; }
this.view.refresh();
this.busy = true;
this.view.animateUnitAlong(tiles, 1000, 250, () => {
this.busy = false;
finish();
});
}
tryFound(unit) {
const def = this.rules.units[unit.type];
if (!def.flags.includes('settler')) return;
if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) {
this.logMessage('Cannot found a city here');
return;
}
if (this.modalOpen) return;
const defaultName = Logic.peekCityName(this.rules, this.state, unit.civ);
this.promptCityName(defaultName, (name) => {
const city = Logic.foundCity(this.rules, this.state, unit, name);
if (city) {
this.announceStatus(`${city.name} founded!`);
this.view.repaintTileAndNeighbors(city.x, city.y);
this.afterAction();
this.selectNextUnit();
}
});
}
promptCityName(defaultName, onConfirm) {
this.modalOpen = true;
// Gameplay hotkeys capture (preventDefault) these keys at the window level,
// which blocks the browser from typing them into the name field below.
this.input.keyboard.clearCaptures();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
const panel = this.add.rectangle(cx, cy, 560, 240, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const title = this.add.text(cx, cy - 84, 'Found City', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex,
}).setOrigin(0.5);
const label = this.add.text(cx, cy - 40, 'Name this city:', {
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5);
root.add([dim, panel, title, label]);
const input = new TextInput(this, cx, cy + 4, {
width: 380, height: 48, value: defaultName, maxLength: 24, autocomplete: 'off',
});
input.focus();
input.el.select();
const close = () => {
input.destroy(); root.destroy(true); this.modalOpen = false;
this.input.keyboard.addCapture(this.hotkeyList);
};
const confirm = () => {
const name = input.value.trim() || defaultName;
close();
onConfirm(name);
};
input.on('keydown', (e) => {
if (e.key === 'Enter') confirm();
else if (e.key === 'Escape') close();
});
const found = new Button(this, cx - 110, cy + 68, 'FOUND', confirm, { width: 180, height: 52 });
const cancel = new Button(this, cx + 110, cy + 68, 'CANCEL', close,
{ width: 180, height: 52, variant: 'ghost' });
root.add([found, cancel]);
}
tryWork(unit, impId) {
if (Logic.startWork(this.rules, this.state, unit, impId)) {
this.logMessage(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
this.afterAction();
this.selectNextUnit();
} else {
this.logMessage(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
}
}
tryCaravan(unit) {
const pair = Logic.canEstablishRoute(this.rules, this.state, unit);
if (!pair) { this.logMessage(`Caravans need a city ${Logic.TRADE_ROUTE_MIN_DIST}+ tiles from home`); return; }
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
if (out) {
this.announceStatus(`Trade route: ${out.from.name}${out.to.name} (+${out.bonus} gold & beakers)`);
this.afterAction();
this.selectNextUnit();
}
}
tryFortify(unit) {
unit.fortified = true;
unit.mp = 0;
this.afterAction();
this.selectNextUnit();
}
tryDisband(unit) {
Logic.removeUnit(this.state, unit);
this.logMessage(`${this.rules.units[unit.type].name} disbanded`);
this.afterAction();
this.selectNextUnit();
}
// ---------------------------------------------------------------------------
// Unit action popup (clicking the currently-selected unit's sprite)
onUnitClick(unit) {
if (this.modalOpen || this.busy || this.state.current !== this.state.humanIndex) return;
this.cancelDisembark();
this.openUnitActionMenu(unit);
}
// Mirrors the same eligibility checks the keyboard shortcuts use (tryFound,
// tryWork, tryCaravan, tryFortify) so the menu never offers something that
// would just toast a failure.
getUnitActions(unit) {
const { rules, state } = this;
const def = rules.units[unit.type];
const actions = [];
if (!unit.fortified) actions.push({ label: 'Fortify', onSelect: () => this.tryFortify(unit) });
if (def.flags.includes('settler') && Logic.canFoundCity(rules, state, unit.x, unit.y)) {
actions.push({ label: 'Found City', onSelect: () => this.tryFound(unit) });
}
const roadImp = this.canRail(unit) ? 'railroad' : 'road';
if (Logic.canWork(rules, state, unit, roadImp)) {
const label = roadImp === 'railroad' ? 'Build Railroad' : 'Build Road';
actions.push({ label, onSelect: () => this.tryWork(unit, roadImp) });
}
if (Logic.canWork(rules, state, unit, 'irrigation')) {
actions.push({ label: 'Irrigate', onSelect: () => this.tryWork(unit, 'irrigation') });
}
if (Logic.canWork(rules, state, unit, 'mine')) {
actions.push({ label: 'Build Mine', onSelect: () => this.tryWork(unit, 'mine') });
}
if (Logic.canWork(rules, state, unit, 'fortress')) {
actions.push({ label: 'Build Fortress', onSelect: () => this.tryWork(unit, 'fortress') });
}
if (Logic.canWork(rules, state, unit, 'transform')) {
actions.push({ label: 'Transform Terrain', onSelect: () => this.tryWork(unit, 'transform') });
}
if (Logic.canEstablishRoute(rules, state, unit)) {
actions.push({ label: 'Establish Trade Route', onSelect: () => this.tryCaravan(unit) });
}
if (Logic.unitsOnBoat(state, unit).length) {
const tiles = Logic.disembarkTiles(rules, state, unit);
if (tiles.length) {
actions.push({ label: 'Disembark', onSelect: () => this.beginDisembark(unit, tiles) });
}
}
const aboard = Logic.unitsOnBoat(state, unit).length;
actions.push({
label: aboard ? `Disband (loses ${aboard} aboard)` : 'Disband',
onSelect: () => this.tryDisband(unit),
});
actions.push({ label: 'Skip Turn', onSelect: () => { unit.mp = 0; this.selectNextUnit(); } });
return actions;
}
// "Disembark" arms target-picking mode: the next tile click either unloads
// the whole hold there (if it's one of the highlighted candidate tiles) or
// just cancels, same as clicking away from any other targeting cursor.
beginDisembark(boat, tiles) {
this.disembarkPending = { boatId: boat.id, tiles };
this.view.showDisembarkTiles(tiles);
}
cancelDisembark() {
if (!this.disembarkPending) return;
this.disembarkPending = null;
this.view?.showDisembarkTiles(null);
}
resolveDisembarkClick(c, r) {
const { boatId, tiles } = this.disembarkPending;
this.cancelDisembark();
const match = tiles.find((t) => t.x === c && t.y === r);
if (!match) return;
const boat = Logic.unitById(this.state, boatId);
if (!boat) return;
this.tryDisembarkAll(boat, match.dx, match.dy);
}
// disembarkTiles() only ever offers tiles clear of foreign cities/units, so
// every passenger's individual disembark() below is guaranteed to succeed —
// no war-confirmation branch needed here (contrast tryStep's single-unit
// path, which can walk a unit into a tile that does need one).
tryDisembarkAll(boat, dx, dy) {
if (this.state.current !== this.state.humanIndex) return;
for (const u of Logic.unitsOnBoat(this.state, boat)) {
Logic.disembark(this.rules, this.state, u, dx, dy);
}
this.afterAction();
}
openUnitActionMenu(unit) {
const def = this.rules.units[unit.type];
this.openActionListMenu(def.name, this.getUnitActions(unit));
}
// Clicked a DIFFERENT tile that has one or more of the player's own units
// while a unit is already selected — offer to move the selected unit there
// (joining whatever's on that tile) or to switch active control to one of
// the units already there.
openUnitInteractionMenu(sel, targetUnits, c, r) {
const actions = [
{ label: 'Move', onSelect: () => this.moveTo(sel, c, r) },
...targetUnits.map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
})),
];
this.openActionListMenu(this.rules.units[targetUnits[0].type].name, actions);
}
// Clicked the SAME tile the selected unit is already stacked on — offer to
// switch active control to one of the other units there, or "Join" to
// leave the current selection as-is (they're already stacked together).
openStackSwitchMenu(sel, stackUnits) {
const actions = stackUnits.filter((u) => u.id !== sel.id).map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
}));
this.openActionListMenu('Unit Stack', actions, 'JOIN');
}
// Shared list-of-buttons modal used by openUnitActionMenu (clicking your
// own selected unit) and openUnitInteractionMenu (clicking another unit).
openActionListMenu(title, actions, cancelLabel = 'CANCEL') {
this.modalOpen = true;
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const itemH = 52;
const gap = 10;
const pw = 380;
const ph = 96 + actions.length * (itemH + gap) + 56;
const px = cx - pw / 2;
const py = cy - ph / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
const panel = this.add.rectangle(cx, py + ph / 2, pw, ph, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const titleText = this.add.text(cx, py + 34, title, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5);
root.add([dim, panel, titleText]);
// Callers open this from a pointerdown/click, so the mouse button is
// often still held when these buttons appear (e.g. selected units are
// frequently screen-centered — selectNextUnit centers the view on them —
// same as this menu, so the imminent mouse-up can land right on a
// button and fire it before the player ever sees the menu). Ignore
// clicks until that one release has passed.
let armed = !this.input.activePointer.isDown;
const arm = () => { armed = true; };
if (!armed) this.input.once('pointerup', arm);
const close = () => {
this.input.off('pointerup', arm);
root.destroy(true);
this.modalOpen = false;
};
actions.forEach((action, i) => {
const by = py + 76 + i * (itemH + gap);
const btn = new Button(this, cx, by + itemH / 2, action.label, () => {
if (!armed) return;
close();
action.onSelect();
}, { width: pw - 48, height: itemH, fontSize: 18 });
root.add(btn);
});
const cancelY = py + 76 + actions.length * (itemH + gap) + 8;
const cancel = new Button(this, cx, cancelY + 20, cancelLabel, () => {
if (!armed) return;
close();
}, { width: pw - 48, height: 44, fontSize: 16, variant: 'ghost' });
root.add(cancel);
}
afterAction() {
// Repaint tiles that may have changed (work orders complete on turn start,
// but roads from engineers etc. show up next refresh — cheap full check
// is unnecessary; unit/city layer + HUD is enough here). Baked-terrain
// features the human changes mid-turn (popping a hut) do need it now,
// though — waiting for startHumanTurn leaves a stale badge all turn.
this.repaintFromEvents();
this.view.refresh();
this.refreshHud();
if (this.state.over) this.onGameOver();
}
// ---------------------------------------------------------------------------
// Turn cycle
startHumanTurn(process = true) {
const human = this.state.humanIndex;
if (process) Logic.beginCivTurn(this.rules, this.state, human);
this.repaintFromEvents();
this.busy = false;
this.endTurnBtn?.setAlpha(1);
const civ = this.state.civs[human];
if (!civ.alive) { this.onGameOver(); return; }
// Research: announce the completed discovery FIRST, open the picker as
// that popup is dismissed (onDismiss replaces the queue-resume; the tech
// screen's close handler resumes it), and let everything else queue up
// behind. Without a completion event (fresh game, resumed save) the
// picker still opens directly.
const techEvent = this.state.events.find((e) => e.type === 'techDone' && e.civ === human && !e.announced);
const needsPick = !civ.researching && Logic.availableTechs(this.rules, civ).length > 0;
if (techEvent) {
techEvent.announced = true;
this.announceStatus(`Research complete: ${this.rules.techs[techEvent.tech].name}`,
needsPick ? () => this.openTech() : undefined);
} else if (needsPick) {
this.openTech();
}
this.presentAIRequests();
this.announceCityAttacks();
this.announceEvents();
this.view.refresh();
this.refreshHud();
this.selectNextUnit();
}
onEndTurn() {
if (this.busy || this.modalOpen || this.phase !== 'playing') return;
if (this.state.current !== this.state.humanIndex) return;
this.cancelDisembark();
Logic.endCivTurn(this.rules, this.state, this.state.humanIndex);
this.saveGame();
this.runToHumanTurn();
}
runToHumanTurn() {
this.setEndTurnFlash(false);
this.busy = true;
this.endTurnBtn?.setAlpha(0.4);
this.refreshUnitPanel();
const stepCiv = () => {
if (this.phase !== 'playing') return;
if (this.state.over) { this.onGameOver(); return; }
if (!this.state.civs[this.state.humanIndex].alive) { this.onGameOver(); return; }
const cur = this.state.current;
if (cur === this.state.humanIndex) {
this.startHumanTurn(true);
return;
}
Logic.beginCivTurn(this.rules, this.state, cur);
const before = new Map(Logic.civUnits(this.state, cur).map((u) => [u.id, [u.x, u.y]]));
runAITurn(this.rules, this.state, cur);
Logic.endCivTurn(this.rules, this.state, cur);
// Resolve the whole civ's turn instantly (as ever). Combats play first,
// from whatever's still on screen from before this civ acted (no
// refresh yet, so a ghost can stand in for a unit that's already dead
// or already moved); then one refresh syncs everyone else, and the
// remaining units' start/end tiles are diffed and glided — same
// "resolve now, animate after" trick as the player's own moves, just
// via a before/after snapshot instead of a waypoint path.
const combatEvents = this.collectNewCombatEvents();
// Combats against the HUMAN's cities are deferred to a dedicated
// cinematic (camera pan + replay + outcome popup) at the start of the
// human's next turn — those demand a response. Rival-vs-rival battles
// at cities the human has explored play live right here instead: pan,
// replay, and a status-log line, no blocking popup. City tiles stay
// marked once explored (see MapView.refresh()'s `explored[idx]`
// check), so this reaches a known rival city even if it's currently
// outside the fog-of-war vision set. `e.defenderCiv === human` catches
// a city the human just lost this step (city.civ already shows the
// conqueror by the time we classify).
const human = this.state.humanIndex;
const elsewhere = [];
const rivalCityCombats = [];
for (const e of combatEvents) {
const city = Logic.cityAt(this.state, e.x, e.y);
const cityExplored = city && this.state.explored[human][Logic.tileIndex(this.state.world, city.x, city.y)];
if (city && (city.civ === human || e.defenderCiv === human)) {
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
} else if (city && cityExplored) {
rivalCityCombats.push({ e, city });
} else {
elsewhere.push(e);
}
}
const visibleCombat = elsewhere.filter((e) => this.view.isTileVisible(e.ax, e.ay)
|| this.view.isTileVisible(e.x, e.y));
const combatUnitIds = new Set(combatEvents.flatMap((e) => [e.attackerId, e.defenderId]));
const moves = [];
for (const [id, from] of before) {
if (combatUnitIds.has(id)) continue;
const u = Logic.unitById(this.state, id);
if (!u || u.carriedBy) continue;
if (u.x !== from[0] || u.y !== from[1]) moves.push({ unitId: id, from, to: [u.x, u.y] });
}
const afterCombat = () => {
this.view.refresh();
this.refreshHud();
this.view.animateUnitsAlong(moves, 1000, 250, () => {
this.time.delayedCall(90, stepCiv);
});
};
// Rival-vs-rival city battles first (pan + replay + log line, one at a
// time), then ordinary visible field combats, then the move glide.
const playRivalCityCombats = (done) => {
const item = rivalCityCombats.shift();
if (!item) { done(); return; }
const { e, city } = item;
const attackerName = this.state.civs[e.attackerCiv]?.name ?? 'An enemy';
const captured = e.attackerWon && city.civ === e.attackerCiv;
const outcome = captured ? `${city.name} has fallen to ${attackerName}!`
: e.attackerWon ? `${city.name}'s defenders were defeated!`
: `${city.name} held its ground!`;
this.logMessage(`${attackerName} attacked ${city.name}${outcome}`);
this.view.panToTile(city.x, city.y);
this.view.animateCombat([e], () => playRivalCityCombats(done));
};
playRivalCityCombats(() => {
if (visibleCombat.length) this.view.animateCombat(visibleCombat, afterCombat);
else afterCombat();
});
};
stepCiv();
}
// ---------------------------------------------------------------------------
// Events, proposals, toasts
// Terrain changes (work orders, founded cities' roads) arrive as engine
// events; repaint just those tiles instead of rebaking the world.
repaintFromEvents() {
for (const e of this.state.events) {
if (e.painted) continue;
e.painted = true;
if (e.type === 'workDone' || e.type === 'hut') {
// A popped hut is cleared from world.huts, but the hut badge lives in
// the baked terrain texture — without this it stays drawn all session.
this.view.repaintTileAndNeighbors(e.x, e.y);
} else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') {
const city = Logic.cityById(this.state, e.cityId);
if (city) this.view.repaintTileAndNeighbors(city.x, city.y);
}
}
}
// Combats against the human's own cities, deferred from runToHumanTurn()'s
// per-civ stepping (see stepCiv there) so they always get a camera pan +
// replay + outcome popup at the start of the human's turn. Rival-vs-rival
// city battles play live during the AI phase instead.
announceCityAttacks() {
const attacks = this.pendingCityAttacks;
this.pendingCityAttacks = [];
for (const atk of attacks) {
const { e, cityName, x, y } = atk;
const attackerName = this.state.civs[e.attackerCiv]?.name ?? 'An enemy';
const outcome = e.attackerWon ? `${cityName}'s defenders were defeated!` : `${cityName} held its ground!`;
this.announceStatus(`${attackerName} attacked ${cityName}! ${outcome}`, undefined, (proceed) => {
this.view.panToTile(x, y);
this.view.animateCombat([e], proceed);
});
}
}
announceEvents() {
const human = this.state.humanIndex;
for (const e of this.state.events) {
if (e.announced) continue;
e.announced = true;
if (e.type === 'war' && e.b === human) {
this.announceStatus(`${this.state.civs[e.a].name} declares WAR on you!`);
} else if (e.type === 'techDone' && e.civ === human) {
this.announceStatus(`Research complete: ${this.rules.techs[e.tech].name}`);
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
if (e.to === human) {
this.announceStatus(`You captured ${e.name}!`);
} else {
// Losing a city gets the same camera-pan-then-replay treatment as
// a defended attack (see announceCityAttacks) — this covers the
// undefended-city case, which never generates a 'combat' event at
// all (see captureCity in CivilizationLogic.js), so this is the
// only place that ever shows anything for it.
const conqueror = this.state.civs[e.to]?.name ?? 'the enemy';
const cityRef = Logic.cityById(this.state, e.cityId);
this.announceStatus(`${e.name} has fallen to ${conqueror}!`, undefined, (proceed) => {
if (!cityRef) { proceed(); return; }
this.view.panToTile(cityRef.x, cityRef.y);
if (e.attackerType) {
this.view.animateCityFall(e.attackerCiv, e.attackerType, cityRef.x, cityRef.y, proceed);
} else {
this.time.delayedCall(500, proceed);
}
});
}
} else if (e.type === 'newGovernment' && e.civ === human) {
this.announceStatus(`The revolution is complete — ${this.rules.governments[e.government].name} established!`);
} else if (e.type === 'civEliminated') {
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
} else if (e.type === 'spaceshipLaunched') {
this.announceStatus(`${this.state.civs[e.civ].name} launched a spaceship!`);
} else if (e.type === 'treatyRenounced' && e.to === human) {
// A grudge that finally boiled over (CivilizationDiplomacy.js).
this.leaderSays(e.from, pickLine(RENOUNCE, this.chatVars(e.from)));
this.announceStatus(`${this.state.civs[e.from].name} has renounced your ${e.was === 'ceasefire' ? 'cease-fire' : e.was}!`,
() => this.openDiplomacy({ focusCivId: e.from }));
} else if (e.type === 'pledgeBroken' && e.to === human) {
this.announceStatus(`${this.state.civs[e.from].name} accuses you of breaking your word over ${e.cityName}.`);
} else if (e.type === 'aiGift' && e.to === human) {
const giver = this.state.civs[e.from].name;
const vars = { ...this.chatVars(e.from), gold: e.gold, tech: e.techId ? this.rules.techs[e.techId].name : '' };
this.leaderSays(e.from, pickLine(e.techId ? GIFT_TECH : GIFT_GOLD, vars));
this.announceStatus(e.techId
? `${giver} sends you the secrets of ${this.rules.techs[e.techId].name} as a gift!`
: `${giver} sends you a gift of ${e.gold} gold!`,
() => this.openDiplomacy({ focusCivId: e.from }));
} else if (e.type === 'contact' && (e.a === human || e.b === human)) {
const other = e.a === human ? e.b : e.a;
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
() => this.openDiplomacy({ focusCivId: other, playIntro: true }));
} else if (e.type === 'buildingDone' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const building = this.rules.buildings[e.building];
if (!city || !building) continue;
// completeBuild() already auto-picked a fallback next build (see
// CivilizationLogic.js pickNextBuild) so the city doesn't idle —
// VIEW CITY is here so the player can override that choice.
this.announceStatus(`${building.name} completed in ${city.name}!`, undefined,
(proceed) => { this.view.panToTile(city.x, city.y); this.time.delayedCall(500, proceed); },
{
label: 'VIEW CITY',
onClick: () => {
this.modalOpen = true;
openCityScreen(this, this.rules, this.state, city, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
this.showNextStatus();
});
},
});
} else if (e.type === 'barbUprising' && e.civ === human) {
// Raiders only get a popup when they're OUR problem; a raid on a rival
// is their business. Camera pan + SHOW ME reuses announceStatus's
// `extra` param (added for buildingDone) rather than new plumbing.
const near = this.nearestCityName(e.x, e.y);
this.announceStatus(
`Barbarian uprising${near ? ` near ${near}` : ''}!`, undefined, undefined,
{ label: 'SHOW ME', onClick: () => { this.view.panToTile(e.x, e.y); this.showNextStatus(); } },
);
} else if (e.type === 'barbLeaderSighted' && e.civ === human) {
const near = this.nearestCityName(e.x, e.y);
this.announceStatus(
`A Barbarian Warlord has been sighted${near ? ` near ${near}` : ''}!\n`
+ 'Cut down his escort and corner him alone to claim a ransom.',
undefined, undefined,
{ label: 'SHOW ME', onClick: () => { this.view.panToTile(e.x, e.y); this.showNextStatus(); } },
);
} else if (e.type === 'ransom' && e.civ === human) {
this.announceStatus(`The horde pays ${e.gold} gold for their Warlord's safe return!`);
} else if (e.type === 'barbLeaderEscaped') {
if (this.state.explored[human]?.[Logic.tileIndex(this.state.world, e.x, e.y)]) {
this.logMessage('The Barbarian Warlord slipped away into the hills.');
}
} else if (e.type === 'barbLeaderKilled') {
if (this.state.explored[human]?.[Logic.tileIndex(this.state.world, e.x, e.y)]) {
this.logMessage('The Barbarian Warlord was cut down in the fighting — no ransom.');
}
} else if (e.type === 'barbCityRazed') {
this.announceStatus(`${e.name} has been burned to the ground by barbarians!`);
} else if (e.type === 'barbCityHeld') {
this.announceStatus(`Barbarians have seized ${e.name}!`, undefined,
(proceed) => { this.view.panToTile(e.x, e.y); this.time.delayedCall(500, proceed); });
} else if (e.type === 'buildingSold' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const building = this.rules.buildings[e.building];
if (!city || !building) continue;
this.announceStatus(`Not enough gold! ${building.name} in ${city.name} was sold to cover upkeep.`);
} else if (e.type === 'spaceshipPart' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const part = this.rules.units[e.part];
if (!city || !part) continue;
this.announceStatus(`${part.name} completed in ${city.name}!`, undefined,
(proceed) => { this.view.panToTile(city.x, city.y); this.time.delayedCall(500, proceed); },
{
label: 'VIEW CITY',
onClick: () => {
this.modalOpen = true;
openCityScreen(this, this.rules, this.state, city, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
this.showNextStatus();
});
},
});
}
}
}
// Appends a line to a leader's conversation log (state.chatLog, the same
// history the diplomacy screen types out) for things they "say" outside that
// screen — renounced treaties, unsolicited gifts. Plain data, so it rides
// along in the save like the rest of the log.
leaderSays(civId, text) {
this.state.chatLog ??= {};
const hist = (this.state.chatLog[civId] ??= []);
hist.push({ who: 'o', text });
if (hist.length > 50) hist.splice(0, hist.length - 50);
}
chatVars(civId) {
return { you: this.state.civs[this.state.humanIndex].name, me: this.state.civs[civId].name };
}
// One AI-initiated audience per turn (rules.diplomacy.maxPerTurn): a popup
// announcing who wants to see you, then the diplomacy screen focused on them
// with ACCEPT/REFUSE in place of the usual actions. Requests the player
// closes without answering stay queued and come back next turn; after
// lapseTurns of that they resolve as a half-weight refusal, so ignoring a
// leader is milder than refusing to their face but not free.
presentAIRequests() {
const human = this.state.humanIndex;
const tuning = this.rules.diplomacy ?? {};
const queue = this.state.pendingRequests ?? [];
// Drop requests the world has invalidated, and lapse the stale ones.
this.state.pendingRequests = queue.filter((req) => {
if (!requestValid(this.rules, this.state, req)) return false;
if (this.state.turn - req.turn >= (tuning.lapseTurns ?? 3)) {
resolveRequest(this.rules, this.state, req, false, 0.5);
return false;
}
return true;
});
// announceStatus owns the queue (and kicks it when nothing else is open),
// so there is nothing to resume here when no request is due.
const max = tuning.maxPerTurn ?? 1;
for (const req of this.state.pendingRequests.slice(0, max)) {
const from = this.state.civs[req.from];
this.announceStatus(`${from.name} requests an audience`, () => this.openDiplomacy({
focusCivId: req.from,
request: req,
onResolved: (resolved) => {
this.state.pendingRequests = this.state.pendingRequests.filter((p) => p !== resolved);
this.refreshHud();
},
}));
}
}
// Closest city of the human's that the player would recognise, for locating
// a raid in the announcement text.
nearestCityName(x, y) {
const mine = Logic.civCities(this.state, this.state.humanIndex);
let best = null;
let bestD = Infinity;
for (const c of mine) {
const d = Logic.cheb(c.x, c.y, x, y);
if (d < bestD) { bestD = d; best = c; }
}
return best ? best.name : null;
}
toastHut(hut) {
const msgs = {
gold: `You found ${hut.gold} gold in the hut!`,
tech: hut.tech ? `The tribe teaches you ${this.rules.techs[hut.tech].name}!` : 'A gift!',
unit: `A band of ${hut.unit} joins you!`,
ambushWon: 'Hostile tribe! Your unit fought them off.',
ambushLost: 'Hostile tribe! Your unit was lost!',
barbarians: `You have unleashed a horde of barbarians! ${hut.count ?? ''} raiders pour out of the hut.`.replace(' ', ' '),
};
this.announceStatus(msgs[hut.outcome] ?? 'An empty hut.');
}
toast(msg) {
const y = 90 + this.toasts.length * 40;
const t = this.add.text(GAME_WIDTH / 2, y, msg, {
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex,
backgroundColor: '#1e1a12ee', padding: { x: 16, y: 7 },
}).setOrigin(0.5).setDepth(D.toast);
this.toastRoot.add(t);
this.toasts.push(t);
this.tweens.add({
targets: t, alpha: 0, delay: 2600, duration: 500,
onComplete: () => {
this.toasts = this.toasts.filter((x) => x !== t);
t.destroy();
},
});
}
confirmWar(civIdx, onYes) {
const name = this.state.civs[civIdx].name;
this.confirmDialog(`Attack ${name}? This means WAR!`, onYes, () => {});
}
confirmDialog(message, onYes, onNo, yesLabel = 'YES', noLabel = 'NO') {
this.modalOpen = true;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setInteractive();
const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 640, 220, COLORS.panel)
.setStrokeStyle(2, COLORS.accent);
const txt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, message, {
fontFamily: FONT, fontSize: '24px', color: COLORS.textHex,
wordWrap: { width: 580 }, align: 'center',
}).setOrigin(0.5);
const close = (fn) => () => { root.destroy(true); this.modalOpen = false; fn?.(); };
const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, yesLabel, close(onYes),
{ width: 180, height: 52 });
const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, noLabel, close(onNo),
{ width: 180, height: 52, variant: 'ghost' });
root.add([dim, panel, txt, yes, no]);
}
// ---------------------------------------------------------------------------
// Screens
openTech() {
if (this.modalOpen) return;
this.modalOpen = true;
openTechScreen(this, this.rules, this.state, () => {
this.modalOpen = false;
this.refreshHud();
// Resume any statuses that queued while the tech screen was up — in
// particular the start-of-turn flow, where the research-complete
// popup's onDismiss opens this screen with the rest of the turn's
// announcements still waiting in the queue.
this.showNextStatus();
});
}
openGovt() {
if (this.modalOpen) return;
this.modalOpen = true;
openGovernmentScreen(this, this.rules, this.state, () => {
this.modalOpen = false;
this.refreshHud();
});
}
openDiplomacy({ focusCivId = null, playIntro = false, request = null, onResolved = null } = {}) {
if (this.modalOpen) return;
this.modalOpen = true;
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
this.showNextStatus();
}, { focusCivId, playIntro, request, onResolved });
}
openSpaceship() {
if (this.modalOpen) return;
this.modalOpen = true;
openSpaceshipScreen(this, this.rules, this.state, () => {
this.modalOpen = false;
this.refreshHud();
if (this.state.over) this.onGameOver();
});
}
openMenu() {
if (this.modalOpen) return;
this.modalOpen = true;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setInteractive();
const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 460, 340, COLORS.panel)
.setStrokeStyle(2, COLORS.accent);
const title = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'MENU', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
}).setOrigin(0.5);
const close = () => { root.destroy(true); this.modalOpen = false; };
const resume = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 50, 'RESUME', close,
{ width: 320, height: 56 });
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
this.saveGame();
this.logMessage('Game saved');
close();
}, { width: 320, height: 56, variant: 'ghost' });
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {
this.saveGame();
this.scene.start('GameMenu');
}, { width: 320, height: 56, variant: 'ghost' });
root.add([dim, panel, title, resume, save, quit]);
}
onGameOver() {
if (this.phase === 'over') return;
this.phase = 'over';
this.busy = false;
this.clearSave();
showVictoryOverlay(this, this.rules, this.state, this.opponentsData, () => {
this.scene.restart({ game: this.gameDef });
});
}
}