Compare commits

...

6 Commits

Author SHA1 Message Date
Brian Fertig b63bf1b651 refactor: enhance GoFish ask animation with fishing line effect
Refactor the ask sequence in GoFishGame.js to introduce a visual fishing line
animation between the asker and target before revealing the result.

Key changes:
- Extract target slot/layout calculations outside conditional blocks for clarity.
- Add `animateFishingLine` method to draw a dynamic hook arc with barb that
  extends from asker to target over 1 second.
- Reorganize animation timing: reveal asker's card first, then cast fishing line,
  then handle catch/fail outcomes.
- Simplify catch/fail logic by unifying post-line animation flows.
- Improve visual feedback with staggered card appearances and smoother transitions.
2026-05-18 23:40:26 -06:00
Brian Fertig 55ff4a3e00 feat(gofish): reveal drawn card to local player during go-fish
When the local player picks a card from the scattered pool, the card is now flipped face-up in place for 700ms before flying to their hand. This provides visual feedback on the drawn card. AI draws remain hidden and fly directly to the hand.
2026-05-18 23:31:45 -06:00
Brian Fertig 4364181041 feat(gofish): implement interactive scattered pool for go-fish draws
Replace the static deck pile with a scattered pool of face-down cards in the center of the table. When a player goes fishing, the game now pauses in a 'pick' phase, allowing the local player to click a card or the AI to randomly select one. This adds visual variety and interactive gameplay to the go-fish mechanic.
2026-05-18 23:27:51 -06:00
Brian Fertig 9469440122 Additional Animations 2026-05-18 23:09:06 -06:00
Brian Fertig 53db492c83 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.
2026-05-18 22:58:10 -06:00
Brian Fertig dcb8503219 refactor GoFishGame: align portrait/chip positions and add status text background 2026-05-18 22:32:19 -06:00
2 changed files with 663 additions and 83 deletions

View File

