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 { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR, PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE, } 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.cardBack = data.cardBack ?? null; this.tilePlacement = data.tilePlacement ?? 'standard'; this.expansion = data.expansion ?? 'base'; this.scenario = data.scenario ?? 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 this.handDisplay = []; this.handCardObjs = []; this.handSelectedIdx = 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 ────────────────────────────────────────────────────── // Active board geometry (base island, or the selected Seafarers scenario). get geo() { return L.geoFor(this.gs); } nodePos(id) { const n = this.geo.nodes[id]; return { x: n.x, y: n.y }; } edgePos(id) { const [a, b] = this.geo.edges[id].nodes; const N = this.geo.nodes; return { x: (N[a].x + N[b].x) / 2, y: (N[a].y + N[b].y) / 2 }; } hexPos(id) { const h = this.geo.hexes[id]; return { x: h.cx, y: h.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 — radial gradient via concentric circles, dark outer to lighter inner. const SEA_X = 1000, SEA_Y = 470, SEA_R = 470; const sea = this.add.graphics().setDepth(D.board - 1); const STEPS = 20; for (let i = STEPS; i >= 0; i--) { const t = i / STEPS; const r = Math.round(SEA_R * (i / STEPS)); // Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80) const ro = 0x07, go = 0x24, bo = 0x3a; const ri = 0x1a, gi = 0x5e, bi = 0x80; const red = Math.round(ro + (ri - ro) * (1 - t)); const green = Math.round(go + (gi - go) * (1 - t)); const blue = Math.round(bo + (bi - bo) * (1 - t)); const color = (red << 16) | (green << 8) | blue; sea.fillStyle(color, 1); sea.fillCircle(SEA_X, SEA_Y, r); } sea.lineStyle(4, 0x041820, 0.9); sea.strokeCircle(SEA_X, SEA_Y, SEA_R); } 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], sea: [12, 13], gold: [14, 15], fog: [16, 17], }; 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 = []; this.hexTileFrames = {}; // Inset a convex hex polygon toward its center. // s=0 collapses to point; s=1 is original. Uses inradius (79.7px) as scale reference. const inset = (pts, cx, cy, s) => pts.map(p => ({ x: cx + (p.x - cx) * s, y: cy + (p.y - cy) * s })); // Border insets/image size scale with the active board's hex size (the base // island uses 92; larger Seafarers boards use a smaller hex). const size = this.geo.size ?? HEX_SIZE; const hexW = Math.sqrt(3) * size; const inradius = size * Math.sqrt(3) / 2; for (const hex of this.gs.hexes) { const pts = this.geo.hexes[hex.id].corners.map((c) => ({ x: this.geo.nodes[c].x, y: this.geo.nodes[c].y })); const { x, y } = this.hexPos(hex.id); const terr = this.hexTerrain(hex); // Scale factors for the 7px colored ring + 4px dark ring (absolute pixels). const s1 = 1 - 7 / inradius; // after colored border const s2 = 1 - 11 / inradius; // after dark border (image area) const innerPts = inset(pts, x, y, s1); const imagePts = inset(pts, x, y, s2); // Layer 1: terrain swatch fill (outer colored border ring) g.fillStyle(terr.swatch, 0.55); g.fillPoints(pts, true); // Layer 2: dark fill inset (inner black border ring) g.fillStyle(0x111111, 1); g.fillPoints(innerPts, true); // Layer 3: tile image masked to innermost polygon (land/desert only) if (terr.tileFrames && this.textures.exists('catan-tiles')) { if (this.hexTileFrames[hex.id] == null) { this.hexTileFrames[hex.id] = terr.tileFrames[Math.floor(Math.random() * 2)]; } const frame = this.hexTileFrames[hex.id]; const maskG = this.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); maskG.fillPoints(imagePts, true); const img = this.add.image(x, y, 'catan-tiles', frame) .setDisplaySize(hexW * s2, size * 2 * s2) .setMask(maskG.createGeometryMask()) .setDepth(D.board + 1); this.hexImgs.push({ img, maskG }); } else { // Fallback / water / gold / fog: solid color fill in the image area g.fillStyle(terr.color, 1); g.fillPoints(imagePts, true); } // Thin outer crisp stroke this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35); this.hexBorderGfx.strokePoints(pts, true); // Terrain label this.hexLabels.push(this.add.text(x, y - 56, terr.label, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118', }).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3)); } } // Visual styling for a hex by terrain kind (Seafarers adds sea/gold/fog). hexTerrain(hex) { switch (hex.kind) { case 'sea': return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: CatanGame.TILE_FRAMES.sea }; case 'gold': return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: CatanGame.TILE_FRAMES.gold }; case 'fog': return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: CatanGame.TILE_FRAMES.fog }; case 'desert': return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert }; default: { const info = RESOURCE_INFO[hex.resource]; return { swatch: info.swatch, color: info.color, label: info.tile, tileFrames: CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11] }; } } } 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)); const title = port.type === 'any' ? '3:1 Harbor' : `2:1 ${RESOURCE_INFO[port.type].label} Harbor`; const desc = port.type === 'any' ? 'Trade 3 of any single resource for 1 resource of your choice. Works with brick, lumber, wool, grain, or ore.' : `Trade 2 ${RESOURCE_INFO[port.type].label.toLowerCase()} for 1 resource of your choice. Only ${RESOURCE_INFO[port.type].label.toLowerCase()} qualifies for this rate.`; c.setInteractive(new Phaser.Geom.Circle(0, 0, 19), Phaser.Geom.Circle.Contains); c.on('pointerover', () => this.showInfoTooltip(px, py - 19, title, desc, 0xb89a5e)); c.on('pointerout', () => this.hideInfoTooltip()); // 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, this.geo.nodes[nid].x, this.geo.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); const landX = [1256, 1324]; const landY = 950; const startX = GAME_WIDTH / 2; // 960 — center of bottom bar const startY = 1015; const arcY = 755; // arc peak // Move dice to throw origin, small, random angle this.diceContainers.forEach((c, i) => { c.setAlpha(1).setScale(0.35).setAngle(Phaser.Math.Between(0, 359)) .setPosition(startX + (i === 0 ? -12 : 12), startY); this.drawDie(this.diceG[i], Phaser.Math.Between(1, 6)); }); // Cycle random faces while airborne let cyclerStopped = false; const cycler = this.time.addEvent({ delay: 55, loop: true, callback: () => { this.drawDie(this.diceG[0], Phaser.Math.Between(1, 6)); this.drawDie(this.diceG[1], Phaser.Math.Between(1, 6)); }, }); const stopCycler = () => { if (!cyclerStopped) { cyclerStopped = true; cycler.remove(); } }; let settled = 0; this.diceContainers.forEach((c, i) => { const lx = landX[i] + (Math.random() * 8 - 4); const ly = landY + (Math.random() * 8 - 4); const outMs = 295 + i * 32; const backMs = 430 + i * 44; const totalMs = outMs + backMs; // X flies straight to landing; Y arcs up then bounces down this.tweens.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' }); this.tweens.chain({ targets: c, tweens: [ { y: arcY, duration: outMs, ease: 'Quad.Out' }, { y: ly, duration: backMs, ease: 'Bounce.Out' }, ] }); // Scale up as die approaches this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' }); // Spin this.tweens.add({ targets: c, angle: c.angle + 540 + Math.random() * 180, duration: totalMs, ease: 'Quad.Out', }); this.time.delayedCall(totalMs, () => { stopCycler(); this.drawDie(this.diceG[i], values[i]); // Snap to nearest upright angle with a small wiggle const upright = Math.round(c.angle / 90) * 90 + (Math.random() * 10 - 5); this.tweens.add({ targets: c, angle: upright, duration: 120, ease: 'Back.Out', onComplete: () => { settled++; if (settled === 2) { this.diceContainers.forEach((dc) => this.tweens.add({ targets: dc, scaleX: 1.14, scaleY: 0.88, duration: 80, yoyo: true }) ); const numberWords = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve']; enqueueSpeech(`numbers-${numberWords[values[0] + values[1]]}`); this.time.delayedCall(160, resolve); } }, }); }); }); }); } // ── 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'); // VP badge above portrait — graphics redrawn in updateHand() once gs is available this.playerVpBadgeGfx = this.add.graphics().setDepth(D.hud + 5); this.playerVpText = this.add.text(90, 906, '0', { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#ffffff', fontStyle: 'bold', }).setOrigin(0.5).setDepth(D.hud + 6); this.add.text(90, 1056, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).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: 200, 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')); if (this.expansion === 'seafarers') mk('ship', 'Build Ship', () => this.enterPlace('ship')); 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); this.buildDevCardTooltip(); this.buildInfoTooltip(); this._buildTurnTriangle(); this._buildSpecialCards(); } _buildSpecialCards() { const makeCard = (x, frameIdx, borderColor) => { const W = 64, H = 90, R = 6, BW = 3; const bg = this.add.graphics(); bg.fillStyle(0x111111, 0.85); bg.fillRoundedRect(-W / 2, -H / 2, W, H, R); const img = this.add.image(0, 0, 'catan-special-cards', frameIdx).setDisplaySize(W - 6, H - 6); const border = this.add.graphics(); border.lineStyle(BW, borderColor, 1); border.strokeRoundedRect(-W / 2, -H / 2, W, H, R); const c = this.add.container(x, 760).setDepth(D.hud + 3); c.add([bg, img, border]); return c; }; const cardY = this.expansion === 'seafarers' ? 830 : 760; this._lrCard = makeCard(1775, 0, 0xdaa520); this._laCard = makeCard(1855, 1, 0xb03030); this._lrCard.setY(cardY); this._laCard.setY(cardY); this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null }; this._specialCardAnimating = { longestRoad: false, largestArmy: false }; const H = 90; const attachHover = (card, title, desc, borderColor) => { card.setInteractive( new Phaser.Geom.Rectangle(-32, -H / 2, 64, H), Phaser.Geom.Rectangle.Contains, ); card.on('pointerover', () => this.showInfoTooltip(card.x, card.y - (H / 2) * card.scaleY, title, desc, borderColor)); card.on('pointerout', () => this.hideInfoTooltip()); }; attachHover( this._lrCard, 'Longest Road', 'Held by the player with the longest unbroken road of 5 or more segments. Worth 2 Victory Points. Lost to any player who later builds a longer road.', 0xdaa520, ); attachHover( this._laCard, 'Largest Army', 'Held by the first player to play 3 Knight cards. Worth 2 Victory Points. Lost to any player who later plays more Knights.', 0xb03030, ); } _getSpecialCardPos(cardType, owner) { if (owner === null) { const cardY = this.expansion === 'seafarers' ? 830 : 760; return cardType === 'longestRoad' ? { x: 1775, y: cardY, scale: 1 } : { x: 1855, y: cardY, scale: 1 }; } if (owner === 0) { return cardType === 'longestRoad' ? { x: 1390, y: 1015, scale: 1 } : { x: 1462, y: 1015, scale: 1 }; } const panel = this.oppPanels?.find((p) => p.seat === owner); if (!panel) return { x: 0, y: 0, scale: 0.25 }; const bx = panel.x; const by = panel.y - 74; return cardType === 'longestRoad' ? { x: bx - 28, y: by, scale: 0.25 } : { x: bx + 28, y: by, scale: 0.25 }; } updateSpecialCards() { if (!this._lrCard || !this.gs) return; for (const cardType of ['longestRoad', 'largestArmy']) { const newOwner = this.gs[cardType].owner; const prevOwner = this._prevSpecialCardOwners[cardType]; const card = cardType === 'longestRoad' ? this._lrCard : this._laCard; if (newOwner !== prevOwner) { const fromPos = this._getSpecialCardPos(cardType, prevOwner); this._prevSpecialCardOwners[cardType] = newOwner; this._animateSpecialCardTransfer(cardType, fromPos, newOwner); } else if (!this._specialCardAnimating[cardType]) { const pos = this._getSpecialCardPos(cardType, newOwner); card.setPosition(pos.x, pos.y).setScale(pos.scale); } } } _animateSpecialCardTransfer(cardType, fromPos, newOwner) { const toPos = this._getSpecialCardPos(cardType, newOwner); const card = cardType === 'longestRoad' ? this._lrCard : this._laCard; card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale); this._specialCardAnimating[cardType] = true; const peakY = Math.min(fromPos.y, toPos.y) - 150; const midX = (fromPos.x + toPos.x) / 2; const midScale = (fromPos.scale + toPos.scale) / 2; const half = 380; this.tweens.chain({ targets: card, tweens: [ { x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' }, { x: toPos.x, y: toPos.y, scale: toPos.scale, duration: half, ease: 'Quad.In', onComplete: () => { this._specialCardAnimating[cardType] = false; }, }, ] }); enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army'); } _buildTurnTriangle() { const g = this.add.graphics().setDepth(D.hud + 8); g.fillStyle(0xffdd00, 1); g.fillTriangle(-10, -13, -10, 13, 10, 0); g.setPosition(-9999, -9999); this.turnIndicator = g; this._turnSeat = null; this.tweens.add({ targets: g, scaleX: 1.4, scaleY: 1.4, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.InOut', }); } _seatPortraitPos(seat) { if (seat === 0) return { x: 90, y: 980, r: 64 }; const panel = this.oppPanels?.find(p => p.seat === seat); return panel ? { x: panel.x, y: panel.y, r: 56 } : null; } _updateTurnIndicator() { const seat = this.gs?.currentPlayer; if (seat == null) return; const pos = this._seatPortraitPos(seat); if (!pos) return; const tx = pos.x - pos.r - 20; const ty = pos.y; if (this._turnSeat === seat) return; this._turnSeat = seat; if (this.turnIndicator.x < 0) { this.turnIndicator.setPosition(tx, ty); } else { this.tweens.add({ targets: this.turnIndicator, x: tx, y: ty, duration: 600, ease: 'Cubic.Out', }); } } buildDevCardTooltip() { const popW = 320, popH = 160, popR = 10; const g = this.add.graphics(); const titleTxt = this.add.text(0, -48, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8', }).setOrigin(0.5); const descTxt = this.add.text(0, 12, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center', wordWrap: { width: popW - 32 }, }).setOrigin(0.5); this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt]) .setDepth(D.panel + 5); this._devTooltip.gfx = g; this._devTooltip.titleTxt = titleTxt; this._devTooltip.descTxt = descTxt; this._devTooltip.popW = popW; this._devTooltip.popH = popH; this._devTooltip.popR = popR; } showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) { const DEV_DESC = { knight: 'Move the Robber to any tile and steal a resource from a player there.', roadBuilding: 'Place 2 roads anywhere you could legally build them, for free.', vp: 'Worth 1 Victory Point. Kept hidden until you reach 10 VP and win.', monopoly: 'Name a resource. Every other player gives you all of that resource.', yearOfPlenty: 'Take any 2 resources of your choice directly from the bank.', }; const tt = this._devTooltip; const { gfx: g, titleTxt, descTxt, popW, popH, popR } = tt; g.clear(); g.fillStyle(0x0d1117, 0.96); g.fillRoundedRect(-popW / 2, -popH / 2, popW, popH, popR); g.lineStyle(2, borderColor, 0.9); g.strokeRoundedRect(-popW / 2, -popH / 2, popW, popH, popR); titleTxt.setText(DEV_INFO[type]?.label ?? type); let d = DEV_DESC[type] ?? ''; if (isNew) d += '\n(Not playable until next turn)'; descTxt.setText(d); const ttX = Phaser.Math.Clamp(cardX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10); tt.setPosition(ttX, cardTopY - popH / 2 - 10); } hideDevCardTooltip() { this._devTooltip?.setPosition(-9999, -9999); } // Generic auto-sizing hover tooltip (title + wrapped description) used by the // Longest Road / Largest Army cards and the harbor markers. buildInfoTooltip() { const g = this.add.graphics(); const titleTxt = this.add.text(0, 0, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8', align: 'center', }).setOrigin(0.5, 0); const descTxt = this.add.text(0, 0, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center', wordWrap: { width: 288 }, }).setOrigin(0.5, 0); this._infoTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt]) .setDepth(D.panel + 5); this._infoTooltip.gfx = g; this._infoTooltip.titleTxt = titleTxt; this._infoTooltip.descTxt = descTxt; } showInfoTooltip(anchorX, anchorTopY, title, desc, borderColor = COLORS.accent) { const tt = this._infoTooltip; const { gfx: g, titleTxt, descTxt } = tt; const popW = 320, padTop = 14, gap = 8, padBot = 14; titleTxt.setText(title); descTxt.setText(desc); const popH = padTop + titleTxt.height + gap + descTxt.height + padBot; const top = -popH / 2; titleTxt.setPosition(0, top + padTop); descTxt.setPosition(0, top + padTop + titleTxt.height + gap); g.clear(); g.fillStyle(0x0d1117, 0.96); g.fillRoundedRect(-popW / 2, top, popW, popH, 10); g.lineStyle(2, borderColor, 0.9); g.strokeRoundedRect(-popW / 2, top, popW, popH, 10); const x = Phaser.Math.Clamp(anchorX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10); const y = Phaser.Math.Clamp(anchorTopY - popH / 2 - 12, popH / 2 + 10, GAME_HEIGHT - popH / 2 - 10); tt.setPosition(x, y); } hideInfoTooltip() { this._infoTooltip?.setPosition(-9999, -9999); } buildCostLegend() { const panelRight = 1900; const panelW = 320; const cx = panelRight - panelW / 2; 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'] }, ]; if (this.expansion === 'seafarers') { rows.push({ name: 'Ship', resources: ['lumber', 'wool'] }); } // Base: original fixed values. Seafarers: taller panel to fit 5th row, still inside bar (y 893–1080). const seafarers = this.expansion === 'seafarers'; const bgH = seafarers ? 180 : 164; const bgCy = seafarers ? 987 : 980; const rowPad = 44; const rowStep = (bgH - rowPad - 14) / (rows.length - 1); 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 lx = panelRight - panelW + 14; const rx = panelRight - 14; const rowY0 = bgCy - bgH / 2 + rowPad; const SW = 16, SH = 16, SG = 4, SR = 3; 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 }; }); this.bankCardPos.dev = { x: cardCx, y: stackTops[5] + 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 cardW = 81, cardH = 117; const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH); const border = this.add.graphics(); border.lineStyle(3, RESOURCE_INFO[resource].swatch, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner - 1); this.tweens.add({ targets: container, x: dst.x, y: dst.y, duration: 1000, ease: 'Quad.InOut', onComplete: () => { playSound(this, SFX.CASINO_WIN); container.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', backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 }, }).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', backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 }, }).setOrigin(0.5, 0).setDepth(D.hud); this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null }); }); 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; panel.info.setText(`${dev} dev ${p.knightsPlayed} knights`); // VP badge circle above the portrait if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; } const vp = L.publicVictoryPoints(this.gs, panel.seat); const col = PLAYER_COLORS[p.colorIndex]; const badgeContainer = this.add.container(panel.x, panel.y - 74).setDepth(D.hud + 5); const bg = this.add.graphics(); bg.fillStyle(col.hexDark, 1); bg.fillCircle(0, 0, 18); bg.lineStyle(2.5, col.hex, 0.9); bg.strokeCircle(0, 0, 18); const vpText = this.add.text(0, 0, String(vp), { fontFamily: '"Julius Sans One"', fontSize: '15px', color: col.key === 'white' ? '#000000' : '#ffffff', fontStyle: 'bold', }).setOrigin(0.5); badgeContainer.add([bg, vpText]); panel.vpBadge = badgeContainer; // Rebuild face-down card rows to the right of the portrait if (!panel.cardFan) panel.cardFan = []; panel.cardFan.forEach(o => o.destroy()); panel.cardFan = []; const cardW = 30, cardH = 43, cardStep = cardW + 3; const rowStartX = panel.x + 88; const maxShow = 8; // ── Resource cards (side by side) ────────────────────────────────────── if (cards > 0) { const frameIdx = this.cardBack?.spriteIndex ?? 0; const show = Math.min(cards, maxShow); for (let i = 0; i < show; i++) { const img = this.add.image(rowStartX + i * cardStep, panel.y, 'cardbacks', frameIdx) .setDisplaySize(cardW, cardH) .setDepth(D.hud + 1); panel.cardFan.push(img); } if (cards > maxShow) { const bx = rowStartX + maxShow * cardStep; panel.cardFan.push(this.add.text(bx, panel.y, `+${cards - maxShow}`, { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700', stroke: '#000000', strokeThickness: 3, }).setOrigin(0, 0.5).setDepth(D.hud + 20)); } } // ── Dev cards (face-down, using dev card back) ───────────────────────── const devTotal = p.devCards.length + p.newDevCards.length + (p.vpCards ?? 0); if (devTotal > 0) { const devY = panel.y + cardH + 5; const devShow = Math.min(devTotal, maxShow); for (let i = 0; i < devShow; i++) { const img = this.add.image(rowStartX + i * cardStep, devY, 'catan-cards', 8) .setDisplaySize(cardW, cardH) .setDepth(D.hud + 1); panel.cardFan.push(img); } if (devTotal > maxShow) { const bx = rowStartX + maxShow * cardStep; panel.cardFan.push(this.add.text(bx, devY, `+${devTotal - maxShow}`, { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700', stroke: '#000000', strokeThickness: 3, }).setOrigin(0, 0.5).setDepth(D.hud + 20)); } } } } // ── full render ───────────────────────────────────────────────────────────── renderAll() { this.renderPieces(); this.renderRobber(); this.updateHand(); this.updateDevHand(); this.updateBank(); this.renderOpponentPanels(); this.updateButtons(); this.updateStatus(); this.updateSpecialCards(); } renderPieces() { this.pieceObjs.forEach((o) => o.destroy()); this.pieceObjs = []; const N = this.geo.nodes; // roads for (const p of this.gs.players) { const col = PLAYER_COLORS[p.colorIndex]; for (const eid of p.roads) { const [a, b] = this.geo.edges[eid].nodes; const g = this.add.graphics().setDepth(D.road); const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y; g.lineStyle(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by); g.lineStyle(12, col.hexDark, 1); g.lineBetween(ax, ay, bx, by); g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by); this.pieceObjs.push(g); } // ships (Seafarers): dashed maritime route in the player's colour for (const eid of (p.ships ?? [])) { const [a, b] = this.geo.edges[eid].nodes; this.pieceObjs.push(this.makeShip(N[a].x, N[a].y, N[b].x, N[b].y, col)); } } // 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(N[nid].x, N[nid].y, col)); for (const nid of p.cities) this.pieceObjs.push(this.makeCity(N[nid].x, N[nid].y, col)); } // pirate (Seafarers): a sea-robber token on its hex if (this.gs.pirateHex != null) { const { x, y } = this.hexPos(this.gs.pirateHex); this.pieceObjs.push( this.add.image(x, y, 'catan-pirate').setOrigin(0.5).setDisplaySize(48, 48).setDepth(D.robber) ); } } // A ship piece: a thick coloured bar along the sea edge with a sail nub. makeShip(ax, ay, bx, by, col) { const g = this.add.graphics().setDepth(D.road); g.lineStyle(15, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by); g.lineStyle(11, col.hexDark, 1); g.lineBetween(ax, ay, bx, by); g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by); // sail at the midpoint const mx = (ax + bx) / 2, my = (ay + by) / 2; g.fillStyle(0xffffff, 0.95); g.fillTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6); g.lineStyle(2, col.hexDark, 1); g.strokeTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6); return g; } makeSettlement(x, y, col) { const g = this.add.graphics().setDepth(D.building); // Drop shadow g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 13, y - 1, 34, 24, 4); // White halo (3px border around the scaled-up shape) g.fillStyle(0xffffff, 1); g.fillRect(x - 16, y - 4, 32, 20); g.fillTriangle(x - 19, y - 4, x + 19, y - 4, x, y - 21); // Player color fill (~30% larger than original) g.fillStyle(col.hex, 1); g.fillRect(x - 13, y - 4, 26, 17); g.fillTriangle(x - 16, y - 4, x + 16, y - 4, x, y - 18); // Dark outline g.lineStyle(2.5, col.hexDark, 1); g.strokeRect(x - 13, y - 4, 26, 17); return g; } makeCity(x, y, col) { const g = this.add.graphics().setDepth(D.building); // Drop shadow g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 19, y - 7, 50, 38, 4); // White halo (3px border around the scaled-up shape) g.fillStyle(0xffffff, 1); g.fillRect(x - 24, y, 27, 21); // lower block halo g.fillRect(x - 8, y - 10, 32, 32); // tower halo g.fillTriangle(x - 11, y - 10, x + 26, y - 10, x + 8, y - 26); // roof halo // Player color fill (~30% larger than original) g.fillStyle(col.hex, 1); g.fillRect(x - 21, y, 21, 18); // lower block g.fillRect(x - 5, y - 10, 26, 29); // tower g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof // Dark outlines g.lineStyle(2.5, col.hexDark, 1); g.strokeRect(x - 21, y, 21, 18); g.strokeRect(x - 5, y - 10, 26, 29); return g; } renderRobber() { if (this.robberObj) this.robberObj.destroy(); const { x, y } = this.hexPos(this.gs.robberHex); this.robberObj = this.add.image(x, y, 'catan-robber') .setDisplaySize(64, 64) .setDepth(D.robber); this.robberObj.postFX.addGlow(0x000000, 8, 0, false, 0.1, 24); } async animateRobber(fromHexId, toHexId) { const clip = `catan-robber-0${Phaser.Math.Between(1, 4)}`; enqueueSpeech(clip); if (this.robberObj) this.robberObj.destroy(); const from = this.hexPos(fromHexId); const to = this.hexPos(toHexId); this.robberObj = this.add.image(from.x, from.y, 'catan-robber') .setDisplaySize(64, 64) .setDepth(D.robber); this.robberObj.postFX.addGlow(0x000000, 8, 0, false, 0.1, 24); const base = this.robberObj.scaleX; await new Promise(resolve => { this.tweens.add({ targets: this.robberObj, x: to.x, y: to.y, duration: 3000, ease: 'Sine.InOut', }); this.tweens.chain({ targets: this.robberObj, tweens: [ { scaleX: base * 2, scaleY: base * 2, duration: 1500, ease: 'Sine.Out' }, { scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve }, ], }); }); } async animateCostPayment(seat, type) { const cost = COSTS[type]; if (!cost) return; const resources = []; for (const [r, n] of Object.entries(cost)) for (let i = 0; i < n; i++) resources.push(r); const isHuman = seat === 0; const usedHandIdxs = new Set(); let oppCardOffset = 0; for (const resource of resources) { const frameIdx = RESOURCE_TYPES.indexOf(resource); const bankPos = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 }; let srcX, srcY, startFaceUp; if (isHuman) { startFaceUp = true; let ci = -1; for (let i = 0; i < this.handDisplay.length; i++) { if (!usedHandIdxs.has(i) && this.handDisplay[i] === resource) { ci = i; usedHandIdxs.add(i); break; } } srcX = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].x : 90; srcY = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].y : 950; } else { startFaceUp = false; const panel = this.oppPanels.find(p => p.seat === seat); if (panel?.cardFan[oppCardOffset]) { srcX = panel.cardFan[oppCardOffset].x; srcY = panel.cardFan[oppCardOffset].y; } else { const pos = this._seatPortraitPos(seat); srcX = (pos?.x ?? 130) + 88 + oppCardOffset * 33; srcY = pos?.y ?? 300; } oppCardOffset++; } await this._flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp); } } _flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) { return new Promise(resolve => { const cardW = 60, cardH = 84; const bigW = 90, bigH = 126; const resource = RESOURCE_TYPES[frameIdx]; const makeBorder = (color) => { const g = this.add.graphics(); g.lineStyle(3, color, 1); g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); return g; }; if (startFaceUp) { // Human card: already face-up, just fly to bank. const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH); const border = makeBorder(RESOURCE_INFO[resource]?.swatch ?? COLORS.accent); const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner); this.tweens.add({ targets: container, x: bankPos.x, y: bankPos.y, duration: 600, ease: 'Quad.InOut', onComplete: () => this.tweens.add({ targets: container, alpha: 0, duration: 200, onComplete: () => { container.destroy(); resolve(); }, }), }); return; } // Opponent card: show face-down → flip → resize → fly. const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH); const border = makeBorder(COLORS.accent); const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner); // Brief beat so the player can register the face-down card. this.time.delayedCall(120, () => { // Flip first half: collapse container width to 0. this.tweens.add({ targets: container, scaleX: 0, duration: 110, ease: 'Linear', onComplete: () => { // Swap to face-up artwork and recolor border (hidden at scaleX=0). img.setTexture('catan-cards', frameIdx).setDisplaySize(cardW, cardH); border.clear(); border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); // Flip second half: restore width. this.tweens.add({ targets: container, scaleX: 1, duration: 110, ease: 'Linear', onComplete: () => { // Scale up to bank card size with a little bounce. this.tweens.add({ targets: container, scaleX: bigW / cardW, scaleY: bigH / cardH, duration: 230, ease: 'Back.Out', onComplete: () => { // Now fly to the bank. this.tweens.add({ targets: container, x: bankPos.x, y: bankPos.y, duration: 720, ease: 'Quad.InOut', onComplete: () => this.tweens.add({ targets: container, alpha: 0, duration: 180, onComplete: () => { container.destroy(); resolve(); }, }), }); }, }); }, }); }, }); }); }); } animatePiecePlacement(seat, type, destX, destY) { return new Promise(resolve => { const srcPos = this._seatPortraitPos(seat); if (!srcPos) { resolve(); return; } const col = PLAYER_COLORS[this.gs.players[seat].colorIndex]; const g = this.add.graphics(); const container = this.add.container(srcPos.x, srcPos.y, [g]).setDepth(D.banner - 1); if (type === 'road') { g.lineStyle(10, 0xffffff, 0.9); g.lineBetween(-13, 0, 13, 0); g.lineStyle(7, col.hexDark, 1); g.lineBetween(-13, 0, 13, 0); g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0); } else if (type === 'settlement') { g.fillStyle(0xffffff, 1); g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17); g.fillStyle(col.hex, 1); g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14); g.lineStyle(2, col.hexDark, 1); g.strokeRect(-9, -3, 18, 12); } else if (type === 'city') { g.fillStyle(0xffffff, 1); g.fillRect(-17, 0, 18, 15); g.fillRect(-6, -9, 21, 24); g.fillTriangle(-9, -9, 17, -9, 6, -22); g.fillStyle(col.hex, 1); g.fillRect(-15, 0, 15, 12); g.fillRect(-4, -8, 17, 20); g.fillTriangle(-7, -8, 15, -8, 6, -19); g.lineStyle(2, col.hexDark, 1); g.strokeRect(-15, 0, 15, 12); g.strokeRect(-4, -8, 17, 20); } const emitter = this.add.particles(0, 0, 'catanParticle', { follow: container, speed: { min: 25, max: 70 }, lifespan: 450, scale: { start: 0.9, end: 0 }, alpha: { start: 0.85, end: 0 }, quantity: 2, frequency: 28, tint: [col.hex, 0xffffff, col.hexDark], angle: { min: 0, max: 360 }, }).setDepth(D.banner - 2); const duration = 2000; const arcHeight = Math.max(180, Math.abs(destY - srcPos.y) * 0.7 + 120); const peakY = Math.min(srcPos.y, destY) - arcHeight; const half = duration / 2; this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' }); this.tweens.chain({ targets: container, tweens: [ { y: peakY, duration: half, ease: 'Quad.Out' }, { y: destY, duration: half, ease: 'Quad.In', onComplete: () => { emitter.stop(); container.destroy(); this.time.delayedCall(500, () => emitter.destroy()); resolve(); } }, ] }); }); } animateDevCardFromBank(seat) { return new Promise(resolve => { const bankPos = this.bankCardPos?.dev ?? { x: 1562, y: 701 }; const destPos = this._seatPortraitPos(seat) ?? { x: 90, y: 980 }; const cardW = 60, cardH = 84; const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH); const border = this.add.graphics(); border.lineStyle(3, COLORS.accent, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); const container = this.add.container(bankPos.x, bankPos.y, [img, border]).setDepth(D.banner); const duration = 1400; const arcHeight = Math.max(150, Math.abs(destPos.y - bankPos.y) * 0.5 + 100); const peakY = Math.min(bankPos.y, destPos.y) - arcHeight; const half = duration / 2; this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' }); this.tweens.chain({ targets: container, tweens: [ { y: peakY, duration: half, ease: 'Quad.Out' }, { y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => { this.tweens.add({ targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280, onComplete: () => { container.destroy(); resolve(); }, }); } }, ] }); }); } updateHand() { const p = this.gs.players[0]; // Sync handDisplay with game state, preserving drag order const updated = [...this.handDisplay]; for (const r of RESOURCE_TYPES) { const have = updated.filter(x => x === r).length; const need = p.resources[r]; if (have > need) { let removed = 0; for (let i = updated.length - 1; i >= 0 && removed < have - need; i--) { if (updated[i] === r) { updated.splice(i, 1); removed++; } } } else { for (let k = 0; k < need - have; k++) updated.push(r); } } if (JSON.stringify(updated) !== JSON.stringify(this.handDisplay)) this.handSelectedIdx = null; this.handDisplay = updated; this.renderHand(); // VP badge const col = PLAYER_COLORS[p.colorIndex]; this.playerVpBadgeGfx.clear(); this.playerVpBadgeGfx.fillStyle(col.hexDark, 1); this.playerVpBadgeGfx.fillCircle(90, 906, 18); this.playerVpBadgeGfx.lineStyle(2.5, col.hex, 0.9); this.playerVpBadgeGfx.strokeCircle(90, 906, 18); this.playerVpText.setText(String(L.victoryPoints(this.gs, 0))); } renderHand() { this.handCardObjs.forEach(c => c.destroy()); this.handCardObjs = []; const N = this.handDisplay.length; if (N === 0) return; const cardW = 60, cardH = 84, cardR = 6, borderW = 3; const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1))); this.handDisplay.forEach((resource, idx) => { const x = 220 + idx * step; const frameIdx = RESOURCE_TYPES.indexOf(resource); const c = this.add.container(x, 950).setDepth(D.hud + idx); const bg = this.add.graphics(); bg.fillStyle(0x111111, 0.85); bg.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, cardR); const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(54, 78); const border = this.add.graphics(); border.lineStyle(borderW, RESOURCE_INFO[resource].swatch, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, cardR); c.add([bg, img, border]); if (idx === this.handSelectedIdx) { const sel = this.add.graphics(); sel.lineStyle(3, 0xffd700, 1); sel.strokeRoundedRect(-cardW / 2 - 3, -cardH / 2 - 3, cardW + 6, cardH + 6, cardR + 2); c.add(sel); c.setScale(1.1).setDepth(D.hud + 20); } c.setSize(cardW, cardH).setInteractive(); this._setupHandDrag(c, idx); this.handCardObjs.push(c); }); } _setupHandDrag(container, idx) { const THRESHOLD = 8; let dragging = false, origX = 0, pointerStartX = 0; let slotIndicator = null; const cancelDrag = () => { if (slotIndicator) { slotIndicator.destroy(); slotIndicator = null; } this.input.off('pointermove', onSceneMove); this.input.off('pointerup', onSceneUp); }; const onSceneMove = (ptr) => { container.x = origX + (ptr.x - pointerStartX); const N = this.handDisplay.length; const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1))); const newIdx = Math.max(0, Math.min(N - 1, Math.round((ptr.x - 220) / step))); if (!slotIndicator) slotIndicator = this.add.rectangle(0, 950, 6, 84, COLORS.accent, 0.8).setDepth(D.hud + 49); slotIndicator.x = 220 + newIdx * step - step / 2; }; const onSceneUp = (ptr) => { cancelDrag(); dragging = false; const N = this.handDisplay.length; const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1))); const newIdx = Math.max(0, Math.min(N - 1, Math.round((ptr.x - 220) / step))); if (newIdx !== idx) { const [card] = this.handDisplay.splice(idx, 1); this.handDisplay.splice(newIdx, 0, card); } this.handSelectedIdx = null; this.renderHand(); }; container.on('pointerdown', (ptr) => { origX = container.x; pointerStartX = ptr.x; dragging = false; }); container.on('pointermove', (ptr) => { if (this.handSelectedIdx !== idx) return; if (!dragging && Math.abs(ptr.x - pointerStartX) >= THRESHOLD) { dragging = true; container.setDepth(D.hud + 50); this.input.on('pointermove', onSceneMove); this.input.on('pointerup', onSceneUp); } }); container.on('pointerup', () => { if (!dragging) { // Click: toggle selection this.handSelectedIdx = this.handSelectedIdx === idx ? null : idx; this.renderHand(); } }); } 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'); if (!cards.length) { this.devHandContainer.add(this.add.text(740, 980, '—', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, }).setOrigin(0, 0.5)); return; } const DEV_VISUAL = { knight: { frame: 5, border: 0xb03030 }, roadBuilding: { frame: 6, border: 0x8b5a2b }, vp: { frame: 7, border: 0xdaa520 }, monopoly: { frame: 9, border: 0x7b2d8b }, yearOfPlenty: { frame: 10, border: 0x2d8b57 }, }; const cardW = 60, cardH = 84, cardR = 6, borderW = 3; const step = 66; const y = 980; let x = 740; cards.forEach((card) => { const isNew = card.endsWith('*'); const type = isNew ? card.slice(0, -1) : card; const visual = DEV_VISUAL[type] ?? { frame: 7, border: COLORS.accent }; const bg = this.add.graphics(); bg.fillStyle(0x111111, 0.85); bg.fillRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR); const img = this.add.image(x, y, 'catan-cards', visual.frame) .setDisplaySize(54, 78) .setAlpha(isNew ? 0.5 : 1); const border = this.add.graphics(); border.lineStyle(borderW, visual.border, isNew ? 0.45 : 1); border.strokeRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR); const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive(); const cx = x, topY = y - cardH / 2; hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border)); hit.on('pointerout', () => this.hideDevCardTooltip()); this.devHandContainer.add([bg, img, border, hit]); x += step; }); } 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] ?? ''); this._updateTurnIndicator(); } 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 DEBUG_FREE_ROADS_AND_SHIPS = false; const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0; set('roll', me && s.phase === 'rollPhase'); set('road', action && hasRoadSpot && (DEBUG_FREE_ROADS_AND_SHIPS && me || L.canAfford(p, COSTS.road) || s.freeRoads > 0)); const hasShipSpot = action && L.legalShipEdges(s, 0).length > 0; const sCost = L.shipCost(s); set('ship', hasShipSpot && action && (DEBUG_FREE_ROADS_AND_SHIPS && me || !sCost || L.canAfford(p, sCost) || s.freeShips > 0)); 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, { tilePlacement: this.tilePlacement, expansion: this.expansion, scenario: this.scenario, }); const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')]; L.setPlayerNames(this.gs, names); this.hexTileFrames = {}; 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 === 'goldPick') { await this.handleGoldPickPhase(); } 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); const preRobberHex = this.gs.robberHex; const preRes0roll = { ...this.gs.players[0].resources }; this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat); if (m.targetSeat === 0) { const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0roll[r]); if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat); } await this.animateRobber(preRobberHex, m.hexId); 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); const preRobberHex = this.gs.robberHex; const preRes0 = { ...this.gs.players[0].resources }; this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat); const animPromise = this.animateRobber(preRobberHex, m.hexId); if (m.targetSeat != null) this.opponentPortraits[seat]?.playEmotion?.('happy'); if (m.targetSeat === 0) { const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0[r]); if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat); } await animPromise; this.renderAll(); await this.delay(450); this.busy = false; this.advance(); } async aiAction() { this.busy = true; const seat = this.gs.currentPlayer; await this.aiInitiateTrades(seat); 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; if (a.type === 'buildRoad' || a.type === 'buildSettlement' || a.type === 'buildCity') { const pieceType = { buildCity: 'city', buildSettlement: 'settlement', buildRoad: 'road' }[a.type]; const dest = a.type === 'buildRoad' ? this.edgePos(a.edgeId) : this.nodePos(a.nodeId); enqueueSpeech(`catan-purchase-${pieceType}`); await this.animateCostPayment(seat, pieceType); await this.animatePiecePlacement(seat, pieceType, dest.x, dest.y); if (a.type === 'buildCity' || a.type === 'buildSettlement') { this.opponentPortraits[seat]?.playEmotion?.('happy'); } } if (a.type === 'buyDev') { enqueueSpeech('catan-purchase-development-card'); await this.animateCostPayment(seat, 'devCard'); playSound(this, SFX.CARD_DEAL); await this.animateDevCardFromBank(seat); } this.gs = this.applyAction(seat, a); if (a.type === 'playDev' && a.card !== 'vp') { await this.animateOppDevCardPlay(seat, a.card, a.resource); } if (this.gs.phase === 'moveRobber') { const m = AI.chooseRobberMove(this.gs, seat); const preRobberHex = this.gs.robberHex; const preRes0action = { ...this.gs.players[0].resources }; this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat); if (m.targetSeat === 0) { const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0action[r]); if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat); } await this.animateRobber(preRobberHex, m.hexId); } 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; } } // ── AI-initiated trades ─────────────────────────────────────────────────────── // Up to 2 per turn. Each 1-for-1 offer goes to the human first (if they hold the // requested resource); on deny/no-hold it falls through to the other AIs. async aiInitiateTrades(seat) { if (this.gs.phase !== 'action') return; const exclude = new Set(); for (let n = 0; n < 2; n++) { const offer = AI.proposeTrade(this.gs, seat, exclude); if (!offer) break; const { give, get } = offer; // requester gives `give`, wants `get` exclude.add(get); let done = false; if (this.gs.players[0].resources[get] > 0) { const accepted = await this.promptHumanTrade(seat, give, get); if (accepted) { this.gs = L.executePlayerTrade(this.gs, seat, 0, { [give]: 1 }, { [get]: 1 }); playSound(this, SFX.CARD_PLACE); this.flashStatus(`You traded ${RESOURCE_INFO[get].label} for ${RESOURCE_INFO[give].label} with ${this.pname(seat)}.`); this.renderAll(); done = true; } } if (!done) { let acc = null; for (let s = 1; s < this.gs.playerCount; s++) { if (s === seat) continue; // Accepter `s` gives the requested resource and gets the offered one. if (AI.respondToTrade(this.gs, s, { [get]: 1 }, { [give]: 1 }, seat)) { acc = s; break; } } if (acc != null) { this.gs = L.executePlayerTrade(this.gs, seat, acc, { [give]: 1 }, { [get]: 1 }); await this.animateAiTrade(seat, acc, give, get); this.flashStatus(`${this.pname(seat)} traded ${RESOURCE_INFO[give].label} for ${RESOURCE_INFO[get].label} with ${this.pname(acc)}.`); this.renderAll(); } } await this.delay(300); } } // Show the "Trade Request" popup to the human. Resolves true on Accept, false on Deny. promptHumanTrade(requesterSeat, giveRes, getRes) { return new Promise((resolve) => { const panel = this.modalPanel(470, 'Trade Request'); const cardW = 96, cardH = 134; const objs = panel.objs; objs.push(this.add.text(1000, 360, `${this.pname(requesterSeat)} wants to trade`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.panel + 1)); const card = (x, label, res) => { objs.push(this.add.text(x, 410, label, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.panel + 1)); objs.push(this.add.image(x, 490, 'catan-cards', RESOURCE_TYPES.indexOf(res)) .setDisplaySize(cardW, cardH).setDepth(D.panel + 1)); objs.push(this.add.text(x, 568, RESOURCE_INFO[res].label, { fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.panel + 1)); }; card(890, 'You get', giveRes); card(1110, 'You give', getRes); const arrowColor = 0xffdd00; const baseY = 490, amp = 12; const drawArrow = (x, dir) => { const g = this.add.graphics({ x, y: baseY }).setDepth(D.panel + 2); g.fillStyle(arrowColor, 1); if (dir === 'down') { g.fillRect(-5, -22, 10, 30); g.fillTriangle(-14, 6, 14, 6, 0, 26); } else { g.fillRect(-5, -8, 10, 30); g.fillTriangle(-14, -6, 14, -6, 0, -26); } objs.push(g); return g; }; const getArrow = drawArrow(812, 'down'); // left of the card you receive const giveArrow = drawArrow(1188, 'up'); // right of the card you give const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); const t2 = this.tweens.add({ targets: giveArrow, y: baseY - amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); objs.push({ destroy: () => { t1.stop(); t2.stop(); } }); objs.push(new Button(this, 905, 640, 'Accept', () => { panel.destroy(); resolve(true); }, { width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1)); objs.push(new Button(this, 1095, 640, 'Deny', () => { panel.destroy(); resolve(false); }, { variant: 'ghost', width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1)); playSound(this, SFX.CARD_SHOW); }); } // Two cards crossing between the two seats' portraits. animateAiTrade(fromSeat, toSeat, giveRes, getRes) { playSound(this, SFX.CARD_PLACE); const from = this._seatPortraitPos(fromSeat) ?? { x: 130, y: 300 }; const to = this._seatPortraitPos(toSeat) ?? { x: 130, y: 300 }; const cardW = 56, cardH = 78; const flyCard = (res, src, dst) => new Promise((resolve) => { const img = this.add.image(0, 0, 'catan-cards', RESOURCE_TYPES.indexOf(res)).setDisplaySize(cardW, cardH); const border = this.add.graphics(); border.lineStyle(3, RESOURCE_INFO[res]?.swatch ?? COLORS.accent, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner + 3); this.tweens.add({ targets: container, x: dst.x, y: dst.y, duration: 700, ease: 'Quad.InOut', onComplete: () => this.tweens.add({ targets: container, alpha: 0, duration: 220, onComplete: () => { container.destroy(); resolve(); }, }), }); }); return Promise.all([ flyCard(giveRes, from, to), flyCard(getRes, to, from), ]); } // ── opponent dev card reveal ────────────────────────────────────────────────── async animateOppDevCardPlay(seat, cardType, resource) { const VISUAL = { knight: { frame: 5, border: 0xb03030 }, roadBuilding: { frame: 6, border: 0x8b5a2b }, monopoly: { frame: 9, border: 0x7b2d8b }, yearOfPlenty: { frame: 10, border: 0x2d8b57 }, }; const SPEECH = { knight: 'catan-dev-knight', roadBuilding: 'catan-dev-road', monopoly: 'catan-dev-monopoly', yearOfPlenty: 'catan-dev-year', }; const visual = VISUAL[cardType]; if (!visual) return; const from = this.portraitPos(seat); const toX = 1380, toY = 240; const W = 270, H = 390, R = 12, BW = 8; const bg = this.add.graphics(); bg.fillStyle(0x111111, 0.92); bg.fillRoundedRect(-W / 2, -H / 2, W, H, R); const img = this.add.image(0, 0, 'catan-cards', visual.frame).setDisplaySize(W - 16, H - 16); const border = this.add.graphics(); border.lineStyle(BW, visual.border, 1); border.strokeRoundedRect(-W / 2, -H / 2, W, H, R); const card = this.add.container(from.x, from.y).setDepth(D.banner + 5).setScale(30 / W); card.add([bg, img, border]); await new Promise(resolve => this.tweens.add({ targets: card, x: toX, y: toY, scale: 1, duration: 500, ease: 'Back.easeOut', onComplete: resolve }) ); let resourceText = null; let fireworksEmitter = null; if (cardType === 'monopoly' && resource) { const textY = toY + 138; resourceText = this.add.text(toX, textY, resource.toUpperCase(), { fontFamily: 'Righteous', fontSize: '40px', color: '#ffd700', stroke: '#000000', strokeThickness: 5, }).setOrigin(0.5, 0.5).setDepth(D.banner + 6); fireworksEmitter = this.add.particles(toX, textY, 'catanParticle', { speed: { min: 60, max: 190 }, lifespan: 950, scale: { start: 1.3, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 12, frequency: -1, tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], angle: { min: 0, max: 360 }, gravityY: 50, }).setDepth(D.banner + 5); const bursts = [ { x: toX - 90, y: textY - 10 }, { x: toX + 90, y: textY - 10 }, { x: toX, y: textY - 28 }, { x: toX - 50, y: textY + 22 }, { x: toX + 50, y: textY + 22 }, ]; bursts.forEach((b, i) => this.time.delayedCall(i * 160, () => fireworksEmitter?.emitParticleAt(b.x, b.y, 14)) ); } const speechFile = SPEECH[cardType]; await new Promise(resolve => { if (!speechFile) { this.time.delayedCall(800, resolve); return; } const audio = new Audio(`assets/speech/${speechFile}.mp3`); audio.onended = resolve; audio.onerror = resolve; audio.play().catch(resolve); }); const fadeTargets = resourceText ? [card, resourceText] : [card]; await new Promise(resolve => this.tweens.add({ targets: fadeTargets, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve }) ); card.destroy(); if (resourceText) resourceText.destroy(); if (fireworksEmitter) { fireworksEmitter.stop(); this.time.delayedCall(950, () => fireworksEmitter.destroy()); } } // ── 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(); } // ── gold picks ─────────────────────────────────────────────────────────────── async handleGoldPickPhase() { this.busy = true; // Process all AI entries in queue order. for (const entry of [...this.gs.goldPickQueue]) { if (entry.seat === 0) continue; const picks = AI.chooseGoldPick(this.gs, entry.seat, entry.amount); this.gs = L.resolveGoldPick(this.gs, entry.seat, picks); } this.renderAll(); // If human has a pick, let them choose. const humanEntry = this.gs.goldPickQueue.find((e) => e.seat === 0); if (humanEntry) { this.busy = false; this.pickResources(humanEntry.amount, `Gold Field! Choose ${humanEntry.amount} resource${humanEntry.amount > 1 ? 's' : ''}`, (rs) => { this.gs = L.resolveGoldPick(this.gs, 0, rs); this.advance(); }); return; } await this.delay(300); 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, async () => { this.clearHighlights(); const targets = L.stealTargets(this.gs, hex.id, 0); if (targets.length <= 1) { const preRobberHex = this.gs.robberHex; this.gs = L.moveRobber(this.gs, hex.id, targets[0] ?? null); await this.animateRobber(preRobberHex, hex.id); 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)`, async () => { panel.destroy(); const preRobberHex = this.gs.robberHex; this.gs = L.moveRobber(this.gs, hexId, seat); await this.animateRobber(preRobberHex, hexId); 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); } } else if (type === 'ship') { for (const eid of L.legalShipEdges(s, 0)) { const { x, y } = this.edgePos(eid); this.addHighlight(x, y, () => this.doBuild('ship', eid), COLORS.gold, 13); } } this.statusText.setText(`Choose where to build a ${type} (or pick another action)`); } async doBuild(type, id) { this.busy = true; this.clearHighlights(); this.placeMode = null; const dest = (type === 'road' || type === 'ship') ? this.edgePos(id) : this.nodePos(id); enqueueSpeech(`catan-purchase-${type}`); await this.animateCostPayment(0, type); await this.animatePiecePlacement(0, type, dest.x, dest.y); const prevGs = this.gs; 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); if (type === 'ship') this.gs = L.buildShip(this.gs, 0, id); // Redraw board if any fog hexes were revealed by this road/ship. if (type === 'road' || type === 'ship') { const revealed = this.gs.hexes.some((h, i) => prevGs.hexes[i].kind === 'fog' && h.kind !== 'fog'); if (revealed) { this.drawHexes(); this.drawChits(); } } playSound(this, SFX.PIECE_CLICK); this.busy = false; this.advance(); } async onBuyDev() { if (this.busy || this.gs.phase !== 'action') return; this.busy = true; this.clearHighlights(); this.placeMode = null; enqueueSpeech('catan-purchase-development-card'); await this.animateCostPayment(0, 'devCard'); playSound(this, SFX.CARD_DEAL); await this.animateDevCardFromBank(0); this.gs = L.buyDevCard(this.gs, 0); this.busy = false; 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, 430, '', { 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, 1000, 490 + i * 58, 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, 480, 920, 580, 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, 308, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1); const hintGet = this.add.text(1240, 308, '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 stepper = (col, r, i, side) => { const y = 355 + i * 60; const bag = side === 'give' ? give : get; const lbl = this.add.text(col - 165, y, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(D.panel + 1); const val = this.add.text(col + 20, y, '0', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.panel + 1); const minusBtn = new Button(this, col - 46, y, '−', () => { if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); } }, { width: 52, height: 44, fontSize: 28, textColor: COLORS.dangerHex, bgHover: COLORS.danger, textHoverColor: '#ffffff' }) .setDepth(D.panel + 1); const plusBtn = new Button(this, col + 82, y, '+', () => { if (side === 'give' && bag[r] >= this.gs.players[0].resources[r]) return; bag[r]++; val.setText(String(bag[r])); }, { width: 52, height: 44, fontSize: 28, textColor: COLORS.goldHex }) .setDepth(D.panel + 1); objs.push(lbl, val, minusBtn, plusBtn); }; 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, 658, '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: 260, height: 48 }).setDepth(D.panel + 1); const offerBtn = new Button(this, 1160, 658, '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, 0)) { 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: 300, height: 48 }).setDepth(D.panel + 1); const cancelBtn = new Button(this, 1000, 726, '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); } _notifyStolenCard(resource, robberName, thiefSeat) { playSound(this, SFX.CASINO_LOSE); // Card animation: grow + spin, then fly to thief portrait const frameIdx = RESOURCE_TYPES.indexOf(resource); const cardW = 60, cardH = 84; let srcX = 220, srcY = 950; for (let i = 0; i < this.handDisplay.length; i++) { if (this.handDisplay[i] === resource && this.handCardObjs[i]) { srcX = this.handCardObjs[i].x; srcY = this.handCardObjs[i].y; break; } } const destPos = this._seatPortraitPos(thiefSeat) ?? { x: 130, y: 300 }; const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH); const border = this.add.graphics(); border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5); const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner + 3); this.tweens.add({ targets: container, scaleX: 2, scaleY: 2, angle: 720, duration: 1000, ease: 'Quad.InOut', onComplete: () => { this.tweens.add({ targets: container, x: destPos.x, y: destPos.y, scaleX: 1, scaleY: 1, duration: 800, ease: 'Quad.InOut', onComplete: () => this.tweens.add({ targets: container, alpha: 0, duration: 280, onComplete: () => container.destroy(), }), }); }, }); // Text notification runs simultaneously const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase(); const txt = this.add.text(GAME_WIDTH / 2, 855, `CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, { fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700', stroke: '#000000', strokeThickness: 5, } ).setOrigin(0.5).setDepth(D.banner + 2); this.tweens.add({ targets: txt, alpha: 0, delay: 3000, duration: 500, onComplete: () => txt.destroy(), }); } 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; this.recordHistory(); const PW = 760, PH = 660, PX = 1000, PY = 540; const titleY = PY - PH / 2 + 70; // 280 const RADIUS = 80; const portraitY = titleY + 140; // 420 const bodyY = portraitY + RADIUS + 95; // 595 const buttonsY = PY + PH / 2 - 72; // 798 // Fireworks across the popup for all winners const fwEmitter = this.add.particles(PX, PY, 'catanParticle', { speed: { min: 80, max: 480 }, lifespan: 1400, scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 3, frequency: 35, tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], angle: { min: 0, max: 360 }, emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-PW / 2, -PH / 2, PW, PH) }, }).setDepth(D.banner + 8); this.time.delayedCall(3200, () => { fwEmitter.stop(); this.time.delayedCall(1400, () => fwEmitter.destroy()); }); const overlay = this.add.rectangle(PX, PY, PW, PH, 0x0a0e14, 0.94) .setStrokeStyle(3, COLORS.accent).setDepth(D.banner); const title = this.add.text(PX, titleY, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, { fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex, }).setOrigin(0.5).setDepth(D.banner + 1); // Portrait backing circle const backingG = this.add.graphics().setDepth(D.banner + 1); backingG.fillStyle(0x1a1a2e, 1); backingG.fillCircle(PX, portraitY, RADIUS + 3); backingG.fillStyle(COLORS.panel, 1); backingG.fillCircle(PX, portraitY, RADIUS + 1); const size = RADIUS * 2; let portraitDom = null; let fallbackSprite = null; let avatarActive = true; if (!isHuman) { // AI winner: sprite fallback behind, happy video on top const opp = this.opponents[winner - 1]; if (opp?.id) { if (this.textures.exists('opponents')) { const maskG = this.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); maskG.fillCircle(PX, portraitY, RADIUS); fallbackSprite = this.add.image(PX, portraitY, 'opponents', opp.spriteIndex ?? 0) .setDisplaySize(size, size) .setMask(maskG.createGeometryMask()) .setDepth(D.banner + 2); } const videoEl = document.createElement('video'); videoEl.muted = true; videoEl.loop = true; videoEl.playsInline = true; videoEl.autoplay = true; videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`; videoEl.src = `assets/videos/${opp.id}-happy.mp4`; videoEl.play().catch(() => { }); videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true }); portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3); } } else { // Human winner: canvas initial placeholder, replaced by avatar if available const canvasEl = document.createElement('canvas'); canvasEl.width = size; canvasEl.height = size; canvasEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;display:block;`; const ctx = canvasEl.getContext('2d'); const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, size, size); ctx.fillStyle = COLORS.accentHex; ctx.font = `bold ${Math.round(RADIUS * 0.9)}px "Julius Sans One", sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(initial, size / 2, size / 2); portraitDom = this.add.dom(PX, portraitY, canvasEl).setDepth(D.banner + 3); (async () => { try { const { profile } = await api.get('/profile'); if (!avatarActive || !profile?.avatarPath) return; const imgEl = document.createElement('img'); imgEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`; await new Promise((res, rej) => { imgEl.onload = res; imgEl.onerror = rej; imgEl.src = profile.avatarPath; }); if (!avatarActive) return; canvasEl.style.display = 'none'; this.add.dom(PX, portraitY, imgEl).setDepth(D.banner + 3); } catch { /* keep initial placeholder */ } })(); } const lines = this.gs.players .map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`) .join('\n'); const body = this.add.text(PX, bodyY, lines, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', }).setOrigin(0.5).setDepth(D.banner + 1); const cleanup = () => { avatarActive = false; overlay.destroy(); title.destroy(); body.destroy(); backingG.destroy(); if (fallbackSprite) fallbackSprite.destroy(); if (portraitDom) portraitDom.destroy(); playAgain.destroy(); leave.destroy(); }; const playAgain = new Button(this, PX - 110, buttonsY, 'Play Again', () => { cleanup(); this.startNewMatch(); }, { width: 200, fontSize: 22 }).setDepth(D.banner + 1); const leave = new Button(this, PX + 110, buttonsY, 'Leave', () => { cleanup(); 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)); } }