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:
parent
4198dd5757
commit
1a7decfa0e
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0–5 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6–7 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue