From d46de05b48e752c4e14d6844488aa54a80cba450 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 17 May 2026 20:56:40 -0600 Subject: [PATCH] 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 --- public/src/games/gofish/GoFishAI.js | 140 ++++++ public/src/games/gofish/GoFishGame.js | 582 +++++++++++++++++++++++++ public/src/games/gofish/GoFishLogic.js | 289 ++++++++++++ public/src/main.js | 2 + public/src/scenes/GameMenuScene.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- server/multiplayer/gameRegistry.js | 5 +- 7 files changed, 1019 insertions(+), 3 deletions(-) create mode 100644 public/src/games/gofish/GoFishAI.js create mode 100644 public/src/games/gofish/GoFishGame.js create mode 100644 public/src/games/gofish/GoFishLogic.js diff --git a/public/src/games/gofish/GoFishAI.js b/public/src/games/gofish/GoFishAI.js new file mode 100644 index 0000000..9f12a28 --- /dev/null +++ b/public/src/games/gofish/GoFishAI.js @@ -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; +} diff --git a/public/src/games/gofish/GoFishGame.js b/public/src/games/gofish/GoFishGame.js new file mode 100644 index 0000000..2f261dd --- /dev/null +++ b/public/src/games/gofish/GoFishGame.js @@ -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); + } +} diff --git a/public/src/games/gofish/GoFishLogic.js b/public/src/games/gofish/GoFishLogic.js new file mode 100644 index 0000000..c21117f --- /dev/null +++ b/public/src/games/gofish/GoFishLogic.js @@ -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'; +} diff --git a/public/src/main.js b/public/src/main.js index 66c5464..3372041 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameMenuScene.js b/public/src/scenes/GameMenuScene.js index 450d361..6a149e5 100644 --- a/public/src/scenes/GameMenuScene.js +++ b/public/src/scenes/GameMenuScene.js @@ -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' }); diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 3836fb1..08ac564 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/server/multiplayer/gameRegistry.js b/server/multiplayer/gameRegistry.js index 00c6e57..d02c604 100644 --- a/server/multiplayer/gameRegistry.js +++ b/server/multiplayer/gameRegistry.js @@ -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 });