fertig-classic-games/public/src/games/dominion/DominionGame.js

2111 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getCard, isType } from './DominionCards.js';
import {
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
buyCard, endTurn, resolvePending, isGameOver, finalScores,
legalActionIids, canGain, emptyPileCount,
} from './DominionLogic.js';
import * as AI from './DominionAI.js';
const CX = GAME_WIDTH / 2;
const D = { bg: -1, supply: 5, inplay: 6, hand: 10, hud: 15, portrait: 20, prompt: 40, modal: 80, popup: 95 };
// Returns the screen (x, y) at distance d along the clockwise perimeter of a
// rectangle with top-left corner (left, top) and dimensions W×H.
function perimPoint(d, W, H, left, top) {
const perim = 2 * (W + H);
d = ((d % perim) + perim) % perim;
if (d < W) return [left + d, top ];
d -= W;
if (d < H) return [left + W, top + d];
d -= H;
if (d < W) return [left + W - d, top + H];
d -= W;
return [left, top + H - d];
}
const SUPPLY_W = 100, SUPPLY_H = 144;
const HAND_W = 132, HAND_H = 190;
const PLAY_W = 78, PLAY_H = 112;
const DECK_PILE_X = 240, DECK_PILE_Y = 968;
const DISCARD_PILE_X = 1704, DISCARD_PILE_Y = 968; // right edge aligns with opponent portrait center (x=1770)
const OPP_W = 36, OPP_H = 52;
const AI_STEP_MS = 420;
const AI_PENDING_MS = 520;
// Card-face colour styling by type.
function typeStyle(def) {
if (!def) return { art: 0x2a2a40, border: 0x554f38, band: 0x14110b };
if (isType(def.id, 'curse')) return { art: 0x6a3d8f, border: 0x9b59b6, band: 0x1c1226 };
if (isType(def.id, 'treasure')) {
const art = def.id === 'gold' ? 0xd4af37 : def.id === 'silver' ? 0xb8bcc2 : 0xb87333;
return { art, border: 0xc8a84b, band: 0x231b0e };
}
if (isType(def.id, 'victory')) return { art: 0x2e7d4f, border: 0x3fa86a, band: 0x10241a };
if (isType(def.id, 'attack')) return { art: 0xbb9a6a, border: 0xd0563b, band: 0x231a12 };
if (isType(def.id, 'reaction')) return { art: 0x8fb0cf, border: 0x4a90d9, band: 0x141d27 };
return { art: 0xcdbb8f, border: 0xc8a84b, band: 0x231f17 }; // action
}
function typeLine(def) {
return def.types.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' ');
}
export default class DominionGame extends Phaser.Scene {
constructor() { super('DominionGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.cardBack = data.cardBack ?? null;
this.playfield = data.playfield ?? null;
this.deckMode = data.deckMode ?? 'standard';
this.playerCount = this.opponents.length + 1;
this.gs = null;
this.gameOver = false;
this.handSprites = [];
this.supplySprites = [];
this.portraits = [];
this.hoverVisible = false;
this.hoverTimer = null;
this.promptObjs = [];
this.selection = new Set();
this._animating = false;
this._pendingAnimState = null;
this._animatingIids = new Set();
this.inPlaySprites = [];
this._dragState = null;
this._dragPotential = null;
this._dragJustEnded = false;
this._dragDropZone = null;
this._dragDropLabel = null;
this._handOrder = null;
this.oppHandSprites = {};
this.oppInPlaySprites = {};
this.phaseDials = [];
this.turnArrow = null;
this._arrowSeat = null;
this._boughtThisTurn = false;
this._suppressTurnUi = false;
this._handFxGraphics = [];
this._handFxTweens = [];
this._supplyFxEmitters = [];
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this._liveDeckPile = null;
this._liveDeckBadge = null;
this._liveDeckCountText = null;
this._liveDiscardPile = null;
this._liveDiscardBadge = null;
this._liveDiscardCountText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
if (!this.textures.exists('dominion-sparkle')) {
const g = this.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillCircle(3, 3, 3);
g.generateTexture('dominion-sparkle', 6, 6);
g.destroy();
}
this.buildBackground();
this.buildPortraits();
this.buildTurnArrow();
this.buildPhaseDials();
this.buildHoverPopup();
this.buildButtons();
this.animLayer = this.add.container(0, 0).setDepth(D.hand + 50);
this.input.on('pointermove', (p) => {
this.lastPointer = { x: p.x, y: p.y };
if (this.hoverVisible) this.positionHover(p.x, p.y);
if (this._dragState) this._onDragMove(p);
else this._checkDragStart(p);
});
this.input.on('pointerup', (p) => {
this._dragPotential = null;
if (this._dragState) this._onDragUp(p);
});
this.input.on('gameout', () => this._cancelDrag());
this.events.once('shutdown', () => {
this.portraits.forEach((pt) => pt?.destroy?.());
this.turnArrow?.destroy();
this.phaseDials.forEach((d) => d?.destroy?.());
});
const initialState = createInitialState({
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
playerCount: this.playerCount,
deckMode: this.deckMode,
});
// Show an empty hand first, then animate the deal.
const p0 = initialState.players[0];
const preDeal = {
...initialState,
players: initialState.players.map((p, i) =>
i === 0 ? { ...p, hand: [], deck: [...p0.hand, ...p0.deck] } : p
),
};
this.gs = preDeal;
this.render();
this._deckAnimDisplay = preDeal.players[0].deck.length; // 10 before deal
this._discardAnimDisplay = 0;
this._animDrawCards(p0.hand, initialState);
}
// ── Static scaffolding ─────────────────────────────────────────────────────
buildBackground() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(CX, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
} else {
this.add.image(CX, GAME_HEIGHT / 2, 'bg-room').setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
}
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(D.bg);
}
// Opponent panels live on the right column; the human sits bottom-left.
oppSlot(i) {
const ys = this.opponents.length === 2 ? [300, 600] : [180, 430, 680];
return { x: 1770, y: ys[i] ?? 180, r: 52 };
}
buildPortraits() {
createPlayerPortrait(this, 92, 928, 56, D.portrait, 'DominionGame');
this.add.text(92, 928 + 56 + 14, this.seatName(0), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
this.opponents.forEach((opp, i) => {
const s = this.oppSlot(i);
const pt = createOpponentPortrait(this, opp, s.x, s.y, s.r, D.portrait);
this.portraits[i + 1] = pt;
this.add.text(s.x, s.y + s.r + 14, opp.name ?? `Player ${i + 2}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
});
}
buildButtons() {
this.btnEndAction = new Button(this, CX, 830, 'End Action Phase', () => this.humanEndAction(), {
width: 240, height: 48, fontSize: 20,
}).setDepth(D.hud).setVisible(false);
this.btnEndTurn = new Button(this, CX, 830, 'End Turn', () => this.humanEndTurn(), {
width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold,
textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
}).setDepth(D.hud).setVisible(false);
new Button(this, 92, 1028, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 130, height: 40, fontSize: 18,
}).setDepth(D.hud);
}
seatName(seat) {
if (seat === 0) return auth.user?.username ?? 'You';
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
}
seatSkill(seat) {
return this.opponents[seat - 1]?.skill ?? 3;
}
// ── Turn arrow + phase dials (persistent overlay) ─────────────────────────────
// Yellow turn arrow, mirroring the Settlers of Catan indicator.
buildTurnArrow() {
const g = this.add.graphics().setDepth(D.portrait + 5);
g.fillStyle(0xffdd00, 1);
g.fillTriangle(-12, -15, -12, 15, 12, 0);
g.setPosition(-9999, -9999);
this.turnArrow = g;
this._arrowSeat = null;
this.tweens.add({
targets: g, scaleX: 1.4, scaleY: 1.4, duration: 700,
yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
}
// Portrait anchor the arrow points at, by seat.
arrowSeatPos(seat) {
if (seat === 0) return { x: 92, y: 928, r: 56 };
const s = this.oppSlot(seat - 1);
return { x: s.x, y: s.y, r: s.r };
}
buildPhaseDials() {
this.phaseDials[0] = this.makePhaseDial(168, 748, 60, 60);
this.opponents.forEach((opp, i) => {
const s = this.oppSlot(i);
const r = 26;
this.phaseDials[i + 1] = this.makePhaseDial(s.x + s.r + 14 + r, s.y, r);
});
}
// A 3-wedge ring (Action/Buy/Clean Up) that spins so the active wedge locks
// under a fixed top pointer. Returns a controller with setActive/setPhase.
makePhaseDial(x, y, outerR, labelDY = 0) {
const scene = this;
const PHASE = { action: 0, buy: 1, cleanup: 2 };
const colors = [0xd0563b, 0xd4a017, 0x4a90d9];
const TWO_PI_3 = (Math.PI * 2) / 3;
const HALF = Math.PI / 3;
const TOP = -Math.PI / 2;
const container = this.add.container(x, y).setDepth(D.portrait);
const ring = this.add.container(0, 0);
container.add(ring);
const a0 = [], a1 = [], mid = [];
for (let i = 0; i < 3; i++) {
const c = TOP + i * TWO_PI_3;
a0[i] = c - HALF; a1[i] = c + HALF; mid[i] = c;
}
const wedges = [];
for (let i = 0; i < 3; i++) {
const w = this.add.graphics();
ring.add(w);
wedges.push(w);
}
const paintWedges = (activeIdx) => {
for (let i = 0; i < 3; i++) {
const w = wedges[i];
const on = i === activeIdx;
w.clear();
w.fillStyle(colors[i], on ? 0.95 : 0.26);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.fillPath();
w.lineStyle(on ? Math.max(2, outerR * 0.05) : 1.5, on ? colors[i] : COLORS.accent, on ? 1 : 0.55);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.strokePath();
}
};
paintWedges(-1);
const ic = outerR * 0.22;
const midR = outerR * 0.66;
const drawSword = (g) => {
g.fillStyle(0xf2ead8, 1);
g.fillTriangle(-0.16 * ic, -0.5 * ic, 0.16 * ic, -0.5 * ic, 0, -1.15 * ic);
g.fillRect(-0.14 * ic, -0.5 * ic, 0.28 * ic, 0.85 * ic);
g.fillStyle(0xc8a84b, 1);
g.fillRect(-0.5 * ic, 0.3 * ic, ic, 0.16 * ic);
g.fillRect(-0.12 * ic, 0.46 * ic, 0.24 * ic, 0.5 * ic);
};
const drawCoin = (g) => {
g.fillStyle(0xd4a017, 1); g.fillCircle(0, 0, 0.95 * ic);
g.lineStyle(Math.max(1, 0.14 * ic), 0x6e5410, 1); g.strokeCircle(0, 0, 0.95 * ic);
g.lineStyle(Math.max(1, 0.14 * ic), 0xfff3c4, 0.9); g.strokeCircle(0, 0, 0.5 * ic);
};
const drawRefresh = (g) => {
g.lineStyle(Math.max(1.5, 0.2 * ic), 0xf2ead8, 1);
g.beginPath(); g.arc(0, 0, 0.85 * ic, Phaser.Math.DegToRad(-50), Phaser.Math.DegToRad(200), false); g.strokePath();
const end = Phaser.Math.DegToRad(200);
const ex = Math.cos(end) * 0.85 * ic, ey = Math.sin(end) * 0.85 * ic;
g.fillStyle(0xf2ead8, 1);
g.fillTriangle(ex - 0.45 * ic, ey - 0.1 * ic, ex + 0.1 * ic, ey - 0.5 * ic, ex + 0.15 * ic, ey + 0.35 * ic);
};
const drawers = [drawSword, drawCoin, drawRefresh];
const icons = [];
for (let i = 0; i < 3; i++) {
const g = this.add.graphics();
drawers[i](g);
g.setPosition(Math.cos(mid[i]) * midR, Math.sin(mid[i]) * midR);
ring.add(g);
icons.push(g);
}
const hub = this.add.graphics();
hub.fillStyle(0x141008, 0.88); hub.fillCircle(0, 0, outerR * 0.42);
hub.lineStyle(Math.max(1.5, outerR * 0.03), COLORS.accent, 0.7); hub.strokeCircle(0, 0, outerR * 0.42);
container.add(hub);
const pointer = this.add.graphics();
const pw = outerR * 0.16, ph = outerR * 0.3, ty = -outerR;
pointer.fillStyle(COLORS.gold, 1);
pointer.fillTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
pointer.lineStyle(1.5, COLORS.textDark, 0.6);
pointer.strokeTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
container.add(pointer);
const names = ['Action Phase', 'Buy Phase', 'Clean-up Phase'];
const nameHex = ['#d0563b', '#d4a017', '#4a90d9'];
const labelSize = Math.max(12, Math.round(outerR * 0.36));
const label = this.add.text(0, -(outerR + labelSize) - labelDY, '', {
fontFamily: 'Righteous', fontSize: `${labelSize}px`, color: nameHex[0],
}).setOrigin(0.5).setVisible(false);
label.setShadow(0, 2, '#000000', 4, false, true);
container.add(label);
const applyLabel = (idx) => { label.setText(names[idx]); label.setColor(nameHex[idx]); };
container.setAlpha(0.38);
return {
container,
_active: false,
_phaseIdx: 0,
_pulse: null,
_rot: null,
setActive(on) {
if (this._active === on) return;
this._active = on;
scene.tweens.add({ targets: container, alpha: on ? 1 : 0.38, duration: 300, ease: 'Sine.Out' });
if (on) {
paintWedges(this._phaseIdx);
applyLabel(this._phaseIdx);
label.setVisible(true);
this._pulse?.remove();
this._pulse = scene.tweens.add({
targets: container, scaleX: 1.06, scaleY: 1.06,
duration: 900, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
} else {
this._pulse?.remove(); this._pulse = null;
this._rot?.remove(); this._rot = null;
scene.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 200 });
ring.rotation = 0;
icons.forEach((g) => { g.rotation = 0; });
this._phaseIdx = 0;
paintWedges(-1);
label.setVisible(false);
}
},
setPhase(name) {
const idx = PHASE[name] ?? 0;
if (idx === this._phaseIdx) return;
this._phaseIdx = idx;
paintWedges(idx);
applyLabel(idx);
this._rot?.remove();
this._rot = scene.tweens.add({
targets: ring, rotation: -idx * TWO_PI_3, duration: 500, ease: 'Cubic.Out',
onUpdate: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
onComplete: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
});
},
destroy() {
this._pulse?.remove(); this._rot?.remove();
container.destroy(true);
},
};
}
// Reflects whose turn it is (arrow) and the active phase (dials) into the overlay.
updateTurnUi() {
if (this._suppressTurnUi) {
this.phaseDials[0]?.setActive(true);
this.phaseDials[0]?.setPhase('cleanup');
return;
}
const gs = this.gs;
if (!gs || !this.turnArrow) return;
if (this.gameOver) {
this.turnArrow.setVisible(false);
} else {
this.turnArrow.setVisible(true);
const seat = gs.turn;
const pos = this.arrowSeatPos(seat);
if (pos && this._arrowSeat !== seat) {
this._arrowSeat = seat;
const tx = pos.x - pos.r - 18, ty = pos.y;
if (this.turnArrow.x < 0) this.turnArrow.setPosition(tx, ty);
else this.tweens.add({ targets: this.turnArrow, x: tx, y: ty, duration: 600, ease: 'Cubic.Out' });
}
}
for (let seat = 0; seat < this.playerCount; seat++) {
const dial = this.phaseDials[seat];
if (!dial) continue;
const active = !this.gameOver && gs.turn === seat;
dial.setActive(active);
if (active) dial.setPhase(gs.phase === 'buy' ? 'buy' : 'action');
}
}
// ── Render (dynamic layer) ───────────────────────────────────────────────────
render() {
this._clearHandFx();
this._clearSupplyFx();
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.dynamicLayer?.destroy(true);
this.dynamicLayer = this.add.container(0, 0);
this.handSprites = [];
this.supplySprites = [];
this.inPlaySprites = [];
this.oppHandSprites = {};
this.oppInPlaySprites = {};
this.renderSupply();
this.renderInPlay();
this.renderHand();
this.renderCounts();
this.renderHud();
this.updateControls();
this.updateTurnUi();
}
renderSupply() {
const gs = this.gs;
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
this.layoutPileRow(base, 100);
const k = gs.kingdom;
this.layoutPileRow(k.slice(0, 5), 274);
this.layoutPileRow(k.slice(5, 10), 446);
// Trash indicator
const tx = 1480, ty = 100;
const t = this.add.text(tx, ty, `Trash\n${gs.trash.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5).setDepth(D.supply);
this.dynamicLayer.add(t);
}
layoutPileRow(ids, y) {
const gap = 16;
const totalW = ids.length * SUPPLY_W + (ids.length - 1) * gap;
const startX = 760 - totalW / 2 + SUPPLY_W / 2;
ids.forEach((id, i) => {
const x = startX + i * (SUPPLY_W + gap);
this.renderPile(id, x, y);
});
}
renderPile(id, x, y) {
const gs = this.gs;
const count = gs.supply[id] ?? 0;
const def = getCard(id);
const face = this.buildCardFace(SUPPLY_W, SUPPLY_H, def, { dimmed: count <= 0 });
face.setPosition(x, y).setDepth(D.supply);
this.dynamicLayer.add(face);
// count badge
const g = this.add.graphics();
g.fillStyle(0x000000, 0.82);
g.fillCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15);
g.lineStyle(2, COLORS.accent, 1);
g.strokeCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15);
g.setDepth(D.supply + 1);
const ct = this.add.text(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, `${count}`, {
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.supply + 1);
this.dynamicLayer.add(g);
this.dynamicLayer.add(ct);
const hit = this.add.rectangle(x, y, SUPPLY_W, SUPPLY_H, 0x000000, 0).setDepth(D.supply + 2);
this.dynamicLayer.add(hit);
this.attachHover(hit, def);
this.supplySprites.push({ id, x, y, hit, face });
// Normal buy wiring (human buy phase, no pending).
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
const p = gs.players[0];
const affordable = count > 0 && p.buys > 0 && p.coins >= def.cost;
if (affordable) {
hit.setInteractive({ useHandCursor: true });
hit.on('pointerup', () => this.humanBuy(id));
this.markBuyable(face);
this._buildSupplyFxItem(x, y);
}
}
}
markBuyable(face) {
const glow = this.add.rectangle(0, 0, SUPPLY_W + 6, SUPPLY_H + 6, COLORS.gold, 0);
glow.setStrokeStyle(3, COLORS.gold, 0.95);
face.addAt(glow, 0);
}
_clearSupplyFx() {
this._supplyFxEmitters.forEach(e => e.destroy());
this._supplyFxEmitters = [];
}
_buildSupplyFxItem(x, y) {
const emitter = this.add.particles(x, y, 'dominion-sparkle', {
x: { min: -SUPPLY_W / 2 + 4, max: SUPPLY_W / 2 - 4 },
y: -SUPPLY_H / 2,
speedX: { min: -18, max: 18 },
speedY: { min: -70, max: -30 },
alpha: { start: 0.9, end: 0 },
scale: { start: 0.8, end: 0.15 },
lifespan: 900,
frequency: 100,
tint: [0xffffff, 0xffeebb, 0xd4a017, 0xc8a84b],
depth: D.supply + 8,
});
this._supplyFxEmitters.push(emitter);
}
renderInPlay() {
const gs = this.gs;
const p = gs.players[gs.turn];
const cards = p.inPlay;
if (cards.length === 0) return;
const gap = 8;
const totalW = Math.min(cards.length, 12) * (PLAY_W + gap);
const startX = CX - totalW / 2 + PLAY_W / 2;
cards.slice(0, 12).forEach((c, i) => {
const def = getCard(c.id);
const face = this.buildCardFace(PLAY_W, PLAY_H, def);
face.setPosition(startX + i * (PLAY_W + gap), 610).setDepth(D.inplay);
this.dynamicLayer.add(face);
const hit = this.add.rectangle(startX + i * (PLAY_W + gap), 610, PLAY_W, PLAY_H, 0x000000, 0)
.setDepth(D.inplay + 1);
this.dynamicLayer.add(hit);
this.attachHover(hit, def);
this.inPlaySprites.push({ iid: c.iid, id: c.id, x: startX + i * (PLAY_W + gap), y: 610, face });
if (gs.turn !== 0) {
if (!this.oppInPlaySprites[gs.turn]) this.oppInPlaySprites[gs.turn] = [];
this.oppInPlaySprites[gs.turn].push({ iid: c.iid, id: c.id, x: startX + i * (PLAY_W + gap), y: 610, face });
}
});
const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.inplay);
this.dynamicLayer.add(lbl);
}
renderHand() {
const gs = this.gs;
const rawHand = gs.players[0].hand;
this._reconcileHandOrder(rawHand);
const hand = this._getOrderedHand(rawHand);
const gap = Math.min(18, (1300 - hand.length * HAND_W) / Math.max(1, hand.length - 1));
const step = HAND_W + Math.max(-HAND_W * 0.45, gap);
const totalW = (hand.length - 1) * step + HAND_W;
const startX = CX - totalW / 2 + HAND_W / 2;
const baseY = 968;
const canPlayAction = !gs.pending && gs.turn === 0 && gs.phase === 'action' && gs.players[0].actions > 0;
const canPlayTreasure = !gs.pending && gs.turn === 0 && gs.phase === 'buy';
const legalAct = new Set(legalActionIids(gs));
hand.forEach((c, i) => {
const def = getCard(c.id);
const x = startX + i * step;
const isPlayableAction = canPlayAction && legalAct.has(c.iid);
const isPlayableTreasure = canPlayTreasure && isType(c.id, 'treasure');
const face = this.buildCardFace(HAND_W, HAND_H, def);
face.setPosition(x, baseY).setDepth(D.hand + i);
if (this._animatingIids.has(c.iid)) face.setAlpha(0);
else if (this._dragState?.iid === c.iid) face.setAlpha(0.2);
this.dynamicLayer.add(face);
const hit = this.add.rectangle(x, baseY, HAND_W, HAND_H, 0x000000, 0).setDepth(D.hand + i + 1);
this.dynamicLayer.add(hit);
this.attachHover(hit, def);
hit.setInteractive({ useHandCursor: true });
const hs = { iid: c.iid, id: c.id, def, x, baseY, face, hit, isPlayableAction, isPlayableTreasure };
this.handSprites.push(hs);
if (isPlayableAction) this._buildHandFxItem(hs, COLORS.accent);
else if (isPlayableTreasure) this._buildHandFxItem(hs, COLORS.gold);
hit.on('pointerdown', (ptr) => {
if (this._animating) return;
this._dragPotential = { hs, startX: ptr.x, startY: ptr.y };
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
});
hit.on('pointerup', () => {
if (this._dragJustEnded) return;
if (isPlayableAction) this.humanPlayAction(c.iid);
else if (isPlayableTreasure) this.humanPlayTreasure(c.iid);
});
});
}
highlightFace(face, color) {
const glow = this.add.rectangle(0, 0, HAND_W + 6, HAND_H + 6, color, 0);
glow.setStrokeStyle(3, color, 0.9);
face.addAt(glow, 0);
}
_clearHandFx() {
this._handFxTweens.forEach(t => t.destroy());
this._handFxGraphics.forEach(g => g.destroy());
this._handFxTweens = [];
this._handFxGraphics = [];
}
_buildHandFxItem(hs, color) {
const gfx = this.add.graphics();
gfx.setDepth(D.hand + 25);
this._handFxGraphics.push(gfx);
const W = HAND_W, H = HAND_H;
const perim = 2 * (W + H);
const TAIL_PX = 90;
const SEGMENTS = 20;
const COMETS = 2;
const tracker = { t: 0 };
const tween = this.tweens.add({
targets: tracker,
t: { from: 0, to: 1 },
duration: 1200,
repeat: -1,
ease: 'Linear',
onUpdate: () => {
const left = hs.x - W / 2;
const top = hs.face.y - H / 2;
gfx.clear();
gfx.lineStyle(2, color, 0.2);
gfx.strokeRect(left, top, W, H);
for (let c = 0; c < COMETS; c++) {
const headDist = ((tracker.t + c / COMETS) % 1) * perim;
for (let seg = 0; seg < SEGMENTS; seg++) {
const frac = seg / SEGMENTS;
const d1 = ((headDist - (1 - frac) * TAIL_PX + perim * 100) % perim);
const d2 = ((headDist - (1 - (seg + 1) / SEGMENTS) * TAIL_PX + perim * 100) % perim);
const [x1, y1] = perimPoint(d1, W, H, left, top);
const [x2, y2] = perimPoint(d2, W, H, left, top);
const alpha = 0.1 + frac * 0.85;
const lineColor = frac > 0.85 ? 0xffffff : color;
const lineWidth = frac > 0.85 ? 4 : 2;
gfx.lineStyle(lineWidth, lineColor, alpha);
gfx.beginPath();
gfx.moveTo(x1, y1);
gfx.lineTo(x2, y2);
gfx.strokePath();
}
}
},
});
this._handFxTweens.push(tween);
}
renderCounts() {
const gs = this.gs;
const displayDeck = this._deckAnimDisplay ?? gs.players[0].deck.length;
const displayDiscard = this._discardAnimDisplay ?? gs.players[0].discard.length;
// Human deck — face-down card pile with count badge
const deckPile = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
deckPile.setPosition(DECK_PILE_X, DECK_PILE_Y).setDepth(D.hand);
deckPile.setVisible(displayDeck > 0);
this.dynamicLayer.add(deckPile);
const dcBg = this.add.graphics();
dcBg.fillStyle(0x000000, 0.72);
dcBg.fillCircle(DECK_PILE_X, DECK_PILE_Y, 22);
dcBg.lineStyle(2, COLORS.accent, 1);
dcBg.strokeCircle(DECK_PILE_X, DECK_PILE_Y, 22);
dcBg.setDepth(D.hand + 1);
dcBg.setVisible(displayDeck > 0);
const dcText = this.add.text(DECK_PILE_X, DECK_PILE_Y, `${displayDeck}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
dcText.setVisible(displayDeck > 0);
this.dynamicLayer.add(dcBg);
this.dynamicLayer.add(dcText);
this._liveDeckPile = deckPile;
this._liveDeckBadge = dcBg;
this._liveDeckCountText = dcText;
// Human discard pile — face-down card with count badge
const discardPile = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
discardPile.setPosition(DISCARD_PILE_X, DISCARD_PILE_Y).setDepth(D.hand);
discardPile.setVisible(displayDiscard > 0);
this.dynamicLayer.add(discardPile);
const dpBg = this.add.graphics();
dpBg.fillStyle(0x000000, 0.72);
dpBg.fillCircle(DISCARD_PILE_X, DISCARD_PILE_Y, 22);
dpBg.lineStyle(2, COLORS.accent, 1);
dpBg.strokeCircle(DISCARD_PILE_X, DISCARD_PILE_Y, 22);
dpBg.setDepth(D.hand + 1);
dpBg.setVisible(displayDiscard > 0);
const dpText = this.add.text(DISCARD_PILE_X, DISCARD_PILE_Y, `${displayDiscard}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
dpText.setVisible(displayDiscard > 0);
this.dynamicLayer.add(dpBg);
this.dynamicLayer.add(dpText);
this._liveDiscardPile = discardPile;
this._liveDiscardBadge = dpBg;
this._liveDiscardCountText = dpText;
this.opponents.forEach((opp, i) => {
const seat = i + 1;
const p = gs.players[seat];
const s = this.oppSlot(i);
const active = gs.turn === seat;
const txt = `Hand ${p.hand.length} Deck ${p.deck.length} Disc ${p.discard.length}`;
this.dynamicLayer.add(this.add.text(s.x, s.y + s.r + 38, txt, {
fontFamily: '"Julius Sans One"', fontSize: '15px',
color: active ? COLORS.goldHex : COLORS.mutedHex, align: 'center',
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 8, y: 4 },
}).setOrigin(0.5).setDepth(D.hud));
// Mini face-down cards representing the opponent's hand
const handSize = p.hand.length;
this.oppHandSprites[seat] = [];
if (handSize > 0) {
const gap = Math.min(4, (200 - handSize * OPP_W) / Math.max(1, handSize - 1));
const step = OPP_W + Math.max(-OPP_W * 0.6, gap);
const totalW = (handSize - 1) * step + OPP_W;
const startX = s.x - totalW / 2 + OPP_W / 2;
const cardY = s.y + s.r + 75;
for (let j = 0; j < handSize; j++) {
const c = p.hand[j];
const mini = this.buildCardFace(OPP_W, OPP_H, null, { faceDown: true });
mini.setPosition(startX + j * step, cardY).setDepth(D.hud + j);
this.dynamicLayer.add(mini);
this.oppHandSprites[seat].push({ iid: c.iid, id: c.id, x: startX + j * step, y: cardY, face: mini });
}
}
});
}
renderHud() {
const gs = this.gs;
const p = gs.players[gs.turn];
const turnName = this.seatName(gs.turn);
const phaseLbl = gs.phase === 'action' ? 'Action Phase' : gs.phase === 'buy' ? 'Buy Phase' : '';
this.dynamicLayer.add(this.add.text(CX, 700, `${turnName}'s turn — ${phaseLbl}`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.hud));
const me = gs.players[gs.turn];
const stat = `Actions ${me.actions} Buys ${me.buys} Coins ${me.coins}`;
this.dynamicLayer.add(this.add.text(CX, 742, stat, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
backgroundColor: 'rgba(0,0,0,0.5)', padding: { x: 14, y: 6 },
}).setOrigin(0.5).setDepth(D.hud));
const provLeft = gs.supply.province ?? 0;
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft} Empty piles: ${emptyPileCount(gs)}/3`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud));
}
updateControls() {
const gs = this.gs;
const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver && !this._animating;
const action = humanTurn && gs.phase === 'action';
const buy = humanTurn && gs.phase === 'buy';
this.btnEndAction.setVisible(action);
this.btnEndTurn.setVisible(buy);
}
// ── Card face builder ───────────────────────────────────────────────────────
buildCardFace(w, h, def, { faceDown = false, dimmed = false } = {}) {
const c = this.add.container(0, 0);
const style = typeStyle(def);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35); g.fillRoundedRect(-w / 2 + 2, -h / 2 + 3, w, h, 8);
g.fillStyle(0x14110b, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 8);
g.lineStyle(2, style.border, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 8);
c.add(g);
if (faceDown) {
if (this.textures.exists('cardbacks')) {
c.add(this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(w - 6, h - 6));
} else {
c.add(this.add.rectangle(0, 0, w - 8, h - 8, 0x2a2a40));
}
if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5));
return c;
}
const artH = h * 0.58;
const artTop = -h / 2;
const bandTop = artTop + artH;
const fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20);
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
if (this.textures.exists('dominion-cards')) {
c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6));
// legibility band over the lower portion
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9));
} else {
c.add(this.add.rectangle(0, artTop + artH / 2, w - 8, artH - 4, style.art));
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.95));
// placeholder: name in the art area for identification
c.add(this.add.text(0, artTop + artH / 2, def.name, {
fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: '#1a1208',
align: 'center', wordWrap: { width: w - 16 },
}).setOrigin(0.5));
}
// Title in the band
c.add(this.add.text(0, bandTop + fsTitle * 0.9, def.name, {
fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: COLORS.textHex,
align: 'center', wordWrap: { width: w - 12 },
}).setOrigin(0.5, 0.5));
// Type line (small)
c.add(this.add.text(0, h / 2 - fsType * 1.2, typeLine(def), {
fontFamily: '"Julius Sans One"', fontSize: `${fsType}px`, color: COLORS.mutedHex,
}).setOrigin(0.5));
// Icon row (between title and type line)
this.addIconRow(c, def, 0, bandTop + (h / 2 - bandTop) * 0.55, h);
// Cost coin (top-left)
this.addCoinBadge(c, -w / 2 + 14, -h / 2 + 14, def.cost, Math.round(h * 0.075));
// VP badge (top-right) for victory cards
if (isType(def.id, 'victory')) {
const vpLabel = def.id === 'gardens' ? '★' : `${def.vp}`;
this.addVpBadge(c, w / 2 - 14, -h / 2 + 14, vpLabel, Math.round(h * 0.075));
}
if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5));
return c;
}
addIconRow(container, def, cx, cy, h) {
const tokens = [];
if (def.plus.cards) tokens.push(['cards', def.plus.cards, 0x2f6fb0]);
if (def.plus.actions) tokens.push(['action', def.plus.actions, 0x3f9b54]);
if (def.plus.buys) tokens.push(['buy', def.plus.buys, 0x8a5fb0]);
if (def.plus.coins) tokens.push(['$', def.plus.coins, 0xd4a017]);
if (def.coin !== undefined) tokens.push(['$', def.coin, 0xd4a017]); // treasure value
if (tokens.length === 0) return;
const fs = Phaser.Math.Clamp(Math.round(h * 0.06), 8, 13);
const tw = fs * 4.2;
const gap = 4;
const totalW = tokens.length * tw + (tokens.length - 1) * gap;
let x = cx - totalW / 2 + tw / 2;
for (const [kind, val, color] of tokens) {
const pill = this.add.rectangle(x, cy, tw, fs * 1.7, color, 0.95);
pill.setStrokeStyle(1, 0x000000, 0.4);
container.add(pill);
const label = kind === '$' ? `+${val}$`
: kind === 'cards' ? `+${val}C`
: kind === 'action' ? `+${val}A`
: `+${val}B`;
container.add(this.add.text(x, cy, label, {
fontFamily: 'Righteous', fontSize: `${fs}px`, color: '#ffffff',
}).setOrigin(0.5));
x += tw + gap;
}
}
addCoinBadge(container, x, y, value, r) {
const g = this.add.graphics();
g.fillStyle(0xd4a017, 1); g.fillCircle(x, y, r);
g.lineStyle(1.5, 0x6b5310, 1); g.strokeCircle(x, y, r);
container.add(g);
container.add(this.add.text(x, y, `${value}`, {
fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.2)}px`, color: '#1a1208',
}).setOrigin(0.5));
}
addVpBadge(container, x, y, label, r) {
const g = this.add.graphics();
g.fillStyle(0x2e7d4f, 1); g.fillCircle(x, y, r);
g.lineStyle(1.5, 0x14241a, 1); g.strokeCircle(x, y, r);
container.add(g);
container.add(this.add.text(x, y, `${label}`, {
fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.1)}px`, color: '#ffffff',
}).setOrigin(0.5));
}
// ── Hover popup ───────────────────────────────────────────────────────────────
buildHoverPopup() {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(D.popup).setVisible(false);
}
attachHover(hitObj, def) {
hitObj.setInteractive({ useHandCursor: hitObj.input?.cursor === 'pointer' });
hitObj.on('pointerover', () => {
if (this.hoverTimer) this.hoverTimer.remove();
this.hoverTimer = this.time.delayedCall(500, () => this.showHover(def));
});
hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
});
}
showHover(def) {
this.hoverPopup.removeAll(true);
const W_CARD = 264, H_CARD = 380;
const PAD = 20, GAP = 16;
const W_POP = W_CARD + PAD * 2;
const hasText = def.text && def.text.trim().length > 0;
const rulesText = hasText
? this.add.text(0, 0, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
align: 'center', wordWrap: { width: W_CARD },
}).setOrigin(0.5, 0)
: null;
const totalH = PAD + H_CARD + (hasText ? GAP + rulesText.height + PAD : PAD);
const halfH = totalH / 2;
const bg = this.add.graphics();
bg.fillStyle(0x0d1117, 0.97);
bg.fillRoundedRect(-W_POP / 2, -halfH, W_POP, totalH, 14);
bg.lineStyle(2, COLORS.accent, 0.95);
bg.strokeRoundedRect(-W_POP / 2, -halfH, W_POP, totalH, 14);
const cardFace = this.buildCardFace(W_CARD, H_CARD, def);
this.hoverPopup.add(bg);
this.hoverPopup.add(cardFace);
cardFace.setPosition(0, -halfH + PAD + H_CARD / 2);
if (rulesText) {
this.hoverPopup.add(rulesText);
rulesText.setPosition(0, -halfH + PAD + H_CARD + GAP);
}
this.hoverPopup.setData('w', W_POP);
this.hoverPopup.setData('h', totalH);
this.hoverVisible = true;
this.hoverPopup.setVisible(true);
const p = this.lastPointer ?? { x: CX, y: GAME_HEIGHT / 2 };
this.positionHover(p.x, p.y);
}
positionHover(px, py) {
const w = this.hoverPopup.getData('w') ?? 360;
const h = this.hoverPopup.getData('h') ?? 200;
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
this.hoverPopup.setPosition(x, y);
}
hideHover() {
this.hoverVisible = false;
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
}
// ── Turn driver ───────────────────────────────────────────────────────────────
setState(s) {
if (this._animating) { this._pendingAnimState = s; return; }
const prev = this.gs;
if (!prev) {
this.gs = s; this.clearPrompt(); this.render(); this.scheduleAdvance(10); return;
}
const newLog = s.log.slice(prev.log.length);
const prevHand = prev.players[0].hand;
const prevInPlay = prev.players[0].inPlay;
const newHand = s.players[0].hand;
const newDiscard = s.players[0].discard;
// Cards that left hand and landed in discard (not played to inPlay)
const handDiscarded = prevHand.filter(c =>
!newHand.find(h => h.iid === c.iid) &&
!s.players[0].inPlay.find(ip => ip.iid === c.iid) &&
newDiscard.find(d => d.iid === c.iid)
);
// Cards that left inPlay and landed in discard (end-of-turn cleanup)
const inPlayDiscarded = prevInPlay.filter(c =>
!s.players[0].inPlay.find(ip => ip.iid === c.iid) &&
newDiscard.find(d => d.iid === c.iid)
);
const allDiscarded = [...inPlayDiscarded, ...handDiscarded];
// Cards newly drawn into hand from deck
const drawnCards = newHand.filter(c => !prevHand.find(h => h.iid === c.iid));
const deckChanged = s.players[0].deck.length !== prev.players[0].deck.length
|| s.players[0].discard.length !== prev.players[0].discard.length;
// Gain event for seat 0 targeting discard or deck (not hand)
const gainEvent = newLog.find(e => e.kind === 'gain' && e.seat === 0 && e.dest !== 'hand');
if (allDiscarded.length > 0) {
if (newLog.some(e => e.kind === 'turnEnd' && e.seat === 0)) {
this.phaseDials[0]?.setPhase('cleanup');
this._suppressTurnUi = true;
}
const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : [];
const prevMe = prev.players[0];
const hadShuffle = newLog.some(e => e.kind === 'shuffle' && e.seat === 0);
this._deckAnimDisplay = prevMe.deck.length;
this._discardAnimDisplay = prevMe.discard.length;
this._animDiscardThenDraw(allDiscarded, draws, s, {
hadShuffle, oldDeckCount: prevMe.deck.length, oldDiscardCount: prevMe.discard.length,
});
return;
}
if (gainEvent) {
const prevDeck = prev.players[0].deck;
const gainedCard = gainEvent.dest === 'discard'
? newDiscard.find(c => !prev.players[0].discard.find(d => d.iid === c.iid))
: s.players[0].deck.find(c => !prevDeck.find(d => d.iid === c.iid));
if (gainedCard) {
const sp = this.supplySprites.find(sp => sp.id === gainEvent.id);
this._animGainCard(gainedCard, sp?.x ?? CX, sp?.y ?? 300, gainEvent.dest, s);
return;
}
}
if (drawnCards.length > 0 && deckChanged) {
const prevMe2 = prev.players[0];
const hadShuffle2 = newLog.some(e => e.kind === 'shuffle' && e.seat === 0);
this._deckAnimDisplay = prevMe2.deck.length;
this._discardAnimDisplay = prevMe2.discard.length;
this._animDrawCards(drawnCards, s, {
hadShuffle: hadShuffle2, oldDeckCount: prevMe2.deck.length, oldDiscardCount: prevMe2.discard.length,
});
return;
}
// AI opponent animations — detect changes for each non-human seat
for (let seat = 1; seat < this.playerCount; seat++) {
const seatLog = newLog.filter(e => e.seat === seat);
const turnEndEvt = seatLog.find(e => e.kind === 'turnEnd');
const playEvts = seatLog.filter(e => e.kind === 'play' || e.kind === 'playTreasure');
const drawEvt = seatLog.find(e => e.kind === 'draw');
const gainEvt = seatLog.find(e => e.kind === 'gain');
if (turnEndEvt) {
const prevP = prev.players[seat];
const cleanup = [...prevP.inPlay, ...prevP.hand];
this._animOppCleanup(seat, cleanup, s.players[seat].hand, s);
return;
}
if (playEvts.length > 0) {
const prevHand = prev.players[seat].hand;
const newInPlay = s.players[seat].inPlay;
const played = prevHand.filter(c => newInPlay.find(ip => ip.iid === c.iid));
const drawn = s.players[seat].hand.filter(c => !prevHand.find(h => h.iid === c.iid));
const humanGainEvt = newLog.find(e => e.kind === 'gain' && e.seat === 0 && e.dest !== 'hand');
if (played.length > 0) {
this._animOppPlayCards(seat, played, drawn, s, humanGainEvt);
return;
}
}
if (drawEvt) {
const prevHand = prev.players[seat].hand;
const drawn = s.players[seat].hand.filter(c => !prevHand.find(h => h.iid === c.iid));
if (drawn.length > 0) { this._animOppDraw(seat, drawn.length, s); return; }
}
if (gainEvt) {
const sp = this.supplySprites.find(sp => sp.id === gainEvt.id);
this._animOppGain(seat, gainEvt.id, sp?.x ?? CX, sp?.y ?? 300, s);
return;
}
}
this.gs = s;
this.clearPrompt();
this.render();
this.scheduleAdvance(10);
}
scheduleAdvance(ms) {
this.time.delayedCall(ms, () => this.advance());
}
advance() {
if (this.gameOver) return;
const gs = this.gs;
if (isGameOver(gs)) { this.onGameOver(); return; }
if (gs.pending) {
if (gs.pending.seat === 0) {
this.promptHuman(gs.pending);
} else {
const skill = this.seatSkill(gs.pending.seat);
this.time.delayedCall(AI_PENDING_MS, () => {
if (this.gameOver) return;
const choice = AI.resolvePending(this.gs, skill);
this.setState(resolvePending(this.gs, choice));
});
}
return;
}
if (gs.turn === 0) {
// Human turn — wait for button / card clicks.
this.updateControls();
return;
}
// AI turn.
this.time.delayedCall(AI_STEP_MS, () => this.aiStep());
}
aiStep() {
if (this.gameOver) return;
const gs = this.gs;
if (gs.pending || gs.turn === 0) { this.advance(); return; }
const seat = gs.turn;
const skill = this.seatSkill(seat);
if (gs.phase === 'action') {
const iid = AI.chooseAction(gs, seat);
if (iid != null) {
playSound(this, SFX.CARD_PLACE);
this.setState(playAction(gs, iid));
} else {
this.setState(endActionPhase(gs));
}
return;
}
if (gs.phase === 'buy') {
if (gs.players[seat].hand.some((c) => isType(c.id, 'treasure'))) {
playSound(this, SFX.COINS);
this.setState(playAllTreasures(gs));
return;
}
const buy = AI.chooseBuy(gs, seat, skill);
if (buy) {
playSound(this, SFX.PURCHASE);
if (buy === 'province') this.portraits[seat]?.playEmotion?.('happy');
this.setState(buyCard(gs, buy));
} else {
this.setState(endTurn(gs));
}
return;
}
}
// ── Human actions (normal flow) ────────────────────────────────────────────
humanPlayAction(iid) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.CARD_PLACE);
this.setState(playAction(this.gs, iid));
}
humanPlayTreasure(iid) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.COINS);
this.setState(playTreasure(this.gs, iid));
}
humanPlayTreasures() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.COINS);
this.setState(playAllTreasures(this.gs));
}
humanBuy(id) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this._boughtThisTurn = true;
playSound(this, SFX.PURCHASE);
this.setState(buyCard(this.gs, id));
}
humanEndAction() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
const hand = this.gs.players[0].hand;
const hasActionCard = hand.some((c) => isType(c.id, 'action'));
const hasActionsLeft = this.gs.players[0].actions > 0;
if (hasActionCard && hasActionsLeft) {
this.showConfirm('You have unplayed action cards. End Action Phase anyway?', () => {
this._boughtThisTurn = false;
this.setState(endActionPhase(this.gs));
});
return;
}
this._boughtThisTurn = false;
this.setState(endActionPhase(this.gs));
}
humanEndTurn() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
if (!this._boughtThisTurn) {
this.showConfirm('You have not purchased anything this turn. End turn anyway?', () => {
this.setState(endTurn(this.gs));
});
return;
}
this.setState(endTurn(this.gs));
}
// ── Human decision prompts ──────────────────────────────────────────────────
clearPrompt() {
this.promptObjs.forEach((o) => o.destroy?.());
this.promptObjs = [];
this.selection.clear();
}
resolveHuman(choice) {
this.clearPrompt();
this.setState(resolvePending(this.gs, choice));
}
promptBanner(text) {
const t = this.add.text(CX, 720, text, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'center',
backgroundColor: 'rgba(0,0,0,0.78)', padding: { x: 18, y: 10 }, wordWrap: { width: 1000 },
}).setOrigin(0.5).setDepth(D.prompt);
this.promptObjs.push(t);
return t;
}
promptButton(x, label, cb, opts = {}) {
const b = new Button(this, x, 825, label, cb, { width: 200, height: 46, fontSize: 20, ...opts })
.setDepth(D.prompt);
this.promptObjs.push(b);
return b;
}
promptHuman(pend) {
switch (pend.kind) {
case 'cellarDiscard':
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Discard any number of cards, then draw that many.', confirm: 'Discard & Draw' });
case 'chapelTrash':
return this.promptMultiHand(pend, { min: 0, max: pend.max, banner: `Trash up to ${pend.max} cards from your hand.`, confirm: 'Trash' });
case 'poacherDiscard':
return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Discard ${pend.count} card(s) — one per empty Supply pile.`, confirm: 'Discard' });
case 'discardDownTo':
return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Militia: discard down to 3 (choose ${pend.count}).`, confirm: 'Discard' });
case 'remodelTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Remodel: trash a card from your hand.', allowSkip: false });
case 'mineTrash':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mine: trash a Treasure (or skip).', allowSkip: true });
case 'throneChoose':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: 'Throne Room: choose an Action to play twice (or skip).', allowSkip: true });
case 'artisanTopdeck':
return this.promptPickHand(pend, { filter: () => true, banner: 'Artisan: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' });
case 'gainFromSupply':
return this.promptGain(pend);
case 'moneylenderTrash':
return this.promptYesNo('Moneylender: trash a Copper for +3 Coins?', (yes) => this.resolveHuman({ confirm: yes }));
case 'vassalPlay':
return this.promptYesNo(`Vassal revealed ${getCard(pend.cardId).name}. Play it?`, (yes) => this.resolveHuman({ play: yes }));
case 'libraryKeep':
return this.promptYesNo(`Library drew ${getCard(pend.cardId).name}. Keep it? (No sets it aside.)`, (yes) => this.resolveHuman({ keep: yes }));
case 'harbingerTopdeck':
return this.promptPickList(this.gs.players[0].discard, 'Harbinger: put a card from your discard onto your deck (or skip).', true, (iid) => this.resolveHuman({ iid }));
case 'banditTrash':
return this.promptPickList(pend.options, 'Bandit: choose a Treasure to trash.', false, (iid) => this.resolveHuman({ iid }));
case 'bureaucratTopdeck':
return this.promptPickList(pend.options, 'Bureaucrat: choose a Victory card to put on your deck.', false, (iid) => this.resolveHuman({ iid }));
case 'moatReveal':
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
case 'sentry':
return this.promptSentry(pend);
default:
return this.resolveHuman({});
}
}
// Multi-select over the hand (discard/trash N).
promptMultiHand(pend, { min, max, banner, confirm }) {
this.promptBanner(banner);
const update = () => {
const n = this.selection.size;
confirmBtn.setEnabled(n >= min && n <= max);
confirmBtn.setLabel(`${confirm} (${n})`);
};
const confirmBtn = this.promptButton(CX, `${confirm} (0)`, () => {
this.resolveHuman({ iids: [...this.selection] });
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
for (const hs of this.handSprites) {
hs.hit.setInteractive({ useHandCursor: true });
hs.hit.removeAllListeners('pointerup');
hs.hit.on('pointerup', () => {
if (this.selection.has(hs.iid)) {
this.selection.delete(hs.iid);
hs.face.setY(hs.baseY);
} else {
if (this.selection.size >= max) return;
this.selection.add(hs.iid);
hs.face.setY(hs.baseY - 28);
}
update();
});
}
update();
}
// Single pick from the hand (immediate resolve). key = which choice field.
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) {
this.promptBanner(banner);
if (allowSkip) {
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' });
}
for (const hs of this.handSprites) {
const ok = filter(hs);
if (!ok) continue;
hs.hit.setInteractive({ useHandCursor: true });
hs.hit.removeAllListeners('pointerup');
this.highlightFace(hs.face, COLORS.danger);
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid }));
}
}
// Gain a card from the Supply (highlight eligible piles).
promptGain(pend) {
const treasureNote = pend.filterTreasure ? ' Treasure' : '';
this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`);
let any = false;
for (const sp of this.supplySprites) {
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
any = true;
sp.hit.setInteractive({ useHandCursor: true });
sp.hit.removeAllListeners('pointerup');
const glow = this.add.rectangle(sp.x, sp.y, SUPPLY_W + 8, SUPPLY_H + 8, COLORS.gold, 0)
.setStrokeStyle(4, COLORS.gold, 0.95).setDepth(D.supply + 3);
this.promptObjs.push(glow);
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
}
if (!any) this.resolveHuman({ id: null });
}
promptYesNo(banner, cb) {
this.promptBanner(banner);
this.promptButton(CX - 120, 'Yes', () => cb(true), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
this.promptButton(CX + 120, 'No', () => cb(false), { variant: 'ghost' });
}
// "Are you sure?" modal — blocks interaction with underlying buttons.
showConfirm(message, onConfirm) {
this.clearPrompt();
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive();
this.promptObjs.push(overlay);
this.promptObjs.push(this.add.text(CX, GAME_HEIGHT / 2 - 40, message, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 900 },
}).setOrigin(0.5).setDepth(D.modal + 1));
this.promptObjs.push(new Button(this, CX - 130, GAME_HEIGHT / 2 + 50, 'Confirm', () => {
this.clearPrompt();
onConfirm();
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
this.promptObjs.push(new Button(this, CX + 130, GAME_HEIGHT / 2 + 50, 'Cancel', () => {
this.clearPrompt();
}, { variant: 'ghost', width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
}
// Modal list of cards to pick one (harbinger discard, bandit/bureaucrat options).
promptPickList(cards, banner, allowSkip, cb) {
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(D.modal).setInteractive();
this.promptObjs.push(overlay);
this.promptObjs.push(this.add.text(CX, 360, banner, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 1100 },
}).setOrigin(0.5).setDepth(D.modal + 1));
const uniq = [];
const seen = new Set();
for (const c of cards) { if (!seen.has(c.iid)) { seen.add(c.iid); uniq.push(c); } }
const gap = 18;
const totalW = Math.min(uniq.length, 8) * (HAND_W + gap);
const startX = CX - totalW / 2 + (HAND_W + gap) / 2;
uniq.slice(0, 8).forEach((c, i) => {
const def = getCard(c.id);
const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(startX + i * (HAND_W + gap), 560).setDepth(D.modal + 1);
this.promptObjs.push(face);
const hit = this.add.rectangle(startX + i * (HAND_W + gap), 560, HAND_W, HAND_H, 0x000000, 0)
.setDepth(D.modal + 2).setInteractive({ useHandCursor: true });
this.promptObjs.push(hit);
this.attachHover(hit, def);
hit.on('pointerup', () => cb(c.iid));
});
if (allowSkip) {
this.promptObjs.push(new Button(this, CX, 760, 'Skip', () => cb(null), { variant: 'ghost', width: 180 }).setDepth(D.modal + 2));
}
}
// Sentry modal: per-card Trash / Discard / Keep over the top 2 cards.
promptSentry(pend) {
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(D.modal).setInteractive();
this.promptObjs.push(overlay);
this.promptObjs.push(this.add.text(CX, 320, 'Sentry: choose what to do with the top 2 cards of your deck.', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center', wordWrap: { width: 1100 },
}).setOrigin(0.5).setDepth(D.modal + 1));
const cards = pend.cards ?? [];
const decisions = new Map(cards.map((c) => [c.iid, 'top'])); // default keep on top
const gap = 80;
const totalW = cards.length * (HAND_W + gap);
const startX = CX - totalW / 2 + (HAND_W + gap) / 2;
const labels = new Map();
cards.forEach((c, i) => {
const def = getCard(c.id);
const x = startX + i * (HAND_W + gap);
const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(x, 540).setDepth(D.modal + 1);
this.promptObjs.push(face);
const hit = this.add.rectangle(x, 540, HAND_W, HAND_H, 0x000000, 0).setDepth(D.modal + 2);
this.promptObjs.push(hit);
this.attachHover(hit, def);
const lbl = this.add.text(x, 540 + HAND_H / 2 + 24, 'Keep on top', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.modal + 2);
this.promptObjs.push(lbl);
labels.set(c.iid, lbl);
const cycle = ['top', 'discard', 'trash'];
const text = { top: 'Keep on top', discard: 'Discard', trash: 'Trash' };
const colors = { top: COLORS.goldHex, discard: COLORS.mutedHex, trash: COLORS.dangerHex };
hit.setInteractive({ useHandCursor: true });
hit.on('pointerup', () => {
const cur = decisions.get(c.iid);
const nxt = cycle[(cycle.indexOf(cur) + 1) % cycle.length];
decisions.set(c.iid, nxt);
lbl.setText(text[nxt]).setColor(colors[nxt]);
});
});
this.promptObjs.push(new Button(this, CX, 800, 'Confirm', () => {
const trash = [], discard = [], top = [];
for (const c of cards) {
const d = decisions.get(c.iid);
if (d === 'trash') trash.push(c.iid);
else if (d === 'discard') discard.push(c.iid);
else top.push(c.iid);
}
this.resolveHuman({ trash, discard, top });
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200 }).setDepth(D.modal + 2));
}
// ── Game over ───────────────────────────────────────────────────────────────
onGameOver() {
if (this.gameOver) return;
this.gameOver = true;
this.clearPrompt();
this.hideHover();
this.updateControls();
this.turnArrow?.setVisible(false);
this.phaseDials.forEach((d) => d?.setActive(false));
const scores = finalScores(this.gs);
const winners = new Set(this.gs.winnerSeats);
const youWon = winners.has(0);
playSound(this, youWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.recordHistory(scores);
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7)
.setDepth(D.modal).setInteractive();
const ordered = scores.slice().sort((a, b) => b.vp - a.vp);
const lines = ordered.map((s) => `${this.seatName(s.seat)}: ${s.vp} VP${winners.has(s.seat) ? ' ★' : ''}`);
const h = 220 + lines.length * 44;
const box = this.add.rectangle(CX, GAME_HEIGHT / 2, 660, h, COLORS.panel, 1)
.setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = youWon ? (winners.size > 1 ? 'Tie game!' : 'You win!') : `${this.seatName(ordered[0].seat)} wins`;
this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 48, title, {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.modal + 2);
lines.forEach((line, i) => {
this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 116 + i * 44, line, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal + 2);
});
new Button(this, CX - 150, GAME_HEIGHT / 2 + h / 2 - 44, 'New game', () => this.scene.restart(), {
width: 260, bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
}).setDepth(D.modal + 2);
new Button(this, CX + 150, GAME_HEIGHT / 2 + h / 2 - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 260,
}).setDepth(D.modal + 2);
}
async recordHistory(scores) {
const mine = scores.find((s) => s.seat === 0)?.vp ?? 0;
const others = scores.filter((s) => s.seat !== 0).map((s) => s.vp);
const winners = new Set(this.gs.winnerSeats);
let result;
if (winners.has(0) && winners.size === 1) result = 'win';
else if (winners.has(0)) result = 'draw';
else result = 'loss';
try {
await api.post('/history/single-player', {
slug: 'dominion', score: mine, opponentScores: others, result,
});
} catch (_) { /* offline / not signed in */ }
}
// ── AI opponent animations ────────────────────────────────────────────────────
_animOppPlayCards(seat, playedCards, drawnCards, newState, humanGainEvt = null) {
this._animating = true;
const slot = this.oppSlot(seat - 1);
// Capture source positions before render destroys sprite refs
const sources = playedCards.map(card => {
const hs = (this.oppHandSprites[seat] ?? []).find(s => s.iid === card.iid);
return { iid: card.iid, id: card.id, x: hs?.x ?? slot.x, y: hs?.y ?? (slot.y + slot.r + 75) };
});
this.gs = newState;
this.clearPrompt();
this.render();
// Hide newly-played in-play sprites — ghost will reveal them on arrival
for (const card of playedCards) {
const sp = (this.oppInPlaySprites[seat] ?? []).find(s => s.iid === card.iid);
if (sp) sp.face.setAlpha(0);
}
// Target positions in the in-play area
const newInPlay = newState.players[seat].inPlay;
const ipGap = 8;
const ipTotal = Math.min(newInPlay.length, 12) * (PLAY_W + ipGap);
const ipStartX = CX - ipTotal / 2 + PLAY_W / 2;
const targets = playedCards.map(card => {
const idx = newInPlay.findIndex(c => c.iid === card.iid);
return { x: ipStartX + Math.max(0, idx) * (PLAY_W + ipGap), y: 610 };
});
let idx = 0;
const next = () => {
if (idx >= sources.length) {
if (drawnCards.length > 0) { this._animOppDraw(seat, drawnCards.length, newState, humanGainEvt); return; }
if (humanGainEvt) { this._chainHumanGain(humanGainEvt, newState); return; }
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
return;
}
const src = sources[idx];
const tgt = targets[idx++];
const sp = (this.oppInPlaySprites[seat] ?? []).find(s => s.iid === src.iid);
this._animOppOnePlay(src, tgt.x, tgt.y, getCard(src.id), () => {
if (sp) sp.face.setAlpha(1);
next();
});
};
next();
}
_animOppOnePlay(src, tx, ty, def, onComplete) {
// Phase 1 (150ms): face-down mini unfolds from scale 0
const fd = this.buildCardFace(OPP_W, OPP_H, null, { faceDown: true });
fd.setPosition(src.x, src.y).setScale(0, 1);
this.animLayer.add(fd);
this.tweens.add({
targets: fd, scaleX: 1, duration: 150, ease: 'Sine.easeOut',
onComplete: () => {
// Phase 2 (150ms): fold back — flip illusion
this.tweens.add({
targets: fd, scaleX: 0, duration: 150, ease: 'Sine.easeIn',
onComplete: () => {
fd.destroy();
// Phase 3 (350ms): face-up, grow from mini scale to full, fly to target
const fu = this.buildCardFace(PLAY_W, PLAY_H, def);
fu.setPosition(src.x, src.y).setScale(OPP_W / PLAY_W, OPP_H / PLAY_H);
this.animLayer.add(fu);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fu, x: tx, y: ty, scaleX: 1, scaleY: 1,
duration: 350, ease: 'Cubic.easeOut',
onComplete: () => { fu.destroy(); onComplete(); },
});
},
});
},
});
}
_animOppDraw(seat, count, newState, humanGainEvt = null) {
if (this.gs !== newState) {
this.gs = newState;
this.clearPrompt();
this.render();
}
const slot = this.oppSlot(seat - 1);
const handSprites = this.oppHandSprites[seat] ?? [];
// The last `count` sprites are the newly drawn ones
const targets = handSprites.slice(-count);
targets.forEach(sp => { if (sp?.face) sp.face.setAlpha(0); });
let idx = 0;
const next = () => {
if (idx >= count) {
handSprites.forEach(sp => { if (sp?.face) sp.face.setAlpha(1); });
if (humanGainEvt) { this._chainHumanGain(humanGainEvt, newState); return; }
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
return;
}
const tgt = targets[idx++];
const tx = tgt?.x ?? slot.x, ty = tgt?.y ?? (slot.y + slot.r + 75);
const mini = this.buildCardFace(OPP_W, OPP_H, null, { faceDown: true });
mini.setPosition(slot.x, slot.y);
this.animLayer.add(mini);
this.tweens.add({
targets: mini, x: tx, y: ty, duration: 320, ease: 'Cubic.easeOut',
onComplete: () => {
mini.destroy();
playSound(this, SFX.CARD_DEAL);
if (tgt?.face) tgt.face.setAlpha(1);
next();
},
});
};
next();
}
_animOppCleanup(seat, cleanupCards, newCards, newState) {
this._animating = true;
const slot = this.oppSlot(seat - 1);
this.phaseDials[seat]?.setPhase('cleanup');
// Capture sources before any render wipes the sprite arrays
const sources = cleanupCards.map(card => {
const ip = (this.oppInPlaySprites[seat] ?? []).find(s => s.iid === card.iid);
if (ip) return { iid: card.iid, id: card.id, x: ip.x, y: ip.y, face: ip.face };
const hs = (this.oppHandSprites[seat] ?? []).find(s => s.iid === card.iid);
if (hs) return { iid: card.iid, id: card.id, x: hs.x, y: hs.y, face: hs.face };
return { iid: card.iid, id: card.id, x: slot.x, y: slot.y, face: null };
});
this.clearPrompt();
let idx = 0;
const nextCleanup = () => {
if (idx >= sources.length) {
this.gs = newState;
this.render();
if (newCards.length > 0) {
this._animOppDraw(seat, newCards.length, newState);
} else {
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
}
return;
}
const src = sources[idx++];
if (src.face) src.face.setAlpha(0);
const card = this.buildCardFace(OPP_W, OPP_H, null, { faceDown: true });
card.setPosition(src.x, src.y);
this.animLayer.add(card);
this.tweens.add({
targets: card, x: slot.x, y: slot.y, duration: 280, ease: 'Cubic.easeIn',
onComplete: () => { card.destroy(); nextCleanup(); },
});
};
nextCleanup();
}
_animOppGain(seat, cardId, srcX, srcY, newState) {
this._animating = true;
this.gs = newState;
this.clearPrompt();
this.render();
const slot = this.oppSlot(seat - 1);
const cardName = getCard(cardId).name;
const label = this.add.text(slot.x - slot.r - 74, slot.y, `Purchased:\n${cardName}`, {
fontFamily: 'Righteous', fontSize: '32px', color: '#FFD700',
align: 'right', stroke: '#000000', strokeThickness: 4,
}).setOrigin(1, 0.5).setDepth(D.hud + 5).setAlpha(1);
const src = { iid: -1, id: cardId, x: srcX, y: srcY };
this._animDiscardCard(src, slot.x, slot.y, () => {
this.tweens.add({
targets: label, alpha: 0, duration: 600, delay: 400, ease: 'Sine.easeIn',
onComplete: () => label.destroy(),
});
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
}, { flyDuration: 1000, foldDuration: 500 });
}
_chainHumanGain(gainEvt, newState) {
const prevDiscard = this.gs.players[0].discard;
const newDiscard = newState.players[0].discard;
const gained = gainEvt.dest === 'discard'
? newDiscard.find(c => !prevDiscard.find(d => d.iid === c.iid))
: newState.players[0].deck.find(c => !this.gs.players[0].deck.find(d => d.iid === c.iid));
if (gained) {
const sp = this.supplySprites.find(s => s.id === gainEvt.id);
this._animGainCard(gained, sp?.x ?? CX, sp?.y ?? 300, gainEvt.dest, newState);
} else {
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
}
}
// ── Draw animations ───────────────────────────────────────────────────────────
_animDiscardThenDraw(discardedCards, drawnCards, newState, shuffleInfo = {}) {
this._animating = true;
// Capture positions + face sprite refs BEFORE any render wipes the arrays.
// The face ref lets us hide each card exactly when its animation begins.
const sources = discardedCards.map(card => {
const hs = this.handSprites.find(s => s.iid === card.iid);
if (hs) return { iid: card.iid, id: card.id, x: hs.x, y: hs.baseY, face: hs.face };
const ip = this.inPlaySprites.find(s => s.iid === card.iid);
if (ip) return { iid: card.iid, id: card.id, x: ip.x, y: ip.y, face: ip.face };
return { iid: card.iid, id: card.id, x: CX, y: 610, face: null };
});
// Don't render yet — keep existing sprites visible until each card starts animating.
this.clearPrompt();
let idx = 0;
const animateNext = () => {
// Each call after the first means the previous card just landed in the discard pile.
if (idx > 0) {
this._discardAnimDisplay = (this._discardAnimDisplay ?? 0) + 1;
if (this._liveDiscardCountText?.active)
this._liveDiscardCountText.setText(`${this._discardAnimDisplay}`);
if (this._liveDiscardPile?.active) this._liveDiscardPile.setVisible(true);
if (this._liveDiscardBadge?.active) this._liveDiscardBadge.setVisible(true);
}
if (idx >= sources.length) {
// All cards have landed. Now render the intermediate/final state.
const p0 = newState.players[0];
if (drawnCards.length > 0) {
this.gs = {
...newState,
players: newState.players.map((p, i) =>
i === 0 ? { ...p, hand: [], deck: [...drawnCards, ...p0.deck] } : p
),
};
} else {
this.gs = newState;
}
this.render();
if (drawnCards.length > 0) {
this._animDrawCards(drawnCards, newState, {
...shuffleInfo,
oldDiscardCount: this._discardAnimDisplay ?? 0,
});
} else {
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this._suppressTurnUi = false;
this.updateTurnUi();
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
} else {
this.scheduleAdvance(10);
}
}
return;
}
const src = sources[idx++];
// Hide the existing sprite at the moment the animation sprite takes over.
if (src.face) src.face.setAlpha(0);
this._animDiscardCard(src, DISCARD_PILE_X, DISCARD_PILE_Y, animateNext);
};
animateNext();
}
_animDiscardCard(src, tx, ty, onComplete, { flyDuration = 500, foldDuration = 250 } = {}) {
const def = getCard(src.id);
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(src.x, src.y);
this.animLayer.add(fuCard);
this.tweens.add({
targets: fuCard, x: tx, y: ty, duration: flyDuration, ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({
targets: fuCard, scaleX: 0, duration: foldDuration, ease: 'Sine.easeIn',
onComplete: () => {
fuCard.destroy();
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(tx, ty).setScale(0, 1);
this.animLayer.add(fdCard);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fdCard, scaleX: 1, duration: foldDuration, ease: 'Sine.easeOut',
onComplete: () => { fdCard.destroy(); onComplete(); },
});
},
});
},
});
}
_animGainCard(gainedCard, srcX, srcY, dest, newState) {
this._animating = true;
this.gs = newState;
this.clearPrompt();
this.render();
const tx = dest === 'deck' ? DECK_PILE_X : DISCARD_PILE_X;
const ty = dest === 'deck' ? DECK_PILE_Y : DISCARD_PILE_Y;
const src = { iid: gainedCard.iid, id: gainedCard.id, x: srcX, y: srcY };
this._animDiscardCard(src, tx, ty, () => {
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
} else {
this.scheduleAdvance(10);
}
});
}
// ── Drag and drop ─────────────────────────────────────────────────────────────
_getOrderedHand(hand) {
if (!this._handOrder || this._handOrder.length === 0) return [...hand];
const orderMap = new Map(this._handOrder.map((iid, i) => [iid, i]));
return [...hand].sort((a, b) => (orderMap.get(a.iid) ?? 999) - (orderMap.get(b.iid) ?? 999));
}
_reconcileHandOrder(hand) {
if (!this._handOrder) return;
const existing = new Set(hand.map(c => c.iid));
this._handOrder = this._handOrder.filter(iid => existing.has(iid));
for (const c of hand) {
if (!this._handOrder.includes(c.iid)) this._handOrder.push(c.iid);
}
if (this._handOrder.length === 0) this._handOrder = null;
}
_checkDragStart(p) {
const dp = this._dragPotential;
if (!dp) return;
const dx = p.x - dp.startX, dy = p.y - dp.startY;
if (Math.sqrt(dx * dx + dy * dy) > 8) {
this._startDrag(dp.hs, p);
this._dragPotential = null;
}
}
_startDrag(hs, pointer) {
if (this._animating) return;
const offsetX = hs.x - pointer.x;
const offsetY = hs.baseY - pointer.y;
this._dragState = {
iid: hs.iid, id: hs.id, def: hs.def,
isPlayableAction: hs.isPlayableAction,
isPlayableTreasure: hs.isPlayableTreasure,
offsetX, offsetY,
};
hs.face.setAlpha(0.2);
const ghost = this.buildCardFace(HAND_W, HAND_H, hs.def);
ghost.setPosition(pointer.x + offsetX, pointer.y + offsetY);
ghost.setDepth(D.hand + 100);
this.animLayer.add(ghost);
this._dragState.ghost = ghost;
if (hs.isPlayableAction || hs.isPlayableTreasure) this._showPlayDropZone();
}
_onDragMove(p) {
const ds = this._dragState;
if (!ds?.ghost) return;
ds.ghost.setPosition(p.x + ds.offsetX, p.y + ds.offsetY);
}
_onDragUp(p) {
const ds = this._dragState;
if (!ds) return;
this._dragJustEnded = true;
this.time.delayedCall(0, () => { this._dragJustEnded = false; });
this._hidePlayDropZone();
ds.ghost.destroy();
this._dragState = null;
const cardCenterY = p.y + ds.offsetY;
if (cardCenterY < 870 && (ds.isPlayableAction || ds.isPlayableTreasure)) {
const hs = this.handSprites.find(s => s.iid === ds.iid);
if (hs) hs.face.setAlpha(1);
if (ds.isPlayableAction) this.humanPlayAction(ds.iid);
else this.humanPlayTreasure(ds.iid);
} else {
this._reorderHand(ds.iid, p.x + ds.offsetX);
}
}
_cancelDrag() {
const ds = this._dragState;
if (!ds) return;
this._hidePlayDropZone();
ds.ghost.destroy();
this._dragState = null;
this._dragPotential = null;
const hs = this.handSprites.find(s => s.iid === ds.iid);
if (hs) hs.face.setAlpha(1);
}
_reorderHand(iid, dropX) {
const ordered = this._getOrderedHand(this.gs.players[0].hand);
const rest = ordered.filter(c => c.iid !== iid);
const gap = Math.min(18, (1300 - rest.length * HAND_W) / Math.max(1, rest.length - 1));
const step = HAND_W + Math.max(-HAND_W * 0.45, gap);
const total = (rest.length - 1) * step + HAND_W;
const startX = CX - total / 2 + HAND_W / 2;
let insertIdx = rest.length;
for (let i = 0; i < rest.length; i++) {
if (dropX < startX + i * step) { insertIdx = i; break; }
}
this._handOrder = [
...rest.slice(0, insertIdx).map(c => c.iid),
iid,
...rest.slice(insertIdx).map(c => c.iid),
];
this.render();
}
_showPlayDropZone() {
const g = this.add.graphics();
g.fillStyle(COLORS.gold, 0.08);
g.lineStyle(2, COLORS.gold, 0.55);
g.fillRoundedRect(CX - 700, 490, 1400, 340, 20);
g.strokeRoundedRect(CX - 700, 490, 1400, 340, 20);
g.setDepth(D.inplay - 1);
this.animLayer.add(g);
this._dragDropZone = g;
const lbl = this.add.text(CX, 660, '▲ Drop here to play', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setAlpha(0.7).setDepth(D.inplay);
this.animLayer.add(lbl);
this._dragDropLabel = lbl;
}
_hidePlayDropZone() {
this._dragDropZone?.destroy();
this._dragDropZone = null;
this._dragDropLabel?.destroy();
this._dragDropLabel = null;
}
_animDrawCards(drawnCards, newState, shuffleInfo = {}) {
const { hadShuffle = false, oldDeckCount = 0 } = shuffleInfo;
this._animating = true;
this._animatingIids = new Set(drawnCards.map(c => c.iid));
this.gs = newState;
this.clearPrompt();
this.render(); // uses _deckAnimDisplay / _discardAnimDisplay overrides
const updateDeckDisplay = () => {
if (this._liveDeckCountText?.active)
this._liveDeckCountText.setText(`${this._deckAnimDisplay}`);
const empty = this._deckAnimDisplay <= 0;
if (this._liveDeckPile?.active) this._liveDeckPile.setVisible(!empty);
if (this._liveDeckBadge?.active) this._liveDeckBadge.setVisible(!empty);
};
const doShuffle = (callback) => {
this.time.delayedCall(400, () => {
playSound(this, SFX.CARD_SHUFFLE);
const startVal = this._discardAnimDisplay ?? 0;
const counter = { v: startVal };
this.tweens.add({
targets: counter, v: 0, duration: 600, ease: 'Cubic.Out',
onUpdate: () => {
this._discardAnimDisplay = Math.round(counter.v);
if (this._liveDiscardCountText?.active)
this._liveDiscardCountText.setText(`${this._discardAnimDisplay}`);
},
onComplete: () => {
const postDeck = (drawnCards.length - drawnSoFar) + newState.players[0].deck.length;
this._deckAnimDisplay = postDeck;
updateDeckDisplay();
callback();
},
});
});
};
let idx = 0;
let drawnSoFar = 0;
let shuffleDone = false;
const animateNext = () => {
if (hadShuffle && !shuffleDone && drawnSoFar === oldDeckCount) {
shuffleDone = true;
doShuffle(animateNext);
return;
}
if (idx >= drawnCards.length) { this._finishDrawAnim(); return; }
const card = drawnCards[idx++];
const sprite = this.handSprites.find(s => s.iid === card.iid);
const tx = sprite?.x ?? CX;
this._animateOneCard(card, tx, 968, () => {
drawnSoFar++;
playSound(this, SFX.CARD_DEAL);
const s2 = this.handSprites.find(s => s.iid === card.iid);
if (s2) s2.face.setAlpha(1);
this._animatingIids.delete(card.iid);
this._deckAnimDisplay = (this._deckAnimDisplay ?? 1) - 1;
updateDeckDisplay();
animateNext();
});
};
animateNext();
}
_animateOneCard(card, tx, ty, onComplete) {
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(DECK_PILE_X, DECK_PILE_Y);
this.animLayer.add(fdCard);
this.tweens.add({
targets: fdCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
onComplete: () => {
fdCard.destroy();
const def = getCard(card.id);
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(DECK_PILE_X, DECK_PILE_Y).setScale(0, 1);
this.animLayer.add(fuCard);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fuCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
onComplete: () => {
this.tweens.add({
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeOut',
onComplete: () => { fuCard.destroy(); onComplete(); },
});
},
});
},
});
}
_finishDrawAnim() {
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this.handSprites.forEach(s => s.face.setAlpha(1));
this._animatingIids.clear();
this._animating = false;
this._suppressTurnUi = false;
this.updateTurnUi();
if (this._pendingAnimState) {
const s = this._pendingAnimState;
this._pendingAnimState = null;
this.setState(s);
} else {
this.scheduleAdvance(10);
}
}
}