709 lines
27 KiB
Markdown
709 lines
27 KiB
Markdown
# Implementazione in Vanilla JS — Tecniche pratiche per una web app "slot machine / ruota / betting"
|
||
|
||
Guida operativa per costruire un'app in stile slot-machine / spinning-reel / betting con **HTML + CSS + JavaScript puri**, senza framework e senza build step. Ogni sezione contiene snippet pronti da incollare. L'obiettivo è un progetto che parte semplicemente aprendo `index.html` (o con un piccolo live server).
|
||
|
||
---
|
||
|
||
## 1. La reel della slot: strip verticale con `transform: translateY`
|
||
|
||
L'idea di base di una reel (rullo) è banale e robusta: una **striscia verticale di elementi** (i simboli) dentro un contenitore con `overflow: hidden`. Si sposta la striscia con `transform: translateY(...)` e si fa in modo che si fermi (snapping) su un **indice target predeterminato**, con un easing `cubic-bezier` in uscita (ease-out) così il rullo decelera e "atterra" sul risultato deciso in anticipo.
|
||
|
||
Concetti chiave:
|
||
|
||
- Ogni simbolo ha un'altezza fissa (`--item-h`). Fermarsi sull'indice `i` significa `translateY(-i * item-h)`.
|
||
- Per dare l'illusione di velocità, si **ripete la lista di simboli N volte**: si anima da un offset alto verso l'indice finale, così la reel scorre parecchio prima di fermarsi.
|
||
- L'atterraggio deterministico si ottiene calcolando l'offset target **prima** di lanciare l'animazione: il risultato è già deciso, l'animazione è solo estetica.
|
||
- La curva `cubic-bezier(0.23, 1, 0.32, 1)` (nota come *easeOutQuint*) dà quella decelerazione "morbida" tipica delle slot. `ease-out` puro equivale a `cubic-bezier(0, 0, 0.58, 1)`.
|
||
|
||
### HTML
|
||
|
||
```html
|
||
<div class="reel" id="reel1">
|
||
<div class="reel__strip">
|
||
<!-- i simboli vengono generati/ripetuti via JS -->
|
||
<div class="reel__item">🍒</div>
|
||
<div class="reel__item">🍋</div>
|
||
<div class="reel__item">🔔</div>
|
||
<div class="reel__item">⭐</div>
|
||
<div class="reel__item">7️⃣</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### CSS
|
||
|
||
```css
|
||
:root {
|
||
--item-h: 100px; /* altezza di un simbolo */
|
||
--visible: 3; /* simboli visibili nella finestra */
|
||
}
|
||
|
||
.reel {
|
||
height: calc(var(--item-h) * var(--visible));
|
||
width: 120px;
|
||
overflow: hidden; /* finestra: taglia tutto il resto */
|
||
border-radius: 12px;
|
||
background: #111;
|
||
box-shadow: inset 0 0 24px rgba(0,0,0,.8);
|
||
}
|
||
|
||
.reel__strip {
|
||
display: flex;
|
||
flex-direction: column;
|
||
will-change: transform; /* hint per il compositor / GPU */
|
||
}
|
||
|
||
.reel__item {
|
||
height: var(--item-h);
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 56px;
|
||
user-select: none;
|
||
}
|
||
|
||
/* classe applicata via JS per la fase di spin */
|
||
.reel__strip--spinning {
|
||
transition: transform 3.2s cubic-bezier(0.23, 1, 0.32, 1);
|
||
}
|
||
```
|
||
|
||
### JavaScript — atterraggio su indice predeterminato
|
||
|
||
```js
|
||
const SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '7️⃣'];
|
||
const ITEM_H = 100;
|
||
const LOOPS = 8; // quante volte ripeto la lista per "allungare" lo scorrimento
|
||
|
||
function buildStrip(stripEl) {
|
||
// Ripeto i simboli LOOPS volte così la reel ha molto materiale da scorrere.
|
||
const repeated = Array.from({ length: LOOPS }, () => SYMBOLS).flat();
|
||
stripEl.innerHTML = repeated
|
||
.map((s) => `<div class="reel__item">${s}</div>`)
|
||
.join('');
|
||
return repeated.length;
|
||
}
|
||
|
||
/**
|
||
* Fa girare la reel e la ferma ESATTAMENTE su `targetIndex` (0..SYMBOLS.length-1).
|
||
* Il risultato è deciso a priori: passo l'indice che voglio far uscire.
|
||
*/
|
||
function spinTo(stripEl, targetIndex) {
|
||
const totalItems = buildStrip(stripEl);
|
||
|
||
// Posizione finale: penultimo "giro" + l'indice target, così scorre a lungo.
|
||
const finalItem = (LOOPS - 2) * SYMBOLS.length + targetIndex;
|
||
const finalY = -finalItem * ITEM_H;
|
||
|
||
// Reset istantaneo in cima, senza transizione.
|
||
stripEl.classList.remove('reel__strip--spinning');
|
||
stripEl.style.transform = 'translateY(0)';
|
||
|
||
// Forzo un reflow così il browser "vede" lo stato iniziale prima di animare.
|
||
void stripEl.offsetHeight;
|
||
|
||
// Attivo la transizione ease-out e imposto il target: la reel decelera e atterra.
|
||
stripEl.classList.add('reel__strip--spinning');
|
||
stripEl.style.transform = `translateY(${finalY}px)`;
|
||
|
||
return new Promise((resolve) => {
|
||
stripEl.addEventListener('transitionend', () => resolve(targetIndex), {
|
||
once: true,
|
||
});
|
||
});
|
||
}
|
||
|
||
// Uso:
|
||
const strip = document.querySelector('#reel1 .reel__strip');
|
||
const wanted = Math.floor(Math.random() * SYMBOLS.length); // o un valore deciso dal server
|
||
spinTo(strip, wanted).then((idx) => {
|
||
console.log('Atterrato su', SYMBOLS[idx]);
|
||
});
|
||
```
|
||
|
||
Per una slot a più rulli, si lancia `spinTo` su ogni reel con un piccolo **stagger** (ritardo crescente) e/o durate leggermente diverse, così i rulli si fermano uno dopo l'altro:
|
||
|
||
```js
|
||
const reels = document.querySelectorAll('.reel__strip');
|
||
const result = [2, 4, 2]; // simboli target per rullo (deciso a priori)
|
||
|
||
Promise.all(
|
||
[...reels].map((strip, i) =>
|
||
new Promise((res) =>
|
||
setTimeout(() => spinTo(strip, result[i]).then(res), i * 250)
|
||
)
|
||
)
|
||
).then(() => console.log('Tutti i rulli fermi:', result));
|
||
```
|
||
|
||
> Nota: `transitionend` può scattare più volte se animi più proprietà. Qui animiamo solo `transform`, e usiamo `{ once: true }` per sicurezza. Se serve robustezza extra, si può fare fallback con un `setTimeout` pari alla durata.
|
||
|
||
---
|
||
|
||
## 2. Web Animations API vs CSS transitions vs `requestAnimationFrame`
|
||
|
||
Tre strumenti, tre casi d'uso. Regola generale (web.dev / MDN): **usa CSS dove possibile**, sali di livello solo quando serve controllo imperativo o logica per-frame.
|
||
|
||
| Tecnica | Quando usarla | Punti di forza | Limiti |
|
||
|---|---|---|---|
|
||
| **CSS transitions** | Passaggio tra due stati (hover, `.is-open`, classe applicata via JS). È quello che usiamo nello snippet reel sopra. | Semplice, dichiarativo, girato sul compositor (GPU) per `transform`/`opacity`. | Solo A→B; poco controllo dinamico; sincronizzare più elementi è scomodo. |
|
||
| **CSS animations (`@keyframes`)** | Animazioni multi-fase/loop puramente estetiche (pulsare di una luce, glow). | Dichiarativo, riavviabile via classe, performante. | I keyframe sono statici; parametrizzarli a runtime richiede custom properties o `@property`. |
|
||
| **Web Animations API (`element.animate()`)** | Quando il target è **calcolato a runtime** (es. angolo finale della ruota) e vuoi Promise, `reverse()`, `pause()`, `playbackRate`. Ideale per ruota e slot orchestrate. | Controllo imperativo + performance CSS-like; `animation.finished` è una Promise; keyframe dinamici. | API un filo più verbosa; niente per canvas/WebGL. |
|
||
| **`requestAnimationFrame`** | Serve logica **per ogni frame**: canvas 2D/WebGL, fisica, particelle, valori che dipendono dall'input in tempo reale (es. coriandoli, counter animati). | Massimo controllo; unico modo di disegnare su canvas frame-by-frame; sincronizzato al refresh del monitor. | Tutto a mano (easing, timing, cleanup); gira sul main thread. |
|
||
|
||
### `element.animate()` (WAAPI) — esempio con Promise
|
||
|
||
```js
|
||
const el = document.querySelector('.animate-me');
|
||
|
||
const anim = el.animate(
|
||
[
|
||
{ transform: 'translateY(-300px)', opacity: 0 },
|
||
{ transform: 'translateY(0)', opacity: 1 },
|
||
],
|
||
{ duration: 700, easing: 'ease-out', fill: 'forwards' }
|
||
);
|
||
|
||
anim.finished.then(() => console.log('Fatto!')); // niente listener manuali
|
||
// anim.reverse(); anim.pause(); anim.playbackRate = 2;
|
||
```
|
||
|
||
`fill: 'forwards'` mantiene lo stato finale invece di tornare allo stato iniziale.
|
||
|
||
### `requestAnimationFrame` — scheletro per canvas/particelle
|
||
|
||
```js
|
||
let rafId;
|
||
function loop(now) {
|
||
// update(now); draw();
|
||
rafId = requestAnimationFrame(loop);
|
||
}
|
||
rafId = requestAnimationFrame(loop);
|
||
// stop: cancelAnimationFrame(rafId);
|
||
```
|
||
|
||
Per la nostra app: **transitions** per la reel semplice, **WAAPI** per la ruota (angolo target calcolato), **rAF** solo se disegniamo particelle/coriandoli custom su canvas (o deleghiamo a `canvas-confetti`, che lo fa per noi).
|
||
|
||
---
|
||
|
||
## 3. Ruota della fortuna: `conic-gradient` + `rotate` verso un angolo target
|
||
|
||
Due parti indipendenti:
|
||
|
||
1. **Disegno degli spicchi** con `conic-gradient` (una sola dichiarazione CSS, zero immagini).
|
||
2. **Spin** con `transform: rotate(...)` che atterra su un angolo target calcolato in JS (WAAPI o transition).
|
||
|
||
### CSS — sfondo a spicchi con `conic-gradient`
|
||
|
||
Per N spicchi uguali, ogni spicchio occupa `360 / N` gradi. Con `conic-gradient` si alternano i colori con degli hard-stop:
|
||
|
||
```css
|
||
.wheel {
|
||
--n: 8; /* numero di spicchi */
|
||
width: 320px;
|
||
aspect-ratio: 1;
|
||
border-radius: 50%;
|
||
/* 8 spicchi da 45deg alternando due colori */
|
||
background: conic-gradient(
|
||
#e63946 0deg 45deg,
|
||
#f1a208 45deg 90deg,
|
||
#e63946 90deg 135deg,
|
||
#f1a208 135deg 180deg,
|
||
#e63946 180deg 225deg,
|
||
#f1a208 225deg 270deg,
|
||
#e63946 270deg 315deg,
|
||
#f1a208 315deg 360deg
|
||
);
|
||
transform: rotate(0deg);
|
||
will-change: transform;
|
||
}
|
||
|
||
/* indicatore/freccia fisso in alto (ore 12) */
|
||
.wheel-wrap { position: relative; display: inline-block; }
|
||
.wheel-wrap::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -6px; left: 50%;
|
||
transform: translateX(-50%);
|
||
border-left: 14px solid transparent;
|
||
border-right: 14px solid transparent;
|
||
border-top: 22px solid #222; /* triangolo che punta verso il centro */
|
||
}
|
||
```
|
||
|
||
Si può anche generare il gradient da JS per N arbitrario:
|
||
|
||
```js
|
||
function conicFor(colors) {
|
||
const step = 360 / colors.length;
|
||
const stops = colors
|
||
.map((c, i) => `${c} ${i * step}deg ${(i + 1) * step}deg`)
|
||
.join(', ');
|
||
return `conic-gradient(${stops})`;
|
||
}
|
||
wheel.style.background = conicFor(['#e63946','#f1a208','#2a9d8f','#264653',
|
||
'#e63946','#f1a208','#2a9d8f','#264653']);
|
||
```
|
||
|
||
### JavaScript — spin che atterra su uno spicchio target
|
||
|
||
Il trucco per l'atterraggio deterministico: parti dall'angolo corrente, aggiungi **diversi giri interi** (es. `5 * 360`) più l'offset necessario per portare lo spicchio target sotto l'indicatore.
|
||
|
||
```js
|
||
const SEGMENTS = 8;
|
||
const SEG = 360 / SEGMENTS; // ampiezza di uno spicchio
|
||
let currentRotation = 0; // stato: rotazione accumulata
|
||
|
||
/**
|
||
* Ferma la ruota sullo spicchio `targetIndex` (0 = quello a ore 12 all'inizio).
|
||
* L'indicatore è fisso in alto: dobbiamo ruotare la ruota così che il centro
|
||
* dello spicchio target finisca a ore 12.
|
||
*/
|
||
function spinWheel(wheel, targetIndex) {
|
||
const fullSpins = 5; // giri "scenografici"
|
||
// centro dello spicchio target, misurato in senso orario dal top:
|
||
const targetCenter = targetIndex * SEG + SEG / 2;
|
||
// per portarlo a ore 12 devo ruotare all'indietro di targetCenter (mod 360)
|
||
const landing = (360 - targetCenter) % 360;
|
||
|
||
// parto dall'attuale angolo normalizzato e aggiungo i giri interi
|
||
const base = currentRotation - (currentRotation % 360);
|
||
const newRotation = base + fullSpins * 360 + landing;
|
||
currentRotation = newRotation;
|
||
|
||
const anim = wheel.animate(
|
||
[{ transform: `rotate(${wheel.dataset.rot || 0}deg)` },
|
||
{ transform: `rotate(${newRotation}deg)` }],
|
||
{ duration: 4200,
|
||
easing: 'cubic-bezier(0.23, 1, 0.32, 1)', // decelerazione morbida
|
||
fill: 'forwards' }
|
||
);
|
||
|
||
wheel.dataset.rot = newRotation;
|
||
return anim.finished.then(() => targetIndex);
|
||
}
|
||
|
||
// Uso:
|
||
const wheel = document.querySelector('.wheel');
|
||
spinWheel(wheel, 3).then((i) => console.log('Vinto lo spicchio', i));
|
||
```
|
||
|
||
Versione alternativa con **CSS transition** invece di WAAPI (stesso principio, si legge l'angolo finale con `transitionend`):
|
||
|
||
```js
|
||
wheel.style.transition = 'transform 4.2s cubic-bezier(0.23,1,0.32,1)';
|
||
wheel.style.transform = `rotate(${newRotation}deg)`;
|
||
wheel.addEventListener('transitionend', () => { /* ... */ }, { once: true });
|
||
```
|
||
|
||
> Attenzione all'accumulo: non resettare mai a `rotate(0)` senza `transition: none`, o la ruota "srotolerà" all'indietro. Meglio tenere la rotazione **sempre crescente** come nello snippet (`currentRotation` accumula).
|
||
|
||
---
|
||
|
||
## 4. Coriandoli con `canvas-confetti`
|
||
|
||
[`canvas-confetti`](https://github.com/catdad/canvas-confetti) è la libreria de-facto per l'effetto coriandoli: performante, zero-config, usa canvas + `requestAnimationFrame` internamente (opzionalmente un Web Worker).
|
||
|
||
### Inclusione via CDN
|
||
|
||
```html
|
||
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.min.js"></script>
|
||
```
|
||
|
||
Esposta come funzione globale `confetti()` su `window`. Ritorna una Promise che si risolve al termine.
|
||
|
||
### Uso base e opzioni
|
||
|
||
```js
|
||
// burst semplice al centro
|
||
confetti();
|
||
|
||
// burst personalizzato dal punto del click
|
||
button.addEventListener('click', (e) => {
|
||
confetti({
|
||
particleCount: 120,
|
||
spread: 70,
|
||
startVelocity: 45,
|
||
origin: {
|
||
x: e.clientX / window.innerWidth,
|
||
y: e.clientY / window.innerHeight,
|
||
},
|
||
colors: ['#e63946', '#f1a208', '#2a9d8f', '#ffd166'],
|
||
});
|
||
});
|
||
```
|
||
|
||
Opzioni principali: `particleCount` (default 50), `angle` (90 = verso l'alto), `spread` (apertura in gradi), `startVelocity`, `origin.x/.y` (0..1), `colors` (array HEX).
|
||
|
||
### Celebrazione "a raffica" (per il momento del risultato)
|
||
|
||
```js
|
||
function celebrate(durationMs = 2500) {
|
||
const end = Date.now() + durationMs;
|
||
(function frame() {
|
||
confetti({ particleCount: 7, angle: 60, spread: 55, origin: { x: 0 } });
|
||
confetti({ particleCount: 7, angle: 120, spread: 55, origin: { x: 1 } });
|
||
if (Date.now() < end) requestAnimationFrame(frame);
|
||
})();
|
||
}
|
||
```
|
||
|
||
### Canvas dedicato (per non toccare il DOM globale)
|
||
|
||
```js
|
||
const myCanvas = document.querySelector('#fx');
|
||
const myConfetti = confetti.create(myCanvas, { resize: true, useWorker: true });
|
||
myConfetti({ particleCount: 100, spread: 160 });
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Suoni: pool di `<audio>` preload vs Web Audio API / Howler.js
|
||
|
||
### Opzione A — pool di `<audio>` preload (zero dipendenze)
|
||
|
||
Un singolo `<audio>` non si può sovrapporre a sé stesso (il secondo play interrompe il primo). Per effetti rapidi ripetuti (tick della reel, click) conviene un **pool** di elementi clonati.
|
||
|
||
```html
|
||
<audio id="snd-spin" src="sfx/spin.mp3" preload="auto"></audio>
|
||
<audio id="snd-win" src="sfx/win.mp3" preload="auto"></audio>
|
||
<audio id="snd-tick" src="sfx/tick.mp3" preload="auto"></audio>
|
||
```
|
||
|
||
```js
|
||
function makePool(src, size = 6) {
|
||
const pool = Array.from({ length: size }, () => {
|
||
const a = new Audio(src);
|
||
a.preload = 'auto';
|
||
return a;
|
||
});
|
||
let i = 0;
|
||
return () => {
|
||
const a = pool[i = (i + 1) % pool.length];
|
||
a.currentTime = 0;
|
||
a.play().catch(() => {}); // ignora blocco autoplay finché non c'è interazione
|
||
};
|
||
}
|
||
|
||
const playTick = makePool('sfx/tick.mp3', 8);
|
||
// durante lo spin: playTick() a ogni simbolo che passa
|
||
```
|
||
|
||
> **Autoplay policy**: i browser bloccano l'audio finché non c'è un gesto utente (click/tap). "Sblocca" l'audio al primo click (es. sul pulsante Spin) facendo partire e mettendo subito in pausa un suono muto.
|
||
|
||
### Opzione B — Web Audio API (latenza minima, controllo fine)
|
||
|
||
Per timing preciso e nessuna latenza (importante per i tick sincronizzati con l'animazione) conviene decodificare una volta il buffer e riprodurlo con `AudioBufferSourceNode` (usa-e-getta).
|
||
|
||
```js
|
||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const buffers = {};
|
||
|
||
async function load(name, url) {
|
||
const res = await fetch(url);
|
||
const arr = await res.arrayBuffer();
|
||
buffers[name] = await ctx.decodeAudioData(arr);
|
||
}
|
||
|
||
function play(name, { volume = 1 } = {}) {
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buffers[name];
|
||
const gain = ctx.createGain();
|
||
gain.gain.value = volume;
|
||
src.connect(gain).connect(ctx.destination);
|
||
src.start();
|
||
}
|
||
|
||
// sblocco su primo gesto utente:
|
||
document.addEventListener('pointerdown', () => ctx.resume(), { once: true });
|
||
await Promise.all([load('tick', 'sfx/tick.mp3'), load('win', 'sfx/win.mp3')]);
|
||
```
|
||
|
||
### Opzione C — Howler.js (comodo, audio sprite, cross-browser)
|
||
|
||
[Howler.js](https://howlerjs.com/) astrae Web Audio con fallback HTML5, gestisce autoplay, volume, loop e **audio sprite** (un solo file con più suoni). Ottimo se vuoi meno codice a mano.
|
||
|
||
```html
|
||
<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
|
||
```
|
||
|
||
```js
|
||
// suono singolo
|
||
const win = new Howl({ src: ['sfx/win.mp3'], volume: 0.8 });
|
||
win.play();
|
||
|
||
// audio sprite: un file, tanti effetti (offset e durata in ms)
|
||
const sfx = new Howl({
|
||
src: ['sfx/sprite.webm', 'sfx/sprite.mp3'],
|
||
sprite: {
|
||
tick: [0, 120],
|
||
spin: [200, 1800],
|
||
win: [2100, 2500],
|
||
},
|
||
});
|
||
sfx.play('tick');
|
||
sfx.play('win');
|
||
```
|
||
|
||
Per la nostra app: **pool `<audio>`** o **Howler sprite** per gli effetti brevi, va benissimo. Web Audio se vuoi sincronizzazione al millisecondo tra tick e animazione.
|
||
|
||
---
|
||
|
||
## 6. Struttura di stato: partecipanti, stanze, assegnazioni + reveal sequenziale
|
||
|
||
Per un'app "assegna partecipanti a stanze" (o premi), tieni uno stato semplice, serializzabile e centralizzato. Nessuna libreria: un oggetto + funzioni pure.
|
||
|
||
```js
|
||
const state = {
|
||
participants: [
|
||
{ id: 'p1', name: 'Alice' },
|
||
{ id: 'p2', name: 'Bob' },
|
||
{ id: 'p3', name: 'Carla' },
|
||
{ id: 'p4', name: 'Dario' },
|
||
],
|
||
rooms: [
|
||
{ id: 'r1', name: 'Stanza Rossa', capacity: 2 },
|
||
{ id: 'r2', name: 'Stanza Blu', capacity: 2 },
|
||
],
|
||
// risultato: mappa participantId -> roomId (riempita dall'estrazione)
|
||
assignments: {}, // { p1: 'r1', p2: 'r2', ... }
|
||
revealedCount: 0, // quanti risultati sono già stati mostrati
|
||
};
|
||
```
|
||
|
||
### Calcolo delle assegnazioni (deciso a priori, poi rivelato)
|
||
|
||
```js
|
||
function shuffle(arr) {
|
||
const a = [...arr];
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function assign(state) {
|
||
const pool = shuffle(state.participants);
|
||
const slots = state.rooms.flatMap((r) =>
|
||
Array.from({ length: r.capacity }, () => r.id)
|
||
);
|
||
const result = {};
|
||
pool.forEach((p, i) => { result[p.id] = slots[i]; });
|
||
state.assignments = result;
|
||
state.revealedCount = 0;
|
||
return result;
|
||
}
|
||
```
|
||
|
||
### Reveal sequenziale (un risultato alla volta, con animazione)
|
||
|
||
L'idea: l'estrazione è già calcolata; il **reveal** la mostra uno per uno, ognuno accompagnato da spin della reel/ruota + suono + eventuale confetti. `async/await` rende la sequenza leggibile.
|
||
|
||
```js
|
||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function revealAll(state, { onReveal }) {
|
||
const entries = Object.entries(state.assignments); // [ [pId, rId], ... ]
|
||
for (const [participantId, roomId] of entries) {
|
||
const participant = state.participants.find((p) => p.id === participantId);
|
||
const room = state.rooms.find((r) => r.id === roomId);
|
||
|
||
// 1) anima (reel/ruota) fino all'indice della stanza target
|
||
const roomIndex = state.rooms.findIndex((r) => r.id === roomId);
|
||
await spinWheel(document.querySelector('.wheel'), roomIndex);
|
||
|
||
// 2) effetti + aggiorna UI
|
||
playTick(); // o sfx.play('win')
|
||
onReveal(participant, room);
|
||
state.revealedCount++;
|
||
|
||
// 3) pausa prima del prossimo
|
||
await wait(700);
|
||
}
|
||
celebrate(); // confetti finale
|
||
}
|
||
|
||
// Uso:
|
||
assign(state);
|
||
revealAll(state, {
|
||
onReveal: (p, r) => {
|
||
const li = document.createElement('li');
|
||
li.textContent = `${p.name} → ${r.name}`;
|
||
document.querySelector('#results').appendChild(li);
|
||
},
|
||
});
|
||
```
|
||
|
||
Vantaggi di questa struttura: lo stato è un **singolo oggetto** facile da loggare/persistere (`localStorage.setItem('state', JSON.stringify(state))`), la logica di estrazione è separata dal reveal (testabile), e il reveal sequenziale è una semplice iterazione `for...of` con `await`.
|
||
|
||
---
|
||
|
||
## 7. Font gratuiti consigliati (Google Fonts) + `<link>` reali
|
||
|
||
Mix consigliato: un **display bold** per titoli/brand, un **mono/LED** per numeri e counter stile slot, e un **pixel/arcade** opzionale per il tocco retro-casino.
|
||
|
||
```html
|
||
<!-- preconnect: velocizza il fetch dei font -->
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
|
||
<!-- Orbitron: display geometrico "sci-fi / HUD", ottimo per titoli e numeri -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&display=swap" rel="stylesheet">
|
||
|
||
<!-- Share Tech Mono: monospace stile terminale/LED, perfetto per i contatori -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||
|
||
<!-- Press Start 2P: bitmap arcade anni '80 (usare a 8/16/24px) -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||
|
||
<!-- Bungee: display bold "insegna/luna park", ottimo per bottoni e brand -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Bungee&display=swap" rel="stylesheet">
|
||
```
|
||
|
||
```css
|
||
:root {
|
||
--font-display: 'Orbitron', system-ui, sans-serif;
|
||
--font-led: 'Share Tech Mono', ui-monospace, monospace;
|
||
--font-arcade: 'Press Start 2P', monospace;
|
||
}
|
||
h1, .brand { font-family: var(--font-display); font-weight: 800; }
|
||
.counter { font-family: var(--font-led); letter-spacing: 2px; }
|
||
.badge { font-family: var(--font-arcade); font-size: 12px; }
|
||
```
|
||
|
||
### Font 7-segment "vero" (LED): DSEG (self-hosted)
|
||
|
||
Google Fonts non ha un font 7-segmenti. Per un display LED autentico usa **DSEG** (SIL Open Font License), da self-hostare con `@font-face`:
|
||
|
||
```css
|
||
/* scarica i .woff2 da https://www.keshikan.net/fonts-e.html e mettili in /fonts */
|
||
@font-face {
|
||
font-family: 'DSEG7 Classic';
|
||
src: url('fonts/DSEG7Classic-Bold.woff2') format('woff2');
|
||
font-weight: 700;
|
||
font-display: swap;
|
||
}
|
||
.led-display { font-family: 'DSEG7 Classic', monospace; }
|
||
```
|
||
|
||
> In alternativa a DSEG, se vuoi restare solo su Google Fonts, **Share Tech Mono** o **DotGothic16** danno un look "digitale" più che sufficiente senza self-hosting.
|
||
|
||
---
|
||
|
||
## 8. Setup vanilla "apri e vai" (uno o pochi file)
|
||
|
||
Obiettivo: doppio click su `index.html` e funziona; al massimo un mini live server.
|
||
|
||
Struttura minima consigliata:
|
||
|
||
```
|
||
elsa-gambling/
|
||
├─ index.html # markup + <link> font + <script> CDN + <script src="app.js">
|
||
├─ style.css # tutti gli stili
|
||
├─ app.js # stato + logica (usa import ES module se ti serve)
|
||
├─ sfx/ # suoni (mp3/webm)
|
||
└─ fonts/ # eventuale DSEG self-hosted
|
||
```
|
||
|
||
Scheletro `index.html`:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Elsa Gambling</title>
|
||
|
||
<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=Orbitron:wght@400..900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||
|
||
<link rel="stylesheet" href="style.css">
|
||
</head>
|
||
<body>
|
||
<main id="app"><!-- UI --></main>
|
||
|
||
<!-- helper via CDN -->
|
||
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
|
||
|
||
<!-- il tuo codice -->
|
||
<script src="app.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
Consigli pratici:
|
||
|
||
- **Un solo file va benissimo per prototipare** (CSS in `<style>`, JS in `<script>`): parte davvero solo aprendo il file. Separa in più file quando cresce.
|
||
- **ES Modules** (`<script type="module" src="app.js">` + `import`) funzionano solo via `http://`, **non** con `file://` (blocco CORS). Se usi `type="module"`, serve un live server; se vuoi il puro doppio-click su `file://`, resta su script classici (niente `import`).
|
||
- **Fetch di file locali** (es. `decodeAudioData` via `fetch('sfx/..')`, o caricare un JSON) **non** funziona su `file://`: serve un server. Aggira precaricando i suoni con `new Audio()`/Howler invece di `fetch`, oppure usa un live server.
|
||
- **Live server istantaneo**, zero install: `npx serve` oppure `python3 -m http.server 8000` nella cartella del progetto. Anche l'estensione "Live Server" di VS Code.
|
||
- **`will-change: transform`** su reel/ruota e usa solo `transform`/`opacity` per le animazioni: restano sul compositor e non causano reflow.
|
||
- **Rispetta `prefers-reduced-motion`**: riduci/annulla spin e coriandoli per chi lo richiede.
|
||
|
||
```css
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.reel__strip--spinning { transition-duration: 0.2s; }
|
||
.wheel { transition: none; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Librerie helper via CDN (riepilogo `<script>`)
|
||
|
||
```html
|
||
<!-- Coriandoli: performante, canvas + rAF, opzionale Web Worker -->
|
||
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.min.js"></script>
|
||
|
||
<!-- Audio: Web Audio con fallback HTML5, audio sprite, volume/loop -->
|
||
<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
|
||
```
|
||
|
||
Opzionali, se servono:
|
||
|
||
```html
|
||
<!-- bezier-easing: calcola easing cubic-bezier in JS (per animazioni rAF custom) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/bezier-easing@2.1.0/dist/bezier-easing.min.js"></script>
|
||
|
||
<!-- js-confetti: alternativa a canvas-confetti, supporta emoji come coriandoli -->
|
||
<script src="https://cdn.jsdelivr.net/npm/js-confetti@latest/dist/js-confetti.browser.js"></script>
|
||
```
|
||
|
||
> Buona pratica: **fissa la versione** (es. `@1.9.4`) invece di `@latest`, così l'app non si rompe quando esce un major.
|
||
|
||
---
|
||
|
||
## Fonti
|
||
|
||
- Slot machine con HTML/CSS/JS (translateY + cubic-bezier): <https://javascript.plainenglish.io/step-by-step-guide-to-building-a-retro-slot-machine-with-html-css-and-javascript-for-beginners-3f866041c7c0>
|
||
- Simple CSS Slot Machine Animation (CodePen): <https://codepen.io/cnbkts/pen/QMXPZM>
|
||
- `cubic-bezier()` — MDN: <https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function/cubic-bezier>
|
||
- `cubic-bezier()` — CSS-Tricks: <https://css-tricks.com/almanac/functions/c/cubic-bezier/>
|
||
- Interactive Guide to CSS Transitions — Josh W. Comeau: <https://www.joshwcomeau.com/animation/css-transitions/>
|
||
- CSS vs JavaScript animations — web.dev: <https://web.dev/articles/css-vs-javascript>
|
||
- CSS & JavaScript animation performance — MDN: <https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/CSS_JavaScript_animation_performance>
|
||
- Using the Web Animations API — MDN: <https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API>
|
||
- CSS Animations vs Web Animations API — CSS-Tricks: <https://css-tricks.com/css-animations-vs-web-animations-api/>
|
||
- `requestAnimationFrame()` — MDN: <https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame>
|
||
- Wheel of Fortune with CSS — Mads Stoumann (DEV): <https://dev.to/madsstoumann/wheel-of-fortune-with-css-p-pi-1ne9>
|
||
- JavaScript + CSS 3D Wheel of Fortune — Emanuele Feronato: <https://emanueleferonato.com/2025/10/31/javascript-css-3d-wheel-of-fortune/>
|
||
- A CSS wheel of fortune — James R. Williams: <https://jamesrwilliams.ca/posts/css-wheel-of-fortune>
|
||
- `conic-gradient()` — CSS-Tricks: <https://css-tricks.com/almanac/functions/c/conic-gradient/>
|
||
- canvas-confetti (GitHub): <https://github.com/catdad/canvas-confetti>
|
||
- canvas-confetti su jsDelivr: <https://www.jsdelivr.com/package/npm/canvas-confetti>
|
||
- Howler.js (sito): <https://howlerjs.com/>
|
||
- Howler.js (GitHub + README): <https://github.com/goldfire/howler.js/>
|
||
- Web Audio API — MDN: <https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API>
|
||
- DSEG — font 7/14 segmenti: <https://www.keshikan.net/fonts-e.html>
|
||
- DSEG (GitHub): <https://github.com/keshikan/DSEG>
|
||
- Orbitron — Google Fonts: <https://fonts.google.com/specimen/Orbitron>
|
||
- Share Tech Mono — Google Fonts: <https://fonts.google.com/specimen/Share+Tech+Mono>
|
||
- Press Start 2P — Google Fonts: <https://fonts.google.com/specimen/Press+Start+2P>
|
||
- Bungee — Google Fonts: <https://fonts.google.com/specimen/Bungee>
|
||
- DotGothic16 — Google Fonts: <https://fonts.google.com/specimen/DotGothic16>
|
||
</content>
|
||
</invoke>
|