From 371833a0e6c44059854aabf949e5e97d30ff54af Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 6 Jun 2026 17:17:36 -0600 Subject: [PATCH] Basic Video Poker --- public/src/games/videopoker/VideoPokerGame.js | 498 ++++++++++++++++++ .../src/games/videopoker/VideoPokerLogic.js | 108 ++++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- server/games/registry.js | 1 + 5 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 public/src/games/videopoker/VideoPokerGame.js create mode 100644 public/src/games/videopoker/VideoPokerLogic.js diff --git a/public/src/games/videopoker/VideoPokerGame.js b/public/src/games/videopoker/VideoPokerGame.js new file mode 100644 index 0000000..2e14c26 --- /dev/null +++ b/public/src/games/videopoker/VideoPokerGame.js @@ -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 (1–5). + 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'); + } +} diff --git a/public/src/games/videopoker/VideoPokerLogic.js b/public/src/games/videopoker/VideoPokerLogic.js new file mode 100644 index 0000000..420f30a --- /dev/null +++ b/public/src/games/videopoker/VideoPokerLogic.js @@ -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 1–4 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 0–8, 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 + } +} diff --git a/public/src/main.js b/public/src/main.js index dd1f492..6b85579 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -54,6 +54,7 @@ import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js'; import SplendorGame from './games/splendor/SplendorGame.js'; import TectonicGame from './games/tectonic/TectonicGame.js'; import LabyrinthGame from './games/labyrinth/LabyrinthGame.js'; +import VideoPokerGame from './games/videopoker/VideoPokerGame.js'; const config = { type: Phaser.AUTO, @@ -121,6 +122,7 @@ const config = { SplendorGame, TectonicGame, LabyrinthGame, + VideoPokerGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index e9a5e9a..24884bb 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', 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]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index 220b7e4..4b2fea6 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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: '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: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });