feat: add Old Maid card game

- Implement core game logic, AI opponent with skill scaling, and Phaser UI
- Register game in server registry and wire into app routing
- Add card assets and sprites
This commit is contained in:
Brian Fertig 2026-05-31 19:14:30 -06:00
parent da0dc25cdd
commit 2bb4d14c97
8 changed files with 1090 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

View File

@ -0,0 +1,128 @@
// Old Maid AI — "tells + memory" opponent.
//
// Old Maid draws are blind, so the only place skill can express itself is in
// avoiding the Old Maid card when you draw. The AI gains real knowledge two
// ways:
// 1. Hard memory — it sees the identity of every card that passes through its
// own hands (cards it draws, and the card someone draws from it). If it
// ever holds or hands off the Old Maid it knows exactly where it went.
// 2. Tells — a skill-scaled chance to "read the opponent" and spot the Old
// Maid in the target's hand on a given turn (the body-language fantasy).
//
// chooseDraw(state, seat, memory, skill) returns the cardId to draw.
// observeLog(memory, state, selfSeat) replays new log entries to update memory.
import { OLD_MAID_RANK } from './OldMaidLogic.js';
const SKILL_PROFILES = {
1: { tellStrength: 0.00, blunder: 1.00, noise: 0, delay: [800, 1300] },
2: { tellStrength: 0.20, blunder: 0.30, noise: 0, delay: [700, 1150] },
3: { tellStrength: 0.45, blunder: 0.15, noise: 0, delay: [600, 1000] },
4: { tellStrength: 0.70, blunder: 0.05, noise: 0, delay: [500, 900] },
5: { tellStrength: 0.92, blunder: 0.00, noise: 0, delay: [450, 800] },
};
export function profileFor(skill) {
return SKILL_PROFILES[skill] ?? SKILL_PROFILES[3];
}
export function createMemory(seatCount) {
return {
seatCount,
omCardId: null, // id of the Old Maid card, once we've ever identified it
omLocation: null, // seat we believe currently holds the Old Maid, or null
confidence: 0, // 0..1 belief strength in omLocation
logCursor: 0,
};
}
/**
* Replay every log entry from memory.logCursor onward and update memory using
* only knowledge `selfSeat` could legitimately have.
*/
export function observeLog(memory, state, selfSeat) {
for (let i = memory.logCursor; i < state.log.length; i++) {
const e = state.log[i];
if (e.kind === 'draw') {
const involved = e.drawerSeat === selfSeat || e.fromSeat === selfSeat;
if (involved) {
// We can see this card's true identity.
if (e.isOldMaid) {
memory.omCardId = e.cardId;
memory.omLocation = e.drawerSeat; // it landed in the drawer's hand
memory.confidence = 1;
} else if (e.drawerSeat === selfSeat && memory.omLocation === e.fromSeat) {
// We drew from a seat we suspected — and it wasn't the Old Maid, so
// the Old Maid definitely stayed behind. Belief firms up.
memory.confidence = Math.min(1, memory.confidence + 0.25);
}
} else if (memory.omLocation === e.fromSeat && memory.confidence > 0) {
// A card we can't see left a seat we believed holds the Old Maid. It
// may have been the Old Maid — we're now less sure it stayed put.
memory.confidence *= 0.5;
}
} else if (e.kind === 'safe') {
// A safe seat has an empty hand, so it cannot hold the (unpairable) Old
// Maid. If we thought it did, we were wrong — reset to unknown.
if (memory.omLocation === e.seat) {
memory.omLocation = null;
memory.confidence = 0;
}
}
}
memory.logCursor = state.log.length;
}
/**
* Pick the cardId to draw from the current player's target. Falls back to a
* uniform-random draw when the AI has no usable read.
*/
export function chooseDraw(state, seat, memory, skill = 3) {
const prof = profileFor(skill);
const targetSeat = nextActiveSeat(state, seat);
if (targetSeat === -1) return null;
const hand = state.players[targetSeat].hand;
if (hand.length === 0) return null;
if (hand.length === 1) return hand[0].id; // no choice
const ids = hand.map((c) => c.id);
const pickRandom = () => ids[Math.floor(Math.random() * ids.length)];
// Careless turn, or lowest skill — draw blind.
if (Math.random() < prof.blunder) return pickRandom();
// Decide which card (if any) we believe is the Old Maid in this hand.
let suspectId = null;
// Hard knowledge: we watched the Old Maid into this seat.
if (memory.omCardId != null && memory.omLocation === targetSeat && memory.confidence >= 0.5) {
if (hand.some((c) => c.id === memory.omCardId)) suspectId = memory.omCardId;
}
// Tell: a skill-scaled read of the opponent. If the target actually holds the
// Old Maid, we spot it with probability tellStrength.
if (suspectId == null && Math.random() < prof.tellStrength) {
const om = hand.find((c) => c.rank === OLD_MAID_RANK);
if (om) suspectId = om.id;
}
if (suspectId == null) return pickRandom();
// Avoid the suspected Old Maid.
const safeIds = ids.filter((id) => id !== suspectId);
if (safeIds.length === 0) return suspectId;
return safeIds[Math.floor(Math.random() * safeIds.length)];
}
// Local mirror of OldMaidLogic.drawTargetSeat so the AI stays self-contained.
function nextActiveSeat(state, seat) {
const N = state.players.length;
let next = (seat + 1) % N;
let safety = N;
while (safety-- > 0) {
const p = state.players[next];
if (next !== seat && p && !p.safe && p.hand.length > 0) return next;
next = (next + 1) % N;
}
return -1;
}

