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 = []; } 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._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; // 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); this.dynamicLayer.add(deckPile); const deckCount = gs.players[0].deck.length; 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); const dcText = this.add.text(DECK_PILE_X, DECK_PILE_Y, `${deckCount}`, { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.hand + 1); this.dynamicLayer.add(dcBg); this.dynamicLayer.add(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); this.dynamicLayer.add(discardPile); const discardCount = gs.players[0].discard.length; 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); const dpText = this.add.text(DISCARD_PILE_X, DISCARD_PILE_Y, `${discardCount}`, { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.hand + 1); this.dynamicLayer.add(dpBg); this.dynamicLayer.add(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 : []; this._animDiscardThenDraw(allDiscarded, draws, s); 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) { this._animDrawCards(drawnCards, s); 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'))) { this.setState(playAllTreasures(gs)); return; } const buy = AI.chooseBuy(gs, seat, skill); if (buy) { playSound(this, SFX.CARD_SHOW); 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; this.setState(playTreasure(this.gs, iid)); } humanPlayTreasures() { if (this.gs.pending || this.gs.turn !== 0 || this._animating) return; playSound(this, SFX.CARD_PLACE); this.setState(playAllTreasures(this.gs)); } humanBuy(id) { if (this.gs.pending || this.gs.turn !== 0 || this._animating) return; this._boughtThisTurn = true; playSound(this, SFX.CARD_SHOW); 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); 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(); 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) { 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 = () => { 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); } else { 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); 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) { this._animating = true; this._animatingIids = new Set(drawnCards.map(c => c.iid)); this.gs = newState; this.clearPrompt(); this.render(); let idx = 0; const animateNext = () => { 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, () => { const s2 = this.handSprites.find(s => s.iid === card.iid); if (s2) s2.face.setAlpha(1); this._animatingIids.delete(card.iid); 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); 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.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); } } }