fertig-classic-games/public/src/games/catan/CatanGame.js

1245 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)); }
}