feat: add Go Fish card game with AI and UI

- Implement Go Fish game logic in GoFishLogic.js with state management, turn handling, and pair scoring
- Create GoFishAI.js with memory-based opponent that tracks card distribution and makes strategic decisions
- Build GoFishGame.js scene with Phaser UI including card rendering, seat layouts, animations, and game flow
- Register Go Fish in gameRegistry with 'cards' category and multiplayer support (1-4 players)
- Update GameMenuScene to display 'Cards' column for card games
- Wire up GoFishGame in GameRoomScene slug dispatch and main.js scene registry
This commit is contained in:
Brian Fertig 2026-05-17 20:56:40 -06:00
parent 356a2e98c5
commit d46de05b48
7 changed files with 1019 additions and 3 deletions

View File

@ -0,0 +1,140 @@
// Go Fish AI — memory-based opponent.
//
// chooseAction(state, seat, memory) returns { targetSeat, rank } — the AI's
// single decision for its turn. After the action resolves, the scene calls
// observeLog(memory, state, fromIdx) to update memory with the new log
// entries.
import { RANKS } from '../cards/Deck.js';
const TOTAL_PER_RANK = 4; // 4 suits per rank
export function createMemory(seatCount) {
return {
// beliefs[targetSeat][rank] ∈ [0, 4]: a soft count of how many of that
// rank we believe `targetSeat` still holds. Bumped when they ask for or
// catch a rank; zeroed when they get fished or hand it over.
beliefs: Array.from({ length: seatCount }, () => Object.fromEntries(RANKS.map((r) => [r, 0]))),
// pairedCount[rank]: how many cards of `rank` have already been removed
// via scored pairs (each pair = 2 cards). Always 0 or 2.
pairedCount: Object.fromEntries(RANKS.map((r) => [r, 0])),
// turnRecorded[targetSeat][rank]: turnCount when belief was set, used for
// decay scoring.
turnRecorded: Array.from({ length: seatCount }, () => ({})),
// Last log index we've consumed, so observe() is idempotent.
logCursor: 0,
};
}
/**
* Replay every log entry from memory.logCursor onward and update memory.
*/
export function observeLog(memory, state, selfSeat) {
for (let i = memory.logCursor; i < state.log.length; i++) {
const e = state.log[i];
if (e.kind === 'ask') {
// The asker held at least 1 of `rank` at the moment of the ask.
memory.beliefs[e.askerSeat][e.rank] = Math.max(memory.beliefs[e.askerSeat][e.rank], 1);
memory.turnRecorded[e.askerSeat][e.rank] = state.turnCount;
if (e.result === 'catch') {
// Target's `rank` cards all moved to asker. Asker now holds ≥ 2.
const transferred = e.count;
memory.beliefs[e.askerSeat][e.rank] += transferred;
memory.beliefs[e.targetSeat][e.rank] = 0;
memory.turnRecorded[e.targetSeat][e.rank] = state.turnCount;
} else if (e.result === 'fish') {
// Confirmed target holds none of that rank.
memory.beliefs[e.targetSeat][e.rank] = 0;
memory.turnRecorded[e.targetSeat][e.rank] = state.turnCount;
}
} else if (e.kind === 'lucky') {
// Asker drew a `rank` from the pool — they now hold one more of it.
memory.beliefs[e.askerSeat][e.rank] += 1;
memory.turnRecorded[e.askerSeat][e.rank] = state.turnCount;
} else if (e.kind === 'pair') {
memory.pairedCount[e.rank] += 2;
// The pairer no longer holds 2 of that rank.
memory.beliefs[e.seat][e.rank] = Math.max(0, memory.beliefs[e.seat][e.rank] - 2);
} else if (e.kind === 'refill') {
// Refills draw from the pool; we don't know specific ranks. No update.
} else if (e.kind === 'sitout') {
// That seat is out; reset their beliefs.
for (const r of RANKS) memory.beliefs[e.seat][r] = 0;
}
}
memory.logCursor = state.log.length;
// Trim ourselves out of beliefs — we know our own hand directly.
for (const r of RANKS) memory.beliefs[selfSeat][r] = 0;
}
/**
* Pick (targetSeat, rank) for the AI. Falls back gracefully if memory is
* empty (e.g. very first turn).
*/
export function chooseAction(state, seat, memory) {
const me = state.players[seat];
if (!me || me.hand.length === 0) return null;
// Our distinct ranks (we must hold at least one).
const myRanks = [...new Set(me.hand.map((c) => c.rank))];
// Cards-per-opponent for statistical weighting.
const handSizes = state.players.map((p) => p.hand.length);
const avgHand = handSizes.reduce((a, b) => a + b, 0) / handSizes.length;
let best = null;
let bestScore = -Infinity;
for (const rank of myRanks) {
const myCount = me.hand.filter((c) => c.rank === rank).length;
const remainingOfRank = Math.max(0, TOTAL_PER_RANK - memory.pairedCount[rank] - myCount);
if (remainingOfRank <= 0) continue; // impossible to catch any
for (let target = 0; target < state.players.length; target++) {
if (target === seat) continue;
const opp = state.players[target];
if (opp.sittingOut || opp.hand.length === 0) continue;
let score = 0;
const belief = memory.beliefs[target][rank] || 0;
// Strong signal: they've shown they hold this rank.
if (belief > 0) score += 3 + Math.min(belief, 3);
// We pair on a catch, so more in-hand = bigger payoff.
score += myCount;
// Decay: stale beliefs are worth less.
const turnSet = memory.turnRecorded[target][rank];
if (turnSet != null) {
const age = state.turnCount - turnSet;
score -= Math.min(age * 0.5, 4);
}
// Statistical: bigger hands are more likely to contain the rank.
score += (opp.hand.length - avgHand) * 0.3;
// Long-tail random tiebreak.
score += Math.random() * 0.1;
if (score > bestScore) {
bestScore = score;
best = { targetSeat: target, rank };
}
}
}
// Fallback: if no opponent looks viable (e.g. all sitting out), pick any
// valid (target, rank) at random.
if (!best) {
for (const rank of myRanks) {
for (let t = 0; t < state.players.length; t++) {
if (t === seat) continue;
const opp = state.players[t];
if (!opp.sittingOut && opp.hand.length > 0) {
return { targetSeat: t, rank };
}
}
}
}
return best;
}

View File

@ -0,0 +1,582 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { Modal } from '../../ui/Modal.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState,
applyAsk,
legalRanksToAsk,
canAsk,
isGameOver,
} from './GoFishLogic.js';
import { createMemory, observeLog, chooseAction } from './GoFishAI.js';
// ── Layout constants ────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
const CARD_W = 90;
const CARD_H = 126;
const CARD_R = 8;
const HAND_SPREAD = 70;
const D = {
felt: -1, board: 0, pool: 5, card: 10, highlight: 20,
ui: 30, portrait: 35, chip: 40, banner: 60, modal: 80,
};
const SLOTS_USED = {
2: ['bottom', 'top'],
3: ['bottom', 'left', 'right'],
4: ['bottom', 'left', 'top', 'right'],
};
function slotLayout(slot) {
switch (slot) {
case 'bottom':
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 },
chipRotation: 0,
rotateCards: 0,
};
case 'top':
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 },
chipRotation: 0,
rotateCards: 180,
};
case 'left':
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 },
chipRotation: Math.PI / 2,
rotateCards: 90,
};
case 'right':
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 },
chipRotation: -Math.PI / 2,
rotateCards: 270,
};
default:
throw new Error(`Unknown slot: ${slot}`);
}
}
const POOL_POS = { x: CX, y: CY };
const SUIT_COLORS = {
s: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' },
c: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' },
h: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' },
d: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' },
};
// ── Scene ───────────────────────────────────────────────────────────────────
export default class GoFishGame extends Phaser.Scene {
constructor() { super('GoFishGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.gs = null;
this.animating = false;
this.gameOver = false;
this.cardObjs = new Map();
this.transientObjs = [];
this.opponentPortraits = [];
this.seatChips = [];
this.slotForSeat = [];
this.aiMemory = [];
this.selectedRank = null;
this.bannerText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.assignSeats();
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.startNewMatch();
}
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
} else {
const color = pf?.fallbackColor
? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d;
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
}
}
assignSeats() {
const playerCount = 1 + this.opponents.length;
const slots = SLOTS_USED[playerCount];
if (!slots) throw new Error(`Go Fish needs 2..4 players, got ${playerCount}`);
this.slotForSeat = slots.slice();
}
buildSeatAreas() {
for (let seat = 0; seat < this.slotForSeat.length; seat++) {
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
if (seat === 0) {
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'GoFishGame');
this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
} else {
const opp = this.opponents[seat - 1];
if (opp) {
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait);
this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
}
}
// Pair counter chip.
this.seatChips[seat] = this.makeSeatChip(layout.chip.x, layout.chip.y);
this.seatChips[seat].container.setRotation(layout.chipRotation);
}
// Make opponent portraits clickable as ask targets (seats 1..N).
for (let seat = 1; seat < this.slotForSeat.length; seat++) {
const layout = slotLayout(this.slotForSeat[seat]);
const hot = this.add.circle(layout.portrait.x, layout.portrait.y, layout.portrait.r + 8, 0xffffff, 0)
.setDepth(D.portrait + 5)
.setInteractive({ useHandCursor: true });
hot.on('pointerdown', () => this.onOpponentClick(seat));
hot.on('pointerover', () => this.highlightOpponent(seat, true));
hot.on('pointerout', () => this.highlightOpponent(seat, false));
this.transientObjs.push(hot);
}
}
makeSeatChip(x, y) {
const container = this.add.container(x, y).setDepth(D.chip);
const bg = this.add.graphics();
bg.fillStyle(COLORS.panel, 0.92);
bg.fillRoundedRect(-90, -22, 180, 44, 10);
bg.lineStyle(2, COLORS.accent, 1);
bg.strokeRoundedRect(-90, -22, 180, 44, 10);
const label = this.add.text(-78, 0, 'PAIRS', {
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
}).setOrigin(0, 0.5);
const count = this.add.text(78, 0, '0', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(1, 0.5);
container.add([bg, label, count]);
return { container, count };
}
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, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 900 },
}).setOrigin(0.5).setDepth(D.banner);
}
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);
new Button(this, 80, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 120, height: 40, fontSize: 18,
}).setDepth(D.ui);
new Button(this, 80, GAME_HEIGHT - 75, 'New', () => this.startNewMatch(), {
variant: 'ghost', width: 120, height: 40, fontSize: 18,
}).setDepth(D.ui);
}
// ── Match lifecycle ────────────────────────────────────────────────────────
startNewMatch() {
if (this.animating) return;
this.gameOver = false;
this.selectedRank = null;
this.clearAllCardObjs();
this.hideBanner();
const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount });
this.aiMemory = [];
for (let s = 0; s < playerCount; s++) {
this.aiMemory[s] = createMemory(playerCount);
observeLog(this.aiMemory[s], this.gs, s);
}
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.updateStatus();
this.maybeStartAITurn();
}
// ── Card sprite factory ───────────────────────────────────────────────────
makeCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
c.setRotation((rotation * Math.PI) / 180);
c.setScale(scale);
this.renderCardFace(c, card, faceUp);
c.card = card;
return c;
}
renderCardFace(container, card, faceUp) {
container.removeAll(true);
const x = -CARD_W / 2, y = -CARD_H / 2;
const g = this.add.graphics();
if (!faceUp) {
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
g.destroy();
container.add(
this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
.setDisplaySize(CARD_W, CARD_H)
.setOrigin(0.5)
);
} else {
const color = this.cardBack?.fallbackColor
? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b;
g.fillStyle(color, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, COLORS.accent, 0.6);
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
g.lineStyle(1, 0xffffff, 0.15);
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
container.add(g);
}
return;
}
const suit = SUIT_COLORS[card.suit] ?? SUIT_COLORS.s;
g.fillStyle(suit.fill, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, suit.stroke, 1);
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
const labelStyle = (sz) => ({ fontFamily: 'Righteous', fontSize: `${sz}px`, color: suit.glyph });
container.add(this.add.text(x + 8, y + 6, card.label, labelStyle(20)));
container.add(this.add.text(x + 8, y + 28, card.suitSymbol, labelStyle(20)));
container.add(this.add.text(0, 0, card.suitSymbol, labelStyle(54)).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, card.label, labelStyle(20)).setOrigin(1, 1));
}
clearAllCardObjs() {
for (const c of this.cardObjs.values()) c.destroy();
this.cardObjs.clear();
for (const o of this.transientObjs) o.destroy();
this.transientObjs = [];
}
// ── Rendering ─────────────────────────────────────────────────────────────
renderAll() {
this.clearAllCardObjs();
this.renderPool();
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeat(seat);
}
this.renderSeatChips();
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);
}
}
renderSeat(seat) {
const player = this.gs.players[seat];
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const n = player.hand.length;
for (let i = 0; i < n; i++) {
const card = player.hand[i];
const offset = i - (n - 1) / 2;
let x, y;
if (layout.handAxis === 'x') {
x = layout.handCenter.x + offset * HAND_SPREAD;
y = layout.handCenter.y;
} else {
x = layout.handCenter.x;
y = layout.handCenter.y + offset * HAND_SPREAD;
}
const c = this.makeCardSprite(card, x, y, {
faceUp: layout.handFaceUp,
rotation: layout.rotateCards,
});
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
if (seat === 0) {
c.on('pointerdown', () => this.onHandCardClick(card.rank));
} else {
c.on('pointerdown', () => this.onOpponentClick(seat));
c.on('pointerover', () => this.highlightOpponent(seat, true));
c.on('pointerout', () => this.highlightOpponent(seat, false));
}
}
// Highlight selected rank for human seat.
if (seat === 0 && this.selectedRank) {
for (let i = 0; i < n; i++) {
const card = player.hand[i];
if (card.rank !== this.selectedRank) continue;
const obj = this.cardObjs.get(`hand-${seat}-${card.id}`);
if (!obj) continue;
const ring = this.add.graphics();
ring.lineStyle(3, COLORS.accent, 1);
ring.strokeRoundedRect(-CARD_W / 2 - 3, -CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2);
ring.setPosition(obj.x, obj.y);
ring.setDepth(D.highlight);
this.transientObjs.push(ring);
obj.setDepth(D.card + 2);
}
}
}
renderSeatChips() {
for (let s = 0; s < this.gs.players.length; s++) {
const p = this.gs.players[s];
const chip = this.seatChips[s];
if (!chip) continue;
chip.count.setText(`${p.pairs}`);
}
}
renderTurnIndicator() {
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];
const lay = slotLayout(slot);
if (!this.turnGlow) {
this.turnGlow = this.add.circle(0, 0, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1);
}
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
}
highlightOpponent(seat, on) {
if (!this.isLocalTurn() || !this.selectedRank) return;
const slot = this.slotForSeat[seat];
const lay = slotLayout(slot);
if (on) {
const ring = this.add.graphics();
ring.lineStyle(4, COLORS.gold, 0.9);
ring.strokeCircle(lay.portrait.x, lay.portrait.y, lay.portrait.r + 6);
ring.setDepth(D.portrait - 2);
this._hoverRing = ring;
} else {
this._hoverRing?.destroy();
this._hoverRing = null;
}
}
// ── Status / banner ────────────────────────────────────────────────────────
updateStatus() {
if (this.gameOver) {
this.statusText.setText('');
return;
}
if (this.isLocalTurn()) {
if (this.selectedRank) {
this.statusText.setText(`Asking for ${this.selectedRank}s — tap an opponent to ask.`);
} else {
this.statusText.setText('Your turn — tap a card to choose what to ask for.');
}
} else {
this.statusText.setText(`${this.opponentName(this.gs.currentPlayer)} is thinking…`);
}
}
showBanner(text) {
this.bannerText.setText(text);
this.bannerText.setVisible(true);
this.bannerBg.clear();
this.bannerBg.fillStyle(COLORS.panel, 0.85);
const w = Math.max(this.bannerText.width + 60, 360);
const h = this.bannerText.height + 28;
this.bannerBg.fillRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10);
this.bannerBg.lineStyle(2, COLORS.accent, 1);
this.bannerBg.strokeRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10);
this.bannerBg.setVisible(true);
}
hideBanner() {
this.bannerText?.setVisible(false);
this.bannerBg?.setVisible(false);
}
// ── Turn flow ──────────────────────────────────────────────────────────────
isLocalTurn() {
return !this.gameOver && this.gs && this.gs.currentPlayer === 0;
}
opponentName(seat) {
if (seat === 0) return 'You';
const opp = this.opponents[seat - 1];
return opp?.name ?? `P${seat + 1}`;
}
onHandCardClick(rank) {
if (!this.isLocalTurn() || this.animating) return;
const legal = legalRanksToAsk(this.gs, 0);
if (!legal.includes(rank)) return;
this.selectedRank = (this.selectedRank === rank) ? null : rank;
this.renderAll();
this.updateStatus();
}
onOpponentClick(targetSeat) {
if (!this.isLocalTurn() || this.animating) return;
if (!this.selectedRank) {
this.showBanner('Pick a card from your hand first.');
this.time.delayedCall(1400, () => this.hideBanner());
return;
}
const target = this.gs.players[targetSeat];
if (!target || target.sittingOut || target.hand.length === 0) {
this.showBanner(`${this.opponentName(targetSeat)} has no cards to give.`);
this.time.delayedCall(1400, () => this.hideBanner());
return;
}
this.executeAsk(0, targetSeat, this.selectedRank);
}
executeAsk(askerSeat, targetSeat, rank) {
this.animating = true;
const before = this.gs;
const after = applyAsk(before, targetSeat, rank);
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();
return;
}
this.hideBanner();
this.maybeStartAITurn();
});
}
formatAskBanner(last) {
const asker = this.opponentName(last.askerSeat);
const target = this.opponentName(last.targetSeat);
const rank = last.rank === 'T' ? '10' : last.rank;
if (last.result === 'catch') {
const tail = last.newPairs > 0 ? ` +${last.newPairs} pair${last.newPairs > 1 ? 's' : ''}!` : '';
return `${asker} asked ${target} for ${rank}s — caught ${last.cardsTransferred.length}!${tail}`;
}
if (last.result === 'lucky') {
return `${asker} asked ${target} for ${rank}s — Go Fish… lucky draw!`;
}
return `${asker} asked ${target} for ${rank}s — Go Fish.`;
}
maybeStartAITurn() {
if (this.gameOver || this.animating) return;
if (this.isLocalTurn()) return;
this.time.delayedCall(700, () => this.runAITurn());
}
runAITurn() {
if (this.gameOver || this.animating) return;
const seat = this.gs.currentPlayer;
if (seat === 0) return;
const action = chooseAction(this.gs, seat, this.aiMemory[seat]);
if (!action) {
// Shouldn't happen — fail-safe: end the game if no action available.
this.endGame();
return;
}
this.executeAsk(seat, action.targetSeat, action.rank);
}
// ── Game over ──────────────────────────────────────────────────────────────
endGame() {
if (this.gameOver) return;
this.gameOver = true;
this.hideBanner();
const rows = this.gs.players
.map((p) => ({ seat: p.seat, pairs: p.pairs }))
.sort((a, b) => b.pairs - a.pairs);
const winners = this.gs.winnerSeats.map((s) => this.opponentName(s)).join(', ');
const lines = [`Game over — winner: ${winners}`];
for (const r of rows) {
lines.push(`${this.opponentName(r.seat)}: ${r.pairs} pair${r.pairs === 1 ? '' : 's'}`);
}
playSound(this, SFX.CASINO_WIN);
new Modal(this, lines.join('\n'), {}).setDepth(D.modal);
}
}

