Added Slot Machines

This commit is contained in:
Brian Fertig 2026-06-12 19:42:27 -06:00
parent 8a8ea2ef3b
commit 09d94a6ae7
13 changed files with 4115 additions and 1 deletions

View File

@ -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
}
]
}

View File

@ -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,
});
}
}

View File

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

View File

@ -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 */ }
}
}

View File

@ -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 };
}

View File

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

View File

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

View File

@ -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)));
}

View File

@ -72,6 +72,7 @@ import JewelQuestGame from './games/jewelquest/JewelQuestGame.js';
import ZumaGame from './games/zuma/ZumaGame.js'; import ZumaGame from './games/zuma/ZumaGame.js';
import BejeweledGame from './games/bejeweled/BejeweledGame.js'; import BejeweledGame from './games/bejeweled/BejeweledGame.js';
import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js'; import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
import SlotsGame from './games/slots/SlotsGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -157,6 +158,7 @@ const config = {
ZumaGame, ZumaGame,
BejeweledGame, BejeweledGame,
MiniMotorwaysGame, MiniMotorwaysGame,
SlotsGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -63,6 +63,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('rushhour', '/data/rushhour.json'); this.load.json('rushhour', '/data/rushhour.json');
this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.json('puddingmonsters', '/data/puddingmonsters.json');
this.load.json('shift-artwork', '/data/shift-artwork.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('blockfighter', '/data/blockfighter.json');
this.load.json('jewelquest', '/data/jewelquest.json'); this.load.json('jewelquest', '/data/jewelquest.json');
this.load.json('zuma', '/data/zuma.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 cbd = this.cache.json.get('card-backs');
const shiftArt = this.cache.json.get('shift-artwork'); const shiftArt = this.cache.json.get('shift-artwork');
const slotsArt = this.cache.json.get('slots-artwork');
const toLoad = [ const toLoad = [
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)), ...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.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)), ...(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) { if (toLoad.length > 0) {

View File

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

View File

@ -0,0 +1,448 @@
// Headless verification for Slot Machines.
// node server/scripts/verifySlots.js [--machine=<id>] [--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 8897% 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 8897% 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);