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:
parent
fcee81a9d6
commit
cc6544f44e
Binary file not shown.
Binary file not shown.
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue