diff --git a/README.md b/README.md index ebe2e6b..ee51a2f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,22 @@ would not be. **Cartoony.** Flat cel fills and one heavy outline, the same house style the other three games use, on a map generated from the topology in -`src/game/map.ts` rather than traced from anybody's board. +`src/game/map.ts` rather than traced from anybody's board. The coordinates in +`src/game/layout.ts` are placed by hand -- roughly where each province falls +in Europe -- and everything else is drawn from those and from the adjacency +rules. + +**It draws the graph, not regions, and that was not the first idea.** The +first idea was Voronoi cells: every point on the board belonging to the +nearest province, which gives a handsome cut-paper board. It is also wrong. +Sixty-five pairs that border each other in the rules came out with regions +that did not touch, and no amount of moving coordinates would fix it, because +the fault is structural -- provinces here interleave. The Adriatic borders +Venice with Trieste sitting between their centres; the Atlantic borders North +Africa around the outside of Spain. Convex cells cannot say that, and a map +that shows two regions meeting when the rules say they do not is worse than +an ugly map. It is a map that loses you the game. So adjacency is drawn as +lines, which say exactly what the rules say and cannot be misread. **It gets the whole screen.** The other three games live in a four-by-three cabinet because the machines they are rebuilding did. This one is not diff --git a/index.html b/index.html new file mode 100644 index 0000000..dcc2887 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Great Powers — the classic negotiation game, in your browser + + + +
+ + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..5ea919b --- /dev/null +++ b/src/App.css @@ -0,0 +1,133 @@ +/* ===================================================================== + Great Powers. + + Cartoony: flat fills, one heavy outline on everything, no gradients + anywhere near the board. And it takes the whole window -- the other games + on this site live in a four-by-three cabinet because the machines they + rebuild did, and this one is not rebuilding a machine. + ===================================================================== */ + +.app { + height: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: 'head head' 'map side'; + gap: 10px 18px; + padding: clamp(10px, 2vw, 22px); +} + +header { grid-area: head; } + +h1 { + margin: 0; + font-size: clamp(1.4rem, 3.2vw, 2.2rem); + font-weight: 900; + letter-spacing: -0.02em; + color: #ffd479; + -webkit-text-stroke: 3px #241a10; + paint-order: stroke fill; +} + +header p { margin: 2px 0 0; font-size: 0.9rem; } + +.dim { color: #a3b3c9; } + +.map-wrap { + grid-area: map; + min-width: 0; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.board { + width: 100%; + height: 100%; + border: 3px solid #241a10; + border-radius: 16px; + background: #6ea3c9; +} + +.sea-bed { fill: #6ea3c9; } + +.edges line { + stroke: #241a10; + stroke-width: 2; + opacity: 0.28; +} + +.province rect, +.province circle { + stroke: #241a10; + stroke-width: 3; + cursor: pointer; +} + +.province.sea circle { stroke-opacity: 0.55; } + +.province .pip { + fill: #fff6e0; + stroke: #241a10; + stroke-width: 2; +} + +.province .label { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-anchor: middle; + pointer-events: none; +} + +.province.picked rect, +.province.picked circle { + stroke: #ffd479; + stroke-width: 5; +} + +.unit path { + stroke: #241a10; + stroke-width: 2.5; + stroke-linejoin: round; +} + +/* --- the side ------------------------------------------------------- */ + +.side { + grid-area: side; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.powers { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 5px; } + +.powers li { + display: flex; + align-items: center; + gap: 9px; + font-weight: 700; + font-size: 0.92rem; +} + +.swatch { + width: 15px; + height: 15px; + border: 2px solid #241a10; + border-radius: 4px; +} + +.count { margin-left: auto; font-variant-numeric: tabular-nums; color: #a3b3c9; } + +.picked h2 { margin: 0 0 2px; font-size: 1rem; } +.picked p { margin: 0; font-size: 0.86rem; line-height: 1.45; } + +@media (max-width: 780px) { + .app { + grid-template-columns: 1fr; + grid-template-areas: 'head' 'map' 'side'; + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6029bb9 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,72 @@ +import { useMemo, useState } from 'react' +import { Board, COLOURS, POWER_NAMES } from './components/Board' +import { OPENING, POWERS, PROVINCES } from './game/map' +import { boardFrom, type Unit } from './game/orders' +import { centreCount, openingOwnership } from './game/turn' +import './App.css' + +/** + * The opening position, on the board, so the map can be looked at. + * + * This is scaffolding: order entry, the press and the turn loop are not + * wired to it yet. What it is for is seeing whether seventy-five provinces + * and twenty-two units are legible at a glance, which is a question no test + * can answer. + */ +export default function App() { + const [picked, setPicked] = useState(null) + + const units = useMemo(() => { + const all: Unit[] = [] + for (const power of POWERS) { + for (const at of OPENING[power].armies) all.push({ power, type: 'army', at }) + for (const at of OPENING[power].fleets) all.push({ power, type: 'fleet', at }) + } + return boardFrom(all) + }, []) + + const own = useMemo(openingOwnership, []) + + return ( +
+
+

Great Powers

+

Spring 1901 — the board before anybody has said anything.

+
+ +
+ +
+ + +
+ ) +} diff --git a/src/components/Board.tsx b/src/components/Board.tsx new file mode 100644 index 0000000..d7ca376 --- /dev/null +++ b/src/components/Board.tsx @@ -0,0 +1,147 @@ +import { PROVINCES, POWERS, base, type Power } from '../game/map' +import { CENTRES, borders, bounds, radius } from '../game/layout' +import type { Board as Units } from '../game/orders' +import type { Ownership } from '../game/turn' + +/** + * The board. + * + * Everything is drawn from `layout.ts` and the rules, so nothing here is a + * picture of anybody's map. Flat fills, one heavy outline, no gradients: the + * same cel style the other three games use, on a board that has to stay + * readable at a glance while somebody is arguing with you about Galicia. + * + * Borders are lines because in this game the adjacency *is* the rules. A + * region map has to decide whether two shapes touch, and when it gets that + * wrong it costs somebody the game. A line cannot be misread. + */ + +export const COLOURS: Record = { + austria: '#e4572e', + england: '#3b5bdb', + france: '#4dabf7', + germany: '#495057', + italy: '#37b24d', + russia: '#9775fa', + turkey: '#f59f00', +} + +const SEA = '#4a7fa8' +const LAND = '#e8d9b5' +const NEUTRAL_SC = '#fff6e0' +const OUTLINE = '#241a10' + +export function Board({ + units, + own, + selected, + onPick, +}: { + units: Units + own: Ownership + selected?: string | null + onPick?: (province: string) => void +}) { + const edges = borders() + const box = bounds() + + return ( + + + + {/* Borders first, so every province sits on top of its own edges. */} + + {edges.map(([a, b]) => ( + + ))} + + + {Object.keys(PROVINCES).map((id) => { + const p = PROVINCES[id]! + const at = CENTRES[id]! + const r = radius(id) + const owner = own.get(id) + const fill = + p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : p.sc ? NEUTRAL_SC : LAND + + return ( + onPick?.(id)} + > + {p.terrain === 'sea' ? ( + + ) : ( + + )} + + {/* A supply centre is the only thing anybody is counting, so it + gets a mark of its own rather than a different shade. */} + {p.sc && } + + + {id.toUpperCase()} + + + ) + })} + + {/* Units last: they are what you are actually looking at. */} + {[...units.entries()].map(([at, unit]) => { + const p = CENTRES[base(unit.at)] ?? CENTRES[at]! + return ( + + {unit.type === 'army' ? ( + + ) : ( + + )} + + ) + })} + + ) +} + +/** Dark text on a light province, light on a dark one. */ +function labelInk(background: string): string { + const n = parseInt(background.slice(1), 16) + const lum = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 + return lum > 140 ? OUTLINE : '#fff6e0' +} + +export const POWER_NAMES: Record = { + austria: 'Austria', + england: 'England', + france: 'France', + germany: 'Germany', + italy: 'Italy', + russia: 'Russia', + turkey: 'Turkey', +} + +export { POWERS } diff --git a/src/game/layout.test.ts b/src/game/layout.test.ts new file mode 100644 index 0000000..cff71a9 --- /dev/null +++ b/src/game/layout.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { ARMY, FLEET, PROVINCES, base } from './map' +import { CENTRES, HEIGHT, WIDTH, borders, radius } from './layout' + +/** + * Coordinates typed by hand are wrong somewhere, and a map is the one thing + * where wrong looks like a style choice. These are the checks that turn + * "Bulgaria seems to be in the North Sea" into a failing test. + */ + +const ids = Object.keys(PROVINCES) +const dist = (a: string, b: string) => + Math.hypot(CENTRES[a]!.x - CENTRES[b]!.x, CENTRES[a]!.y - CENTRES[b]!.y) + +describe('the placings', () => { + it('has a spot for every province and nothing else', () => { + expect(Object.keys(CENTRES).sort()).toEqual(ids.sort()) + }) + + it('keeps them all on the board', () => { + for (const [id, p] of Object.entries(CENTRES)) { + expect(p.x, id).toBeGreaterThan(0) + expect(p.x, id).toBeLessThan(WIDTH) + expect(p.y, id).toBeGreaterThan(0) + expect(p.y, id).toBeLessThan(HEIGHT) + } + }) + + it('keeps them far enough apart to be separate places', () => { + const close: string[] = [] + for (const a of ids) { + for (const b of ids) { + if (a >= b) continue + if (dist(a, b) < 24) close.push(`${a}/${b} at ${dist(a, b).toFixed(0)}`) + } + } + expect(close).toEqual([]) + }) +}) + +describe('the geography agrees with the rules', () => { + const edges = borders() + + it('draws a line for every border in the rules, and no others', () => { + const expected = new Set() + for (const id of ids) { + for (const to of ARMY[id] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':')) + const keys = PROVINCES[id]!.coasts ? PROVINCES[id]!.coasts!.map((c) => `${id}/${c}`) : [id] + for (const key of keys) { + for (const to of FLEET[key] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':')) + } + } + expect(new Set(edges.map((e) => e.join(':')))).toEqual(expected) + }) + + it('never draws the same border twice', () => { + expect(new Set(edges.map((e) => e.join(':'))).size).toBe(edges.length) + }) + + /** + * A smoke test for a province placed in the wrong country, and nothing + * more ambitious than that. The threshold is loose because provinces are + * not the same size: Moscow borders five things and is enormous, Norway + * reaches St Petersburg across the top of Finland, and North Africa runs + * the length of the Mediterranean. Tightening this until those failed + * would be measuring Europe rather than checking my typing. + */ + it('does not place a province in the wrong part of the continent', () => { + const far: string[] = [] + for (const [a, b] of edges) { + if (PROVINCES[a]!.terrain === 'sea' || PROVINCES[b]!.terrain === 'sea') continue + if (dist(a, b) > 250) far.push(`${a}-${b} at ${dist(a, b).toFixed(0)}`) + } + expect(far).toEqual([]) + }) + + it('puts the seas where the water is', () => { + // The Atlantic west of Portugal, the Black Sea east of Bulgaria, and + // Norway north of Spain. Three facts that would survive any redrawing. + expect(CENTRES.mao!.x).toBeLessThan(CENTRES.por!.x) + expect(CENTRES.bla!.x).toBeGreaterThan(CENTRES.bul!.x) + expect(CENTRES.nwy!.y).toBeLessThan(CENTRES.spa!.y) + }) +}) + +describe('the drawing', () => { + it('never lets two provinces overlap on the board', () => { + const overlapping: string[] = [] + for (const a of ids) { + for (const b of ids) { + if (a >= b) continue + if (dist(a, b) < radius(a) + radius(b) - 12) overlapping.push(`${a}/${b}`) + } + } + expect(overlapping).toEqual([]) + }) +}) diff --git a/src/game/layout.ts b/src/game/layout.ts new file mode 100644 index 0000000..c7e5994 --- /dev/null +++ b/src/game/layout.ts @@ -0,0 +1,187 @@ +import { ARMY, FLEET, PROVINCES, base } from './map' + +/** + * Where everything sits, and how the regions are drawn. + * + * The board this game is played on is somebody's artwork and is not here. + * What is here is a set of coordinates I placed by hand -- roughly where each + * province falls in Europe, in a thousand by eight hundred box -- and the map + * is drawn from those and from the adjacency rules. It is a drawing of the + * data rather than a tracing of a board, the same rule the cave game's + * dodecahedron is drawn under. + * + * **It draws the graph, not regions, and that was not the first idea.** + * + * The first idea was Voronoi cells -- every point on the board belonging to + * the nearest province -- which gives a handsome cut-paper board and is + * wrong. Sixty-five pairs that border each other in the rules had regions + * that did not touch, and no amount of nudging coordinates would fix it, + * because the fault is structural: provinces here interleave. The Adriatic + * borders Venice with Trieste sitting between their centres; the Atlantic + * borders North Africa around the outside of Spain. Convex cells cannot say + * that. + * + * A map that shows two regions meeting when the rules say they do not is + * worse than an ugly map. It is a map that loses you the game. So the + * adjacency is drawn as lines, which can say exactly what the rules say, and + * the province is a shape sitting on top of it. Nobody can misread a line. + */ + +export const WIDTH = 1000 +export const HEIGHT = 800 + +export interface Point { + x: number + y: number +} + +/** + * The centres, west to east and north to south. Placed by eye against an + * atlas: near enough that a player recognises Europe, and not a copy of + * anybody's board. + */ +export const CENTRES: Record = { + // --- the ocean and the northern seas --- + nao: { x: 60, y: 120 }, + nwg: { x: 250, y: 80 }, + bar: { x: 520, y: 40 }, + iri: { x: 160, y: 300 }, + nth: { x: 315, y: 235 }, + ska: { x: 395, y: 195 }, + hel: { x: 352, y: 265 }, + bal: { x: 470, y: 235 }, + bot: { x: 505, y: 155 }, + eng: { x: 215, y: 375 }, + mao: { x: 75, y: 400 }, + + // --- the British Isles --- + cly: { x: 215, y: 215 }, + edi: { x: 250, y: 240 }, + lvp: { x: 210, y: 275 }, + yor: { x: 258, y: 288 }, + wal: { x: 200, y: 318 }, + lon: { x: 258, y: 332 }, + + // --- Scandinavia and the north --- + nwy: { x: 390, y: 130 }, + swe: { x: 445, y: 140 }, + fin: { x: 520, y: 100 }, + stp: { x: 625, y: 90 }, + den: { x: 398, y: 248 }, + + // --- Russia --- + lvn: { x: 560, y: 190 }, + mos: { x: 690, y: 200 }, + war: { x: 545, y: 278 }, + ukr: { x: 620, y: 320 }, + sev: { x: 700, y: 355 }, + + // --- Germany and the Low Countries --- + pru: { x: 483, y: 268 }, + ber: { x: 432, y: 292 }, + kie: { x: 390, y: 286 }, + ruh: { x: 363, y: 332 }, + mun: { x: 392, y: 368 }, + sil: { x: 468, y: 322 }, + hol: { x: 330, y: 300 }, + bel: { x: 300, y: 345 }, + + // --- France and Iberia --- + pic: { x: 280, y: 376 }, + par: { x: 275, y: 418 }, + bre: { x: 208, y: 418 }, + bur: { x: 328, y: 404 }, + gas: { x: 248, y: 462 }, + mar: { x: 312, y: 482 }, + spa: { x: 182, y: 512 }, + por: { x: 115, y: 522 }, + + // --- Italy --- + pie: { x: 372, y: 456 }, + ven: { x: 408, y: 448 }, + tus: { x: 396, y: 492 }, + rom: { x: 420, y: 524 }, + nap: { x: 458, y: 562 }, + apu: { x: 468, y: 522 }, + + // --- Austria and the Balkans --- + tyr: { x: 412, y: 402 }, + boh: { x: 442, y: 358 }, + vie: { x: 472, y: 392 }, + bud: { x: 518, y: 402 }, + gal: { x: 542, y: 342 }, + tri: { x: 452, y: 442 }, + ser: { x: 508, y: 446 }, + alb: { x: 500, y: 482 }, + gre: { x: 518, y: 532 }, + bul: { x: 566, y: 462 }, + rum: { x: 598, y: 406 }, + + // --- Turkey and the Levant --- + con: { x: 628, y: 506 }, + ank: { x: 702, y: 500 }, + smy: { x: 668, y: 556 }, + arm: { x: 765, y: 500 }, + syr: { x: 742, y: 572 }, + + // --- the Mediterranean and Africa --- + adr: { x: 458, y: 482 }, + ion: { x: 478, y: 610 }, + aeg: { x: 572, y: 548 }, + eas: { x: 655, y: 622 }, + bla: { x: 665, y: 432 }, + tys: { x: 412, y: 568 }, + lyo: { x: 330, y: 532 }, + wes: { x: 268, y: 572 }, + naf: { x: 230, y: 640 }, + tun: { x: 398, y: 640 }, +} + +/** + * Every pair of provinces that border each other, once each. + * + * The union of both graphs at province level: an army's border and a fleet's + * are different questions, but a line on the map means "these two touch", and + * which unit can use it is what the panel beside the map is for. + */ +export function borders(): [string, string][] { + const seen = new Set() + const out: [string, string][] = [] + + const add = (a: string, b: string) => { + const key = a < b ? `${a}:${b}` : `${b}:${a}` + if (seen.has(key) || a === b) return + seen.add(key) + out.push(a < b ? [a, b] : [b, a]) + } + + for (const [id, tos] of Object.entries(ARMY)) for (const to of tos) add(id, base(to)) + for (const [key, tos] of Object.entries(FLEET)) { + for (const to of tos) add(base(key), base(to)) + } + return out +} + +/** How big a province is drawn. Seas are wide and vague, land is compact. */ +export const radius = (id: string): number => + PROVINCES[id]!.terrain === 'sea' ? 26 : PROVINCES[id]!.sc ? 21 : 17 + +/** + * The box the board actually occupies, rather than the box the coordinates + * were typed into. Europe is not rectangular and the placings do not fill a + * thousand by eight hundred; drawing that box leaves a third of the frame as + * empty ocean, which wastes the one thing this game was given more of than + * the others -- room. + */ +export function bounds(pad = 34): { x: number; y: number; w: number; h: number } { + const ids = Object.keys(CENTRES) + const lo = (f: (id: string) => number) => Math.min(...ids.map(f)) + const hi = (f: (id: string) => number) => Math.max(...ids.map(f)) + + const minX = lo((id) => CENTRES[id]!.x - radius(id)) + const maxX = hi((id) => CENTRES[id]!.x + radius(id)) + const minY = lo((id) => CENTRES[id]!.y - radius(id)) + const maxY = hi((id) => CENTRES[id]!.y + radius(id)) + + return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..bf6321c --- /dev/null +++ b/src/index.css @@ -0,0 +1,15 @@ +:root { + font-synthesis: none; + -webkit-font-smoothing: antialiased; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; margin: 0; } + +body { + background: radial-gradient(120% 90% at 50% 0%, #23324a 0%, #16202f 55%, #0d141d 100%); + color: #f2e9d8; + font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +)