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:
parent
2c95e76b00
commit
c5f34b7c28
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 A–R.
|
||||
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!`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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 1–5.
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue