From 9919fdfe3055ca25b41b820d8b210f357236fe59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:34:26 +0000 Subject: [PATCH] Blockilization: city-states marked on the map, and spoken to with a click A city-state's banner looked just like a civ's capital (a crown, its people, its name). Now it carries a "City-state" tab, its kind's glyph in place of the crown, a gold "!" while it asks something of you, and your influence with it on a bar beneath, marked at friendship and alliance and coloured by how it stands with you. The tooltip over its city says the same in words. Clicking a city-state's city (with no unit of yours selected) opens its card alone in Diplomacy, Civ V's way: its envoy greets you by your standing, and everything that can be done with it is there (gifts, quests, pledges, tribute, war and peace). The leaders' ribbon, its notes and the advisor's tip open the same card, and a name on the city-states' list does too. Another civ's city opens Diplomacy with its leader. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01W9EYvwEihXHZKXCYU2v8g7 --- src/games/blockilization/client/citystates.ts | 109 ++++++++++++++---- src/games/blockilization/client/concepts.ts | 2 +- src/games/blockilization/client/diplomacy.ts | 12 +- src/games/blockilization/client/guide.ts | 3 +- src/games/blockilization/client/input.ts | 24 +++- src/games/blockilization/client/notes.ts | 6 +- src/games/blockilization/client/overlay.ts | 66 +++++++++-- src/games/blockilization/client/ribbon.ts | 17 +-- src/games/blockilization/client/store.ts | 9 +- src/games/blockilization/client/ui.ts | 8 +- 10 files changed, 204 insertions(+), 52 deletions(-) diff --git a/src/games/blockilization/client/citystates.ts b/src/games/blockilization/client/citystates.ts index 30443bf..6f70fd3 100644 --- a/src/games/blockilization/client/citystates.ts +++ b/src/games/blockilization/client/citystates.ts @@ -5,7 +5,7 @@ import { lookAt } from './camera'; import { CITY_STATE_KINDS } from './concepts'; import { emblemEl, icon } from './icons'; import { ask, store } from './store'; -import { h, send, tile, type El } from './ui'; +import { h, send, tile, togglePanel, type El } from './ui'; /** * The city-states, as Civ V's city-state screen has them, one card each: its kind and what it gives @@ -13,9 +13,27 @@ import { h, send, tile, type El } from './ui'; * begin, who its ally is, what it asks of you (its quests: the influence each wins, the turns left, * a look at the place), who has pledged to protect it (and your pledge), tribute you could demand of it, and gifts of gold (with the influence each * buys), war and peace, and a look at where it stands on the map. Your allies first, then your - * friends, then the rest. + * friends, then the rest. A click on a city-state's city (or its emblem on the leaders' ribbon) + * opens its card alone, as Civ V's city-state screen greets you: its envoy's words, and all that + * can be done with it. */ +/** Diplomacy, open at one city-state (a click on its city, or on its emblem on the leaders' ribbon). */ +export function openCityState(index: number) { + store.diplo.with = -1; + store.diplo.cityState = index; + store.diplo.deal = null; + store.diplo.check = null; + if (store.panel !== 'diplomacy') togglePanel('diplomacy'); + else store.changed++; +} + +/** Look at one city-state's card alone, or (null) at them all. */ +function speakTo(index: number | null) { + store.diplo.cityState = index; + store.changed++; +} + /** Each standing's word and colour. */ const STANDING: Record = { ally: { word: 'Your ally', cls: 'good' }, @@ -24,6 +42,8 @@ const STANDING: Record = { war: { word: 'At war', cls: 'bad' }, }; const ORDER = ['ally', 'friend', 'neutral', 'war']; +/** A standing in words, as its chip has it ("Your ally"). */ +export const standingWord = (s: string) => (STANDING[s] ?? STANDING.neutral).word; /** The influence bar's top: a little past alliance, so the marks sit in it. */ const BAR_TOP = 90; @@ -156,12 +176,53 @@ function protection(p: PlayerSummary): El | null { ); } -/** One city-state's card. */ -function card(p: PlayerSummary): El { - const v = store.view!; +/** What its envoy says to you, by how it stands with you (and, meeting you as a stranger, by its kind). */ +function greeting(p: PlayerSummary): string { + const cs = p.cityState!; + if (cs.standing === 'war') return `${p.name} has nothing to say to you while your armies march on its walls. Make peace, and we may speak again.`; + if (cs.standing === 'ally') return `Our great ally! ${p.name} stands with you, as you stand with us. Our riches are yours to share.`; + if (cs.standing === 'friend') return `Welcome back, friend. ${p.name} remembers your kindness, and repays it.`; + if (cs.influence < 0) return `${p.name} has not forgotten how you have treated us. Mend that, if you want our friendship.`; + switch (cs.kind) { + case 'maritime': + return `Greetings, traveller. Our harbours are full and our nets are heavy: a friend of ${p.name} never goes hungry.`; + case 'cultured': + return `Welcome to ${p.name}, where the artists never rest. Our friends share in all we make.`; + case 'militaristic': + return `${p.name} bows to no one. But we arm those who prove themselves our friends.`; + case 'mercantile': + return `A visitor! Everything in ${p.name} has its price, and our friends get the best of it.`; + case 'religious': + return `Peace be with you, pilgrim. Those who befriend ${p.name} are blessed.`; + default: + return `Greetings from ${p.name}, a free city.`; + } +} + +/** A city-state's head on its card: its emblem, name, kind and how it stands with you (on the list, a click opens its card alone). */ +function head(p: PlayerSummary, alone: boolean): El { const cs = p.cityState!; const kind = CITY_STATE_KINDS.find((k) => k.id === cs.kind); const st = STANDING[cs.standing] ?? STANDING.neutral; + const name = alone ? h('b.cs-name', {}, p.name) : h('button.cs-name.cs-open', { onclick: () => speakTo(p.index), title: `Speak to ${p.name}` }, p.name); + return h( + 'div.cs-head', + {}, + h('span.cs-em', { 'data-pedia': `citystate:${cs.kind}` }, emblemEl(p.civ, alone ? 64 : 40, cs.kind)), + h( + 'div.cv-grow', + {}, + name, + h('div.cs-kind', { 'data-pedia': `citystate:${cs.kind}` }, kind ? tile(kind.glyph, kind.color, 20) : null, h('span', {}, kind ? `${kind.name} city-state: ${kind.sub.toLowerCase()}` : cs.kind)), + ), + h(`span.cv-chip${st.cls ? `.${st.cls}` : ''}`, {}, st.word), + ); +} + +/** One city-state's card: on the list, or alone (spoken to). */ +function card(p: PlayerSummary, alone = false): El { + const v = store.view!; + const cs = p.cityState!; const ally = cs.ally >= 0 ? v.players[cs.ally] : null; const allyName = !ally ? 'No ally' : ally.index === v.you ? 'Allied with you' : `Allied with ${ally.met ? ally.name : 'a civilization you haven’t met'}`; const gifts = cs.gifts.map(({ gold, influence }) => @@ -174,20 +235,11 @@ function card(p: PlayerSummary): El { ), ); return h( - `div.cs-card.${cs.standing}`, + `div.cs-card.${cs.standing}${alone ? '.alone' : ''}`, {}, - h( - 'div.cs-head', - {}, - h('span.cs-em', { 'data-pedia': `citystate:${cs.kind}` }, emblemEl(p.civ, 40, cs.kind)), - h( - 'div.cv-grow', - {}, - h('b.cs-name', {}, p.name), - h('div.cs-kind', { 'data-pedia': `citystate:${cs.kind}` }, kind ? tile(kind.glyph, kind.color, 20) : null, h('span', {}, kind ? `${kind.name}: ${kind.sub.toLowerCase()}` : cs.kind)), - ), - h(`span.cv-chip${st.cls ? `.${st.cls}` : ''}`, {}, st.word), - ), + head(p, alone), + // (Its envoy's words, on its card alone.) + alone && h('div.cs-said', {}, `“${greeting(p)}”`), h('div.cs-row', {}, influenceBar(cs), h('b.cs-num', {}, String(cs.influence))), h('div.cs-row.cs-facts', {}, h('span', {}, icon('crown', 13, ally ? '#f2c14e' : '#6c7688'), allyName), drift(cs) && h('span.cs-drift', { title: 'How your influence moves each turn, and where it stops' }, icon('turn', 13, '#9aa4b5'), drift(cs))), gives(p), @@ -204,6 +256,8 @@ function card(p: PlayerSummary): El { ? h('button.cv-btn.small', { onclick: () => send({ t: 'peace', target: p.index }) }, icon('peace', 13), 'Peace') : h('button.cv-btn.small.icon.danger', { onclick: () => ask(`Declare war on ${p.name}? Its friendship (and any alliance) is lost.`, 'Declare war', () => send({ t: 'war', target: p.index })), title: 'Declare war on it' }, icon('war', 13)), ), + // (Its card alone: the gift that isn't a button here, a soldier given from beside its land.) + alone && !p.war && h('small.cv-muted.cs-hint', {}, `You can give it a soldier too: take a military unit into (or next to) its land, and choose “Gift to ${p.name}” from its orders.`), ); } @@ -215,6 +269,10 @@ export function renderCityStates(): El[] { const v = store.view!; const states = v.players.filter((p) => p.cityState && p.met && p.alive).sort((a, b) => ORDER.indexOf(a.cityState!.standing) - ORDER.indexOf(b.cityState!.standing) || b.cityState!.influence - a.cityState!.influence); if (!states.length) return [h('div.cv-muted', {}, 'You haven’t met a city-state yet. Explore!')]; + // One spoken to: its card alone, and the way back to them all. + const one = states.find((p) => p.index === store.diplo.cityState); + if (one) + return [...(states.length > 1 ? [h('button.cv-btn.small.cs-back', { onclick: () => speakTo(null) }, icon('crown', 13), `All city-states (${states.length})`)] : []), card(one, true)]; const count = (s: string) => states.filter((p) => p.cityState!.standing === s).length; const asks = openQuests(states); return [ @@ -227,7 +285,7 @@ export function renderCityStates(): El[] { asks > 0 && h('span', {}, h('b', {}, String(asks)), asks === 1 ? ' request for you' : ' requests for you'), h('small.cv-muted', {}, `Friends from ${FRIEND} influence; the civ with the most, from ${ALLY}, is the ally. Influence falls back a little each turn.`), ), - ...states.map(card), + ...states.map((p) => card(p)), ]; } @@ -241,6 +299,14 @@ export const CITYSTATES_CSS = ` .cs-card.war { box-shadow: inset 0 0 0 2px #11141a, inset 4px 0 0 #ff6b5e; } .cs-head { display: flex; align-items: center; gap: 10px; } .cs-name { display: block; font: 400 24px/1 'Jersey 10', monospace; color: #fff; text-shadow: 2px 2px 0 #0b0d12; } +/* (On the list a city-state's name is a button, to speak to it: in the name's look, not a button's.) */ +.cs-head .cs-open { padding: 0; font: 400 24px/1 'Jersey 10', monospace; color: #fff; text-align: left; } +.cs-head .cs-open:hover { color: #ffe3a1; text-decoration: underline; text-underline-offset: 3px; } +.cs-card.alone { gap: 10px; padding: 14px 16px; } +.cs-card.alone .cs-name { font-size: 32px; } +.cs-said { padding: 6px 10px; background: rgba(13,19,27,0.75); border: 2px solid #07090c; font-family: 'Chakra Petch', system-ui, sans-serif; font-style: italic; font-size: 14px; color: #f1e6c8; animation: cvIn .25s; } +.cs-hint { font-family: 'Chakra Petch', system-ui, sans-serif; font-size: 13px; } +.cs-back { align-self: flex-start; } .cs-kind { display: flex; align-items: center; gap: 6px; margin-top: 3px; color: var(--muted); font-size: 14px; } .cs-row { display: flex; align-items: center; gap: 10px; } .cs-inf { position: relative; flex: 1; padding-bottom: 12px; } @@ -275,7 +341,10 @@ export const CITYSTATES_CSS = ` .cv-root.cb .cs-quest.done { background: rgba(90,174,255,.07); } @media (max-width: 760px), (max-height: 520px) { .cs-card { padding: 8px; gap: 6px; } - .cs-name { font-size: 21px; } + .cs-name, .cs-head .cs-open { font-size: 21px; } + .cs-card.alone { padding: 10px; } + .cs-card.alone .cs-name { font-size: 26px; } + .cs-card.alone .cs-em .cv-emblem { width: 48px; height: 48px; } .cs-give b { width: 80px; } } `; diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index 2c122f4..b1e362a 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -226,7 +226,7 @@ export const CONCEPTS: Article[] = [ sub: 'War, peace and city-states', sections: [ { text: 'You meet a civilization when you first see its units or land. From the Diplomacy panel you can declare war, make peace (a peace treaty holds for a number of turns), and open your borders to its units.' }, - { title: 'City-states', text: `City-states are small single-city nations. Gifts of gold and services (clearing barbarian camps near them) win influence with them, which drifts back each turn. At ${FRIEND} influence they are your friends; the friend with the most, at ${ALLY} or more, is their ally. Friends and allies get what their [[cat:citystate|kind]] gives; an ally also gets their luxuries and their vote in the United Nations.` }, + { title: 'City-states', text: `City-states are small single-city nations, marked “City-state” over their banners on the map, with your influence on a bar beneath (a “!” while one asks something of you). Click a city-state’s city to speak to it. Gifts of gold and services (clearing barbarian camps near them) win influence with them, which drifts back each turn. At ${FRIEND} influence they are your friends; the friend with the most, at ${ALLY} or more, is their ally. Friends and allies get what their [[cat:citystate|kind]] gives; an ally also gets their luxuries and their vote in the United Nations.` }, { title: 'Pledges to protect', text: `From a city-state’s card you may pledge to protect it (met, at peace, your influence not below 0). Your influence with it then rests ${PLEDGE_REST} higher, and anyone else who bullies it for tribute or declares war on it offends you, and the leaders who have pledged remember it. Bully or attack it yourself and the pledge is broken (${PLEDGE_BROKEN} influence lost); let your influence fall below 0 and it lapses. Either way you can’t pledge to it again for ${PLEDGE_TURNS} turns (at standard speed).` }, { title: 'Liberating cities', text: `A city you take from a civilization that hadn’t founded it (it had been taken from its founder before) can be liberated instead of annexed, kept as a puppet or razed: it goes back to its founder, who never forgets it. A city-state given back its city gives you ${LIBERATE_INFLUENCE} influence with it, enough to be its ally. A civilization or city-state that had been destroyed comes back to life with the city, at peace with you, its quarrels with the world forgotten. Liberating cities also weighs against your conquests in other leaders’ eyes. An original capital given back counts again as its founder’s for [[victory:domination|Domination]].` }, { title: 'Vassals', text: 'A civilization losing a war can capitulate instead of fighting to the end: offer it (or demand it of a beaten enemy) at the trade table, where it ends the war. A vassal keeps its cities, but its master makes its wars and its peace (it is at war with whoever its master is, and at peace with the rest), it pays its master a quarter of its gold income each turn, and their borders are open to each other. Its cities count as its master’s toward [[victory:domination|Domination]]. Once it has served ten turns and has half its master’s people, a vassal may declare its independence; a master may set its vassal free; and a vassal whose master falls goes free. Beaten leaders kneel, and winning ones demand it.' }, diff --git a/src/games/blockilization/client/diplomacy.ts b/src/games/blockilization/client/diplomacy.ts index 9a62320..df86db1 100644 --- a/src/games/blockilization/client/diplomacy.ts +++ b/src/games/blockilization/client/diplomacy.ts @@ -150,6 +150,7 @@ export function renderDiplomacy(): El { !civs.length && !cityStates && !v.clans.length ? h('div.cv-muted', { style: 'padding:10px' }, "You haven't met anyone yet. Explore!") : null, ); const p = chosen !== null && chosen >= 0 ? v.players[chosen] : null; + // (The city-states' page with one spoken to is marked apart from the list of them all, so neither keeps the other's scroll.) const pane = p ? store.diplo.deal ? tradePane(p) @@ -160,7 +161,7 @@ export function renderDiplomacy(): El { ? h('div.cv-dip-pane.cv-scroll', {}, h('div.cv-h', {}, 'World Congress'), ...renderCongress()) : chosen === -4 ? h('div.cv-dip-pane.cv-scroll', {}, h('div.cv-h', {}, 'Barbarian clans'), ...renderClans()) - : h('div.cv-dip-pane.cv-scroll', {}, h('div.cv-h', {}, 'City-states'), ...renderCityStates()); + : h(`div.cv-dip-pane.cv-scroll${store.diplo.cityState !== null ? '.cs-one' : ''}`, {}, h('div.cv-h', {}, 'City-states'), ...renderCityStates()); return h( 'div.cv-big.cv-panel.cv-dip', {}, @@ -170,14 +171,21 @@ export function renderDiplomacy(): El { ); } -/** Look at another civ (any deal being drawn up is put away). */ +/** Look at another civ, or a page (any deal being drawn up is put away; the city-states' page shows them all). */ function pick(i: number) { store.diplo.with = i; + store.diplo.cityState = null; store.diplo.deal = null; store.diplo.check = null; store.changed++; } +/** Diplomacy, open at a civ (its leader) or a page (-1: the city-states, all of them; -2: world politics). */ +export function openDiplomacy(i: number) { + pick(i); + if (store.panel !== 'diplomacy') togglePanel('diplomacy'); +} + /** A civ's page: its leader, what it thinks of you and why, what's between you, what can be done. */ function civPane(p: PlayerSummary): El { const v = store.view!; diff --git a/src/games/blockilization/client/guide.ts b/src/games/blockilization/client/guide.ts index 4c2906c..0b5e3ab 100644 --- a/src/games/blockilization/client/guide.ts +++ b/src/games/blockilization/client/guide.ts @@ -1,5 +1,6 @@ import type { Settings } from '../rules/state'; import { UNITS } from '../rules/units'; +import { openCityState } from './citystates'; import { VICTORIES } from './concepts'; import { icon, svg } from './icons'; import { setLens } from './lenses'; @@ -67,7 +68,7 @@ export function guideTips(): Tip[] { { key: 'settler', title: 'Found another city', when: mine.length > 0 && !!settler, text: 'More cities are how an empire grows. With the Settler selected, the Settler lens shows the best sites you know, numbered: food, fresh water, the coast and resources, away from others’ land.', act: settler && { label: 'Select the Settler', run: () => selectUnitAt(settler.id, settler.tile) } }, { key: 'lenses', evergreen: true, title: 'Lenses', when: v.turn >= 6 && mine.length > 0, text: 'The buttons over the minimap (or L) colour the map: every tile’s yields, the best city sites, resources, religion, and borders with the tile each city claims next.', act: { label: 'Show the yields', run: () => setLens('yields') } }, { key: 'neighbour', title: neighbour ? `You’ve met ${neighbour.name}` : 'A neighbour', when: !!neighbour, text: 'Other civilizations race you for land, wonders and the win. See how you stand in Diplomacy (F4) and the Overview’s demographics (F9); a strong army keeps a neighbour friendly.', act: { label: 'Open Diplomacy', run: open('diplomacy') } }, - { key: 'citystate', title: cityState ? `${cityState.name}, a city-state` : 'A city-state', when: !!cityState, text: 'City-states are small single-city nations. Gifts of gold, and doing what they ask (Diplomacy shows their requests), win their friendship, and with it food, culture, faith, happiness or units, by their kind.', act: { label: 'Open Diplomacy', run: open('diplomacy') } }, + { key: 'citystate', title: cityState ? `${cityState.name}, a city-state` : 'A city-state', when: !!cityState, text: `City-states are small single-city nations, marked “City-state” over their banners. ${click(true)} one’s city to speak to it: gifts of gold, and doing what it asks, win its friendship, and with it food, culture, faith, happiness or units, by its kind.`, act: cityState && { label: `Speak to ${cityState.name}`, run: () => openCityState(cityState.index) } }, { key: 'barbarians', title: 'Barbarians', when: barbarian, text: 'Barbarians come out of camps in the wilds and raid whoever is near. Keep a unit fortified in your city (F) and on hills; clearing a camp brings gold, and grateful city-states.' }, { key: 'policy', title: 'Adopt a social policy', when: s.culture >= s.policyCost, text: 'You have the culture for a new policy. Tradition suits a few great cities, Liberty many small ones, Honor war.', act: { label: 'Open Social Policies', run: open('policies') } }, { key: 'notes', evergreen: true, title: 'What needs deciding', when: v.turn >= 8, text: 'What the turn waits on, and what could be done (a promotion, a pantheon), stays at the top right; N opens every note of the game, and O your cities and units side by side.', act: { label: 'Open the notifications', run: open('notes') } }, diff --git a/src/games/blockilization/client/input.ts b/src/games/blockilization/client/input.ts index 845ae55..1e1630f 100644 --- a/src/games/blockilization/client/input.ts +++ b/src/games/blockilization/client/input.ts @@ -1,6 +1,6 @@ import { unitName } from '../rules/armies'; import { canFortify, isMilitary, UNITS } from '../rules/units'; -import type { PreviewAsk, UnitWire } from '../protocol'; +import type { CityWire, PreviewAsk, UnitWire } from '../protocol'; import { cam, centreHere, pick, zoom } from './camera'; import { flagsIn, hitAt, type Hit } from './overlay'; import { ask, attackable, cityAt, enemyAt, me, myCity, myUnit, store, unitsAt } from './store'; @@ -15,9 +15,12 @@ import { closePinMenu, openPinMenu } from './pins'; import { chatOpen, pingTile, toggleChat } from './chat'; import { workAt, workTip } from './workadvice'; import { openClans } from './clans'; +import { openCityState } from './citystates'; +import { openDiplomacy } from './diplomacy'; /** - * The mouse, a finger and the keyboard on the map: a click (or tap) selects a unit or opens a city, a + * The mouse, a finger and the keyboard on the map: a click (or tap) selects a unit or opens a city + * (another's, with nothing of yours selected: its city-state's card, or its leader, to talk to), a * click elsewhere with a unit selected sends it there (right-click too, and onto an enemy: an attack), * dragging (one finger) moves the view, the wheel (two fingers pinched, which move it too) zooms; a * finger held still on a tile shows what's there (as the mouse's hover does); the keys move the view, @@ -73,6 +76,20 @@ export function tileMenuFree(touch: boolean): boolean { return !!v && !v.over && !v.spectator && store.selectedCity === null && store.cityTarget === null && !store.unitAim && (touch || !myUnit(store.selectedUnit)); } +/** + * Another's city clicked with nothing of yours selected: who to talk to there, Civ V's way (a + * city-state's card, or its civ's leader in Diplomacy). False if there's no one (a civ you have no + * dealings with). + */ +function talkAt(city: CityWire): boolean { + const p = store.view?.players[city.owner]; + if (!p?.alive || !p.met || p.index === me()) return false; + if (p.cityState) openCityState(p.index); + else if (p.relation && p.civ !== 'barbarian') openDiplomacy(p.index); + else return false; + return true; +} + /** A click on the map (or a tap, `touch`): on a unit's flag or a city's banner (`hit`), else on the tile under it. */ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, shift = false) { const v = store.view; @@ -114,6 +131,8 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s return; } if (!right && city?.owner === me()) return openCity(city.id); + // (Another's banner: its city-state or its leader, unless a unit of yours is about to be sent there.) + if (!right && city && !myUnit(store.selectedUnit) && talkAt(city)) return; } if (t < 0) return; const sel = myUnit(store.selectedUnit); @@ -143,6 +162,7 @@ function act(t: number, right: boolean, hit: Hit | null = null, touch = false, s return; } if (city?.owner === me()) return openCity(city.id); + if (!sel && city && talkAt(city)) return; // (A camp found: its clan, to deal with.) if (!sel && v.clans.some((c) => c.tile === t)) return openClans(); if (!sel) { diff --git a/src/games/blockilization/client/notes.ts b/src/games/blockilization/client/notes.ts index ee28d30..0622ed7 100644 --- a/src/games/blockilization/client/notes.ts +++ b/src/games/blockilization/client/notes.ts @@ -3,6 +3,7 @@ import { IDEOLOGIES } from '../rules/policies'; import { unitName } from '../rules/armies'; import { lookAt } from './camera'; import { icon, svg } from './icons'; +import { openCityState } from './citystates'; import { openCongress, sessionWhen, unvoted } from './congress'; import { reviewOffer } from './diplomacy'; import { defenceWarnings, lookAtDefence } from './defend'; @@ -44,10 +45,13 @@ export interface Decision { act: () => void; } -/** Where a note leads: a city-state's request to the city-states' page, the city it's about opened (a choice to make there), else a look at its tile. */ +/** Where a note leads: a city-state's request to its card (or the city-states' page), the city it's about opened (a choice to make there), else a look at its tile. */ function follow(n: { at?: number; openCity?: boolean; kind?: string }) { if (n.kind === 'citystate') { + const home = n.at === undefined ? undefined : cityAt(n.at); + if (home && store.view?.players[home.owner]?.cityState) return openCityState(home.owner); store.diplo.with = -1; + store.diplo.cityState = null; store.diplo.deal = null; return open('diplomacy')(); } diff --git a/src/games/blockilization/client/overlay.ts b/src/games/blockilization/client/overlay.ts index dcf799d..93937e2 100644 --- a/src/games/blockilization/client/overlay.ts +++ b/src/games/blockilization/client/overlay.ts @@ -1,5 +1,6 @@ import { centre, centreNear, corners, HEX, HEX_W, hexW, SEA_Y } from '../geometry'; import type { CityInfo, CityWire } from '../protocol'; +import { ALLY, FRIEND } from '../rules/citystates'; import { CIVS } from '../rules/civs'; import { neighbour, within } from '../rules/hex'; import { NATURAL_WONDERS } from '../rules/nature'; @@ -7,6 +8,7 @@ import { RESOURCES, TERRAINS, YIELD_KEYS, type Yields } from '../rules/terrain'; import { PARADROP_RANGE, UNITS } from '../rules/units'; import { centreHere, homeShift, landY, projectInto, toScreen } from './camera'; import { bar, drawFigures, figuresShowing } from './combatfx'; +import { CITY_STATE_KINDS } from './concepts'; import { cut } from './fog'; import { iconBitmap, resourceGlyph, unitGlyph, YIELD_COLOR } from './icons'; import { activeLens, drawLens } from './lenses'; @@ -88,6 +90,13 @@ export const tileY = landY; const colorOf = (owner: number) => CIVS[store.view?.players[owner]?.civ ?? '']?.color ?? '#888'; const accentOf = (owner: number) => CIVS[store.view?.players[owner]?.civ ?? '']?.accent ?? '#fff'; +/** Each kind of city-state's glyph on its banner, in a light shade of its kind's colour (the banner's dark). */ +const CITY_STATE_TINT: Record = { maritime: '#7cc4ff', cultured: '#e0a0f0', militaristic: '#ff8a7a', mercantile: '#f6cf4a', religious: '#efe6cc' }; +/** How a city-state stands with you, in the colour of the influence bar under its banner (the city-states' screen's). */ +const STANDING_TINT: Record = { ally: '#f2c14e', friend: '#6fd27a', neutral: '#c9d1dc', war: '#ff6b5e' }; +/** The influence bar's top: a little past alliance, so both marks sit in it (`citystates.ts`'s). */ +const INFLUENCE_TOP = 90; + /** Each improvement's glyph, by the side of its tile. */ const IMPROVEMENT_GLYPH: Record = { farm: 'wheat', mine: 'pick', trading_post: 'gold', lumber_mill: 'axe', pasture: 'cattle', camp: 'deer', plantation: 'leaf', quarry: 'stone', fishing_boats: 'fish', @@ -724,12 +733,17 @@ export function drawOverlay(now: number) { if (!s) continue; const own = city.owner === me(); const owner = store.view!.players[city.owner]; + // A city-state's (Civ V's way): its kind's glyph where a capital's crown would be, "City-state" on + // a tab over the banner, a "!" at its end while it asks something of you, and your influence with + // it on a bar under it, marked where friendship and alliance begin, in your standing's colour. + const cs = owner?.cityState; + const asks = !!cs?.quests.some((q) => !q.done); const fs = Math.max(10, Math.min(14, zoomed * 0.52)) * ui; ctx.font = mapFont(fs); const label = city.name; const tw = ctx.measureText(label).width; const bh = Math.round(fs * 2); - const bw = Math.round(bh + tw + fs * 1.4 + (city.capital ? fs * 1.2 : 0) + (own && city.building ? bh : 0)); + const bw = Math.round(bh + tw + fs * 1.4 + (city.capital || cs ? fs * 1.2 : 0) + (own && city.building ? bh : 0) + (asks ? bh - 4 : 0)); const x0 = Math.round(s.x - bw / 2); const y0 = Math.round(s.y - bh / 2); const mid = y0 + bh / 2; @@ -744,12 +758,49 @@ export function drawOverlay(now: number) { ctx.textBaseline = 'middle'; shadowText(String(city.pop), x0 + bh / 2, mid + 1, accentOf(city.owner)); let x = x0 + bh + fs * 0.5; - if (city.capital) { + if (cs) { + glyph(CITY_STATE_KINDS.find((k) => k.id === cs.kind)?.glyph ?? 'crown', x + fs * 0.5, mid, fs * 1.1, CITY_STATE_TINT[cs.kind] ?? '#ffffff'); + x += fs * 1.2; + } else if (city.capital) { glyph('crown', x + fs * 0.5, mid, fs * 1.05, '#f2c14e'); x += fs * 1.2; } ctx.textAlign = 'left'; - shadowText(label, x, mid + 1, owner?.cityState ? '#e9eef3' : '#ffffff'); + shadowText(label, x, mid + 1, '#ffffff'); + if (cs) { + // The tab: "City-state", in its kind's shade, on the banner's top edge. + ctx.font = mapFont(fs * 0.7); + const word = 'City-state'; + const ww = Math.round(ctx.measureText(word).width + fs * 0.9); + const wh = Math.round(fs * 1.25); + const wx = Math.round(x0 + bw / 2 - ww / 2); + const wy = y0 - 2 - wh; + ctx.fillStyle = '#07090c'; + ctx.fillRect(wx - 2, wy - 2, ww + 4, wh + 2); + ctx.fillStyle = 'rgba(16,19,26,0.92)'; + ctx.fillRect(wx, wy, ww, wh); + ctx.textAlign = 'center'; + shadowText(word, wx + ww / 2, wy + wh / 2 + 1, CITY_STATE_TINT[cs.kind] ?? '#e9eef3'); + ctx.font = mapFont(fs); + // Its request for you: a gold "!" at the banner's end. + if (asks) { + const side = bh - 8; + const bx = x0 + bw - side - 4; + block(bx, y0 + 4, side, side, '#f2c14e', 'rgba(0,0,0,0)'); + shadowText('!', bx + side / 2, mid + 1, '#1b1407'); + } + // Your influence: a bar the banner's width, under it. + const iy = y0 + bh + 2; + const ih = Math.max(3, Math.round(3 * ui)); + ctx.fillStyle = '#07090c'; + ctx.fillRect(x0 - 2, iy, bw + 4, ih + 2); + ctx.fillStyle = 'rgba(255,255,255,0.14)'; + ctx.fillRect(x0, iy, bw, ih); + ctx.fillStyle = tone(STANDING_TINT[cs.standing] ?? STANDING_TINT.neutral); + ctx.fillRect(x0, iy, Math.round(bw * Math.max(0, Math.min(1, cs.influence / INFLUENCE_TOP))), ih); + ctx.fillStyle = '#07090c'; + for (const at of [FRIEND, ALLY]) ctx.fillRect(Math.round(x0 + (bw * at) / INFLUENCE_TOP) - 1, iy, 2, ih); + } // Its owner's team: a small numbered tab at the banner's left end. if (typeof owner?.team === 'number') { const side = Math.round(fs * 1.15); @@ -781,17 +832,18 @@ export function drawOverlay(now: number) { ctx.font = mapFont(fs * 0.85); shadowText(String(city.building.turns), bx + side / 2, by + side / 2 + 1, '#ffe1b0'); } - // Damaged: its health under the banner; growing (its own): food over it. + // Damaged: its health under the banner (a city-state's, under its influence); growing (its own): food over it. const barX = x0 + bh + 2; const barW = bw - bh - 2; + const hpY = y0 + bh + 2 + (cs ? Math.max(3, Math.round(3 * ui)) + 2 : 0); const hp = bar(-1 - city.id, city.hp, city.tile, -city.id, now); if (hp.ghost < city.maxHp) { ctx.fillStyle = '#07090c'; - ctx.fillRect(barX - 2, y0 + bh + 2, barW + 2, 6); + ctx.fillRect(barX - 2, hpY, barW + 2, 6); ctx.fillStyle = '#ffffff'; - ctx.fillRect(barX, y0 + bh + 4, Math.round((barW - 2) * (hp.ghost / city.maxHp)), 2); + ctx.fillRect(barX, hpY + 2, Math.round((barW - 2) * (hp.ghost / city.maxHp)), 2); ctx.fillStyle = tone('#6fd27a'); - ctx.fillRect(barX, y0 + bh + 4, Math.round((barW - 2) * (hp.hp / city.maxHp)), 2); + ctx.fillRect(barX, hpY + 2, Math.round((barW - 2) * (hp.hp / city.maxHp)), 2); } if (own && city.foodNeed) { ctx.fillStyle = '#07090c'; diff --git a/src/games/blockilization/client/ribbon.ts b/src/games/blockilization/client/ribbon.ts index 548d26f..8075a3e 100644 --- a/src/games/blockilization/client/ribbon.ts +++ b/src/games/blockilization/client/ribbon.ts @@ -2,10 +2,11 @@ import type { PlayerSummary } from '../protocol'; import { CIVS } from '../rules/civs'; import { emblemEl, svg } from './icons'; import { PICTURES, type PictureId } from './picturefiles'; -import { openQuests } from './citystates'; +import { openCityState, openQuests } from './citystates'; +import { openDiplomacy } from './diplomacy'; import { faceEl } from './chat'; import { store, tone } from './store'; -import { h, togglePanel, type El } from './ui'; +import { h, type El } from './ui'; /** * The leaders down the right of the screen, Civilization's way: each civ met, its leader in a frame of @@ -28,14 +29,6 @@ const STANDING: Record = { ally: '#f2c14e', friend: '#6fd27a', n /** The city-states' group: open or shut. */ let statesOpen = false; -function talkTo(index: number) { - store.diplo.with = index; - store.diplo.deal = null; - store.diplo.check = null; - if (store.panel !== 'diplomacy') togglePanel('diplomacy'); - else store.changed++; -} - const badge = (corner: string, glyph: string, cls: string) => h(`span.lr-badge.${corner}.${cls}`, { html: svg(glyph, 12) }); /** With people at war taking turns one side at a time: whether a player's side is yet to play its segment, or playing it now. */ @@ -81,7 +74,7 @@ function leader(p: PlayerSummary): El { // (A face their leader's just made, `chat.ts`.) faceEl(p.index), ); - el.addEventListener('click', () => talkTo(p.index)); + el.addEventListener('click', () => openDiplomacy(p.index)); return el; } @@ -111,7 +104,7 @@ function cityStates(list: PlayerSummary[]): El | null { emblemEl(p.civ, 26, cs.kind), h('b', {}, String(Math.round(cs.influence))), ); - el.addEventListener('click', () => talkTo(-1)); + el.addEventListener('click', () => openCityState(p.index)); return el; }) : []), diff --git a/src/games/blockilization/client/store.ts b/src/games/blockilization/client/store.ts index f3b939e..4a72086 100644 --- a/src/games/blockilization/client/store.ts +++ b/src/games/blockilization/client/store.ts @@ -127,9 +127,10 @@ export interface Store { * The diplomacy panel: the civ looked at (-1: the city-states, -2: world politics, -3: the World * Congress), the deal being drawn up with it (null: * none), the server's word on that deal, offers put off till later (by id), and a deal just put - * (its answer clears the deal once it's made or offered). + * (its answer clears the deal once it's made or offered); on the city-states' page, the one + * spoken to (null: all of them). */ - diplo: { with: number | null; deal: DealItem[] | null; check: DealCheck | null; later: number[]; pending: boolean }; + diplo: { with: number | null; deal: DealItem[] | null; check: DealCheck | null; later: number[]; pending: boolean; cityState: number | null }; changed: number; } @@ -176,7 +177,7 @@ export const store: Store = { uiScale: 1, uiFit: 1, colourBlind: false, - diplo: { with: null, deal: null, check: null, later: [], pending: false }, + diplo: { with: null, deal: null, check: null, later: [], pending: false, cityState: null }, changed: 0, }; @@ -271,7 +272,7 @@ export function resetGame() { store.overHidden = false; store.interfaceHidden = false; store.takeOverOpen = false; - store.diplo = { with: null, deal: null, check: null, later: [], pending: false }; + store.diplo = { with: null, deal: null, check: null, later: [], pending: false, cityState: null }; store.changed++; } diff --git a/src/games/blockilization/client/ui.ts b/src/games/blockilization/client/ui.ts index a1018c3..ff24973 100644 --- a/src/games/blockilization/client/ui.ts +++ b/src/games/blockilization/client/ui.ts @@ -27,7 +27,7 @@ import { bindPedia, natureEffects, PEDIA_CSS, pediaKey, renderPedia, yieldWords import { NATURAL_WONDERS } from '../rules/nature'; import { layOutSoon, renderTechTree, TECH_CSS } from './techtree'; import { decisions, NOTES_CSS, renderNoteCentre, renderNotesPart } from './notes'; -import { ABILITY_TEXT, VICTORIES } from './concepts'; +import { ABILITY_TEXT, CITY_STATE_KINDS, VICTORIES } from './concepts'; import { OVERVIEW_CSS, renderOverview } from './overview'; import { HOTKEYS_CSS, renderHotkeys, renderMenu } from './hotkeys'; import { discardedBox, eraRow, hubTop, inviteRow, LOBBY_CSS, lobbyFoot, mapTypeText, savedGamesBox, scenarioCards, seatTeam, seedRow, speedText, teamMark, teamsRow, victoryCards, type GameLinks } from './lobby'; @@ -52,7 +52,7 @@ import { ask, attackable, cityAt, enemyAt, me, myCity, myUnit, outOfReach, reach import { seasonName } from './weatherfx'; import { chargeFrom, previewPath, routeOf, swapsWith, whyNot, type Route } from './path'; import { leaveMarch, marchOf, marching } from './march'; -import { CITYSTATES_CSS } from './citystates'; +import { CITYSTATES_CSS, standingWord } from './citystates'; import { CLANS_CSS } from './clans'; import { CONGRESS_CSS, openCongress, PROJECT_LOOK, sessionWhen, unvoted } from './congress'; import { isProject, PROJECTS } from '../rules/projects'; @@ -2533,6 +2533,10 @@ function renderTip() { const here = cityAt(t); if (here) bits.push(line('city', CIVS[store.view.players[here.owner]?.civ]?.color ?? '#fff', `${here.name} · strength ${here.strength} · ${Math.round(here.hp)}/${here.maxHp} HP`)); else if (owner) bits.push(h('div.ln', {}, emblemEl(owner.civ, 14, owner.cityState?.kind), `${owner.cityState ? owner.name : (CIVS[owner.civ]?.adjective ?? '')} land`)); + // (A city-state's city: said so, with how it stands with you; another's city, nothing of yours selected: a click talks to them.) + const host = here && here.owner !== me() ? store.view.players[here.owner] : null; + if (host?.cityState) bits.push(h('div.ln', {}, emblemEl(host.civ, 14, host.cityState.kind), `${CITY_STATE_KINDS.find((x) => x.id === host.cityState!.kind)?.name ?? ''} city-state · ${standingWord(host.cityState.standing)} · influence ${host.cityState.influence}`)); + if (host?.met && (host.cityState || host.relation) && !myUnit(store.selectedUnit)) bits.push(h('div.ln.way', {}, icon(host.cityState ? 'peace' : 'chat', 13), `${click(true)} to talk to ${host.name}`)); for (const u of unitsAt(t)) if (k.visible || u.owner === me()) { const p = store.view.players[u.owner];