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:
parent
da0dc25cdd
commit
2bb4d14c97
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue