2111 lines
81 KiB
JavaScript
2111 lines
81 KiB
JavaScript
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);
|
||
}
|
||
}
|
||
}
|