3 colonne

This commit is contained in:
2026-07-02 14:02:09 +02:00
parent be284db25c
commit 130d6f944a
3 changed files with 160 additions and 108 deletions

228
app.js
View File

@@ -40,6 +40,10 @@
tick() { tick() {
this.blip(1300, 0.03, "square", 0.12); this.blip(1300, 0.03, "square", 0.12);
}, },
reelStop() {
this.blip(210, 0.08, "square", 0.28);
this.blip(120, 0.13, "sine", 0.2);
},
ding() { ding() {
this.blip(1318, 0.5, "sine", 0.3); // E6 this.blip(1318, 0.5, "sine", 0.3); // E6
this.blip(1568, 0.55, "sine", 0.25); // G6 this.blip(1568, 0.55, "sine", 0.25); // G6
@@ -281,140 +285,160 @@
})(last); })(last);
} }
/* ---------- Rullo slot ---------- */ /* ---------- Rulli slot (3 colonne) ---------- */
const ITEM_H = 88; const ITEM_H = 88;
const reelEl = $("#reel"); const reelEls = [$("#reel0"), $("#reel1"), $("#reel2")];
// simbolo (emoji) per ogni stanza — come i simboli di una slot vera
const ROOM_SYMBOL = {
zeus: "⚡", hera: "👑", ignota: "❓", scale: "🪜",
rischio: "🎲", eolo: "🌪️", idro: "💧", argo: "⛵",
};
const symbolOf = (room) => ROOM_SYMBOL[room.id] || "🏠";
function reelItemHTML(room) { function reelItemHTML(room) {
const jz = room.jacuzzi ? " jz" : ""; const jz = room.jacuzzi ? " jz" : "";
const icon = room.jacuzzi ? "🛁" : "🏠"; return `<div class="reel-item${jz}" data-room="${room.id}">${symbolOf(room)}</div>`;
return `<div class="reel-item${jz}"><span>${icon}</span><span>${room.nome}</span></div>`;
} }
// Costruisce una striscia lunga che termina sulla stanza target (centrata // Costruisce la striscia di un rullo che termina sulla stanza target
// nella riga di mezzo della finestra a 3 righe) e ritorna l'indice target. // (centrata nella riga di mezzo); ritorna l'indice target.
function buildStrip(targetId) { function buildStripIn(el, targetId) {
// azzera le animazioni del giro precedente (evita accumulo + fill forwards) el.getAnimations().forEach((a) => a.cancel());
reelEl.getAnimations().forEach((a) => a.cancel());
const loops = 14; const loops = 14;
const seq = []; const seq = [];
for (let l = 0; l < loops; l++) for (let l = 0; l < loops; l++) for (const r of ROOMS) seq.push(r);
for (const r of ROOMS) seq.push(r); const start = Math.floor(Math.random() * ROOMS.length); // offset diverso per rullo
// 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 rotated = seq.slice(start).concat(seq.slice(0, start));
const targetIndex = rotated.length; // la target va in coda const targetIndex = rotated.length; // la target va in coda
rotated.push(roomById[targetId]); rotated.push(roomById[targetId]);
// aggiungo 2 righe sotto per riempire la finestra rotated.push(ROOMS[0], ROOMS[1]); // riempi sotto la finestra
rotated.push(ROOMS[0], ROOMS[1]); el.innerHTML = rotated.map(reelItemHTML).join("");
reelEl.innerHTML = rotated.map(reelItemHTML).join(""); el.style.transform = "translateY(0)";
reelEl.style.transform = "translateY(0)";
return targetIndex; return targetIndex;
} }
function currentReelIndex() { // stato a riposo: mostra i simboli fermi invece di rulli vuoti
const t = getComputedStyle(reelEl).transform; function fillReelsIdle() {
reelEls.forEach((el, i) => {
el.getAnimations().forEach((a) => a.cancel());
el.innerHTML = ROOMS.map(reelItemHTML).join("");
const k = i + 1; // centra un simbolo diverso per rullo
el.style.transform = `translateY(${-(k - 1) * ITEM_H}px)`;
});
}
function currentIndexOf(el) {
const t = getComputedStyle(el).transform;
if (!t || t === "none") return 0; if (!t || t === "none") return 0;
const y = parseFloat(t.split(",")[5]); // matrix(a,b,c,d,e,f) -> f = translateY const y = parseFloat(t.split(",")[5]); // matrix(a,b,c,d,e,f) -> f = translateY
return Math.round(-y / ITEM_H); return Math.round(-y / ITEM_H);
} }
// Nome "pulito" (senza emoji) dell'elemento del rullo a un dato indice. const roomAtIndex = (el, i) =>
function reelNameAt(i) { el.children[i] ? roomById[el.children[i].dataset.room] : null;
const el = reelEl.children[i];
return el ? el.textContent.replace(/[🏠🛁]/g, "").trim() : "";
}
// Probabilità di ragebait (finto stop sulla stanza sbagliata) per giro. // Probabilità di ragebait (finto stop) sul rullo finale.
const TEASE_CHANCE = 0.3; const TEASE_CHANCE = 0.3;
// GIRO. Nel ~30% dei casi: decelera e "si ferma" su una stanza sbagliata function bounceReel(el, finalY) {
// (adiacente), tiene la suspense, poi scatta sulla stanza vera. Altrimenti return el.animate(
// atterra direttamente con un normale assestamento. [
async function spinReel(targetId) { { transform: `translateY(${finalY}px)` },
const targetIndex = buildStrip(targetId); { transform: `translateY(${finalY + 12}px)` },
const finalY = -(targetIndex - 1) * ITEM_H; // target centrata { transform: `translateY(${finalY}px)` },
const targetName = roomById[targetId].nome; ],
{ duration: 220, easing: "ease-out", fill: "forwards" }
).finished;
}
// direzione del bluff: la "finta" stanza deve essere diversa dalla vera // rullo che atterra dritto sulla target
async function reelLand(el, finalY, duration) {
await el.animate(
[{ transform: "translateY(0)" }, { transform: `translateY(${finalY}px)` }],
{ duration, easing: "cubic-bezier(0.12, 0.85, 0.18, 1)", fill: "forwards" }
).finished;
await bounceReel(el, finalY);
Audio.reelStop();
}
// rullo con RAGEBAIT: si ferma sulla stanza sbagliata, suspense, poi scatta
async function reelLandTease(el, targetIndex, finalY) {
const targetRoom = roomAtIndex(el, targetIndex);
let dir = Math.random() < 0.5 ? -1 : 1; let dir = Math.random() < 0.5 ? -1 : 1;
if (reelNameAt(targetIndex + dir) === targetName) dir = -dir; if (roomAtIndex(el, targetIndex + dir)?.id === targetRoom.id) dir = -dir;
if (reelNameAt(targetIndex + dir) === targetName) dir = 0; // niente adiacente valida if (roomAtIndex(el, targetIndex + dir)?.id === targetRoom.id)
const wantTease = dir !== 0 && Math.random() < TEASE_CHANCE; return reelLand(el, finalY, 4200); // niente adiacente valida
const fakeRoom = roomAtIndex(el, targetIndex + dir);
const fakeY = -(targetIndex + dir - 1) * ITEM_H;
// --- tick sincronizzati --- // FASE 1: decelera e "si ferma" sulla stanza SBAGLIATA
let ticking = false; await el.animate(
let lastIdx = 0; [{ transform: "translateY(0)" }, { transform: `translateY(${fakeY}px)` }],
function startTicks() { { duration: 3600, easing: "cubic-bezier(0.08, 0.85, 0.14, 1)", fill: "forwards" }
ticking = true; ).finished;
lastIdx = currentReelIndex(); Audio.reelStop();
(function loop() {
if (!ticking) return;
const idx = currentReelIndex();
if (idx !== lastIdx) {
Audio.tick();
lastIdx = idx;
}
requestAnimationFrame(loop);
})();
}
const stopTicks = () => (ticking = false);
// ZOOM sulla macchina + shake mentre gira // FASE 2: gaslighting — sembra fatta
Audio.blip(320, 0.18, "sine", 0.16);
const win = $(".reel-window");
win.classList.add("teasing");
mc(`Ferma su ${fakeRoom.nome} ${symbolOf(fakeRoom)}...? 🤨`);
await sleep(750);
// FASE 3: SCATTO alla stanza vera
Audio.blip(200, 0.14, "sawtooth", 0.22, 90); // whoosh
await el.animate(
[{ transform: `translateY(${fakeY}px)` }, { transform: `translateY(${finalY}px)` }],
{ duration: 520, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "forwards" }
).finished;
win.classList.remove("teasing");
Audio.reelStop();
}
// GIRO: i 3 rulli partono insieme e si fermano da sinistra a destra,
// allineandosi tutti sulla stanza assegnata (l'ultimo può fare il ragebait).
async function spinReel(targetId) {
const targetIndex = reelEls.map((el) => buildStripIn(el, targetId))[0]; // uguale per tutti
const finalY = -(targetIndex - 1) * ITEM_H;
const targetRoom = roomById[targetId];
$("#reel-result").innerHTML = "&nbsp;";
// ZOOM + shake; lo shake si ferma a metà spin (quando inizia a rallentare)
zoomMachineIn(); zoomMachineIn();
const mainDur = 4200;
setTimeout(zoomMachineSteady, mainDur / 2);
const stopRiser = Audio.riser(wantTease ? 4.6 : 4.2); const stopRiser = Audio.riser(5.0);
startTicks();
if (wantTease) { // tick sincronizzati sul 3° rullo (gira più a lungo)
const fakeY = -(targetIndex + dir - 1) * ITEM_H; let ticking = true;
// FASE 1: decelera fino a "fermarsi" sulla stanza SBAGLIATA let lastIdx = 0;
const a1 = reelEl.animate( (function loop() {
[{ transform: "translateY(0)" }, { transform: `translateY(${fakeY}px)` }], if (!ticking) return;
{ duration: 3600, easing: "cubic-bezier(0.08, 0.85, 0.14, 1)", fill: "forwards" } const i = currentIndexOf(reelEls[2]);
); if (i !== lastIdx) {
await a1.finished; Audio.tick();
stopTicks(); lastIdx = i;
}
requestAnimationFrame(loop);
})();
// FASE 2: gaslighting — sembra tutto finito const wantTease = Math.random() < TEASE_CHANCE;
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 // stop staggerato sinistra -> destra
Audio.blip(200, 0.14, "sawtooth", 0.22, 90); // whoosh await Promise.all([
startTicks(); reelLand(reelEls[0], finalY, 2600),
const a2 = reelEl.animate( reelLand(reelEls[1], finalY, 3300),
[{ transform: `translateY(${fakeY}px)` }, { transform: `translateY(${finalY}px)` }], wantTease
{ duration: 520, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "forwards" } ? reelLandTease(reelEls[2], targetIndex, finalY)
); : reelLand(reelEls[2], finalY, 4200),
await a2.finished; ]);
stopTicks();
win.classList.remove("teasing"); ticking = false;
} 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(); stopRiser();
// fine scroll: ferma il tremolio ma resta zoomata (il ritorno avviene
// dopo la vittoria, con una mini-animazione a molla — vedi zoomMachineOut)
zoomMachineSteady(); zoomMachineSteady();
$("#reel-result").innerHTML = `${symbolOf(targetRoom)} <b>${targetRoom.nome}</b> ${symbolOf(targetRoom)}`;
} }
/* ---------- Zoom macchina ---------- */ /* ---------- Zoom macchina ---------- */
@@ -490,6 +514,8 @@
renderRooms(); renderRooms();
renderLeaderboard(); renderLeaderboard();
setProgress(); setProgress();
fillReelsIdle();
$("#reel-result").innerHTML = "&nbsp;";
$("#player-name").textContent = "—"; $("#player-name").textContent = "—";
$("#odds").innerHTML = ""; $("#odds").innerHTML = "";
mc("Tira giù la leva per far salire il primo! ⬇️"); mc("Tira giù la leva per far salire il primo! ⬇️");

