Compare commits
No commits in common. "1a7decfa0ef4b6d2784168522b7279874df402ff" and "307f3b7123116fc6ec00f106e25eaa7e99d5e385" have entirely different histories.
1a7decfa0e
...
307f3b7123
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 1003 B |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -6,7 +6,6 @@ import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoa
|
|||
import {
|
||||
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
||||
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
||||
victoryPoints, WIN_VP,
|
||||
} from './CatanLogic.js';
|
||||
|
||||
// Value of a vertex = production potential of its adjacent hexes + diversity.
|
||||
|
|
@ -273,82 +272,21 @@ function chooseHelpfulDev(state, seat, citySettlements, settleSpots) {
|
|||
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 ─────────────────────────────────────────────────
|
||||
// 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, requesterSeat = 0) {
|
||||
export function respondToTrade(state, seat, give, get) {
|
||||
const p = state.players[seat];
|
||||
// Must be able to afford what it gives.
|
||||
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);
|
||||
if (getCount === 0) return false;
|
||||
|
||||
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);
|
||||
// Value resources by how much they unblock our best target.
|
||||
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
|
||||
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 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;
|
||||
}
|
||||
|
||||
// 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,17 +196,6 @@ export default class CatanGame extends Phaser.Scene {
|
|||
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));
|
||||
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
|
||||
const jg = this.add.graphics().setDepth(D.port - 1);
|
||||
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);
|
||||
|
||||
this.buildDevCardTooltip();
|
||||
this.buildInfoTooltip();
|
||||
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() {
|
||||
|
|
@ -604,48 +488,6 @@ export default class CatanGame extends Phaser.Scene {
|
|||
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() {
|
||||
const panelRight = 1900;
|
||||
const panelW = 320;
|
||||
|
|
@ -881,11 +723,9 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.add.text(x, y + 70, this.pname(seat), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
wordWrap: { width: 180 }, align: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 6, y: 3 },
|
||||
}).setOrigin(0.5, 0).setDepth(D.hud);
|
||||
const info = this.add.text(x, y + 96, '', {
|
||||
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);
|
||||
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 cards = L.handSize(p);
|
||||
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
|
||||
if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; }
|
||||
|
|
@ -973,7 +818,6 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this.renderOpponentPanels();
|
||||
this.updateButtons();
|
||||
this.updateStatus();
|
||||
this.updateSpecialCards();
|
||||
}
|
||||
|
||||
renderPieces() {
|
||||
|
|
@ -1628,7 +1472,6 @@ export default class CatanGame extends Phaser.Scene {
|
|||
async aiAction() {
|
||||
this.busy = true;
|
||||
const seat = this.gs.currentPlayer;
|
||||
await this.aiInitiateTrades(seat);
|
||||
let steps = 0;
|
||||
while (this.gs.phase === 'action' && steps++ < 60) {
|
||||
const a = AI.chooseAction(this.gs, seat);
|
||||
|
|
@ -1651,9 +1494,6 @@ export default class CatanGame extends Phaser.Scene {
|
|||
await this.animateDevCardFromBank(seat);
|
||||
}
|
||||
this.gs = this.applyAction(seat, a);
|
||||
if (a.type === 'playDev' && a.card !== 'vp') {
|
||||
await this.animateOppDevCardPlay(seat, a.card);
|
||||
}
|
||||
if (this.gs.phase === 'moveRobber') {
|
||||
const m = AI.chooseRobberMove(this.gs, seat);
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
async onRoll() {
|
||||
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;
|
||||
for (let seat = 1; seat < this.gs.playerCount; seat++) {
|
||||
// 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; }
|
||||
close();
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ export function rollSpecificDice(state, d1, d2) {
|
|||
// Three-doubles penalty: furthest-from-home pawn of current player
|
||||
// goes back to the nest; turn ends. No moves played.
|
||||
applyThreeDoublesPenalty(s);
|
||||
s.dice = [d1, d2]; // endTurn nulled dice; restore for animation/display
|
||||
return s;
|
||||
}
|
||||
const allOut = pawnsInNest(s, s.currentPlayer) === 0;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
|
|
@ -13,7 +12,6 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
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', {
|
||||
fontFamily: 'Righteous',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
export default class LandingScene extends Phaser.Scene {
|
||||
|
|
@ -37,7 +36,6 @@ export default class LandingScene extends Phaser.Scene {
|
|||
});
|
||||
|
||||
this.renderButtons();
|
||||
addFullscreenButton(this);
|
||||
|
||||
if (this._authUnsub) this._authUnsub();
|
||||
this._authUnsub = auth.subscribe(() => {
|
||||
|
|
|
|||
|
|
@ -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-piece-click', '/assets/fx/piece-click.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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||