feat(gofish): add animated card dealing and pair collection effects

- Implement round-robin card dealing animation from the pool to player hands.
- Add visual feedback for collecting pairs with card placement animations.
- Integrate fireworks particle effects when new pairs are formed.
- Update game logic to track paired cards for animation purposes.
- Improve game flow with staggered animations and delayed state updates.
This commit is contained in:
Brian Fertig 2026-05-18 22:58:10 -06:00
parent dcb8503219
commit 53db492c83
2 changed files with 202 additions and 25 deletions

View File

@ -267,16 +267,82 @@ export default class GoFishGame extends Phaser.Scene {
this.hideBanner(); this.hideBanner();
const playerCount = this.slotForSeat.length; const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount }); const finalState = createInitialState({ playerCount });
this.aiMemory = []; this.aiMemory = [];
for (let s = 0; s < playerCount; s++) { for (let s = 0; s < playerCount; s++) {
this.aiMemory[s] = createMemory(playerCount); this.aiMemory[s] = createMemory(playerCount);
observeLog(this.aiMemory[s], this.gs, s); observeLog(this.aiMemory[s], finalState, s);
} }
playSound(this, SFX.CARD_SHUFFLE); playSound(this, SFX.CARD_SHUFFLE);
this.renderAll(); this.animating = true;
this.updateStatus();
this.maybeStartAITurn(); // 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 ─────────────────────────────────────────────────── // ── Card sprite factory ───────────────────────────────────────────────────
@ -533,34 +599,139 @@ export default class GoFishGame extends Phaser.Scene {
this.animating = true; this.animating = true;
const before = this.gs; const before = this.gs;
const after = applyAsk(before, targetSeat, rank); const after = applyAsk(before, targetSeat, rank);
if (after === before) { if (after === before) { this.animating = false; return; }
this.animating = false;
return;
}
const last = after.lastAsk; const last = after.lastAsk;
const summary = this.formatAskBanner(last); this.showBanner(this.formatAskBanner(last));
this.showBanner(summary);
playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL); playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL);
this.gs = after; this.gs = after;
this.selectedRank = null; this.selectedRank = null;
// Update each AI's memory from the new log entries.
for (let s = 0; s < this.gs.players.length; s++) { for (let s = 0; s < this.gs.players.length; s++) {
observeLog(this.aiMemory[s], this.gs, s); observeLog(this.aiMemory[s], this.gs, s);
} }
this.time.delayedCall(900, () => { if (last.newPairs > 0 && last.pairedCards?.length >= 2) {
this.renderAll(); this.time.delayedCall(600, () => {
this.updateStatus(); this.hideBanner();
this.animating = false; this.renderAll();
if (isGameOver(this.gs)) { this.updateStatus();
this.endGame(); this.animatePairedCards(askerSeat, last.pairedCards, () => {
return; 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) { formatAskBanner(last) {

View File

@ -146,13 +146,14 @@ export function applyAsk(state, targetSeat, rank) {
if (matches.length > 0) { if (matches.length > 0) {
target.hand = target.hand.filter((c) => c.rank !== rank); target.hand = target.hand.filter((c) => c.rank !== rank);
asker.hand.push(...matches); asker.hand.push(...matches);
const newPairs = collectPairs(asker, next); const { count: newPairs, pairedCards } = collectPairs(asker, next);
next.lastAsk = { next.lastAsk = {
askerSeat, targetSeat, rank, askerSeat, targetSeat, rank,
result: 'catch', result: 'catch',
cardsTransferred: matches.map(cloneCard), cardsTransferred: matches.map(cloneCard),
drawnCard: null, drawnCard: null,
newPairs, newPairs,
pairedCards,
}; };
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length }); 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). // 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(); drawnCard = next.pool.pop();
asker.hand.push(drawnCard); asker.hand.push(drawnCard);
if (drawnCard.rank === rank) lucky = true; if (drawnCard.rank === rank) lucky = true;
const newPairs = collectPairs(asker, next); const { count: newPairs, pairedCards } = collectPairs(asker, next);
next.lastAsk = { next.lastAsk = {
askerSeat, targetSeat, rank, askerSeat, targetSeat, rank,
result: lucky ? 'lucky' : 'fish', result: lucky ? 'lucky' : 'fish',
cardsTransferred: [], cardsTransferred: [],
drawnCard: cloneCard(drawnCard), drawnCard: cloneCard(drawnCard),
newPairs, newPairs,
pairedCards,
}; };
if (lucky) { if (lucky) {
next.log.push({ kind: 'lucky', askerSeat, rank }); next.log.push({ kind: 'lucky', askerSeat, rank });
@ -191,6 +193,7 @@ export function applyAsk(state, targetSeat, rank) {
cardsTransferred: [], cardsTransferred: [],
drawnCard: null, drawnCard: null,
newPairs: 0, 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 * 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) { function collectPairs(player, state) {
let collected = 0; let collected = 0;
const pairedCards = [];
while (true) { while (true) {
const byRank = new Map(); const byRank = new Map();
let foundRank = null; let foundRank = null;
@ -217,6 +222,7 @@ function collectPairs(player, state) {
} }
if (!foundRank) break; if (!foundRank) break;
const pair = byRank.get(foundRank); const pair = byRank.get(foundRank);
pairedCards.push(cloneCard(pair[0]), cloneCard(pair[1]));
const idsToRemove = new Set([pair[0].id, pair[1].id]); const idsToRemove = new Set([pair[0].id, pair[1].id]);
player.hand = player.hand.filter((c) => !idsToRemove.has(c.id)); player.hand = player.hand.filter((c) => !idsToRemove.has(c.id));
player.pairs += 1; player.pairs += 1;
@ -224,7 +230,7 @@ function collectPairs(player, state) {
collected += 1; collected += 1;
if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank }); if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank });
} }
return collected; return { count: collected, pairedCards };
} }
/** /**