/* ===== 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" | "combo" | "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) 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 } = 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; 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 = "❌ NO MATCH ❌"; 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 = '
' + '
' + '' + '
' + '' + '' + '
tap per saltare ▸
'; 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("shalom", { vol: 0.9, 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"); const stop = Samples.play("shalom", { vol: 0.9, fallback: () => Audio.jackpot(), }); try { v.currentTime = 0; await v.play(); } catch (e) {} await shalomWait(v, 14000, o); if (stop) stop(0.4); hideShalom(o); } // EFFETTO 3: combo — flashbang fuoco+leone 3s, poi parte il video con la musica async function shalomCombo() { const o = ensureShalomOverlay(); showShalom(o, "mode-fire"); screenShake(); await shalomWait(null, 3000, o); const v = o.querySelector(".shalom-video"); o.classList.remove("mode-fire"); o.classList.add("mode-video"); const stop = Samples.play("shalom", { vol: 0.9, fallback: () => Audio.jackpot(), }); try { v.currentTime = 0; await v.play(); } catch (e) {} await shalomWait(v, 14000, o); if (stop) stop(0.4); hideShalom(o); } // EFFETTO 4: 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, shalomCombo, shalomGesu, ]; const SHALOM_BY_NAME = { fire: shalomFireLion, video: shalomVideoOnly, combo: shalomCombo, 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 `
${entry.nome}
`; } const jz = entry.jacuzzi ? " jz" : ""; const icon = entry.jacuzzi ? '🛁' : ""; return `
${icon}${entry.nome}
`; } // 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) => `${n}`) .join(""); const emptyCount = r.posti - occ.length; const empties = Array.from({ length: emptyCount }) .map(() => ``) .join(""); return `
${r.jacuzzi ? "🛁 " : ""}${r.nome} ${occ.length}/${r.posti} · ${g}
${chips}${empties}
`; }).join(""); } function renderLeaderboard() { const sorted = players.slice().sort((a, b) => b.chips - a.chips); $("#leaderboard").innerHTML = sorted .map( (p) => `
${p.you ? "⭐ " : ""}${p.name}${p.chips}
`, ) .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 ${roomById[currentBet.roomId].nome}` : "Clicca una stanza per puntare 10 gettoni (o SPINNA)"; $("#odds").innerHTML = `
${hint}
` + odds .map( (o) => `
${o.room.jacuzzi ? "🛁 " : ""}${o.room.nome} x${o.mult.toFixed(1)}
`, ) .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 = `NESSUN MATCH`; 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 ? "🛁 " : ""}${room.nome}${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) => ``, ) .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, combo: shalomCombo, gesu: shalomGesu, random: playShalom, }; })();