Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 9dbf3feae4 feat(catan): implement Seafarers expansion features (gold hexes, ships, fog)
- Add gold hex resource picking phase with AI support
- Implement ship building mechanics and update build costs UI
- Introduce fog tiles that reveal terrain when roads/ships are built adjacent
- Replace robber token with pirate ship graphic
- Persist random tile frames for consistent hex visuals
- Update game state machine to handle gold pick queue and phase transitions
- Adjust UI layout (card positions, build panel) to accommodate Seafarers elements
2026-05-29 16:13:53 -06:00
Brian Fertig c5f34b7c28 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.
2026-05-29 15:06:20 -06:00
17 changed files with 1127 additions and 256 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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; }
@ -85,14 +86,25 @@ export function chooseDiscard(state, seat) {
return discard;
}
// Pick `amount` free resources from a gold hex — prioritise what is under-produced.
export function chooseGoldPick(state, seat, amount) {
const prod = productionByResource(state, seat);
const order = [...RESOURCE_TYPES].sort((a, b) => prod[a] - prod[b]);
const picks = [];
for (let i = 0; i < amount; i++) picks.push(order[i % order.length]);
return picks;
}
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 +117,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 +128,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 +224,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 +252,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 +408,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); }
@ -86,9 +91,9 @@ export default class CatanGame extends Phaser.Scene {
// Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80)
const ro = 0x07, go = 0x24, bo = 0x3a;
const ri = 0x1a, gi = 0x5e, bi = 0x80;
const red = Math.round(ro + (ri - ro) * (1 - t));
const red = Math.round(ro + (ri - ro) * (1 - t));
const green = Math.round(go + (gi - go) * (1 - t));
const blue = Math.round(bo + (bi - bo) * (1 - t));
const blue = Math.round(bo + (bi - bo) * (1 - t));
const color = (red << 16) | (green << 8) | blue;
sea.fillStyle(color, 1);
sea.fillCircle(SEA_X, SEA_Y, r);
@ -108,12 +113,15 @@ export default class CatanGame extends Phaser.Scene {
// Frame pairs per resource: pick one at random each draw.
static TILE_FRAMES = {
lumber: [0, 1],
wool: [2, 3],
brick: [4, 5],
ore: [6, 7],
grain: [8, 9],
desert: [10, 11],
lumber: [0, 1],
wool: [2, 3],
brick: [4, 5],
ore: [6, 7],
grain: [8, 9],
desert: [10, 11],
sea: [12, 13],
gold: [14, 15],
fog: [16, 17],
};
drawHexes() {
@ -130,41 +138,48 @@ 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')) {
if (this.hexTileFrames[hex.id] == null) {
this.hexTileFrames[hex.id] = terr.tileFrames[Math.floor(Math.random() * 2)];
}
const frame = this.hexTileFrames[hex.id];
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 +187,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: CatanGame.TILE_FRAMES.sea };
case 'gold':
return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: CatanGame.TILE_FRAMES.gold };
case 'fog':
return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: CatanGame.TILE_FRAMES.fog };
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 +242,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);
}
}
@ -281,11 +313,11 @@ export default class CatanGame extends Phaser.Scene {
return new Promise((resolve) => {
playSound(this, SFX.DICE_ROLL);
const landX = [1256, 1324];
const landY = 950;
const landX = [1256, 1324];
const landY = 950;
const startX = GAME_WIDTH / 2; // 960 — center of bottom bar
const startY = 1015;
const arcY = 755; // arc peak
const arcY = 755; // arc peak
// Move dice to throw origin, small, random angle
this.diceContainers.forEach((c, i) => {
@ -308,17 +340,19 @@ export default class CatanGame extends Phaser.Scene {
let settled = 0;
this.diceContainers.forEach((c, i) => {
const lx = landX[i] + (Math.random() * 8 - 4);
const ly = landY + (Math.random() * 8 - 4);
const outMs = 295 + i * 32;
const ly = landY + (Math.random() * 8 - 4);
const outMs = 295 + i * 32;
const backMs = 430 + i * 44;
const totalMs = outMs + backMs;
// X flies straight to landing; Y arcs up then bounces down
this.tweens.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' });
this.tweens.chain({ targets: c, tweens: [
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
]});
this.tweens.chain({
targets: c, tweens: [
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
]
});
// Scale up as die approaches
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
// Spin
@ -342,7 +376,7 @@ export default class CatanGame extends Phaser.Scene {
this.diceContainers.forEach((dc) =>
this.tweens.add({ targets: dc, scaleX: 1.14, scaleY: 0.88, duration: 80, yoyo: true })
);
const numberWords = ['','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve'];
const numberWords = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
enqueueSpeech(`numbers-${numberWords[values[0] + values[1]]}`);
this.time.delayedCall(160, resolve);
}
@ -395,6 +429,7 @@ export default class CatanGame extends Phaser.Scene {
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 200, height: 46, fontSize: 19 }).setDepth(D.hud); this.buttons[key] = b; by += step; return b; };
mk('roll', 'Roll Dice', () => this.onRoll());
mk('road', 'Build Road', () => this.enterPlace('road'));
if (this.expansion === 'seafarers') mk('ship', 'Build Ship', () => this.enterPlace('ship'));
mk('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
mk('city', 'Build City', () => this.enterPlace('city'));
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
@ -425,8 +460,11 @@ export default class CatanGame extends Phaser.Scene {
return c;
};
const cardY = this.expansion === 'seafarers' ? 830 : 760;
this._lrCard = makeCard(1775, 0, 0xdaa520);
this._laCard = makeCard(1855, 1, 0xb03030);
this._lrCard.setY(cardY);
this._laCard.setY(cardY);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
@ -454,9 +492,10 @@ export default class CatanGame extends Phaser.Scene {
_getSpecialCardPos(cardType, owner) {
if (owner === null) {
const cardY = this.expansion === 'seafarers' ? 830 : 760;
return cardType === 'longestRoad'
? { x: 1775, y: 760, scale: 1 }
: { x: 1855, y: 760, scale: 1 };
? { x: 1775, y: cardY, scale: 1 }
: { x: 1855, y: cardY, scale: 1 };
}
if (owner === 0) {
return cardType === 'longestRoad'
@ -492,23 +531,26 @@ export default class CatanGame extends Phaser.Scene {
_animateSpecialCardTransfer(cardType, fromPos, newOwner) {
const toPos = this._getSpecialCardPos(cardType, newOwner);
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale);
this._specialCardAnimating[cardType] = true;
const peakY = Math.min(fromPos.y, toPos.y) - 150;
const midX = (fromPos.x + toPos.x) / 2;
const peakY = Math.min(fromPos.y, toPos.y) - 150;
const midX = (fromPos.x + toPos.x) / 2;
const midScale = (fromPos.scale + toPos.scale) / 2;
const half = 380;
const half = 380;
this.tweens.chain({ targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{ x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; },
},
]});
this.tweens.chain({
targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{
x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; },
},
]
});
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
}
@ -569,20 +611,20 @@ export default class CatanGame extends Phaser.Scene {
}).setOrigin(0.5);
this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
.setDepth(D.panel + 5);
this._devTooltip.gfx = g;
this._devTooltip.gfx = g;
this._devTooltip.titleTxt = titleTxt;
this._devTooltip.descTxt = descTxt;
this._devTooltip.popW = popW;
this._devTooltip.popH = popH;
this._devTooltip.popR = popR;
this._devTooltip.descTxt = descTxt;
this._devTooltip.popW = popW;
this._devTooltip.popH = popH;
this._devTooltip.popR = popR;
}
showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) {
const DEV_DESC = {
knight: 'Move the Robber to any tile and steal a resource from a player there.',
knight: 'Move the Robber to any tile and steal a resource from a player there.',
roadBuilding: 'Place 2 roads anywhere you could legally build them, for free.',
vp: 'Worth 1 Victory Point. Kept hidden until you reach 10 VP and win.',
monopoly: 'Name a resource. Every other player gives you all of that resource.',
vp: 'Worth 1 Victory Point. Kept hidden until you reach 10 VP and win.',
monopoly: 'Name a resource. Every other player gives you all of that resource.',
yearOfPlenty: 'Take any 2 resources of your choice directly from the bank.',
};
const tt = this._devTooltip;
@ -650,13 +692,6 @@ export default class CatanGame extends Phaser.Scene {
const panelRight = 1900;
const panelW = 320;
const cx = panelRight - panelW / 2;
const bgCy = 980, bgH = 164;
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0));
const rows = [
{ name: 'Road', resources: ['brick', 'lumber'] },
@ -664,12 +699,27 @@ export default class CatanGame extends Phaser.Scene {
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
];
if (this.expansion === 'seafarers') {
rows.push({ name: 'Ship', resources: ['lumber', 'wool'] });
}
// Base: original fixed values. Seafarers: taller panel to fit 5th row, still inside bar (y 893–1080).
const seafarers = this.expansion === 'seafarers';
const bgH = seafarers ? 180 : 164;
const bgCy = seafarers ? 987 : 980;
const rowPad = 44;
const rowStep = (bgH - rowPad - 14) / (rows.length - 1);
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0));
const lx = panelRight - panelW + 14;
const rx = panelRight - 14;
const rowY0 = bgCy - bgH / 2 + 44;
const rowStep = (bgH - 44 - 14) / (rows.length - 1);
const SW = 16, SH = 16, SG = 4, SR = 3; // swatch w/h/gap/radius
const rowY0 = bgCy - bgH / 2 + rowPad;
const SW = 16, SH = 16, SG = 4, SR = 3;
const g = this.add.graphics();
panel.add(g);
@ -696,8 +746,8 @@ export default class CatanGame extends Phaser.Scene {
const panelX = 1489, panelW = 200, panelH = 632;
// Centre vertically in the playfield zone above the bottom bar (y=10..882)
const panelY = 10 + Math.round((872 - panelH) / 2); // 130
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126
const textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126
const textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
const panelCx = panelX + panelW / 2; // for BANK title
const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4;
@ -706,7 +756,7 @@ export default class CatanGame extends Phaser.Scene {
// Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top
const step = 96;
const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step);
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
this.bankCardPos = {};
RESOURCE_TYPES.forEach((r, i) => {
@ -844,9 +894,9 @@ export default class CatanGame extends Phaser.Scene {
const radius = seat === 0 ? 64 : 56;
const label = this.add.text(dst.x + radius + 10, dst.y,
RESOURCE_INFO[resource].label.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.banner);
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.banner);
this.tweens.add({
targets: label, alpha: 0, y: dst.y - 24,
duration: 700, delay: 300,
@ -979,25 +1029,53 @@ 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);
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);
this.pieceObjs.push(
this.add.image(x, y, 'catan-pirate').setOrigin(0.5).setDisplaySize(48, 48).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) {
@ -1023,12 +1101,12 @@ export default class CatanGame extends Phaser.Scene {
// White halo (3px border around the scaled-up shape)
g.fillStyle(0xffffff, 1);
g.fillRect(x - 24, y, 27, 21); // lower block halo
g.fillRect(x - 8, y - 10, 32, 32); // tower halo
g.fillRect(x - 8, y - 10, 32, 32); // tower halo
g.fillTriangle(x - 11, y - 10, x + 26, y - 10, x + 8, y - 26); // roof halo
// Player color fill (~30% larger than original)
g.fillStyle(col.hex, 1);
g.fillRect(x - 21, y, 21, 18); // lower block
g.fillRect(x - 5, y - 10, 26, 29); // tower
g.fillRect(x - 5, y - 10, 26, 29); // tower
g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof
// Dark outlines
g.lineStyle(2.5, col.hexDark, 1);
@ -1051,7 +1129,7 @@ export default class CatanGame extends Phaser.Scene {
enqueueSpeech(clip);
if (this.robberObj) this.robberObj.destroy();
const from = this.hexPos(fromHexId);
const to = this.hexPos(toHexId);
const to = this.hexPos(toHexId);
this.robberObj = this.add.image(from.x, from.y, 'catan-robber')
.setDisplaySize(64, 64)
.setDepth(D.robber);
@ -1068,7 +1146,7 @@ export default class CatanGame extends Phaser.Scene {
targets: this.robberObj,
tweens: [
{ scaleX: base * 2, scaleY: base * 2, duration: 1500, ease: 'Sine.Out' },
{ scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve },
{ scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve },
],
});
});
@ -1114,7 +1192,7 @@ export default class CatanGame extends Phaser.Scene {
_flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) {
return new Promise(resolve => {
const cardW = 60, cardH = 84;
const bigW = 90, bigH = 126;
const bigW = 90, bigH = 126;
const resource = RESOURCE_TYPES[frameIdx];
const makeBorder = (color) => {
@ -1192,12 +1270,12 @@ export default class CatanGame extends Phaser.Scene {
if (type === 'road') {
g.lineStyle(10, 0xffffff, 0.9); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(7, col.hexDark, 1); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0);
} else if (type === 'settlement') {
g.fillStyle(0xffffff, 1);
g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17);
g.fillStyle(col.hex, 1);
g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14);
g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14);
g.lineStyle(2, col.hexDark, 1); g.strokeRect(-9, -3, 18, 12);
} else if (type === 'city') {
g.fillStyle(0xffffff, 1);
@ -1222,15 +1300,19 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(srcPos.y, destY) - arcHeight;
const half = duration / 2;
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
this.tweens.chain({ targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{ y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
emitter.stop();
container.destroy();
this.time.delayedCall(500, () => emitter.destroy());
resolve();
}},
]});
this.tweens.chain({
targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{
y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
emitter.stop();
container.destroy();
this.time.delayedCall(500, () => emitter.destroy());
resolve();
}
},
]
});
});
}
@ -1251,15 +1333,19 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
const half = duration / 2;
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
this.tweens.chain({ targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{ y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
this.tweens.add({
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
onComplete: () => { container.destroy(); resolve(); },
});
}},
]});
this.tweens.chain({
targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{
y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
this.tweens.add({
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
onComplete: () => { container.destroy(); resolve(); },
});
}
},
]
});
});
}
@ -1399,10 +1485,10 @@ export default class CatanGame extends Phaser.Scene {
}
const DEV_VISUAL = {
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
vp: { frame: 7, border: 0xdaa520 },
monopoly: { frame: 9, border: 0x7b2d8b },
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
vp: { frame: 7, border: 0xdaa520 },
monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
};
@ -1431,7 +1517,7 @@ export default class CatanGame extends Phaser.Scene {
const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive();
const cx = x, topY = y - cardH / 2;
hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border));
hit.on('pointerout', () => this.hideDevCardTooltip());
hit.on('pointerout', () => this.hideDevCardTooltip());
this.devHandContainer.add([bg, img, border, hit]);
x += step;
@ -1468,6 +1554,9 @@ export default class CatanGame extends Phaser.Scene {
const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0;
set('roll', me && s.phase === 'rollPhase');
set('road', (action && hasRoadSpot && L.canAfford(p, COSTS.road)) || (action && s.freeRoads > 0 && hasRoadSpot));
const hasShipSpot = action && L.legalShipEdges(s, 0).length > 0;
const sCost = L.shipCost(s);
set('ship', hasShipSpot && ((action && sCost && L.canAfford(p, sCost)) || (action && s.freeShips > 0)));
set('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement));
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city));
set('buyDev', action && s.devDeck.length > 0 && L.canAfford(p, COSTS.devCard));
@ -1497,9 +1586,14 @@ 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.hexTileFrames = {};
this.drawHexes();
this.drawPorts();
this.drawChits();
@ -1518,6 +1612,8 @@ export default class CatanGame extends Phaser.Scene {
} else if (s.phase === 'rollPhase') {
if (me) { /* wait for Roll button */ }
else await this.aiRoll();
} else if (s.phase === 'goldPick') {
await this.handleGoldPickPhase();
} else if (s.phase === 'discard') {
await this.handleDiscardPhase();
} else if (s.phase === 'moveRobber') {
@ -1678,10 +1774,10 @@ export default class CatanGame extends Phaser.Scene {
applyAction(seat, a) {
switch (a.type) {
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
case 'buildSettlement': return L.buildSettlement(this.gs, seat, a.nodeId);
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
case 'buyDev': return L.buyDevCard(this.gs, seat);
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
case 'buyDev': return L.buyDevCard(this.gs, seat);
case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
case 'playDev':
if (a.card === 'knight') return L.playKnight(this.gs, seat);
@ -1774,10 +1870,10 @@ export default class CatanGame extends Phaser.Scene {
objs.push(g);
return g;
};
const getArrow = drawArrow(812, 'down'); // left of the card you receive
const getArrow = drawArrow(812, 'down'); // left of the card you receive
const giveArrow = drawArrow(1188, 'up'); // right of the card you give
const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
const t2 = this.tweens.add({ targets: giveArrow, y: baseY - amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
objs.push({ destroy: () => { t1.stop(); t2.stop(); } });
@ -1820,15 +1916,15 @@ export default class CatanGame extends Phaser.Scene {
// ── opponent dev card reveal ──────────────────────────────────────────────────
async animateOppDevCardPlay(seat, cardType, resource) {
const VISUAL = {
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
monopoly: { frame: 9, border: 0x7b2d8b },
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
};
const SPEECH = {
knight: 'catan-dev-knight',
knight: 'catan-dev-knight',
roadBuilding: 'catan-dev-road',
monopoly: 'catan-dev-monopoly',
monopoly: 'catan-dev-monopoly',
yearOfPlenty: 'catan-dev-year',
};
@ -1872,7 +1968,7 @@ export default class CatanGame extends Phaser.Scene {
const bursts = [
{ x: toX - 90, y: textY - 10 }, { x: toX + 90, y: textY - 10 },
{ x: toX, y: textY - 28 }, { x: toX - 50, y: textY + 22 },
{ x: toX, y: textY - 28 }, { x: toX - 50, y: textY + 22 },
{ x: toX + 50, y: textY + 22 },
];
bursts.forEach((b, i) =>
@ -1916,6 +2012,31 @@ export default class CatanGame extends Phaser.Scene {
this.advance();
}
// ── gold picks ───────────────────────────────────────────────────────────────
async handleGoldPickPhase() {
this.busy = true;
// Process all AI entries in queue order.
for (const entry of [...this.gs.goldPickQueue]) {
if (entry.seat === 0) continue;
const picks = AI.chooseGoldPick(this.gs, entry.seat, entry.amount);
this.gs = L.resolveGoldPick(this.gs, entry.seat, picks);
}
this.renderAll();
// If human has a pick, let them choose.
const humanEntry = this.gs.goldPickQueue.find((e) => e.seat === 0);
if (humanEntry) {
this.busy = false;
this.pickResources(humanEntry.amount, `Gold Field! Choose ${humanEntry.amount} resource${humanEntry.amount > 1 ? 's' : ''}`, (rs) => {
this.gs = L.resolveGoldPick(this.gs, 0, rs);
this.advance();
});
return;
}
await this.delay(300);
this.busy = false;
this.advance();
}
// ── human: discards ─────────────────────────────────────────────────────────
async handleDiscardPhase() {
this.busy = true;
@ -1990,6 +2111,11 @@ export default class CatanGame extends Phaser.Scene {
const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700);
}
} else if (type === 'ship') {
for (const eid of L.legalShipEdges(s, 0)) {
const { x, y } = this.edgePos(eid);
this.addHighlight(x, y, () => this.doBuild('ship', eid), COLORS.gold, 13);
}
}
this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
}
@ -2002,9 +2128,16 @@ export default class CatanGame extends Phaser.Scene {
enqueueSpeech(`catan-purchase-${type}`);
await this.animateCostPayment(0, type);
await this.animatePiecePlacement(0, type, dest.x, dest.y);
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
const prevGs = this.gs;
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
if (type === 'settlement') this.gs = L.buildSettlement(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
if (type === 'ship') this.gs = L.buildShip(this.gs, 0, id);
// Redraw board if any fog hexes were revealed by this road/ship.
if (type === 'road' || type === 'ship') {
const revealed = this.gs.hexes.some((h, i) => prevGs.hexes[i].kind === 'fog' && h.kind !== 'fog');
if (revealed) { this.drawHexes(); this.drawChits(); }
}
playSound(this, SFX.PIECE_CLICK);
this.busy = false;
this.advance();
@ -2087,12 +2220,12 @@ export default class CatanGame extends Phaser.Scene {
if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights();
const give = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const get = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const get = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel);
const box = this.add.rectangle(1000, 480, 920, 580, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel);
const title = this.add.text(1000, 240, 'Trade', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
const hintGive = this.add.text(760, 308, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const hintGet = this.add.text(1240, 308, 'You get', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const hintGet = this.add.text(1240, 308, 'You get', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const objs = [overlay, box, title, hintGive, hintGet];
const stepper = (col, r, i, side) => {
@ -2257,9 +2390,9 @@ export default class CatanGame extends Phaser.Scene {
const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase();
const txt = this.add.text(GAME_WIDTH / 2, 855,
`CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, {
fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700',
stroke: '#000000', strokeThickness: 5,
}
fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700',
stroke: '#000000', strokeThickness: 5,
}
).setOrigin(0.5).setDepth(D.banner + 2);
this.tweens.add({
targets: txt, alpha: 0, delay: 3000, duration: 500,
@ -2273,8 +2406,10 @@ export default class CatanGame extends Phaser.Scene {
backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
}).setOrigin(0.5).setDepth(D.banner);
banner.setAlpha(0);
this.tweens.add({ targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() })) });
this.tweens.add({
targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() }))
});
}
// ── game over ─────────────────────────────────────────────────────────────────
@ -2285,11 +2420,11 @@ export default class CatanGame extends Phaser.Scene {
this.recordHistory();
const PW = 760, PH = 660, PX = 1000, PY = 540;
const titleY = PY - PH / 2 + 70; // 280
const RADIUS = 80;
const titleY = PY - PH / 2 + 70; // 280
const RADIUS = 80;
const portraitY = titleY + 140; // 420
const bodyY = portraitY + RADIUS + 95; // 595
const buttonsY = PY + PH / 2 - 72; // 798
const bodyY = portraitY + RADIUS + 95; // 595
const buttonsY = PY + PH / 2 - 72; // 798
// Fireworks across the popup for all winners
const fwEmitter = this.add.particles(PX, PY, 'catanParticle', {
@ -2344,7 +2479,7 @@ export default class CatanGame extends Phaser.Scene {
videoEl.autoplay = true;
videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
videoEl.src = `/assets/videos/${opp.id}-happy.mp4`;
videoEl.play().catch(() => {});
videoEl.play().catch(() => { });
videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true });
portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3);
}

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 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],
@ -110,12 +139,20 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random' }
diceTotal: null,
robberReturnPhase: 'action',
discardQueue: [],
goldPickQueue: [],
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 +162,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 +263,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]--; }
}
@ -237,15 +307,18 @@ export function rollDice(state) {
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
} else {
produceResources(s, s.diceTotal);
s.phase = 'action';
if (s.phase !== 'goldPick') s.phase = 'action';
}
return s;
}
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 +327,31 @@ function produceResources(s, total) {
if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; }
}
}
// Gold hexes: each settlement/city adjacent earns 1/2 free-choice resources.
for (const hex of s.hexes) {
if (hex.kind !== 'gold' || hex.number !== total || hex.hasRobber) continue;
for (const nodeId of geo.hexes[hex.id].corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
const amt = bld.type === 'city' ? 2 : 1;
s.goldPickQueue.push({ seat: bld.seat, amount: amt });
}
}
if (s.goldPickQueue.length) s.phase = 'goldPick';
}
// resources: array of resource strings with length matching the queue entry's amount.
export function resolveGoldPick(state, seat, resources) {
const s = cloneState(state);
const idx = s.goldPickQueue.findIndex((e) => e.seat === seat);
if (idx === -1) return s;
for (const r of resources) {
if (s.bank[r] > 0) { s.players[seat].resources[r]++; s.bank[r]--; }
}
logEvent(s, `${playerName(s, seat)} takes ${resources.join(', ')} from gold.`);
s.goldPickQueue.splice(idx, 1);
if (s.goldPickQueue.length === 0) s.phase = 'action';
return s;
}
// ── discard / robber ───────────────────────────────────────────────────────────
@ -284,7 +382,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);
}
@ -324,6 +422,84 @@ export function buildRoad(state, seat, edgeId) {
if (!free && !canAfford(s.players[seat], COSTS.road)) return s;
if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road);
s.players[seat].roads.push(edgeId);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
}
// Reveal any fog hexes that share an edge with edgeId.
function revealFogHexes(s, edgeId) {
const geo = geoFor(s);
for (const hexId of geo.edges[edgeId].hexes) {
const hex = s.hexes[hexId];
if (!hex || hex.kind !== 'fog') continue;
if (hex.fogData) {
hex.kind = hex.fogData.kind;
hex.resource = hex.fogData.resource;
hex.number = hex.fogData.number;
} else {
hex.kind = 'sea';
}
hex.fogData = null;
}
}
// ── 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);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
@ -498,11 +674,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 +695,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 +735,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 +754,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: { lumber:1, wool: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 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,
});
},
};

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,83 @@
// 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 = { wool: 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,
// Fog hexes carry hidden data that replaces kind/resource/number on reveal.
fogData: c.kind === 'fog' && c.reveal ? { kind: c.reveal.kind, resource: c.reveal.resource ?? null, number: c.reveal.number ?? null } : null,
}));
// 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,
});

View File

@ -38,30 +38,31 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 312,
frameHeight: 312,
});
this.load.image('catan-robber', '/assets/images/catan-robber.png');
this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('catan-robber', '/assets/images/catan-robber.png');
this.load.image('catan-pirate', '/assets/images/catan-pirate.png');
this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('bg-casino', '/assets/images/background-casino.png');
this.load.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json');
this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json');
this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3');
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');
this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3');
this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3');
this.load.audio('sfx-coins', '/assets/fx/coins.mp3');
this.load.audio('sfx-purchase', '/assets/fx/purchase.mp3');
this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3');
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');
this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3');
this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3');
this.load.audio('sfx-coins', '/assets/fx/coins.mp3');
this.load.audio('sfx-purchase', '/assets/fx/purchase.mp3');
this.load.audio('sfx-casino-blackjack', '/assets/fx/casino-blackjack.mp3');
this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3');
this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3');
this.load.audio('sfx-chip-bet', '/assets/fx/chip-bet.mp3');
this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3');
this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3');
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3');
this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3');
this.load.audio('sfx-chip-bet', '/assets/fx/chip-bet.mp3');
this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3');
this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3');
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
@ -81,7 +82,7 @@ export default class PreloadScene extends Phaser.Scene {
const toLoad = [
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
];
if (toLoad.length > 0) {