Added Catan and multiple sound effects

This commit is contained in:
Brian Fertig 2026-05-23 08:55:15 -06:00
parent f667f1ead3
commit 036298227f
23 changed files with 2121 additions and 2 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -49,6 +49,9 @@
"brad-upset-03",
"brad-upset-04",
"brad-upset-05"
],
"pick": [
"brad-pick"
]
}
},
@ -179,7 +182,30 @@
"id": "gerome",
"spriteIndex": 7,
"name": "Gerome",
"bio": "I'm here for the thrill of extreme victory!"
"bio": "I'm here for the thrill of extreme victory!",
"speech": {
"intro": [
"gerome-intro-01",
"gerome-intro-02"
],
"happy": [
"gerome-happy-01",
"gerome-happy-02",
"gerome-happy-03",
"gerome-happy-04",
"gerome-happy-05"
],
"upset": [
"gerome-upset-01",
"gerome-upset-02",
"gerome-upset-03",
"gerome-upset-04",
"gerome-upset-05"
],
"pick": [
"gerome-pick"
]
}
},
{
"id": "fireball",

View File

@ -0,0 +1,291 @@
// CatanAI.js — heuristic opponent for Settlers of Catan. No Phaser, no async.
// The scene drives AI turns by repeatedly calling chooseAction() and applying
// the returned action until it returns { type: 'endTurn' }.
import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
import {
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
} from './CatanLogic.js';
// Value of a vertex = production potential of its adjacent hexes + diversity.
function nodeValue(state, nodeId) {
let v = 0;
const seen = new Set();
for (const hx of NODES[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
v += pipCount(hex.number);
seen.add(hex.resource);
}
return v + seen.size * 0.6;
}
// What resources does this seat under-produce? (lower weight = scarcer for them)
function productionByResource(state, seat) {
const prod = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const p = state.players[seat];
const tally = (nodeId, mult) => {
for (const hx of NODES[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
prod[hex.resource] += pipCount(hex.number) * mult;
}
};
p.settlements.forEach((n) => tally(n, 1));
p.cities.forEach((n) => tally(n, 2));
return prod;
}
// ── setup ──────────────────────────────────────────────────────────────────
export function chooseSetupSettlement(state, seat) {
const nodes = legalSettlementNodes(state, seat, true);
let best = nodes[0], bestScore = -Infinity;
const prod = productionByResource(state, seat);
for (const n of nodes) {
let score = nodeValue(state, n);
// Bonus for grabbing resources the seat doesn't yet produce.
for (const hx of NODES[n].hexes) {
const hex = state.hexes[hx];
if (hex.resource !== 'desert' && hex.number != null && prod[hex.resource] === 0) score += 1.5;
}
if (score > bestScore) { bestScore = score; best = n; }
}
return best;
}
export function chooseSetupRoad(state, seat) {
const from = state.setup.lastSettlement;
const edges = legalRoadEdges(state, seat, true, from);
let best = edges[0], bestScore = -Infinity;
for (const eid of edges) {
const [a, b] = EDGES[eid].nodes;
const far = a === from ? b : a;
const score = nodeValue(state, far);
if (score > bestScore) { bestScore = score; best = eid; }
}
return best;
}
// ── discard / robber ──────────────────────────────────────────────────────────
export function chooseDiscard(state, seat) {
const p = state.players[seat];
let need = Math.floor(handSize(p) / 2);
const discard = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
// Shed from the most abundant resources first.
const order = [...RESOURCE_TYPES].sort((x, y) => p.resources[y] - p.resources[x]);
let i = 0;
while (need > 0) {
const r = order[i % order.length];
if (discard[r] < p.resources[r]) { discard[r]++; need--; }
i++;
if (i > 1000) break;
}
return discard;
}
export function chooseRobberMove(state, seat) {
let best = null, bestScore = -Infinity, bestTarget = null;
for (const hex of state.hexes) {
if (hex.hasRobber) continue;
let score = -1;
let touchesSelf = false;
let richest = null, richestCards = -1;
for (const nodeId of HEXES[hex.id].corners) {
const bld = nodeBuilding(state, nodeId);
if (!bld) continue;
if (bld.seat === seat) { touchesSelf = true; continue; }
const mult = bld.type === 'city' ? 2 : 1;
const num = hex.number ? pipCount(hex.number) : 0;
score += num * mult * (1 + publicVictoryPoints(state, bld.seat));
const cards = handSize(state.players[bld.seat]);
if (cards > richestCards) { richestCards = cards; richest = bld.seat; }
}
if (touchesSelf) score -= 100; // never hurt ourselves
if (score > bestScore) { bestScore = score; best = hex.id; bestTarget = richest; }
}
// Fallback: any legal hex.
if (best === null) best = state.hexes.find((h) => !h.hasRobber)?.id ?? state.robberHex;
return { hexId: best, targetSeat: bestTarget };
}
// ── pre-roll: play a knight to clear the robber off our best hex ───────────────
export function choosePreRoll(state, seat) {
const p = state.players[seat];
if (p.playedDevThisTurn || !p.devCards.includes('knight')) return null;
const robberOnOurs = HEXES[state.robberHex].corners.some((n) => {
const b = nodeBuilding(state, n);
return b && b.seat === seat;
});
return robberOnOurs ? { type: 'playDev', card: 'knight' } : null;
}
// ── main action loop ───────────────────────────────────────────────────────────
function deficit(have, cost) {
let total = 0;
for (const [r, n] of Object.entries(cost)) total += Math.max(0, n - have[r]);
return total;
}
// One bank/port trade that moves us toward `cost`, or null.
function tradeToward(state, seat, cost) {
const have = state.players[seat].resources;
const missing = RESOURCE_TYPES.filter((r) => (cost[r] || 0) > have[r]);
if (!missing.length) return null;
// Most-needed missing resource.
const get = missing.sort((a, b) => ((cost[b] || 0) - have[b]) - ((cost[a] || 0) - have[a]))[0];
for (const give of RESOURCE_TYPES) {
if (give === get) continue;
const ratio = bestTradeRatio(state, seat, give);
const spare = have[give] - (cost[give] || 0);
if (spare >= ratio && state.bank[get] > 0) return { type: 'bankTrade', give, get };
}
return null;
}
export function chooseAction(state, seat) {
const p = state.players[seat];
const have = p.resources;
// Best city upgrade (upgrade highest-value settlement first).
const citySettlements = [...p.settlements].sort((a, b) => nodeValue(state, b) - nodeValue(state, a));
const settleSpots = legalSettlementNodes(state, seat, false)
.sort((a, b) => nodeValue(state, b) - nodeValue(state, a));
// 0. Spend free roads from Road Building before anything else.
if (state.freeRoads > 0) {
const edges = legalRoadEdges(state, seat, false);
if (edges.length) {
const road = chooseExpansionRoad(state, seat);
return { type: 'buildRoad', edgeId: road ?? edges[0] };
}
}
// 1. Play a dev card that directly helps (one per turn; engine enforces).
if (!p.playedDevThisTurn) {
const dev = chooseHelpfulDev(state, seat, citySettlements, settleSpots);
if (dev) return dev;
}
// 2. Build a city.
if (citySettlements.length && canAfford(p, COSTS.city)) {
return { type: 'buildCity', nodeId: citySettlements[0] };
}
// 3. Build a settlement.
if (settleSpots.length && canAfford(p, COSTS.settlement)) {
return { type: 'buildSettlement', nodeId: settleSpots[0] };
}
// 4. Build a road that opens a new settlement spot or extends longest road.
if (canAfford(p, COSTS.road)) {
const road = chooseExpansionRoad(state, seat);
if (road != null) return { type: 'buildRoad', edgeId: road };
}
// 5. Trade toward the most reachable target (city if we have a settlement, else settlement).
const target = citySettlements.length ? COSTS.city : (settleSpots.length || canReachNewSpot(state, seat) ? COSTS.settlement : null);
if (target && deficit(have, target) > 0) {
const t = tradeToward(state, seat, target);
if (t) return t;
}
// 6. Buy a development card when flush and nothing better to do.
if (state.devDeck.length && canAfford(p, COSTS.devCard) && handSize(p) >= 3) {
return { type: 'buyDev' };
}
return { type: 'endTurn' };
}
// Is there any legal road that would create a buildable settlement spot?
function canReachNewSpot(state, seat) {
return chooseExpansionRoad(state, seat) != null;
}
function chooseExpansionRoad(state, seat) {
const edges = legalRoadEdges(state, seat, false);
if (!edges.length) return null;
let best = null, bestScore = -Infinity;
for (const eid of edges) {
const [a, b] = EDGES[eid].nodes;
let score = 0;
for (const node of [a, b]) {
// Reward roads pointing at empty, distance-rule-legal vertices.
if (!nodeBuilding(state, node) && !NODES[node].adj.some((x) => nodeBuilding(state, x))) {
score += nodeValue(state, node);
}
}
// Slight bias to chase Longest Road when we're close.
const ourLen = state.longestRoad.length;
if (state.longestRoad.owner !== seat && state.players[seat].roads.length >= 4) score += 1.5;
if (score > bestScore) { bestScore = score; best = eid; }
}
// Only build a road if it actually heads somewhere useful.
return bestScore > 0 ? best : (state.players[seat].roads.length < 4 ? null : best);
}
function chooseHelpfulDev(state, seat, citySettlements, settleSpots) {
const p = state.players[seat];
// Knight for Largest Army lead (when it would put us at the front).
if (p.devCards.includes('knight')) {
const mine = p.knightsPlayed + 1;
const others = state.players.filter((q) => q.seat !== seat).map((q) => q.knightsPlayed);
const lead = mine >= 3 && mine > Math.max(0, ...others) && state.largestArmy.owner !== seat;
if (lead) return { type: 'playDev', card: 'knight' };
}
// Monopoly if opponents collectively hold a lot of a resource we want.
if (p.devCards.includes('monopoly')) {
let bestRes = null, bestCount = 0;
for (const r of RESOURCE_TYPES) {
const opp = state.players.reduce((s, q) => s + (q.seat === seat ? 0 : q.resources[r]), 0);
if (opp > bestCount) { bestCount = opp; bestRes = r; }
}
if (bestCount >= 4) return { type: 'playDev', card: 'monopoly', resource: bestRes };
}
// Year of Plenty to complete a city or settlement.
if (p.devCards.includes('yearOfPlenty')) {
const target = citySettlements.length ? COSTS.city : (settleSpots.length ? COSTS.settlement : null);
if (target) {
const missing = [];
for (const r of RESOURCE_TYPES) {
let m = (target[r] || 0) - p.resources[r];
while (m-- > 0) missing.push(r);
}
if (missing.length >= 1 && missing.length <= 2) {
const r1 = missing[0];
const r2 = missing[1] ?? missing[0];
return { type: 'playDev', card: 'yearOfPlenty', r1, r2 };
}
}
}
// Road Building when we can use the free roads to expand.
if (p.devCards.includes('roadBuilding') && legalRoadEdges(state, seat, false).length >= 1) {
if (state.players[seat].roads.length >= 2) return { type: 'playDev', card: 'roadBuilding' };
}
return null;
}
// ── player↔AI trade evaluation ─────────────────────────────────────────────────
// Decide whether `seat` (AI) accepts a trade where it GIVES `give` and GETS `get`.
export function respondToTrade(state, seat, give, get) {
const p = state.players[seat];
// Must be able to afford what it gives.
for (const r of RESOURCE_TYPES) if ((give[r] || 0) > p.resources[r]) return false;
const giveCount = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0), 0);
const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0);
if (getCount === 0) return false;
// Value resources by how much they unblock our best target.
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
const need = (r) => Math.max(0, (target[r] || 0) - p.resources[r]);
const valIn = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0) * (1 + need(r)), 0);
const valOut = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0) * (1 + need(r) * 0.5), 0);
// Accept if we gain value and it isn't badly lopsided in card count.
return valIn >= valOut && getCount >= giveCount - 1;
}