View File

@ -0,0 +1,289 @@
// Go Fish — pure state engine. No Phaser imports.
//
// Pairs variant rules:
// - Standard 52-card deck. 4 players, 7 cards each. Remainder is the pool.
// - On your turn, pick an opponent and ask for a rank you hold.
// - If they hold any of that rank, they hand over ALL cards of that rank;
// you then check your hand for pairs (any 2 cards of the same rank are
// immediately scored as a pair). You then ask again.
// - If they don't, "Go Fish": draw 1 from the pool. If the drawn card matches
// the asked rank ("lucky fish"), keep going. Otherwise the turn passes.
// - If your hand goes empty mid-game and the pool has cards, you draw up to
// 5 to refill. If the pool is empty you sit out for the rest of the game.
// - Game ends when the pool is empty AND every player has ≤ 1 card (no more
// pairs possible). Most pairs wins; ties allowed.
import { SUITS, RANKS, Card } from '../cards/Deck.js';
export const HAND_DEAL = 7;
export const REFILL_TARGET = 5;
// Mulberry32 — seedable PRNG (mirrors Phase10).
function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, seed) {
const rand = seed === undefined ? Math.random : rng(seed);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
function buildDeck() {
const cards = [];
let id = 0;
for (const suit of SUITS) {
for (const rank of RANKS) {
const c = new Card(rank, suit);
c.id = id++;
cards.push(c);
}
}
return cards;
}
function cloneCard(c) {
const out = new Card(c.rank, c.suit);
out.id = c.id;
return out;
}
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
hand: p.hand.map(cloneCard),
pairs: p.pairs,
pairedRanks: p.pairedRanks.slice(),
sittingOut: p.sittingOut,
})),
pool: state.pool.map(cloneCard),
currentPlayer: state.currentPlayer,
phase: state.phase,
lastAsk: state.lastAsk ? { ...state.lastAsk } : null,
log: state.log.map((e) => ({ ...e })),
winnerSeats: state.winnerSeats.slice(),
seed: state.seed,
turnCount: state.turnCount,
};
}
export function createInitialState({ playerCount = 4, seed } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Go Fish supports 2..4 players, got ${playerCount}`);
}
const deck = buildDeck();
shuffle(deck, seed);
const players = [];
for (let i = 0; i < playerCount; i++) {
players.push({
seat: i,
hand: deck.splice(0, HAND_DEAL),
pairs: 0,
pairedRanks: [],
sittingOut: false,
});
}
const state = {
players,
pool: deck,
currentPlayer: 0,
phase: 'play',
lastAsk: null,
log: [],
winnerSeats: [],
seed: seed ?? null,
turnCount: 0,
};
// Any starting pairs are scored immediately.
for (const p of state.players) collectPairs(p, state);
// Edge: if a player was dealt all duplicates and now has 0 cards, refill.
for (const p of state.players) ensureHasCards(state, p.seat);
checkGameOver(state);
return state;
}
export function legalRanksToAsk(state, seat) {
const set = new Set();
for (const c of state.players[seat].hand) set.add(c.rank);
return [...set];
}
export function canAsk(state, seat) {
if (state.phase !== 'play') return false;
if (state.currentPlayer !== seat) return false;
if (state.players[seat].sittingOut) return false;
return state.players[seat].hand.length > 0;
}
/**
* Ask `targetSeat` for `rank`. The asker (currentPlayer) must hold at least
* one card of that rank. Returns a new state plus a description of what
* happened so the UI can animate it.
*/
export function applyAsk(state, targetSeat, rank) {
if (state.phase !== 'play') return state;
const askerSeat = state.currentPlayer;
if (targetSeat === askerSeat) return state;
const next = cloneState(state);
const asker = next.players[askerSeat];
const target = next.players[targetSeat];
if (!asker || !target) return state;
if (target.sittingOut) return state;
if (!asker.hand.some((c) => c.rank === rank)) return state;
// Transfer all of `rank` from target to asker.
const matches = target.hand.filter((c) => c.rank === rank);
if (matches.length > 0) {
target.hand = target.hand.filter((c) => c.rank !== rank);
asker.hand.push(...matches);
const newPairs = collectPairs(asker, next);
next.lastAsk = {
askerSeat, targetSeat, rank,
result: 'catch',
cardsTransferred: matches.map(cloneCard),
drawnCard: null,
newPairs,
};
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).
ensureHasCards(next, targetSeat);
// Asker may have emptied own hand by pairing — refill them too.
ensureHasCards(next, askerSeat);
// If asker still has no cards or can't ask, advance turn.
if (!canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
return next;
}
// 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 {
next.lastAsk = {
askerSeat, targetSeat, rank,
result: 'fish',
cardsTransferred: [],
drawnCard: null,
newPairs: 0,
};
}
ensureHasCards(next, askerSeat);
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
return next;
}
/**
* 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.
*/
function collectPairs(player, state) {
let collected = 0;
while (true) {
const byRank = new Map();
let foundRank = null;
for (const c of player.hand) {
const list = byRank.get(c.rank) ?? [];
list.push(c);
byRank.set(c.rank, list);
if (list.length >= 2) { foundRank = c.rank; break; }
}
if (!foundRank) break;
const pair = byRank.get(foundRank);
const idsToRemove = new Set([pair[0].id, pair[1].id]);
player.hand = player.hand.filter((c) => !idsToRemove.has(c.id));
player.pairs += 1;
player.pairedRanks.push(foundRank);
collected += 1;
if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank });
}
return collected;
}
/**
* If the player's hand is empty, draw up to REFILL_TARGET from the pool.
* If still empty afterward, mark them as sitting out.
*/
function ensureHasCards(state, seat) {
const player = state.players[seat];
if (player.sittingOut) return;
if (player.hand.length > 0) return;
while (player.hand.length < REFILL_TARGET && state.pool.length > 0) {
player.hand.push(state.pool.pop());
}
if (player.hand.length > 0) {
state.log.push({ kind: 'refill', seat, count: player.hand.length });
collectPairs(player, state);
}
if (player.hand.length === 0) {
player.sittingOut = true;
state.log.push({ kind: 'sitout', seat });
}
}
function advanceTurn(state) {
state.turnCount += 1;
const N = state.players.length;
let next = (state.currentPlayer + 1) % N;
let safety = N + 1;
while (safety-- > 0) {
const p = state.players[next];
if (!p.sittingOut && p.hand.length > 0) {
state.currentPlayer = next;
return;
}
next = (next + 1) % N;
}
// No-one can play — game over will be detected separately.
}
function checkGameOver(state) {
if (state.phase !== 'play') return;
// A player can make progress if (a) they have ≥1 card AND the pool has ≥1
// card (they can fish) OR (b) the pool is empty but they share a rank with
// another player they could ask for. Otherwise no progress is possible.
const anyoneCanAct = state.players.some((p) => {
if (p.sittingOut) return false;
if (p.hand.length === 0) return false;
if (state.pool.length > 0) return true;
return state.players.some(
(q) => q !== p && !q.sittingOut && q.hand.some((c) => p.hand.some((d) => d.rank === c.rank)),
);
});
if (!anyoneCanAct) {
state.phase = 'gameOver';
const max = Math.max(...state.players.map((p) => p.pairs));
state.winnerSeats = state.players.filter((p) => p.pairs === max).map((p) => p.seat);
}
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}

View File

@ -19,6 +19,7 @@ import YatziGame from './games/yatzi/YatziGame.js';
import SkipBoGame from './games/skipbo/SkipBoGame.js';
import Phase10Game from './games/phase10/Phase10Game.js';
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
import GoFishGame from './games/gofish/GoFishGame.js';
const config = {
type: Phaser.AUTO,
@ -51,6 +52,7 @@ const config = {
SkipBoGame,
Phase10Game,
ChineseCheckersGame,
GoFishGame,
],
};

View File

@ -36,9 +36,11 @@ export default class GameMenuScene extends Phaser.Scene {
loadingText.destroy();
const tabletop = games.filter((g) => g.category === 'tabletop');
const cards = games.filter((g) => g.category === 'cards');
const casino = games.filter((g) => g.category === 'casino');
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
this.renderColumn('Cards', cards, cx, 260);
this.renderColumn('Casino', casino, cx + 420, 260);
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });

View File

@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -30,6 +30,7 @@ registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlay
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });