feat: add Hearts card game with AI opponents

- Implement Hearts game logic (HeartsLogic.js) with classic rules: passing, trick-taking, scoring, and moon-shooting mechanics
- Add AI opponent logic (HeartsAI.js) with heuristic-based card selection for passing and playing
- Create HeartsGame.js Phaser scene with full UI: card rendering, animations, trick collection, score tracking, and game flow
- Register Hearts in game registry as a 4-player card game
- Integrate Hearts into game routing and menu dispatch
This commit is contained in:
Brian Fertig 2026-05-21 21:28:31 -06:00
parent 4644b8e3af
commit 3cea4f10b6
6 changed files with 1113 additions and 1 deletions

View File

@ -0,0 +1,115 @@
// Hearts AI — heuristic opponent. No Phaser imports.
//
// choosePass(state, seat) → array of 3 card ids to pass.
// choosePlay(state, seat) → a single card id to play (always legal).
//
// The AI has no hidden-information memory; it plays a sound, defensive game:
// shed dangerous cards in the pass, duck under tricks that carry points, and
// dump high/dangerous cards when void in the led suit.
import { legalPlays, cardPoints, isQueenOfSpades } from './HeartsLogic.js';
// How much we'd like to be rid of a card. Higher = more dangerous to keep.
function danger(card) {
if (isQueenOfSpades(card)) return 1000;
if (card.suit === 's' && card.value > 12) return 500 + card.value; // K♠ / A♠
if (card.suit === 'h') return 100 + card.value;
return card.value;
}
export function choosePass(state, seat) {
const hand = state.players[seat].hand;
// Suit counts so we can reward voiding a short side suit.
const counts = { s: 0, h: 0, d: 0, c: 0 };
for (const c of hand) counts[c.suit] += 1;
const scored = hand.map((c) => {
let score = danger(c);
// Encourage voiding short non-heart suits — being void buys flexibility.
if (c.suit !== 'h' && !isQueenOfSpades(c) && counts[c.suit] <= 2) score += 40;
return { c, score };
});
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, 3).map((s) => s.c.id);
}
export function choosePlay(state, seat) {
const legal = legalPlays(state, seat);
if (legal.length === 0) return null;
if (legal.length === 1) return legal[0].id;
const leading = state.trick.length === 0;
if (leading) return chooseLead(state, seat, legal).id;
const haveLead = state.players[seat].hand.some((c) => c.suit === state.leadSuit);
if (!haveLead) return chooseSlough(legal).id;
return chooseFollow(state, legal).id;
}
// Leading a trick: lead low and safe; avoid leading spades while we still hold
// the Queen (don't draw spades onto ourselves) and avoid hearts when possible.
function chooseLead(state, seat, legal) {
const holdsQueen = state.players[seat].hand.some(isQueenOfSpades);
let pool = legal;
if (holdsQueen) {
const nonSpade = legal.filter((c) => c.suit !== 's');
if (nonSpade.length > 0) pool = nonSpade;
}
const nonHeart = pool.filter((c) => c.suit !== 'h');
if (nonHeart.length > 0) pool = nonHeart;
return lowest(pool);
}
// Void in the led suit — discard the most dangerous card we can.
function chooseSlough(legal) {
let best = legal[0];
let bestDanger = -Infinity;
for (const c of legal) {
const d = danger(c);
if (d > bestDanger) { bestDanger = d; best = c; }
}
return best;
}
// Following in-suit: try to stay under the current winner; otherwise win as
// cheaply as possible.
function chooseFollow(state, legal) {
const winning = currentWinningCard(state);
const under = legal.filter((c) => c.value < winning.value);
if (under.length > 0) {
// We can avoid taking the lead — dump our highest/most dangerous safe card.
let best = under[0];
let bestDanger = -Infinity;
for (const c of under) {
const d = danger(c);
if (d > bestDanger) { bestDanger = d; best = c; }
}
return best;
}
// Every legal card beats the current winner — we'll likely take it. Spend
// the cheapest card (and never volunteer the Queen if we can avoid it).
const nonQueen = legal.filter((c) => !isQueenOfSpades(c));
return lowest(nonQueen.length > 0 ? nonQueen : legal);
}
function currentWinningCard(state) {
const lead = state.leadSuit;
let winner = state.trick[0].card;
for (const t of state.trick) {
if (t.card.suit === lead && t.card.value > winner.value) winner = t.card;
}
return winner;
}
function lowest(cards) {
let best = cards[0];
for (const c of cards) if (c.value < best.value) best = c;
return best;
}

