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.
This commit is contained in:
Brian Fertig 2026-05-18 23:27:51 -06:00
parent 9469440122
commit 4364181041
2 changed files with 207 additions and 35 deletions

View File

@ -9,6 +9,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState,
applyAsk,
applyFishPick,
legalRanksToAsk,
canAsk,
isGameOver,
@ -137,6 +138,7 @@ export default class GoFishGame extends Phaser.Scene {
this.aiMemory = [];
this.selectedRank = null;
this.bannerText = null;
this.poolCardPositions = new Map();
}
create() {
@ -223,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, '', {
@ -241,6 +233,32 @@ 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() {
// 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;
@ -282,7 +300,6 @@ export default class GoFishGame extends Phaser.Scene {
{ 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.
@ -337,6 +354,7 @@ export default class GoFishGame extends Phaser.Scene {
// 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();
@ -408,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);
}
@ -416,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));
}
}
}
@ -519,6 +559,15 @@ export default class GoFishGame extends Phaser.Scene {
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()) {
if (this.selectedRank) {
this.statusText.setText(`Asking for ${this.selectedRank}s — tap an opponent to ask.`);
@ -603,6 +652,21 @@ export default class GoFishGame extends Phaser.Scene {
const last = after.lastAsk;
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;
}
// Catch, or fish with empty pool — normal completion.
this.showBanner(this.formatAskBanner(last));
playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL);
@ -859,6 +923,84 @@ export default class GoFishGame extends Phaser.Scene {
}
}
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) {
this.animateFishDraw(sprite, askerSeat, continueAfterDraw);
} else {
this.time.delayedCall(50, continueAfterDraw);
}
}
animateFishDraw(sprite, askerSeat, onComplete) {
const layout = slotLayout(this.slotForSeat[askerSeat]);
sprite.setDepth(D.banner - 4);
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);
}
formatAskBanner(last) {
const asker = this.opponentName(last.askerSeat);
const target = this.opponentName(last.targetSeat);
@ -875,6 +1017,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

@ -168,25 +168,10 @@ 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 { 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 });
}
} else {
// Pause — player must interactively pick a card from the scattered pool.
next.phase = 'pick';
next.lastAsk = {
askerSeat, targetSeat, rank,
result: 'fish',
@ -195,8 +180,49 @@ export function applyAsk(state, targetSeat, rank) {
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);