Files
shalom/app.js
2026-07-02 13:41:26 +02:00

815 lines
26 KiB
JavaScript

/* ===== 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]));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ---------- 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);
},
// nota di ottone per la fanfara
brass(freq, when, dur, vol = 0.25) {
if (!this.on || !this.ctx) return;
const t = this.ctx.currentTime + when;
const o = this.ctx.createOscillator();
const o2 = this.ctx.createOscillator();
const g = this.ctx.createGain();
const lp = this.ctx.createBiquadFilter();
o.type = "sawtooth";
o2.type = "square";
o.frequency.value = freq;
o2.frequency.value = freq * 2;
lp.type = "lowpass";
lp.frequency.value = 2800;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.03);
g.gain.setValueAtTime(vol, t + dur * 0.65);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g);
o2.connect(g);
g.connect(lp).connect(this.master);
o.start(t);
o2.start(t);
o.stop(t + dur + 0.02);
o2.stop(t + dur + 0.02);
},
// trombe trionfali: "ta ta-ta-taaa"
fanfare() {
this.brass(392, 0.0, 0.12, 0.22); // G4
this.brass(523, 0.14, 0.12, 0.24); // C5
this.brass(659, 0.28, 0.12, 0.24); // E5
this.brass(784, 0.42, 0.7, 0.3); // G5 tenuta
for (let i = 0; i < 6; i++)
setTimeout(() => this.blip(1568 + Math.random() * 700, 0.08, "sine", 0.1), 520 + i * 60);
},
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 (canvas a schermo intero, dimensionato a mano) ---------- */
// IMPORTANTE: niente useWorker + niente resize automatico. Dimensiono io il
// canvas alla viewport, altrimenti il buffer resta 300x150 e i coriandoli
// finiscono tutti ammassati nell'angolo in alto a sinistra.
const confettiCanvas = $("#confetti");
function sizeConfetti() {
confettiCanvas.width = window.innerWidth;
confettiCanvas.height = window.innerHeight;
}
sizeConfetti();
window.addEventListener("resize", sizeConfetti);
const myConfetti =
typeof confetti !== "undefined"
? confetti.create(confettiCanvas, { resize: false, useWorker: false })
: () => {};
const CONFETTI_COLORS = ["#ffd23f", "#ff9e00", "#ff2e88", "#00e5ff", "#29e08a", "#ff3b5c", "#ffffff"];
// VALANGA di coriandoli su TUTTO lo schermo
function bigBurst() {
const C = CONFETTI_COLORS;
// esplosione centrale gigante a 360°
myConfetti({ particleCount: 400, spread: 360, startVelocity: 45, scalar: 1.1, origin: { x: 0.5, y: 0.5 }, colors: C });
// fila di esplosioni lungo TUTTA la larghezza
for (let x = 0; x <= 1.0001; x += 0.1) {
myConfetti({ particleCount: 60, spread: 110, startVelocity: 55, origin: { x, y: 0.55 }, colors: C });
}
// cannoni laterali potenti dal basso
myConfetti({ particleCount: 250, angle: 60, spread: 120, startVelocity: 75, origin: { x: 0, y: 0.85 }, colors: C });
myConfetti({ particleCount: 250, angle: 120, spread: 120, startVelocity: 75, origin: { x: 1, y: 0.85 }, colors: C });
// pioggia dall'alto su tutta la larghezza
for (let x = 0; x <= 1.0001; x += 0.1) {
myConfetti({ particleCount: 40, spread: 160, startVelocity: 35, gravity: 1, origin: { x, y: -0.1 }, colors: C });
}
}
// pioggia continua a schermo pieno per N ms
function confettiRain(durationMs) {
const end = Date.now() + durationMs;
(function frame() {
for (let x = 0; x <= 1.0001; x += 0.12) {
myConfetti({ particleCount: 10, spread: 130, startVelocity: 45, gravity: 1, origin: { x, y: -0.05 }, colors: CONFETTI_COLORS });
}
if (Date.now() < end) requestAnimationFrame(frame);
})();
}
function celebrate(kind) {
bigBurst();
// seconda ondata immediata per raddoppiare la densità
setTimeout(bigBurst, 180);
confettiRain(kind === "jackpot" ? 3500 : 1400);
}
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);
}
// Nome "pulito" (senza emoji) dell'elemento del rullo a un dato indice.
function reelNameAt(i) {
const el = reelEl.children[i];
return el ? el.textContent.replace(/[🏠🛁]/g, "").trim() : "";
}
// Probabilità di ragebait (finto stop sulla stanza sbagliata) per giro.
const TEASE_CHANCE = 0.3;
// GIRO. Nel ~30% dei casi: decelera e "si ferma" su una stanza sbagliata
// (adiacente), tiene la suspense, poi scatta sulla stanza vera. Altrimenti
// atterra direttamente con un normale assestamento.
async function spinReel(targetId) {
const targetIndex = buildStrip(targetId);
const finalY = -(targetIndex - 1) * ITEM_H; // target centrata
const targetName = roomById[targetId].nome;
// direzione del bluff: la "finta" stanza deve essere diversa dalla vera
let dir = Math.random() < 0.5 ? -1 : 1;
if (reelNameAt(targetIndex + dir) === targetName) dir = -dir;
if (reelNameAt(targetIndex + dir) === targetName) dir = 0; // niente adiacente valida
const wantTease = dir !== 0 && Math.random() < TEASE_CHANCE;
// --- tick sincronizzati ---
let ticking = false;
let lastIdx = 0;
function startTicks() {
ticking = true;
lastIdx = currentReelIndex();
(function loop() {
if (!ticking) return;
const idx = currentReelIndex();
if (idx !== lastIdx) {
Audio.tick();
lastIdx = idx;
}
requestAnimationFrame(loop);
})();
}
const stopTicks = () => (ticking = false);
const stopRiser = Audio.riser(wantTease ? 4.6 : 4.2);
startTicks();
if (wantTease) {
const fakeY = -(targetIndex + dir - 1) * ITEM_H;
// FASE 1: decelera fino a "fermarsi" sulla stanza SBAGLIATA
const a1 = reelEl.animate(
[{ transform: "translateY(0)" }, { transform: `translateY(${fakeY}px)` }],
{ duration: 3600, easing: "cubic-bezier(0.08, 0.85, 0.14, 1)", fill: "forwards" }
);
await a1.finished;
stopTicks();
// FASE 2: gaslighting — sembra tutto finito
Audio.blip(320, 0.18, "sine", 0.16);
const win = $(".reel-window");
win.classList.add("teasing");
mc(`Ferma su ${reelNameAt(targetIndex + dir)}...? 🤨`);
await sleep(700);
// FASE 3: SCATTO alla stanza vera
Audio.blip(200, 0.14, "sawtooth", 0.22, 90); // whoosh
startTicks();
const a2 = reelEl.animate(
[{ transform: `translateY(${fakeY}px)` }, { transform: `translateY(${finalY}px)` }],
{ duration: 520, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "forwards" }
);
await a2.finished;
stopTicks();
win.classList.remove("teasing");
} else {
// atterraggio diretto + micro-assestamento
const a = reelEl.animate(
[{ transform: "translateY(0)" }, { transform: `translateY(${finalY}px)` }],
{ duration: 4200, easing: "cubic-bezier(0.12, 0.85, 0.18, 1)", fill: "forwards" }
);
await a.finished;
stopTicks();
reelEl.animate(
[
{ transform: `translateY(${finalY}px)` },
{ transform: `translateY(${finalY + 12}px)` },
{ transform: `translateY(${finalY}px)` },
],
{ duration: 260, easing: "ease-out", fill: "forwards" }
);
await sleep(260);
}
stopRiser();
}
/* ---------- 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;
let leverArmed = 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("Tira giù la leva per far salire il primo! ⬇️");
// il bottone principale serve solo per RIGIOCA a fine partita
$("#btn-primary").style.display = "none";
$("#btn-primary").onclick = onPrimary;
armLever(true);
}
/* ---------- 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;
armLever(false);
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à? 🎲`);
// animazione leva + boost monete
$("#lever").classList.add("pull");
setTimeout(() => $("#lever").classList.remove("pull"), 320);
if (window.__coinBoost) window.__coinBoost();
await spinReel(roomId);
// atterraggio
occupants[roomId].push(person);
highlightRoom(roomId);
renderRooms();
highlightRoom(roomId);
resolveBets(person, roomId, oddsMap);
currentBet = null;
const isJackpot = room.jacuzzi;
Audio.ding(); // "click" di aggancio della stanza
Audio.fanfare(); // 🎺 trombe alla selezione
if (isJackpot) {
Audio.jackpot();
celebrate("jackpot");
screenShake();
screenFlash();
mc(`🎉 JACKPOT! ${person} conquista ${room.nome} con IDROMASSAGGIO! 🛁`);
log(`JACKPOT: ${person}${room.nome} 🛁`, "jackpot");
} else {
celebrate("normal");
mc(`${person} va in ${room.nome}!`);
log(`${person}${room.nome}`);
}
Audio.coin();
cursor++;
setProgress();
renderLeaderboard();
busy = false;
if (cursor >= revealQueue.length) {
setTimeout(endGame, 1100);
} else if (auto) {
setTimeout(nextTurn, 1400);
} else {
// riarma la leva per il prossimo giro
setTimeout(() => {
if (!busy && cursor < revealQueue.length && !auto) {
armLever(true);
mc("Tira di nuovo la leva! ⬇️");
}
}, 1300);
}
}
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() {
armLever(false);
const winner = players.slice().sort((a, b) => b.chips - a.chips)[0];
const you = players.find((p) => p.you);
mc(`🏁 Tutti sistemati! Vincitore scommesse: ${winner.name} (${winner.chips} gettoni).`);
log(`Fine. I tuoi gettoni: ${you.chips}. Miglior scommettitore: ${winner.name}.`, "win");
Audio.fanfare();
celebrate("jackpot");
$("#btn-primary").style.display = "";
$("#btn-primary").textContent = "RIGIOCA 🔁";
$("#btn-primary").disabled = false;
$("#btn-primary").onclick = () => {
newGame(false);
};
}
/* ---------- 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("");
}
/* ---------- Leva (trigger dei giri) ---------- */
function armLever(on) {
leverArmed = on;
const lever = $("#lever");
lever.classList.toggle("armed", on);
}
function pullAndSpin() {
if (!leverArmed || busy) return;
armLever(false);
nextTurn();
}
function bindLever() {
const lever = $("#lever");
let dragging = false;
let startY = 0;
lever.addEventListener("pointerdown", (e) => {
if (!leverArmed || busy) return;
dragging = true;
startY = e.clientY;
try {
lever.setPointerCapture(e.pointerId);
} catch (err) {}
e.preventDefault();
});
lever.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dy = Math.max(0, Math.min(70, e.clientY - startY));
lever.style.transform = `rotate(${(dy / 70) * 28}deg)`;
});
const release = (e) => {
if (!dragging) return;
dragging = false;
const dy = Math.max(0, e.clientY - startY);
lever.style.transform = "";
// tap secco (dy piccolo) oppure tirata piena => parte; tirata a metà => annulla
if (dy < 8 || dy >= 26) pullAndSpin();
};
lever.addEventListener("pointerup", release);
lever.addEventListener("pointercancel", () => {
dragging = false;
$("#lever").style.transform = "";
});
}
/* ---------- Controlli ---------- */
function onPrimary() {
if (busy) return;
if (cursor >= revealQueue.length) {
endGame();
return;
}
nextTurn();
}
function bindControls() {
bindLever();
$("#btn-primary").onclick = onPrimary;
$("#btn-auto").onclick = (e) => {
auto = !auto;
e.currentTarget.classList.toggle("active", auto);
if (auto) {
armLever(false);
if (!busy && cursor < revealQueue.length) nextTurn();
} else if (!busy && cursor < revealQueue.length) {
armLever(true);
mc("Tira la leva! ⬇️");
}
};
$("#btn-reroll").onclick = () => {
if (busy) return;
Audio.init();
Audio.resume();
newGame(true);
mc("Nuovo sorteggio pronto! 🎲 Tira la leva ⬇️");
};
$("#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);
// hook di debug (per test/verifica; innocuo)
window.__celebrate = celebrate;
})();