View File

@ -0,0 +1,616 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.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,
selectPass,
playCard,
legalPlays,
startNextHand,
PASS_COUNT,
} from './HeartsLogic.js';
import { choosePass, choosePlay } from './HeartsAI.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 = 64;
const D = {
felt: -1, label: 0, score: 5, card: 10, trick: 15,
highlight: 20, ui: 30, portrait: 35, banner: 60, modal: 80,
};
// Seats are fixed: you sit at the bottom, opponents fill the other three.
const SLOTS = ['bottom', 'left', 'top', 'right'];
const DIRECTION_LABEL = { left: 'left', right: 'right', across: 'across', hold: '(holding)' };
function slotLayout(slot) {
switch (slot) {
case 'bottom':
return { handCenter: { x: CX, y: GAME_HEIGHT - 100 }, handAxis: 'x', faceUp: true, rotateCards: 0,
portrait: { x: CX - 560, y: GAME_HEIGHT - 150, r: 56 }, trick: { x: CX, y: CY + 95 } };
case 'top':
return { handCenter: { x: CX, y: 110 }, handAxis: 'x', faceUp: false, rotateCards: 180,
portrait: { x: CX - 560, y: 150, r: 50 }, trick: { x: CX, y: CY - 95 } };
case 'left':
return { handCenter: { x: 110, y: CY }, handAxis: 'y', faceUp: false, rotateCards: 90,
portrait: { x: 230, y: CY - 320, r: 50 }, trick: { x: CX - 135, y: CY } };
case 'right':
return { handCenter: { x: GAME_WIDTH - 110, y: CY }, handAxis: 'y', faceUp: false, rotateCards: 270,
portrait: { x: GAME_WIDTH - 230, y: CY - 320, r: 50 }, trick: { x: CX + 135, y: CY } };
default:
throw new Error(`Unknown slot: ${slot}`);
}
}
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 HeartsGame extends Phaser.Scene {
constructor() { super('HeartsGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.gs = null;
this.busy = false;
this.gameOver = false;
this.awaitingHuman = false;
this.handCardObjs = new Map(); // key `${seat}-${cardId}` → container
this.trickSprites = new Map(); // seat → container currently in the center
this.transient = []; // throwaway highlights / deal sprites
this.legalIds = new Set();
this.passSelection = new Set();
this.passButton = null;
this.scoreTexts = [];
this.turnGlow = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.buildSeats();
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);
}
}
buildSeats() {
for (let seat = 0; seat < SLOTS.length; seat++) {
const lay = slotLayout(SLOTS[seat]);
const name = this.seatName(seat);
if (seat === 0) {
createPlayerPortrait(this, lay.portrait.x, lay.portrait.y, lay.portrait.r, D.portrait, 'HeartsGame');
} else {
const opp = this.opponents[seat - 1];
if (opp) createOpponentPortrait(this, opp, lay.portrait.x, lay.portrait.y, lay.portrait.r, D.portrait);
}
this.add.text(lay.portrait.x, lay.portrait.y + lay.portrait.r + 16, name, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
// Score chip beneath the name.
const chip = this.add.text(lay.portrait.x, lay.portrait.y + lay.portrait.r + 40, 'Total 0 +0', {
fontFamily: 'Righteous', fontSize: '17px', color: COLORS.goldHex,
backgroundColor: 'rgba(0,0,0,0.6)', padding: { x: 8, y: 4 },
}).setOrigin(0.5).setDepth(D.score);
this.scoreTexts[seat] = chip;
}
}
buildCenter() {
this.heartsText = this.add.text(CX, CY - 170, '', {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.ui);
this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false);
this.bannerText = this.add.text(CX, CY + 200, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 900 },
}).setOrigin(0.5).setDepth(D.banner).setVisible(false);
}
buildHUD() {
this.statusBg = this.add.graphics().setDepth(D.ui - 1);
this.statusText = this.add.text(CX, GAME_HEIGHT - 232, '', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.ui);
new Button(this, 90, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 130, height: 40, fontSize: 18,
}).setDepth(D.ui);
new Button(this, 90, GAME_HEIGHT - 78, 'New', () => { if (!this.busy) this.startNewMatch(); }, {
variant: 'ghost', width: 130, height: 40, fontSize: 18,
}).setDepth(D.ui);
}
seatName(seat) {
if (seat === 0) return auth.user?.username ?? 'You';
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
}
// ── Match lifecycle ─────────────────────────────────────────────────────────
startNewMatch() {
this.gameOver = false;
this.awaitingHuman = false;
this.clearTrick();
this.clearHandObjs();
this.clearTransient();
this.hideBanner();
this.gs = createInitialState();
this.updateScores();
this.beginHand();
}
async beginHand() {
this.busy = true;
this.passSelection.clear();
this.clearTrick();
this.hideBanner();
this.updateHeartsIndicator();
playSound(this, SFX.CARD_SHUFFLE);
await this.dealAnimation();
this.renderHands();
this.updateScores();
this.busy = false;
if (this.gs.phase === 'passing') {
this.setupPassing();
} else {
await this.advance();
}
}
// ── Passing ──────────────────────────────────────────────────────────────────
setupPassing() {
// AI opponents lock in their passes immediately (hidden).
for (let seat = 1; seat < 4; seat++) {
this.gs = selectPass(this.gs, seat, choosePass(this.gs, seat));
}
const dir = DIRECTION_LABEL[this.gs.passDirection];
this.setStatus(`Choose ${PASS_COUNT} cards to pass ${dir}, then press Pass.`);
this.renderHands();
this.passButton = new Button(this, CX, GAME_HEIGHT - 285, `Pass ${PASS_COUNT}`, () => this.confirmPass(), {
width: 220, height: 50, fontSize: 22,
bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
}).setDepth(D.ui);
this.passButton.setEnabled(false);
}
togglePassSelection(cardId) {
if (this.passSelection.has(cardId)) {
this.passSelection.delete(cardId);
} else if (this.passSelection.size < PASS_COUNT) {
this.passSelection.add(cardId);
}
this.passButton?.setEnabled(this.passSelection.size === PASS_COUNT);
this.renderHands();
}
async confirmPass() {
if (this.passSelection.size !== PASS_COUNT) return;
const ids = [...this.passSelection];
this.passButton?.destroy();
this.passButton = null;
this.passSelection.clear();
this.busy = true;
const dir = DIRECTION_LABEL[this.gs.passDirection];
this.gs = selectPass(this.gs, 0, ids); // all four are ready → pass resolves
this.showBanner(`Cards passed ${dir}.`);
this.renderHands();
await this.delay(900);
this.hideBanner();
this.busy = false;
await this.advance();
}
// ── Turn flow ─────────────────────────────────────────────────────────────────
async advance() {
if (this.gameOver) return;
const gs = this.gs;
if (gs.phase === 'playing') {
this.updateTurnIndicator();
if (gs.currentPlayer === 0) {
this.awaitingHuman = true;
this.legalIds = new Set(legalPlays(gs, 0).map((c) => c.id));
this.renderHands();
this.setStatus(this.gs.trickNumber === 0 && this.gs.trick.length === 0
? 'Your lead — play the 2♣.'
: 'Your turn — play a card.');
} else {
this.awaitingHuman = false;
this.setStatus(`${this.seatName(gs.currentPlayer)} is thinking…`);
await this.delay(550);
await this.applyPlay(choosePlay(this.gs, this.gs.currentPlayer));
await this.advance();
}
} else if (gs.phase === 'handOver') {
await this.showHandOver();
} else if (gs.phase === 'gameOver') {
await this.endGame();
}
}
onCardClick(cardId) {
if (this.busy) return;
if (this.gs.phase === 'passing') {
this.togglePassSelection(cardId);
} else if (this.gs.phase === 'playing' && this.awaitingHuman && this.legalIds.has(cardId)) {
this.playHuman(cardId);
}
}
async playHuman(cardId) {
this.awaitingHuman = false;
this.legalIds = new Set();
await this.applyPlay(cardId);
await this.advance();
}
async applyPlay(cardId) {
if (cardId == null) return;
this.busy = true;
const seat = this.gs.currentPlayer;
const lay = slotLayout(SLOTS[seat]);
const card = this.gs.players[seat].hand.find((c) => c.id === cardId);
const cardData = card ? { rank: card.rank, suit: card.suit, id: card.id, label: card.label, suitSymbol: card.suitSymbol } : null;
this.gs = playCard(this.gs, cardId);
playSound(this, SFX.CARD_PLACE);
this.renderHands();
// Fly the played card from the seat's hand into its trick slot.
if (cardData) {
const sprite = this.makeCardSprite(cardData, lay.handCenter.x, lay.handCenter.y, { faceUp: true });
sprite.setDepth(D.trick);
this.trickSprites.set(seat, sprite);
await this.tweenTo(sprite, lay.trick.x, lay.trick.y, 220);
}
// Did that play complete the trick?
if (this.gs.lastTrick && this.gs.trick.length === 0) {
const lt = this.gs.lastTrick;
this.showBanner(`${this.seatName(lt.winnerSeat)} takes the trick${lt.points ? ` (+${lt.points})` : ''}.`);
await this.delay(750);
await this.collectTrick(lt.winnerSeat);
this.hideBanner();
this.updateScores();
this.updateHeartsIndicator();
}
this.busy = false;
}
async collectTrick(winnerSeat) {
const lay = slotLayout(SLOTS[winnerSeat]);
const tweens = [];
for (const sprite of this.trickSprites.values()) {
tweens.push(new Promise((resolve) => {
this.tweens.add({
targets: sprite, x: lay.portrait.x, y: lay.portrait.y, scaleX: 0.4, scaleY: 0.4,
alpha: 0.2, duration: 320, ease: 'Cubic.easeIn', onComplete: resolve,
});
}));
}
await Promise.all(tweens);
this.clearTrick();
}
// ── Hand / game end ───────────────────────────────────────────────────────────
async showHandOver() {
const gs = this.gs;
const lines = gs.players.map((p, seat) => {
const gained = gs.handScores[seat];
return `${this.seatName(seat).padEnd(0)}: +${gained}${p.totalScore}`;
});
let title = 'Hand complete';
if (gs.moonShooter != null) title = `${this.seatName(gs.moonShooter)} shot the moon!`;
await new Promise((resolve) => {
const panel = this.buildPanel(title, lines, 'Next hand', () => { panel.destroy(true); resolve(); });
});
this.gs = startNextHand(this.gs);
await this.beginHand();
}
async endGame() {
this.gameOver = true;
this.awaitingHuman = false;
this.updateTurnIndicator();
const gs = this.gs;
const order = [...gs.players].sort((a, b) => a.totalScore - b.totalScore);
const winners = new Set(gs.winnerSeats);
const lines = order.map((p) => {
const tag = winners.has(p.seat) ? ' ★' : '';
return `${this.seatName(p.seat)}: ${p.totalScore}${tag}`;
});
const youWon = winners.has(0);
const title = youWon ? (winners.size > 1 ? 'Tie game!' : 'You win!') : `${this.seatName(order[0].seat)} wins`;
playSound(this, youWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.recordHistory();
const panel = this.buildPanel(title, lines, 'New game', () => { panel.destroy(true); this.startNewMatch(); }, true);
}
async recordHistory() {
const totals = this.gs.players.map((p) => p.totalScore);
const winners = new Set(this.gs.winnerSeats);
let result;
if (winners.has(0) && winners.size === 1) result = 'win';
else if (winners.has(0)) result = 'draw';
else result = 'loss';
try {
await api.post('/history/single-player', {
slug: 'hearts',
score: totals[0],
opponentScores: totals.slice(1),
result,
});
} catch (_) { /* not signed in / offline — ignore */ }
}
buildPanel(title, lines, btnLabel, onClick, withLeave = false) {
const panel = this.add.container(0, 0).setDepth(D.modal);
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive();
const h = 200 + lines.length * 44;
const box = this.add.rectangle(CX, CY, 640, h, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent);
const titleText = this.add.text(CX, CY - h / 2 + 46, title, {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.goldHex,
}).setOrigin(0.5);
panel.add([overlay, box, titleText]);
lines.forEach((line, i) => {
panel.add(this.add.text(CX, CY - h / 2 + 110 + i * 44, line, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5));
});
const btnY = CY + h / 2 - 50;
if (withLeave) {
panel.add(new Button(this, CX - 150, btnY, btnLabel, onClick, { width: 260 }));
panel.add(new Button(this, CX + 150, btnY, 'Leave', () => this.scene.start('GameMenu'), { width: 260, variant: 'ghost' }));
} else {
panel.add(new Button(this, CX, btnY, btnLabel, onClick, { width: 300 }));
}
return panel;
}
// ── Rendering ──────────────────────────────────────────────────────────────────
renderHands() {
this.clearHandObjs();
this.clearTransient();
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeatHand(seat);
}
}
renderSeatHand(seat) {
const player = this.gs.players[seat];
const lay = slotLayout(SLOTS[seat]);
const n = player.hand.length;
const selecting = this.gs.phase === 'passing' && seat === 0;
const playable = this.gs.phase === 'playing' && seat === 0 && this.awaitingHuman;
for (let i = 0; i < n; i++) {
const card = player.hand[i];
const offset = i - (n - 1) / 2;
let x = lay.handCenter.x;
let y = lay.handCenter.y;
if (lay.handAxis === 'x') x += offset * HAND_SPREAD;
else y += offset * HAND_SPREAD;
const selected = selecting && this.passSelection.has(card.id);
if (selected) y -= 26;
const sprite = this.makeCardSprite(card, x, y, { faceUp: lay.faceUp, rotation: lay.rotateCards });
this.handCardObjs.set(`${seat}-${card.id}`, sprite);
if (seat === 0) {
const interactive = selecting || (playable && this.legalIds.has(card.id));
const dimmed = playable && !this.legalIds.has(card.id);
if (dimmed) sprite.setAlpha(0.45);
if (interactive) {
sprite.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
sprite.input.cursor = 'pointer';
sprite.on('pointerover', () => { if (!this.busy) { sprite.y -= 10; } });
sprite.on('pointerout', () => { if (!this.busy) { sprite.y += 10; } });
sprite.on('pointerdown', () => this.onCardClick(card.id));
}
if (selected) this.ringCard(x, y);
}
}
}
ringCard(x, y) {
const ring = this.add.graphics().setDepth(D.highlight);
ring.lineStyle(3, COLORS.accent, 1);
ring.strokeRoundedRect(x - CARD_W / 2 - 3, y - CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2);
this.transient.push(ring);
}
makeCardSprite(card, x, y, { faceUp = true, rotation = 0 } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
c.setRotation((rotation * Math.PI) / 180);
this.renderCardFace(c, card, faceUp);
c.cardId = card.id;
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);
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));
}
updateScores() {
for (let seat = 0; seat < this.gs.players.length; seat++) {
const p = this.gs.players[seat];
this.scoreTexts[seat]?.setText(`Total ${p.totalScore} +${p.roundPoints}`);
}
}
updateHeartsIndicator() {
this.heartsText?.setText(this.gs.heartsBroken ? '♥ Hearts broken' : '');
}
updateTurnIndicator() {
if (this.gameOver || this.gs.phase !== 'playing') { this.turnGlow?.setVisible(false); return; }
const lay = slotLayout(SLOTS[this.gs.currentPlayer]);
if (!this.turnGlow) this.turnGlow = this.add.circle(0, 0, 72, COLORS.accent, 0.2).setDepth(D.portrait - 1);
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y).setVisible(true);
}
// ── Deal animation ──────────────────────────────────────────────────────────────
async dealAnimation() {
const sprites = [];
const deck = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '', id: -1 }, CX, CY, { faceUp: false });
deck.setDepth(D.card);
const counts = this.gs.players.map((p) => p.hand.length);
const maxCards = Math.max(...counts);
let order = 0;
const tweens = [];
for (let i = 0; i < maxCards; i++) {
for (let seat = 0; seat < 4; seat++) {
if (i >= counts[seat]) continue;
const lay = slotLayout(SLOTS[seat]);
const sprite = this.makeCardSprite({ label: '', suit: 's', suitSymbol: '', id: -1 }, CX, CY, { faceUp: false });
sprite.setDepth(D.card);
sprites.push(sprite);
const delay = order * 12;
order += 1;
tweens.push(new Promise((resolve) => {
this.tweens.add({
targets: sprite, x: lay.handCenter.x, y: lay.handCenter.y, delay, duration: 170, ease: 'Cubic.easeOut',
onStart: () => { if (delay % 48 === 0) playSound(this, SFX.CARD_DEAL); },
onComplete: resolve,
});
}));
}
}
await Promise.all(tweens);
for (const s of sprites) s.destroy();
deck.destroy();
}
// ── Status / banner ───────────────────────────────────────────────────────────
setStatus(text) {
this.statusText.setText(text);
const t = this.statusText;
const pad = 10;
this.statusBg.clear();
this.statusBg.fillStyle(0x000000, 0.55);
this.statusBg.fillRoundedRect(t.x - t.width / 2 - pad, t.y - t.height / 2 - pad, t.width + pad * 2, t.height + pad * 2, 6);
this.statusBg.setVisible(true);
}
showBanner(text) {
this.bannerText.setText(text).setVisible(true);
const w = Math.max(this.bannerText.width + 60, 360);
const h = this.bannerText.height + 28;
this.bannerBg.clear();
this.bannerBg.fillStyle(COLORS.panel, 0.9);
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);
}
// ── Cleanup helpers ─────────────────────────────────────────────────────────────
clearHandObjs() {
for (const c of this.handCardObjs.values()) c.destroy();
this.handCardObjs.clear();
}
clearTransient() {
for (const o of this.transient) o.destroy();
this.transient = [];
}
clearTrick() {
for (const s of this.trickSprites.values()) s.destroy();
this.trickSprites.clear();
}
tweenTo(target, x, y, duration) {
return new Promise((resolve) => this.tweens.add({ targets: target, x, y, duration, ease: 'Cubic.easeOut', onComplete: resolve }));
}
delay(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
}
}

