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;