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