ci paice
This commit is contained in:
61
README.md
Normal file
61
README.md
Normal file
@@ -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).
|
||||||
635
app.js
Normal file
635
app.js
Normal file
@@ -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 `<div class="reel-item${jz}"><span>${icon}</span><span>${room.nome}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => `<span class="occ-chip">${n}</span>`).join("");
|
||||||
|
const emptyCount = r.posti - occ.length;
|
||||||
|
const empties = Array.from({ length: emptyCount })
|
||||||
|
.map(() => `<span class="occ-slot-empty"></span>`)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="room-card gender-${g} ${r.jacuzzi ? "jacuzzi" : ""}" data-room="${r.id}">
|
||||||
|
<div class="room-head">
|
||||||
|
<span class="room-name">${r.jacuzzi ? "🛁 " : ""}${r.nome}</span>
|
||||||
|
<span class="room-cap">${occ.length}/${r.posti} · ${g}</span>
|
||||||
|
</div>
|
||||||
|
<div class="room-occupants">${chips}${empties}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeaderboard() {
|
||||||
|
const sorted = players.slice().sort((a, b) => b.chips - a.chips);
|
||||||
|
$("#leaderboard").innerHTML = sorted
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<div class="lb-row ${p.you ? "you" : ""}"><span>${p.you ? "⭐ " : ""}${p.name}</span><span class="lb-chips">${p.chips}</span></div>`
|
||||||
|
)
|
||||||
|
.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 <b>${roomById[currentBet.roomId].nome}</b>`
|
||||||
|
: "Clicca una stanza per puntare 10 gettoni (o SPINNA)";
|
||||||
|
$("#odds").innerHTML =
|
||||||
|
`<div class="bet-hint">${hint}</div>` +
|
||||||
|
odds
|
||||||
|
.map(
|
||||||
|
(o) =>
|
||||||
|
`<div class="odd-row ${currentBet && currentBet.roomId === o.room.id ? "picked" : ""}" data-bet="${o.room.id}">
|
||||||
|
<span class="odd-name">${o.room.jacuzzi ? "🛁 " : ""}${o.room.nome}</span>
|
||||||
|
<span class="odd-mult">x${o.mult.toFixed(1)}</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.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) => `<span class="bulb" style="animation-delay:${(i % 8) * 0.12}s"></span>`)
|
||||||
|
.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);
|
||||||
|
})();
|
||||||
106
index.html
Normal file
106
index.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>🎰 ELSA GAMBLING — Assegnazione Stanze</title>
|
||||||
|
|
||||||
|
<!-- Font: Orbitron (LED/tech), Anton & Bebas (display) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Anton&family=Bebas+Neue&family=Orbitron:wght@500;700;900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sfondo 3D: pioggia di monete (three.js) -->
|
||||||
|
<canvas id="bg3d"></canvas>
|
||||||
|
<!-- Coriandoli (canvas-confetti) sopra a tutto -->
|
||||||
|
<canvas id="confetti"></canvas>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-emoji">🎰</span>
|
||||||
|
<span class="brand-text neon-pink">ELSA</span>
|
||||||
|
<span class="brand-text neon-gold">GAMBLING</span>
|
||||||
|
<span class="brand-emoji">🎰</span>
|
||||||
|
</div>
|
||||||
|
<div class="hud">
|
||||||
|
<div class="hud-box">
|
||||||
|
<span class="hud-label">GIRO</span>
|
||||||
|
<span id="progress" class="hud-value">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="hud-box">
|
||||||
|
<span class="hud-label">I TUOI GETTONI</span>
|
||||||
|
<span id="balance" class="hud-value led">100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<!-- Colonna sinistra: tabellone stanze -->
|
||||||
|
<aside class="panel rooms-panel">
|
||||||
|
<h2 class="panel-title">🏠 STANZE</h2>
|
||||||
|
<div id="rooms-board" class="rooms-board"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Centro: slot machine -->
|
||||||
|
<section class="machine">
|
||||||
|
<div class="marquee" id="marquee"></div>
|
||||||
|
|
||||||
|
<div class="now-playing">
|
||||||
|
<span class="np-label">SUL PALCO</span>
|
||||||
|
<span id="player-name" class="np-name">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slot">
|
||||||
|
<div class="slot-side left"></div>
|
||||||
|
<div class="reel-window">
|
||||||
|
<div class="payline"></div>
|
||||||
|
<div id="reel" class="reel-strip"></div>
|
||||||
|
<div class="reel-fade top"></div>
|
||||||
|
<div class="reel-fade bottom"></div>
|
||||||
|
</div>
|
||||||
|
<div class="slot-side right">
|
||||||
|
<div class="lever" id="lever"><div class="lever-knob"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mc" class="mc-line">Benvenuti alla lotteria delle stanze 🎲</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button id="btn-primary" class="btn btn-gold">START 🎰</button>
|
||||||
|
<button id="btn-auto" class="btn btn-ghost">AUTO ▶</button>
|
||||||
|
<button id="btn-reroll" class="btn btn-ghost">RE-ROLL 🎲</button>
|
||||||
|
<button id="btn-sound" class="btn btn-ghost">🔊</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Colonna destra: scommesse + leaderboard + log -->
|
||||||
|
<aside class="panel bet-panel">
|
||||||
|
<h2 class="panel-title">💰 SCOMMESSE</h2>
|
||||||
|
<div id="odds" class="odds"></div>
|
||||||
|
|
||||||
|
<h2 class="panel-title">🏆 CLASSIFICA</h2>
|
||||||
|
<div id="leaderboard" class="leaderboard"></div>
|
||||||
|
|
||||||
|
<h2 class="panel-title">📢 CRONACA</h2>
|
||||||
|
<div id="log" class="log"></div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Librerie via CDN (funzionano anche aprendo il file in locale, serve rete) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Codice app -->
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="matching.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
matching.js
Normal file
103
matching.js
Normal file
@@ -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);
|
||||||
584
styles.css
Normal file
584
styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user