View File

@ -0,0 +1,695 @@
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 { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState,
applyDraw,
drawTargetSeat,
canDraw,
isGameOver,
} from './OldMaidLogic.js';
import { createMemory, observeLog, chooseDraw, profileFor } from './OldMaidAI.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, 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': {
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
const bpr = 56, bpx = CX - 90 - 12 - bpr, bpy = bchipY + 22 - bpr;
return {
handCenter: { x: CX, y: GAME_HEIGHT - 100 },
handAxis: 'x', handFaceUp: true,
portrait: { x: bpx, y: bpy, r: bpr },
nameLabel: { x: bpx, y: bpy - bpr - 14 },
chip: { x: CX, y: bchipY }, chipRotation: 0, rotateCards: 0,
};
}
case 'top': {
const tchipY = 110 + CARD_H / 2 + 30;
const tpr = 50, tpx = CX - 90 - 12 - tpr, tpy = tchipY - 22 + tpr;
return {
handCenter: { x: CX, y: 110 },
handAxis: 'x', handFaceUp: false,
portrait: { x: tpx, y: tpy, r: tpr },
nameLabel: { x: tpx, y: tpy + tpr + 14 },
chip: { x: CX, y: tchipY }, chipRotation: 0, rotateCards: 180,
};
}
case 'left': {
const lchipX = 110 + CARD_H / 2 + 10 + 22;
const lpr = 50, lpx = lchipX - 22 + lpr, lpy = CY - 90 - 12 - lpr;
return {
handCenter: { x: 110, y: CY },
handAxis: 'y', handFaceUp: false,
portrait: { x: lpx, y: lpy, r: lpr },
nameLabel: { x: lpx, y: lpy - lpr - 14 },
chip: { x: lchipX, y: CY }, chipRotation: Math.PI / 2, rotateCards: 90,
};
}
case 'right': {
const rchipX = GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22;
const rpr = 50, rpx = rchipX + 22 - rpr, rpy = CY - 90 - 12 - rpr;
return {
handCenter: { x: GAME_WIDTH - 110, y: CY },
handAxis: 'y', handFaceUp: false,
portrait: { x: rpx, y: rpy, r: rpr },
nameLabel: { x: rpx, y: rpy - rpr - 14 },
chip: { x: rchipX, y: CY }, chipRotation: -Math.PI / 2, rotateCards: 270,
};
}
default:
throw new Error(`Unknown slot: ${slot}`);
}
}
const DECK_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' },
};
const GOFISH_CARD_FRAME = { A: 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, T: 9, J: 10, Q: 11, K: 12 };
// ── Scene ───────────────────────────────────────────────────────────────────
export default class OldMaidGame extends Phaser.Scene {
constructor() { super('OldMaidGame'); }
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 = [];
}
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(`Old Maid needs 2..4 players, got ${playerCount}`);
this.slotForSeat = slots.slice();
}
buildSeatAreas() {
for (let seat = 0; seat < this.slotForSeat.length; seat++) {
const layout = slotLayout(this.slotForSeat[seat]);
if (seat === 0) {
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'OldMaidGame');
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);
}
}
this.seatChips[seat] = this.makeSeatChip(layout.chip.x, layout.chip.y);
this.seatChips[seat].container.setRotation(layout.chipRotation);
}
}
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() {
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
this.bannerText = this.add.text(CX, CY, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 900 },
}).setOrigin(0.5).setDepth(D.banner).setVisible(false);
}
buildHUD() {
const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30;
this.statusBg = this.add.graphics().setDepth(D.ui - 1);
this.statusText = this.add.text(CX - 90, bchipY - 22 - 14, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'left',
}).setOrigin(0, 1).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.clearAllCardObjs();
this.hideBanner();
const playerCount = this.slotForSeat.length;
const finalState = createInitialState({ playerCount });
this.aiMemory = [];
for (let s = 0; s < playerCount; s++) {
this.aiMemory[s] = createMemory(playerCount);
observeLog(this.aiMemory[s], finalState, s);
}
playSound(this, SFX.CARD_SHUFFLE);
this.animating = true;
const deck = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '' }, DECK_POS.x, DECK_POS.y, { faceUp: false });
this.transientObjs.push(deck);
for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); }
// Round-robin deal sequence over the dealt hands.
const hands = finalState.players.map((p) => [...p.hand]);
const maxCards = Math.max(...hands.map((h) => h.length));
const sequence = [];
for (let round = 0; round < maxCards; round++) {
for (let seat = 0; seat < playerCount; seat++) {
if (round < hands[seat].length) sequence.push({ seat, card: hands[seat][round], handIndex: round });
}
}
const STAGGER = 70, DURATION = 180;
sequence.forEach(({ seat, card, handIndex }, idx) => {
this.time.delayedCall(idx * STAGGER, () => {
const layout = slotLayout(this.slotForSeat[seat]);
const n = hands[seat].length;
const offset = handIndex - (n - 1) / 2;
const tx = layout.handAxis === 'x' ? layout.handCenter.x + offset * HAND_SPREAD : layout.handCenter.x;
const ty = layout.handAxis === 'x' ? layout.handCenter.y : layout.handCenter.y + offset * HAND_SPREAD;
const sprite = this.makeCardSprite(card, DECK_POS.x, DECK_POS.y, { faceUp: false, rotation: layout.rotateCards });
sprite.setDepth(D.card + 5);
this.transientObjs.push(sprite);
if (idx % 4 === 0) playSound(this, SFX.CARD_DEAL);
this.tweens.add({
targets: sprite, x: tx, y: ty, duration: DURATION, ease: 'Cubic.easeOut',
onComplete: () => {
if (!sprite.active) return;
if (layout.handFaceUp) this.renderCardFace(sprite, card, true);
sprite.setDepth(D.card);
},
});
});
});
const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250;
this.time.delayedCall(doneAt, () => {
this.gs = finalState;
this.renderAll();
const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2);
if (initialPairs.length > 0) {
this.playInitialDealPairs(initialPairs, 0, () => { this.animating = false; this.beginTurn(); });
} else {
this.animating = false;
this.beginTurn();
}
});
}
playInitialDealPairs(pairs, idx, onComplete) {
if (idx >= pairs.length) { onComplete(); return; }
const { seat, pairedCards } = pairs[idx];
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
const count = pairedCards.length / 2;
this.showBanner(`${this.opponentName(seat)} discards ${count > 1 ? count + ' starting pairs' : 'a starting pair'} of ${rank}s`);
this.animatePairDiscard(seat, pairedCards, () => {
this.hideBanner();
this.time.delayedCall(200, () => this.playInitialDealPairs(pairs, idx + 1, onComplete));
});
}
// ── 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;
}
// Special unmatched Old Maid card — distinct custom face.
if (card.isOldMaid) {
g.fillStyle(0x2c1a3a, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, 0xc678dd, 1);
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
container.add(this.add.text(0, -8, '👵', { fontFamily: 'sans-serif', fontSize: '52px' }).setOrigin(0.5));
container.add(this.add.text(0, y + CARD_H - 22, 'OLD MAID', {
fontFamily: 'Righteous', fontSize: '13px', color: '#e6c8ff',
}).setOrigin(0.5));
return;
}
const frame = GOFISH_CARD_FRAME[card.rank];
if (frame !== undefined && this.textures.exists('gofish-cards')) {
g.destroy();
container.add(this.add.image(0, 0, 'gofish-cards', frame).setDisplaySize(CARD_W, CARD_H).setOrigin(0.5));
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 ─────────────────────────────────────────────────────────────
handCardPos(seat, index, n, layout) {
const offset = index - (n - 1) / 2;
if (layout.handAxis === 'x') return { x: layout.handCenter.x + offset * HAND_SPREAD, y: layout.handCenter.y };
return { x: layout.handCenter.x, y: layout.handCenter.y + offset * HAND_SPREAD };
}
renderAll() {
this.clearAllCardObjs();
const drawTarget = (!this.gameOver && this.isLocalTurn()) ? drawTargetSeat(this.gs, 0) : -1;
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeat(seat, seat === drawTarget);
}
this.renderSeatChips();
this.renderTurnIndicator();
this.updateStatus();
}
renderSeat(seat, isDrawTarget) {
const player = this.gs.players[seat];
const layout = slotLayout(this.slotForSeat[seat]);
const n = player.hand.length;
for (let i = 0; i < n; i++) {
const card = player.hand[i];
const { x, y } = this.handCardPos(seat, i, n, layout);
const c = this.makeCardSprite(card, x, y, { faceUp: layout.handFaceUp, rotation: layout.rotateCards });
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
if (isDrawTarget) {
// The human's draw target — its face-down cards are clickable.
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
const baseY = y;
c.on('pointerover', () => {
if (this.animating) return;
this.tweens.add({ targets: c, scaleX: 1.08, scaleY: 1.08, duration: 100 });
c.setDepth(D.highlight);
});
c.on('pointerout', () => {
this.tweens.add({ targets: c, scaleX: 1, scaleY: 1, duration: 100, y: baseY });
c.setDepth(D.card);
});
c.on('pointerdown', () => this.onNeighborCardClick(seat, card.id));
}
}
}
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.safe ? 'SAFE ✓' : `${p.discardPairs}`);
chip.count.setColor(p.safe ? COLORS.goldHex : COLORS.accentHex);
}
}
renderTurnIndicator() {
const seat = this.gs.currentPlayer;
const lay = slotLayout(this.slotForSeat[seat]);
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).setVisible(!this.gameOver);
}
// ── Status / banner ─────────────────────────────────────────────────────────
updateStatus() {
if (this.gameOver) { this.statusText.setText(''); this.statusBg.setVisible(false); return; }
if (this.isLocalTurn()) {
const target = drawTargetSeat(this.gs, 0);
this.statusText.setText(target === -1 ? 'Your turn…' : `Your turn — draw a card from ${this.opponentName(target)}.`);
} else {
this.statusText.setText(`${this.opponentName(this.gs.currentPlayer)} is thinking…`);
}
this.refreshStatusBg();
}
refreshStatusBg() {
const t = this.statusText, pad = 8;
this.statusBg.clear();
this.statusBg.fillStyle(0x000000, 0.55);
this.statusBg.fillRoundedRect(t.x - pad, t.y - t.height - pad, t.width + pad * 2, t.height + pad * 2, 6);
this.statusBg.setVisible(true);
}
showBanner(text) {
this.bannerText.setText(text).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';
return this.opponents[seat - 1]?.name ?? `P${seat + 1}`;
}
skillForSeat(seat) {
return this.opponents[seat - 1]?.skill ?? 3;
}
beginTurn() {
if (this.gameOver) return;
if (isGameOver(this.gs)) { this.endGame(); return; }
if (this.isLocalTurn()) { this.updateStatus(); return; }
const seat = this.gs.currentPlayer;
const [lo, hi] = profileFor(this.skillForSeat(seat)).delay;
const delay = lo + Math.random() * (hi - lo);
this.time.delayedCall(delay, () => this.runAITurn());
}
runAITurn() {
if (this.gameOver || this.animating) return;
const seat = this.gs.currentPlayer;
if (seat === 0) return;
observeLog(this.aiMemory[seat], this.gs, seat);
if (!canDraw(this.gs, seat)) { this.endGame(); return; }
const cardId = chooseDraw(this.gs, seat, this.aiMemory[seat], this.skillForSeat(seat));
if (cardId == null) { this.endGame(); return; }
this.executeDraw(seat, cardId);
}
onNeighborCardClick(targetSeat, cardId) {
if (!this.isLocalTurn() || this.animating) return;
if (drawTargetSeat(this.gs, 0) !== targetSeat) return;
this.executeDraw(0, cardId);
}
executeDraw(drawerSeat, cardId) {
this.animating = true;
const before = this.gs;
const targetSeat = drawTargetSeat(before, drawerSeat);
const after = applyDraw(before, drawerSeat, cardId);
if (after === before) { this.animating = false; return; }
const last = after.lastDraw;
// Grab the on-screen sprite of the drawn card from the target's hand.
const startSprite = this.cardObjs.get(`hand-${targetSeat}-${cardId}`);
const drawerLayout = slotLayout(this.slotForSeat[drawerSeat]);
const startX = startSprite?.x ?? slotLayout(this.slotForSeat[targetSeat]).handCenter.x;
const startY = startSprite?.y ?? slotLayout(this.slotForSeat[targetSeat]).handCenter.y;
// Detach the start sprite so renderAll's clear doesn't kill mid-flight; use a fresh flyer.
const flyer = this.makeCardSprite(last.card, startX, startY, {
faceUp: targetSeat === 0, // only the human's hand was face-up
rotation: slotLayout(this.slotForSeat[targetSeat]).rotateCards,
});
flyer.setDepth(D.banner - 4);
this.transientObjs.push(flyer);
if (startSprite) { startSprite.destroy(); this.cardObjs.delete(`hand-${targetSeat}-${cardId}`); }
playSound(this, SFX.CARD_DEAL);
this.showBanner(`${this.opponentName(drawerSeat)} draws from ${this.opponentName(targetSeat)}`);
const destRot = (drawerLayout.rotateCards * Math.PI) / 180;
this.tweens.add({
targets: flyer,
x: drawerLayout.handCenter.x, y: drawerLayout.handCenter.y,
rotation: destRot, duration: 420, ease: 'Cubic.easeOut',
onComplete: () => {
// Reveal to the human when they drew; hide when an AI took a face-up card.
if (drawerSeat === 0) this.flipCardFaceUp(flyer, last.card);
else if (targetSeat === 0) this.flipCardFaceDown(flyer);
this.time.delayedCall(drawerSeat === 0 || targetSeat === 0 ? 320 : 120, () => {
this.commitDraw(after, drawerSeat, targetSeat, last, flyer);
});
},
});
}
commitDraw(after, drawerSeat, targetSeat, last, flyer) {
if (flyer?.active) flyer.destroy();
this.gs = after;
for (let s = 0; s < this.gs.players.length; s++) observeLog(this.aiMemory[s], this.gs, s);
// React to receiving the Old Maid.
if (last.card.isOldMaid && drawerSeat !== 0) this.opponentPortraits[drawerSeat]?.playEmotion('upset');
const finish = () => {
this.hideBanner();
this.renderAll();
if (isGameOver(this.gs)) { this.animating = false; this.endGame(); return; }
this.animating = false;
this.beginTurn();
};
if (last.paired) {
this.renderAll(); // paired cards already removed from hand state
if (drawerSeat !== 0) this.opponentPortraits[drawerSeat]?.playEmotion('happy');
this.animatePairDiscard(drawerSeat, last.pairedCards, finish);
} else {
finish();
}
}
flipCardFaceUp(container, card) {
this.tweens.add({
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
onComplete: () => {
container.setRotation(0);
this.renderCardFace(container, card, true);
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
},
});
}
flipCardFaceDown(container) {
this.tweens.add({
targets: container, scaleX: 0, duration: 150, ease: 'Linear',
onComplete: () => {
this.renderCardFace(container, container.card, false);
this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' });
},
});
}
animatePairDiscard(seat, pairedCards, onComplete) {
const layout = slotLayout(this.slotForSeat[seat]);
const n = pairedCards.length;
const GAP = 10;
const totalW = n * CARD_W + (n - 1) * GAP;
const sprites = [];
for (let i = 0; i < n; i++) {
let tx, ty;
switch (this.slotForSeat[seat]) {
case 'bottom': tx = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); ty = layout.handCenter.y - CARD_H - GAP; break;
case 'top': tx = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); ty = layout.handCenter.y + CARD_H + GAP; break;
case 'left': tx = layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2 + i * (CARD_W + GAP); ty = CY; break;
case 'right': tx = layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2 - (n - 1 - i) * (CARD_W + GAP); ty = CY; break;
default: tx = CX; ty = CY;
}
const sprite = this.makeCardSprite(pairedCards[i], CX, CY, { faceUp: true });
sprite.setDepth(D.banner - 5).setAlpha(0);
this.transientObjs.push(sprite);
this.tweens.add({ targets: sprite, x: tx, y: ty, alpha: 1, duration: 300, ease: 'Back.easeOut' });
sprites.push({ sprite, tx, ty });
}
playSound(this, SFX.CARD_PLACE);
this.time.delayedCall(350, () => {
const cx = sprites.reduce((s, o) => s + o.tx, 0) / sprites.length;
const cy = sprites.reduce((s, o) => s + o.ty, 0) / sprites.length;
this.spawnFireworks(cx, cy);
});
this.time.delayedCall(1200, () => {
for (const { sprite } of sprites) {
if (!sprite.active) continue;
this.tweens.add({ targets: sprite, x: CX, y: CY, alpha: 0, duration: 450, ease: 'Cubic.easeIn', onComplete: () => { if (sprite.active) sprite.destroy(); } });
}
});
this.time.delayedCall(1700, onComplete);
}
spawnFireworks(cx, cy) {
const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b];
const COUNT = 18;
for (let i = 0; i < COUNT; i++) {
const angle = (i / COUNT) * Math.PI * 2;
const dist = 60 + Math.random() * 70;
const g = this.add.graphics().setDepth(D.banner - 2);
g.fillStyle(BURST_COLORS[i % BURST_COLORS.length], 1);
g.fillCircle(0, 0, 3 + Math.random() * 4);
g.setPosition(cx, cy);
this.transientObjs.push(g);
this.tweens.add({
targets: g, x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist, alpha: 0,
duration: 600 + Math.random() * 300, ease: 'Quad.easeOut',
onComplete: () => { if (g.active) g.destroy(); },
});
}
}
// ── Game over ────────────────────────────────────────────────────────────────
endGame() {
if (this.gameOver) return;
this.gameOver = true;
this.animating = false;
this.hideBanner();
this.renderAll();
const loser = this.gs.loserSeat;
const humanLost = loser === 0;
if (loser > 0) this.opponentPortraits[loser]?.playEmotion('upset');
this.recordResult(humanLost ? 'loss' : 'win');
const lines = [];
if (humanLost) {
lines.push('You got stuck with the Old Maid!', 'Better luck next time.');
} else {
lines.push(`${this.opponentName(loser)} is stuck with the Old Maid!`, 'You got away safe. 🎉');
}
const cx = CX, cy = CY;
const overlay = this.add.rectangle(cx, cy, 760, 300, 0x0a0e14, 0.92).setStrokeStyle(3, COLORS.accent).setDepth(D.modal);
const txt = this.add.text(cx, cy - 40, lines.join('\n'), {
fontFamily: '"Julius Sans One"', fontSize: '30px',
color: humanLost ? COLORS.textHex : '#ffd700', align: 'center',
}).setOrigin(0.5).setDepth(D.modal + 1);
const again = new Button(this, cx - 100, cy + 80, 'Play Again', () => {
overlay.destroy(); txt.destroy(); again.destroy(); leave.destroy(); this.startNewMatch();
}, { width: 170, fontSize: 22 }).setDepth(D.modal + 1);
const leave = new Button(this, cx + 100, cy + 80, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 170, fontSize: 22 }).setDepth(D.modal + 1);
}
async recordResult(result) {
try {
const score = result === 'win' ? 100 : 0;
await api.post('/history/single-player', { slug: 'oldmaid', score, opponentScores: [], result });
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,263 @@
// Old Maid — pure state engine. No Phaser imports.
//
// Rules variant:
// - Standard 52-card deck PLUS one special unmatched "Old Maid" card (53 total).
// - Deal every card round-robin to 4 players. Each player immediately discards
// every pair (two cards of equal rank). The Old Maid card has its own sentinel
// rank ('OM') and can never pair.
// - On your turn you draw one card (blind) from the next active seat clockwise,
// add it to your hand, and discard a pair if the draw completes one. Turn then
// passes to that neighbor.
// - A player whose hand empties is "safe" and is removed from the rotation.
// - Play continues until exactly one card remains in play — the Old Maid — and
// its holder LOSES. Everyone else is safe.
import { SUITS, RANKS, Card } from '../cards/Deck.js';
export const OLD_MAID_RANK = 'OM';
// Mulberry32 — seedable PRNG (mirrors GoFishLogic).
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]];
}
}
/** The special unmatched card. Not a real playing card, so we mint a plain object. */
function makeOldMaidCard(id) {
return { rank: OLD_MAID_RANK, suit: null, value: 0, key: 'OM', isOldMaid: true, id };
}
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);
}
}
cards.push(makeOldMaidCard(id++));
return cards;
}
function cloneCard(c) {
if (c.isOldMaid) return makeOldMaidCard(c.id);
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),
discardPairs: p.discardPairs,
safe: p.safe,
finishOrder: p.finishOrder,
})),
currentPlayer: state.currentPlayer,
phase: state.phase,
lastDraw: state.lastDraw ? { ...state.lastDraw } : null,
loserSeat: state.loserSeat,
log: state.log.map((e) => ({ ...e })),
turnCount: state.turnCount,
finishCounter: state.finishCounter,
seed: state.seed,
};
}
export function createInitialState({ playerCount = 4, seed } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Old Maid 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: [], discardPairs: 0, safe: false, finishOrder: null });
}
// Round-robin deal of the whole deck.
for (let i = 0; i < deck.length; i++) {
players[i % playerCount].hand.push(deck[i]);
}
const state = {
players,
currentPlayer: 0,
phase: 'play',
lastDraw: null,
loserSeat: -1,
log: [],
turnCount: 0,
finishCounter: 0,
seed: seed ?? null,
initialDealPairs: [],
};
// Discard any pairs dealt into the opening hands.
for (const p of state.players) {
const { count, pairedCards } = discardPairs(p, state);
if (count > 0) state.initialDealPairs.push({ seat: p.seat, pairedCards });
markSafeIfEmpty(state, p.seat);
}
// First active seat takes the opening turn.
if (!isActive(state, state.currentPlayer)) advanceTurn(state);
checkGameOver(state);
return state;
}
/**
* Repeatedly remove any 2 cards of the same rank from the player's hand. The
* Old Maid card (rank 'OM') never pairs. Returns { count, pairedCards } where
* pairedCards holds clones of every removed card for animation purposes.
*/
export function discardPairs(player, state) {
let collected = 0;
const pairedCards = [];
while (true) {
const byRank = new Map();
let foundRank = null;
for (const c of player.hand) {
if (c.rank === OLD_MAID_RANK) continue;
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 group = byRank.get(foundRank).slice(0, 2);
pairedCards.push(...group.map(cloneCard));
const idsToRemove = new Set(group.map((c) => c.id));
player.hand = player.hand.filter((c) => !idsToRemove.has(c.id));
player.discardPairs += 1;
collected += 1;
if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank });
}
return { count: collected, pairedCards };
}
function isActive(state, seat) {
const p = state.players[seat];
return p && !p.safe && p.hand.length > 0;
}
function markSafeIfEmpty(state, seat) {
const p = state.players[seat];
if (!p.safe && p.hand.length === 0) {
p.safe = true;
p.finishOrder = state.finishCounter++;
state.log.push({ kind: 'safe', seat });
}
}
/** The seat the given player draws from: next active seat clockwise. */
export function drawTargetSeat(state, seat) {
const N = state.players.length;
let next = (seat + 1) % N;
let safety = N;
while (safety-- > 0) {
if (next !== seat && isActive(state, next)) return next;
next = (next + 1) % N;
}
return -1;
}
export function canDraw(state, seat) {
if (state.phase !== 'play') return false;
if (state.currentPlayer !== seat) return false;
if (!isActive(state, seat)) return false;
return drawTargetSeat(state, seat) !== -1;
}
/** Cards the current player may draw (the whole target hand — draws are blind). */
export function legalDraws(state, seat) {
if (!canDraw(state, seat)) return [];
const target = drawTargetSeat(state, seat);
return state.players[target].hand.map((c) => c.id);
}
/**
* Draw card `cardId` from the current player's draw target into their hand,
* discard a resulting pair, and advance the turn. Returns a new state plus a
* `lastDraw` description for the UI to animate.
*/
export function applyDraw(state, drawerSeat, cardId) {
if (state.phase !== 'play') return state;
if (state.currentPlayer !== drawerSeat) return state;
const targetSeat = drawTargetSeat(state, drawerSeat);
if (targetSeat === -1) return state;
const next = cloneState(state);
const drawer = next.players[drawerSeat];
const target = next.players[targetSeat];
const idx = target.hand.findIndex((c) => c.id === cardId);
if (idx === -1) return state; // not a card the target holds
const [card] = target.hand.splice(idx, 1);
drawer.hand.push(card);
next.log.push({ kind: 'draw', drawerSeat, fromSeat: targetSeat, cardId: card.id, isOldMaid: !!card.isOldMaid });
const { count: pairs, pairedCards } = discardPairs(drawer, next);
next.lastDraw = {
drawerSeat,
fromSeat: targetSeat,
card: cloneCard(card),
paired: pairs > 0,
pairedCards,
};
// Target may have emptied; drawer may have emptied by pairing.
markSafeIfEmpty(next, targetSeat);
markSafeIfEmpty(next, drawerSeat);
advanceTurn(next);
checkGameOver(next);
return next;
}
function advanceTurn(state) {
state.turnCount += 1;
const N = state.players.length;
let next = (state.currentPlayer + 1) % N;
let safety = N + 1;
while (safety-- > 0) {
if (isActive(state, next)) { state.currentPlayer = next; return; }
next = (next + 1) % N;
}
// No active player can act — game over detected separately.
}
function checkGameOver(state) {
if (state.phase !== 'play') return;
const holders = state.players.filter((p) => p.hand.length > 0);
// Game ends when a single player is left holding cards (the lone Old Maid),
// or when no draw is possible (defensive: fewer than 2 active seats).
const activeSeats = state.players.filter((_, s) => isActive(state, s));
if (holders.length <= 1 || activeSeats.length < 2) {
state.phase = 'gameOver';
const loser = holders.find((p) => p.hand.some((c) => c.isOldMaid));
state.loserSeat = loser ? loser.seat : (holders[0]?.seat ?? -1);
}
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}

View File

@ -45,6 +45,7 @@ import BattleshipGame from './games/battleship/BattleshipGame.js';
import MastermindGame from './games/mastermind/MastermindGame.js';
import Connect4Game from './games/connect4/Connect4Game.js';
import BoggleGame from './games/boggle/BoggleGame.js';
import OldMaidGame from './games/oldmaid/OldMaidGame.js';
const config = {
type: Phaser.AUTO,
@ -103,6 +104,7 @@ const config = {
MastermindGame,
Connect4Game,
BoggleGame,
OldMaidGame,
],
};

View File

@ -21,7 +21,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', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -60,3 +60,4 @@ registerGame({ slug: 'battleship', name: 'Battleship', category: 'ta
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 32 });
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 33 });
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });