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:
parent
bace0356c2
commit
aee3352efe
|
|
@ -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.',
|
||||
],
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,16 +302,43 @@ 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.
|
||||
// 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,
|
||||
`${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, {
|
||||
|
|
@ -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 2–4 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue