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

@ -119,6 +119,9 @@ export default class CatanGame extends Phaser.Scene {
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() {
@ -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: {
@ -341,10 +347,12 @@ export default class CatanGame extends Phaser.Scene {
// 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({
targets: c, tweens: [
{ y: arcY, duration: outMs, ease: 'Quad.Out' }, { y: arcY, duration: outMs, ease: 'Quad.Out' },
{ y: ly, duration: backMs, ease: 'Bounce.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'
@ -528,13 +541,16 @@ export default class CatanGame extends Phaser.Scene {
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({
targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' }, { x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{ x: toPos.x, y: toPos.y, scale: toPos.scale, {
x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In', duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; }, onComplete: () => { this._specialCardAnimating[cardType] = false; },
}, },
]}); ]
});
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army'); enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
} }
@ -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);
@ -1033,12 +1057,9 @@ 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));
} }
} }
@ -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({
targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' }, { y: peakY, duration: half, ease: 'Quad.Out' },
{ y: destY, duration: half, ease: 'Quad.In', onComplete: () => { {
y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
emitter.stop(); emitter.stop();
container.destroy(); container.destroy();
this.time.delayedCall(500, () => emitter.destroy()); this.time.delayedCall(500, () => emitter.destroy());
resolve(); 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({
targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' }, { y: peakY, duration: half, ease: 'Quad.Out' },
{ y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => { {
y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
this.tweens.add({ this.tweens.add({
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280, targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
onComplete: () => { container.destroy(); resolve(); }, onComplete: () => { container.destroy(); resolve(); },
}); });
}}, }
]}); },
]
});
}); });
} }
@ -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') {
@ -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);
const prevGs = this.gs;
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id); 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();
@ -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 ─────────────────────────────────────────────────────────────────
@ -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

@ -39,6 +39,7 @@ export default class PreloadScene extends Phaser.Scene {
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('catan-pirate', '/assets/images/catan-pirate.png');
this.load.image('bg-menu', '/assets/images/background-menu.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-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');