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] +}