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:
parent
356a2e98c5
commit
d46de05b48
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue