fertig-classic-games/public/src/games/videopoker/VideoPokerGame.js

501 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 0x0a1456, // classic video-poker royal blue (lower)
screenTop: 0x16278f, // royal blue (upper)
screenEdge: 0x4a6cff, // bright blue screen-edge glow
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();
// CRT scanlines: dark horizontal lines tiled across the screen. An 8px strip
// with a 4px dark line + 4px gap gives thick, clearly visible scanlines.
// Drawn above the cards (D.card + 2) so the lines sweep over them too.
const strip = this.make.graphics({ x: 0, y: 0, add: false });
strip.fillStyle(0x000000, 0.45);
strip.fillRect(0, 0, SCR_W, 4);
strip.generateTexture('vpScan', SCR_W, 8);
const scan = this.add.tileSprite(SCR_X, SCR_Y, SCR_W, SCR_H, 'vpScan')
.setOrigin(0, 0).setDepth(D.card + 2).setAlpha(0.55).setMask(this.screenMask);
// Gentle vertical drift for a live-CRT shimmer.
this.tweens.add({ targets: scan, tilePositionY: SCR_H, duration: 9000, repeat: -1, ease: 'Linear' });
// Curved-corner vignette + blue screen edge glow.
const vg = this.add.graphics().setDepth(D.glow);
vg.lineStyle(3, CRT.screenEdge, 0.25);
vg.strokeRoundedRect(SCR_X + 2, SCR_Y + 2, SCR_W - 4, SCR_H - 4, 14);
// 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');
}
}