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

2552 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { 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 8931080).
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)); }
}