feat(civ): add diplomacy chat, government screen, and gameplay improvements

- Introduce CivilizationChat.js with OPENERS, PLAYER_LINES, and REPLIES pools
  for dynamic diplomacy dialogue between leaders and player.
- Add chat log panel to diplomacy screen with typing animation, scrolling,
  and persistent chat history stored in state.
- All diplomatic actions (treaties, war, gifts, tech exchange) now generate
  contextual chat messages.
- Add GOVT button and openGovernmentScreen() with empire wellbeing summary,
  government cards showing pros/cons, and revolution mechanic.
- Allow disbanding units with warning about troops aboard transports.
- Improve combat capture: land units can capture undefended cities after
  winning battle; sea/air units cannot.
- Enhance unit stack tooltips with home city display, ship cargo summary,
  grouped friendly stacks, and enemy effective defense info.
- Preserve portrait emotion animations by reusing when switching leaders.
This commit is contained in:
Brian Fertig 2026-07-17 10:32:01 -06:00
parent bace0356c2
commit aee3352efe
6 changed files with 649 additions and 26 deletions

View File

@ -0,0 +1,196 @@
// Civilization — canned diplomacy chat lines for the running conversation log
// in the diplomacy screen. Pools are arrays of template strings; pickLine()
// picks a random variant and fills {token} placeholders. Tokens in use:
// {you} — the player's leader name (the person being spoken to)
// {me} — the opponent leader's name
// {gold} — gold amount, {tech}/{give}/{get} — technology names.
// Resolved text (not templates) is what gets stored in state.chatLog.
export function pickLine(pool, vars = {}) {
const t = pool[Math.floor(Math.random() * pool.length)];
return t.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? vars[k] : `{${k}}`));
}
// Opening line when the player first views a leader with no chat history —
// keyed by current relation, then by mood (Logic.attitudeMood: upset/idle/happy).
export const OPENERS = {
war: {
upset: [
'We are at war, {you}! I will crush you and scatter your armies to the winds. Tell me, why should I bother meeting with you? What do you propose?',
'You dare show your face, {you}? My armies hunger for the ruin of your cities. Speak quickly, before my patience runs out.',
'There is nothing between us but blood and fire, {you}. Say what you came to say.',
],
idle: [
'So, {you}. This war serves neither of us particularly well. What is it you want?',
'You have fought better than I expected, {you}. I am listening — for now.',
'War is expensive, {you}. If you have come with terms, I will hear them.',
],
happy: [
'Strange days, {you} — I bear you no hatred, yet our armies clash. Perhaps we can mend this?',
'I confess I rather like you, {you}, which makes this war all the more regrettable. Speak.',
],
},
ceasefire: {
upset: [
'The guns are silent, {you}, but do not mistake that for forgiveness.',
'This cease-fire hangs by a thread, {you}. Choose your next words with care.',
],
idle: [
'Our cease-fire holds, {you}. Shall we speak of something more permanent?',
'An uneasy quiet sits between us, {you}. What brings you to my court?',
],
happy: [
'The silence of the guns suits us both, {you}. Perhaps a lasting peace is within reach?',
'I am glad the fighting has stopped, {you}. Let us keep it that way.',
],
},
contact: {
upset: [
'I know of you, {you}, and little of what I know pleases me. State your business.',
'Your reputation precedes you, {you} — and it is not a flattering one. Why are you here?',
],
idle: [
'Greetings, {you}. Our peoples are strangers yet. What do you propose?',
'So you are the famous {you}. Speak — I am curious what you want.',
],
happy: [
'Well met, {you}! I have heard fine things of your civilization. Shall we talk?',
'A pleasure at last, {you}. I suspect our peoples could be great friends.',
],
},
peace: {
upset: [
'We have peace on paper, {you}, but my people do not trust yours. What do you want?',
'The treaty holds, {you} — barely. Do not test it.',
],
idle: [
'Peace serves us both, {you}. What matters shall we discuss today?',
'You find me in a reasonable mood, {you}. What brings you?',
],
happy: [
'Always a pleasure, {you}! Our peace has been good for both our peoples.',
'My friend {you}! Come, let us talk as neighbors do.',
],
},
alliance: {
upset: [
'We are allies, {you}, though lately I wonder why. Speak.',
'An alliance is a promise, {you}. See that you keep yours.',
],
idle: [
'Greetings, ally. What business does {you} bring today?',
'Our banners fly together, {you}. What do you need of me?',
],
happy: [
'My trusted friend {you}! Together we are unstoppable. What can I do for you?',
'Ah, {you}! There is no leader I would rather stand beside. Speak, friend.',
],
},
};
// What the player "says" when clicking each action button.
export const PLAYER_LINES = {
proposeCeasefire: [
'I propose we end hostilities. Would you agree to a cease-fire?',
'Enough blood has been spilled. Let us lay down arms — a cease-fire, here and now.',
'This war profits neither of us, {me}. Will you agree to halt the fighting?',
],
proposePeace: [
'Let us put this conflict behind us for good. I propose a formal peace treaty.',
'Our peoples deserve better than endless struggle. Will you sign a peace treaty?',
'I offer you peace, {me}, honestly and openly. Do you accept?',
],
proposeAlliance: [
'Our interests align, {me}. I propose we formalize our friendship — an alliance.',
'Together we would be stronger than apart. Will you join me in an alliance?',
'Let our banners fly side by side. I propose an alliance.',
],
declareWar: [
'Words are finished between us, {me}. I declare war!',
'You leave me no choice. From this day, we are at war!',
'Prepare your defenses, {me} — my armies march on you. This means war!',
],
giftGold: [
'Please accept this gift of {gold} gold, with my compliments.',
'A token of goodwill: {gold} gold from my treasury to yours.',
'May this {gold} gold serve your people well. A gift, freely given.',
],
giftTech: [
'My scholars will share the secrets of {tech} with your people. A gift.',
'Please accept knowledge of {tech}, with my compliments.',
'I offer you {tech}, freely and without condition.',
],
exchangeTech: [
'A trade of knowledge: my {give} for your {get}. Do we have a deal?',
'I propose an exchange — {give} for {get}. Fair, I think.',
'Your scholars know {get}; mine know {give}. Shall we trade?',
],
};
// The leader's reply to each action. Proposals key by treaty kind with
// accept/reject variants; war and gifts have a single outcome each.
export const REPLIES = {
ceasefire: {
accept: [
'Very well. Let the guns fall silent — a cease-fire it is.',
'Agreed. My armies will hold, so long as yours do the same.',
'So be it. The fighting stops — for now.',
],
reject: [
'Ha! You ask for mercy with one hand and hold a sword in the other. No.',
'I decline. My armies are not finished with you yet.',
'No cease-fire. You started this dance, {you} — now finish it.',
],
},
peace: {
accept: [
'Agreed. Let there be peace between our peoples at last.',
'I accept. May this treaty outlive us both.',
'Peace, then. My people will be glad of it.',
],
reject: [
'Peace? With you? Not while I still draw breath.',
'Your words are pretty, {you}, but I do not trust them. I refuse.',
'No. The time for peace has not yet come.',
],
},
alliance: {
accept: [
'An alliance! Yes — together we shall shape the fate of this world.',
'I accept, and gladly. Your enemies are now my enemies.',
'So be it, ally. May our friendship endure the ages.',
],
reject: [
'An alliance binds tighter than I am willing to be bound. I must refuse.',
'Not yet, {you}. Prove your friendship further, and ask again.',
'I decline. My people walk their own path for now.',
],
},
exchange: {
accept: [
'A fair trade. My scholars will send what they know.',
'Done. Knowledge for knowledge — the best kind of bargain.',
'Agreed. May we both profit from it.',
],
reject: [
'You ask for too much and offer too little. No deal.',
'My scholars advise against it. I must decline.',
'Tempting, but no. Perhaps another offer?',
],
},
declareWar: [
'So be it, fool! You will regret this day for as long as you live — which will not be long!',
'War?! Then war you shall have, {you} — to the bitter end!',
'You have made a grave mistake. My armies will darken your horizons!',
],
giftGold: [
'Gold, freely given? You surprise me, {you}. My treasury thanks you.',
'A generous gift. I will remember this kindness.',
'You have my thanks. Perhaps I have misjudged you.',
],
giftTech: [
'The secrets of {tech}? A princely gift. My scholars rejoice.',
'Knowledge is the finest of gifts. You have my gratitude, {you}.',
'My people will put {tech} to good use. Thank you.',
],
};

