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 {
|
import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
applyAsk,
|
applyAsk,
|
||||||
|
applyFishPick,
|
||||||
legalRanksToAsk,
|
legalRanksToAsk,
|
||||||
canAsk,
|
canAsk,
|
||||||
isGameOver,
|
isGameOver,
|
||||||
|
|
@ -137,6 +138,7 @@ export default class GoFishGame extends Phaser.Scene {
|
||||||
this.aiMemory = [];
|
this.aiMemory = [];
|
||||||
this.selectedRank = null;
|
this.selectedRank = null;
|
||||||
this.bannerText = null;
|
this.bannerText = null;
|
||||||
|
this.poolCardPositions = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -223,16 +225,6 @@ export default class GoFishGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCenter() {
|
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.
|
// Last-action banner.
|
||||||
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
|
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
|
||||||
this.bannerText = this.add.text(CX, CY + CARD_H + 80, '', {
|
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);
|
}).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() {
|
buildHUD() {
|
||||||
// Sit just right of bottom portrait (right edge = CX - 90) and just above chip top (bchipY - 22).
|
// 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;
|
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 }
|
{ label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false }
|
||||||
);
|
);
|
||||||
this.transientObjs.push(deck);
|
this.transientObjs.push(deck);
|
||||||
this.poolCountText.setText('52');
|
|
||||||
for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); }
|
for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); }
|
||||||
|
|
||||||
// Build round-robin deal sequence.
|
// 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.
|
// Once the last card has landed, switch to live game state.
|
||||||
const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250;
|
const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250;
|
||||||
this.time.delayedCall(doneAt, () => {
|
this.time.delayedCall(doneAt, () => {
|
||||||
|
this.scatterPool(finalState.pool);
|
||||||
this.gs = finalState;
|
this.gs = finalState;
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
|
|
@ -408,7 +426,7 @@ export default class GoFishGame extends Phaser.Scene {
|
||||||
|
|
||||||
renderAll() {
|
renderAll() {
|
||||||
this.clearAllCardObjs();
|
this.clearAllCardObjs();
|
||||||
this.renderPool();
|
this.renderScatteredPool();
|
||||||
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
||||||
this.renderSeat(seat);
|
this.renderSeat(seat);
|
||||||
}
|
}
|
||||||
|
|
@ -416,11 +434,33 @@ export default class GoFishGame extends Phaser.Scene {
|
||||||
this.renderTurnIndicator();
|
this.renderTurnIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPool() {
|
renderScatteredPool() {
|
||||||
this.poolCountText.setText(`${this.gs.pool.length}`);
|
const localPickable = this.gs.phase === 'pick' && this.isLocalTurn();
|
||||||
if (this.gs.pool.length > 0) {
|
for (const card of this.gs.pool) {
|
||||||
const c = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false });
|
const pos = this.poolCardPositions.get(card.id);
|
||||||
this.cardObjs.set('pool', c);
|
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);
|
this.statusBg.setVisible(false);
|
||||||
return;
|
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.isLocalTurn()) {
|
||||||
if (this.selectedRank) {
|
if (this.selectedRank) {
|
||||||
this.statusText.setText(`Asking for ${this.selectedRank}s — tap an opponent to ask.`);
|
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;
|
const last = after.lastAsk;
|
||||||
|
|
||||||
this.animateAsk(askerSeat, targetSeat, rank, last, before, () => {
|
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));
|
this.showBanner(this.formatAskBanner(last));
|
||||||
playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL);
|
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) {
|
formatAskBanner(last) {
|
||||||
const asker = this.opponentName(last.askerSeat);
|
const asker = this.opponentName(last.askerSeat);
|
||||||
const target = this.opponentName(last.targetSeat);
|
const target = this.opponentName(last.targetSeat);
|
||||||
|
|
@ -875,6 +1017,10 @@ export default class GoFishGame extends Phaser.Scene {
|
||||||
|
|
||||||
maybeStartAITurn() {
|
maybeStartAITurn() {
|
||||||
if (this.gameOver || this.animating) return;
|
if (this.gameOver || this.animating) return;
|
||||||
|
if (this.gs.phase === 'pick' && !this.isLocalTurn()) {
|
||||||
|
this.time.delayedCall(700, () => this.runAIFishPick());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isLocalTurn()) return;
|
if (this.isLocalTurn()) return;
|
||||||
this.time.delayedCall(700, () => this.runAITurn());
|
this.time.delayedCall(700, () => this.runAITurn());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -168,12 +168,50 @@ export function applyAsk(state, targetSeat, rank) {
|
||||||
|
|
||||||
// Miss — Go Fish.
|
// Miss — Go Fish.
|
||||||
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'fish', count: 0 });
|
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'fish', count: 0 });
|
||||||
let drawnCard = null;
|
|
||||||
let lucky = false;
|
|
||||||
if (next.pool.length > 0) {
|
if (next.pool.length > 0) {
|
||||||
drawnCard = next.pool.pop();
|
// 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);
|
asker.hand.push(drawnCard);
|
||||||
if (drawnCard.rank === rank) lucky = true;
|
const lucky = drawnCard.rank === rank;
|
||||||
const { count: newPairs, pairedCards } = collectPairs(asker, next);
|
const { count: newPairs, pairedCards } = collectPairs(asker, next);
|
||||||
next.lastAsk = {
|
next.lastAsk = {
|
||||||
askerSeat, targetSeat, rank,
|
askerSeat, targetSeat, rank,
|
||||||
|
|
@ -183,20 +221,8 @@ export function applyAsk(state, targetSeat, rank) {
|
||||||
newPairs,
|
newPairs,
|
||||||
pairedCards,
|
pairedCards,
|
||||||
};
|
};
|
||||||
if (lucky) {
|
if (lucky) next.log.push({ kind: 'lucky', askerSeat, rank });
|
||||||
next.log.push({ kind: 'lucky', askerSeat, rank });
|
next.phase = 'play';
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.lastAsk = {
|
|
||||||
askerSeat, targetSeat, rank,
|
|
||||||
result: 'fish',
|
|
||||||
cardsTransferred: [],
|
|
||||||
drawnCard: null,
|
|
||||||
newPairs: 0,
|
|
||||||
pairedCards: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureHasCards(next, askerSeat);
|
ensureHasCards(next, askerSeat);
|
||||||
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
|
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
|
||||||
checkGameOver(next);
|
checkGameOver(next);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue