// 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 }); }); } }