diff --git a/assets/characters/giocatore-carte.json b/assets/characters/giocatore-carte.json
new file mode 100644
index 0000000..fc04181
--- /dev/null
+++ b/assets/characters/giocatore-carte.json
@@ -0,0 +1,38 @@
+{
+ "image": "giocatore-carte.png",
+ "height": 100,
+ "frames": {
+ "sit": {
+ "x": 0,
+ "y": 0,
+ "w": 67,
+ "h": 100,
+ "ax": 25.7,
+ "ay": 100
+ },
+ "talk": {
+ "x": 69,
+ "y": 0,
+ "w": 68,
+ "h": 100,
+ "ax": 27.6,
+ "ay": 100
+ },
+ "laugh": {
+ "x": 139,
+ "y": 0,
+ "w": 72,
+ "h": 99,
+ "ax": 19.3,
+ "ay": 99
+ },
+ "shuffle": {
+ "x": 213,
+ "y": 0,
+ "w": 67,
+ "h": 97,
+ "ax": 25.2,
+ "ay": 97
+ }
+ }
+}
diff --git a/assets/characters/giocatore-carte.png b/assets/characters/giocatore-carte.png
new file mode 100644
index 0000000..1a8584c
Binary files /dev/null and b/assets/characters/giocatore-carte.png differ
diff --git a/assets/voices/andrea-6e63dafda1.mp3 b/assets/voices/andrea-6e63dafda1.mp3
new file mode 100644
index 0000000..3ad8f1a
Binary files /dev/null and b/assets/voices/andrea-6e63dafda1.mp3 differ
diff --git a/assets/voices/andrea-71f3bff8a0.mp3 b/assets/voices/andrea-71f3bff8a0.mp3
new file mode 100644
index 0000000..76a44b3
Binary files /dev/null and b/assets/voices/andrea-71f3bff8a0.mp3 differ
diff --git a/assets/voices/andrea-b851b9ed26.mp3 b/assets/voices/andrea-b851b9ed26.mp3
new file mode 100644
index 0000000..1120fa3
Binary files /dev/null and b/assets/voices/andrea-b851b9ed26.mp3 differ
diff --git a/assets/voices/andrea-be276497e3.mp3 b/assets/voices/andrea-be276497e3.mp3
new file mode 100644
index 0000000..fa07c4e
Binary files /dev/null and b/assets/voices/andrea-be276497e3.mp3 differ
diff --git a/assets/voices/andrea-d2f285f610.mp3 b/assets/voices/andrea-d2f285f610.mp3
new file mode 100644
index 0000000..3fa62f3
Binary files /dev/null and b/assets/voices/andrea-d2f285f610.mp3 differ
diff --git a/assets/voices/andrea-e8599cc8b6.mp3 b/assets/voices/andrea-e8599cc8b6.mp3
new file mode 100644
index 0000000..6c308f6
Binary files /dev/null and b/assets/voices/andrea-e8599cc8b6.mp3 differ
diff --git a/assets/voices/giocatore_carte-12f97003ec.mp3 b/assets/voices/giocatore_carte-12f97003ec.mp3
new file mode 100644
index 0000000..a3a3e59
Binary files /dev/null and b/assets/voices/giocatore_carte-12f97003ec.mp3 differ
diff --git a/assets/voices/giocatore_carte-13af2e2331.mp3 b/assets/voices/giocatore_carte-13af2e2331.mp3
new file mode 100644
index 0000000..a2f2416
Binary files /dev/null and b/assets/voices/giocatore_carte-13af2e2331.mp3 differ
diff --git a/assets/voices/giocatore_carte-1523994a6c.mp3 b/assets/voices/giocatore_carte-1523994a6c.mp3
new file mode 100644
index 0000000..f6ccdd8
Binary files /dev/null and b/assets/voices/giocatore_carte-1523994a6c.mp3 differ
diff --git a/assets/voices/giocatore_carte-43e76cf11b.mp3 b/assets/voices/giocatore_carte-43e76cf11b.mp3
new file mode 100644
index 0000000..97f3bb2
Binary files /dev/null and b/assets/voices/giocatore_carte-43e76cf11b.mp3 differ
diff --git a/assets/voices/giocatore_carte-4f39bc172d.mp3 b/assets/voices/giocatore_carte-4f39bc172d.mp3
new file mode 100644
index 0000000..cb89ad0
Binary files /dev/null and b/assets/voices/giocatore_carte-4f39bc172d.mp3 differ
diff --git a/assets/voices/giocatore_carte-6ce98bce81.mp3 b/assets/voices/giocatore_carte-6ce98bce81.mp3
new file mode 100644
index 0000000..35d8e16
Binary files /dev/null and b/assets/voices/giocatore_carte-6ce98bce81.mp3 differ
diff --git a/assets/voices/giocatore_carte-729b3344da.mp3 b/assets/voices/giocatore_carte-729b3344da.mp3
new file mode 100644
index 0000000..46ae995
Binary files /dev/null and b/assets/voices/giocatore_carte-729b3344da.mp3 differ
diff --git a/assets/voices/giocatore_carte-89686f55a0.mp3 b/assets/voices/giocatore_carte-89686f55a0.mp3
new file mode 100644
index 0000000..19fd2a9
Binary files /dev/null and b/assets/voices/giocatore_carte-89686f55a0.mp3 differ
diff --git a/assets/voices/giocatore_carte-8dfadb4acc.mp3 b/assets/voices/giocatore_carte-8dfadb4acc.mp3
new file mode 100644
index 0000000..5a1f685
Binary files /dev/null and b/assets/voices/giocatore_carte-8dfadb4acc.mp3 differ
diff --git a/assets/voices/giocatore_carte-a8846bfa7b.mp3 b/assets/voices/giocatore_carte-a8846bfa7b.mp3
new file mode 100644
index 0000000..21f6fe2
Binary files /dev/null and b/assets/voices/giocatore_carte-a8846bfa7b.mp3 differ
diff --git a/assets/voices/giocatore_carte-f1f219bd1c.mp3 b/assets/voices/giocatore_carte-f1f219bd1c.mp3
new file mode 100644
index 0000000..c953961
Binary files /dev/null and b/assets/voices/giocatore_carte-f1f219bd1c.mp3 differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index 6ebf404..8f8921a 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -487,6 +487,10 @@
"file": "andrea-f798fa755d.mp3",
"sec": 4.9
},
+ "andrea|Ha già mescolato. Mi sa che aspettava proprio un pollo di passaggio.": {
+ "file": "andrea-71f3bff8a0.mp3",
+ "sec": 5.09
+ },
"andrea|Una gomma da masticare ancora incartata, persa davanti al palco. Qualcuno ha perso l'appetito.": {
"file": "andrea-f2ea95922d.mp3",
"sec": 6.54
@@ -1519,6 +1523,70 @@
"file": "andrea-60cdae2e35.mp3",
"sec": 5.1
},
+ "andrea|Una briscola?": {
+ "file": "andrea-d2f285f610.mp3",
+ "sec": 1.04
+ },
+ "andrea|La rivincita?": {
+ "file": "andrea-98e4366a56.mp3",
+ "sec": 0.83
+ },
+ "andrea|Ora cerco il palio. A dopo.": {
+ "file": "andrea-6e63dafda1.mp3",
+ "sec": 2.61
+ },
+ "giocatore_carte|Gnamo. In due, senza soci e senza scuse.": {
+ "file": "giocatore_carte-1523994a6c.mp3",
+ "sec": 3.78
+ },
+ "giocatore_carte|Il tavolo è qui. Il coraggio ce lo metti te.": {
+ "file": "giocatore_carte-8dfadb4acc.mp3",
+ "sec": 3.4
+ },
+ "andrea|Con le toscane.": {
+ "file": "andrea-e8599cc8b6.mp3",
+ "sec": 1.03
+ },
+ "andrea|Con le napoletane.": {
+ "file": "andrea-be276497e3.mp3",
+ "sec": 1.09
+ },
+ "andrea|Ci ripenso.": {
+ "file": "andrea-b851b9ed26.mp3",
+ "sec": 0.8
+ },
+ "giocatore_carte|Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?": {
+ "file": "giocatore_carte-89686f55a0.mp3",
+ "sec": 6.14
+ },
+ "giocatore_carte|Le napoletane? Le tengo per i villeggianti. Come la vuoi?": {
+ "file": "giocatore_carte-4f39bc172d.mp3",
+ "sec": 5.13
+ },
+ "andrea|Alla bona.": {
+ "file": "andrea-67afa54e05.mp3",
+ "sec": 0.69
+ },
+ "andrea|Seria.": {
+ "file": "andrea-f6ef96032e.mp3",
+ "sec": 0.58
+ },
+ "andrea|A fassi male.": {
+ "file": "andrea-f52137b691.mp3",
+ "sec": 0.78
+ },
+ "giocatore_carte|Alla bona. I carichi li regalo, oggi.": {
+ "file": "giocatore_carte-43e76cf11b.mp3",
+ "sec": 3.43
+ },
+ "giocatore_carte|Allora posa il gomito e guarda le carte.": {
+ "file": "giocatore_carte-f1f219bd1c.mp3",
+ "sec": 2.51
+ },
+ "giocatore_carte|Io le conto tutte. Anche quelle che butti via per sbaglio.": {
+ "file": "giocatore_carte-12f97003ec.mp3",
+ "sec": 4.19
+ },
"andrea|Una Fanta.": {
"file": "andrea-8927b87738.mp3",
"sec": 0.65
@@ -1815,10 +1883,6 @@
"file": "andrea-83b1337b4d.mp3",
"sec": 3.31
},
- "andrea|La rivincita?": {
- "file": "andrea-98e4366a56.mp3",
- "sec": 0.83
- },
"andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3",
"sec": 1.29
@@ -1847,18 +1911,6 @@
"file": "mariano-1c9cd0c325.mp3",
"sec": 5.96
},
- "andrea|Alla bona.": {
- "file": "andrea-67afa54e05.mp3",
- "sec": 0.69
- },
- "andrea|Seria.": {
- "file": "andrea-f6ef96032e.mp3",
- "sec": 0.58
- },
- "andrea|A fassi male.": {
- "file": "andrea-f52137b691.mp3",
- "sec": 0.78
- },
"mariano|Alla bona: tiro con gli occhi chiusi.": {
"file": "mariano-b1b1ca273b.mp3",
"sec": 2.2
@@ -2015,6 +2067,22 @@
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
+ "giocatore_carte|Oh, bravo! Ora però rimescolo io.": {
+ "file": "giocatore_carte-a8846bfa7b.mp3",
+ "sec": 2.9
+ },
+ "giocatore_carte|I carichi portali qui, mica alla stazione! Rivincita quando vuoi.": {
+ "file": "giocatore_carte-6ce98bce81.mp3",
+ "sec": 4.72
+ },
+ "giocatore_carte|Sessanta pari. Un si litiga nemmeno sul conto!": {
+ "file": "giocatore_carte-13af2e2331.mp3",
+ "sec": 3.86
+ },
+ "giocatore_carte|Vai, ti tengo il posto. Le carte mica scappano.": {
+ "file": "giocatore_carte-729b3344da.mp3",
+ "sec": 3.3
+ },
"mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62
diff --git a/docs/BRISCOLA.md b/docs/BRISCOLA.md
new file mode 100644
index 0000000..e9b0cd1
--- /dev/null
+++ b/docs/BRISCOLA.md
@@ -0,0 +1,129 @@
+# Briscola all’oratorio
+
+Implementazione del 26 ottobre 2026. ES modules statici, nessun build step.
+
+## Partita e comandi
+
+Il giocatore di carte è un personaggio inventato e provvisorio, seduto a destra alle tavolate
+dell’oratorio: **[DA ANDREA: chi è]**. Il dialogo offre «Alla bona», «Seria», «A fassi male»;
+dopo una partita conclusa offre la rivincita. `briscola_vinta` ricorda una vittoria e
+`briscola_giocata` abilita la rivincita anche dopo sconfitta o patta. Nessuno dei due flag
+serve alla soluzione dell’avventura.
+
+Un clic o tocco su una carta la gioca al rilascio sullo stesso bersaglio. Nessuna carica.
+Le frecce e 1–2–3 scelgono, Invio gioca, Esc esce. I tre pulsanti con nome e seme
+offrono bersagli aggiuntivi e accesso tramite Tab. I comandi di tiro degli altri minigiochi
+rimangono separati; in `MINIGAMES` pressione, rilascio, annullamento e mira sono opzionali.
+
+Mazzo da 40 carte, 120 punti, distribuzione alternata e mazziere deterministico dal seed.
+La briscola è in fondo al mazzo e compresa nel contatore. Chi prende pesca prima e riapre;
+non esiste obbligo di seme. La presa resta visibile 1,25 secondi. Solo dopo la ventesima
+presa si contano tutte le carte, una ogni 0,35 secondi, mostrando carta ingrandita,
+incremento e totale. A 60–60 il risultato è patta. Il risultato resta fino all’uscita.
+
+## Avversari e taratura
+
+`observation` espone soltanto la propria mano, le carte pubbliche, i conteggi e l’eventuale
+briscola pescata dall’altro. Non passa la mano avversaria, l’ordine del mazzo o il seed della
+distribuzione. «Alla bona» sceglie casualmente; «Seria» scarta lisci, conserva briscole e
+prende carichi con quelle piccole. «A fassi male» ricostruisce le carte non viste, confronta
+le mosse su 40 distribuzioni possibili e simula seguiti giocati con buon senso. Sono ipotesi,
+non la distribuzione effettiva. A mazzo finito il complemento delle carte pubbliche e della
+propria mano identifica l’altra mano: da quel momento usa minimax esatto fino alla fine.
+
+Comando riproducibile: `node tools/calibrate_briscola.mjs 1000`.
+Semi `Math.imul(i, 2654435761) >>> 0`, i da 1 a 1000, stessi mazzi per ogni livello.
+Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 volte su 1000.
+
+| Livello | Vittorie Andrea | Vittorie avversario | Patte | Punti medi Andrea |
+| --- | ---: | ---: | ---: | ---: |
+| Alla bona | 847 (84,7%) | 144 (14,4%) | 9 (0,9%) | 79,159 |
+| Seria | 481 (48,1%) | 494 (49,4%) | 25 (2,5%) | 59,952 |
+| A fassi male | 359 (35,9%) | 621 (62,1%) | 20 (2,0%) | 55,628 |
+
+Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
+
+## Grafica e audio
+
+Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
+nomi e disegni (`DECKS` e `DECK_CARDS` in `briscola.js`; la scelta è il flag `carte_napoletane`).
+
+- **Toscane** (predefinite): semi francesi cuori, mattoni, fiori, picche; figure gobbo, donna e regio (il regio
+ sempre con la corona). Numerali senza numeri, semi disposti come nel mazzo vero e capovolti nella metà bassa;
+ figure intere col seme e la lettera (G, D, R) in alto a sinistra. Riferimento: `assets/raw/carte/toscane-riferimento.png`.
+- **Napoletane**: monete, coppe, lame ricurve incrociate, bastoni nodosi; fante, cavallo e re. Valore nell’angolo
+ e seme scritto per non affidarsi solo al colore.
+
+Tutto disegnato via canvas, in pixel art originale.
+I bersagli della mano sono 84×120 pixel a 640×360. Dorso con serpentello coronato e cornice
+simmetrica; tavolino di legno e panno, mano avversaria coperta, mazzo e mucchietti delle prese.
+
+Sprite generato con lo **strumento immagini integrato**, poi preparato con
+`slice_sprites.py --alpha --fixed-grid --cols 2 --rows 2` e `prepare_character.py`.
+Pose: `sit`, `talk`, `laugh`, `shuffle`. Foglio originale in
+`assets/raw/giocatore-carte-sheet.png`; prompt completo in
+`prompts/personaggi/giocatore-carte-sheet.txt` (entrambi esclusi da git).
+
+Effetti Web Audio `card-slide`, `card-tap`, `card-shuffle`, collegati allo stesso volume,
+muto e pausa degli altri effetti. Il colpo accompagna l’arrivo della carta dopo lo scorrimento.
+13 nuove battute XTTS (4 Andrea, 9 giocatore), con indice e durate aggiornati.
+Per generarle senza GPU/rete, `tools/voci_xtts.py` accetta `XTTS_DEVICE=cpu` e
+`XTTS_MODEL_DIR` con la cartella del modello già scaricato; le impostazioni predefinite
+rimangono quelle del progetto. Non è stata verificata all’ascolto la pronuncia delle nuove voci.
+
+## Verifica e limiti della sessione
+
+**`npm test`: passato.** Regole, determinismo, conservazione delle 40 carte e dei 120 punti,
+pescata, ultima briscola, fine mazzo, patta, livelli, accesso negato ai dati nascosti,
+minimax confrontato con un oracolo esaustivo, renderer puro, input geometrico,
+dialoghi/flag/rivincita e audio. Passano anche i test precedenti e la soluzione completa.
+
+`npm run browser` e `npm run browser:briscola` sono stati lanciati ma **bloccati dall’ambiente**:
+`PermissionError: [Errno 1] Operation not permitted` alla creazione del socket del server.
+Anche l’avvio isolato di Chromium fallisce con `sandbox_host_linux.cc:41`,
+`shutdown: Operation not permitted`. Non è stato possibile verificare l’interfaccia reale
+con Playwright né produrre schermate desktop/telefono.
+
+Il test `tests/briscola.browser.mjs` è pronto: partite complete con mouse, touch e tastiera
+su desktop e telefono verticale, più touch orizzontale; pausa, muto, gesto annullato,
+conteggio, risultato e rivincita. Accelera solo le attese tramite `game.update`, senza
+giocare carte di Andrea direttamente nel motore. Salva in `/tmp/regolo-shots/briscola-*.png`.
+Le misure Web Audio dei tre nuovi suoni sono comprese nel collaudo browser multimedia.
+
+Anteprime **offline, non schermate browser**, prodotte e ispezionate nella sessione:
+
+- `/tmp/regolo-shots/briscola-offline-carte.png`
+- `/tmp/regolo-shots/briscola-offline-tavolo.png`
+- `/tmp/regolo-shots/briscola-offline-risultato.png`
+
+## File
+
+Nuovi: `src/engine/briscola.js`, `src/engine/briscola-render.js`,
+`assets/characters/giocatore-carte.png`, `assets/characters/giocatore-carte.json`,
+`tests/briscola.test.mjs`, `tests/briscola-render.test.mjs`, `tests/briscola.browser.mjs`,
+`tools/calibrate_briscola.mjs`, questo documento e 13 MP3 elencati qui sotto.
+
+Modificati: `src/engine/game.js`, `src/engine/render.js`, `src/engine/soundscape.js`,
+`src/main.js`, `src/story/story.js`, `index.html`, `style.css`, `package.json`,
+`tests/story.test.mjs`, `tests/multimedia.test.mjs`, `tests/multimedia.browser.mjs`,
+`tools/voci.json`, `tools/voci_xtts.py`, `assets/voices/index.json`.
+
+Nuovi file in `assets/voices/`:
+
+```text
+andrea-6e63dafda1.mp3
+andrea-71f3bff8a0.mp3
+andrea-b851b9ed26.mp3
+andrea-d2f285f610.mp3
+giocatore_carte-12f97003ec.mp3
+giocatore_carte-13af2e2331.mp3
+giocatore_carte-43e76cf11b.mp3
+giocatore_carte-6ce98bce81.mp3
+giocatore_carte-729b3344da.mp3
+giocatore_carte-8dfadb4acc.mp3
+giocatore_carte-9c07caaab4.mp3
+giocatore_carte-a8846bfa7b.mp3
+giocatore_carte-f1f219bd1c.mp3
+```
+
+`docs/STATO.md` invariato. Nessun commit, tag o push.
diff --git a/docs/STATO.md b/docs/STATO.md
index 905472d..84b7b37 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -18,6 +18,13 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
+- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
+ sprite e battute provvisorie di Codex). Parlandoci si sceglie il mazzo (toscane o napoletane) e il livello
+ («Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
+ partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude),
+ disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
+ Prossimo: il **tressette a coppie «ai 21»** con busso, striscio e volo (da chiedere ad Andrea: le accuse?).
+
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
diff --git a/index.html b/index.html
index 171af9f..8710606 100644
--- a/index.html
+++ b/index.html
@@ -103,6 +103,24 @@
diff --git a/package.json b/package.json
index 27e22a2..a76890e 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
+ "browser:briscola": "node tests/briscola.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
diff --git a/src/engine/briscola-render.js b/src/engine/briscola-render.js
new file mode 100644
index 0000000..2f4efb8
--- /dev/null
+++ b/src/engine/briscola-render.js
@@ -0,0 +1,295 @@
+import { canPlay, cardName, counted, countRows } from './briscola.js';
+import { text, panel } from './bocce-render.js';
+
+// Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono.
+export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
+export function cardAt(c, [x, y]) {
+ if (!canPlay(c)) return -1;
+ return c.hands.player.findIndex((_, i) => x >= HAND.x + i * HAND.gap && x < HAND.x + i * HAND.gap + HAND.w && y >= HAND.y && y < HAND.y + HAND.h);
+}
+const ink = '#38271f', paper = '#fff0cd', gold = '#dba32b', red = '#aa4034', blue = '#386a89', green = '#487348';
+function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
+function poly(ctx, points, color) {
+ ctx.fillStyle = color; ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); ctx.fill();
+}
+// Simboli originali: moneta incisa, calice, lama ricurva e bastone coi nodi.
+function suit(ctx, name, x, y) {
+ ctx.save(); ctx.translate(x, y);
+ if (name === 'denari') {
+ rect(ctx, -4, -3, 8, 6, ink); rect(ctx, -3, -4, 6, 8, ink);
+ rect(ctx, -3, -2, 6, 4, gold); rect(ctx, -2, -3, 4, 6, gold);
+ rect(ctx, -2, -2, 1, 4, '#ffe58c'); rect(ctx, 0, -1, 2, 2, '#9e6b25');
+ } else if (name === 'coppe') {
+ rect(ctx, -4, -4, 8, 2, ink); rect(ctx, -3, -2, 6, 3, gold);
+ rect(ctx, -2, 0, 4, 2, red); rect(ctx, -1, 2, 2, 2, ink);
+ rect(ctx, -3, 4, 6, 1, gold); rect(ctx, -3, -3, 6, 1, '#ffdd70');
+ } else if (name === 'spade') {
+ poly(ctx, [[-4,5],[-3,1],[0,-1],[2,-5],[4,-6],[3,-1],[-1,3],[-2,6]], ink);
+ rect(ctx, 1, -3, 2, 3, '#9eb3ba'); rect(ctx, -1, 0, 2, 2, '#c9d8d6');
+ rect(ctx, -5, 3, 5, 1, gold); rect(ctx, -4, 5, 2, 2, red);
+ } else {
+ poly(ctx, [[-3,6],[-4,4],[-1,-1],[0,-6],[3,-5],[3,-2],[0,3],[-1,6]], ink);
+ rect(ctx, 0, -4, 2, 5, '#94632e'); rect(ctx, -2, 1, 2, 4, '#ae7a39');
+ rect(ctx, 2, -3, 2, 2, gold); rect(ctx, -3, 2, 2, 2, gold); rect(ctx, 3, -5, 2, 1, green);
+ }
+ ctx.restore();
+}
+function regolo(ctx) {
+ // Cornice simmetrica e un piccolo re sorridente, avvolto nelle proprie spire.
+ rect(ctx, 3, 3, 36, 54, blue); rect(ctx, 5, 5, 32, 50, gold); rect(ctx, 6, 6, 30, 48, '#203e44');
+ for (let y = 9; y < 54; y += 6) for (const x of [8, 32]) rect(ctx, x, y, 2, 2, '#63867c');
+ poly(ctx, [[15,32],[28,32],[32,36],[32,43],[28,47],[14,47],[10,43],[10,37]], ink);
+ poly(ctx, [[15,34],[27,34],[30,37],[30,42],[27,45],[15,45],[12,42],[12,38]], green);
+ poly(ctx, [[17,37],[26,37],[28,39],[27,41],[16,41],[15,39]], '#203e44');
+ rect(ctx, 14, 42, 13, 2, '#a3bc72'); rect(ctx, 16, 36, 11, 1, '#a3bc72');
+ poly(ctx, [[19,25],[25,25],[25,33],[23,36],[18,36],[17,33],[20,32]], green);
+ rect(ctx, 20, 28, 2, 6, '#d0cd83');
+ poly(ctx, [[15,16],[27,16],[30,19],[30,25],[27,29],[15,29],[12,25],[12,20]], ink);
+ poly(ctx, [[16,17],[26,17],[29,20],[29,24],[26,27],[16,27],[13,24],[13,21]], '#94b365');
+ rect(ctx, 15, 19, 4, 4, paper); rect(ctx, 23, 19, 4, 4, paper);
+ rect(ctx, 17, 20, 2, 3, ink); rect(ctx, 23, 20, 2, 3, ink);
+ rect(ctx, 18, 25, 6, 1, ink); rect(ctx, 16, 24, 2, 1, ink); rect(ctx, 24, 24, 2, 1, ink);
+ rect(ctx, 17, 12, 9, 4, gold); for (const x of [17, 21, 25]) rect(ctx, x, 9, 1, 4, '#ffe396');
+ rect(ctx, 21, 13, 1, 2, red);
+ for (const x of [15, 25]) { rect(ctx, x, 50, 2, 2, gold); rect(ctx, x + 1, 49, 1, 4, gold); }
+}
+function figure(ctx, rank, name) {
+ // Figure intere: fante in tunica, cavaliere a cavallo, re con corona e manto.
+ const king = rank === 'Re', horse = rank === 'Cavallo', y = horse ? 15 : 17;
+ if (horse) {
+ poly(ctx, [[8,36],[12,29],[27,30],[30,23],[35,23],[37,29],[32,33],[31,41],[12,41]], ink);
+ rect(ctx, 11, 33, 20, 7, '#b78044'); rect(ctx, 30, 26, 4, 8, '#c79c60');
+ rect(ctx, 12, 40, 3, 10, ink); rect(ctx, 27, 39, 3, 11, ink);
+ rect(ctx, 15, 40, 2, 8, '#a36b3c'); rect(ctx, 32, 27, 1, 1, paper);
+ }
+ if (king) poly(ctx, [[15,24],[29,24],[33,48],[10,48]], red);
+ poly(ctx, [[19,y],[25,y],[27,y+2],[28,y+4],[26,y+5],[26,y+8],[20,y+9],[17,y+6],[17,y+2]], ink);
+ poly(ctx, [[20,y+1],[25,y+1],[25,y+3],[27,y+4],[25,y+5],[25,y+7],[21,y+8],[19,y+5]], '#eab578');
+ rect(ctx, 18, y + 1, 2, 4, '#86502d'); rect(ctx, 20, y + 1, 4, 1, '#ffe0a2');
+ rect(ctx, 24, y + 2, 1, 2, ink); rect(ctx, 24, y + 6, 3, 1, ink);
+ rect(ctx, 17, y - 2, 10, 3, king ? gold : blue);
+ if (king) for (const x of [17, 21, 25]) rect(ctx, x, y - 5, 2, 4, gold);
+ else rect(ctx, 18, y - 5, 2, 4, red);
+ poly(ctx, [[18,y+9],[26,y+9],[29,y+12],[27,y+19],[29,y+24],[15,y+24],[17,y+19],[15,y+12]], ink);
+ poly(ctx, [[19,y+10],[25,y+10],[27,y+12],[25,y+19],[27,y+22],[17,y+22],[19,y+18],[17,y+12]], horse ? red : blue);
+ rect(ctx, 18, y + 10, 3, 10, king ? gold : '#6291a5'); rect(ctx, 17, y + 19, 11, 2, gold);
+ rect(ctx, 23, y + 11, 1, 6, paper); rect(ctx, 23, y + 19, 2, 2, ink);
+ rect(ctx, 12, y + 11, 4, 3, '#eab578'); rect(ctx, 28, y + 10, 4, 4, '#eab578');
+ if (!horse) {
+ poly(ctx, [[17,y+24],[21,y+24],[20,y+32],[16,y+32]], ink);
+ poly(ctx, [[23,y+24],[27,y+24],[29,y+32],[25,y+32]], ink);
+ rect(ctx, 18, y + 24, 2, 5, '#b9884f'); rect(ctx, 24, y + 24, 2, 4, '#b9884f');
+ rect(ctx, 15, y + 32, 6, 2, red); rect(ctx, 24, y + 32, 6, 2, red);
+ }
+ suit(ctx, name, 10, 24);
+ if (king) { rect(ctx, 32, 22, 1, 24, gold); rect(ctx, 31, 20, 3, 3, gold); }
+}
+const PIPS = {
+ Asso: [[21,32]], 2: [[21,22],[21,42]], 3: [[21,20],[21,32],[21,44]],
+ 4: [[13,23],[29,23],[13,41],[29,41]],
+ 5: [[13,21],[29,21],[21,32],[13,43],[29,43]],
+ 6: [[13,20],[29,20],[13,32],[29,32],[13,44],[29,44]],
+ 7: [[13,19],[29,19],[13,32],[29,32],[13,45],[29,45],[21,32]],
+};
+// Le toscane hanno semi francesi, nessun indice sulle numerali e figure intere.
+const TUSCAN_COLORS = {
+ cuori: { cloth: '#326447', light: '#65936a', cloak: '#ab393b', ground: '#bc7760' },
+ mattoni: { cloth: '#a93832', light: '#d56a46', cloak: '#315f7e', ground: '#b3a05b' },
+ fiori: { cloth: '#315e85', light: '#6995ac', cloak: '#bc842e', ground: '#829968' },
+ picche: { cloth: '#755079', light: '#ac7d9a', cloak: '#376b66', ground: '#9e8271' },
+};
+const TUSCAN_PIPS = {
+ Asso: [[21,30]], 2: [[21,17],[21,43]], 3: [[21,15],[21,30],[21,45]],
+ 4: [[12,16],[30,16],[12,44],[30,44]],
+ 5: [[12,16],[30,16],[21,30],[12,44],[30,44]],
+ 6: [[12,15],[30,15],[12,30],[30,30],[12,45],[30,45]],
+ 7: [[10,14],[32,14],[21,22],[10,30],[32,30],[10,46],[32,46]],
+};
+function tuscanSuit(ctx, name, x, y, inverted = false) {
+ ctx.save(); ctx.translate(x, y);
+ if (inverted) ctx.scale(1, -1);
+ const color = name === 'cuori' || name === 'mattoni' ? '#c12f35' : '#202326';
+ // Sagome a gradini sulla griglia: nessuna sfumatura nei semi.
+ if (name === 'cuori') {
+ poly(ctx, [[-4,-4],[-1,-4],[0,-3],[1,-4],[4,-4],[5,-3],[5,0],[4,1],[4,2],[3,2],[3,3],[2,3],[2,4],[1,4],[1,5],[0,6],[-1,5],[-1,4],[-2,4],[-2,3],[-3,3],[-3,2],[-4,2],[-4,1],[-5,0],[-5,-3]], color);
+ } else if (name === 'mattoni') {
+ poly(ctx, [[0,-6],[1,-4],[2,-3],[3,-2],[5,0],[3,2],[2,3],[1,4],[0,6],[-1,4],[-2,3],[-3,2],[-5,0],[-3,-2],[-2,-3],[-1,-4]], color);
+ } else if (name === 'fiori') {
+ poly(ctx, [[-2,-5],[2,-5],[3,-4],[3,-2],[2,-1],[4,-2],[6,0],[6,2],[4,4],[2,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-2,4],[-4,4],[-6,2],[-6,0],[-4,-2],[-2,-1],[-3,-2],[-3,-4]], color);
+ } else {
+ poly(ctx, [[0,-6],[1,-5],[1,-4],[3,-2],[5,0],[5,3],[3,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-3,4],[-5,3],[-5,0],[-3,-2],[-1,-4],[-1,-5]], color);
+ }
+ ctx.restore();
+}
+function tuscanFigure(ctx, rank, name) {
+ const p = TUSCAN_COLORS[name], skin = '#e9b785', shade = '#bf805d', trim = '#e0b957';
+ const queen = rank === 'Donna', king = rank === 'Regio';
+ // Cornice sottile, terreno e silhouette originali, disegnati su una griglia 42×60.
+ rect(ctx, 3, 3, 36, 54, '#b6a48a'); rect(ctx, 3.5, 3.5, 35, 53, paper);
+ poly(ctx, [[4,51],[14,49],[26,50],[38,48],[38,56],[4,56]], p.ground);
+ if (queen) {
+ // Dama di profilo: diadema, manica a sbuffo, gonna a ventaglio e fazzoletto.
+ poly(ctx, [[21,11],[27,11],[29,14],[28,22],[31,26],[27,29],[19,24],[18,18]], ink);
+ poly(ctx, [[22,12],[26,12],[27,15],[25,19],[22,21],[20,18],[19,17],[21,16]], skin);
+ rect(ctx, 23, 12, 4, 2, '#70452c'); rect(ctx, 26, 14, 2, 7, '#70452c');
+ rect(ctx, 21, 15, 1, 1, ink); rect(ctx, 20, 18, 2, 1, shade);
+ poly(ctx, [[21,12],[21,10],[23,11],[24,9],[25,11],[27,10],[27,13]], trim);
+ rect(ctx, 24, 10, 1, 1, '#c12f35');
+ poly(ctx, [[21,21],[26,20],[30,24],[28,31],[31,39],[37,54],[12,54],[15,43],[18,33],[17,26]], ink);
+ poly(ctx, [[22,22],[26,21],[28,24],[26,31],[29,39],[35,53],[14,53],[17,42],[20,32],[19,26]], p.cloth);
+ poly(ctx, [[21,32],[25,32],[25,40],[31,52],[23,51],[19,46]], p.light);
+ poly(ctx, [[26,34],[29,41],[33,51],[30,48],[27,43]], trim);
+ poly(ctx, [[19,35],[18,45],[16,52],[20,52],[21,49],[19,47]], p.cloak);
+ poly(ctx, [[18,23],[22,25],[20,29],[16,31],[12,29],[13,26]], '#d8dfd7');
+ rect(ctx, 17, 25, 4, 2, '#94a7ad');
+ poly(ctx, [[14,28],[16,30],[12,33],[9,32],[10,30]], skin);
+ poly(ctx, [[9,31],[12,32],[10,35],[8,35],[6,40],[6,35]], '#fcf7df');
+ rect(ctx, 8, 34, 1, 3, '#94a7ad');
+ poly(ctx, [[20,29],[26,29],[27,31],[20,33],[18,32]], trim);
+ rect(ctx, 22, 23, 1, 3, trim); rect(ctx, 16, 54, 7, 1, ink);
+ } else if (king) {
+ // Regio anziano: corona sempre visibile, barba bianca e bordi d'ermellino.
+ poly(ctx, [[15,23],[27,22],[32,27],[36,54],[9,54],[11,31]], ink);
+ poly(ctx, [[15,24],[27,23],[30,28],[34,53],[11,53],[13,31]], p.cloak);
+ poly(ctx, [[18,27],[25,27],[27,52],[16,52]], p.cloth);
+ rect(ctx, 20, 33, 3, 18, p.light); rect(ctx, 16, 48, 11, 2, trim);
+ poly(ctx, [[15,24],[18,25],[16,34],[15,45],[12,52],[11,51],[13,33]], '#f6edcf');
+ poly(ctx, [[26,24],[28,25],[29,35],[32,51],[29,52],[26,35],[24,27]], '#f6edcf');
+ for (const [x,y] of [[14,29],[13,37],[12,46],[27,29],[28,38],[30,47]]) rect(ctx, x, y, 1, 2, ink);
+ poly(ctx, [[17,13],[24,12],[27,16],[26,23],[23,28],[18,26],[15,21],[15,16]], '#b9bfbb');
+ poly(ctx, [[18,14],[24,14],[25,17],[27,19],[24,20],[24,22],[19,22],[17,18]], skin);
+ rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 18, 16, 1, 2, shade);
+ poly(ctx, [[16,18],[19,21],[22,20],[25,21],[24,25],[21,28],[18,25],[16,23]], '#f7f0d7');
+ rect(ctx, 21, 22, 3, 1, '#9ba8a3'); rect(ctx, 18, 22, 1, 3, '#c6cdc0');
+ poly(ctx, [[16,14],[15,8],[17,8],[18,11],[20,10],[20,7],[22,7],[22,10],[24,11],[25,8],[27,8],[26,14]], ink);
+ poly(ctx, [[17,13],[16,9],[17,9],[18,12],[21,11],[21,8],[22,12],[25,12],[26,9],[25,13]], trim);
+ rect(ctx, 17, 13, 9, 2, trim); rect(ctx, 21, 13, 2, 1, '#c12f35');
+ rect(ctx, 33, 23, 1, 30, '#9b6a29'); rect(ctx, 33, 24, 1, 27, trim);
+ poly(ctx, [[33,19],[35,21],[35,23],[33,25],[31,23],[31,21]], trim);
+ rect(ctx, 33, 21, 1, 2, '#fff1ae'); rect(ctx, 31, 32, 4, 3, skin);
+ rect(ctx, 10, 33, 4, 3, skin); rect(ctx, 15, 53, 6, 2, ink); rect(ctx, 24, 53, 6, 2, ink);
+ } else {
+ // Gobbo giovane, senza corona: berretto morbido, mantello corto e mazza.
+ poly(ctx, [[15,23],[26,22],[31,27],[33,39],[27,43],[11,40],[10,28]], ink);
+ poly(ctx, [[15,24],[25,23],[29,27],[31,38],[27,41],[12,39],[12,28]], p.cloak);
+ poly(ctx, [[18,37],[23,38],[21,48],[20,53],[15,53],[17,46]], ink);
+ poly(ctx, [[24,37],[28,37],[28,46],[31,53],[26,53],[23,46]], ink);
+ rect(ctx, 18, 41, 2, 9, '#d3ad60'); rect(ctx, 25, 41, 2, 6, '#d3ad60');
+ rect(ctx, 16, 50, 4, 4, '#744531'); rect(ctx, 27, 50, 3, 4, '#744531');
+ rect(ctx, 13, 54, 7, 1, ink); rect(ctx, 26, 54, 7, 1, ink);
+ poly(ctx, [[17,12],[24,12],[27,16],[26,23],[22,25],[17,22],[15,18]], '#70452c');
+ poly(ctx, [[19,14],[24,14],[24,16],[26,18],[24,19],[24,21],[21,23],[18,20]], skin);
+ rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 23, 20, 2, 1, shade);
+ rect(ctx, 17, 15, 2, 7, '#70452c'); rect(ctx, 16, 19, 2, 5, '#70452c');
+ poly(ctx, [[16,14],[17,11],[21,10],[25,11],[26,14],[28,15],[16,15]], p.cloth);
+ rect(ctx, 18, 13, 8, 1, p.light);
+ poly(ctx, [[18,24],[24,23],[27,27],[25,33],[28,40],[15,40],[17,33],[15,27]], ink);
+ poly(ctx, [[19,25],[23,24],[25,27],[23,33],[26,39],[17,39],[19,33],[17,27]], p.cloth);
+ rect(ctx, 20, 26, 2, 7, p.light); rect(ctx, 16, 34, 10, 2, trim);
+ poly(ctx, [[18,23],[21,25],[20,29],[17,25]], '#f6edcf');
+ rect(ctx, 21, 34, 2, 2, ink);
+ poly(ctx, [[10,27],[13,29],[12,33],[9,34],[8,32]], skin);
+ poly(ctx, [[27,28],[30,30],[28,34],[25,35],[24,33],[27,32]], skin);
+ poly(ctx, [[7,30],[9,30],[9,46],[11,50],[11,53],[6,53],[6,49],[7,46]], ink);
+ rect(ctx, 8, 33, 1, 17, '#ae783a'); rect(ctx, 7, 49, 3, 3, trim);
+ rect(ctx, 7, 31, 3, 2, skin);
+ }
+ ctx.save(); ctx.translate(9, 8); ctx.scale(0.65, 0.65); tuscanSuit(ctx, name, 0, 0); ctx.restore();
+ text(ctx, queen ? 'D' : king ? 'R' : 'G', 7, 16, 4, ink);
+}
+function tuscanCard(ctx, card) {
+ // Angoli arrotondati a gradini e un solo bordo sottile; nessuna scritta al piede.
+ const outline = [[2,0],[40,0],[42,2],[42,58],[40,60],[2,60],[0,58],[0,2]];
+ ctx.save(); ctx.translate(1, 2); poly(ctx, outline, '#15231d'); ctx.restore();
+ poly(ctx, outline, '#89765f');
+ poly(ctx, [[2,1],[40,1],[41,2],[41,58],[40,59],[2,59],[1,58],[1,2]], paper);
+ if (TUSCAN_PIPS[card.rank]) {
+ for (const [x, y] of TUSCAN_PIPS[card.rank]) tuscanSuit(ctx, card.suit, x, y, y > 30);
+ } else tuscanFigure(ctx, card.rank, card.suit);
+}
+export function drawCard(ctx, card, x, y, w = 84, h = 120, back = false) {
+ ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(w / 42, h / 60);
+ if (!back && TUSCAN_COLORS[card.suit]) { tuscanCard(ctx, card); ctx.restore(); return; }
+ rect(ctx, 1, 2, 42, 60, '#15231d'); rect(ctx, 0, 0, 42, 60, ink);
+ rect(ctx, 1, 1, 40, 58, '#c5a576'); rect(ctx, 2, 2, 38, 56, paper);
+ if (back) regolo(ctx);
+ else {
+ const rank = { Asso: 'A', Fante: 'F', Cavallo: 'C', Re: 'R', Gobbo: 'G', Donna: 'D', Regio: 'R' }[card.rank] ?? card.rank;
+ text(ctx, rank, 4, 7, 6, ink); suit(ctx, card.suit, 34, 8);
+ if (PIPS[card.rank]) {
+ for (const [px, py] of PIPS[card.rank]) {
+ // Una lama per simbolo: le coppie si allungano e s'incrociano al centro.
+ if (card.suit === 'spade' && (px === 13 || px === 29)) {
+ ctx.save(); ctx.translate(px === 13 ? 18 : 24, py); ctx.scale(px === 13 ? 1.8 : -1.8, 1);
+ suit(ctx, card.suit, 0, 0); ctx.restore();
+ } else suit(ctx, card.suit, px, py);
+ }
+ } else figure(ctx, card.rank, card.suit);
+ text(ctx, card.suit.toUpperCase(), 21, 55, 3.7, ink, 'center');
+ }
+ ctx.restore();
+}
+function countTable(ctx, c, names) {
+ panel(ctx, 12, 32, 616, 318);
+ const rows = countRows(c), shown = rows.slice(0, c.count), totals = counted(c);
+ for (const [side, who] of ['player', 'cpu'].entries()) {
+ const x = 25 + side * 310;
+ text(ctx, names[who], x, 48, 7);
+ const cards = shown.filter(p => p.who === who);
+ cards.forEach(({ card }, i) => {
+ const cx = x + (i % 8) * 36, cy = 64 + Math.floor(i / 8) * 49;
+ drawCard(ctx, card, cx, cy, 30, 40);
+ text(ctx, '+' + card.points, cx + 15, cy + 44, 5, '#ffe09b', 'center');
+ });
+ text(ctx, `${totals[who]} punti`, x, 321, 10, '#ffe09b');
+ }
+ const last = shown.at(-1);
+ if (c.step === 'count' && last) {
+ panel(ctx, 270, 115, 100, 140); drawCard(ctx, last.card, 278, 123);
+ }
+ text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
+}
+export function drawBriscola(ctx, c, def) {
+ ctx.save(); ctx.imageSmoothingEnabled = false;
+ rect(ctx, 0, 0, 640, 360, '#573720');
+ for (let y = 0; y < 360; y += 24) {
+ rect(ctx, 0, y, 640, 2, '#38271e');
+ for (let x = 0; x < 640; x += 53) rect(ctx, x + y % 17, y + 6, 29, 1, '#72462a');
+ }
+ rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
+ for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
+ text(ctx, 'BRISCOLA ALL’ORATORIO', 320, 15, 9, '#ffe0a0', 'center');
+ if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
+ text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
+ c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
+ // La briscola sporge sotto il mazzo, girata di traverso: più di metà resta scoperta, anche l'asso che ha
+ // un seme solo nel mezzo (le toscane non hanno numeri negli angoli).
+ if (c.stock.length) {
+ ctx.save(); ctx.translate(154, 150); ctx.rotate(Math.PI / 2);
+ drawCard(ctx, c.trumpCard, 0, 0, 64, 92); ctx.restore();
+ if (c.stock.length > 1) {
+ for (let i = Math.min(5, Math.ceil(c.stock.length / 7)); i >= 0; i--)
+ drawCard(ctx, null, 38 + i, 116 - i, 58, 83, true);
+ }
+ }
+ text(ctx, `Mazzo: ${c.stock.length}`, 34, 243, 7);
+ text(ctx, 'Briscola:', 34, 263, 7); // a 6 punti la B sembra una D
+ text(ctx, c.trump.toUpperCase(), 34, 278, 7, '#ffe09b');
+ for (const [i, who] of ['cpu', 'player'].entries()) {
+ const y = 74 + i * 183;
+ if (c.taken[who].length) for (let n = 0; n < Math.min(4, c.taken[who].length / 2); n++) drawCard(ctx, null, 526 + n * 2, y - n * 2, 42, 60, true);
+ text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
+ }
+ c.table.forEach((p, i) => {
+ // Il brevissimo scorrimento precede il fermo della presa.
+ const slide = i === c.table.length - 1 ? Math.max(0, 1 - c.t / 0.18) : 0;
+ drawCard(ctx, p.card, 242 + i * 86, 130 + slide * (p.who === 'player' ? 60 : -40), 64, 92);
+ });
+ c.hands.player.forEach((card, i) => {
+ const x = HAND.x + i * HAND.gap;
+ if (canPlay(c) && c.selected === i) rect(ctx, x - 3, HAND.y - 3, HAND.w + 6, HAND.h + 6, '#ffdb76');
+ drawCard(ctx, card, x, HAND.y, HAND.w, HAND.h);
+ text(ctx, String(i + 1), x + HAND.w / 2, 221, 7, '#fff0cd', 'center');
+ });
+ ctx.restore();
+}
diff --git a/src/engine/briscola.js b/src/engine/briscola.js
new file mode 100644
index 0000000..dff8f79
--- /dev/null
+++ b/src/engine/briscola.js
@@ -0,0 +1,199 @@
+// Briscola in due: regole e avversari senza DOM. Le carte sono immutabili, lo stato è serializzabile.
+// Due mazzi, stesse regole, stessi id: cambiano solo nomi e disegni (scelti nel dialogo, idea di Andrea).
+// Toscane: semi francesi (i quadri in Toscana sono «mattoni»), figure gobbo, donna e regio.
+export const DECKS = {
+ toscane: { suits: ['cuori', 'mattoni', 'fiori', 'picche'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Gobbo', 'Donna', 'Regio'] },
+ napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
+};
+export const LEVELS = ['bona', 'seria', 'male'];
+const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
+const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
+const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
+ Object.freeze({ id: s * 10 + r, suit, rank, points: VALUES[r], strength: ORDER[r] }))));
+export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
+export const CARDS = DECK_CARDS.toscane, SUITS = DECKS.toscane.suits;
+export const other = who => who === 'player' ? 'cpu' : 'player';
+export const cardName = card => `${card.rank} di ${card.suit}`;
+export const points = cards => cards.reduce((n, card) => n + card.points, 0);
+function rng(seed) { let n = seed >>> 0; return () => ((n = (Math.imul(n, 1664525) + 1013904223) >>> 0) / 4294967296); }
+function shuffle(cards, random) {
+ for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
+ return cards;
+}
+// Il secondo vince solo con una briscola o con una carta più alta dello stesso seme.
+export function beats(second, first, trump) {
+ return second.suit === first.suit ? second.strength > first.strength : second.suit === trump;
+}
+export function trickWinner(table, trump) {
+ return beats(table[1].card, table[0].card, trump) ? table[1].who : table[0].who;
+}
+export function result(taken) {
+ const player = points(taken.player), cpu = points(taken.cpu);
+ return { player, cpu, winner: player === cpu ? null : player > cpu ? 'player' : 'cpu' };
+}
+export function newBriscola(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
+ if (!DECKS[deck]) deck = 'toscane';
+ const random = rng(seed), dealer = random() < 0.5 ? 'player' : 'cpu';
+ const stock = shuffle([...DECK_CARDS[deck]], random), hands = { player: [], cpu: [] };
+ // Distribuzione alternata, a partire da chi è di mano.
+ for (let i = 0; i < 3; i++) for (const who of [other(dealer), dealer]) hands[who].push(stock.shift());
+ const trumpCard = stock.shift(); stock.push(trumpCard);
+ return { step: 'play', t: 0, age: 0, level: LEVELS.includes(level) ? level : 'seria', deck, dealer,
+ hands, stock, trumpCard, trump: trumpCard.suit, current: other(dealer), table: [],
+ taken: { player: [], cpu: [] }, history: [], selected: 0, last: null, drawnTrump: null,
+ aiSeed: (seed ^ 0x9e3779b9) >>> 0, events: [{ kind: 'card-shuffle' }], count: 0 };
+}
+export const canPlay = c => c.step === 'play' && c.current === 'player';
+export function select(c, index) {
+ if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
+ c.selected = index; return true;
+}
+export function nudge(c, direction) {
+ return select(c, (c.selected + direction + c.hands.player.length) % c.hands.player.length);
+}
+export function play(c, index = c.selected, who = 'player') {
+ if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
+ const [card] = c.hands[who].splice(index, 1);
+ c.table.push({ who, card }); c.t = 0;
+ c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
+ c.events.push({ kind: 'card-slide' }); c.landing = true;
+ if (c.table.length === 2) {
+ c.step = 'trick'; c.last = { winner: trickWinner(c.table, c.trump), cards: c.table.map(p => p.card) };
+ } else c.current = other(who);
+ return true;
+}
+export function collect(c) {
+ if (c.step !== 'trick') return false;
+ const winner = c.last.winner;
+ c.taken[winner].push(...c.last.cards);
+ c.history.push({ winner, cards: [...c.last.cards] }); c.table = [];
+ // Chi prende pesca per primo. La briscola scoperta è davvero l'ultima carta.
+ for (const who of [winner, other(winner)]) if (c.stock.length) {
+ const card = c.stock.shift(); c.hands[who].push(card);
+ if (card.id === c.trumpCard.id) c.drawnTrump = who;
+ }
+ c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
+ c.step = c.hands.player.length ? 'play' : 'count';
+ return true;
+}
+// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
+// Mai il mazzo, la mano avversaria o il seed della distribuzione.
+export function observation(c, who = 'cpu') {
+ return { who, deck: c.deck, hand: [...c.hands[who]], opponentCount: c.hands[other(who)].length,
+ table: c.table.map(p => ({ ...p })), played: c.history.flatMap(t => t.cards),
+ ownPoints: points(c.taken[who]), otherPoints: points(c.taken[other(who)]),
+ remaining: c.stock.length, trump: c.trump, trumpCard: c.trumpCard,
+ knownOpponent: c.drawnTrump === other(who) && !c.history.some(t => t.cards.some(a => a.id === c.trumpCard.id))
+ && !c.table.some(p => p.card.id === c.trumpCard.id) ? [c.trumpCard] : [] };
+}
+export function unseen(v) {
+ const seen = new Set([...v.hand, ...v.played, ...v.table.map(p => p.card)].map(a => a.id));
+ if (v.remaining) seen.add(v.trumpCard.id);
+ return DECK_CARDS[v.deck ?? 'toscane'].filter(a => !seen.has(a.id));
+}
+// Giocatore attento di riferimento: scarta i lisci, conserva le briscole e prende i carichi.
+export function attentive(v) {
+ const lead = v.table[0]?.card;
+ const cost = a => a.points * 2 + (a.suit === v.trump ? 10 + a.strength : a.strength * 0.15);
+ const sorted = v.hand.map((a, i) => ({ a, i })).sort((a, b) => cost(a.a) - cost(b.a));
+ if (lead) {
+ const wins = sorted.filter(({ a }) => beats(a, lead, v.trump));
+ const take = wins.find(({ a }) => a.suit !== v.trump || lead.points + a.points >= 8 || !v.remaining);
+ if (take) return take.i;
+ }
+ return sorted[0].i;
+}
+// Finale a informazione completa: minimax esatto, nessuna euristica né campionamento.
+// Massimizza i punti finali (quindi anche vittoria/patta), con memoizzazione per i sottogiochi.
+export function perfect(hand, enemy, table, trump, who, memo = new Map()) {
+ const solve = (own, opp, lead, turn) => {
+ if (!own.length && !opp.length && !lead.length) return 0;
+ const key = `${own.map(a => a.id)}|${opp.map(a => a.id)}|${lead.map(p => p.who + p.card.id)}|${turn}`;
+ if (memo.has(key)) return memo.get(key);
+ const mine = turn === who, choices = mine ? own : opp;
+ let best = mine ? -Infinity : Infinity;
+ choices.forEach((card, i) => {
+ const next = choices.filter((_, j) => i !== j), a = mine ? next : own, b = mine ? opp : next;
+ const pair = [...lead, { who: turn, card }];
+ let value;
+ if (pair.length === 2) {
+ const winner = trickWinner(pair, trump);
+ value = (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(a, b, [], winner);
+ } else value = solve(a, b, pair, other(turn));
+ best = mine ? Math.max(best, value) : Math.min(best, value);
+ });
+ memo.set(key, best); return best;
+ };
+ return hand.map((card, i) => {
+ const own = hand.filter((_, j) => i !== j), pair = [...table, { who, card }];
+ if (pair.length === 1) return solve(own, enemy, pair, other(who));
+ const winner = trickWinner(pair, trump);
+ return (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(own, enemy, [], winner);
+ });
+}
+const bestIndex = scores => scores.indexOf(Math.max(...scores));
+
+// «A fassi male»: mondi possibili compatibili con le carte uscite, mai la distribuzione vera.
+// Stesse distribuzioni campionate per tutte le mosse, entrambi giocano attenti nei seguiti.
+// Taratura: 1000 partite/livello, seed imul(1..1000, 2654435761), tools/calibrate_briscola.mjs.
+// Vittorie di Andrea attento: bona 84,7%, seria 48,1%, male 35,9%; patte 0,9%, 2,5%, 2,0%.
+// Mazziere Andrea in 496/1000 partite. È una misura contro questa euristica, non contro persone.
+export function chooseCard(v, level = 'seria', seed = 1) {
+ const random = rng(seed);
+ if (level === 'bona') return Math.floor(random() * v.hand.length);
+ if (level !== 'male') return attentive(v);
+ const unknown = unseen(v);
+ if (!v.remaining) return bestIndex(perfect(v.hand, unknown, v.table, v.trump, v.who));
+ const totals = v.hand.map(() => 0), known = new Set(v.knownOpponent.map(a => a.id));
+ for (let sample = 0; sample < 40; sample++) {
+ const pool = shuffle(unknown.filter(a => !known.has(a.id)), random);
+ const enemy = [...v.knownOpponent, ...pool.splice(0, v.opponentCount - known.size)];
+ const stock = [...pool, v.trumpCard];
+ for (let move = 0; move < v.hand.length; move++) {
+ const hands = { [v.who]: [...v.hand], [other(v.who)]: [...enemy] };
+ let table = [...v.table], turn = v.who, deck = [...stock], delta = 0, first = true;
+ while (hands.player.length || hands.cpu.length || table.length) {
+ const idx = first ? move : attentive({ hand: hands[turn], table, trump: v.trump, remaining: deck.length });
+ first = false;
+ const [card] = hands[turn].splice(idx, 1); table.push({ who: turn, card });
+ if (table.length === 1) { turn = other(turn); continue; }
+ turn = trickWinner(table, v.trump);
+ delta += (turn === v.who ? 1 : -1) * points(table.map(p => p.card)); table = [];
+ for (const who of [turn, other(turn)]) if (deck.length) hands[who].push(deck.shift());
+ }
+ totals[move] += delta;
+ }
+ }
+ return bestIndex(totals);
+}
+export function update(c, dt) {
+ if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
+ c.t += dt;
+ if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
+ if (c.step === 'trick' && c.t >= 1.25) collect(c);
+ else if (c.step === 'count') {
+ // Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
+ c.count = Math.min(40, Math.floor(c.t / 0.35));
+ if (c.count === 40) { c.result = result(c.taken); c.step = 'result'; }
+ } else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.7) {
+ c.aiSeed = (Math.imul(c.aiSeed, 1664525) + 1013904223) >>> 0;
+ play(c, chooseCard(observation(c), c.level, c.aiSeed), 'cpu');
+ }
+}
+export function countRows(c) {
+ return ['player', 'cpu'].flatMap(who => c.taken[who].map(card => ({ who, card })));
+}
+export function counted(c) {
+ const totals = { player: 0, cpu: 0 };
+ for (const p of countRows(c).slice(0, c.count)) totals[p.who] += p.card.points;
+ return totals;
+}
+export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
+ if (c.step === 'result') return `${c.result.winner ? 'Vince ' + names[c.result.winner] : 'Patta'}! ${c.result.player} a ${c.result.cpu}.`;
+ if (c.step === 'count') {
+ const last = countRows(c)[c.count - 1];
+ return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
+ }
+ if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
+ return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} sceglie…`;
+}
diff --git a/src/engine/game.js b/src/engine/game.js
index d57358c..5506edd 100644
--- a/src/engine/game.js
+++ b/src/engine/game.js
@@ -3,9 +3,11 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
+import * as briscola from './briscola.js';
-// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi.
+// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = {
+ briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
};
@@ -267,16 +269,17 @@ export class Game {
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
- closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
- closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); }
- closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); }
+ closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
+ closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
+ closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
+ closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
const c = this.closeup;
const m = this.minigame();
- if (m) return m.aim(c, [x, y]);
+ if (m) return !!m.aim?.(c, [x, y]);
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
@@ -440,7 +443,9 @@ export class Game {
const type = this.story.closeups[e.closeup].type;
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
- : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level) }
+ // `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
+ : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
+ Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
}
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
diff --git a/src/engine/render.js b/src/engine/render.js
index 5011ee8..a119762 100644
--- a/src/engine/render.js
+++ b/src/engine/render.js
@@ -5,6 +5,7 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
+import { drawBriscola } from './briscola-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -666,6 +667,7 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
+ if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js
index 97f8385..f3721af 100644
--- a/src/engine/soundscape.js
+++ b/src/engine/soundscape.js
@@ -101,6 +101,12 @@ export class Soundscape {
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
+ else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
+ else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
+ else if (kind === 'card-shuffle') {
+ this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
+ for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
+ }
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
diff --git a/src/main.js b/src/main.js
index 5f9c248..17725aa 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,6 +10,8 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
+import * as briscola from './engine/briscola.js';
+import { cardAt } from './engine/briscola-render.js';
import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id);
@@ -399,6 +401,12 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
+ if (story.closeups[game.closeup.id].type === 'briscola') {
+ if (game.skip()) return;
+ press = { card: cardAt(game.closeup, scenePoint(e)), state: game.closeup };
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
const aimed = game.closeupAim(scenePoint(e));
if (story.closeups[game.closeup.id].type === 'biliardo') {
if (!aimed && !game.speech) return;
@@ -415,6 +423,11 @@ canvas.addEventListener('pointerdown', e => {
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
+ if (press.card !== undefined) {
+ const p = press; press = null;
+ if (running && game.closeup === p.state && p.card >= 0 && cardAt(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
+ return;
+ }
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
@@ -427,7 +440,7 @@ canvas.addEventListener('pointerup', e => {
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
-canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } });
+canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -443,19 +456,24 @@ canvas.addEventListener('pointermove', e => {
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
-// Comandi dei minigiochi (bocce e biliardo) separati dai verbi dell'avventura: mira a passi, forza regolabile e
-// lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata.
+// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = {
+ briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
-$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower(c, Number(e.target.value) / 100); });
-$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot(c); });
+$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
+$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
+for (let i = 0; i < 3; i++) {
+ const button = $('briscola-card-' + i);
+ button.addEventListener('click', () => game?.closeupPlay(i));
+ button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
+}
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]])
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
-addEventListener('blur', () => game?.closeupCancel());
+addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
// --- Verbi.
for (const verb of VERBS) {
@@ -542,6 +560,26 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
+ if (story.closeups[game.closeup.id].type === 'briscola') {
+ const inControls = e.target.closest?.('#briscola-controls');
+ if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
+ if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return;
+ if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && e.key === 'Enter' && briscola.canPlay(game.closeup)) return;
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
+ e.preventDefault();
+ if (e.repeat || game.skip()) return;
+ const c = game.closeup;
+ if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
+ else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
+ else if (c.step === 'result') game.closeupClose();
+ else if (e.key === 'Enter') game.closeupPlay();
+ if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
+ return;
+ }
+ if (e.key === 'Escape') game.closeupClose();
+ else if (e.key.toLowerCase() === 'm') toggleMute();
+ return;
+ }
if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
const c = game.closeup, m = mini();
@@ -628,9 +666,24 @@ function updateUi() {
updateChoices();
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
- $('bocce-controls').hidden = !mg;
+ const cards = def?.type === 'briscola';
+ $('bocce-controls').hidden = !mg || cards;
$('bocce-controls').inert = !running;
- if (mg) {
+ $('briscola-controls').hidden = !cards;
+ $('briscola-controls').inert = !running;
+ if (cards) {
+ $('panel').hidden = $('choices').hidden = true;
+ const line = briscola.status(c, def.names);
+ if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
+ for (let i = 0; i < 3; i++) {
+ const button = $('briscola-card-' + i), card = c.hands.player[i];
+ const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
+ if (button.textContent !== label) button.textContent = label;
+ button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
+ button.setAttribute('aria-pressed', String(c.selected === i && !!card));
+ }
+ }
+ if (mg && !cards) {
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names);
@@ -641,9 +694,9 @@ function updateUi() {
$('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira';
$('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo';
- for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
}
+ if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
@@ -655,6 +708,7 @@ function updateUi() {
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo');
+ $('stage').classList.toggle('briscola', !!cards);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
diff --git a/src/story/story.js b/src/story/story.js
index 963071f..be87a28 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -107,6 +107,14 @@ export const story = {
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: {
+ // [DA ANDREA: chi è] Partita facoltativa; battute provvisorie.
+ briscola: {
+ type: 'briscola', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
+ win: [{ set: 'briscola_vinta' }, { set: 'briscola_giocata' }, { say: 'Oh, bravo! Ora però rimescolo io.', who: 'giocatore_carte', pose: 'laugh' }],
+ lose: [{ set: 'briscola_giocata' }, { say: 'I carichi portali qui, mica alla stazione! Rivincita quando vuoi.', who: 'giocatore_carte', pose: 'laugh' }],
+ draw: [{ set: 'briscola_giocata' }, { say: 'Sessanta pari. Un si litiga nemmeno sul conto!', who: 'giocatore_carte', pose: 'talk' }],
+ quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
+ },
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: {
type: 'bocce',
@@ -178,6 +186,8 @@ export const story = {
},
},
characters: {
+ // [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
+ giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
@@ -738,6 +748,8 @@ export const story = {
name: 'Oratorio della Pieve',
map: [82, 45],
background: 'assets/scenes/oratorio.png',
+ speakers: { giocatore_carte: [595, 185] },
+ people: [{ who: 'giocatore_carte', at: [595, 239], facing: -1, idle: 'sit', talk: ['sit', 'talk'], blink: 'shuffle', every: 5, size: 1.05 }],
walk: [[0, 236], [300, 226], [480, 222], [500, 232], [640, 254], [640, 360], [0, 360]],
depth: [[222, 0.5], [360, 1.15]],
entry: [320, 345],
@@ -762,6 +774,15 @@ export const story = {
use: [{ say: 'Mi siedo e aspetto che arrivi la pastasciutta? Ci vorrebbe una settimana.' }],
},
},
+ {
+ // [DA ANDREA: chi è] Seduto alle tavolate, col mazzo davanti.
+ id: 'giocatore_carte', name: 'Il giocatore di carte', box: [580, 195, 39, 45], at: [580, 266], face: 'right', default: 'talk',
+ art: [{ rect: [578, 211, 9, 5], fill: '#f3dfb1' }, { rect: [579, 211, 7, 3], fill: '#34584e' }],
+ actions: {
+ look: [{ say: 'Ha già mescolato. Mi sa che aspettava proprio un pollo di passaggio.' }],
+ talk: [{ dialogue: 'giocatore_carte' }],
+ },
+ },
{
id: 'gomma_terra', name: 'gomma da masticare', box: [134, 230, 16, 10], at: [140, 244], face: 'up', when: ['!has:gomma', '!gomma_presa'],
art: [{ rect: [137, 233, 10, 4], fill: '#f27ab0' }, { rect: [138, 233, 3, 1], fill: '#ffd0e8' }],
@@ -2118,6 +2139,27 @@ export const story = {
},
},
dialogues: {
+ giocatore_carte: {
+ nodes: {
+ start: { options: [
+ { text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
+ { text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
+ { text: 'Ora cerco il palio. A dopo.', next: 'end' },
+ ] },
+ // Il mazzo (idea di Andrea): le toscane di casa, o le napoletane. Stesse regole, cambiano i disegni.
+ mazzo: { options: [
+ { text: 'Con le toscane.', next: 'livello', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
+ { text: 'Con le napoletane.', next: 'livello', then: [{ set: 'carte_napoletane' }, { say: 'Le napoletane? Le tengo per i villeggianti. Come la vuoi?', who: 'giocatore_carte', pose: 'shuffle' }] },
+ { text: 'Ci ripenso.', next: 'end' },
+ ] },
+ livello: { options: [
+ { text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'briscola', level: 'bona' }] },
+ { text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'briscola', level: 'seria' }] },
+ { text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] },
+ { text: 'Ci ripenso.', next: 'end' },
+ ] },
+ },
+ },
// Il chiosco della Tosca: le bibite si bevono subito; ghiacciolo e cedrata si portano via (una volta per tipo).
tosca: {
nodes: {
diff --git a/style.css b/style.css
index 0f353fc..c92d780 100644
--- a/style.css
+++ b/style.css
@@ -26,6 +26,14 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
+#briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
+#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
+.briscola-hand { display: flex; gap: 8px; }
+.briscola-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; }
+.briscola-hand button[aria-pressed="true"] { border-color: #ffdb76; }
+.briscola-hand button:disabled { opacity: .5; cursor: default; }
+#briscola-controls summary { cursor: pointer; padding: 10px 0; }
+#briscola-controls p { max-width: 76em; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
@@ -60,6 +68,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
#stage.biliardo #scene { height: auto; }
#stage.biliardo #closeup-close { position: static; display: block; margin: 6px 10px 6px auto; min-height: 32px; }
+#stage.briscola { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
+#stage.briscola #scene { height: auto; }
+#stage.briscola #closeup-close { position: static; display: block; margin: 6px 10px 6px auto; min-height: 36px; }
/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
@@ -270,6 +281,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#app { width: 100vw; flex-direction: row; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
+ #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
+ #briscola-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
+ .briscola-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
diff --git a/tests/briscola-render.test.mjs b/tests/briscola-render.test.mjs
new file mode 100644
index 0000000..4c68f8c
--- /dev/null
+++ b/tests/briscola-render.test.mjs
@@ -0,0 +1,41 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { drawBriscola, cardAt, HAND } from '../src/engine/briscola-render.js';
+import { newBriscola } from '../src/engine/briscola.js';
+import { story } from '../src/story/story.js';
+import { simulate } from '../tools/calibrate_briscola.mjs';
+
+function context() {
+ const labels = [], rects = [];
+ return { labels, rects, save() {}, restore() {}, translate() {}, scale() {}, rotate() {}, beginPath() {}, closePath() {}, moveTo() {}, lineTo() {}, fill() {}, strokeRect() {},
+ fillRect(...args) { rects.push(args); }, fillText(text) { labels.push(text); } };
+}
+test('renderer puro: durante la partita nasconde punteggi e carte avversarie', () => {
+ const c = newBriscola(91421), before = JSON.stringify(c), ctx = context();
+ drawBriscola(ctx, c, story.closeups.briscola); assert.equal(JSON.stringify(c), before);
+ assert.ok(ctx.labels.includes('Mazzo: 34'));
+ assert.ok(!ctx.labels.some(t => /punti|Vince|Totale/.test(t)));
+ c.hands.cpu = c.hands.cpu.map(a => new Proxy(a, { get() { throw new Error('Il disegno legge una carta coperta'); } }));
+ assert.doesNotThrow(() => drawBriscola(context(), c, story.closeups.briscola));
+});
+test('bersagli delle carte: bordi, spazi vuoti e input fuori turno', () => {
+ const c = newBriscola(4); c.current = 'player';
+ for (let i = 0; i < 3; i++) {
+ const x = HAND.x + i * HAND.gap;
+ assert.equal(cardAt(c, [x, HAND.y]), i);
+ assert.equal(cardAt(c, [x + HAND.w - 1, HAND.y + HAND.h - 1]), i);
+ assert.equal(cardAt(c, [x + HAND.w + 1, HAND.y + 20]), -1);
+ }
+ c.hands.player.pop(); assert.equal(cardAt(c, [HAND.x + 2 * HAND.gap + 10, HAND.y + 10]), -1);
+ c.current = 'cpu'; assert.equal(cardAt(c, [HAND.x + 10, HAND.y + 10]), -1);
+});
+test('conteggio e risultato mostrano ogni carta conquistata e i totali solo alla fine', () => {
+ const c = simulate(91421, 'male'), ctx = context(), before = JSON.stringify(c);
+ drawBriscola(ctx, c, story.closeups.briscola);
+ assert.equal(ctx.labels.filter(t => /^\+\d/.test(t)).length, 40);
+ assert.ok(ctx.labels.includes(`${c.result.player} punti`)); assert.ok(ctx.labels.includes(`${c.result.cpu} punti`));
+ assert.equal(JSON.stringify(c), before);
+ c.step = 'count'; c.count = 3;
+ const partial = context(); drawBriscola(partial, c, story.closeups.briscola);
+ assert.equal(partial.labels.filter(t => /^\+\d/.test(t)).length, 3);
+});
diff --git a/tests/briscola.browser.mjs b/tests/briscola.browser.mjs
new file mode 100644
index 0000000..cb29b4b
--- /dev/null
+++ b/tests/briscola.browser.mjs
@@ -0,0 +1,174 @@
+// Partite complete con eventi reali. Si accelerano solo le attese, mai le mosse di Andrea.
+// npm run browser:briscola — /tmp/regolo-shots/briscola-*.png
+import { chromium } from 'playwright';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import assert from 'node:assert/strict';
+import { staticServer } from './helpers.mjs';
+
+const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
+const server = await staticServer(), errors = [];
+let browser;
+async function clickCard(page, mode, index) {
+ const r = await page.locator('#scene').boundingBox();
+ const x = r.x + (176 + index * 102 + 42) / 640 * r.width, y = r.y + 290 / 360 * r.height;
+ if (mode === 'touch') await page.touchscreen.tap(x, y);
+ else if (mode === 'keyboard') {
+ await page.keyboard.press(String(index + 1));
+ assert.equal(await page.evaluate(() => game.closeup.selected), index);
+ await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowLeft');
+ assert.equal(await page.evaluate(() => game.closeup.selected), index);
+ await page.keyboard.press('Enter');
+ } else await page.mouse.click(x, y);
+}
+async function advance(page) {
+ return page.evaluate(async () => {
+ const { canPlay } = await import('/src/engine/briscola.js');
+ for (let i = 0; i < 6 && !canPlay(game.closeup) && !['count', 'result'].includes(game.closeup.step); i++) game.update(1.3);
+ return game.closeup.step;
+ });
+}
+async function artwork(page) {
+ const pictures = await page.evaluate(async () => {
+ await document.fonts.ready;
+ const { DECK_CARDS, newBriscola } = await import('/src/engine/briscola.js');
+ const { drawCard, drawBriscola } = await import('/src/engine/briscola-render.js');
+ const { story } = await import('/src/story/story.js');
+ const canvas = document.createElement('canvas'), ctx = canvas.getContext('2d'), pictures = {};
+ for (const [deck, cards] of Object.entries(DECK_CARDS)) {
+ canvas.width = 900; canvas.height = 560;
+ ctx.imageSmoothingEnabled = false;
+ ctx.fillStyle = '#234c40'; ctx.fillRect(0, 0, 900, 560);
+ cards.forEach((card, i) => drawCard(ctx, card, 8 + (i % 10) * 89, 8 + Math.floor(i / 10) * 136));
+ pictures[`${deck}-mazzo`] = canvas.toDataURL().split(',')[1];
+ canvas.width = 640; canvas.height = 360;
+ const c = newBriscola(41, { deck }), before = JSON.stringify(c);
+ drawBriscola(ctx, c, story.closeups.briscola);
+ if (before !== JSON.stringify(c)) throw new Error('Il renderer modifica lo stato');
+ pictures[`${deck}-tavolo`] = canvas.toDataURL().split(',')[1];
+ }
+ return pictures;
+ });
+ for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
+}
+try {
+ browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+ const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
+ { name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
+ { name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
+ ]);
+ scenarios.push({ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } });
+ for (const { name, mode, viewport } of scenarios) {
+ const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('console', e => { if (e.type() === 'error') errors.push(e.text()); });
+ await page.goto(`http://127.0.0.1:${server.port}/`);
+ await page.click('#splash'); await page.click('#new-game');
+ await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
+ await page.evaluate(async () => {
+ const { settle } = await import('/tests/helpers.mjs'); settle(game); game.go('oratorio'); settle(game);
+ });
+ await page.waitForTimeout(1500);
+ if (name === 'desktop-mouse') {
+ await page.screenshot({ path: `${out}/briscola-oratorio.png` });
+ await artwork(page);
+ }
+ await page.evaluate(async () => {
+ const { settle } = await import('/tests/helpers.mjs');
+ game.act('talk', 'giocatore_carte'); settle(game);
+ });
+ await page.locator('#choices li', { hasText: 'Una briscola?' }).click();
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ // Sul telefono le napoletane, altrove le toscane: tutti e due i mazzi passano dal dialogo.
+ const deck = name.startsWith('telefono') ? 'napoletane' : 'toscane';
+ await page.locator('#choices li', { hasText: deck === 'napoletane' ? 'Con le napoletane.' : 'Con le toscane.' }).click();
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ await page.locator('#choices li', { hasText: 'A fassi male.' }).click();
+ assert.equal(await page.evaluate(async () => { (await import('/tests/helpers.mjs')).settle(game); return game.closeup.deck; }), deck);
+ await page.evaluate(async deck => {
+ const { newBriscola } = await import('/src/engine/briscola.js');
+ game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) };
+ }, deck);
+ await page.waitForSelector('#briscola-controls:not([hidden])');
+ assert.ok(await page.isHidden('#bocce-controls'));
+ const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
+ assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
+ for (const id of ['#corner', '#sound']) {
+ const r = await page.locator(id).boundingBox(); assert.ok(r.y + r.height <= scene.y, `${id} fuori dal tavolo`);
+ }
+ assert.ok(scene.width * 84 / 640 >= 44, 'carte toccabili sul telefono');
+ await advance(page);
+ await page.screenshot({ path: `${out}/briscola-${name}-inizio.png` });
+ if (name === 'desktop-mouse') {
+ // Il tempo premuto non carica nulla; uscire dal bersaglio o annullare non gioca.
+ const r = await page.locator('#scene').boundingBox(), x = r.x + 218 / 640 * r.width, y = r.y + 290 / 360 * r.height;
+ const before = await page.evaluate(() => game.closeup.hands.player.length);
+ await page.mouse.move(x, y); await page.mouse.down(); await page.waitForTimeout(400);
+ assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before);
+ await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up();
+ assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before);
+ // Anche la selezione da tastiera sui pulsanti segue il focus.
+ await page.locator('#briscola-card-0').focus(); await page.keyboard.press('ArrowRight');
+ assert.equal(await page.evaluate(() => game.closeup.selected), 1);
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'briscola-card-1');
+ await page.locator('#briscola-card-1').evaluate(b => b.blur());
+ // Muto e pausa fermano anche le attese della partita.
+ await page.keyboard.press('m'); assert.equal(await page.evaluate(() => music.muted), true);
+ assert.equal(await page.evaluate(() => ambience.nodes.size), 0);
+ await page.keyboard.press('m');
+ await page.click('#pause-button');
+ const paused = await page.evaluate(() => JSON.stringify(game.closeup)); await page.waitForTimeout(250);
+ assert.equal(await page.evaluate(() => JSON.stringify(game.closeup)), paused);
+ await page.keyboard.press('Escape');
+ }
+ let played = 0, capturedTrick = false, capturedEnd = false;
+ while (!['count', 'result'].includes(await advance(page))) {
+ const index = await page.evaluate(async () => {
+ const { observation, attentive } = await import('/src/engine/briscola.js');
+ return attentive(observation(game.closeup, 'player'));
+ });
+ const before = await page.evaluate(() => game.closeup.hands.player.length);
+ await clickCard(page, mode, index); played++;
+ assert.equal(await page.evaluate(() => game.closeup.hands.player.length), before - 1, 'un gesto gioca esattamente una carta');
+ if (!capturedTrick) {
+ await page.evaluate(() => { if (game.closeup.step === 'play') game.update(0.75); });
+ assert.equal(await page.evaluate(() => game.closeup.step), 'trick');
+ assert.equal(await page.evaluate(() => game.closeup.table.length), 2);
+ await page.waitForTimeout(200);
+ await page.screenshot({ path: `${out}/briscola-${name}-presa.png` }); capturedTrick = true;
+ }
+ if (!capturedEnd && await page.evaluate(() => !game.closeup.stock.length)) {
+ await page.screenshot({ path: `${out}/briscola-${name}-fine-mazzo.png` }); capturedEnd = true;
+ }
+ assert.ok(played <= 20);
+ }
+ assert.equal(played, 20);
+ await page.evaluate(() => game.update(3));
+ await page.screenshot({ path: `${out}/briscola-${name}-conteggio.png` });
+ await page.evaluate(() => game.update(15));
+ await page.waitForFunction(() => game.closeup.step === 'result');
+ await page.waitForTimeout(50);
+ const result = await page.evaluate(() => game.closeup.result);
+ assert.equal(result.player + result.cpu, 120);
+ await page.screenshot({ path: `${out}/briscola-${name}-risultato.png` });
+ if (mode === 'keyboard') await page.keyboard.press('Enter');
+ else if (mode === 'touch') await page.tap('#closeup-close');
+ else await page.click('#closeup-close');
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ assert.equal(await page.evaluate(() => !!game.state.flags.briscola_vinta), result.winner === 'player');
+ assert.ok(await page.evaluate(() => game.state.flags.briscola_giocata));
+ // Rivincita e abbandono con Esc: nessun flag di vittoria regalato.
+ await page.evaluate(async () => {
+ const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
+ });
+ await page.locator('#choices li', { hasText: 'La rivincita?' }).click();
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ await page.locator('#choices li', { hasText: 'Alla bona.' }).click();
+ await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+ await page.keyboard.press('Escape'); assert.equal(await page.evaluate(() => game.closeup), null);
+ console.log(`${name}: 20 carte giocate, prese, conteggio, risultato e rivincita OK`);
+ await page.close();
+ }
+ assert.deepEqual(errors, []); console.log(`Schermate: ${out}/briscola-*.png`);
+} finally { await browser?.close(); server.kill(); }
diff --git a/tests/briscola.test.mjs b/tests/briscola.test.mjs
new file mode 100644
index 0000000..6f973d7
--- /dev/null
+++ b/tests/briscola.test.mjs
@@ -0,0 +1,183 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { CARDS, SUITS, DECKS, DECK_CARDS, newBriscola, points, beats, trickWinner, play, collect, update, observation, unseen, chooseCard, attentive, perfect, result, counted, canPlay, select, nudge } from '../src/engine/briscola.js';
+import { simulate, calibrate } from '../tools/calibrate_briscola.mjs';
+import { Game, MINIGAMES } from '../src/engine/game.js';
+import { story } from '../src/story/story.js';
+import { settle } from './helpers.mjs';
+const card = (suit, rank) => CARDS.find(a => a.suit === suit && a.rank === rank);
+
+test('mazzo italiano: 40 carte distinte, 120 punti, ordine di presa completo', () => {
+ assert.equal(CARDS.length, 40); assert.equal(new Set(CARDS.map(a => a.id)).size, 40); assert.equal(points(CARDS), 120);
+ const order = ['Asso', '3', 'Regio', 'Donna', 'Gobbo', '7', '6', '5', '4', '2'];
+ for (const suit of SUITS) for (let i = 0; i < order.length; i++) for (let j = 0; j < order.length; j++)
+ assert.equal(beats(card(suit, order[i]), card(suit, order[j]), 'mattoni'), i < j);
+ assert.equal(beats(card('mattoni', '2'), card('cuori', 'Asso'), 'mattoni'), true);
+ assert.equal(beats(card('picche', 'Asso'), card('cuori', '2'), 'mattoni'), false);
+ assert.equal(trickWinner([{ who: 'cpu', card: card('cuori', '2') }, { who: 'player', card: card('picche', 'Asso') }], 'mattoni'), 'cpu');
+});
+
+test('distribuzione: seed ripetibile, mazziere casuale, primo di mano e briscola in fondo', () => {
+ const dealers = new Set();
+ for (let n = 1; n <= 30; n++) {
+ const seed = Math.imul(n, 2654435761) >>> 0, c = newBriscola(seed);
+ assert.deepEqual(c, newBriscola(seed)); dealers.add(c.dealer);
+ assert.notEqual(c.current, c.dealer);
+ assert.equal(c.hands.player.length, 3); assert.equal(c.hands.cpu.length, 3);
+ assert.equal(c.stock.length, 34); assert.equal(c.stock.at(-1).id, c.trumpCard.id);
+ assert.equal(new Set([...c.stock, ...c.hands.player, ...c.hands.cpu].map(a => a.id)).size, 40);
+ }
+ assert.equal(dealers.size, 2);
+});
+
+test('nessun obbligo di seme; la presa resta sul tavolo, chi prende pesca prima ed esce', () => {
+ const c = newBriscola(42);
+ c.current = 'cpu'; c.trump = 'mattoni';
+ c.hands.cpu = [card('cuori', 'Asso')];
+ c.hands.player = [card('cuori', '2'), card('mattoni', '2')];
+ const stock = [...c.stock];
+ assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1));
+ assert.equal(c.step, 'trick'); assert.equal(c.last.winner, 'player'); assert.equal(c.table.length, 2);
+ update(c, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length);
+ update(c, 0.26);
+ assert.equal(c.hands.player.at(-1).id, stock[0].id);
+ assert.equal(c.hands.cpu.at(-1).id, stock[1].id);
+ assert.equal(c.current, 'player'); assert.equal(c.table.length, 0);
+});
+
+test('l’ultima pescata è la briscola, poi tre prese senza mazzo; totale e conteggio finale', () => {
+ for (const level of ['bona', 'seria', 'male']) {
+ const c = newBriscola(612347, { level }); let lastDraw = false;
+ for (let i = 0; i < 200 && c.step !== 'count'; i++) {
+ if (canPlay(c)) play(c, attentive(observation(c, 'player')));
+ if (c.step === 'trick' && c.stock.length === 2) {
+ const winner = c.last.winner; collect(c);
+ const loser = winner === 'cpu' ? 'player' : 'cpu';
+ assert.equal(c.hands[loser].at(-1).id, c.trumpCard.id);
+ assert.equal(c.drawnTrump, loser); assert.equal(c.hands.player.length, 3); assert.equal(c.hands.cpu.length, 3);
+ assert.equal(c.history.length, 17); lastDraw = true;
+ } else update(c, 2);
+ }
+ assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20);
+ assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0);
+ assert.equal(c.result, undefined, 'risultato solo dopo il conteggio');
+ assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40);
+ assert.deepEqual(counted(c), { player: 0, cpu: 0 });
+ update(c, 0.36); assert.equal(c.count, 1);
+ update(c, 14); assert.equal(c.step, 'result'); assert.equal(c.result.player + c.result.cpu, 120);
+ assert.deepEqual(counted(c), { player: c.result.player, cpu: c.result.cpu });
+ }
+});
+
+test('60–60 è patta, 61 vince; input bloccato fuori turno e selezione valida', () => {
+ // Due semi per ciascuno: ogni seme vale esattamente 30.
+ assert.deepEqual(result({ player: CARDS.slice(0, 20), cpu: CARDS.slice(20) }), { player: 60, cpu: 60, winner: null });
+ const c = newBriscola(1); c.current = 'player';
+ assert.ok(select(c, 2)); assert.ok(nudge(c, 1)); assert.equal(c.selected, 0);
+ assert.equal(select(c, 3), false); assert.equal(play(c, -1), false);
+ assert.ok(play(c)); assert.equal(play(c), false); assert.equal(select(c, 1), false);
+ assert.equal(result({ player: [card('cuori', 'Asso')], cpu: [] }).winner, 'player');
+ const p = [11, 10, 11, 10, 11, 4, 4].map((n, i) => ({ id: i, points: n }));
+ assert.equal(result({ player: p, cpu: [{ points: 59 }] }).winner, 'player');
+});
+
+test('i tre livelli sono deterministici per tutta la partita', () => {
+ for (const level of ['bona', 'seria', 'male']) {
+ const a = simulate(927421, level), b = simulate(927421, level);
+ assert.deepEqual(a, b); assert.equal(a.history.length, 20);
+ assert.equal(a.result.player + a.result.cpu, 120);
+ }
+ assert.equal(newBriscola(1, { level: 'inesistente' }).level, 'seria');
+});
+
+test('Seria prende il carico con la briscola piccola e liscia senza sprecarla', () => {
+ const v = { hand: [card('mattoni', 'Regio'), card('picche', '2'), card('mattoni', '2')], trump: 'mattoni', remaining: 20, table: [{ who: 'player', card: card('cuori', 'Asso') }] };
+ assert.equal(chooseCard(v, 'seria'), 2);
+ v.table[0].card = card('cuori', '4'); assert.equal(chooseCard(v, 'seria'), 1);
+});
+
+test('IA senza informazioni nascoste: vista invariata anche invertendo mano e mazzo veri', () => {
+ const c = newBriscola(9127), before = observation(c), d = structuredClone(c);
+ [d.hands.player[0], d.stock[0]] = [d.stock[0], d.hands.player[0]]; d.stock.reverse(); d.aiSeed = 999;
+ assert.deepEqual(observation(d), before);
+ for (const level of ['bona', 'seria', 'male']) assert.equal(chooseCard(observation(d), level, 432), chooseCard(before, level, 432));
+ // Fallisce immediatamente se observation prova a leggere qualcosa oltre il numero di carte.
+ const hidden = a => new Proxy(a, { get(target, key) { if (key !== 'length') throw new Error('Informazione nascosta: ' + String(key)); return target.length; } });
+ c.stock = hidden(c.stock); c.hands.player = hidden(c.hands.player);
+ assert.deepEqual(observation(c), before);
+ const saved = JSON.stringify(before); chooseCard(before, 'male', 17); assert.equal(JSON.stringify(before), saved);
+ assert.ok(!('seed' in before) && !('stock' in before));
+});
+
+test('finale noto: complementare delle carte viste e minimax contro ogni risposta possibile', () => {
+ const c = newBriscola(69298, { level: 'male' });
+ while (c.stock.length) {
+ if (canPlay(c)) play(c, attentive(observation(c, 'player')));
+ update(c, 2);
+ }
+ for (const who of ['player', 'cpu']) {
+ const v = observation(c, who);
+ assert.deepEqual(unseen(v).map(a => a.id).sort((a,b) => a-b), c.hands[who === 'cpu' ? 'player' : 'cpu'].map(a => a.id).sort((a,b) => a-b));
+ }
+ // Oracolo indipendente: enumera ogni seguito del motore reale, senza euristiche.
+ function exhaustive(state, who) {
+ if (!state.hands.player.length && !state.hands.cpu.length) return points(state.taken[who]);
+ const values = state.hands[state.current].map((_, i) => {
+ const next = structuredClone(state); play(next, i, state.current); if (next.step === 'trick') collect(next);
+ return exhaustive(next, who);
+ });
+ return state.current === who ? Math.max(...values) : Math.min(...values);
+ }
+ const who = c.current, v = observation(c, who), scores = perfect(v.hand, unseen(v), v.table, v.trump, who);
+ const oracle = v.hand.map((_, i) => { const d = structuredClone(c); play(d, i, who); if (d.step === 'trick') collect(d); return exhaustive(d, who); });
+ const chosen = chooseCard(v, 'male');
+ assert.equal(oracle[chosen], Math.max(...oracle)); assert.equal(scores[chosen], Math.max(...scores));
+ // Anche tutte le successive risposte, con la prima carta già sul tavolo.
+ while (c.hands.player.length || c.hands.cpu.length) {
+ const turn = c.current, view = observation(c, turn);
+ const expected = view.hand.map((_, i) => {
+ const d = structuredClone(c); play(d, i, turn); if (d.step === 'trick') collect(d);
+ return exhaustive(d, turn);
+ });
+ const index = chooseCard(view, 'male'); assert.equal(expected[index], Math.max(...expected));
+ play(c, index, turn); if (c.step === 'trick') collect(c);
+ }
+});
+
+test('taratura su mazzi uguali: Alla bona < Seria < A fassi male contro il giocatore attento', () => {
+ const runs = calibrate(160);
+ assert.ok(runs[0].wins.player > runs[1].wins.player, JSON.stringify(runs));
+ assert.ok(runs[1].wins.player > runs[2].wins.player, JSON.stringify(runs));
+});
+
+test('oratorio: dialogo, livelli, abbandono, risultati, rivincita e salvataggio facoltativo', () => {
+ for (const [outcome, winner] of [['win', 'player'], ['lose', 'cpu'], ['draw', null], ['quit', undefined]]) {
+ const g = new Game(story); settle(g); g.go('oratorio'); settle(g);
+ g.act('talk', 'giocatore_carte'); settle(g); g.choose(0); settle(g);
+ assert.deepEqual(g.options().map(o => o.text), ['Con le toscane.', 'Con le napoletane.', 'Ci ripenso.']);
+ const deck = winner === 'cpu' ? 'napoletane' : 'toscane';
+ g.choose(deck === 'napoletane' ? 1 : 0); settle(g);
+ assert.deepEqual(g.options().slice(0, 3).map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.']);
+ g.choose(2); settle(g); assert.equal(g.closeup.level, 'male'); assert.equal(g.closeup.deck, deck);
+ assert.equal(g.closeupPress(), false); assert.equal(g.closeupRelease(), false); assert.equal(g.closeupCancel(), false);
+ assert.equal(g.closeupAim([200, 250]), false);
+ if (outcome !== 'quit') Object.assign(g.closeup, { step: 'result', result: { winner } });
+ g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
+ assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
+ g.act('talk', 'giocatore_carte'); settle(g);
+ assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'La rivincita?');
+ assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
+ }
+});
+test('due mazzi, stesse regole: le napoletane cambiano solo i nomi', () => {
+ const t = newBriscola(7), n = newBriscola(7, { deck: 'napoletane' });
+ assert.equal(t.deck, 'toscane'); assert.equal(n.deck, 'napoletane');
+ assert.equal(newBriscola(7, { deck: 'piacentine' }).deck, 'toscane');
+ assert.deepEqual(n.stock.map(a => a.id), t.stock.map(a => a.id));
+ assert.equal(points(DECK_CARDS.napoletane), 120);
+ assert.ok(n.hands.player.every(a => DECKS.napoletane.suits.includes(a.suit)));
+ assert.ok(t.hands.player.every(a => DECKS.toscane.suits.includes(a.suit)));
+ assert.ok(DECK_CARDS.toscane.some(a => a.rank === 'Regio' && a.points === 4 && a.suit === 'mattoni'));
+ // L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane.
+ assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit)));
+});
diff --git a/tests/multimedia.browser.mjs b/tests/multimedia.browser.mjs
index a0145f8..9e365d5 100644
--- a/tests/multimedia.browser.mjs
+++ b/tests/multimedia.browser.mjs
@@ -102,7 +102,7 @@ export async function checkMultimedia(browser, port, out, errors) {
const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {};
- for (const kind of ['page', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
+ for (const kind of ['page', 'card-slide', 'card-tap', 'card-shuffle', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
const ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true });
diff --git a/tests/multimedia.test.mjs b/tests/multimedia.test.mjs
index c4d10ed..17d1965 100644
--- a/tests/multimedia.test.mjs
+++ b/tests/multimedia.test.mjs
@@ -101,3 +101,14 @@ test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } });
assert.doesNotThrow(() => { s.unlock(); s.configure({ active: true }); s.update('paese', 'sera', 1); s.play('page'); });
});
+
+test('carte: fruscio, battuta e mescolata seguono volume effetti, muto e pausa', () => {
+ const s = new Soundscape({ createContext: audioContext, random: () => 0.3 }); s.unlock();
+ for (const kind of ['card-slide', 'card-tap', 'card-shuffle']) {
+ s.configure({ active: true, muted: false, volume: 0.27 }); s.play(kind);
+ assert.ok(s.nodes.size > 0, kind); assert.equal(s.master.gain.value, 0.27);
+ s.configure({ muted: true }); assert.equal(s.nodes.size, 0); s.play(kind); assert.equal(s.nodes.size, 0);
+ s.configure({ muted: false, volume: 0 }); s.play(kind); assert.equal(s.nodes.size, 0);
+ s.configure({ active: false, volume: 0.5 }); s.play(kind); assert.equal(s.nodes.size, 0);
+ }
+});
diff --git a/tests/story.test.mjs b/tests/story.test.mjs
index aaa7674..b5cb50d 100644
--- a/tests/story.test.mjs
+++ b/tests/story.test.mjs
@@ -13,7 +13,7 @@ const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'd
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
for (const [id, c] of Object.entries(story.closeups ?? {})) {
- for (const k of ['comment', 'closing', 'complete']) yield [`${id}.${k}`, c[k] ?? []];
+ for (const k of ['comment', 'closing', 'complete', 'win', 'lose', 'draw', 'quit']) yield [`${id}.${k}`, c[k] ?? []];
for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
}
for (const [id, scene] of Object.entries(story.scenes)) {
diff --git a/tools/calibrate_briscola.mjs b/tools/calibrate_briscola.mjs
new file mode 100644
index 0000000..512a933
--- /dev/null
+++ b/tools/calibrate_briscola.mjs
@@ -0,0 +1,25 @@
+// node tools/calibrate_briscola.mjs [partite per livello, predefinite 1000]
+// Stessi semi e mazzi per i tre livelli; Andrea gioca con l'euristica «attenta».
+import { newBriscola, observation, attentive, play, update, canPlay, LEVELS } from '../src/engine/briscola.js';
+
+export function simulate(seed, level) {
+ const c = newBriscola(seed, { level });
+ for (let steps = 0; steps < 200 && c.step !== 'result'; steps++) {
+ if (canPlay(c)) play(c, attentive(observation(c, 'player')));
+ update(c, 20); c.events.length = 0;
+ }
+ if (c.step !== 'result') throw new Error('Partita non conclusa');
+ return c;
+}
+export function calibrate(n = 1000) {
+ return LEVELS.map(level => {
+ const wins = { player: 0, cpu: 0, draw: 0 };
+ let sum = 0, dealers = 0;
+ for (let i = 1; i <= n; i++) {
+ const c = simulate(Math.imul(i, 2654435761) >>> 0, level);
+ wins[c.result.winner ?? 'draw']++; sum += c.result.player; dealers += c.dealer === 'player';
+ }
+ return { level, games: n, wins, playerPercent: 100 * wins.player / n, drawPercent: 100 * wins.draw / n, average: sum / n, playerDeals: dealers };
+ });
+}
+if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 1000), null, 2));
diff --git a/tools/voci.json b/tools/voci.json
index d497fc3..dba6f73 100644
--- a/tools/voci.json
+++ b/tools/voci.json
@@ -3,6 +3,10 @@
"andrea": {
"speaker": "Andrew Chipper"
},
+ "giocatore_carte": {
+ "speaker": "Viktor Eka",
+ "filtro": "asetrate=24000*1.04,aresample=24000,atempo=0.98"
+ },
"vecchietta": {
"speaker": "Tammy Grit"
},
diff --git a/tools/voci_xtts.py b/tools/voci_xtts.py
index 2b0654d..04682aa 100644
--- a/tools/voci_xtts.py
+++ b/tools/voci_xtts.py
@@ -25,7 +25,18 @@ if lavori:
import torch
import torchaudio
from TTS.api import TTS
- tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
+ # Anche senza GPU (collaudo locale): XTTS_DEVICE=cpu, stesso modello e stesse voci.
+ device = os.environ.get("XTTS_DEVICE", "cuda")
+ if device == "cpu":
+ torch.set_num_threads(2)
+ model_dir = os.environ.get("XTTS_MODEL_DIR")
+ if model_dir:
+ # Modello già scaricato: nessun controllo di aggiornamenti in rete.
+ from TTS.utils.synthesizer import Synthesizer
+ tts = TTS()
+ tts.synthesizer = Synthesizer(model_dir=model_dir, use_cuda=device == "cuda")
+ else:
+ tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
sr = tts.synthesizer.output_sample_rate
for i, j in enumerate(lavori, 1):
pezzi = []