1245 lines
55 KiB
JavaScript
1245 lines
55 KiB
JavaScript
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, HEX_SIZE, HEX_W,
|
||
} 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.buildBankPanel();
|
||
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.hexBorderGfx = this.add.graphics().setDepth(D.board + 2);
|
||
this.hexImgs = [];
|
||
this.hexLabels = [];
|
||
this.portObjs = [];
|
||
}
|
||
|
||
// Frame pairs per resource: pick one at random each draw.
|
||
static TILE_FRAMES = {
|
||
lumber: [0, 1],
|
||
wool: [2, 3],
|
||
brick: [4, 5],
|
||
ore: [6, 7],
|
||
grain: [8, 9],
|
||
desert: [10, 11],
|
||
};
|
||
|
||
drawHexes() {
|
||
const g = this.hexGfx;
|
||
g.clear();
|
||
this.hexBorderGfx.clear();
|
||
this.hexImgs.forEach(({ img, maskG }) => { img.destroy(); maskG.destroy(); });
|
||
this.hexImgs = [];
|
||
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 { x, y } = this.hexPos(hex.id);
|
||
|
||
// Color fill as fallback base layer
|
||
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
|
||
g.fillStyle(color, 1);
|
||
g.fillPoints(pts, true);
|
||
|
||
// Tile image clipped to hex polygon
|
||
if (this.textures.exists('catan-tiles')) {
|
||
const frames = CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11];
|
||
const frame = frames[Math.floor(Math.random() * 2)];
|
||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||
maskG.fillStyle(0xffffff);
|
||
maskG.fillPoints(pts, true);
|
||
const img = this.add.image(x, y, 'catan-tiles', frame)
|
||
.setDisplaySize(HEX_W, HEX_SIZE * 2)
|
||
.setMask(maskG.createGeometryMask())
|
||
.setDepth(D.board + 1);
|
||
this.hexImgs.push({ img, maskG });
|
||
}
|
||
|
||
// Border on top of images
|
||
this.hexBorderGfx.lineStyle(4, 0x6b4a1a, 0.85);
|
||
this.hexBorderGfx.strokePoints(pts, true);
|
||
|
||
// Resource label
|
||
const label = hex.resource === 'desert' ? 'Desert' : RESOURCE_INFO[hex.resource].tile;
|
||
this.hexLabels.push(this.add.text(x, y - 56, label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
|
||
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
|
||
}
|
||
}
|
||
|
||
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 = [];
|
||
this.chitByHexId = {};
|
||
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);
|
||
this.chitByHexId[hex.id] = 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, 'CatanGame');
|
||
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;
|
||
const cardW = 60, cardH = 84, cardR = 6, borderW = 3;
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
const x = startX + i * gap, y = 950;
|
||
// 1. Dark background fill
|
||
const gFill = this.add.graphics().setDepth(D.hud);
|
||
gFill.fillStyle(0x111111, 0.85);
|
||
gFill.fillRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
|
||
// 2. Card artwork (270×390 source → 54×78 display)
|
||
this.add.image(x, y, 'catan-cards', i).setDisplaySize(54, 78).setDepth(D.hud);
|
||
// 3. Colored border on top of artwork
|
||
const gBorder = this.add.graphics().setDepth(D.hud);
|
||
gBorder.lineStyle(borderW, RESOURCE_INFO[r].swatch, 1);
|
||
gBorder.strokeRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
|
||
// 4. Resource label below card
|
||
this.add.text(x, y + cardH / 2 + 9, RESOURCE_INFO[r].label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||
// 5. Quantity number over artwork
|
||
this.resText[r] = this.add.text(x, y + 6, '0', {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: '#ffffff',
|
||
stroke: '#000000', strokeThickness: 4,
|
||
shadow: { color: '#000000', fill: true, offsetX: 2, offsetY: 2, blur: 3 },
|
||
}).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 (bottom bar, right of dice)
|
||
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 panelRight = 1900;
|
||
const panelW = 320;
|
||
const cx = panelRight - panelW / 2;
|
||
const bgCy = 980, bgH = 164;
|
||
|
||
const panel = this.add.container(0, 0).setDepth(D.hud);
|
||
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
|
||
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
|
||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5, 0));
|
||
|
||
const rows = [
|
||
{ name: 'Road', resources: ['brick', 'lumber'] },
|
||
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
|
||
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
|
||
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
|
||
];
|
||
|
||
const lx = panelRight - panelW + 14;
|
||
const rx = panelRight - 14;
|
||
const rowY0 = bgCy - bgH / 2 + 44;
|
||
const rowStep = (bgH - 44 - 14) / (rows.length - 1);
|
||
const SW = 16, SH = 16, SG = 4, SR = 3; // swatch w/h/gap/radius
|
||
|
||
const g = this.add.graphics();
|
||
panel.add(g);
|
||
|
||
rows.forEach(({ name, resources }, i) => {
|
||
const ry = rowY0 + i * rowStep;
|
||
panel.add(this.add.text(lx, ry, name, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||
}).setOrigin(0, 0.5));
|
||
const totalW = resources.length * SW + (resources.length - 1) * SG;
|
||
let sx = rx - totalW;
|
||
for (const r of resources) {
|
||
g.fillStyle(RESOURCE_INFO[r].swatch, 1);
|
||
g.fillRoundedRect(sx, ry - SH / 2, SW, SH, SR);
|
||
sx += SW + SG;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── bank panel ───────────────────────────────────────────────────────────────
|
||
buildBankPanel() {
|
||
this.bankText = {};
|
||
// Panel flush against sea-circle right edge; wider to fit card + count text side by side
|
||
const panelX = 1489, panelW = 200, panelH = 632;
|
||
// Centre vertically in the playfield zone above the bottom bar (y=10..882)
|
||
const panelY = 10 + Math.round((872 - panelH) / 2); // 130
|
||
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126
|
||
const textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
|
||
const panelCx = panelX + panelW / 2; // for BANK title
|
||
|
||
const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4;
|
||
const imgW = 81, imgH = 117; // portrait in code; -90° rotation → landscape on screen
|
||
|
||
// Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top
|
||
const step = 96;
|
||
const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step);
|
||
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
|
||
|
||
this.bankCardPos = {};
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.bankCardPos[r] = { x: cardCx, y: stackTops[i] + cardH / 2 };
|
||
});
|
||
|
||
// Layer 1: panel background + card shadow fills
|
||
const gBg = this.add.graphics().setDepth(D.hud - 1);
|
||
gBg.fillStyle(COLORS.panel, 0.92);
|
||
gBg.fillRect(panelX, panelY, panelW, panelH);
|
||
gBg.lineStyle(2, COLORS.accent, 0.7);
|
||
gBg.strokeRect(panelX, panelY, panelW, panelH);
|
||
|
||
this.add.text(panelCx, panelY + 14, 'BANK', {
|
||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||
|
||
[...RESOURCE_TYPES, 'dev'].forEach((_, i) => {
|
||
const top = stackTops[i];
|
||
gBg.fillStyle(0x000000, 0.4);
|
||
gBg.fillRoundedRect(cardCx - cardW / 2 + shadow, top + shadow, cardW, cardH, cardR);
|
||
gBg.fillStyle(0x111111, 0.9);
|
||
gBg.fillRoundedRect(cardCx - cardW / 2, top, cardW, cardH, cardR);
|
||
});
|
||
|
||
gBg.lineStyle(1, COLORS.accent, 0.6);
|
||
gBg.lineBetween(panelX + 8, dividerY, panelX + panelW - 8, dividerY);
|
||
|
||
// Layer 2: card artwork images, rotated 90° CCW
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.add.image(cardCx, stackTops[i] + cardH / 2, 'catan-cards', i)
|
||
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
|
||
});
|
||
this.add.image(cardCx, stackTops[5] + cardH / 2, 'catan-cards', 8)
|
||
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
|
||
|
||
// Layer 3: colored borders
|
||
const gBorders = this.add.graphics().setDepth(D.hud);
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
gBorders.lineStyle(borderW, RESOURCE_INFO[r].swatch, 1);
|
||
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[i], cardW, cardH, cardR);
|
||
});
|
||
gBorders.lineStyle(borderW, COLORS.accent, 1);
|
||
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[5], cardW, cardH, cardR);
|
||
|
||
// Layer 4: count text to the right of each card
|
||
const countStyle = {
|
||
fontFamily: 'Righteous', fontSize: '30px', color: '#ffffff',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
};
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.bankText[r] = this.add.text(textX, stackTops[i] + cardH / 2, '19', countStyle)
|
||
.setOrigin(0.5).setDepth(D.hud);
|
||
});
|
||
this.bankText.dev = this.add.text(textX, stackTops[5] + cardH / 2, '25', countStyle)
|
||
.setOrigin(0.5).setDepth(D.hud);
|
||
}
|
||
|
||
updateBank() {
|
||
if (!this.bankText) return;
|
||
const b = this.gs.bank;
|
||
for (const r of RESOURCE_TYPES) this.bankText[r]?.setText(String(b[r]));
|
||
this.bankText.dev?.setText(String(this.gs.devDeck.length));
|
||
}
|
||
|
||
// ── resource collection animations ──────────────────────────────────────────
|
||
portraitPos(seat) {
|
||
if (seat === 0) return { x: 90, y: 980 };
|
||
const panel = this.oppPanels.find(p => p.seat === seat);
|
||
return panel ? { x: panel.x, y: panel.y } : { x: 130, y: 300 };
|
||
}
|
||
|
||
async animateResourceCollection(oldGs, newGs) {
|
||
if (newGs.diceTotal === 7) return;
|
||
const cards = [];
|
||
for (let seat = 0; seat < newGs.players.length; seat++) {
|
||
for (const r of RESOURCE_TYPES) {
|
||
const delta = newGs.players[seat].resources[r] - oldGs.players[seat].resources[r];
|
||
for (let n = 0; n < delta; n++) cards.push({ seat, resource: r });
|
||
}
|
||
}
|
||
if (cards.length === 0) return;
|
||
const matchingHexes = newGs.hexes.filter(h => h.number === newGs.diceTotal && !h.hasRobber);
|
||
await Promise.all(matchingHexes.map(h => this.animateChitPulse(h)));
|
||
for (const { seat, resource } of cards) await this.animateCardFlight(seat, resource);
|
||
}
|
||
|
||
animateChitPulse(hex) {
|
||
return new Promise(resolve => {
|
||
const chit = this.chitByHexId?.[hex.id];
|
||
const { x, y } = this.hexPos(hex.id);
|
||
const color = RESOURCE_INFO[hex.resource]?.swatch ?? COLORS.accent;
|
||
const doParticles = () => {
|
||
const emitter = this.add.particles(x, y + 6, 'catanParticle', {
|
||
speed: { min: 80, max: 220 }, lifespan: 700,
|
||
scale: { start: 1.4, end: 0 }, alpha: { start: 1, end: 0 },
|
||
quantity: 3, frequency: 25,
|
||
tint: [color, 0xffffff, 0xffd700], angle: { min: 0, max: 360 },
|
||
}).setDepth(D.chit + 2);
|
||
this.time.delayedCall(650, () => emitter.destroy());
|
||
};
|
||
if (!chit) { doParticles(); this.time.delayedCall(800, resolve); return; }
|
||
this.tweens.add({
|
||
targets: chit, scale: 1.6, duration: 200, ease: 'Back.easeOut',
|
||
onComplete: () => {
|
||
doParticles();
|
||
this.time.delayedCall(350, () => this.tweens.add({
|
||
targets: chit, scale: 1, duration: 200, ease: 'Back.easeIn',
|
||
onComplete: () => this.time.delayedCall(80, resolve),
|
||
}));
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
animateCardFlight(seat, resource) {
|
||
return new Promise(resolve => {
|
||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||
const src = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
|
||
const dst = this.portraitPos(seat);
|
||
const img = this.add.image(src.x, src.y, 'catan-cards', frameIdx)
|
||
.setDisplaySize(81, 117).setDepth(D.banner - 1);
|
||
let flipped = false;
|
||
this.tweens.add({
|
||
targets: img, x: dst.x, y: dst.y, duration: 500, ease: 'Quad.InOut',
|
||
onUpdate: (tween) => {
|
||
if (!flipped && tween.progress > 0.4) {
|
||
flipped = true;
|
||
this.tweens.add({
|
||
targets: img, scaleX: 0, duration: 100, ease: 'Linear',
|
||
onComplete: () => this.tweens.add({ targets: img, scaleX: 1, duration: 100, ease: 'Linear' }),
|
||
});
|
||
}
|
||
},
|
||
onComplete: () => {
|
||
playSound(this, SFX.CASINO_WIN);
|
||
img.destroy();
|
||
const radius = seat === 0 ? 64 : 56;
|
||
const label = this.add.text(dst.x + radius + 10, dst.y,
|
||
RESOURCE_INFO[resource].label.toUpperCase(), {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0, 0.5).setDepth(D.banner);
|
||
this.tweens.add({
|
||
targets: label, alpha: 0, y: dst.y - 24,
|
||
duration: 700, delay: 300,
|
||
onComplete: () => label.destroy(),
|
||
});
|
||
this.time.delayedCall(80, resolve);
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── 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.updateBank();
|
||
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 preGs = this.gs;
|
||
const ns = L.rollDice(this.gs);
|
||
await this.animateDice(ns.dice);
|
||
this.gs = ns;
|
||
await this.animateResourceCollection(preGs, 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 preGs = this.gs;
|
||
const ns = L.rollDice(this.gs);
|
||
await this.animateDice(ns.dice);
|
||
this.gs = ns;
|
||
await this.animateResourceCollection(preGs, 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)); }
|
||
}
|