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:
parent
4644b8e3af
commit
3cea4f10b6
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue