feat(catan): implement Seafarers expansion and dynamic board architecture

- Decouple static board geometry from game state by introducing a board registry and `geoFor(state)` accessor. All AI and logic functions now dynamically resolve topology.
- Add support for Seafarers scenarios (New Shores, Four Islands, Oceania, Fog Island) with scenario-specific setup rules and victory conditions.
- Implement ship mechanics: players build maritime routes on coastal/sea edges that count toward the longest road.
- Introduce sea, gold, and fog terrain types. Update hex rendering, AI pathfinding, and placement rules to respect land vs. water constraints.
- Add pirate token rendering and logic, plus expansion scoring hooks for bonus victory points.
- Update lobby UI to allow expansion and scenario selection, passing configuration through to the game scene.
- Refactor `CatanBoard.js` geometry generation for O(1) edge lookups and dynamic port assignment.
This commit is contained in:
Brian Fertig 2026-05-29 15:06:20 -06:00
parent 2c95e76b00
commit c5f34b7c28
15 changed files with 865 additions and 123 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

View File

@ -2,18 +2,18 @@
// The scene drives AI turns by repeatedly calling chooseAction() and applying
// the returned action until it returns { type: 'endTurn' }.
import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
import { pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
import {
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
victoryPoints, WIN_VP, longestRoadFor,
victoryPoints, longestRoadFor, geoFor, winVpFor,
} from './CatanLogic.js';
// Value of a vertex = production potential of its adjacent hexes + diversity.
function nodeValue(state, nodeId) {
let v = 0;
const seen = new Set();
for (const hx of NODES[nodeId].hexes) {
for (const hx of geoFor(state).nodes[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
v += pipCount(hex.number);
@ -27,7 +27,7 @@ function productionByResource(state, seat) {
const prod = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const p = state.players[seat];
const tally = (nodeId, mult) => {
for (const hx of NODES[nodeId].hexes) {
for (const hx of geoFor(state).nodes[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
prod[hex.resource] += pipCount(hex.number) * mult;
@ -46,7 +46,7 @@ export function chooseSetupSettlement(state, seat) {
for (const n of nodes) {
let score = nodeValue(state, n);
// Bonus for grabbing resources the seat doesn't yet produce.
for (const hx of NODES[n].hexes) {
for (const hx of geoFor(state).nodes[n].hexes) {
const hex = state.hexes[hx];
if (hex.resource !== 'desert' && hex.number != null && prod[hex.resource] === 0) score += 1.5;
}
@ -59,8 +59,9 @@ export function chooseSetupRoad(state, seat) {
const from = state.setup.lastSettlement;
const edges = legalRoadEdges(state, seat, true, from);
let best = edges[0], bestScore = -Infinity;
const geo = geoFor(state);
for (const eid of edges) {
const [a, b] = EDGES[eid].nodes;
const [a, b] = geo.edges[eid].nodes;
const far = a === from ? b : a;
const score = nodeValue(state, far);
if (score > bestScore) { bestScore = score; best = eid; }
@ -87,12 +88,14 @@ export function chooseDiscard(state, seat) {
export function chooseRobberMove(state, seat) {
let best = null, bestScore = -Infinity, bestTarget = null;
const geo = geoFor(state);
for (const hex of state.hexes) {
if (hex.hasRobber) continue;
if (hex.kind && hex.kind !== 'land') continue; // robber only on land hexes
let score = -1;
let touchesSelf = false;
let richest = null, richestCards = -1;
for (const nodeId of HEXES[hex.id].corners) {
for (const nodeId of geo.hexes[hex.id].corners) {
const bld = nodeBuilding(state, nodeId);
if (!bld) continue;
if (bld.seat === seat) { touchesSelf = true; continue; }
@ -105,8 +108,10 @@ export function chooseRobberMove(state, seat) {
if (touchesSelf) score -= 100; // never hurt ourselves
if (score > bestScore) { bestScore = score; best = hex.id; bestTarget = richest; }
}
// Fallback: any legal hex.
if (best === null) best = state.hexes.find((h) => !h.hasRobber)?.id ?? state.robberHex;
// Fallback: any legal land hex.
if (best === null) {
best = state.hexes.find((h) => !h.hasRobber && (!h.kind || h.kind === 'land'))?.id ?? state.robberHex;
}
return { hexId: best, targetSeat: bestTarget };
}
@ -114,7 +119,7 @@ export function chooseRobberMove(state, seat) {
export function choosePreRoll(state, seat) {
const p = state.players[seat];
if (p.playedDevThisTurn || !p.devCards.includes('knight')) return null;
const robberOnOurs = HEXES[state.robberHex].corners.some((n) => {
const robberOnOurs = geoFor(state).hexes[state.robberHex].corners.some((n) => {
const b = nodeBuilding(state, n);
return b && b.seat === seat;
});
@ -210,11 +215,12 @@ function canReachNewSpot(state, seat) {
function chooseExpansionRoad(state, seat) {
const edges = legalRoadEdges(state, seat, false);
if (!edges.length) return null;
const geo = geoFor(state);
// Build road adjacency and find connected components of the player's network.
const roadAdj = new Map();
for (const rid of state.players[seat].roads) {
const [ra, rb] = EDGES[rid].nodes;
const [ra, rb] = geo.edges[rid].nodes;
if (!roadAdj.has(ra)) roadAdj.set(ra, new Set());
if (!roadAdj.has(rb)) roadAdj.set(rb, new Set());
roadAdj.get(ra).add(rb);
@ -237,18 +243,18 @@ function chooseExpansionRoad(state, seat) {
let best = null, bestScore = -Infinity;
for (const eid of edges) {
const [a, b] = EDGES[eid].nodes;
const [a, b] = geo.edges[eid].nodes;
let score = 0;
for (const node of [a, b]) {
// Direct endpoint: full value if buildable.
if (!nodeBuilding(state, node) && !NODES[node].adj.some((x) => nodeBuilding(state, x))) {
if (!nodeBuilding(state, node) && !geo.nodes[node].adj.some((x) => nodeBuilding(state, x))) {
score += nodeValue(state, node);
}
// 1-hop lookahead: nodes one road-length further, half weight.
for (const adj of NODES[node].adj) {
for (const adj of geo.nodes[node].adj) {
if (adj === a || adj === b) continue;
if (!nodeBuilding(state, adj) && !NODES[adj].adj.some((x) => nodeBuilding(state, x))) {
if (!nodeBuilding(state, adj) && !geo.nodes[adj].adj.some((x) => nodeBuilding(state, x))) {
score += nodeValue(state, adj) * 0.5;
}
}
@ -393,7 +399,8 @@ function tradeWinsGame(state, seat, give, get) {
}
const afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
const ownVP = victoryPoints(state, seat);
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= WIN_VP) return true;
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= WIN_VP) return true;
const win = winVpFor(state);
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= win) return true;
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= win) return true;
return false;
}

View File

@ -11,7 +11,6 @@ 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];
@ -107,25 +106,43 @@ function hexCorners(cx, cy, size) {
return pts;
}
function buildGeometry(cx, cy, size) {
const hexes = [];
// Hex centres, rows of [3,4,5,4,3], each row horizontally centred.
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 };
}
// Hex centres for a board made of horizontally-centred rows (e.g. [3,4,5,4,3]).
// Hex ids are assigned left-to-right, top-to-bottom. Used by the base island and
// by Seafarers scenarios whose layouts are rectangular bands of hexes.
export function rowCenters(rows, cx = BOARD_CX, cy = BOARD_CY, size = HEX_SIZE) {
const hexW = SQRT3 * size;
const rowV = 1.5 * size;
const centers = [];
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 r = 0; r < rows.length; r++) {
const count = rows[r];
const rowY = cy + (r - (rows.length - 1) / 2) * rowV;
const startX = cx - ((count - 1) / 2) * hexW;
for (let c = 0; c < count; c++) {
hexes.push({ id: id++, cx: startX + c * HEX_W, cy: rowY, row: r, col: c });
centers.push({ id: id++, cx: startX + c * hexW, cy: rowY, row: r, col: c });
}
}
return centers;
}
// Build node/edge topology from a list of hex centres. Pure geometry; carries no
// resource/number/robber data (that lives in game state, keyed by hex id).
// `centers` must be in hex-id order. Optional `portCount`>0 auto-distributes that
// many generic port slots around the rim (the base island uses 9); scenarios pass
// 0 and attach their own ports via portsFromEdges().
function assemble(centers, size, cx, cy, portCount = 0) {
const hexes = centers.map((c) => ({ id: c.id, cx: c.cx, cy: c.cy, row: c.row, col: c.col }));
// 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);
const k = `${Math.round(p.x)}_${Math.round(p.y)}`;
let n = nodeMap.get(k);
if (!n) {
n = { id: nodes.length, x: Math.round(p.x), y: Math.round(p.y), hexes: [], adj: [] };
@ -155,9 +172,7 @@ function buildGeometry(cx, cy, size) {
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);
ensureEdge(corners[i].id, corners[(i + 1) % 6].id);
}
}
@ -169,45 +184,71 @@ function buildGeometry(cx, cy, size) {
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),
// O(1) edge lookup by node pair.
const edgeIndex = edgeMap;
let portSlots = [];
if (portCount > 0) {
// Coastal edges (touch exactly 1 hex), `portCount` 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);
});
for (let i = 0; i < portCount; i++) {
const e = coastal[Math.round((i * coastal.length) / portCount) % coastal.length];
portSlots.push(portSlot(nodes, e, cx, cy));
}
}
return { hexes, nodes, edges, portSlots };
return { hexes, nodes, edges, edgeIndex, portSlots, cx, cy, size };
}
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 };
// A port marker anchored on a coastal edge, pointing offshore from the centre.
function portSlot(nodes, edge, cx, cy) {
const m = midpoint(nodes, edge);
return {
edgeId: edge.id,
nodes: [...edge.nodes],
x: m.x, y: m.y,
angle: Math.atan2(m.y - cy, m.x - cx),
};
}
// Default geometry baked at module load — shared by every consumer.
export const GEOMETRY = buildGeometry(BOARD_CX, BOARD_CY, HEX_SIZE);
// Build explicit port slots for a scenario, given [{ edgeId, type }] entries.
// Returns slots carrying the resolved type so callers can drop them into state.ports.
export function portsFromEdges(geo, entries) {
return entries.map(({ edgeId, type }) => {
const edge = geo.edges[edgeId];
return { ...portSlot(geo.nodes, edge, geo.cx, geo.cy), type };
});
}
// Convenience accessors.
// Default island geometry baked at module load — shared by every consumer.
export const GEOMETRY = assemble(rowCenters(HEX_ROWS), HEX_SIZE, BOARD_CX, BOARD_CY, 9);
// Convenience accessors (the base-island geometry).
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) {
// Build an arbitrary board from a hex-centre list (Seafarers scenarios).
export function buildBoard({ centers, size = HEX_SIZE, cx = BOARD_CX, cy = BOARD_CY }) {
return assemble(centers, size, cx, cy, 0);
}
// ── Board registry ───────────────────────────────────────────────────────────
// Pure topology keyed by board id. Dynamic per-hex data (resource/number/kind/
// robber) lives in game state, not here, so geometry is never deep-cloned per
// action. The base island is always available under 'base'.
const BOARDS = { base: GEOMETRY };
export function registerBoard(id, geo) { BOARDS[id] = geo; return geo; }
export function getBoard(id) { return BOARDS[id] ?? GEOMETRY; }
// Edge id between two adjacent node ids within a given geometry, or -1.
export function edgeBetween(geo, 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);
const e = geo.edgeIndex.get(`${lo}_${hi}`);
return e ? e.id : -1;
}

View File

@ -8,8 +8,8 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP, HEX_SIZE, HEX_W,
RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE,
} from './CatanBoard.js';
import * as L from './CatanLogic.js';
import * as AI from './CatanAI.js';
@ -25,6 +25,8 @@ export default class CatanGame extends Phaser.Scene {
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
this.expansion = data.expansion ?? 'base';
this.scenario = data.scenario ?? null;
this.gs = null;
this.busy = false;
this.highlights = [];
@ -59,12 +61,15 @@ export default class CatanGame extends Phaser.Scene {
}
// ── coordinate helpers ──────────────────────────────────────────────────────
nodePos(id) { return { x: NODES[id].x, y: NODES[id].y }; }
// Active board geometry (base island, or the selected Seafarers scenario).
get geo() { return L.geoFor(this.gs); }
nodePos(id) { const n = this.geo.nodes[id]; return { x: n.x, y: n.y }; }
edgePos(id) {
const [a, b] = EDGES[id].nodes;
return { x: (NODES[a].x + NODES[b].x) / 2, y: (NODES[a].y + NODES[b].y) / 2 };
const [a, b] = this.geo.edges[id].nodes;
const N = this.geo.nodes;
return { x: (N[a].x + N[b].x) / 2, y: (N[a].y + N[b].y) / 2 };
}
hexPos(id) { return { x: HEXES[id].cx, y: HEXES[id].cy }; }
hexPos(id) { const h = this.geo.hexes[id]; return { x: h.cx, y: h.cy }; }
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
pname(seat) { return L.playerName(this.gs, seat); }
@ -130,41 +135,45 @@ export default class CatanGame extends Phaser.Scene {
const inset = (pts, cx, cy, s) =>
pts.map(p => ({ x: cx + (p.x - cx) * s, y: cy + (p.y - cy) * s }));
for (const hex of this.gs.hexes) {
const pts = HEXES[hex.id].corners.map((c) => ({ x: NODES[c].x, y: NODES[c].y }));
const { x, y } = this.hexPos(hex.id);
// Border insets/image size scale with the active board's hex size (the base
// island uses 92; larger Seafarers boards use a smaller hex).
const size = this.geo.size ?? HEX_SIZE;
const hexW = Math.sqrt(3) * size;
const inradius = size * Math.sqrt(3) / 2;
// Inradius ≈ 79.7; compute scale factors for 7px colored ring + 4px dark ring
const s1 = 1 - 7 / (HEX_SIZE * Math.sqrt(3) / 2); // after colored border
const s2 = 1 - 11 / (HEX_SIZE * Math.sqrt(3) / 2); // after dark border (image area)
for (const hex of this.gs.hexes) {
const pts = this.geo.hexes[hex.id].corners.map((c) => ({ x: this.geo.nodes[c].x, y: this.geo.nodes[c].y }));
const { x, y } = this.hexPos(hex.id);
const terr = this.hexTerrain(hex);
// Scale factors for the 7px colored ring + 4px dark ring (absolute pixels).
const s1 = 1 - 7 / inradius; // after colored border
const s2 = 1 - 11 / inradius; // after dark border (image area)
const innerPts = inset(pts, x, y, s1);
const imagePts = inset(pts, x, y, s2);
// Layer 1: resource swatch fill (outer colored border ring)
const swatch = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].swatch;
g.fillStyle(swatch, 0.55);
// Layer 1: terrain swatch fill (outer colored border ring)
g.fillStyle(terr.swatch, 0.55);
g.fillPoints(pts, true);
// Layer 2: dark fill inset (inner black border ring)
g.fillStyle(0x111111, 1);
g.fillPoints(innerPts, true);
// Layer 3: tile image masked to innermost polygon
if (this.textures.exists('catan-tiles')) {
const frames = CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11];
const frame = frames[Math.floor(Math.random() * 2)];
// Layer 3: tile image masked to innermost polygon (land/desert only)
if (terr.tileFrames && this.textures.exists('catan-tiles')) {
const frame = terr.tileFrames[Math.floor(Math.random() * 2)];
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillPoints(imagePts, true);
const img = this.add.image(x, y, 'catan-tiles', frame)
.setDisplaySize(HEX_W * s2, HEX_SIZE * 2 * s2)
.setDisplaySize(hexW * s2, size * 2 * s2)
.setMask(maskG.createGeometryMask())
.setDepth(D.board + 1);
this.hexImgs.push({ img, maskG });
} else {
// Fallback: resource color fill in the image area
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
g.fillStyle(color, 1);
// Fallback / water / gold / fog: solid color fill in the image area
g.fillStyle(terr.color, 1);
g.fillPoints(imagePts, true);
}
@ -172,14 +181,31 @@ export default class CatanGame extends Phaser.Scene {
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
this.hexBorderGfx.strokePoints(pts, true);
// Resource label
const label = hex.resource === 'desert' ? 'Desert' : RESOURCE_INFO[hex.resource].tile;
this.hexLabels.push(this.add.text(x, y - 56, label, {
// Terrain label
this.hexLabels.push(this.add.text(x, y - 56, terr.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
}
}
// Visual styling for a hex by terrain kind (Seafarers adds sea/gold/fog).
hexTerrain(hex) {
switch (hex.kind) {
case 'sea':
return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: null };
case 'gold':
return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: null };
case 'fog':
return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: null };
case 'desert':
return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert };
default: {
const info = RESOURCE_INFO[hex.resource];
return { swatch: info.swatch, color: info.color, label: info.tile, tileFrames: CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11] };
}
}
}
drawPorts() {
this.portObjs.forEach((o) => o.destroy());
this.portObjs = [];
@ -210,7 +236,7 @@ export default class CatanGame extends Phaser.Scene {
// little jetties to the two coastal nodes
const jg = this.add.graphics().setDepth(D.port - 1);
jg.lineStyle(3, 0x6b4a1a, 0.8);
for (const nid of port.nodes) jg.lineBetween(px, py, NODES[nid].x, NODES[nid].y);
for (const nid of port.nodes) jg.lineBetween(px, py, this.geo.nodes[nid].x, this.geo.nodes[nid].y);
this.portObjs.push(c, jg);
}
}
@ -979,25 +1005,56 @@ export default class CatanGame extends Phaser.Scene {
renderPieces() {
this.pieceObjs.forEach((o) => o.destroy());
this.pieceObjs = [];
const N = this.geo.nodes;
// roads
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const eid of p.roads) {
const [a, b] = EDGES[eid].nodes;
const [a, b] = this.geo.edges[eid].nodes;
const g = this.add.graphics().setDepth(D.road);
const ax = NODES[a].x, ay = NODES[a].y, bx = NODES[b].x, by = NODES[b].y;
const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y;
g.lineStyle(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(12, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
this.pieceObjs.push(g);
}
// ships (Seafarers): dashed maritime route in the player's colour
for (const eid of (p.ships ?? [])) {
const [a, b] = this.geo.edges[eid].nodes;
this.pieceObjs.push(this.makeShip(N[a].x, N[a].y, N[b].x, N[b].y, col));
}
}
// settlements + cities
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(NODES[nid].x, NODES[nid].y, col));
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(NODES[nid].x, NODES[nid].y, col));
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(N[nid].x, N[nid].y, col));
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(N[nid].x, N[nid].y, col));
}
// pirate (Seafarers): a sea-robber token on its hex
if (this.gs.pirateHex != null) {
const { x, y } = this.hexPos(this.gs.pirateHex);
const pg = this.add.graphics().setDepth(D.robber);
pg.fillStyle(0x000000, 0.45); pg.fillCircle(x + 2, y + 3, 20);
pg.fillStyle(0x1b1b1b, 1); pg.fillCircle(x, y, 18);
pg.lineStyle(3, 0xe8e4d8, 1); pg.strokeCircle(x, y, 18);
this.pieceObjs.push(pg);
this.pieceObjs.push(this.add.text(x, y, '☠', { fontSize: '22px', color: '#e8e4d8' }).setOrigin(0.5).setDepth(D.robber));
}
}
// A ship piece: a thick coloured bar along the sea edge with a sail nub.
makeShip(ax, ay, bx, by, col) {
const g = this.add.graphics().setDepth(D.road);
g.lineStyle(15, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(11, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by);
// sail at the midpoint
const mx = (ax + bx) / 2, my = (ay + by) / 2;
g.fillStyle(0xffffff, 0.95);
g.fillTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
g.lineStyle(2, col.hexDark, 1);
g.strokeTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
return g;
}
makeSettlement(x, y, col) {
@ -1497,7 +1554,11 @@ export default class CatanGame extends Phaser.Scene {
this.busy = false;
this.placeMode = null;
const playerCount = Math.min(4, 1 + this.opponents.length);
this.gs = L.createInitialState(playerCount, { tilePlacement: this.tilePlacement });
this.gs = L.createInitialState(playerCount, {
tilePlacement: this.tilePlacement,
expansion: this.expansion,
scenario: this.scenario,
});
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names);
this.drawHexes();

View File

@ -2,10 +2,22 @@
// Every action takes a state and returns a NEW state (deep-cloned first).
import {
NODES, EDGES, HEXES, PORT_SLOTS, edgeBetween,
getBoard, edgeBetween,
RESOURCE_BAG, STANDARD_RESOURCES, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
CHIT_SPIRAL, CHIT_SEQUENCE,
} from './CatanBoard.js';
import { getExpansion } from './expansions/index.js';
// Topology (nodes/edges/hexes/ports) for the board this state is played on.
// The base island lives under 'base'; Seafarers scenarios register their own.
export function geoFor(state) {
return getBoard(state.boardId ?? 'base');
}
// Victory-point target: scenarios may raise it (e.g. Seafarers New Shores = 13).
export function winVpFor(state) {
return state.winVP ?? WIN_VP;
}
// ── small utilities ─────────────────────────────────────────────────────────
function shuffle(arr) {
@ -42,13 +54,14 @@ export function edgeOwner(state, edgeId) {
}
// ── initial state ─────────────────────────────────────────────────────────────
export function createInitialState(playerCount = 3, { tilePlacement = 'random' } = {}) {
const n = Math.max(3, Math.min(4, playerCount));
// Resources onto hexes.
// Builds the standard 19-hex island (kind/resource/number/ports) — the base game
// board, factored out so a Seafarers scenario can supply its own board instead.
function buildBaseIsland(tilePlacement) {
const geo = getBoard('base');
const resources = tilePlacement === 'standard' ? [...STANDARD_RESOURCES] : shuffle(RESOURCE_BAG);
const hexes = HEXES.map((h, i) => ({
const hexes = geo.hexes.map((h, i) => ({
id: h.id,
kind: resources[i] === 'desert' ? 'desert' : 'land',
resource: resources[i],
number: null,
hasRobber: false,
@ -59,20 +72,29 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random' }
// Number chits: walk the standard spiral, skip desert, assign fixed sequence AR.
let chitIdx = 0;
for (const hexId of CHIT_SPIRAL) {
if (hexes[hexId].resource !== 'desert') {
hexes[hexId].number = CHIT_SEQUENCE[chitIdx++];
}
if (hexes[hexId].resource !== 'desert') hexes[hexId].number = CHIT_SEQUENCE[chitIdx++];
}
// Port types onto fixed slots.
const portTypes = shuffle(PORT_BAG);
const ports = PORT_SLOTS.map((slot, i) => ({
const ports = geo.portSlots.map((slot, i) => ({
edgeId: slot.edgeId,
nodes: [...slot.nodes],
type: portTypes[i],
x: slot.x, y: slot.y, angle: slot.angle,
}));
return { boardId: 'base', hexes, ports, robberHex: desertHex.id, pirateHex: null, homeIds: [], winVP: WIN_VP };
}
export function createInitialState(playerCount = 3, { tilePlacement = 'random', expansion = 'base', scenario = null } = {}) {
const n = Math.max(3, Math.min(4, playerCount));
const exp = getExpansion(expansion);
const sc = exp.scenarios?.[scenario] ?? null;
// Board: a Seafarers scenario builds its own; otherwise the standard island.
const board = sc ? sc.buildBoard() : buildBaseIsland(tilePlacement);
const players = [];
for (let seat = 0; seat < n; seat++) {
players.push({
@ -83,6 +105,7 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random' }
settlements: [],
cities: [],
roads: [],
ships: [], // Seafarers: edge ids carrying a ship (empty in base)
devCards: [], // playable now (bought on a previous turn)
newDevCards: [], // bought this turn, not yet playable
vpCards: 0, // hidden victory-point dev cards
@ -95,14 +118,20 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random' }
const seats = shuffle([...Array(n).keys()]);
const order = [...seats, ...[...seats].reverse()];
return {
const state = {
playerCount: n,
hexes,
ports,
expansion,
scenario,
boardId: board.boardId,
winVP: board.winVP ?? WIN_VP,
hexes: board.hexes,
ports: board.ports,
homeIds: board.homeIds ?? [], // Seafarers: home-island hex ids for bonus VP
players,
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
devDeck: shuffle(DEV_DECK),
robberHex: desertHex.id,
robberHex: board.robberHex,
pirateHex: board.pirateHex ?? null, // Seafarers: sea-robber position
phase: 'setup',
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
currentPlayer: order[0],
@ -111,11 +140,18 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random' }
robberReturnPhase: 'action',
discardQueue: [],
freeRoads: 0,
freeShips: 0, // Seafarers: free ships (e.g. from a future card)
longestRoad: { owner: null, length: 0 },
largestArmy: { owner: null, count: 0 },
winner: null,
log: [],
};
// Expansion + scenario setup rules (run once).
for (const rule of exp.setupRules ?? []) rule(state);
for (const rule of sc?.setupRules ?? []) rule(state);
return state;
}
function logEvent(state, msg) {
@ -125,42 +161,74 @@ function logEvent(state, msg) {
// ── legality helpers ──────────────────────────────────────────────────────────
export function legalSettlementNodes(state, seat, setup = false) {
const geo = geoFor(state);
const out = [];
for (const node of NODES) {
for (const node of geo.nodes) {
if (!nodeOnLand(state, node.id)) continue; // Seafarers: must touch land
if (nodeBuilding(state, node.id)) continue;
if (node.adj.some((a) => nodeBuilding(state, a))) continue; // distance rule
if (!setup) {
const touchesRoad = node.adj.some((a) => {
const eid = edgeBetween(node.id, a);
return eid >= 0 && state.players[seat].roads.includes(eid);
const touchesRoute = node.adj.some((a) => {
const eid = edgeBetween(geo, node.id, a);
return eid >= 0 && (state.players[seat].roads.includes(eid) || state.players[seat].ships.includes(eid));
});
if (!touchesRoad) continue;
if (!touchesRoute) continue;
}
out.push(node.id);
}
return out;
}
// A node is buildable only if at least one adjacent hex is land/gold (not all sea).
// On the base island every hex is land, so this is always true there.
export function nodeOnLand(state, nodeId) {
const geo = geoFor(state);
const hexes = geo.nodes[nodeId].hexes;
if (!hexes.length) return false;
return hexes.some((h) => {
const k = state.hexes[h]?.kind;
return k !== 'sea' && k !== 'fog';
});
}
export function legalCityNodes(state, seat) {
return [...state.players[seat].settlements];
}
function nodeConnectsForRoad(state, seat, nodeId, excludeEdgeId) {
const geo = geoFor(state);
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat) return false; // blocked by opponent building
if (bld && bld.seat === seat) return true; // own settlement/city
for (const adj of NODES[nodeId].adj) {
const eid = edgeBetween(nodeId, adj);
for (const adj of geo.nodes[nodeId].adj) {
const eid = edgeBetween(geo, nodeId, adj);
if (eid === excludeEdgeId || eid < 0) continue;
if (state.players[seat].roads.includes(eid)) return true;
}
return false;
}
// An edge carries a road or a ship belonging to any player.
function edgeOccupied(state, edgeId) {
for (const p of state.players) {
if (p.roads.includes(edgeId) || p.ships.includes(edgeId)) return true;
}
return false;
}
// Is an edge on land (both endpoints touch land) — roads go here.
function edgeIsLand(state, edgeId) {
const geo = geoFor(state);
const [a, b] = geo.edges[edgeId].nodes;
return nodeOnLand(state, a) && nodeOnLand(state, b);
}
export function legalRoadEdges(state, seat, setup = false, fromNode = null) {
const geo = geoFor(state);
const out = [];
for (const e of EDGES) {
if (edgeOwner(state, e.id) !== null) continue;
for (const e of geo.edges) {
if (edgeOccupied(state, e.id)) continue;
if (!edgeIsLand(state, e.id)) continue; // roads only on land edges
const [a, b] = e.nodes;
if (setup) {
if (a === fromNode || b === fromNode) out.push(e.id);
@ -194,8 +262,9 @@ export function placeSetupSettlement(state, seat, nodeId) {
s.setup.placing = 'road';
// Second round (idx >= playerCount): grant resources from adjacent hexes.
if (s.setup.idx >= s.playerCount) {
for (const hx of NODES[nodeId].hexes) {
for (const hx of geoFor(s).nodes[nodeId].hexes) {
const hex = s.hexes[hx];
if (hex.kind && hex.kind !== 'land') continue; // skip sea/gold/fog/desert
if (hex.resource === 'desert') continue;
if (s.bank[hex.resource] > 0) { s.players[seat].resources[hex.resource]++; s.bank[hex.resource]--; }
}
@ -243,9 +312,12 @@ export function rollDice(state) {
}
function produceResources(s, total) {
const geo = geoFor(s);
for (const hex of s.hexes) {
if (hex.number !== total || hex.hasRobber) continue;
const corners = HEXES[hex.id].corners;
if (hex.kind && hex.kind !== 'land') continue; // sea/gold/fog handled elsewhere
if (!RESOURCE_TYPES.includes(hex.resource)) continue;
const corners = geo.hexes[hex.id].corners;
for (const nodeId of corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
@ -254,6 +326,8 @@ function produceResources(s, total) {
if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; }
}
}
// Seafarers gold hexes (and any other expansion production) resolve here.
getExpansion(s.expansion).onProduce?.(s, total);
}
// ── discard / robber ───────────────────────────────────────────────────────────
@ -284,7 +358,7 @@ export function applyDiscard(state, seat, discard) {
// Seats with a building on the given hex (excluding `seat`) that have cards.
export function stealTargets(state, hexId, seat) {
const targets = new Set();
for (const nodeId of HEXES[hexId].corners) {
for (const nodeId of geoFor(state).hexes[hexId].corners) {
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat && handSize(state.players[bld.seat]) > 0) targets.add(bld.seat);
}
@ -329,6 +403,65 @@ export function buildRoad(state, seat, edgeId) {
return s;
}
// ── ships (Seafarers) ──────────────────────────────────────────────────────────
// Ship cost is supplied by the active expansion; null in the base game (no ships).
export function shipCost(state) {
return getExpansion(state.expansion).costs?.ship ?? null;
}
// An edge borders open water (sea or unexplored fog) — ships are built here.
function edgeIsSea(state, edgeId) {
const geo = geoFor(state);
return geo.edges[edgeId].hexes.some((h) => {
const k = state.hexes[h]?.kind;
return k === 'sea' || k === 'fog';
});
}
// A ship edge connects to the player's network: a coastal settlement/city of
// theirs, or an existing ship of theirs (a continuous route, not blocked by an
// opponent's building at the joining node).
function shipConnects(state, seat, edge) {
const geo = geoFor(state);
for (const node of edge.nodes) {
const bld = nodeBuilding(state, node);
if (bld && bld.seat !== seat) continue; // opponent building blocks the route
if (bld && bld.seat === seat) return true; // own coastal building
for (const adj of geo.nodes[node].adj) {
const eid = edgeBetween(geo, node, adj);
if (eid >= 0 && state.players[seat].ships.includes(eid)) return true;
}
}
return false;
}
export function legalShipEdges(state, seat) {
if (!shipCost(state)) return [];
const geo = geoFor(state);
const out = [];
for (const e of geo.edges) {
if (edgeOccupied(state, e.id)) continue;
if (!edgeIsSea(state, e.id)) continue;
if (state.pirateHex != null && e.hexes.includes(state.pirateHex)) continue; // pirate blocks
if (shipConnects(state, seat, e)) out.push(e.id);
}
return out;
}
export function buildShip(state, seat, edgeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (!legalShipEdges(s, seat).includes(edgeId)) return s;
const cost = shipCost(s);
const free = s.freeShips > 0;
if (!free && !canAfford(s.players[seat], cost)) return s;
if (free) s.freeShips--; else pay(s, s.players[seat], cost);
s.players[seat].ships.push(edgeId);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
}
export function buildSettlement(state, seat, nodeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
@ -498,11 +631,13 @@ export function endTurn(state) {
// ── longest road / largest army / victory ──────────────────────────────────────
export function longestRoadFor(state, seat) {
const roads = state.players[seat].roads;
const geo = geoFor(state);
// Seafarers: a trade route is roads + ships combined; base players have no ships.
const roads = [...state.players[seat].roads, ...state.players[seat].ships];
if (roads.length === 0) return 0;
const incident = new Map();
for (const eid of roads) {
for (const node of EDGES[eid].nodes) {
for (const node of geo.edges[eid].nodes) {
if (!incident.has(node)) incident.set(node, []);
incident.get(node).push(eid);
}
@ -517,7 +652,7 @@ export function longestRoadFor(state, seat) {
if (len > 0 && blocked(node)) return;
for (const eid of incident.get(node) || []) {
if (used.has(eid)) continue;
const [a, b] = EDGES[eid].nodes;
const [a, b] = geo.edges[eid].nodes;
const next = a === node ? b : a;
used.add(eid);
dfs(next, used, len + 1);
@ -557,12 +692,17 @@ export function recomputeLargestArmy(state) {
state.largestArmy = { owner, count: maxC };
}
// Seafarers new-island (and any other expansion) bonus VP. 0 in the base game.
function bonusVP(state, seat) {
return getExpansion(state.expansion).scoring?.bonusVP?.(state, seat) ?? 0;
}
export function victoryPoints(state, seat) {
const p = state.players[seat];
let vp = p.settlements.length + p.cities.length * 2 + p.vpCards;
if (state.longestRoad.owner === seat) vp += 2;
if (state.largestArmy.owner === seat) vp += 2;
return vp;
return vp + bonusVP(state, seat);
}
// Public VP excludes hidden VP dev cards (for opponent display).
@ -571,11 +711,11 @@ export function publicVictoryPoints(state, seat) {
let vp = p.settlements.length + p.cities.length * 2;
if (state.longestRoad.owner === seat) vp += 2;
if (state.largestArmy.owner === seat) vp += 2;
return vp;
return vp + bonusVP(state, seat);
}
function checkWin(state, seat) {
if (victoryPoints(state, seat) >= WIN_VP) {
if (victoryPoints(state, seat) >= winVpFor(state)) {
state.winner = seat;
state.phase = 'gameOver';
logEvent(state, `${playerName(state, seat)} wins!`);

View File

@ -0,0 +1,45 @@
// Catan — expansion registry.
//
// Mirrors the Dominion expansion framework (public/src/games/dominion/expansions):
// the base game's rules live in CatanLogic's built-in functions; an expansion is a
// hook object the engine consults via getExpansion(state.expansion) ONLY when a
// non-base expansion is active, so base play is untouched. Unlike Dominion,
// Seafarers also changes the board, so an expansion may carry `scenarios` whose
// buildBoard() produces the topology and per-hex assignments at game start.
//
// An expansion may expose any of:
// costs extra build costs (e.g. { ship: { brick:1, lumber:1 } })
// scenarios { id: scenarioObj } selectable layouts (see seafarers.js)
// setupRules [ (state) => void ] applied once after base setup
// onProduce (state, total) => void extra production (e.g. gold hexes)
// legality { legalShipEdges, ... } expansion-piece placement rules
// actions { buildShip, moveShip } expansion build actions
// robber pirate (sea-robber) hooks
// reveal (state, hexId, seat) => void fog-tile exploration
// scoring { bonusVP } extra victory points (e.g. new-island bonus)
// ai { chooseShip, choosePirate, chooseGoldPick, chooseReveal }
import { seafarers } from './seafarers.js';
const BASE = {
id: 'base',
name: 'Base Game',
scenarios: null,
};
export const EXPANSIONS = {
base: BASE,
seafarers,
};
// Order shown in the setup screen.
export const EXPANSION_ORDER = ['base', 'seafarers'];
export function getExpansion(id) {
return EXPANSIONS[id] ?? BASE;
}
// The scenario object for a given expansion/scenario id pair, or null.
export function getScenario(expansionId, scenarioId) {
return getExpansion(expansionId).scenarios?.[scenarioId] ?? null;
}

View File

@ -0,0 +1,55 @@
// Seafarers — "The Fog Island".
//
// A home island on the left side (10 hexes, fully visible) separated from a fog
// island on the right side (10 hexes, hidden until ships reach them) by a sea
// channel of 1-3 hexes. Two gold hexes are hidden in the fog.
//
// Home island: ids 4,5,9,10,15,16,22,23,28,29
// Fog island: ids 7,8,13,14,20,21,26,27,31,32
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
const F = (reveal) => ({ kind: 'fog', reveal });
// rows: [4,5,6,7,6,5,4] = 37 hexes
// row0: 0 1 2 3
// row1: 4 5 6 7 8
// row2: 9 10 11 12 13 14
// row3: 15 16 17 18 19 20 21
// row4: 22 23 24 25 26 27
// row5: 28 29 30 31 32
// row6: 33 34 35 36
const CELLS = [
S, S, S, S, // row0: 0- 3
L('lumber',9), L('ore',6), S, F(L('wool',5)), F(G(12)), // row1: 4- 8
L('brick',6), L('grain',8), S, S, F(L('grain',9)), F(L('lumber',3)), // row2: 9-14
L('wool',3), L('lumber',11), S, S, S, F(L('brick',11)), F(L('ore',8)), // row3: 15-21
D, L('grain',4), S, S, F(G(10)), F(L('grain',4)), // row4: 22-27
L('wool',10), L('ore',2), S, F(L('ore',5)), F(L('wool',12)), // row5: 28-32
S, S, S, S, // row6: 33-36
];
const HOME_ISLAND = [4,5, 9,10, 15,16, 22,23, 28,29];
export const fogIsland = {
id: 'fog-island',
name: 'The Fog Island',
winVP: 12,
newIslandBonus: 2,
homeIslandHexIds: HOME_ISLAND,
buildBoard() {
return assembleBoard({
id: 'seafarers:fog-island',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: HOME_ISLAND,
pirate: 18,
winVP: 12,
size: 65,
});
},
};

View File

@ -0,0 +1,47 @@
// Seafarers — "The Four Islands".
//
// Four separated, internally-connected island clusters in the four quadrants of
// a 37-hex [4,5,6,7,6,5,4] grid. No single home island — all islands are equal
// targets. First settlement on each new island earns 2 bonus VP.
//
// Island A (NW): ids 0,1,4,5,9
// Island B (NE): ids 3,7,8,13,14
// Island C (SW): ids 22,23,28,29,33
// Island D (SE): ids 26,27,31,32,36
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
// rows: [4,5,6,7,6,5,4] = 37 hexes
const CELLS = [
L('lumber',6), L('wool',3), S, L('ore',4), // row0: 0- 3
L('lumber',9), L('brick',5), S, L('wool',8), L('grain',10), // row1: 4- 8
L('grain',11), S, S, S, L('brick',12), G(5), // row2: 9-14
S, S, S, S, S, S, S, // row3: 15-21
S, L('grain',3), L('lumber',9), S, L('ore',10), L('lumber',2), // row4: 22-27
L('brick',11), L('wool',4), S, L('ore',6), G(2), // row5: 28-32
L('grain',8), S, S, D, // row6: 33-36
];
export const fourIslands = {
id: 'four-islands',
name: 'The Four Islands',
winVP: 12,
newIslandBonus: 2,
homeIslandHexIds: [],
buildBoard() {
return assembleBoard({
id: 'seafarers:four-islands',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: [],
pirate: 18,
winVP: 12,
size: 65,
});
},
};

View File

@ -0,0 +1,60 @@
// Seafarers — "Heading for New Shores".
//
// The home island is the full standard 19-hex Catan layout (resources and chits
// from STANDARD_RESOURCES + CHIT_SPIRAL/CHIT_SEQUENCE), centered in a 37-hex
// [4,5,6,7,6,5,4] grid. Outer islands: two gold hexes at the top corners, three
// small resource islands on the flanks.
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
// rows: [4,5,6,7,6,5,4] = 37 hexes
// row0: 0 1 2 3
// row1: 4 5 6 7 8
// row2: 9 10 11 12 13 14
// row3: 15 16 17 18 19 20 21
// row4: 22 23 24 25 26 27
// row5: 28 29 30 31 32
// row6: 33 34 35 36
//
// Home island (19 hexes) = standard Catan layout centered in rows 15.
// Standard hex → grid id: each row is offset +1 col into the wider grid row.
// std row0 [3] → grid row1 cols 1-3 → ids 5,6,7
// std row1 [4] → grid row2 cols 1-4 → ids 10,11,12,13
// std row2 [5] → grid row3 cols 1-5 → ids 16,17,18,19,20
// std row3 [4] → grid row4 cols 1-4 → ids 23,24,25,26
// std row4 [3] → grid row5 cols 1-3 → ids 29,30,31
const CELLS = [
G(4), S, S, G(10), // row0: 0- 3
S, L('ore',5), L('wool',2), L('lumber',6), S, // row1: 4- 8
L('ore',9), L('grain',10), L('brick',9), L('wool',4), L('brick',3), L('lumber',8), // row2: 9-14
S, L('lumber',8), L('grain',11), D, L('grain',5), L('ore',8), S, // row3: 15-21
S, L('lumber',4), L('ore',3), L('grain',6), L('wool',10), L('wool',5), // row4: 22-27
S, L('brick',11), L('wool',12), L('lumber',9), S, // row5: 28-32
S, S, S, S, // row6: 33-36
];
const HOME_ISLAND = [5,6,7, 10,11,12,13, 16,17,18,19,20, 23,24,25,26, 29,30,31];
export const newShores = {
id: 'new-shores',
name: 'Heading for New Shores',
winVP: 13,
newIslandBonus: 2,
homeIslandHexIds: HOME_ISLAND,
buildBoard() {
return assembleBoard({
id: 'seafarers:new-shores',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: HOME_ISLAND,
pirate: 1,
winVP: 13,
size: 65,
});
},
};

View File

@ -0,0 +1,61 @@
// Seafarers — "Oceania" (random sea board).
//
// A fixed set of land positions ringed by sea, but the resources, numbers, and
// the two gold hexes are shuffled fresh every game for replayability.
//
// NOTE: digital adaptation — the land footprint is fixed so every board stays
// fully connected and settleable; only the tiles on it are randomized.
import { assembleBoard, shuffle } from './shared.js';
// Land hex ids on the [4,5,6,5,4] grid — a central island plus outer spurs.
// Three coherent island clusters: NW (0,4,5,9), NE (3,7,8,13,14), S (20,21,22,23).
const LAND_POS = [0, 3, 4, 5, 7, 8, 9, 13, 14, 20, 21, 22, 23];
// Resource mix for the producing hexes (one fewer than LAND_POS to leave a desert
// slot; two of these become gold). Sized to LAND_POS.length.
const RESOURCE_MIX = [
'brick', 'brick', 'lumber', 'lumber', 'lumber', 'wool', 'wool',
'grain', 'grain', 'grain', 'ore', 'ore', 'ore',
];
const NUMBERS = [2, 3, 3, 4, 4, 5, 5, 6, 8, 9, 9, 10, 11];
export const oceania = {
id: 'oceania',
name: 'Oceania (Random)',
winVP: 13,
newIslandBonus: 2,
homeIslandHexIds: [],
buildBoard() {
const cells = Array.from({ length: 24 }, () => ({ kind: 'sea' }));
const land = shuffle(LAND_POS);
const desertId = land[0];
const goldIds = new Set([land[1], land[2]]);
const producing = land.slice(3);
const res = shuffle(RESOURCE_MIX);
const nums = shuffle(NUMBERS);
cells[desertId] = { kind: 'desert' };
let ni = 0;
for (const id of goldIds) cells[id] = { kind: 'gold', number: nums[ni++] };
for (const id of producing) {
cells[id] = { kind: 'land', resource: res[ni % res.length], number: nums[ni % nums.length] };
ni++;
}
// Pirate starts on a random sea hex.
const seaIds = cells.map((c, i) => (c.kind === 'sea' ? i : -1)).filter((i) => i >= 0);
const pirate = seaIds.length ? shuffle(seaIds)[0] : null;
return assembleBoard({
id: 'seafarers:oceania',
rows: [4, 5, 6, 5, 4],
cells,
homeIds: [],
pirate,
winVP: 13,
});
},
};

View File

@ -0,0 +1,81 @@
// Shared helpers for Seafarers scenario boards.
//
// A scenario describes its board as a list of `cells` (one per hex, in hex-id
// order) plus a row layout. assembleBoard() turns that into registered topology
// + the per-hex state data CatanLogic expects. No Phaser here.
import {
rowCenters, buildBoard, registerBoard, portsFromEdges,
HEX_SIZE, BOARD_CX, BOARD_CY, PORT_BAG,
} from '../../CatanBoard.js';
export const SHIP_COST = { brick: 1, lumber: 1 };
// Larger Seafarers boards use a slightly smaller hex so they fit the canvas.
export const SEA_HEX_SIZE = 74;
export function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// A cell: { kind: 'land'|'sea'|'gold'|'fog'|'desert', resource?, number? }.
// Distribute generic + 2:1 ports onto coastal land edges, spaced around the rim.
function autoPorts(geo, hexes, cx, cy) {
const isLand = (hid) => {
const k = hexes[hid]?.kind;
return k === 'land' || k === 'gold' || k === 'desert';
};
// Shore edges: touch exactly one land hex (the other side is water or
// off-board), so a settlement on that shore could use the port.
const ring = geo.edges.filter((e) => e.hexes.filter(isLand).length === 1);
ring.sort((p, q) => {
const pm = midOf(geo, p), qm = midOf(geo, q);
return Math.atan2(pm.y - cy, pm.x - cx) - Math.atan2(qm.y - cy, qm.x - cx);
});
const bag = shuffle(PORT_BAG);
const n = Math.min(bag.length, ring.length);
const entries = [];
const used = new Set();
for (let i = 0; i < n && ring.length; i++) {
let idx = Math.round((i * ring.length) / n) % ring.length;
while (used.has(idx)) idx = (idx + 1) % ring.length;
used.add(idx);
entries.push({ edgeId: ring[idx].id, type: bag[i] });
}
return portsFromEdges(geo, entries);
}
function midOf(geo, edge) {
const a = geo.nodes[edge.nodes[0]], b = geo.nodes[edge.nodes[1]];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
// Build a scenario board from cells. Returns the board payload createInitialState
// merges into game state. `pirate` is a sea hex id (or null).
export function assembleBoard({ id, rows, cells, homeIds = [], pirate = null, winVP = 13, size = SEA_HEX_SIZE, cx = BOARD_CX, cy = BOARD_CY }) {
const centers = rowCenters(rows, cx, cy, size);
const geo = buildBoard({ centers, size, cx, cy });
registerBoard(id, geo);
const hexes = cells.map((c, i) => ({
id: i,
kind: c.kind,
resource: c.resource ?? null,
number: c.number ?? null,
hasRobber: false,
}));
// Robber starts on the (first) desert, if any.
const desert = hexes.find((h) => h.kind === 'desert');
let robberHex = desert ? desert.id : null;
if (desert) desert.hasRobber = true;
const ports = autoPorts(geo, hexes, cx, cy);
return { boardId: id, hexes, ports, robberHex, pirateHex: pirate, homeIds: [...homeIds], winVP };
}

View File

@ -0,0 +1,31 @@
// Seafarers expansion for Catan.
//
// Pure data + functions, no Phaser. The engine (CatanLogic/CatanAI) calls these
// hooks only while this expansion is the active one. Board layouts live in
// ./scenarios/*; this module collects them and declares the shared ship rules,
// gold/pirate/fog mechanics, and AI hooks.
import { SHIP_COST } from './scenarios/shared.js';
import { newShores } from './scenarios/new-shores.js';
import { fourIslands } from './scenarios/four-islands.js';
import { oceania } from './scenarios/oceania.js';
import { fogIsland } from './scenarios/fog-island.js';
const SCENARIOS = {
'new-shores': newShores,
'four-islands': fourIslands,
'oceania': oceania,
'fog-island': fogIsland,
};
// Order shown in the setup screen.
export const SCENARIO_ORDER = ['new-shores', 'four-islands', 'oceania', 'fog-island'];
export const seafarers = {
id: 'seafarers',
name: 'Seafarers',
sheet: 'catan-seafarers', // optional art; procedural fallback
costs: { ship: SHIP_COST },
scenarios: SCENARIOS,
scenarioOrder: SCENARIO_ORDER,
};

View File

@ -14,6 +14,7 @@ export default class GameRoomScene extends Phaser.Scene {
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
this.expansion = data.expansion ?? 'base';
this.scenario = data.scenario ?? null;
this.deckMode = data.deckMode ?? 'standard';
this.wordLength = data.wordLength ?? 4;
}
@ -28,6 +29,7 @@ export default class GameRoomScene extends Phaser.Scene {
cardBack: this.cardBack,
tilePlacement: this.tilePlacement,
expansion: this.expansion,
scenario: this.scenario,
deckMode: this.deckMode,
wordLength: this.wordLength,
});

View File

@ -34,6 +34,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4;
this.selectedExpansion = 'base';
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this._initializing = false;
@ -117,7 +118,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (!this._startingGame) setMenuMusicVolume(0.6);
});
if (isCatan) this.buildTilePlacementSection(340, 1013);
if (isCatan) this.buildCatanExpansionSection(340, 1013);
if (isGoFish) this.buildMatchVariantSection(340, 1013);
@ -495,7 +496,115 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.startBtn.setEnabled(this.selected.size >= min);
}
// ── Catan: tile placement toggle ───────────────────────────────────────────
// ── Catan: Expansion + (Scenario | Tile placement) selection ───────────────
static CATAN_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'seafarers', label: 'Seafarers' },
];
// Scenario ids must match the keys in games/catan/expansions/seafarers.js.
static CATAN_SCENARIOS = [
{ id: 'new-shores', label: 'New Shores' },
{ id: 'four-islands', label: 'Four Islands' },
{ id: 'oceania', label: 'Oceania' },
{ id: 'fog-island', label: 'Fog Island' },
];
buildCatanExpansionSection(centerX, centerY) {
const C = OpponentSelectScene;
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._catanSubLabelY = centerY - 14;
this._catanSubRow0Y = centerY + 12;
this._catanSubRow1Y = centerY + 46;
this._catanCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker (Base / Seafarers).
mkLabel(expLabelY, 'Expansion');
const pillW = 150, pillH = 36, gap = 14;
const rowW = C.CATAN_EXPANSIONS.length * pillW + (C.CATAN_EXPANSIONS.length - 1) * gap;
this._catanExpBtns = [];
C.CATAN_EXPANSIONS.forEach((opt, i) => {
const x = centerX - rowW / 2 + i * (pillW + gap) + pillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
this._catanExpBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderCatanSubsection();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanExpBtns.push({ bg, id: opt.id });
});
this._catanSubObjs = [];
this.renderCatanSubsection();
}
// Base → Tile Placement pills; Seafarers → Scenario pills.
renderCatanSubsection() {
const C = OpponentSelectScene;
(this._catanSubObjs ?? []).forEach((o) => o.destroy());
this._catanSubObjs = [];
const centerX = this._catanCenterX;
const seafarers = this.selectedExpansion === 'seafarers';
const t = this.add.text(centerX, this._catanSubLabelY, seafarers ? 'Scenario' : 'Tile Placement', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const lbg = this.add.rectangle(centerX, this._catanSubLabelY, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(lbg, t);
this._catanSubObjs.push(t, lbg);
const options = seafarers
? C.CATAN_SCENARIOS
: [{ id: 'random', label: 'Random' }, { id: 'standard', label: 'Standard' }];
const selKey = () => (seafarers ? this.selectedScenario : this.selectedTilePlacement);
const setSel = (id) => { if (seafarers) this.selectedScenario = id; else this.selectedTilePlacement = id; };
const pillW = 150, pillH = 34, gap = 12, cols = 2;
const rowW = cols * pillW + (cols - 1) * gap;
this._catanSubBtns = [];
options.forEach((opt, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + gap) + pillW / 2;
const y = row === 0 ? this._catanSubRow0Y : this._catanSubRow1Y;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, selKey() === opt.id ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._catanSubBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === selKey() ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { setSel(opt.id); refresh(); });
bg.on('pointerover', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanSubBtns.push({ bg, id: opt.id });
this._catanSubObjs.push(bg, pillBg, label);
});
}
// ── Catan: tile placement toggle (legacy; superseded by the expansion picker)
buildTilePlacementSection(centerX, centerY) {
const options = [
{ id: 'random', label: 'Random' },
@ -873,6 +982,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant,
expansion: this.selectedExpansion,
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
? this.selectedScenario : null,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
});