View File

@ -0,0 +1,378 @@
// Hearts — pure state engine. No Phaser imports.
//
// Classic rules:
// - Always 4 players, standard 52-card deck, 13 cards each.
// - Before each hand players pass 3 cards. Direction cycles by hand number:
// left → right → across → hold(no pass), repeating.
// - The holder of 2♣ leads the first trick (and must lead the 2♣).
// - Must follow the led suit if able; otherwise play any card.
// - Hearts may not be *led* until "broken" (a heart has been played to a
// trick) — unless the leader holds only hearts.
// - No points (hearts or Q♠) may be played on the first trick, unless a
// player holds nothing but point cards.
// - Highest card of the led suit wins the trick; winner leads the next.
// - Scoring: each heart = 1 pt, Q♠ = 13 pts (26 per hand).
// - Shooting the moon: if one player takes all 26 points, they score 0 and
// every other player gets +26.
// - Match runs until any player's total reaches 100; lowest total wins.
import { SUITS, RANKS, Card } from '../cards/Deck.js';
export const HAND_SIZE = 13;
export const PLAYER_COUNT = 4;
export const PASS_COUNT = 3;
export const GAME_OVER_SCORE = 100;
export const MAX_POINTS = 26;
// Pass direction by hand index (0-based), repeating every 4 hands.
const PASS_CYCLE = ['left', 'right', 'across', 'hold'];
// Mulberry32 — seedable PRNG (mirrors the other games).
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, rand) {
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;
}
/** Point value of a single card. */
export function cardPoints(card) {
if (card.suit === 'h') return 1;
if (card.suit === 's' && card.rank === 'Q') return 13;
return 0;
}
export function isQueenOfSpades(card) {
return card.suit === 's' && card.rank === 'Q';
}
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
hand: p.hand.map(cloneCard),
wonCards: p.wonCards.map(cloneCard),
roundPoints: p.roundPoints,
totalScore: p.totalScore,
})),
phase: state.phase,
handNumber: state.handNumber,
passDirection: state.passDirection,
pendingPass: state.pendingPass.map((arr) => arr.slice()),
passReady: state.passReady.slice(),
currentPlayer: state.currentPlayer,
leadSuit: state.leadSuit,
trick: state.trick.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })),
trickNumber: state.trickNumber,
heartsBroken: state.heartsBroken,
lastTrick: state.lastTrick
? { winnerSeat: state.lastTrick.winnerSeat, plays: state.lastTrick.plays.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })), points: state.lastTrick.points }
: null,
handScores: state.handScores ? state.handScores.slice() : null,
moonShooter: state.moonShooter,
winnerSeats: state.winnerSeats.slice(),
seed: state.seed,
log: state.log.map((e) => ({ ...e })),
};
}
/** Find the seat holding the 2 of clubs. */
function seatWith2Clubs(players) {
for (const p of players) {
if (p.hand.some((c) => c.suit === 'c' && c.rank === '2')) return p.seat;
}
return 0;
}
/** Deal a fresh hand into the existing state (mutates), resetting per-hand fields. */
function dealHand(state, rand) {
const deck = buildDeck();
shuffle(deck, rand);
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
state.players[seat].hand = deck.splice(0, HAND_SIZE);
state.players[seat].wonCards = [];
state.players[seat].roundPoints = 0;
}
state.passDirection = PASS_CYCLE[state.handNumber % PASS_CYCLE.length];
state.pendingPass = [[], [], [], []];
state.passReady = [false, false, false, false];
state.trick = [];
state.leadSuit = null;
state.trickNumber = 0;
state.heartsBroken = false;
state.lastTrick = null;
state.handScores = null;
state.moonShooter = null;
if (state.passDirection === 'hold') {
// No passing this hand — go straight to play.
state.phase = 'playing';
state.currentPlayer = seatWith2Clubs(state.players);
} else {
state.phase = 'passing';
state.currentPlayer = 0;
}
}
export function createInitialState({ seed } = {}) {
const rand = seed === undefined ? Math.random : rng(seed);
const players = [];
for (let i = 0; i < PLAYER_COUNT; i++) {
players.push({ seat: i, hand: [], wonCards: [], roundPoints: 0, totalScore: 0 });
}
const state = {
players,
phase: 'passing',
handNumber: 0,
passDirection: 'left',
pendingPass: [[], [], [], []],
passReady: [false, false, false, false],
currentPlayer: 0,
leadSuit: null,
trick: [],
trickNumber: 0,
heartsBroken: false,
lastTrick: null,
handScores: null,
moonShooter: null,
winnerSeats: [],
seed: seed ?? null,
log: [],
_rand: rand,
};
dealHand(state, rand);
return state;
}
// Seat that `seat` passes to, given the current direction.
export function passTargetSeat(seat, direction) {
switch (direction) {
case 'left': return (seat + 1) % PLAYER_COUNT;
case 'right': return (seat + PLAYER_COUNT - 1) % PLAYER_COUNT;
case 'across': return (seat + 2) % PLAYER_COUNT;
default: return seat;
}
}
/**
* Record a seat's chosen 3 cards for passing. Does not move cards yet.
* Returns a new state. When all four seats are ready, the pass resolves.
*/
export function selectPass(state, seat, cardIds) {
if (state.phase !== 'passing') return state;
if (state.passReady[seat]) return state;
if (!Array.isArray(cardIds) || cardIds.length !== PASS_COUNT) return state;
const hand = state.players[seat].hand;
const chosen = cardIds.map((id) => hand.find((c) => c.id === id)).filter(Boolean);
if (chosen.length !== PASS_COUNT) return state;
const next = cloneState(state);
next.pendingPass[seat] = cardIds.slice();
next.passReady[seat] = true;
if (next.passReady.every(Boolean)) resolvePass(next);
return next;
}
function resolvePass(state) {
const removed = []; // removed[seat] = Card[]
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
const ids = new Set(state.pendingPass[seat]);
const player = state.players[seat];
removed[seat] = player.hand.filter((c) => ids.has(c.id));
player.hand = player.hand.filter((c) => !ids.has(c.id));
}
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
const target = passTargetSeat(seat, state.passDirection);
state.players[target].hand.push(...removed[seat]);
}
state.phase = 'playing';
state.currentPlayer = seatWith2Clubs(state.players);
state.log.push({ kind: 'passResolved', direction: state.passDirection });
}
function handHasOnly(hand, predicate) {
return hand.length > 0 && hand.every(predicate);
}
/**
* Cards `seat` may legally play right now. Returns Card[] (references into the
* player's hand). Empty if it's not their turn / not the playing phase.
*/
export function legalPlays(state, seat) {
if (state.phase !== 'playing') return [];
if (state.currentPlayer !== seat) return [];
const hand = state.players[seat].hand;
if (hand.length === 0) return [];
const isFirstTrick = state.trickNumber === 0;
const leading = state.trick.length === 0;
// First card of the very first trick must be the 2 of clubs.
if (isFirstTrick && leading) {
const twoClubs = hand.find((c) => c.suit === 'c' && c.rank === '2');
return twoClubs ? [twoClubs] : hand.slice();
}
if (leading) {
// Leader can't open with hearts until broken, unless only hearts remain.
if (!state.heartsBroken) {
const nonHearts = hand.filter((c) => c.suit !== 'h');
if (nonHearts.length > 0) return nonHearts;
}
return hand.slice();
}
// Following: must follow the led suit if possible.
const sameSuit = hand.filter((c) => c.suit === state.leadSuit);
let candidates = sameSuit.length > 0 ? sameSuit : hand.slice();
// No points may be played on the first trick (unless forced).
if (isFirstTrick) {
const nonPoint = candidates.filter((c) => cardPoints(c) === 0);
if (nonPoint.length > 0) candidates = nonPoint;
}
return candidates;
}
export function isLegalPlay(state, seat, cardId) {
return legalPlays(state, seat).some((c) => c.id === cardId);
}
/**
* Play one card for the current player. Resolves the trick when the fourth
* card lands, and scores / re-deals / ends the match as needed.
* Returns a new state.
*/
export function playCard(state, cardId) {
if (state.phase !== 'playing') return state;
const seat = state.currentPlayer;
if (!isLegalPlay(state, seat, cardId)) return state;
const next = cloneState(state);
const player = next.players[seat];
const idx = player.hand.findIndex((c) => c.id === cardId);
const [card] = player.hand.splice(idx, 1);
if (next.trick.length === 0) next.leadSuit = card.suit;
next.trick.push({ seat, card });
if (card.suit === 'h') next.heartsBroken = true;
next.log.push({ kind: 'play', seat, card: { rank: card.rank, suit: card.suit }, leadSuit: next.leadSuit });
if (next.trick.length < PLAYER_COUNT) {
next.currentPlayer = (seat + 1) % PLAYER_COUNT;
return next;
}
// Trick complete — determine the winner (high card of the led suit).
resolveTrick(next);
return next;
}
function resolveTrick(state) {
const lead = state.leadSuit;
let winner = state.trick[0];
for (const t of state.trick) {
if (t.card.suit === lead && t.card.value > winner.card.value) winner = t;
}
const points = state.trick.reduce((sum, t) => sum + cardPoints(t.card), 0);
const winnerSeat = winner.seat;
const wonCards = state.trick.map((t) => t.card);
state.players[winnerSeat].wonCards.push(...wonCards);
state.players[winnerSeat].roundPoints += points;
state.lastTrick = {
winnerSeat,
plays: state.trick.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })),
points,
};
state.log.push({ kind: 'trickWon', winnerSeat, points });
state.trick = [];
state.leadSuit = null;
state.trickNumber += 1;
const handDone = state.players.every((p) => p.hand.length === 0);
if (handDone) {
scoreHand(state);
} else {
state.currentPlayer = winnerSeat;
}
}
function scoreHand(state) {
const roundPoints = state.players.map((p) => p.roundPoints);
const shooter = roundPoints.findIndex((pts) => pts === MAX_POINTS);
const applied = roundPoints.slice();
if (shooter !== -1) {
// Shoot the moon: shooter scores 0, everyone else +26.
state.moonShooter = shooter;
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
applied[seat] = seat === shooter ? 0 : MAX_POINTS;
}
} else {
state.moonShooter = null;
}
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
state.players[seat].totalScore += applied[seat];
}
state.handScores = applied;
state.log.push({ kind: 'handScored', applied, shooter });
const reached = state.players.some((p) => p.totalScore >= GAME_OVER_SCORE);
if (reached) {
state.phase = 'gameOver';
const min = Math.min(...state.players.map((p) => p.totalScore));
state.winnerSeats = state.players.filter((p) => p.totalScore === min).map((p) => p.seat);
} else {
state.phase = 'handOver';
}
}
/**
* Begin the next hand after a 'handOver' pause. Re-deals and sets up passing.
* Returns a new state.
*/
export function startNextHand(state) {
if (state.phase !== 'handOver') return state;
const next = cloneState(state);
next._rand = state._rand ?? Math.random;
next.handNumber += 1;
dealHand(next, next._rand);
return next;
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}

View File

@ -23,6 +23,7 @@ import UnoGame from './games/uno/UnoGame.js';
import CrapsGame from './games/craps/CrapsGame.js';
import RouletteGame from './games/roulette/RouletteGame.js';
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
import HeartsGame from './games/hearts/HeartsGame.js';
const config = {
type: Phaser.AUTO,
@ -59,6 +60,7 @@ const config = {
CrapsGame,
RouletteGame,
MexicanTrainGame,
HeartsGame,
],
};

View File

@ -15,7 +15,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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -36,3 +36,4 @@ registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, min
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });