From 09d94a6ae7bdb8ec673e62c8da03016fb735748d Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 12 Jun 2026 19:42:27 -0600 Subject: [PATCH] Added Slot Machines --- public/data/slots-artwork.json | 435 +++++++++++++++ public/src/games/slots/SlotsFeatures.js | 384 +++++++++++++ public/src/games/slots/SlotsFx.js | 295 ++++++++++ public/src/games/slots/SlotsGame.js | 697 ++++++++++++++++++++++++ public/src/games/slots/SlotsLogic.js | 537 ++++++++++++++++++ public/src/games/slots/SlotsReels.js | 282 ++++++++++ public/src/games/slots/SlotsSymbols.js | 597 ++++++++++++++++++++ public/src/games/slots/machines.js | 433 +++++++++++++++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- public/src/scenes/PreloadScene.js | 3 + server/games/registry.js | 1 + server/scripts/verifySlots.js | 448 +++++++++++++++ 13 files changed, 4115 insertions(+), 1 deletion(-) create mode 100644 public/data/slots-artwork.json create mode 100644 public/src/games/slots/SlotsFeatures.js create mode 100644 public/src/games/slots/SlotsFx.js create mode 100644 public/src/games/slots/SlotsGame.js create mode 100644 public/src/games/slots/SlotsLogic.js create mode 100644 public/src/games/slots/SlotsReels.js create mode 100644 public/src/games/slots/SlotsSymbols.js create mode 100644 public/src/games/slots/machines.js create mode 100644 server/scripts/verifySlots.js diff --git a/public/data/slots-artwork.json b/public/data/slots-artwork.json new file mode 100644 index 0000000..8384028 --- /dev/null +++ b/public/data/slots-artwork.json @@ -0,0 +1,435 @@ +{ + "_readme": [ + "Drop-in art for Slot Machines. Set \"path\" to enable an override; null entries are skipped.", + "Symbol PNGs: 200x200, transparent background, file at public/assets/images/slots/{machineId}/{symbolId}.png", + "then set path to /assets/images/slots/{machineId}/{symbolId}.png.", + "Optional extras per machine: cabinet (800x260 marquee art) and card (400x440 lobby card art)." + ], + "artwork": [ + { + "id": "liberty-belle-spade", + "key": "slots-liberty-belle-spade", + "path": null + }, + { + "id": "liberty-belle-diamond", + "key": "slots-liberty-belle-diamond", + "path": null + }, + { + "id": "liberty-belle-star", + "key": "slots-liberty-belle-star", + "path": null + }, + { + "id": "liberty-belle-heart", + "key": "slots-liberty-belle-heart", + "path": null + }, + { + "id": "liberty-belle-horseshoe", + "key": "slots-liberty-belle-horseshoe", + "path": null + }, + { + "id": "liberty-belle-bell", + "key": "slots-liberty-belle-bell", + "path": null + }, + { + "id": "liberty-belle-cabinet", + "key": "slots-liberty-belle-cabinet", + "path": null + }, + { + "id": "liberty-belle-card", + "key": "slots-liberty-belle-card", + "path": null + }, + { + "id": "fruit-frenzy-cherry", + "key": "slots-fruit-frenzy-cherry", + "path": null + }, + { + "id": "fruit-frenzy-lemon", + "key": "slots-fruit-frenzy-lemon", + "path": null + }, + { + "id": "fruit-frenzy-plum", + "key": "slots-fruit-frenzy-plum", + "path": null + }, + { + "id": "fruit-frenzy-orange", + "key": "slots-fruit-frenzy-orange", + "path": null + }, + { + "id": "fruit-frenzy-melon", + "key": "slots-fruit-frenzy-melon", + "path": null + }, + { + "id": "fruit-frenzy-bell", + "key": "slots-fruit-frenzy-bell", + "path": null + }, + { + "id": "fruit-frenzy-bar", + "key": "slots-fruit-frenzy-bar", + "path": null + }, + { + "id": "fruit-frenzy-cabinet", + "key": "slots-fruit-frenzy-cabinet", + "path": null + }, + { + "id": "fruit-frenzy-card", + "key": "slots-fruit-frenzy-card", + "path": null + }, + { + "id": "lucky-sevens-blank", + "key": "slots-lucky-sevens-blank", + "path": null + }, + { + "id": "lucky-sevens-bar1", + "key": "slots-lucky-sevens-bar1", + "path": null + }, + { + "id": "lucky-sevens-seven-white", + "key": "slots-lucky-sevens-seven-white", + "path": null + }, + { + "id": "lucky-sevens-bar2", + "key": "slots-lucky-sevens-bar2", + "path": null + }, + { + "id": "lucky-sevens-seven-blue", + "key": "slots-lucky-sevens-seven-blue", + "path": null + }, + { + "id": "lucky-sevens-bar3", + "key": "slots-lucky-sevens-bar3", + "path": null + }, + { + "id": "lucky-sevens-seven-red", + "key": "slots-lucky-sevens-seven-red", + "path": null + }, + { + "id": "lucky-sevens-wild", + "key": "slots-lucky-sevens-wild", + "path": null + }, + { + "id": "lucky-sevens-cabinet", + "key": "slots-lucky-sevens-cabinet", + "path": null + }, + { + "id": "lucky-sevens-card", + "key": "slots-lucky-sevens-card", + "path": null + }, + { + "id": "pharaohs-fortune-ankh", + "key": "slots-pharaohs-fortune-ankh", + "path": null + }, + { + "id": "pharaohs-fortune-eye", + "key": "slots-pharaohs-fortune-eye", + "path": null + }, + { + "id": "pharaohs-fortune-cat", + "key": "slots-pharaohs-fortune-cat", + "path": null + }, + { + "id": "pharaohs-fortune-ace", + "key": "slots-pharaohs-fortune-ace", + "path": null + }, + { + "id": "pharaohs-fortune-king", + "key": "slots-pharaohs-fortune-king", + "path": null + }, + { + "id": "pharaohs-fortune-queen", + "key": "slots-pharaohs-fortune-queen", + "path": null + }, + { + "id": "pharaohs-fortune-jack", + "key": "slots-pharaohs-fortune-jack", + "path": null + }, + { + "id": "pharaohs-fortune-sphinx", + "key": "slots-pharaohs-fortune-sphinx", + "path": null + }, + { + "id": "pharaohs-fortune-pyramid", + "key": "slots-pharaohs-fortune-pyramid", + "path": null + }, + { + "id": "pharaohs-fortune-pharaoh", + "key": "slots-pharaohs-fortune-pharaoh", + "path": null + }, + { + "id": "pharaohs-fortune-cabinet", + "key": "slots-pharaohs-fortune-cabinet", + "path": null + }, + { + "id": "pharaohs-fortune-card", + "key": "slots-pharaohs-fortune-card", + "path": null + }, + { + "id": "abyssal-treasures-bubble", + "key": "slots-abyssal-treasures-bubble", + "path": null + }, + { + "id": "abyssal-treasures-octopus", + "key": "slots-abyssal-treasures-octopus", + "path": null + }, + { + "id": "abyssal-treasures-coral", + "key": "slots-abyssal-treasures-coral", + "path": null + }, + { + "id": "abyssal-treasures-seahorse", + "key": "slots-abyssal-treasures-seahorse", + "path": null + }, + { + "id": "abyssal-treasures-starfish", + "key": "slots-abyssal-treasures-starfish", + "path": null + }, + { + "id": "abyssal-treasures-shell", + "key": "slots-abyssal-treasures-shell", + "path": null + }, + { + "id": "abyssal-treasures-angler", + "key": "slots-abyssal-treasures-angler", + "path": null + }, + { + "id": "abyssal-treasures-mermaid", + "key": "slots-abyssal-treasures-mermaid", + "path": null + }, + { + "id": "abyssal-treasures-pearl", + "key": "slots-abyssal-treasures-pearl", + "path": null + }, + { + "id": "abyssal-treasures-cabinet", + "key": "slots-abyssal-treasures-cabinet", + "path": null + }, + { + "id": "abyssal-treasures-card", + "key": "slots-abyssal-treasures-card", + "path": null + }, + { + "id": "dragons-hoard-king", + "key": "slots-dragons-hoard-king", + "path": null + }, + { + "id": "dragons-hoard-gem", + "key": "slots-dragons-hoard-gem", + "path": null + }, + { + "id": "dragons-hoard-jack", + "key": "slots-dragons-hoard-jack", + "path": null + }, + { + "id": "dragons-hoard-knight", + "key": "slots-dragons-hoard-knight", + "path": null + }, + { + "id": "dragons-hoard-queen", + "key": "slots-dragons-hoard-queen", + "path": null + }, + { + "id": "dragons-hoard-castle", + "key": "slots-dragons-hoard-castle", + "path": null + }, + { + "id": "dragons-hoard-ace", + "key": "slots-dragons-hoard-ace", + "path": null + }, + { + "id": "dragons-hoard-chest", + "key": "slots-dragons-hoard-chest", + "path": null + }, + { + "id": "dragons-hoard-coin", + "key": "slots-dragons-hoard-coin", + "path": null + }, + { + "id": "dragons-hoard-dragon", + "key": "slots-dragons-hoard-dragon", + "path": null + }, + { + "id": "dragons-hoard-cabinet", + "key": "slots-dragons-hoard-cabinet", + "path": null + }, + { + "id": "dragons-hoard-card", + "key": "slots-dragons-hoard-card", + "path": null + }, + { + "id": "gold-rush-jack", + "key": "slots-gold-rush-jack", + "path": null + }, + { + "id": "gold-rush-wagon", + "key": "slots-gold-rush-wagon", + "path": null + }, + { + "id": "gold-rush-queen", + "key": "slots-gold-rush-queen", + "path": null + }, + { + "id": "gold-rush-pickaxe", + "key": "slots-gold-rush-pickaxe", + "path": null + }, + { + "id": "gold-rush-ace", + "key": "slots-gold-rush-ace", + "path": null + }, + { + "id": "gold-rush-king", + "key": "slots-gold-rush-king", + "path": null + }, + { + "id": "gold-rush-sheriff", + "key": "slots-gold-rush-sheriff", + "path": null + }, + { + "id": "gold-rush-saloon", + "key": "slots-gold-rush-saloon", + "path": null + }, + { + "id": "gold-rush-dynamite", + "key": "slots-gold-rush-dynamite", + "path": null + }, + { + "id": "gold-rush-bandit", + "key": "slots-gold-rush-bandit", + "path": null + }, + { + "id": "gold-rush-cabinet", + "key": "slots-gold-rush-cabinet", + "path": null + }, + { + "id": "gold-rush-card", + "key": "slots-gold-rush-card", + "path": null + }, + { + "id": "sugar-spin-jelly", + "key": "slots-sugar-spin-jelly", + "path": null + }, + { + "id": "sugar-spin-gummy", + "key": "slots-sugar-spin-gummy", + "path": null + }, + { + "id": "sugar-spin-candy-cane", + "key": "slots-sugar-spin-candy-cane", + "path": null + }, + { + "id": "sugar-spin-mint", + "key": "slots-sugar-spin-mint", + "path": null + }, + { + "id": "sugar-spin-berry", + "key": "slots-sugar-spin-berry", + "path": null + }, + { + "id": "sugar-spin-sugar", + "key": "slots-sugar-spin-sugar", + "path": null + }, + { + "id": "sugar-spin-lollipop", + "key": "slots-sugar-spin-lollipop", + "path": null + }, + { + "id": "sugar-spin-chocolate", + "key": "slots-sugar-spin-chocolate", + "path": null + }, + { + "id": "sugar-spin-rainbow", + "key": "slots-sugar-spin-rainbow", + "path": null + }, + { + "id": "sugar-spin-cabinet", + "key": "slots-sugar-spin-cabinet", + "path": null + }, + { + "id": "sugar-spin-card", + "key": "slots-sugar-spin-card", + "path": null + } + ] +} diff --git a/public/src/games/slots/SlotsFeatures.js b/public/src/games/slots/SlotsFeatures.js new file mode 100644 index 0000000..6c83afe --- /dev/null +++ b/public/src/games/slots/SlotsFeatures.js @@ -0,0 +1,384 @@ +// Interactive feature overlays for SlotsGame: the Liberty Belle pull lever, +// Fruit Frenzy HOLD/NUDGE controls, the free-spins banner, Dragon's Hoard +// hold-and-spin board, the Gold Rush pick-a-cart bonus, and the jackpot strip. + +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { holdSpinRespin, pick } from './SlotsLogic.js'; +import { textureKeyFor } from './SlotsSymbols.js'; + +const wait = (scene, ms) => new Promise((r) => scene.time.delayedCall(ms, r)); +const oneShot = (file, volume = 0.7) => { + try { const a = new Audio(`/assets/fx/${file}`); a.volume = volume; a.play(); } catch { /* ignore */ } +}; + +// ── Liberty Belle pull lever ────────────────────────────────────────────────── +export function buildLever(scene, x, y, height, theme, onPull) { + const g = scene.add.graphics().setDepth(20); + g.fillStyle(0x2a1d10, 1); + g.fillRoundedRect(x - 12, y, 24, height, 10); + g.fillStyle(theme.trim, 1); + g.fillRoundedRect(x - 5, y + 10, 10, height - 20, 5); + + const arm = scene.add.graphics().setDepth(21); + const knob = { t: 0 }; // 0 = up, 1 = pulled + const draw = () => { + arm.clear(); + const ky = y + 16 + (height - 60) * knob.t; + arm.lineStyle(14, 0x6b6f76, 1); + arm.beginPath(); arm.moveTo(x, y + height / 2); arm.lineTo(x, ky + 8); arm.strokePath(); + arm.fillStyle(0xd4313f, 1); + arm.fillCircle(x, ky, 26); + arm.fillStyle(0xf38b96, 0.8); + arm.fillCircle(x - 8, ky - 8, 8); + }; + draw(); + + const zone = scene.add.zone(x, y + height / 2, 90, height + 40) + .setInteractive({ useHandCursor: true }).setDepth(22); + let enabled = true; + zone.on('pointerdown', () => { + if (!enabled) return; + enabled = false; + playSound(scene, SFX.PIECE_CLICK); + scene.tweens.add({ + targets: knob, t: 1, duration: 240, ease: 'Quad.in', onUpdate: draw, + onComplete: () => { + onPull(); + scene.tweens.add({ + targets: knob, t: 0, duration: 480, ease: 'Bounce.easeOut', + delay: 180, onUpdate: draw, + }); + }, + }); + }); + + return { + setEnabled(v) { enabled = v; zone.setAlpha(v ? 1 : 0.99); }, + }; +} + +// ── Fruit Frenzy HOLD / NUDGE ───────────────────────────────────────────────── +export class HoldNudgeUI { + constructor(scene, view, depth = 30) { + this.scene = scene; + this.view = view; + this.depth = depth; + this.holdButtons = []; + this.nudgeButtons = []; + this.banner = null; + } + + showBanner(str, colorHex) { + this.hideBanner(); + this.banner = this.scene.add.text( + this.view.x + this.view.width() / 2, this.view.y - 34, str, { + fontFamily: 'Righteous', fontSize: '32px', color: colorHex, + stroke: '#14100a', strokeThickness: 6, + }).setOrigin(0.5).setDepth(this.depth); + this.scene.tweens.add({ + targets: this.banner, alpha: { from: 1, to: 0.55 }, + duration: 480, yoyo: true, repeat: -1, + }); + } + + hideBanner() { this.banner?.destroy(); this.banner = null; } + + // Holds: toggle buttons under each reel; the next SPIN consumes them. + offerHolds(onToggle) { + this.clear(); + this.showBanner('HOLD REELS?', '#ffb3c7'); + playSound(this.scene, 'sfx-card-show-8bit'); + for (let c = 0; c < this.view.nReels; c++) { + const { x } = this.view.cellCenter(c, 0); + const y = this.view.y + this.view.height() + 42; + const btn = new Button(this.scene, x, y, 'HOLD', () => { + const held = onToggle(c); + btn.setActive(held); + playSound(this.scene, SFX.CHIP_BET); + }, { width: 130, height: 52, fontSize: 22 }); + btn.setDepth(this.depth); + this.holdButtons.push(btn); + } + } + + // Nudges: per-reel ▼ buttons + COLLECT. Resolves with the last evaluation's + // win once nudges run out or the player banks a win early. + runNudges(count, onNudge) { + this.clear(); + return new Promise((resolve) => { + let lastWin = 0; + let left = count; + const finish = () => { this.clear(); resolve(lastWin); }; + this.showBanner(`NUDGES: ${left}`, '#ffb3c7'); + playSound(this.scene, SFX.SCIFI_RISER); + + const collect = new Button(this.scene, + this.view.x + this.view.width() / 2, this.view.y + this.view.height() + 104, + 'DONE', finish, { width: 170, height: 52, fontSize: 22, variant: 'ghost' }); + collect.setDepth(this.depth); + this.nudgeButtons.push(collect); + + for (let c = 0; c < this.view.nReels; c++) { + const { x } = this.view.cellCenter(c, 0); + const y = this.view.y + this.view.height() + 42; + const btn = new Button(this.scene, x, y, '▼', async () => { + if (left <= 0) return; + left--; + this.nudgeButtons.forEach((b) => b.setEnabled(false)); + const res = await onNudge(c); + lastWin = res.totalWin; + this.showBanner(left > 0 + ? `NUDGES: ${left}${lastWin > 0 ? ` WIN ${lastWin}` : ''}` + : 'LAST NUDGE!', '#ffb3c7'); + if (left <= 0) { finish(); return; } + this.nudgeButtons.forEach((b) => b.setEnabled(true)); + collect.setLabel(lastWin > 0 ? `TAKE ${lastWin}` : 'DONE'); + }, { width: 130, height: 52, fontSize: 26 }); + btn.setDepth(this.depth); + this.nudgeButtons.push(btn); + } + }); + } + + clear() { + this.hideBanner(); + for (const b of [...this.holdButtons, ...this.nudgeButtons]) b.destroy(); + this.holdButtons = []; + this.nudgeButtons = []; + } +} + +// ── Free spins banner ───────────────────────────────────────────────────────── +export class FreeSpinBanner { + constructor(scene, view, theme, depth = 30) { + this.scene = scene; + const x = view.x + view.width() / 2; + this.bg = scene.add.graphics().setDepth(depth); + this.bg.fillStyle(0x000000, 0.72); + this.bg.fillRoundedRect(x - 330, view.y - 64, 660, 52, 14); + this.bg.lineStyle(3, theme.glow, 0.9); + this.bg.strokeRoundedRect(x - 330, view.y - 64, 660, 52, 14); + this.text = scene.add.text(x, view.y - 38, '', { + fontFamily: 'Righteous', fontSize: '28px', color: theme.accentHex, + }).setOrigin(0.5).setDepth(depth + 1); + } + + update(remaining, total, winSoFar) { + this.text.setText(`FREE SPINS ${total - remaining} / ${total} • WIN ${winSoFar.toLocaleString()}`); + } + + destroy() { this.bg.destroy(); this.text.destroy(); } +} + +// ── Dragon's Hoard hold-and-spin ────────────────────────────────────────────── +// Runs the whole respin board; resolves with the engine award. The scene +// resolves the Grand amount (progressive) itself. +export async function runHoldSpin(scene, view, fx, state, bet, machine, depth = 32) { + const cfg = machine.features.holdSpin; + playSound(scene, SFX.SCIFI_RISER); + + const veil = scene.add.rectangle( + view.x + view.width() / 2, view.y + view.height() / 2, + view.width() + 24, view.height() + 24, 0x000000, 0.78, + ).setDepth(depth); + const title = scene.add.text(view.x + view.width() / 2, view.y - 40, 'HOLD & SPIN', { + fontFamily: 'Righteous', fontSize: '36px', color: machine.theme.accentHex, + stroke: '#14100a', strokeThickness: 7, + }).setOrigin(0.5).setDepth(depth + 3); + + // Respin lamps. + const lampG = scene.add.graphics().setDepth(depth + 3); + const drawLamps = (n) => { + lampG.clear(); + const cx = view.x + view.width() / 2; + const y = view.y + view.height() + 36; + for (let i = 0; i < cfg.respins; i++) { + lampG.fillStyle(i < n ? 0xf0c441 : 0x3a3325, 1); + lampG.fillCircle(cx - 36 + i * 36, y, 12); + } + }; + + const coinObjs = []; + const coinKey = textureKeyFor(scene, machine.id, cfg.coinSymbol); + const labelFor = (coin) => (coin.tier ? coin.tier.toUpperCase() : `${coin.value * bet}`); + const colorFor = (coin) => (coin.tier ? '#ff8c8c' : '#fff6d8'); + const addCoin = (coin, pop) => { + const p = view.cellCenter(coin.c, coin.r); + const img = scene.add.image(p.x, p.y, coinKey).setDepth(depth + 1); + img.setDisplaySize(view.cellW * 0.9, view.cellH * 0.9); + const t = scene.add.text(p.x, p.y - 2, labelFor(coin), { + fontFamily: 'Righteous', fontSize: coin.tier ? '24px' : '28px', + color: colorFor(coin), stroke: '#3a2400', strokeThickness: 6, + }).setOrigin(0.5).setDepth(depth + 2); + coinObjs.push(img, t); + if (pop) { + img.setScale(img.scaleX * 0.2, img.scaleY * 0.2); + scene.tweens.add({ targets: img, scaleX: img.scaleX * 5, scaleY: img.scaleY * 5, duration: 220, ease: 'Back.out' }); + fx.burst(p.x, p.y, 8, 0xf0c441); + playSound(scene, SFX.COINS); + } + }; + for (const coin of state.holdSpin.locked) addCoin(coin, false); + drawLamps(state.holdSpin.respinsLeft); + + // Respin loop. + let result = null; + let guard = 0; + while (++guard < 1000) { + await wait(scene, 950); + playSound(scene, SFX.DICE_ROLL); + // Shimmer the empty cells while "respinning". + const flick = scene.add.graphics().setDepth(depth + 1); + const lockedSet = new Set(state.holdSpin.locked.map((l) => `${l.c},${l.r}`)); + for (let c = 0; c < view.nReels; c++) { + for (let r = 0; r < view.rows; r++) { + if (lockedSet.has(`${c},${r}`)) continue; + const p = view.cellCenter(c, r); + flick.fillStyle(0xf0c441, 0.12); + flick.fillRoundedRect(p.x - view.cellW * 0.42, p.y - view.cellH * 0.42, view.cellW * 0.84, view.cellH * 0.84, 10); + } + } + scene.tweens.add({ targets: flick, alpha: { from: 1, to: 0 }, duration: 600, onComplete: () => flick.destroy() }); + await wait(scene, 650); + + result = holdSpinRespin(state, bet); + for (const coin of result.newCoins) addCoin(coin, true); + drawLamps(result.finished ? 0 : result.respinsLeft); + if (result.finished) break; + } + + await wait(scene, 700); + const cleanup = () => { veil.destroy(); title.destroy(); lampG.destroy(); for (const o of coinObjs) o.destroy(); }; + return { award: result.award, cleanup }; +} + +// ── Gold Rush pick-a-cart bonus ─────────────────────────────────────────────── +export function runPickBonus(scene, fx, machine, bonus, depth = 50) { + return new Promise((resolve) => { + playSound(scene, SFX.SCIFI_RISER); + const objs = []; + const veil = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0703, 0.88) + .setDepth(depth).setInteractive(); + objs.push(veil); + objs.push(scene.add.text(GAME_WIDTH / 2, 150, 'MINE CART BONUS', { + fontFamily: 'Righteous', fontSize: '64px', color: machine.theme.accentHex, + stroke: '#14100a', strokeThickness: 10, + }).setOrigin(0.5).setDepth(depth + 1)); + const sub = scene.add.text(GAME_WIDTH / 2, 222, `PICK ${bonus.picksLeft} CARTS`, { + fontFamily: '"Julius Sans One"', fontSize: '30px', color: '#f2ead8', + }).setOrigin(0.5).setDepth(depth + 1); + objs.push(sub); + + const totalText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 140, '', { + fontFamily: 'Righteous', fontSize: '44px', color: '#ffe27a', + }).setOrigin(0.5).setDepth(depth + 1); + objs.push(totalText); + + const cartKey = textureKeyFor(scene, machine.id, 'wagon'); + const cards = []; + const cols = 3; + const cw = 250; const ch = 200; const gap = 36; + const x0 = GAME_WIDTH / 2 - (cols * cw + (cols - 1) * gap) / 2 + cw / 2; + const y0 = 360; + + const finish = () => { + const btn = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 70, 'COLLECT', { + fontFamily: 'Righteous', fontSize: '40px', color: '#1a1208', + backgroundColor: '#d4a017', padding: { x: 36, y: 12 }, + }).setOrigin(0.5).setDepth(depth + 2).setInteractive({ useHandCursor: true }); + objs.push(btn); + btn.on('pointerup', () => { + for (const o of objs) o.destroy(); + for (const c of cards) c.destroy(); + resolve(bonus.total); + }); + // Ghost-reveal the unpicked carts. + cards.forEach((card, idx) => { + if (bonus.picked.includes(idx) || card.revealed) return; + card.revealed = true; + card.label.setText(`${bonus.prizes[idx].toLocaleString()}`).setAlpha(0.45); + card.img.setAlpha(0.3); + }); + }; + + for (let i = 0; i < bonus.prizes.length; i++) { + const x = x0 + (i % cols) * (cw + gap); + const y = y0 + Math.floor(i / cols) * (ch + gap); + const card = scene.add.container(x, y).setDepth(depth + 1); + const bg = scene.add.graphics(); + bg.fillStyle(0x4a3217, 1); + bg.fillRoundedRect(-cw / 2, -ch / 2, cw, ch, 16); + bg.lineStyle(4, 0xf0c441, 0.9); + bg.strokeRoundedRect(-cw / 2, -ch / 2, cw, ch, 16); + const img = scene.add.image(0, -14, cartKey).setDisplaySize(120, 120); + const label = scene.add.text(0, 62, '?', { + fontFamily: 'Righteous', fontSize: '36px', color: '#ffe27a', + }).setOrigin(0.5); + card.add([bg, img, label]); + card.img = img; card.label = label; card.revealed = false; + card.setSize(cw, ch).setInteractive({ useHandCursor: true }); + card.on('pointerover', () => scene.tweens.add({ targets: card, angle: { from: -1.5, to: 1.5 }, duration: 90, yoyo: true, repeat: 1, onComplete: () => card.setAngle(0) })); + card.on('pointerup', () => { + if (card.revealed || bonus.picksLeft <= 0) return; + const res = pick(bonus, i); + if (!res) return; + card.revealed = true; + label.setText(res.prize.toLocaleString()); + scene.tweens.add({ targets: card, scale: 1.12, duration: 140, yoyo: true }); + fx.coinShower(x, y, 14); + playSound(scene, SFX.COINS); + totalText.setText(`TOTAL: ${res.totalSoFar.toLocaleString()}`); + sub.setText(res.done ? '' : `PICK ${bonus.picksLeft} MORE`); + if (res.done) { oneShot('firework.mp3', 0.7); finish(); } + }); + cards.push(card); + } + }); +} + +// ── Jackpot strip (Dragon's Hoard) ──────────────────────────────────────────── +export class JackpotStrip { + constructor(scene, machine, x, y, depth = 16) { + this.scene = scene; + this.machine = machine; + this.x = x; this.y = y; + this.g = scene.add.graphics().setDepth(depth); + this.labels = {}; + this.values = {}; + const tiers = [ + ['mini', '#8cf28a'], ['minor', '#59e7ff'], ['major', '#c89bff'], ['grand', '#ffd86b'], + ]; + const w = 200; const gap = 14; + const x0 = x - (tiers.length * w + (tiers.length - 1) * gap) / 2 + w / 2; + tiers.forEach(([tier, color], i) => { + const tx = x0 + i * (w + gap); + this.g.fillStyle(0x000000, 0.7); + this.g.fillRoundedRect(tx - w / 2, y - 30, w, 60, 12); + this.g.lineStyle(2, machine.theme.trim, 0.8); + this.g.strokeRoundedRect(tx - w / 2, y - 30, w, 60, 12); + this.labels[tier] = scene.add.text(tx, y - 15, tier.toUpperCase(), { + fontFamily: '"Julius Sans One"', fontSize: '16px', color, + }).setOrigin(0.5).setDepth(depth + 1); + this.values[tier] = scene.add.text(tx, y + 9, '', { + fontFamily: 'Righteous', fontSize: '22px', color: '#fff6d8', + }).setOrigin(0.5).setDepth(depth + 1); + }); + } + + refresh(bet, grandAmount) { + const jp = this.machine.features.holdSpin.jackpots; + this.values.mini.setText((jp.mini * bet).toLocaleString()); + this.values.minor.setText((jp.minor * bet).toLocaleString()); + this.values.major.setText((jp.major * bet).toLocaleString()); + this.values.grand.setText(Math.floor(grandAmount).toLocaleString()); + } + + flash(tier) { + const targets = [this.labels[tier], this.values[tier]]; + this.scene.tweens.add({ + targets, alpha: { from: 0.2, to: 1 }, duration: 180, yoyo: true, repeat: 5, + }); + } +} diff --git a/public/src/games/slots/SlotsFx.js b/public/src/games/slots/SlotsFx.js new file mode 100644 index 0000000..c77a312 --- /dev/null +++ b/public/src/games/slots/SlotsFx.js @@ -0,0 +1,295 @@ +// Win presentation & juice for SlotsGame: payline tracing, symbol pulses, +// tumble pops/falls, coin showers, rolling counters, BIG WIN tiers. +// Everything animates engine-decided results; nothing here invents a payout. + +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { textureKeyFor } from './SlotsSymbols.js'; + +const LINE_COLORS = [0xffd86b, 0x59e7ff, 0xff7da0, 0x8cf28a, 0xc89bff, 0xffa45e]; + +const oneShot = (file, volume = 0.7) => { + try { const a = new Audio(`/assets/fx/${file}`); a.volume = volume; a.play(); } catch { /* ignore */ } +}; + +const wait = (scene, ms) => new Promise((r) => scene.time.delayedCall(ms, r)); + +export class SlotsFx { + constructor(scene, reelView, machine, depth = 40) { + this.scene = scene; + this.view = reelView; + this.machine = machine; + this.depth = depth; + this.lineG = scene.add.graphics().setDepth(depth); + } + + // ── Particles ──────────────────────────────────────────────────────────────── + burst(x, y, count = 6, tint = 0xffe9a0) { + const em = this.scene.add.particles(x, y, 'slots-spark', { + speed: { min: 90, max: 260 }, lifespan: 450, scale: { start: 1, end: 0 }, + quantity: count, tint, emitting: false, + }).setDepth(this.depth + 2); + em.explode(count); + this.scene.time.delayedCall(600, () => em.destroy()); + } + + coinShower(x, y, count = 24) { + const em = this.scene.add.particles(x, y, 'slots-coin-p', { + speedX: { min: -260, max: 260 }, speedY: { min: -520, max: -220 }, + gravityY: 900, lifespan: 1400, rotate: { min: 0, max: 360 }, + scale: { min: 0.7, max: 1.2 }, quantity: count, emitting: false, + }).setDepth(this.depth + 2); + em.explode(count); + this.scene.time.delayedCall(1600, () => em.destroy()); + } + + floatText(x, y, str, color = '#ffe9a0', size = 34) { + const t = this.scene.add.text(x, y, str, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, + stroke: '#14100a', strokeThickness: 6, + }).setOrigin(0.5).setDepth(this.depth + 3); + this.scene.tweens.add({ + targets: t, y: y - 60, alpha: { from: 1, to: 0 }, + duration: 1100, ease: 'Quad.out', onComplete: () => t.destroy(), + }); + } + + // ── Counters ───────────────────────────────────────────────────────────────── + rollCounter(textObj, from, to, ms = 700) { + return new Promise((resolve) => { + const carrier = { v: from }; + let lastTick = 0; + this.scene.tweens.add({ + targets: carrier, v: to, duration: ms, ease: 'Quad.out', + onUpdate: (tw) => { + textObj.setText(Math.floor(carrier.v).toLocaleString()); + const now = tw.elapsed; + if (now - lastTick > 160) { lastTick = now; playSound(this.scene, SFX.COINS); } + }, + onComplete: () => { textObj.setText(Math.floor(to).toLocaleString()); resolve(); }, + }); + }); + } + + // ── Payline wins ───────────────────────────────────────────────────────────── + async playLineWins(step, machine) { + const wins = step.wins.slice(0, 6); + for (let i = 0; i < wins.length; i++) { + const win = wins[i]; + const color = LINE_COLORS[win.line % LINE_COLORS.length]; + await this.traceLine(win.cells, color); + this.pulseCells(win.cells); + const last = this.view.cellCenter(win.cells[win.cells.length - 1].c, win.cells[win.cells.length - 1].r); + const mult = win.wildMult > 1 ? ` ×${win.wildMult}` : ''; + this.floatText(last.x, last.y - 30, `+${(win.credits * (step.multiplier ?? 1)).toLocaleString()}${mult}`); + playSound(this.scene, 'sfx-casino-win-8bit'); + await wait(this.scene, 360); + this.lineG.clear(); + } + if (step.multiplier > 1) this.flashMultiplier(step.multiplier); + } + + traceLine(cells, color) { + return new Promise((resolve) => { + const pts = cells.map(({ c, r }) => this.view.cellCenter(c, r)); + const carrier = { t: 0 }; + this.scene.tweens.add({ + targets: carrier, t: 1, duration: 240, ease: 'Quad.out', + onUpdate: () => { + this.lineG.clear(); + this.lineG.lineStyle(7, color, 0.9); + this.lineG.beginPath(); + this.lineG.moveTo(pts[0].x, pts[0].y); + const total = (pts.length - 1) * carrier.t; + for (let s = 1; s <= Math.floor(total); s++) this.lineG.lineTo(pts[s].x, pts[s].y); + const f = total - Math.floor(total); + const s = Math.floor(total); + if (s < pts.length - 1 && f > 0) { + this.lineG.lineTo( + pts[s].x + (pts[s + 1].x - pts[s].x) * f, + pts[s].y + (pts[s + 1].y - pts[s].y) * f, + ); + } + this.lineG.strokePath(); + }, + onComplete: resolve, + }); + }); + } + + pulseCells(cells) { + for (const { c, r } of cells) { + const img = this.view.image(c, r); + this.scene.tweens.add({ + targets: img, + scaleX: img.scaleX * 1.16, scaleY: img.scaleY * 1.16, + duration: 150, yoyo: true, ease: 'Quad.out', + }); + const p = this.view.cellCenter(c, r); + this.burst(p.x, p.y, 4); + } + } + + // ── Scatter-pays wins (Sugar Spin) ─────────────────────────────────────────── + async playScatterPay(step) { + for (const win of step.wins) { + this.pulseCells(win.cells); + oneShot('gem-01.mp3', 0.5); + const mid = win.cells[Math.floor(win.cells.length / 2)]; + const p = this.view.cellCenter(mid.c, mid.r); + this.floatText(p.x, p.y, `+${(win.credits * (step.multiplier ?? 1)).toLocaleString()}`); + await wait(this.scene, 260); + } + if (step.multiplier > 1) this.flashMultiplier(step.multiplier); + } + + flashMultiplier(mult) { + const x = this.view.x + this.view.width() / 2; + const t = this.scene.add.text(x, this.view.y - 36, `CASCADE ×${mult}`, { + fontFamily: 'Righteous', fontSize: '40px', color: this.machine.theme.accentHex, + stroke: '#14100a', strokeThickness: 8, + }).setOrigin(0.5).setDepth(this.depth + 3).setScale(0.4); + this.scene.tweens.add({ + targets: t, scale: 1, duration: 220, ease: 'Back.out', + onComplete: () => this.scene.tweens.add({ + targets: t, alpha: 0, delay: 500, duration: 250, onComplete: () => t.destroy(), + }), + }); + } + + // ── Tumbles ────────────────────────────────────────────────────────────────── + async playTumble(step) { + const { view, scene } = this; + // Pop the removed symbols. + for (const { c, r } of step.removed) { + const img = view.image(c, r); + const p = view.cellCenter(c, r); + this.burst(p.x, p.y, 5, 0xfff1bd); + scene.tweens.add({ + targets: img, + scaleX: img.scaleX * 1.3, scaleY: img.scaleY * 1.3, alpha: 0, + duration: 190, ease: 'Quad.in', + }); + } + oneShot(step.cascadeIndex % 2 === 0 ? 'gem-01.mp3' : 'gem-02.mp3', 0.55); + await wait(scene, 210); + + // Survivors fall into place. + for (const { c, fromR, toR } of step.falls) { + const img = view.image(c, fromR); + scene.tweens.add({ + targets: img, y: view.cellCenter(c, toR).y, + duration: 240 + (toR - fromR) * 40, ease: 'Bounce.easeOut', + }); + } + + // Refills drop in from above the window (temporary sprites; showGrid + // afterwards makes the canonical engine grid authoritative). + const temps = []; + for (const { c, r, symbol } of step.refills) { + const target = view.cellCenter(c, r); + const img = scene.add.image(target.x, view.y - (view.rows - r) * view.cellH * 0.6 - view.cellH, textureKeyFor(scene, this.machine.id, symbol)) + .setDepth(this.depth + 1).setMask(view.mask); + img.setDisplaySize(view.cellW * 0.88, view.cellH * 0.88); + temps.push(img); + scene.tweens.add({ + targets: img, y: target.y, + duration: 300 + r * 40, ease: 'Bounce.easeOut', + }); + } + await wait(scene, 480); + for (const t of temps) t.destroy(); + view.showGrid(step.grid); + } + + // ── Rainbow multiplier (Sugar Spin finale) ─────────────────────────────────── + async playRainbow(step) { + playSound(this.scene, SFX.SCIFI_RISER); + for (const { c, r, value } of step.cells) { + const p = this.view.cellCenter(c, r); + this.pulseCells([{ c, r }]); + this.floatText(p.x, p.y - 20, `×${value}`, '#ffa3e0', 44); + await wait(this.scene, 250); + } + const x = this.view.x + this.view.width() / 2; + this.floatText(x, this.view.y + this.view.height() / 2, `TOTAL ×${step.factor}!`, '#ffa3e0', 60); + oneShot('firework.mp3', 0.7); + await wait(this.scene, 700); + } + + // ── Expanding wild (Pharaoh free spins) ────────────────────────────────────── + async playExpandWild(step) { + playSound(this.scene, SFX.SCIFI_RISER); + const wildKey = textureKeyFor(this.scene, this.machine.id, this.machine.wild.symbol); + for (const { c, r } of step.cells) { + const img = this.view.image(c, r); + img.setTexture(wildKey); + img.setDisplaySize(this.view.cellW * 0.88, this.view.cellH * 0.88); + img.baseScaleX = img.scaleX; img.baseScaleY = img.scaleY; + this.pulseCells([{ c, r }]); + await wait(this.scene, 110); + } + await wait(this.scene, 240); + } + + // ── Scatter / bonus-trigger celebration ────────────────────────────────────── + async playScatterAward(step) { + playSound(this.scene, SFX.SCIFI_RISER); + this.pulseCells(step.cells); + for (const { c, r } of step.cells) { + const p = this.view.cellCenter(c, r); + this.burst(p.x, p.y, 8, 0xffd86b); + } + if (step.freeSpins > 0) { + const x = this.view.x + this.view.width() / 2; + const y = this.view.y + this.view.height() / 2; + const t = this.scene.add.text(x, y, `${step.freeSpins} FREE SPINS!`, { + fontFamily: 'Righteous', fontSize: '72px', color: this.machine.theme.accentHex, + stroke: '#14100a', strokeThickness: 10, + }).setOrigin(0.5).setDepth(this.depth + 4).setScale(0.3); + oneShot('firework.mp3', 0.7); + this.scene.tweens.add({ targets: t, scale: 1, duration: 320, ease: 'Back.out' }); + await wait(this.scene, 1500); + this.scene.tweens.add({ targets: t, alpha: 0, duration: 250, onComplete: () => t.destroy() }); + } else { + await wait(this.scene, 500); + } + } + + // ── BIG WIN overlay ────────────────────────────────────────────────────────── + async bigWin(amount, bet) { + const ratio = amount / Math.max(1, bet); + if (ratio < 10) return; + const label = ratio >= 50 ? 'EPIC WIN' : ratio >= 25 ? 'MEGA WIN' : 'BIG WIN'; + const scene = this.scene; + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + + const veil = scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62) + .setDepth(this.depth + 9); + const title = scene.add.text(cx, cy - 90, label, { + fontFamily: 'Righteous', fontSize: '120px', color: '#ffd86b', + stroke: '#5a3a00', strokeThickness: 14, + }).setOrigin(0.5).setDepth(this.depth + 10).setScale(0.2) + .setShadow(0, 0, 'rgba(255,216,107,0.9)', 30); + const amountText = scene.add.text(cx, cy + 50, '0', { + fontFamily: 'Righteous', fontSize: '92px', color: '#fff6d8', + stroke: '#14100a', strokeThickness: 10, + }).setOrigin(0.5).setDepth(this.depth + 10); + + scene.cameras.main.shake(280, ratio >= 50 ? 0.009 : 0.006); + playSound(scene, SFX.CASINO_WIN); + oneShot('firework.mp3', 0.8); + scene.tweens.add({ targets: title, scale: 1, duration: 380, ease: 'Back.out' }); + this.coinShower(cx - 320, cy + 180, 26); + this.coinShower(cx + 320, cy + 180, 26); + await this.rollCounter(amountText, 0, amount, ratio >= 50 ? 1700 : 1100); + if (ratio >= 25) this.coinShower(cx, cy + 220, 34); + await wait(scene, 900); + scene.tweens.add({ + targets: [veil, title, amountText], alpha: 0, duration: 320, + onComplete: () => { veil.destroy(); title.destroy(); amountText.destroy(); }, + }); + } + + destroy() { this.lineG.destroy(); } +} diff --git a/public/src/games/slots/SlotsGame.js b/public/src/games/slots/SlotsGame.js new file mode 100644 index 0000000..24f442c --- /dev/null +++ b/public/src/games/slots/SlotsGame.js @@ -0,0 +1,697 @@ +// Slot Machines — casino-floor lobby of 8 themed machines (3 vintage, 5 modern), +// shared casino bankroll (same wallet as Video Poker). The engine +// (SlotsLogic.js) decides every outcome; this scene and its helper modules +// (SlotsReels/SlotsSymbols/SlotsFx/SlotsFeatures) only animate the results. +// +// View flow uses scene.restart({ view: 'lobby' | 'machine', machineId }) so +// each view starts from a clean display list. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { MACHINES, MACHINE_BY_ID } from './machines.js'; +import { + createSession, spin, spinIsFree, setHold, applyNudge, + grandSliver, createPickBonus, +} from './SlotsLogic.js'; +import { bakeMachineSymbols, bakeParticleTextures, textureKeyFor } from './SlotsSymbols.js'; +import { ReelView } from './SlotsReels.js'; +import { SlotsFx } from './SlotsFx.js'; +import { + buildLever, HoldNudgeUI, FreeSpinBanner, runHoldSpin, runPickBonus, JackpotStrip, +} from './SlotsFeatures.js'; + +const D = { bg: 0, cab: 5, reel: 10, ui: 40 }; +const JACKPOT_LS = 'slots-jackpot-grand'; + +const FEATURE_BLURBS = { + 'liberty-belle': 'Pull the lever. Three bells ring in the big one.', + 'fruit-frenzy': 'Losing spins can offer HOLDs or award up to 3 NUDGES.', + 'lucky-sevens': 'The diamond is wild — 1 wild doubles a win, 2 wilds pay ×4.', + 'pharaohs-fortune': '3+ pyramids award free spins with an expanding wild on reel 3.', + 'abyssal-treasures': 'Wins explode and new symbols cascade in at ×1 ×2 ×4 ×6.', + 'dragons-hoard': '6+ coins lock for 3 respins. Fill the board for the GRAND.', + 'gold-rush': 'Bandit wilds stick for 3 spins. 3+ dynamite opens the mine carts.', + 'sugar-spin': '8+ matching candies anywhere pay & tumble. Rainbows multiply.', +}; + +export default class SlotsGame extends Phaser.Scene { + constructor() { super('SlotsGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'slots', name: 'Slot Machines' }; + this.view = data.view ?? 'lobby'; + this.machineId = data.machineId ?? null; + this.credits = data.credits ?? null; // null → fetch from profile + this.sessionNet = data.sessionNet ?? 0; + this.busy = false; + this.betIdx = 1; + this.freeSpinWin = 0; + } + + async create() { + bakeParticleTextures(this); + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + if (this.credits === null) await this.loadPlayerChips(); + if (this.view === 'machine' && MACHINE_BY_ID[this.machineId]) this.buildMachine(); + else this.buildLobby(); + } + + // ── Bankroll (Video Poker pattern: shared casino chips) ───────────────────── + async loadPlayerChips() { + try { + const { profile } = await api.get('/profile'); + this.credits = profile.chips ?? 2000; + } catch { + this.credits = 2000; + } + } + + 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: 'slots', score: this.credits, opponentScores: [], result, + }); + } catch { /* ignore */ } + } + + loadJackpot() { + const seed = MACHINE_BY_ID['dragons-hoard'].features.holdSpin.grand.seed; + try { + const v = Number(localStorage.getItem(JACKPOT_LS)); + return Number.isFinite(v) && v >= seed ? v : seed; + } catch { return seed; } + } + + saveJackpot(v) { try { localStorage.setItem(JACKPOT_LS, String(v)); } catch { /* ignore */ } } + bestWin(id) { try { return Number(localStorage.getItem(`slots-best-${id}`)) || 0; } catch { return 0; } } + + // ── Background ─────────────────────────────────────────────────────────────── + buildBackground(glowColor) { + const g = this.add.graphics().setDepth(D.bg); + const top = Phaser.Display.Color.ValueToColor(0x141019); + const bot = Phaser.Display.Color.ValueToColor(0x06050a); + 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); + } + g.fillStyle(glowColor, 0.05); + g.fillEllipse(GAME_WIDTH / 2, GAME_HEIGHT - 30, 1500, 220); + } + + // ════════════════════════ LOBBY ═══════════════════════════════════════════════ + buildLobby() { + this.buildBackground(COLORS.gold); + this.add.text(GAME_WIDTH / 2, 64, 'SLOT MACHINES', { + fontFamily: 'Righteous', fontSize: '56px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui).setShadow(0, 0, 'rgba(212,160,23,0.85)', 22); + this.add.text(GAME_WIDTH / 2, 118, 'CHOOSE A MACHINE', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.creditText = this.add.text(GAME_WIDTH - 50, 64, `CHIPS ${this.credits.toLocaleString()}`, { + fontFamily: 'Righteous', fontSize: '30px', color: '#8cf28a', + }).setOrigin(1, 0.5).setDepth(D.ui); + + new Button(this, 150, 64, 'Leave', () => this.leave(), { width: 180, height: 54, variant: 'ghost' }) + .setDepth(D.ui); + + const lastPlayed = (() => { try { return localStorage.getItem('slots-last-machine'); } catch { return null; } })(); + const jackpot = this.loadJackpot(); + + const cw = 420; const chh = 380; const gap = 32; + const x0 = GAME_WIDTH / 2 - (4 * cw + 3 * gap) / 2 + cw / 2; + MACHINES.forEach((m, i) => { + const x = x0 + (i % 4) * (cw + gap); + const y = 380 + Math.floor(i / 4) * (chh + 50); + this.buildLobbyCard(m, x, y, cw, chh, { lastPlayed, jackpot, index: i }); + }); + } + + buildLobbyCard(m, x, y, w, h, { lastPlayed, jackpot, index }) { + bakeMachineSymbols(this, m); + const t = m.theme; + const card = this.add.container(x, y).setDepth(D.ui); + + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.55); + g.fillRoundedRect(-w / 2 + 6, -h / 2 + 10, w, h, 20); + g.fillStyle(t.cabinet, 1); + g.fillRoundedRect(-w / 2, -h / 2, w, h, 20); + g.fillStyle(t.cabinetHi, 0.5); + g.fillRoundedRect(-w / 2 + 8, -h / 2 + 8, w - 16, 26, 12); + g.lineStyle(3, t.trim, 0.9); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 20); + // Screen inset. + g.fillStyle(t.screenBot, 1); + g.fillRoundedRect(-w / 2 + 26, -42, w - 52, 130, 12); + g.lineStyle(2, t.glow, 0.5); + g.strokeRoundedRect(-w / 2 + 26, -42, w - 52, 130, 12); + card.add(g); + + // Marquee. + const cardArtKey = `slots-${m.id}-card`; + if (this.textures.exists(cardArtKey)) { + const art = this.add.image(0, -6, cardArtKey).setDisplaySize(w - 16, h - 90); + card.add(art); + } + const name = this.add.text(0, -h / 2 + 52, m.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '30px', color: t.accentHex, align: 'center', + }).setOrigin(0.5).setShadow(0, 0, t.accentHex, 16); + const tag = this.add.text(0, -h / 2 + 86, m.tagline, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0.5); + card.add([name, tag]); + + // Era badge. + const era = this.add.text(w / 2 - 16, -h / 2 + 18, m.era.toUpperCase(), { + fontFamily: '"Julius Sans One"', fontSize: '14px', + color: m.era === 'vintage' ? '#d9a93b' : '#59e7ff', + }).setOrigin(1, 0.5); + card.add(era); + + // Three sample symbols on the mini screen. + if (!this.textures.exists(cardArtKey)) { + const sample = m.symbols.slice(0, 3); + sample.forEach((sym, si) => { + const img = this.add.image((si - 1) * 110, 22, textureKeyFor(this, m.id, sym)) + .setDisplaySize(96, 96); + card.add(img); + }); + } + + // Feature chip + stats. + const chip = this.add.text(0, 118, m.featureLabel, { + fontFamily: 'Righteous', fontSize: '20px', color: '#14100a', + backgroundColor: Phaser.Display.Color.IntegerToColor(t.glow).rgba, + padding: { x: 14, y: 5 }, + }).setOrigin(0.5); + card.add(chip); + + const best = this.bestWin(m.id); + const statStr = m.id === 'dragons-hoard' + ? `GRAND ${Math.floor(jackpot).toLocaleString()}` + : (best > 0 ? `BEST WIN ${best.toLocaleString()}` : 'UNPLAYED'); + const stat = this.add.text(0, 156, statStr, { + fontFamily: '"Julius Sans One"', fontSize: '18px', + color: m.id === 'dragons-hoard' ? '#ffd86b' : COLORS.mutedHex, + }).setOrigin(0.5); + card.add(stat); + if (m.id === 'dragons-hoard') { + this.tweens.add({ targets: stat, alpha: { from: 1, to: 0.55 }, duration: 700, yoyo: true, repeat: -1 }); + } + if (lastPlayed === m.id) { + const lp = this.add.text(-w / 2 + 16, -h / 2 + 18, '▸ LAST PLAYED', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#8cf28a', + }).setOrigin(0, 0.5); + card.add(lp); + } + + // Attract-mode marquee lights. + const lights = this.add.graphics(); + card.add(lights); + const bulbs = 7; + const bulbState = { phase: Math.random() * Math.PI * 2 }; + const drawBulbs = () => { + lights.clear(); + for (let b = 0; b < bulbs; b++) { + const lx = -w / 2 + 40 + (b * (w - 80)) / (bulbs - 1); + const on = Math.sin(bulbState.phase + b * 0.9) > 0; + lights.fillStyle(on ? t.glow : 0x1a140c, on ? 0.95 : 0.8); + lights.fillCircle(lx, -h / 2 + 110, 5); + } + }; + drawBulbs(); + this.tweens.add({ + targets: bulbState, phase: bulbState.phase + Math.PI * 2, + duration: 2400 + index * 180, repeat: -1, onUpdate: drawBulbs, + }); + + card.setSize(w, h).setInteractive({ useHandCursor: true }); + card.on('pointerover', () => this.tweens.add({ targets: card, scale: 1.035, duration: 120 })); + card.on('pointerout', () => this.tweens.add({ targets: card, scale: 1, duration: 120 })); + card.on('pointerup', () => this.openMachine(m.id)); + } + + openMachine(id) { + try { localStorage.setItem('slots-last-machine', id); } catch { /* ignore */ } + playSound(this, SFX.CHIP_BET); + this.scene.restart({ + game: this.gameDef, view: 'machine', machineId: id, + credits: this.credits, sessionNet: this.sessionNet, + }); + } + + backToLobby() { + this.flushChips(); + this.scene.restart({ + game: this.gameDef, view: 'lobby', + credits: this.credits, sessionNet: this.sessionNet, + }); + } + + async leave() { + await this.flushChips(); + this.scene.start('GameMenu'); + } + + // ════════════════════════ MACHINE ═════════════════════════════════════════════ + machineLayout(m) { + const { reels, rows } = m.layout; + if (rows === 1) return { cellW: 230, cellH: 230, gap: 16 }; + if (reels === 3) return { cellW: 200, cellH: 158, gap: 14 }; + if (reels === 6) return { cellW: 132, cellH: 102, gap: 8 }; + return { cellW: 184, cellH: 158, gap: 10 }; + } + + buildMachine() { + const m = MACHINE_BY_ID[this.machineId]; + this.machine = m; + bakeMachineSymbols(this, m); + this.session = createSession(m, Math.random); + this.bet = m.betLevels[this.betIdx] ?? m.betLevels[0]; + this.jackpot = this.loadJackpot(); + this.buildBackground(m.theme.glow); + + // Geometry. + const { cellW, cellH, gap } = this.machineLayout(m); + const winW = m.layout.reels * cellW + (m.layout.reels - 1) * gap; + const winH = m.layout.rows * cellH; + const wx = 720 - winW / 2; + const wy = m.layout.rows === 1 ? 360 : 318 - (m.layout.rows - 3) * 40; + + // Cabinet shell. + const g = this.add.graphics().setDepth(D.cab); + const cabX = wx - 56; const cabY = 96; + const cabW = winW + 112; const cabH = wy + winH + 96 - cabY; + g.fillStyle(0x000000, 0.5); + g.fillRoundedRect(cabX + 8, cabY + 12, cabW, cabH, 30); + g.fillStyle(m.theme.cabinet, 1); + g.fillRoundedRect(cabX, cabY, cabW, cabH, 28); + g.fillStyle(m.theme.cabinetHi, 0.45); + g.fillRoundedRect(cabX + 10, cabY + 10, cabW - 20, 30, 14); + g.lineStyle(4, m.theme.trim, 0.95); + g.strokeRoundedRect(cabX, cabY, cabW, cabH, 28); + + // Marquee (drop-in art if present). + const cabinetArtKey = `slots-${m.id}-cabinet`; + if (this.textures.exists(cabinetArtKey)) { + this.add.image(wx + winW / 2, cabY + 80, cabinetArtKey) + .setDisplaySize(cabW - 40, 120).setDepth(D.cab + 1); + } else { + this.add.text(wx + winW / 2, cabY + 58, m.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '52px', color: m.theme.accentHex, + }).setOrigin(0.5).setDepth(D.cab + 1).setShadow(0, 0, m.theme.accentHex, 24); + this.add.text(wx + winW / 2, cabY + 104, `${m.tagline.toUpperCase()} • ${m.featureLabel}`, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.cab + 1); + } + + // Marquee chase lights. + const lights = this.add.graphics().setDepth(D.cab + 1); + const lightState = { phase: 0 }; + const nLights = Math.floor(cabW / 34); + const drawLights = () => { + lights.clear(); + for (let b = 0; b < nLights; b++) { + const on = Math.sin(lightState.phase + b * 0.8) > 0.2; + lights.fillStyle(on ? m.theme.glow : 0x14100a, on ? 0.9 : 0.7); + lights.fillCircle(cabX + 18 + b * 34, cabY + 134, 4.5); + } + }; + drawLights(); + this.tweens.add({ + targets: lightState, phase: Math.PI * 2, duration: 2600, repeat: -1, onUpdate: drawLights, + }); + + // Reels. + this.reelView = new ReelView(this, m, { x: wx, y: wy, cellW, cellH, gap, depth: D.reel }); + this.reelView.showGrid(this.session.grid ?? this.idleGrid(m)); + this.fx = new SlotsFx(this, this.reelView, m, D.reel + 20); + this.holdNudge = m.features.holdNudge ? new HoldNudgeUI(this, this.reelView, D.ui - 2) : null; + + // Jackpot strip (Dragon's Hoard). + if (m.features.holdSpin) { + this.jackpotStrip = new JackpotStrip(this, m, wx + winW / 2, cabY + 172, D.cab + 2); + this.jackpotStrip.refresh(this.bet, this.jackpot); + } + + // Lever (Liberty Belle) — and a SPIN button for everyone. + if (m.features.lever) { + this.lever = buildLever(this, cabX + cabW + 46, wy - 20, winH + 60, m.theme, () => this.onSpin()); + } + + this.buildPaytablePanel(m); + this.buildControls(m); + this.refreshMeters(); + this.setStatus(m.features.lever ? 'PULL THE LEVER TO SPIN' : 'PLACE YOUR BET • PRESS SPIN'); + } + + idleGrid(m) { + // Show each reel's strip top as the idle window. + return m.strips.map((strip) => Array.from({ length: m.layout.rows }, (_, r) => strip[r % strip.length])); + } + + // ── Right-hand paytable panel ──────────────────────────────────────────────── + buildPaytablePanel(m) { + const px = 1336; const py = 96; const pw = GAME_WIDTH - px - 28; const ph = 700; + const g = this.add.graphics().setDepth(D.ui - 1); + g.fillStyle(0x0a0810, 0.92); + g.fillRoundedRect(px, py, pw, ph, 18); + g.lineStyle(3, m.theme.trim, 0.7); + g.strokeRoundedRect(px, py, pw, ph, 18); + this.add.text(px + pw / 2, py + 30, 'PAYTABLE', { + fontFamily: 'Righteous', fontSize: '28px', color: m.theme.accentHex, + }).setOrigin(0.5).setDepth(D.ui).setShadow(0, 0, m.theme.accentHex, 12); + + this.payCells = []; + const isScatterPays = m.evaluation === 'scatterPays'; + const entries = Object.entries(m.paytable); + const counts = isScatterPays ? [8, 10, 12] : (m.layout.reels === 3 ? [2, 3] : [3, 4, 5]); + const rowH = Math.min(52, (ph - 150) / (entries.length + 1)); + const iconX = px + 44; + const colW = (pw - 110) / counts.length; + + // Column headers. + counts.forEach((cnt, ci) => { + this.add.text(px + 92 + ci * colW + colW / 2, py + 66, isScatterPays ? `${cnt}+` : `×${cnt}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + }); + + entries.forEach(([sym, table], ri) => { + const ry = py + 96 + ri * rowH; + const isGroup = !!m.symbolGroups?.[sym]; + if (isGroup) { + this.add.text(iconX, ry, sym === 'any-seven' ? 'ANY 7' : 'ANY BAR', { + fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + } else { + this.add.image(iconX, ry, textureKeyFor(this, m.id, sym)) + .setDisplaySize(rowH - 8, rowH - 8).setDepth(D.ui); + } + counts.forEach((cnt, ci) => { + let calc; + if (isScatterPays) { + const tier = table.find((tt) => cnt >= tt.min); + calc = tier ? (bet) => Math.round(tier.pay * bet) : null; + } else { + const pay = table[cnt]; + calc = pay !== undefined ? (bet) => Math.round(pay * bet / m.paylines.length) : null; + } + if (!calc) return; + const txt = this.add.text(px + 92 + ci * colW + colW / 2, ry, '', { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.payCells.push({ txt, calc }); + }); + }); + + // Feature / scatter footnotes. + let fy = py + 96 + entries.length * rowH + 14; + const note = (str, color = COLORS.mutedHex) => { + this.add.text(px + 24, fy, str, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color, + wordWrap: { width: pw - 48 }, + }).setOrigin(0, 0).setDepth(D.ui); + fy += 44; + }; + if (m.wild?.multipliers) note('Wild substitutes: 1 wild ×2 pay, 2 wilds ×4 pay.', m.theme.accentHex); + else if (m.wild) note(`${m.wild.symbol.toUpperCase()} is WILD.`, m.theme.accentHex); + if (m.scatter?.freeSpins) note('3 / 4 / 5 scatters: 8 / 12 / 20 free spins + scatter pay.', m.theme.accentHex); + else if (m.scatter?.pays) note('3+ scatters pay anywhere and open the bonus.', m.theme.accentHex); + note(FEATURE_BLURBS[m.id]); + if (m.paylines) note(`${m.paylines.length} payline${m.paylines.length > 1 ? 's' : ''}, wins pay left to right.`); + else note('No paylines — 8+ anywhere pays.'); + + this.refreshPaytable(); + } + + refreshPaytable() { + for (const { txt, calc } of this.payCells) txt.setText(calc(this.bet).toLocaleString()); + } + + // ── Controls & meters ──────────────────────────────────────────────────────── + buildControls(m) { + // Meters live under the paytable panel; controls span the bottom bar. + const meterY = 856; + const mkMeter = (x, label, color) => { + this.add.text(x, meterY - 30, label, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + return this.add.text(x, meterY + 4, '0', { + fontFamily: 'Righteous', fontSize: '32px', color, + }).setOrigin(0.5).setDepth(D.ui).setShadow(0, 0, color, 10); + }; + this.creditText = mkMeter(1420, 'CHIPS', '#8cf28a'); + this.betText = mkMeter(1620, 'BET', m.theme.accentHex); + this.winText = mkMeter(1790, 'WIN', '#ffd86b'); + + this.statusText = this.add.text(760, 932, '', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + + const btnY = GAME_HEIGHT - 64; + this.betDownBtn = new Button(this, 300, btnY, '−', () => this.adjustBet(-1), { width: 84, height: 60, fontSize: 32 }); + this.betUpBtn = new Button(this, 444, btnY, '+', () => this.adjustBet(1), { width: 84, height: 60, fontSize: 32 }); + this.spinBtn = new Button(this, 760, btnY, 'SPIN', () => this.onSpin(), + { width: 300, height: 76, fontSize: 34, bg: COLORS.gold, variant: 'solid' }); + this.maxBetBtn = new Button(this, 1040, btnY, 'MAX BET', () => { + this.betIdx = this.machine.betLevels.length - 1; + this.bet = this.machine.betLevels[this.betIdx]; + playSound(this, SFX.CHIP_BET); + this.refreshMeters(); this.refreshPaytable(); + this.jackpotStrip?.refresh(this.bet, this.jackpot); + this.onSpin(); + }, { width: 190, height: 60, fontSize: 24 }); + this.lobbyBtn = new Button(this, GAME_WIDTH - 150, btnY, 'Machines', () => this.backToLobby(), + { width: 200, height: 60, variant: 'ghost', fontSize: 24 }); + [this.betDownBtn, this.betUpBtn, this.spinBtn, this.maxBetBtn, this.lobbyBtn] + .forEach((b) => b.setDepth(D.ui)); + } + + adjustBet(dir) { + if (this.busy) return; + const levels = this.machine.betLevels; + this.betIdx = Phaser.Math.Clamp(this.betIdx + dir, 0, levels.length - 1); + this.bet = levels[this.betIdx]; + playSound(this, SFX.CHIP_BET); + this.refreshMeters(); + this.refreshPaytable(); + this.jackpotStrip?.refresh(this.bet, this.jackpot); + } + + refreshMeters(win = null) { + this.creditText.setText(this.credits.toLocaleString()); + this.betText.setText(this.bet.toLocaleString()); + if (win !== null) this.winText.setText(win.toLocaleString()); + } + + setStatus(str) { this.statusText?.setText(str); } + + setControlsEnabled(v) { + [this.betDownBtn, this.betUpBtn, this.spinBtn, this.maxBetBtn, this.lobbyBtn] + .forEach((b) => b?.setEnabled(v)); + this.lever?.setEnabled(v); + } + + // ── The spin ───────────────────────────────────────────────────────────────── + async onSpin() { + if (this.busy || this.view !== 'machine') return; + const m = this.machine; + const free = spinIsFree(this.session); + if (!free && this.credits < this.bet) { + this.setStatus('NOT ENOUGH CHIPS'); + playSound(this, 'sfx-casino-lose-8bit'); + return; + } + this.busy = true; + this.setControlsEnabled(false); + this.holdNudge?.hideBanner(); + + if (!free) { + this.credits -= this.bet; + this.sessionNet -= this.bet; + if (m.features.holdSpin) { + this.jackpot += grandSliver(m, this.bet); + this.saveJackpot(this.jackpot); + this.jackpotStrip.refresh(this.bet, this.jackpot); + } + } + this.refreshMeters(0); + this.setStatus(free ? 'FREE SPIN' : 'GOOD LUCK!'); + + // The engine resolves everything up front; capture holds before it resets them. + const heldBefore = this.session.holds.held.slice(); + const result = spin(this.session, this.bet); + this.holdNudge?.clear(); + + // Anticipation when the engine says something big landed. + const big = result.holdSpinTriggered || result.scatterCount >= (m.scatter ? 3 : Infinity) + || result.freeSpinsAwarded > 0; + playSound(this, m.era === 'vintage' ? SFX.DICE_ROLL : SFX.ROULETTE); + await this.reelView.spin(result.stops, { + anticipationFrom: big ? m.layout.reels - 1 : null, + stepped: !!m.features.steppedStops, + held: heldBefore, + }); + + let spinTotal = result.totalWin; + + // Animate the engine's step script in order. + for (const step of result.steps) { + if (step.type === 'lineWins') await this.fx.playLineWins(step, m); + else if (step.type === 'scatterPay') await this.fx.playScatterPay(step); + else if (step.type === 'tumble') await this.fx.playTumble(step); + else if (step.type === 'rainbow') await this.fx.playRainbow(step); + else if (step.type === 'expandWild') await this.fx.playExpandWild(step); + else if (step.type === 'scatterAward') await this.fx.playScatterAward(step); + else if (step.type === 'stickyWilds') this.playStickySteps(step); + } + this.reelView.showGrid(result.grid); + this.decorateCells(result); + if (m.features.stickyWilds) { + this.reelView.setSticky( + this.session.stickyWilds.map(({ c, r }) => ({ c, r })), + m.features.stickyWilds.symbol, + ); + } + + // Interactive features. + if (result.holdSpinTriggered) { + const { award, cleanup } = await runHoldSpin(this, this.reelView, this.fx, this.session, this.bet, m); + let bonusWin = award.credits; + for (const j of award.jackpots) this.jackpotStrip.flash(j.tier); + if (award.grandHit) { + bonusWin += Math.floor(this.jackpot); + this.jackpotStrip.flash('grand'); + this.jackpot = m.features.holdSpin.grand.seed; + this.saveJackpot(this.jackpot); + this.jackpotStrip.refresh(this.bet, this.jackpot); + } + cleanup(); + spinTotal += bonusWin; + } + if (result.pickBonusTriggered) { + const bonus = createPickBonus(m, this.bet, Math.random); + spinTotal += await runPickBonus(this, this.fx, m, bonus); + } + if (result.nudgesAwarded > 0) { + this.setStatus(''); + spinTotal += await this.holdNudge.runNudges(result.nudgesAwarded, async (c) => { + const res = applyNudge(this.session, c, this.bet); + await this.reelView.nudge(c, this.session.stops[c]); + if (res.totalWin > 0) { + await this.fx.playLineWins({ wins: res.wins, multiplier: 1 }, m); + } + return res; + }); + this.reelView.showGrid(this.session.grid); + } + + // Settle. + if (spinTotal > 0) { + this.credits += spinTotal; + this.sessionNet += spinTotal; + await this.fx.rollCounter(this.winText, 0, spinTotal, Math.min(900, 250 + spinTotal)); + this.refreshMeters(spinTotal); + await this.fx.bigWin(spinTotal, this.bet); + this.setStatus(`WIN ${spinTotal.toLocaleString()}`); + this.saveBest(spinTotal); + } else { + this.refreshMeters(0); + if (!free) this.setStatus('NO WIN — SPIN AGAIN'); + } + + // Holds offered for the NEXT spin. + if (result.holdsOffered) { + this.holdNudge.offerHolds((c) => { + const held = !this.session.holds.held[c]; + setHold(this.session, c, held); + return held; + }); + this.setStatus('CHOOSE REELS TO HOLD, THEN SPIN'); + } + + if (!free) { + this.recordHistory(spinTotal > 0 ? 'win' : 'loss'); + this.flushChips(); + } + + // Free spins play themselves out. + if (result.freeSpinsAwarded > 0 && !this.freeSpinBanner) { + this.freeSpinBanner = new FreeSpinBanner(this, this.reelView, this.machine.theme, D.ui - 2); + this.freeSpinWin = 0; + } + if (this.freeSpinBanner) { + this.freeSpinWin += free ? spinTotal : 0; + if (spinIsFree(this.session)) { + this.freeSpinBanner.update(this.session.freeSpins.remaining, this.session.freeSpins.total, this.freeSpinWin); + this.busy = false; + this.time.delayedCall(650, () => this.onSpin()); + return; + } + // Free spins over. + this.freeSpinBanner.destroy(); + this.freeSpinBanner = null; + if (this.freeSpinWin > 0) { + this.setStatus(`FREE SPINS PAID ${this.freeSpinWin.toLocaleString()}`); + this.saveBest(this.freeSpinWin); + } + this.flushChips(); + } + + this.busy = false; + this.setControlsEnabled(true); + } + + playStickySteps(step) { + for (const { c, r } of step.placed) { + const p = this.reelView.cellCenter(c, r); + this.fx.burst(p.x, p.y, 8, 0xffe27a); + this.fx.floatText(p.x, p.y - 24, 'STICKY!', '#ffe27a', 26); + } + if (step.placed.length > 0) playSound(this, SFX.SCIFI_RISER); + } + + // Coin values / rainbow multipliers painted over the resolved grid. + decorateCells(result) { + this.reelView.clearOverlays(); + const m = this.machine; + if (m.features.holdSpin) { + for (const { c, r, value, tier } of result.coinCells) { + this.reelView.setOverlayText(c, r, tier ? tier.toUpperCase() : `${value * this.bet}`, + { color: tier ? '#ff8c8c' : '#fff6d8', size: tier ? 20 : 24 }); + } + } + if (m.features.rainbow) { + for (const [k, v] of Object.entries(this.session.rainbowValues)) { + const [c, r] = k.split(',').map(Number); + this.reelView.setOverlayText(c, r, `×${v}`, { color: '#ffa3e0', size: 26 }); + } + } + } + + saveBest(amount) { + try { + const k = `slots-best-${this.machine.id}`; + if (amount > (Number(localStorage.getItem(k)) || 0)) localStorage.setItem(k, String(amount)); + } catch { /* ignore */ } + } +} diff --git a/public/src/games/slots/SlotsLogic.js b/public/src/games/slots/SlotsLogic.js new file mode 100644 index 0000000..84d0088 --- /dev/null +++ b/public/src/games/slots/SlotsLogic.js @@ -0,0 +1,537 @@ +// Pure slot-machine engine — no Phaser imports. All randomness flows through +// the injected rng so seeded runs replay identically (verifySlots.js relies +// on this). Documented rng draw order per spin: +// 1. reel stops, left to right (held reels skipped) +// 2. coin values for landed coins, column-major +// 3. cascade refills, per cascade: column-major, top-down +// (rainbow roll first, then strip draw) +// 4. feature rolls: hold offer, then nudge award +// Hold-and-spin respins draw in holdSpinRespin(): cells column-major, +// presence roll then value roll. +// +// The engine returns a complete ordered steps[] script for every spin; the +// renderer only animates it and never invents a symbol or payout. + +export function randInt(rng, n) { return Math.floor(rng() * n); } + +export function weightedDraw(rng, pairs) { + let total = 0; + for (const [, w] of pairs) total += w; + let roll = rng() * total; + for (const [value, w] of pairs) { + roll -= w; + if (roll <= 0) return value; + } + return pairs[pairs.length - 1][0]; +} + +// Largest numeric key in table that is <= count (pay tables are monotonic). +function lookupTier(table, count) { + let best = null; + for (const k of Object.keys(table)) { + const n = Number(k); + if (n <= count && (best === null || n > best)) best = n; + } + return best === null ? null : { count: best, pay: table[best] }; +} + +const key = (c, r) => `${c},${r}`; + +// ── Session ─────────────────────────────────────────────────────────────────── +export function createSession(machine, rng = Math.random) { + return { + machine, + rng, + mode: 'base', // 'base' | 'freespins' | 'holdspin' + stops: machine.strips.map(() => 0), + grid: null, + freeSpins: null, // { remaining, total } + stickyWilds: [], // [{ c, r, spinsLeft }] + holds: { offered: false, held: machine.strips.map(() => false) }, + nudgesLeft: 0, + holdSpin: null, // { locked: [{c,r,value|tier}], respinsLeft } + coinValues: {}, // 'c,r' → { value } | { tier } + rainbowValues: {}, // 'c,r' → multiplier value + }; +} + +export function spinIsFree(state) { return state.mode === 'freespins'; } + +// ── Grid construction ───────────────────────────────────────────────────────── +// grid[col][row] = symbol id. stop = strip index of the TOP visible row. +export function gridFromStops(machine, stops, overrides = {}) { + const { rows } = machine.layout; + const grid = []; + for (let c = 0; c < machine.strips.length; c++) { + const strip = machine.strips[c]; + grid[c] = []; + for (let r = 0; r < rows; r++) { + grid[c][r] = overrides[key(c, r)] ?? strip[(stops[c] + r) % strip.length]; + } + } + return grid; +} + +// ── Line evaluation ─────────────────────────────────────────────────────────── +// Returns { wins: [{ line, symbol, count, cells, credits, wildMult }], total }. +// Pay values are per LINE bet; credits = round(pay * wildMult * bet / nLines). +export function evaluateLines(machine, grid, bet) { + const wildSym = machine.wild?.symbol ?? null; + const wildMults = machine.wild?.multipliers ?? null; + const groups = machine.symbolGroups ?? {}; + const nLines = machine.paylines.length; + const wins = []; + let total = 0; + + machine.paylines.forEach((line, li) => { + let best = null; + for (const target of Object.keys(machine.paytable)) { + const members = groups[target] ?? null; + const isWildTarget = target === wildSym; + let count = 0; + let wildsInWin = 0; + for (let c = 0; c < line.length; c++) { + const sym = grid[c][line[c]]; + const isWild = wildSym !== null && sym === wildSym; + let match; + if (isWildTarget) match = isWild; + else if (members) match = members.includes(sym) || isWild; + else match = sym === target || isWild; + if (!match) break; + count++; + if (isWild && !isWildTarget) wildsInWin++; + } + if (count === 0) continue; + const tier = lookupTier(machine.paytable[target], count); + if (!tier) continue; + // A group/symbol win made of nothing but wilds belongs to the wild's own + // paytable entry (or a better symbol) — still valid, just scored here too; + // best-pick keeps the highest. + const wildMult = (wildMults && !isWildTarget && wildsInWin > 0) + ? (wildMults[wildsInWin] ?? 1) : 1; + const credits = Math.round(tier.pay * wildMult * bet / nLines); + if (credits > 0 && (!best || credits > best.credits)) { + best = { + line: li, + symbol: target, + count: tier.count, + cells: line.slice(0, tier.count).map((row, c) => ({ c, r: row })), + credits, + wildMult, + }; + } + } + if (best) { wins.push(best); total += best.credits; } + }); + return { wins, total }; +} + +// ── Scatter-pays evaluation (Sugar Spin) ────────────────────────────────────── +// paytable[symbol] = [{ min, pay }] sorted by min desc; pay is ×total-bet. +export function evaluateScatterPays(machine, grid, bet) { + const counts = {}; + const cellsBySym = {}; + for (let c = 0; c < grid.length; c++) { + for (let r = 0; r < grid[c].length; r++) { + const sym = grid[c][r]; + counts[sym] = (counts[sym] ?? 0) + 1; + (cellsBySym[sym] ??= []).push({ c, r }); + } + } + const wins = []; + let total = 0; + for (const [sym, tiers] of Object.entries(machine.paytable)) { + const n = counts[sym] ?? 0; + const tier = tiers.find((t) => n >= t.min); + if (!tier) continue; + const credits = Math.round(tier.pay * bet); + if (credits <= 0) continue; + wins.push({ symbol: sym, count: n, cells: cellsBySym[sym], credits }); + total += credits; + } + return { wins, total }; +} + +export function evaluateGrid(machine, grid, bet) { + return machine.evaluation === 'scatterPays' + ? evaluateScatterPays(machine, grid, bet) + : evaluateLines(machine, grid, bet); +} + +// ── Scatter symbol (anywhere) counting ──────────────────────────────────────── +function findScatters(machine, grid) { + if (!machine.scatter) return { count: 0, cells: [] }; + const cells = []; + for (let c = 0; c < grid.length; c++) { + for (let r = 0; r < grid[c].length; r++) { + if (grid[c][r] === machine.scatter.symbol) cells.push({ c, r }); + } + } + return { count: cells.length, cells }; +} + +// ── Cascades / tumbles ──────────────────────────────────────────────────────── +// Mutates grid in place; returns steps + total. Used by Abyssal (lines with +// multiplier ladder) and Sugar Spin (scatter pays + rainbow multipliers). +export function resolveCascades(state, grid, bet) { + const { machine, rng } = state; + const ladder = machine.features.cascade?.multipliers ?? null; + const rainbowCfg = machine.features.rainbow ?? null; + const steps = []; + let total = 0; + let cascadeIndex = 0; + + for (;;) { + const mult = ladder ? ladder[Math.min(cascadeIndex, ladder.length - 1)] : 1; + const ev = evaluateGrid(machine, grid, bet); + if (ev.total <= 0) break; + const winCredits = ev.total * mult; + total += winCredits; + steps.push({ + type: machine.evaluation === 'scatterPays' ? 'scatterPay' : 'lineWins', + wins: ev.wins, multiplier: mult, credits: winCredits, + }); + + // Union of winning cells to remove. + const removeSet = new Set(); + for (const w of ev.wins) for (const cell of w.cells) removeSet.add(key(cell.c, cell.r)); + + // Tumble each column: survivors fall, refills drop in from the strip. + const rows = machine.layout.rows; + const removed = [...removeSet].map((k) => { + const [c, r] = k.split(',').map(Number); + return { c, r }; + }); + const falls = []; + const refills = []; + for (let c = 0; c < grid.length; c++) { + const survivors = []; + for (let r = 0; r < rows; r++) { + if (!removeSet.has(key(c, r))) survivors.push({ fromR: r, sym: grid[c][r] }); + } + const missing = rows - survivors.length; + if (missing === 0) continue; + // Slide survivors to the bottom of the column. + for (let i = survivors.length - 1; i >= 0; i--) { + const toR = rows - (survivors.length - i); + const { fromR, sym } = survivors[i]; + if (state.rainbowValues[key(c, fromR)] !== undefined && fromR !== toR) { + state.rainbowValues[key(c, toR)] = state.rainbowValues[key(c, fromR)]; + delete state.rainbowValues[key(c, fromR)]; + } + if (fromR !== toR) falls.push({ c, fromR, toR }); + grid[c][toR] = sym; + } + // Refill the vacated top cells, top-down. + const strip = machine.strips[c]; + for (let r = 0; r < missing; r++) { + let sym; + if (rainbowCfg && rng() < rainbowCfg.chance) { + sym = rainbowCfg.symbol; + state.rainbowValues[key(c, r)] = weightedDraw(rng, rainbowCfg.values); + } else { + sym = strip[randInt(rng, strip.length)]; + } + grid[c][r] = sym; + refills.push({ c, r, symbol: sym }); + } + } + steps.push({ + type: 'tumble', removed, falls, refills, + grid: grid.map((col) => col.slice()), cascadeIndex, + }); + cascadeIndex++; + } + + // Rainbow multipliers apply to the whole tumble sequence at the end. + if (rainbowCfg && total > 0) { + const cells = []; + for (const [k, v] of Object.entries(state.rainbowValues)) { + const [c, r] = k.split(',').map(Number); + cells.push({ c, r, value: v }); + } + if (cells.length > 0) { + const factor = cells.reduce((s, cell) => s + cell.value, 0); + const boosted = total * factor; + steps.push({ type: 'rainbow', cells, factor, credits: boosted - total }); + total = boosted; + } + } + return { steps, total }; +} + +// ── Core spin ───────────────────────────────────────────────────────────────── +// The caller owns the money: charge `bet` before calling unless spinIsFree(). +export function spin(state, bet) { + const { machine, rng } = state; + if (state.mode === 'holdspin') throw new Error('resolve hold-and-spin respins first'); + const freeSpin = state.mode === 'freespins'; + if (freeSpin) state.freeSpins.remaining--; + + // 1. Stops (held reels keep their previous stop). + const heldAny = state.holds.held.some(Boolean); + const stops = machine.strips.map((strip, i) => + (state.holds.held[i] ? state.stops[i] : randInt(rng, strip.length))); + state.stops = stops; + state.holds = { offered: false, held: machine.strips.map(() => false) }; + state.nudgesLeft = 0; + state.rainbowValues = {}; + + // Sticky wild overrides (placed on previous spins). + const overrides = {}; + const stickyCfg = machine.features.stickyWilds ?? null; + for (const s of state.stickyWilds) overrides[key(s.c, s.r)] = stickyCfg.symbol; + + let grid = gridFromStops(machine, stops, overrides); + const steps = []; + + // 2. Coin values (Dragon's Hoard) — column-major draw order. + const holdSpinCfg = machine.features.holdSpin ?? null; + state.coinValues = {}; + const coinCells = []; + if (holdSpinCfg) { + for (let c = 0; c < grid.length; c++) { + for (let r = 0; r < grid[c].length; r++) { + if (grid[c][r] !== holdSpinCfg.coinSymbol) continue; + const drawn = weightedDraw(rng, holdSpinCfg.coinValues); + state.coinValues[key(c, r)] = + typeof drawn === 'number' ? { value: drawn } : { tier: drawn }; + coinCells.push({ c, r, ...state.coinValues[key(c, r)] }); + } + } + } + + // Expanding wild on the configured reel during free spins (Pharaoh). + if (freeSpin && machine.scatter?.expandingWildReel !== undefined) { + const col = machine.scatter.expandingWildReel; + if (grid[col].some((s) => s === machine.wild.symbol)) { + grid[col] = grid[col].map(() => machine.wild.symbol); + steps.push({ + type: 'expandWild', col, + cells: grid[col].map((_, r) => ({ c: col, r })), + }); + } + } + + // 3. Evaluation (+ cascades where configured). + let totalWin = 0; + if (machine.features.cascade || machine.features.tumble) { + const res = resolveCascades(state, grid, bet); + steps.push(...res.steps); + totalWin += res.total; + } else { + const ev = evaluateGrid(machine, grid, bet); + if (ev.total > 0) { + steps.push({ + type: machine.evaluation === 'scatterPays' ? 'scatterPay' : 'lineWins', + wins: ev.wins, multiplier: 1, credits: ev.total, + }); + totalWin += ev.total; + } + } + + // Scatter pays + free spin awards. + let freeSpinsAwarded = 0; + const sc = findScatters(machine, grid); + if (sc.count > 0 && machine.scatter) { + let credits = 0; + if (machine.scatter.pays) { + const tier = lookupTier(machine.scatter.pays, sc.count); + if (tier) credits = Math.round(tier.pay * bet); + } + if (machine.scatter.freeSpins && (!freeSpin || machine.scatter.retrigger)) { + const fsTier = lookupTier(machine.scatter.freeSpins, sc.count); + if (fsTier) freeSpinsAwarded = fsTier.pay; + } + if (credits > 0 || freeSpinsAwarded > 0) { + steps.push({ + type: 'scatterAward', symbol: machine.scatter.symbol, + cells: sc.cells, credits, freeSpins: freeSpinsAwarded, + }); + totalWin += credits; + } + } + if (freeSpinsAwarded > 0) { + if (state.freeSpins) { + state.freeSpins.remaining += freeSpinsAwarded; + state.freeSpins.total += freeSpinsAwarded; + } else { + state.freeSpins = { remaining: freeSpinsAwarded, total: freeSpinsAwarded }; + } + state.mode = 'freespins'; + } else if (freeSpin && state.freeSpins.remaining <= 0) { + state.mode = 'base'; + state.freeSpins = null; + } + + // Hold-and-spin trigger (Dragon's Hoard). + let holdSpinTriggered = false; + if (holdSpinCfg && coinCells.length >= holdSpinCfg.trigger) { + holdSpinTriggered = true; + state.mode = 'holdspin'; + state.holdSpin = { + locked: coinCells.map(({ c, r, value, tier }) => ({ c, r, value, tier })), + respinsLeft: holdSpinCfg.respins, + }; + } + + // Sticky wilds: age the existing ones, then add this spin's natural landings. + // Placement/refresh must read the NATURAL strip symbol — the override grid + // always shows a wild at a sticky cell, which would refresh stickies forever. + let pickBonusTriggered = false; + if (stickyCfg) { + const expiring = []; + state.stickyWilds = state.stickyWilds.filter((s) => { + s.spinsLeft--; + if (s.spinsLeft <= 0) { expiring.push({ c: s.c, r: s.r }); return false; } + return true; + }); + const placed = []; + for (let c = 0; c < grid.length; c++) { + const strip = machine.strips[c]; + for (let r = 0; r < grid[c].length; r++) { + if (strip[(stops[c] + r) % strip.length] !== stickyCfg.symbol) continue; + const existing = state.stickyWilds.find((s) => s.c === c && s.r === r); + if (existing) existing.spinsLeft = stickyCfg.spins; + else { + state.stickyWilds.push({ c, r, spinsLeft: stickyCfg.spins }); + placed.push({ c, r }); + } + } + } + if (placed.length > 0 || expiring.length > 0) { + steps.push({ type: 'stickyWilds', placed, expiring }); + } + } + if (machine.features.pickBonus && sc.count >= machine.features.pickBonus.min) { + pickBonusTriggered = true; + } + + // 4. Feature rolls (Fruit Frenzy): only on losing base spins. Holds are not + // offered on the spin right after holds were used. + let holdsOffered = false; + let nudgesAwarded = 0; + const hn = machine.features.holdNudge ?? null; + if (hn && state.mode === 'base' && totalWin === 0) { + if (!heldAny && rng() < hn.holdChance) { + holdsOffered = true; + state.holds.offered = true; + } else if (rng() < hn.nudgeChance) { + nudgesAwarded = weightedDraw(rng, hn.nudgeWeights); + state.nudgesLeft = nudgesAwarded; + } + } + + const sliver = holdSpinCfg?.grand?.sliver ?? 0; + state.grid = grid; + return { + stops, grid, steps, totalWin, + coinCells, + freeSpinsAwarded, + freeSpinsRemaining: state.freeSpins?.remaining ?? 0, + holdSpinTriggered, + pickBonusTriggered, + scatterCount: sc.count, + holdsOffered, + nudgesAwarded, + progressiveContribution: sliver > 0 && !freeSpin ? bet * sliver : 0, + }; +} + +// ── Fruit Frenzy: holds & nudges ────────────────────────────────────────────── +export function setHold(state, reelIdx, held) { + if (!state.holds.offered) return false; + state.holds.held[reelIdx] = held; + return true; +} + +// Nudge one reel down a single strip step and re-evaluate the grid. The caller +// settles the LAST evaluation when the player stops nudging. +export function applyNudge(state, reelIdx, bet) { + if (state.nudgesLeft <= 0) return null; + const { machine } = state; + const strip = machine.strips[reelIdx]; + state.nudgesLeft--; + state.stops[reelIdx] = (state.stops[reelIdx] + 1) % strip.length; + const grid = gridFromStops(machine, state.stops); + state.grid = grid; + const ev = evaluateGrid(machine, grid, bet); + return { grid, wins: ev.wins, totalWin: ev.total, nudgesLeft: state.nudgesLeft }; +} + +// Peek at what a nudge on reelIdx would produce, without committing. +export function peekNudge(state, reelIdx, bet) { + const { machine } = state; + const strip = machine.strips[reelIdx]; + const stops = state.stops.slice(); + stops[reelIdx] = (stops[reelIdx] + 1) % strip.length; + const grid = gridFromStops(machine, stops); + return evaluateGrid(machine, grid, bet); +} + +// ── Dragon's Hoard: hold-and-spin ───────────────────────────────────────────── +export function holdSpinRespin(state, bet) { + const cfg = state.machine.features.holdSpin; + const hs = state.holdSpin; + const { reels, rows } = state.machine.layout; + const lockedSet = new Set(hs.locked.map((l) => key(l.c, l.r))); + const newCoins = []; + + for (let c = 0; c < reels; c++) { + for (let r = 0; r < rows; r++) { + if (lockedSet.has(key(c, r))) continue; + if (state.rng() < cfg.coinChance) { + const drawn = weightedDraw(state.rng, cfg.coinValues); + const coin = typeof drawn === 'number' + ? { c, r, value: drawn } : { c, r, tier: drawn }; + hs.locked.push(coin); + newCoins.push(coin); + } + } + } + if (newCoins.length > 0) hs.respinsLeft = cfg.respins; + else hs.respinsLeft--; + + const full = hs.locked.length >= reels * rows; + const finished = full || hs.respinsLeft <= 0; + let award = null; + if (finished) { + let credits = 0; + const jackpots = []; + for (const coin of hs.locked) { + if (coin.value !== undefined) credits += coin.value * bet; + else { + const amount = cfg.jackpots[coin.tier] * bet; + credits += amount; + jackpots.push({ tier: coin.tier, amount }); + } + } + award = { credits, jackpots, grandHit: full }; + state.mode = 'base'; + state.holdSpin = null; + } + return { newCoins, respinsLeft: finished ? 0 : hs.respinsLeft, finished, award }; +} + +export function grandSliver(machine, bet) { + return (machine.features.holdSpin?.grand?.sliver ?? 0) * bet; +} + +// ── Gold Rush: pick-a-cart bonus ────────────────────────────────────────────── +export function createPickBonus(machine, bet, rng) { + const cfg = machine.features.pickBonus; + const prizes = []; + for (let i = 0; i < cfg.carts; i++) { + prizes.push(Math.round(weightedDraw(rng, cfg.prizes) * bet)); + } + return { prizes, picksLeft: cfg.picks, picked: [], total: 0 }; +} + +export function pick(bonus, idx) { + if (bonus.picksLeft <= 0 || bonus.picked.includes(idx)) return null; + bonus.picked.push(idx); + bonus.picksLeft--; + const prize = bonus.prizes[idx]; + bonus.total += prize; + return { prize, done: bonus.picksLeft <= 0, totalSoFar: bonus.total }; +} diff --git a/public/src/games/slots/SlotsReels.js b/public/src/games/slots/SlotsReels.js new file mode 100644 index 0000000..584846c --- /dev/null +++ b/public/src/games/slots/SlotsReels.js @@ -0,0 +1,282 @@ +// ReelView — masked spinning reel columns for SlotsGame. +// +// Each reel is a recycled stack of rows+2 Images windowed by a shared geometry +// mask. Spin physics: accelerate → constant scroll (with a scaleY/alpha blur +// fake) → engine-supplied stop with a surge-out ease and settle bounce. +// Anticipation reels get a long, pulsing slow-stop. Liberty Belle uses stepped +// notch stops. The view only ever displays what the engine decided. + +import { textureKeyFor } from './SlotsSymbols.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; + +const SPIN_SPEED = 0.024; // cells per ms at full speed +const ACCEL_MS = 160; +const STAGGER_MS = 220; +const ANTICIPATION_MS = 1500; + +export class ReelView { + constructor(scene, machine, { x, y, cellW, cellH, gap = 10, depth = 10 }) { + this.scene = scene; + this.machine = machine; + this.x = x; this.y = y; + this.cellW = cellW; this.cellH = cellH; this.gap = gap; + this.depth = depth; + this.rows = machine.layout.rows; + this.nReels = machine.strips.length; + this.spinningCount = 0; + this.overlays = []; // value texts pinned to cells + this.stickyImgs = []; // "stuck to the glass" wild overlays + + const w = this.width(); const h = this.height(); + + // Reel backplates + window frame. + const g = scene.add.graphics().setDepth(depth - 2); + g.fillStyle(0x000000, 0.85); + g.fillRoundedRect(x - 10, y - 10, w + 20, h + 20, 18); + for (let c = 0; c < this.nReels; c++) { + g.fillStyle(machine.theme.reelBg, 1); + g.fillRoundedRect(this.reelX(c), y, cellW, h, 10); + } + this.frame = g; + + // Shared window mask. + const maskG = scene.make.graphics({ add: false }); + maskG.fillStyle(0xffffff); + maskG.fillRect(x, y, w, h); + this.mask = maskG.createGeometryMask(); + this.maskG = maskG; + + // Per-reel image stacks. + this.reels = []; + for (let c = 0; c < this.nReels; c++) { + const imgs = []; + for (let i = -1; i <= this.rows; i++) { + const img = scene.add.image(this.reelX(c) + cellW / 2, 0, '__DEFAULT') + .setDepth(depth).setMask(this.mask); + imgs.push(img); + } + this.reels.push({ + imgs, pos: 0, speed: 0, spinning: false, + bounce: 0, blur: 0, lastNotch: 0, + }); + this.applyReel(c); + } + + // Shade strips top/bottom for reel curvature. + const shade = scene.add.graphics().setDepth(depth + 1).setMask(this.mask); + shade.fillGradientStyle(0x000000, 0x000000, 0x000000, 0x000000, 0.45, 0.45, 0, 0); + shade.fillRect(x, y, w, Math.min(34, h * 0.18)); + shade.fillGradientStyle(0x000000, 0x000000, 0x000000, 0x000000, 0, 0, 0.45, 0.45); + shade.fillRect(x, y + h - Math.min(34, h * 0.18), w, Math.min(34, h * 0.18)); + this.shade = shade; + + this.updateHandler = (time, delta) => this.update(delta); + scene.events.on('update', this.updateHandler); + scene.events.once('shutdown', () => this.destroy(true)); + } + + width() { return this.nReels * this.cellW + (this.nReels - 1) * this.gap; } + height() { return this.rows * this.cellH; } + reelX(c) { return this.x + c * (this.cellW + this.gap); } + cellCenter(c, r) { + return { x: this.reelX(c) + this.cellW / 2, y: this.y + r * this.cellH + this.cellH / 2 }; + } + image(c, r) { return this.reels[c].imgs[r + 1]; } + + symKey(symbolId) { return textureKeyFor(this.scene, this.machine.id, symbolId); } + + // Position + texture every image of one reel from its float strip pos. + applyReel(c) { + const reel = this.reels[c]; + const strip = this.machine.strips[c]; + const len = strip.length; + const top = Math.floor(reel.pos); + const frac = reel.pos - top; + for (let i = -1; i <= this.rows; i++) { + const img = reel.imgs[i + 1]; + const stripIdx = ((top + i) % len + len) % len; + const key = this.symKey(strip[stripIdx]); + if (img.texture.key !== key) { + img.setTexture(key); + img.setDisplaySize(this.cellW * 0.88, this.cellH * 0.88); + img.baseScaleX = img.scaleX; img.baseScaleY = img.scaleY; + } + img.y = this.y + (i + frac) * this.cellH + this.cellH / 2 + reel.bounce; + const blur = reel.blur; + img.scaleY = (img.baseScaleY ?? img.scaleY) * (1 + blur * 0.55); + img.scaleX = img.baseScaleX ?? img.scaleX; + img.alpha = 1 - blur * 0.3; + } + } + + update(delta) { + for (let c = 0; c < this.nReels; c++) { + const reel = this.reels[c]; + if (!reel.spinning) continue; + const len = this.machine.strips[c].length; + reel.pos = ((reel.pos - reel.speed * delta) % len + len) % len; + this.applyReel(c); + } + } + + // Show a resolved grid instantly (used after spins/tumbles to make the + // canonical engine grid authoritative on screen). + showGrid(grid) { + for (let c = 0; c < this.nReels; c++) { + const reel = this.reels[c]; + reel.spinning = false; reel.speed = 0; reel.blur = 0; reel.bounce = 0; + const strip = this.machine.strips[c]; + const len = strip.length; + for (let i = -1; i <= this.rows; i++) { + const img = reel.imgs[i + 1]; + const inWindow = i >= 0 && i < this.rows; + const symbolId = inWindow + ? grid[c][i] + : strip[((Math.floor(reel.pos) + i) % len + len) % len]; + img.setTexture(this.symKey(symbolId)); + img.setDisplaySize(this.cellW * 0.88, this.cellH * 0.88); + img.baseScaleX = img.scaleX; img.baseScaleY = img.scaleY; + img.alpha = 1; + img.y = this.y + i * this.cellH + this.cellH / 2; + } + } + } + + // Spin all reels to the given stops. Resolves when the last reel settles. + // opts: { anticipationFrom: reelIndex|null, stepped: bool, held: bool[] } + spin(stops, opts = {}) { + const { anticipationFrom = null, stepped = false, held = [] } = opts; + this.clearOverlays(); + return new Promise((resolve) => { + let remaining = 0; + let extra = 0; + for (let c = 0; c < this.nReels; c++) { + if (held[c]) continue; + remaining++; + const anticipate = anticipationFrom !== null && c >= anticipationFrom; + if (anticipate) extra += ANTICIPATION_MS; + const stopDelay = 520 + c * STAGGER_MS + (anticipate ? extra : 0); + this.startReel(c, stops[c], stopDelay, stepped, anticipate, () => { + playSound(this.scene, SFX.PIECE_CLICK); + if (this.onReelStop) this.onReelStop(c, anticipate); + if (--remaining === 0) resolve(); + }); + } + if (remaining === 0) resolve(); + }); + } + + startReel(c, stop, stopDelay, stepped, anticipate, done) { + const scene = this.scene; + const reel = this.reels[c]; + const len = this.machine.strips[c].length; + reel.spinning = true; + + // Accelerate into the blur. + scene.tweens.add({ + targets: reel, speed: SPIN_SPEED, blur: 1, + duration: ACCEL_MS, ease: 'Quad.in', + }); + + scene.time.delayedCall(stopDelay, () => { + // Hand over from free-running scroll to a deterministic surge-out stop. + reel.spinning = false; + reel.blur = 0; + const start = reel.pos; + let distance = ((start - stop) % len + len) % len; + distance += len * (stepped ? 1 : 2); // at least full wraps for drama + const carrier = { t: 0 }; + let lastStepNotch = 0; + scene.tweens.add({ + targets: carrier, t: 1, + duration: stepped ? Math.min(1500, 220 * Math.min(distance, 7)) : (anticipate ? 1150 : 680), + ease: stepped ? 'Stepped' : 'Cubic.easeOut', + easeParams: stepped ? [Math.min(distance, 10)] : undefined, + onUpdate: () => { + reel.pos = ((start - distance * carrier.t) % len + len) % len; + if (stepped) { + const notch = Math.floor(carrier.t * Math.min(distance, 10)); + if (notch !== lastStepNotch) { + lastStepNotch = notch; + playSound(scene, SFX.PIECE_CLICK); + } + } + this.applyReel(c); + }, + onComplete: () => { + reel.pos = stop; + this.applyReel(c); + // Settle bounce. + scene.tweens.add({ + targets: reel, bounce: 12, + duration: 90, yoyo: true, ease: 'Quad.out', + onUpdate: () => this.applyReel(c), + onComplete: () => { reel.bounce = 0; this.applyReel(c); done(); }, + }); + }, + }); + }); + } + + // Bump one reel a single notch down (Fruit Frenzy nudge). + nudge(c, stop) { + return new Promise((resolve) => { + const reel = this.reels[c]; + const len = this.machine.strips[c].length; + const start = reel.pos; + const carrier = { t: 0 }; + this.scene.tweens.add({ + targets: carrier, t: 1, duration: 200, ease: 'Back.easeOut', + onUpdate: () => { + reel.pos = ((start - carrier.t) % len + len) % len; + this.applyReel(c); + }, + onComplete: () => { + reel.pos = ((stop % len) + len) % len; + this.applyReel(c); + playSound(this.scene, SFX.PIECE_CLICK); + resolve(); + }, + }); + }); + } + + // ── Cell overlays (coin values, rainbow multipliers) ─────────────────────── + setOverlayText(c, r, str, style = {}) { + const { x, y } = this.cellCenter(c, r); + const t = this.scene.add.text(x, y + (style.dy ?? 0), str, { + fontFamily: 'Righteous', fontSize: `${style.size ?? 26}px`, + color: style.color ?? '#fff6d8', + stroke: '#14100a', strokeThickness: 5, + }).setOrigin(0.5).setDepth(this.depth + 3).setMask(this.mask); + this.overlays.push(t); + return t; + } + + clearOverlays() { + for (const t of this.overlays) t.destroy(); + this.overlays = []; + } + + // ── Sticky wilds pinned over the glass ────────────────────────────────────── + setSticky(cells, symbolId) { + for (const s of this.stickyImgs) s.destroy(); + this.stickyImgs = []; + for (const { c, r } of cells) { + const { x, y } = this.cellCenter(c, r); + const img = this.scene.add.image(x, y, this.symKey(symbolId)) + .setDepth(this.depth + 2); + img.setDisplaySize(this.cellW * 0.92, this.cellH * 0.92); + this.stickyImgs.push(img); + } + } + + destroy(fromShutdown = false) { + this.scene.events.off('update', this.updateHandler); + if (fromShutdown) return; // scene teardown destroys children itself + for (const reel of this.reels) for (const img of reel.imgs) img.destroy(); + this.clearOverlays(); + this.setSticky([], null); + this.frame.destroy(); this.shade.destroy(); this.maskG.destroy(); + } +} diff --git a/public/src/games/slots/SlotsSymbols.js b/public/src/games/slots/SlotsSymbols.js new file mode 100644 index 0000000..7d7b5ae --- /dev/null +++ b/public/src/games/slots/SlotsSymbols.js @@ -0,0 +1,597 @@ +// Procedural reel-symbol painters for Slot Machines. +// +// Every symbol is painted once into a 200×200 baked texture +// ('slots-sym-{machineId}-{symbolId}') via a RenderTexture, so reels and +// tumbles animate plain Images. If the user has supplied drop-in art +// ('slots-{machineId}-{symbolId}', loaded by PreloadScene from +// slots-artwork.json), textureKeyFor() prefers it — zero code changes. + +const S = 200; // texture size +const C = S / 2; // center + +export function dropInKey(machineId, symbolId) { return `slots-${machineId}-${symbolId}`; } +export function bakedKey(machineId, symbolId) { return `slots-sym-${machineId}-${symbolId}`; } + +export function textureKeyFor(scene, machineId, symbolId) { + const k = dropInKey(machineId, symbolId); + return scene.textures.exists(k) ? k : bakedKey(machineId, symbolId); +} + +export function allSymbolIds(machine) { + const ids = new Set(); + for (const strip of machine.strips) for (const s of strip) ids.add(s); + if (machine.wild) ids.add(machine.wild.symbol); + if (machine.features.rainbow) ids.add(machine.features.rainbow.symbol); + return [...ids]; +} + +// ── Drawing primitives ──────────────────────────────────────────────────────── +const P = (arr) => arr.map(([x, y]) => ({ x, y })); + +function starPts(cx, cy, spikes, outer, inner, rot = -Math.PI / 2) { + const out = []; + for (let i = 0; i < spikes * 2; i++) { + const r = i % 2 === 0 ? outer : inner; + const a = rot + (i * Math.PI) / spikes; + out.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); + } + return out; +} + +function heartPath(g, cx, cy, size, color) { + g.fillStyle(color, 1); + g.fillCircle(cx - size * 0.35, cy - size * 0.25, size * 0.4); + g.fillCircle(cx + size * 0.35, cy - size * 0.25, size * 0.4); + g.fillPoints(P([ + [cx - size * 0.72, cy - size * 0.08], + [cx + size * 0.72, cy - size * 0.08], + [cx, cy + size * 0.75], + ]), true); +} + +function barShape(g, cy, label, addText, color = 0x14100a, textColor = '#f6efe0') { + g.fillStyle(color, 1); + g.fillRoundedRect(C - 72, cy - 22, 144, 44, 10); + g.lineStyle(4, 0xc8a84b, 1); + g.strokeRoundedRect(C - 72, cy - 22, 144, 44, 10); + addText(label, { x: C, y: cy, size: 30, color: textColor, font: 'Righteous' }); +} + +// ── Painters ───────────────────────────────────────────────────────────────── +// Keyed '{machineId}/{symbolId}' with a '{symbolId}' fallback for shared art. +// Each receives ({ g, text, theme }). + +const PAINTERS = { + // ··· shared card royals ··· + ace: cardLetter('A'), king: cardLetter('K'), queen: cardLetter('Q'), jack: cardLetter('J'), + + // ··· Liberty Belle ··· + bell({ g }) { + g.fillStyle(0xd9a93b, 1); + g.fillCircle(C, 64, 16); + g.fillPoints(P([[C - 14, 64], [C + 14, 64], [C + 52, 138], [C - 52, 138]]), true); + g.fillCircle(C, 96, 44); + g.fillStyle(0xb07f1f, 1); + g.fillRoundedRect(C - 62, 132, 124, 16, 7); + g.fillStyle(0x7a5410, 1); + g.fillCircle(C, 158, 12); + g.fillStyle(0xf7e3a1, 0.8); + g.fillEllipse(C - 22, 86, 14, 34); + }, + horseshoe({ g }) { + g.lineStyle(26, 0x9aa3ad, 1); + g.beginPath(); + g.arc(C, 92, 56, Math.PI * 0.95, Math.PI * 2.05, false); + g.strokePath(); + g.lineStyle(26, 0x9aa3ad, 1); + g.beginPath(); g.moveTo(C - 54, 76); g.lineTo(C - 60, 142); g.strokePath(); + g.beginPath(); g.moveTo(C + 54, 76); g.lineTo(C + 60, 142); g.strokePath(); + g.fillStyle(0x5d666f, 1); + for (const [x, y] of [[C - 58, 138], [C + 58, 138], [C - 50, 60], [C + 50, 60], [C, 36]]) g.fillCircle(x, y, 6); + }, + star({ g }) { + g.fillStyle(0xe8b733, 1); + g.fillPoints(starPts(C, C + 6, 5, 74, 30), true); + g.fillStyle(0xfbe8a6, 0.85); + g.fillPoints(starPts(C, C + 6, 5, 36, 15), true); + }, + heart({ g }) { heartPath(g, C, C + 4, 92, 0xc0392b); g.fillStyle(0xf2b8ad, 0.7); g.fillCircle(C - 30, 72, 13); }, + diamond({ g }) { + g.fillStyle(0xd35400, 1); + g.fillPoints(P([[C, 26], [C + 64, C + 4], [C, 178], [C - 64, C + 4]]), true); + g.fillStyle(0xf8c471, 0.75); + g.fillPoints(P([[C, 52], [C + 34, C + 4], [C, 152], [C - 34, C + 4]]), true); + }, + spade({ g }) { + heartPath(g, C, 84, 84, 0x232b36); + // flip illusion: heart drawn upward + stem + g.fillStyle(0x232b36, 1); + g.fillPoints(P([[C - 7, 120], [C + 7, 120], [C + 22, 168], [C - 22, 168]]), true); + }, + + // ··· Fruit Frenzy ··· + cherry({ g }) { + g.lineStyle(8, 0x3f7a2a, 1); + g.beginPath(); g.moveTo(C - 26, 132); g.lineTo(C + 2, 44); g.strokePath(); + g.beginPath(); g.moveTo(C + 38, 124); g.lineTo(C + 2, 44); g.strokePath(); + g.fillStyle(0x4f9a34, 1); + g.fillEllipse(C + 26, 48, 44, 20); + g.fillStyle(0xc62b33, 1); + g.fillCircle(C - 30, 142, 30); + g.fillCircle(C + 42, 134, 30); + g.fillStyle(0xf0888d, 0.8); + g.fillCircle(C - 40, 132, 9); g.fillCircle(C + 32, 124, 9); + }, + lemon({ g }) { + g.fillStyle(0xeed02a, 1); + g.fillEllipse(C, C + 8, 132, 92); + g.fillCircle(C - 70, C + 8, 14); g.fillCircle(C + 70, C + 8, 14); + g.fillStyle(0xfbf0a0, 0.7); g.fillEllipse(C - 26, C - 8, 38, 20); + }, + orange({ g }) { + g.fillStyle(0xe87f1e, 1); g.fillCircle(C, C + 10, 62); + g.fillStyle(0x4f9a34, 1); g.fillEllipse(C + 24, 48, 40, 18); + g.fillStyle(0xf6b66e, 0.7); g.fillCircle(C - 22, C - 8, 16); + }, + plum({ g }) { + g.fillStyle(0x6e3a8c, 1); g.fillCircle(C, C + 10, 60); + g.lineStyle(5, 0x4d2363, 1); + g.beginPath(); g.arc(C - 6, C + 10, 52, -Math.PI * 0.45, Math.PI * 0.45); g.strokePath(); + g.fillStyle(0x4f9a34, 1); g.fillEllipse(C + 22, 46, 38, 16); + g.fillStyle(0xb88ed1, 0.7); g.fillCircle(C - 24, C - 6, 14); + }, + melon({ g }) { + g.fillStyle(0x2f7a33, 1); + g.slice(C, 64, 92, 0, Math.PI, false); g.fillPath(); + g.fillStyle(0xe8536a, 1); + g.slice(C, 64, 76, 0, Math.PI, false); g.fillPath(); + g.fillStyle(0x21262b, 1); + for (const [x, y] of [[C - 36, 96], [C, 116], [C + 36, 96], [C - 14, 84], [C + 16, 88]]) { + g.fillEllipse(x, y, 8, 13); + } + }, + bar({ g, text }) { barShape(g, C - 34, 'BAR', text); barShape(g, C + 34, 'BAR', text); }, + + // ··· Lucky Sevens ··· + 'lucky-sevens/wild': ({ g, text }) => { + g.fillStyle(0x101319, 1); + g.fillPoints(P([[C, 30], [C + 70, C], [C, 170], [C - 70, C]]), true); + g.lineStyle(6, 0x8fd8ff, 1); + g.strokePoints(P([[C, 42], [C + 56, C], [C, 158], [C - 56, C], [C, 42]]), true); + g.lineStyle(3, 0x8fd8ff, 0.7); + g.strokePoints(P([[C, 66], [C + 32, C], [C, 134], [C - 32, C], [C, 66]]), true); + text('WILD', { x: C, y: C, size: 26, color: '#8fd8ff', font: 'Righteous' }); + }, + 'seven-red': seven('#d4313f'), + 'seven-white': seven('#f4f1e8'), + 'seven-blue': seven('#4f8fe0'), + bar1({ g, text }) { barShape(g, C, 'BAR', text); }, + bar2({ g, text }) { barShape(g, C - 28, 'BAR', text); barShape(g, C + 28, 'BAR', text); }, + bar3({ g, text }) { barShape(g, C - 52, 'BAR', text); barShape(g, C, 'BAR', text); barShape(g, C + 52, 'BAR', text); }, + blank({ g }) { g.fillStyle(0x000000, 0.06); g.fillRoundedRect(C - 50, C - 6, 100, 12, 6); }, + + // ··· Pharaoh's Fortune ··· + pharaoh({ g, text }) { + g.fillStyle(0xe6b94d, 1); + g.fillRoundedRect(C - 44, 40, 88, 96, 18); + g.fillPoints(P([[C - 44, 60], [C - 72, 130], [C - 44, 122]]), true); + g.fillPoints(P([[C + 44, 60], [C + 72, 130], [C + 44, 122]]), true); + g.fillStyle(0x1c2c4e, 1); + for (let i = 0; i < 3; i++) g.fillRect(C - 44 + 10, 48 + i * 16, 68, 7); + g.fillStyle(0x18120a, 1); + g.fillEllipse(C - 18, 104, 18, 9); g.fillEllipse(C + 18, 104, 18, 9); + g.fillStyle(0xe6b94d, 1); + g.fillRoundedRect(C - 12, 130, 24, 34, 8); + text('WILD', { x: C, y: 182, size: 22, color: '#ffd86b', font: 'Righteous' }); + }, + pyramid({ g, text }) { + g.fillStyle(0xc99b3f, 1); + g.fillPoints(P([[C, 34], [C + 78, 148], [C - 78, 148]]), true); + g.fillStyle(0xf7df9a, 1); + g.fillPoints(P([[C, 34], [C + 22, 66], [C - 22, 66]]), true); + g.lineStyle(3, 0x8a6722, 0.8); + for (let i = 1; i < 4; i++) { + const y = 66 + i * 22; const w = 22 + i * 19; + g.beginPath(); g.moveTo(C - w, y); g.lineTo(C + w, y); g.strokePath(); + } + text('BONUS', { x: C, y: 174, size: 22, color: '#ffd86b', font: 'Righteous' }); + }, + sphinx({ g }) { + g.fillStyle(0xd2a648, 1); + g.fillPoints(P([[40, 150], [40, 120], [78, 112], [86, 70], [122, 70], [128, 112], [164, 118], [164, 150]]), true); + g.fillRoundedRect(78, 52, 48, 52, 10); + g.fillStyle(0x1c2c4e, 1); + g.fillRect(82, 60, 8, 36); g.fillRect(114, 60, 8, 36); + g.fillStyle(0x18120a, 1); + g.fillEllipse(96, 78, 7, 4); g.fillEllipse(110, 78, 7, 4); + }, + ankh({ g }) { + g.lineStyle(20, 0xe6b94d, 1); + g.strokeEllipse(C, 64, 52, 64); + g.beginPath(); g.moveTo(C, 96); g.lineTo(C, 170); g.strokePath(); + g.beginPath(); g.moveTo(C - 44, 118); g.lineTo(C + 44, 118); g.strokePath(); + }, + eye({ g }) { + g.lineStyle(11, 0x3f6fc4, 1); + g.beginPath(); g.arc(C, C - 4, 56, Math.PI * 1.15, Math.PI * 1.85); g.strokePath(); + g.beginPath(); g.arc(C, C - 30, 60, Math.PI * 0.12, Math.PI * 0.88); g.strokePath(); + g.fillStyle(0x18120a, 1); g.fillCircle(C, C - 8, 20); + g.fillStyle(0x3f6fc4, 1); g.fillCircle(C, C - 8, 11); + g.lineStyle(9, 0x3f6fc4, 1); + g.beginPath(); g.moveTo(C - 34, C + 28); g.lineTo(C - 48, C + 64); g.strokePath(); + g.beginPath(); g.moveTo(C + 30, C + 26); g.lineTo(C + 34, C + 58); g.strokePath(); + g.beginPath(); g.arc(C + 18, C + 58, 16, Math.PI * 1.5, Math.PI * 0.8); g.strokePath(); + }, + cat({ g }) { + g.fillStyle(0x1b1410, 1); + g.fillPoints(P([[C - 26, 56], [C - 32, 24], [C - 8, 44]]), true); + g.fillPoints(P([[C + 26, 56], [C + 32, 24], [C + 8, 44]]), true); + g.fillCircle(C, 64, 30); + g.fillEllipse(C, 130, 64, 76); + g.fillStyle(0xe6b94d, 1); + g.fillRoundedRect(C - 26, 88, 52, 10, 5); + g.fillStyle(0x57f287, 1); + g.fillEllipse(C - 12, 60, 8, 11); g.fillEllipse(C + 12, 60, 8, 11); + }, + + // ··· Abyssal Treasures ··· + pearl({ g, text }) { + g.fillStyle(0x2b5e6e, 1); + g.slice(C, 128, 74, Math.PI, Math.PI * 2, false); g.fillPath(); + g.lineStyle(5, 0x59e7ff, 0.8); + for (let i = -2; i <= 2; i++) { + g.beginPath(); g.moveTo(C, 128); g.lineTo(C + i * 30, 62); g.strokePath(); + } + g.fillStyle(0xf2f6f4, 1); g.fillCircle(C, 108, 30); + g.fillStyle(0xffffff, 0.9); g.fillCircle(C - 10, 98, 9); + text('WILD', { x: C, y: 176, size: 22, color: '#59e7ff', font: 'Righteous' }); + }, + mermaid({ g }) { + g.fillStyle(0x2fae9b, 1); + g.fillPoints(P([[C - 8, 30], [C + 26, 78], [C + 10, 128], [C - 26, 96]]), true); + g.fillPoints(P([[C - 2, 110], [C - 44, 168], [C - 6, 156], [C + 6, 176], [C + 26, 142]]), true); + g.lineStyle(4, 0x9af0e2, 0.8); + for (let i = 0; i < 3; i++) { + g.beginPath(); g.arc(C - 2 + i * 4, 66 + i * 22, 16, Math.PI * 0.15, Math.PI * 0.85); g.strokePath(); + } + }, + octopus({ g }) { + g.fillStyle(0x8857b8, 1); + g.slice(C, 90, 50, Math.PI, Math.PI * 2, false); g.fillPath(); + g.fillRect(C - 50, 88, 100, 14); + g.lineStyle(14, 0x8857b8, 1); + for (const [dx, sw] of [[-38, 1], [-13, -1], [13, 1], [38, -1]]) { + g.beginPath(); + g.moveTo(C + dx, 100); + g.lineTo(C + dx + sw * 4, 130); + g.arc(C + dx + sw * 18, 134, 15, Math.PI, Math.PI * (sw > 0 ? 1.9 : 0.1), sw < 0); + g.strokePath(); + } + g.fillStyle(0xf2f6f4, 1); g.fillCircle(C - 18, 76, 11); g.fillCircle(C + 18, 76, 11); + g.fillStyle(0x18120a, 1); g.fillCircle(C - 16, 78, 5); g.fillCircle(C + 20, 78, 5); + }, + seahorse({ g }) { + g.lineStyle(22, 0xe8913e, 1); + g.beginPath(); g.arc(C + 6, 70, 30, Math.PI * 1.1, Math.PI * 2.2); g.strokePath(); + g.beginPath(); g.arc(C - 4, 124, 28, Math.PI * 0.3, Math.PI * 1.35, true); g.strokePath(); + g.lineStyle(14, 0xe8913e, 1); + g.beginPath(); g.arc(C + 18, 152, 18, Math.PI * 1.2, Math.PI * 0.5); g.strokePath(); + g.fillStyle(0xe8913e, 1); + g.fillPoints(P([[C + 28, 46], [C + 58, 38], [C + 36, 62]]), true); + g.fillStyle(0x18120a, 1); g.fillCircle(C + 16, 56, 6); + }, + angler({ g }) { + g.fillStyle(0x27445c, 1); + g.fillEllipse(C, 118, 120, 84); + g.fillPoints(P([[C + 52, 118], [C + 88, 92], [C + 88, 144]]), true); + g.fillStyle(0xf2f6f4, 1); + g.fillPoints(P([[C - 52, 112], [C - 34, 112], [C - 43, 130]]), true); + g.fillPoints(P([[C - 28, 114], [C - 10, 114], [C - 19, 132]]), true); + g.lineStyle(5, 0xbfe85a, 1); + g.beginPath(); g.moveTo(C - 30, 84); g.arc(C - 52, 62, 26, Math.PI * 0.3, Math.PI * 1.2, true); g.strokePath(); + g.fillStyle(0xeaff6e, 1); g.fillCircle(C - 74, 52, 12); + g.fillStyle(0xeaff6e, 0.25); g.fillCircle(C - 74, 52, 24); + g.fillStyle(0xf2f6f4, 1); g.fillCircle(C + 6, 96, 10); + g.fillStyle(0x18120a, 1); g.fillCircle(C + 8, 98, 5); + }, + starfish({ g }) { + g.fillStyle(0xee7d68, 1); + g.fillPoints(starPts(C, C, 5, 80, 34), true); + g.fillStyle(0xf8c5ae, 0.9); + for (const { x, y } of starPts(C, C, 5, 48, 48)) g.fillCircle(x, y, 6); + g.fillCircle(C, C, 8); + }, + shell({ g }) { + g.fillStyle(0xefb9a2, 1); + g.slice(C, 150, 86, Math.PI * 1.08, Math.PI * 1.92, false); g.fillPath(); + g.lineStyle(5, 0xc4815f, 1); + for (let i = -2; i <= 2; i++) { + g.beginPath(); g.moveTo(C, 150); g.lineTo(C + i * 32, 70 + Math.abs(i) * 12); g.strokePath(); + } + g.fillStyle(0xc4815f, 1); g.fillRoundedRect(C - 18, 146, 36, 18, 8); + }, + coral({ g }) { + g.lineStyle(16, 0xd64f8e, 1); + g.beginPath(); g.moveTo(C, 170); g.lineTo(C, 110); g.lineTo(C - 30, 70); g.strokePath(); + g.beginPath(); g.moveTo(C, 122); g.lineTo(C + 36, 80); g.lineTo(C + 36, 48); g.strokePath(); + g.beginPath(); g.moveTo(C - 30, 70); g.lineTo(C - 54, 44); g.strokePath(); + g.beginPath(); g.moveTo(C - 30, 70); g.lineTo(C - 6, 38); g.strokePath(); + g.fillStyle(0xf08ab6, 1); + for (const [x, y] of [[C - 54, 44], [C - 6, 38], [C + 36, 48]]) g.fillCircle(x, y, 10); + }, + bubble({ g }) { + g.fillStyle(0x6fc4e8, 0.25); g.fillCircle(C, C, 62); + g.lineStyle(7, 0x9fdcf2, 1); g.strokeCircle(C, C, 62); + g.fillStyle(0xffffff, 0.85); g.fillEllipse(C - 24, C - 26, 18, 12); + g.fillStyle(0x6fc4e8, 0.4); g.fillCircle(C + 38, C + 44, 12); + }, + + // ··· Dragon's Hoard ··· + dragon({ g, text }) { + g.fillStyle(0xb83232, 1); + g.fillPoints(P([[C - 70, 96], [C - 4, 58], [C + 64, 76], [C + 64, 108], [C - 2, 132], [C - 36, 120]]), true); + g.fillPoints(P([[C - 4, 58], [C + 10, 24], [C + 30, 60]]), true); // horn + g.fillPoints(P([[C + 28, 60], [C + 52, 36], [C + 56, 70]]), true); // horn 2 + g.fillStyle(0xe8483f, 1); + g.fillPoints(P([[C - 70, 96], [C - 36, 120], [C - 58, 128]]), true); // jaw + g.fillStyle(0xffd34d, 1); g.fillEllipse(C + 18, 84, 18, 12); + g.fillStyle(0x18120a, 1); g.fillEllipse(C + 18, 84, 5, 10); + text('WILD', { x: C, y: 168, size: 24, color: '#ff9d4d', font: 'Righteous' }); + }, + coin({ g }) { + g.fillStyle(0x8a5e12, 1); g.fillCircle(C, C, 70); + g.fillStyle(0xf0c441, 1); g.fillCircle(C, C - 4, 66); + g.lineStyle(5, 0xb8860b, 1); g.strokeCircle(C, C - 4, 50); + g.fillStyle(0xb8860b, 1); + g.fillPoints(starPts(C, C - 4, 4, 34, 12, 0), true); + g.fillStyle(0xfbe8a6, 0.9); g.fillEllipse(C - 26, C - 30, 16, 9); + }, + knight({ g }) { + g.fillStyle(0x8d99ae, 1); + g.slice(C, 96, 52, Math.PI, Math.PI * 2, false); g.fillPath(); + g.fillRect(C - 52, 94, 104, 38); + g.fillStyle(0x18120a, 1); + g.fillRect(C - 40, 100, 80, 9); + for (let i = -1; i <= 1; i++) g.fillRect(C + i * 22 - 3, 112, 6, 16); + g.fillStyle(0xe8483f, 1); + g.fillPoints(P([[C - 4, 44], [C + 4, 44], [C + 18, 14], [C - 18, 14]]), true); + }, + castle({ g }) { + g.fillStyle(0x9aa3ad, 1); + g.fillRect(C - 64, 84, 128, 80); + g.fillRect(C - 78, 64, 30, 100); g.fillRect(C + 48, 64, 30, 100); + for (const x of [C - 78, C - 64, C - 24, C + 8, C + 48, C + 64]) g.fillRect(x, 48, 16, 18); + g.fillStyle(0x3b3f46, 1); + g.slice(C, 164, 26, Math.PI, Math.PI * 2, false); g.fillPath(); + g.fillRect(C - 26, 162, 52, 4); + g.fillStyle(0x18120a, 0.8); + g.fillRect(C - 66, 96, 10, 16); g.fillRect(C + 56, 96, 10, 16); + }, + chest({ g }) { + g.fillStyle(0x7a4a22, 1); + g.fillRoundedRect(C - 64, 92, 128, 70, 10); + g.slice(C, 92, 64, Math.PI, Math.PI * 2, false); g.fillPath(); + g.fillStyle(0xf0c441, 1); + g.fillRect(C - 64, 86, 128, 12); + g.fillRect(C - 10, 40, 20, 122); + g.fillRoundedRect(C - 16, 100, 32, 30, 6); + g.fillStyle(0x18120a, 1); g.fillCircle(C, 112, 7); + g.fillStyle(0xfbe8a6, 1); + for (const [x, y] of [[C - 40, 38], [C + 38, 42], [C - 6, 26]]) g.fillCircle(x, y, 7); + }, + gem({ g }) { + g.fillStyle(0x2e9e63, 1); + g.fillPoints(P([[C - 56, 78], [C - 28, 48], [C + 28, 48], [C + 56, 78], [C, 156]]), true); + g.lineStyle(4, 0x9ff2c6, 0.9); + g.strokePoints(P([[C - 56, 78], [C + 56, 78]]), false); + g.strokePoints(P([[C - 28, 48], [C - 18, 78], [C, 156]]), false); + g.strokePoints(P([[C + 28, 48], [C + 18, 78], [C, 156]]), false); + }, + + // ··· Gold Rush Gulch ··· + bandit({ g, text }) { + g.fillStyle(0x2b211a, 1); + g.fillEllipse(C, 70, 124, 20); + g.fillRoundedRect(C - 38, 30, 76, 44, 14); + g.fillStyle(0xd9b48a, 1); + g.slice(C, 76, 38, 0, Math.PI, false); g.fillPath(); + g.fillStyle(0xb8392e, 1); + g.fillPoints(P([[C - 38, 92], [C + 38, 92], [C + 20, 146], [C - 20, 146]]), true); + g.fillStyle(0xf2e3c8, 1); + for (const [x, y] of [[C - 16, 104], [C + 14, 104], [C - 2, 122]]) g.fillCircle(x, y, 4); + g.fillStyle(0x18120a, 1); + g.fillEllipse(C - 15, 82, 12, 7); g.fillEllipse(C + 15, 82, 12, 7); + text('WILD', { x: C, y: 176, size: 24, color: '#ffe27a', font: 'Righteous' }); + }, + dynamite({ g, text }) { + g.fillStyle(0xb83232, 1); + for (const dx of [-30, 0, 30]) g.fillRoundedRect(C + dx - 13, 64, 26, 96, 9); + g.lineStyle(4, 0x7a1c1c, 1); + for (const dx of [-30, 0, 30]) g.strokeRoundedRect(C + dx - 13, 64, 26, 96, 9); + g.lineStyle(5, 0xc8a84b, 1); + g.beginPath(); g.moveTo(C, 64); g.arc(C + 22, 44, 22, Math.PI, Math.PI * 2.4); g.strokePath(); + g.fillStyle(0xffe27a, 1); + g.fillPoints(starPts(C + 44, 36, 4, 16, 6, 0), true); + text('BONUS', { x: C, y: 182, size: 20, color: '#ffe27a', font: 'Righteous' }); + }, + sheriff({ g }) { + g.fillStyle(0xf0c441, 1); + g.fillPoints(starPts(C, C, 6, 78, 46), true); + for (const { x, y } of starPts(C, C, 6, 78, 78)) g.fillCircle(x, y, 9); + g.lineStyle(5, 0xa87f17, 1); g.strokeCircle(C, C, 34); + }, + saloon({ g }) { + g.fillStyle(0x6e4c24, 1); + g.fillRect(C - 70, 44, 140, 22); + g.fillStyle(0x8a6132, 1); + for (const side of [-1, 1]) { + g.fillRoundedRect(C + (side === -1 ? -62 : 10), 78, 52, 84, 8); + } + g.fillStyle(0x55391a, 1); + for (const side of [-1, 1]) { + const x = C + (side === -1 ? -54 : 18); + g.fillRoundedRect(x, 88, 36, 28, 5); + g.fillRoundedRect(x, 124, 36, 28, 5); + } + g.lineStyle(6, 0x55391a, 1); + g.beginPath(); g.moveTo(C - 78, 66); g.lineTo(C - 78, 170); g.strokePath(); + g.beginPath(); g.moveTo(C + 78, 66); g.lineTo(C + 78, 170); g.strokePath(); + }, + wagon({ g }) { + g.fillStyle(0x5d4324, 1); + g.fillPoints(P([[C - 68, 78], [C + 68, 78], [C + 46, 138], [C - 46, 138]]), true); + g.fillStyle(0xf0c441, 1); + for (const [x, y] of [[C - 30, 72], [C, 64], [C + 30, 72], [C - 14, 60], [C + 16, 58]]) g.fillCircle(x, y, 13); + g.fillStyle(0x2b211a, 1); + g.fillCircle(C - 34, 152, 16); g.fillCircle(C + 34, 152, 16); + g.fillStyle(0x9aa3ad, 1); + g.fillCircle(C - 34, 152, 6); g.fillCircle(C + 34, 152, 6); + }, + pickaxe({ g }) { + g.lineStyle(14, 0x8a6132, 1); + g.beginPath(); g.moveTo(C - 36, 160); g.lineTo(C + 36, 48); g.strokePath(); + g.lineStyle(18, 0x9aa3ad, 1); + g.beginPath(); g.arc(C + 12, 92, 62, Math.PI * 1.25, Math.PI * 1.95); g.strokePath(); + }, + + // ··· Sugar Spin ··· + rainbow({ g }) { + const cols = [0xe8483f, 0xf0a541, 0x57c46a, 0x4f8fe0, 0x8857b8]; + cols.forEach((col, i) => { + g.lineStyle(13, col, 1); + g.beginPath(); g.arc(C, 128, 78 - i * 13, Math.PI, Math.PI * 2); g.strokePath(); + }); + g.fillStyle(0xffffff, 1); + g.fillPoints(starPts(C - 58, 146, 4, 16, 6, 0), true); + g.fillPoints(starPts(C + 58, 146, 4, 16, 6, 0), true); + }, + chocolate({ g }) { + g.fillStyle(0x5d3a1e, 1); + g.fillRoundedRect(C - 58, C - 58, 116, 116, 14); + g.fillStyle(0x7a4f2a, 1); + for (const dx of [-29, 29]) for (const dy of [-29, 29]) { + g.fillRoundedRect(C + dx - 22, C + dy - 22, 44, 44, 8); + } + g.fillStyle(0xa5764a, 0.7); g.fillRoundedRect(C - 51, C - 51, 30, 14, 7); + }, + 'candy-cane': ({ g }) => { + g.lineStyle(30, 0xf2ece2, 1); + g.beginPath(); g.moveTo(C - 18, 168); g.lineTo(C - 18, 78); g.arc(C + 12, 78, 30, Math.PI, 0); g.lineTo(C + 42, 100); g.strokePath(); + g.lineStyle(30, 0xd4313f, 1); + for (const [y1, y2] of [[150, 130], [110, 92]]) { + g.beginPath(); g.moveTo(C - 18, y1); g.lineTo(C - 18, y2); g.strokePath(); + } + g.beginPath(); g.arc(C + 12, 78, 30, Math.PI * 1.4, Math.PI * 1.75); g.strokePath(); + }, + gummy({ g }) { + g.fillStyle(0x57c46a, 0.92); + g.fillCircle(C - 26, 70, 16); g.fillCircle(C + 26, 70, 16); + g.fillCircle(C, 84, 36); + g.fillEllipse(C, 138, 76, 64); + g.fillEllipse(C - 38, 124, 22, 36); g.fillEllipse(C + 38, 124, 22, 36); + g.fillStyle(0xa6e8b2, 0.8); g.fillCircle(C - 12, 74, 9); + g.fillStyle(0x2c7a3c, 1); g.fillCircle(C - 12, 86, 5); g.fillCircle(C + 12, 86, 5); + }, + lollipop({ g }) { + g.fillStyle(0xf2ece2, 1); g.fillRoundedRect(C - 6, C, 12, 84, 6); + g.fillStyle(0xe06a9f, 1); g.fillCircle(C, 76, 56); + g.lineStyle(9, 0xf2ece2, 1); + g.beginPath(); g.arc(C, 76, 40, 0, Math.PI * 1.4); g.strokePath(); + g.beginPath(); g.arc(C, 76, 22, Math.PI, Math.PI * 2.4); g.strokePath(); + g.fillStyle(0xf2ece2, 1); g.fillCircle(C, 76, 7); + }, + jelly({ g }) { + g.fillStyle(0xd4313f, 0.95); + g.slice(C, 132, 62, Math.PI, Math.PI * 2, false); g.fillPath(); + g.fillEllipse(C, 132, 124, 26); + g.fillStyle(0xf2ece2, 1); g.fillEllipse(C, 148, 150, 18); + g.fillStyle(0xf6979f, 0.85); g.fillEllipse(C - 22, 96, 18, 12); + }, + mint({ g }) { + for (let i = 0; i < 8; i++) { + g.fillStyle(i % 2 === 0 ? 0x4fb877 : 0xf2ece2, 1); + g.slice(C, C, 64, (i * Math.PI) / 4, ((i + 1) * Math.PI) / 4, false); + g.fillPath(); + } + g.lineStyle(5, 0x2c7a3c, 1); g.strokeCircle(C, C, 64); + }, + berry({ g }) { + g.fillStyle(0xb83a6e, 1); + const cells = [[0, -22], [-22, -8], [22, -8], [-30, 16], [0, 8], [30, 16], [-14, 38], [14, 38], [0, 58]]; + for (const [dx, dy] of cells) g.fillCircle(C + dx, C + dy - 4, 17); + g.fillStyle(0xe87fa8, 0.8); + for (const [dx, dy] of cells.slice(0, 4)) g.fillCircle(C + dx - 4, C + dy - 8, 5); + g.fillStyle(0x4f9a34, 1); + g.fillEllipse(C - 14, C - 44, 30, 13); g.fillEllipse(C + 14, C - 44, 30, 13); + }, + sugar({ g }) { + g.fillStyle(0xf6f3ea, 1); + g.fillPoints(P([[C - 44, 70], [C + 18, 56], [C + 52, 84], [C - 10, 100]]), true); // top + g.fillStyle(0xddd6c4, 1); + g.fillPoints(P([[C - 44, 70], [C - 10, 100], [C - 10, 158], [C - 44, 128]]), true); // left + g.fillStyle(0xc9c0aa, 1); + g.fillPoints(P([[C - 10, 100], [C + 52, 84], [C + 52, 142], [C - 10, 158]]), true); // right + g.fillStyle(0xffffff, 1); + g.fillPoints(starPts(C + 56, 52, 4, 14, 5, 0), true); + }, +}; + +function cardLetter(letter) { + return ({ g, text, theme }) => { + g.lineStyle(6, theme.trim, 0.9); + g.strokeRoundedRect(C - 52, C - 64, 104, 128, 16); + text(letter, { x: C, y: C, size: 96, color: theme.accentHex, font: 'Righteous' }); + }; +} + +function seven(colorHex) { + return ({ text }) => { + text('7', { + x: C, y: C, size: 150, color: colorHex, font: 'Righteous', + stroke: '#14100a', strokeThickness: 10, + }); + }; +} + +// ── Baking ──────────────────────────────────────────────────────────────────── +export function bakeMachineSymbols(scene, machine) { + for (const id of allSymbolIds(machine)) { + const key = bakedKey(machine.id, id); + if (scene.textures.exists(key)) continue; + const g = scene.make.graphics({ add: false }); + const extras = []; + const text = (str, { x = C, y = C, size = 32, color = '#ffffff', font = 'Righteous', stroke, strokeThickness } = {}) => { + const style = { fontFamily: font, fontSize: `${size}px`, color }; + if (stroke) { style.stroke = stroke; style.strokeThickness = strokeThickness ?? 6; } + const t = scene.make.text({ x, y, text: str, style, add: false }).setOrigin(0.5); + extras.push(t); + return t; + }; + const painter = PAINTERS[`${machine.id}/${id}`] ?? PAINTERS[id] ?? cardLetter('?'); + painter({ g, text, theme: machine.theme }); + const rt = scene.make.renderTexture({ width: S, height: S, add: false }); + rt.draw(g, 0, 0); + for (const t of extras) rt.draw(t, t.x, t.y); + rt.saveTexture(key); + g.destroy(); + for (const t of extras) t.destroy(); + } +} + +// Small particle textures shared by all machines. +export function bakeParticleTextures(scene) { + if (!scene.textures.exists('slots-coin-p')) { + const g = scene.make.graphics({ add: false }); + g.fillStyle(0x8a5e12, 1); g.fillCircle(14, 14, 13); + g.fillStyle(0xf0c441, 1); g.fillCircle(14, 13, 11); + g.fillStyle(0xfbe8a6, 0.95); g.fillEllipse(10, 9, 7, 4); + g.generateTexture('slots-coin-p', 28, 28); + g.destroy(); + } + if (!scene.textures.exists('slots-spark')) { + const g = scene.make.graphics({ add: false }); + g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5); + g.generateTexture('slots-spark', 10, 10); + g.destroy(); + } +} diff --git a/public/src/games/slots/machines.js b/public/src/games/slots/machines.js new file mode 100644 index 0000000..2a0c634 --- /dev/null +++ b/public/src/games/slots/machines.js @@ -0,0 +1,433 @@ +// Slot machine definitions — pure data, no Phaser imports. +// +// Reel strips are written compressed: each entry is either a symbol id string +// (one copy) or [symbolId, count]. expandStrip() flattens to the real strip. +// Symbol weighting comes entirely from repetition counts, which keeps the +// paytable math verifiable by Monte-Carlo (server/scripts/verifySlots.js). +// +// Money model: +// - betLevels are TOTAL bet per spin in credits (shared casino bankroll). +// - 'lines' machines: paytable values are multipliers of the LINE bet +// (total bet / paylines.length). Per-win credits = round(pay * wildMult * bet / nLines). +// - 'scatterPays' machines: paytable tiers are multipliers of the TOTAL bet. +// - scatter.pays and pickBonus/holdSpin coin values are multipliers of the TOTAL bet. + +export function expandStrip(compressed) { + const out = []; + for (const entry of compressed) { + if (Array.isArray(entry)) { + const [sym, count] = entry; + for (let i = 0; i < count; i++) out.push(sym); + } else out.push(entry); + } + return out; +} + +// Spread each symbol's copies evenly around the strip (deterministic, no rng) +// so spinning reels never show cheap-looking blocks of identical symbols — +// EXCEPT where an entry asks for blocks: [sym, count, blockSize] places the +// copies as runs of blockSize consecutive slots (stacked coins / candy clumps), +// which is what lets a 3-row window show multiple copies on one reel. +export function interleaveStrip(compressed) { + const entries = compressed.map((e) => (Array.isArray(e) ? e : [e, 1])); + const len = entries.reduce((s, [, c]) => s + c, 0); + const strip = new Array(len).fill(null); + const sorted = entries.slice().sort((a, b) => b[1] - a[1]); + sorted.forEach(([sym, count, block = 1], si) => { + const nChunks = Math.ceil(count / block); + const step = len / nChunks; + let remaining = count; + for (let i = 0; i < nChunks; i++) { + let idx = Math.floor(i * step + step / 2 + si) % len; + const size = Math.min(block, remaining); + remaining -= size; + for (let j = 0; j < size; j++) { + while (strip[idx] !== null) idx = (idx + 1) % len; + strip[idx] = sym; + idx = (idx + 1) % len; + } + } + }); + return strip; +} + +// Standard 20-payline set for 5×3 video slots (row index per reel, 0 = top). +export const LINES_20 = [ + [1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [2, 2, 2, 2, 2], [0, 1, 2, 1, 0], [2, 1, 0, 1, 2], + [1, 0, 0, 0, 1], [1, 2, 2, 2, 1], [0, 0, 1, 2, 2], [2, 2, 1, 0, 0], [1, 2, 1, 0, 1], + [1, 0, 1, 2, 1], [0, 1, 1, 1, 0], [2, 1, 1, 1, 2], [0, 1, 0, 1, 0], [2, 1, 2, 1, 2], + [1, 1, 0, 1, 1], [1, 1, 2, 1, 1], [0, 2, 0, 2, 0], [2, 0, 2, 0, 2], [0, 2, 2, 2, 0], +]; + +// 5 paylines for a 3×3 window: three rows + two diagonals. +export const LINES_5 = [ + [1, 1, 1], [0, 0, 0], [2, 2, 2], [0, 1, 2], [2, 1, 0], +]; + +export const MACHINES = [ + // ── 1. Liberty Belle — 1899 mechanical, pull lever, single line ───────────── + { + id: 'liberty-belle', + name: 'Liberty Belle', + tagline: '1899 Original', + era: 'vintage', + featureLabel: 'PULL THE LEVER', + layout: { reels: 3, rows: 1 }, + evaluation: 'lines', + betLevels: [1, 2, 5, 10, 25], + symbols: ['bell', 'horseshoe', 'star', 'heart', 'diamond', 'spade'], + reelStrips: [ + ['spade', 'diamond', 'star', 'heart', 'diamond', 'spade', 'horseshoe', 'heart', 'diamond', 'spade', + 'star', 'heart', 'bell', 'diamond', 'spade', 'horseshoe', 'heart', 'diamond', 'star', 'spade'], + ['diamond', 'spade', 'heart', 'star', 'spade', 'diamond', 'heart', 'horseshoe', 'spade', 'diamond', + 'heart', 'star', 'diamond', 'bell', 'spade', 'heart', 'horseshoe', 'diamond', 'spade', 'star'], + ['heart', 'spade', 'diamond', 'star', 'diamond', 'heart', 'spade', 'horseshoe', 'diamond', 'spade', + 'star', 'heart', 'spade', 'diamond', 'bell', 'heart', 'horseshoe', 'spade', 'diamond', 'star'], + ], + paylines: [[0, 0, 0]], + paytable: { + bell: { 3: 150, 2: 12 }, + horseshoe: { 3: 60, 2: 6 }, + star: { 3: 30, 2: 3 }, + heart: { 3: 18, 2: 2 }, + diamond: { 3: 12, 2: 1 }, + spade: { 3: 8, 2: 1 }, + }, + wild: null, + scatter: null, + features: { lever: true, steppedStops: true }, + theme: { + cabinet: 0x5a3a1e, cabinetHi: 0x7a5430, trim: 0xc8a84b, glow: 0xffd98a, + screenTop: 0x2b1d10, screenBot: 0x191009, accentHex: '#ffd98a', reelBg: 0xf3ead2, + }, + }, + + // ── 2. Fruit Frenzy — 1960s fruit machine, HOLD & NUDGE ───────────────────── + { + id: 'fruit-frenzy', + name: 'Fruit Frenzy', + tagline: '1965 Electro-Mechanical', + era: 'vintage', + featureLabel: 'HOLD & NUDGE', + layout: { reels: 3, rows: 3 }, + evaluation: 'lines', + betLevels: [5, 10, 25, 50], + symbols: ['bar', 'bell', 'melon', 'plum', 'orange', 'lemon', 'cherry'], + reelStrips: [ + ['cherry', 'lemon', 'plum', 'orange', 'cherry', 'melon', 'lemon', 'plum', 'bell', 'orange', + 'cherry', 'lemon', 'melon', 'plum', 'orange', 'cherry', 'bell', 'lemon', 'bar', 'plum', + 'orange', 'cherry', 'melon', 'bell'], + ['lemon', 'cherry', 'orange', 'plum', 'melon', 'cherry', 'lemon', 'bell', 'plum', 'orange', + 'cherry', 'melon', 'lemon', 'plum', 'bar', 'orange', 'cherry', 'bell', 'lemon', 'plum', + 'cherry', 'orange', 'melon', 'bell'], + ['plum', 'cherry', 'lemon', 'orange', 'cherry', 'melon', 'plum', 'bell', 'lemon', 'orange', + 'cherry', 'plum', 'melon', 'lemon', 'orange', 'bar', 'cherry', 'bell', 'plum', 'lemon', + 'orange', 'cherry', 'melon', 'bell'], + ], + paylines: LINES_5, + paytable: { + bar: { 3: 150 }, + bell: { 3: 80 }, + melon: { 3: 50 }, + plum: { 3: 30 }, + orange: { 3: 20 }, + lemon: { 3: 14 }, + cherry: { 3: 10, 2: 3 }, + }, + wild: null, + scatter: null, + features: { + holdNudge: { + holdChance: 0.12, // offered after a losing spin (not twice in a row) + nudgeChance: 0.10, // awarded after a losing spin when holds weren't offered + nudgeWeights: [[1, 50], [2, 30], [3, 20]], + }, + }, + theme: { + cabinet: 0x8c1d2f, cabinetHi: 0xb53247, trim: 0xe8e4da, glow: 0xff7da0, + screenTop: 0x241016, screenBot: 0x14080c, accentHex: '#ffb3c7', reelBg: 0xfdf6e6, + }, + }, + + // ── 3. Lucky Sevens — 1970s Vegas, doubling wild diamonds ─────────────────── + { + id: 'lucky-sevens', + name: 'Lucky Sevens', + tagline: '1975 Vegas Strip', + era: 'vintage', + featureLabel: 'WILDS DOUBLE PAYS', + layout: { reels: 3, rows: 1 }, + evaluation: 'lines', + betLevels: [1, 2, 5, 10, 25], + symbols: ['wild', 'seven-red', 'seven-white', 'seven-blue', 'bar3', 'bar2', 'bar1'], + reelStrips: [ + ['blank', 'bar1', 'blank', 'seven-white', 'blank', 'bar2', 'blank', 'bar1', 'seven-blue', 'blank', + 'bar3', 'blank', 'bar2', 'blank', 'seven-red', 'blank', 'bar1', 'blank', 'seven-blue', 'blank', + 'bar1', 'blank', 'wild', 'blank'], + ['bar1', 'blank', 'seven-blue', 'blank', 'bar2', 'blank', 'bar1', 'blank', 'seven-white', 'blank', + 'bar3', 'blank', 'bar2', 'blank', 'seven-red', 'blank', 'bar1', 'blank', 'wild', 'blank', + 'bar1', 'blank', 'seven-blue', 'blank'], + ['blank', 'bar2', 'blank', 'seven-white', 'blank', 'bar1', 'blank', 'seven-blue', 'blank', 'bar3', + 'blank', 'bar1', 'blank', 'seven-red', 'blank', 'bar2', 'blank', 'wild', 'blank', 'bar1', + 'blank', 'seven-blue', 'blank', 'bar1'], + ], + paylines: [[0, 0, 0]], + paytable: { + 'wild': { 3: 300 }, + 'seven-red': { 3: 100 }, + 'seven-white': { 3: 80 }, + 'seven-blue': { 3: 50 }, + 'any-seven': { 3: 12 }, + 'bar3': { 3: 25 }, + 'bar2': { 3: 15 }, + 'bar1': { 3: 10 }, + 'any-bar': { 3: 4 }, + }, + symbolGroups: { + 'any-seven': ['seven-red', 'seven-white', 'seven-blue'], + 'any-bar': ['bar1', 'bar2', 'bar3'], + }, + wild: { symbol: 'wild', multipliers: { 1: 2, 2: 4 } }, + scatter: null, + features: {}, + theme: { + cabinet: 0x1b2440, cabinetHi: 0x2c3a66, trim: 0xd4313f, glow: 0x6fa8ff, + screenTop: 0x10162b, screenBot: 0x080b16, accentHex: '#8fb8ff', reelBg: 0xf2f2f2, + }, + }, + + // ── 4. Pharaoh's Fortune — free spins + expanding wild ────────────────────── + { + id: 'pharaohs-fortune', + name: "Pharaoh's Fortune", + tagline: 'Free Spins of the Nile', + era: 'modern', + featureLabel: 'FREE SPINS', + layout: { reels: 5, rows: 3 }, + evaluation: 'lines', + betLevels: [10, 25, 50, 100, 250], + symbols: ['pharaoh', 'pyramid', 'sphinx', 'ankh', 'eye', 'cat', 'ace', 'king', 'queen', 'jack'], + reelStrips: [ + [['sphinx', 3], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'pyramid'], + [['pharaoh', 2], ['sphinx', 3], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 5], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], + [['pharaoh', 2], ['sphinx', 3], ['ankh', 3], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], + [['pharaoh', 2], ['sphinx', 3], ['ankh', 4], ['eye', 3], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 5], 'pyramid'], + [['sphinx', 4], ['ankh', 4], ['eye', 4], ['cat', 4], ['ace', 6], ['king', 6], ['queen', 5], ['jack', 6], 'pyramid'], + ], + paylines: LINES_20, + paytable: { + pharaoh: { 3: 85, 4: 340, 5: 1700 }, + sphinx: { 3: 50, 4: 170, 5: 680 }, + ankh: { 3: 40, 4: 135, 5: 500 }, + eye: { 3: 34, 4: 100, 5: 400 }, + cat: { 3: 27, 4: 85, 5: 340 }, + ace: { 3: 17, 4: 50, 5: 200 }, + king: { 3: 14, 4: 40, 5: 170 }, + queen: { 3: 10, 4: 34, 5: 135 }, + jack: { 3: 10, 4: 27, 5: 100 }, + }, + wild: { symbol: 'pharaoh' }, + scatter: { + symbol: 'pyramid', + pays: { 3: 2, 4: 10, 5: 50 }, + freeSpins: { 3: 8, 4: 12, 5: 20 }, + retrigger: true, + expandingWildReel: 2, + }, + features: {}, + theme: { + cabinet: 0x223047, cabinetHi: 0x32486b, trim: 0xe6b94d, glow: 0xffd86b, + screenTop: 0x2c2410, screenBot: 0x120d05, accentHex: '#ffd86b', reelBg: 0x1c1507, + }, + }, + + // ── 5. Abyssal Treasures — cascading reels + multiplier ladder ────────────── + { + id: 'abyssal-treasures', + name: 'Abyssal Treasures', + tagline: 'Cascades of the Deep', + era: 'modern', + featureLabel: 'CASCADING REELS', + layout: { reels: 5, rows: 3 }, + evaluation: 'lines', + betLevels: [10, 25, 50, 100, 250], + symbols: ['pearl', 'mermaid', 'octopus', 'seahorse', 'angler', 'starfish', 'shell', 'coral', 'bubble'], + reelStrips: [ + [['mermaid', 3], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 7], ['bubble', 6]], + [['pearl', 2], ['mermaid', 3], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 5]], + [['pearl', 2], ['mermaid', 3], ['octopus', 3], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], + [['pearl', 2], ['mermaid', 3], ['octopus', 4], ['seahorse', 3], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], + [['mermaid', 4], ['octopus', 4], ['seahorse', 4], ['angler', 4], ['starfish', 6], ['shell', 6], ['coral', 6], ['bubble', 6]], + ], + paylines: LINES_20, + paytable: { + pearl: { 3: 50, 4: 200, 5: 750 }, + mermaid: { 3: 30, 4: 100, 5: 375 }, + octopus: { 3: 22, 4: 75, 5: 250 }, + seahorse: { 3: 18, 4: 50, 5: 200 }, + angler: { 3: 15, 4: 38, 5: 150 }, + starfish: { 3: 10, 4: 25, 5: 100 }, + shell: { 3: 8, 4: 20, 5: 75 }, + coral: { 3: 6, 4: 15, 5: 62 }, + bubble: { 3: 6, 4: 12, 5: 50 }, + }, + wild: { symbol: 'pearl' }, + scatter: null, + features: { cascade: { multipliers: [1, 2, 4, 6] } }, + theme: { + cabinet: 0x0d2f3d, cabinetHi: 0x15485e, trim: 0x3fd6c0, glow: 0x59e7ff, + screenTop: 0x06222e, screenBot: 0x020d13, accentHex: '#59e7ff', reelBg: 0x04161f, + }, + }, + + // ── 6. Dragon's Hoard — hold-and-spin coins + progressive jackpot ─────────── + { + id: 'dragons-hoard', + name: "Dragon's Hoard", + tagline: 'Hold & Spin Jackpots', + era: 'modern', + featureLabel: 'JACKPOT COINS', + layout: { reels: 5, rows: 3 }, + evaluation: 'lines', + betLevels: [10, 25, 50, 100, 250], + symbols: ['dragon', 'coin', 'knight', 'castle', 'chest', 'gem', 'ace', 'king', 'queen', 'jack'], + reelStrips: [ + [['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 6], ['jack', 7]], + [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], + [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], + [['dragon', 2], ['coin', 4, 2], ['knight', 3], ['castle', 3], ['chest', 3], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], + [['coin', 4, 2], ['knight', 3], ['castle', 4], ['chest', 4], ['gem', 4], ['ace', 5], ['king', 5], ['queen', 5], ['jack', 6]], + ], + paylines: LINES_20, + paytable: { + dragon: { 3: 60, 4: 240, 5: 1200 }, + knight: { 3: 36, 4: 120, 5: 450 }, + castle: { 3: 30, 4: 90, 5: 360 }, + chest: { 3: 24, 4: 75, 5: 300 }, + gem: { 3: 18, 4: 60, 5: 240 }, + ace: { 3: 12, 4: 36, 5: 150 }, + king: { 3: 12, 4: 30, 5: 120 }, + queen: { 3: 9, 4: 24, 5: 90 }, + jack: { 3: 6, 4: 18, 5: 75 }, + }, + wild: { symbol: 'dragon' }, + scatter: null, + features: { + holdSpin: { + trigger: 6, + respins: 3, + coinSymbol: 'coin', + coinChance: 0.085, // per empty cell, per respin + // [value-or-tier, weight]; numeric values are ×total-bet + coinValues: [[1, 40], [2, 28], [3, 15], [5, 10], [10, 5], ['mini', 1.5], ['minor', 0.4], ['major', 0.1]], + jackpots: { mini: 20, minor: 60, major: 250 }, // ×total-bet + grand: { seed: 5000, sliver: 0.01 }, // progressive, localStorage-backed + }, + }, + theme: { + cabinet: 0x33121c, cabinetHi: 0x55202e, trim: 0xe8483f, glow: 0xff8c3b, + screenTop: 0x2a0f08, screenBot: 0x120503, accentHex: '#ff9d4d', reelBg: 0x190a06, + }, + }, + + // ── 7. Gold Rush Gulch — sticky wilds + pick-a-cart bonus ─────────────────── + { + id: 'gold-rush', + name: 'Gold Rush Gulch', + tagline: 'Sticky Bandits & Mine Carts', + era: 'modern', + featureLabel: 'PICK BONUS', + layout: { reels: 5, rows: 3 }, + evaluation: 'lines', + betLevels: [10, 25, 50, 100, 250], + symbols: ['bandit', 'dynamite', 'sheriff', 'saloon', 'wagon', 'pickaxe', 'ace', 'king', 'queen', 'jack'], + reelStrips: [ + [['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 7], ['jack', 6], 'dynamite'], + ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], + ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], + ['bandit', ['sheriff', 3], ['saloon', 3], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], + [['sheriff', 3], ['saloon', 4], ['wagon', 4], ['pickaxe', 4], ['ace', 6], ['king', 6], ['queen', 6], ['jack', 6], 'dynamite'], + ], + paylines: LINES_20, + paytable: { + sheriff: { 3: 30, 4: 100, 5: 380 }, + saloon: { 3: 25, 4: 75, 5: 300 }, + wagon: { 3: 20, 4: 64, 5: 255 }, + pickaxe: { 3: 15, 4: 46, 5: 180 }, + ace: { 3: 10, 4: 30, 5: 128 }, + king: { 3: 10, 4: 26, 5: 100 }, + queen: { 3: 8, 4: 20, 5: 75 }, + jack: { 3: 5, 4: 15, 5: 64 }, + }, + wild: { symbol: 'bandit' }, + scatter: { symbol: 'dynamite', pays: { 3: 2, 4: 8, 5: 30 } }, + features: { + stickyWilds: { symbol: 'bandit', spins: 3 }, + pickBonus: { + scatter: 'dynamite', min: 3, picks: 3, carts: 9, + prizes: [[2, 30], [3, 25], [5, 20], [8, 12], [12, 7], [20, 4], [50, 2]], // ×total-bet + }, + }, + theme: { + cabinet: 0x4a3217, cabinetHi: 0x6e4c24, trim: 0xf0c441, glow: 0xffe27a, + screenTop: 0x271c0c, screenBot: 0x110c05, accentHex: '#ffe27a', reelBg: 0x1c1409, + }, + }, + + // ── 8. Sugar Spin — 6×5 scatter-pays tumble + rainbow multipliers ─────────── + { + id: 'sugar-spin', + name: 'Sugar Spin', + tagline: 'Tumbling Candy Mountain', + era: 'modern', + featureLabel: 'TUMBLE WINS', + layout: { reels: 6, rows: 5 }, + evaluation: 'scatterPays', + betLevels: [10, 25, 50, 100, 250], + symbols: ['rainbow', 'chocolate', 'candy-cane', 'gummy', 'lollipop', 'jelly', 'mint', 'berry', 'sugar'], + reelStrips: [ + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + [['chocolate', 2, 2], ['candy-cane', 2, 2], ['gummy', 3, 2], ['lollipop', 4, 2], ['jelly', 4, 2], ['mint', 5, 2], ['berry', 5, 2], ['sugar', 5, 2]], + ], + paylines: null, + // scatterPays tiers: count → ×total-bet, highest qualifying tier wins. + paytable: { + 'chocolate': [{ min: 12, pay: 16 }, { min: 10, pay: 8 }, { min: 8, pay: 3 }], + 'candy-cane': [{ min: 12, pay: 8 }, { min: 10, pay: 4 }, { min: 8, pay: 1.6 }], + 'gummy': [{ min: 12, pay: 5 }, { min: 10, pay: 2.5 }, { min: 8, pay: 1 }], + 'lollipop': [{ min: 12, pay: 4 }, { min: 10, pay: 1.6 }, { min: 8, pay: 0.6 }], + 'jelly': [{ min: 12, pay: 3 }, { min: 10, pay: 1.2 }, { min: 8, pay: 0.5 }], + 'mint': [{ min: 12, pay: 2.5 }, { min: 10, pay: 0.8 }, { min: 8, pay: 0.3 }], + 'berry': [{ min: 12, pay: 2 }, { min: 10, pay: 0.6 }, { min: 8, pay: 0.25 }], + 'sugar': [{ min: 12, pay: 1.5 }, { min: 10, pay: 0.5 }, { min: 8, pay: 0.15 }], + }, + wild: null, + scatter: null, + features: { + scatterPays: { min: 8 }, + tumble: true, + rainbow: { + symbol: 'rainbow', + chance: 0.015, // per refilled cell during tumbles + values: [[2, 55], [3, 25], [5, 15], [10, 5]], + }, + }, + theme: { + cabinet: 0x6e2160, cabinetHi: 0x933582, trim: 0xff8ad2, glow: 0xffa3e0, + screenTop: 0x35103a, screenBot: 0x16071d, accentHex: '#ffa3e0', reelBg: 0x230b2a, + }, + }, +]; + +export const MACHINE_BY_ID = Object.fromEntries(MACHINES.map((m) => [m.id, m])); + +// Resolve strips once; consumers should use machine.strips (expanded). +// Hand-authored strips (all single entries) keep their written order; +// compressed strips are interleaved so identical symbols never clump. +for (const m of MACHINES) { + m.strips = m.reelStrips.map((s) => (s.some(Array.isArray) ? interleaveStrip(s) : expandStrip(s))); +} diff --git a/public/src/main.js b/public/src/main.js index 42e4264..8a69af2 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -72,6 +72,7 @@ import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; import ZumaGame from './games/zuma/ZumaGame.js'; import BejeweledGame from './games/bejeweled/BejeweledGame.js'; import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js'; +import SlotsGame from './games/slots/SlotsGame.js'; const config = { type: Phaser.AUTO, @@ -157,6 +158,7 @@ const config = { ZumaGame, BejeweledGame, MiniMotorwaysGame, + SlotsGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index a313425..9810df0 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', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame' }; + 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', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index f297d1a..9dd1436 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -63,6 +63,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('rushhour', '/data/rushhour.json'); this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.json('shift-artwork', '/data/shift-artwork.json'); + this.load.json('slots-artwork', '/data/slots-artwork.json'); this.load.json('blockfighter', '/data/blockfighter.json'); this.load.json('jewelquest', '/data/jewelquest.json'); this.load.json('zuma', '/data/zuma.json'); @@ -170,10 +171,12 @@ export default class PreloadScene extends Phaser.Scene { const cbd = this.cache.json.get('card-backs'); const shiftArt = this.cache.json.get('shift-artwork'); + const slotsArt = this.cache.json.get('slots-artwork'); const toLoad = [ ...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)), ...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)), ...(shiftArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)), + ...(slotsArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)), ]; if (toLoad.length > 0) { diff --git a/server/games/registry.js b/server/games/registry.js index 7693290..d236cd1 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -87,3 +87,4 @@ registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: ' registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 }); registerGame({ slug: 'bejeweled', name: 'Bejeweled Blitz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 61 }); registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 62 }); +registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 54 }); diff --git a/server/scripts/verifySlots.js b/server/scripts/verifySlots.js new file mode 100644 index 0000000..95b561e --- /dev/null +++ b/server/scripts/verifySlots.js @@ -0,0 +1,448 @@ +// Headless verification for Slot Machines. +// node server/scripts/verifySlots.js [--machine=] [--spins=N] +// Exits non-zero on any failure. +// +// 1. Fixture tests: hand-built stops through gridFromStops / evaluateLines / +// feature entry points, asserting exact payouts and state transitions. +// 2. Monte-Carlo per machine (default 300k spins, use --spins=1000000 for the +// full run): RTP must land in the 88–97% band, every configured feature +// must fire, and grid/termination invariants hold on every spin. + +import { MACHINES, MACHINE_BY_ID } from '../../public/src/games/slots/machines.js'; +import { + createSession, spin, spinIsFree, gridFromStops, evaluateLines, + evaluateScatterPays, setHold, applyNudge, peekNudge, + holdSpinRespin, createPickBonus, pick, randInt, +} from '../../public/src/games/slots/SlotsLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// rng that plays back queued values, then falls through to a seeded stream. +function queueRng(values, seed = 1) { + const tail = mulberry32(seed); + let i = 0; + return () => (i < values.length ? values[i++] : tail()); +} + +// rng value that makes randInt(rng, len) yield exactly `idx`. +const stopVal = (idx, len) => (idx + 0.5) / len; + +// Find a stop for the reel whose visible window avoids all of `avoid` symbols. +function findCleanStop(machine, reel, avoid) { + const strip = machine.strips[reel]; + const { rows } = machine.layout; + for (let s = 0; s < strip.length; s++) { + let ok = true; + for (let r = 0; r < rows; r++) { + if (avoid.includes(strip[(s + r) % strip.length])) { ok = false; break; } + } + if (ok) return s; + } + throw new Error(`no clean stop on reel ${reel}`); +} + +const args = Object.fromEntries(process.argv.slice(2) + .filter((a) => a.startsWith('--')) + .map((a) => { const [k, v] = a.slice(2).split('='); return [k, v ?? true]; })); +const SPINS = Number(args.spins ?? 300000); +const ONLY = args.machine ?? null; + +// ════════════════════════════════════════════════════════════════════════════ +console.log('Fixtures: Liberty Belle line pays'); +{ + const m = MACHINE_BY_ID['liberty-belle']; + const bellStops = m.strips.map((s) => s.indexOf('bell')); + const ev = evaluateLines(m, gridFromStops(m, bellStops), 10); + check('3 bells pay 150×bet', ev.total === 1500, `got ${ev.total}`); + + const pairStops = [m.strips[0].indexOf('bell'), m.strips[1].indexOf('bell'), m.strips[2].indexOf('spade')]; + const ev2 = evaluateLines(m, gridFromStops(m, pairStops), 10); + check('leftmost bell pair pays 12×bet', ev2.total === 120, `got ${ev2.total}`); + + const mixStops = [m.strips[0].indexOf('spade'), m.strips[1].indexOf('bell'), m.strips[2].indexOf('bell')]; + const ev3 = evaluateLines(m, gridFromStops(m, mixStops), 10); + check('non-leftmost pair pays 0 (spade pair impossible here)', + ev3.wins.every((w) => w.symbol !== 'bell'), JSON.stringify(ev3.wins)); +} + +console.log('Fixtures: Lucky Sevens wild multipliers'); +{ + const m = MACHINE_BY_ID['lucky-sevens']; + const w = (r) => m.strips[r].indexOf('wild'); + const red = (r) => m.strips[r].indexOf('seven-red'); + const bet = 10; + + const one = evaluateLines(m, gridFromStops(m, [w(0), red(1), red(2)]), bet); + check('1 wild doubles red sevens (100→200)', one.total === 2000, `got ${one.total}`); + check('win reports wildMult 2', one.wins[0]?.wildMult === 2); + + const two = evaluateLines(m, gridFromStops(m, [w(0), w(1), red(2)]), bet); + check('2 wilds quadruple red sevens (100→400)', two.total === 4000, `got ${two.total}`); + + const three = evaluateLines(m, gridFromStops(m, [w(0), w(1), w(2)]), bet); + check('3 wilds pay their own 300 line', three.total === 3000, `got ${three.total}`); + + const mixed = evaluateLines(m, gridFromStops(m, + [red(0), m.strips[1].indexOf('seven-blue'), m.strips[2].indexOf('seven-white')]), bet); + const anySevenPay = m.paytable['any-seven'][3] * bet; + check('mixed sevens pay any-seven', mixed.total === anySevenPay, `got ${mixed.total}, want ${anySevenPay}`); +} + +console.log('Fixtures: Fruit Frenzy hold & nudge'); +{ + const m = MACHINE_BY_ID['fruit-frenzy']; + const bet = 5; + const state = createSession(m, mulberry32(42)); + state.stops = [0, 0, 0]; + state.nudgesLeft = 2; + const res = applyNudge(state, 1, bet); + check('nudge advances reel 1 by one step', state.stops.join(',') === '0,1,0'); + check('nudge decrements remaining', res.nudgesLeft === 1 && state.nudgesLeft === 1); + check('nudge grid matches gridFromStops', JSON.stringify(res.grid) === JSON.stringify(gridFromStops(m, [0, 1, 0]))); + const peek = peekNudge(state, 0, bet); + check('peekNudge does not move the reel', state.stops.join(',') === '0,1,0' && typeof peek.total === 'number'); + + // Holds: held reel keeps its stop and consumes no rng. + const s2 = createSession(m, queueRng([stopVal(7, 24), stopVal(11, 24)], 9)); + s2.stops = [3, 4, 5]; + s2.holds.offered = true; + setHold(s2, 0, true); + const r2 = spin(s2, bet); + check('held reel 0 kept stop 3', r2.stops[0] === 3, `got ${r2.stops[0]}`); + check('unheld reels respun to queued stops', r2.stops[1] === 7 && r2.stops[2] === 11); + check('holds are not offered right after a held spin', r2.holdsOffered === false); +} + +console.log("Fixtures: Pharaoh's Fortune free spins + expanding wild"); +{ + const m = MACHINE_BY_ID['pharaohs-fortune']; + const bet = 20; + const pyr = (r) => m.strips[r].indexOf('pyramid'); + const clean3 = findCleanStop(m, 3, ['pyramid']); + const clean4 = findCleanStop(m, 4, ['pyramid']); + const state = createSession(m, queueRng([ + stopVal(pyr(0), 40), stopVal(pyr(1), 40), stopVal(pyr(2), 40), + stopVal(clean3, 40), stopVal(clean4, 40), + ], 5)); + const res = spin(state, bet); + check('3 scatters award 8 free spins', res.freeSpinsAwarded === 8, `got ${res.freeSpinsAwarded}`); + check('mode switches to freespins', state.mode === 'freespins'); + const award = res.steps.find((s) => s.type === 'scatterAward'); + check('scatter pays 2×bet', award?.credits === 40, `got ${award?.credits}`); + check('free spin costs nothing flag', spinIsFree(state) === true); + + // Expanding wild on reel 2 during free spins. + const ph2 = m.strips[2].indexOf('pharaoh'); + const cleanStops = [0, 1, 3, 4].map((r) => findCleanStop(m, r, ['pyramid'])); + const s2 = createSession(m, queueRng([ + stopVal(cleanStops[0], 40), stopVal(cleanStops[1], 40), stopVal(ph2, 40), + stopVal(cleanStops[2], 40), stopVal(cleanStops[3], 40), + ], 6)); + s2.mode = 'freespins'; + s2.freeSpins = { remaining: 3, total: 8 }; + const r2 = spin(s2, bet); + check('free spin decrements remaining', r2.freeSpinsRemaining === 2); + check('wild on reel 3 expands', r2.steps.some((s) => s.type === 'expandWild' && s.col === 2)); + check('reel 3 is all wild after expansion', r2.grid[2].every((sym) => sym === 'pharaoh')); + + // No expansion in base mode. + const s3 = createSession(s2.machine, queueRng([ + stopVal(cleanStops[0], 40), stopVal(cleanStops[1], 40), stopVal(ph2, 40), + stopVal(cleanStops[2], 40), stopVal(cleanStops[3], 40), + ], 6)); + const r3 = spin(s3, bet); + check('no expansion outside free spins', !r3.steps.some((s) => s.type === 'expandWild')); +} + +console.log('Fixtures: Abyssal Treasures cascade determinism'); +{ + const m = MACHINE_BY_ID['abyssal-treasures']; + const run = () => { + const st = createSession(m, mulberry32(777)); + const results = []; + for (let i = 0; i < 200; i++) results.push(spin(st, 20)); + return results; + }; + const a = run(); const b = run(); + check('seeded runs replay identically', + JSON.stringify(a.map((r) => r.steps)) === JSON.stringify(b.map((r) => r.steps))); + const cascaded = a.find((r) => r.steps.filter((s) => s.type === 'tumble').length >= 2); + check('multi-cascade occurred in 200 spins', !!cascaded); + if (cascaded) { + const winSteps = cascaded.steps.filter((s) => s.type === 'lineWins'); + check('second cascade uses 2× multiplier', winSteps[1]?.multiplier === 2, + `got ${winSteps[1]?.multiplier}`); + } +} + +console.log("Fixtures: Dragon's Hoard hold-and-spin"); +{ + const m = MACHINE_BY_ID['dragons-hoard']; + const bet = 10; + const mkLocked = () => [0, 1, 2, 3, 4, 5].map((i) => ({ c: i % 5, r: Math.floor(i / 5), value: 1 })); + + // A new coin resets respins to 3 (first empty cell hits, the rest miss). + const s1 = createSession(m, queueRng([0.0, 0.0, ...Array(20).fill(0.99)])); + s1.mode = 'holdspin'; + s1.holdSpin = { locked: mkLocked(), respinsLeft: 1 }; + const r1 = holdSpinRespin(s1, bet); + check('new coin resets respins to 3', !r1.finished && r1.respinsLeft === 3, `got ${r1.respinsLeft}`); + check('new coin is locked', s1.holdSpin.locked.length === 7, `got ${s1.holdSpin.locked.length}`); + + // Burning out three respins finishes and pays the locked coins. + const s2 = createSession(m, () => 0.99); // never hits + s2.mode = 'holdspin'; + s2.holdSpin = { locked: mkLocked(), respinsLeft: 3 }; + let last = null; + for (let i = 0; i < 10 && !last?.finished; i++) last = holdSpinRespin(s2, bet); + check('respins terminate', last?.finished === true); + check('award sums coin values ×bet', last?.award?.credits === 60, `got ${last?.award?.credits}`); + check('mode returns to base', s2.mode === 'base'); + + // Filling the whole board hits the Grand. + const s3 = createSession(m, () => 0.0); // every cell hits + s3.mode = 'holdspin'; + s3.holdSpin = { locked: mkLocked(), respinsLeft: 3 }; + const r3 = holdSpinRespin(s3, bet); + check('full board finishes immediately', r3.finished === true); + check('full board flags the Grand', r3.award?.grandHit === true); +} + +console.log('Fixtures: Gold Rush sticky wilds + pick bonus'); +{ + const m = MACHINE_BY_ID['gold-rush']; + const bet = 10; + const banditIdx = m.strips[1].indexOf('bandit'); + const len = m.strips.map((s) => s.length); + const state = createSession(m, queueRng([ + stopVal(0, len[0]), stopVal(banditIdx, len[1]), stopVal(2, len[2]), + stopVal(4, len[3]), stopVal(6, len[4]), + ], 11)); + spin(state, bet); + const sticky = state.stickyWilds.find((s) => s.c === 1 && s.r === 0); + check('landed bandit becomes sticky for 3 spins', sticky?.spinsLeft === 3); + const r2 = spin(state, bet); + check('sticky wild overrides the next spin', r2.grid[1][0] === 'bandit'); + check('sticky ages after the spin', state.stickyWilds.find((s) => s.c === 1 && s.r === 0)?.spinsLeft <= 3); + + const rng = mulberry32(99); + const bonus = createPickBonus(m, bet, rng); + check('bonus deals 9 carts', bonus.prizes.length === 9 && bonus.prizes.every((p) => p >= 2 * bet)); + const p1 = pick(bonus, 0); const p2 = pick(bonus, 4); const p3 = pick(bonus, 8); + check('three picks then done', p1 && p2 && p3?.done === true); + check('total accumulates picks', bonus.total === bonus.prizes[0] + bonus.prizes[4] + bonus.prizes[8]); + check('re-picking a cart is rejected', pick(bonus, 0) === null); +} + +console.log('Fixtures: Sugar Spin scatter pays'); +{ + const m = MACHINE_BY_ID['sugar-spin']; + const bet = 50; + const others = ['chocolate', 'candy-cane', 'gummy', 'lollipop', 'jelly', 'mint', 'berry']; + const build = (nSugar) => { + const grid = []; + let placed = 0; let o = 0; + for (let c = 0; c < 6; c++) { + grid[c] = []; + for (let r = 0; r < 5; r++) { + if (placed < nSugar) { grid[c][r] = 'sugar'; placed++; } + else { grid[c][r] = others[o % others.length]; o++; } + } + } + return grid; + }; + const tiers = m.paytable.sugar; + const win = evaluateScatterPays(m, build(8), bet); + const want8 = Math.round(tiers.find((t) => 8 >= t.min).pay * bet); + check('8 anywhere pays the base tier', win.total === want8, `got ${win.total}, want ${want8}`); + check('winning cells are every copy', win.wins[0]?.cells.length === 8); + const lose = evaluateScatterPays(m, build(7), bet); + check('7 anywhere pays nothing', lose.total === 0, `got ${lose.total}`); + const big = evaluateScatterPays(m, build(12), bet); + const want12 = Math.round(tiers.find((t) => 12 >= t.min).pay * bet); + check('12 anywhere pays the top tier', big.total === want12, `got ${big.total}, want ${want12}`); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Monte-Carlo simulation with naive-but-reasonable feature auto-play. + +function validSymbols(machine) { + const set = new Set(); + for (const strip of machine.strips) for (const s of strip) set.add(s); + if (machine.wild) set.add(machine.wild.symbol); + if (machine.features.rainbow) set.add(machine.features.rainbow.symbol); + return set; +} + +function autoNudge(state, bet) { + // Greedy: take the first 1-step nudge that wins; otherwise nudge reel 0. + let settled = 0; + while (state.nudgesLeft > 0) { + let bestReel = 0; let bestTotal = -1; + for (let i = 0; i < state.machine.strips.length; i++) { + const peek = peekNudge(state, i, bet); + if (peek.total > bestTotal) { bestTotal = peek.total; bestReel = i; } + } + const res = applyNudge(state, bestReel, bet); + settled = res.totalWin; + if (settled > 0) break; + } + return settled; +} + +function autoHold(state) { + // Hold reels whose centre row shares a symbol with another reel's centre row. + const center = Math.floor(state.machine.layout.rows / 2); + const syms = state.grid.map((col) => col[center]); + for (let i = 0; i < syms.length; i++) { + const shared = syms.some((s, j) => j !== i && s === syms[i]); + if (shared && state.machine.paytable[syms[i]]) setHold(state, i, true); + } +} + +function simulate(machine, spins, seed) { + const rng = mulberry32(seed); + const state = createSession(machine, rng); + const bet = machine.betLevels[1]; // a mid-low bet exercises rounding + const grand = machine.features.holdSpin?.grand ?? null; + let jackpot = grand ? grand.seed : 0; + + const stats = { + bet, totalBet: 0, totalReturn: 0, hits: 0, maxWin: 0, + freeSpinTriggers: 0, freeSpinsPlayed: 0, cascades2: 0, + holdSpins: 0, grands: 0, jackpotTiers: { mini: 0, minor: 0, major: 0 }, + holdsOffered: 0, nudgeRounds: 0, pickBonuses: 0, stickyPlacements: 0, + rainbowBoosts: 0, invariantFailures: 0, + }; + const okSymbols = validSymbols(machine); + const checkGrid = (grid) => { + for (const col of grid) { + if (col.length !== machine.layout.rows) return false; + for (const s of col) if (!okSymbols.has(s)) return false; + } + return grid.length === machine.strips.length; + }; + + for (let i = 0; i < spins; i++) { + stats.totalBet += bet; + let res = spin(state, bet); + if (!checkGrid(res.grid) || res.totalWin < 0) stats.invariantFailures++; + let ret = res.totalWin; + jackpot += res.progressiveContribution; + + if (res.nudgesAwarded > 0) { stats.nudgeRounds++; ret += autoNudge(state, bet); } + if (res.holdsOffered) { stats.holdsOffered++; autoHold(state); } + if (res.steps.some((s) => s.type === 'tumble' && s.cascadeIndex >= 1)) stats.cascades2++; + if (res.steps.some((s) => s.type === 'rainbow')) stats.rainbowBoosts++; + if (res.steps.some((s) => s.type === 'stickyWilds' && s.placed.length > 0)) stats.stickyPlacements++; + + if (res.holdSpinTriggered) { + stats.holdSpins++; + let guard = 0; let out = null; + while (!(out = holdSpinRespin(state, bet)).finished && ++guard < 1000) { /* respin */ } + if (guard >= 1000) stats.invariantFailures++; + ret += out.award.credits; + for (const j of out.award.jackpots) stats.jackpotTiers[j.tier]++; + if (out.award.grandHit) { + stats.grands++; + ret += Math.floor(jackpot); + jackpot = grand.seed; + } + } + if (res.pickBonusTriggered) { + stats.pickBonuses++; + const bonus = createPickBonus(machine, bet, rng); + while (bonus.picksLeft > 0) { + let idx = randInt(rng, bonus.prizes.length); + while (bonus.picked.includes(idx)) idx = (idx + 1) % bonus.prizes.length; + pick(bonus, idx); + } + ret += bonus.total; + } + if (res.freeSpinsAwarded > 0) stats.freeSpinTriggers++; + + // Play out free spins (cost 0) inside the same purchased spin. + let fsGuard = 0; + while (spinIsFree(state) && ++fsGuard < 10000) { + const fr = spin(state, bet); + stats.freeSpinsPlayed++; + if (!checkGrid(fr.grid) || fr.totalWin < 0) stats.invariantFailures++; + ret += fr.totalWin; + } + if (fsGuard >= 10000) stats.invariantFailures++; + + stats.totalReturn += ret; + if (ret > 0) stats.hits++; + if (ret > stats.maxWin) stats.maxWin = ret; + } + stats.rtp = stats.totalReturn / stats.totalBet; + stats.hitRate = stats.hits / spins; + return stats; +} + +const toRun = MACHINES.filter((m) => !ONLY || m.id === ONLY); +if (ONLY && toRun.length === 0) { + console.error(`unknown machine id '${ONLY}'`); + process.exit(1); +} + +for (const machine of toRun) { + console.log(`\nMonte-Carlo: ${machine.name} (${SPINS.toLocaleString()} spins @ bet ${machine.betLevels[1]})`); + const s = simulate(machine, SPINS, 0xC0FFEE ^ machine.id.length); + console.log(` RTP ${(s.rtp * 100).toFixed(2)}% hit-rate ${(s.hitRate * 100).toFixed(1)}% max win ${s.maxWin.toLocaleString()}`); + const feats = []; + if (s.freeSpinTriggers) feats.push(`free-spin triggers ${s.freeSpinTriggers} (${s.freeSpinsPlayed} spins)`); + if (s.cascades2) feats.push(`2+ cascades ${s.cascades2}`); + if (s.holdSpins) feats.push(`hold&spins ${s.holdSpins} (grand ${s.grands}, mini/minor/major ${s.jackpotTiers.mini}/${s.jackpotTiers.minor}/${s.jackpotTiers.major})`); + if (s.holdsOffered || s.nudgeRounds) feats.push(`holds ${s.holdsOffered}, nudge rounds ${s.nudgeRounds}`); + if (s.pickBonuses) feats.push(`pick bonuses ${s.pickBonuses}`); + if (s.stickyPlacements) feats.push(`sticky wilds ${s.stickyPlacements}`); + if (s.rainbowBoosts) feats.push(`rainbow boosts ${s.rainbowBoosts}`); + if (feats.length) console.log(` features: ${feats.join(' · ')}`); + + check(`${machine.id}: RTP in 88–97% band`, s.rtp >= 0.88 && s.rtp <= 0.97, `${(s.rtp * 100).toFixed(2)}%`); + check(`${machine.id}: no invariant failures`, s.invariantFailures === 0, String(s.invariantFailures)); + if (machine.scatter?.freeSpins) { + check(`${machine.id}: free spins trigger`, s.freeSpinTriggers > 0); + } + if (machine.features.cascade || machine.features.tumble) { + check(`${machine.id}: multi-cascades occur`, s.cascades2 > 0); + } + if (machine.features.holdSpin) { + check(`${machine.id}: hold-and-spin triggers`, s.holdSpins > 0); + check(`${machine.id}: each jackpot tier hits`, + s.jackpotTiers.mini > 0 && s.jackpotTiers.minor > 0 && s.jackpotTiers.major > 0, + JSON.stringify(s.jackpotTiers)); + } + if (machine.features.holdNudge) { + check(`${machine.id}: holds offered`, s.holdsOffered > 0); + check(`${machine.id}: nudges awarded`, s.nudgeRounds > 0); + } + if (machine.features.pickBonus) { + check(`${machine.id}: pick bonus triggers`, s.pickBonuses > 0); + } + if (machine.features.stickyWilds) { + check(`${machine.id}: sticky wilds placed`, s.stickyPlacements > 0); + } + if (machine.features.rainbow) { + check(`${machine.id}: rainbow boosts occur`, s.rainbowBoosts > 0); + } +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL CHECKS PASSED'); +process.exit(failures ? 1 : 0);