diff --git a/public/assets/music/track01.mp3 b/public/assets/music/track01.mp3 index b583a63..80159e4 100644 Binary files a/public/assets/music/track01.mp3 and b/public/assets/music/track01.mp3 differ diff --git a/public/assets/music/track02.mp3 b/public/assets/music/track02.mp3 index f13c32f..dcec0c5 100644 Binary files a/public/assets/music/track02.mp3 and b/public/assets/music/track02.mp3 differ diff --git a/public/src/games/gofish/GoFishGame.js b/public/src/games/gofish/GoFishGame.js index 7c7bb2a..37fb408 100644 --- a/public/src/games/gofish/GoFishGame.js +++ b/public/src/games/gofish/GoFishGame.js @@ -148,6 +148,7 @@ export default class GoFishGame extends Phaser.Scene { this.buildSeatAreas(); this.buildCenter(); this.buildHUD(); + this.buildMatchesPanel(); this.startNewMatch(); } @@ -275,6 +276,65 @@ export default class GoFishGame extends Phaser.Scene { }).setDepth(D.ui); } + // ── Matches panel ───────────────────────────────────────────────────────── + + buildMatchesPanel() { + const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2']; + const px = 10, py = 10; + const padX = 9, padY = 8; + const titleH = 18; + const rowH = 16; + const panelW = 188; + const panelH = padY + titleH + 5 + RANK_ORDER.length * rowH + 5 + rowH + padY; + + const bg = this.add.graphics().setDepth(D.ui - 0.5); + bg.fillStyle(0x000000, 0.70); + bg.fillRoundedRect(px, py, panelW, panelH, 7); + bg.lineStyle(1, COLORS.accent, 0.35); + bg.strokeRoundedRect(px, py, panelW, panelH, 7); + + this.add.text(px + padX, py + padY, 'Matches so far', { + fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex, + }).setDepth(D.ui); + + this._matchPanelRows = RANK_ORDER.map((rank, i) => { + const label = rank === 'T' ? '10' : rank; + const t = this.add.text( + px + padX, + py + padY + titleH + 5 + i * rowH, + `${label.padEnd(2)} = 0 / 2`, + { fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex } + ).setDepth(D.ui); + return { rank, text: t }; + }); + + this._matchRemainingText = this.add.text( + px + padX, + py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5, + 'Remaining Matches: 26', + { fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex } + ).setDepth(D.ui); + } + + updateMatchesPanel() { + if (!this.gs || !this._matchPanelRows) return; + const tally = new Map(this._matchPanelRows.map(({ rank }) => [rank, 0])); + for (const player of this.gs.players) { + for (const rank of player.pairedRanks) { + tally.set(rank, (tally.get(rank) ?? 0) + 1); + } + } + let totalScored = 0; + for (const { rank, text } of this._matchPanelRows) { + const scored = tally.get(rank) ?? 0; + totalScored += scored; + const label = rank === 'T' ? '10' : rank; + text.setText(`${label.padEnd(2)} = ${scored} / 2`); + text.setColor(scored === 2 ? COLORS.mutedHex : COLORS.textHex); + } + this._matchRemainingText.setText(`Remaining Matches: ${26 - totalScored}`); + } + // ── Match lifecycle ──────────────────────────────────────────────────────── startNewMatch() { @@ -456,6 +516,7 @@ export default class GoFishGame extends Phaser.Scene { } this.renderSeatChips(); this.renderTurnIndicator(); + this.updateMatchesPanel(); } renderScatteredPool() { @@ -704,23 +765,23 @@ export default class GoFishGame extends Phaser.Scene { if (last.newPairs > 0 && last.pairedCards?.length >= 2) { // Cards from animateAsk are already visible in position. // Do NOT call renderAll() here — it would destroy the liveSprites. - // renderAll() is deferred to after the pair animation completes. + // renderAll() is deferred to after the pair + refill animations complete. this.hideBanner(); this.animatePairedCards(askerSeat, last.pairedCards, () => { - this.renderAll(); - this.updateStatus(); - this.animating = false; - if (isGameOver(this.gs)) { this.endGame(); return; } - this.maybeStartAITurn(); + this.playRefillsThenFinish(() => { + this.animating = false; + if (isGameOver(this.gs)) { this.endGame(); return; } + this.maybeStartAITurn(); + }); }, liveSprites ?? null); } else { this.time.delayedCall(900, () => { - this.renderAll(); - this.updateStatus(); - this.animating = false; - if (isGameOver(this.gs)) { this.endGame(); return; } this.hideBanner(); - this.maybeStartAITurn(); + this.playRefillsThenFinish(() => { + this.animating = false; + if (isGameOver(this.gs)) { this.endGame(); return; } + this.maybeStartAITurn(); + }); }); } }); @@ -965,6 +1026,84 @@ export default class GoFishGame extends Phaser.Scene { this.time.delayedCall(2200, onComplete); } + // ── Refill animation ────────────────────────────────────────────────────── + + animateRefill(seat, drawnCards, onComplete) { + if (drawnCards.length === 0) { onComplete(); return; } + const slot = this.slotForSeat[seat]; + const layout = slotLayout(slot); + const finalHand = this.gs.players[seat].hand; + const STAGGER = 130; + const FLY = 340; + const targetRot = (layout.rotateCards * Math.PI) / 180; + + const name = this.opponentName(seat); + this.showBanner(`${name}'s hand was empty — drawing new cards…`); + + drawnCards.forEach((card, i) => { + this.time.delayedCall(i * STAGGER, () => { + // Re-use the existing face-down pool sprite if still in cardObjs. + const poolKey = `pool-${card.id}`; + const existing = this.cardObjs.get(poolKey); + let sprite; + if (existing) { + this.cardObjs.delete(poolKey); + this.transientObjs.push(existing); + sprite = existing; + } else { + const pos = this.poolCardPositions.get(card.id) ?? POOL_POS; + sprite = this.makeCardSprite(card, pos.x, pos.y, { faceUp: false }); + this.transientObjs.push(sprite); + } + sprite.setDepth(D.card + 5); + + // Destination: the card's actual position in the final hand. + const cardInHand = finalHand.find(c => c.id === card.id); + let destX = layout.handCenter.x; + let destY = layout.handCenter.y; + if (cardInHand) { + const n = finalHand.length; + const idx = finalHand.indexOf(cardInHand); + const offset = idx - (n - 1) / 2; + if (layout.handAxis === 'x') destX = layout.handCenter.x + offset * HAND_SPREAD; + else destY = layout.handCenter.y + offset * HAND_SPREAD; + } + + if (i % 2 === 0) playSound(this, SFX.CARD_DEAL); + + this.tweens.add({ + targets: sprite, x: destX, y: destY, rotation: targetRot, + duration: FLY, ease: 'Cubic.easeOut', + onComplete: () => { + if (seat === 0 && cardInHand) this.flipCardFaceUp(sprite, card); + }, + }); + }); + }); + + const total = (drawnCards.length - 1) * STAGGER + FLY + (seat === 0 ? 380 : 160); + this.time.delayedCall(total, () => { + this.hideBanner(); + onComplete(); + }); + } + + // Run each refill entry sequentially, then call renderAll + updateStatus + onDone. + playRefillsThenFinish(onDone) { + const refills = (this.gs.lastAsk?.refills ?? []).filter(r => r.cards.length > 0); + const next = (idx) => { + if (idx >= refills.length) { + this.renderAll(); + this.updateStatus(); + onDone(); + return; + } + const { seat, cards } = refills[idx]; + this.animateRefill(seat, cards, () => next(idx + 1)); + }; + next(0); + } + spawnFireworks(cx, cy) { const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b]; const COUNT = 20; @@ -1026,22 +1165,22 @@ export default class GoFishGame extends Phaser.Scene { if (last.newPairs > 0 && last.pairedCards?.length >= 2) { this.time.delayedCall(600, () => { this.hideBanner(); - this.renderAll(); - this.updateStatus(); this.animatePairedCards(askerSeat, last.pairedCards, () => { - this.animating = false; - if (isGameOver(this.gs)) { this.endGame(); return; } - this.maybeStartAITurn(); + this.playRefillsThenFinish(() => { + this.animating = false; + if (isGameOver(this.gs)) { this.endGame(); return; } + this.maybeStartAITurn(); + }); }); }); } else { this.time.delayedCall(900, () => { - this.renderAll(); - this.updateStatus(); - this.animating = false; - if (isGameOver(this.gs)) { this.endGame(); return; } this.hideBanner(); - this.maybeStartAITurn(); + this.playRefillsThenFinish(() => { + this.animating = false; + if (isGameOver(this.gs)) { this.endGame(); return; } + this.maybeStartAITurn(); + }); }); } }; diff --git a/public/src/games/gofish/GoFishLogic.js b/public/src/games/gofish/GoFishLogic.js index 004457d..6d66fe2 100644 --- a/public/src/games/gofish/GoFishLogic.js +++ b/public/src/games/gofish/GoFishLogic.js @@ -160,10 +160,12 @@ export function applyAsk(state, targetSeat, rank) { pairedCards, }; next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length }); + const refillLog = []; // Asker may have emptied target's hand — refill them now (before asker's next ask). - ensureHasCards(next, targetSeat); + ensureHasCards(next, targetSeat, refillLog); // Asker may have emptied own hand by pairing — refill them too. - ensureHasCards(next, askerSeat); + ensureHasCards(next, askerSeat, refillLog); + next.lastAsk.refills = refillLog; // If asker still has no cards or can't ask, advance turn. if (!canAsk(next, askerSeat)) advanceTurn(next); checkGameOver(next); @@ -196,7 +198,9 @@ export function applyAsk(state, targetSeat, rank) { newPairs: 0, pairedCards: [], }; - ensureHasCards(next, askerSeat); + const emptyPoolRefills = []; + ensureHasCards(next, askerSeat, emptyPoolRefills); + next.lastAsk.refills = emptyPoolRefills; advanceTurn(next); checkGameOver(next); return next; @@ -227,7 +231,9 @@ export function applyFishPick(state, cardId) { }; if (lucky) next.log.push({ kind: 'lucky', askerSeat, rank }); next.phase = 'play'; - ensureHasCards(next, askerSeat); + const pickRefills = []; + ensureHasCards(next, askerSeat, pickRefills); + next.lastAsk.refills = pickRefills; if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next); checkGameOver(next); return next; @@ -267,15 +273,19 @@ function collectPairs(player, state) { * If the player's hand is empty, draw up to REFILL_TARGET from the pool. * If still empty afterward, mark them as sitting out. */ -function ensureHasCards(state, seat) { +function ensureHasCards(state, seat, refillLog) { const player = state.players[seat]; if (player.sittingOut) return; if (player.hand.length > 0) return; + const drawn = []; while (player.hand.length < REFILL_TARGET && state.pool.length > 0) { - player.hand.push(state.pool.pop()); + const card = state.pool.pop(); + player.hand.push(card); + drawn.push(cloneCard(card)); } if (player.hand.length > 0) { state.log.push({ kind: 'refill', seat, count: player.hand.length }); + if (refillLog && drawn.length > 0) refillLog.push({ seat, cards: drawn }); collectPairs(player, state); } if (player.hand.length === 0) {