View File

@ -18,7 +18,7 @@ import { runAITurn, respondToProposal } from './CivilizationAI.js';
import { CivilizationMapView } from './CivilizationMapView.js';
import { openCityScreen } from './CivilizationCityScreen.js';
import {
openTechScreen, openDiplomacyScreen, openSpaceshipScreen, showVictoryOverlay,
openTechScreen, openDiplomacyScreen, openGovernmentScreen, openSpaceshipScreen, showVictoryOverlay,
} from './CivilizationScreens.js';
const FONT = '"Julius Sans One"';
@ -403,6 +403,7 @@ export default class CivilizationGame extends Phaser.Scene {
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);
@ -1029,6 +1030,13 @@ export default class CivilizationGame extends Phaser.Scene {
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)
@ -1075,6 +1083,11 @@ export default class CivilizationGame extends Phaser.Scene {
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;
}
@ -1377,6 +1390,8 @@ export default class CivilizationGame extends Phaser.Scene {
}
});
}
} 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') {
@ -1492,6 +1507,15 @@ export default class CivilizationGame extends Phaser.Scene {
});
}
openGovt() {
if (this.modalOpen) return;
this.modalOpen = true;
openGovernmentScreen(this, this.rules, this.state, () => {
this.modalOpen = false;
this.refreshHud();
});
}
openDiplomacy({ focusCivId = null, playIntro = false } = {}) {
if (this.modalOpen) return;
this.modalOpen = true;

View File

@ -1024,12 +1024,18 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
}
attacker.hp = Math.max(1, duel.attackerHp);
if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true;
if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0) {
// A land winner advancing into a now-empty enemy city captures it on the
// way in (same rule as tryMove's undefended-city path). Sea/air winners
// stay put instead — they raid the walls but can never take the city.
const conquered = city && city.civ !== attacker.civ ? city : null;
if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0
&& !(conquered && attDef.domain !== 'land')) {
attacker.x = tx;
attacker.y = ty;
dropCarried(rules, state, attacker, tx, ty);
exploreAround(state, attacker.civ, tx, ty, 2);
makeContacts(rules, state, attacker.civ, tx, ty);
if (conquered) captureCity(rules, state, attacker, conquered);
advanced = true;
}
} else {

View File

@ -676,7 +676,10 @@ export class CivilizationMapView {
} else {
container.setData('hoverOnly', true);
}
this.tooltip.attachTo(container, () => describeUnitStackTooltip(rules, civ, this.opponentsData, units));
this.tooltip.attachTo(container, () => describeUnitStackTooltip(
rules, this.state, civ, this.opponentsData, units,
this.state.units.find((u) => u.id === this.selectedUnitId) ?? null,
));
container.setDepth(y + 2);
this.dynamic.add(container);
this.unitContainers.set(unit.id, container);

View File

@ -7,7 +7,8 @@ import { Button } from '../../ui/Button.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { Tooltip } from '../../ui/Tooltip.js';
import * as Logic from './CivilizationLogic.js';
import { describeTechTooltip } from './CivilizationTooltips.js';
import { describeTechTooltip, GOVERNMENT_TEXT } from './CivilizationTooltips.js';
import { OPENERS, PLAYER_LINES, REPLIES, pickLine } from './CivilizationChat.js';
const FONT = '"Julius Sans One"';
const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
@ -120,8 +121,13 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
const human = state.humanIndex;
const civ = state.civs[human];
let portrait = null;
let portraitCivId = null;
let ring = null;
let chatCleanup = () => {}; // assigned once the chat panel exists
const { root, close } = modalShell(scene, 'DIPLOMACY', () => {
portrait?.destroy();
ring?.destroy();
chatCleanup();
onClose();
});
const width = 1700;
@ -152,7 +158,12 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
contacts.forEach((other, i) => {
const y = top + 30 + i * 74;
const rel = civ.relations[other.id];
const rect = scene.add.rectangle(left + 190, y, 380, 64, 0x181510)
// Row tinted a dark version of the civ's map colour (~30% brightness).
const base = parseInt(other.color.slice(1), 16);
const dark = (Math.round(((base >> 16) & 0xff) * 0.3) << 16)
| (Math.round(((base >> 8) & 0xff) * 0.3) << 8)
| Math.round((base & 0xff) * 0.3);
const rect = scene.add.rectangle(left + 190, y, 380, 64, dark)
.setStrokeStyle(2, other === selected ? COLORS.gold : COLORS.muted, 0.9);
const mood = Logic.attitudeMood(civ.attitude[other.id] ?? 0);
const moodDot = { upset: 0xe06c75, idle: 0xc8a84b, happy: 0x4a9e44 }[mood];
@ -169,9 +180,119 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
return { contact: 'No treaty', war: 'AT WAR', ceasefire: 'Cease-fire', peace: 'Peace treaty', alliance: 'Alliance' }[rel] ?? rel;
}
// -------------------------------------------------------------------------
// Chat log panel (right side): running conversation with the selected
// leader. Their lines render in their civ colour, the player's in theirs,
// typed out character by character. History lives in state.chatLog (plain
// data, so it rides along in the save) keyed by civ id.
const chatX = left + 1120;
const chatW = 500;
const chatY = top + 20;
const chatH = 770;
const chatBg = scene.add.rectangle(chatX + chatW / 2, chatY + chatH / 2, chatW, chatH, 0x000000, 1)
.setStrokeStyle(2, COLORS.muted, 0.6);
root.add(chatBg);
let msgContainer = scene.add.container(0, 0);
root.add(msgContainer);
const chatMaskG = scene.make.graphics({ x: 0, y: 0, add: false });
chatMaskG.fillStyle(0xffffff);
chatMaskG.fillRect(chatX, chatY, chatW, chatH);
msgContainer.setMask(chatMaskG.createGeometryMask());
let msgY = chatY + 16; // next message's top edge (pre-scroll coordinates)
let scrollUp = 0; // 0 = pinned to the newest message
let overflow = 0;
const typeQueue = [];
let typing = null;
function historyFor(id) {
state.chatLog ??= {};
return (state.chatLog[id] ??= []);
}
function applyScroll() {
overflow = Math.max(0, msgY - (chatY + chatH - 16));
scrollUp = Math.min(Math.max(scrollUp, 0), overflow);
msgContainer.y = -overflow + scrollUp;
}
chatBg.setInteractive();
chatBg.on('wheel', (pointer, dx, dy) => {
scrollUp -= dy * 0.5;
applyScroll();
});
function addChatText(who, text) {
const isPlayer = who === 'p';
const txt = scene.add.text(
isPlayer ? chatX + chatW - 16 : chatX + 16, msgY, text, {
fontFamily: FONT, fontSize: '19px', fontStyle: 'bold',
color: isPlayer ? civ.color : selected.color,
align: isPlayer ? 'right' : 'left',
wordWrap: { width: chatW - 90 }, lineSpacing: 4,
},
).setOrigin(isPlayer ? 1 : 0, 0);
msgY += txt.height + 14;
msgContainer.add(txt);
applyScroll();
return txt;
}
function pumpTypeQueue() {
if (typing || !typeQueue.length) return;
typing = typeQueue.shift();
typing.timer = scene.time.addEvent({
delay: 18,
loop: true,
callback: () => {
typing.i = Math.min(typing.full.length, typing.i + 2);
typing.txt.setText(typing.full.slice(0, typing.i));
if (typing.i >= typing.full.length) {
typing.timer.remove();
typing = null;
pumpTypeQueue();
}
},
});
}
function stopTyping() {
typing?.timer?.remove();
typing = null;
typeQueue.length = 0;
}
chatCleanup = () => {
stopTyping();
chatMaskG.destroy();
};
// Appends to the selected leader's history and types the message out.
// addChatText measures the full string first, so the message's slot height
// is reserved before the reveal starts — no reflow while typing.
function pushChat(who, text) {
const hist = historyFor(selected.id);
hist.push({ who, text });
if (hist.length > 50) hist.splice(0, hist.length - 50);
scrollUp = 0;
const txt = addChatText(who, text);
txt.setText('');
typeQueue.push({ txt, full: text, i: 0 });
pumpTypeQueue();
}
// Redraws the selected leader's saved history instantly (no animation) —
// used when switching leaders, where any in-flight typing is abandoned
// (its full text is already in history).
function renderChatHistory() {
stopTyping();
msgContainer.removeAll(true);
msgY = chatY + 16;
scrollUp = 0;
for (const m of historyFor(selected.id)) addChatText(m.who, m.text);
}
function drawDetail() {
portrait?.destroy();
portrait = null;
detail.destroy(true);
detail = scene.add.container(0, 0);
root.add(detail);
@ -181,15 +302,42 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
const mood = Logic.attitudeMood(attitude);
const cx = left + 700;
// Video portrait with the character's current mood.
const opData = opponentsData.find((o) => o.id === other.leaderId)
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
const shouldPlayIntro = introPending && other.id === focusCivId;
introPending = false;
try {
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: shouldPlayIntro });
if (mood !== 'idle') portrait.playEmotion(mood);
} catch (_) { /* portrait optional */ }
// Video portrait with the character's current mood. Only rebuilt when the
// shown civ changes: destroying the portrait cuts off any emotion video
// and speech clip mid-play (destroy() clears the video src and resets the
// speech queue), and action buttons redraw this pane right after calling
// playEmotion — reuse lets those clips run to completion like the
// persistent portraits in other games do.
if (portraitCivId !== other.id) {
portrait?.destroy();
portrait = null;
portraitCivId = other.id;
const opData = opponentsData.find((o) => o.id === other.leaderId)
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
const shouldPlayIntro = introPending && other.id === focusCivId;
introPending = false;
// Thick ring in the civ's map colour around the portrait. Drawn straight
// to the scene (not `detail`) because the portrait's backing renders at
// depth 66, above the modal container — the ring sits just outside the
// video circle, so the DOM video layer never covers it.
ring?.destroy();
ring = scene.add.graphics().setDepth(66);
ring.lineStyle(10, parseInt(other.color.slice(1), 16), 1);
ring.strokeCircle(cx, top + 170, 138);
try {
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: shouldPlayIntro });
if (mood !== 'idle') portrait.playEmotion(mood);
} catch (_) { /* portrait optional */ }
// Show this leader's chat history; a leader we've never chatted with
// opens by stating their current feelings toward the player.
renderChatHistory();
if (!historyFor(other.id).length) {
pushChat('o', pickLine(OPENERS[rel]?.[mood] ?? OPENERS.contact.idle,
{ you: civ.name, me: other.name }));
}
}
const vars = { you: civ.name, me: other.name };
const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood];
detail.add(scene.add.text(cx, top + 330,
@ -210,11 +358,17 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
if (rel === 'contact') actions.push(['PROPOSE PEACE', () => propose('peace')]);
if (rel === 'peace') actions.push(['PROPOSE ALLIANCE', () => propose('alliance')]);
if (rel !== 'war') actions.push(['DECLARE WAR', () => {
pushChat('p', pickLine(PLAYER_LINES.declareWar, vars));
pushChat('o', pickLine(REPLIES.declareWar, vars));
Logic.declareWar(rules, state, human, other.id);
drawDetail();
}]);
actions.push(['GIFT 50 GOLD', () => {
if (Logic.giftGold(state, human, other.id, 50)) drawDetail();
if (Logic.giftGold(state, human, other.id, 50)) {
pushChat('p', pickLine(PLAYER_LINES.giftGold, { ...vars, gold: 50 }));
pushChat('o', pickLine(REPLIES.giftGold, vars));
drawDetail();
}
}]);
actions.push(['EXCHANGE TECH', () => openExchange(other)]);
actions.push(['GIFT TECH', () => openGift(other)]);
@ -227,15 +381,19 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
function propose(kind) {
if (!Logic.canPropose(state, human, other.id, kind)) return;
const lineKey = { ceasefire: 'proposeCeasefire', peace: 'proposePeace', alliance: 'proposeAlliance' }[kind];
pushChat('p', pickLine(PLAYER_LINES[lineKey], vars));
const accepted = respondToProposal(rules, state, other.id, human, kind);
if (accepted) {
Logic.applyTreaty(state, human, other.id, kind);
portrait?.playEmotion('happy');
pushChat('o', pickLine(REPLIES[kind].accept, vars));
} else {
portrait?.playEmotion('upset');
Logic.bumpAttitude(state, other.id, human, -3);
pushChat('o', pickLine(REPLIES[kind].reject, vars));
}
scene.time.delayedCall(900, drawDetail);
drawDetail();
}
function openGift(target) {
@ -243,6 +401,9 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
pickTech(scene, rules, detail, cx, top + 120, 'GIFT A TECHNOLOGY', mine, (techId) => {
Logic.giftTech(rules, state, human, target.id, techId);
portrait?.playEmotion('happy');
const tvars = { ...vars, tech: rules.techs[techId].name };
pushChat('p', pickLine(PLAYER_LINES.giftTech, tvars));
pushChat('o', pickLine(REPLIES.giftTech, tvars));
drawDetail();
});
}
@ -253,14 +414,18 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
if (!mine.length || !theirs.length) return;
pickTech(scene, rules, detail, cx, top + 120, 'OFFER WHICH TECH?', mine, (giveId) => {
pickTech(scene, rules, detail, cx, top + 120, 'ASK FOR WHICH TECH?', theirs, (getId) => {
pushChat('p', pickLine(PLAYER_LINES.exchangeTech,
{ ...vars, give: rules.techs[giveId].name, get: rules.techs[getId].name }));
const ok = respondToProposal(rules, state, target.id, human, 'exchange', { giveId, getId });
if (ok) {
Logic.exchangeTechs(rules, state, human, target.id, giveId, getId);
portrait?.playEmotion('happy');
pushChat('o', pickLine(REPLIES.exchange.accept, vars));
} else {
portrait?.playEmotion('upset');
pushChat('o', pickLine(REPLIES.exchange.reject, vars));
}
scene.time.delayedCall(900, drawDetail);
drawDetail();
});
});
}
@ -290,6 +455,164 @@ function pickTech(scene, rules, parent, cx, cy, title, techIds, onPick) {
});
}
// ---------------------------------------------------------------------------
// Government
// Pros/cons bullets generated from the government's rules-JSON fields so the
// displayed numbers can never drift from what CivilizationLogic actually does
// with them (tileYield, cityYields, endCivTurn, reputation).
function govBullets(g) {
const out = [];
const pro = (text) => out.push({ text, pro: true });
const con = (text) => out.push({ text, pro: false });
if (g.noScience) con('No research — science output is zero');
if (g.corruptionFactor === 0) pro('No corruption at all');
else if (g.corruptionFactor <= 0.4) {
pro(g.flatCorruption ? 'Low corruption, the same across the whole empire' : 'Low corruption');
} else if (g.corruptionFactor <= 0.7) pro('Reduced corruption');
else if (g.corruptionFactor >= 1.5) con('Crippling corruption');
else con('Heavy corruption, worse far from the capital');
if (g.tradeBonus) pro(`+${g.tradeBonus} trade on tiles already producing trade`);
if (g.despotPenalty) con('1 food/shield/trade on tiles yielding 3 or more');
if (g.freeUnits > 0) pro(`${g.freeUnits} free units per city, then 1 ${g.unitUpkeep}/unit each turn`);
else con(`No free unit support — every unit costs 1 ${g.unitUpkeep} per turn`);
if (g.settlerFood >= 2) con(`Settlers eat ${g.settlerFood} food per turn`);
if (g.warPenalty >= 2) con('Broken treaties cost double reputation');
return out;
}
export function openGovernmentScreen(scene, rules, state, onClose) {
const { root } = modalShell(scene, 'GOVERNMENT', onClose);
const width = 1700;
const height = 940;
const left = GAME_WIDTH / 2 - width / 2 + 40;
const top = GAME_HEIGHT / 2 - height / 2 + 80;
const civ = state.civs[state.humanIndex];
let content = scene.add.container(0, 0);
root.add(content);
function redraw() {
content.destroy(true);
content = scene.add.container(0, 0);
root.add(content);
const inRevolution = civ.government === 'anarchy' && !!civ.pendingGovernment;
// Header: current government (or revolution countdown) + empire wellbeing
// aggregated from the same cityYields the cities themselves run on.
const cities = Logic.civCities(state, state.humanIndex);
const sum = {
gross: 0, support: 0, shield: 0, trade: 0, corruption: 0, netTrade: 0,
gold: 0, upkeep: 0, supportGold: 0, science: 0, pop: 0,
};
for (const c of cities) {
const y = Logic.cityYields(rules, state, c);
sum.gross += y.grossShield; sum.support += y.supportShields; sum.shield += y.shield;
sum.trade += y.trade; sum.corruption += y.corruption; sum.netTrade += y.netTrade;
sum.gold += y.gold; sum.upkeep += y.upkeep; sum.supportGold += y.supportGold;
sum.science += y.science; sum.pop += c.size;
}
const headline = inRevolution
? `ANARCHY — revolution in progress: ${rules.governments[civ.pendingGovernment].name} takes power in ~${civ.revolutionTurns} turn${civ.revolutionTurns === 1 ? '' : 's'}`
: `Current Government: ${rules.governments[civ.government].name}`;
content.add(scene.add.text(left, top + 2, headline, {
fontFamily: FONT, fontSize: '26px',
color: inRevolution ? '#e06c75' : COLORS.goldHex,
}));
const netGold = sum.gold - sum.upkeep - sum.supportGold;
content.add(scene.add.text(left, top + 44,
`Production: ${sum.shield} shields/turn (${sum.gross} gross ${sum.support} unit support) `
+ `Trade: ${sum.netTrade}/turn (${sum.trade} ${sum.corruption} corruption)`, {
fontFamily: FONT, fontSize: '19px', color: COLORS.textHex,
}));
content.add(scene.add.text(left, top + 76,
`Gold: ${netGold >= 0 ? '+' : ''}${netGold}/turn (${sum.gold} income ${sum.upkeep} building upkeep ${sum.supportGold} unit pay) `
+ `Science: ${sum.science}/turn${rules.governments[civ.government].noScience ? ' (halted by Anarchy)' : ''} `
+ `Cities: ${cities.length} · Population: ${sum.pop}`, {
fontFamily: FONT, fontSize: '19px', color: COLORS.textHex,
}));
// Government cards, 3×2.
const cardW = 520;
const cardH = 330;
const x0 = GAME_WIDTH / 2 - (3 * cardW + 2 * 30) / 2;
const y0 = top + 124;
rules.governmentList.forEach((g, i) => {
const cx0 = x0 + (i % 3) * (cardW + 30);
const cy0 = y0 + Math.floor(i / 3) * (cardH + 22);
const card = scene.add.container(0, 0);
content.add(card);
const isCurrent = g.id === civ.government;
const isPending = inRevolution && g.id === civ.pendingGovernment;
const locked = g.prereq && !civ.known[g.prereq];
const stroke = isCurrent ? COLORS.gold : (isPending ? COLORS.accent : COLORS.muted);
card.add(scene.add.rectangle(cx0 + cardW / 2, cy0 + cardH / 2, cardW, cardH, 0x181510)
.setStrokeStyle(isCurrent || isPending ? 3 : 2, stroke, 0.9));
card.add(scene.add.text(cx0 + 18, cy0 + 14, `${g.name}${isCurrent ? ' — CURRENT' : ''}`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.goldHex,
}));
let ty = cy0 + 48;
const blurb = scene.add.text(cx0 + 18, ty, GOVERNMENT_TEXT[g.id] ?? '', {
fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex,
wordWrap: { width: cardW - 36 }, lineSpacing: 3,
});
card.add(blurb);
ty += blurb.height + 10;
for (const b of govBullets(g)) {
const line = scene.add.text(cx0 + 18, ty, `${b.pro ? '+' : ''} ${b.text}`, {
fontFamily: FONT, fontSize: '15px',
color: b.pro ? '#4a9e44' : '#e06c75',
wordWrap: { width: cardW - 36 }, lineSpacing: 3,
});
card.add(line);
ty += line.height + 6;
}
// Footer: what (if anything) the player can do with this government.
const footer = (text) => card.add(scene.add.text(cx0 + cardW / 2, cy0 + cardH - 30, text, {
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5));
if (isCurrent) { /* header already says CURRENT */ }
else if (isPending) footer('Taking power soon…');
else if (g.id === 'anarchy') footer('Transitional — cannot be chosen');
else if (locked) footer(`Requires ${rules.techs[g.prereq].name}`);
else if (inRevolution) footer('Revolution already underway');
else {
card.add(new Button(scene, cx0 + cardW / 2, cy0 + cardH - 36, 'START REVOLUTION',
() => confirmRevolution(g), { width: 260, height: 46, fontSize: 16 }));
}
if (locked) card.setAlpha(0.55);
});
}
function confirmRevolution(g) {
const box = scene.add.container(0, 0);
root.add(box);
const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setInteractive();
const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 680, 280, COLORS.panel)
.setStrokeStyle(2, COLORS.accent);
const txt = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 44,
`Overthrow ${rules.governments[civ.government].name}?\n\nThe empire will fall into ANARCHY for 24 turns\nbefore ${g.name} takes power.`, {
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, align: 'center', lineSpacing: 5,
}).setOrigin(0.5);
const yes = new Button(scene, GAME_WIDTH / 2 - 120, GAME_HEIGHT / 2 + 72, 'REVOLT!', () => {
box.destroy(true);
if (Logic.startRevolution(rules, state, civ, g.id)) {
scene.refreshHud?.();
redraw();
}
}, { width: 200, height: 52 });
const no = new Button(scene, GAME_WIDTH / 2 + 120, GAME_HEIGHT / 2 + 72, 'CANCEL',
() => box.destroy(true), { width: 200, height: 52, variant: 'ghost' });
box.add([dim, panel, txt, yes, no]);
}
redraw();
}
// ---------------------------------------------------------------------------
// Spaceship

