214 lines
8.0 KiB
JavaScript
214 lines
8.0 KiB
JavaScript
// CatanBoard.js — pure geometry + static data for Settlers of Catan.
|
||
// No Phaser imports. Shared by CatanLogic, CatanAI, and CatanGame so they all
|
||
// agree on one node/edge/hex coordinate model.
|
||
|
||
// ── Layout constants (pixel space, 1920×1080 canvas) ────────────────────────
|
||
// Board sits in the centre-right; left strip is reserved for opponent portraits
|
||
// and the bottom strip for the human's hand.
|
||
export const BOARD_CX = 1000;
|
||
export const BOARD_CY = 470;
|
||
export const HEX_SIZE = 92; // centre-to-corner radius (pointy-top)
|
||
|
||
const SQRT3 = Math.sqrt(3);
|
||
export const HEX_W = SQRT3 * HEX_SIZE; // flat-to-flat width / in-row spacing
|
||
const ROW_V = 1.5 * HEX_SIZE; // vertical spacing between rows
|
||
|
||
export const HEX_ROWS = [3, 4, 5, 4, 3];
|
||
|
||
// ── Resource / chit / port / cost / dev-deck definitions ────────────────────
|
||
export const RESOURCE_INFO = {
|
||
brick: { label: 'Brick', tile: 'Hills', color: 0xc1502e, swatch: 0xc1502e },
|
||
lumber: { label: 'Lumber', tile: 'Forest', color: 0x2e7d32, swatch: 0x2e7d32 },
|
||
wool: { label: 'Wool', tile: 'Pasture', color: 0x8bc34a, swatch: 0x8bc34a },
|
||
grain: { label: 'Grain', tile: 'Fields', color: 0xf0c419, swatch: 0xe0b000 },
|
||
ore: { label: 'Ore', tile: 'Mountains', color: 0x9aa3ab, swatch: 0x9aa3ab },
|
||
};
|
||
export const RESOURCE_TYPES = ['brick', 'lumber', 'wool', 'grain', 'ore'];
|
||
export const DESERT_COLOR = 0xe3cf94;
|
||
|
||
// 19 hexes: 4 lumber, 3 brick, 4 wool, 4 grain, 3 ore, 1 desert.
|
||
export const RESOURCE_BAG = [
|
||
'lumber', 'lumber', 'lumber', 'lumber',
|
||
'brick', 'brick', 'brick',
|
||
'wool', 'wool', 'wool', 'wool',
|
||
'grain', 'grain', 'grain', 'grain',
|
||
'ore', 'ore', 'ore',
|
||
'desert',
|
||
];
|
||
|
||
// Fixed hex resources for the standard Catan beginner layout.
|
||
// Indexed by hex ID (rows [3,4,5,4,3], left-to-right top-to-bottom).
|
||
export const STANDARD_RESOURCES = [
|
||
'ore', 'wool', 'lumber', // row 0: hex 0-2
|
||
'grain', 'brick', 'wool', 'brick', // row 1: hex 3-6
|
||
'lumber', 'grain', 'desert','grain', 'ore', // row 2: hex 7-11
|
||
'lumber', 'ore', 'grain', 'wool', // row 3: hex 12-15
|
||
'brick', 'wool', 'lumber', // row 4: hex 16-18
|
||
];
|
||
|
||
// 18 number chits for the 18 non-desert hexes.
|
||
export const CHIT_BAG = [2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12];
|
||
|
||
// Hex IDs visited in the standard Catan clockwise spiral (outer ring → inner ring → centre).
|
||
// Rows [3,4,5,4,3] assign IDs left-to-right, top-to-bottom: 0-2, 3-6, 7-11, 12-15, 16-18.
|
||
export const CHIT_SPIRAL = [0, 1, 2, 6, 11, 15, 18, 17, 16, 12, 7, 3, 4, 5, 10, 14, 13, 8, 9];
|
||
// Standard chit values A–R placed in spiral order; desert hex is skipped when assigning.
|
||
export const CHIT_SEQUENCE = [5, 2, 6, 3, 8, 10, 9, 12, 11, 4, 8, 10, 9, 4, 5, 6, 3, 11];
|
||
|
||
// 9 ports: 4 generic 3:1, one 2:1 per resource.
|
||
export const PORT_BAG = ['any', 'any', 'any', 'any', 'brick', 'lumber', 'wool', 'grain', 'ore'];
|
||
|
||
export const COSTS = {
|
||
road: { brick: 1, lumber: 1 },
|
||
settlement: { brick: 1, lumber: 1, wool: 1, grain: 1 },
|
||
city: { grain: 2, ore: 3 },
|
||
devCard: { wool: 1, grain: 1, ore: 1 },
|
||
};
|
||
|
||
// 25-card development deck.
|
||
export const DEV_DECK = [
|
||
...Array(14).fill('knight'),
|
||
...Array(5).fill('vp'),
|
||
...Array(2).fill('roadBuilding'),
|
||
...Array(2).fill('yearOfPlenty'),
|
||
...Array(2).fill('monopoly'),
|
||
];
|
||
|
||
export const DEV_INFO = {
|
||
knight: { label: 'Knight', short: 'Knight' },
|
||
vp: { label: 'Victory Point', short: 'VP' },
|
||
roadBuilding: { label: 'Road Building', short: 'Roads' },
|
||
yearOfPlenty: { label: 'Year of Plenty', short: 'Plenty' },
|
||
monopoly: { label: 'Monopoly', short: 'Monop.' },
|
||
};
|
||
|
||
export const PLAYER_COLORS = [
|
||
{ key: 'blue', hex: 0x2d6cdf, hexDark: 0x1c4490, name: 'Blue' },
|
||
{ key: 'red', hex: 0xd23b3b, hexDark: 0x8f2424, name: 'Red' },
|
||
{ key: 'orange', hex: 0xe08a1e, hexDark: 0x9c5d10, name: 'Orange' },
|
||
{ key: 'white', hex: 0xe8e4d8, hexDark: 0x9c9684, name: 'White' },
|
||
];
|
||
|
||
export const WIN_VP = 10;
|
||
|
||
// Probability pips for a number chit: 2/12→1 … 6/8→5.
|
||
export function pipCount(n) {
|
||
return 6 - Math.abs(7 - n);
|
||
}
|
||
|
||
// ── Geometry generation ─────────────────────────────────────────────────────
|
||
// Pointy-top corner: top & bottom vertices, angle = 60*i - 90 degrees.
|
||
function hexCorners(cx, cy, size) {
|
||
const pts = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (Math.PI / 180) * (60 * i - 90);
|
||
pts.push({ x: cx + size * Math.cos(a), y: cy + size * Math.sin(a) });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function buildGeometry(cx, cy, size) {
|
||
const hexes = [];
|
||
// Hex centres, rows of [3,4,5,4,3], each row horizontally centred.
|
||
let id = 0;
|
||
for (let r = 0; r < HEX_ROWS.length; r++) {
|
||
const count = HEX_ROWS[r];
|
||
const rowY = cy + (r - 2) * ROW_V;
|
||
const startX = cx - ((count - 1) / 2) * HEX_W;
|
||
for (let c = 0; c < count; c++) {
|
||
hexes.push({ id: id++, cx: startX + c * HEX_W, cy: rowY, row: r, col: c });
|
||
}
|
||
}
|
||
|
||
// Nodes: dedup hex corners by rounded pixel key.
|
||
const nodeKey = (p) => `${Math.round(p.x)}_${Math.round(p.y)}`;
|
||
const nodeMap = new Map(); // key -> node
|
||
const nodes = [];
|
||
const ensureNode = (p) => {
|
||
const k = nodeKey(p);
|
||
let n = nodeMap.get(k);
|
||
if (!n) {
|
||
n = { id: nodes.length, x: Math.round(p.x), y: Math.round(p.y), hexes: [], adj: [] };
|
||
nodeMap.set(k, n);
|
||
nodes.push(n);
|
||
}
|
||
return n;
|
||
};
|
||
|
||
// Edges: dedup by sorted node-id pair.
|
||
const edgeMap = new Map();
|
||
const edges = [];
|
||
const ensureEdge = (a, b) => {
|
||
const lo = Math.min(a, b), hi = Math.max(a, b);
|
||
const k = `${lo}_${hi}`;
|
||
let e = edgeMap.get(k);
|
||
if (!e) {
|
||
e = { id: edges.length, nodes: [lo, hi], hexes: [] };
|
||
edgeMap.set(k, e);
|
||
edges.push(e);
|
||
}
|
||
return e;
|
||
};
|
||
|
||
for (const hex of hexes) {
|
||
const corners = hexCorners(hex.cx, hex.cy, size).map(ensureNode);
|
||
hex.corners = corners.map((n) => n.id);
|
||
for (const n of corners) if (!n.hexes.includes(hex.id)) n.hexes.push(hex.id);
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = corners[i].id;
|
||
const b = corners[(i + 1) % 6].id;
|
||
ensureEdge(a, b);
|
||
}
|
||
}
|
||
|
||
// Node adjacency + edge→hex membership.
|
||
for (const e of edges) {
|
||
const [a, b] = e.nodes;
|
||
if (!nodes[a].adj.includes(b)) nodes[a].adj.push(b);
|
||
if (!nodes[b].adj.includes(a)) nodes[b].adj.push(a);
|
||
e.hexes = nodes[a].hexes.filter((h) => nodes[b].hexes.includes(h));
|
||
}
|
||
|
||
// Port slots: coastal edges (touch exactly 1 hex), 9 spaced around the rim.
|
||
const coastal = edges.filter((e) => e.hexes.length === 1);
|
||
coastal.sort((p, q) => {
|
||
const pm = midpoint(nodes, p), qm = midpoint(nodes, q);
|
||
return Math.atan2(pm.y - cy, pm.x - cx) - Math.atan2(qm.y - cy, qm.x - cx);
|
||
});
|
||
const portSlots = [];
|
||
for (let i = 0; i < 9; i++) {
|
||
const e = coastal[Math.round((i * coastal.length) / 9) % coastal.length];
|
||
const m = midpoint(nodes, e);
|
||
portSlots.push({
|
||
edgeId: e.id,
|
||
nodes: [...e.nodes],
|
||
x: m.x, y: m.y,
|
||
// outward direction (board centre → edge midpoint), for drawing the marker offshore
|
||
angle: Math.atan2(m.y - cy, m.x - cx),
|
||
});
|
||
}
|
||
|
||
return { hexes, nodes, edges, portSlots };
|
||
}
|
||
|
||
function midpoint(nodes, edge) {
|
||
const a = nodes[edge.nodes[0]], b = nodes[edge.nodes[1]];
|
||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
}
|
||
|
||
// Default geometry baked at module load — shared by every consumer.
|
||
export const GEOMETRY = buildGeometry(BOARD_CX, BOARD_CY, HEX_SIZE);
|
||
|
||
// Convenience accessors.
|
||
export const HEXES = GEOMETRY.hexes;
|
||
export const NODES = GEOMETRY.nodes;
|
||
export const EDGES = GEOMETRY.edges;
|
||
export const PORT_SLOTS = GEOMETRY.portSlots;
|
||
|
||
// Edge id between two adjacent node ids, or -1.
|
||
export function edgeBetween(a, b) {
|
||
const lo = Math.min(a, b), hi = Math.max(a, b);
|
||
const e = EDGES.find((x) => x.nodes[0] === lo && x.nodes[1] === hi);
|
||
return e ? e.id : -1;
|
||
}
|