Basic Video Poker

This commit is contained in:
Brian Fertig 2026-06-06 17:17:36 -06:00
parent 1c33302a13
commit 371833a0e6
5 changed files with 610 additions and 1 deletions

View File

@ -0,0 +1,498 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
createGame, setBet, deal, toggleHold, drawReplacements,
PAY_TABLE, MIN_BET, MAX_BET, HAND_SIZE,
} from './VideoPokerLogic.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const D = { bg: 0, cabinet: 5, screen: 10, scan: 14, glow: 16, card: 20, ui: 40, overlay: 60 };
// CRT monitor frame (the plastic cabinet) and the inset screen inside it.
const MON_X = 110, MON_Y = 150, MON_W = 1180, MON_H = 720;
const SCR_PAD = 56; // bezel thickness
const SCR_X = MON_X + SCR_PAD, SCR_Y = MON_Y + SCR_PAD + 6;
const SCR_W = MON_W - SCR_PAD * 2, SCR_H = MON_H - SCR_PAD * 2 - 6;
// Cards drawn inside the screen.
const CARD_W = 168, CARD_H = 236, CARD_R = 12, CARD_GAP = 24;
// Retro CRT palette (independent of the global gold theme).
const CRT = {
cabinet: 0x2a2d33,
cabinetHi: 0x444851,
cabinetLo: 0x14161a,
screenBg: 0x041014,
screenTop: 0x06222a,
phosphor: 0x39ff9e, // green phosphor glow
phosphorHex: '#39ff9e',
amber: 0xffcf4a,
amberHex: '#ffcf4a',
held: 0xff4d6d,
heldHex: '#ff4d6d',
scan: 0x39ff9e,
};
const SUIT_RED = '#d9263c';
const SUIT_BLACK = '#10131a';
export default class VideoPokerGame extends Phaser.Scene {
constructor() { super('VideoPokerGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'videopoker', name: 'Video Poker' };
this.state = createGame(MIN_BET);
this.credits = 2000; // synced from server in create()
this.sessionNet = 0; // net chips delta pending persistence
this.handsPlayed = 0;
this.busy = false; // true during deal/draw animation
this.cardObjs = []; // per-slot container of graphics for each card
this.heldTags = []; // per-slot "HELD" label
this.payRows = []; // per-row { cells: text[], nameText }
}
async create() {
this.buildBackground();
this.buildCabinet();
this.buildScreen();
this.buildPayTable();
this.buildControls();
this.buildMeters();
await this.loadPlayerChips();
this.refreshMeters();
this.highlightBetColumn();
this.showIdleScreen();
}
// ── Bankroll ────────────────────────────────────────────────────────────────
async loadPlayerChips() {
try {
const { profile } = await api.get('/profile');
this.credits = profile.chips ?? 2000;
} catch {
this.credits = 2000;
}
}
// Persist the running net delta to the shared casino bankroll (best effort).
async flushChips() {
const delta = this.sessionNet;
if (delta === 0) return;
this.sessionNet = 0;
try { await api.post('/profile/chips/adjust', { delta }); }
catch { this.sessionNet += delta; /* retry on next flush */ }
}
async recordHistory(result) {
try {
await api.post('/history/single-player', {
slug: 'videopoker',
score: this.credits,
opponentScores: [],
result,
});
} catch { /* ignore */ }
}
// ── Background & cabinet ──────────────────────────────────────────────────────
buildBackground() {
const g = this.add.graphics().setDepth(D.bg);
const top = Phaser.Display.Color.ValueToColor(0x10131a);
const bot = Phaser.Display.Color.ValueToColor(0x05070b);
for (let i = 0; i < GAME_HEIGHT; i += 4) {
const c = Phaser.Display.Color.Interpolate.ColorWithColor(top, bot, 100, Math.floor((i / GAME_HEIGHT) * 100));
g.fillStyle(Phaser.Display.Color.GetColor(c.r, c.g, c.b), 1);
g.fillRect(0, i, GAME_WIDTH, 4);
}
// Faint floor ambience under the cabinet glow.
g.fillStyle(CRT.phosphor, 0.04);
g.fillEllipse(MON_X + MON_W / 2, MON_Y + MON_H + 60, MON_W + 120, 160);
this.add.text(GAME_WIDTH / 2, 64, 'VIDEO POKER', {
fontFamily: 'Righteous', fontSize: '46px', color: CRT.amberHex,
}).setOrigin(0.5).setDepth(D.ui)
.setShadow(0, 0, 'rgba(255,207,74,0.85)', 18);
this.add.text(GAME_WIDTH / 2, 108, 'JACKS OR BETTER', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
}
buildCabinet() {
const g = this.add.graphics().setDepth(D.cabinet);
// Outer plastic shell with a rounded bezel and bevel highlights.
g.fillStyle(CRT.cabinetLo, 1);
g.fillRoundedRect(MON_X - 14, MON_Y - 14, MON_W + 28, MON_H + 28, 38);
g.fillStyle(CRT.cabinet, 1);
g.fillRoundedRect(MON_X, MON_Y, MON_W, MON_H, 30);
// Top bevel highlight.
g.fillStyle(CRT.cabinetHi, 0.55);
g.fillRoundedRect(MON_X + 10, MON_Y + 10, MON_W - 20, 26, 12);
// Inner shadow lip framing the screen.
g.fillStyle(0x000000, 0.85);
g.fillRoundedRect(SCR_X - 12, SCR_Y - 12, SCR_W + 24, SCR_H + 24, 22);
// Two decorative cabinet screws.
for (const sx of [MON_X + 24, MON_X + MON_W - 24]) {
g.fillStyle(CRT.cabinetLo, 1); g.fillCircle(sx, MON_Y + MON_H - 22, 8);
g.fillStyle(CRT.cabinetHi, 0.6); g.fillCircle(sx - 2, MON_Y + MON_H - 24, 3);
}
}
buildScreen() {
// Inset screen with a vertical phosphor gradient.
const g = this.add.graphics().setDepth(D.screen);
const top = Phaser.Display.Color.ValueToColor(CRT.screenTop);
const bot = Phaser.Display.Color.ValueToColor(CRT.screenBg);
for (let i = 0; i < SCR_H; i += 3) {
const c = Phaser.Display.Color.Interpolate.ColorWithColor(top, bot, 100, Math.floor((i / SCR_H) * 100));
g.fillStyle(Phaser.Display.Color.GetColor(c.r, c.g, c.b), 1);
g.fillRect(SCR_X, SCR_Y + i, SCR_W, 3);
}
// Geometry mask so cards + scanlines never spill past the screen.
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillRoundedRect(SCR_X, SCR_Y, SCR_W, SCR_H, 16);
this.screenMask = maskG.createGeometryMask();
// Scrolling scanlines (generated 1×4 strip tiled across the screen).
const strip = this.make.graphics({ x: 0, y: 0, add: false });
strip.fillStyle(CRT.scan, 0.10);
strip.fillRect(0, 0, SCR_W, 2);
strip.generateTexture('vpScan', SCR_W, 4);
const scan = this.add.tileSprite(SCR_X, SCR_Y, SCR_W, SCR_H, 'vpScan')
.setOrigin(0, 0).setDepth(D.scan).setAlpha(0.5)
.setBlendMode(Phaser.BlendModes.ADD).setMask(this.screenMask);
this.tweens.add({ targets: scan, tilePositionY: SCR_H, duration: 8000, repeat: -1, ease: 'Linear' });
// Curved-corner vignette + screen edge glow.
const vg = this.add.graphics().setDepth(D.glow);
vg.lineStyle(3, CRT.phosphor, 0.18);
vg.strokeRoundedRect(SCR_X + 2, SCR_Y + 2, SCR_W - 4, SCR_H - 4, 14);
vg.fillStyle(0x000000, 0.0);
// Status line that shows messages on the screen (e.g. GAME OVER / payout).
this.statusText = this.add.text(SCR_X + SCR_W / 2, SCR_Y + 40, '', {
fontFamily: 'Righteous', fontSize: '30px', color: CRT.amberHex, align: 'center',
}).setOrigin(0.5).setDepth(D.card).setMask(this.screenMask)
.setShadow(0, 0, 'rgba(255,207,74,0.8)', 14);
// Card slot anchors centered in the lower portion of the screen.
const totalW = HAND_SIZE * CARD_W + (HAND_SIZE - 1) * CARD_GAP;
const startX = SCR_X + (SCR_W - totalW) / 2 + CARD_W / 2;
const cy = SCR_Y + SCR_H - CARD_H / 2 - 56;
this.slotX = [];
this.slotY = cy;
for (let i = 0; i < HAND_SIZE; i++) {
const cx = startX + i * (CARD_W + CARD_GAP);
this.slotX.push(cx);
const cont = this.add.container(cx, cy).setDepth(D.card).setMask(this.screenMask);
this.cardObjs.push(cont);
const tag = this.add.text(cx, cy - CARD_H / 2 - 26, 'HELD', {
fontFamily: 'Righteous', fontSize: '24px', color: CRT.heldHex,
}).setOrigin(0.5).setDepth(D.card).setVisible(false).setMask(this.screenMask)
.setShadow(0, 0, 'rgba(255,77,109,0.9)', 12);
this.heldTags.push(tag);
// Click-to-hold hit zone over each slot.
const zone = this.add.zone(cx, cy, CARD_W + CARD_GAP, CARD_H + 40)
.setInteractive({ useHandCursor: true }).setDepth(D.card);
zone.on('pointerup', () => this.onSlotClicked(i));
}
}
// Draw a single card face (or back) into its slot container.
renderCard(i, card, faceUp) {
const cont = this.cardObjs[i];
cont.removeAll(true);
const hw = CARD_W / 2, hh = CARD_H / 2;
const g = this.add.graphics();
if (!faceUp || !card) {
g.fillStyle(0x0b1f3a, 1);
g.fillRoundedRect(-hw, -hh, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, CRT.phosphor, 0.5);
g.strokeRoundedRect(-hw, -hh, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, 0x2a6cff, 0.6);
g.strokeRoundedRect(-hw + 14, -hh + 14, CARD_W - 28, CARD_H - 28, 8);
cont.add(g);
return;
}
const col = card.isRed ? SUIT_RED : SUIT_BLACK;
g.fillStyle(0xf7f4ec, 1);
g.fillRoundedRect(-hw, -hh, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, 0x000000, 0.25);
g.strokeRoundedRect(-hw, -hh, CARD_W, CARD_H, CARD_R);
cont.add(g);
const corner = (x, y, originX, originY) => {
const t = this.add.text(x, y, `${card.label}\n${card.suitSymbol}`, {
fontFamily: '"Julius Sans One"', fontSize: '34px', color: col, align: 'center', lineSpacing: -6,
}).setOrigin(originX, originY);
cont.add(t);
};
corner(-hw + 16, -hh + 12, 0, 0);
const br = this.add.text(hw - 16, hh - 12, `${card.label}\n${card.suitSymbol}`, {
fontFamily: '"Julius Sans One"', fontSize: '34px', color: col, align: 'center', lineSpacing: -6,
}).setOrigin(0, 0).setAngle(180);
cont.add(br);
const center = this.add.text(0, 0, card.suitSymbol, {
fontFamily: '"Julius Sans One"', fontSize: '92px', color: col,
}).setOrigin(0.5);
cont.add(center);
}
showIdleScreen() {
for (let i = 0; i < HAND_SIZE; i++) this.renderCard(i, null, false);
this.setStatus('PLACE YOUR BET • PRESS DEAL', CRT.amberHex);
}
setStatus(msg, colorHex = CRT.amberHex) {
this.statusText.setText(msg).setColor(colorHex);
}
// ── Pay table panel ──────────────────────────────────────────────────────────
buildPayTable() {
const px = 1320, py = MON_Y - 60, pw = GAME_WIDTH - px - 20, ph = MON_H;
const g = this.add.graphics().setDepth(D.ui);
g.fillStyle(0x07140f, 0.92);
g.fillRoundedRect(px, py, pw, ph, 18);
g.lineStyle(3, CRT.phosphor, 0.65);
g.strokeRoundedRect(px, py, pw, ph, 18);
this.add.text(px + pw / 2, py + 30, 'WINNINGS', {
fontFamily: 'Righteous', fontSize: '30px', color: CRT.phosphorHex,
}).setOrigin(0.5).setDepth(D.ui).setShadow(0, 0, 'rgba(57,255,158,0.8)', 12);
const nameX = px + 24;
const colW = 78;
const colsRight = px + pw - 20;
const col5X = colsRight;
const col1X = col5X - 4 * colW;
this.payColX = [col1X, col1X + colW, col1X + 2 * colW, col1X + 3 * colW, col1X + 4 * colW - 10];
// Coin column headers (15).
this.payHeaderY = py + 70;
for (let c = 0; c < 5; c++) {
this.add.text(this.payColX[c], this.payHeaderY, String(c + 1), {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
}
// Column highlight bar (moves with the current bet).
this.payColHi = this.add.graphics().setDepth(D.ui - 0.5);
const rowTop = py + 96;
const rowH = (ph - 96 - 24) / PAY_TABLE.length;
this.payRowGeo = { rowTop, rowH, px, pw };
PAY_TABLE.forEach((row, r) => {
const ry = rowTop + r * rowH + rowH / 2;
const nameText = this.add.text(nameX, ry, row.name, {
fontFamily: '"Julius Sans One"', fontSize: '21px', color: CRT.amberHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
const cells = [];
for (let c = 0; c < 5; c++) {
const t = this.add.text(this.payColX[c], ry, String(row.payouts[c]), {
fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
cells.push(t);
}
this.payRows.push({ nameText, cells, key: row.key });
});
// Per-row highlight (winning hand flash).
this.payRowHi = this.add.graphics().setDepth(D.ui - 0.5);
}
highlightBetColumn() {
const { rowTop, rowH } = this.payRowGeo;
const x = this.payColX[this.state.bet - 1];
this.payColHi.clear();
this.payColHi.fillStyle(CRT.amber, 0.14);
this.payColHi.fillRoundedRect(x - 34, this.payHeaderY - 16, 68, (rowH * PAY_TABLE.length) + 40, 8);
// Bold the active column header & cells.
this.payRows.forEach((row) => {
row.cells.forEach((cell, c) => {
cell.setColor(c === this.state.bet - 1 ? CRT.amberHex : COLORS.textHex);
});
});
}
highlightWinningRow(key) {
this.payRowHi.clear();
if (!key) return;
const idx = PAY_TABLE.findIndex((p) => p.key === key);
if (idx < 0) return;
const { rowTop, rowH, px, pw } = this.payRowGeo;
const ry = rowTop + idx * rowH;
this.payRowHi.fillStyle(CRT.phosphor, 0.22);
this.payRowHi.fillRoundedRect(px + 8, ry, pw - 16, rowH, 6);
const row = this.payRows[idx];
this.tweens.add({
targets: [row.nameText, ...row.cells],
alpha: { from: 0.35, to: 1 }, duration: 220, yoyo: true, repeat: 3,
});
}
// ── Controls ─────────────────────────────────────────────────────────────────
buildControls() {
const y = MON_Y + MON_H + 70;
const btnOpts = { width: 220, height: 70, fontSize: 24 };
this.betOneBtn = new Button(this, MON_X + 130, y, 'BET ONE', () => this.onBetOne(), btnOpts);
this.betMaxBtn = new Button(this, MON_X + 370, y, 'BET MAX', () => this.onBetMax(), btnOpts);
this.dealBtn = new Button(this, MON_X + 660, y, 'DEAL', () => this.onDealOrDraw(),
{ ...btnOpts, width: 260, bg: COLORS.gold, variant: 'solid' });
this.leaveBtn = new Button(this, MON_X + 940, y, 'Leave', () => this.leave(),
{ ...btnOpts, variant: 'ghost' });
[this.betOneBtn, this.betMaxBtn, this.dealBtn, this.leaveBtn].forEach((b) => b.setDepth(D.ui));
}
buildMeters() {
const y = MON_Y + MON_H + 18;
const mk = (x, label, color) => {
this.add.text(x, y - 14, label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
return this.add.text(x, y + 8, '0', {
fontFamily: 'Righteous', fontSize: '28px', color,
}).setOrigin(0, 0.5).setDepth(D.ui).setShadow(0, 0, 'rgba(57,255,158,0.7)', 10);
};
this.creditText = mk(1360, 'CREDITS', CRT.phosphorHex);
this.betText = mk(1560, 'BET', CRT.amberHex);
this.winText = mk(1700, 'WIN', CRT.amberHex);
}
refreshMeters() {
this.creditText.setText(this.credits.toLocaleString());
this.betText.setText(String(this.state.bet));
const win = this.state.lastWin?.coinsWon ?? 0;
this.winText.setText(String(win));
}
// ── Input handlers ───────────────────────────────────────────────────────────
onBetOne() {
if (this.busy || this.state.phase === 'draw') return;
const next = this.state.bet >= MAX_BET ? MIN_BET : this.state.bet + 1;
setBet(this.state, next);
playSound(this, SFX.CHIP_BET);
this.highlightBetColumn();
this.refreshMeters();
}
onBetMax() {
if (this.busy || this.state.phase === 'draw') return;
setBet(this.state, MAX_BET);
playSound(this, SFX.CHIP_BET);
this.highlightBetColumn();
this.refreshMeters();
// Bet Max also deals immediately (classic machine behaviour) when idle.
if (this.state.phase !== 'draw') this.onDealOrDraw();
}
onDealOrDraw() {
if (this.busy) return;
if (this.state.phase === 'draw') this.doDraw();
else this.doDeal();
}
onSlotClicked(i) {
if (this.busy || this.state.phase !== 'draw') return;
toggleHold(this.state, i);
playSound(this, SFX.CARD_SHOW);
this.heldTags[i].setVisible(this.state.held[i]);
// Subtle lift to show the hold.
this.tweens.add({ targets: this.cardObjs[i], y: this.state.held[i] ? this.slotY - 14 : this.slotY, duration: 120 });
}
// ── Deal / Draw flow ─────────────────────────────────────────────────────────
doDeal() {
if (this.credits < this.state.bet) {
this.setStatus('NOT ENOUGH CREDITS', CRT.heldHex);
return;
}
this.busy = true;
this.credits -= this.state.bet;
this.sessionNet -= this.state.bet;
this.payRowHi.clear();
this.heldTags.forEach((t) => t.setVisible(false));
this.cardObjs.forEach((c) => { c.y = this.slotY; });
deal(this.state);
this.refreshMeters();
this.setStatus('HOLD CARDS • PRESS DRAW', CRT.phosphorHex);
this.dealBtn.setLabel('DRAW');
playSound(this, SFX.CARD_SHUFFLE);
this.dealSequence(this.state.hand.map((_, i) => i), () => { this.busy = false; });
}
doDraw() {
this.busy = true;
const { replaced, win } = drawReplacements(this.state);
this.setStatus('', CRT.amberHex);
const finish = () => {
this.resolveWin(win);
this.busy = false;
};
if (replaced.length === 0) { finish(); return; }
playSound(this, SFX.CARD_DEAL);
this.dealSequence(replaced, finish);
}
// Animate face-up reveals for the given slot indices, one after another.
dealSequence(indices, done) {
let n = 0;
const next = () => {
if (n >= indices.length) { done && done(); return; }
const i = indices[n++];
const card = this.state.hand[i];
const cont = this.cardObjs[i];
cont.setScale(1, 0);
this.renderCard(i, card, true);
playSound(this, SFX.CARD_DEAL);
this.tweens.add({
targets: cont, scaleY: 1, duration: 110, ease: 'Quad.out',
onComplete: () => this.time.delayedCall(40, next),
});
};
next();
}
resolveWin(win) {
this.refreshMeters();
this.highlightWinningRow(win.key);
this.dealBtn.setLabel('DEAL');
this.state.phase = 'bet';
this.handsPlayed += 1;
if (win.coinsWon > 0) {
this.credits += win.coinsWon;
this.sessionNet += win.coinsWon;
this.refreshMeters();
this.setStatus(`${win.name.toUpperCase()} — WIN ${win.coinsWon}`, CRT.phosphorHex);
playSound(this, win.coinsWon >= 50 ? SFX.COINS : SFX.CASINO_WIN);
this.recordHistory('win');
} else {
this.setStatus('GAME OVER — NO WIN', CRT.heldHex);
playSound(this, SFX.CASINO_LOSE);
this.recordHistory('loss');
}
// Persist the net change to the shared bankroll after each hand.
this.flushChips();
}
async leave() {
await this.flushChips();
this.scene.start('GameMenu');
}
}

View File

@ -0,0 +1,108 @@
// Video Poker — Jacks or Better (9/6) — pure logic, no Phaser.
//
// Reuses the shared 52-card Deck and the Hold 'Em hand evaluator. evaluateHand
// already distinguishes Royal Flush from Straight Flush and reports pair value
// via its tiebreakers, so the Jacks-or-Better refinements layer on cleanly.
import { Deck } from '../cards/Deck.js';
import { evaluateHand } from '../holdem/HoldemLogic.js';
// ── Pay table ───────────────────────────────────────────────────────────────
// payouts indexed by (coins - 1), i.e. [1c, 2c, 3c, 4c, 5c]. Columns 14 are
// linear multiples of the per-coin rate; only the Royal Flush jumps at 5 coins
// (250 → 4000), the classic max-bet jackpot incentive.
export const PAY_TABLE = [
{ key: 'royal', name: 'Ryl Flush', payouts: [250, 500, 750, 1000, 4000] },
{ key: 'straightf', name: 'Strt Flush', payouts: [50, 100, 150, 200, 250] },
{ key: 'quads', name: '4 of a Kind', payouts: [25, 50, 75, 100, 125] },
{ key: 'fullhouse', name: 'Full House', payouts: [9, 18, 27, 36, 45] },
{ key: 'flush', name: 'Flush', payouts: [6, 12, 18, 24, 30] },
{ key: 'straight', name: 'Straight', payouts: [4, 8, 12, 16, 20] },
{ key: 'trips', name: '3 of a Kind', payouts: [3, 6, 9, 12, 15] },
{ key: 'twopair', name: 'Two Pair', payouts: [2, 4, 6, 8, 10] },
{ key: 'jacks', name: '2 Jacks +', payouts: [1, 2, 3, 4, 5] },
];
const PAY_BY_KEY = Object.fromEntries(PAY_TABLE.map((p) => [p.key, p]));
export const MIN_BET = 1;
export const MAX_BET = 5;
export const HAND_SIZE = 5;
// ── Game state ────────────────────────────────────────────────────────────────
// phase: 'bet' → awaiting a deal
// 'draw' → cards dealt, player choosing holds
// 'result' → draw resolved, payout known
export function createGame(bet = MIN_BET) {
return {
deck: null,
hand: [],
held: [false, false, false, false, false],
phase: 'bet',
bet,
lastWin: null, // { key, name, coinsWon } after a draw, else null
};
}
export function setBet(state, bet) {
if (state.phase === 'draw') return state.bet; // locked once dealt
state.bet = Math.max(MIN_BET, Math.min(MAX_BET, bet | 0));
return state.bet;
}
// Deal a fresh 5-card hand from a freshly shuffled deck.
export function deal(state) {
state.deck = new Deck();
state.deck.shuffle();
state.hand = state.deck.deal(HAND_SIZE);
state.held = [false, false, false, false, false];
state.lastWin = null;
state.phase = 'draw';
return state.hand;
}
export function toggleHold(state, i) {
if (state.phase !== 'draw') return;
state.held[i] = !state.held[i];
}
// Replace every non-held card from the remaining deck, then score the result.
// Returns { hand, replaced: number[], win }. `replaced` lists the positions
// that received a new card (useful for the deal animation).
export function drawReplacements(state) {
const replaced = [];
for (let i = 0; i < HAND_SIZE; i++) {
if (!state.held[i]) {
state.hand[i] = state.deck.deal(1)[0];
replaced.push(i);
}
}
state.lastWin = scoreHand(state.hand, state.bet);
state.phase = 'result';
return { hand: state.hand, replaced, win: state.lastWin };
}
// Classify a 5-card hand under Jacks or Better and compute the coin payout.
// Returns { key, name, coinsWon }. Non-paying hands → { key: null, coinsWon: 0 }.
export function scoreHand(cards, bet) {
const ev = evaluateHand(cards); // { rank 08, name, tiebreakers }
const key = payKeyFor(ev);
if (!key) return { key: null, name: ev.name, coinsWon: 0 };
const row = PAY_BY_KEY[key];
const coinsWon = row.payouts[Math.max(MIN_BET, Math.min(MAX_BET, bet)) - 1];
return { key, name: row.name, coinsWon };
}
// Map a Hold 'Em evaluation onto a Jacks-or-Better pay-table key (or null).
function payKeyFor(ev) {
switch (ev.rank) {
case 8: return ev.name === 'Royal Flush' ? 'royal' : 'straightf';
case 7: return 'quads';
case 6: return 'fullhouse';
case 5: return 'flush';
case 4: return 'straight';
case 3: return 'trips';
case 2: return 'twopair';
case 1: return ev.tiebreakers[0] >= 11 ? 'jacks' : null; // pair must be J/Q/K/A
default: return null; // high card pays nothing
}
}

View File

@ -54,6 +54,7 @@ import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
import SplendorGame from './games/splendor/SplendorGame.js'; import SplendorGame from './games/splendor/SplendorGame.js';
import TectonicGame from './games/tectonic/TectonicGame.js'; import TectonicGame from './games/tectonic/TectonicGame.js';
import LabyrinthGame from './games/labyrinth/LabyrinthGame.js'; import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -121,6 +122,7 @@ const config = {
SplendorGame, SplendorGame,
TectonicGame, TectonicGame,
LabyrinthGame, LabyrinthGame,
VideoPokerGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame' }; 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', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -69,3 +69,4 @@ registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 't
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 }); registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 }); registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 }); registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 });
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });