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 + + + + + + + + + + + + + + +
+
+
+ ๐ŸŽฐ + ELSA + GAMBLING + ๐ŸŽฐ +
+
+
+ GIRO + 0 / 0 +
+
+ I TUOI GETTONI + 100 +
+
+
+ +
+ + + + +
+
+ +
+ 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; + } +}