Compare commits
4 Commits
e2b28b6c0e
...
25ccdb5da9
| Author | SHA1 | Date |
|---|---|---|
|
|
25ccdb5da9 | |
|
|
aa6fce0f6c | |
|
|
6abcbfa32b | |
|
|
5eb5c71ebf |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -24,6 +24,7 @@ const MARKET_X = 80; // left edge of decks
|
|||
const CARDS_X = MARKET_X + DECK_W + 24; // first face-up card
|
||||
const TIER_Y = { 3: 168, 2: 168 + CH + 26, 1: 168 + (CH + 26) * 2 };
|
||||
const NOBLE = 96, NOBLE_Y = 56;
|
||||
const NOBLE_THUMB = 34, NOBLE_THUMB_GAP = 4; // square noble thumbnails in player panels
|
||||
|
||||
const BANK_X = 830, BANK_Y0 = 176, BANK_STEP = 88, TOKEN_R = 36;
|
||||
|
||||
|
|
@ -32,6 +33,17 @@ const CONTROL_Y = 794; // bottom context bar
|
|||
|
||||
const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 };
|
||||
|
||||
// ── bought-card thumbnails in player panels ───────────────────────────────────
|
||||
const THUMB_W = 44;
|
||||
const THUMB_H = Math.round(THUMB_W * CH / CW); // ≈ 60 — maintains card aspect ratio
|
||||
const THUMB_GAP = 4; // horizontal gap between cards
|
||||
const THUMB_ROW_GAP = 3; // vertical gap between rows
|
||||
const PORTRAIT_R = 34; // must match buildPortraits
|
||||
const THUMB_AREA_REL_X = PORTRAIT_R * 2 + 16 + 8; // portrait right-edge + 8 px left padding
|
||||
const THUMB_GEM_REL_X = PANEL_W - 16 - 14 - 5 * 36 - 14; // gem-row left edge, panel-relative
|
||||
const THUMB_PER_ROW = Math.floor((THUMB_GEM_REL_X - THUMB_AREA_REL_X) / (THUMB_W + THUMB_GAP));
|
||||
const THUMB_ROW_TOP_REL = 38; // panel-relative top (aligns with portrait top)
|
||||
|
||||
const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6);
|
||||
|
||||
export default class SplendorGame extends Phaser.Scene {
|
||||
|
|
@ -47,9 +59,15 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
this.busy = false;
|
||||
this.selection = []; // colours chosen for a take action
|
||||
this.selectedCard = null; // { card, source } chosen to buy/reserve
|
||||
this.dyn = []; // dynamic objects rebuilt every render
|
||||
this.preview = []; // hover card preview — cleared separately
|
||||
this.portraits = []; // one per seat, created once
|
||||
this.dyn = []; // dynamic objects rebuilt every render
|
||||
this.preview = []; // hover card preview — cleared separately
|
||||
this.portraits = []; // one per seat, created once
|
||||
this.animatingReserve = null; // { seat, cardId } suppresses that pill during animation
|
||||
this.animatingBuy = null; // { seat, cardId } suppresses thumbnail during animation
|
||||
this.boughtCards = {}; // seat → card[] — persists purchased cards for display
|
||||
this._thumbHoverTimer = null; // pending 500 ms intent timer for thumbnail preview
|
||||
this.animatingNoble = null; // { seat, nobleId } suppresses noble thumbnail during animation
|
||||
this._pendingNoble = null; // { seat, noble, srcX, srcY } queued noble animation
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -94,7 +112,7 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
this.portraits[idx] = createPlayerPortrait(this, px, py, portraitR, DEPTH.ui + 1, 'SplendorGame');
|
||||
} else {
|
||||
const opp = this.opponents[idx - 1];
|
||||
this.portraits[idx] = createOpponentPortrait(this, opp, px, py, portraitR, DEPTH.ui + 1, { playIntro: false });
|
||||
this.portraits[idx] = createOpponentPortrait(this, opp, px, py, portraitR, DEPTH.ui + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -114,15 +132,26 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
|
||||
new Button(this, GAME_WIDTH - 96, 40, 'Leave', () => this.scene.start('GameMenu'),
|
||||
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── render: rebuild all dynamic visuals from this.gs ─────────────────────────
|
||||
reg(obj) { this.dyn.push(obj); return obj; }
|
||||
|
||||
regMaskedImg(x, y, w, h, key, frame, depth, r = 10) {
|
||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff).fillRoundedRect(x, y, w, h, r);
|
||||
this.dyn.push({ destroy: () => maskG.destroy() });
|
||||
return this.reg(
|
||||
this.add.image(x + w / 2, y + h / 2, key, frame)
|
||||
.setDisplaySize(w, h).setMask(maskG.createGeometryMask()).setDepth(depth)
|
||||
);
|
||||
}
|
||||
|
||||
clearDyn() {
|
||||
this.clearPreview();
|
||||
if (this._thumbHoverTimer) { this._thumbHoverTimer.remove(); this._thumbHoverTimer = null; }
|
||||
for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } }
|
||||
this.dyn = [];
|
||||
}
|
||||
|
|
@ -132,6 +161,76 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
this.preview = [];
|
||||
}
|
||||
|
||||
findCardBoardPos(cardId) {
|
||||
for (const tier of [1, 2, 3]) {
|
||||
const col = this.gs.board[tier].findIndex((c) => c?.id === cardId);
|
||||
if (col !== -1) {
|
||||
return { x: CARDS_X + col * (CW + CARD_GAP) + CW / 2, y: TIER_Y[tier] + CH / 2 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findCard(cardId) {
|
||||
for (const tier of [1, 2, 3]) {
|
||||
const c = this.gs.board[tier].find((c) => c?.id === cardId);
|
||||
if (c) return c;
|
||||
}
|
||||
for (const p of this.gs.players) {
|
||||
const c = p.reserved.find((c) => c?.id === cardId);
|
||||
if (c) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wraps applyAction and detects if a noble was earned this turn.
|
||||
_applyAction(action) {
|
||||
const prevNobles = this.gs.nobles.slice();
|
||||
const prevCounts = this.gs.players.map((p) => p.nobles.length);
|
||||
const next = applyAction(this.gs, action);
|
||||
this.gs = next;
|
||||
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
||||
if (this.gs.players[seat].nobles.length > prevCounts[seat]) {
|
||||
const claimed = prevNobles.find((pn) => !this.gs.nobles.some((n) => n.id === pn.id));
|
||||
if (claimed) {
|
||||
const si = prevNobles.indexOf(claimed);
|
||||
this._pendingNoble = {
|
||||
seat,
|
||||
noble: claimed,
|
||||
srcX: MARKET_X + si * (NOBLE + 14) + NOBLE / 2,
|
||||
srcY: NOBLE_Y + NOBLE / 2,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nobleThumbPos(seat, nobleIdx) {
|
||||
const n = this.gs.players.length;
|
||||
const gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const panelY = 76 + seat * (h + gap);
|
||||
const totalCY = panelY + 70 + 57;
|
||||
const nobleCount = this.gs.players[seat].nobles.length;
|
||||
const groupW = nobleCount * NOBLE_THUMB + Math.max(0, nobleCount - 1) * NOBLE_THUMB_GAP;
|
||||
const tx = PANEL_X + PANEL_W - 16 - groupW + nobleIdx * (NOBLE_THUMB + NOBLE_THUMB_GAP);
|
||||
const ty = totalCY + 14 + 3;
|
||||
return { cx: tx + NOBLE_THUMB / 2, cy: ty + NOBLE_THUMB / 2 };
|
||||
}
|
||||
|
||||
boughtCardThumbPos(seat, idx) {
|
||||
const n = this.gs.players.length;
|
||||
const gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const panelY = 76 + seat * (h + gap);
|
||||
const col = idx % THUMB_PER_ROW;
|
||||
const row = Math.floor(idx / THUMB_PER_ROW);
|
||||
const tx = PANEL_X + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP);
|
||||
const ty = panelY + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP);
|
||||
return { cx: tx + THUMB_W / 2, cy: ty + THUMB_H / 2 };
|
||||
}
|
||||
|
||||
showCardPreview(card, barCenterX, barBottomY) {
|
||||
this.clearPreview();
|
||||
const pw = 270, ph = 390;
|
||||
|
|
@ -153,12 +252,16 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
if (!this.hasArt) {
|
||||
g.fillStyle(tint, 0.9).fillRoundedRect(px, py, pw, 65, { tl: 10, tr: 10, bl: 0, br: 0 });
|
||||
}
|
||||
g.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeRoundedRect(px, py, pw, ph, 10);
|
||||
|
||||
if (this.hasArt) {
|
||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff).fillRoundedRect(px, py, pw, ph, 10);
|
||||
this.preview.push({ destroy: () => maskG.destroy() });
|
||||
rp(this.add.image(px + pw / 2, py + ph / 2, 'splendor-cards', cardFrame(card))
|
||||
.setDisplaySize(pw, ph).setDepth(DEPTH.popup));
|
||||
.setDisplaySize(pw, ph).setMask(maskG.createGeometryMask()).setDepth(DEPTH.popup));
|
||||
}
|
||||
// Border drawn above art
|
||||
rp(this.add.graphics().setDepth(DEPTH.popup + 1))
|
||||
.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeRoundedRect(px, py, pw, ph, 10);
|
||||
|
||||
// Points
|
||||
if (card.points > 0) {
|
||||
|
|
@ -206,12 +309,13 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
this.gs.nobles.forEach((n, i) => {
|
||||
const x = MARKET_X + i * (NOBLE + 14);
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
|
||||
g.fillStyle(0x000000, 0.45).fillRoundedRect(x + 3, NOBLE_Y + 4, NOBLE, NOBLE, 10);
|
||||
g.fillStyle(0x2b2620, 1).fillRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
|
||||
g.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
|
||||
if (this.hasArt) {
|
||||
this.reg(this.add.image(x + NOBLE / 2, NOBLE_Y + NOBLE / 2, 'splendor-cards', nobleFrame(n))
|
||||
.setDisplaySize(NOBLE, NOBLE).setDepth(DEPTH.card + 1));
|
||||
this.regMaskedImg(x, NOBLE_Y, NOBLE, NOBLE, 'splendor-cards', nobleFrame(n), DEPTH.card + 1);
|
||||
}
|
||||
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
|
||||
.lineStyle(1.5, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
|
||||
// prestige (always drawn over art)
|
||||
this.reg(this.add.text(x + 8, NOBLE_Y + 4, '3', {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
|
||||
|
|
@ -238,13 +342,15 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
// deck pile
|
||||
const deckN = this.gs.decks[tier].length;
|
||||
const dg = this.reg(this.add.graphics().setDepth(DEPTH.card));
|
||||
dg.fillStyle(0x000000, 0.45).fillRoundedRect(MARKET_X + 3, y + 4, DECK_W, CH, 10);
|
||||
dg.fillStyle(tier === 3 ? 0x2a3550 : tier === 2 ? 0x3a4a2a : 0x4a3a2a, 1)
|
||||
.fillRoundedRect(MARKET_X, y, DECK_W, CH, 10);
|
||||
dg.lineStyle(2, 0x000000, 0.6).strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
|
||||
if (deckN > 0 && this.hasArt) {
|
||||
this.reg(this.add.image(MARKET_X + DECK_W / 2, y + CH / 2, 'splendor-cards', deckBackFrame(tier))
|
||||
.setDisplaySize(DECK_W, CH).setDepth(DEPTH.card + 1));
|
||||
this.regMaskedImg(MARKET_X, y, DECK_W, CH, 'splendor-cards', deckBackFrame(tier), DEPTH.card + 1);
|
||||
}
|
||||
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
|
||||
.lineStyle(1.5, tier === 3 ? 0x4a6090 : tier === 2 ? 0x5a7040 : 0x705030, 0.8)
|
||||
.strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
|
||||
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + 30, '★'.repeat(tier), {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.card + 2));
|
||||
|
|
@ -274,18 +380,20 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
// Card background — always draw the tinted rect (provides border shape and colour identity)
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.card + 1));
|
||||
const tint = GEM_HEX[card.bonus];
|
||||
g.fillStyle(0x000000, 0.45).fillRoundedRect(x + 3, y + 4, CW, CH, 10);
|
||||
g.fillStyle(0x14110b, 1).fillRoundedRect(x, y, CW, CH, 10);
|
||||
g.fillStyle(tint, 0.20).fillRoundedRect(x, y, CW, CH, 10);
|
||||
if (!this.hasArt) {
|
||||
g.fillStyle(tint, 0.9).fillRoundedRect(x, y, CW, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
|
||||
}
|
||||
g.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
|
||||
|
||||
// Spritesheet background art drawn over the tinted rect when available
|
||||
// Spritesheet art clipped to rounded rect
|
||||
if (this.hasArt) {
|
||||
this.reg(this.add.image(x + CW / 2, y + CH / 2, 'splendor-cards', cardFrame(card))
|
||||
.setDisplaySize(CW, CH).setDepth(DEPTH.card + 1));
|
||||
this.regMaskedImg(x, y, CW, CH, 'splendor-cards', cardFrame(card), DEPTH.card + 1);
|
||||
}
|
||||
// Border drawn on top of art
|
||||
this.reg(this.add.graphics().setDepth(DEPTH.card + 2))
|
||||
.lineStyle(1.5, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
|
||||
|
||||
// Vector overlays — always drawn on top of whatever background is present
|
||||
if (card.points > 0) {
|
||||
|
|
@ -366,8 +474,13 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
}).setOrigin(1, 0).setDepth(DEPTH.card + 3));
|
||||
}
|
||||
if (this.isHumanTurn() && color !== GOLD && n > 0) {
|
||||
const ring = this.reg(this.add.graphics().setDepth(DEPTH.card + 3).setAlpha(0));
|
||||
ring.lineStyle(4, 0xffffff, 1).strokeCircle(cx, cy, TOKEN_R + 7);
|
||||
|
||||
const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 3));
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 4));
|
||||
zone.on('pointerover', () => ring.setAlpha(1));
|
||||
zone.on('pointerout', () => ring.setAlpha(0));
|
||||
zone.on('pointerdown', () => this.onTokenClick(color));
|
||||
}
|
||||
});
|
||||
|
|
@ -399,9 +512,10 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
|
||||
// gem cells: cards (bonus) over tokens — compact, right-aligned
|
||||
const order = [...GEMS, GOLD];
|
||||
const gemR = 14;
|
||||
const gemR = 10;
|
||||
const totalR = 14;
|
||||
const gemSpacing = 36;
|
||||
const gemStartX = x + w - 16 - gemR - (order.length - 1) * gemSpacing;
|
||||
const gemStartX = x + w - 16 - totalR - (order.length - 1) * gemSpacing;
|
||||
order.forEach((color, ci) => {
|
||||
const ccx = gemStartX + ci * gemSpacing;
|
||||
const ccy = y + 70;
|
||||
|
|
@ -409,14 +523,14 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
const toks = p.tokens[color] ?? 0;
|
||||
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, gemR);
|
||||
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, gemR);
|
||||
cg.lineStyle(1.5, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, gemR);
|
||||
if (color !== GOLD) {
|
||||
this.reg(this.add.text(ccx, ccy, String(cards), {
|
||||
fontFamily: 'Righteous', fontSize: '14px',
|
||||
fontFamily: 'Righteous', fontSize: '12px',
|
||||
color: color === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
}
|
||||
this.reg(this.add.text(ccx, ccy + 24, `${toks}`, {
|
||||
this.reg(this.add.text(ccx, ccy + 22, `${toks}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
// discard interactivity (human, over limit)
|
||||
|
|
@ -432,22 +546,85 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
// divider + total row (card bonuses + tokens)
|
||||
const ccy = y + 70;
|
||||
const divY = ccy + 38;
|
||||
const totalCY = ccy + 53;
|
||||
const totalCY = ccy + 57;
|
||||
const divG = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
divG.lineStyle(1, COLORS.accent, 0.35)
|
||||
.lineBetween(gemStartX - gemR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + gemR + 2, divY);
|
||||
.lineBetween(gemStartX - totalR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + totalR + 2, divY);
|
||||
order.forEach((color, ci) => {
|
||||
const ccx = gemStartX + ci * gemSpacing;
|
||||
const total = (color === GOLD ? 0 : (p.bonuses[color] ?? 0)) + (p.tokens[color] ?? 0);
|
||||
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
tg.fillStyle(GEM_HEX[color], total > 0 ? 0.8 : 0.2).fillCircle(ccx, totalCY, 10);
|
||||
tg.lineStyle(1.5, GEM_EDGE[color], 1).strokeCircle(ccx, totalCY, 10);
|
||||
tg.fillStyle(GEM_HEX[color], total > 0 ? 0.8 : 0.2).fillCircle(ccx, totalCY, totalR);
|
||||
tg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, totalCY, totalR);
|
||||
this.reg(this.add.text(ccx, totalCY, String(total), {
|
||||
fontFamily: 'Righteous', fontSize: '12px',
|
||||
fontFamily: 'Righteous', fontSize: '14px',
|
||||
color: color === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
});
|
||||
|
||||
// noble thumbnails — right-aligned below the total circles row
|
||||
if (p.nobles.length > 0) {
|
||||
const nobleCount = p.nobles.length;
|
||||
const nobleGroupW = nobleCount * NOBLE_THUMB + (nobleCount - 1) * NOBLE_THUMB_GAP;
|
||||
const nobleGroupLeft = x + w - 16 - nobleGroupW;
|
||||
const nTY = totalCY + 14 + 3;
|
||||
p.nobles.forEach((noble, ni) => {
|
||||
if (this.animatingNoble?.seat === idx && this.animatingNoble?.nobleId === noble.id) return;
|
||||
const nTX = nobleGroupLeft + ni * (NOBLE_THUMB + NOBLE_THUMB_GAP);
|
||||
const ng = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
ng.fillStyle(0x000000, 0.32).fillRoundedRect(nTX + 2, nTY + 2, NOBLE_THUMB, NOBLE_THUMB, 4);
|
||||
ng.fillStyle(0x2b2620, 1).fillRoundedRect(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 4);
|
||||
if (this.hasArt) {
|
||||
this.regMaskedImg(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 'splendor-cards', nobleFrame(noble), DEPTH.ui + 2, 4);
|
||||
}
|
||||
this.reg(this.add.graphics().setDepth(DEPTH.ui + 3))
|
||||
.lineStyle(1.5, COLORS.accent, 0.85).strokeRoundedRect(nTX, nTY, NOBLE_THUMB, NOBLE_THUMB, 4);
|
||||
this.reg(this.add.text(nTX + 3, nTY + 1, '3', {
|
||||
fontFamily: 'Righteous', fontSize: '10px', color: COLORS.goldHex,
|
||||
}).setDepth(DEPTH.ui + 4));
|
||||
});
|
||||
}
|
||||
|
||||
// bought card thumbnails (right of portrait, left of gems, wrapping into rows)
|
||||
(this.boughtCards[idx] ?? []).forEach((bc, bi) => {
|
||||
if (this.animatingBuy?.seat === idx && this.animatingBuy?.cardId === bc.id) return;
|
||||
const col = bi % THUMB_PER_ROW;
|
||||
const row = Math.floor(bi / THUMB_PER_ROW);
|
||||
const tx = x + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP);
|
||||
const ty = y + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP);
|
||||
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
tg.fillStyle(0x000000, 0.32).fillRoundedRect(tx + 2, ty + 2, THUMB_W, THUMB_H, 4);
|
||||
tg.fillStyle(0x14110b, 1).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
|
||||
tg.fillStyle(GEM_HEX[bc.bonus], 0.22).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
|
||||
if (this.hasArt) {
|
||||
this.regMaskedImg(tx, ty, THUMB_W, THUMB_H, 'splendor-cards', cardFrame(bc), DEPTH.ui + 2, 4);
|
||||
}
|
||||
this.reg(this.add.graphics().setDepth(DEPTH.ui + 3))
|
||||
.lineStyle(1, GEM_EDGE[bc.bonus], 0.9).strokeRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
|
||||
if (bc.points > 0) {
|
||||
this.reg(this.add.text(tx + 3, ty + 1, String(bc.points), {
|
||||
fontFamily: 'Righteous', fontSize: '10px',
|
||||
color: bc.bonus === 'white' ? '#333' : '#fff',
|
||||
}).setDepth(DEPTH.ui + 4));
|
||||
}
|
||||
// 500 ms hover-intent preview
|
||||
const tz = this.reg(
|
||||
this.add.zone(tx + THUMB_W / 2, ty + THUMB_H / 2, THUMB_W, THUMB_H)
|
||||
.setInteractive().setDepth(DEPTH.ui + 5)
|
||||
);
|
||||
tz.on('pointerover', () => {
|
||||
this._thumbHoverTimer = this.time.delayedCall(500, () => {
|
||||
for (const p of this.portraits) p?.hide();
|
||||
this.showCardPreview(bc, tx + THUMB_W / 2, ty + THUMB_H);
|
||||
});
|
||||
});
|
||||
tz.on('pointerout', () => {
|
||||
if (this._thumbHoverTimer) { this._thumbHoverTimer.remove(); this._thumbHoverTimer = null; }
|
||||
this.clearPreview();
|
||||
for (const p of this.portraits) p?.show();
|
||||
});
|
||||
});
|
||||
|
||||
// reserved cards
|
||||
const rN = p.reserved.length;
|
||||
this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, {
|
||||
|
|
@ -456,6 +633,7 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
if (rN > 0) {
|
||||
const isHuman = idx === this.humanSeat;
|
||||
p.reserved.forEach((card, ri) => {
|
||||
if (this.animatingReserve?.seat === idx && this.animatingReserve?.cardId === card.id) return;
|
||||
const rx = x + 150 + ri * 116, ry = y + h - 40;
|
||||
const canBuy = isHuman && this.isHumanTurn() && canAfford(p, card);
|
||||
const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
|
|
@ -482,9 +660,13 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
|
||||
// ── bottom control bar ──────────────────────────────────────────────────────
|
||||
drawControlBar() {
|
||||
const x = MARKET_X, y = CONTROL_Y, w = 920, h = GAME_HEIGHT - y - 24;
|
||||
const n = this.gs?.players?.length ?? 4;
|
||||
const panelGap = 16;
|
||||
const panelH = Math.min(220, Math.floor((GAME_HEIGHT - 90 - panelGap * (n - 1)) / n));
|
||||
const lastPanelBottom = 76 + (n - 1) * (panelH + panelGap) + panelH;
|
||||
const x = MARKET_X, y = CONTROL_Y, w = 920, h = Math.max(120, lastPanelBottom - y);
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
|
||||
g.fillStyle(0x000000, 0.3).fillRoundedRect(x, y, w, h, 12);
|
||||
g.fillStyle(0x000000, 0.55).fillRoundedRect(x, y, w, h, 12);
|
||||
g.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(x, y, w, h, 12);
|
||||
|
||||
if (isGameOver(this.gs)) return;
|
||||
|
|
@ -524,10 +706,10 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
bx += 170;
|
||||
}
|
||||
if (source === 'board' && human.reserved.length < MAX_RESERVED) {
|
||||
this.addButton(bx + 80, y + 70, 'Reserve (+1 gold)',
|
||||
this.addButton(bx + 115, y + 70, 'Reserve (+1 gold)',
|
||||
() => this.applyHuman({ type: 'reserve', cardId: card.id, tier: card.tier }),
|
||||
{ width: 230, height: 46, variant: 'ghost' });
|
||||
bx += 250;
|
||||
bx += 285;
|
||||
}
|
||||
this.addButton(bx + 70, y + 70, 'Cancel', () => { this.selectedCard = null; this.render(); },
|
||||
{ width: 130, height: 46, variant: 'ghost' });
|
||||
|
|
@ -566,12 +748,9 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
|
||||
drawTurnLabel() {
|
||||
const txt = isGameOver(this.gs) ? 'Game over' : `${this.pname(this.gs.current)}'s turn`;
|
||||
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 70, txt, {
|
||||
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 48, txt, {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
|
||||
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 40, `First to ${WIN_POINTS} prestige wins`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -586,9 +765,11 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
const gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const order = [...GEMS, GOLD];
|
||||
const cellW = (PANEL_W - 32) / order.length;
|
||||
const totalR = 14;
|
||||
const gemSpacing = 36;
|
||||
const gemStartX = PANEL_X + PANEL_W - 16 - totalR - (order.length - 1) * gemSpacing;
|
||||
const ci = order.indexOf(color);
|
||||
return { x: PANEL_X + 16 + cellW * ci + cellW / 2, y: 76 + seat * (h + gap) + 70 };
|
||||
return { x: gemStartX + ci * gemSpacing, y: 76 + seat * (h + gap) + 70 };
|
||||
}
|
||||
|
||||
humanGemPos(color) { return this.playerGemPos(this.humanSeat, color); }
|
||||
|
|
@ -600,7 +781,7 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
: action.colors;
|
||||
|
||||
const preTokens = { ...this.gs.players[seat].tokens };
|
||||
this.gs = applyAction(this.gs, action);
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard') {
|
||||
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
|
||||
|
|
@ -626,6 +807,12 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
duration: 380,
|
||||
delay: i * 90,
|
||||
ease: 'Cubic.easeOut',
|
||||
onStart: () => {
|
||||
try {
|
||||
const a = new Audio(`/assets/fx/gem-0${(i % 2) + 1}.mp3`);
|
||||
a.volume = 0.7; a.play();
|
||||
} catch { /* */ }
|
||||
},
|
||||
onComplete: () => {
|
||||
gem.destroy();
|
||||
if (--pending === 0) {
|
||||
|
|
@ -653,10 +840,311 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
displayWidth: 32, displayHeight: 32,
|
||||
duration: 300,
|
||||
ease: 'Cubic.easeOut',
|
||||
onStart: () => {
|
||||
try {
|
||||
const a = new Audio(`/assets/fx/gem-0${(destIdx % 2) + 1}.mp3`);
|
||||
a.volume = 0.7; a.play();
|
||||
} catch { /* */ }
|
||||
},
|
||||
onComplete: () => gem.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── noble animation ──────────────────────────────────────────────────────────
|
||||
animNoble(seat, noble, srcX, srcY, onDone) {
|
||||
const DISP = 270;
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
|
||||
for (const p of this.portraits) p?.hide();
|
||||
|
||||
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000)
|
||||
.setAlpha(0).setDepth(DEPTH.popup);
|
||||
this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 });
|
||||
|
||||
const container = this.add.container(srcX, srcY).setDepth(DEPTH.popup + 1);
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x2b2620, 1).fillRoundedRect(-NOBLE / 2, -NOBLE / 2, NOBLE, NOBLE, 10);
|
||||
container.add(bg);
|
||||
if (this.hasArt) {
|
||||
container.add(
|
||||
this.add.image(0, 0, 'splendor-cards', nobleFrame(noble)).setDisplaySize(NOBLE, NOBLE)
|
||||
);
|
||||
}
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(2, COLORS.accent, 0.9).strokeRoundedRect(-NOBLE / 2, -NOBLE / 2, NOBLE, NOBLE, 10);
|
||||
container.add(border);
|
||||
container.add(this.add.text(-NOBLE / 2 + 8, -NOBLE / 2 + 4, '3', {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
|
||||
}));
|
||||
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: cx, y: cy,
|
||||
scale: DISP / NOBLE,
|
||||
duration: 420,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
this._playReserveFireworks(cx, cy, 'white');
|
||||
try { const a = new Audio('/assets/fx/firework.mp3'); a.volume = 0.7; a.play(); } catch { /* */ }
|
||||
|
||||
this.time.delayedCall(1200, () => {
|
||||
const ni = this.gs.players[seat].nobles.findIndex((n) => n.id === noble.id);
|
||||
const { cx: thumbCX, cy: thumbCY } = this.nobleThumbPos(seat, ni >= 0 ? ni : 0);
|
||||
|
||||
this.tweens.add({ targets: overlay, alpha: 0, duration: 300,
|
||||
onComplete: () => { try { overlay.destroy(); } catch { /* */ } } });
|
||||
try { const a = new Audio('/assets/fx/ui-attach.mp3'); a.volume = 0.8; a.play(); } catch { /* */ }
|
||||
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: thumbCX, y: thumbCY,
|
||||
scale: NOBLE_THUMB / NOBLE,
|
||||
alpha: 0,
|
||||
duration: 360,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
for (const p of this.portraits) p?.show();
|
||||
container.removeAll(true);
|
||||
container.destroy();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Builds a card container centred at (x,y) in world space with full overlay art.
|
||||
// All draw coords are in container-local space (0,0 = card centre).
|
||||
_buildCardContainer(x, y, card) {
|
||||
const container = this.add.container(x, y).setDepth(DEPTH.popup + 1);
|
||||
const tint = GEM_HEX[card.bonus];
|
||||
|
||||
// Background
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x14110b, 1).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
|
||||
bg.fillStyle(tint, 0.22).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
|
||||
container.add(bg);
|
||||
|
||||
// Art
|
||||
if (this.hasArt) {
|
||||
container.add(
|
||||
this.add.image(0, 0, 'splendor-cards', cardFrame(card)).setDisplaySize(CW, CH)
|
||||
);
|
||||
}
|
||||
|
||||
// Border above art
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(1.5, GEM_EDGE[card.bonus], 1).strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
|
||||
container.add(border);
|
||||
|
||||
// Points — top-left
|
||||
if (card.points > 0) {
|
||||
container.add(this.add.text(-CW / 2 + 10, -CH / 2 + 2, String(card.points), {
|
||||
fontFamily: 'Righteous', fontSize: '26px',
|
||||
color: card.bonus === 'white' ? '#222' : '#fff',
|
||||
}));
|
||||
}
|
||||
|
||||
// Gem bonus badge — top-right
|
||||
const badgeG = this.add.graphics();
|
||||
badgeG.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(CW / 2 - 22, -CH / 2 + 15, 12);
|
||||
badgeG.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(CW / 2 - 22, -CH / 2 + 15, 12);
|
||||
container.add(badgeG);
|
||||
if (this.hasGems) {
|
||||
container.add(
|
||||
this.add.image(CW / 2 - 22, -CH / 2 + 15, 'splendor-gems', gemFrame(card.bonus))
|
||||
.setDisplaySize(18, 18)
|
||||
);
|
||||
}
|
||||
|
||||
// Cost pips — lower-left, stacked
|
||||
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
|
||||
costs.forEach((c, i) => {
|
||||
const pipY = CH / 2 - 22 - i * 30;
|
||||
const pipG = this.add.graphics();
|
||||
pipG.fillStyle(GEM_HEX[c], 1).fillCircle(-CW / 2 + 20, pipY, 12);
|
||||
pipG.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(-CW / 2 + 20, pipY, 12);
|
||||
container.add(pipG);
|
||||
container.add(this.add.text(-CW / 2 + 20, pipY, String(card.cost[c]), {
|
||||
fontFamily: 'Righteous', fontSize: '15px',
|
||||
color: c === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── reserve animation ───────────────────────────────────────────────────────
|
||||
animReserve(seat, card, srcX, srcY, onDone) {
|
||||
const PW = 270, PH = 390;
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
|
||||
// DOM video portraits render above the canvas; hide them for the duration
|
||||
for (const p of this.portraits) p?.hide();
|
||||
|
||||
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000)
|
||||
.setAlpha(0).setDepth(DEPTH.popup);
|
||||
this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 });
|
||||
|
||||
const container = this._buildCardContainer(srcX, srcY, card);
|
||||
|
||||
// Phase 1: fly to centre and grow
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: cx, y: cy,
|
||||
scaleX: PW / CW, scaleY: PH / CH,
|
||||
duration: 420,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
this._playReserveFireworks(cx, cy, card.bonus);
|
||||
|
||||
// Phase 2: hold 1.2 s, then shrink to reserve-pill slot
|
||||
this.time.delayedCall(1200, () => {
|
||||
const n = this.gs.players.length;
|
||||
const gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const panelY = 76 + seat * (h + gap);
|
||||
const ri = this.gs.players[seat].reserved.findIndex((c) => c.id === card.id);
|
||||
const pillCX = PANEL_X + 150 + Math.max(0, ri) * 116 + 52;
|
||||
const pillCY = panelY + h - 40 + 15;
|
||||
|
||||
this.tweens.add({ targets: overlay, alpha: 0, duration: 300,
|
||||
onComplete: () => { try { overlay.destroy(); } catch { /* */ } } });
|
||||
|
||||
try { const a = new Audio('/assets/fx/ui-attach.mp3'); a.volume = 0.8; a.play(); } catch { /* */ }
|
||||
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: pillCX, y: pillCY,
|
||||
scale: 0.08,
|
||||
alpha: 0,
|
||||
duration: 360,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
for (const p of this.portraits) p?.show();
|
||||
container.removeAll(true);
|
||||
container.destroy();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_playReserveFireworks(cx, cy, bonus) {
|
||||
// 5 distinct bursts staggered in time, each at a random position around the card
|
||||
const tint = GEM_HEX[bonus];
|
||||
const edge = GEM_EDGE[bonus];
|
||||
const palettes = [
|
||||
[tint, edge, 0xffffff],
|
||||
[0xffd700, 0xffbb00, 0xffffff],
|
||||
[tint, 0xffffff, edge],
|
||||
[0xff6644, 0xffaa00, 0xffd700],
|
||||
[edge, tint, 0xaaffdd],
|
||||
];
|
||||
|
||||
const BURSTS = 5;
|
||||
// Card at center is 270×390; place bursts in a ring around it
|
||||
const RX = 195, RY = 240; // semi-axes of the ring (just outside card edges)
|
||||
|
||||
for (let b = 0; b < BURSTS; b++) {
|
||||
const delay = b * 190 + Math.random() * 70;
|
||||
// Spread bursts evenly around the card with some jitter
|
||||
const angle = (b / BURSTS) * Math.PI * 2 + (Math.random() - 0.5) * 0.7;
|
||||
const rScale = 1.05 + Math.random() * 0.55;
|
||||
const bx = cx + Math.cos(angle) * RX * rScale;
|
||||
const by = cy + Math.sin(angle) * RY * rScale;
|
||||
const palette = palettes[b % palettes.length];
|
||||
const sparkCount = 16 + Math.floor(Math.random() * 8);
|
||||
|
||||
this.time.delayedCall(delay, () => {
|
||||
try { const fw = new Audio('/assets/fx/firework.mp3'); fw.volume = 0.7; fw.play(); } catch { /* */ }
|
||||
|
||||
// Central flash — expands and fades
|
||||
const flash = this.add.circle(bx, by, 7, palette[0], 1).setDepth(DEPTH.popup + 2);
|
||||
this.tweens.add({
|
||||
targets: flash, scaleX: 4.5, scaleY: 4.5, alpha: 0,
|
||||
duration: 260, ease: 'Cubic.easeOut',
|
||||
onComplete: () => { try { flash.destroy(); } catch { /* */ } },
|
||||
});
|
||||
|
||||
// Sparks radiate outward from the burst point
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
const sparkAngle = (i / sparkCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.3;
|
||||
const speed = 90 + Math.random() * 170;
|
||||
const size = 2.5 + Math.random() * 5;
|
||||
const color = palette[Math.floor(Math.random() * palette.length)];
|
||||
const spark = this.add.circle(bx, by, size, color, 1).setDepth(DEPTH.popup + 2);
|
||||
this.tweens.add({
|
||||
targets: spark,
|
||||
x: bx + Math.cos(sparkAngle) * speed,
|
||||
y: by + Math.sin(sparkAngle) * speed + speed * 0.28, // slight gravity droop
|
||||
scaleX: 0.12, scaleY: 0.12,
|
||||
alpha: 0,
|
||||
duration: 600 + Math.random() * 480,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => { try { spark.destroy(); } catch { /* */ } },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── buy animation ────────────────────────────────────────────────────────────
|
||||
animBuy(seat, card, srcX, srcY, onDone) {
|
||||
if (!this.hasArt) { onDone(); return; }
|
||||
|
||||
const PW = 270, PH = 390;
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
|
||||
for (const p of this.portraits) p?.hide();
|
||||
|
||||
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000)
|
||||
.setAlpha(0).setDepth(DEPTH.popup);
|
||||
this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 });
|
||||
|
||||
const container = this._buildCardContainer(srcX, srcY, card);
|
||||
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: cx, y: cy,
|
||||
scaleX: PW / CW, scaleY: PH / CH,
|
||||
duration: 420,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
this._playReserveFireworks(cx, cy, card.bonus);
|
||||
|
||||
this.time.delayedCall(1200, () => {
|
||||
const bought = this.boughtCards[seat] ?? [];
|
||||
const bi = bought.findIndex((c) => c.id === card.id);
|
||||
const { cx: thumbCX, cy: thumbCY } = this.boughtCardThumbPos(seat, bi >= 0 ? bi : bought.length - 1);
|
||||
|
||||
this.tweens.add({ targets: overlay, alpha: 0, duration: 300,
|
||||
onComplete: () => { try { overlay.destroy(); } catch { /* */ } } });
|
||||
|
||||
try { const a = new Audio('/assets/fx/ui-attach.mp3'); a.volume = 0.8; a.play(); } catch { /* */ }
|
||||
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: thumbCX, y: thumbCY,
|
||||
scaleX: THUMB_W / CW, scaleY: THUMB_H / CH,
|
||||
alpha: 0,
|
||||
duration: 360,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
for (const p of this.portraits) p?.show();
|
||||
container.removeAll(true);
|
||||
container.destroy();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── human input ─────────────────────────────────────────────────────────────
|
||||
onTokenClick(color) {
|
||||
if (!this.isHumanTurn()) return;
|
||||
|
|
@ -698,7 +1186,7 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
|
||||
// Save token state before and after applying the action
|
||||
const preTokens = { ...this.gs.players[this.humanSeat].tokens };
|
||||
this.gs = applyAction(this.gs, action);
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
const postTokens = this.gs.players[this.humanSeat].tokens;
|
||||
|
||||
|
|
@ -728,6 +1216,12 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
duration: 380,
|
||||
delay: i * 90,
|
||||
ease: 'Cubic.easeOut',
|
||||
onStart: () => {
|
||||
try {
|
||||
const a = new Audio(`/assets/fx/gem-0${(i % 2) + 1}.mp3`);
|
||||
a.volume = 0.7; a.play();
|
||||
} catch { /* */ }
|
||||
},
|
||||
onComplete: () => {
|
||||
gem.destroy();
|
||||
if (--pending === 0) {
|
||||
|
|
@ -778,10 +1272,74 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
applyHuman(action) {
|
||||
this.selectedCard = null;
|
||||
this.selection = [];
|
||||
this.gs = applyAction(this.gs, action);
|
||||
|
||||
if (this.hasArt && action.type === 'buy') {
|
||||
const seat = this.humanSeat;
|
||||
const card = this.findCard(action.cardId);
|
||||
const boardPos = this.findCardBoardPos(action.cardId);
|
||||
let srcX, srcY;
|
||||
if (boardPos) {
|
||||
srcX = boardPos.x; srcY = boardPos.y;
|
||||
} else {
|
||||
const n = this.gs.players.length, gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const panelY = 76 + seat * (h + gap);
|
||||
const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId));
|
||||
srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
|
||||
}
|
||||
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard' && this.gs.current === seat) { this.render(); return; }
|
||||
|
||||
if (!this.boughtCards[seat]) this.boughtCards[seat] = [];
|
||||
this.boughtCards[seat].push(card);
|
||||
this.animatingBuy = { seat, cardId: card.id };
|
||||
this.busy = true;
|
||||
this.render();
|
||||
|
||||
this.animBuy(seat, card, srcX, srcY, () => {
|
||||
this.animatingBuy = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) {
|
||||
const srcPos = action.type === 'reserve'
|
||||
? this.findCardBoardPos(action.cardId)
|
||||
: { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 };
|
||||
const srcX = srcPos?.x ?? GAME_WIDTH / 2;
|
||||
const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
|
||||
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const reserved = this.gs.players[this.humanSeat].reserved;
|
||||
const card = reserved[reserved.length - 1];
|
||||
this.animatingReserve = { seat: this.humanSeat, cardId: card.id };
|
||||
this.busy = true;
|
||||
this.render();
|
||||
|
||||
this.animReserve(this.humanSeat, card, srcX, srcY, () => {
|
||||
this.animatingReserve = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
|
||||
this.render(); // hand over to the discard UI
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
|
|
@ -790,6 +1348,20 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
|
||||
// ── turn loop ───────────────────────────────────────────────────────────────
|
||||
advance() {
|
||||
if (this._pendingNoble) {
|
||||
const { seat, noble, srcX, srcY } = this._pendingNoble;
|
||||
this._pendingNoble = null;
|
||||
this.animatingNoble = { seat, nobleId: noble.id };
|
||||
this.busy = true;
|
||||
this.render();
|
||||
this.animNoble(seat, noble, srcX, srcY, () => {
|
||||
this.animatingNoble = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; }
|
||||
if (this.gs.phase === 'discard') {
|
||||
// Only the human ever parks here interactively; AI auto-discards inline.
|
||||
|
|
@ -819,7 +1391,73 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
|
||||
this.gs = applyAction(this.gs, action);
|
||||
if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) {
|
||||
const srcPos = action.type === 'reserve'
|
||||
? this.findCardBoardPos(action.cardId)
|
||||
: { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 };
|
||||
const srcX = srcPos?.x ?? GAME_WIDTH / 2;
|
||||
const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
|
||||
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard') {
|
||||
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
|
||||
}
|
||||
|
||||
const reserved = this.gs.players[seat].reserved;
|
||||
const card = reserved[reserved.length - 1];
|
||||
this.animatingReserve = { seat, cardId: card.id };
|
||||
this.render();
|
||||
|
||||
this.animReserve(seat, card, srcX, srcY, () => {
|
||||
this.animatingReserve = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasArt && action.type === 'buy') {
|
||||
const card = this.findCard(action.cardId);
|
||||
const boardPos = this.findCardBoardPos(action.cardId);
|
||||
let srcX, srcY;
|
||||
if (boardPos) {
|
||||
srcX = boardPos.x; srcY = boardPos.y;
|
||||
} else {
|
||||
const n = this.gs.players.length, gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
const panelY = 76 + seat * (h + gap);
|
||||
const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId));
|
||||
srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
|
||||
}
|
||||
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard') {
|
||||
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
|
||||
}
|
||||
|
||||
if (card.points > 0) this.portraits[seat]?.playEmotion('happy');
|
||||
if (!this.boughtCards[seat]) this.boughtCards[seat] = [];
|
||||
this.boughtCards[seat].push(card);
|
||||
this.animatingBuy = { seat, cardId: card.id };
|
||||
this.render();
|
||||
|
||||
this.animBuy(seat, card, srcX, srcY, () => {
|
||||
this.animatingBuy = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'buy') {
|
||||
const card = this.findCard(action.cardId);
|
||||
if (card?.points > 0) this.portraits[seat]?.playEmotion('happy');
|
||||
}
|
||||
this._applyAction(action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard') {
|
||||
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
|
||||
|
|
@ -834,7 +1472,7 @@ export default class SplendorGame extends Phaser.Scene {
|
|||
if (!action) return;
|
||||
if (action.type === 'buy') playSound(this, SFX.PURCHASE);
|
||||
else if (action.type === 'reserve' || action.type === 'reserveDeck') playSound(this, SFX.CARD_DEAL);
|
||||
else if (action.type === 'take2' || action.type === 'take3') playSound(this, SFX.COINS);
|
||||
// gem sounds played per-gem in animAITake / confirmTake
|
||||
}
|
||||
|
||||
showTurnBanner(text) {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
|
||||
}
|
||||
|
||||
if (this.gameDef.cardGame) {
|
||||
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
|
||||
this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
|
||||
'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb),
|
||||
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP);
|
||||
|
|
@ -150,7 +150,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
const pfd = this.cache.json.get('playfields') ?? {};
|
||||
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
|
||||
}
|
||||
if (this.gameDef.cardGame) {
|
||||
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
|
||||
const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
|
||||
if (cardBacks.length > 0) {
|
||||
const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)];
|
||||
|
|
|
|||
Loading…
Reference in New Issue