feat(catan): implement Seafarers expansion features (gold hexes, ships, fog)

- Add gold hex resource picking phase with AI support
- Implement ship building mechanics and update build costs UI
- Introduce fog tiles that reveal terrain when roads/ships are built adjacent
- Replace robber token with pirate ship graphic
- Persist random tile frames for consistent hex visuals
- Update game state machine to handle gold pick queue and phase transitions
- Adjust UI layout (card positions, build panel) to accommodate Seafarers elements
This commit is contained in:
Brian Fertig 2026-05-29 16:13:53 -06:00
parent c5f34b7c28
commit 9dbf3feae4
7 changed files with 279 additions and 150 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -86,6 +86,15 @@ export function chooseDiscard(state, seat) {
return discard; return discard;
} }
// Pick `amount` free resources from a gold hex — prioritise what is under-produced.
export function chooseGoldPick(state, seat, amount) {
const prod = productionByResource(state, seat);
const order = [...RESOURCE_TYPES].sort((a, b) => prod[a] - prod[b]);
const picks = [];
for (let i = 0; i < amount; i++) picks.push(order[i % order.length]);
return picks;
}
export function chooseRobberMove(state, seat) { export function chooseRobberMove(state, seat) {
let best = null, bestScore = -Infinity, bestTarget = null; let best = null, bestScore = -Infinity, bestTarget = null;
const geo = geoFor(state); const geo = geoFor(state);

View File

@ -91,9 +91,9 @@ export default class CatanGame extends Phaser.Scene {
// Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80) // Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80)
const ro = 0x07, go = 0x24, bo = 0x3a; const ro = 0x07, go = 0x24, bo = 0x3a;
const ri = 0x1a, gi = 0x5e, bi = 0x80; const ri = 0x1a, gi = 0x5e, bi = 0x80;
const red = Math.round(ro + (ri - ro) * (1 - t)); const red = Math.round(ro + (ri - ro) * (1 - t));
const green = Math.round(go + (gi - go) * (1 - t)); const green = Math.round(go + (gi - go) * (1 - t));
const blue = Math.round(bo + (bi - bo) * (1 - t)); const blue = Math.round(bo + (bi - bo) * (1 - t));
const color = (red << 16) | (green << 8) | blue; const color = (red << 16) | (green << 8) | blue;
sea.fillStyle(color, 1); sea.fillStyle(color, 1);
sea.fillCircle(SEA_X, SEA_Y, r); sea.fillCircle(SEA_X, SEA_Y, r);
@ -113,12 +113,15 @@ export default class CatanGame extends Phaser.Scene {
// Frame pairs per resource: pick one at random each draw. // Frame pairs per resource: pick one at random each draw.
static TILE_FRAMES = { static TILE_FRAMES = {
lumber: [0, 1], lumber: [0, 1],
wool: [2, 3], wool: [2, 3],
brick: [4, 5], brick: [4, 5],
ore: [6, 7], ore: [6, 7],
grain: [8, 9], grain: [8, 9],
desert: [10, 11], desert: [10, 11],
sea: [12, 13],
gold: [14, 15],
fog: [16, 17],
}; };
drawHexes() { drawHexes() {
@ -147,7 +150,7 @@ export default class CatanGame extends Phaser.Scene {
const terr = this.hexTerrain(hex); const terr = this.hexTerrain(hex);
// Scale factors for the 7px colored ring + 4px dark ring (absolute pixels). // Scale factors for the 7px colored ring + 4px dark ring (absolute pixels).
const s1 = 1 - 7 / inradius; // after colored border const s1 = 1 - 7 / inradius; // after colored border
const s2 = 1 - 11 / inradius; // after dark border (image area) const s2 = 1 - 11 / inradius; // after dark border (image area)
const innerPts = inset(pts, x, y, s1); const innerPts = inset(pts, x, y, s1);
const imagePts = inset(pts, x, y, s2); const imagePts = inset(pts, x, y, s2);
@ -162,7 +165,10 @@ export default class CatanGame extends Phaser.Scene {
// Layer 3: tile image masked to innermost polygon (land/desert only) // Layer 3: tile image masked to innermost polygon (land/desert only)
if (terr.tileFrames && this.textures.exists('catan-tiles')) { if (terr.tileFrames && this.textures.exists('catan-tiles')) {
const frame = terr.tileFrames[Math.floor(Math.random() * 2)]; 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 }); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff); maskG.fillStyle(0xffffff);
maskG.fillPoints(imagePts, true); maskG.fillPoints(imagePts, true);
@ -192,11 +198,11 @@ export default class CatanGame extends Phaser.Scene {
hexTerrain(hex) { hexTerrain(hex) {
switch (hex.kind) { switch (hex.kind) {
case 'sea': case 'sea':
return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: null }; return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: CatanGame.TILE_FRAMES.sea };
case 'gold': case 'gold':
return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: null }; return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: CatanGame.TILE_FRAMES.gold };
case 'fog': case 'fog':
return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: null }; return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: CatanGame.TILE_FRAMES.fog };
case 'desert': case 'desert':
return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert }; return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert };
default: { default: {
@ -307,11 +313,11 @@ export default class CatanGame extends Phaser.Scene {
return new Promise((resolve) => { return new Promise((resolve) => {
playSound(this, SFX.DICE_ROLL); playSound(this, SFX.DICE_ROLL);
const landX = [1256, 1324]; const landX = [1256, 1324];
const landY = 950; const landY = 950;
const startX = GAME_WIDTH / 2; // 960 — center of bottom bar const startX = GAME_WIDTH / 2; // 960 — center of bottom bar
const startY = 1015; const startY = 1015;
const arcY = 755; // arc peak const arcY = 755; // arc peak
// Move dice to throw origin, small, random angle // Move dice to throw origin, small, random angle
this.diceContainers.forEach((c, i) => { this.diceContainers.forEach((c, i) => {
@ -334,17 +340,19 @@ export default class CatanGame extends Phaser.Scene {
let settled = 0; let settled = 0;
this.diceContainers.forEach((c, i) => { this.diceContainers.forEach((c, i) => {
const lx = landX[i] + (Math.random() * 8 - 4); const lx = landX[i] + (Math.random() * 8 - 4);
const ly = landY + (Math.random() * 8 - 4); const ly = landY + (Math.random() * 8 - 4);
const outMs = 295 + i * 32; const outMs = 295 + i * 32;
const backMs = 430 + i * 44; const backMs = 430 + i * 44;
const totalMs = outMs + backMs; const totalMs = outMs + backMs;
// X flies straight to landing; Y arcs up then bounces down // 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.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' });
this.tweens.chain({ targets: c, tweens: [ this.tweens.chain({
{ y: arcY, duration: outMs, ease: 'Quad.Out' }, targets: c, tweens: [
{ y: ly, duration: backMs, ease: 'Bounce.Out' }, { y: arcY, duration: outMs, ease: 'Quad.Out' },
]}); { y: ly, duration: backMs, ease: 'Bounce.Out' },
]
});
// Scale up as die approaches // Scale up as die approaches
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' }); this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
// Spin // Spin
@ -368,7 +376,7 @@ export default class CatanGame extends Phaser.Scene {
this.diceContainers.forEach((dc) => this.diceContainers.forEach((dc) =>
this.tweens.add({ targets: dc, scaleX: 1.14, scaleY: 0.88, duration: 80, yoyo: true }) 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']; const numberWords = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
enqueueSpeech(`numbers-${numberWords[values[0] + values[1]]}`); enqueueSpeech(`numbers-${numberWords[values[0] + values[1]]}`);
this.time.delayedCall(160, resolve); this.time.delayedCall(160, resolve);
} }
@ -421,6 +429,7 @@ export default class CatanGame extends Phaser.Scene {
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; }; 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('roll', 'Roll Dice', () => this.onRoll());
mk('road', 'Build Road', () => this.enterPlace('road')); 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('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
mk('city', 'Build City', () => this.enterPlace('city')); mk('city', 'Build City', () => this.enterPlace('city'));
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev()); mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
@ -451,8 +460,11 @@ export default class CatanGame extends Phaser.Scene {
return c; return c;
}; };
const cardY = this.expansion === 'seafarers' ? 830 : 760;
this._lrCard = makeCard(1775, 0, 0xdaa520); this._lrCard = makeCard(1775, 0, 0xdaa520);
this._laCard = makeCard(1855, 1, 0xb03030); this._laCard = makeCard(1855, 1, 0xb03030);
this._lrCard.setY(cardY);
this._laCard.setY(cardY);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null }; this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false }; this._specialCardAnimating = { longestRoad: false, largestArmy: false };
@ -480,9 +492,10 @@ export default class CatanGame extends Phaser.Scene {
_getSpecialCardPos(cardType, owner) { _getSpecialCardPos(cardType, owner) {
if (owner === null) { if (owner === null) {
const cardY = this.expansion === 'seafarers' ? 830 : 760;
return cardType === 'longestRoad' return cardType === 'longestRoad'
? { x: 1775, y: 760, scale: 1 } ? { x: 1775, y: cardY, scale: 1 }
: { x: 1855, y: 760, scale: 1 }; : { x: 1855, y: cardY, scale: 1 };
} }
if (owner === 0) { if (owner === 0) {
return cardType === 'longestRoad' return cardType === 'longestRoad'
@ -518,23 +531,26 @@ export default class CatanGame extends Phaser.Scene {
_animateSpecialCardTransfer(cardType, fromPos, newOwner) { _animateSpecialCardTransfer(cardType, fromPos, newOwner) {
const toPos = this._getSpecialCardPos(cardType, newOwner); const toPos = this._getSpecialCardPos(cardType, newOwner);
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard; const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale); card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale);
this._specialCardAnimating[cardType] = true; this._specialCardAnimating[cardType] = true;
const peakY = Math.min(fromPos.y, toPos.y) - 150; const peakY = Math.min(fromPos.y, toPos.y) - 150;
const midX = (fromPos.x + toPos.x) / 2; const midX = (fromPos.x + toPos.x) / 2;
const midScale = (fromPos.scale + toPos.scale) / 2; const midScale = (fromPos.scale + toPos.scale) / 2;
const half = 380; const half = 380;
this.tweens.chain({ targets: card, tweens: [ this.tweens.chain({
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' }, targets: card, tweens: [
{ x: toPos.x, y: toPos.y, scale: toPos.scale, { x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
duration: half, ease: 'Quad.In', {
onComplete: () => { this._specialCardAnimating[cardType] = false; }, 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'); enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
} }
@ -595,20 +611,20 @@ export default class CatanGame extends Phaser.Scene {
}).setOrigin(0.5); }).setOrigin(0.5);
this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt]) this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
.setDepth(D.panel + 5); .setDepth(D.panel + 5);
this._devTooltip.gfx = g; this._devTooltip.gfx = g;
this._devTooltip.titleTxt = titleTxt; this._devTooltip.titleTxt = titleTxt;
this._devTooltip.descTxt = descTxt; this._devTooltip.descTxt = descTxt;
this._devTooltip.popW = popW; this._devTooltip.popW = popW;
this._devTooltip.popH = popH; this._devTooltip.popH = popH;
this._devTooltip.popR = popR; this._devTooltip.popR = popR;
} }
showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) { showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) {
const DEV_DESC = { const DEV_DESC = {
knight: 'Move the Robber to any tile and steal a resource from a player there.', 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.', 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.', 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.', 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.', yearOfPlenty: 'Take any 2 resources of your choice directly from the bank.',
}; };
const tt = this._devTooltip; const tt = this._devTooltip;
@ -676,13 +692,6 @@ export default class CatanGame extends Phaser.Scene {
const panelRight = 1900; const panelRight = 1900;
const panelW = 320; const panelW = 320;
const cx = panelRight - panelW / 2; const cx = panelRight - panelW / 2;
const bgCy = 980, bgH = 164;
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0));
const rows = [ const rows = [
{ name: 'Road', resources: ['brick', 'lumber'] }, { name: 'Road', resources: ['brick', 'lumber'] },
@ -690,12 +699,27 @@ export default class CatanGame extends Phaser.Scene {
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] }, { name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] }, { name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
]; ];
if (this.expansion === 'seafarers') {
rows.push({ name: 'Ship', resources: ['lumber', 'wool'] });
}
// Base: original fixed values. Seafarers: taller panel to fit 5th row, still inside bar (y 8931080).
const seafarers = this.expansion === 'seafarers';
const bgH = seafarers ? 180 : 164;
const bgCy = seafarers ? 987 : 980;
const rowPad = 44;
const rowStep = (bgH - rowPad - 14) / (rows.length - 1);
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
panel.add(this.add.text(cx, bgCy - bgH / 2 + 7, 'Build Costs', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0));
const lx = panelRight - panelW + 14; const lx = panelRight - panelW + 14;
const rx = panelRight - 14; const rx = panelRight - 14;
const rowY0 = bgCy - bgH / 2 + 44; const rowY0 = bgCy - bgH / 2 + rowPad;
const rowStep = (bgH - 44 - 14) / (rows.length - 1); const SW = 16, SH = 16, SG = 4, SR = 3;
const SW = 16, SH = 16, SG = 4, SR = 3; // swatch w/h/gap/radius
const g = this.add.graphics(); const g = this.add.graphics();
panel.add(g); panel.add(g);
@ -722,8 +746,8 @@ export default class CatanGame extends Phaser.Scene {
const panelX = 1489, panelW = 200, panelH = 632; const panelX = 1489, panelW = 200, panelH = 632;
// Centre vertically in the playfield zone above the bottom bar (y=10..882) // Centre vertically in the playfield zone above the bottom bar (y=10..882)
const panelY = 10 + Math.round((872 - panelH) / 2); // 130 const panelY = 10 + Math.round((872 - panelH) / 2); // 130
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126 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 textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
const panelCx = panelX + panelW / 2; // for BANK title const panelCx = panelX + panelW / 2; // for BANK title
const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4; const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4;
@ -732,7 +756,7 @@ export default class CatanGame extends Phaser.Scene {
// Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top // Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top
const step = 96; const step = 96;
const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step); const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step);
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
this.bankCardPos = {}; this.bankCardPos = {};
RESOURCE_TYPES.forEach((r, i) => { RESOURCE_TYPES.forEach((r, i) => {
@ -870,9 +894,9 @@ export default class CatanGame extends Phaser.Scene {
const radius = seat === 0 ? 64 : 56; const radius = seat === 0 ? 64 : 56;
const label = this.add.text(dst.x + radius + 10, dst.y, const label = this.add.text(dst.x + radius + 10, dst.y,
RESOURCE_INFO[resource].label.toUpperCase(), { RESOURCE_INFO[resource].label.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700', fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3, stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.banner); }).setOrigin(0, 0.5).setDepth(D.banner);
this.tweens.add({ this.tweens.add({
targets: label, alpha: 0, y: dst.y - 24, targets: label, alpha: 0, y: dst.y - 24,
duration: 700, delay: 300, duration: 700, delay: 300,
@ -1015,7 +1039,7 @@ export default class CatanGame extends Phaser.Scene {
const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y; 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(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(12, col.hexDark, 1); 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); g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
this.pieceObjs.push(g); this.pieceObjs.push(g);
} }
// ships (Seafarers): dashed maritime route in the player's colour // ships (Seafarers): dashed maritime route in the player's colour
@ -1033,21 +1057,18 @@ export default class CatanGame extends Phaser.Scene {
// pirate (Seafarers): a sea-robber token on its hex // pirate (Seafarers): a sea-robber token on its hex
if (this.gs.pirateHex != null) { if (this.gs.pirateHex != null) {
const { x, y } = this.hexPos(this.gs.pirateHex); const { x, y } = this.hexPos(this.gs.pirateHex);
const pg = this.add.graphics().setDepth(D.robber); this.pieceObjs.push(
pg.fillStyle(0x000000, 0.45); pg.fillCircle(x + 2, y + 3, 20); this.add.image(x, y, 'catan-pirate').setOrigin(0.5).setDisplaySize(48, 48).setDepth(D.robber)
pg.fillStyle(0x1b1b1b, 1); pg.fillCircle(x, y, 18); );
pg.lineStyle(3, 0xe8e4d8, 1); pg.strokeCircle(x, y, 18);
this.pieceObjs.push(pg);
this.pieceObjs.push(this.add.text(x, y, '☠', { fontSize: '22px', color: '#e8e4d8' }).setOrigin(0.5).setDepth(D.robber));
} }
} }
// A ship piece: a thick coloured bar along the sea edge with a sail nub. // A ship piece: a thick coloured bar along the sea edge with a sail nub.
makeShip(ax, ay, bx, by, col) { makeShip(ax, ay, bx, by, col) {
const g = this.add.graphics().setDepth(D.road); const g = this.add.graphics().setDepth(D.road);
g.lineStyle(15, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by); 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(11, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by); g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by);
// sail at the midpoint // sail at the midpoint
const mx = (ax + bx) / 2, my = (ay + by) / 2; const mx = (ax + bx) / 2, my = (ay + by) / 2;
g.fillStyle(0xffffff, 0.95); g.fillStyle(0xffffff, 0.95);
@ -1080,12 +1101,12 @@ export default class CatanGame extends Phaser.Scene {
// White halo (3px border around the scaled-up shape) // White halo (3px border around the scaled-up shape)
g.fillStyle(0xffffff, 1); g.fillStyle(0xffffff, 1);
g.fillRect(x - 24, y, 27, 21); // lower block halo g.fillRect(x - 24, y, 27, 21); // lower block halo
g.fillRect(x - 8, y - 10, 32, 32); // tower 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 g.fillTriangle(x - 11, y - 10, x + 26, y - 10, x + 8, y - 26); // roof halo
// Player color fill (~30% larger than original) // Player color fill (~30% larger than original)
g.fillStyle(col.hex, 1); g.fillStyle(col.hex, 1);
g.fillRect(x - 21, y, 21, 18); // lower block g.fillRect(x - 21, y, 21, 18); // lower block
g.fillRect(x - 5, y - 10, 26, 29); // tower g.fillRect(x - 5, y - 10, 26, 29); // tower
g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof
// Dark outlines // Dark outlines
g.lineStyle(2.5, col.hexDark, 1); g.lineStyle(2.5, col.hexDark, 1);
@ -1108,7 +1129,7 @@ export default class CatanGame extends Phaser.Scene {
enqueueSpeech(clip); enqueueSpeech(clip);
if (this.robberObj) this.robberObj.destroy(); if (this.robberObj) this.robberObj.destroy();
const from = this.hexPos(fromHexId); const from = this.hexPos(fromHexId);
const to = this.hexPos(toHexId); const to = this.hexPos(toHexId);
this.robberObj = this.add.image(from.x, from.y, 'catan-robber') this.robberObj = this.add.image(from.x, from.y, 'catan-robber')
.setDisplaySize(64, 64) .setDisplaySize(64, 64)
.setDepth(D.robber); .setDepth(D.robber);
@ -1125,7 +1146,7 @@ export default class CatanGame extends Phaser.Scene {
targets: this.robberObj, targets: this.robberObj,
tweens: [ tweens: [
{ scaleX: base * 2, scaleY: base * 2, duration: 1500, ease: 'Sine.Out' }, { scaleX: base * 2, scaleY: base * 2, duration: 1500, ease: 'Sine.Out' },
{ scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve }, { scaleX: base, scaleY: base, duration: 1500, ease: 'Sine.In', onComplete: resolve },
], ],
}); });
}); });
@ -1171,7 +1192,7 @@ export default class CatanGame extends Phaser.Scene {
_flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) { _flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) {
return new Promise(resolve => { return new Promise(resolve => {
const cardW = 60, cardH = 84; const cardW = 60, cardH = 84;
const bigW = 90, bigH = 126; const bigW = 90, bigH = 126;
const resource = RESOURCE_TYPES[frameIdx]; const resource = RESOURCE_TYPES[frameIdx];
const makeBorder = (color) => { const makeBorder = (color) => {
@ -1249,12 +1270,12 @@ export default class CatanGame extends Phaser.Scene {
if (type === 'road') { if (type === 'road') {
g.lineStyle(10, 0xffffff, 0.9); g.lineBetween(-13, 0, 13, 0); 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(7, col.hexDark, 1); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0); g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0);
} else if (type === 'settlement') { } else if (type === 'settlement') {
g.fillStyle(0xffffff, 1); g.fillStyle(0xffffff, 1);
g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17); g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17);
g.fillStyle(col.hex, 1); g.fillStyle(col.hex, 1);
g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14); 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); g.lineStyle(2, col.hexDark, 1); g.strokeRect(-9, -3, 18, 12);
} else if (type === 'city') { } else if (type === 'city') {
g.fillStyle(0xffffff, 1); g.fillStyle(0xffffff, 1);
@ -1279,15 +1300,19 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(srcPos.y, destY) - arcHeight; const peakY = Math.min(srcPos.y, destY) - arcHeight;
const half = duration / 2; const half = duration / 2;
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' }); this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
this.tweens.chain({ targets: container, tweens: [ this.tweens.chain({
{ y: peakY, duration: half, ease: 'Quad.Out' }, targets: container, tweens: [
{ y: destY, duration: half, ease: 'Quad.In', onComplete: () => { { y: peakY, duration: half, ease: 'Quad.Out' },
emitter.stop(); {
container.destroy(); y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
this.time.delayedCall(500, () => emitter.destroy()); emitter.stop();
resolve(); container.destroy();
}}, this.time.delayedCall(500, () => emitter.destroy());
]}); resolve();
}
},
]
});
}); });
} }
@ -1308,15 +1333,19 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight; const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
const half = duration / 2; const half = duration / 2;
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' }); this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
this.tweens.chain({ targets: container, tweens: [ this.tweens.chain({
{ y: peakY, duration: half, ease: 'Quad.Out' }, targets: container, tweens: [
{ y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => { { y: peakY, duration: half, ease: 'Quad.Out' },
this.tweens.add({ {
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280, y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
onComplete: () => { container.destroy(); resolve(); }, this.tweens.add({
}); targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
}}, onComplete: () => { container.destroy(); resolve(); },
]}); });
}
},
]
});
}); });
} }
@ -1456,10 +1485,10 @@ export default class CatanGame extends Phaser.Scene {
} }
const DEV_VISUAL = { const DEV_VISUAL = {
knight: { frame: 5, border: 0xb03030 }, knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b }, roadBuilding: { frame: 6, border: 0x8b5a2b },
vp: { frame: 7, border: 0xdaa520 }, vp: { frame: 7, border: 0xdaa520 },
monopoly: { frame: 9, border: 0x7b2d8b }, monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 }, yearOfPlenty: { frame: 10, border: 0x2d8b57 },
}; };
@ -1488,7 +1517,7 @@ export default class CatanGame extends Phaser.Scene {
const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive(); const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive();
const cx = x, topY = y - cardH / 2; const cx = x, topY = y - cardH / 2;
hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border)); hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border));
hit.on('pointerout', () => this.hideDevCardTooltip()); hit.on('pointerout', () => this.hideDevCardTooltip());
this.devHandContainer.add([bg, img, border, hit]); this.devHandContainer.add([bg, img, border, hit]);
x += step; x += step;
@ -1525,6 +1554,9 @@ export default class CatanGame extends Phaser.Scene {
const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0; const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0;
set('roll', me && s.phase === 'rollPhase'); set('roll', me && s.phase === 'rollPhase');
set('road', (action && hasRoadSpot && L.canAfford(p, COSTS.road)) || (action && s.freeRoads > 0 && hasRoadSpot)); set('road', (action && hasRoadSpot && L.canAfford(p, COSTS.road)) || (action && s.freeRoads > 0 && hasRoadSpot));
const hasShipSpot = action && L.legalShipEdges(s, 0).length > 0;
const sCost = L.shipCost(s);
set('ship', hasShipSpot && ((action && sCost && L.canAfford(p, sCost)) || (action && s.freeShips > 0)));
set('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement)); set('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement));
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city)); 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('buyDev', action && s.devDeck.length > 0 && L.canAfford(p, COSTS.devCard));
@ -1561,6 +1593,7 @@ export default class CatanGame extends Phaser.Scene {
}); });
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')]; const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names); L.setPlayerNames(this.gs, names);
this.hexTileFrames = {};
this.drawHexes(); this.drawHexes();
this.drawPorts(); this.drawPorts();
this.drawChits(); this.drawChits();
@ -1579,6 +1612,8 @@ export default class CatanGame extends Phaser.Scene {
} else if (s.phase === 'rollPhase') { } else if (s.phase === 'rollPhase') {
if (me) { /* wait for Roll button */ } if (me) { /* wait for Roll button */ }
else await this.aiRoll(); else await this.aiRoll();
} else if (s.phase === 'goldPick') {
await this.handleGoldPickPhase();
} else if (s.phase === 'discard') { } else if (s.phase === 'discard') {
await this.handleDiscardPhase(); await this.handleDiscardPhase();
} else if (s.phase === 'moveRobber') { } else if (s.phase === 'moveRobber') {
@ -1739,10 +1774,10 @@ export default class CatanGame extends Phaser.Scene {
applyAction(seat, a) { applyAction(seat, a) {
switch (a.type) { switch (a.type) {
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId); case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
case 'buildSettlement': return L.buildSettlement(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 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
case 'buyDev': return L.buyDevCard(this.gs, seat); case 'buyDev': return L.buyDevCard(this.gs, seat);
case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get); case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
case 'playDev': case 'playDev':
if (a.card === 'knight') return L.playKnight(this.gs, seat); if (a.card === 'knight') return L.playKnight(this.gs, seat);
@ -1835,10 +1870,10 @@ export default class CatanGame extends Phaser.Scene {
objs.push(g); objs.push(g);
return g; return g;
}; };
const getArrow = drawArrow(812, 'down'); // left of the card you receive const getArrow = drawArrow(812, 'down'); // left of the card you receive
const giveArrow = drawArrow(1188, 'up'); // right of the card you give 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 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' }); 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({ destroy: () => { t1.stop(); t2.stop(); } });
@ -1881,15 +1916,15 @@ export default class CatanGame extends Phaser.Scene {
// ── opponent dev card reveal ────────────────────────────────────────────────── // ── opponent dev card reveal ──────────────────────────────────────────────────
async animateOppDevCardPlay(seat, cardType, resource) { async animateOppDevCardPlay(seat, cardType, resource) {
const VISUAL = { const VISUAL = {
knight: { frame: 5, border: 0xb03030 }, knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b }, roadBuilding: { frame: 6, border: 0x8b5a2b },
monopoly: { frame: 9, border: 0x7b2d8b }, monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 }, yearOfPlenty: { frame: 10, border: 0x2d8b57 },
}; };
const SPEECH = { const SPEECH = {
knight: 'catan-dev-knight', knight: 'catan-dev-knight',
roadBuilding: 'catan-dev-road', roadBuilding: 'catan-dev-road',
monopoly: 'catan-dev-monopoly', monopoly: 'catan-dev-monopoly',
yearOfPlenty: 'catan-dev-year', yearOfPlenty: 'catan-dev-year',
}; };
@ -1933,7 +1968,7 @@ export default class CatanGame extends Phaser.Scene {
const bursts = [ const bursts = [
{ x: toX - 90, y: textY - 10 }, { x: toX + 90, y: textY - 10 }, { 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, y: textY - 28 }, { x: toX - 50, y: textY + 22 },
{ x: toX + 50, y: textY + 22 }, { x: toX + 50, y: textY + 22 },
]; ];
bursts.forEach((b, i) => bursts.forEach((b, i) =>
@ -1977,6 +2012,31 @@ export default class CatanGame extends Phaser.Scene {
this.advance(); 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 ───────────────────────────────────────────────────────── // ── human: discards ─────────────────────────────────────────────────────────
async handleDiscardPhase() { async handleDiscardPhase() {
this.busy = true; this.busy = true;
@ -2051,6 +2111,11 @@ export default class CatanGame extends Phaser.Scene {
const { x, y } = this.nodePos(nid); const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700); 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)`); this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
} }
@ -2063,9 +2128,16 @@ export default class CatanGame extends Phaser.Scene {
enqueueSpeech(`catan-purchase-${type}`); enqueueSpeech(`catan-purchase-${type}`);
await this.animateCostPayment(0, type); await this.animateCostPayment(0, type);
await this.animatePiecePlacement(0, type, dest.x, dest.y); await this.animatePiecePlacement(0, type, dest.x, dest.y);
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id); 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 === 'settlement') this.gs = L.buildSettlement(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(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); playSound(this, SFX.PIECE_CLICK);
this.busy = false; this.busy = false;
this.advance(); this.advance();
@ -2148,12 +2220,12 @@ export default class CatanGame extends Phaser.Scene {
if (this.busy || this.gs.phase !== 'action') return; if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights(); this.clearHighlights();
const give = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 }; 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 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 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 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 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 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 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 objs = [overlay, box, title, hintGive, hintGet];
const stepper = (col, r, i, side) => { const stepper = (col, r, i, side) => {
@ -2318,9 +2390,9 @@ export default class CatanGame extends Phaser.Scene {
const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase(); const label = (RESOURCE_INFO[resource]?.label ?? resource).toUpperCase();
const txt = this.add.text(GAME_WIDTH / 2, 855, const txt = this.add.text(GAME_WIDTH / 2, 855,
`CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, { `CARD STOLEN: ${label} BY ${robberName.toUpperCase()}`, {
fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700', fontFamily: 'Righteous', fontSize: '38px', color: '#ffd700',
stroke: '#000000', strokeThickness: 5, stroke: '#000000', strokeThickness: 5,
} }
).setOrigin(0.5).setDepth(D.banner + 2); ).setOrigin(0.5).setDepth(D.banner + 2);
this.tweens.add({ this.tweens.add({
targets: txt, alpha: 0, delay: 3000, duration: 500, targets: txt, alpha: 0, delay: 3000, duration: 500,
@ -2334,8 +2406,10 @@ export default class CatanGame extends Phaser.Scene {
backgroundColor: '#111923ee', padding: { x: 26, y: 12 }, backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
}).setOrigin(0.5).setDepth(D.banner); }).setOrigin(0.5).setDepth(D.banner);
banner.setAlpha(0); banner.setAlpha(0);
this.tweens.add({ targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut', this.tweens.add({
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() })) }); 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 ───────────────────────────────────────────────────────────────── // ── game over ─────────────────────────────────────────────────────────────────
@ -2346,11 +2420,11 @@ export default class CatanGame extends Phaser.Scene {
this.recordHistory(); this.recordHistory();
const PW = 760, PH = 660, PX = 1000, PY = 540; const PW = 760, PH = 660, PX = 1000, PY = 540;
const titleY = PY - PH / 2 + 70; // 280 const titleY = PY - PH / 2 + 70; // 280
const RADIUS = 80; const RADIUS = 80;
const portraitY = titleY + 140; // 420 const portraitY = titleY + 140; // 420
const bodyY = portraitY + RADIUS + 95; // 595 const bodyY = portraitY + RADIUS + 95; // 595
const buttonsY = PY + PH / 2 - 72; // 798 const buttonsY = PY + PH / 2 - 72; // 798
// Fireworks across the popup for all winners // Fireworks across the popup for all winners
const fwEmitter = this.add.particles(PX, PY, 'catanParticle', { const fwEmitter = this.add.particles(PX, PY, 'catanParticle', {
@ -2405,7 +2479,7 @@ export default class CatanGame extends Phaser.Scene {
videoEl.autoplay = true; videoEl.autoplay = true;
videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`; 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.src = `/assets/videos/${opp.id}-happy.mp4`;
videoEl.play().catch(() => {}); videoEl.play().catch(() => { });
videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true }); videoEl.addEventListener('error', () => { videoEl.style.display = 'none'; }, { once: true });
portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3); portraitDom = this.add.dom(PX, portraitY, videoEl).setDepth(D.banner + 3);
} }

View File

@ -139,6 +139,7 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
diceTotal: null, diceTotal: null,
robberReturnPhase: 'action', robberReturnPhase: 'action',
discardQueue: [], discardQueue: [],
goldPickQueue: [],
freeRoads: 0, freeRoads: 0,
freeShips: 0, // Seafarers: free ships (e.g. from a future card) freeShips: 0, // Seafarers: free ships (e.g. from a future card)
longestRoad: { owner: null, length: 0 }, longestRoad: { owner: null, length: 0 },
@ -306,7 +307,7 @@ export function rollDice(state) {
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber'; s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
} else { } else {
produceResources(s, s.diceTotal); produceResources(s, s.diceTotal);
s.phase = 'action'; if (s.phase !== 'goldPick') s.phase = 'action';
} }
return s; return s;
} }
@ -326,8 +327,31 @@ function produceResources(s, total) {
if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; } if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; }
} }
} }
// Seafarers gold hexes (and any other expansion production) resolve here. // Gold hexes: each settlement/city adjacent earns 1/2 free-choice resources.
getExpansion(s.expansion).onProduce?.(s, total); for (const hex of s.hexes) {
if (hex.kind !== 'gold' || hex.number !== total || hex.hasRobber) continue;
for (const nodeId of geo.hexes[hex.id].corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
const amt = bld.type === 'city' ? 2 : 1;
s.goldPickQueue.push({ seat: bld.seat, amount: amt });
}
}
if (s.goldPickQueue.length) s.phase = 'goldPick';
}
// resources: array of resource strings with length matching the queue entry's amount.
export function resolveGoldPick(state, seat, resources) {
const s = cloneState(state);
const idx = s.goldPickQueue.findIndex((e) => e.seat === seat);
if (idx === -1) return s;
for (const r of resources) {
if (s.bank[r] > 0) { s.players[seat].resources[r]++; s.bank[r]--; }
}
logEvent(s, `${playerName(s, seat)} takes ${resources.join(', ')} from gold.`);
s.goldPickQueue.splice(idx, 1);
if (s.goldPickQueue.length === 0) s.phase = 'action';
return s;
} }
// ── discard / robber ─────────────────────────────────────────────────────────── // ── discard / robber ───────────────────────────────────────────────────────────
@ -398,11 +422,29 @@ export function buildRoad(state, seat, edgeId) {
if (!free && !canAfford(s.players[seat], COSTS.road)) return s; if (!free && !canAfford(s.players[seat], COSTS.road)) return s;
if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road); if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road);
s.players[seat].roads.push(edgeId); s.players[seat].roads.push(edgeId);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s); recomputeLongestRoad(s);
checkWin(s, seat); checkWin(s, seat);
return s; return s;
} }
// Reveal any fog hexes that share an edge with edgeId.
function revealFogHexes(s, edgeId) {
const geo = geoFor(s);
for (const hexId of geo.edges[edgeId].hexes) {
const hex = s.hexes[hexId];
if (!hex || hex.kind !== 'fog') continue;
if (hex.fogData) {
hex.kind = hex.fogData.kind;
hex.resource = hex.fogData.resource;
hex.number = hex.fogData.number;
} else {
hex.kind = 'sea';
}
hex.fogData = null;
}
}
// ── ships (Seafarers) ────────────────────────────────────────────────────────── // ── ships (Seafarers) ──────────────────────────────────────────────────────────
// Ship cost is supplied by the active expansion; null in the base game (no ships). // Ship cost is supplied by the active expansion; null in the base game (no ships).
export function shipCost(state) { export function shipCost(state) {
@ -457,6 +499,7 @@ export function buildShip(state, seat, edgeId) {
if (!free && !canAfford(s.players[seat], cost)) return s; if (!free && !canAfford(s.players[seat], cost)) return s;
if (free) s.freeShips--; else pay(s, s.players[seat], cost); if (free) s.freeShips--; else pay(s, s.players[seat], cost);
s.players[seat].ships.push(edgeId); s.players[seat].ships.push(edgeId);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s); recomputeLongestRoad(s);
checkWin(s, seat); checkWin(s, seat);
return s; return s;

View File

@ -8,7 +8,7 @@
// buildBoard() produces the topology and per-hex assignments at game start. // buildBoard() produces the topology and per-hex assignments at game start.
// //
// An expansion may expose any of: // An expansion may expose any of:
// costs extra build costs (e.g. { ship: { brick:1, lumber:1 } }) // costs extra build costs (e.g. { ship: { lumber:1, wool:1 } })
// scenarios { id: scenarioObj } selectable layouts (see seafarers.js) // scenarios { id: scenarioObj } selectable layouts (see seafarers.js)
// setupRules [ (state) => void ] applied once after base setup // setupRules [ (state) => void ] applied once after base setup
// onProduce (state, total) => void extra production (e.g. gold hexes) // onProduce (state, total) => void extra production (e.g. gold hexes)

View File

@ -9,7 +9,7 @@ import {
HEX_SIZE, BOARD_CX, BOARD_CY, PORT_BAG, HEX_SIZE, BOARD_CX, BOARD_CY, PORT_BAG,
} from '../../CatanBoard.js'; } from '../../CatanBoard.js';
export const SHIP_COST = { brick: 1, lumber: 1 }; export const SHIP_COST = { wool: 1, lumber: 1 };
// Larger Seafarers boards use a slightly smaller hex so they fit the canvas. // Larger Seafarers boards use a slightly smaller hex so they fit the canvas.
export const SEA_HEX_SIZE = 74; export const SEA_HEX_SIZE = 74;
@ -68,6 +68,8 @@ export function assembleBoard({ id, rows, cells, homeIds = [], pirate = null, wi
resource: c.resource ?? null, resource: c.resource ?? null,
number: c.number ?? null, number: c.number ?? null,
hasRobber: false, hasRobber: false,
// Fog hexes carry hidden data that replaces kind/resource/number on reveal.
fogData: c.kind === 'fog' && c.reveal ? { kind: c.reveal.kind, resource: c.reveal.resource ?? null, number: c.reveal.number ?? null } : null,
})); }));
// Robber starts on the (first) desert, if any. // Robber starts on the (first) desert, if any.

View File

@ -38,30 +38,31 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 312, frameWidth: 312,
frameHeight: 312, frameHeight: 312,
}); });
this.load.image('catan-robber', '/assets/images/catan-robber.png'); this.load.image('catan-robber', '/assets/images/catan-robber.png');
this.load.image('bg-menu', '/assets/images/background-menu.png'); this.load.image('catan-pirate', '/assets/images/catan-pirate.png');
this.load.image('bg-room', '/assets/images/background-room.png'); this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('bg-casino', '/assets/images/background-casino.png'); this.load.image('bg-casino', '/assets/images/background-casino.png');
this.load.image('main-title', '/assets/images/main-title.png'); this.load.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json'); this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json'); this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json'); this.load.json('music', '/data/music.json');
this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3'); this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3');
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3'); this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');
this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3'); this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3');
this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3'); this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3');
this.load.audio('sfx-coins', '/assets/fx/coins.mp3'); this.load.audio('sfx-coins', '/assets/fx/coins.mp3');
this.load.audio('sfx-purchase', '/assets/fx/purchase.mp3'); this.load.audio('sfx-purchase', '/assets/fx/purchase.mp3');
this.load.audio('sfx-casino-blackjack', '/assets/fx/casino-blackjack.mp3'); this.load.audio('sfx-casino-blackjack', '/assets/fx/casino-blackjack.mp3');
this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3'); this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3');
this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3'); this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3');
this.load.audio('sfx-chip-bet', '/assets/fx/chip-bet.mp3'); this.load.audio('sfx-chip-bet', '/assets/fx/chip-bet.mp3');
this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3'); this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3');
this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3'); this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3');
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3'); this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3'); this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3'); this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
@ -81,7 +82,7 @@ export default class PreloadScene extends Phaser.Scene {
const toLoad = [ const toLoad = [
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)), ...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)), ...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
]; ];
if (toLoad.length > 0) { if (toLoad.length > 0) {