The board, drawn
Coordinates placed by hand, roughly where each province falls in Europe, and everything else drawn from those and from the adjacency rules. Nothing here is a picture of anybody's board. It draws the graph rather than regions, and that was not the first idea. The first idea was Voronoi cells -- every point belonging to the nearest province -- which makes a handsome cut-paper board and is 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 fixes it: provinces here interleave. The Adriatic borders Venice with Trieste 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 otherwise is worse than an ugly map -- it is a map that loses you the game. The tests found three provinces drawn on top of each other and a distance check of mine that was measuring Europe rather than checking my typing: it failed fifteen honest placings because oceans are large and Moscow borders five things. The exact check is that a line exists for every border in the rules and for no others. The viewBox is fitted to what is actually drawn. Europe is not rectangular, and the box the coordinates were typed into left a third of the frame as empty ocean -- which wastes the one thing this game was given more of than the others.
This commit is contained in:
@@ -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
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Great Powers — the classic negotiation game, in your browser</title>
|
||||
<meta name="author" content="John Coffey" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+133
@@ -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';
|
||||
}
|
||||
}
|
||||
+72
@@ -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<string | null>(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 (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>Great Powers</h1>
|
||||
<p className="dim">Spring 1901 — the board before anybody has said anything.</p>
|
||||
</header>
|
||||
|
||||
<div className="map-wrap">
|
||||
<Board units={units} own={own} selected={picked} onPick={setPicked} />
|
||||
</div>
|
||||
|
||||
<aside className="side">
|
||||
<ul className="powers">
|
||||
{POWERS.map((power) => (
|
||||
<li key={power}>
|
||||
<span className="swatch" style={{ background: COLOURS[power] }} />
|
||||
{POWER_NAMES[power]}
|
||||
<span className="count">{centreCount(own, power)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="picked">
|
||||
{picked ? (
|
||||
<>
|
||||
<h2>{PROVINCES[picked]!.name}</h2>
|
||||
<p className="dim">
|
||||
{PROVINCES[picked]!.terrain === 'sea'
|
||||
? 'Open water. Fleets only.'
|
||||
: PROVINCES[picked]!.terrain === 'coast'
|
||||
? 'Coastal. Armies and fleets.'
|
||||
: 'Inland. Armies only.'}
|
||||
{PROVINCES[picked]!.sc ? ' A supply centre.' : ''}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dim">Click a province.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Power, string> = {
|
||||
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 (
|
||||
<svg
|
||||
className="board"
|
||||
viewBox={`${box.x} ${box.y} ${box.w} ${box.h}`}
|
||||
role="img"
|
||||
aria-label="The board: seventy-five provinces and the borders between them."
|
||||
>
|
||||
<rect className="sea-bed" x={box.x} y={box.y} width={box.w} height={box.h} />
|
||||
|
||||
{/* Borders first, so every province sits on top of its own edges. */}
|
||||
<g className="edges">
|
||||
{edges.map(([a, b]) => (
|
||||
<line
|
||||
key={`${a}-${b}`}
|
||||
x1={CENTRES[a]!.x}
|
||||
y1={CENTRES[a]!.y}
|
||||
x2={CENTRES[b]!.x}
|
||||
y2={CENTRES[b]!.y}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{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 (
|
||||
<g
|
||||
className={`province ${p.terrain} ${p.sc ? 'centre' : ''} ${selected === id ? 'picked' : ''}`}
|
||||
key={id}
|
||||
onClick={() => onPick?.(id)}
|
||||
>
|
||||
{p.terrain === 'sea' ? (
|
||||
<circle cx={at.x} cy={at.y} r={r} fill={fill} />
|
||||
) : (
|
||||
<rect
|
||||
x={at.x - r}
|
||||
y={at.y - r * 0.82}
|
||||
width={r * 2}
|
||||
height={r * 1.64}
|
||||
rx={r * 0.45}
|
||||
fill={fill}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 && <circle className="pip" cx={at.x} cy={at.y - r * 0.86} r={4} />}
|
||||
|
||||
<text
|
||||
className="label"
|
||||
x={at.x}
|
||||
y={at.y + 5}
|
||||
style={{ fill: labelInk(p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : LAND) }}
|
||||
>
|
||||
{id.toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Units last: they are what you are actually looking at. */}
|
||||
{[...units.entries()].map(([at, unit]) => {
|
||||
const p = CENTRES[base(unit.at)] ?? CENTRES[at]!
|
||||
return (
|
||||
<g className="unit" key={at} transform={`translate(${p.x} ${p.y + 16})`}>
|
||||
{unit.type === 'army' ? (
|
||||
<path d="M-11 6 L-11 -3 L0 -9 L11 -3 L11 6 Z" fill={COLOURS[unit.power]} />
|
||||
) : (
|
||||
<path d="M-12 4 L12 4 L7 -2 L2 -2 L2 -9 L-3 -2 L-12 -2 Z" fill={COLOURS[unit.power]} />
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<Power, string> = {
|
||||
austria: 'Austria',
|
||||
england: 'England',
|
||||
france: 'France',
|
||||
germany: 'Germany',
|
||||
italy: 'Italy',
|
||||
russia: 'Russia',
|
||||
turkey: 'Turkey',
|
||||
}
|
||||
|
||||
export { POWERS }
|
||||
@@ -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<string>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<string, Point> = {
|
||||
// --- 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<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user