diff --git a/app.js b/app.js index 752ee86..1a238fb 100644 --- a/app.js +++ b/app.js @@ -4,7 +4,9 @@ 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 genderOf = Object.fromEntries( + PARTICIPANTS.map((p) => [p.nome, p.genere]), + ); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* ---------- Audio sintetizzato (nessun file esterno) ---------- */ @@ -104,16 +106,22 @@ 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); + 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) + 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); + setTimeout( + () => this.blip(1800 + Math.random() * 1400, 0.08, "sine", 0.12), + 600 + i * 70, + ); }, // riser di tensione: ritorna una funzione stop() riser(duration) { @@ -160,7 +168,9 @@ 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("canplaythrough", () => (this.ready[k] = true), { + once: true, + }); a.addEventListener("error", () => (this.ready[k] = false)); this.el[k] = a; }); @@ -185,16 +195,19 @@ 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); + 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, + ); }; }, }; @@ -216,23 +229,65 @@ ? confetti.create(confettiCanvas, { resize: false, useWorker: false }) : () => {}; - const CONFETTI_COLORS = ["#ffd23f", "#ff9e00", "#ff2e88", "#00e5ff", "#29e08a", "#ff3b5c", "#ffffff"]; + 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 }); + 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 }); + 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 }); + 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 }); + myConfetti({ + particleCount: 40, + spread: 160, + startVelocity: 35, + gravity: 1, + origin: { x, y: -0.1 }, + colors: C, + }); } } @@ -241,7 +296,14 @@ 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 }); + myConfetti({ + particleCount: 10, + spread: 130, + startVelocity: 45, + gravity: 1, + origin: { x, y: -0.05 }, + colors: CONFETTI_COLORS, + }); } if (Date.now() < end) requestAnimationFrame(frame); })(); @@ -304,7 +366,13 @@ 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"] }); + myConfetti({ + particleCount: 120, + spread: 180, + startVelocity: 40, + origin: { y: 0.4 }, + colors: ["#ff3b5c", "#8b0022"], + }); } /* ---------- Sfondo three.js: pioggia di monete ---------- */ @@ -313,7 +381,11 @@ const canvas = $("#bg3d"); let renderer; try { - renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); + renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + }); } catch (e) { return; // WebGL non disponibile: l'app funziona lo stesso senza sfondo 3D } @@ -398,35 +470,59 @@ const ITEM_H = 88; const reelEls = [$("#reel0"), $("#reel1"), $("#reel2")]; - function reelItemHTML(room) { - const jz = room.jacuzzi ? " jz" : ""; - const icon = room.jacuzzi ? '🛁' : ""; - return `
${icon}${room.nome}
`; + // 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. + // (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 = 14; + const loops = 12; const seq = []; - for (let l = 0; l < loops; l++) for (const r of ROOMS) seq.push(r); - const start = Math.floor(Math.random() * ROOMS.length); // offset diverso per rullo - const rotated = seq.slice(start).concat(seq.slice(0, start)); - const targetIndex = rotated.length; // la target va in coda - rotated.push(roomById[targetId]); - rotated.push(ROOMS[0], ROOMS[1]); // riempi sotto la finestra - el.innerHTML = rotated.map(reelItemHTML).join(""); + 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 i simboli fermi invece di rulli vuoti + // 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 un simbolo diverso per rullo + const k = i + 1; // centra una stanza diversa per rullo el.style.transform = `translateY(${-(k - 1) * ITEM_H}px)`; }); } @@ -438,8 +534,19 @@ return Math.round(-y / ITEM_H); } - const roomAtIndex = (el, i) => - el.children[i] ? roomById[el.children[i].dataset.room] : null; + // 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; @@ -448,7 +555,10 @@ // 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); + return Matching.shuffle( + ROOMS.map((r) => r.id), + Math.random, + ).slice(0, 3); } function bounceReel(el, finalY) { @@ -458,7 +568,7 @@ { transform: `translateY(${finalY + 12}px)` }, { transform: `translateY(${finalY}px)` }, ], - { duration: 220, easing: "ease-out", fill: "forwards" } + { duration: 220, easing: "ease-out", fill: "forwards" }, ).finished; } @@ -466,35 +576,53 @@ // 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: scegli una stanza adiacente diversa dalla vera + // 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 (roomAtIndex(el, targetIndex + dir)?.id === targetRoom.id) dir = -dir; - if (roomAtIndex(el, targetIndex + dir)?.id === targetRoom.id) teaseMs = 0; // no adiacente + if (adjIsTarget(dir)) dir = -dir; + if (adjIsTarget(dir)) teaseMs = 0; // entrambe le adiacenti sono la target if (teaseMs > 0) { - const fakeRoom = roomAtIndex(el, targetIndex + dir); + const fakeName = nameAtIndex(el, targetIndex + dir); const fakeY = -(targetIndex + dir - 1) * ITEM_H; const col = el.parentElement; // decelera e "si ferma" sulla stanza SBAGLIATA await el.animate( - [{ transform: "translateY(0)" }, { transform: `translateY(${fakeY}px)` }], - { duration, easing: "cubic-bezier(0.08, 0.85, 0.14, 1)", fill: "forwards" } + [ + { transform: "translateY(0)" }, + { transform: `translateY(${fakeY}px)` }, + ], + { + duration, + easing: "cubic-bezier(0.05, 0.92, 0.1, 1)", + fill: "forwards", + }, ).finished; Audio.reelStop(); // suspense su quella colonna Audio.blip(330, 0.13, "sine", 0.14); col.classList.add("teasing"); - mc(`${fakeRoom.nome}...? 🤨`); + 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" } + [ + { 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(); @@ -503,8 +631,15 @@ } // atterraggio diretto + micro-assestamento await el.animate( - [{ transform: "translateY(0)" }, { transform: `translateY(${finalY}px)` }], - { duration, easing: "cubic-bezier(0.12, 0.85, 0.18, 1)", fill: "forwards" } + [ + { transform: "translateY(0)" }, + { transform: `translateY(${finalY}px)` }, + ], + { + duration, + easing: "cubic-bezier(0.05, 0.92, 0.1, 1)", + fill: "forwards", + }, ).finished; await bounceReel(el, finalY); Audio.reelStop(); @@ -517,16 +652,19 @@ 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 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 a metà spin (quando inizia a rallentare) + // ZOOM + shake; lo shake si ferma presto, così la parte lenta è calma e + // leggibile mentre i nomi scorrono piano zoomMachineIn(); - const mainDur = 4200; - setTimeout(zoomMachineSteady, mainDur / 2); + const mainDur = 6400; + setTimeout(zoomMachineSteady, 2500); - const stopRiser = Audio.riser(5.6); + const stopRiser = Audio.riser(7.0); // whirr reale dei rulli sotto ai tick/riser sintetizzati const stopWheel = Samples.play("reelSpin", { vol: 0.65 }); @@ -543,13 +681,14 @@ requestAnimationFrame(loop); })(); - const durs = [2600, 3300, 4200]; + 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; + const teaseMs = + tease && Math.random() < TEASE_CHANCE ? 380 + i * 260 : 0; return reelLand(el, targetIndex, finalY, durs[i], teaseMs); - }) + }), ); ticking = false; @@ -583,7 +722,7 @@ { transform: "scale(0.985)" }, { transform: "scale(1)" }, ], - { duration: 620, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" } + { duration: 620, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" }, ); } @@ -610,7 +749,8 @@ // svelati per prestigio crescente: le stanze con jacuzzi (jackpot) per ultime const people = PARTICIPANTS.map((p) => p.nome); return Matching.shuffle(people, Math.random).sort( - (a, b) => prestige(assignment.byPerson[a]) - prestige(assignment.byPerson[b]) + (a, b) => + prestige(assignment.byPerson[a]) - prestige(assignment.byPerson[b]), ); } @@ -650,7 +790,9 @@ board.innerHTML = ROOMS.map((r) => { const g = assignment.roomGender[r.id]; const occ = occupants[r.id]; - const chips = occ.map((n) => `${n}`).join(""); + const chips = occ + .map((n) => `${n}`) + .join(""); const emptyCount = r.posti - occ.length; const empties = Array.from({ length: emptyCount }) .map(() => ``) @@ -671,7 +813,7 @@ $("#leaderboard").innerHTML = sorted .map( (p) => - `
${p.you ? "⭐ " : ""}${p.name}${p.chips}
` + `
${p.you ? "⭐ " : ""}${p.name}${p.chips}
`, ) .join(""); $("#balance").textContent = players.find((p) => p.you).chips; @@ -697,9 +839,13 @@ function computeOdds(person) { const g = genderOf[person]; const rooms = ROOMS.filter( - (r) => assignment.roomGender[r.id] === g && occupants[r.id].length < r.posti + (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, ); - 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; @@ -720,7 +866,7 @@ `
${o.room.jacuzzi ? "🛁 " : ""}${o.room.nome} x${o.mult.toFixed(1)} -
` + `, ) .join(""); $("#odds") @@ -732,7 +878,7 @@ currentBet = { roomId: rid }; Audio.coin(); renderOdds(person, odds); - }) + }), ); } @@ -761,7 +907,10 @@ if (currentBet.roomId === landedRoomId) { const win = Math.round(10 * o); you.chips += win - 10; - log(`Hai vinto ${win} gettoni! (${roomById[landedRoomId].nome})`, "win"); + log( + `Hai vinto ${win} gettoni! (${roomById[landedRoomId].nome})`, + "win", + ); } else { you.chips -= 10; log(`Hai perso la puntata su ${roomById[currentBet.roomId].nome}.`); @@ -771,7 +920,8 @@ players .filter((p) => !p.you && p._bet) .forEach((p) => { - if (p._bet.roomId === landedRoomId) p.chips += Math.round(10 * p._bet.mult) - 10; + if (p._bet.roomId === landedRoomId) + p.chips += Math.round(10 * p._bet.mult) - 10; else p.chips -= 10; }); } @@ -838,7 +988,8 @@ // --- 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 ? " 🛁" : ""}`; + $("#reel-result").innerHTML = + `${room.jacuzzi ? "🛁 " : ""}${room.nome}${room.jacuzzi ? " 🛁" : ""}`; // atterraggio occupants[roomId].push(person); @@ -853,7 +1004,13 @@ 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(); } }); + Samples.play("jackpot", { + vol: 0.9, + fallback: () => { + Audio.fanfare(); + Audio.jackpot(); + }, + }); celebrate("jackpot"); screenShake(); screenFlash(); @@ -892,7 +1049,9 @@ } function highlightRoom(roomId) { - document.querySelectorAll(".room-card").forEach((c) => c.classList.remove("hot")); + 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"); @@ -903,8 +1062,13 @@ 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"); + 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 = ""; @@ -919,7 +1083,10 @@ function initMarquee() { const m = $("#marquee"); m.innerHTML = Array.from({ length: 16 }) - .map((_, i) => ``) + .map( + (_, i) => + ``, + ) .join(""); } @@ -1011,7 +1178,7 @@ Audio.init(); Audio.resume(); }, - { once: true } + { once: true }, ); } diff --git a/styles.css b/styles.css index 5b9c551..4da050c 100644 --- a/styles.css +++ b/styles.css @@ -365,6 +365,7 @@ body { font-size: 15px; line-height: 1; } +/* le stanze fantasma sono identiche alle reali (indistinguibili) */ /* caption con il nome della stanza risultante */ .reel-result { min-height: 34px;