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