2552 lines
108 KiB
JavaScript
2552 lines
108 KiB
JavaScript
import * as Phaser from 'phaser';
|
||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { auth } from '../../services/auth.js';
|
||
import { api } from '../../services/api.js';
|
||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||
import {
|
||
RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
|
||
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE,
|
||
} from './CatanBoard.js';
|
||
import * as L from './CatanLogic.js';
|
||
import * as AI from './CatanAI.js';
|
||
|
||
const D = { board: 0, port: 4, chit: 8, robber: 11, road: 12, building: 14, highlight: 20, hud: 30, panel: 60, banner: 80 };
|
||
|
||
export default class CatanGame extends Phaser.Scene {
|
||
constructor() { super('CatanGame'); }
|
||
|
||
init(data) {
|
||
this.gameDef = data.game;
|
||
this.opponents = data.opponents ?? [];
|
||
this.playfield = data.playfield ?? null;
|
||
this.cardBack = data.cardBack ?? null;
|
||
this.tilePlacement = data.tilePlacement ?? 'standard';
|
||
this.expansion = data.expansion ?? 'base';
|
||
this.scenario = data.scenario ?? null;
|
||
this.gs = null;
|
||
this.busy = false;
|
||
this.highlights = [];
|
||
this.pieceObjs = [];
|
||
this.chitObjs = [];
|
||
this.robberObj = null;
|
||
this.opponentPortraits = [];
|
||
this.buttons = {};
|
||
this.placeMode = null; // 'road' | 'settlement' | 'city' | null
|
||
this.handDisplay = [];
|
||
this.handCardObjs = [];
|
||
this.handSelectedIdx = null;
|
||
}
|
||
|
||
create() {
|
||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||
this.buildParticleTexture();
|
||
this.buildPlayfield();
|
||
this.buildBoardStatic();
|
||
this.buildDice();
|
||
this.buildHUD();
|
||
this.buildBankPanel();
|
||
this.buildOpponentPanels();
|
||
this.startNewMatch();
|
||
}
|
||
|
||
buildParticleTexture() {
|
||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||
g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5);
|
||
g.generateTexture('catanParticle', 10, 10);
|
||
g.destroy();
|
||
}
|
||
|
||
// ── coordinate helpers ──────────────────────────────────────────────────────
|
||
// Active board geometry (base island, or the selected Seafarers scenario).
|
||
get geo() { return L.geoFor(this.gs); }
|
||
nodePos(id) { const n = this.geo.nodes[id]; return { x: n.x, y: n.y }; }
|
||
edgePos(id) {
|
||
const [a, b] = this.geo.edges[id].nodes;
|
||
const N = this.geo.nodes;
|
||
return { x: (N[a].x + N[b].x) / 2, y: (N[a].y + N[b].y) / 2 };
|
||
}
|
||
hexPos(id) { const h = this.geo.hexes[id]; return { x: h.cx, y: h.cy }; }
|
||
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
|
||
pname(seat) { return L.playerName(this.gs, seat); }
|
||
|
||
// ── playfield / static board ─────────────────────────────────────────────────
|
||
buildPlayfield() {
|
||
const pf = this.playfield;
|
||
if (pf?.key && this.textures.exists(pf.key)) {
|
||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.board - 2);
|
||
} else {
|
||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x14506b).setDepth(D.board - 2);
|
||
}
|
||
// Sea backdrop — radial gradient via concentric circles, dark outer to lighter inner.
|
||
const SEA_X = 1000, SEA_Y = 470, SEA_R = 470;
|
||
const sea = this.add.graphics().setDepth(D.board - 1);
|
||
const STEPS = 20;
|
||
for (let i = STEPS; i >= 0; i--) {
|
||
const t = i / STEPS;
|
||
const r = Math.round(SEA_R * (i / STEPS));
|
||
// Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80)
|
||
const ro = 0x07, go = 0x24, bo = 0x3a;
|
||
const ri = 0x1a, gi = 0x5e, bi = 0x80;
|
||
const red = Math.round(ro + (ri - ro) * (1 - t));
|
||
const green = Math.round(go + (gi - go) * (1 - t));
|
||
const blue = Math.round(bo + (bi - bo) * (1 - t));
|
||
const color = (red << 16) | (green << 8) | blue;
|
||
sea.fillStyle(color, 1);
|
||
sea.fillCircle(SEA_X, SEA_Y, r);
|
||
}
|
||
sea.lineStyle(4, 0x041820, 0.9);
|
||
sea.strokeCircle(SEA_X, SEA_Y, SEA_R);
|
||
}
|
||
|
||
buildBoardStatic() {
|
||
// Hexes drawn once from the (static) topology; resources/numbers come from state at startNewMatch.
|
||
this.hexGfx = this.add.graphics().setDepth(D.board);
|
||
this.hexBorderGfx = this.add.graphics().setDepth(D.board + 2);
|
||
this.hexImgs = [];
|
||
this.hexLabels = [];
|
||
this.portObjs = [];
|
||
}
|
||
|
||
// Frame pairs per resource: pick one at random each draw.
|
||
static TILE_FRAMES = {
|
||
lumber: [0, 1],
|
||
wool: [2, 3],
|
||
brick: [4, 5],
|
||
ore: [6, 7],
|
||
grain: [8, 9],
|
||
desert: [10, 11],
|
||
sea: [12, 13],
|
||
gold: [14, 15],
|
||
fog: [16, 17],
|
||
};
|
||
|
||
drawHexes() {
|
||
const g = this.hexGfx;
|
||
g.clear();
|
||
this.hexBorderGfx.clear();
|
||
this.hexImgs.forEach(({ img, maskG }) => { img.destroy(); maskG.destroy(); });
|
||
this.hexImgs = [];
|
||
this.hexLabels.forEach((t) => t.destroy());
|
||
this.hexLabels = [];
|
||
this.hexTileFrames = {};
|
||
|
||
// Inset a convex hex polygon toward its center.
|
||
// s=0 collapses to point; s=1 is original. Uses inradius (79.7px) as scale reference.
|
||
const inset = (pts, cx, cy, s) =>
|
||
pts.map(p => ({ x: cx + (p.x - cx) * s, y: cy + (p.y - cy) * s }));
|
||
|
||
// Border insets/image size scale with the active board's hex size (the base
|
||
// island uses 92; larger Seafarers boards use a smaller hex).
|
||
const size = this.geo.size ?? HEX_SIZE;
|
||
const hexW = Math.sqrt(3) * size;
|
||
const inradius = size * Math.sqrt(3) / 2;
|
||
|
||
for (const hex of this.gs.hexes) {
|
||
const pts = this.geo.hexes[hex.id].corners.map((c) => ({ x: this.geo.nodes[c].x, y: this.geo.nodes[c].y }));
|
||
const { x, y } = this.hexPos(hex.id);
|
||
const terr = this.hexTerrain(hex);
|
||
|
||
// Scale factors for the 7px colored ring + 4px dark ring (absolute pixels).
|
||
const s1 = 1 - 7 / inradius; // after colored border
|
||
const s2 = 1 - 11 / inradius; // after dark border (image area)
|
||
const innerPts = inset(pts, x, y, s1);
|
||
const imagePts = inset(pts, x, y, s2);
|
||
|
||
// Layer 1: terrain swatch fill (outer colored border ring)
|
||
g.fillStyle(terr.swatch, 0.55);
|
||
g.fillPoints(pts, true);
|
||
|
||
// Layer 2: dark fill inset (inner black border ring)
|
||
g.fillStyle(0x111111, 1);
|
||
g.fillPoints(innerPts, true);
|
||
|
||
// Layer 3: tile image masked to innermost polygon (land/desert only)
|
||
if (terr.tileFrames && this.textures.exists('catan-tiles')) {
|
||
if (this.hexTileFrames[hex.id] == null) {
|
||
this.hexTileFrames[hex.id] = terr.tileFrames[Math.floor(Math.random() * 2)];
|
||
}
|
||
const frame = this.hexTileFrames[hex.id];
|
||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||
maskG.fillStyle(0xffffff);
|
||
maskG.fillPoints(imagePts, true);
|
||
const img = this.add.image(x, y, 'catan-tiles', frame)
|
||
.setDisplaySize(hexW * s2, size * 2 * s2)
|
||
.setMask(maskG.createGeometryMask())
|
||
.setDepth(D.board + 1);
|
||
this.hexImgs.push({ img, maskG });
|
||
} else {
|
||
// Fallback / water / gold / fog: solid color fill in the image area
|
||
g.fillStyle(terr.color, 1);
|
||
g.fillPoints(imagePts, true);
|
||
}
|
||
|
||
// Thin outer crisp stroke
|
||
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
|
||
this.hexBorderGfx.strokePoints(pts, true);
|
||
|
||
// Terrain label
|
||
this.hexLabels.push(this.add.text(x, y - 56, terr.label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
|
||
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
|
||
}
|
||
}
|
||
|
||
// Visual styling for a hex by terrain kind (Seafarers adds sea/gold/fog).
|
||
hexTerrain(hex) {
|
||
switch (hex.kind) {
|
||
case 'sea':
|
||
return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: CatanGame.TILE_FRAMES.sea };
|
||
case 'gold':
|
||
return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: CatanGame.TILE_FRAMES.gold };
|
||
case 'fog':
|
||
return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: CatanGame.TILE_FRAMES.fog };
|
||
case 'desert':
|
||
return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert };
|
||
default: {
|
||
const info = RESOURCE_INFO[hex.resource];
|
||
return { swatch: info.swatch, color: info.color, label: info.tile, tileFrames: CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11] };
|
||
}
|
||
}
|
||
}
|
||
|
||
drawPorts() {
|
||
this.portObjs.forEach((o) => o.destroy());
|
||
this.portObjs = [];
|
||
for (const port of this.gs.ports) {
|
||
const out = 30;
|
||
const px = port.x + Math.cos(port.angle) * out;
|
||
const py = port.y + Math.sin(port.angle) * out;
|
||
const c = this.add.container(px, py).setDepth(D.port);
|
||
const g = this.add.graphics();
|
||
g.fillStyle(0x6b4a1a, 1); g.fillCircle(0, 0, 19);
|
||
g.fillStyle(0xefe2c0, 1); g.fillCircle(0, 0, 16);
|
||
c.add(g);
|
||
const label = port.type === 'any' ? '3:1' : '2:1';
|
||
const sub = port.type === 'any' ? '' : RESOURCE_INFO[port.type].label[0];
|
||
c.add(this.add.text(0, -4, label, { fontFamily: 'Righteous', fontSize: '13px', color: '#2a2118' }).setOrigin(0.5));
|
||
if (sub) c.add(this.add.text(0, 8, sub, { fontFamily: 'Righteous', fontSize: '11px', color: '#8a5a18' }).setOrigin(0.5));
|
||
|
||
const title = port.type === 'any'
|
||
? '3:1 Harbor'
|
||
: `2:1 ${RESOURCE_INFO[port.type].label} Harbor`;
|
||
const desc = port.type === 'any'
|
||
? 'Trade 3 of any single resource for 1 resource of your choice. Works with brick, lumber, wool, grain, or ore.'
|
||
: `Trade 2 ${RESOURCE_INFO[port.type].label.toLowerCase()} for 1 resource of your choice. Only ${RESOURCE_INFO[port.type].label.toLowerCase()} qualifies for this rate.`;
|
||
c.setInteractive(new Phaser.Geom.Circle(0, 0, 19), Phaser.Geom.Circle.Contains);
|
||
c.on('pointerover', () => this.showInfoTooltip(px, py - 19, title, desc, 0xb89a5e));
|
||
c.on('pointerout', () => this.hideInfoTooltip());
|
||
|
||
// little jetties to the two coastal nodes
|
||
const jg = this.add.graphics().setDepth(D.port - 1);
|
||
jg.lineStyle(3, 0x6b4a1a, 0.8);
|
||
for (const nid of port.nodes) jg.lineBetween(px, py, this.geo.nodes[nid].x, this.geo.nodes[nid].y);
|
||
this.portObjs.push(c, jg);
|
||
}
|
||
}
|
||
|
||
// Polished numeric chits: parchment token, number (red for 6/8), probability pips.
|
||
drawChits() {
|
||
this.chitObjs.forEach((o) => o.destroy());
|
||
this.chitObjs = [];
|
||
this.chitByHexId = {};
|
||
for (const hex of this.gs.hexes) {
|
||
if (hex.number == null) continue;
|
||
const { x, y } = this.hexPos(hex.id);
|
||
const c = this.add.container(x, y + 6).setDepth(D.chit);
|
||
const g = this.add.graphics();
|
||
g.fillStyle(0x000000, 0.18); g.fillCircle(2, 3, 25);
|
||
g.fillStyle(0xf3e6c4, 1); g.fillCircle(0, 0, 24);
|
||
g.lineStyle(2.5, 0xb89a5e, 1); g.strokeCircle(0, 0, 24);
|
||
g.lineStyle(1.5, 0xd8c79a, 1); g.strokeCircle(0, 0, 20);
|
||
c.add(g);
|
||
const hot = hex.number === 6 || hex.number === 8;
|
||
c.add(this.add.text(0, -5, String(hex.number), {
|
||
fontFamily: 'Righteous', fontSize: hot ? '26px' : '24px',
|
||
color: hot ? '#c0392b' : '#2a2118',
|
||
}).setOrigin(0.5));
|
||
// pips
|
||
const n = pipCount(hex.number);
|
||
const pg = this.add.graphics();
|
||
pg.fillStyle(hot ? 0xc0392b : 0x2a2118, 1);
|
||
const spacing = 5;
|
||
const startX = -((n - 1) * spacing) / 2;
|
||
for (let i = 0; i < n; i++) pg.fillCircle(startX + i * spacing, 13, 2);
|
||
c.add(pg);
|
||
this.chitObjs.push(c);
|
||
this.chitByHexId[hex.id] = c;
|
||
// pop-in
|
||
c.setScale(0);
|
||
this.tweens.add({ targets: c, scale: 1, duration: 260, delay: hex.id * 18, ease: 'Back.easeOut' });
|
||
}
|
||
}
|
||
|
||
// ── dice ──────────────────────────────────────────────────────────────────────
|
||
buildDice() {
|
||
this.diceG = [];
|
||
this.diceContainers = [];
|
||
const baseX = 1290, baseY = 950;
|
||
for (let i = 0; i < 2; i++) {
|
||
const g = this.add.graphics();
|
||
const c = this.add.container(baseX + (i === 0 ? -34 : 34), baseY, [g]).setDepth(D.hud).setAlpha(0.25);
|
||
this.diceG.push(g); this.diceContainers.push(c);
|
||
this.drawDie(g, 1);
|
||
}
|
||
}
|
||
drawDie(g, value) {
|
||
const s = 26;
|
||
g.clear();
|
||
g.fillStyle(0xf0e8d0, 1); g.fillRoundedRect(-s, -s, s * 2, s * 2, 6);
|
||
g.lineStyle(2, 0x2c1a0e, 1); g.strokeRoundedRect(-s, -s, s * 2, s * 2, 6);
|
||
const P = {
|
||
1: [[0, 0]], 2: [[-.55, -.55], [.55, .55]], 3: [[-.55, -.55], [0, 0], [.55, .55]],
|
||
4: [[-.55, -.55], [.55, -.55], [-.55, .55], [.55, .55]],
|
||
5: [[-.55, -.55], [.55, -.55], [0, 0], [-.55, .55], [.55, .55]],
|
||
6: [[-.55, -.55], [.55, -.55], [-.55, 0], [.55, 0], [-.55, .55], [.55, .55]],
|
||
};
|
||
g.fillStyle(0x1a1a1a, 1);
|
||
for (const [px, py] of (P[value] || P[1])) g.fillCircle(px * 16, py * 16, 4);
|
||
}
|
||
animateDice(values) {
|
||
return new Promise((resolve) => {
|
||
playSound(this, SFX.DICE_ROLL);
|
||
|
||
const landX = [1256, 1324];
|
||
const landY = 950;
|
||
const startX = GAME_WIDTH / 2; // 960 — center of bottom bar
|
||
const startY = 1015;
|
||
const arcY = 755; // arc peak
|
||
|
||
// Move dice to throw origin, small, random angle
|
||
this.diceContainers.forEach((c, i) => {
|
||
c.setAlpha(1).setScale(0.35).setAngle(Phaser.Math.Between(0, 359))
|
||
.setPosition(startX + (i === 0 ? -12 : 12), startY);
|
||
this.drawDie(this.diceG[i], Phaser.Math.Between(1, 6));
|
||
});
|
||
|
||
// Cycle random faces while airborne
|
||
let cyclerStopped = false;
|
||
const cycler = this.time.addEvent({
|
||
delay: 55, loop: true,
|
||
callback: () => {
|
||
this.drawDie(this.diceG[0], Phaser.Math.Between(1, 6));
|
||
this.drawDie(this.diceG[1], Phaser.Math.Between(1, 6));
|
||
},
|
||
});
|
||
const stopCycler = () => { if (!cyclerStopped) { cyclerStopped = true; cycler.remove(); } };
|
||
|
||
let settled = 0;
|
||
this.diceContainers.forEach((c, i) => {
|
||
const lx = landX[i] + (Math.random() * 8 - 4);
|
||
const ly = landY + (Math.random() * 8 - 4);
|
||
const outMs = 295 + i * 32;
|
||
const backMs = 430 + i * 44;
|
||
const totalMs = outMs + backMs;
|
||
|
||
// X flies straight to landing; Y arcs up then bounces down
|
||
this.tweens.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' });
|
||
this.tweens.chain({
|
||
targets: c, tweens: [
|
||
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
|
||
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
|
||
]
|
||
});
|
||
// Scale up as die approaches
|
||
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
|
||
// Spin
|
||
this.tweens.add({
|
||
targets: c,
|
||
angle: c.angle + 540 + Math.random() * 180,
|
||
duration: totalMs,
|
||
ease: 'Quad.Out',
|
||
});
|
||
|
||
this.time.delayedCall(totalMs, () => {
|
||
stopCycler();
|
||
this.drawDie(this.diceG[i], values[i]);
|
||
// Snap to nearest upright angle with a small wiggle
|
||
const upright = Math.round(c.angle / 90) * 90 + (Math.random() * 10 - 5);
|
||
this.tweens.add({
|
||
targets: c, angle: upright, duration: 120, ease: 'Back.Out',
|
||
onComplete: () => {
|
||
settled++;
|
||
if (settled === 2) {
|
||
this.diceContainers.forEach((dc) =>
|
||
this.tweens.add({ targets: dc, scaleX: 1.14, scaleY: 0.88, duration: 80, yoyo: true })
|
||
);
|
||
const numberWords = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
|
||
enqueueSpeech(`numbers-${numberWords[values[0] + values[1]]}`);
|
||
this.time.delayedCall(160, resolve);
|
||
}
|
||
},
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── HUD (human hand + buttons + status) ────────────────────────────────────────
|
||
buildHUD() {
|
||
// bottom panel
|
||
this.add.rectangle(GAME_WIDTH / 2, 985, GAME_WIDTH, 190, COLORS.panel, 0.92).setDepth(D.hud - 1);
|
||
this.add.rectangle(GAME_WIDTH / 2, 893, GAME_WIDTH, 4, COLORS.accent, 0.6).setDepth(D.hud - 1);
|
||
|
||
// human portrait
|
||
createPlayerPortrait(this, 90, 980, 64, D.hud, 'CatanGame');
|
||
// VP badge above portrait — graphics redrawn in updateHand() once gs is available
|
||
this.playerVpBadgeGfx = this.add.graphics().setDepth(D.hud + 5);
|
||
this.playerVpText = this.add.text(90, 906, '0', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#ffffff', fontStyle: 'bold',
|
||
}).setOrigin(0.5).setDepth(D.hud + 6);
|
||
this.add.text(90, 1056, auth.user?.username ?? 'You', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.hud);
|
||
|
||
// dev card hand area label
|
||
this.devHandContainer = this.add.container(0, 0).setDepth(D.hud);
|
||
this.add.text(740, 916, 'Development Cards', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||
|
||
// status banner (top centre)
|
||
this.statusText = this.add.text(1000, 40, '', {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||
backgroundColor: '#111923cc', padding: { x: 18, y: 8 },
|
||
}).setOrigin(0.5).setDepth(D.banner);
|
||
|
||
// log line (bottom-left)
|
||
this.logText = this.add.text(170, 1060, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0.5).setDepth(D.hud);
|
||
|
||
// cost legend (bottom bar, right of dice)
|
||
this.buildCostLegend();
|
||
|
||
// action buttons (vertical column, right)
|
||
const bx = 1815; let by = 250; const step = 60;
|
||
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 200, height: 46, fontSize: 19 }).setDepth(D.hud); this.buttons[key] = b; by += step; return b; };
|
||
mk('roll', 'Roll Dice', () => this.onRoll());
|
||
mk('road', 'Build Road', () => this.enterPlace('road'));
|
||
if (this.expansion === 'seafarers') mk('ship', 'Build Ship', () => this.enterPlace('ship'));
|
||
mk('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
|
||
mk('city', 'Build City', () => this.enterPlace('city'));
|
||
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
|
||
mk('playDev', 'Play Dev Card', () => this.openDevMenu());
|
||
mk('trade', 'Trade', () => this.openTradePanel());
|
||
mk('endTurn', 'End Turn', () => this.onEndTurn());
|
||
|
||
new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud);
|
||
|
||
this.buildDevCardTooltip();
|
||
this.buildInfoTooltip();
|
||
this._buildTurnTriangle();
|
||
this._buildSpecialCards();
|
||
}
|
||
|
||
_buildSpecialCards() {
|
||
const makeCard = (x, frameIdx, borderColor) => {
|
||
const W = 64, H = 90, R = 6, BW = 3;
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x111111, 0.85);
|
||
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
|
||
const img = this.add.image(0, 0, 'catan-special-cards', frameIdx).setDisplaySize(W - 6, H - 6);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(BW, borderColor, 1);
|
||
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
|
||
const c = this.add.container(x, 760).setDepth(D.hud + 3);
|
||
c.add([bg, img, border]);
|
||
return c;
|
||
};
|
||
|
||
const cardY = this.expansion === 'seafarers' ? 830 : 760;
|
||
this._lrCard = makeCard(1775, 0, 0xdaa520);
|
||
this._laCard = makeCard(1855, 1, 0xb03030);
|
||
this._lrCard.setY(cardY);
|
||
this._laCard.setY(cardY);
|
||
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
|
||
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
|
||
|
||
const H = 90;
|
||
const attachHover = (card, title, desc, borderColor) => {
|
||
card.setInteractive(
|
||
new Phaser.Geom.Rectangle(-32, -H / 2, 64, H),
|
||
Phaser.Geom.Rectangle.Contains,
|
||
);
|
||
card.on('pointerover', () =>
|
||
this.showInfoTooltip(card.x, card.y - (H / 2) * card.scaleY, title, desc, borderColor));
|
||
card.on('pointerout', () => this.hideInfoTooltip());
|
||
};
|
||
attachHover(
|
||
this._lrCard, 'Longest Road',
|
||
'Held by the player with the longest unbroken road of 5 or more segments. Worth 2 Victory Points. Lost to any player who later builds a longer road.',
|
||
0xdaa520,
|
||
);
|
||
attachHover(
|
||
this._laCard, 'Largest Army',
|
||
'Held by the first player to play 3 Knight cards. Worth 2 Victory Points. Lost to any player who later plays more Knights.',
|
||
0xb03030,
|
||
);
|
||
}
|
||
|
||
_getSpecialCardPos(cardType, owner) {
|
||
if (owner === null) {
|
||
const cardY = this.expansion === 'seafarers' ? 830 : 760;
|
||
return cardType === 'longestRoad'
|
||
? { x: 1775, y: cardY, scale: 1 }
|
||
: { x: 1855, y: cardY, scale: 1 };
|
||
}
|
||
if (owner === 0) {
|
||
return cardType === 'longestRoad'
|
||
? { x: 1390, y: 1015, scale: 1 }
|
||
: { x: 1462, y: 1015, scale: 1 };
|
||
}
|
||
const panel = this.oppPanels?.find((p) => p.seat === owner);
|
||
if (!panel) return { x: 0, y: 0, scale: 0.25 };
|
||
const bx = panel.x;
|
||
const by = panel.y - 74;
|
||
return cardType === 'longestRoad'
|
||
? { x: bx - 28, y: by, scale: 0.25 }
|
||
: { x: bx + 28, y: by, scale: 0.25 };
|
||
}
|
||
|
||
updateSpecialCards() {
|
||
if (!this._lrCard || !this.gs) return;
|
||
for (const cardType of ['longestRoad', 'largestArmy']) {
|
||
const newOwner = this.gs[cardType].owner;
|
||
const prevOwner = this._prevSpecialCardOwners[cardType];
|
||
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
|
||
|
||
if (newOwner !== prevOwner) {
|
||
const fromPos = this._getSpecialCardPos(cardType, prevOwner);
|
||
this._prevSpecialCardOwners[cardType] = newOwner;
|
||
this._animateSpecialCardTransfer(cardType, fromPos, newOwner);
|
||
} else if (!this._specialCardAnimating[cardType]) {
|
||
const pos = this._getSpecialCardPos(cardType, newOwner);
|
||
card.setPosition(pos.x, pos.y).setScale(pos.scale);
|
||
}
|
||
}
|
||
}
|
||
|
||
_animateSpecialCardTransfer(cardType, fromPos, newOwner) {
|
||
const toPos = this._getSpecialCardPos(cardType, newOwner);
|
||
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
|
||
|
||
card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale);
|
||
this._specialCardAnimating[cardType] = true;
|
||
|
||
const peakY = Math.min(fromPos.y, toPos.y) - 150;
|
||
const midX = (fromPos.x + toPos.x) / 2;
|
||
const midScale = (fromPos.scale + toPos.scale) / 2;
|
||
const half = 380;
|
||
|
||
this.tweens.chain({
|
||
targets: card, tweens: [
|
||
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
|
||
{
|
||
x: toPos.x, y: toPos.y, scale: toPos.scale,
|
||
duration: half, ease: 'Quad.In',
|
||
onComplete: () => { this._specialCardAnimating[cardType] = false; },
|
||
},
|
||
]
|
||
});
|
||
|
||
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
|
||
}
|
||
|
||
_buildTurnTriangle() {
|
||
const g = this.add.graphics().setDepth(D.hud + 8);
|
||
g.fillStyle(0xffdd00, 1);
|
||
g.fillTriangle(-10, -13, -10, 13, 10, 0);
|
||
g.setPosition(-9999, -9999);
|
||
this.turnIndicator = g;
|
||
this._turnSeat = null;
|
||
this.tweens.add({
|
||
targets: g,
|
||
scaleX: 1.4, scaleY: 1.4,
|
||
duration: 700,
|
||
yoyo: true,
|
||
repeat: -1,
|
||
ease: 'Sine.InOut',
|
||
});
|
||
}
|
||
|
||
_seatPortraitPos(seat) {
|
||
if (seat === 0) return { x: 90, y: 980, r: 64 };
|
||
const panel = this.oppPanels?.find(p => p.seat === seat);
|
||
return panel ? { x: panel.x, y: panel.y, r: 56 } : null;
|
||
}
|
||
|
||
_updateTurnIndicator() {
|
||
const seat = this.gs?.currentPlayer;
|
||
if (seat == null) return;
|
||
const pos = this._seatPortraitPos(seat);
|
||
if (!pos) return;
|
||
const tx = pos.x - pos.r - 20;
|
||
const ty = pos.y;
|
||
if (this._turnSeat === seat) return;
|
||
this._turnSeat = seat;
|
||
if (this.turnIndicator.x < 0) {
|
||
this.turnIndicator.setPosition(tx, ty);
|
||
} else {
|
||
this.tweens.add({
|
||
targets: this.turnIndicator,
|
||
x: tx, y: ty,
|
||
duration: 600,
|
||
ease: 'Cubic.Out',
|
||
});
|
||
}
|
||
}
|
||
|
||
buildDevCardTooltip() {
|
||
const popW = 320, popH = 160, popR = 10;
|
||
const g = this.add.graphics();
|
||
const titleTxt = this.add.text(0, -48, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8',
|
||
}).setOrigin(0.5);
|
||
const descTxt = this.add.text(0, 12, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||
align: 'center', wordWrap: { width: popW - 32 },
|
||
}).setOrigin(0.5);
|
||
this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
|
||
.setDepth(D.panel + 5);
|
||
this._devTooltip.gfx = g;
|
||
this._devTooltip.titleTxt = titleTxt;
|
||
this._devTooltip.descTxt = descTxt;
|
||
this._devTooltip.popW = popW;
|
||
this._devTooltip.popH = popH;
|
||
this._devTooltip.popR = popR;
|
||
}
|
||
|
||
showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) {
|
||
const DEV_DESC = {
|
||
knight: 'Move the Robber to any tile and steal a resource from a player there.',
|
||
roadBuilding: 'Place 2 roads anywhere you could legally build them, for free.',
|
||
vp: 'Worth 1 Victory Point. Kept hidden until you reach 10 VP and win.',
|
||
monopoly: 'Name a resource. Every other player gives you all of that resource.',
|
||
yearOfPlenty: 'Take any 2 resources of your choice directly from the bank.',
|
||
};
|
||
const tt = this._devTooltip;
|
||
const { gfx: g, titleTxt, descTxt, popW, popH, popR } = tt;
|
||
g.clear();
|
||
g.fillStyle(0x0d1117, 0.96);
|
||
g.fillRoundedRect(-popW / 2, -popH / 2, popW, popH, popR);
|
||
g.lineStyle(2, borderColor, 0.9);
|
||
g.strokeRoundedRect(-popW / 2, -popH / 2, popW, popH, popR);
|
||
titleTxt.setText(DEV_INFO[type]?.label ?? type);
|
||
let d = DEV_DESC[type] ?? '';
|
||
if (isNew) d += '\n(Not playable until next turn)';
|
||
descTxt.setText(d);
|
||
const ttX = Phaser.Math.Clamp(cardX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10);
|
||
tt.setPosition(ttX, cardTopY - popH / 2 - 10);
|
||
}
|
||
|
||
hideDevCardTooltip() {
|
||
this._devTooltip?.setPosition(-9999, -9999);
|
||
}
|
||
|
||
// Generic auto-sizing hover tooltip (title + wrapped description) used by the
|
||
// Longest Road / Largest Army cards and the harbor markers.
|
||
buildInfoTooltip() {
|
||
const g = this.add.graphics();
|
||
const titleTxt = this.add.text(0, 0, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8', align: 'center',
|
||
}).setOrigin(0.5, 0);
|
||
const descTxt = this.add.text(0, 0, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||
align: 'center', wordWrap: { width: 288 },
|
||
}).setOrigin(0.5, 0);
|
||
this._infoTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
|
||
.setDepth(D.panel + 5);
|
||
this._infoTooltip.gfx = g;
|
||
this._infoTooltip.titleTxt = titleTxt;
|
||
this._infoTooltip.descTxt = descTxt;
|
||
}
|
||
|
||
showInfoTooltip(anchorX, anchorTopY, title, desc, borderColor = COLORS.accent) {
|
||
const tt = this._infoTooltip;
|
||
const { gfx: g, titleTxt, descTxt } = tt;
|
||
const popW = 320, padTop = 14, gap = 8, padBot = 14;
|
||
titleTxt.setText(title);
|
||
descTxt.setText(desc);
|
||
const popH = padTop + titleTxt.height + gap + descTxt.height + padBot;
|
||
const top = -popH / 2;
|
||
titleTxt.setPosition(0, top + padTop);
|
||
descTxt.setPosition(0, top + padTop + titleTxt.height + gap);
|
||
g.clear();
|
||
g.fillStyle(0x0d1117, 0.96);
|
||
g.fillRoundedRect(-popW / 2, top, popW, popH, 10);
|
||
g.lineStyle(2, borderColor, 0.9);
|
||
g.strokeRoundedRect(-popW / 2, top, popW, popH, 10);
|
||
const x = Phaser.Math.Clamp(anchorX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10);
|
||
const y = Phaser.Math.Clamp(anchorTopY - popH / 2 - 12, popH / 2 + 10, GAME_HEIGHT - popH / 2 - 10);
|
||
tt.setPosition(x, y);
|
||
}
|
||
|
||
hideInfoTooltip() {
|
||
this._infoTooltip?.setPosition(-9999, -9999);
|
||
}
|
||
|
||
buildCostLegend() {
|
||
const panelRight = 1900;
|
||
const panelW = 320;
|
||
const cx = panelRight - panelW / 2;
|
||
|
||
const rows = [
|
||
{ name: 'Road', resources: ['brick', 'lumber'] },
|
||
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
|
||
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
|
||
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
|
||
];
|
||
if (this.expansion === 'seafarers') {
|
||
rows.push({ name: 'Ship', resources: ['lumber', 'wool'] });
|
||
}
|
||
|
||
// Base: original fixed values. Seafarers: taller panel to fit 5th row, still inside bar (y 893–1080).
|
||
const seafarers = this.expansion === 'seafarers';
|
||
const bgH = seafarers ? 180 : 164;
|
||
const bgCy = seafarers ? 987 : 980;
|
||
const rowPad = 44;
|
||
const rowStep = (bgH - rowPad - 14) / (rows.length - 1);
|
||
|
||
const panel = this.add.container(0, 0).setDepth(D.hud);
|
||
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
|
||
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
|
||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5, 0));
|
||
|
||
const lx = panelRight - panelW + 14;
|
||
const rx = panelRight - 14;
|
||
const rowY0 = bgCy - bgH / 2 + rowPad;
|
||
const SW = 16, SH = 16, SG = 4, SR = 3;
|
||
|
||
const g = this.add.graphics();
|
||
panel.add(g);
|
||
|
||
rows.forEach(({ name, resources }, i) => {
|
||
const ry = rowY0 + i * rowStep;
|
||
panel.add(this.add.text(lx, ry, name, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||
}).setOrigin(0, 0.5));
|
||
const totalW = resources.length * SW + (resources.length - 1) * SG;
|
||
let sx = rx - totalW;
|
||
for (const r of resources) {
|
||
g.fillStyle(RESOURCE_INFO[r].swatch, 1);
|
||
g.fillRoundedRect(sx, ry - SH / 2, SW, SH, SR);
|
||
sx += SW + SG;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── bank panel ───────────────────────────────────────────────────────────────
|
||
buildBankPanel() {
|
||
this.bankText = {};
|
||
// Panel flush against sea-circle right edge; wider to fit card + count text side by side
|
||
const panelX = 1489, panelW = 200, panelH = 632;
|
||
// Centre vertically in the playfield zone above the bottom bar (y=10..882)
|
||
const panelY = 10 + Math.round((872 - panelH) / 2); // 130
|
||
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126
|
||
const textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
|
||
const panelCx = panelX + panelW / 2; // for BANK title
|
||
|
||
const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4;
|
||
const imgW = 81, imgH = 117; // portrait in code; -90° rotation → landscape on screen
|
||
|
||
// Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top
|
||
const step = 96;
|
||
const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step);
|
||
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
|
||
|
||
this.bankCardPos = {};
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.bankCardPos[r] = { x: cardCx, y: stackTops[i] + cardH / 2 };
|
||
});
|
||
this.bankCardPos.dev = { x: cardCx, y: stackTops[5] + cardH / 2 };
|
||
|
||
// Layer 1: panel background + card shadow fills
|
||
const gBg = this.add.graphics().setDepth(D.hud - 1);
|
||
gBg.fillStyle(COLORS.panel, 0.92);
|
||
gBg.fillRect(panelX, panelY, panelW, panelH);
|
||
gBg.lineStyle(2, COLORS.accent, 0.7);
|
||
gBg.strokeRect(panelX, panelY, panelW, panelH);
|
||
|
||
this.add.text(panelCx, panelY + 14, 'BANK', {
|
||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||
|
||
[...RESOURCE_TYPES, 'dev'].forEach((_, i) => {
|
||
const top = stackTops[i];
|
||
gBg.fillStyle(0x000000, 0.4);
|
||
gBg.fillRoundedRect(cardCx - cardW / 2 + shadow, top + shadow, cardW, cardH, cardR);
|
||
gBg.fillStyle(0x111111, 0.9);
|
||
gBg.fillRoundedRect(cardCx - cardW / 2, top, cardW, cardH, cardR);
|
||
});
|
||
|
||
gBg.lineStyle(1, COLORS.accent, 0.6);
|
||
gBg.lineBetween(panelX + 8, dividerY, panelX + panelW - 8, dividerY);
|
||
|
||
// Layer 2: card artwork images, rotated 90° CCW
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.add.image(cardCx, stackTops[i] + cardH / 2, 'catan-cards', i)
|
||
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
|
||
});
|
||
this.add.image(cardCx, stackTops[5] + cardH / 2, 'catan-cards', 8)
|
||
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
|
||
|
||
// Layer 3: colored borders
|
||
const gBorders = this.add.graphics().setDepth(D.hud);
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
gBorders.lineStyle(borderW, RESOURCE_INFO[r].swatch, 1);
|
||
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[i], cardW, cardH, cardR);
|
||
});
|
||
gBorders.lineStyle(borderW, COLORS.accent, 1);
|
||
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[5], cardW, cardH, cardR);
|
||
|
||
// Layer 4: count text to the right of each card
|
||
const countStyle = {
|
||
fontFamily: 'Righteous', fontSize: '30px', color: '#ffffff',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
};
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.bankText[r] = this.add.text(textX, stackTops[i] + cardH / 2, '19', countStyle)
|
||
.setOrigin(0.5).setDepth(D.hud);
|
||
});
|
||
this.bankText.dev = this.add.text(textX, stackTops[5] + cardH / 2, '25', countStyle)
|
||
.setOrigin(0.5).setDepth(D.hud);
|
||
}
|
||
|
||
updateBank() {
|
||
if (!this.bankText) return;
|
||
const b = this.gs.bank;
|
||
for (const r of RESOURCE_TYPES) this.bankText[r]?.setText(String(b[r]));
|
||
this.bankText.dev?.setText(String(this.gs.devDeck.length));
|
||
}
|
||
|
||
// ── resource collection animations ──────────────────────────────────────────
|
||
portraitPos(seat) {
|
||
if (seat === 0) return { x: 90, y: 980 };
|
||
const panel = this.oppPanels.find(p => p.seat === seat);
|
||
return panel ? { x: panel.x, y: panel.y } : { x: 130, y: 300 };
|
||
}
|
||
|
||
async animateResourceCollection(oldGs, newGs) {
|
||
if (newGs.diceTotal === 7) return;
|
||
const cards = [];
|
||
for (let seat = 0; seat < newGs.players.length; seat++) {
|
||
for (const r of RESOURCE_TYPES) {
|
||
const delta = newGs.players[seat].resources[r] - oldGs.players[seat].resources[r];
|
||
for (let n = 0; n < delta; n++) cards.push({ seat, resource: r });
|
||
}
|
||
}
|
||
if (cards.length === 0) return;
|
||
const matchingHexes = newGs.hexes.filter(h => h.number === newGs.diceTotal && !h.hasRobber);
|
||
await Promise.all(matchingHexes.map(h => this.animateChitPulse(h)));
|
||
for (const { seat, resource } of cards) await this.animateCardFlight(seat, resource);
|
||
}
|
||
|
||
animateChitPulse(hex) {
|
||
return new Promise(resolve => {
|
||
const chit = this.chitByHexId?.[hex.id];
|
||
const { x, y } = this.hexPos(hex.id);
|
||
const color = RESOURCE_INFO[hex.resource]?.swatch ?? COLORS.accent;
|
||
const doParticles = () => {
|
||
const emitter = this.add.particles(x, y + 6, 'catanParticle', {
|
||
speed: { min: 80, max: 220 }, lifespan: 700,
|
||
scale: { start: 1.4, end: 0 }, alpha: { start: 1, end: 0 },
|
||
quantity: 3, frequency: 25,
|
||
tint: [color, 0xffffff, 0xffd700], angle: { min: 0, max: 360 },
|
||
}).setDepth(D.chit + 2);
|
||
this.time.delayedCall(650, () => emitter.destroy());
|
||
};
|
||
if (!chit) { doParticles(); this.time.delayedCall(800, resolve); return; }
|
||
this.tweens.add({
|
||
targets: chit, scale: 1.6, duration: 200, ease: 'Back.easeOut',
|
||
onComplete: () => {
|
||
doParticles();
|
||
this.time.delayedCall(350, () => this.tweens.add({
|
||
targets: chit, scale: 1, duration: 200, ease: 'Back.easeIn',
|
||
onComplete: () => this.time.delayedCall(80, resolve),
|
||
}));
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
animateCardFlight(seat, resource) {
|
||
return new Promise(resolve => {
|
||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||
const src = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
|
||
const dst = this.portraitPos(seat);
|
||
const cardW = 81, cardH = 117;
|
||
|
||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(3, RESOURCE_INFO[resource].swatch, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner - 1);
|
||
|
||
this.tweens.add({
|
||
targets: container, x: dst.x, y: dst.y, duration: 1000, ease: 'Quad.InOut',
|
||
onComplete: () => {
|
||
playSound(this, SFX.CASINO_WIN);
|
||
container.destroy();
|
||
const radius = seat === 0 ? 64 : 56;
|
||
const label = this.add.text(dst.x + radius + 10, dst.y,
|
||
RESOURCE_INFO[resource].label.toUpperCase(), {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0, 0.5).setDepth(D.banner);
|
||
this.tweens.add({
|
||
targets: label, alpha: 0, y: dst.y - 24,
|
||
duration: 700, delay: 300,
|
||
onComplete: () => label.destroy(),
|
||
});
|
||
this.time.delayedCall(80, resolve);
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── opponents (left column) ─────────────────────────────────────────────────
|
||
buildOpponentPanels() {
|
||
this.oppPanels = [];
|
||
const aiSeats = this.opponents.length; // human is seat 0
|
||
}
|
||
|
||
renderOpponentPanels() {
|
||
// build once we know player count
|
||
if (this.oppPanels.length) { this.updateOpponentPanels(); return; }
|
||
const n = this.gs.playerCount;
|
||
const seats = [];
|
||
for (let s = 1; s < n; s++) seats.push(s);
|
||
const startY = 170, gap = Math.min(250, (820) / seats.length);
|
||
seats.forEach((seat, i) => {
|
||
const x = 130, y = startY + i * gap;
|
||
const opp = this.opponents[seat - 1];
|
||
const portrait = createOpponentPortrait(this, opp, x, y, 56, D.hud);
|
||
this.opponentPortraits[seat] = portrait;
|
||
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
|
||
this.add.circle(x, y, 62, col.hex, 0).setStrokeStyle(4, col.hex, 0.9).setDepth(D.hud + 4);
|
||
this.add.text(x, y + 70, this.pname(seat), {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||
wordWrap: { width: 180 }, align: 'center',
|
||
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
|
||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||
const info = this.add.text(x, y + 96, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center',
|
||
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
|
||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||
this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null });
|
||
});
|
||
this.updateOpponentPanels();
|
||
}
|
||
|
||
updateOpponentPanels() {
|
||
for (const panel of this.oppPanels) {
|
||
const p = this.gs.players[panel.seat];
|
||
const cards = L.handSize(p);
|
||
const dev = p.devCards.length + p.newDevCards.length;
|
||
panel.info.setText(`${dev} dev ${p.knightsPlayed} knights`);
|
||
|
||
// VP badge circle above the portrait
|
||
if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; }
|
||
const vp = L.publicVictoryPoints(this.gs, panel.seat);
|
||
const col = PLAYER_COLORS[p.colorIndex];
|
||
const badgeContainer = this.add.container(panel.x, panel.y - 74).setDepth(D.hud + 5);
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(col.hexDark, 1); bg.fillCircle(0, 0, 18);
|
||
bg.lineStyle(2.5, col.hex, 0.9); bg.strokeCircle(0, 0, 18);
|
||
const vpText = this.add.text(0, 0, String(vp), {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: col.key === 'white' ? '#000000' : '#ffffff', fontStyle: 'bold',
|
||
}).setOrigin(0.5);
|
||
badgeContainer.add([bg, vpText]);
|
||
panel.vpBadge = badgeContainer;
|
||
|
||
// Rebuild face-down card rows to the right of the portrait
|
||
if (!panel.cardFan) panel.cardFan = [];
|
||
panel.cardFan.forEach(o => o.destroy());
|
||
panel.cardFan = [];
|
||
|
||
const cardW = 30, cardH = 43, cardStep = cardW + 3;
|
||
const rowStartX = panel.x + 88;
|
||
const maxShow = 8;
|
||
|
||
// ── Resource cards (side by side) ──────────────────────────────────────
|
||
if (cards > 0) {
|
||
const frameIdx = this.cardBack?.spriteIndex ?? 0;
|
||
const show = Math.min(cards, maxShow);
|
||
for (let i = 0; i < show; i++) {
|
||
const img = this.add.image(rowStartX + i * cardStep, panel.y, 'cardbacks', frameIdx)
|
||
.setDisplaySize(cardW, cardH)
|
||
.setDepth(D.hud + 1);
|
||
panel.cardFan.push(img);
|
||
}
|
||
if (cards > maxShow) {
|
||
const bx = rowStartX + maxShow * cardStep;
|
||
panel.cardFan.push(this.add.text(bx, panel.y, `+${cards - maxShow}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0, 0.5).setDepth(D.hud + 20));
|
||
}
|
||
}
|
||
|
||
// ── Dev cards (face-down, using dev card back) ─────────────────────────
|
||
const devTotal = p.devCards.length + p.newDevCards.length + (p.vpCards ?? 0);
|
||
if (devTotal > 0) {
|
||
const devY = panel.y + cardH + 5;
|
||
const devShow = Math.min(devTotal, maxShow);
|
||
for (let i = 0; i < devShow; i++) {
|
||
const img = this.add.image(rowStartX + i * cardStep, devY, 'catan-cards', 8)
|
||
.setDisplaySize(cardW, cardH)
|
||
.setDepth(D.hud + 1);
|
||
panel.cardFan.push(img);
|
||
}
|
||
if (devTotal > maxShow) {
|
||
const bx = rowStartX + maxShow * cardStep;
|
||
panel.cardFan.push(this.add.text(bx, devY, `+${devTotal - maxShow}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0, 0.5).setDepth(D.hud + 20));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── full render ─────────────────────────────────────────────────────────────
|
||
renderAll() {
|
||
this.renderPieces();
|
||
this.renderRobber();
|
||
this.updateHand();
|
||
this.updateDevHand();
|
||
this.updateBank();
|
||
this.renderOpponentPanels();
|
||
this.updateButtons();
|
||
this.updateStatus();
|
||
this.updateSpecialCards();
|
||
}
|
||
|
||
renderPieces() {
|
||
this.pieceObjs.forEach((o) => o.destroy());
|
||
this.pieceObjs = [];
|
||
const N = this.geo.nodes;
|
||
// roads
|
||
for (const p of this.gs.players) {
|
||
const col = PLAYER_COLORS[p.colorIndex];
|
||
for (const eid of p.roads) {
|
||
const [a, b] = this.geo.edges[eid].nodes;
|
||
const g = this.add.graphics().setDepth(D.road);
|
||
const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y;
|
||
g.lineStyle(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
|
||
g.lineStyle(12, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
|
||
g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
|
||
this.pieceObjs.push(g);
|
||
}
|
||
// ships (Seafarers): dashed maritime route in the player's colour
|
||
for (const eid of (p.ships ?? [])) {
|
||
const [a, b] = this.geo.edges[eid].nodes;
|
||
this.pieceObjs.push(this.makeShip(N[a].x, N[a].y, N[b].x, N[b].y, col));
|
||
}
|
||
}
|
||
// settlements + cities
|
||
for (const p of this.gs.players) {
|
||
const col = PLAYER_COLORS[p.colorIndex];
|
||
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(N[nid].x, N[nid].y, col));
|
||
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(N[nid].x, N[nid].y, col));
|
||
}
|
||
// pirate (Seafarers): a sea-robber token on its hex
|
||
if (this.gs.pirateHex != null) {
|
||
const { x, y } = this.hexPos(this.gs.pirateHex);
|
||
this.pieceObjs.push(
|
||
this.add.image(x, y, 'catan-pirate').setOrigin(0.5).setDisplaySize(48, 48).setDepth(D.robber)
|
||
);
|
||
}
|
||
}
|
||
|
||
// A ship piece: a thick coloured bar along the sea edge with a sail nub.
|
||
makeShip(ax, ay, bx, by, col) {
|
||
const g = this.add.graphics().setDepth(D.road);
|
||
g.lineStyle(15, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
|
||
g.lineStyle(11, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
|
||
g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by);
|
||
// sail at the midpoint
|
||
const mx = (ax + bx) / 2, my = (ay + by) / 2;
|
||
g.fillStyle(0xffffff, 0.95);
|
||
g.fillTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
|
||
g.lineStyle(2, col.hexDark, 1);
|
||
g.strokeTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
|
||
return g;
|
||
}
|
||
|
||
makeSettlement(x, y, col) {
|
||
const g = this.add.graphics().setDepth(D.building);
|
||
// Drop shadow
|
||
g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 13, y - 1, 34, 24, 4);
|
||
// White halo (3px border around the scaled-up shape)
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillRect(x - 16, y - 4, 32, 20);
|
||
g.fillTriangle(x - 19, y - 4, x + 19, y - 4, x, y - 21);
|
||
// Player color fill (~30% larger than original)
|
||
g.fillStyle(col.hex, 1);
|
||
g.fillRect(x - 13, y - 4, 26, 17);
|
||
g.fillTriangle(x - 16, y - 4, x + 16, y - 4, x, y - 18);
|
||
// Dark outline
|
||
g.lineStyle(2.5, col.hexDark, 1); g.strokeRect(x - 13, y - 4, 26, 17);
|
||
return g;
|
||
}
|
||
makeCity(x, y, col) {
|
||
const g = this.add.graphics().setDepth(D.building);
|
||
// Drop shadow
|
||
g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 19, y - 7, 50, 38, 4);
|
||
// White halo (3px border around the scaled-up shape)
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillRect(x - 24, y, 27, 21); // lower block halo
|
||
g.fillRect(x - 8, y - 10, 32, 32); // tower halo
|
||
g.fillTriangle(x - 11, y - 10, x + 26, y - 10, x + 8, y - 26); // roof halo
|
||
// Player color fill (~30% larger than original)
|
||
g.fillStyle(col.hex, 1);
|
||
g.fillRect(x - 21, y, 21, 18); // lower block
|
||
g.fillRect(x - 5, y - 10, 26, 29); // tower
|
||
g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof
|
||
// Dark outlines
|
||
g.lineStyle(2.5, col.hexDark, 1);
|
||
g.strokeRect(x - 21, y, 21, 18);
|
||
g.strokeRect(x - 5, y - 10, 26, 29);
|
||
return g;
|
||
}
|
||
|
||
renderRobber() {
|
||
if (this.robberObj) this.robberObj.destroy();
|
||
const { x, y } = this.hexPos(this.gs.robberHex);
|
||
this.robberObj = this.add.image(x, y, 'catan-robber')
|
||
.setDisplaySize(64, 64)
|
||
.setDepth(D.robber);
|
||
this.robberObj.postFX.addGlow(0x000000, 8, 0, false, 0.1, 24);
|
||
}
|
||
|
||
async animateRobber(fromHexId, toHexId) {
|
||
const clip = `catan-robber-0${Phaser.Math.Between(1, 4)}`;
|
||
enqueueSpeech(clip);
|
||
if (this.robberObj) this.robberObj.destroy();
|
||
const from = this.hexPos(fromHexId);
|
||
const to = this.hexPos(toHexId);
|
||
this.robberObj = this.add.image(from.x, from.y, 'catan-robber')
|
||
.setDisplaySize(64, 64)
|
||
.setDepth(D.robber);
|
||
this.robberObj.postFX.addGlow(0x000000, 8, 0, false, 0.1, 24);
|
||
const base = this.robberObj.scaleX;
|
||
await new Promise(resolve => {
|
||
this.tweens.add({
|
||
targets: this.robberObj,
|
||
x: to.x, y: to.y,
|
||
duration: 3000,
|
||
ease: 'Sine.InOut',
|
||
});
|
||
this.tweens.chain({
|
||
targets: this.robberObj,
|
||
tweens: [
|
||
{ scaleX: base * 2, scaleY: base * 2, duration: 1500, ease: 'Sine.Out' },
|
||
{ scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve },
|
||
],
|
||
});
|
||
});
|
||
}
|
||
|
||
async animateCostPayment(seat, type) {
|
||
const cost = COSTS[type];
|
||
if (!cost) return;
|
||
const resources = [];
|
||
for (const [r, n] of Object.entries(cost)) for (let i = 0; i < n; i++) resources.push(r);
|
||
const isHuman = seat === 0;
|
||
const usedHandIdxs = new Set();
|
||
let oppCardOffset = 0;
|
||
for (const resource of resources) {
|
||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||
const bankPos = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
|
||
let srcX, srcY, startFaceUp;
|
||
if (isHuman) {
|
||
startFaceUp = true;
|
||
let ci = -1;
|
||
for (let i = 0; i < this.handDisplay.length; i++) {
|
||
if (!usedHandIdxs.has(i) && this.handDisplay[i] === resource) { ci = i; usedHandIdxs.add(i); break; }
|
||
}
|
||
srcX = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].x : 90;
|
||
srcY = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].y : 950;
|
||
} else {
|
||
startFaceUp = false;
|
||
const panel = this.oppPanels.find(p => p.seat === seat);
|
||
if (panel?.cardFan[oppCardOffset]) {
|
||
srcX = panel.cardFan[oppCardOffset].x;
|
||
srcY = panel.cardFan[oppCardOffset].y;
|
||
} else {
|
||
const pos = this._seatPortraitPos(seat);
|
||
srcX = (pos?.x ?? 130) + 88 + oppCardOffset * 33;
|
||
srcY = pos?.y ?? 300;
|
||
}
|
||
oppCardOffset++;
|
||
}
|
||
await this._flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp);
|
||
}
|
||
}
|
||
|
||
_flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) {
|
||
return new Promise(resolve => {
|
||
const cardW = 60, cardH = 84;
|
||
const bigW = 90, bigH = 126;
|
||
const resource = RESOURCE_TYPES[frameIdx];
|
||
|
||
const makeBorder = (color) => {
|
||
const g = this.add.graphics();
|
||
g.lineStyle(3, color, 1);
|
||
g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
return g;
|
||
};
|
||
|
||
if (startFaceUp) {
|
||
// Human card: already face-up, just fly to bank.
|
||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||
const border = makeBorder(RESOURCE_INFO[resource]?.swatch ?? COLORS.accent);
|
||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner);
|
||
this.tweens.add({
|
||
targets: container, x: bankPos.x, y: bankPos.y, duration: 600, ease: 'Quad.InOut',
|
||
onComplete: () => this.tweens.add({
|
||
targets: container, alpha: 0, duration: 200,
|
||
onComplete: () => { container.destroy(); resolve(); },
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Opponent card: show face-down → flip → resize → fly.
|
||
const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH);
|
||
const border = makeBorder(COLORS.accent);
|
||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner);
|
||
|
||
// Brief beat so the player can register the face-down card.
|
||
this.time.delayedCall(120, () => {
|
||
// Flip first half: collapse container width to 0.
|
||
this.tweens.add({
|
||
targets: container, scaleX: 0, duration: 110, ease: 'Linear',
|
||
onComplete: () => {
|
||
// Swap to face-up artwork and recolor border (hidden at scaleX=0).
|
||
img.setTexture('catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||
border.clear();
|
||
border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
// Flip second half: restore width.
|
||
this.tweens.add({
|
||
targets: container, scaleX: 1, duration: 110, ease: 'Linear',
|
||
onComplete: () => {
|
||
// Scale up to bank card size with a little bounce.
|
||
this.tweens.add({
|
||
targets: container, scaleX: bigW / cardW, scaleY: bigH / cardH,
|
||
duration: 230, ease: 'Back.Out',
|
||
onComplete: () => {
|
||
// Now fly to the bank.
|
||
this.tweens.add({
|
||
targets: container, x: bankPos.x, y: bankPos.y, duration: 720, ease: 'Quad.InOut',
|
||
onComplete: () => this.tweens.add({
|
||
targets: container, alpha: 0, duration: 180,
|
||
onComplete: () => { container.destroy(); resolve(); },
|
||
}),
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
animatePiecePlacement(seat, type, destX, destY) {
|
||
return new Promise(resolve => {
|
||
const srcPos = this._seatPortraitPos(seat);
|
||
if (!srcPos) { resolve(); return; }
|
||
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
|
||
const g = this.add.graphics();
|
||
const container = this.add.container(srcPos.x, srcPos.y, [g]).setDepth(D.banner - 1);
|
||
if (type === 'road') {
|
||
g.lineStyle(10, 0xffffff, 0.9); g.lineBetween(-13, 0, 13, 0);
|
||
g.lineStyle(7, col.hexDark, 1); g.lineBetween(-13, 0, 13, 0);
|
||
g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0);
|
||
} else if (type === 'settlement') {
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17);
|
||
g.fillStyle(col.hex, 1);
|
||
g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14);
|
||
g.lineStyle(2, col.hexDark, 1); g.strokeRect(-9, -3, 18, 12);
|
||
} else if (type === 'city') {
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillRect(-17, 0, 18, 15); g.fillRect(-6, -9, 21, 24); g.fillTriangle(-9, -9, 17, -9, 6, -22);
|
||
g.fillStyle(col.hex, 1);
|
||
g.fillRect(-15, 0, 15, 12); g.fillRect(-4, -8, 17, 20); g.fillTriangle(-7, -8, 15, -8, 6, -19);
|
||
g.lineStyle(2, col.hexDark, 1); g.strokeRect(-15, 0, 15, 12); g.strokeRect(-4, -8, 17, 20);
|
||
}
|
||
const emitter = this.add.particles(0, 0, 'catanParticle', {
|
||
follow: container,
|
||
speed: { min: 25, max: 70 },
|
||
lifespan: 450,
|
||
scale: { start: 0.9, end: 0 },
|
||
alpha: { start: 0.85, end: 0 },
|
||
quantity: 2,
|
||
frequency: 28,
|
||
tint: [col.hex, 0xffffff, col.hexDark],
|
||
angle: { min: 0, max: 360 },
|
||
}).setDepth(D.banner - 2);
|
||
const duration = 2000;
|
||
const arcHeight = Math.max(180, Math.abs(destY - srcPos.y) * 0.7 + 120);
|
||
const peakY = Math.min(srcPos.y, destY) - arcHeight;
|
||
const half = duration / 2;
|
||
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
|
||
this.tweens.chain({
|
||
targets: container, tweens: [
|
||
{ y: peakY, duration: half, ease: 'Quad.Out' },
|
||
{
|
||
y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
|
||
emitter.stop();
|
||
container.destroy();
|
||
this.time.delayedCall(500, () => emitter.destroy());
|
||
resolve();
|
||
}
|
||
},
|
||
]
|
||
});
|
||
});
|
||
}
|
||
|
||
animateDevCardFromBank(seat) {
|
||
return new Promise(resolve => {
|
||
const bankPos = this.bankCardPos?.dev ?? { x: 1562, y: 701 };
|
||
const destPos = this._seatPortraitPos(seat) ?? { x: 90, y: 980 };
|
||
const cardW = 60, cardH = 84;
|
||
|
||
const img = this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(cardW, cardH);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(3, COLORS.accent, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
const container = this.add.container(bankPos.x, bankPos.y, [img, border]).setDepth(D.banner);
|
||
|
||
const duration = 1400;
|
||
const arcHeight = Math.max(150, Math.abs(destPos.y - bankPos.y) * 0.5 + 100);
|
||
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
|
||
const half = duration / 2;
|
||
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
|
||
this.tweens.chain({
|
||
targets: container, tweens: [
|
||
{ y: peakY, duration: half, ease: 'Quad.Out' },
|
||
{
|
||
y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
|
||
this.tweens.add({
|
||
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
|
||
onComplete: () => { container.destroy(); resolve(); },
|
||
});
|
||
}
|
||
},
|
||
]
|
||
});
|
||
});
|
||
}
|
||
|
||
updateHand() {
|
||
const p = this.gs.players[0];
|
||
|
||
// Sync handDisplay with game state, preserving drag order
|
||
const updated = [...this.handDisplay];
|
||
for (const r of RESOURCE_TYPES) {
|
||
const have = updated.filter(x => x === r).length;
|
||
const need = p.resources[r];
|
||
if (have > need) {
|
||
let removed = 0;
|
||
for (let i = updated.length - 1; i >= 0 && removed < have - need; i--) {
|
||
if (updated[i] === r) { updated.splice(i, 1); removed++; }
|
||
}
|
||
} else {
|
||
for (let k = 0; k < need - have; k++) updated.push(r);
|
||
}
|
||
}
|
||
if (JSON.stringify(updated) !== JSON.stringify(this.handDisplay)) this.handSelectedIdx = null;
|
||
this.handDisplay = updated;
|
||
this.renderHand();
|
||
|
||
// VP badge
|
||
const col = PLAYER_COLORS[p.colorIndex];
|
||
this.playerVpBadgeGfx.clear();
|
||
this.playerVpBadgeGfx.fillStyle(col.hexDark, 1); this.playerVpBadgeGfx.fillCircle(90, 906, 18);
|
||
this.playerVpBadgeGfx.lineStyle(2.5, col.hex, 0.9); this.playerVpBadgeGfx.strokeCircle(90, 906, 18);
|
||
this.playerVpText.setText(String(L.victoryPoints(this.gs, 0)));
|
||
}
|
||
|
||
renderHand() {
|
||
this.handCardObjs.forEach(c => c.destroy());
|
||
this.handCardObjs = [];
|
||
const N = this.handDisplay.length;
|
||
if (N === 0) return;
|
||
const cardW = 60, cardH = 84, cardR = 6, borderW = 3;
|
||
const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1)));
|
||
|
||
this.handDisplay.forEach((resource, idx) => {
|
||
const x = 220 + idx * step;
|
||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||
const c = this.add.container(x, 950).setDepth(D.hud + idx);
|
||
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x111111, 0.85);
|
||
bg.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, cardR);
|
||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(54, 78);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(borderW, RESOURCE_INFO[resource].swatch, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, cardR);
|
||
c.add([bg, img, border]);
|
||
|
||
if (idx === this.handSelectedIdx) {
|
||
const sel = this.add.graphics();
|
||
sel.lineStyle(3, 0xffd700, 1);
|
||
sel.strokeRoundedRect(-cardW / 2 - 3, -cardH / 2 - 3, cardW + 6, cardH + 6, cardR + 2);
|
||
c.add(sel);
|
||
c.setScale(1.1).setDepth(D.hud + 20);
|
||
}
|
||
|
||
c.setSize(cardW, cardH).setInteractive();
|
||
this._setupHandDrag(c, idx);
|
||
this.handCardObjs.push(c);
|
||
});
|
||
}
|
||
|
||
_setupHandDrag(container, idx) {
|
||
const THRESHOLD = 8;
|
||
let dragging = false, origX = 0, pointerStartX = 0;
|
||
let slotIndicator = null;
|
||
|
||
const cancelDrag = () => {
|
||
if (slotIndicator) { slotIndicator.destroy(); slotIndicator = null; }
|
||
this.input.off('pointermove', onSceneMove);
|
||
this.input.off('pointerup', onSceneUp);
|
||
};
|
||
|
||
const onSceneMove = (ptr) => {
|
||
container.x = origX + (ptr.x - pointerStartX);
|
||
const N = this.handDisplay.length;
|
||
const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1)));
|
||
const newIdx = Math.max(0, Math.min(N - 1, Math.round((ptr.x - 220) / step)));
|
||
if (!slotIndicator) slotIndicator = this.add.rectangle(0, 950, 6, 84, COLORS.accent, 0.8).setDepth(D.hud + 49);
|
||
slotIndicator.x = 220 + newIdx * step - step / 2;
|
||
};
|
||
|
||
const onSceneUp = (ptr) => {
|
||
cancelDrag();
|
||
dragging = false;
|
||
const N = this.handDisplay.length;
|
||
const step = Math.min(66, Math.max(34, 490 / Math.max(N - 1, 1)));
|
||
const newIdx = Math.max(0, Math.min(N - 1, Math.round((ptr.x - 220) / step)));
|
||
if (newIdx !== idx) {
|
||
const [card] = this.handDisplay.splice(idx, 1);
|
||
this.handDisplay.splice(newIdx, 0, card);
|
||
}
|
||
this.handSelectedIdx = null;
|
||
this.renderHand();
|
||
};
|
||
|
||
container.on('pointerdown', (ptr) => {
|
||
origX = container.x; pointerStartX = ptr.x; dragging = false;
|
||
});
|
||
|
||
container.on('pointermove', (ptr) => {
|
||
if (this.handSelectedIdx !== idx) return;
|
||
if (!dragging && Math.abs(ptr.x - pointerStartX) >= THRESHOLD) {
|
||
dragging = true;
|
||
container.setDepth(D.hud + 50);
|
||
this.input.on('pointermove', onSceneMove);
|
||
this.input.on('pointerup', onSceneUp);
|
||
}
|
||
});
|
||
|
||
container.on('pointerup', () => {
|
||
if (!dragging) {
|
||
// Click: toggle selection
|
||
this.handSelectedIdx = this.handSelectedIdx === idx ? null : idx;
|
||
this.renderHand();
|
||
}
|
||
});
|
||
}
|
||
|
||
updateDevHand() {
|
||
this.devHandContainer.removeAll(true);
|
||
const p = this.gs.players[0];
|
||
const cards = [...p.devCards, ...p.newDevCards.map((c) => c + '*')];
|
||
if (p.vpCards) for (let i = 0; i < p.vpCards; i++) cards.push('vp');
|
||
|
||
if (!cards.length) {
|
||
this.devHandContainer.add(this.add.text(740, 980, '—', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0.5));
|
||
return;
|
||
}
|
||
|
||
const DEV_VISUAL = {
|
||
knight: { frame: 5, border: 0xb03030 },
|
||
roadBuilding: { frame: 6, border: 0x8b5a2b },
|
||
vp: { frame: 7, border: 0xdaa520 },
|
||
monopoly: { frame: 9, border: 0x7b2d8b },
|
||
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
|
||
};
|
||
|
||
const cardW = 60, cardH = 84, cardR = 6, borderW = 3;
|
||
const step = 66;
|
||
const y = 980;
|
||
let x = 740;
|
||
|
||
cards.forEach((card) => {
|
||
const isNew = card.endsWith('*');
|
||
const type = isNew ? card.slice(0, -1) : card;
|
||
const visual = DEV_VISUAL[type] ?? { frame: 7, border: COLORS.accent };
|
||
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x111111, 0.85);
|
||
bg.fillRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
|
||
|
||
const img = this.add.image(x, y, 'catan-cards', visual.frame)
|
||
.setDisplaySize(54, 78)
|
||
.setAlpha(isNew ? 0.5 : 1);
|
||
|
||
const border = this.add.graphics();
|
||
border.lineStyle(borderW, visual.border, isNew ? 0.45 : 1);
|
||
border.strokeRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
|
||
|
||
const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive();
|
||
const cx = x, topY = y - cardH / 2;
|
||
hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border));
|
||
hit.on('pointerout', () => this.hideDevCardTooltip());
|
||
|
||
this.devHandContainer.add([bg, img, border, hit]);
|
||
x += step;
|
||
});
|
||
}
|
||
|
||
updateStatus() {
|
||
const s = this.gs;
|
||
let msg = '';
|
||
const me = s.currentPlayer === 0;
|
||
if (s.phase === 'setup') {
|
||
msg = me ? `Place your ${s.setup.placing}` : `${this.pname(s.currentPlayer)} is placing…`;
|
||
} else if (s.phase === 'rollPhase') {
|
||
msg = me ? 'Your turn — roll the dice' : `${this.pname(s.currentPlayer)}'s turn`;
|
||
} else if (s.phase === 'discard') {
|
||
msg = s.discardQueue.includes(0) ? 'Discard half your cards' : 'Opponents discarding…';
|
||
} else if (s.phase === 'moveRobber') {
|
||
msg = me ? 'Move the robber' : `${this.pname(s.currentPlayer)} moves the robber`;
|
||
} else if (s.phase === 'action') {
|
||
msg = me ? `Your turn — VP: ${L.victoryPoints(s, 0)}` : `${this.pname(s.currentPlayer)} is playing…`;
|
||
}
|
||
this.statusText.setText(msg);
|
||
this.logText.setText(s.log[s.log.length - 1] ?? '');
|
||
this._updateTurnIndicator();
|
||
}
|
||
|
||
updateButtons() {
|
||
const s = this.gs;
|
||
const me = s.currentPlayer === 0 && !this.busy;
|
||
const p = s.players[0];
|
||
const action = me && s.phase === 'action';
|
||
const set = (k, on) => this.buttons[k]?.setEnabled(!!on);
|
||
const hasSettleSpot = action && L.legalSettlementNodes(s, 0, false).length > 0;
|
||
const DEBUG_FREE_ROADS_AND_SHIPS = false;
|
||
const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0;
|
||
set('roll', me && s.phase === 'rollPhase');
|
||
set('road', action && hasRoadSpot && (DEBUG_FREE_ROADS_AND_SHIPS && me || L.canAfford(p, COSTS.road) || s.freeRoads > 0));
|
||
const hasShipSpot = action && L.legalShipEdges(s, 0).length > 0;
|
||
const sCost = L.shipCost(s);
|
||
set('ship', hasShipSpot && action && (DEBUG_FREE_ROADS_AND_SHIPS && me || !sCost || L.canAfford(p, sCost) || s.freeShips > 0));
|
||
set('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement));
|
||
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city));
|
||
set('buyDev', action && s.devDeck.length > 0 && L.canAfford(p, COSTS.devCard));
|
||
set('playDev', action && p.devCards.some((c) => c !== 'vp'));
|
||
set('trade', action && L.handSize(p) > 0);
|
||
set('endTurn', action && (s.freeRoads === 0 || !hasRoadSpot));
|
||
}
|
||
|
||
// ── highlights ────────────────────────────────────────────────────────────────
|
||
clearHighlights() {
|
||
this.highlights.forEach((o) => o.destroy());
|
||
this.highlights = [];
|
||
}
|
||
addHighlight(x, y, onClick, color = COLORS.accent, r = 16) {
|
||
const dot = this.add.graphics().setDepth(D.highlight);
|
||
dot.fillStyle(color, 0.85); dot.fillCircle(x, y, r);
|
||
dot.lineStyle(3, 0xffffff, 0.5); dot.strokeCircle(x, y, r);
|
||
this.tweens.add({ targets: dot, alpha: { from: 0.9, to: 0.3 }, duration: 600, yoyo: true, repeat: -1 });
|
||
const zone = this.add.zone(x, y, r * 2.4, r * 2.4).setInteractive({ useHandCursor: true }).setDepth(D.highlight + 1);
|
||
zone.on('pointerdown', onClick);
|
||
this.highlights.push(dot, zone);
|
||
}
|
||
|
||
// ── new match / turn driver ─────────────────────────────────────────────────
|
||
startNewMatch() {
|
||
this.clearHighlights();
|
||
this.busy = false;
|
||
this.placeMode = null;
|
||
const playerCount = Math.min(4, 1 + this.opponents.length);
|
||
this.gs = L.createInitialState(playerCount, {
|
||
tilePlacement: this.tilePlacement,
|
||
expansion: this.expansion,
|
||
scenario: this.scenario,
|
||
});
|
||
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
|
||
L.setPlayerNames(this.gs, names);
|
||
this.hexTileFrames = {};
|
||
this.drawHexes();
|
||
this.drawPorts();
|
||
this.drawChits();
|
||
this.renderAll();
|
||
this.time.delayedCall(700, () => this.advance());
|
||
}
|
||
|
||
async advance() {
|
||
const s = this.gs;
|
||
this.renderAll();
|
||
if (s.phase === 'gameOver') { this.onGameOver(); return; }
|
||
const me = s.currentPlayer === 0;
|
||
if (s.phase === 'setup') {
|
||
if (me) this.promptSetup();
|
||
else await this.aiSetupStep();
|
||
} else if (s.phase === 'rollPhase') {
|
||
if (me) { /* wait for Roll button */ }
|
||
else await this.aiRoll();
|
||
} else if (s.phase === 'goldPick') {
|
||
await this.handleGoldPickPhase();
|
||
} else if (s.phase === 'discard') {
|
||
await this.handleDiscardPhase();
|
||
} else if (s.phase === 'moveRobber') {
|
||
if (me) this.promptRobber();
|
||
else await this.aiRobber();
|
||
} else if (s.phase === 'action') {
|
||
if (me) { /* wait for action buttons */ }
|
||
else await this.aiAction();
|
||
}
|
||
}
|
||
|
||
// ── human: setup ──────────────────────────────────────────────────────────────
|
||
promptSetup() {
|
||
this.clearHighlights();
|
||
const s = this.gs;
|
||
if (s.setup.placing === 'settlement') {
|
||
for (const nid of L.legalSettlementNodes(s, 0, true)) {
|
||
const { x, y } = this.nodePos(nid);
|
||
this.addHighlight(x, y, () => {
|
||
this.clearHighlights();
|
||
this.gs = L.placeSetupSettlement(this.gs, 0, nid);
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
this.advance();
|
||
});
|
||
}
|
||
} else {
|
||
for (const eid of L.legalRoadEdges(s, 0, true, s.setup.lastSettlement)) {
|
||
const { x, y } = this.edgePos(eid);
|
||
this.addHighlight(x, y, () => {
|
||
this.clearHighlights();
|
||
this.gs = L.placeSetupRoad(this.gs, 0, eid);
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
this.advance();
|
||
}, COLORS.gold, 13);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── AI steps ────────────────────────────────────────────────────────────────
|
||
async aiSetupStep() {
|
||
this.busy = true;
|
||
const seat = this.gs.currentPlayer;
|
||
await this.delay(420);
|
||
if (this.gs.setup.placing === 'settlement') {
|
||
this.gs = L.placeSetupSettlement(this.gs, seat, AI.chooseSetupSettlement(this.gs, seat));
|
||
} else {
|
||
this.gs = L.placeSetupRoad(this.gs, seat, AI.chooseSetupRoad(this.gs, seat));
|
||
}
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
async aiRoll() {
|
||
this.busy = true;
|
||
const seat = this.gs.currentPlayer;
|
||
this.showTurnBanner(`${this.pname(seat)}'s Turn`);
|
||
await this.delay(550);
|
||
const pre = AI.choosePreRoll(this.gs, seat);
|
||
if (pre) {
|
||
this.gs = L.playKnight(this.gs, seat);
|
||
this.renderAll(); await this.delay(400);
|
||
const m = AI.chooseRobberMove(this.gs, seat);
|
||
const preRobberHex = this.gs.robberHex;
|
||
const preRes0roll = { ...this.gs.players[0].resources };
|
||
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
|
||
if (m.targetSeat === 0) {
|
||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0roll[r]);
|
||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||
}
|
||
await this.animateRobber(preRobberHex, m.hexId);
|
||
this.renderAll(); await this.delay(400);
|
||
}
|
||
if (this.gs.phase === 'rollPhase') {
|
||
const preGs = this.gs;
|
||
const ns = L.rollDice(this.gs);
|
||
await this.animateDice(ns.dice);
|
||
this.gs = ns;
|
||
await this.animateResourceCollection(preGs, ns);
|
||
this.renderAll();
|
||
await this.delay(500);
|
||
}
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
async aiRobber() {
|
||
this.busy = true;
|
||
const seat = this.gs.currentPlayer;
|
||
await this.delay(450);
|
||
const m = AI.chooseRobberMove(this.gs, seat);
|
||
const preRobberHex = this.gs.robberHex;
|
||
const preRes0 = { ...this.gs.players[0].resources };
|
||
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
|
||
const animPromise = this.animateRobber(preRobberHex, m.hexId);
|
||
if (m.targetSeat != null) this.opponentPortraits[seat]?.playEmotion?.('happy');
|
||
if (m.targetSeat === 0) {
|
||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0[r]);
|
||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||
}
|
||
await animPromise;
|
||
this.renderAll();
|
||
await this.delay(450);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
async aiAction() {
|
||
this.busy = true;
|
||
const seat = this.gs.currentPlayer;
|
||
await this.aiInitiateTrades(seat);
|
||
let steps = 0;
|
||
while (this.gs.phase === 'action' && steps++ < 60) {
|
||
const a = AI.chooseAction(this.gs, seat);
|
||
if (a.type === 'endTurn') { this.gs = L.endTurn(this.gs); break; }
|
||
const before = JSON.stringify(this.gs.players[seat]) + this.gs.phase;
|
||
if (a.type === 'buildRoad' || a.type === 'buildSettlement' || a.type === 'buildCity') {
|
||
const pieceType = { buildCity: 'city', buildSettlement: 'settlement', buildRoad: 'road' }[a.type];
|
||
const dest = a.type === 'buildRoad' ? this.edgePos(a.edgeId) : this.nodePos(a.nodeId);
|
||
enqueueSpeech(`catan-purchase-${pieceType}`);
|
||
await this.animateCostPayment(seat, pieceType);
|
||
await this.animatePiecePlacement(seat, pieceType, dest.x, dest.y);
|
||
if (a.type === 'buildCity' || a.type === 'buildSettlement') {
|
||
this.opponentPortraits[seat]?.playEmotion?.('happy');
|
||
}
|
||
}
|
||
if (a.type === 'buyDev') {
|
||
enqueueSpeech('catan-purchase-development-card');
|
||
await this.animateCostPayment(seat, 'devCard');
|
||
playSound(this, SFX.CARD_DEAL);
|
||
await this.animateDevCardFromBank(seat);
|
||
}
|
||
this.gs = this.applyAction(seat, a);
|
||
if (a.type === 'playDev' && a.card !== 'vp') {
|
||
await this.animateOppDevCardPlay(seat, a.card, a.resource);
|
||
}
|
||
if (this.gs.phase === 'moveRobber') {
|
||
const m = AI.chooseRobberMove(this.gs, seat);
|
||
const preRobberHex = this.gs.robberHex;
|
||
const preRes0action = { ...this.gs.players[0].resources };
|
||
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
|
||
if (m.targetSeat === 0) {
|
||
const stolen = RESOURCE_TYPES.find(r => this.gs.players[0].resources[r] < preRes0action[r]);
|
||
if (stolen) this._notifyStolenCard(stolen, this.pname(seat), seat);
|
||
}
|
||
await this.animateRobber(preRobberHex, m.hexId);
|
||
}
|
||
this.renderAll();
|
||
await this.delay(480);
|
||
if (this.gs.phase === 'gameOver') break;
|
||
const after = JSON.stringify(this.gs.players[seat]) + this.gs.phase;
|
||
if (before === after && a.type !== 'playDev') { this.gs = L.endTurn(this.gs); break; }
|
||
}
|
||
if (steps >= 60 && this.gs.phase === 'action') this.gs = L.endTurn(this.gs);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
applyAction(seat, a) {
|
||
switch (a.type) {
|
||
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
|
||
case 'buildSettlement': return L.buildSettlement(this.gs, seat, a.nodeId);
|
||
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
|
||
case 'buyDev': return L.buyDevCard(this.gs, seat);
|
||
case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
|
||
case 'playDev':
|
||
if (a.card === 'knight') return L.playKnight(this.gs, seat);
|
||
if (a.card === 'roadBuilding') return L.playRoadBuilding(this.gs, seat);
|
||
if (a.card === 'yearOfPlenty') return L.playYearOfPlenty(this.gs, seat, a.r1, a.r2);
|
||
if (a.card === 'monopoly') return L.playMonopoly(this.gs, seat, a.resource);
|
||
return this.gs;
|
||
default: return this.gs;
|
||
}
|
||
}
|
||
|
||
// ── AI-initiated trades ───────────────────────────────────────────────────────
|
||
// Up to 2 per turn. Each 1-for-1 offer goes to the human first (if they hold the
|
||
// requested resource); on deny/no-hold it falls through to the other AIs.
|
||
async aiInitiateTrades(seat) {
|
||
if (this.gs.phase !== 'action') return;
|
||
const exclude = new Set();
|
||
for (let n = 0; n < 2; n++) {
|
||
const offer = AI.proposeTrade(this.gs, seat, exclude);
|
||
if (!offer) break;
|
||
const { give, get } = offer; // requester gives `give`, wants `get`
|
||
exclude.add(get);
|
||
|
||
let done = false;
|
||
if (this.gs.players[0].resources[get] > 0) {
|
||
const accepted = await this.promptHumanTrade(seat, give, get);
|
||
if (accepted) {
|
||
this.gs = L.executePlayerTrade(this.gs, seat, 0, { [give]: 1 }, { [get]: 1 });
|
||
playSound(this, SFX.CARD_PLACE);
|
||
this.flashStatus(`You traded ${RESOURCE_INFO[get].label} for ${RESOURCE_INFO[give].label} with ${this.pname(seat)}.`);
|
||
this.renderAll();
|
||
done = true;
|
||
}
|
||
}
|
||
|
||
if (!done) {
|
||
let acc = null;
|
||
for (let s = 1; s < this.gs.playerCount; s++) {
|
||
if (s === seat) continue;
|
||
// Accepter `s` gives the requested resource and gets the offered one.
|
||
if (AI.respondToTrade(this.gs, s, { [get]: 1 }, { [give]: 1 }, seat)) { acc = s; break; }
|
||
}
|
||
if (acc != null) {
|
||
this.gs = L.executePlayerTrade(this.gs, seat, acc, { [give]: 1 }, { [get]: 1 });
|
||
await this.animateAiTrade(seat, acc, give, get);
|
||
this.flashStatus(`${this.pname(seat)} traded ${RESOURCE_INFO[give].label} for ${RESOURCE_INFO[get].label} with ${this.pname(acc)}.`);
|
||
this.renderAll();
|
||
}
|
||
}
|
||
await this.delay(300);
|
||
}
|
||
}
|
||
|
||
// Show the "Trade Request" popup to the human. Resolves true on Accept, false on Deny.
|
||
promptHumanTrade(requesterSeat, giveRes, getRes) {
|
||
return new Promise((resolve) => {
|
||
const panel = this.modalPanel(470, 'Trade Request');
|
||
const cardW = 96, cardH = 134;
|
||
const objs = panel.objs;
|
||
|
||
objs.push(this.add.text(1000, 360, `${this.pname(requesterSeat)} wants to trade`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||
|
||
const card = (x, label, res) => {
|
||
objs.push(this.add.text(x, 410, label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||
objs.push(this.add.image(x, 490, 'catan-cards', RESOURCE_TYPES.indexOf(res))
|
||
.setDisplaySize(cardW, cardH).setDepth(D.panel + 1));
|
||
objs.push(this.add.text(x, 568, RESOURCE_INFO[res].label, {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||
};
|
||
card(890, 'You get', giveRes);
|
||
card(1110, 'You give', getRes);
|
||
|
||
const arrowColor = 0xffdd00;
|
||
const baseY = 490, amp = 12;
|
||
const drawArrow = (x, dir) => {
|
||
const g = this.add.graphics({ x, y: baseY }).setDepth(D.panel + 2);
|
||
g.fillStyle(arrowColor, 1);
|
||
if (dir === 'down') {
|
||
g.fillRect(-5, -22, 10, 30);
|
||
g.fillTriangle(-14, 6, 14, 6, 0, 26);
|
||
} else {
|
||
g.fillRect(-5, -8, 10, 30);
|
||
g.fillTriangle(-14, -6, 14, -6, 0, -26);
|
||
}
|
||
objs.push(g);
|
||
return g;
|
||
};
|
||
const getArrow = drawArrow(812, 'down'); // left of the card you receive
|
||
const giveArrow = drawArrow(1188, 'up'); // right of the card you give
|
||
|
||
const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||
const t2 = this.tweens.add({ targets: giveArrow, y: baseY - amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||
objs.push({ destroy: () => { t1.stop(); t2.stop(); } });
|
||
|
||
objs.push(new Button(this, 905, 640, 'Accept', () => { panel.destroy(); resolve(true); },
|
||
{ width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
|
||
objs.push(new Button(this, 1095, 640, 'Deny', () => { panel.destroy(); resolve(false); },
|
||
{ variant: 'ghost', width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
|
||
playSound(this, SFX.CARD_SHOW);
|
||
});
|
||
}
|
||
|
||
// Two cards crossing between the two seats' portraits.
|
||
animateAiTrade(fromSeat, toSeat, giveRes, getRes) {
|
||
playSound(this, SFX.CARD_PLACE);
|
||
const from = this._seatPortraitPos(fromSeat) ?? { x: 130, y: 300 };
|
||
const to = this._seatPortraitPos(toSeat) ?? { x: 130, y: 300 };
|
||
const cardW = 56, cardH = 78;
|
||
|
||
const flyCard = (res, src, dst) => new Promise((resolve) => {
|
||
const img = this.add.image(0, 0, 'catan-cards', RESOURCE_TYPES.indexOf(res)).setDisplaySize(cardW, cardH);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(3, RESOURCE_INFO[res]?.swatch ?? COLORS.accent, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner + 3);
|
||
this.tweens.add({
|
||
targets: container, x: dst.x, y: dst.y, duration: 700, ease: 'Quad.InOut',
|
||
onComplete: () => this.tweens.add({
|
||
targets: container, alpha: 0, duration: 220,
|
||
onComplete: () => { container.destroy(); resolve(); },
|
||
}),
|
||
});
|
||
});
|
||
|
||
return Promise.all([
|
||
flyCard(giveRes, from, to),
|
||
flyCard(getRes, to, from),
|
||
]);
|
||
}
|
||
|
||
// ── opponent dev card reveal ──────────────────────────────────────────────────
|
||
async animateOppDevCardPlay(seat, cardType, resource) {
|
||
const VISUAL = {
|
||
knight: { frame: 5, border: 0xb03030 },
|
||
roadBuilding: { frame: 6, border: 0x8b5a2b },
|
||
monopoly: { frame: 9, border: 0x7b2d8b },
|
||
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
|
||
};
|
||
const SPEECH = {
|
||
knight: 'catan-dev-knight',
|
||
roadBuilding: 'catan-dev-road',
|
||
monopoly: 'catan-dev-monopoly',
|
||
yearOfPlenty: 'catan-dev-year',
|
||
};
|
||
|
||
const visual = VISUAL[cardType];
|
||
if (!visual) return;
|
||
|
||
const from = this.portraitPos(seat);
|
||
const toX = 1380, toY = 240;
|
||
const W = 270, H = 390, R = 12, BW = 8;
|
||
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x111111, 0.92);
|
||
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
|
||
const img = this.add.image(0, 0, 'catan-cards', visual.frame).setDisplaySize(W - 16, H - 16);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(BW, visual.border, 1);
|
||
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
|
||
const card = this.add.container(from.x, from.y).setDepth(D.banner + 5).setScale(30 / W);
|
||
card.add([bg, img, border]);
|
||
|
||
await new Promise(resolve =>
|
||
this.tweens.add({ targets: card, x: toX, y: toY, scale: 1, duration: 500, ease: 'Back.easeOut', onComplete: resolve })
|
||
);
|
||
|
||
let resourceText = null;
|
||
let fireworksEmitter = null;
|
||
if (cardType === 'monopoly' && resource) {
|
||
const textY = toY + 138;
|
||
resourceText = this.add.text(toX, textY, resource.toUpperCase(), {
|
||
fontFamily: 'Righteous', fontSize: '40px',
|
||
color: '#ffd700', stroke: '#000000', strokeThickness: 5,
|
||
}).setOrigin(0.5, 0.5).setDepth(D.banner + 6);
|
||
|
||
fireworksEmitter = this.add.particles(toX, textY, 'catanParticle', {
|
||
speed: { min: 60, max: 190 }, lifespan: 950,
|
||
scale: { start: 1.3, end: 0 }, alpha: { start: 1, end: 0 },
|
||
quantity: 12, frequency: -1,
|
||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
|
||
angle: { min: 0, max: 360 }, gravityY: 50,
|
||
}).setDepth(D.banner + 5);
|
||
|
||
const bursts = [
|
||
{ x: toX - 90, y: textY - 10 }, { x: toX + 90, y: textY - 10 },
|
||
{ x: toX, y: textY - 28 }, { x: toX - 50, y: textY + 22 },
|
||
{ x: toX + 50, y: textY + 22 },
|
||
];
|
||
bursts.forEach((b, i) =>
|
||
this.time.delayedCall(i * 160, () => fireworksEmitter?.emitParticleAt(b.x, b.y, 14))
|
||
);
|
||
}
|
||
|
||
const speechFile = SPEECH[cardType];
|
||
await new Promise(resolve => {
|
||
if (!speechFile) { this.time.delayedCall(800, resolve); return; }
|
||
const audio = new Audio(`assets/speech/${speechFile}.mp3`);
|
||
audio.onended = resolve;
|
||
audio.onerror = resolve;
|
||
audio.play().catch(resolve);
|
||
});
|
||
|
||
const fadeTargets = resourceText ? [card, resourceText] : [card];
|
||
await new Promise(resolve =>
|
||
this.tweens.add({ targets: fadeTargets, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve })
|
||
);
|
||
|
||
card.destroy();
|
||
if (resourceText) resourceText.destroy();
|
||
if (fireworksEmitter) {
|
||
fireworksEmitter.stop();
|
||
this.time.delayedCall(950, () => fireworksEmitter.destroy());
|
||
}
|
||
}
|
||
|
||
// ── human: roll ───────────────────────────────────────────────────────────────
|
||
async onRoll() {
|
||
if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return;
|
||
this.busy = true;
|
||
this.buttons.roll.setEnabled(false);
|
||
const preGs = this.gs;
|
||
const ns = L.rollDice(this.gs);
|
||
await this.animateDice(ns.dice);
|
||
this.gs = ns;
|
||
await this.animateResourceCollection(preGs, ns);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
// ── gold picks ───────────────────────────────────────────────────────────────
|
||
async handleGoldPickPhase() {
|
||
this.busy = true;
|
||
// Process all AI entries in queue order.
|
||
for (const entry of [...this.gs.goldPickQueue]) {
|
||
if (entry.seat === 0) continue;
|
||
const picks = AI.chooseGoldPick(this.gs, entry.seat, entry.amount);
|
||
this.gs = L.resolveGoldPick(this.gs, entry.seat, picks);
|
||
}
|
||
this.renderAll();
|
||
// If human has a pick, let them choose.
|
||
const humanEntry = this.gs.goldPickQueue.find((e) => e.seat === 0);
|
||
if (humanEntry) {
|
||
this.busy = false;
|
||
this.pickResources(humanEntry.amount, `Gold Field! Choose ${humanEntry.amount} resource${humanEntry.amount > 1 ? 's' : ''}`, (rs) => {
|
||
this.gs = L.resolveGoldPick(this.gs, 0, rs);
|
||
this.advance();
|
||
});
|
||
return;
|
||
}
|
||
await this.delay(300);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
// ── human: discards ─────────────────────────────────────────────────────────
|
||
async handleDiscardPhase() {
|
||
this.busy = true;
|
||
// AI discards first.
|
||
for (const seat of [...this.gs.discardQueue]) {
|
||
if (seat === 0) continue;
|
||
this.gs = L.applyDiscard(this.gs, seat, AI.chooseDiscard(this.gs, seat));
|
||
}
|
||
this.renderAll();
|
||
if (this.gs.discardQueue.includes(0)) {
|
||
this.busy = false;
|
||
this.openDiscardPanel(); // human picks; on confirm → advance
|
||
return;
|
||
}
|
||
await this.delay(300);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
// ── human: robber ─────────────────────────────────────────────────────────────
|
||
promptRobber() {
|
||
this.clearHighlights();
|
||
for (const hex of this.gs.hexes) {
|
||
if (hex.hasRobber) continue;
|
||
const { x, y } = this.hexPos(hex.id);
|
||
this.addHighlight(x, y, async () => {
|
||
this.clearHighlights();
|
||
const targets = L.stealTargets(this.gs, hex.id, 0);
|
||
if (targets.length <= 1) {
|
||
const preRobberHex = this.gs.robberHex;
|
||
this.gs = L.moveRobber(this.gs, hex.id, targets[0] ?? null);
|
||
await this.animateRobber(preRobberHex, hex.id);
|
||
this.advance();
|
||
} else {
|
||
this.pickStealTarget(hex.id, targets);
|
||
}
|
||
}, 0x222222, 20);
|
||
}
|
||
}
|
||
|
||
pickStealTarget(hexId, targets) {
|
||
const panel = this.modalPanel(540, 'Steal from which player?');
|
||
targets.forEach((seat, i) => {
|
||
this.modalButton(panel, 1000, 480 + i * 64, `${this.pname(seat)} (${L.handSize(this.gs.players[seat])} cards)`, async () => {
|
||
panel.destroy();
|
||
const preRobberHex = this.gs.robberHex;
|
||
this.gs = L.moveRobber(this.gs, hexId, seat);
|
||
await this.animateRobber(preRobberHex, hexId);
|
||
this.advance();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── human: build modes ────────────────────────────────────────────────────────
|
||
enterPlace(type) {
|
||
if (this.busy || this.gs.phase !== 'action' || this.gs.currentPlayer !== 0) return;
|
||
this.clearHighlights();
|
||
this.placeMode = type;
|
||
const s = this.gs;
|
||
if (type === 'road') {
|
||
for (const eid of L.legalRoadEdges(s, 0, false)) {
|
||
const { x, y } = this.edgePos(eid);
|
||
this.addHighlight(x, y, () => this.doBuild('road', eid), COLORS.gold, 13);
|
||
}
|
||
} else if (type === 'settlement') {
|
||
for (const nid of L.legalSettlementNodes(s, 0, false)) {
|
||
const { x, y } = this.nodePos(nid);
|
||
this.addHighlight(x, y, () => this.doBuild('settlement', nid));
|
||
}
|
||
} else if (type === 'city') {
|
||
for (const nid of L.legalCityNodes(s, 0)) {
|
||
const { x, y } = this.nodePos(nid);
|
||
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700);
|
||
}
|
||
} else if (type === 'ship') {
|
||
for (const eid of L.legalShipEdges(s, 0)) {
|
||
const { x, y } = this.edgePos(eid);
|
||
this.addHighlight(x, y, () => this.doBuild('ship', eid), COLORS.gold, 13);
|
||
}
|
||
}
|
||
this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
|
||
}
|
||
|
||
async doBuild(type, id) {
|
||
this.busy = true;
|
||
this.clearHighlights();
|
||
this.placeMode = null;
|
||
const dest = (type === 'road' || type === 'ship') ? this.edgePos(id) : this.nodePos(id);
|
||
enqueueSpeech(`catan-purchase-${type}`);
|
||
await this.animateCostPayment(0, type);
|
||
await this.animatePiecePlacement(0, type, dest.x, dest.y);
|
||
const prevGs = this.gs;
|
||
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
|
||
if (type === 'settlement') this.gs = L.buildSettlement(this.gs, 0, id);
|
||
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
|
||
if (type === 'ship') this.gs = L.buildShip(this.gs, 0, id);
|
||
// Redraw board if any fog hexes were revealed by this road/ship.
|
||
if (type === 'road' || type === 'ship') {
|
||
const revealed = this.gs.hexes.some((h, i) => prevGs.hexes[i].kind === 'fog' && h.kind !== 'fog');
|
||
if (revealed) { this.drawHexes(); this.drawChits(); }
|
||
}
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
async onBuyDev() {
|
||
if (this.busy || this.gs.phase !== 'action') return;
|
||
this.busy = true;
|
||
this.clearHighlights(); this.placeMode = null;
|
||
enqueueSpeech('catan-purchase-development-card');
|
||
await this.animateCostPayment(0, 'devCard');
|
||
playSound(this, SFX.CARD_DEAL);
|
||
await this.animateDevCardFromBank(0);
|
||
this.gs = L.buyDevCard(this.gs, 0);
|
||
this.busy = false;
|
||
this.advance();
|
||
}
|
||
|
||
onEndTurn() {
|
||
if (this.busy || this.gs.phase !== 'action') return;
|
||
this.clearHighlights();
|
||
this.placeMode = null;
|
||
this.gs = L.endTurn(this.gs);
|
||
this.advance();
|
||
}
|
||
|
||
// ── dev card menu ─────────────────────────────────────────────────────────────
|
||
openDevMenu() {
|
||
if (this.busy || this.gs.phase !== 'action') return;
|
||
this.clearHighlights(); this.placeMode = null;
|
||
const playable = [...new Set(this.gs.players[0].devCards.filter((c) => c !== 'vp'))];
|
||
if (!playable.length) return;
|
||
const panel = this.modalPanel(560, 'Play a development card');
|
||
playable.forEach((card, i) => {
|
||
this.modalButton(panel, 1000, 500 + i * 64, DEV_INFO[card].label, () => {
|
||
panel.destroy();
|
||
this.playHumanDev(card);
|
||
});
|
||
});
|
||
this.modalButton(panel, 1000, 500 + playable.length * 64, 'Cancel', () => panel.destroy(), 'ghost');
|
||
}
|
||
|
||
playHumanDev(card) {
|
||
if (card === 'knight') {
|
||
this.gs = L.playKnight(this.gs, 0);
|
||
this.advance(); // phase becomes moveRobber → promptRobber
|
||
} else if (card === 'roadBuilding') {
|
||
this.gs = L.playRoadBuilding(this.gs, 0);
|
||
this.advance();
|
||
} else if (card === 'monopoly') {
|
||
this.pickResources(1, 'Monopolize which resource?', (rs) => {
|
||
this.gs = L.playMonopoly(this.gs, 0, rs[0]);
|
||
this.advance();
|
||
});
|
||
} else if (card === 'yearOfPlenty') {
|
||
this.pickResources(2, 'Choose 2 resources', (rs) => {
|
||
this.gs = L.playYearOfPlenty(this.gs, 0, rs[0], rs[1]);
|
||
this.advance();
|
||
});
|
||
}
|
||
}
|
||
|
||
// pick `count` resources (with repetition) then callback
|
||
pickResources(count, title, cb) {
|
||
const chosen = [];
|
||
const panel = this.modalPanel(540, title);
|
||
const label = this.add.text(1000, 430, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
panel.add(label);
|
||
const refresh = () => label.setText(chosen.map((r) => RESOURCE_INFO[r].label).join(', ') || '—');
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
this.modalButton(panel, 1000, 490 + i * 58, RESOURCE_INFO[r].label, () => {
|
||
chosen.push(r); refresh();
|
||
if (chosen.length >= count) { panel.destroy(); label.destroy(); cb(chosen); }
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── trade panel (bank / port / player offer) ───────────────────────────────────
|
||
openTradePanel() {
|
||
if (this.busy || this.gs.phase !== 'action') return;
|
||
this.clearHighlights();
|
||
const give = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||
const get = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel);
|
||
const box = this.add.rectangle(1000, 480, 920, 580, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel);
|
||
const title = this.add.text(1000, 240, 'Trade', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const hintGive = this.add.text(760, 308, 'You give', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const hintGet = this.add.text(1240, 308, 'You get', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const objs = [overlay, box, title, hintGive, hintGet];
|
||
|
||
const stepper = (col, r, i, side) => {
|
||
const y = 355 + i * 60;
|
||
const bag = side === 'give' ? give : get;
|
||
|
||
const lbl = this.add.text(col - 165, y, RESOURCE_INFO[r].label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||
|
||
const val = this.add.text(col + 20, y, '0', {
|
||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.panel + 1);
|
||
|
||
const minusBtn = new Button(this, col - 46, y, '−', () => {
|
||
if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); }
|
||
}, { width: 52, height: 44, fontSize: 28, textColor: COLORS.dangerHex, bgHover: COLORS.danger, textHoverColor: '#ffffff' })
|
||
.setDepth(D.panel + 1);
|
||
|
||
const plusBtn = new Button(this, col + 82, y, '+', () => {
|
||
if (side === 'give' && bag[r] >= this.gs.players[0].resources[r]) return;
|
||
bag[r]++; val.setText(String(bag[r]));
|
||
}, { width: 52, height: 44, fontSize: 28, textColor: COLORS.goldHex })
|
||
.setDepth(D.panel + 1);
|
||
|
||
objs.push(lbl, val, minusBtn, plusBtn);
|
||
};
|
||
RESOURCE_TYPES.forEach((r, i) => { stepper(760, r, i, 'give'); stepper(1240, r, i, 'get'); });
|
||
|
||
const close = () => objs.forEach((o) => o.destroy());
|
||
|
||
const bankBtn = new Button(this, 850, 658, 'Bank / Port', () => {
|
||
const gKeys = RESOURCE_TYPES.filter((r) => give[r] > 0);
|
||
const tKeys = RESOURCE_TYPES.filter((r) => get[r] > 0);
|
||
if (gKeys.length === 1 && tKeys.length === 1 && get[tKeys[0]] === 1) {
|
||
const r = gKeys[0];
|
||
if (give[r] === L.bestTradeRatio(this.gs, 0, r)) {
|
||
close();
|
||
this.gs = L.tradeWithBank(this.gs, 0, r, tKeys[0]);
|
||
playSound(this, SFX.CHIP_BET);
|
||
this.advance();
|
||
return;
|
||
}
|
||
}
|
||
this.flashStatus('Bank trade needs N of one resource for 1 of another (N = your ratio).');
|
||
}, { width: 260, height: 48 }).setDepth(D.panel + 1);
|
||
|
||
const offerBtn = new Button(this, 1160, 658, 'Offer to Players', () => {
|
||
const gCount = RESOURCE_TYPES.reduce((s, r) => s + give[r], 0);
|
||
const tCount = RESOURCE_TYPES.reduce((s, r) => s + get[r], 0);
|
||
if (!gCount || !tCount) { this.flashStatus('Set what you give and get.'); return; }
|
||
let accepted = null;
|
||
for (let seat = 1; seat < this.gs.playerCount; seat++) {
|
||
// AI gives `get` (what we want), receives `give` (what we offer).
|
||
if (AI.respondToTrade(this.gs, seat, get, give, 0)) { accepted = seat; break; }
|
||
}
|
||
if (accepted == null) { this.flashStatus('No opponent accepted that offer.'); return; }
|
||
close();
|
||
this.gs = L.executePlayerTrade(this.gs, 0, accepted, give, get);
|
||
playSound(this, SFX.CARD_PLACE);
|
||
this.flashStatus(`${this.pname(accepted)} accepted the trade.`);
|
||
this.advance();
|
||
}, { width: 300, height: 48 }).setDepth(D.panel + 1);
|
||
|
||
const cancelBtn = new Button(this, 1000, 726, 'Cancel', () => close(), { variant: 'ghost', width: 160, height: 44 }).setDepth(D.panel + 1);
|
||
objs.push(bankBtn, offerBtn, cancelBtn);
|
||
}
|
||
|
||
// ── discard panel ───────────────────────────────────────────────────────────
|
||
openDiscardPanel() {
|
||
const need = L.discardAmount(this.gs.players[0]);
|
||
const discard = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel);
|
||
const box = this.add.rectangle(1000, 470, 720, 460, COLORS.panel, 1).setStrokeStyle(3, COLORS.danger).setDepth(D.panel);
|
||
const title = this.add.text(1000, 280, `Discard ${need} cards`, { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.dangerHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const counter = this.add.text(1000, 330, `0 / ${need}`, { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const objs = [overlay, box, title, counter];
|
||
const sum = () => RESOURCE_TYPES.reduce((s, r) => s + discard[r], 0);
|
||
const refresh = () => { counter.setText(`${sum()} / ${need}`); confirmBtn.setEnabled(sum() === need); };
|
||
|
||
RESOURCE_TYPES.forEach((r, i) => {
|
||
const x = 760 + i * 120, y = 430;
|
||
const g = this.add.graphics().setDepth(D.panel + 1);
|
||
g.fillStyle(RESOURCE_INFO[r].swatch, 1); g.fillRoundedRect(x - 40, y - 34, 80, 68, 8);
|
||
const have = this.add.text(x, y - 10, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#1a1208' }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const val = this.add.text(x, y + 12, '0', { fontFamily: 'Righteous', fontSize: '20px', color: '#1a1208' }).setOrigin(0.5).setDepth(D.panel + 1);
|
||
const minus = this.add.text(x - 22, y + 60, '−', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
|
||
const plus = this.add.text(x + 22, y + 60, '+', { fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
|
||
minus.on('pointerdown', () => { if (discard[r] > 0) { discard[r]--; val.setText(String(discard[r])); refresh(); } });
|
||
plus.on('pointerdown', () => { if (discard[r] < this.gs.players[0].resources[r] && sum() < need) { discard[r]++; val.setText(String(discard[r])); refresh(); } });
|
||
objs.push(g, have, val, minus, plus);
|
||
});
|
||
|
||
const confirmBtn = new Button(this, 1000, 640, 'Discard', () => {
|
||
if (sum() !== need) return;
|
||
objs.forEach((o) => o.destroy()); confirmBtn.destroy();
|
||
this.gs = L.applyDiscard(this.gs, 0, discard);
|
||
this.handleDiscardPhase();
|
||
}, { width: 200, height: 48 }).setDepth(D.panel + 1);
|
||
confirmBtn.setEnabled(false);
|
||
objs.push(confirmBtn);
|
||
}
|
||
|
||
// ── modal helpers ───────────────────────────────────────────────────────────
|
||
modalPanel(topY, title) {
|
||
const objs = [];
|
||
objs.push(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive().setDepth(D.panel));
|
||
objs.push(this.add.rectangle(1000, topY, 460, 420, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(D.panel));
|
||
objs.push(this.add.text(1000, topY - 170, title, { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, wordWrap: { width: 420 }, align: 'center' }).setOrigin(0.5).setDepth(D.panel + 1));
|
||
return {
|
||
objs,
|
||
add: (scene) => null,
|
||
destroy() { objs.forEach((o) => o.destroy()); },
|
||
};
|
||
}
|
||
modalButton(panel, x, y, label, fn, variant = 'solid') {
|
||
const b = new Button(this, x, y, label, fn, { variant, width: 340, height: 50, fontSize: 20 }).setDepth(D.panel + 1);
|
||
panel.objs.push(b);
|
||
return b;
|
||
}
|
||
|
||
flashStatus(msg) {
|
||
this.statusText.setText(msg);
|
||
}
|
||
|
||
_notifyStolenCard(resource, robberName, thiefSeat) {
|
||
playSound(this, SFX.CASINO_LOSE);
|
||
|
||
// Card animation: grow + spin, then fly to thief portrait
|
||
const frameIdx = RESOURCE_TYPES.indexOf(resource);
|
||
const cardW = 60, cardH = 84;
|
||
let srcX = 220, srcY = 950;
|
||
for (let i = 0; i < this.handDisplay.length; i++) {
|
||
if (this.handDisplay[i] === resource && this.handCardObjs[i]) {
|
||
srcX = this.handCardObjs[i].x;
|
||
srcY = this.handCardObjs[i].y;
|
||
break;
|
||
}
|
||
}
|
||
const destPos = this._seatPortraitPos(thiefSeat) ?? { x: 130, y: 300 };
|
||
const img = this.add.image(0, 0, 'catan-cards', frameIdx).setDisplaySize(cardW, cardH);
|
||
const border = this.add.graphics();
|
||
border.lineStyle(3, RESOURCE_INFO[resource]?.swatch ?? COLORS.accent, 1);
|
||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||
const container = this.add.container(srcX, srcY, [img, border]).setDepth(D.banner + 3);
|
||
this.tweens.add({
|
||
targets: container, scaleX: 2, scaleY: 2, angle: 720,
|
||
duration: 1000, ease: 'Quad.InOut',
|
||
onComplete: () => {
|
||
this.tweens.add({
|
||
targets: container, x: destPos.x, y: destPos.y, scaleX: 1, scaleY: 1,
|
||
duration: 800, ease: 'Quad.InOut',
|
||
onComplete: () => this.tweens.add({
|
||
targets: container, alpha: 0, duration: 280,
|
||
onComplete: () => container.destroy(),
|
||
}),
|
||
});
|
||
},
|
||
});
|
||
|
||
// Text notification runs simultaneously
|
||
const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase();
|
||
const txt = this.add.text(GAME_WIDTH / 2, 855,
|
||
`CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, {
|
||
fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700',
|
||
stroke: '#000000', strokeThickness: 5,
|
||
}
|
||
).setOrigin(0.5).setDepth(D.banner + 2);
|
||
this.tweens.add({
|
||
targets: txt, alpha: 0, delay: 3000, duration: 500,
|
||
onComplete: () => txt.destroy(),
|
||
});
|
||
}
|
||
|
||
showTurnBanner(text) {
|
||
const banner = this.add.text(1000, 120, text, {
|
||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||
backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
|
||
}).setOrigin(0.5).setDepth(D.banner);
|
||
banner.setAlpha(0);
|
||
this.tweens.add({
|
||
targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
|
||
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() }))
|
||
});
|
||
}
|
||
|
||
// ── game over ─────────────────────────────────────────────────────────────────
|
||
onGameOver() {
|
||
this.clearHighlights();
|
||
const winner = this.gs.winner;
|
||
const isHuman = winner === 0;
|
||
this.recordHistory();
|
||
|
||
const PW = 760, PH = 660, PX = 1000, PY = 540;
|
||
const titleY = PY - PH / 2 + 70; // 280
|
||
const RADIUS = 80;
|
||
const portraitY = titleY + 140; // 420
|
||
const bodyY = portraitY + RADIUS + 95; // 595
|
||
const buttonsY = PY + PH / 2 - 72; // 798
|
||
|
||
// Fireworks across the popup for all winners
|
||
const fwEmitter = this.add.particles(PX, PY, 'catanParticle', {
|
||
speed: { min: 80, max: 480 }, lifespan: 1400,
|
||
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
|
||
quantity: 3, frequency: 35,
|
||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
|
||
angle: { min: 0, max: 360 },
|
||
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-PW / 2, -PH / 2, PW, PH) },
|
||
}).setDepth(D.banner + 8);
|
||
this.time.delayedCall(3200, () => {
|
||
fwEmitter.stop();
|
||
this.time.delayedCall(1400, () => fwEmitter.destroy());
|
||
});
|
||
|
||
const overlay = this.add.rectangle(PX, PY, PW, PH, 0x0a0e14, 0.94)
|
||
.setStrokeStyle(3, COLORS.accent).setDepth(D.banner);
|
||
|
||
const title = this.add.text(PX, titleY, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, {
|
||
fontFamily: 'Righteous', fontSize: '44px', color: isHuman ? '#ffd700' : COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
||
|
||
// Portrait backing circle
|
||
const backingG = this.add.graphics().setDepth(D.banner + 1);
|
||
backingG.fillStyle(0x1a1a2e, 1);
|
||
backingG.fillCircle(PX, portraitY, RADIUS + 3);
|
||
backingG.fillStyle(COLORS.panel, 1);
|
||
backingG.fillCircle(PX, portraitY, RADIUS + 1);
|
||
|
||
const size = RADIUS * 2;
|
||
let portraitDom = null;
|
||
let fallbackSprite = null;
|
||
let avatarActive = true;
|
||
|
||
if (!isHuman) {
|
||
// AI winner: sprite fallback behind, happy video on top
|
||
const opp = this.opponents[winner - 1];
|
||
if (opp?.id) {
|
||
if (this.textures.exists('opponents')) {
|
||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||
maskG.fillStyle(0xffffff);
|
||
maskG.fillCircle(PX, portraitY, RADIUS);
|
||
fallbackSprite = this.add.image(PX, portraitY, 'opponents', opp.spriteIndex ?? 0)
|
||
.setDisplaySize(size, size)
|
||
.setMask(maskG.createGeometryMask())
|
||
.setDepth(D.banner + 2);
|
||
}
|
||
const videoEl = document.createElement('video');
|
||
videoEl.muted = true;
|
||
videoEl.loop = true;
|
||
videoEl.playsInline = true;
|
||
videoEl.autoplay = true;
|
||
videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
|
||
videoEl.src = `assets/videos/${opp.id}-happy.mp4`;
|
||
videoEl.play().catch(() => { });
|
||
videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true });
|
||
portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3);
|
||
}
|
||
} else {
|
||
// Human winner: canvas initial placeholder, replaced by avatar if available
|
||
const canvasEl = document.createElement('canvas');
|
||
canvasEl.width = size; canvasEl.height = size;
|
||
canvasEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;display:block;`;
|
||
const ctx = canvasEl.getContext('2d');
|
||
const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase();
|
||
ctx.fillStyle = '#1a1a2e';
|
||
ctx.fillRect(0, 0, size, size);
|
||
ctx.fillStyle = COLORS.accentHex;
|
||
ctx.font = `bold ${Math.round(RADIUS * 0.9)}px "Julius Sans One", sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(initial, size / 2, size / 2);
|
||
portraitDom = this.add.dom(PX, portraitY, canvasEl).setDepth(D.banner + 3);
|
||
|
||
(async () => {
|
||
try {
|
||
const { profile } = await api.get('/profile');
|
||
if (!avatarActive || !profile?.avatarPath) return;
|
||
const imgEl = document.createElement('img');
|
||
imgEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
|
||
await new Promise((res, rej) => { imgEl.onload = res; imgEl.onerror = rej; imgEl.src = profile.avatarPath; });
|
||
if (!avatarActive) return;
|
||
canvasEl.style.display = 'none';
|
||
this.add.dom(PX, portraitY, imgEl).setDepth(D.banner + 3);
|
||
} catch { /* keep initial placeholder */ }
|
||
})();
|
||
}
|
||
|
||
const lines = this.gs.players
|
||
.map((p, i) => `${this.pname(i)}: ${L.victoryPoints(this.gs, i)} VP`)
|
||
.join('\n');
|
||
const body = this.add.text(PX, bodyY, lines, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
||
}).setOrigin(0.5).setDepth(D.banner + 1);
|
||
|
||
const cleanup = () => {
|
||
avatarActive = false;
|
||
overlay.destroy(); title.destroy(); body.destroy(); backingG.destroy();
|
||
if (fallbackSprite) fallbackSprite.destroy();
|
||
if (portraitDom) portraitDom.destroy();
|
||
playAgain.destroy(); leave.destroy();
|
||
};
|
||
const playAgain = new Button(this, PX - 110, buttonsY, 'Play Again', () => {
|
||
cleanup(); this.startNewMatch();
|
||
}, { width: 200, fontSize: 22 }).setDepth(D.banner + 1);
|
||
const leave = new Button(this, PX + 110, buttonsY, 'Leave', () => {
|
||
cleanup(); this.scene.start('GameMenu');
|
||
}, { variant: 'ghost', width: 200, fontSize: 22 }).setDepth(D.banner + 1);
|
||
}
|
||
|
||
async recordHistory() {
|
||
const totals = this.gs.players.map((_, i) => L.victoryPoints(this.gs, i));
|
||
const result = this.gs.winner === 0 ? 'win' : 'loss';
|
||
try {
|
||
await api.post('/history/single-player', {
|
||
slug: 'catan', score: totals[0], opponentScores: totals.slice(1), result,
|
||
});
|
||
} catch (_) { /* offline / not signed in — ignore */ }
|
||
}
|
||
|
||
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
|
||
}
|