View File

@@ -61,7 +61,11 @@
<div class="slot-side left"></div> <div class="slot-side left"></div>
<div class="reel-window"> <div class="reel-window">
<div class="payline"></div> <div class="payline"></div>
<div id="reel" class="reel-strip"></div> <div class="reels">
<div class="reel-col"><div id="reel0" class="reel-strip"></div></div>
<div class="reel-col"><div id="reel1" class="reel-strip"></div></div>
<div class="reel-col"><div id="reel2" class="reel-strip"></div></div>
</div>
<div class="reel-fade top"></div> <div class="reel-fade top"></div>
<div class="reel-fade bottom"></div> <div class="reel-fade bottom"></div>
</div> </div>
@@ -70,6 +74,7 @@
</div> </div>
</div> </div>
<div id="reel-result" class="reel-result">&nbsp;</div>
<div id="mc" class="mc-line">Benvenuti alla lotteria delle stanze 🎲</div> <div id="mc" class="mc-line">Benvenuti alla lotteria delle stanze 🎲</div>
<div class="controls"> <div class="controls">

View File

@@ -324,6 +324,19 @@ body {
border: 2px solid rgba(255, 255, 255, 0.15); border: 2px solid rgba(255, 255, 255, 0.15);
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9); box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.9);
} }
/* tre colonne affiancate dentro la finestra */
.reels {
display: flex;
height: 100%;
}
.reel-col {
flex: 1;
position: relative;
overflow: hidden;
}
.reel-col + .reel-col {
border-left: 2px solid rgba(255, 255, 255, 0.08);
}
.reel-strip { .reel-strip {
will-change: transform; will-change: transform;
} }
@@ -332,17 +345,25 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; font-size: 54px;
font-family: "Anton", sans-serif; line-height: 1;
font-size: 34px; filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.2));
letter-spacing: 1px;
color: #fff;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.35);
} }
.reel-item.jz { .reel-item.jz {
filter: drop-shadow(0 0 14px var(--gold-2));
}
/* caption con il nome della stanza risultante */
.reel-result {
min-height: 34px;
font-family: "Anton", sans-serif;
font-size: 26px;
letter-spacing: 1px;
color: var(--gold); color: var(--gold);
text-shadow: 0 0 14px var(--gold-2); text-shadow: 0 0 14px var(--gold-2);
} }
.reel-result b {
font-weight: 400;
}
.payline { .payline {
position: absolute; position: absolute;
left: 0; left: 0;