diff --git a/public/assets/images/dominion-cards.psd b/public/assets/images/dominion-cards.psd index 37cd6cb..4d2d6d8 100644 Binary files a/public/assets/images/dominion-cards.psd and b/public/assets/images/dominion-cards.psd differ diff --git a/public/assets/images/dominioncards.png b/public/assets/images/dominioncards.png index 24e9985..18c261b 100644 Binary files a/public/assets/images/dominioncards.png and b/public/assets/images/dominioncards.png differ diff --git a/public/src/games/dominion/DominionGame.js b/public/src/games/dominion/DominionGame.js index a33eb39..575e188 100644 --- a/public/src/games/dominion/DominionGame.js +++ b/public/src/games/dominion/DominionGame.js @@ -23,6 +23,7 @@ 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 AI_STEP_MS = 420; const AI_PENDING_MS = 520; @@ -68,6 +69,7 @@ export default class DominionGame extends Phaser.Scene { this._animating = false; this._pendingAnimState = null; this._animatingIids = new Set(); + this.inPlaySprites = []; } create() { @@ -140,13 +142,10 @@ export default class DominionGame extends Phaser.Scene { } buildButtons() { - this.btnEndAction = new Button(this, 1640, 778, 'End Action Phase', () => this.humanEndAction(), { + this.btnEndAction = new Button(this, CX, 830, 'End Action Phase', () => this.humanEndAction(), { width: 240, height: 48, fontSize: 20, }).setDepth(D.hud).setVisible(false); - this.btnPlayTreasure = new Button(this, 1640, 778, 'Play Treasures', () => this.humanPlayTreasures(), { - width: 240, height: 48, fontSize: 20, bg: COLORS.panel, bgHover: COLORS.gold, - }).setDepth(D.hud).setVisible(false); - this.btnEndTurn = new Button(this, 1640, 836, 'End Turn', () => this.humanEndTurn(), { + 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); @@ -174,6 +173,7 @@ export default class DominionGame extends Phaser.Scene { this.dynamicLayer = this.add.container(0, 0); this.handSprites = []; this.supplySprites = []; + this.inPlaySprites = []; this.renderSupply(); this.renderInPlay(); @@ -270,6 +270,7 @@ export default class DominionGame extends Phaser.Scene { .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 }); }); const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, @@ -339,10 +340,22 @@ export default class DominionGame extends Phaser.Scene { this.dynamicLayer.add(dcBg); this.dynamicLayer.add(dcText); - // Human discard - this.dynamicLayer.add(this.add.text(1500, 968, `Discard\n${gs.players[0].discard.length}`, { - fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center', - }).setOrigin(0.5).setDepth(D.hand)); + // 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; @@ -402,9 +415,7 @@ export default class DominionGame extends Phaser.Scene { const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver && !this._animating; const action = humanTurn && gs.phase === 'action'; const buy = humanTurn && gs.phase === 'buy'; - const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure')); this.btnEndAction.setVisible(action); - this.btnPlayTreasure.setVisible(buy && hasTreasure); this.btnEndTurn.setVisible(buy); } @@ -602,16 +613,59 @@ export default class DominionGame extends Phaser.Scene { // ── Turn driver ─────────────────────────────────────────────────────────────── setState(s) { - if (this._animating) { - this._pendingAnimState = 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) { + const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : []; + this._animDiscardThenDraw(allDiscarded, draws, s); return; } - const prevHand = this.gs?.players[0].hand ?? []; - const newHand = s.players[0].hand; - const drawnCards = newHand.filter(c => !prevHand.find(h => h.iid === c.iid)); - const deckChanged = s.players[0].deck.length !== (this.gs?.players[0].deck.length ?? -1) - || s.players[0].discard.length !== (this.gs?.players[0].discard.length ?? -1); + 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); @@ -1013,6 +1067,108 @@ export default class DominionGame extends Phaser.Scene { // ── 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._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) { + const def = getCard(src.id); + const fuCard = this.buildCardFace(HAND_W, HAND_H, def); + fuCard.setPosition(src.x, src.y); + this.animLayer.add(fuCard); + + // Phase 1 (0–500ms): fly face-up to target + this.tweens.add({ + targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeIn', + onComplete: () => { + // Phase 2 (500–750ms): fold to edge + this.tweens.add({ + targets: fuCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn', + onComplete: () => { + fuCard.destroy(); + // Phase 3 (750–1000ms): unfold face-down + 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: 250, 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); + } + }); + } + _calcHandPositions(hand) { 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);