Compare commits
No commits in common. "9dbf3feae4b8c2bdfcbaf7ff44a0c0c192e54989" and "57eeb3bfeeb8e547ee54f963053d1ea02e3d9222" have entirely different histories.
9dbf3feae4
...
57eeb3bfee
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.2 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 { pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
|
||||
import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
|
||||
import {
|
||||
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
||||
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
||||
victoryPoints, longestRoadFor, geoFor, winVpFor,
|
||||
victoryPoints, WIN_VP, longestRoadFor,
|
||||
} 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 geoFor(state).nodes[nodeId].hexes) {
|
||||
for (const hx of 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 geoFor(state).nodes[nodeId].hexes) {
|
||||
for (const hx of 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 geoFor(state).nodes[n].hexes) {
|
||||
for (const hx of NODES[n].hexes) {
|
||||
const hex = state.hexes[hx];
|
||||
if (hex.resource !== 'desert' && hex.number != null && prod[hex.resource] === 0) score += 1.5;
|
||||
}
|
||||
|
|
@ -59,9 +59,8 @@ 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] = geo.edges[eid].nodes;
|
||||
const [a, b] = EDGES[eid].nodes;
|
||||
const far = a === from ? b : a;
|
||||
const score = nodeValue(state, far);
|
||||
if (score > bestScore) { bestScore = score; best = eid; }
|
||||
|
|
@ -86,25 +85,14 @@ 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 geo.hexes[hex.id].corners) {
|
||||
for (const nodeId of HEXES[hex.id].corners) {
|
||||
const bld = nodeBuilding(state, nodeId);
|
||||
if (!bld) continue;
|
||||
if (bld.seat === seat) { touchesSelf = true; continue; }
|
||||
|
|
@ -117,10 +105,8 @@ 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 land hex.
|
||||
if (best === null) {
|
||||
best = state.hexes.find((h) => !h.hasRobber && (!h.kind || h.kind === 'land'))?.id ?? state.robberHex;
|
||||
}
|
||||
// Fallback: any legal hex.
|
||||
if (best === null) best = state.hexes.find((h) => !h.hasRobber)?.id ?? state.robberHex;
|
||||
return { hexId: best, targetSeat: bestTarget };
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +114,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 = geoFor(state).hexes[state.robberHex].corners.some((n) => {
|
||||
const robberOnOurs = HEXES[state.robberHex].corners.some((n) => {
|
||||
const b = nodeBuilding(state, n);
|
||||
return b && b.seat === seat;
|
||||
});
|
||||
|
|
@ -224,12 +210,11 @@ 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] = geo.edges[rid].nodes;
|
||||
const [ra, rb] = 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);
|
||||
|
|
@ -252,18 +237,18 @@ function chooseExpansionRoad(state, seat) {
|
|||
|
||||
let best = null, bestScore = -Infinity;
|
||||
for (const eid of edges) {
|
||||
const [a, b] = geo.edges[eid].nodes;
|
||||
const [a, b] = EDGES[eid].nodes;
|
||||
let score = 0;
|
||||
|
||||
for (const node of [a, b]) {
|
||||
// Direct endpoint: full value if buildable.
|
||||
if (!nodeBuilding(state, node) && !geo.nodes[node].adj.some((x) => nodeBuilding(state, x))) {
|
||||
if (!nodeBuilding(state, node) && !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 geo.nodes[node].adj) {
|
||||
for (const adj of NODES[node].adj) {
|
||||
if (adj === a || adj === b) continue;
|
||||
if (!nodeBuilding(state, adj) && !geo.nodes[adj].adj.some((x) => nodeBuilding(state, x))) {
|
||||
if (!nodeBuilding(state, adj) && !NODES[adj].adj.some((x) => nodeBuilding(state, x))) {
|
||||
score += nodeValue(state, adj) * 0.5;
|
||||
}
|
||||
}
|
||||
|
|
@ -408,8 +393,7 @@ function tradeWinsGame(state, seat, give, get) {
|
|||
}
|
||||
const afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
|
||||
const ownVP = victoryPoints(state, seat);
|
||||
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;
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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];
|
||||
|
||||
|
|
@ -106,43 +107,25 @@ function hexCorners(cx, cy, size) {
|
|||
return pts;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
function buildGeometry(cx, cy, size) {
|
||||
const hexes = [];
|
||||
// Hex centres, rows of [3,4,5,4,3], each row horizontally centred.
|
||||
let id = 0;
|
||||
for (let r = 0; r < 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 r = 0; r < HEX_ROWS.length; r++) {
|
||||
const count = HEX_ROWS[r];
|
||||
const rowY = cy + (r - 2) * ROW_V;
|
||||
const startX = cx - ((count - 1) / 2) * HEX_W;
|
||||
for (let c = 0; c < count; c++) {
|
||||
centers.push({ id: id++, cx: startX + c * hexW, cy: rowY, row: r, col: c });
|
||||
hexes.push({ id: id++, cx: startX + c * HEX_W, 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 = `${Math.round(p.x)}_${Math.round(p.y)}`;
|
||||
const k = nodeKey(p);
|
||||
let n = nodeMap.get(k);
|
||||
if (!n) {
|
||||
n = { id: nodes.length, x: Math.round(p.x), y: Math.round(p.y), hexes: [], adj: [] };
|
||||
|
|
@ -172,7 +155,9 @@ function assemble(centers, size, cx, cy, portCount = 0) {
|
|||
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++) {
|
||||
ensureEdge(corners[i].id, corners[(i + 1) % 6].id);
|
||||
const a = corners[i].id;
|
||||
const b = corners[(i + 1) % 6].id;
|
||||
ensureEdge(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,71 +169,45 @@ function assemble(centers, size, cx, cy, portCount = 0) {
|
|||
e.hexes = nodes[a].hexes.filter((h) => nodes[b].hexes.includes(h));
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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),
|
||||
});
|
||||
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, edgeIndex, portSlots, cx, cy, size };
|
||||
return { hexes, nodes, edges, portSlots };
|
||||
}
|
||||
|
||||
// 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),
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
// Default geometry baked at module load — shared by every consumer.
|
||||
export const GEOMETRY = buildGeometry(BOARD_CX, BOARD_CY, HEX_SIZE);
|
||||
|
||||
// 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).
|
||||
// Convenience accessors.
|
||||
export const HEXES = GEOMETRY.hexes;
|
||||
export const NODES = GEOMETRY.nodes;
|
||||
export const EDGES = GEOMETRY.edges;
|
||||
export const PORT_SLOTS = GEOMETRY.portSlots;
|
||||
|
||||
// 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) {
|
||||
// Edge id between two adjacent node ids, or -1.
|
||||
export function edgeBetween(a, b) {
|
||||
const lo = Math.min(a, b), hi = Math.max(a, b);
|
||||
const e = geo.edgeIndex.get(`${lo}_${hi}`);
|
||||
const e = EDGES.find((x) => x.nodes[0] === lo && x.nodes[1] === 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 {
|
||||
RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
|
||||
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE,
|
||||
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
|
||||
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP, HEX_SIZE, HEX_W,
|
||||
} from './CatanBoard.js';
|
||||
import * as L from './CatanLogic.js';
|
||||
import * as AI from './CatanAI.js';
|
||||
|
|
@ -25,8 +25,6 @@ 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 = [];
|
||||
|
|
@ -61,15 +59,12 @@ export default class CatanGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// ── coordinate helpers ──────────────────────────────────────────────────────
|
||||
// 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 }; }
|
||||
nodePos(id) { return { x: NODES[id].x, y: NODES[id].y }; }
|
||||
edgePos(id) {
|
||||
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 };
|
||||
const [a, b] = EDGES[id].nodes;
|
||||
return { x: (NODES[a].x + NODES[b].x) / 2, y: (NODES[a].y + NODES[b].y) / 2 };
|
||||
}
|
||||
hexPos(id) { const h = this.geo.hexes[id]; return { x: h.cx, y: h.cy }; }
|
||||
hexPos(id) { return { x: HEXES[id].cx, y: HEXES[id].cy }; }
|
||||
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
|
||||
pname(seat) { return L.playerName(this.gs, seat); }
|
||||
|
||||
|
|
@ -91,9 +86,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);
|
||||
|
|
@ -113,15 +108,12 @@ 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],
|
||||
sea: [12, 13],
|
||||
gold: [14, 15],
|
||||
fog: [16, 17],
|
||||
lumber: [0, 1],
|
||||
wool: [2, 3],
|
||||
brick: [4, 5],
|
||||
ore: [6, 7],
|
||||
grain: [8, 9],
|
||||
desert: [10, 11],
|
||||
};
|
||||
|
||||
drawHexes() {
|
||||
|
|
@ -138,48 +130,41 @@ 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 }));
|
||||
|
||||
// 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;
|
||||
|
||||
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 pts = HEXES[hex.id].corners.map((c) => ({ x: NODES[c].x, y: 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)
|
||||
// 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)
|
||||
const innerPts = inset(pts, x, y, s1);
|
||||
const imagePts = inset(pts, x, y, s2);
|
||||
|
||||
// Layer 1: terrain swatch fill (outer colored border ring)
|
||||
g.fillStyle(terr.swatch, 0.55);
|
||||
// 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);
|
||||
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 (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];
|
||||
// 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)];
|
||||
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(hexW * s2, size * 2 * s2)
|
||||
.setDisplaySize(HEX_W * s2, HEX_SIZE * 2 * s2)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(D.board + 1);
|
||||
this.hexImgs.push({ img, maskG });
|
||||
} else {
|
||||
// Fallback / water / gold / fog: solid color fill in the image area
|
||||
g.fillStyle(terr.color, 1);
|
||||
// Fallback: resource color fill in the image area
|
||||
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
|
||||
g.fillStyle(color, 1);
|
||||
g.fillPoints(imagePts, true);
|
||||
}
|
||||
|
||||
|
|
@ -187,31 +172,14 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
|
||||
this.hexBorderGfx.strokePoints(pts, true);
|
||||
|
||||
// Terrain label
|
||||
this.hexLabels.push(this.add.text(x, y - 56, terr.label, {
|
||||
// Resource label
|
||||
const label = hex.resource === 'desert' ? 'Desert' : RESOURCE_INFO[hex.resource].tile;
|
||||
this.hexLabels.push(this.add.text(x, y - 56, 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 = [];
|
||||
|
|
@ -242,7 +210,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, this.geo.nodes[nid].x, this.geo.nodes[nid].y);
|
||||
for (const nid of port.nodes) jg.lineBetween(px, py, NODES[nid].x, NODES[nid].y);
|
||||
this.portObjs.push(c, jg);
|
||||
}
|
||||
}
|
||||
|
|
@ -313,11 +281,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) => {
|
||||
|
|
@ -340,19 +308,17 @@ 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
|
||||
|
|
@ -376,7 +342,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);
|
||||
}
|
||||
|
|
@ -429,7 +395,6 @@ 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());
|
||||
|
|
@ -460,11 +425,8 @@ 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 };
|
||||
|
||||
|
|
@ -492,10 +454,9 @@ 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: cardY, scale: 1 }
|
||||
: { x: 1855, y: cardY, scale: 1 };
|
||||
? { x: 1775, y: 760, scale: 1 }
|
||||
: { x: 1855, y: 760, scale: 1 };
|
||||
}
|
||||
if (owner === 0) {
|
||||
return cardType === 'longestRoad'
|
||||
|
|
@ -531,26 +492,23 @@ 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');
|
||||
}
|
||||
|
|
@ -611,20 +569,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;
|
||||
|
|
@ -692,23 +650,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
const panelRight = 1900;
|
||||
const panelW = 320;
|
||||
const cx = panelRight - panelW / 2;
|
||||
|
||||
const rows = [
|
||||
{ name: 'Road', resources: ['brick', 'lumber'] },
|
||||
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
|
||||
{ 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 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));
|
||||
|
|
@ -716,10 +658,18 @@ export default class CatanGame extends Phaser.Scene {
|
|||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5, 0));
|
||||
|
||||
const rows = [
|
||||
{ name: 'Road', resources: ['brick', 'lumber'] },
|
||||
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
|
||||
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
|
||||
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
|
||||
];
|
||||
|
||||
const lx = panelRight - panelW + 14;
|
||||
const rx = panelRight - 14;
|
||||
const rowY0 = bgCy - bgH / 2 + rowPad;
|
||||
const SW = 16, SH = 16, SG = 4, SR = 3;
|
||||
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 g = this.add.graphics();
|
||||
panel.add(g);
|
||||
|
|
@ -746,8 +696,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;
|
||||
|
|
@ -756,7 +706,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) => {
|
||||
|
|
@ -894,9 +844,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,
|
||||
|
|
@ -1029,53 +979,25 @@ 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] = this.geo.edges[eid].nodes;
|
||||
const [a, b] = EDGES[eid].nodes;
|
||||
const g = this.add.graphics().setDepth(D.road);
|
||||
const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y;
|
||||
const ax = NODES[a].x, ay = NODES[a].y, bx = NODES[b].x, by = NODES[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(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));
|
||||
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));
|
||||
}
|
||||
// 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) {
|
||||
|
|
@ -1101,12 +1023,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);
|
||||
|
|
@ -1129,7 +1051,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);
|
||||
|
|
@ -1146,7 +1068,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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -1192,7 +1114,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) => {
|
||||
|
|
@ -1270,12 +1192,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);
|
||||
|
|
@ -1300,19 +1222,15 @@ 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();
|
||||
}},
|
||||
]});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1333,19 +1251,15 @@ 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(); },
|
||||
});
|
||||
}},
|
||||
]});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1485,10 +1399,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 },
|
||||
};
|
||||
|
||||
|
|
@ -1517,7 +1431,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;
|
||||
|
|
@ -1554,9 +1468,6 @@ 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));
|
||||
|
|
@ -1586,14 +1497,9 @@ 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,
|
||||
expansion: this.expansion,
|
||||
scenario: this.scenario,
|
||||
});
|
||||
this.gs = L.createInitialState(playerCount, { tilePlacement: this.tilePlacement });
|
||||
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
|
||||
L.setPlayerNames(this.gs, names);
|
||||
this.hexTileFrames = {};
|
||||
this.drawHexes();
|
||||
this.drawPorts();
|
||||
this.drawChits();
|
||||
|
|
@ -1612,8 +1518,6 @@ 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') {
|
||||
|
|
@ -1774,10 +1678,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);
|
||||
|
|
@ -1870,10 +1774,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(); } });
|
||||
|
||||
|
|
@ -1916,15 +1820,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',
|
||||
};
|
||||
|
||||
|
|
@ -1968,7 +1872,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) =>
|
||||
|
|
@ -2012,31 +1916,6 @@ 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;
|
||||
|
|
@ -2111,11 +1990,6 @@ 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)`);
|
||||
}
|
||||
|
|
@ -2128,16 +2002,9 @@ 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);
|
||||
const prevGs = this.gs;
|
||||
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
|
||||
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 === '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(); }
|
||||
}
|
||||
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.busy = false;
|
||||
this.advance();
|
||||
|
|
@ -2220,12 +2087,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) => {
|
||||
|
|
@ -2390,9 +2257,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,
|
||||
|
|
@ -2406,10 +2273,8 @@ 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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -2420,11 +2285,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', {
|
||||
|
|
@ -2479,7 +2344,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,10 @@
|
|||
// Every action takes a state and returns a NEW state (deep-cloned first).
|
||||
|
||||
import {
|
||||
getBoard, edgeBetween,
|
||||
NODES, EDGES, HEXES, PORT_SLOTS, 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) {
|
||||
|
|
@ -54,14 +42,13 @@ export function edgeOwner(state, edgeId) {
|
|||
}
|
||||
|
||||
// ── initial state ─────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
export function createInitialState(playerCount = 3, { tilePlacement = 'random' } = {}) {
|
||||
const n = Math.max(3, Math.min(4, playerCount));
|
||||
|
||||
// Resources onto hexes.
|
||||
const resources = tilePlacement === 'standard' ? [...STANDARD_RESOURCES] : shuffle(RESOURCE_BAG);
|
||||
const hexes = geo.hexes.map((h, i) => ({
|
||||
const hexes = HEXES.map((h, i) => ({
|
||||
id: h.id,
|
||||
kind: resources[i] === 'desert' ? 'desert' : 'land',
|
||||
resource: resources[i],
|
||||
number: null,
|
||||
hasRobber: false,
|
||||
|
|
@ -72,29 +59,20 @@ function buildBaseIsland(tilePlacement) {
|
|||
// 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 = geo.portSlots.map((slot, i) => ({
|
||||
const ports = PORT_SLOTS.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({
|
||||
|
|
@ -105,7 +83,6 @@ 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
|
||||
|
|
@ -118,20 +95,14 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
|
|||
const seats = shuffle([...Array(n).keys()]);
|
||||
const order = [...seats, ...[...seats].reverse()];
|
||||
|
||||
const state = {
|
||||
return {
|
||||
playerCount: n,
|
||||
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
|
||||
hexes,
|
||||
ports,
|
||||
players,
|
||||
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
|
||||
devDeck: shuffle(DEV_DECK),
|
||||
robberHex: board.robberHex,
|
||||
pirateHex: board.pirateHex ?? null, // Seafarers: sea-robber position
|
||||
robberHex: desertHex.id,
|
||||
phase: 'setup',
|
||||
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
|
||||
currentPlayer: order[0],
|
||||
|
|
@ -139,20 +110,12 @@ 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) {
|
||||
|
|
@ -162,74 +125,42 @@ function logEvent(state, msg) {
|
|||
|
||||
// ── legality helpers ──────────────────────────────────────────────────────────
|
||||
export function legalSettlementNodes(state, seat, setup = false) {
|
||||
const geo = geoFor(state);
|
||||
const out = [];
|
||||
for (const node of geo.nodes) {
|
||||
if (!nodeOnLand(state, node.id)) continue; // Seafarers: must touch land
|
||||
for (const node of NODES) {
|
||||
if (nodeBuilding(state, node.id)) continue;
|
||||
if (node.adj.some((a) => nodeBuilding(state, a))) continue; // distance rule
|
||||
if (!setup) {
|
||||
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));
|
||||
const touchesRoad = node.adj.some((a) => {
|
||||
const eid = edgeBetween(node.id, a);
|
||||
return eid >= 0 && state.players[seat].roads.includes(eid);
|
||||
});
|
||||
if (!touchesRoute) continue;
|
||||
if (!touchesRoad) 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 geo.nodes[nodeId].adj) {
|
||||
const eid = edgeBetween(geo, nodeId, adj);
|
||||
for (const adj of NODES[nodeId].adj) {
|
||||
const eid = edgeBetween(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 geo.edges) {
|
||||
if (edgeOccupied(state, e.id)) continue;
|
||||
if (!edgeIsLand(state, e.id)) continue; // roads only on land edges
|
||||
for (const e of EDGES) {
|
||||
if (edgeOwner(state, e.id) !== null) continue;
|
||||
const [a, b] = e.nodes;
|
||||
if (setup) {
|
||||
if (a === fromNode || b === fromNode) out.push(e.id);
|
||||
|
|
@ -263,9 +194,8 @@ 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 geoFor(s).nodes[nodeId].hexes) {
|
||||
for (const hx of 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]--; }
|
||||
}
|
||||
|
|
@ -307,18 +237,15 @@ export function rollDice(state) {
|
|||
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
|
||||
} else {
|
||||
produceResources(s, s.diceTotal);
|
||||
if (s.phase !== 'goldPick') s.phase = 'action';
|
||||
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;
|
||||
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;
|
||||
const corners = HEXES[hex.id].corners;
|
||||
for (const nodeId of corners) {
|
||||
const bld = nodeBuilding(s, nodeId);
|
||||
if (!bld) continue;
|
||||
|
|
@ -327,31 +254,6 @@ 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 ───────────────────────────────────────────────────────────
|
||||
|
|
@ -382,7 +284,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 geoFor(state).hexes[hexId].corners) {
|
||||
for (const nodeId of HEXES[hexId].corners) {
|
||||
const bld = nodeBuilding(state, nodeId);
|
||||
if (bld && bld.seat !== seat && handSize(state.players[bld.seat]) > 0) targets.add(bld.seat);
|
||||
}
|
||||
|
|
@ -422,84 +324,6 @@ 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;
|
||||
|
|
@ -674,13 +498,11 @@ export function endTurn(state) {
|
|||
|
||||
// ── longest road / largest army / victory ──────────────────────────────────────
|
||||
export function longestRoadFor(state, seat) {
|
||||
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];
|
||||
const roads = state.players[seat].roads;
|
||||
if (roads.length === 0) return 0;
|
||||
const incident = new Map();
|
||||
for (const eid of roads) {
|
||||
for (const node of geo.edges[eid].nodes) {
|
||||
for (const node of EDGES[eid].nodes) {
|
||||
if (!incident.has(node)) incident.set(node, []);
|
||||
incident.get(node).push(eid);
|
||||
}
|
||||
|
|
@ -695,7 +517,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] = geo.edges[eid].nodes;
|
||||
const [a, b] = EDGES[eid].nodes;
|
||||
const next = a === node ? b : a;
|
||||
used.add(eid);
|
||||
dfs(next, used, len + 1);
|
||||
|
|
@ -735,17 +557,12 @@ 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 + bonusVP(state, seat);
|
||||
return vp;
|
||||
}
|
||||
|
||||
// Public VP excludes hidden VP dev cards (for opponent display).
|
||||
|
|
@ -754,11 +571,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 + bonusVP(state, seat);
|
||||
return vp;
|
||||
}
|
||||
|
||||
function checkWin(state, seat) {
|
||||
if (victoryPoints(state, seat) >= winVpFor(state)) {
|
||||
if (victoryPoints(state, seat) >= WIN_VP) {
|
||||
state.winner = seat;
|
||||
state.phase = 'gameOver';
|
||||
logEvent(state, `${playerName(state, seat)} wins!`);
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// 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 };
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -10,9 +10,8 @@ import {
|
|||
} from './DominionCards.js';
|
||||
import {
|
||||
legalActionIids, allCards, handCoinValue,
|
||||
affordableSupply, canGain, cardCost,
|
||||
affordableSupply, canGain,
|
||||
} from './DominionLogic.js';
|
||||
import { getExpansion } from './expansions/index.js';
|
||||
|
||||
function countOwned(state, seat, id) {
|
||||
return allCards(state, seat).filter((c) => c.id === id).length;
|
||||
|
|
@ -27,17 +26,11 @@ const TERMINAL_PRIORITY = {
|
|||
workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20,
|
||||
};
|
||||
|
||||
// Base priorities merged with the active expansion's (expansion wins on overlap).
|
||||
function terminalPriority(state) {
|
||||
return { ...TERMINAL_PRIORITY, ...(getExpansion(state.expansion).ai?.terminalPriority ?? {}) };
|
||||
}
|
||||
|
||||
export function chooseAction(state, seat) {
|
||||
const legal = legalActionIids(state);
|
||||
if (legal.length === 0) return null;
|
||||
const p = state.players[seat];
|
||||
const byIid = new Map(p.hand.map((c) => [c.iid, c]));
|
||||
const TP = terminalPriority(state);
|
||||
|
||||
// Play cantrips / villages first (they replace the action they cost).
|
||||
const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1);
|
||||
|
|
@ -50,19 +43,18 @@ export function chooseAction(state, seat) {
|
|||
return cantrips[0];
|
||||
}
|
||||
|
||||
// Only terminals remain (one action left). Throne-variants need another action to be worth it.
|
||||
// Only terminals remain (one action left). Throne Room needs another action to be worth it.
|
||||
const terminals = legal.slice();
|
||||
terminals.sort((a, b) => {
|
||||
const ida = byIid.get(a).id, idb = byIid.get(b).id;
|
||||
return (TP[idb] ?? 0) - (TP[ida] ?? 0);
|
||||
return (TERMINAL_PRIORITY[idb] ?? 0) - (TERMINAL_PRIORITY[ida] ?? 0);
|
||||
});
|
||||
const best = terminals[0];
|
||||
const bestId = byIid.get(best).id;
|
||||
if (bestId === 'throneroom' || bestId === 'kingscourt') {
|
||||
if (byIid.get(best).id === 'throneroom') {
|
||||
const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action'));
|
||||
if (!hasOther) {
|
||||
// Skip the multiplier; play the next-best terminal instead, if any.
|
||||
const alt = terminals.find((iid) => byIid.get(iid).id !== bestId);
|
||||
// Skip Throne Room; play the next-best terminal instead, if any.
|
||||
const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom');
|
||||
return alt ?? best;
|
||||
}
|
||||
}
|
||||
|
|
@ -86,37 +78,28 @@ export function chooseBuy(state, seat, skill = 3) {
|
|||
const coins = p.coins;
|
||||
if (p.buys <= 0) return null;
|
||||
const provincesLeft = state.supply.province ?? 0;
|
||||
const colonyGame = state.supply.colony !== undefined;
|
||||
const coloniesLeft = state.supply.colony ?? 0;
|
||||
|
||||
// Top-end victory: Colony (Prosperity) sits above Province.
|
||||
if (colonyGame && coins >= 11 && coloniesLeft > 0) return 'colony';
|
||||
if (coins >= 8 && provincesLeft > 0) return 'province';
|
||||
|
||||
// Late-game greening — thresholds widen with skill (better players green sooner).
|
||||
const greenAt = 3 + Math.round(skill / 2);
|
||||
if ((colonyGame ? coloniesLeft : provincesLeft) <= greenAt) {
|
||||
if (provincesLeft <= greenAt) {
|
||||
if (coins >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy';
|
||||
if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate';
|
||||
}
|
||||
|
||||
// Engine building (mid coins; never skip Gold/Province). Expansion engine cards
|
||||
// are considered first, then the base list.
|
||||
const engineBuys = [...(getExpansion(state.expansion).ai?.engineBuys ?? []), ...ENGINE_BUYS];
|
||||
// Base keeps its original ≤5 gate; expansions may have worthwhile $6 engine cards.
|
||||
const engineCoinCap = state.expansion && state.expansion !== 'base' ? 6 : 5;
|
||||
if (skill >= 3 && coins <= engineCoinCap) {
|
||||
for (const { id, cap } of engineBuys) {
|
||||
// Engine building (mid coins; never skip Gold/Province).
|
||||
if (skill >= 3 && coins <= 5) {
|
||||
for (const { id, cap } of ENGINE_BUYS) {
|
||||
if (!state.kingdom.includes(id)) continue;
|
||||
if (cardCost(state, id, { seat, phase: 'buy' }) > coins) continue;
|
||||
if (getCard(id).cost > coins) continue;
|
||||
if ((state.supply[id] ?? 0) <= 0) continue;
|
||||
if (countOwned(state, seat, id) >= cap) continue;
|
||||
// Keep terminals roughly balanced against villages at higher skill.
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// Treasure economy. In Colony games Platinum is the premier economy buy.
|
||||
if (colonyGame && coins >= 5 && (state.supply.platinum ?? 0) > 0) return 'platinum';
|
||||
if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
|
||||
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
|
||||
return null;
|
||||
|
|
@ -142,15 +125,12 @@ function trashRank(id, copperKeep) {
|
|||
return 0; // never auto-trash anything else
|
||||
}
|
||||
|
||||
function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
|
||||
let options = affordableSupply(state, maxCost, filterTreasure, !!opts.exact);
|
||||
if (opts.exclude && opts.exclude.length) options = options.filter((id) => !opts.exclude.includes(id));
|
||||
function bestGain(state, seat, maxCost, filterTreasure) {
|
||||
const options = affordableSupply(state, maxCost, filterTreasure);
|
||||
if (options.length === 0) return null;
|
||||
// Gain value ordering.
|
||||
const rank = (id) => {
|
||||
if (id === 'colony') return 1100;
|
||||
if (id === 'province') return 1000;
|
||||
if (id === 'platinum') return 950;
|
||||
if (id === 'gold') return 900;
|
||||
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
|
||||
const def = getCard(id);
|
||||
|
|
@ -163,16 +143,6 @@ function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
|
|||
return options[0];
|
||||
}
|
||||
|
||||
// Helpers an expansion's AI pending-resolvers operate through.
|
||||
const AI_API = {
|
||||
cardCost,
|
||||
bestGain,
|
||||
discardRank,
|
||||
countOwned,
|
||||
isType,
|
||||
getCard,
|
||||
};
|
||||
|
||||
export function resolvePending(state, skill = 3) {
|
||||
const pend = state.pending;
|
||||
if (!pend) return {};
|
||||
|
|
@ -277,10 +247,7 @@ export function resolvePending(state, skill = 3) {
|
|||
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
|
||||
return { iid: opts[0]?.iid ?? null };
|
||||
}
|
||||
default: {
|
||||
// Expansion-defined pending kind.
|
||||
const fn = getExpansion(state.expansion).ai?.pending?.[pend.kind];
|
||||
return fn ? fn(state, seat, pend, AI_API) : {};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,12 @@
|
|||
// engine auto-applies and the UI renders as the icon summary. Everything
|
||||
// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry.
|
||||
|
||||
// `sheet` is the Phaser texture key the `frame` indexes into. Base cards live on
|
||||
// the 'dominion-cards' sheet; expansions ship their own (e.g. 'dominion-prosperity').
|
||||
export const def = (id, frame, cost, types, extra = {}) => ({
|
||||
const def = (id, frame, cost, types, extra = {}) => ({
|
||||
id, name: extra.name ?? titleCase(id), frame, cost, types,
|
||||
plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) },
|
||||
coin: extra.coin, // treasure value
|
||||
vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic)
|
||||
text: extra.text ?? '',
|
||||
sheet: extra.sheet ?? 'dominion-cards',
|
||||
});
|
||||
|
||||
function titleCase(id) {
|
||||
|
|
@ -120,23 +117,18 @@ export function isType(id, type) {
|
|||
return getCard(id).types.includes(type);
|
||||
}
|
||||
|
||||
// Pick 10 distinct Kingdom ids from `pool` using the supplied rng (0..1).
|
||||
export function chooseRandomKingdom(rand, pool = KINGDOM_POOL) {
|
||||
const p = pool.slice();
|
||||
for (let i = p.length - 1; i > 0; i--) {
|
||||
// Pick 10 distinct Kingdom ids from the pool using the supplied rng (0..1).
|
||||
export function chooseRandomKingdom(rand) {
|
||||
const pool = KINGDOM_POOL.slice();
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[p[i], p[j]] = [p[j], p[i]];
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
return p.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||
return pool.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||
}
|
||||
|
||||
// Resolve a Kingdom for the chosen deck mode. `opts.pool`/`opts.presets` let an
|
||||
// expansion supply its own card pool and recommended layouts; both default to
|
||||
// the base set, so base play is unchanged.
|
||||
export function kingdomFor(deckMode, rand, opts = {}) {
|
||||
const pool = opts.pool ?? KINGDOM_POOL;
|
||||
const presets = opts.presets ?? KINGDOM_PRESETS;
|
||||
if (deckMode === 'random') return chooseRandomKingdom(rand, pool);
|
||||
const preset = presets[deckMode] ?? presets[Object.keys(presets)[0]] ?? FIRST_GAME;
|
||||
export function kingdomFor(deckMode, rand) {
|
||||
if (deckMode === 'random') return chooseRandomKingdom(rand);
|
||||
const preset = KINGDOM_PRESETS[deckMode] ?? FIRST_GAME;
|
||||
return preset.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { getCard, isType } from './DominionCards.js';
|
|||
import {
|
||||
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
|
||||
buyCard, endTurn, resolvePending, isGameOver, finalScores,
|
||||
legalActionIids, canGain, emptyPileCount, buyCost, buyAllowed,
|
||||
legalActionIids, canGain, emptyPileCount,
|
||||
} from './DominionLogic.js';
|
||||
import * as AI from './DominionAI.js';
|
||||
|
||||
|
|
@ -69,7 +69,6 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.opponents = data.opponents ?? [];
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.expansion = data.expansion ?? 'base';
|
||||
this.deckMode = data.deckMode ?? 'standard';
|
||||
this.playerCount = this.opponents.length + 1;
|
||||
|
||||
|
|
@ -151,7 +150,6 @@ export default class DominionGame extends Phaser.Scene {
|
|||
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
|
||||
playerCount: this.playerCount,
|
||||
deckMode: this.deckMode,
|
||||
expansion: this.expansion,
|
||||
});
|
||||
// Show an empty hand first, then animate the deal.
|
||||
const p0 = initialState.players[0];
|
||||
|
|
@ -463,12 +461,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
|
||||
renderSupply() {
|
||||
const gs = this.gs;
|
||||
// Basic supply row; Prosperity inserts Platinum after Gold and Colony after Province.
|
||||
const base = ['copper', 'silver', 'gold'];
|
||||
if (gs.supply.platinum !== undefined) base.push('platinum');
|
||||
base.push('estate', 'duchy', 'province');
|
||||
if (gs.supply.colony !== undefined) base.push('colony');
|
||||
base.push('curse');
|
||||
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
|
||||
this.layoutPileRow(base, 100);
|
||||
const k = gs.kingdom;
|
||||
this.layoutPileRow(k.slice(0, 5), 274);
|
||||
|
|
@ -521,7 +514,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
// Normal buy wiring (human buy phase, no pending).
|
||||
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
|
||||
const p = gs.players[0];
|
||||
const affordable = count > 0 && p.buys > 0 && p.coins >= buyCost(gs, id) && buyAllowed(gs, id);
|
||||
const affordable = count > 0 && p.buys > 0 && p.coins >= def.cost;
|
||||
if (affordable) {
|
||||
hit.setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerup', () => this.humanBuy(id));
|
||||
|
|
@ -797,32 +790,9 @@ export default class DominionGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
|
||||
const provLeft = gs.supply.province ?? 0;
|
||||
const colonyNote = gs.supply.colony !== undefined ? ` Colonies left: ${gs.supply.colony ?? 0}` : '';
|
||||
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft}${colonyNote} Empty piles: ${emptyPileCount(gs)}/3`, {
|
||||
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft} Empty piles: ${emptyPileCount(gs)}/3`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
|
||||
this.renderVpTokens();
|
||||
}
|
||||
|
||||
// VP-token badges (Prosperity: Monument, Bishop, …). Drawn per seat near each
|
||||
// portrait; only shown for seats that have accrued tokens.
|
||||
renderVpTokens() {
|
||||
const gs = this.gs;
|
||||
gs.players.forEach((p, seat) => {
|
||||
const n = p.vpTokens ?? 0;
|
||||
if (n <= 0) return;
|
||||
const pos = seat === 0 ? { x: 92 + 46, y: 928 - 46 } : (() => {
|
||||
const s = this.oppSlot(seat - 1); return { x: s.x - s.r - 6, y: s.y - s.r - 2 };
|
||||
})();
|
||||
const g = this.add.graphics().setDepth(D.hud + 1);
|
||||
g.fillStyle(0x000000, 0.82); g.fillCircle(pos.x, pos.y, 18);
|
||||
g.lineStyle(2, COLORS.gold, 1); g.strokeCircle(pos.x, pos.y, 18);
|
||||
this.dynamicLayer.add(g);
|
||||
this.dynamicLayer.add(this.add.text(pos.x, pos.y, `★${n}`, {
|
||||
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud + 2));
|
||||
});
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
|
|
@ -861,9 +831,8 @@ export default class DominionGame extends Phaser.Scene {
|
|||
const fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20);
|
||||
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
|
||||
|
||||
const sheetKey = def.sheet ?? 'dominion-cards';
|
||||
if (this.textures.exists(sheetKey)) {
|
||||
c.add(this.add.image(0, 0, sheetKey, def.frame).setDisplaySize(w - 6, h - 6));
|
||||
if (this.textures.exists('dominion-cards')) {
|
||||
c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6));
|
||||
// legibility band over the lower portion
|
||||
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9));
|
||||
} else {
|
||||
|
|
@ -1357,41 +1326,6 @@ export default class DominionGame extends Phaser.Scene {
|
|||
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
|
||||
case 'sentry':
|
||||
return this.promptSentry(pend);
|
||||
|
||||
// ── Prosperity ────────────────────────────────────────────────────────
|
||||
case 'bishopTrash':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: trash a card for +VP (half its cost).', allowSkip: false, key: 'iid' });
|
||||
case 'bishopMayTrash':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: you may trash a card from your hand.', allowSkip: true, key: 'iid' });
|
||||
case 'vaultDiscard':
|
||||
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Vault: discard any number of cards for +1 Coin each.', confirm: 'Discard' });
|
||||
case 'vaultMayDiscard':
|
||||
return this.promptMultiHand(pend, { min: 0, max: 2, banner: 'Vault: discard 2 cards to draw 1 (or none).', confirm: 'Discard' });
|
||||
case 'expandTrash':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Expand: trash a card (gain one costing up to 3 more).', allowSkip: false, key: 'iid' });
|
||||
case 'forgeTrash':
|
||||
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Forge: trash any number of cards; gain one costing their exact total.', confirm: 'Trash' });
|
||||
case 'forgeGain':
|
||||
return this.promptGain(pend, { exact: true, cost: pend.exactCost });
|
||||
case 'kingsCourtChoose':
|
||||
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: "King's Court: choose an Action to play three times (or skip).", allowSkip: true, key: 'iid' });
|
||||
case 'mintReveal':
|
||||
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mint: reveal a Treasure to gain a copy (or skip).', allowSkip: true, key: 'iid' });
|
||||
case 'anvilDiscard':
|
||||
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Anvil: discard a Treasure to gain a card costing up to 4 (or skip).', allowSkip: true, key: 'iid' });
|
||||
case 'clerkTopdeck':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Clerk: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' });
|
||||
case 'tiaraTopdeck':
|
||||
return this.promptYesNo(`Tiara: put the gained ${getCard(pend.cardId).name} onto your deck?`, (yes) => this.resolveHuman({ topdeck: yes }));
|
||||
case 'crystalBall':
|
||||
return this.promptCrystalBall(pend);
|
||||
case 'investmentChoice':
|
||||
return this.promptInvestment(pend);
|
||||
case 'watchtowerReact':
|
||||
return this.promptWatchtower(pend);
|
||||
case 'warchestGain':
|
||||
return this.promptGain(pend, { maxCost: 5, exclude: pend.named ?? [] });
|
||||
|
||||
default:
|
||||
return this.resolveHuman({});
|
||||
}
|
||||
|
|
@ -1428,11 +1362,10 @@ export default class DominionGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Single pick from the hand (immediate resolve). key = which choice field.
|
||||
// choiceExtra is merged into the resolved choice (e.g. Investment's mode).
|
||||
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid', choiceExtra = {} }) {
|
||||
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) {
|
||||
this.promptBanner(banner);
|
||||
if (allowSkip) {
|
||||
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null, ...choiceExtra }), { variant: 'ghost' });
|
||||
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' });
|
||||
}
|
||||
for (const hs of this.handSprites) {
|
||||
const ok = filter(hs);
|
||||
|
|
@ -1440,28 +1373,17 @@ export default class DominionGame extends Phaser.Scene {
|
|||
hs.hit.setInteractive({ useHandCursor: true });
|
||||
hs.hit.removeAllListeners('pointerup');
|
||||
this.highlightFace(hs.face, COLORS.danger);
|
||||
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid, ...choiceExtra }));
|
||||
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid }));
|
||||
}
|
||||
}
|
||||
|
||||
// Gain a card from the Supply (highlight eligible piles).
|
||||
// opts: { exact, cost } for an exact-cost gain (Forge); { maxCost, exclude } overrides.
|
||||
promptGain(pend, opts = {}) {
|
||||
const exact = !!opts.exact;
|
||||
const cost = opts.cost ?? pend.exactCost;
|
||||
const maxCost = opts.maxCost ?? pend.maxCost;
|
||||
const exclude = opts.exclude ?? [];
|
||||
const banner = exact
|
||||
? `Gain a card costing exactly ${cost}.`
|
||||
: `Gain a${pend.filterTreasure ? ' Treasure' : ''} card costing up to ${maxCost}.`;
|
||||
this.promptBanner(banner);
|
||||
promptGain(pend) {
|
||||
const treasureNote = pend.filterTreasure ? ' Treasure' : '';
|
||||
this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`);
|
||||
let any = false;
|
||||
for (const sp of this.supplySprites) {
|
||||
if (exclude.includes(sp.id)) continue;
|
||||
const ok = exact
|
||||
? canGain(this.gs, sp.id, cost, false, true)
|
||||
: canGain(this.gs, sp.id, maxCost, pend.filterTreasure);
|
||||
if (!ok) continue;
|
||||
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
|
||||
any = true;
|
||||
sp.hit.setInteractive({ useHandCursor: true });
|
||||
sp.hit.removeAllListeners('pointerup');
|
||||
|
|
@ -1470,42 +1392,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.promptObjs.push(glow);
|
||||
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
|
||||
}
|
||||
if (!any) this.resolveHuman(exact ? {} : { id: null });
|
||||
}
|
||||
|
||||
// Crystal Ball: act on the revealed top card of your deck.
|
||||
promptCrystalBall(pend) {
|
||||
const def = getCard(pend.cardId);
|
||||
this.promptBanner(`Crystal Ball reveals ${def.name}.`);
|
||||
const playable = isType(pend.cardId, 'action') || isType(pend.cardId, 'treasure');
|
||||
let x = CX - (playable ? 285 : 190);
|
||||
this.promptButton(x, 'Trash', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' }); x += 190;
|
||||
this.promptButton(x, 'Discard', () => this.resolveHuman({ action: 'discard' }), { variant: 'ghost' }); x += 190;
|
||||
if (playable) { this.promptButton(x, 'Play', () => this.resolveHuman({ action: 'play' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex }); x += 190; }
|
||||
this.promptButton(x, 'Keep', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
// Investment: choose +1 Coin, or trash a Treasure for VP.
|
||||
promptInvestment(pend) {
|
||||
this.promptBanner('Investment: choose a benefit.');
|
||||
this.promptButton(CX - 150, '+1 Coin', () => this.resolveHuman({ mode: 'coin' }), { variant: 'ghost' });
|
||||
this.promptButton(CX + 150, 'Trash a Treasure', () => {
|
||||
this.clearPrompt();
|
||||
this.promptPickHand(pend, {
|
||||
filter: (c) => isType(c.id, 'treasure'),
|
||||
banner: 'Investment: trash a Treasure (+1 VP, +1 VP per differently named Treasure in hand).',
|
||||
allowSkip: false, key: 'iid',
|
||||
choiceExtra: { mode: 'trash' },
|
||||
});
|
||||
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
|
||||
}
|
||||
|
||||
// Watchtower: react to a just-gained card.
|
||||
promptWatchtower(pend) {
|
||||
this.promptBanner(`Watchtower: you gained ${getCard(pend.cardId).name}. Reveal Watchtower to…`);
|
||||
this.promptButton(CX - 200, 'Trash it', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' });
|
||||
this.promptButton(CX, 'Top-deck it', () => this.resolveHuman({ action: 'topdeck' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
|
||||
this.promptButton(CX + 200, 'Keep it', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
|
||||
if (!any) this.resolveHuman({ id: null });
|
||||
}
|
||||
|
||||
promptYesNo(banner, cb) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
getCard, CARDS, BASE_TREASURES,
|
||||
kingdomFor, isType,
|
||||
} from './DominionCards.js';
|
||||
import { getExpansion, registerExpansionCards } from './expansions/index.js';
|
||||
|
||||
// Mulberry32 — seedable PRNG (mirrors the other games).
|
||||
function rng(seed) {
|
||||
|
|
@ -44,11 +43,9 @@ const HAND_SIZE = 5;
|
|||
|
||||
// ── State construction ──────────────────────────────────────────────────────
|
||||
|
||||
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard', expansion = 'base' } = {}) {
|
||||
registerExpansionCards();
|
||||
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) {
|
||||
const rand = seed === undefined ? Math.random : rng(seed);
|
||||
const exp = getExpansion(expansion);
|
||||
const kingdom = kingdomFor(deckMode, rand, { pool: exp.kingdomPool ?? undefined, presets: exp.presets ?? undefined });
|
||||
const kingdom = kingdomFor(deckMode, rand);
|
||||
|
||||
const state = {
|
||||
playerCount,
|
||||
|
|
@ -65,17 +62,11 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
|
|||
winnerSeats: [],
|
||||
seed: seed ?? null,
|
||||
deckMode,
|
||||
expansion,
|
||||
_rand: rand,
|
||||
_nextIid: 1,
|
||||
// transient per-effect scratch
|
||||
sentryLook: null,
|
||||
libraryAside: null,
|
||||
// expansion scratch (reset each turn / persisted across cloneState)
|
||||
curseIsCopper: false,
|
||||
tiaraTopdeckArmed: 0,
|
||||
tiaraDoubleArmed: false,
|
||||
_warchestNamed: [],
|
||||
};
|
||||
|
||||
const victoryPile = playerCount <= 2 ? 8 : 12;
|
||||
|
|
@ -89,26 +80,17 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
|
|||
state.supply.province = victoryPile;
|
||||
state.supply.curse = 10 * (playerCount - 1);
|
||||
|
||||
// Expansion basic cards (e.g. Prosperity's Platinum & Colony).
|
||||
for (const id of exp.basics ?? []) {
|
||||
state.supply[id] = exp.basicPileSize ? exp.basicPileSize(id, playerCount) : 10;
|
||||
}
|
||||
|
||||
// Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count).
|
||||
for (const id of kingdom) {
|
||||
state.supply[id] = isType(id, 'victory') ? victoryPile : 10;
|
||||
}
|
||||
|
||||
// Expansion setup rules (e.g. Charlatan → Curses count as Coppers).
|
||||
for (const rule of exp.setupRules ?? []) rule(state);
|
||||
|
||||
// Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5.
|
||||
for (let seat = 0; seat < playerCount; seat++) {
|
||||
const p = {
|
||||
seat,
|
||||
deck: [], hand: [], discard: [], inPlay: [],
|
||||
actions: 0, buys: 0, coins: 0,
|
||||
vpTokens: 0,
|
||||
merchantSilverBonus: 0,
|
||||
firstSilverPlayed: false,
|
||||
};
|
||||
|
|
@ -145,7 +127,6 @@ export function cloneState(state) {
|
|||
actions: p.actions,
|
||||
buys: p.buys,
|
||||
coins: p.coins,
|
||||
vpTokens: p.vpTokens ?? 0,
|
||||
merchantSilverBonus: p.merchantSilverBonus,
|
||||
firstSilverPlayed: p.firstSilverPlayed,
|
||||
})),
|
||||
|
|
@ -161,15 +142,10 @@ export function cloneState(state) {
|
|||
winnerSeats: state.winnerSeats.slice(),
|
||||
seed: state.seed,
|
||||
deckMode: state.deckMode,
|
||||
expansion: state.expansion,
|
||||
_rand: state._rand,
|
||||
_nextIid: state._nextIid,
|
||||
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
|
||||
libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null,
|
||||
curseIsCopper: state.curseIsCopper ?? false,
|
||||
tiaraTopdeckArmed: state.tiaraTopdeckArmed ?? 0,
|
||||
tiaraDoubleArmed: state.tiaraDoubleArmed ?? false,
|
||||
_warchestNamed: (state._warchestNamed ?? []).slice(),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
|
@ -206,82 +182,9 @@ function gain(state, seat, id, dest = 'discard') {
|
|||
else if (dest === 'hand') p.hand.push(inst);
|
||||
else p.discard.push(inst);
|
||||
state.log.push({ kind: 'gain', seat, id, dest });
|
||||
runGainTriggers(state, seat, inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
// While-in-play gain triggers (Hoard, Collection, Tiara) and reveal-from-hand
|
||||
// gain reactions (Watchtower). No-op for the base game.
|
||||
function runGainTriggers(state, seat, inst) {
|
||||
const exp = getExpansion(state.expansion);
|
||||
if (exp.id === 'base') return;
|
||||
for (const key of Object.keys(exp.onGain ?? {})) {
|
||||
exp.onGain[key](state, seat, inst, ENGINE_API);
|
||||
if (state.pending) return; // a trigger asked for a decision
|
||||
}
|
||||
for (const key of Object.keys(exp.gainReactions ?? {})) {
|
||||
exp.gainReactions[key](state, seat, inst, ENGINE_API);
|
||||
if (state.pending) return;
|
||||
}
|
||||
}
|
||||
|
||||
function trashFromInPlay(state, seat, iid) {
|
||||
const p = state.players[seat];
|
||||
const idx = p.inPlay.findIndex((c) => c.iid === iid);
|
||||
if (idx === -1) return null;
|
||||
const [c] = p.inPlay.splice(idx, 1);
|
||||
state.trash.push(c);
|
||||
state.log.push({ kind: 'trash', seat, id: c.id });
|
||||
return c;
|
||||
}
|
||||
|
||||
function gainVP(state, seat, n) {
|
||||
if (!n) return;
|
||||
const p = state.players[seat];
|
||||
p.vpTokens = (p.vpTokens ?? 0) + n;
|
||||
state.log.push({ kind: 'vp', seat, n });
|
||||
}
|
||||
|
||||
// A card's current cost: printed cost plus any active expansion cost modifiers
|
||||
// (Quarry, Peddler, …), clamped at 0. Equals the printed cost for the base game.
|
||||
export function cardCost(state, id, ctx = {}) {
|
||||
const def = CARDS[id];
|
||||
if (!def) return Infinity;
|
||||
let cost = def.cost;
|
||||
for (const mod of getExpansion(state.expansion).costModifiers ?? []) {
|
||||
cost += mod(state, id, ctx);
|
||||
}
|
||||
return Math.max(0, cost);
|
||||
}
|
||||
|
||||
// The surface expansion effect/resolver/task functions operate through. All
|
||||
// helpers are hoisted function declarations, so referencing them here is safe.
|
||||
const ENGINE_API = {
|
||||
getCard, isType, CARDS,
|
||||
cardCost,
|
||||
emptyPileCount: (state) => emptyPileCount(state),
|
||||
otherSeats: (state, seat) => otherSeats(state, seat),
|
||||
draw: (state, seat, n) => drawInto(state, state.players[seat], n),
|
||||
gain: (state, seat, id, dest) => gain(state, seat, id, dest),
|
||||
reshuffle: (state, seat) => reshuffle(state, state.players[seat]),
|
||||
trashFromHand: (state, seat, iid) => trashFrom(state, state.players[seat], iid),
|
||||
trashFromInPlay: (state, seat, iid) => trashFromInPlay(state, seat, iid),
|
||||
trashCard: (state, card) => {
|
||||
state.trash.push({ iid: card.iid, id: card.id });
|
||||
state.log.push({ kind: 'trash', seat: state.turn, id: card.id });
|
||||
},
|
||||
discardFromHand: (state, seat, iid) => discardFromHand(state, state.players[seat], iid),
|
||||
gainVP: (state, seat, n) => gainVP(state, seat, n),
|
||||
queueAttack: (state, seat, factory, source) => queueAttack(state, seat, factory, source),
|
||||
queue: (state, tasks) => state.queue.unshift(...tasks),
|
||||
setPending: (state, pend) => { state.pending = pend; },
|
||||
log: (state, entry) => state.log.push(entry),
|
||||
countOwned: (state, seat, id) => allCards(state, seat).filter((c) => c.id === id).length,
|
||||
warchestName: (state, namerSeat, ownerSeat) =>
|
||||
getExpansion(state.expansion).ai?.warchestName?.(state, namerSeat, ownerSeat, ENGINE_API) ?? null,
|
||||
playTreasureEffect: (state, seat, card) => resolveTreasure(state, seat, card),
|
||||
};
|
||||
|
||||
function trashFrom(state, p, iid) {
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||
if (idx === -1) return null;
|
||||
|
|
@ -311,10 +214,6 @@ function startTurn(state) {
|
|||
state.phase = 'action';
|
||||
state.pending = null;
|
||||
state.queue = [];
|
||||
// Reset per-turn expansion scratch.
|
||||
state.tiaraTopdeckArmed = 0;
|
||||
state.tiaraDoubleArmed = false;
|
||||
state._warchestNamed = [];
|
||||
state.turnsTaken[state.turn] += 1;
|
||||
state.log.push({ kind: 'turnStart', seat: state.turn });
|
||||
}
|
||||
|
|
@ -387,7 +286,6 @@ export function finalScores(state) {
|
|||
if (c.id === 'gardens') vp += Math.floor(cards.length / 10);
|
||||
else if (def.vp !== undefined) vp += def.vp;
|
||||
}
|
||||
vp += p.vpTokens ?? 0;
|
||||
return { seat: p.seat, vp, cards: cards.length };
|
||||
});
|
||||
}
|
||||
|
|
@ -435,8 +333,7 @@ export function playAllTreasures(state) {
|
|||
const next = cloneState(state);
|
||||
const p = next.players[next.turn];
|
||||
let idx;
|
||||
// Stop if a treasure effect (Anvil, Crystal Ball, …) demands a decision.
|
||||
while (!next.pending && (idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
|
||||
while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
|
||||
applyTreasure(next, next.turn, idx);
|
||||
}
|
||||
return next;
|
||||
|
|
@ -446,37 +343,13 @@ function applyTreasure(state, seat, handIdx) {
|
|||
const p = state.players[seat];
|
||||
const [card] = p.hand.splice(handIdx, 1);
|
||||
p.inPlay.push(card);
|
||||
resolveTreasure(state, seat, card);
|
||||
state.log.push({ kind: 'playTreasure', seat, id: card.id });
|
||||
runQueue(state);
|
||||
}
|
||||
|
||||
// Apply a treasure's value + vanilla bonuses + any expansion on-play effect for
|
||||
// an already-in-play card. Used by applyTreasure and by cards that play a
|
||||
// treasure from elsewhere (e.g. Crystal Ball).
|
||||
function resolveTreasure(state, seat, card) {
|
||||
const p = state.players[seat];
|
||||
const def = getCard(card.id);
|
||||
let coin = def.coin ?? 0;
|
||||
p.coins += def.coin ?? 0;
|
||||
if (card.id === 'silver' && !p.firstSilverPlayed) {
|
||||
coin += p.merchantSilverBonus;
|
||||
p.coins += p.merchantSilverBonus;
|
||||
p.firstSilverPlayed = true;
|
||||
}
|
||||
// Tiara (approximate): the first treasure played each turn while Tiara is in
|
||||
// play produces double its coins. Single-shot for now.
|
||||
if (state.tiaraDoubleArmed && coin > 0) {
|
||||
coin *= 2;
|
||||
state.tiaraDoubleArmed = false;
|
||||
}
|
||||
p.coins += coin;
|
||||
// Vanilla bonuses some treasures carry (Collection/Tiara +Buy, etc.).
|
||||
if (def.plus.actions) p.actions += def.plus.actions;
|
||||
if (def.plus.buys) p.buys += def.plus.buys;
|
||||
if (def.plus.coins) p.coins += def.plus.coins;
|
||||
if (def.plus.cards) drawInto(state, p, def.plus.cards);
|
||||
// Expansion treasure on-play effect.
|
||||
const fn = getExpansion(state.expansion).treasureEffects?.[card.id];
|
||||
if (fn) fn(state, seat, ENGINE_API);
|
||||
state.log.push({ kind: 'playTreasure', seat, id: card.id });
|
||||
}
|
||||
|
||||
export function buyCard(state, id) {
|
||||
|
|
@ -486,35 +359,17 @@ export function buyCard(state, id) {
|
|||
if (!def) return state;
|
||||
if (p.buys <= 0) return state;
|
||||
if ((state.supply[id] ?? 0) <= 0) return state;
|
||||
const cost = cardCost(state, id, { seat: state.turn, phase: 'buy' });
|
||||
if (p.coins < cost) return state;
|
||||
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
|
||||
if (restrict && !restrict(state, state.turn)) return state;
|
||||
if (p.coins < def.cost) return state;
|
||||
|
||||
const next = cloneState(state);
|
||||
const np = next.players[next.turn];
|
||||
np.coins -= cost;
|
||||
np.coins -= def.cost;
|
||||
np.buys -= 1;
|
||||
gain(next, next.turn, id, 'discard');
|
||||
next.log.push({ kind: 'buy', seat: next.turn, id });
|
||||
const onBuy = getExpansion(next.expansion).onBuy?.[id];
|
||||
if (onBuy) onBuy(next, next.turn, ENGINE_API);
|
||||
runQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// Current cost to BUY a card now (printed + modifiers), for the UI/AI.
|
||||
export function buyCost(state, id) {
|
||||
return cardCost(state, id, { seat: state.turn, phase: 'buy' });
|
||||
}
|
||||
|
||||
// Whether the active player could buy `id` right now (ignoring coins), honoring
|
||||
// expansion buy restrictions (e.g. Grand Market with Copper in play).
|
||||
export function buyAllowed(state, id) {
|
||||
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
|
||||
return !restrict || restrict(state, state.turn);
|
||||
}
|
||||
|
||||
// ── Effect engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Process queued tasks depth-first until the queue drains or a player decision
|
||||
|
|
@ -566,12 +421,8 @@ function execTask(state, task) {
|
|||
case 'bureaucratAttack':
|
||||
bureaucratAttack(state, task.seat);
|
||||
break;
|
||||
default: {
|
||||
// Expansion-defined task type.
|
||||
const fn = getExpansion(state.expansion).tasks?.[task.type];
|
||||
if (fn) fn(state, task, ENGINE_API);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -586,9 +437,9 @@ function applyEffect(state, seat, id) {
|
|||
if (def.plus.coins) p.coins += def.plus.coins;
|
||||
if (def.plus.cards) drawInto(state, p, def.plus.cards);
|
||||
|
||||
// Card-specific: base table first, then the active expansion's effects.
|
||||
const fn = SPECIAL[id] ?? getExpansion(state.expansion).effects?.[id];
|
||||
if (fn) fn(state, seat, ENGINE_API);
|
||||
// Card-specific.
|
||||
const fn = SPECIAL[id];
|
||||
if (fn) fn(state, seat);
|
||||
}
|
||||
|
||||
// Other players' seats in turn order starting after `seat`.
|
||||
|
|
@ -844,7 +695,7 @@ export function resolvePending(state, choice) {
|
|||
const iid = choice?.iid;
|
||||
const c = iid != null ? trashFrom(next, p, iid) : null;
|
||||
if (c) {
|
||||
const maxCost = cardCost(next, c.id, { seat }) + 2;
|
||||
const maxCost = getCard(c.id).cost + 2;
|
||||
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' };
|
||||
}
|
||||
break;
|
||||
|
|
@ -856,7 +707,7 @@ export function resolvePending(state, choice) {
|
|||
const [c] = p.hand.splice(idx, 1);
|
||||
next.trash.push(c);
|
||||
next.log.push({ kind: 'trash', seat, id: c.id });
|
||||
const maxCost = cardCost(next, c.id, { seat }) + 3;
|
||||
const maxCost = getCard(c.id).cost + 3;
|
||||
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' };
|
||||
}
|
||||
break;
|
||||
|
|
@ -969,12 +820,8 @@ export function resolvePending(state, choice) {
|
|||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Expansion-defined pending kind.
|
||||
const fn = getExpansion(next.expansion).resolvers?.[pend.kind];
|
||||
if (fn) fn(next, pend, choice, ENGINE_API);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
runQueue(next);
|
||||
|
|
@ -983,24 +830,21 @@ export function resolvePending(state, choice) {
|
|||
|
||||
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
|
||||
|
||||
// `exactCost` (Forge) requires cost === maxCost rather than ≤.
|
||||
export function canGain(state, id, maxCost, filterTreasure = false, exactCost = false) {
|
||||
export function canGain(state, id, maxCost, filterTreasure = false) {
|
||||
const def = CARDS[id];
|
||||
if (!def) return false;
|
||||
if ((state.supply[id] ?? 0) <= 0) return false;
|
||||
const cost = cardCost(state, id, { seat: state.turn });
|
||||
if (exactCost ? cost !== maxCost : cost > maxCost) return false;
|
||||
if (def.cost > maxCost) return false;
|
||||
if (filterTreasure && !def.types.includes('treasure')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function affordableSupply(state, maxCost, filterTreasure = false, exactCost = false) {
|
||||
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure, exactCost));
|
||||
export function affordableSupply(state, maxCost, filterTreasure = false) {
|
||||
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure));
|
||||
}
|
||||
|
||||
export function supplyIds(state) {
|
||||
const basics = getExpansion(state.expansion).basics ?? [];
|
||||
return [...BASE_TREASURES, ...basics, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
|
||||
return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
|
||||
}
|
||||
|
||||
export function emptyPileCount(state) {
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
// Dominion — expansion registry.
|
||||
//
|
||||
// The base game's card effects live in DominionLogic's built-in tables. An
|
||||
// expansion is pure data plus effect functions that operate through the engine
|
||||
// `api` object the dispatcher passes in (see DominionLogic's ENGINE_API). This
|
||||
// keeps base play untouched: the engine composes `base + activeExpansion` only
|
||||
// when a non-base expansion is selected.
|
||||
//
|
||||
// Each expansion exposes any of:
|
||||
// cards – card defs to merge into the global CARDS map
|
||||
// basics – extra basic-supply pile ids active while this expansion is in play
|
||||
// basicPileSize – (id) -> pile size for those basics
|
||||
// kingdomPool – ids Random draws from
|
||||
// presets – { deckMode: [10 ids] } recommended layouts
|
||||
// setupRules – [ (state) => void ] applied once at game start
|
||||
// effects – { id: (state, seat, api) => void } Action on-play effects
|
||||
// treasureEffects – { id: (state, seat, api) => void } Treasure on-play effects
|
||||
// onGain – { id: (state, gainSeat, gainedId, api) => void } while-in-play gain triggers
|
||||
// gainReactions – { id: (state, gainSeat, gainedIid, api) => void } reveal-from-hand reactions
|
||||
// onBuy – { id: (state, seat, api) => void } when this card is bought
|
||||
// costModifiers – [ (state, id, ctx) => number ] delta applied to a card's cost
|
||||
// buyRestrictions – { id: (state, seat) => boolean } false = cannot buy right now
|
||||
// tasks – { type: (state, task, api) => void } custom queue task handlers
|
||||
// resolvers – { kind: (state, pend, choice, api) => void } custom pending resolution
|
||||
// ai – { terminalPriority, engineBuys, pending } AI hooks
|
||||
|
||||
import { CARDS } from '../DominionCards.js';
|
||||
import { prosperity } from './prosperity.js';
|
||||
|
||||
const BASE = {
|
||||
id: 'base',
|
||||
name: 'Base Game',
|
||||
cards: [],
|
||||
basics: [],
|
||||
// null → use the base KINGDOM_PRESETS / KINGDOM_POOL in DominionCards.
|
||||
presets: null,
|
||||
kingdomPool: null,
|
||||
};
|
||||
|
||||
export const EXPANSIONS = {
|
||||
base: BASE,
|
||||
prosperity,
|
||||
};
|
||||
|
||||
// Order shown in the setup screen.
|
||||
export const EXPANSION_ORDER = ['base', 'prosperity'];
|
||||
|
||||
export function getExpansion(id) {
|
||||
return EXPANSIONS[id] ?? BASE;
|
||||
}
|
||||
|
||||
// Merge every expansion's card defs into the global CARDS map so getCard()
|
||||
// resolves them everywhere. Idempotent; called from createInitialState.
|
||||
let _registered = false;
|
||||
export function registerExpansionCards() {
|
||||
if (_registered) return;
|
||||
for (const exp of Object.values(EXPANSIONS)) {
|
||||
for (const c of exp.cards ?? []) CARDS[c.id] = c;
|
||||
}
|
||||
_registered = true;
|
||||
}
|
||||
|
|
@ -1,672 +0,0 @@
|
|||
// Dominion — Prosperity expansion (2nd edition, full 25-card set + Colony/Platinum).
|
||||
//
|
||||
// Pure data + effect functions. Effects never touch Phaser and never import the
|
||||
// engine directly; they operate through the `api` object the dispatcher passes
|
||||
// (see DominionLogic's ENGINE_API). State stays plain/cloneable — only data is
|
||||
// written onto it.
|
||||
//
|
||||
// ── Spritesheet: public/assets/images/dominion-prosperity.png ───────────────────
|
||||
// 270×390 per cell, same convention as the base sheet (art fills the top; the
|
||||
// title/icon band is drawn at runtime over the lower portion). Frame index is
|
||||
// flat, left→right, top→bottom. THIS ORDER MUST MATCH THE ART:
|
||||
//
|
||||
// 0 platinum 1 colony
|
||||
// ── $3 ── 2 anvil 3 watchtower
|
||||
// ── $4 ── 4 bishop 5 clerk 6 investment 7 monument
|
||||
// 8 quarry 9 tiara 10 workersvillage
|
||||
// ── $5 ── 11 charlatan 12 city 13 collection 14 crystalball
|
||||
// 15 magnate 16 mint 17 rabble 18 vault 19 warchest
|
||||
// ── $6 ── 20 grandmarket 21 hoard
|
||||
// ── $7 ── 22 bank 23 expand 24 forge 25 kingscourt
|
||||
// ── $8 ── 26 peddler
|
||||
//
|
||||
// NOTE: A handful of cards (Tiara, War Chest, Charlatan's setup rule, Clerk's
|
||||
// start-of-turn reaction) have subtle official wording; the chosen interpretation
|
||||
// is commented at each. Adjust freely against your physical copy.
|
||||
|
||||
import { def, isType } from '../DominionCards.js';
|
||||
|
||||
const SHEET = 'dominion-prosperity';
|
||||
const d = (id, frame, cost, types, extra = {}) =>
|
||||
def(id, frame, cost, types, { ...extra, sheet: SHEET });
|
||||
|
||||
// ── Card table ──────────────────────────────────────────────────────────────
|
||||
const CARD_DEFS = [
|
||||
// Basic cards (added to the supply whenever Prosperity is in play).
|
||||
d('platinum', 0, 5, ['treasure'], { name: 'Platinum', coin: 5, text: 'Worth 5 Coins.' }),
|
||||
d('colony', 1, 11, ['victory'], { name: 'Colony', vp: 10, text: 'Worth 10 Victory Points.' }),
|
||||
|
||||
// $3
|
||||
d('anvil', 2, 3, ['treasure'], { name: 'Anvil', coin: 1, text: '+1 Coin. When you play this, you may discard a Treasure to gain a card costing up to 4 Coins.' }),
|
||||
d('watchtower', 3, 3, ['action', 'reaction'], { name: 'Watchtower', text: 'Draw until you have 6 cards in hand. When you gain a card, you may reveal this from your hand, to either trash that card or put it onto your deck.' }),
|
||||
|
||||
// $4
|
||||
d('bishop', 4, 4, ['action'], { name: 'Bishop', plus: { coins: 1 }, text: '+1 Coin, +1 VP. Trash a card from your hand. +VP equal to half its cost in Coins, rounded down. Each other player may trash a card from their hand.' }),
|
||||
d('clerk', 5, 4, ['action', 'reaction', 'attack'], { name: 'Clerk', plus: { coins: 2 }, text: '+2 Coins. Each other player with 5 or more cards in hand puts one onto their deck. At the start of your turn, you may play this from your hand.' }),
|
||||
d('investment', 6, 4, ['treasure'], { name: 'Investment', coin: 0, text: 'Trash this. Choose one: +1 Coin; or trash a Treasure from your hand, +1 VP, and reveal your hand for +1 VP per differently named Treasure in it.' }),
|
||||
d('monument', 7, 4, ['action'], { name: 'Monument', plus: { coins: 2 }, text: '+2 Coins, +1 VP.' }),
|
||||
d('quarry', 8, 4, ['treasure'], { name: 'Quarry', coin: 1, text: '+1 Coin. While this is in play, Action cards cost 2 Coins less, but not less than 0.' }),
|
||||
d('tiara', 9, 4, ['treasure'], { name: 'Tiara', plus: { buys: 1 }, coin: 0, text: '+1 Buy. The next time you gain a card this turn, you may put it onto your deck. While this is in play, the first time you play a Treasure each turn, it produces double its Coins.' }),
|
||||
d('workersvillage', 10, 4, ['action'], { name: "Worker's Village", plus: { cards: 1, actions: 2, buys: 1 }, text: '+1 Card, +2 Actions, +1 Buy.' }),
|
||||
|
||||
// $5
|
||||
d('charlatan', 11, 5, ['action', 'attack'], { name: 'Charlatan', plus: { coins: 3 }, text: '+3 Coins. Each other player gains a Curse. (In games using this, Curses are also Coppers worth 1 Coin.)' }),
|
||||
d('city', 12, 5, ['action'], { name: 'City', plus: { cards: 1, actions: 2 }, text: '+1 Card, +2 Actions. If there is 1 or more empty Supply pile, +1 Card. If 2 or more, +1 Buy and +1 Coin.' }),
|
||||
d('collection', 13, 5, ['treasure'], { name: 'Collection', plus: { buys: 1 }, coin: 2, text: '+2 Coins, +1 Buy. While this is in play, when you gain an Action card, +1 VP.' }),
|
||||
d('crystalball', 14, 5, ['treasure'], { name: 'Crystal Ball', coin: 1, text: '+1 Coin. When you play this, look at the top card of your deck. You may trash it, discard it, or play it if it is an Action or Treasure.' }),
|
||||
d('magnate', 15, 5, ['treasure'], { name: 'Magnate', coin: 0, text: 'Reveal your hand. +1 Card per Treasure in it.' }),
|
||||
d('mint', 16, 5, ['action'], { name: 'Mint', text: 'You may reveal a Treasure from your hand. Gain a copy of it. (When you buy this, trash all Treasures you have in play.)' }),
|
||||
d('rabble', 17, 5, ['action', 'attack'], { name: 'Rabble', plus: { cards: 3 }, text: '+3 Cards. Each other player reveals the top 3 cards of their deck, discards the Actions and Treasures, and puts the rest back on top in any order.' }),
|
||||
d('vault', 18, 5, ['action'], { name: 'Vault', plus: { cards: 2 }, text: '+2 Cards. Discard any number of cards for +1 Coin each. Each other player may discard 2 cards to draw a card.' }),
|
||||
d('warchest', 19, 5, ['treasure'], { name: 'War Chest', coin: 0, text: 'The player to your left names a card. Gain a card costing up to 5 Coins that has not been named for this War Chest this turn.' }),
|
||||
|
||||
// $6
|
||||
d('grandmarket', 20, 6, ['action'], { name: 'Grand Market', plus: { cards: 1, actions: 1, buys: 1, coins: 2 }, text: "+1 Card, +1 Action, +1 Buy, +2 Coins. You can't buy this if you have any Copper in play." }),
|
||||
d('hoard', 21, 6, ['treasure'], { name: 'Hoard', coin: 2, text: '+2 Coins. While this is in play, when you gain a Victory card, gain a Gold.' }),
|
||||
|
||||
// $7
|
||||
d('bank', 22, 7, ['treasure'], { name: 'Bank', coin: 0, text: 'When you play this, it is worth 1 Coin per Treasure you have in play (counting this).' }),
|
||||
d('expand', 23, 7, ['action'], { name: 'Expand', text: 'Trash a card from your hand. Gain a card costing up to 3 Coins more than it.' }),
|
||||
d('forge', 24, 7, ['action'], { name: 'Forge', text: 'Trash any number of cards from your hand. Gain a card costing exactly the total Coin cost of the trashed cards.' }),
|
||||
d('kingscourt', 25, 7, ['action'], { name: "King's Court", text: 'You may play an Action card from your hand three times.' }),
|
||||
|
||||
// $8
|
||||
d('peddler', 26, 8, ['action'], { name: 'Peddler', plus: { cards: 1, actions: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Coin. During your Buy phase, this costs 2 Coins less per Action card you have in play.' }),
|
||||
];
|
||||
|
||||
// 25 Kingdom ids (everything except the two basics), in the frame order above.
|
||||
const KINGDOM_POOL = CARD_DEFS.filter((c) => c.id !== 'platinum' && c.id !== 'colony').map((c) => c.id);
|
||||
|
||||
// ── Recommended Kingdoms ──────────────────────────────────────────────────────
|
||||
// Themed 10-card layouts. Each uses only implemented Prosperity cards. Labels are
|
||||
// shown as pills in the setup screen; tweak freely.
|
||||
const PRESETS = {
|
||||
'beginners': ['bishop', 'city', 'expand', 'forge', 'hoard', 'monument', 'rabble', 'vault', 'watchtower', 'workersvillage'],
|
||||
'friendly-interactive':['bishop', 'charlatan', 'city', 'collection', 'grandmarket', 'hoard', 'magnate', 'monument', 'rabble', 'vault'],
|
||||
'bigger-treasures': ['anvil', 'bank', 'crystalball', 'hoard', 'investment', 'magnate', 'mint', 'quarry', 'tiara', 'warchest'],
|
||||
'the-king': ['bank', 'city', 'expand', 'kingscourt', 'monument', 'peddler', 'quarry', 'rabble', 'vault', 'watchtower'],
|
||||
};
|
||||
|
||||
export const PROSPERITY_PRESET_LABELS = {
|
||||
'beginners': 'Beginners',
|
||||
'friendly-interactive':'Friendly Interactive',
|
||||
'bigger-treasures': 'Bigger Treasures',
|
||||
'the-king': 'The King',
|
||||
};
|
||||
|
||||
// ── Setup rules ───────────────────────────────────────────────────────────────
|
||||
// Charlatan: while it is in the Supply, Curses are also Coppers (worth 1 Coin).
|
||||
function charlatanCurseIsCopper(state) {
|
||||
if (!state.kingdom.includes('charlatan')) return;
|
||||
state.curseIsCopper = true; // engine treats Curse as a $1 treasure when this is set
|
||||
}
|
||||
|
||||
// ── Small helpers ───────────────────────────────────────────────────────────
|
||||
const isTreasure = (id) => isType(id, 'treasure');
|
||||
const isAction = (id) => isType(id, 'action');
|
||||
const isVictory = (id) => isType(id, 'victory');
|
||||
|
||||
// ── Action effects (on-play) ──────────────────────────────────────────────────
|
||||
const effects = {
|
||||
bishop(state, seat, api) {
|
||||
api.gainVP(state, seat, 1);
|
||||
const p = state.players[seat];
|
||||
// Always trash one (player may have to trash their only card; engine allows
|
||||
// trashing nothing only if hand is empty).
|
||||
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'bishopTrash' });
|
||||
else queueBishopOthers(state, seat, api);
|
||||
},
|
||||
|
||||
monument(state, seat, api) {
|
||||
api.gainVP(state, seat, 1);
|
||||
},
|
||||
|
||||
city(state, seat, api) {
|
||||
const empties = api.emptyPileCount(state);
|
||||
const p = state.players[seat];
|
||||
if (empties >= 1) api.draw(state, seat, 1);
|
||||
if (empties >= 2) { p.buys += 1; p.coins += 1; }
|
||||
},
|
||||
|
||||
rabble(state, seat, api) {
|
||||
api.queueAttack(state, seat, (o) => ({ type: 'rabbleAttack', seat: o }), 'rabble');
|
||||
},
|
||||
|
||||
charlatan(state, seat, api) {
|
||||
api.queueAttack(state, seat, (o) => ({ type: 'gainCurse', seat: o }), 'charlatan');
|
||||
},
|
||||
|
||||
clerk(state, seat, api) {
|
||||
// Each other player with 5+ cards topdecks one (their choice). Moat-gated.
|
||||
api.queueAttack(state, seat, (o) => ({ type: 'clerkTopdeck', seat: o }), 'clerk');
|
||||
},
|
||||
|
||||
vault(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'vaultDiscard' });
|
||||
else queueVaultOthers(state, seat, api);
|
||||
},
|
||||
|
||||
expand(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'expandTrash' });
|
||||
},
|
||||
|
||||
forge(state, seat, api) {
|
||||
api.setPending(state, { seat, kind: 'forgeTrash' });
|
||||
},
|
||||
|
||||
kingscourt(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.some((c) => isAction(c.id))) api.setPending(state, { seat, kind: 'kingsCourtChoose' });
|
||||
},
|
||||
|
||||
mint(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'mintReveal' });
|
||||
},
|
||||
|
||||
watchtower(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
let guard = 0;
|
||||
while (p.hand.length < 6 && guard++ < 20) {
|
||||
if (api.draw(state, seat, 1) === 0) break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Treasure effects (on-play) ──────────────────────────────────────────────
|
||||
const treasureEffects = {
|
||||
bank(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
const treasures = p.inPlay.filter((c) => isTreasure(c.id)).length; // Bank already in play
|
||||
p.coins += treasures;
|
||||
},
|
||||
|
||||
magnate(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
const treasures = p.hand.filter((c) => isTreasure(c.id)).length;
|
||||
if (treasures > 0) api.draw(state, seat, treasures);
|
||||
},
|
||||
|
||||
anvil(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
// May discard a Treasure (other than this Anvil, still in hand?) to gain up to $4.
|
||||
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'anvilDiscard' });
|
||||
},
|
||||
|
||||
crystalball(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
if (p.deck.length === 0) api.reshuffle(state, seat);
|
||||
if (p.deck.length === 0) return;
|
||||
const top = p.deck[0];
|
||||
api.setPending(state, { seat, kind: 'crystalBall', cardIid: top.iid, cardId: top.id });
|
||||
},
|
||||
|
||||
investment(state, seat, api) {
|
||||
// Trash this Investment from play, then choose mode.
|
||||
api.trashFromInPlay(state, seat, currentlyPlayed(state, seat, 'investment'));
|
||||
api.setPending(state, { seat, kind: 'investmentChoice' });
|
||||
},
|
||||
|
||||
collection(state, seat, api) {
|
||||
// Coins/buys applied via def.plus in applyTreasure; gain trigger handled in onGain.
|
||||
},
|
||||
|
||||
quarry() { /* cost reduction handled by costModifiers */ },
|
||||
|
||||
hoard() { /* gain trigger handled by onGain */ },
|
||||
|
||||
tiara(state, seat, api) {
|
||||
// Arm "next gain may be topdecked" for the rest of this turn.
|
||||
state.tiaraTopdeckArmed = (state.tiaraTopdeckArmed ?? 0) + 1;
|
||||
// (Treasure-doubling for the first Treasure each turn is approximated: the
|
||||
// engine doubles the next Treasure's coin output via state.tiaraDoubleArmed.)
|
||||
state.tiaraDoubleArmed = true;
|
||||
},
|
||||
|
||||
warchest(state, seat, api) {
|
||||
// The player to your left names a card to deny; we gain the best legal card
|
||||
// costing up to $5 that wasn't named for this War Chest this turn.
|
||||
const left = (seat + 1) % state.playerCount;
|
||||
state._warchestNamed ??= [];
|
||||
const named = api.warchestName(state, left, seat); // AI/opponent names a card id
|
||||
if (named) state._warchestNamed.push(named);
|
||||
api.setPending(state, { seat, kind: 'warchestGain', named: state._warchestNamed.slice() });
|
||||
},
|
||||
};
|
||||
|
||||
function currentlyPlayed(state, seat, id) {
|
||||
const p = state.players[seat];
|
||||
// The just-played copy is the last matching card in play.
|
||||
for (let i = p.inPlay.length - 1; i >= 0; i--) if (p.inPlay[i].id === id) return p.inPlay[i].iid;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── While-in-play gain triggers ───────────────────────────────────────────────
|
||||
const onGain = {
|
||||
hoard(state, gainSeat, gainedInst, api) {
|
||||
// Active player's Hoard: when they gain a Victory card (by any means), gain a Gold.
|
||||
if (gainSeat !== state.turn) return;
|
||||
if (!isVictory(gainedInst.id)) return;
|
||||
const hoards = state.players[gainSeat].inPlay.filter((c) => c.id === 'hoard').length;
|
||||
for (let i = 0; i < hoards; i++) api.gain(state, gainSeat, 'gold', 'discard');
|
||||
},
|
||||
collection(state, gainSeat, gainedInst, api) {
|
||||
if (gainSeat !== state.turn) return;
|
||||
if (!isAction(gainedInst.id)) return;
|
||||
const n = state.players[gainSeat].inPlay.filter((c) => c.id === 'collection').length;
|
||||
if (n > 0) api.gainVP(state, gainSeat, n);
|
||||
},
|
||||
tiara(state, gainSeat, gainedInst, api) {
|
||||
if (gainSeat !== state.turn) return;
|
||||
if (!(state.tiaraTopdeckArmed > 0)) return;
|
||||
// Offer to topdeck the just-gained card (only if it's somewhere we can move it).
|
||||
state.tiaraTopdeckArmed -= 1;
|
||||
api.setPending(state, { seat: gainSeat, kind: 'tiaraTopdeck', cardIid: gainedInst.iid, cardId: gainedInst.id });
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reaction-on-gain (revealed from hand) ─────────────────────────────────────
|
||||
const gainReactions = {
|
||||
watchtower(state, gainSeat, gainedInst, api) {
|
||||
const p = state.players[gainSeat];
|
||||
if (!p.hand.some((c) => c.id === 'watchtower')) return;
|
||||
api.setPending(state, { seat: gainSeat, kind: 'watchtowerReact', cardIid: gainedInst.iid, cardId: gainedInst.id });
|
||||
},
|
||||
};
|
||||
|
||||
// ── On-buy effects ────────────────────────────────────────────────────────────
|
||||
const onBuy = {
|
||||
mint(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
const treasures = p.inPlay.filter((c) => isTreasure(c.id));
|
||||
p.inPlay = p.inPlay.filter((c) => !isTreasure(c.id));
|
||||
for (const c of treasures) api.trashCard(state, c);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Cost modifiers ────────────────────────────────────────────────────────────
|
||||
function quarryMod(state, id, ctx) {
|
||||
// Quarry: while in play, Action cards cost $2 less each Quarry.
|
||||
if (!isAction(id)) return 0;
|
||||
const seat = ctx.seat ?? state.turn;
|
||||
const n = state.players[seat]?.inPlay.filter((c) => c.id === 'quarry').length ?? 0;
|
||||
return -2 * n;
|
||||
}
|
||||
function peddlerMod(state, id, ctx) {
|
||||
// Peddler: during the owner's Buy phase, costs $2 less per Action in play.
|
||||
if (id !== 'peddler') return 0;
|
||||
if (ctx.phase !== 'buy') return 0;
|
||||
const seat = ctx.seat ?? state.turn;
|
||||
const actions = state.players[seat]?.inPlay.filter((c) => isAction(c.id)).length ?? 0;
|
||||
return -2 * actions;
|
||||
}
|
||||
|
||||
// ── Buy restrictions ──────────────────────────────────────────────────────────
|
||||
const buyRestrictions = {
|
||||
grandmarket(state, seat) {
|
||||
// Can't buy Grand Market with any Copper in play.
|
||||
return !state.players[seat].inPlay.some((c) => c.id === 'copper');
|
||||
},
|
||||
};
|
||||
|
||||
// ── Custom queue tasks ────────────────────────────────────────────────────────
|
||||
const tasks = {
|
||||
gainCurse(state, task, api) {
|
||||
api.gain(state, task.seat, 'curse', 'discard');
|
||||
},
|
||||
rabbleAttack(state, task, api) {
|
||||
const seat = task.seat;
|
||||
const p = state.players[seat];
|
||||
const revealed = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (p.deck.length === 0) api.reshuffle(state, seat);
|
||||
if (p.deck.length === 0) break;
|
||||
revealed.push(p.deck.shift());
|
||||
}
|
||||
api.log(state, { kind: 'reveal', seat, ids: revealed.map((c) => c.id) });
|
||||
const back = [];
|
||||
for (const c of revealed) {
|
||||
if (isAction(c.id) || isTreasure(c.id)) p.discard.push(c);
|
||||
else back.push(c);
|
||||
}
|
||||
// Put the rest back on top (victims rarely care about order vs the AI; keep order).
|
||||
for (let i = back.length - 1; i >= 0; i--) p.deck.unshift(back[i]);
|
||||
},
|
||||
clerkTopdeck(state, task, api) {
|
||||
const seat = task.seat;
|
||||
const p = state.players[seat];
|
||||
if (p.hand.length >= 5) api.setPending(state, { seat, kind: 'clerkTopdeck' });
|
||||
},
|
||||
bishopMayTrash(state, task, api) {
|
||||
const seat = task.seat;
|
||||
if (state.players[seat].hand.length > 0) api.setPending(state, { seat, kind: 'bishopMayTrash' });
|
||||
},
|
||||
vaultMayDiscard(state, task, api) {
|
||||
const seat = task.seat;
|
||||
if (state.players[seat].hand.length >= 2) api.setPending(state, { seat, kind: 'vaultMayDiscard' });
|
||||
},
|
||||
};
|
||||
|
||||
function queueBishopOthers(state, seat, api) {
|
||||
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'bishopMayTrash', seat: o })));
|
||||
}
|
||||
function queueVaultOthers(state, seat, api) {
|
||||
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'vaultMayDiscard', seat: o })));
|
||||
}
|
||||
|
||||
// ── Pending resolution (applies a chosen action; engine resumes the queue) ─────
|
||||
const resolvers = {
|
||||
bishopTrash(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const iid = choice?.iid;
|
||||
const c = iid != null ? api.trashFromHand(state, seat, iid) : null;
|
||||
if (c) {
|
||||
const half = Math.floor(api.cardCost(state, c.id, { seat }) / 2);
|
||||
if (half > 0) api.gainVP(state, seat, half);
|
||||
}
|
||||
queueBishopOthers(state, seat, api);
|
||||
},
|
||||
bishopMayTrash(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
|
||||
},
|
||||
vaultDiscard(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
for (const iid of iids) api.discardFromHand(state, seat, iid);
|
||||
p.coins += iids.length;
|
||||
queueVaultOthers(state, seat, api);
|
||||
},
|
||||
vaultMayDiscard(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
if (iids.length >= 2) {
|
||||
api.discardFromHand(state, seat, iids[0]);
|
||||
api.discardFromHand(state, seat, iids[1]);
|
||||
api.draw(state, seat, 1);
|
||||
}
|
||||
},
|
||||
expandTrash(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const c = choice?.iid != null ? api.trashFromHand(state, seat, choice.iid) : null;
|
||||
if (c) {
|
||||
const maxCost = api.cardCost(state, c.id, { seat }) + 3;
|
||||
api.setPending(state, { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'expand' });
|
||||
}
|
||||
},
|
||||
forgeTrash(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
let total = 0;
|
||||
for (const iid of iids) {
|
||||
const c = p.hand.find((h) => h.iid === iid);
|
||||
if (c) total += api.cardCost(state, c.id, { seat });
|
||||
}
|
||||
for (const iid of iids) api.trashFromHand(state, seat, iid);
|
||||
// Gain a card costing EXACTLY `total`.
|
||||
api.setPending(state, { seat, kind: 'forgeGain', exactCost: total });
|
||||
},
|
||||
forgeGain(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const id = choice?.id;
|
||||
if (id && api.cardCost(state, id, { seat }) === pend.exactCost && (state.supply[id] ?? 0) > 0) {
|
||||
api.gain(state, seat, id, 'discard');
|
||||
}
|
||||
},
|
||||
kingsCourtChoose(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const idx = choice?.iid != null ? p.hand.findIndex((c) => c.iid === choice.iid && isAction(c.id)) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.inPlay.push(c);
|
||||
api.log(state, { kind: 'play', seat, id: c.id, throne: true });
|
||||
api.queue(state, [
|
||||
{ type: 'effect', seat, id: c.id },
|
||||
{ type: 'effect', seat, id: c.id },
|
||||
{ type: 'effect', seat, id: c.id },
|
||||
]);
|
||||
}
|
||||
},
|
||||
mintReveal(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const c = choice?.iid != null ? p.hand.find((h) => h.iid === choice.iid && isTreasure(h.id)) : null;
|
||||
if (c) api.gain(state, seat, c.id, 'discard');
|
||||
},
|
||||
anvilDiscard(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
if (choice?.iid != null) {
|
||||
const c = api.discardFromHand(state, seat, choice.iid);
|
||||
if (c) api.setPending(state, { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'anvil' });
|
||||
}
|
||||
},
|
||||
crystalBall(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
const idx = p.deck.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== 0) return; // top card moved unexpectedly
|
||||
if (choice?.action === 'trash') {
|
||||
const [c] = p.deck.splice(0, 1);
|
||||
api.trashCard(state, c);
|
||||
} else if (choice?.action === 'discard') {
|
||||
const [c] = p.deck.splice(0, 1);
|
||||
p.discard.push(c);
|
||||
} else if (choice?.action === 'play' && (isAction(pend.cardId) || isTreasure(pend.cardId))) {
|
||||
const [c] = p.deck.splice(0, 1);
|
||||
p.inPlay.push(c);
|
||||
api.log(state, { kind: 'play', seat, id: c.id });
|
||||
if (isTreasure(c.id)) api.playTreasureEffect(state, seat, c);
|
||||
else api.queue(state, [{ type: 'effect', seat, id: c.id }]);
|
||||
}
|
||||
// else: leave on top
|
||||
},
|
||||
investmentChoice(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
if (choice?.mode === 'coin') {
|
||||
p.coins += 1;
|
||||
} else {
|
||||
// trash a Treasure from hand, +1 VP, +1 VP per differently named Treasure revealed.
|
||||
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
|
||||
api.gainVP(state, seat, 1);
|
||||
const names = new Set(p.hand.filter((c) => isTreasure(c.id)).map((c) => c.id));
|
||||
api.gainVP(state, seat, names.size);
|
||||
}
|
||||
},
|
||||
tiaraTopdeck(state, pend, choice, api) {
|
||||
if (!choice?.topdeck) return;
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
// Find the gained card in discard (default gain dest) or hand and move to deck top.
|
||||
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); return; }
|
||||
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
|
||||
},
|
||||
watchtowerReact(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
if (choice?.action === 'trash') {
|
||||
// Trash the just-gained card from wherever it landed (discard by default).
|
||||
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); api.trashCard(state, c); return; }
|
||||
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); api.trashCard(state, c); }
|
||||
} else if (choice?.action === 'topdeck') {
|
||||
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
|
||||
}
|
||||
},
|
||||
clerkTopdeck(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
let iid = choice?.iid;
|
||||
if (iid == null || !p.hand.some((c) => c.iid === iid)) iid = p.hand[0]?.iid; // must topdeck something
|
||||
if (iid != null) {
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.deck.unshift(c);
|
||||
api.log(state, { kind: 'topdeck', seat, id: c.id });
|
||||
}
|
||||
},
|
||||
warchestGain(state, pend, choice, api) {
|
||||
const seat = pend.seat;
|
||||
const id = choice?.id;
|
||||
const named = new Set(pend.named ?? []);
|
||||
if (id && !named.has(id) && api.cardCost(state, id, { seat }) <= 5 && (state.supply[id] ?? 0) > 0) {
|
||||
api.gain(state, seat, id, 'discard');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── AI hooks (layered on base; absent keys fall through to base defaults) ─────
|
||||
const ai = {
|
||||
terminalPriority: {
|
||||
witch: 96, rabble: 88, charlatan: 86, militia: 80, clerk: 78,
|
||||
kingscourt: 72, expand: 58, forge: 57, bishop: 52, mint: 48,
|
||||
monument: 46, vault: 44, city: 20, watchtower: 18,
|
||||
},
|
||||
engineBuys: [
|
||||
{ id: 'grandmarket', cap: 4 }, { id: 'kingscourt', cap: 2 }, { id: 'city', cap: 4 },
|
||||
{ id: 'peddler', cap: 5 }, { id: 'monument', cap: 3 }, { id: 'collection', cap: 2 },
|
||||
{ id: 'hoard', cap: 2 }, { id: 'vault', cap: 2 }, { id: 'rabble', cap: 1 },
|
||||
{ id: 'bishop', cap: 1 }, { id: 'watchtower', cap: 1 }, { id: 'workersvillage', cap: 4 },
|
||||
{ id: 'charlatan', cap: 1 },
|
||||
],
|
||||
pending: {
|
||||
bishopTrash(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const c = pickToTrash(state, seat, api) ?? p.hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
|
||||
return { iid: c?.iid ?? null };
|
||||
},
|
||||
bishopMayTrash(state, seat, pend, api) {
|
||||
const junk = junkInHand(state, seat, api);
|
||||
return { iid: junk?.iid ?? null };
|
||||
},
|
||||
vaultDiscard(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse' || c.id === 'copper');
|
||||
return { iids: junk.map((c) => c.iid) };
|
||||
},
|
||||
vaultMayDiscard(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse').slice(0, 2);
|
||||
return { iids: junk.length >= 2 ? junk.map((c) => c.iid) : [] };
|
||||
},
|
||||
expandTrash(state, seat, pend, api) {
|
||||
const c = junkInHand(state, seat, api) ?? state.players[seat].hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
|
||||
return { iid: c?.iid ?? null };
|
||||
},
|
||||
forgeTrash(state, seat, pend, api) {
|
||||
// Trash Coppers/Estates/Curses to forge toward a $6-$8 card if total lands right.
|
||||
const p = state.players[seat];
|
||||
const junk = p.hand.filter((c) => c.id === 'copper' || c.id === 'estate' || c.id === 'curse');
|
||||
return { iids: junk.map((c) => c.iid) };
|
||||
},
|
||||
forgeGain(state, seat, pend, api) {
|
||||
const id = api.bestGain(state, seat, pend.exactCost, false, { exact: true });
|
||||
return { id };
|
||||
},
|
||||
kingsCourtChoose(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const actions = p.hand.filter((c) => isAction(c.id) && c.id !== 'kingscourt')
|
||||
.sort((a, b) => (ai.terminalPriority[b.id] ?? api.cardCost(state, b.id)) - (ai.terminalPriority[a.id] ?? api.cardCost(state, a.id)));
|
||||
return { iid: actions[0]?.iid ?? null };
|
||||
},
|
||||
mintReveal(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const best = p.hand.filter((c) => isTreasure(c.id)).sort((a, b) => api.cardCost(state, b.id) - api.cardCost(state, a.id))[0];
|
||||
return { iid: best?.iid ?? null };
|
||||
},
|
||||
anvilDiscard(state, seat, pend, api) {
|
||||
// Discard a Copper to gain a $4 if a good $4 exists; else decline.
|
||||
const p = state.players[seat];
|
||||
const copper = p.hand.find((c) => c.id === 'copper');
|
||||
return { iid: copper?.iid ?? null };
|
||||
},
|
||||
crystalBall(state, seat, pend, api) {
|
||||
const id = pend.cardId;
|
||||
if (id === 'curse' || id === 'estate') return { action: 'trash' };
|
||||
if (isVictory(id)) return { action: 'discard' };
|
||||
if (isAction(id) || isTreasure(id)) return { action: 'play' };
|
||||
return { action: 'keep' };
|
||||
},
|
||||
investmentChoice(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const copper = p.hand.find((c) => c.id === 'copper');
|
||||
if (copper) return { mode: 'trash', iid: copper.iid };
|
||||
return { mode: 'coin' };
|
||||
},
|
||||
tiaraTopdeck(state, seat, pend, api) {
|
||||
// Topdeck good non-victory gains; leave junk in discard.
|
||||
const good = isAction(pend.cardId) || pend.cardId === 'gold' || pend.cardId === 'platinum';
|
||||
return { topdeck: good };
|
||||
},
|
||||
watchtowerReact(state, seat, pend, api) {
|
||||
if (pend.cardId === 'curse') return { action: 'trash' };
|
||||
if (pend.cardId === 'copper' && api.countOwned(state, seat, 'copper') > 3) return { action: 'trash' };
|
||||
return { action: 'keep' };
|
||||
},
|
||||
clerkTopdeck(state, seat, pend, api) {
|
||||
const p = state.players[seat];
|
||||
const ranked = p.hand.slice().sort((a, b) => api.discardRank(b.id) - api.discardRank(a.id));
|
||||
return { iid: ranked[0]?.iid ?? null };
|
||||
},
|
||||
warchestGain(state, seat, pend, api) {
|
||||
const id = api.bestGain(state, seat, 5, false, { exclude: pend.named });
|
||||
return { id };
|
||||
},
|
||||
},
|
||||
// The opponent to a War Chest player names a card to deny (we deny Province/Gold/best action).
|
||||
warchestName(state, namerSeat, ownerSeat, api) {
|
||||
if ((state.supply.province ?? 0) > 0) return 'province';
|
||||
if ((state.supply.gold ?? 0) > 0) return 'gold';
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
function pickToTrash(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
return p.hand.find((c) => c.id === 'curse')
|
||||
?? p.hand.find((c) => c.id === 'estate')
|
||||
?? p.hand.find((c) => c.id === 'copper');
|
||||
}
|
||||
function junkInHand(state, seat, api) {
|
||||
const p = state.players[seat];
|
||||
return p.hand.find((c) => c.id === 'curse')
|
||||
?? p.hand.find((c) => c.id === 'estate')
|
||||
?? p.hand.find((c) => c.id === 'copper');
|
||||
}
|
||||
|
||||
export const prosperity = {
|
||||
id: 'prosperity',
|
||||
name: 'Prosperity',
|
||||
sheet: SHEET,
|
||||
cards: CARD_DEFS,
|
||||
basics: ['platinum', 'colony'],
|
||||
basicPileSize: (id, playerCount) => {
|
||||
if (id === 'platinum') return 12;
|
||||
if (id === 'colony') return playerCount <= 2 ? 8 : 12;
|
||||
return 10;
|
||||
},
|
||||
kingdomPool: KINGDOM_POOL,
|
||||
presets: PRESETS,
|
||||
presetLabels: PROSPERITY_PRESET_LABELS,
|
||||
setupRules: [charlatanCurseIsCopper],
|
||||
effects,
|
||||
treasureEffects,
|
||||
onGain,
|
||||
gainReactions,
|
||||
onBuy,
|
||||
costModifiers: [quarryMod, peddlerMod],
|
||||
buyRestrictions,
|
||||
tasks,
|
||||
resolvers,
|
||||
ai,
|
||||
};
|
||||
|
|
@ -13,8 +13,6 @@ export default class GameRoomScene 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.deckMode = data.deckMode ?? 'standard';
|
||||
this.wordLength = data.wordLength ?? 4;
|
||||
}
|
||||
|
|
@ -28,8 +26,6 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
playfield: this.playfield,
|
||||
cardBack: this.cardBack,
|
||||
tilePlacement: this.tilePlacement,
|
||||
expansion: this.expansion,
|
||||
scenario: this.scenario,
|
||||
deckMode: this.deckMode,
|
||||
wordLength: this.wordLength,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.cardBackTiles = [];
|
||||
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;
|
||||
|
|
@ -118,7 +116,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
if (!this._startingGame) setMenuMusicVolume(0.6);
|
||||
});
|
||||
|
||||
if (isCatan) this.buildCatanExpansionSection(340, 1013);
|
||||
if (isCatan) this.buildTilePlacementSection(340, 1013);
|
||||
|
||||
if (isGoFish) this.buildMatchVariantSection(340, 1013);
|
||||
|
||||
|
|
@ -496,115 +494,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.startBtn.setEnabled(this.selected.size >= min);
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
// ── Catan: tile placement toggle ───────────────────────────────────────────
|
||||
buildTilePlacementSection(centerX, centerY) {
|
||||
const options = [
|
||||
{ id: 'random', label: 'Random' },
|
||||
|
|
@ -650,121 +540,59 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Dominion: Expansion + Kingdom selection ────────────────────────────────
|
||||
// Kingdom preset options per expansion. Each id must match a preset key in the
|
||||
// corresponding expansion module (or the base KINGDOM_PRESETS), plus 'random'.
|
||||
static DOMINION_EXPANSIONS = [
|
||||
{ id: 'base', label: 'Base Game' },
|
||||
{ id: 'prosperity', label: 'Prosperity' },
|
||||
];
|
||||
static DOMINION_KINGDOMS = {
|
||||
base: [
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
// ── Dominion: Kingdom deck mode toggle ─────────────────────────────────────
|
||||
buildDeckModeSection(centerX, centerY) {
|
||||
const options = [
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'size-distortion', label: 'Size Distortion' },
|
||||
{ id: 'deck-top', label: 'Deck Top' },
|
||||
{ id: 'silver-gold', label: 'Silver & Gold' },
|
||||
{ id: 'helpful-actions', label: 'Helpful Actions' },
|
||||
{ id: 'random', label: 'Random' },
|
||||
],
|
||||
prosperity: [
|
||||
{ id: 'beginners', label: 'Beginners' },
|
||||
{ id: 'friendly-interactive', label: 'Friendly' },
|
||||
{ id: 'bigger-treasures', label: 'Bigger Treasures' },
|
||||
{ id: 'the-king', label: 'The King' },
|
||||
{ id: 'random', label: 'Random' },
|
||||
],
|
||||
};
|
||||
];
|
||||
const pillW = 150, pillH = 40, pillGap = 12;
|
||||
const cols = 3;
|
||||
const rowW = cols * pillW + (cols - 1) * pillGap;
|
||||
// Two rows — shift the whole section up so both rows sit above y=1080.
|
||||
const labelY = centerY - 53;
|
||||
const row0Y = centerY - 12;
|
||||
const row1Y = centerY + 38;
|
||||
|
||||
buildDeckModeSection(centerX, centerY) {
|
||||
const C = OpponentSelectScene;
|
||||
// Vertical budget in the left column (~y 930..1078).
|
||||
const expLabelY = centerY - 80;
|
||||
const expRowY = centerY - 52;
|
||||
this._kingdomLabelY = centerY - 14;
|
||||
this._kingdomRow0Y = centerY + 12;
|
||||
this._kingdomRow1Y = centerY + 46;
|
||||
this._kingdomCenterX = centerX;
|
||||
const labelText = this.add.text(centerX, labelY, 'Kingdom', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '20px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
|
||||
this.children.moveBelow(labelBg, labelText);
|
||||
|
||||
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.
|
||||
mkLabel(expLabelY, 'Expansion');
|
||||
const expPillW = 150, expPillH = 36, expGap = 14;
|
||||
const expRowW = C.DOMINION_EXPANSIONS.length * expPillW + (C.DOMINION_EXPANSIONS.length - 1) * expGap;
|
||||
this._expansionBtns = [];
|
||||
C.DOMINION_EXPANSIONS.forEach((opt, i) => {
|
||||
const x = centerX - expRowW / 2 + i * (expPillW + expGap) + expPillW / 2;
|
||||
const sel = this.selectedExpansion === opt.id;
|
||||
const bg = this.add.rectangle(x, expRowY, expPillW, expPillH, COLORS.panel)
|
||||
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const pillBg = this.add.rectangle(x, expRowY, expPillW, expPillH, 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;
|
||||
// Reset Kingdom to the first preset of the newly-chosen expansion.
|
||||
this.selectedDeckMode = (C.DOMINION_KINGDOMS[opt.id] ?? C.DOMINION_KINGDOMS.base)[0].id;
|
||||
this._expansionBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
|
||||
this.renderKingdomPills();
|
||||
});
|
||||
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._expansionBtns.push({ bg, id: opt.id });
|
||||
});
|
||||
|
||||
// Kingdom label + (dynamic) pills.
|
||||
mkLabel(this._kingdomLabelY, 'Kingdom');
|
||||
this._kingdomObjs = [];
|
||||
this.renderKingdomPills();
|
||||
}
|
||||
|
||||
// (Re)build the Kingdom preset pills for the current expansion.
|
||||
renderKingdomPills() {
|
||||
const C = OpponentSelectScene;
|
||||
(this._kingdomObjs ?? []).forEach((o) => o.destroy());
|
||||
this._kingdomObjs = [];
|
||||
this._deckModeBtns = [];
|
||||
|
||||
const options = C.DOMINION_KINGDOMS[this.selectedExpansion] ?? C.DOMINION_KINGDOMS.base;
|
||||
if (!options.some((o) => o.id === this.selectedDeckMode)) this.selectedDeckMode = options[0].id;
|
||||
|
||||
const centerX = this._kingdomCenterX;
|
||||
const pillW = 150, pillH = 34, pillGap = 12, cols = 3;
|
||||
const rowW = cols * pillW + (cols - 1) * pillGap;
|
||||
|
||||
options.forEach((opt, i) => {
|
||||
const col = i % cols, row = Math.floor(i / cols);
|
||||
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
|
||||
const y = row === 0 ? this._kingdomRow0Y : this._kingdomRow1Y;
|
||||
const sel = this.selectedDeckMode === opt.id;
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
|
||||
const y = row === 0 ? row0Y : row1Y;
|
||||
const isSelected = this.selectedDeckMode === opt.id;
|
||||
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
|
||||
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
|
||||
.setStrokeStyle(3, isSelected ? 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,
|
||||
this.add.text(x, y, opt.label, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '16px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const refresh = () => this._deckModeBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted));
|
||||
const refresh = () => {
|
||||
this._deckModeBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted)
|
||||
);
|
||||
};
|
||||
bg.on('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); });
|
||||
bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
|
||||
bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
|
||||
this._deckModeBtns.push({ bg, id: opt.id });
|
||||
this._kingdomObjs.push(bg, pillBg, label);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -981,9 +809,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
cardBack: this.selectedCardBack,
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,31 +38,30 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
frameWidth: 312,
|
||||
frameHeight: 312,
|
||||
});
|
||||
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('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('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 });
|
||||
|
||||
|
|
@ -70,9 +69,6 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
// the title/icon band is drawn at runtime). Optional — the scene falls back
|
||||
// to procedural placeholders when the sheet is absent.
|
||||
this.load.spritesheet('dominion-cards', '/assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 });
|
||||
// Prosperity expansion art (frame order documented in expansions/prosperity.js).
|
||||
// Optional — same procedural fallback applies when the sheet is absent.
|
||||
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
|
||||
}
|
||||
|
||||
async create() {
|
||||
|
|
@ -82,7 +78,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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue