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:
parent
dcb8503219
commit
53db492c83
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue