Compare commits

..

No commits in common. "1a7decfa0ef4b6d2784168522b7279874df402ff" and "307f3b7123116fc6ec00f106e25eaa7e99d5e385" have entirely different histories.

22 changed files with 11 additions and 430 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -6,7 +6,6 @@ import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoa
import { import {
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio, legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
handSize, nodeBuilding, stealTargets, publicVictoryPoints, handSize, nodeBuilding, stealTargets, publicVictoryPoints,
victoryPoints, WIN_VP,
} from './CatanLogic.js'; } from './CatanLogic.js';
// Value of a vertex = production potential of its adjacent hexes + diversity. // Value of a vertex = production potential of its adjacent hexes + diversity.
@ -273,82 +272,21 @@ function chooseHelpfulDev(state, seat, citySettlements, settleSpots) {
return null; return null;
} }
// ── AI trade initiation ─────────────────────────────────────────────────────────
// Propose a 1-for-1 trade that moves `seat` toward its highest objective:
// it gives `give` (a surplus resource) and wants `get` (a needed resource). Or null.
export function proposeTrade(state, seat, exclude = new Set()) {
const p = state.players[seat];
const have = p.resources;
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
if (canAfford(p, target)) return null; // nothing to trade for — just build
const missing = RESOURCE_TYPES.filter((r) => (target[r] || 0) > have[r] && !exclude.has(r));
if (!missing.length) return null;
// Most-needed missing resource.
const get = missing.sort((a, b) => ((target[b] || 0) - have[b]) - ((target[a] || 0) - have[a]))[0];
// Give the resource we have the biggest surplus of (beyond what the target needs).
let give = null, bestSpare = 0;
for (const g of RESOURCE_TYPES) {
if (g === get) continue;
const spare = have[g] - (target[g] || 0);
if (spare >= 1 && spare > bestSpare) { bestSpare = spare; give = g; }
}
if (!give) return null;
return { give, get };
}
// ── player↔AI trade evaluation ───────────────────────────────────────────────── // ── player↔AI trade evaluation ─────────────────────────────────────────────────
// Decide whether `seat` (AI) accepts a trade where it GIVES `give` and GETS `get`. // Decide whether `seat` (AI) accepts a trade where it GIVES `give` and GETS `get`.
// Willingness tightens as the *requester's* visible VP grows. export function respondToTrade(state, seat, give, get) {
export function respondToTrade(state, seat, give, get, requesterSeat = 0) {
const p = state.players[seat]; const p = state.players[seat];
// Must be able to afford what it gives. // Must be able to afford what it gives.
for (const r of RESOURCE_TYPES) if ((give[r] || 0) > p.resources[r]) return false; for (const r of RESOURCE_TYPES) if ((give[r] || 0) > p.resources[r]) return false;
const giveCount = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0), 0);
const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0); const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0);
if (getCount === 0) return false; if (getCount === 0) return false;
const reqVP = publicVictoryPoints(state, requesterSeat); // Value resources by how much they unblock our best target.
if (reqVP >= 8) return tradeWinsGame(state, seat, give, get); // very unwilling
if (reqVP >= 6) return tradeBeneficial(state, seat, give, get); // beneficial-only
return baseAccept(state, seat, give, get); // existing logic
}
// 05 VP: original value heuristic.
function baseAccept(state, seat, give, get) {
const p = state.players[seat];
const giveCount = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0), 0);
const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0);
const target = p.settlements.length ? COSTS.city : COSTS.settlement; const target = p.settlements.length ? COSTS.city : COSTS.settlement;
const need = (r) => Math.max(0, (target[r] || 0) - p.resources[r]); const need = (r) => Math.max(0, (target[r] || 0) - p.resources[r]);
const valIn = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0) * (1 + need(r)), 0); const valIn = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0) * (1 + need(r)), 0);
const valOut = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0) * (1 + need(r) * 0.5), 0); const valOut = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0) * (1 + need(r) * 0.5), 0);
// Accept if we gain value and it isn't badly lopsided in card count.
return valIn >= valOut && getCount >= giveCount - 1; return valIn >= valOut && getCount >= giveCount - 1;
} }
// 67 VP: only accept trades that clearly benefit us and cost us nothing we need.
function tradeBeneficial(state, seat, give, get) {
const p = state.players[seat];
const have = p.resources;
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
const need = (r) => Math.max(0, (target[r] || 0) - have[r]);
const prod = productionByResource(state, seat);
// Benefit: an incoming resource we need for our target, or one we can't produce.
const benefit = RESOURCE_TYPES.some((r) => (get[r] || 0) > 0 && (need(r) > 0 || prod[r] === 0));
// Not harmful: every outgoing resource stays at/above what our target needs.
const notHarmful = RESOURCE_TYPES.every((r) => have[r] - (give[r] || 0) >= need(r));
return benefit && notHarmful;
}
// 8+ VP: only accept if the trade directly lets us build a game-winning piece.
function tradeWinsGame(state, seat, give, get) {
const p = state.players[seat];
const have2 = { ...p.resources };
for (const r of RESOURCE_TYPES) {
have2[r] -= (give[r] || 0);
have2[r] += (get[r] || 0);
}
const afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
const ownVP = victoryPoints(state, seat);
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= WIN_VP) return true;
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= WIN_VP) return true;
return false;
}

