diff --git a/public/src/games/gofish/GoFishGame.js b/public/src/games/gofish/GoFishGame.js index 8f17992..d9f85bd 100644 --- a/public/src/games/gofish/GoFishGame.js +++ b/public/src/games/gofish/GoFishGame.js @@ -267,16 +267,82 @@ export default class GoFishGame extends Phaser.Scene { this.hideBanner(); const playerCount = this.slotForSeat.length; - this.gs = createInitialState({ playerCount }); + const finalState = createInitialState({ playerCount }); this.aiMemory = []; for (let s = 0; s < playerCount; s++) { this.aiMemory[s] = createMemory(playerCount); - observeLog(this.aiMemory[s], this.gs, s); + observeLog(this.aiMemory[s], finalState, s); } + playSound(this, SFX.CARD_SHUFFLE); - this.renderAll(); - this.updateStatus(); - this.maybeStartAITurn(); + this.animating = true; + + // Show the deck as the deal source and reset chip display. + const deck = this.makeCardSprite( + { label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false } + ); + this.transientObjs.push(deck); + this.poolCountText.setText('52'); + for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); } + + // Build round-robin deal sequence. + const hands = finalState.players.map(p => [...p.hand]); + const maxCards = Math.max(...hands.map(h => h.length)); + const sequence = []; + for (let round = 0; round < maxCards; round++) { + for (let seat = 0; seat < playerCount; seat++) { + if (round < hands[seat].length) { + sequence.push({ seat, card: hands[seat][round], handIndex: round }); + } + } + } + + const STAGGER = 90; // ms between each card launch + const DURATION = 200; // ms for the card to fly + + sequence.forEach(({ seat, card, handIndex }, idx) => { + this.time.delayedCall(idx * STAGGER, () => { + const layout = slotLayout(this.slotForSeat[seat]); + const n = hands[seat].length; + const offset = handIndex - (n - 1) / 2; + const tx = layout.handAxis === 'x' + ? layout.handCenter.x + offset * HAND_SPREAD + : layout.handCenter.x; + const ty = layout.handAxis === 'x' + ? layout.handCenter.y + : layout.handCenter.y + offset * HAND_SPREAD; + + const sprite = this.makeCardSprite(card, POOL_POS.x, POOL_POS.y, { + faceUp: false, rotation: layout.rotateCards, + }); + sprite.setDepth(D.card + 5); + this.transientObjs.push(sprite); + + if (idx % 4 === 0) playSound(this, SFX.CARD_DEAL); + + this.tweens.add({ + targets: sprite, + x: tx, y: ty, + duration: DURATION, + ease: 'Cubic.easeOut', + onComplete: () => { + if (!sprite.active) return; + if (layout.handFaceUp) this.renderCardFace(sprite, card, true); + sprite.setDepth(D.card); + }, + }); + }); + }); + + // Once the last card has landed, switch to live game state. + const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250; + this.time.delayedCall(doneAt, () => { + this.gs = finalState; + this.animating = false; + this.renderAll(); + this.updateStatus(); + this.maybeStartAITurn(); + }); } // ── Card sprite factory ─────────────────────────────────────────────────── @@ -533,34 +599,139 @@ export default class GoFishGame extends Phaser.Scene { this.animating = true; const before = this.gs; const after = applyAsk(before, targetSeat, rank); - if (after === before) { - this.animating = false; - return; - } + if (after === before) { this.animating = false; return; } const last = after.lastAsk; - const summary = this.formatAskBanner(last); - this.showBanner(summary); + this.showBanner(this.formatAskBanner(last)); playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL); this.gs = after; this.selectedRank = null; - // Update each AI's memory from the new log entries. for (let s = 0; s < this.gs.players.length; s++) { observeLog(this.aiMemory[s], this.gs, s); } - this.time.delayedCall(900, () => { - this.renderAll(); - this.updateStatus(); - this.animating = false; - if (isGameOver(this.gs)) { - this.endGame(); - return; + 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(); + }); + }); + } else { + this.time.delayedCall(900, () => { + this.renderAll(); + this.updateStatus(); + this.animating = false; + if (isGameOver(this.gs)) { this.endGame(); return; } + this.hideBanner(); + this.maybeStartAITurn(); + }); + } + } + + animatePairedCards(askerSeat, pairedCards, onComplete) { + const slot = this.slotForSeat[askerSeat]; + const layout = slotLayout(slot); + const n = pairedCards.length; + const GAP = 10; + const totalW = n * CARD_W + (n - 1) * GAP; + + const sprites = []; + for (let i = 0; i < n; i++) { + let targetX, targetY; + switch (slot) { + case 'bottom': + targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); + targetY = layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2; + break; + case 'top': + targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); + targetY = layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2; + break; + case 'left': { + const leftmostX = layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2; + targetX = leftmostX + i * (CARD_W + GAP); + targetY = CY; + break; + } + case 'right': { + const rightmostX = layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2; + targetX = rightmostX - (n - 1 - i) * (CARD_W + GAP); + targetY = CY; + break; + } + default: + targetX = CX; targetY = CY; } - this.hideBanner(); - this.maybeStartAITurn(); + + const sprite = this.makeCardSprite(pairedCards[i], CX, CY, { faceUp: true, rotation: 0 }); + sprite.setDepth(D.banner - 5); + sprite.setAlpha(0); + this.transientObjs.push(sprite); + + this.tweens.add({ + targets: sprite, + x: targetX, y: targetY, alpha: 1, + duration: 300, + ease: 'Back.easeOut', + }); + + sprites.push({ sprite, targetX, targetY }); + } + + playSound(this, SFX.CARD_PLACE); + + this.time.delayedCall(350, () => { + const cx = sprites.reduce((s, { targetX: tx }) => s + tx, 0) / sprites.length; + const cy = sprites.reduce((s, { targetY: ty }) => s + ty, 0) / sprites.length; + this.spawnFireworks(cx, cy); + this.time.delayedCall(350, () => this.spawnFireworks(cx, cy)); }); + + this.time.delayedCall(1600, () => { + for (const { sprite } of sprites) { + if (!sprite.active) continue; + this.tweens.add({ + targets: sprite, + x: CX, y: CY, alpha: 0, + duration: 500, + ease: 'Cubic.easeIn', + onComplete: () => { if (sprite.active) sprite.destroy(); }, + }); + } + }); + + this.time.delayedCall(2200, onComplete); + } + + spawnFireworks(cx, cy) { + const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b]; + const COUNT = 20; + for (let i = 0; i < COUNT; i++) { + const angle = (i / COUNT) * Math.PI * 2; + const dist = 70 + Math.random() * 70; + const color = BURST_COLORS[i % BURST_COLORS.length]; + const r = 3 + Math.random() * 4; + const g = this.add.graphics().setDepth(D.banner - 2); + g.fillStyle(color, 1); + g.fillCircle(0, 0, r); + g.setPosition(cx, cy); + this.transientObjs.push(g); + this.tweens.add({ + targets: g, + x: cx + Math.cos(angle) * dist, + y: cy + Math.sin(angle) * dist, + alpha: 0, + duration: 600 + Math.random() * 300, + ease: 'Quad.easeOut', + onComplete: () => { if (g.active) g.destroy(); }, + }); + } } formatAskBanner(last) { diff --git a/public/src/games/gofish/GoFishLogic.js b/public/src/games/gofish/GoFishLogic.js index c21117f..9ef86a7 100644 --- a/public/src/games/gofish/GoFishLogic.js +++ b/public/src/games/gofish/GoFishLogic.js @@ -146,13 +146,14 @@ export function applyAsk(state, targetSeat, rank) { if (matches.length > 0) { target.hand = target.hand.filter((c) => c.rank !== rank); asker.hand.push(...matches); - const newPairs = collectPairs(asker, next); + const { count: newPairs, pairedCards } = collectPairs(asker, next); next.lastAsk = { askerSeat, targetSeat, rank, result: 'catch', cardsTransferred: matches.map(cloneCard), drawnCard: null, newPairs, + pairedCards, }; next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length }); // Asker may have emptied target's hand — refill them now (before asker's next ask). @@ -173,13 +174,14 @@ export function applyAsk(state, targetSeat, rank) { drawnCard = next.pool.pop(); asker.hand.push(drawnCard); if (drawnCard.rank === rank) lucky = true; - const newPairs = collectPairs(asker, next); + const { count: newPairs, pairedCards } = collectPairs(asker, next); next.lastAsk = { askerSeat, targetSeat, rank, result: lucky ? 'lucky' : 'fish', cardsTransferred: [], drawnCard: cloneCard(drawnCard), newPairs, + pairedCards, }; if (lucky) { next.log.push({ kind: 'lucky', askerSeat, rank }); @@ -191,6 +193,7 @@ export function applyAsk(state, targetSeat, rank) { cardsTransferred: [], drawnCard: null, newPairs: 0, + pairedCards: [], }; } @@ -202,10 +205,12 @@ export function applyAsk(state, targetSeat, rank) { /** * Repeatedly remove any 2 cards of the same rank from the player's hand and - * increment their pair count. Returns the number of pairs collected. + * increment their pair count. Returns { count, pairedCards } where pairedCards + * holds clones of each removed card for animation purposes. */ function collectPairs(player, state) { let collected = 0; + const pairedCards = []; while (true) { const byRank = new Map(); let foundRank = null; @@ -217,6 +222,7 @@ function collectPairs(player, state) { } if (!foundRank) break; const pair = byRank.get(foundRank); + pairedCards.push(cloneCard(pair[0]), cloneCard(pair[1])); const idsToRemove = new Set([pair[0].id, pair[1].id]); player.hand = player.hand.filter((c) => !idsToRemove.has(c.id)); player.pairs += 1; @@ -224,7 +230,7 @@ function collectPairs(player, state) { collected += 1; if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank }); } - return collected; + return { count: collected, pairedCards }; } /**