1357 lines
43 KiB
JavaScript
1357 lines
43 KiB
JavaScript
/* ===== ELSA GAMBLING — motore del reveal ===== */
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ===== DEBUG / ANTEPRIMA EFFETTI =====================================
|
|
Per vedere gli effetti senza aspettare le vincite jacuzzi casuali.
|
|
Rimetti entrambi a null/false per il gioco normale. */
|
|
// Forza un effetto shalom fisso: "fire" | "video" | "gesu" | null
|
|
const FORCE_SHALOM = null;
|
|
// Forza il risultato del giro su una stanza con idromassaggio (così l'effetto
|
|
// parte a ogni giro). true = jacuzzi a caso; oppure l'id di una stanza
|
|
// ("argo" | "eolo" | "rischio" | "zeus" | ...); false = risultato normale.
|
|
const FORCE_RESULT = false;
|
|
/* ==================================================================== */
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const roomById = Object.fromEntries(ROOMS.map((r) => [r.id, r]));
|
|
const genderOf = Object.fromEntries(
|
|
PARTICIPANTS.map((p) => [p.nome, p.genere]),
|
|
);
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/* ---------- Audio sintetizzato (nessun file esterno) ---------- */
|
|
const Audio = {
|
|
ctx: null,
|
|
master: null,
|
|
on: true,
|
|
init() {
|
|
if (this.ctx) return;
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
this.ctx = new AC();
|
|
this.master = this.ctx.createGain();
|
|
this.master.gain.value = 0.5;
|
|
this.master.connect(this.ctx.destination);
|
|
},
|
|
resume() {
|
|
if (this.ctx && this.ctx.state === "suspended") this.ctx.resume();
|
|
},
|
|
blip(freq, dur = 0.05, type = "square", vol = 0.3, glideTo) {
|
|
if (!this.on || !this.ctx) return;
|
|
const t = this.ctx.currentTime;
|
|
const o = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
o.type = type;
|
|
o.frequency.setValueAtTime(freq, t);
|
|
if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
|
|
g.gain.setValueAtTime(vol, t);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
o.connect(g).connect(this.master);
|
|
o.start(t);
|
|
o.stop(t + dur + 0.02);
|
|
},
|
|
tick() {
|
|
this.blip(1300, 0.03, "square", 0.12);
|
|
},
|
|
reelStop() {
|
|
this.blip(210, 0.08, "square", 0.28);
|
|
this.blip(120, 0.13, "sine", 0.2);
|
|
},
|
|
ding() {
|
|
this.blip(1318, 0.5, "sine", 0.3); // E6
|
|
this.blip(1568, 0.55, "sine", 0.25); // G6
|
|
},
|
|
coin() {
|
|
this.blip(1760, 0.06, "square", 0.2);
|
|
setTimeout(() => this.blip(2637, 0.12, "square", 0.2), 60);
|
|
},
|
|
buzz() {
|
|
this.blip(140, 0.45, "sawtooth", 0.3, 70);
|
|
},
|
|
// sirena da allarme per il "nessun match"
|
|
siren() {
|
|
if (!this.on || !this.ctx) return;
|
|
const t = this.ctx.currentTime;
|
|
const o = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
o.type = "sawtooth";
|
|
const freqs = [880, 440, 880, 440, 880, 440, 880];
|
|
freqs.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.16));
|
|
g.gain.setValueAtTime(0.16, t);
|
|
g.gain.setValueAtTime(0.16, t + 1.05);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.2);
|
|
o.connect(g).connect(this.master);
|
|
o.start(t);
|
|
o.stop(t + 1.25);
|
|
},
|
|
// nota di ottone per la fanfara
|
|
brass(freq, when, dur, vol = 0.25) {
|
|
if (!this.on || !this.ctx) return;
|
|
const t = this.ctx.currentTime + when;
|
|
const o = this.ctx.createOscillator();
|
|
const o2 = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
const lp = this.ctx.createBiquadFilter();
|
|
o.type = "sawtooth";
|
|
o2.type = "square";
|
|
o.frequency.value = freq;
|
|
o2.frequency.value = freq * 2;
|
|
lp.type = "lowpass";
|
|
lp.frequency.value = 2800;
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(vol, t + 0.03);
|
|
g.gain.setValueAtTime(vol, t + dur * 0.65);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
o.connect(g);
|
|
o2.connect(g);
|
|
g.connect(lp).connect(this.master);
|
|
o.start(t);
|
|
o2.start(t);
|
|
o.stop(t + dur + 0.02);
|
|
o2.stop(t + dur + 0.02);
|
|
},
|
|
// trombe trionfali: "ta ta-ta-taaa"
|
|
fanfare() {
|
|
this.brass(392, 0.0, 0.12, 0.22); // G4
|
|
this.brass(523, 0.14, 0.12, 0.24); // C5
|
|
this.brass(659, 0.28, 0.12, 0.24); // E5
|
|
this.brass(784, 0.42, 0.7, 0.3); // G5 tenuta
|
|
for (let i = 0; i < 6; i++)
|
|
setTimeout(
|
|
() => this.blip(1568 + Math.random() * 700, 0.08, "sine", 0.1),
|
|
520 + i * 60,
|
|
);
|
|
},
|
|
jackpot() {
|
|
const notes = [523, 659, 784, 1046, 1318, 1568];
|
|
notes.forEach((f, i) =>
|
|
setTimeout(() => this.blip(f, 0.35, "square", 0.28), i * 90),
|
|
);
|
|
// scintillio
|
|
for (let i = 0; i < 10; i++)
|
|
setTimeout(
|
|
() => this.blip(1800 + Math.random() * 1400, 0.08, "sine", 0.12),
|
|
600 + i * 70,
|
|
);
|
|
},
|
|
// riser di tensione: ritorna una funzione stop()
|
|
riser(duration) {
|
|
if (!this.on || !this.ctx) return () => {};
|
|
const t = this.ctx.currentTime;
|
|
const o = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
o.type = "sawtooth";
|
|
o.frequency.setValueAtTime(180, t);
|
|
o.frequency.exponentialRampToValueAtTime(900, t + duration);
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(0.12, t + duration * 0.7);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + duration + 0.1);
|
|
const lp = this.ctx.createBiquadFilter();
|
|
lp.type = "lowpass";
|
|
lp.frequency.value = 1200;
|
|
o.connect(g).connect(lp).connect(this.master);
|
|
o.start(t);
|
|
o.stop(t + duration + 0.2);
|
|
return () => {
|
|
try {
|
|
g.gain.cancelScheduledValues(this.ctx.currentTime);
|
|
g.gain.setTargetAtTime(0.0001, this.ctx.currentTime, 0.03);
|
|
} catch (e) {}
|
|
};
|
|
},
|
|
};
|
|
|
|
/* ---------- Campioni reali stile slot (Mixkit, royalty-free) ----------
|
|
File in /sounds. Usiamo HTMLAudioElement (non fetch+decode) così
|
|
funziona anche aprendo l'app via file:// dove Chrome blocca fetch dei
|
|
file locali. Rispetta il mute globale (Audio.on) e fa fallback ai suoni
|
|
sintetizzati se un file non carica. */
|
|
const Samples = {
|
|
files: {
|
|
reelSpin: "sounds/reel-spin.mp3", // whirr dei rulli durante il roll
|
|
win: "sounds/win.mp3", // jingle di vincita (match normale)
|
|
jackpot: "sounds/jackpot.mp3", // sirena di jackpot (idromassaggio)
|
|
coins: "sounds/coins.mp3", // pioggia di monete
|
|
shalom: "sounds/shalom.mp3", // musica dell'effetto "shalom" (vittoria jacuzzi)
|
|
leone: "sounds/leone.m4a", // audio dell'effetto leone infuocato (AAC, compat. Safari)
|
|
gesuSong: "sounds/gesu_song.mp3", // canzone dell'effetto "Gesù"
|
|
},
|
|
el: {},
|
|
ready: {},
|
|
preload() {
|
|
Object.entries(this.files).forEach(([k, url]) => {
|
|
const a = new window.Audio(url); // window.Audio: qui "Audio" è l'oggetto synth
|
|
a.preload = "auto";
|
|
a.addEventListener("canplaythrough", () => (this.ready[k] = true), {
|
|
once: true,
|
|
});
|
|
a.addEventListener("error", () => (this.ready[k] = false));
|
|
this.el[k] = a;
|
|
});
|
|
},
|
|
// ritorna una funzione stop(fadeSec) per interromperlo (utile per il loop)
|
|
play(key, opts = {}) {
|
|
const { vol = 0.8, loop = false, fallback, gain } = opts;
|
|
if (!Audio.on) return () => {};
|
|
const base = this.el[key];
|
|
if (!base || this.ready[key] === false) {
|
|
if (fallback) fallback();
|
|
return () => {};
|
|
}
|
|
const a = loop ? base : base.cloneNode(); // clone -> più play sovrapposti
|
|
a.loop = loop;
|
|
a.volume = vol;
|
|
// Boost oltre il max (1.0) solo per QUESTO suono: instrada l'elemento in
|
|
// Web Audio con un GainNode dedicato (non tocca il volume globale).
|
|
if (gain && gain > 1 && Audio.ctx) {
|
|
try {
|
|
const src = Audio.ctx.createMediaElementSource(a);
|
|
const g = Audio.ctx.createGain();
|
|
g.gain.value = gain;
|
|
src.connect(g).connect(Audio.ctx.destination);
|
|
} catch (e) {}
|
|
}
|
|
try {
|
|
a.currentTime = 0;
|
|
} catch (e) {}
|
|
const p = a.play();
|
|
if (p && p.catch) p.catch(() => fallback && fallback());
|
|
return (fade = 0.2) => {
|
|
const steps = 8;
|
|
let i = 0;
|
|
const iv = setInterval(
|
|
() => {
|
|
i++;
|
|
a.volume = Math.max(0, vol * (1 - i / steps));
|
|
if (i >= steps) {
|
|
clearInterval(iv);
|
|
try {
|
|
a.pause();
|
|
} catch (e) {}
|
|
}
|
|
},
|
|
(fade * 1000) / steps,
|
|
);
|
|
};
|
|
},
|
|
};
|
|
Samples.preload();
|
|
|
|
/* ---------- Coriandoli (canvas a schermo intero, dimensionato a mano) ---------- */
|
|
// IMPORTANTE: niente useWorker + niente resize automatico. Dimensiono io il
|
|
// canvas alla viewport, altrimenti il buffer resta 300x150 e i coriandoli
|
|
// finiscono tutti ammassati nell'angolo in alto a sinistra.
|
|
const confettiCanvas = $("#confetti");
|
|
function sizeConfetti() {
|
|
confettiCanvas.width = window.innerWidth;
|
|
confettiCanvas.height = window.innerHeight;
|
|
}
|
|
sizeConfetti();
|
|
window.addEventListener("resize", sizeConfetti);
|
|
const myConfetti =
|
|
typeof confetti !== "undefined"
|
|
? confetti.create(confettiCanvas, { resize: false, useWorker: false })
|
|
: () => {};
|
|
|
|
const CONFETTI_COLORS = [
|
|
"#ffd23f",
|
|
"#ff9e00",
|
|
"#ff2e88",
|
|
"#00e5ff",
|
|
"#29e08a",
|
|
"#ff3b5c",
|
|
"#ffffff",
|
|
];
|
|
|
|
// VALANGA di coriandoli su TUTTO lo schermo
|
|
function bigBurst() {
|
|
const C = CONFETTI_COLORS;
|
|
// esplosione centrale gigante a 360°
|
|
myConfetti({
|
|
particleCount: 400,
|
|
spread: 360,
|
|
startVelocity: 45,
|
|
scalar: 1.1,
|
|
origin: { x: 0.5, y: 0.5 },
|
|
colors: C,
|
|
});
|
|
// fila di esplosioni lungo TUTTA la larghezza
|
|
for (let x = 0; x <= 1.0001; x += 0.1) {
|
|
myConfetti({
|
|
particleCount: 60,
|
|
spread: 110,
|
|
startVelocity: 55,
|
|
origin: { x, y: 0.55 },
|
|
colors: C,
|
|
});
|
|
}
|
|
// cannoni laterali potenti dal basso
|
|
myConfetti({
|
|
particleCount: 250,
|
|
angle: 60,
|
|
spread: 120,
|
|
startVelocity: 75,
|
|
origin: { x: 0, y: 0.85 },
|
|
colors: C,
|
|
});
|
|
myConfetti({
|
|
particleCount: 250,
|
|
angle: 120,
|
|
spread: 120,
|
|
startVelocity: 75,
|
|
origin: { x: 1, y: 0.85 },
|
|
colors: C,
|
|
});
|
|
// pioggia dall'alto su tutta la larghezza
|
|
for (let x = 0; x <= 1.0001; x += 0.1) {
|
|
myConfetti({
|
|
particleCount: 40,
|
|
spread: 160,
|
|
startVelocity: 35,
|
|
gravity: 1,
|
|
origin: { x, y: -0.1 },
|
|
colors: C,
|
|
});
|
|
}
|
|
}
|
|
|
|
// pioggia continua a schermo pieno per N ms
|
|
function confettiRain(durationMs) {
|
|
const end = Date.now() + durationMs;
|
|
(function frame() {
|
|
for (let x = 0; x <= 1.0001; x += 0.12) {
|
|
myConfetti({
|
|
particleCount: 10,
|
|
spread: 130,
|
|
startVelocity: 45,
|
|
gravity: 1,
|
|
origin: { x, y: -0.05 },
|
|
colors: CONFETTI_COLORS,
|
|
});
|
|
}
|
|
if (Date.now() < end) requestAnimationFrame(frame);
|
|
})();
|
|
}
|
|
|
|
function celebrate(kind) {
|
|
bigBurst();
|
|
// seconda ondata immediata per raddoppiare la densità
|
|
setTimeout(bigBurst, 180);
|
|
confettiRain(kind === "jackpot" ? 3500 : 1400);
|
|
}
|
|
|
|
function screenShake() {
|
|
const app = $("#app");
|
|
app.classList.remove("shake");
|
|
void app.offsetWidth;
|
|
app.classList.add("shake");
|
|
}
|
|
function screenFlash() {
|
|
let f = $(".flash");
|
|
if (!f) {
|
|
f = document.createElement("div");
|
|
f.className = "flash";
|
|
document.body.appendChild(f);
|
|
}
|
|
f.classList.remove("go");
|
|
void f.offsetWidth;
|
|
f.classList.add("go");
|
|
}
|
|
|
|
// EFFETTO PAZZO quando i rulli non combaciano: sirena + strobo + glitch + timbro
|
|
function crazyNoMatch() {
|
|
// buzz di errore "sbagliato!" (doppio, stile game-show) prima del caos
|
|
Audio.buzz();
|
|
setTimeout(() => Audio.buzz(), 180);
|
|
setTimeout(() => Audio.siren(), 320); // la sirena entra dopo il buzz
|
|
screenShake();
|
|
|
|
// strobo a schermo intero
|
|
let strobe = $(".strobe");
|
|
if (!strobe) {
|
|
strobe = document.createElement("div");
|
|
strobe.className = "strobe";
|
|
document.body.appendChild(strobe);
|
|
}
|
|
strobe.classList.remove("go");
|
|
void strobe.offsetWidth;
|
|
strobe.classList.add("go");
|
|
|
|
// glitch sui rulli
|
|
const win = $(".reel-window");
|
|
win.classList.add("glitch");
|
|
setTimeout(() => win.classList.remove("glitch"), 900);
|
|
|
|
// timbro "NESSUN MATCH"
|
|
const stamp = document.createElement("div");
|
|
stamp.className = "nomatch-stamp";
|
|
stamp.innerHTML = "<span>❌ NO MATCH ❌</span>";
|
|
document.body.appendChild(stamp);
|
|
setTimeout(() => stamp.remove(), 1300);
|
|
|
|
// coriandoli "sbagliati" (solo rossi) per rincarare la beffa
|
|
myConfetti({
|
|
particleCount: 120,
|
|
spread: 180,
|
|
startVelocity: 40,
|
|
origin: { y: 0.4 },
|
|
colors: ["#ff3b5c", "#8b0022"],
|
|
});
|
|
}
|
|
|
|
/* ---------- Effetto SHALOM (vittoria stanze con idromassaggio) ---------- */
|
|
let shalomOverlay = null;
|
|
function ensureShalomOverlay() {
|
|
if (shalomOverlay) return shalomOverlay;
|
|
const o = document.createElement("div");
|
|
o.className = "shalom-overlay";
|
|
o.innerHTML =
|
|
'<div class="shalom-flames left"></div>' +
|
|
'<div class="shalom-flames right"></div>' +
|
|
'<img class="shalom-lion" src="image/lion.jpg" alt="" />' +
|
|
'<div class="shalom-rays"></div>' +
|
|
'<img class="shalom-gesu" src="image/gesu.jpeg" alt="" />' +
|
|
'<video class="shalom-video" src="video/videoplayback.mp4" muted playsinline preload="auto"></video>' +
|
|
'<div class="shalom-skip">tap per saltare ▸</div>';
|
|
document.body.appendChild(o);
|
|
shalomOverlay = o;
|
|
return o;
|
|
}
|
|
function showShalom(o, mode) {
|
|
o.classList.remove("mode-fire", "mode-video");
|
|
o.classList.add("on", mode);
|
|
}
|
|
function hideShalom(o) {
|
|
const v = o.querySelector(".shalom-video");
|
|
try {
|
|
v.pause();
|
|
} catch (e) {}
|
|
o.classList.remove("on", "mode-fire", "mode-video");
|
|
}
|
|
// Attende: fine video, tetto di tempo, oppure tap sull'overlay (skip).
|
|
function shalomWait(v, capMs, o) {
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(to);
|
|
if (v) v.removeEventListener("ended", finish);
|
|
o.removeEventListener("pointerdown", finish);
|
|
resolve();
|
|
};
|
|
const to = setTimeout(finish, capMs);
|
|
if (v) v.addEventListener("ended", finish);
|
|
o.addEventListener("pointerdown", finish);
|
|
});
|
|
}
|
|
|
|
// EFFETTO 1: fiammate dai due angoli in basso + leone (flashbang) ~3.5s
|
|
async function shalomFireLion() {
|
|
const o = ensureShalomOverlay();
|
|
showShalom(o, "mode-fire");
|
|
screenShake();
|
|
const stop = Samples.play("leone", {
|
|
vol: 1.0,
|
|
gain: 2.5, // ruggito più forte del resto (boost dedicato)
|
|
fallback: () => Audio.jackpot(),
|
|
});
|
|
await shalomWait(null, 3500, o);
|
|
if (stop) stop(0.4);
|
|
hideShalom(o);
|
|
}
|
|
|
|
// EFFETTO 2: video a schermo intero con la musica shalom
|
|
async function shalomVideoOnly() {
|
|
const o = ensureShalomOverlay();
|
|
const v = o.querySelector(".shalom-video");
|
|
showShalom(o, "mode-video");
|
|
v.style.visibility = "hidden"; // primi 3s: solo musica, video nascosto
|
|
const stop = Samples.play("shalom", {
|
|
vol: 0.9,
|
|
fallback: () => Audio.jackpot(),
|
|
});
|
|
await sleep(3000); // dopo 3s il video appare E parte insieme
|
|
v.style.visibility = "";
|
|
try {
|
|
v.currentTime = 0;
|
|
await v.play();
|
|
} catch (e) {}
|
|
await shalomWait(v, 17000, o); // 3s musica + 17s video = 20s (o tap per skippare)
|
|
if (stop) stop(0.4);
|
|
hideShalom(o);
|
|
}
|
|
|
|
// EFFETTO 3: Gesù scende dall'alto con luce evangelica + canzone
|
|
async function shalomGesu() {
|
|
const o = ensureShalomOverlay();
|
|
showShalom(o, "mode-gesu");
|
|
const stop = Samples.play("gesuSong", {
|
|
vol: 0.9,
|
|
fallback: () => Audio.fanfare(),
|
|
});
|
|
await shalomWait(null, 14000, o);
|
|
if (stop) stop(0.5);
|
|
hideShalom(o);
|
|
}
|
|
|
|
const SHALOM_EFFECTS = [shalomFireLion, shalomVideoOnly, shalomGesu];
|
|
const SHALOM_BY_NAME = {
|
|
fire: shalomFireLion,
|
|
video: shalomVideoOnly,
|
|
gesu: shalomGesu,
|
|
};
|
|
// "sacchetto" mescolato: a ogni vittoria jacuzzi esce un effetto DIVERSO
|
|
// (senza ripetizioni) finché non sono usciti tutti, poi si rimescola.
|
|
let shalomBag = [];
|
|
function playShalom() {
|
|
if (FORCE_SHALOM && SHALOM_BY_NAME[FORCE_SHALOM]) {
|
|
return SHALOM_BY_NAME[FORCE_SHALOM](); // effetto fisso (debug)
|
|
}
|
|
if (shalomBag.length === 0) {
|
|
shalomBag = Matching.shuffle(SHALOM_EFFECTS.slice(), Math.random);
|
|
}
|
|
const fx = shalomBag.shift();
|
|
return fx();
|
|
}
|
|
|
|
/* ---------- Sfondo three.js: pioggia di monete ---------- */
|
|
function initBackground() {
|
|
if (typeof THREE === "undefined") return;
|
|
const canvas = $("#bg3d");
|
|
let renderer;
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
alpha: true,
|
|
});
|
|
} catch (e) {
|
|
return; // WebGL non disponibile: l'app funziona lo stesso senza sfondo 3D
|
|
}
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 100);
|
|
camera.position.z = 30;
|
|
|
|
// texture moneta disegnata su canvas
|
|
const tex = (() => {
|
|
const c = document.createElement("canvas");
|
|
c.width = c.height = 64;
|
|
const g = c.getContext("2d");
|
|
const grad = g.createRadialGradient(24, 20, 4, 32, 32, 30);
|
|
grad.addColorStop(0, "#fff6cf");
|
|
grad.addColorStop(0.4, "#ffd23f");
|
|
grad.addColorStop(1, "#b36b00");
|
|
g.fillStyle = grad;
|
|
g.beginPath();
|
|
g.arc(32, 32, 28, 0, Math.PI * 2);
|
|
g.fill();
|
|
g.fillStyle = "rgba(179,107,0,0.9)";
|
|
g.font = "bold 30px Georgia";
|
|
g.textAlign = "center";
|
|
g.textBaseline = "middle";
|
|
g.fillText("€", 32, 34);
|
|
return new THREE.CanvasTexture(c);
|
|
})();
|
|
|
|
const N = 520;
|
|
const geo = new THREE.BufferGeometry();
|
|
const pos = new Float32Array(N * 3);
|
|
const speed = new Float32Array(N);
|
|
for (let i = 0; i < N; i++) {
|
|
pos[i * 3] = (Math.random() - 0.5) * 80;
|
|
pos[i * 3 + 1] = (Math.random() - 0.5) * 80;
|
|
pos[i * 3 + 2] = (Math.random() - 0.5) * 40;
|
|
speed[i] = 6 + Math.random() * 12;
|
|
}
|
|
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
|
const mat = new THREE.PointsMaterial({
|
|
size: 1.7,
|
|
map: tex,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
const points = new THREE.Points(geo, mat);
|
|
scene.add(points);
|
|
|
|
function resize() {
|
|
const w = innerWidth,
|
|
h = innerHeight;
|
|
renderer.setSize(w, h, false);
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
addEventListener("resize", resize);
|
|
resize();
|
|
|
|
let last = performance.now();
|
|
let boost = 0;
|
|
window.__coinBoost = () => (boost = 1.6);
|
|
(function loop(now) {
|
|
const dt = Math.min((now - last) / 1000, 0.05);
|
|
last = now;
|
|
const arr = geo.attributes.position.array;
|
|
const mul = 1 + boost;
|
|
for (let i = 0; i < N; i++) {
|
|
arr[i * 3 + 1] -= speed[i] * dt * mul;
|
|
if (arr[i * 3 + 1] < -40) arr[i * 3 + 1] = 40;
|
|
}
|
|
geo.attributes.position.needsUpdate = true;
|
|
points.rotation.y += 0.02 * dt;
|
|
boost *= 0.94;
|
|
renderer.render(scene, camera);
|
|
requestAnimationFrame(loop);
|
|
})(last);
|
|
}
|
|
|
|
/* ---------- Rulli slot (3 colonne) ---------- */
|
|
const ITEM_H = 88;
|
|
const reelEls = [$("#reel0"), $("#reel1"), $("#reel2")];
|
|
|
|
// Stanze FANTASMA: scorrono nei rulli solo per gaslightare, non escono MAI
|
|
// come risultato (né nel match né nel no-match). Al massimo compaiono in un
|
|
// finto stop, poi scattano via.
|
|
const FAKE_ROOMS = [
|
|
"Piscina",
|
|
"Nel baule di una macchina",
|
|
"QG di Allianz",
|
|
"Cucina",
|
|
"Bagno",
|
|
"Garlasco",
|
|
"Predappio",
|
|
"Backrooms",
|
|
].map((nome) => ({ nome, fake: true }));
|
|
const FAKES_PER_LOOP = 3; // costante -> indice target uguale su tutti i rulli
|
|
|
|
function reelItemHTML(entry) {
|
|
if (entry.fake) {
|
|
return `<div class="reel-item fake" data-fake="1"><span>${entry.nome}</span></div>`;
|
|
}
|
|
const jz = entry.jacuzzi ? " jz" : "";
|
|
const icon = entry.jacuzzi ? '<span class="jz-badge">🛁</span>' : "";
|
|
return `<div class="reel-item${jz}" data-room="${entry.id}">${icon}<span>${entry.nome}</span></div>`;
|
|
}
|
|
|
|
// Costruisce la striscia di un rullo che termina sulla stanza target
|
|
// (centrata nella riga di mezzo); ritorna l'indice target. Ogni "giro" della
|
|
// sequenza contiene le stanze reali + alcune fantasma (mischiate).
|
|
function buildStripIn(el, targetId) {
|
|
el.getAnimations().forEach((a) => a.cancel());
|
|
const loops = 12;
|
|
const seq = [];
|
|
for (let l = 0; l < loops; l++) {
|
|
const fakes = Matching.shuffle(FAKE_ROOMS, Math.random).slice(
|
|
0,
|
|
FAKES_PER_LOOP,
|
|
);
|
|
const chunk = Matching.shuffle([...ROOMS, ...fakes], Math.random);
|
|
seq.push(...chunk);
|
|
}
|
|
const targetIndex = seq.length; // la target (reale) va alla payline
|
|
seq.push(roomById[targetId]);
|
|
seq.push(ROOMS[0], ROOMS[1]); // riempi sotto la finestra (stanze reali)
|
|
el.innerHTML = seq.map(reelItemHTML).join("");
|
|
el.style.transform = "translateY(0)";
|
|
return targetIndex;
|
|
}
|
|
|
|
// stato a riposo: mostra le stanze reali ferme invece di rulli vuoti
|
|
function fillReelsIdle() {
|
|
reelEls.forEach((el, i) => {
|
|
el.getAnimations().forEach((a) => a.cancel());
|
|
el.innerHTML = ROOMS.map(reelItemHTML).join("");
|
|
const k = i + 1; // centra una stanza diversa per rullo
|
|
el.style.transform = `translateY(${-(k - 1) * ITEM_H}px)`;
|
|
});
|
|
}
|
|
|
|
function currentIndexOf(el) {
|
|
const t = getComputedStyle(el).transform;
|
|
if (!t || t === "none") return 0;
|
|
const y = parseFloat(t.split(",")[5]); // matrix(a,b,c,d,e,f) -> f = translateY
|
|
return Math.round(-y / ITEM_H);
|
|
}
|
|
|
|
// stanza reale a un indice (null se è una fantasma o non esiste)
|
|
const roomAtIndex = (el, i) => {
|
|
const c = el.children[i];
|
|
return c && c.dataset.room ? roomById[c.dataset.room] : null;
|
|
};
|
|
// nome mostrato a un indice (reale o fantasma)
|
|
const nameAtIndex = (el, i) => {
|
|
const c = el.children[i];
|
|
if (!c) return "";
|
|
return c.dataset.room
|
|
? roomById[c.dataset.room].nome
|
|
: c.textContent.trim();
|
|
};
|
|
|
|
// Probabilità di ragebait (finto stop) per colonna.
|
|
const TEASE_CHANCE = 0.3;
|
|
// Probabilità che il giro NON dia match (3 stanze diverse) -> respin.
|
|
const NOMATCH_CHANCE = 0.3;
|
|
|
|
// 3 stanze DIVERSE per un "nessun match" (mai una linea vincente)
|
|
function pickMismatch() {
|
|
return Matching.shuffle(
|
|
ROOMS.map((r) => r.id),
|
|
Math.random,
|
|
).slice(0, 3);
|
|
}
|
|
|
|
// Keyframe di decelerazione con coda lenta LUNGA a parità di durata totale:
|
|
// ~88% della distanza nel primo ~40% del tempo (veloce), poi l'ultimo 12%
|
|
// nel restante ~60% (crawl lento, così si leggono i nomi ~2s in più).
|
|
function decelKeyframes(targetY) {
|
|
return [
|
|
{ transform: "translateY(0)", easing: "cubic-bezier(0.17, 0.7, 0.4, 1)" },
|
|
{
|
|
transform: `translateY(${targetY * 0.88}px)`,
|
|
offset: 0.4,
|
|
easing: "cubic-bezier(0.25, 0.1, 0.2, 1)",
|
|
},
|
|
{ transform: `translateY(${targetY}px)`, offset: 1 },
|
|
];
|
|
}
|
|
|
|
function bounceReel(el, finalY) {
|
|
return el.animate(
|
|
[
|
|
{ transform: `translateY(${finalY}px)` },
|
|
{ transform: `translateY(${finalY + 12}px)` },
|
|
{ transform: `translateY(${finalY}px)` },
|
|
],
|
|
{ duration: 220, easing: "ease-out", fill: "forwards" },
|
|
).finished;
|
|
}
|
|
|
|
// Atterraggio di un rullo sulla target. Se teaseMs > 0 fa il RAGEBAIT:
|
|
// si ferma su una stanza adiacente sbagliata, tiene la suspense (teaseMs),
|
|
// poi scatta sulla vera. Funziona su QUALSIASI colonna.
|
|
async function reelLand(el, targetIndex, finalY, duration, teaseMs) {
|
|
// eventuale bluff: fermati su una casella adiacente diversa dalla vera
|
|
// (può essere anche una stanza FANTASMA -> gaslighting massimo)
|
|
if (teaseMs > 0) {
|
|
const targetRoom = roomAtIndex(el, targetIndex);
|
|
// true se l'adiacente è proprio la stanza vera (da evitare come finta)
|
|
const adjIsTarget = (d) =>
|
|
roomAtIndex(el, targetIndex + d)?.id === targetRoom.id;
|
|
let dir = Math.random() < 0.5 ? -1 : 1;
|
|
if (adjIsTarget(dir)) dir = -dir;
|
|
if (adjIsTarget(dir)) teaseMs = 0; // entrambe le adiacenti sono la target
|
|
if (teaseMs > 0) {
|
|
const fakeName = nameAtIndex(el, targetIndex + dir);
|
|
const fakeY = -(targetIndex + dir - 1) * ITEM_H;
|
|
const col = el.parentElement;
|
|
|
|
// decelera lento e "si ferma" sulla stanza SBAGLIATA
|
|
await el.animate(decelKeyframes(fakeY), {
|
|
duration,
|
|
fill: "forwards",
|
|
}).finished;
|
|
Audio.reelStop();
|
|
|
|
// suspense su quella colonna
|
|
Audio.blip(330, 0.13, "sine", 0.14);
|
|
col.classList.add("teasing");
|
|
mc(`${fakeName}...? 🤨`);
|
|
await sleep(teaseMs);
|
|
|
|
// SCATTO alla stanza vera
|
|
Audio.blip(200, 0.12, "sawtooth", 0.2, 90); // whoosh
|
|
await el.animate(
|
|
[
|
|
{ transform: `translateY(${fakeY}px)` },
|
|
{ transform: `translateY(${finalY}px)` },
|
|
],
|
|
{
|
|
duration: 480,
|
|
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
|
|
fill: "forwards",
|
|
},
|
|
).finished;
|
|
col.classList.remove("teasing");
|
|
Audio.reelStop();
|
|
return;
|
|
}
|
|
}
|
|
// atterraggio diretto (coda lenta lunga) + micro-assestamento
|
|
await el.animate(decelKeyframes(finalY), {
|
|
duration,
|
|
fill: "forwards",
|
|
}).finished;
|
|
await bounceReel(el, finalY);
|
|
Audio.reelStop();
|
|
}
|
|
|
|
// GIRO: i 3 rulli partono insieme e si fermano da sinistra a destra,
|
|
// allineandosi sulla stanza assegnata. OGNI colonna può fare il ragebait
|
|
// (in modo indipendente); le colonne più a destra tengono la suspense più a
|
|
// lungo, così il dramma "cascata" verso l'ultimo rullo.
|
|
async function spinReel(reelTargets, opts) {
|
|
const tease = !opts || opts.tease !== false;
|
|
// ogni rullo può atterrare su una stanza diversa (per il "nessun match")
|
|
const targetIndex = reelEls.map((el, i) =>
|
|
buildStripIn(el, reelTargets[i]),
|
|
)[0];
|
|
const finalY = -(targetIndex - 1) * ITEM_H;
|
|
$("#reel-result").innerHTML = " ";
|
|
|
|
// ZOOM + shake; lo shake si ferma presto, così la parte lenta è calma e
|
|
// leggibile mentre i nomi scorrono piano
|
|
zoomMachineIn();
|
|
const mainDur = 6400;
|
|
setTimeout(zoomMachineSteady, 2500);
|
|
|
|
const stopRiser = Audio.riser(7.0);
|
|
// whirr reale dei rulli sotto ai tick/riser sintetizzati
|
|
const stopWheel = Samples.play("reelSpin", { vol: 0.65 });
|
|
|
|
// tick sincronizzati sul 3° rullo (gira più a lungo)
|
|
let ticking = true;
|
|
let lastIdx = 0;
|
|
(function loop() {
|
|
if (!ticking) return;
|
|
const i = currentIndexOf(reelEls[2]);
|
|
if (i !== lastIdx) {
|
|
Audio.tick();
|
|
lastIdx = i;
|
|
}
|
|
requestAnimationFrame(loop);
|
|
})();
|
|
|
|
const durs = [4200, 5300, 6400];
|
|
await Promise.all(
|
|
reelEls.map((el, i) => {
|
|
// ogni colonna ha una sua chance di bluff; suspense crescente verso destra
|
|
const teaseMs =
|
|
tease && Math.random() < TEASE_CHANCE ? 380 + i * 260 : 0;
|
|
return reelLand(el, targetIndex, finalY, durs[i], teaseMs);
|
|
}),
|
|
);
|
|
|
|
ticking = false;
|
|
stopRiser();
|
|
stopWheel(0.25); // taglia il whirr quando l'ultimo rullo atterra
|
|
zoomMachineSteady();
|
|
}
|
|
|
|
/* ---------- Zoom macchina ---------- */
|
|
let _shakeTimer = null;
|
|
function zoomMachineIn() {
|
|
const slot = $(".slot");
|
|
slot.getAnimations().forEach((a) => a.cancel()); // pulisce un eventuale ritorno in corso
|
|
slot.classList.add("zoom");
|
|
clearTimeout(_shakeTimer);
|
|
_shakeTimer = setTimeout(() => slot.classList.add("shake"), 460); // shake dopo lo zoom-in
|
|
}
|
|
function zoomMachineSteady() {
|
|
clearTimeout(_shakeTimer);
|
|
$(".slot").classList.remove("shake"); // resta zoomata, ma ferma
|
|
}
|
|
function zoomMachineOut() {
|
|
const slot = $(".slot");
|
|
clearTimeout(_shakeTimer);
|
|
slot.classList.remove("shake");
|
|
slot.classList.remove("zoom");
|
|
// mini-animazione a molla per tornare al posto (leggero rimbalzo sotto l'1)
|
|
slot.animate(
|
|
[
|
|
{ transform: "scale(1.14)" },
|
|
{ transform: "scale(0.985)" },
|
|
{ transform: "scale(1)" },
|
|
],
|
|
{ duration: 620, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" },
|
|
);
|
|
}
|
|
|
|
/* ---------- Stato di gioco ---------- */
|
|
let assignment = null; // {byPerson, roomGender, fRooms, mRooms}
|
|
let revealQueue = []; // nomi in ordine di reveal
|
|
let occupants = {}; // roomId -> [nomi] (svelati finora)
|
|
let cursor = 0;
|
|
let auto = false;
|
|
let busy = false;
|
|
let leverArmed = false;
|
|
let noMatchStreak = 0; // no-match consecutivi (tetto per non incastrare)
|
|
|
|
// scommettitori (TU + banco simulato)
|
|
let players = [];
|
|
let currentBet = null; // {roomId}
|
|
|
|
function buildRevealOrder() {
|
|
// ordine completamente casuale: ogni persona (e quindi ogni stanza, anche
|
|
// quelle con idromassaggio) può uscire in qualsiasi momento
|
|
return Matching.shuffle(
|
|
PARTICIPANTS.map((p) => p.nome),
|
|
Math.random,
|
|
);
|
|
}
|
|
|
|
function newGame(keepChips) {
|
|
assignment = Matching.assign(ROOMS, PARTICIPANTS);
|
|
revealQueue = buildRevealOrder();
|
|
occupants = {};
|
|
ROOMS.forEach((r) => (occupants[r.id] = []));
|
|
cursor = 0;
|
|
currentBet = null;
|
|
noMatchStreak = 0;
|
|
shalomBag = [];
|
|
if (!keepChips) {
|
|
players = [
|
|
{ name: "TU", chips: 100, you: true },
|
|
{ name: "Il Banco", chips: 100 },
|
|
{ name: "Madama Fortuna", chips: 100 },
|
|
{ name: "Jinx", chips: 100 },
|
|
];
|
|
}
|
|
renderRooms();
|
|
renderLeaderboard();
|
|
setProgress();
|
|
fillReelsIdle();
|
|
$("#reel-result").innerHTML = " ";
|
|
$("#player-name").textContent = "—";
|
|
$("#odds").innerHTML = "";
|
|
mc("Tira giù la leva per far salire il primo! ⬇️");
|
|
// il bottone principale serve solo per RIGIOCA a fine partita
|
|
$("#btn-primary").style.display = "none";
|
|
$("#btn-primary").onclick = onPrimary;
|
|
armLever(true);
|
|
}
|
|
|
|
/* ---------- Rendering ---------- */
|
|
function renderRooms() {
|
|
const board = $("#rooms-board");
|
|
board.innerHTML = ROOMS.map((r) => {
|
|
const g = assignment.roomGender[r.id];
|
|
const occ = occupants[r.id];
|
|
const chips = occ
|
|
.map((n) => `<span class="occ-chip">${n}</span>`)
|
|
.join("");
|
|
const emptyCount = r.posti - occ.length;
|
|
const empties = Array.from({ length: emptyCount })
|
|
.map(() => `<span class="occ-slot-empty"></span>`)
|
|
.join("");
|
|
return `
|
|
<div class="room-card gender-${g} ${r.jacuzzi ? "jacuzzi" : ""}" data-room="${r.id}">
|
|
<div class="room-head">
|
|
<span class="room-name">${r.jacuzzi ? "🛁 " : ""}${r.nome}</span>
|
|
<span class="room-cap">${occ.length}/${r.posti} · ${g}</span>
|
|
</div>
|
|
<div class="room-occupants">${chips}${empties}</div>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
function renderLeaderboard() {
|
|
const sorted = players.slice().sort((a, b) => b.chips - a.chips);
|
|
$("#leaderboard").innerHTML = sorted
|
|
.map(
|
|
(p) =>
|
|
`<div class="lb-row ${p.you ? "you" : ""}"><span>${p.you ? "⭐ " : ""}${p.name}</span><span class="lb-chips">${p.chips}</span></div>`,
|
|
)
|
|
.join("");
|
|
$("#balance").textContent = players.find((p) => p.you).chips;
|
|
}
|
|
|
|
function setProgress() {
|
|
$("#progress").textContent = `${cursor} / ${revealQueue.length}`;
|
|
}
|
|
|
|
function mc(text) {
|
|
$("#mc").textContent = text;
|
|
}
|
|
|
|
function log(text, cls) {
|
|
const e = document.createElement("div");
|
|
e.className = "log-entry " + (cls || "");
|
|
e.textContent = text;
|
|
$("#log").prepend(e);
|
|
}
|
|
|
|
/* ---------- Scommesse ---------- */
|
|
// Quote cosmetiche: più letti liberi in una stanza => più probabile => quota bassa.
|
|
function computeOdds(person) {
|
|
const g = genderOf[person];
|
|
const rooms = ROOMS.filter(
|
|
(r) =>
|
|
assignment.roomGender[r.id] === g && occupants[r.id].length < r.posti,
|
|
);
|
|
const freeTotal = rooms.reduce(
|
|
(s, r) => s + (r.posti - occupants[r.id].length),
|
|
0,
|
|
);
|
|
return rooms.map((r) => {
|
|
const free = r.posti - occupants[r.id].length;
|
|
let mult = freeTotal / free;
|
|
mult = Math.max(1.3, Math.round(mult * 10) / 10);
|
|
return { room: r, mult };
|
|
});
|
|
}
|
|
|
|
function renderOdds(person, odds) {
|
|
const hint = currentBet
|
|
? `Hai puntato 10 gettoni su <b>${roomById[currentBet.roomId].nome}</b>`
|
|
: "Clicca una stanza per puntare 10 gettoni (o SPINNA)";
|
|
$("#odds").innerHTML =
|
|
`<div class="bet-hint">${hint}</div>` +
|
|
odds
|
|
.map(
|
|
(o) =>
|
|
`<div class="odd-row ${currentBet && currentBet.roomId === o.room.id ? "picked" : ""}" data-bet="${o.room.id}">
|
|
<span class="odd-name">${o.room.jacuzzi ? "🛁 " : ""}${o.room.nome}</span>
|
|
<span class="odd-mult">x${o.mult.toFixed(1)}</span>
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
$("#odds")
|
|
.querySelectorAll(".odd-row")
|
|
.forEach((row) =>
|
|
row.addEventListener("click", () => {
|
|
if (busy) return;
|
|
const rid = row.getAttribute("data-bet");
|
|
currentBet = { roomId: rid };
|
|
Audio.coin();
|
|
renderOdds(person, odds);
|
|
}),
|
|
);
|
|
}
|
|
|
|
// I bot puntano sul favorito (quota più bassa) o a caso.
|
|
function botsPlace(odds) {
|
|
players
|
|
.filter((p) => !p.you)
|
|
.forEach((p) => {
|
|
if (p.chips < 10) {
|
|
p._bet = null;
|
|
return;
|
|
}
|
|
const pick =
|
|
Math.random() < 0.6
|
|
? odds.slice().sort((a, b) => a.mult - b.mult)[0]
|
|
: odds[Math.floor(Math.random() * odds.length)];
|
|
p._bet = pick ? { roomId: pick.room.id, mult: pick.mult } : null;
|
|
});
|
|
}
|
|
|
|
function resolveBets(person, landedRoomId, oddsMap) {
|
|
// TU
|
|
const you = players.find((p) => p.you);
|
|
if (currentBet) {
|
|
const o = oddsMap[currentBet.roomId];
|
|
if (currentBet.roomId === landedRoomId) {
|
|
const win = Math.round(10 * o);
|
|
you.chips += win - 10;
|
|
log(
|
|
`Hai vinto ${win} gettoni! (${roomById[landedRoomId].nome})`,
|
|
"win",
|
|
);
|
|
} else {
|
|
you.chips -= 10;
|
|
log(`Hai perso la puntata su ${roomById[currentBet.roomId].nome}.`);
|
|
}
|
|
}
|
|
// bot
|
|
players
|
|
.filter((p) => !p.you && p._bet)
|
|
.forEach((p) => {
|
|
if (p._bet.roomId === landedRoomId)
|
|
p.chips += Math.round(10 * p._bet.mult) - 10;
|
|
else p.chips -= 10;
|
|
});
|
|
}
|
|
|
|
/* ---------- Turno di reveal ---------- */
|
|
async function nextTurn() {
|
|
if (busy) return;
|
|
if (cursor >= revealQueue.length) {
|
|
endGame();
|
|
return;
|
|
}
|
|
busy = true;
|
|
armLever(false);
|
|
Audio.init();
|
|
Audio.resume();
|
|
|
|
const person = revealQueue[cursor];
|
|
let roomId = assignment.byPerson[person];
|
|
if (FORCE_RESULT) {
|
|
// debug: forza il risultato su una jacuzzi (o su un id specifico)
|
|
const jac = ROOMS.filter((r) => r.jacuzzi);
|
|
roomId =
|
|
typeof FORCE_RESULT === "string"
|
|
? FORCE_RESULT
|
|
: jac[Math.floor(Math.random() * jac.length)].id;
|
|
}
|
|
const room = roomById[roomId];
|
|
|
|
$("#player-name").textContent = person;
|
|
highlightRoom(null);
|
|
|
|
// fase scommesse
|
|
const odds = computeOdds(person);
|
|
const oddsMap = Object.fromEntries(odds.map((o) => [o.room.id, o.mult]));
|
|
renderOdds(person, odds);
|
|
botsPlace(odds);
|
|
mc(`Tocca a ${person}! Dove finirà? 🎲`);
|
|
|
|
// animazione leva + boost monete
|
|
$("#lever").classList.add("pull");
|
|
setTimeout(() => $("#lever").classList.remove("pull"), 320);
|
|
if (window.__coinBoost) window.__coinBoost();
|
|
|
|
// 30% dei giri: NESSUN MATCH (3 stanze diverse) -> effetto pazzo + respin.
|
|
// Tetto di 2 no-match di fila per non bloccare nessuno.
|
|
const noMatch =
|
|
!FORCE_RESULT && noMatchStreak < 2 && Math.random() < NOMATCH_CHANCE;
|
|
|
|
if (noMatch) {
|
|
noMatchStreak++;
|
|
await spinReel(pickMismatch(), { tease: false });
|
|
$("#reel-result").innerHTML = `<b class="rr-nomatch">NESSUN MATCH</b>`;
|
|
crazyNoMatch();
|
|
currentBet = null; // puntate annullate (push): nessun gettone cambia
|
|
log(`${person}: nessun match! Si ri-tira 💥`);
|
|
mc("💥 NESSUN MATCH! Ri-tira la leva! ⬇️");
|
|
setTimeout(zoomMachineOut, 1000);
|
|
busy = false;
|
|
// stesso giocatore: NON avanza il cursore
|
|
if (auto) {
|
|
setTimeout(nextTurn, 1600);
|
|
} else {
|
|
setTimeout(() => {
|
|
if (!busy && cursor < revealQueue.length && !auto) {
|
|
armLever(true);
|
|
mc("💥 NESSUN MATCH! Ri-tira la leva! ⬇️");
|
|
}
|
|
}, 1100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- MATCH: i 3 rulli si allineano sulla stanza assegnata ---
|
|
noMatchStreak = 0;
|
|
await spinReel([roomId, roomId, roomId], { tease: true });
|
|
$("#reel-result").innerHTML =
|
|
`${room.jacuzzi ? "🛁 " : ""}<b>${room.nome}</b>${room.jacuzzi ? " 🛁" : ""}`;
|
|
|
|
// atterraggio
|
|
occupants[roomId].push(person);
|
|
highlightRoom(roomId);
|
|
renderRooms();
|
|
highlightRoom(roomId);
|
|
|
|
resolveBets(person, roomId, oddsMap);
|
|
currentBet = null;
|
|
|
|
const isJackpot = room.jacuzzi;
|
|
Audio.ding(); // "click" di aggancio della stanza
|
|
if (isJackpot) {
|
|
// sirena reale di jackpot; fallback: fanfara + jackpot sintetizzati
|
|
Samples.play("jackpot", {
|
|
vol: 0.9,
|
|
fallback: () => {
|
|
Audio.fanfare();
|
|
Audio.jackpot();
|
|
},
|
|
});
|
|
celebrate("jackpot");
|
|
screenShake();
|
|
screenFlash();
|
|
mc(`🎉 JACKPOT! ${person} conquista ${room.nome} con IDROMASSAGGIO! 🛁`);
|
|
log(`JACKPOT: ${person} → ${room.nome} 🛁`, "jackpot");
|
|
} else {
|
|
// jingle reale di vincita; fallback: fanfara sintetizzata
|
|
Samples.play("win", { vol: 0.85, fallback: () => Audio.fanfare() });
|
|
celebrate("normal");
|
|
mc(`${person} va in ${room.nome}!`);
|
|
log(`${person} → ${room.nome}`);
|
|
}
|
|
Samples.play("coins", { vol: 0.6, fallback: () => Audio.coin() });
|
|
|
|
// la macchina resta zoomata per godersi la vittoria, poi torna al posto
|
|
setTimeout(zoomMachineOut, isJackpot ? 1200 : 800);
|
|
|
|
// EFFETTO SHALOM: sulle vittorie jacuzzi (o SEMPRE se FORCE_SHALOM è attivo,
|
|
// per debug: così vedi l'effetto a ogni vittoria, non solo sulle jacuzzi)
|
|
if (isJackpot || FORCE_SHALOM) {
|
|
await sleep(600); // lascia vedere un attimo i coriandoli
|
|
await playShalom();
|
|
}
|
|
|
|
cursor++;
|
|
setProgress();
|
|
renderLeaderboard();
|
|
busy = false;
|
|
|
|
if (cursor >= revealQueue.length) {
|
|
setTimeout(endGame, 1100);
|
|
} else if (auto) {
|
|
setTimeout(nextTurn, 1400);
|
|
} else {
|
|
// riarma la leva per il prossimo giro
|
|
setTimeout(() => {
|
|
if (!busy && cursor < revealQueue.length && !auto) {
|
|
armLever(true);
|
|
mc("Tira di nuovo la leva! ⬇️");
|
|
}
|
|
}, 1300);
|
|
}
|
|
}
|
|
|
|
function highlightRoom(roomId) {
|
|
document
|
|
.querySelectorAll(".room-card")
|
|
.forEach((c) => c.classList.remove("hot"));
|
|
if (roomId) {
|
|
const el = document.querySelector(`.room-card[data-room="${roomId}"]`);
|
|
if (el) el.classList.add("hot");
|
|
}
|
|
}
|
|
|
|
function endGame() {
|
|
armLever(false);
|
|
const winner = players.slice().sort((a, b) => b.chips - a.chips)[0];
|
|
const you = players.find((p) => p.you);
|
|
mc(
|
|
`🏁 Tutti sistemati! Vincitore scommesse: ${winner.name} (${winner.chips} gettoni).`,
|
|
);
|
|
log(
|
|
`Fine. I tuoi gettoni: ${you.chips}. Miglior scommettitore: ${winner.name}.`,
|
|
"win",
|
|
);
|
|
Audio.fanfare();
|
|
celebrate("jackpot");
|
|
$("#btn-primary").style.display = "";
|
|
$("#btn-primary").textContent = "RIGIOCA 🔁";
|
|
$("#btn-primary").disabled = false;
|
|
$("#btn-primary").onclick = () => {
|
|
newGame(false);
|
|
};
|
|
}
|
|
|
|
/* ---------- Marquee ---------- */
|
|
function initMarquee() {
|
|
const m = $("#marquee");
|
|
m.innerHTML = Array.from({ length: 16 })
|
|
.map(
|
|
(_, i) =>
|
|
`<span class="bulb" style="animation-delay:${(i % 8) * 0.12}s"></span>`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
/* ---------- Leva (trigger dei giri) ---------- */
|
|
function armLever(on) {
|
|
leverArmed = on;
|
|
const lever = $("#lever");
|
|
lever.classList.toggle("armed", on);
|
|
}
|
|
|
|
function pullAndSpin() {
|
|
if (!leverArmed || busy) return;
|
|
armLever(false);
|
|
nextTurn();
|
|
}
|
|
|
|
function bindLever() {
|
|
const lever = $("#lever");
|
|
let dragging = false;
|
|
let startY = 0;
|
|
lever.addEventListener("pointerdown", (e) => {
|
|
if (!leverArmed || busy) return;
|
|
dragging = true;
|
|
startY = e.clientY;
|
|
try {
|
|
lever.setPointerCapture(e.pointerId);
|
|
} catch (err) {}
|
|
e.preventDefault();
|
|
});
|
|
lever.addEventListener("pointermove", (e) => {
|
|
if (!dragging) return;
|
|
const dy = Math.max(0, Math.min(70, e.clientY - startY));
|
|
lever.style.transform = `rotate(${(dy / 70) * 28}deg)`;
|
|
});
|
|
const release = (e) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
const dy = Math.max(0, e.clientY - startY);
|
|
lever.style.transform = "";
|
|
// tap secco (dy piccolo) oppure tirata piena => parte; tirata a metà => annulla
|
|
if (dy < 8 || dy >= 26) pullAndSpin();
|
|
};
|
|
lever.addEventListener("pointerup", release);
|
|
lever.addEventListener("pointercancel", () => {
|
|
dragging = false;
|
|
$("#lever").style.transform = "";
|
|
});
|
|
}
|
|
|
|
/* ---------- Controlli ---------- */
|
|
function onPrimary() {
|
|
if (busy) return;
|
|
if (cursor >= revealQueue.length) {
|
|
endGame();
|
|
return;
|
|
}
|
|
nextTurn();
|
|
}
|
|
|
|
function bindControls() {
|
|
bindLever();
|
|
$("#btn-primary").onclick = onPrimary;
|
|
$("#btn-auto").onclick = (e) => {
|
|
auto = !auto;
|
|
e.currentTarget.classList.toggle("active", auto);
|
|
if (auto) {
|
|
armLever(false);
|
|
if (!busy && cursor < revealQueue.length) nextTurn();
|
|
} else if (!busy && cursor < revealQueue.length) {
|
|
armLever(true);
|
|
mc("Tira la leva! ⬇️");
|
|
}
|
|
};
|
|
$("#btn-reroll").onclick = () => {
|
|
if (busy) return;
|
|
Audio.init();
|
|
Audio.resume();
|
|
newGame(true);
|
|
mc("Nuovo sorteggio pronto! 🎲 Tira la leva ⬇️");
|
|
};
|
|
$("#btn-sound").onclick = (e) => {
|
|
Audio.on = !Audio.on;
|
|
e.currentTarget.textContent = Audio.on ? "🔊" : "🔇";
|
|
};
|
|
// prima interazione: sblocca audio
|
|
document.addEventListener(
|
|
"pointerdown",
|
|
() => {
|
|
Audio.init();
|
|
Audio.resume();
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
|
|
/* ---------- Avvio ---------- */
|
|
try {
|
|
initBackground();
|
|
} catch (e) {
|
|
console.warn("Sfondo 3D disattivato:", e);
|
|
}
|
|
initMarquee();
|
|
bindControls();
|
|
newGame(false);
|
|
|
|
// hook di debug (per test/verifica; innocuo)
|
|
window.__celebrate = celebrate;
|
|
window.__shalom = {
|
|
fire: shalomFireLion,
|
|
video: shalomVideoOnly,
|
|
gesu: shalomGesu,
|
|
random: playShalom,
|
|
};
|
|
})();
|