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 (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'); } }