From fd05217f9067d9617a0b3ef18d1d8ae152d71358 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 07:50:40 -0700 Subject: [PATCH] Territories, not circles The board is regions again. Voronoi cells divide it with no gaps and no overlaps and look like a board, which is what this was always supposed to be. What they cannot do is reproduce every adjacency in the rules, because provinces interleave -- the Adriatic borders Venice with Trieste between their centres. The lesson I drew from that was to abandon regions and draw the adjacency graph, which is unimpeachable and is not a map. It is circles joined by lines. The right answer is to stop asking the picture to carry the rules. Clicking a province lights up exactly where its unit may legally go, taken from the graph rather than from which shapes happen to share an edge. Vienna lights Bohemia, Galicia, Budapest, Trieste and Tyrolia; a fleet on the north coast of Spain does not light the Gulf of Lyon. The picture is a picture and the rules answer for themselves when asked. --- README.md | 27 ++++--- src/App.css | 40 +++++------ src/components/Board.tsx | 147 ++++++++++++++++++--------------------- src/game/layout.test.ts | 52 +++++++++++++- src/game/layout.ts | 74 ++++++++++++++++++++ 5 files changed, 230 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index ee51a2f..f154eda 100644 --- a/README.md +++ b/README.md @@ -42,17 +42,22 @@ other three games use, on a map generated from the topology in 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. +**Territories, with the rules kept out of the picture.** The regions are +Voronoi cells -- every point belonging to the nearest province -- which +divides the board with no gaps and no overlaps and looks like a board. + +What it cannot do is reproduce every adjacency in the rules, because +provinces here interleave: the Adriatic borders Venice with Trieste sitting +between their centres, and the Atlantic borders North Africa round the +outside of Spain. Convex cells cannot say that. + +The wrong lesson to draw from that -- and the one I drew first -- is to give +up on regions and draw the adjacency graph instead. It is unimpeachable and +it is not a map; it is circles joined by lines. The right answer is to stop +asking the picture to carry the rules. **Click a province and exactly the +provinces its unit may legally reach light up**, taken from the adjacency +graph itself. The picture is a picture, and the rules answer for themselves +when they are asked. **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/src/App.css b/src/App.css index 5ea919b..a8876b8 100644 --- a/src/App.css +++ b/src/App.css @@ -50,42 +50,42 @@ header p { margin: 2px 0 0; font-size: 0.9rem; } background: #6ea3c9; } -.sea-bed { fill: #6ea3c9; } +.ocean { fill: #4a7fa8; } -.edges line { +.region { stroke: #241a10; - stroke-width: 2; - opacity: 0.28; -} - -.province rect, -.province circle { - stroke: #241a10; - stroke-width: 3; + stroke-width: 2.5; + stroke-linejoin: round; cursor: pointer; } -.province.sea circle { stroke-opacity: 0.55; } +/* Open water gets a lighter line: a coastline is a border, the middle of the + Atlantic is a convention. */ +.region.sea { stroke-opacity: 0.3; stroke-width: 2; } -.province .pip { +.region.picked { stroke: #ffd479; stroke-width: 5; } + +/* Where the selected unit may actually go, taken from the rules rather than + from which shapes happen to touch. */ +.region.open { stroke: #fff6e0; stroke-width: 4; stroke-dasharray: 7 5; } + +.pip { fill: #fff6e0; stroke: #241a10; - stroke-width: 2; + stroke-width: 1.8; + pointer-events: none; } -.province .label { - font-size: 12px; +.label { + font-size: 11.5px; font-weight: 800; letter-spacing: 0.04em; text-anchor: middle; pointer-events: none; + fill: #241a10; } -.province.picked rect, -.province.picked circle { - stroke: #ffd479; - stroke-width: 5; -} +.label.wet { fill: #dceaf5; } .unit path { stroke: #241a10; diff --git a/src/components/Board.tsx b/src/components/Board.tsx index d7ca376..086b432 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,34 +1,38 @@ import { PROVINCES, POWERS, base, type Power } from '../game/map' -import { CENTRES, borders, bounds, radius } from '../game/layout' +import { CENTRES, bounds, cell, path, radius, reachableFrom } 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. + * Territories, drawn from coordinates I placed by hand and divided by the + * halfway line between them. 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. * - * 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. + * The regions are what the map looks like; they are not what the rules are + * read from. A Voronoi cell cannot reproduce every adjacency in this game -- + * provinces interleave, and the Adriatic borders Venice with Trieste sitting + * between their centres -- so clicking a province lights up exactly where its + * unit may legally go, taken from the adjacency graph itself. The picture is + * a picture; the rules answer for themselves when asked. */ export const COLOURS: Record = { austria: '#e4572e', england: '#3b5bdb', france: '#4dabf7', - germany: '#495057', + germany: '#4b545e', italy: '#37b24d', russia: '#9775fa', - turkey: '#f59f00', + turkey: '#f2a03d', } -const SEA = '#4a7fa8' -const LAND = '#e8d9b5' -const NEUTRAL_SC = '#fff6e0' +const SEA = '#5b93bd' +const SEA_DEEP = '#4a7fa8' +const LAND = '#ded0ab' +const LAND_SC = '#efe3c2' const OUTLINE = '#241a10' export function Board({ @@ -42,79 +46,73 @@ export function Board({ selected?: string | null onPick?: (province: string) => void }) { - const edges = borders() - const box = bounds() + const box = bounds(10) + const ids = Object.keys(PROVINCES) + + const standing = selected ? units.get(selected) : undefined + const reachable = new Set(standing ? reachableFrom(standing) : []) return ( - + - {/* Borders first, so every province sits on top of its own edges. */} - - {edges.map(([a, b]) => ( - Number(PROVINCES[b]!.terrain === 'sea') - Number(PROVINCES[a]!.terrain === 'sea')) + .map((id) => { + const p = PROVINCES[id]! + const owner = own.get(id) + const fill = + p.terrain === 'sea' ? (p.sc ? SEA : SEA_DEEP) : owner ? COLOURS[owner] : p.sc ? LAND_SC : LAND + + return ( + onPick?.(id)} + /> + ) + })} + + {/* Supply centres get a mark of their own: they are the only thing + anybody is actually counting. */} + {ids + .filter((id) => PROVINCES[id]!.sc) + .map((id) => ( + ))} - - {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()} - - - ) - })} + {ids.map((id) => ( + + {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' ? ( ) : ( @@ -127,13 +125,6 @@ export function Board({ ) } -/** 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', @@ -144,4 +135,4 @@ export const POWER_NAMES: Record = { turkey: 'Turkey', } -export { POWERS } +export { POWERS, OUTLINE } diff --git a/src/game/layout.test.ts b/src/game/layout.test.ts index cff71a9..d4da082 100644 --- a/src/game/layout.test.ts +++ b/src/game/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { ARMY, FLEET, PROVINCES, base } from './map' -import { CENTRES, HEIGHT, WIDTH, borders, radius } from './layout' +import { CENTRES, HEIGHT, WIDTH, borders, cell, radius, reachableFrom } from './layout' /** * Coordinates typed by hand are wrong somewhere, and a map is the one thing @@ -95,3 +95,53 @@ describe('the drawing', () => { expect(overlapping).toEqual([]) }) }) + +describe('the regions', () => { + it('gives every province a shape with area in it', () => { + for (const id of ids) { + const poly = cell(id) + expect(poly.length, id).toBeGreaterThan(2) + // Shoelace: a region nobody can see is a region that is not there. + let area = 0 + for (let i = 0; i < poly.length; i++) { + const a = poly[i]! + const b = poly[(i + 1) % poly.length]! + area += a.x * b.y - b.x * a.y + } + expect(Math.abs(area / 2), id).toBeGreaterThan(300) + } + }) + + it('contains its own centre', () => { + for (const id of ids) { + const poly = cell(id, 0) + const me = CENTRES[id]! + for (let i = 0; i < poly.length; i++) { + const a = poly[i]! + const b = poly[(i + 1) % poly.length]! + const cross = (b.x - a.x) * (me.y - a.y) - (b.y - a.y) * (me.x - a.x) + expect(cross, `${id} edge ${i}`).toBeGreaterThan(-0.01) + } + } + }) +}) + +describe('what a unit may reach', () => { + /** + * The point of this being separate from the drawing. The regions cannot + * express every adjacency in the rules, so the map never claims to: this + * is what lights up when a province is clicked, and it comes from the + * graph rather than from which shapes happen to share an edge. + */ + it('is the rules, not the picture', () => { + expect(reachableFrom({ type: 'army', at: 'vie' }).sort()).toEqual( + ['boh', 'bud', 'gal', 'tri', 'tyr'], + ) + expect(reachableFrom({ type: 'fleet', at: 'stp/sc' }).sort()).toEqual(['bot', 'fin', 'lvn']) + }) + + it('knows a fleet on one coast cannot use the other', () => { + expect(reachableFrom({ type: 'fleet', at: 'spa/nc' })).not.toContain('lyo') + expect(reachableFrom({ type: 'fleet', at: 'spa/sc' })).toContain('lyo') + }) +}) diff --git a/src/game/layout.ts b/src/game/layout.ts index c7e5994..ef965c6 100644 --- a/src/game/layout.ts +++ b/src/game/layout.ts @@ -185,3 +185,77 @@ export function bounds(pad = 34): { x: number; y: number; w: number; h: number } return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 } } + +/** + * The region belonging to one province: the whole board, cut back by the + * halfway line between it and every other centre. + * + * This is a Voronoi diagram, and it is worth being straight about what it + * can and cannot do. It divides the plane with no gaps and no overlaps, and + * it looks like a board. It cannot reproduce every adjacency in the rules, + * because provinces here interleave -- the Adriatic borders Venice with + * Trieste between their centres -- and convex cells cannot say that. + * + * So the regions are what the map *looks* like, and they are not what the + * rules are read from. Clicking a province lights up exactly where its unit + * may legally go, taken from the adjacency graph. That is the honest split: + * the picture is a picture, and the rules answer for themselves when asked. + */ +export function cell(id: string, margin = 5): Point[] { + const me = CENTRES[id] + if (!me) return [] + + let poly: Point[] = [ + { x: -60, y: -60 }, + { x: WIDTH + 60, y: -60 }, + { x: WIDTH + 60, y: HEIGHT + 60 }, + { x: -60, y: HEIGHT + 60 }, + ] + + for (const [other, them] of Object.entries(CENTRES)) { + if (other === id) continue + const nx = them.x - me.x + const ny = them.y - me.y + const len = Math.hypot(nx, ny) + if (len === 0) continue + const on = { + x: (me.x + them.x) / 2 - (nx / len) * (margin / 2), + y: (me.y + them.y) / 2 - (ny / len) * (margin / 2), + } + poly = clip(poly, on, { x: nx / len, y: ny / len }) + if (poly.length === 0) break + } + return poly +} + +/** Everything on the near side of a line through `on` facing `normal`. */ +function clip(poly: Point[], on: Point, normal: Point): Point[] { + const side = (p: Point) => (p.x - on.x) * normal.x + (p.y - on.y) * normal.y + const out: Point[] = [] + for (let i = 0; i < poly.length; i++) { + const a = poly[i]! + const b = poly[(i + 1) % poly.length]! + const sa = side(a) + const sb = side(b) + if (sa <= 0) out.push(a) + if ((sa < 0 && sb > 0) || (sa > 0 && sb < 0)) { + const t = sa / (sa - sb) + out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }) + } + } + return out +} + +export const path = (poly: readonly Point[]): string => + poly.length === 0 ? '' : `M${poly.map((p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join('L')}Z` + +/** Where a unit standing here may legally go. The rules, not the picture. */ +export function reachableFrom(unit: { type: 'army' | 'fleet'; at: string }): string[] { + const out = new Set() + if (unit.type === 'army') { + for (const to of ARMY[base(unit.at)] ?? []) out.add(base(to)) + } else { + for (const to of FLEET[unit.at] ?? []) out.add(base(to)) + } + return [...out] +}