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.
This commit is contained in:
Brian Fertig 2026-05-24 15:15:51 -06:00
parent 4198dd5757
commit 1a7decfa0e
5 changed files with 289 additions and 9 deletions

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,6 +405,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(); this._buildSpecialCards();
} }
@ -413,17 +425,38 @@ export default class CatanGame extends Phaser.Scene {
return c; return c;
}; };
this._lrCard = makeCard(1783, 0, 0xdaa520); this._lrCard = makeCard(1775, 0, 0xdaa520);
this._laCard = makeCard(1847, 1, 0xb03030); this._laCard = makeCard(1855, 1, 0xb03030);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null }; this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false }; 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) { _getSpecialCardPos(cardType, owner) {
if (owner === null) { if (owner === null) {
return cardType === 'longestRoad' return cardType === 'longestRoad'
? { x: 1783, y: 760, scale: 1 } ? { x: 1775, y: 760, scale: 1 }
: { x: 1847, y: 760, scale: 1 }; : { x: 1855, y: 760, scale: 1 };
} }
if (owner === 0) { if (owner === 0) {
return cardType === 'longestRoad' return cardType === 'longestRoad'
@ -571,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;
@ -1553,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);
@ -1617,6 +1693,130 @@ 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 ────────────────────────────────────────────────── // ── opponent dev card reveal ──────────────────────────────────────────────────
async animateOppDevCardPlay(seat, cardType) { async animateOppDevCardPlay(seat, cardType) {
const VISUAL = { const VISUAL = {
@ -1914,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

@ -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

@ -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);
}