View File

@ -1,4 +1,5 @@
import { COLORS } from '../../config.js';
import { cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP } from './CivilizationLogic.js';
// Flavor/ability text for unit flags — data/civilization-rules.json has no
// description field on units, so this is authored here. A few flags
@ -59,7 +60,9 @@ const EFFECT_TEXT = {
// authored since the rules JSON carries only raw numeric fields, keyed off
// what CivilizationLogic.js actually does with those fields (tradeBonus,
// corruptionFactor, despotPenalty, freeUnits, unitUpkeep, warPenalty).
const GOVERNMENT_TEXT = {
export const GOVERNMENT_TEXT = {
despotism: 'The starting government: heavy corruption and a yield penalty on productive tiles, but cheap unit support.',
anarchy: 'Lawless transition between governments — crippling corruption and no research. Ends on its own.',
monarchy: 'Cuts corruption well below Despotism and drops the Despotism penalty to tile yields.',
communism: 'Low, distance-independent corruption anywhere in the empire, plus 6 free unit upkeep per city.',
republic: 'Sharply reduces corruption and adds +1 trade to tiles already producing trade, at the cost of unit upkeep.',
@ -98,11 +101,63 @@ export function describeBuildingTooltip(rules, building) {
return { title: building.name, lines };
}
// Combat is always a duel against the tile's single strongest defender
// (pickDefender), so enemy tooltips surface that unit's effective defense —
// terrain, fortify, walls etc. included — instead of letting the raw stat
// lines suggest the stack defends with combined numbers. `attacker` is the
// player's currently-selected unit when there is one (its domain/flags can
// change the number, e.g. Pikemen vs mounted); otherwise a plain land unit
// stands in.
function topDefenderLines(rules, state, units, attacker) {
const atk = (attacker && attacker.civ !== units[0].civ) ? attacker : {
type: Object.keys(rules.units).find((id) => rules.units[id].domain === 'land'
&& !rules.units[id].flags.includes('mounted')),
};
let top = units[0];
let topD = -1;
for (const u of units) {
const d = defenderStrength(rules, state, u, atk);
if (d > topD) { topD = d; top = u; }
}
const vs = atk === attacker ? ` vs your ${rules.units[attacker.type].name}` : '';
const lines = [{
text: `Defends at ${topD.toFixed(1)}${vs}`,
color: COLORS.goldHex,
}];
if (units.length > 1) {
lines[0].text = `Top defender: ${rules.units[top.type].name} — defends at ${topD.toFixed(1)}${vs}`;
const idx = tileIndex(state.world, top.x, top.y);
const protectedStack = !!cityAt(state, top.x, top.y)
|| !!(state.world.improvements[idx] & IMP.FORTRESS);
lines.push({
text: protectedStack
? '• Attacks fight one defender at a time'
: '• If the defender falls, the whole stack is lost',
});
}
return lines;
}
// Carried units never appear in a tile's unit stack (the map view skips
// anything with carriedBy), so summarize a boat's hold here: "2× Musketeers,
// 1× Settlers" across every boat in `units`, or null when nothing is aboard.
function cargoSummary(rules, state, units) {
const counts = new Map();
for (const boat of units) {
for (const u of unitsOnBoat(state, boat)) {
const name = rules.units[u.type].name;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
if (!counts.size) return null;
return [...counts].map(([name, count]) => `${count}× ${name}`).join(', ');
}
// Hover tooltip for a map unit/stack: which leader controls it plus a
// per-unit-type breakdown. `units` are live game-state unit instances (all
// sharing `civ`, since only one civ's units normally occupy a tile), not
// rule defs — look each one's def up via rules.units[u.type].
export function describeUnitStackTooltip(rules, civ, opponentsData, units) {
export function describeUnitStackTooltip(rules, state, civ, opponentsData, units, attacker = null) {
const opData = opponentsData?.find((o) => o.id === civ.leaderId);
const lines = [];
if (units.length === 1) {
@ -110,6 +165,12 @@ export function describeUnitStackTooltip(rules, civ, opponentsData, units) {
const def = rules.units[u.type];
lines.push({ text: def.name, color: COLORS.goldHex });
lines.push({ text: `Attack ${def.attack} · Defense ${def.defense} · Move ${def.move}` });
if (civ.human) {
const home = cityById(state, u.homeCity);
lines.push({ text: `• Home: ${home ? home.name : 'none'}` });
const cargo = cargoSummary(rules, state, [u]);
if (cargo) lines.push({ text: `• Carrying: ${cargo}` });
}
if (u.vet) lines.push({ text: '• Veteran' });
if (u.fortified) lines.push({ text: '• Fortified' });
for (const flag of def.flags ?? []) {
@ -117,18 +178,28 @@ export function describeUnitStackTooltip(rules, civ, opponentsData, units) {
if (text) lines.push({ text: `${text}` });
}
} else {
const byName = new Map();
// Friendly stacks group by type + home city so upkeep sources stay
// visible; enemy homes are hidden info, so their stacks group by type.
const byKey = new Map();
for (const u of units) {
const def = rules.units[u.type];
const entry = byName.get(def.name) ?? { count: 0, def };
const home = civ.human ? cityById(state, u.homeCity) : null;
const key = `${def.name}|${home?.name ?? ''}`;
const entry = byKey.get(key) ?? { count: 0, def, home };
entry.count += 1;
byName.set(def.name, entry);
byKey.set(key, entry);
}
lines.push({ text: `${units.length} units`, color: COLORS.goldHex });
for (const [name, { count, def }] of byName) {
lines.push({ text: `${count}× ${name} (A${def.attack} D${def.defense} M${def.move})` });
for (const { count, def, home } of byKey.values()) {
const homeTxt = home ? ` · ${home.name}` : '';
lines.push({ text: `${count}× ${def.name} (A${def.attack} D${def.defense} M${def.move})${homeTxt}` });
}
if (civ.human) {
const cargo = cargoSummary(rules, state, units);
if (cargo) lines.push({ text: `Aboard ships: ${cargo}` });
}
}
if (!civ.human) lines.push(...topDefenderLines(rules, state, units, attacker));
return {
title: civ.name,
titleColor: civ.color,