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:
parent
9469440122
commit
4364181041
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue