From aee3352efebfc35e4d12a9a2aaabae080b247517 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 17 Jul 2026 10:32:01 -0600 Subject: [PATCH] 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. --- src/games/civilization/CivilizationChat.js | 196 ++++++++++ src/games/civilization/CivilizationGame.js | 26 +- src/games/civilization/CivilizationLogic.js | 8 +- src/games/civilization/CivilizationMapView.js | 5 +- src/games/civilization/CivilizationScreens.js | 355 +++++++++++++++++- .../civilization/CivilizationTooltips.js | 85 ++++- 6 files changed, 649 insertions(+), 26 deletions(-) create mode 100644 src/games/civilization/CivilizationChat.js diff --git a/src/games/civilization/CivilizationChat.js b/src/games/civilization/CivilizationChat.js new file mode 100644 index 0000000..1b94565 --- /dev/null +++ b/src/games/civilization/CivilizationChat.js @@ -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.', + ], +}; diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 3449dba..1a5a83c 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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; diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 50135ad..2d149d4 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -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 { diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 0206679..6c4d185 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -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); diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js index 8bc643c..e36ebff 100644 --- a/src/games/civilization/CivilizationScreens.js +++ b/src/games/civilization/CivilizationScreens.js @@ -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 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 diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index 57ecf2b..ea2f490 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -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,