fertig-classic-games/public/src/games/slots/SlotsFeatures.js

385 lines
16 KiB
JavaScript

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