diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c758a49
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# ๐ฐ ELSA GAMBLING
+
+Web app (HTML + CSS + JS vanilla) che assegna le persone alle stanze con un
+**algoritmo di matching** e rivela il risultato come uno **show di scommesse /
+slot machine**: rulli che rallentano, near-miss, drumroll, coriandoli, jackpot
+sulle stanze con jacuzzi.
+
+## Come avviare
+
+Basta aprire **`index.html`** in un browser (doppio click) โ serve connessione a
+internet perchรฉ three.js (sfondo monete) e canvas-confetti sono caricati da CDN.
+
+In alternativa, con un mini server locale:
+
+```bash
+python3 -m http.server 8080
+# poi apri http://localhost:8080
+```
+
+## Come si gioca
+
+- **START / SPINNA!** โ rivela la persona successiva con un giro di slot.
+- **AUTO โถ** โ rivela tutti in sequenza da solo (modalitร proiettore).
+- **RE-ROLL ๐ฒ** โ rifร il sorteggio (nuova assegnazione).
+- **๐** โ on/off dei suoni (sintetizzati, nessun file audio).
+- **Scommesse** โ prima di ogni giro clicca una stanza nel pannello a destra per
+ puntare 10 gettoni; se indovini vinci in base alla quota. Contro di te giocano
+ 3 scommettitori simulati (leaderboard live).
+
+I posti con **jacuzzi** (Argo, Eolo, "Quanto te la rischi") sono i jackpot e
+vengono rivelati per ultimi, con celebrazione extra.
+
+## Regole del matching
+
+- Ogni stanza รจ **mono-genere** (M con M, F con F).
+- L'algoritmo partiziona le stanze tra i due generi rispettando le capienze, poi
+ distribuisce le persone a caso senza lasciare stanze vuote.
+- Verificato su 5000 run: 0 violazioni del vincolo, nessuna stanza vuota.
+
+## File
+
+| File | Ruolo |
+|------|-------|
+| `index.html` | Struttura + link CDN/font |
+| `styles.css` | Tema neon/casino |
+| `data.js` | Stanze e partecipanti (sorgente dati) |
+| `matching.js` | Algoritmo di assegnazione (testabile in Node) |
+| `app.js` | Motore reveal: rulli, audio, three.js, coriandoli, scommesse |
+| `docs/` | Ricerca su stile gambling, suspense, interattivitร , implementazione |
+
+## Dati attuali
+
+16 persone (6 F / 10 M) ยท 8 stanze / 18 posti. Modifica `data.js` per cambiare
+nomi, generi o stanze โ l'app si adatta da sola.
+
+## Idee non ancora fatte (possibili evoluzioni)
+
+- **Scommesse multi-persona reali** dai telefoni (ora: singolo operatore + bot
+ simulati). Servirebbe `BroadcastChannel` o un piccolo backend.
+- **SFX reali** al posto dei suoni sintetizzati.
+- **Gran finale** dedicato all'ultimo jackpot (ora tutti i jackpot sono uguali).
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..b9dc8f1
--- /dev/null
+++ b/app.js
@@ -0,0 +1,635 @@
+/* ===== ELSA GAMBLING โ motore del reveal ===== */
+(function () {
+ "use strict";
+
+ 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]));
+
+ /* ---------- 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);
+ },
+ 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);
+ },
+ 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) {}
+ };
+ },
+ };
+
+ /* ---------- Coriandoli ---------- */
+ const myConfetti =
+ typeof confetti !== "undefined"
+ ? confetti.create($("#confetti"), { resize: true, useWorker: true })
+ : () => {};
+
+ function celebrate(kind) {
+ if (kind === "jackpot") {
+ const end = Date.now() + 1400;
+ const colors = ["#ffd23f", "#ff9e00", "#ff2e88", "#00e5ff", "#ffffff"];
+ (function frame() {
+ myConfetti({ particleCount: 6, angle: 60, spread: 70, origin: { x: 0 }, colors });
+ myConfetti({ particleCount: 6, angle: 120, spread: 70, origin: { x: 1 }, colors });
+ myConfetti({ particleCount: 4, spread: 120, startVelocity: 55, origin: { y: 0.3 }, colors });
+ if (Date.now() < end) requestAnimationFrame(frame);
+ })();
+ } else {
+ myConfetti({
+ particleCount: 90,
+ spread: 80,
+ origin: { y: 0.55 },
+ colors: ["#ffd23f", "#00e5ff", "#ff2e88", "#ffffff"],
+ });
+ }
+ }
+
+ 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");
+ }
+
+ /* ---------- 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);
+ }
+
+ /* ---------- Rullo slot ---------- */
+ const ITEM_H = 88;
+ const reelEl = $("#reel");
+
+ function reelItemHTML(room) {
+ const jz = room.jacuzzi ? " jz" : "";
+ const icon = room.jacuzzi ? "๐" : "๐ ";
+ return `
${icon}${room.nome}
`;
+ }
+
+ // Costruisce una striscia lunga che termina sulla stanza target (centrata
+ // nella riga di mezzo della finestra a 3 righe) e ritorna l'indice target.
+ function buildStrip(targetId) {
+ // azzera le animazioni del giro precedente (evita accumulo + fill forwards)
+ reelEl.getAnimations().forEach((a) => a.cancel());
+ const loops = 14;
+ const seq = [];
+ for (let l = 0; l < loops; l++)
+ for (const r of ROOMS) seq.push(r);
+ // offset iniziale casuale per non ripartire sempre uguale
+ const start = Math.floor(Math.random() * ROOMS.length);
+ const rotated = seq.slice(start).concat(seq.slice(0, start));
+ const targetIndex = rotated.length; // la target va in coda
+ rotated.push(roomById[targetId]);
+ // aggiungo 2 righe sotto per riempire la finestra
+ rotated.push(ROOMS[0], ROOMS[1]);
+ reelEl.innerHTML = rotated.map(reelItemHTML).join("");
+ reelEl.style.transform = "translateY(0)";
+ return targetIndex;
+ }
+
+ function currentReelIndex() {
+ const t = getComputedStyle(reelEl).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);
+ }
+
+ function spinReel(targetId) {
+ return new Promise((resolve) => {
+ const targetIndex = buildStrip(targetId);
+ // centro la target nella riga di mezzo (finestra 3 righe)
+ const finalY = -(targetIndex - 1) * ITEM_H;
+ const duration = 4200;
+
+ // tick sincronizzati al passaggio dei simboli
+ let lastIdx = 0;
+ let ticking = true;
+ (function tickLoop() {
+ if (!ticking) return;
+ const idx = currentReelIndex();
+ if (idx !== lastIdx) {
+ Audio.tick();
+ lastIdx = idx;
+ }
+ requestAnimationFrame(tickLoop);
+ })();
+
+ const stopRiser = Audio.riser(duration / 1000);
+
+ const anim = reelEl.animate(
+ [{ transform: "translateY(0)" }, { transform: `translateY(${finalY}px)` }],
+ { duration, easing: "cubic-bezier(0.12, 0.85, 0.18, 1)", fill: "forwards" }
+ );
+ anim.onfinish = () => {
+ ticking = false;
+ stopRiser();
+ // micro-bounce di assestamento (near-miss / lock-in)
+ reelEl.animate(
+ [
+ { transform: `translateY(${finalY}px)` },
+ { transform: `translateY(${finalY + 14}px)` },
+ { transform: `translateY(${finalY}px)` },
+ ],
+ { duration: 260, easing: "ease-out" }
+ );
+ resolve();
+ };
+ });
+ }
+
+ /* ---------- 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;
+
+ // scommettitori (TU + banco simulato)
+ let players = [];
+ let currentBet = null; // {roomId}
+
+ function prestige(roomId) {
+ const r = roomById[roomId];
+ return (r.jacuzzi ? 2 : 0) + (r.posti === 1 ? 1 : 0);
+ }
+
+ function buildRevealOrder() {
+ // 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])
+ );
+ }
+
+ function newGame(keepChips) {
+ assignment = Matching.assign(ROOMS, PARTICIPANTS);
+ revealQueue = buildRevealOrder();
+ occupants = {};
+ ROOMS.forEach((r) => (occupants[r.id] = []));
+ cursor = 0;
+ currentBet = null;
+ 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();
+ $("#player-name").textContent = "โ";
+ $("#odds").innerHTML = "";
+ mc("Si parte! Premi START ๐ฐ");
+ $("#btn-primary").textContent = "START ๐ฐ";
+ $("#btn-primary").disabled = false;
+ }
+
+ /* ---------- 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;
+ Audio.init();
+ Audio.resume();
+
+ const person = revealQueue[cursor];
+ const roomId = assignment.byPerson[person];
+ 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ร ? ๐ฒ`);
+
+ // leva + spin
+ $("#lever").classList.add("pull");
+ setTimeout(() => $("#lever").classList.remove("pull"), 300);
+ if (window.__coinBoost) window.__coinBoost();
+
+ $("#btn-primary").disabled = true;
+ await spinReel(roomId);
+
+ // atterraggio
+ occupants[roomId].push(person);
+ highlightRoom(roomId);
+ renderRooms();
+ highlightRoom(roomId);
+
+ resolveBets(person, roomId, oddsMap);
+ currentBet = null;
+
+ const isJackpot = room.jacuzzi;
+ if (isJackpot) {
+ Audio.jackpot();
+ celebrate("jackpot");
+ screenShake();
+ screenFlash();
+ mc(`๐ JACKPOT! ${person} conquista ${room.nome} con IDROMASSAGGIO! ๐`);
+ log(`JACKPOT: ${person} โ ${room.nome} ๐`, "jackpot");
+ } else {
+ Audio.ding();
+ celebrate("normal");
+ mc(`${person} va in ${room.nome}!`);
+ log(`${person} โ ${room.nome}`);
+ }
+ Audio.coin();
+
+ cursor++;
+ setProgress();
+ renderLeaderboard();
+ busy = false;
+
+ if (cursor >= revealQueue.length) {
+ $("#btn-primary").textContent = "FINE ๐";
+ $("#btn-primary").disabled = false;
+ setTimeout(endGame, 900);
+ } else {
+ $("#btn-primary").textContent = "SPINNA! ๐ฐ";
+ $("#btn-primary").disabled = false;
+ if (auto) setTimeout(nextTurn, 1400);
+ }
+ }
+
+ 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() {
+ 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");
+ celebrate("jackpot");
+ $("#btn-primary").textContent = "RIGIOCA ๐";
+ $("#btn-primary").disabled = false;
+ $("#btn-primary").onclick = () => {
+ newGame(false);
+ $("#btn-primary").onclick = onPrimary;
+ };
+ }
+
+ /* ---------- Marquee ---------- */
+ function initMarquee() {
+ const m = $("#marquee");
+ m.innerHTML = Array.from({ length: 16 })
+ .map((_, i) => ``)
+ .join("");
+ }
+
+ /* ---------- Controlli ---------- */
+ function onPrimary() {
+ if (busy) return;
+ if (cursor >= revealQueue.length) {
+ endGame();
+ return;
+ }
+ nextTurn();
+ }
+
+ function bindControls() {
+ $("#btn-primary").onclick = onPrimary;
+ $("#btn-auto").onclick = (e) => {
+ auto = !auto;
+ e.currentTarget.classList.toggle("active", auto);
+ if (auto && !busy && cursor < revealQueue.length) nextTurn();
+ };
+ $("#btn-reroll").onclick = () => {
+ if (busy) return;
+ Audio.init();
+ Audio.resume();
+ newGame(true);
+ mc("Nuovo sorteggio pronto! ๐ฒ Premi START.");
+ };
+ $("#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);
+})();
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..322c27a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ ๐ฐ ELSA GAMBLING โ Assegnazione Stanze
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SUL PALCO
+ โ
+
+
+
+
+ Benvenuti alla lotteria delle stanze ๐ฒ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/matching.js b/matching.js
new file mode 100644
index 0000000..6b92376
--- /dev/null
+++ b/matching.js
@@ -0,0 +1,103 @@
+// Algoritmo di assegnazione persone -> stanze.
+// Vincolo: ogni stanza รจ mono-genere (M con M, F con F).
+// Strategia: 1) partiziona le stanze tra i due generi in modo che ciascun
+// genere ci stia (capienza sufficiente); 2) distribuisce casualmente le
+// persone nelle stanze del proprio genere, cercando di non lasciare stanze
+// completamente vuote.
+
+(function (global) {
+ function shuffle(arr, rng) {
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+ }
+
+ // Sceglie quali stanze vanno alle femmine e quali ai maschi.
+ // Preferisce partizioni in cui la capienza femminile combacia esattamente
+ // col numero di femmine (zero letti sprecati da quel lato => niente stanze
+ // vuote lato femmine). Fallback: qualsiasi partizione valida.
+ function chooseGenderPartition(rooms, nF, nM, rng) {
+ const n = rooms.length;
+ const total = rooms.reduce((s, r) => s + r.posti, 0);
+ const valid = [];
+ const exact = [];
+ // esclude mask 0 e mask "tutte" cosรฌ ogni genere ha almeno una stanza
+ for (let mask = 1; mask < (1 << n) - 1; mask++) {
+ let capF = 0;
+ for (let i = 0; i < n; i++) if (mask & (1 << i)) capF += rooms[i].posti;
+ const capM = total - capF;
+ if (capF >= nF && capM >= nM) {
+ valid.push(mask);
+ if (capF === nF) exact.push(mask);
+ }
+ }
+ const pool = exact.length ? exact : valid;
+ if (!pool.length) {
+ throw new Error(
+ "Nessuna partizione valida: capienza insufficiente per il vincolo di genere."
+ );
+ }
+ return pool[Math.floor(rng() * pool.length)];
+ }
+
+ // Distribuisce le persone nelle stanze del proprio genere.
+ // Prima assegna 1 persona a stanza (copertura => niente stanze vuote se
+ // le persone sono >= delle stanze), poi riempie i letti restanti a caso.
+ function fillRooms(roomsG, peopleG, rng) {
+ const rooms = shuffle(roomsG, rng);
+ const people = shuffle(peopleG, rng);
+ const assign = {};
+ rooms.forEach((r) => (assign[r.id] = []));
+
+ let idx = 0;
+ if (people.length >= rooms.length) {
+ for (const r of rooms) assign[r.id].push(people[idx++]);
+ }
+
+ const slots = [];
+ for (const r of rooms) {
+ const free = r.posti - assign[r.id].length;
+ for (let k = 0; k < free; k++) slots.push(r.id);
+ }
+ const freeSlots = shuffle(slots, rng);
+ while (idx < people.length) {
+ const roomId = freeSlots.shift();
+ assign[roomId].push(people[idx++]);
+ }
+ return assign;
+ }
+
+ function assign(rooms, participants, rng) {
+ rng = rng || Math.random;
+ const females = participants.filter((p) => p.genere === "F");
+ const males = participants.filter((p) => p.genere === "M");
+
+ const mask = chooseGenderPartition(rooms, females.length, males.length, rng);
+ const fRooms = rooms.filter((r, i) => mask & (1 << i));
+ const mRooms = rooms.filter((r, i) => !(mask & (1 << i)));
+
+ const fAssign = fillRooms(fRooms, females.map((p) => p.nome), rng);
+ const mAssign = fillRooms(mRooms, males.map((p) => p.nome), rng);
+
+ const byPerson = {};
+ const roomGender = {};
+ fRooms.forEach((r) => (roomGender[r.id] = "F"));
+ mRooms.forEach((r) => (roomGender[r.id] = "M"));
+ for (const rid in fAssign) fAssign[rid].forEach((nome) => (byPerson[nome] = rid));
+ for (const rid in mAssign) mAssign[rid].forEach((nome) => (byPerson[nome] = rid));
+
+ return {
+ byPerson, // nome -> roomId
+ roomGender, // roomId -> "F" | "M"
+ fRooms,
+ mRooms,
+ };
+ }
+
+ const Matching = { assign, shuffle };
+ if (typeof module !== "undefined" && module.exports) module.exports = Matching;
+ global.Matching = Matching;
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..841e446
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,584 @@
+/* ===== ELSA GAMBLING โ stile neon/casino ===== */
+:root {
+ --bg: #0a0512;
+ --bg2: #140a26;
+ --panel: rgba(24, 12, 46, 0.72);
+ --gold: #ffd23f;
+ --gold-2: #ff9e00;
+ --pink: #ff2e88;
+ --cyan: #00e5ff;
+ --green: #29e08a;
+ --red: #ff3b5c;
+ --text: #f3e9ff;
+ --muted: #9c86b8;
+}
+
+* {
+ box-sizing: border-box;
+}
+html,
+body {
+ margin: 0;
+ height: 100%;
+ background: radial-gradient(1200px 700px at 50% -10%, #2a1150 0%, var(--bg) 60%),
+ var(--bg);
+ color: var(--text);
+ font-family: "Bebas Neue", system-ui, sans-serif;
+ overflow: hidden;
+}
+
+#bg3d {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ display: block;
+}
+#confetti {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ pointer-events: none;
+}
+
+#app {
+ position: relative;
+ z-index: 10;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ padding: 14px 18px 18px;
+ gap: 12px;
+}
+
+/* ===== Topbar ===== */
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-family: "Anton", sans-serif;
+ font-size: clamp(26px, 4vw, 52px);
+ letter-spacing: 2px;
+}
+.brand-emoji {
+ font-size: 0.8em;
+ filter: drop-shadow(0 0 10px var(--gold));
+}
+.neon-gold {
+ color: var(--gold);
+ text-shadow: 0 0 6px var(--gold), 0 0 22px var(--gold-2), 0 0 40px var(--gold-2);
+}
+.neon-pink {
+ color: var(--pink);
+ text-shadow: 0 0 6px var(--pink), 0 0 22px var(--pink), 0 0 42px #ff007b;
+}
+.hud {
+ display: flex;
+ gap: 12px;
+}
+.hud-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 6px 16px;
+ background: var(--panel);
+ border: 1px solid rgba(255, 210, 63, 0.35);
+ border-radius: 12px;
+ box-shadow: inset 0 0 18px rgba(255, 46, 136, 0.12);
+}
+.hud-label {
+ font-size: 13px;
+ letter-spacing: 3px;
+ color: var(--muted);
+}
+.hud-value {
+ font-family: "Orbitron", monospace;
+ font-weight: 900;
+ font-size: 26px;
+ color: var(--cyan);
+ text-shadow: 0 0 10px var(--cyan);
+}
+.hud-value.led {
+ color: var(--gold);
+ text-shadow: 0 0 12px var(--gold-2);
+}
+
+/* ===== Stage layout ===== */
+.stage {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 1fr 1.35fr 1fr;
+ gap: 16px;
+}
+
+.panel {
+ background: var(--panel);
+ border: 1px solid rgba(0, 229, 255, 0.22);
+ border-radius: 16px;
+ padding: 12px 14px;
+ backdrop-filter: blur(6px);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.panel-title {
+ font-family: "Anton", sans-serif;
+ font-weight: 400;
+ letter-spacing: 2px;
+ font-size: 20px;
+ margin: 4px 0 10px;
+ color: var(--gold);
+ text-shadow: 0 0 10px var(--gold-2);
+}
+.bet-panel .panel-title {
+ margin-top: 12px;
+}
+
+/* ===== Rooms board ===== */
+.rooms-board {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-right: 4px;
+}
+.room-card {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 12px;
+ padding: 8px 10px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.15));
+ transition: box-shadow 0.3s, border-color 0.3s, transform 0.3s;
+}
+.room-card.gender-F {
+ border-left: 4px solid var(--pink);
+}
+.room-card.gender-M {
+ border-left: 4px solid var(--cyan);
+}
+.room-card.jacuzzi {
+ box-shadow: 0 0 14px rgba(255, 210, 63, 0.25);
+}
+.room-card.hot {
+ border-color: var(--gold);
+ box-shadow: 0 0 22px var(--gold-2);
+ transform: scale(1.02);
+}
+.room-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+.room-name {
+ font-family: "Anton", sans-serif;
+ font-size: 18px;
+ letter-spacing: 1px;
+}
+.room-cap {
+ font-family: "Orbitron", monospace;
+ font-size: 13px;
+ color: var(--muted);
+}
+.room-occupants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+ min-height: 6px;
+}
+.occ-chip {
+ font-family: "Bebas Neue", sans-serif;
+ font-size: 15px;
+ letter-spacing: 0.5px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ background: rgba(0, 229, 255, 0.16);
+ border: 1px solid rgba(0, 229, 255, 0.4);
+ animation: pop 0.5s ease;
+}
+.gender-F .occ-chip {
+ background: rgba(255, 46, 136, 0.16);
+ border-color: rgba(255, 46, 136, 0.45);
+}
+.occ-slot-empty {
+ width: 42px;
+ height: 20px;
+ border-radius: 999px;
+ border: 1px dashed rgba(255, 255, 255, 0.18);
+}
+@keyframes pop {
+ 0% {
+ transform: scale(0.4);
+ opacity: 0;
+ }
+ 70% {
+ transform: scale(1.15);
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+/* ===== Slot machine ===== */
+.machine {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ min-height: 0;
+}
+.marquee {
+ display: flex;
+ gap: 10px;
+ padding: 6px;
+}
+.marquee .bulb {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--gold);
+ box-shadow: 0 0 10px var(--gold-2), 0 0 18px var(--gold-2);
+ animation: chase 1s infinite;
+}
+@keyframes chase {
+ 0%,
+ 100% {
+ opacity: 0.25;
+ transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+}
+.now-playing {
+ text-align: center;
+}
+.np-label {
+ display: block;
+ font-size: 14px;
+ letter-spacing: 5px;
+ color: var(--muted);
+}
+.np-name {
+ font-family: "Anton", sans-serif;
+ font-size: clamp(28px, 4vw, 48px);
+ color: var(--text);
+ text-shadow: 0 0 16px var(--pink);
+}
+
+.slot {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ padding: 16px;
+ border-radius: 22px;
+ background: linear-gradient(180deg, #2a0e4e, #150725);
+ border: 3px solid var(--gold);
+ box-shadow: 0 0 30px rgba(255, 158, 0, 0.5),
+ inset 0 0 40px rgba(0, 0, 0, 0.6);
+}
+.slot-side {
+ width: 46px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.reel-window {
+ position: relative;
+ width: min(46vw, 460px);
+ height: 264px;
+ overflow: hidden;
+ border-radius: 12px;
+ background: #05020a;
+ border: 2px solid rgba(255, 255, 255, 0.15);
+ box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9);
+}
+.reel-strip {
+ will-change: transform;
+}
+.reel-item {
+ height: 88px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ font-family: "Anton", sans-serif;
+ font-size: 34px;
+ letter-spacing: 1px;
+ color: #fff;
+ text-shadow: 0 0 10px rgba(255, 255, 255, 0.35);
+}
+.reel-item.jz {
+ color: var(--gold);
+ text-shadow: 0 0 14px var(--gold-2);
+}
+.payline {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 88px;
+ height: 88px;
+ border-top: 2px solid rgba(255, 210, 63, 0.7);
+ border-bottom: 2px solid rgba(255, 210, 63, 0.7);
+ box-shadow: 0 0 26px rgba(255, 210, 63, 0.4);
+ pointer-events: none;
+ z-index: 3;
+}
+.reel-fade {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 88px;
+ z-index: 2;
+ pointer-events: none;
+}
+.reel-fade.top {
+ top: 0;
+ background: linear-gradient(180deg, #05020a, transparent);
+}
+.reel-fade.bottom {
+ bottom: 0;
+ background: linear-gradient(0deg, #05020a, transparent);
+}
+
+/* leva */
+.lever {
+ width: 14px;
+ height: 130px;
+ background: linear-gradient(90deg, #7a7a85, #d7d7e0, #7a7a85);
+ border-radius: 8px;
+ position: relative;
+ transform-origin: bottom center;
+ transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.lever.pull {
+ transform: rotate(28deg);
+}
+.lever-knob {
+ position: absolute;
+ top: -14px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #ff8ab0, var(--red) 60%, #8b0022);
+ box-shadow: 0 0 16px var(--red);
+}
+
+.mc-line {
+ min-height: 26px;
+ text-align: center;
+ font-size: 20px;
+ letter-spacing: 1px;
+ color: var(--cyan);
+ text-shadow: 0 0 10px rgba(0, 229, 255, 0.5);
+}
+
+/* ===== Controlli ===== */
+.controls {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+.btn {
+ font-family: "Anton", sans-serif;
+ letter-spacing: 2px;
+ font-size: 20px;
+ padding: 10px 22px;
+ border-radius: 12px;
+ border: none;
+ cursor: pointer;
+ color: #1a0a2e;
+ transition: transform 0.12s, box-shadow 0.2s, filter 0.2s;
+}
+.btn:active {
+ transform: translateY(2px) scale(0.98);
+}
+.btn:disabled {
+ filter: grayscale(0.7) brightness(0.6);
+ cursor: not-allowed;
+}
+.btn-gold {
+ background: linear-gradient(180deg, #ffe98a, var(--gold) 45%, var(--gold-2));
+ box-shadow: 0 6px 0 #b36b00, 0 0 24px rgba(255, 158, 0, 0.6);
+}
+.btn-gold:active {
+ box-shadow: 0 2px 0 #b36b00, 0 0 24px rgba(255, 158, 0, 0.6);
+}
+.btn-ghost {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ box-shadow: none;
+}
+.btn-ghost.active {
+ border-color: var(--green);
+ color: var(--green);
+ box-shadow: 0 0 14px rgba(41, 224, 138, 0.5);
+}
+
+/* ===== Scommesse ===== */
+.odds {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 32%;
+ overflow-y: auto;
+}
+.odd-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+ cursor: pointer;
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s;
+}
+.odd-row:hover {
+ border-color: var(--gold);
+}
+.odd-row.picked {
+ border-color: var(--green);
+ box-shadow: 0 0 14px rgba(41, 224, 138, 0.45);
+}
+.odd-row.disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+.odd-name {
+ font-size: 17px;
+ letter-spacing: 0.5px;
+}
+.odd-mult {
+ font-family: "Orbitron", monospace;
+ font-weight: 700;
+ color: var(--gold);
+}
+.bet-hint {
+ font-size: 14px;
+ color: var(--muted);
+ letter-spacing: 1px;
+ margin: 2px 0 4px;
+}
+
+/* ===== Leaderboard ===== */
+.leaderboard {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.lb-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 3px 10px;
+ border-radius: 8px;
+ font-size: 17px;
+ transition: background 0.3s;
+}
+.lb-row.you {
+ background: rgba(255, 210, 63, 0.14);
+ color: var(--gold);
+}
+.lb-chips {
+ font-family: "Orbitron", monospace;
+ font-weight: 700;
+}
+
+/* ===== Log ===== */
+.log {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 3px;
+ font-size: 15px;
+ letter-spacing: 0.4px;
+ color: var(--text);
+}
+.log-entry {
+ padding: 2px 6px;
+ border-left: 2px solid rgba(255, 255, 255, 0.15);
+ opacity: 0.9;
+}
+.log-entry.win {
+ border-color: var(--green);
+}
+.log-entry.jackpot {
+ border-color: var(--gold);
+ color: var(--gold);
+}
+
+/* ===== FX ===== */
+.shake {
+ animation: shake 0.5s;
+}
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translate(0, 0);
+ }
+ 20% {
+ transform: translate(-8px, 4px);
+ }
+ 40% {
+ transform: translate(8px, -6px);
+ }
+ 60% {
+ transform: translate(-6px, 6px);
+ }
+ 80% {
+ transform: translate(6px, -4px);
+ }
+}
+.flash {
+ position: fixed;
+ inset: 0;
+ z-index: 55;
+ pointer-events: none;
+ background: radial-gradient(circle, rgba(255, 210, 63, 0.55), transparent 60%);
+ opacity: 0;
+}
+.flash.go {
+ animation: flash 0.6s ease;
+}
+@keyframes flash {
+ 0% {
+ opacity: 0;
+ }
+ 30% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+@media (max-width: 980px) {
+ .stage {
+ grid-template-columns: 1fr;
+ grid-auto-rows: min-content;
+ overflow-y: auto;
+ }
+ body {
+ overflow: auto;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.001ms !important;
+ }
+}