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:
parent
c5f34b7c28
commit
9dbf3feae4
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 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 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue