Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 89 additions & 20 deletions src/games/blockilization/client/citystates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,35 @@ 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
* a friend and an ally, your influence on a bar marked where friendship (30) and alliance (60)
* 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<string, { word: string; cls: string }> = {
ally: { word: 'Your ally', cls: 'good' },
Expand All @@ -24,6 +42,8 @@ const STANDING: Record<string, { word: string; cls: string }> = {
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;
Expand Down Expand Up @@ -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 }) =>
Expand All @@ -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),
Expand All @@ -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.`),
);
}

Expand All @@ -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 [
Expand All @@ -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)),
];
}

Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
}
`;
2 changes: 1 addition & 1 deletion src/games/blockilization/client/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
Expand Down
12 changes: 10 additions & 2 deletions src/games/blockilization/client/diplomacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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',
{},
Expand All @@ -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!;
Expand Down
3 changes: 2 additions & 1 deletion src/games/blockilization/client/guide.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') } },
Expand Down
Loading
Loading