@ -9,6 +9,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState,
applyAsk,
applyFishPick,
legalRanksToAsk,
canAsk,
isGameOver,
@ -37,50 +38,70 @@ const SLOTS_USED = {
function slotLayout(slot) {
switch (slot) {
case 'bottom':
case 'bottom': {
// chip bottom edge = bchipY + 22; portrait bottom aligns with chip bottom
// portrait left of chip with 12px gap
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
const bpr = 56, bpx = CX - 90 - 12 - bpr, bpy = bchipY + 22 - bpr;
return {
handCenter: { x: CX, y: GAME_HEIGHT - 100 },
handAxis: 'x',
handFaceUp: true,
portrait: { x: 220, y: GAME_HEIGHT - 130, r: 56 },
nameLabel: { x: 220, y: GAME_HEIGHT - 60 },
chip: { x: CX, y: GAME_HEIGHT - 100 - CARD_H / 2 - 30 },
portrait: { x: bpx, y: bpy, r: bpr },
nameLabel: { x: bpx, y: bpy - bpr - 14 },
chip: { x: CX, y: bchipY },
chipRotation: 0,
rotateCards: 0,
};
case 'top':
}
case 'top': {
// chip top edge = tchipY - 22; portrait top aligns with chip top
// portrait left of chip with 12px gap
const tchipY = 110 + CARD_H / 2 + 30;
const tpr = 50, tpx = CX - 90 - 12 - tpr, tpy = tchipY - 22 + tpr;
return {
handCenter: { x: CX, y: 110 },
handAxis: 'x',
handFaceUp: false,
portrait: { x: 220, y: 130, r: 50 },
nameLabel: { x: 220, y: 200 },
chip: { x: CX, y: 110 + CARD_H / 2 + 30 },
portrait: { x: tpx, y: tpy, r: tpr },
nameLabel: { x: tpx, y: tpy + tpr + 14 },
chip: { x: CX, y: tchipY },
chipRotation: 0,
rotateCards: 180,
};
case 'left':
}
case 'left': {
// chip at (205, CY) rotated — extends ±90 in y, ±22 in x
// portrait left edge aligned with chip left edge (chipX - 22)
const lchipX = 110 + CARD_H / 2 + 10 + 22;
const lpr = 50, lpx = lchipX - 22 + lpr, lpy = CY - 90 - 12 - lpr;
return {
handCenter: { x: 110, y: CY },
handAxis: 'y',
handFaceUp: false,
portrait: { x: 130, y: 220, r: 50 },
nameLabel: { x: 130, y: 290 },
chip: { x: 110 + CARD_H / 2 + 10 + 22, y: CY },
portrait: { x: lpx, y: lpy, r: lpr },
nameLabel: { x: lpx, y: lpy - lpr - 14 },
chip: { x: lchipX, y: CY },
chipRotation: Math.PI / 2,
rotateCards: 90,
};
case 'right':
}
case 'right': {
// chip at (1715, CY) rotated — extends ±90 in y, ±22 in x
// portrait right edge aligned with chip right edge (chipX + 22)
const rchipX = GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22;
const rpr = 50, rpx = rchipX + 22 - rpr, rpy = CY - 90 - 12 - rpr;
return {
handCenter: { x: GAME_WIDTH - 110, y: CY },
handAxis: 'y',
handFaceUp: false,
portrait: { x: GAME_WIDTH - 130, y: 220, r: 50 },
nameLabel: { x: GAME_WIDTH - 130, y: 290 },
chip: { x: GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22, y: CY },
portrait: { x: rpx, y: rpy, r: rpr },
nameLabel: { x: rpx, y: rpy - rpr - 14 },
chip: { x: rchipX, y: CY },
chipRotation: -Math.PI / 2,
rotateCards: 270,
};
}
default:
throw new Error(`Unknown slot: ${slot}`);
}
@ -117,6 +138,7 @@ export default class GoFishGame extends Phaser.Scene {
this.aiMemory = [];
this.selectedRank = null;
this.bannerText = null;
this.poolCardPositions = new Map();
}
create() {
@ -203,16 +225,6 @@ export default class GoFishGame extends Phaser.Scene {
}
buildCenter() {
// Pool placeholder.
this.add.rectangle(POOL_POS.x, POOL_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
.setStrokeStyle(2, COLORS.accent).setDepth(D.pool);
this.add.text(POOL_POS.x, POOL_POS.y - CARD_H / 2 - 18, 'POOL', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.poolCountText = this.add.text(POOL_POS.x, POOL_POS.y + CARD_H / 2 + 16, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
// Last-action banner.
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
this.bannerText = this.add.text(CX, CY + CARD_H + 80, '', {
@ -221,10 +233,39 @@ export default class GoFishGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.banner);
}
getCentralBounds() {
const PAD = 20;
let left = PAD, right = GAME_WIDTH - PAD, top = PAD, bottom = GAME_HEIGHT - PAD;
for (const slot of this.slotForSeat) {
if (slot === 'bottom') bottom = Math.min(bottom, (GAME_HEIGHT - 100) - CARD_H / 2 - PAD);
if (slot === 'top') top = Math.max(top, 110 + CARD_H / 2 + PAD);
if (slot === 'left') left = Math.max(left, 110 + CARD_H / 2 + PAD);
if (slot === 'right') right = Math.min(right, (GAME_WIDTH - 110) - CARD_H / 2 - PAD);
}
return { left, right, top, bottom };
}
scatterPool(pool) {
this.poolCardPositions.clear();
const { left, right, top, bottom } = this.getCentralBounds();
const xMin = left + CARD_W / 2, xMax = right - CARD_W / 2;
const yMin = top + CARD_H / 2, yMax = bottom - CARD_H / 2;
for (const card of pool) {
this.poolCardPositions.set(card.id, {
x: xMin + Math.random() * (xMax - xMin),
y: yMin + Math.random() * (yMax - yMin),
rot: Math.random() * 60 - 30,
});
}
}
buildHUD() {
this.statusText = this.add.text(CX, GAME_HEIGHT - 230, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.ui);
// Sit just right of bottom portrait (right edge = CX - 90) and just above chip top (bchipY - 22).
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
this.statusBg = this.add.graphics().setDepth(D.ui - 1);
this.statusText = this.add.text(CX - 90, bchipY - 22 - 14, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'left',
}).setOrigin(0, 1).setDepth(D.ui);
new Button(this, 80, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 120, height: 40, fontSize: 18,
@ -244,16 +285,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);
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.scatterPool(finalState.pool);
this.gs = finalState;
this.animating = false;
this.renderAll();
this.updateStatus();
this.maybeStartAITurn();
});
}
// ── Card sprite factory ───────────────────────────────────────────────────
@ -319,7 +426,7 @@ export default class GoFishGame extends Phaser.Scene {
renderAll() {
this.clearAllCardObjs();
this.renderPool();
this.renderScatteredPool();
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeat(seat);
}
@ -327,11 +434,33 @@ export default class GoFishGame extends Phaser.Scene {
this.renderTurnIndicator();
}
renderPool() {
this.poolCountText.setText(`${this.gs.pool.length}`);
if (this.gs.pool.length > 0) {
const c = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false });
this.cardObjs.set('pool', c);
renderScatteredPool() {
const localPickable = this.gs.phase === 'pick' && this.isLocalTurn();
for (const card of this.gs.pool) {
const pos = this.poolCardPositions.get(card.id);
if (!pos) continue;
const c = this.makeCardSprite(
{ label: '', suit: 's', suitSymbol: '' }, pos.x, pos.y,
{ faceUp: false, rotation: pos.rot }
);
c.setDepth(D.pool);
this.cardObjs.set(`pool-${card.id}`, c);
if (localPickable) {
c.setInteractive(
new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H),
Phaser.Geom.Rectangle.Contains
);
c.input.cursor = 'pointer';
c.on('pointerover', () => {
this.tweens.add({ targets: c, scaleX: 1.08, scaleY: 1.08, duration: 100 });
c.setDepth(D.highlight);
});
c.on('pointerout', () => {
this.tweens.add({ targets: c, scaleX: 1, scaleY: 1, duration: 100 });
c.setDepth(D.pool);
});
c.on('pointerdown', () => this.onPoolCardClick(card.id));
}
}
}
@ -427,6 +556,16 @@ export default class GoFishGame extends Phaser.Scene {
updateStatus() {
if (this.gameOver) {
this.statusText.setText('');
this.statusBg.setVisible(false);
return;
}
if (this.gs?.phase === 'pick') {
this.statusText.setText(
this.isLocalTurn()
? 'Click any face-down card from the center to draw.'
: `${this.opponentName(this.gs.currentPlayer)} is drawing…`
);
this.refreshStatusBg();
return;
}
if (this.isLocalTurn()) {
@ -438,6 +577,16 @@ export default class GoFishGame extends Phaser.Scene {
} else {
this.statusText.setText(`${this.opponentName(this.gs.currentPlayer)} is thinking…`);
}
this.refreshStatusBg();
}
refreshStatusBg() {
const t = this.statusText;
const pad = 8;
this.statusBg.clear();
this.statusBg.fillStyle(0x000000, 0.55);
this.statusBg.fillRoundedRect(t.x - pad, t.y - t.height - pad, t.width + pad * 2, t.height + pad * 2, 6);
this.statusBg.setVisible(true);
}
showBanner(text) {
@ -499,33 +648,428 @@ 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);
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();
this.animateAsk(askerSeat, targetSeat, rank, last, before, () => {
// Fish with pool cards remaining — player must pick interactively.
if (after.phase === 'pick') {
this.gs = after;
this.selectedRank = null;
for (let s = 0; s < this.gs.players.length; s++) {
observeLog(this.aiMemory[s], this.gs, s);
}
this.renderAll();
this.updateStatus();
this.animating = false;
this.maybeStartAITurn();
return;
}
this.hideBanner();
this.maybeStartAITurn();
// Catch, or fish with empty pool — normal completion.
this.showBanner(this.formatAskBanner(last));
playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL);
this.gs = after;
this.selectedRank = null;
for (let s = 0; s < this.gs.players.length; s++) {
observeLog(this.aiMemory[s], this.gs, s);
}
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();
});
}
});
}
animateAsk(askerSeat, targetSeat, rank, last, beforeState, onComplete) {
const askerSlot = this.slotForSeat[askerSeat];
const askerLayout = slotLayout(askerSlot);
const askerShow = this.slotShowPos(askerSlot, askerLayout);
const targetSlot = this.slotForSeat[targetSeat];
const targetLayout = slotLayout(targetSlot);
const targetShow = this.slotShowPos(targetSlot, targetLayout);
const askerCard = beforeState.players[askerSeat].hand.find(c => c.rank === rank);
if (!askerCard) { onComplete(); return; }
const askerSprite = this.makeCardSprite(askerCard, askerLayout.handCenter.x, askerLayout.handCenter.y, {
faceUp: false, rotation: 0,
});
askerSprite.setDepth(D.banner - 4);
this.transientObjs.push(askerSprite);
// Move asker card to show zone then flip it face-up.
this.tweens.add({
targets: askerSprite,
x: askerShow.x, y: askerShow.y,
duration: 280,
ease: 'Cubic.easeOut',
onComplete: () => this.flipCardFaceUp(askerSprite, askerCard),
});
// After card is revealed, cast fishing line toward the target, then show result.
this.time.delayedCall(640, () => {
this.animateFishingLine(askerShow.x, askerShow.y, targetShow.x, targetShow.y, () => {
if (last.result === 'catch') {
const n = last.cardsTransferred.length;
const isTargetVert = targetSlot === 'left' || targetSlot === 'right';
const targetSprites = last.cardsTransferred.map((card, i) => {
const tx = isTargetVert ? targetShow.x
: targetShow.x + (i - (n - 1) / 2) * (CARD_W + 8);
const ty = isTargetVert ? targetShow.y + (i - (n - 1) / 2) * (CARD_H + 8)
: targetShow.y;
const sprite = this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, {
faceUp: false, rotation: 0,
});
sprite.setDepth(D.banner - 4);
this.transientObjs.push(sprite);
this.tweens.add({
targets: sprite,
x: tx, y: ty,
duration: 250,
delay: i * 80,
ease: 'Cubic.easeOut',
onComplete: () => this.flipCardFaceUp(sprite, card),
});
return { sprite, tx, ty };
});
const waitMs = 250 + (n - 1) * 80 + 380;
this.time.delayedCall(waitMs, () => {
if (askerSprite.active) {
this.tweens.add({
targets: askerSprite, alpha: 0, duration: 200,
onComplete: () => { if (askerSprite.active) askerSprite.destroy(); },
});
}
for (const { sprite } of targetSprites) {
if (!sprite.active) continue;
this.tweens.add({
targets: sprite,
x: askerLayout.handCenter.x, y: askerLayout.handCenter.y,
alpha: 0, duration: 380, ease: 'Cubic.easeIn',
onComplete: () => { if (sprite.active) sprite.destroy(); },
});
}
this.time.delayedCall(420, onComplete);
});
} else {
// Fish — flip card back face-down and return it to hand.
this.flipCardFaceDown(askerSprite);
this.time.delayedCall(340, () => {
this.tweens.add({
targets: askerSprite,
x: askerLayout.handCenter.x, y: askerLayout.handCenter.y,
duration: 280, ease: 'Cubic.easeIn',
onComplete: () => { if (askerSprite.active) askerSprite.destroy(); },
});
this.time.delayedCall(320, onComplete);
});
}
});
});
}
slotShowPos(slot, layout) {
const GAP = 10;
switch (slot) {
case 'bottom': return { x: layout.handCenter.x, y: layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2 };
case 'top': return { x: layout.handCenter.x, y: layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2 };
case 'left': return { x: layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2, y: layout.handCenter.y };
case 'right': return { x: layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2, y: layout.handCenter.y };
default: return { x: CX, y: CY };
}
}
flipCardFaceUp(container, card) {
this.tweens.add({
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
onComplete: () => {
container.setRotation(0);
this.renderCardFace(container, card, true);
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
},
});
}
flipCardFaceDown(container) {
this.tweens.add({
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
onComplete: () => {
this.renderCardFace(container, container.card, false);
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
},
});
}
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;
}
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(); },
});
}
}
onPoolCardClick(cardId) {
if (!this.isLocalTurn() || this.animating || this.gs.phase !== 'pick') return;
this.doFishPick(cardId);
}
runAIFishPick() {
if (this.gameOver || this.animating) return;
if (this.gs.phase !== 'pick' || this.isLocalTurn()) return;
const pool = this.gs.pool;
if (pool.length === 0) return;
const card = pool[Math.floor(Math.random() * pool.length)];
this.doFishPick(card.id);
}
doFishPick(cardId) {
this.animating = true;
const askerSeat = this.gs.lastAsk.askerSeat;
const after = applyFishPick(this.gs, cardId);
if (after === this.gs) { this.animating = false; return; }
const sprite = this.cardObjs.get(`pool-${cardId}`);
if (sprite) this.cardObjs.delete(`pool-${cardId}`);
this.poolCardPositions.delete(cardId);
const continueAfterDraw = () => {
const last = after.lastAsk;
this.showBanner(this.formatAskBanner(last));
playSound(this, SFX.CARD_DEAL);
this.gs = after;
this.selectedRank = null;
for (let s = 0; s < this.gs.players.length; s++) {
observeLog(this.aiMemory[s], this.gs, s);
}
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();
});
}
};
if (sprite) {
const revealCard = askerSeat === 0 ? after.lastAsk.drawnCard : null;
this.animateFishDraw(sprite, askerSeat, revealCard, continueAfterDraw);
} else {
this.time.delayedCall(50, continueAfterDraw);
}
}
animateFishDraw(sprite, askerSeat, revealCard, onComplete) {
const layout = slotLayout(this.slotForSeat[askerSeat]);
sprite.setDepth(D.banner - 4);
const flyToHand = () => {
this.tweens.add({
targets: sprite,
x: layout.handCenter.x, y: layout.handCenter.y,
rotation: 0,
alpha: 0,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => { if (sprite.active) sprite.destroy(); },
});
this.time.delayedCall(450, onComplete);
};
if (revealCard) {
// Human pick: flip face-up in place, pause to show the card, then fly to hand.
this.flipCardFaceUp(sprite, revealCard);
this.time.delayedCall(700, flyToHand);
} else {
flyToHand();
}
}
animateFishingLine(fromX, fromY, toX, toY, onComplete) {
const gfx = this.add.graphics().setDepth(D.banner - 3);
const totalDx = toX - fromX, totalDy = toY - fromY;
const totalLen = Math.sqrt(totalDx * totalDx + totalDy * totalDy) || 1;
const ndx = totalDx / totalLen, ndy = totalDy / totalLen;
const px = -ndy, py = ndx; // left-perpendicular to line direction
const HOOK_R = 10;
const BARB_LEN = 7;
const SWEEP = (4 * Math.PI) / 3; // 240°
const drawFrame = (t) => {
gfx.clear();
gfx.lineStyle(2, 0xffe066, 1);
const tipX = fromX + t * totalDx;
const tipY = fromY + t * totalDy;
gfx.beginPath();
gfx.moveTo(fromX, fromY);
gfx.lineTo(tipX, tipY);
gfx.strokePath();
// Hook arc offset perpendicular from tip
const hookCx = tipX + px * HOOK_R;
const hookCy = tipY + py * HOOK_R;
const startAngle = Math.atan2(tipY - hookCy, tipX - hookCx);
const endAngle = startAngle + SWEEP;
gfx.beginPath();
gfx.arc(hookCx, hookCy, HOOK_R, startAngle, endAngle, false);
gfx.strokePath();
// Barb at hook tip
const barbX = hookCx + HOOK_R * Math.cos(endAngle);
const barbY = hookCy + HOOK_R * Math.sin(endAngle);
gfx.beginPath();
gfx.moveTo(barbX, barbY);
gfx.lineTo(barbX - Math.sin(endAngle) * BARB_LEN, barbY + Math.cos(endAngle) * BARB_LEN);
gfx.strokePath();
};
const progress = { t: 0 };
this.tweens.add({
targets: progress,
t: 1,
duration: 1000,
ease: 'Cubic.easeOut',
onUpdate: () => drawFrame(progress.t),
onComplete: () => {
drawFrame(1);
this.time.delayedCall(150, () => {
this.tweens.add({
targets: gfx,
alpha: 0,
duration: 250,
ease: 'Linear',
onComplete: () => { gfx.destroy(); onComplete(); },
});
});
},
});
}
@ -545,6 +1089,10 @@ export default class GoFishGame extends Phaser.Scene {
maybeStartAITurn() {
if (this.gameOver || this.animating) return;
if (this.gs.phase === 'pick' && !this.isLocalTurn()) {
this.time.delayedCall(700, () => this.runAIFishPick());
return;
}
if (this.isLocalTurn()) return;
this.time.delayedCall(700, () => this.runAITurn());
}

View File

@ -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).
@ -167,33 +168,61 @@ export function applyAsk(state, targetSeat, rank) {
// Miss — Go Fish.
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'fish', count: 0 });
let drawnCard = null;
let lucky = false;
if (next.pool.length > 0) {
drawnCard = next.pool.pop();
asker.hand.push(drawnCard);
if (drawnCard.rank === rank) lucky = true;
const newPairs = collectPairs(asker, next);
next.lastAsk = {
askerSeat, targetSeat, rank,
result: lucky ? 'lucky' : 'fish',
cardsTransferred: [],
drawnCard: cloneCard(drawnCard),
newPairs,
};
if (lucky) {
next.log.push({ kind: 'lucky', askerSeat, rank });
}
} else {
// Pause — player must interactively pick a card from the scattered pool.
next.phase = 'pick';
next.lastAsk = {
askerSeat, targetSeat, rank,
result: 'fish',
cardsTransferred: [],
drawnCard: null,
newPairs: 0,
pairedCards: [],
};
return next;
}
// Pool is empty — no card to draw, advance turn immediately.
next.lastAsk = {
askerSeat, targetSeat, rank,
result: 'fish',
cardsTransferred: [],
drawnCard: null,
newPairs: 0,
pairedCards: [],
};
ensureHasCards(next, askerSeat);
if (!canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
return next;
}
/**
* Complete the interactive "Go Fish" draw. `cardId` is the id of the card the
* player (or AI) chose from the scattered pool. Only valid when phase === 'pick'.
*/
export function applyFishPick(state, cardId) {
if (state.phase !== 'pick') return state;
const { askerSeat, targetSeat, rank } = state.lastAsk;
const next = cloneState(state);
const poolIdx = next.pool.findIndex((c) => c.id === cardId);
if (poolIdx === -1) return state;
const [drawnCard] = next.pool.splice(poolIdx, 1);
const asker = next.players[askerSeat];
asker.hand.push(drawnCard);
const lucky = drawnCard.rank === rank;
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 });
next.phase = 'play';
ensureHasCards(next, askerSeat);
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
@ -202,10 +231,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 +248,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 +256,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 };
}
/**