feat(gofish): add matches panel and refill animations

- Add a "Matches so far" panel to the HUD that tracks paired ranks and remaining matches.
- Introduce `animateRefill` to visually animate cards flying from the pool to an empty hand.
- Refactor turn flow to use `playRefillsThenFinish` for sequential refill animations after pairing or empty-hand draws.
- Update `GoFishLogic` to capture refill events in `lastAsk.refills` for the animation system.
This commit is contained in:
Brian Fertig 2026-05-20 19:27:11 -06:00
parent fcee81a9d6
commit cc6544f44e
4 changed files with 176 additions and 27 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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();
});
});
}
};

View File

@ -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) {