Added Catan and multiple sound effects
This commit is contained in:
parent
f667f1ead3
commit
036298227f
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.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)); }
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue