Files
shalom/docs/04-implementazione-vanilla-js.md
2026-07-02 12:55:16 +02:00

27 KiB
Raw Blame History

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

<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

: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

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:

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

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

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:

.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:

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.

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):

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 è la libreria de-facto per l'effetto coriandoli: performante, zero-config, usa canvas + requestAnimationFrame internamente (opzionalmente un Web Worker).

Inclusione via CDN

<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

// 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)

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)

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.

<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>
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).

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 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.

<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
// 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.

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)

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.

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.


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.

<!-- 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">
: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:

/* 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:

<!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.
@media (prefers-reduced-motion: reduce) {
  .reel__strip--spinning { transition-duration: 0.2s; }
  .wheel { transition: none; }
}

9. Librerie helper via CDN (riepilogo <script>)

<!-- 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:

<!-- 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