View File

@ -196,17 +196,6 @@ export default class CatanGame extends Phaser.Scene {
const sub = port.type === 'any' ? '' : RESOURCE_INFO[port.type].label[0]; const sub = port.type === 'any' ? '' : RESOURCE_INFO[port.type].label[0];
c.add(this.add.text(0, -4, label, { fontFamily: 'Righteous', fontSize: '13px', color: '#2a2118' }).setOrigin(0.5)); c.add(this.add.text(0, -4, label, { fontFamily: 'Righteous', fontSize: '13px', color: '#2a2118' }).setOrigin(0.5));
if (sub) c.add(this.add.text(0, 8, sub, { fontFamily: 'Righteous', fontSize: '11px', color: '#8a5a18' }).setOrigin(0.5)); if (sub) c.add(this.add.text(0, 8, sub, { fontFamily: 'Righteous', fontSize: '11px', color: '#8a5a18' }).setOrigin(0.5));
const title = port.type === 'any'
? '3:1 Harbor'
: `2:1 ${RESOURCE_INFO[port.type].label} Harbor`;
const desc = port.type === 'any'
? 'Trade 3 of any single resource for 1 resource of your choice. Works with brick, lumber, wool, grain, or ore.'
: `Trade 2 ${RESOURCE_INFO[port.type].label.toLowerCase()} for 1 resource of your choice. Only ${RESOURCE_INFO[port.type].label.toLowerCase()} qualifies for this rate.`;
c.setInteractive(new Phaser.Geom.Circle(0, 0, 19), Phaser.Geom.Circle.Contains);
c.on('pointerover', () => this.showInfoTooltip(px, py - 19, title, desc, 0xb89a5e));
c.on('pointerout', () => this.hideInfoTooltip());
// little jetties to the two coastal nodes // little jetties to the two coastal nodes
const jg = this.add.graphics().setDepth(D.port - 1); const jg = this.add.graphics().setDepth(D.port - 1);
jg.lineStyle(3, 0x6b4a1a, 0.8); jg.lineStyle(3, 0x6b4a1a, 0.8);
@ -405,112 +394,7 @@ export default class CatanGame extends Phaser.Scene {
new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud); new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud);
this.buildDevCardTooltip(); this.buildDevCardTooltip();
this.buildInfoTooltip();
this._buildTurnTriangle(); this._buildTurnTriangle();
this._buildSpecialCards();
}
_buildSpecialCards() {
const makeCard = (x, frameIdx, borderColor) => {
const W = 64, H = 90, R = 6, BW = 3;
const bg = this.add.graphics();
bg.fillStyle(0x111111, 0.85);
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
const img = this.add.image(0, 0, 'catan-special-cards', frameIdx).setDisplaySize(W - 6, H - 6);
const border = this.add.graphics();
border.lineStyle(BW, borderColor, 1);
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
const c = this.add.container(x, 760).setDepth(D.hud + 3);
c.add([bg, img, border]);
return c;
};
this._lrCard = makeCard(1775, 0, 0xdaa520);
this._laCard = makeCard(1855, 1, 0xb03030);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
const H = 90;
const attachHover = (card, title, desc, borderColor) => {
card.setInteractive(
new Phaser.Geom.Rectangle(-32, -H / 2, 64, H),
Phaser.Geom.Rectangle.Contains,
);
card.on('pointerover', () =>
this.showInfoTooltip(card.x, card.y - (H / 2) * card.scaleY, title, desc, borderColor));
card.on('pointerout', () => this.hideInfoTooltip());
};
attachHover(
this._lrCard, 'Longest Road',
'Held by the player with the longest unbroken road of 5 or more segments. Worth 2 Victory Points. Lost to any player who later builds a longer road.',
0xdaa520,
);
attachHover(
this._laCard, 'Largest Army',
'Held by the first player to play 3 Knight cards. Worth 2 Victory Points. Lost to any player who later plays more Knights.',
0xb03030,
);
}
_getSpecialCardPos(cardType, owner) {
if (owner === null) {
return cardType === 'longestRoad'
? { x: 1775, y: 760, scale: 1 }
: { x: 1855, y: 760, scale: 1 };
}
if (owner === 0) {
return cardType === 'longestRoad'
? { x: 1390, y: 1015, scale: 1 }
: { x: 1462, y: 1015, scale: 1 };
}
const panel = this.oppPanels?.find((p) => p.seat === owner);
if (!panel) return { x: 0, y: 0, scale: 0.25 };
const bx = panel.x;
const by = panel.y - 74;
return cardType === 'longestRoad'
? { x: bx - 28, y: by, scale: 0.25 }
: { x: bx + 28, y: by, scale: 0.25 };
}
updateSpecialCards() {
if (!this._lrCard || !this.gs) return;
for (const cardType of ['longestRoad', 'largestArmy']) {
const newOwner = this.gs[cardType].owner;
const prevOwner = this._prevSpecialCardOwners[cardType];
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
if (newOwner !== prevOwner) {
const fromPos = this._getSpecialCardPos(cardType, prevOwner);
this._prevSpecialCardOwners[cardType] = newOwner;
this._animateSpecialCardTransfer(cardType, fromPos, newOwner);
} else if (!this._specialCardAnimating[cardType]) {
const pos = this._getSpecialCardPos(cardType, newOwner);
card.setPosition(pos.x, pos.y).setScale(pos.scale);
}
}
}
_animateSpecialCardTransfer(cardType, fromPos, newOwner) {
const toPos = this._getSpecialCardPos(cardType, newOwner);
const card = cardType === 'longestRoad' ? this._lrCard : this._laCard;
card.setPosition(fromPos.x, fromPos.y).setScale(fromPos.scale);
this._specialCardAnimating[cardType] = true;
const peakY = Math.min(fromPos.y, toPos.y) - 150;
const midX = (fromPos.x + toPos.x) / 2;
const midScale = (fromPos.scale + toPos.scale) / 2;
const half = 380;
this.tweens.chain({ targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{ x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; },
},
]});
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
} }
_buildTurnTriangle() { _buildTurnTriangle() {
@ -604,48 +488,6 @@ export default class CatanGame extends Phaser.Scene {
this._devTooltip?.setPosition(-9999, -9999); this._devTooltip?.setPosition(-9999, -9999);
} }
// Generic auto-sizing hover tooltip (title + wrapped description) used by the
// Longest Road / Largest Army cards and the harbor markers.
buildInfoTooltip() {
const g = this.add.graphics();
const titleTxt = this.add.text(0, 0, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8', align: 'center',
}).setOrigin(0.5, 0);
const descTxt = this.add.text(0, 0, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
align: 'center', wordWrap: { width: 288 },
}).setOrigin(0.5, 0);
this._infoTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
.setDepth(D.panel + 5);
this._infoTooltip.gfx = g;
this._infoTooltip.titleTxt = titleTxt;
this._infoTooltip.descTxt = descTxt;
}
showInfoTooltip(anchorX, anchorTopY, title, desc, borderColor = COLORS.accent) {
const tt = this._infoTooltip;
const { gfx: g, titleTxt, descTxt } = tt;
const popW = 320, padTop = 14, gap = 8, padBot = 14;
titleTxt.setText(title);
descTxt.setText(desc);
const popH = padTop + titleTxt.height + gap + descTxt.height + padBot;
const top = -popH / 2;
titleTxt.setPosition(0, top + padTop);
descTxt.setPosition(0, top + padTop + titleTxt.height + gap);
g.clear();
g.fillStyle(0x0d1117, 0.96);
g.fillRoundedRect(-popW / 2, top, popW, popH, 10);
g.lineStyle(2, borderColor, 0.9);
g.strokeRoundedRect(-popW / 2, top, popW, popH, 10);
const x = Phaser.Math.Clamp(anchorX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10);
const y = Phaser.Math.Clamp(anchorTopY - popH / 2 - 12, popH / 2 + 10, GAME_HEIGHT - popH / 2 - 10);
tt.setPosition(x, y);
}
hideInfoTooltip() {
this._infoTooltip?.setPosition(-9999, -9999);
}
buildCostLegend() { buildCostLegend() {
const panelRight = 1900; const panelRight = 1900;
const panelW = 320; const panelW = 320;
@ -881,11 +723,9 @@ export default class CatanGame extends Phaser.Scene {
this.add.text(x, y + 70, this.pname(seat), { this.add.text(x, y + 70, this.pname(seat), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
wordWrap: { width: 180 }, align: 'center', wordWrap: { width: 180 }, align: 'center',
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
}).setOrigin(0.5, 0).setDepth(D.hud); }).setOrigin(0.5, 0).setDepth(D.hud);
const info = this.add.text(x, y + 96, '', { const info = this.add.text(x, y + 96, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center', fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center',
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
}).setOrigin(0.5, 0).setDepth(D.hud); }).setOrigin(0.5, 0).setDepth(D.hud);
this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null }); this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null });
}); });
@ -897,7 +737,12 @@ export default class CatanGame extends Phaser.Scene {
const p = this.gs.players[panel.seat]; const p = this.gs.players[panel.seat];
const cards = L.handSize(p); const cards = L.handSize(p);
const dev = p.devCards.length + p.newDevCards.length; const dev = p.devCards.length + p.newDevCards.length;
panel.info.setText(`${dev} dev ${p.knightsPlayed} knights`); const badges = [];
if (this.gs.longestRoad.owner === panel.seat) badges.push('LR');
if (this.gs.largestArmy.owner === panel.seat) badges.push('LA');
panel.info.setText(
`${dev} dev ${p.knightsPlayed} knights` + (badges.length ? `\n[${badges.join(' ')}]` : '')
);
// VP badge circle above the portrait // VP badge circle above the portrait
if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; } if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; }
@ -973,7 +818,6 @@ export default class CatanGame extends Phaser.Scene {
this.renderOpponentPanels(); this.renderOpponentPanels();
this.updateButtons(); this.updateButtons();
this.updateStatus(); this.updateStatus();
this.updateSpecialCards();
} }
renderPieces() { renderPieces() {
@ -1628,7 +1472,6 @@ export default class CatanGame extends Phaser.Scene {
async aiAction() { async aiAction() {
this.busy = true; this.busy = true;
const seat = this.gs.currentPlayer; const seat = this.gs.currentPlayer;
await this.aiInitiateTrades(seat);
let steps = 0; let steps = 0;
while (this.gs.phase === 'action' && steps++ < 60) { while (this.gs.phase === 'action' && steps++ < 60) {
const a = AI.chooseAction(this.gs, seat); const a = AI.chooseAction(this.gs, seat);
@ -1651,9 +1494,6 @@ export default class CatanGame extends Phaser.Scene {
await this.animateDevCardFromBank(seat); await this.animateDevCardFromBank(seat);
} }
this.gs = this.applyAction(seat, a); this.gs = this.applyAction(seat, a);
if (a.type === 'playDev' && a.card !== 'vp') {
await this.animateOppDevCardPlay(seat, a.card);
}
if (this.gs.phase === 'moveRobber') { if (this.gs.phase === 'moveRobber') {
const m = AI.chooseRobberMove(this.gs, seat); const m = AI.chooseRobberMove(this.gs, seat);
const preRobberHex = this.gs.robberHex; const preRobberHex = this.gs.robberHex;
@ -1693,182 +1533,6 @@ export default class CatanGame extends Phaser.Scene {
} }
} }
// ── AI-initiated trades ───────────────────────────────────────────────────────
// Up to 2 per turn. Each 1-for-1 offer goes to the human first (if they hold the
// requested resource); on deny/no-hold it falls through to the other AIs.
async aiInitiateTrades(seat) {
if (this.gs.phase !== 'action') return;
const exclude = new Set();
for (let n = 0; n < 2; n++) {
const offer = AI.proposeTrade(this.gs, seat, exclude);
if (!offer) break;
const { give, get } = offer; // requester gives `give`, wants `get`
exclude.add(get);
let done = false;
if (this.gs.players[0].resources[get] > 0) {
const accepted = await this.promptHumanTrade(seat, give, get);
if (accepted) {
this.gs = L.executePlayerTrade(this.gs, seat, 0, { [give]: 1 }, { [get]: 1 });
playSound(this, SFX.CARD_PLACE);
this.flashStatus(`You traded ${RESOURCE_INFO[get].label} for ${RESOURCE_INFO[give].label} with ${this.pname(seat)}.`);
this.renderAll();
done = true;
}
}
if (!done) {
let acc = null;
for (let s = 1; s < this.gs.playerCount; s++) {
if (s === seat) continue;
// Accepter `s` gives the requested resource and gets the offered one.
if (AI.respondToTrade(this.gs, s, { [get]: 1 }, { [give]: 1 }, seat)) { acc = s; break; }
}
if (acc != null) {
this.gs = L.executePlayerTrade(this.gs, seat, acc, { [give]: 1 }, { [get]: 1 });
await this.animateAiTrade(seat, acc, give, get);
this.flashStatus(`${this.pname(seat)} traded ${RESOURCE_INFO[give].label} for ${RESOURCE_INFO[get].label} with ${this.pname(acc)}.`);
this.renderAll();
}
}
await this.delay(300);
}
}
// Show the "Trade Request" popup to the human. Resolves true on Accept, false on Deny.
promptHumanTrade(requesterSeat, giveRes, getRes) {
return new Promise((resolve) => {
const panel = this.modalPanel(470, 'Trade Request');
const cardW = 96, cardH = 134;
const objs = panel.objs;
objs.push(this.add.text(1000, 360, `${this.pname(requesterSeat)} wants to trade`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.panel + 1));
const card = (x, label, res) => {
objs.push(this.add.text(x, 410, label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.panel + 1));
objs.push(this.add.image(x, 490, 'catan-cards', RESOURCE_TYPES.indexOf(res))
.setDisplaySize(cardW, cardH).setDepth(D.panel + 1));
objs.push(this.add.text(x, 568, RESOURCE_INFO[res].label, {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.panel + 1));
};
card(890, 'You get', giveRes);
card(1110, 'You give', getRes);
const arrowColor = 0xffdd00;
const baseY = 490, amp = 12;
const drawArrow = (x, dir) => {
const g = this.add.graphics({ x, y: baseY }).setDepth(D.panel + 2);
g.fillStyle(arrowColor, 1);
if (dir === 'down') {
g.fillRect(-5, -22, 10, 30);
g.fillTriangle(-14, 6, 14, 6, 0, 26);
} else {
g.fillRect(-5, -8, 10, 30);
g.fillTriangle(-14, -6, 14, -6, 0, -26);
}
objs.push(g);
return g;
};
const getArrow = drawArrow(812, 'down'); // left of the card you receive
const giveArrow = drawArrow(1188, 'up'); // right of the card you give
const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
const t2 = this.tweens.add({ targets: giveArrow, y: baseY - amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
objs.push({ destroy: () => { t1.stop(); t2.stop(); } });
objs.push(new Button(this, 905, 640, 'Accept', () => { panel.destroy(); resolve(true); },
{ width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
objs.push(new Button(this, 1095, 640, 'Deny', () => { panel.destroy(); resolve(false); },
{ variant: 'ghost', width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
playSound(this, SFX.CARD_SHOW);
});
}
// Two cards crossing between the two seats' portraits.
animateAiTrade(fromSeat, toSeat, giveRes, getRes) {
playSound(this, SFX.CARD_PLACE);
const from = this._seatPortraitPos(fromSeat) ?? { x: 130, y: 300 };
const to = this._seatPortraitPos(toSeat) ?? { x: 130, y: 300 };
const cardW = 56, cardH = 78;
const flyCard = (res, src, dst) => new Promise((resolve) => {
const img = this.add.image(0, 0, 'catan-cards', RESOURCE_TYPES.indexOf(res)).setDisplaySize(cardW, cardH);
const border = this.add.graphics();
border.lineStyle(3, RESOURCE_INFO[res]?.swatch ?? COLORS.accent, 1);
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner + 3);
this.tweens.add({
targets: container, x: dst.x, y: dst.y, duration: 700, ease: 'Quad.InOut',
onComplete: () => this.tweens.add({
targets: container, alpha: 0, duration: 220,
onComplete: () => { container.destroy(); resolve(); },
}),
});
});
return Promise.all([
flyCard(giveRes, from, to),
flyCard(getRes, to, from),
]);
}
// ── opponent dev card reveal ──────────────────────────────────────────────────
async animateOppDevCardPlay(seat, cardType) {
const VISUAL = {
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
};
const SPEECH = {
knight: 'catan-dev-knight',
roadBuilding: 'catan-dev-road',
monopoly: 'catan-dev-monopoly',
yearOfPlenty: 'catan-dev-year',
};
const visual = VISUAL[cardType];
if (!visual) return;
const from = this.portraitPos(seat);
const toX = 1380, toY = 240;
const W = 270, H = 390, R = 12, BW = 8;
const bg = this.add.graphics();
bg.fillStyle(0x111111, 0.92);
bg.fillRoundedRect(-W / 2, -H / 2, W, H, R);
const img = this.add.image(0, 0, 'catan-cards', visual.frame).setDisplaySize(W - 16, H - 16);
const border = this.add.graphics();
border.lineStyle(BW, visual.border, 1);
border.strokeRoundedRect(-W / 2, -H / 2, W, H, R);
const card = this.add.container(from.x, from.y).setDepth(D.banner + 5).setScale(30 / W);
card.add([bg, img, border]);
await new Promise(resolve =>
this.tweens.add({ targets: card, x: toX, y: toY, scale: 1, duration: 500, ease: 'Back.easeOut', onComplete: resolve })
);
const speechFile = SPEECH[cardType];
await new Promise(resolve => {
if (!speechFile) { this.time.delayedCall(800, resolve); return; }
const audio = new Audio(`/assets/speech/${speechFile}.mp3`);
audio.onended = resolve;
audio.onerror = resolve;
audio.play().catch(resolve);
});
await new Promise(resolve =>
this.tweens.add({ targets: card, alpha: 0, duration: 400, ease: 'Quad.In', onComplete: resolve })
);
card.destroy();
}
// ── human: roll ─────────────────────────────────────────────────────────────── // ── human: roll ───────────────────────────────────────────────────────────────
async onRoll() { async onRoll() {
if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return; if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return;
@ -2114,7 +1778,7 @@ export default class CatanGame extends Phaser.Scene {
let accepted = null; let accepted = null;
for (let seat = 1; seat < this.gs.playerCount; seat++) { for (let seat = 1; seat < this.gs.playerCount; seat++) {
// AI gives `get` (what we want), receives `give` (what we offer). // AI gives `get` (what we want), receives `give` (what we offer).
if (AI.respondToTrade(this.gs, seat, get, give, 0)) { accepted = seat; break; } if (AI.respondToTrade(this.gs, seat, get, give)) { accepted = seat; break; }
} }
if (accepted == null) { this.flashStatus('No opponent accepted that offer.'); return; } if (accepted == null) { this.flashStatus('No opponent accepted that offer.'); return; }
close(); close();

View File

@ -81,7 +81,6 @@ export function rollSpecificDice(state, d1, d2) {
// Three-doubles penalty: furthest-from-home pawn of current player // Three-doubles penalty: furthest-from-home pawn of current player
// goes back to the nest; turn ends. No moves played. // goes back to the nest; turn ends. No moves played.
applyThreeDoublesPenalty(s); applyThreeDoublesPenalty(s);
s.dice = [d1, d2]; // endTurn nulled dice; restore for animation/display
return s; return s;
} }
const allOut = pawnsInNest(s, s.currentPlayer) === 0; const allOut = pawnsInNest(s, s.currentPlayer) === 0;

View File

@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js'; import { api } from '../services/api.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic } from '../ui/MenuMusic.js';
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
@ -13,7 +12,6 @@ export default class GameMenuScene extends Phaser.Scene {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT); this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this);
const titleText = this.add.text(cx, 120, 'Choose a game', { const titleText = this.add.text(cx, 120, 'Choose a game', {
fontFamily: 'Righteous', fontFamily: 'Righteous',

View File

@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic } from '../ui/MenuMusic.js';
export default class LandingScene extends Phaser.Scene { export default class LandingScene extends Phaser.Scene {
@ -37,7 +36,6 @@ export default class LandingScene extends Phaser.Scene {
}); });
this.renderButtons(); this.renderButtons();
addFullscreenButton(this);
if (this._authUnsub) this._authUnsub(); if (this._authUnsub) this._authUnsub();
this._authUnsub = auth.subscribe(() => { this._authUnsub = auth.subscribe(() => {

View File

@ -59,8 +59,6 @@ export default class PreloadScene extends Phaser.Scene {
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 });
} }
async create() { async create() {

View File

@ -1,14 +0,0 @@
import { GAME_WIDTH } from '../config.js';
import { Button } from './Button.js';
export function addFullscreenButton(scene) {
const width = 300;
return new Button(
scene,
GAME_WIDTH - width / 2 - 30,
52,
'Toggle Fullscreen',
() => scene.scale.toggleFullscreen(),
{ width, height: 52, fontSize: 22, variant: 'ghost' },
).setDepth(100);
}