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.
This commit is contained in:
2026-09-09 07:50:40 -07:00
parent ad6e384956
commit fd05217f90
5 changed files with 230 additions and 110 deletions
+74
View File
@@ -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<string>()
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]
}