View File

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

View File

@ -0,0 +1,990 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP,
} from './CatanBoard.js';
import * as L from './CatanLogic.js';
import * as AI from './CatanAI.js';
const D = { board: 0, port: 4, chit: 8, robber: 11, road: 12, building: 14, highlight: 20, hud: 30, panel: 60, banner: 80 };
export default class CatanGame extends Phaser.Scene {
constructor() { super('CatanGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.busy = false;
this.highlights = [];
this.pieceObjs = [];
this.chitObjs = [];
this.robberObj = null;
this.opponentPortraits = [];
this.buttons = {};
this.placeMode = null; // 'road' | 'settlement' | 'city' | null
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoardStatic();
this.buildDice();
this.buildHUD();
this.buildOpponentPanels();
this.startNewMatch();
}
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5);
g.generateTexture('catanParticle', 10, 10);
g.destroy();
}
// ── coordinate helpers ──────────────────────────────────────────────────────
nodePos(id) { return { x: NODES[id].x, y: NODES[id].y }; }
edgePos(id) {
const [a, b] = EDGES[id].nodes;
return { x: (NODES[a].x + NODES[b].x) / 2, y: (NODES[a].y + NODES[b].y) / 2 };
}
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); }
// ── playfield / static board ─────────────────────────────────────────────────
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.board - 2);
} else {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x14506b).setDepth(D.board - 2);
}
// Sea backdrop ring under the island.
const sea = this.add.graphics().setDepth(D.board - 1);
sea.fillStyle(0x0d3a52, 1);
sea.fillCircle(1000, 470, 470);
sea.lineStyle(6, 0x0a2c40, 1);
sea.strokeCircle(1000, 470, 470);
}
buildBoardStatic() {
// Hexes drawn once from the (static) topology; resources/numbers come from state at startNewMatch.
this.hexGfx = this.add.graphics().setDepth(D.board);
this.hexLabels = [];
this.portObjs = [];
}
drawHexes() {
const g = this.hexGfx;
g.clear();
this.hexLabels.forEach((t) => t.destroy());
this.hexLabels = [];
for (const hex of this.gs.hexes) {
const pts = HEXES[hex.id].corners.map((c) => ({ x: NODES[c].x, y: NODES[c].y }));
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
g.fillStyle(color, 1);
g.fillPoints(pts, true);
g.lineStyle(4, 0x6b4a1a, 0.85);
g.strokePoints(pts, true);
const { x, y } = this.hexPos(hex.id);
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 + 1));
}
}
drawPorts() {
this.portObjs.forEach((o) => o.destroy());
this.portObjs = [];
for (const port of this.gs.ports) {
const out = 30;
const px = port.x + Math.cos(port.angle) * out;
const py = port.y + Math.sin(port.angle) * out;
const c = this.add.container(px, py).setDepth(D.port);
const g = this.add.graphics();
g.fillStyle(0x6b4a1a, 1); g.fillCircle(0, 0, 19);
g.fillStyle(0xefe2c0, 1); g.fillCircle(0, 0, 16);
c.add(g);
const label = port.type === 'any' ? '3:1' : '2:1';
const sub = port.type === 'any' ? '' : RESOURCE_INFO[port.type].label[0];
c.add(this.add.text(0, -4, label, { fontFamily: 'Righteous', fontSize: '13px', color: '#2a2118' }).setOrigin(0.5));
if (sub) c.add(this.add.text(0, 8, sub, { fontFamily: 'Righteous', fontSize: '11px', color: '#8a5a18' }).setOrigin(0.5));
// little jetties to the two coastal nodes
const jg = this.add.graphics().setDepth(D.port - 1);
jg.lineStyle(3, 0x6b4a1a, 0.8);
for (const nid of port.nodes) jg.lineBetween(px, py, NODES[nid].x, NODES[nid].y);
this.portObjs.push(c, jg);
}
}
// Polished numeric chits: parchment token, number (red for 6/8), probability pips.
drawChits() {
this.chitObjs.forEach((o) => o.destroy());
this.chitObjs = [];
for (const hex of this.gs.hexes) {
if (hex.number == null) continue;
const { x, y } = this.hexPos(hex.id);
const c = this.add.container(x, y + 6).setDepth(D.chit);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.18); g.fillCircle(2, 3, 25);
g.fillStyle(0xf3e6c4, 1); g.fillCircle(0, 0, 24);
g.lineStyle(2.5, 0xb89a5e, 1); g.strokeCircle(0, 0, 24);
g.lineStyle(1.5, 0xd8c79a, 1); g.strokeCircle(0, 0, 20);
c.add(g);
const hot = hex.number === 6 || hex.number === 8;
c.add(this.add.text(0, -5, String(hex.number), {
fontFamily: 'Righteous', fontSize: hot ? '26px' : '24px',
color: hot ? '#c0392b' : '#2a2118',
}).setOrigin(0.5));
// pips
const n = pipCount(hex.number);
const pg = this.add.graphics();
pg.fillStyle(hot ? 0xc0392b : 0x2a2118, 1);
const spacing = 5;
const startX = -((n - 1) * spacing) / 2;
for (let i = 0; i < n; i++) pg.fillCircle(startX + i * spacing, 13, 2);
c.add(pg);
this.chitObjs.push(c);
// pop-in
c.setScale(0);
this.tweens.add({ targets: c, scale: 1, duration: 260, delay: hex.id * 18, ease: 'Back.easeOut' });
}
}
// ── dice ──────────────────────────────────────────────────────────────────────
buildDice() {
this.diceG = [];
this.diceContainers = [];
const baseX = 1290, baseY = 950;
for (let i = 0; i < 2; i++) {
const g = this.add.graphics();
const c = this.add.container(baseX + (i === 0 ? -34 : 34), baseY, [g]).setDepth(D.hud).setAlpha(0.25);
this.diceG.push(g); this.diceContainers.push(c);
this.drawDie(g, 1);
}
}
drawDie(g, value) {
const s = 26;
g.clear();
g.fillStyle(0xf0e8d0, 1); g.fillRoundedRect(-s, -s, s * 2, s * 2, 6);
g.lineStyle(2, 0x2c1a0e, 1); g.strokeRoundedRect(-s, -s, s * 2, s * 2, 6);
const P = {
1: [[0, 0]], 2: [[-.55, -.55], [.55, .55]], 3: [[-.55, -.55], [0, 0], [.55, .55]],
4: [[-.55, -.55], [.55, -.55], [-.55, .55], [.55, .55]],
5: [[-.55, -.55], [.55, -.55], [0, 0], [-.55, .55], [.55, .55]],
6: [[-.55, -.55], [.55, -.55], [-.55, 0], [.55, 0], [-.55, .55], [.55, .55]],
};
g.fillStyle(0x1a1a1a, 1);
for (const [px, py] of (P[value] || P[1])) g.fillCircle(px * 16, py * 16, 4);
}
animateDice(values) {
return new Promise((resolve) => {
playSound(this, SFX.DICE_ROLL);
this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0; const total = 650;
const tick = () => {
this.drawDie(this.diceG[0], Phaser.Math.Between(1, 6));
this.drawDie(this.diceG[1], Phaser.Math.Between(1, 6));
elapsed += 70;
if (elapsed < total) this.time.delayedCall(70, tick);
else {
this.drawDie(this.diceG[0], values[0]);
this.drawDie(this.diceG[1], values[1]);
this.diceContainers.forEach((c) => this.tweens.add({ targets: c, scale: 1.18, duration: 90, yoyo: true }));
this.time.delayedCall(140, resolve);
}
};
tick();
});
}
// ── HUD (human hand + buttons + status) ────────────────────────────────────────
buildHUD() {
// bottom panel
this.add.rectangle(GAME_WIDTH / 2, 985, GAME_WIDTH, 190, COLORS.panel, 0.92).setDepth(D.hud - 1);
this.add.rectangle(GAME_WIDTH / 2, 893, GAME_WIDTH, 4, COLORS.accent, 0.6).setDepth(D.hud - 1);
// human portrait
createPlayerPortrait(this, 90, 980, 64, D.hud, 'Catan');
this.add.text(90, 1056, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
// resource hand
this.resText = {};
const startX = 230, gap = 86;
RESOURCE_TYPES.forEach((r, i) => {
const x = startX + i * gap, y = 950;
const g = this.add.graphics().setDepth(D.hud);
g.fillStyle(RESOURCE_INFO[r].swatch, 1); g.fillRoundedRect(x - 32, y - 30, 64, 60, 8);
g.lineStyle(2, 0x000000, 0.35); g.strokeRoundedRect(x - 32, y - 30, 64, 60, 8);
this.add.text(x, y - 14, RESOURCE_INFO[r].label, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#1a1208',
}).setOrigin(0.5).setDepth(D.hud);
this.resText[r] = this.add.text(x, y + 8, '0', {
fontFamily: 'Righteous', fontSize: '24px', color: '#1a1208',
}).setOrigin(0.5).setDepth(D.hud);
});
// dev card hand area label
this.devHandContainer = this.add.container(0, 0).setDepth(D.hud);
this.add.text(740, 916, 'Development Cards', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
// status banner (top centre)
this.statusText = this.add.text(1000, 40, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
backgroundColor: '#111923cc', padding: { x: 18, y: 8 },
}).setOrigin(0.5).setDepth(D.banner);
// log line (bottom-left)
this.logText = this.add.text(170, 1060, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
// cost legend (right, above buttons)
this.buildCostLegend();
// action buttons (vertical column, right)
const bx = 1815; let by = 250; const step = 60;
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 168, 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'));
mk('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
mk('city', 'Build City', () => this.enterPlace('city'));
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
mk('playDev', 'Play Dev Card', () => this.openDevMenu());
mk('trade', 'Trade', () => this.openTradePanel());
mk('endTurn', 'End Turn', () => this.onEndTurn());
new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud);
}
buildCostLegend() {
const x = 1600, y = 230;
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(x, y + 80, 180, 200, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
panel.add(this.add.text(x, y - 4, 'Build Costs', { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex }).setOrigin(0.5));
const lines = [
['Road', 'Brick Lumber'],
['Settle', 'Br Lu Wo Gr'],
['City', '2 Grain 3 Ore'],
['Dev', 'Wool Grain Ore'],
];
lines.forEach((ln, i) => {
panel.add(this.add.text(x - 78, y + 30 + i * 38, ln[0], { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex }).setOrigin(0, 0.5));
panel.add(this.add.text(x - 78, y + 48 + i * 38, ln[1], { fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex }).setOrigin(0, 0.5));
});
}
// ── opponents (left column) ─────────────────────────────────────────────────
buildOpponentPanels() {
this.oppPanels = [];
const aiSeats = this.opponents.length; // human is seat 0
}
renderOpponentPanels() {
// build once we know player count
if (this.oppPanels.length) { this.updateOpponentPanels(); return; }
const n = this.gs.playerCount;
const seats = [];
for (let s = 1; s < n; s++) seats.push(s);
const startY = 170, gap = Math.min(250, (820) / seats.length);
seats.forEach((seat, i) => {
const x = 130, y = startY + i * gap;
const opp = this.opponents[seat - 1];
const portrait = createOpponentPortrait(this, opp, x, y, 56, D.hud);
this.opponentPortraits[seat] = portrait;
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
this.add.circle(x, y, 62, col.hex, 0).setStrokeStyle(4, col.hex, 0.9).setDepth(D.hud + 4);
this.add.text(x, y + 70, this.pname(seat), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
wordWrap: { width: 180 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.hud);
const info = this.add.text(x, y + 96, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.hud);
this.oppPanels.push({ seat, info, x, y });
});
this.updateOpponentPanels();
}
updateOpponentPanels() {
for (const panel of this.oppPanels) {
const p = this.gs.players[panel.seat];
const cards = L.handSize(p);
const dev = p.devCards.length + p.newDevCards.length;
const badges = [];
if (this.gs.longestRoad.owner === panel.seat) badges.push('LR');
if (this.gs.largestArmy.owner === panel.seat) badges.push('LA');
panel.info.setText(
`${L.publicVictoryPoints(this.gs, panel.seat)} VP ${cards} cards\n` +
`${dev} dev ${p.knightsPlayed} knights` + (badges.length ? `\n[${badges.join(' ')}]` : '')
);
}
}
// ── full render ─────────────────────────────────────────────────────────────
renderAll() {
this.renderPieces();
this.renderRobber();
this.updateHand();
this.updateDevHand();
this.renderOpponentPanels();
this.updateButtons();
this.updateStatus();
}
renderPieces() {
this.pieceObjs.forEach((o) => o.destroy());
this.pieceObjs = [];
// roads
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const eid of p.roads) {
const [a, b] = EDGES[eid].nodes;
const g = this.add.graphics().setDepth(D.road);
g.lineStyle(12, col.hexDark, 1); g.lineBetween(NODES[a].x, NODES[a].y, NODES[b].x, NODES[b].y);
g.lineStyle(7, col.hex, 1); g.lineBetween(NODES[a].x, NODES[a].y, NODES[b].x, NODES[b].y);
this.pieceObjs.push(g);
}
}
// settlements + cities
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(NODES[nid].x, NODES[nid].y, col));
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(NODES[nid].x, NODES[nid].y, col));
}
}
makeSettlement(x, y, col) {
const g = this.add.graphics().setDepth(D.building);
g.fillStyle(0x000000, 0.25); g.fillRoundedRect(x - 11, y - 6, 24, 18, 3);
g.fillStyle(col.hex, 1);
g.fillRect(x - 10, y - 3, 20, 13);
g.fillTriangle(x - 12, y - 3, x + 12, y - 3, x, y - 14);
g.lineStyle(2, col.hexDark, 1);
g.strokeRect(x - 10, y - 3, 20, 13);
return g;
}
makeCity(x, y, col) {
const g = this.add.graphics().setDepth(D.building);
g.fillStyle(0x000000, 0.25); g.fillRoundedRect(x - 17, y - 10, 36, 24, 3);
g.fillStyle(col.hex, 1);
g.fillRect(x - 16, y, 16, 14); // lower block
g.fillRect(x - 4, y - 8, 20, 22); // tower block
g.fillTriangle(x - 6, y - 8, x + 18, y - 8, x + 6, y - 18);
g.lineStyle(2, col.hexDark, 1);
g.strokeRect(x - 16, y, 16, 14);
g.strokeRect(x - 4, y - 8, 20, 22);
return g;
}
renderRobber() {
if (this.robberObj) this.robberObj.destroy();
const { x, y } = this.hexPos(this.gs.robberHex);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.3); g.fillEllipse(2, 30, 30, 10);
g.fillStyle(0x2b2b2b, 1);
g.fillEllipse(0, 26, 30, 14); // base
g.fillRoundedRect(-11, -2, 22, 30, 8); // body
g.fillCircle(0, -10, 12); // head
g.lineStyle(2, 0x000000, 0.5); g.strokeCircle(0, -10, 12);
this.robberObj = this.add.container(x, y - 14, [g]).setDepth(D.robber);
}
updateHand() {
const p = this.gs.players[0];
for (const r of RESOURCE_TYPES) this.resText[r].setText(String(p.resources[r]));
}
updateDevHand() {
this.devHandContainer.removeAll(true);
const p = this.gs.players[0];
const cards = [...p.devCards, ...p.newDevCards.map((c) => c + '*')];
if (p.vpCards) for (let i = 0; i < p.vpCards; i++) cards.push('vp');
let x = 740;
const y = 970;
cards.forEach((card) => {
const isNew = card.endsWith('*');
const type = isNew ? card.slice(0, -1) : card;
const g = this.add.graphics();
g.fillStyle(isNew ? 0x6a5a2a : 0x3a2f6b, 1); g.fillRoundedRect(x - 28, y - 36, 56, 72, 6);
g.lineStyle(2, COLORS.accent, 0.8); g.strokeRoundedRect(x - 28, y - 36, 56, 72, 6);
this.devHandContainer.add(g);
this.devHandContainer.add(this.add.text(x, y, DEV_INFO[type]?.short ?? type, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#f2ead8', align: 'center', wordWrap: { width: 52 },
}).setOrigin(0.5));
x += 64;
});
if (!cards.length) {
this.devHandContainer.add(this.add.text(740, 970, '—', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0, 0.5));
}
}
updateStatus() {
const s = this.gs;
let msg = '';
const me = s.currentPlayer === 0;
if (s.phase === 'setup') {
msg = me ? `Place your ${s.setup.placing}` : `${this.pname(s.currentPlayer)} is placing…`;
} else if (s.phase === 'rollPhase') {
msg = me ? 'Your turn — roll the dice' : `${this.pname(s.currentPlayer)}'s turn`;
} else if (s.phase === 'discard') {
msg = s.discardQueue.includes(0) ? 'Discard half your cards' : 'Opponents discarding…';
} else if (s.phase === 'moveRobber') {
msg = me ? 'Move the robber' : `${this.pname(s.currentPlayer)} moves the robber`;
} else if (s.phase === 'action') {
msg = me ? `Your turn — VP: ${L.victoryPoints(s, 0)}` : `${this.pname(s.currentPlayer)} is playing…`;
}
this.statusText.setText(msg);
this.logText.setText(s.log[s.log.length - 1] ?? '');
}
updateButtons() {
const s = this.gs;
const me = s.currentPlayer === 0 && !this.busy;
const p = s.players[0];
const action = me && s.phase === 'action';
const set = (k, on) => this.buttons[k]?.setEnabled(!!on);
const hasSettleSpot = action && L.legalSettlementNodes(s, 0, false).length > 0;
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));
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));
set('playDev', action && p.devCards.some((c) => c !== 'vp'));
set('trade', action && L.handSize(p) > 0);
set('endTurn', action && (s.freeRoads === 0 || !hasRoadSpot));
}
// ── highlights ────────────────────────────────────────────────────────────────
clearHighlights() {
this.highlights.forEach((o) => o.destroy());
this.highlights = [];
}
addHighlight(x, y, onClick, color = COLORS.accent, r = 16) {
const dot = this.add.graphics().setDepth(D.highlight);
dot.fillStyle(color, 0.85); dot.fillCircle(x, y, r);
dot.lineStyle(3, 0xffffff, 0.5); dot.strokeCircle(x, y, r);
this.tweens.add({ targets: dot, alpha: { from: 0.9, to: 0.3 }, duration: 600, yoyo: true, repeat: -1 });
const zone = this.add.zone(x, y, r * 2.4, r * 2.4).setInteractive({ useHandCursor: true }).setDepth(D.highlight + 1);
zone.on('pointerdown', onClick);
this.highlights.push(dot, zone);
}
// ── new match / turn driver ─────────────────────────────────────────────────
startNewMatch() {
this.clearHighlights();
this.busy = false;
this.placeMode = null;
const playerCount = Math.min(4, 1 + this.opponents.length);
this.gs = L.createInitialState(playerCount);
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names);
this.drawHexes();
this.drawPorts();
this.drawChits();
this.renderAll();
this.time.delayedCall(700, () => this.advance());
}
async advance() {
const s = this.gs;
this.renderAll();
if (s.phase === 'gameOver') { this.onGameOver(); return; }
const me = s.currentPlayer === 0;
if (s.phase === 'setup') {
if (me) this.promptSetup();
else await this.aiSetupStep();
} else if (s.phase === 'rollPhase') {
if (me) { /* wait for Roll button */ }
else await this.aiRoll();
} else if (s.phase === 'discard') {
await this.handleDiscardPhase();
} else if (s.phase === 'moveRobber') {
if (me) this.promptRobber();
else await this.aiRobber();
} else if (s.phase === 'action') {
if (me) { /* wait for action buttons */ }
else await this.aiAction();
}
}
// ── human: setup ──────────────────────────────────────────────────────────────
promptSetup() {
this.clearHighlights();
const s = this.gs;
if (s.setup.placing === 'settlement') {
for (const nid of L.legalSettlementNodes(s, 0, true)) {
const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => {
this.clearHighlights();
this.gs = L.placeSetupSettlement(this.gs, 0, nid);
playSound(this, SFX.PIECE_CLICK);
this.advance();
});
}
} else {
for (const eid of L.legalRoadEdges(s, 0, true, s.setup.lastSettlement)) {
const { x, y } = this.edgePos(eid);
this.addHighlight(x, y, () => {
this.clearHighlights();
this.gs = L.placeSetupRoad(this.gs, 0, eid);
playSound(this, SFX.PIECE_CLICK);
this.advance();
}, COLORS.gold, 13);
}
}
}
// ── AI steps ────────────────────────────────────────────────────────────────
async aiSetupStep() {
this.busy = true;
const seat = this.gs.currentPlayer;
await this.delay(420);
if (this.gs.setup.placing === 'settlement') {
this.gs = L.placeSetupSettlement(this.gs, seat, AI.chooseSetupSettlement(this.gs, seat));
} else {
this.gs = L.placeSetupRoad(this.gs, seat, AI.chooseSetupRoad(this.gs, seat));
}
playSound(this, SFX.PIECE_CLICK);
this.busy = false;
this.advance();
}
async aiRoll() {
this.busy = true;
const seat = this.gs.currentPlayer;
this.showTurnBanner(`${this.pname(seat)}'s Turn`);
await this.delay(550);
const pre = AI.choosePreRoll(this.gs, seat);
if (pre) {
this.gs = L.playKnight(this.gs, seat);
this.renderAll(); await this.delay(400);
const m = AI.chooseRobberMove(this.gs, seat);
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
this.renderAll(); await this.delay(400);
}
if (this.gs.phase === 'rollPhase') {
const ns = L.rollDice(this.gs);
await this.animateDice(ns.dice);
this.gs = ns;
this.renderAll();
await this.delay(500);
}
this.busy = false;
this.advance();
}
async aiRobber() {
this.busy = true;
const seat = this.gs.currentPlayer;
await this.delay(450);
const m = AI.chooseRobberMove(this.gs, seat);
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
if (m.targetSeat != null) this.opponentPortraits[seat]?.playEmotion?.('happy');
this.renderAll();
await this.delay(450);
this.busy = false;
this.advance();
}
async aiAction() {
this.busy = true;
const seat = this.gs.currentPlayer;
let steps = 0;
while (this.gs.phase === 'action' && steps++ < 60) {
const a = AI.chooseAction(this.gs, seat);
if (a.type === 'endTurn') { this.gs = L.endTurn(this.gs); break; }
const before = JSON.stringify(this.gs.players[seat]) + this.gs.phase;
this.gs = this.applyAction(seat, a);
if (this.gs.phase === 'moveRobber') {
const m = AI.chooseRobberMove(this.gs, seat);
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
}
this.renderAll();
await this.delay(480);
if (this.gs.phase === 'gameOver') break;
const after = JSON.stringify(this.gs.players[seat]) + this.gs.phase;
if (before === after && a.type !== 'playDev') { this.gs = L.endTurn(this.gs); break; }
}
if (steps >= 60 && this.gs.phase === 'action') this.gs = L.endTurn(this.gs);
this.busy = false;
this.advance();
}
applyAction(seat, a) {
switch (a.type) {
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 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
case 'playDev':
if (a.card === 'knight') return L.playKnight(this.gs, seat);
if (a.card === 'roadBuilding') return L.playRoadBuilding(this.gs, seat);
if (a.card === 'yearOfPlenty') return L.playYearOfPlenty(this.gs, seat, a.r1, a.r2);
if (a.card === 'monopoly') return L.playMonopoly(this.gs, seat, a.resource);
return this.gs;
default: return this.gs;
}
}
// ── human: roll ───────────────────────────────────────────────────────────────
async onRoll() {
if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return;
this.busy = true;
this.buttons.roll.setEnabled(false);
const ns = L.rollDice(this.gs);
await this.animateDice(ns.dice);
this.gs = ns;
this.busy = false;
this.advance();
}
// ── human: discards ─────────────────────────────────────────────────────────
async handleDiscardPhase() {
this.busy = true;
// AI discards first.
for (const seat of [...this.gs.discardQueue]) {
if (seat === 0) continue;
this.gs = L.applyDiscard(this.gs, seat, AI.chooseDiscard(this.gs, seat));
}
this.renderAll();
if (this.gs.discardQueue.includes(0)) {
this.busy = false;
this.openDiscardPanel(); // human picks; on confirm → advance
return;
}
await this.delay(300);
this.busy = false;
this.advance();
}
// ── human: robber ─────────────────────────────────────────────────────────────
promptRobber() {
this.clearHighlights();
for (const hex of this.gs.hexes) {
if (hex.hasRobber) continue;
const { x, y } = this.hexPos(hex.id);
this.addHighlight(x, y, () => {
this.clearHighlights();
const targets = L.stealTargets(this.gs, hex.id, 0);
if (targets.length <= 1) {
this.gs = L.moveRobber(this.gs, hex.id, targets[0] ?? null);
this.advance();
} else {
this.pickStealTarget(hex.id, targets);
}
}, 0x222222, 20);
}
}
pickStealTarget(hexId, targets) {
const panel = this.modalPanel(540, 'Steal from which player?');
targets.forEach((seat, i) => {
this.modalButton(panel, 1000, 480 + i * 64, `${this.pname(seat)} (${L.handSize(this.gs.players[seat])} cards)`, () => {
panel.destroy();
this.gs = L.moveRobber(this.gs, hexId, seat);
this.advance();
});
});
}
// ── human: build modes ────────────────────────────────────────────────────────
enterPlace(type) {
if (this.busy || this.gs.phase !== 'action' || this.gs.currentPlayer !== 0) return;
this.clearHighlights();
this.placeMode = type;
const s = this.gs;
if (type === 'road') {
for (const eid of L.legalRoadEdges(s, 0, false)) {
const { x, y } = this.edgePos(eid);
this.addHighlight(x, y, () => this.doBuild('road', eid), COLORS.gold, 13);
}
} else if (type === 'settlement') {
for (const nid of L.legalSettlementNodes(s, 0, false)) {
const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => this.doBuild('settlement', nid));
}
} else if (type === 'city') {
for (const nid of L.legalCityNodes(s, 0)) {
const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700);
}
}
this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
}
doBuild(type, id) {
this.clearHighlights();
this.placeMode = null;
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);
playSound(this, SFX.PIECE_CLICK);
this.advance();
}
onBuyDev() {
if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights(); this.placeMode = null;
this.gs = L.buyDevCard(this.gs, 0);
playSound(this, SFX.CARD_DEAL);
this.advance();
}
onEndTurn() {
if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights();
this.placeMode = null;
this.gs = L.endTurn(this.gs);
this.advance();
}
// ── dev card menu ─────────────────────────────────────────────────────────────
openDevMenu() {
if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights(); this.placeMode = null;
const playable = [...new Set(this.gs.players[0].devCards.filter((c) => c !== 'vp'))];
if (!playable.length) return;
const panel = this.modalPanel(560, 'Play a development card');
playable.forEach((card, i) => {
this.modalButton(panel, 1000, 500 + i * 64, DEV_INFO[card].label, () => {
panel.destroy();
this.playHumanDev(card);
});
});
this.modalButton(panel, 1000, 500 + playable.length * 64, 'Cancel', () => panel.destroy(), 'ghost');
}
playHumanDev(card) {
if (card === 'knight') {
this.gs = L.playKnight(this.gs, 0);
this.advance(); // phase becomes moveRobber → promptRobber
} else if (card === 'roadBuilding') {
this.gs = L.playRoadBuilding(this.gs, 0);
this.advance();
} else if (card === 'monopoly') {
this.pickResources(1, 'Monopolize which resource?', (rs) => {
this.gs = L.playMonopoly(this.gs, 0, rs[0]);
this.advance();
});
} else if (card === 'yearOfPlenty') {
this.pickResources(2, 'Choose 2 resources', (rs) => {
this.gs = L.playYearOfPlenty(this.gs, 0, rs[0], rs[1]);
this.advance();
});
}
}
// pick `count` resources (with repetition) then callback
pickResources(count, title, cb) {
const chosen = [];
const panel = this.modalPanel(540, title);
const label = this.add.text(1000, 470, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
panel.add(label);
const refresh = () => label.setText(chosen.map((r) => RESOURCE_INFO[r].label).join(', ') || '—');
RESOURCE_TYPES.forEach((r, i) => {
this.modalButton(panel, 850 + (i % 3) * 150, 540 + Math.floor(i / 3) * 64, RESOURCE_INFO[r].label, () => {
chosen.push(r); refresh();
if (chosen.length >= count) { panel.destroy(); label.destroy(); cb(chosen); }
});
});
}
// ── trade panel (bank / port / player offer) ───────────────────────────────────
openTradePanel() {
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 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, 470, 760, 540, 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, 300, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const hintGet = this.add.text(1240, 300, '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 valTexts = {};
const stepper = (col, r, i, side) => {
const x = col, y = 350 + i * 50;
const lbl = this.add.text(x - 150, y, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(D.panel + 1);
const minus = this.add.text(x - 10, y, '', { fontFamily: 'Righteous', fontSize: '28px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
const val = this.add.text(x + 30, y, '0', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const plus = this.add.text(x + 70, y, '+', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
const bag = side === 'give' ? give : get;
valTexts[side + r] = val;
minus.on('pointerdown', () => { if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); } });
plus.on('pointerdown', () => {
if (side === 'give' && bag[r] >= this.gs.players[0].resources[r]) return;
bag[r]++; val.setText(String(bag[r]));
});
objs.push(lbl, minus, val, plus);
};
RESOURCE_TYPES.forEach((r, i) => { stepper(760, r, i, 'give'); stepper(1240, r, i, 'get'); });
const close = () => objs.forEach((o) => o.destroy());
const bankBtn = new Button(this, 850, 640, 'Bank / Port', () => {
const gKeys = RESOURCE_TYPES.filter((r) => give[r] > 0);
const tKeys = RESOURCE_TYPES.filter((r) => get[r] > 0);
if (gKeys.length === 1 && tKeys.length === 1 && get[tKeys[0]] === 1) {
const r = gKeys[0];
if (give[r] === L.bestTradeRatio(this.gs, 0, r)) {
close();
this.gs = L.tradeWithBank(this.gs, 0, r, tKeys[0]);
playSound(this, SFX.CHIP_BET);
this.advance();
return;
}
}
this.flashStatus('Bank trade needs N of one resource for 1 of another (N = your ratio).');
}, { width: 220, height: 48 }).setDepth(D.panel + 1);
const offerBtn = new Button(this, 1150, 640, 'Offer to Players', () => {
const gCount = RESOURCE_TYPES.reduce((s, r) => s + give[r], 0);
const tCount = RESOURCE_TYPES.reduce((s, r) => s + get[r], 0);
if (!gCount || !tCount) { this.flashStatus('Set what you give and get.'); return; }
let accepted = null;
for (let seat = 1; seat < this.gs.playerCount; seat++) {
// AI gives `get` (what we want), receives `give` (what we offer).
if (AI.respondToTrade(this.gs, seat, get, give)) { accepted = seat; break; }
}
if (accepted == null) { this.flashStatus('No opponent accepted that offer.'); return; }
close();
this.gs = L.executePlayerTrade(this.gs, 0, accepted, give, get);
playSound(this, SFX.CARD_PLACE);
this.flashStatus(`${this.pname(accepted)} accepted the trade.`);
this.advance();
}, { width: 220, height: 48 }).setDepth(D.panel + 1);
const cancelBtn = new Button(this, 1000, 700, 'Cancel', () => close(), { variant: 'ghost', width: 160, height: 44 }).setDepth(D.panel + 1);
objs.push(bankBtn, offerBtn, cancelBtn);
}
// ── discard panel ───────────────────────────────────────────────────────────
openDiscardPanel() {
const need = L.discardAmount(this.gs.players[0]);
const discard = { 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, 470, 720, 460, COLORS.panel, 1).setStrokeStyle(3, COLORS.danger).setDepth(D.panel);
const title = this.add.text(1000, 280, `Discard ${need} cards`, { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.dangerHex }).setOrigin(0.5).setDepth(D.panel + 1);
const counter = this.add.text(1000, 330, `0 / ${need}`, { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const objs = [overlay, box, title, counter];
const sum = () => RESOURCE_TYPES.reduce((s, r) => s + discard[r], 0);
const refresh = () => { counter.setText(`${sum()} / ${need}`); confirmBtn.setEnabled(sum() === need); };
RESOURCE_TYPES.forEach((r, i) => {
const x = 760 + i * 120, y = 430;
const g = this.add.graphics().setDepth(D.panel + 1);
g.fillStyle(RESOURCE_INFO[r].swatch, 1); g.fillRoundedRect(x - 40, y - 34, 80, 68, 8);
const have = this.add.text(x, y - 10, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#1a1208' }).setOrigin(0.5).setDepth(D.panel + 1);
const val = this.add.text(x, y + 12, '0', { fontFamily: 'Righteous', fontSize: '20px', color: '#1a1208' }).setOrigin(0.5).setDepth(D.panel + 1);
const minus = this.add.text(x - 22, y + 60, '', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
const plus = this.add.text(x + 22, y + 60, '+', { fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
minus.on('pointerdown', () => { if (discard[r] > 0) { discard[r]--; val.setText(String(discard[r])); refresh(); } });
plus.on('pointerdown', () => { if (discard[r] < this.gs.players[0].resources[r] && sum() < need) { discard[r]++; val.setText(String(discard[r])); refresh(); } });
objs.push(g, have, val, minus, plus);
});
const confirmBtn = new Button(this, 1000, 640, 'Discard', () => {
if (sum() !== need) return;
objs.forEach((o) => o.destroy()); confirmBtn.destroy();
this.gs = L.applyDiscard(this.gs, 0, discard);
this.handleDiscardPhase();
}, { width: 200, height: 48 }).setDepth(D.panel + 1);
confirmBtn.setEnabled(false);
objs.push(confirmBtn);
}
// ── modal helpers ───────────────────────────────────────────────────────────
modalPanel(topY, title) {
const objs = [];
objs.push(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel));
objs.push(this.add.rectangle(1000, topY, 460, 420, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel));
objs.push(this.add.text(1000, topY - 170, title, { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, wordWrap: { width: 420 }, align: 'center' }).setOrigin(0.5).setDepth(D.panel + 1));
return {
objs,
add: (scene) => null,
destroy() { objs.forEach((o) => o.destroy()); },
};
}
modalButton(panel, x, y, label, fn, variant = 'solid') {
const b = new Button(this, x, y, label, fn, { variant, width: 340, height: 50, fontSize: 20 }).setDepth(D.panel + 1);
panel.objs.push(b);
return b;
}
flashStatus(msg) {
this.statusText.setText(msg);
}
showTurnBanner(text) {
const banner = this.add.text(1000, 120, text, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
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() })) });
}
// ── game over ─────────────────────────────────────────────────────────────────
onGameOver() {
this.clearHighlights();
const winner = this.gs.winner;
const isHuman = winner === 0;
if (isHuman) {
const emitter = this.add.particles(1000, 470, 'catanParticle', {
speed: { min: 120, max: 420 }, lifespan: 1300, scale: { start: 1.2, end: 0 },
alpha: { start: 1, end: 0 }, quantity: 4, frequency: 30,
tint: [0xffd700, 0xffffff, COLORS.accent], angle: { min: 0, max: 360 },
}).setDepth(D.banner);
this.time.delayedCall(1800, () => emitter.destroy());
}
this.recordHistory();
const overlay = this.add.rectangle(1000, 470, 760, 420, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(D.banner);
const lines = this.gs.players
.map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`)
.join('\n');
const title = this.add.text(1000, 330, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, {
fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.banner + 1);
const body = this.add.text(1000, 460, lines, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.banner + 1);
const playAgain = new Button(this, 900, 600, 'Play Again', () => {
overlay.destroy(); title.destroy(); body.destroy(); playAgain.destroy(); leave.destroy();
this.startNewMatch();
}, { width: 200, fontSize: 22 }).setDepth(D.banner + 1);
const leave = new Button(this, 1110, 600, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 200, fontSize: 22 }).setDepth(D.banner + 1);
}
async recordHistory() {
const totals = this.gs.players.map((_, i) => L.victoryPoints(this.gs, i));
const result = this.gs.winner === 0 ? 'win' : 'loss';
try {
await api.post('/history/single-player', {
slug: 'catan', score: totals[0], opponentScores: totals.slice(1), result,
});
} catch (_) { /* offline / not signed in — ignore */ }
}
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
}

View File

@ -0,0 +1,612 @@
// CatanLogic.js — pure state engine for Settlers of Catan. No Phaser imports.
// Every action takes a state and returns a NEW state (deep-cloned first).
import {
NODES, EDGES, HEXES, PORT_SLOTS, edgeBetween,
RESOURCE_BAG, CHIT_BAG, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
} from './CatanBoard.js';
// ── small utilities ─────────────────────────────────────────────────────────
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;
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
const emptyResources = () => ({ brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 });
export function handSize(player) {
return RESOURCE_TYPES.reduce((s, r) => s + player.resources[r], 0);
}
// Hex adjacency (share an edge → share 2 corner nodes).
const HEX_NEIGHBORS = HEXES.map(() => []);
for (const e of EDGES) {
if (e.hexes.length === 2) {
const [h1, h2] = e.hexes;
if (!HEX_NEIGHBORS[h1].includes(h2)) HEX_NEIGHBORS[h1].push(h2);
if (!HEX_NEIGHBORS[h2].includes(h1)) HEX_NEIGHBORS[h2].push(h1);
}
}
// ── occupancy queries ────────────────────────────────────────────────────────
export function nodeBuilding(state, nodeId) {
for (const p of state.players) {
if (p.cities.includes(nodeId)) return { seat: p.seat, type: 'city' };
if (p.settlements.includes(nodeId)) return { seat: p.seat, type: 'settlement' };
}
return null;
}
export function edgeOwner(state, edgeId) {
for (const p of state.players) if (p.roads.includes(edgeId)) return p.seat;
return null;
}
// ── initial state ─────────────────────────────────────────────────────────────
export function createInitialState(playerCount = 3) {
const n = Math.max(3, Math.min(4, playerCount));
// Resources onto hexes.
const resources = shuffle(RESOURCE_BAG);
const hexes = HEXES.map((h, i) => ({
id: h.id,
resource: resources[i],
number: null,
hasRobber: false,
}));
const desertHex = hexes.find((h) => h.resource === 'desert');
desertHex.hasRobber = true;
// Number chits onto non-desert hexes, 6/8 never adjacent.
const nonDesert = hexes.filter((h) => h.resource !== 'desert');
for (let attempt = 0; attempt < 500; attempt++) {
const chits = shuffle(CHIT_BAG);
nonDesert.forEach((h, i) => { h.number = chits[i]; });
let ok = true;
for (const h of nonDesert) {
if (h.number !== 6 && h.number !== 8) continue;
for (const nb of HEX_NEIGHBORS[h.id]) {
const other = hexes[nb];
if (other.number === 6 || other.number === 8) { ok = false; break; }
}
if (!ok) break;
}
if (ok) break;
}
// Port types onto fixed slots.
const portTypes = shuffle(PORT_BAG);
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,
}));
const players = [];
for (let seat = 0; seat < n; seat++) {
players.push({
seat,
colorIndex: seat,
isHuman: seat === 0,
resources: emptyResources(),
settlements: [],
cities: [],
roads: [],
devCards: [], // playable now (bought on a previous turn)
newDevCards: [], // bought this turn, not yet playable
vpCards: 0, // hidden victory-point dev cards
knightsPlayed: 0,
playedDevThisTurn: false,
});
}
// Snake setup order: 0..n-1 then n-1..0.
const order = [];
for (let s = 0; s < n; s++) order.push(s);
for (let s = n - 1; s >= 0; s--) order.push(s);
return {
playerCount: n,
hexes,
ports,
players,
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
devDeck: shuffle(DEV_DECK),
robberHex: desertHex.id,
phase: 'setup',
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
currentPlayer: order[0],
dice: null,
diceTotal: null,
robberReturnPhase: 'action',
discardQueue: [],
freeRoads: 0,
longestRoad: { owner: null, length: 0 },
largestArmy: { owner: null, count: 0 },
winner: null,
log: [],
};
}
function logEvent(state, msg) {
state.log.push(msg);
if (state.log.length > 12) state.log.shift();
}
// ── legality helpers ──────────────────────────────────────────────────────────
export function legalSettlementNodes(state, seat, setup = false) {
const out = [];
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 touchesRoad = node.adj.some((a) => {
const eid = edgeBetween(node.id, a);
return eid >= 0 && state.players[seat].roads.includes(eid);
});
if (!touchesRoad) continue;
}
out.push(node.id);
}
return out;
}
export function legalCityNodes(state, seat) {
return [...state.players[seat].settlements];
}
function nodeConnectsForRoad(state, seat, nodeId, excludeEdgeId) {
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat) return false; // blocked by opponent building
if (bld && bld.seat === seat) return true; // own settlement/city
for (const adj of NODES[nodeId].adj) {
const eid = edgeBetween(nodeId, adj);
if (eid === excludeEdgeId || eid < 0) continue;
if (state.players[seat].roads.includes(eid)) return true;
}
return false;
}
export function legalRoadEdges(state, seat, setup = false, fromNode = null) {
const out = [];
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);
continue;
}
if (nodeConnectsForRoad(state, seat, a, e.id) || nodeConnectsForRoad(state, seat, b, e.id)) {
out.push(e.id);
}
}
return out;
}
// ── cost helpers ───────────────────────────────────────────────────────────────
export function canAfford(player, cost) {
return Object.entries(cost).every(([r, n]) => player.resources[r] >= n);
}
function pay(state, player, cost) {
for (const [r, n] of Object.entries(cost)) {
player.resources[r] -= n;
state.bank[r] += n;
}
}
// ── setup phase ─────────────────────────────────────────────────────────────
export function placeSetupSettlement(state, seat, nodeId) {
const s = cloneState(state);
if (s.phase !== 'setup' || s.currentPlayer !== seat || s.setup.placing !== 'settlement') return s;
if (!legalSettlementNodes(s, seat, true).includes(nodeId)) return s;
s.players[seat].settlements.push(nodeId);
s.setup.lastSettlement = nodeId;
s.setup.placing = 'road';
// Second round (idx >= playerCount): grant resources from adjacent hexes.
if (s.setup.idx >= s.playerCount) {
for (const hx of NODES[nodeId].hexes) {
const hex = s.hexes[hx];
if (hex.resource === 'desert') continue;
if (s.bank[hex.resource] > 0) { s.players[seat].resources[hex.resource]++; s.bank[hex.resource]--; }
}
}
return s;
}
export function placeSetupRoad(state, seat, edgeId) {
const s = cloneState(state);
if (s.phase !== 'setup' || s.currentPlayer !== seat || s.setup.placing !== 'road') return s;
if (!legalRoadEdges(s, seat, true, s.setup.lastSettlement).includes(edgeId)) return s;
s.players[seat].roads.push(edgeId);
s.setup.idx++;
if (s.setup.idx >= s.setup.order.length) {
// Setup complete — first player rolls.
s.phase = 'rollPhase';
s.currentPlayer = s.setup.order[0];
recomputeLongestRoad(s);
logEvent(s, 'Setup complete. Roll to begin!');
} else {
s.currentPlayer = s.setup.order[s.setup.idx];
s.setup.placing = 'settlement';
}
return s;
}
// ── dice / production ─────────────────────────────────────────────────────────
export function rollDice(state) {
const s = cloneState(state);
if (s.phase !== 'rollPhase') return s;
const d1 = 1 + Math.floor(Math.random() * 6);
const d2 = 1 + Math.floor(Math.random() * 6);
s.dice = [d1, d2];
s.diceTotal = d1 + d2;
logEvent(s, `${playerName(s, s.currentPlayer)} rolled ${d1 + d2}.`);
if (s.diceTotal === 7) {
s.robberReturnPhase = 'action';
s.discardQueue = s.players.filter((p) => handSize(p) > 7).map((p) => p.seat);
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
} else {
produceResources(s, s.diceTotal);
s.phase = 'action';
}
return s;
}
function produceResources(s, total) {
for (const hex of s.hexes) {
if (hex.number !== total || hex.hasRobber) continue;
const corners = HEXES[hex.id].corners;
for (const nodeId of corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
const amt = bld.type === 'city' ? 2 : 1;
const give = Math.min(amt, s.bank[hex.resource]);
if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; }
}
}
}
// ── discard / robber ───────────────────────────────────────────────────────────
export function discardAmount(player) {
return Math.floor(handSize(player) / 2);
}
export function applyDiscard(state, seat, discard) {
const s = cloneState(state);
if (s.phase !== 'discard' || !s.discardQueue.includes(seat)) return s;
const need = discardAmount(s.players[seat]);
const total = RESOURCE_TYPES.reduce((t, r) => t + (discard[r] || 0), 0);
if (total !== need) return s;
for (const r of RESOURCE_TYPES) {
const k = discard[r] || 0;
if (k > s.players[seat].resources[r]) return s;
}
for (const r of RESOURCE_TYPES) {
const k = discard[r] || 0;
s.players[seat].resources[r] -= k;
s.bank[r] += k;
}
s.discardQueue = s.discardQueue.filter((x) => x !== seat);
if (s.discardQueue.length === 0) s.phase = 'moveRobber';
return s;
}
// Seats with a building on the given hex (excluding `seat`) that have cards.
export function stealTargets(state, hexId, seat) {
const targets = new Set();
for (const nodeId of HEXES[hexId].corners) {
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat && handSize(state.players[bld.seat]) > 0) targets.add(bld.seat);
}
return [...targets];
}
export function moveRobber(state, hexId, targetSeat = null) {
const s = cloneState(state);
if (s.phase !== 'moveRobber') return s;
if (hexId === s.robberHex) return s;
s.hexes[s.robberHex].hasRobber = false;
s.hexes[hexId].hasRobber = true;
s.robberHex = hexId;
const seat = s.currentPlayer;
const valid = stealTargets(s, hexId, seat);
if (targetSeat !== null && valid.includes(targetSeat)) {
const victim = s.players[targetSeat];
const pool = [];
for (const r of RESOURCE_TYPES) for (let i = 0; i < victim.resources[r]; i++) pool.push(r);
if (pool.length) {
const r = pool[Math.floor(Math.random() * pool.length)];
victim.resources[r]--;
s.players[seat].resources[r]++;
logEvent(s, `${playerName(s, seat)} stole from ${playerName(s, targetSeat)}.`);
}
}
s.phase = s.robberReturnPhase;
return s;
}
// ── building ───────────────────────────────────────────────────────────────────
export function buildRoad(state, seat, edgeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (!legalRoadEdges(s, seat, false).includes(edgeId)) return s;
const free = s.freeRoads > 0;
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);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
}
export function buildSettlement(state, seat, nodeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (!legalSettlementNodes(s, seat, false).includes(nodeId)) return s;
if (!canAfford(s.players[seat], COSTS.settlement)) return s;
pay(s, s.players[seat], COSTS.settlement);
s.players[seat].settlements.push(nodeId);
recomputeLongestRoad(s); // may break an opponent's road
checkWin(s, seat);
return s;
}
export function buildCity(state, seat, nodeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (!s.players[seat].settlements.includes(nodeId)) return s;
if (!canAfford(s.players[seat], COSTS.city)) return s;
pay(s, s.players[seat], COSTS.city);
s.players[seat].settlements = s.players[seat].settlements.filter((x) => x !== nodeId);
s.players[seat].cities.push(nodeId);
checkWin(s, seat);
return s;
}
// ── development cards ────────────────────────────────────────────────────────
export function buyDevCard(state, seat) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (s.devDeck.length === 0 || !canAfford(s.players[seat], COSTS.devCard)) return s;
pay(s, s.players[seat], COSTS.devCard);
const card = s.devDeck.pop();
if (card === 'vp') {
s.players[seat].vpCards++;
logEvent(s, `${playerName(s, seat)} bought a development card.`);
checkWin(s, seat);
} else {
s.players[seat].newDevCards.push(card);
logEvent(s, `${playerName(s, seat)} bought a development card.`);
}
return s;
}
function canPlayDev(s, seat, type) {
if (s.currentPlayer !== seat) return false;
if (s.players[seat].playedDevThisTurn) return false;
if (!s.players[seat].devCards.includes(type)) return false;
return s.phase === 'action' || (s.phase === 'rollPhase' && type === 'knight');
}
function consumeDev(s, seat, type) {
const i = s.players[seat].devCards.indexOf(type);
s.players[seat].devCards.splice(i, 1);
s.players[seat].playedDevThisTurn = true;
}
export function playKnight(state, seat) {
const s = cloneState(state);
if (!canPlayDev(s, seat, 'knight')) return s;
consumeDev(s, seat, 'knight');
s.players[seat].knightsPlayed++;
recomputeLargestArmy(s);
s.robberReturnPhase = s.phase; // return to rollPhase or action after moving
s.phase = 'moveRobber';
logEvent(s, `${playerName(s, seat)} played a Knight.`);
checkWin(s, seat);
return s;
}
export function playRoadBuilding(state, seat) {
const s = cloneState(state);
if (!canPlayDev(s, seat, 'roadBuilding')) return s;
consumeDev(s, seat, 'roadBuilding');
s.freeRoads = 2;
logEvent(s, `${playerName(s, seat)} played Road Building.`);
return s;
}
export function playYearOfPlenty(state, seat, r1, r2) {
const s = cloneState(state);
if (!canPlayDev(s, seat, 'yearOfPlenty')) return s;
consumeDev(s, seat, 'yearOfPlenty');
for (const r of [r1, r2]) {
if (RESOURCE_TYPES.includes(r) && s.bank[r] > 0) { s.players[seat].resources[r]++; s.bank[r]--; }
}
logEvent(s, `${playerName(s, seat)} played Year of Plenty.`);
return s;
}
export function playMonopoly(state, seat, resource) {
const s = cloneState(state);
if (!canPlayDev(s, seat, 'monopoly')) return s;
if (!RESOURCE_TYPES.includes(resource)) return s;
consumeDev(s, seat, 'monopoly');
let taken = 0;
for (const p of s.players) {
if (p.seat === seat) continue;
taken += p.resources[resource];
p.resources[resource] = 0;
}
s.players[seat].resources[resource] += taken;
logEvent(s, `${playerName(s, seat)} monopolized ${resource} (+${taken}).`);
return s;
}
// ── trading ────────────────────────────────────────────────────────────────────
export function ownsPort(state, seat, type) {
for (const port of state.ports) {
if (port.type !== type) continue;
for (const nodeId of port.nodes) {
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat === seat) return true;
}
}
return false;
}
export function bestTradeRatio(state, seat, resource) {
if (ownsPort(state, seat, resource)) return 2;
if (ownsPort(state, seat, 'any')) return 3;
return 4;
}
export function tradeWithBank(state, seat, giveRes, getRes) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (giveRes === getRes) return s;
const ratio = bestTradeRatio(s, seat, giveRes);
if (s.players[seat].resources[giveRes] < ratio || s.bank[getRes] < 1) return s;
s.players[seat].resources[giveRes] -= ratio;
s.bank[giveRes] += ratio;
s.players[seat].resources[getRes] += 1;
s.bank[getRes] -= 1;
logEvent(s, `${playerName(s, seat)} traded ${ratio} ${giveRes} for 1 ${getRes}.`);
return s;
}
// Direct resource swap between two players (validity pre-checked by caller/AI).
export function executePlayerTrade(state, fromSeat, toSeat, give, get) {
const s = cloneState(state);
const A = s.players[fromSeat], B = s.players[toSeat];
for (const r of RESOURCE_TYPES) {
if (A.resources[r] < (give[r] || 0)) return s;
if (B.resources[r] < (get[r] || 0)) return s;
}
for (const r of RESOURCE_TYPES) {
A.resources[r] -= (give[r] || 0); B.resources[r] += (give[r] || 0);
B.resources[r] -= (get[r] || 0); A.resources[r] += (get[r] || 0);
}
logEvent(s, `${playerName(s, fromSeat)} traded with ${playerName(s, toSeat)}.`);
return s;
}
// ── end turn ─────────────────────────────────────────────────────────────────
export function endTurn(state) {
const s = cloneState(state);
if (s.phase !== 'action') return s;
const p = s.players[s.currentPlayer];
p.devCards.push(...p.newDevCards);
p.newDevCards = [];
p.playedDevThisTurn = false;
s.freeRoads = 0;
s.dice = null;
s.currentPlayer = (s.currentPlayer + 1) % s.playerCount;
s.phase = 'rollPhase';
return s;
}
// ── longest road / largest army / victory ──────────────────────────────────────
function longestRoadFor(state, seat) {
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 EDGES[eid].nodes) {
if (!incident.has(node)) incident.set(node, []);
incident.get(node).push(eid);
}
}
const blocked = (nodeId) => {
const bld = nodeBuilding(state, nodeId);
return bld && bld.seat !== seat;
};
let best = 0;
const dfs = (node, used, len) => {
if (len > best) best = len;
if (len > 0 && blocked(node)) return;
for (const eid of incident.get(node) || []) {
if (used.has(eid)) continue;
const [a, b] = EDGES[eid].nodes;
const next = a === node ? b : a;
used.add(eid);
dfs(next, used, len + 1);
used.delete(eid);
}
};
for (const node of incident.keys()) dfs(node, new Set(), 0);
return best;
}
export function recomputeLongestRoad(state) {
const lengths = state.players.map((p) => longestRoadFor(state, p.seat));
const maxLen = Math.max(...lengths);
const cur = state.longestRoad.owner;
if (maxLen < 5) {
state.longestRoad = { owner: null, length: maxLen };
return;
}
const leaders = lengths.map((l, seat) => ({ l, seat })).filter((x) => x.l === maxLen);
let owner;
if (cur !== null && lengths[cur] === maxLen) owner = cur; // incumbent keeps on tie
else if (leaders.length === 1) owner = leaders[0].seat; // unique new leader
else owner = cur !== null && lengths[cur] >= 5 ? cur : null; // tie, no clear taker
state.longestRoad = { owner, length: maxLen };
}
export function recomputeLargestArmy(state) {
const counts = state.players.map((p) => p.knightsPlayed);
const maxC = Math.max(...counts);
const cur = state.largestArmy.owner;
if (maxC < 3) { state.largestArmy = { owner: null, count: maxC }; return; }
const leaders = counts.map((c, seat) => ({ c, seat })).filter((x) => x.c === maxC);
let owner;
if (cur !== null && counts[cur] === maxC) owner = cur;
else if (leaders.length === 1) owner = leaders[0].seat;
else owner = cur !== null && counts[cur] >= 3 ? cur : null;
state.largestArmy = { owner, count: maxC };
}
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;
}
// Public VP excludes hidden VP dev cards (for opponent display).
export function publicVictoryPoints(state, seat) {
const p = state.players[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;
}
function checkWin(state, seat) {
if (victoryPoints(state, seat) >= WIN_VP) {
state.winner = seat;
state.phase = 'gameOver';
logEvent(state, `${playerName(state, seat)} wins!`);
}
}
// ── names (filled in by the scene via setPlayerNames) ──────────────────────────
export function playerName(state, seat) {
return state.players[seat]?.name ?? (seat === 0 ? 'You' : `Player ${seat}`);
}
export function setPlayerNames(state, names) {
state.players.forEach((p, i) => { p.name = names[i] ?? (i === 0 ? 'You' : `Player ${i}`); });
}
export { WIN_VP, COSTS, RESOURCE_TYPES };

View File

@ -24,6 +24,7 @@ import CrapsGame from './games/craps/CrapsGame.js';
import RouletteGame from './games/roulette/RouletteGame.js';
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
import HeartsGame from './games/hearts/HeartsGame.js';
import CatanGame from './games/catan/CatanGame.js';
const config = {
type: Phaser.AUTO,
@ -61,6 +62,7 @@ const config = {
RouletteGame,
MexicanTrainGame,
HeartsGame,
CatanGame,
],
};

View File

@ -15,7 +15,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -37,3 +37,4 @@ registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1,
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });