1793 lines
76 KiB
JavaScript
1793 lines
76 KiB
JavaScript
// 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 });
|
||
});
|
||
}
|
||
}
|