Compare commits

..

3 Commits

Author SHA1 Message Date
Brian Fertig 1a7decfa0e feat(catan): enhance AI trading logic and add UI tooltips/fullscreen support
- Implement intelligent AI trade initiation (proposeTrade) and response logic (respondToTrade) that adjusts willingness based on the requester's victory points.
- Add hover tooltips for harbors, Longest Road, and Largest Army cards to improve user understanding of game mechanics.
- Add a generic info tooltip system for UI elements.
- Add fullscreen toggle button to Landing and Game Menu scenes.
- Fix AI trade animation and status messages for better feedback.
2026-05-24 15:15:51 -06:00
Brian Fertig 4198dd5757 feat(catan): add animated special cards and opponent dev card reveals
- Implement visual tracking for Longest Road and Largest Army cards with
  smooth transfer animations between players and the central display.
- Add audio feedback for special card ownership changes and opponent dev card plays.
- Display opponent development card details with a zoom-in animation when played.
- Update opponent panels to remove special card badges in favor of the new visual system.
- Add necessary spritesheet and audio assets to the preload scene.
- Minor fix to restore dice state in Parchisi logic after three-doubles penalty.
2026-05-24 12:53:38 -06:00
Brian Fertig c2fb49706f Added Favicons 2026-05-24 11:18:57 -06:00
22 changed files with 430 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -6,6 +6,7 @@ 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.
@ -272,21 +273,82 @@ 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`.
export function respondToTrade(state, seat, give, get) { // Willingness tightens as the *requester's* visible VP grows.
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;
// Value resources by how much they unblock our best target. const reqVP = publicVictoryPoints(state, requesterSeat);
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,6 +196,17 @@ 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);
@ -394,7 +405,112 @@ 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() {
@ -488,6 +604,48 @@ 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;
@ -723,9 +881,11 @@ 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 });
}); });
@ -737,12 +897,7 @@ 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;
const badges = []; panel.info.setText(`${dev} dev ${p.knightsPlayed} knights`);
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; }
@ -818,6 +973,7 @@ export default class CatanGame extends Phaser.Scene {
this.renderOpponentPanels(); this.renderOpponentPanels();
this.updateButtons(); this.updateButtons();
this.updateStatus(); this.updateStatus();
this.updateSpecialCards();
} }
renderPieces() { renderPieces() {
@ -1472,6 +1628,7 @@ 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);
@ -1494,6 +1651,9 @@ 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;
@ -1533,6 +1693,182 @@ 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;
@ -1778,7 +2114,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)) { accepted = seat; break; } if (AI.respondToTrade(this.gs, seat, get, give, 0)) { 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,6 +81,7 @@ 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,6 +2,7 @@ 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 {
@ -12,6 +13,7 @@ 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,6 +2,7 @@ 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 {
@ -36,6 +37,7 @@ 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,6 +59,8 @@ 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

@ -0,0 +1,14 @@
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);
}