Basic Video Poker
This commit is contained in:
parent
1c33302a13
commit
371833a0e6
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue