Compare commits

...
13 Commits
Author SHA1 Message Date
manzolo fef236dc54 Versione 0.1.12 2026-10-26 14:34:08 +01:00
manzoloandClaude Opus 5.5 c82bef70b2 Oratorio: il libro della Storia infinita passa sulla prima tavolata, a sinistra del palo, lontano dal giocatore di carte (Andrea: «troppo vicini, non riesco a interagire»); test che li distingue
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 14:28:21 +01:00
manzolo e9907e3e66 Versione 0.1.11 2026-10-26 14:01:26 +01:00
manzoloandClaude Opus 5.5 92e7a289be Briscola all'oratorio: alle tavolate il giocatore di carte (segnaposto, sprite di Codex) sfida Andrea a briscola in due. Si sceglie il mazzo, toscane (cuori, mattoni, fiori, picche; gobbo, donna e regio) o napoletane, e il livello: «Alla bona», «Seria», «A fassi male» (conta le carte uscite, finale perfetto a mazzo finito, mai la mano altrui). Regole fedeli, conteggio delle prese a fine partita, rivincita, suoni di carte, comandi con mouse, tocco e tastiera. Carte e dorso col Regolo serpentello disegnati da Codex; motore e prove nel browser rivisti da Claude
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 13:56:39 +01:00
manzolo 2362fc7741 Versione 0.1.10 2026-10-26 00:19:14 +01:00
manzoloandClaude Opus 5.5 2dd4257fd4 Piazza: la Torre dello Scoronconcolo, con l'aneddoto di Michele del Tavolaccino (Lorenzino de' Medici, il duca Alessandro, le galere contro i Turchi), trovato da Andrea su Wikipedia
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-26 00:14:20 +01:00
manzolo d51f5e56ea Versione 0.1.9 2026-10-26 00:03:14 +01:00
manzoloandClaude Opus 5.5 7b7e0031d1 Biliardo: immagini e suoni insieme. Durante la steccata aspetta la fisica, non il disegno: la biglia parte quando la stecca la tocca, col suo suono, e si disegnano sempre le posizioni vere (segnalato da Codex: urti che sembravano senza contatto e suoni in anticipo di 180 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:55:31 +01:00
manzoloandClaude Opus 5.5 94e9da4009 Difficoltà alle bocce e al biliardo, scelta nel dialogo con l'avversario: «Alla bona», «Seria», «A fassi male». Tarata con partite simulate contro un giocatore attento: al biliardo vince 9 su 12, circa 4 su 10 e quasi mai; alle bocce 72%, 50% e 35% delle mani. Battute nuove per Mariano e l'Ingegnere, test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:46:46 +01:00
manzoloandClaude Opus 5.5 bd66ea45ad Biliardo più bello (Codex): tavolo in pixel art visto dall'alto con cornice a fasce e diamanti, sala in penombra e luce delle lampade sul panno, segnapunti a cifre rosse, steccata e birilli che cadono animati, biglie in pixel, l'Ing. Tozzi a bordo tavolo che dà il gesso, esulta o allarga le braccia. Più: scritte a 8 punti (a 6 la B sembrava una D) e la stecca che torna a seguire il mouse finché un clic non fissa la mira (Andrea: «non riesco più a girare la stecca»)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:36:23 +01:00
manzoloandClaude Opus 5.5 9eabd5c6e1 Biliardo più preciso (revisione di Codex): urti calcolati nel punto esatto di contatto, anche nei colpi di taglio e veloci; la guida simula il tiro vero e mostra le due biglie dopo l'urto e i falli; la mira resta ferma andando sui comandi; frecce e Maiusc per la mira fine; comandi che non coprono il tavolo; test nel browser con mouse, touch e telefono. Più un tetto agli urti per passo, così due biglie a contatto non possono bloccare il gioco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:12:46 +01:00
manzoloandClaude Opus 5.5 ddc877a2d1 Sala dei biliardi: trio jazz da circolo sintetizzato da zero (tools/jazz_biliardo.py: contrabbasso, piano elettrico, spazzole, vibrafono, swing in si bemolle) e un sottofondo con gli altri tavoli e il bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:59:43 +01:00
manzoloandClaude Opus 5.5 14a1e6e5b8 L'Ing. Tozzi nella sala dei biliardi (sprite di Codex dalla foto di Andrea): la sfida parte parlandogli, come con Mariano, con la rivincita dopo una vittoria; la sala in scala più alta, Andrea non sta più sotto il tavolo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:56:55 +01:00
82 changed files with 2649 additions and 207 deletions
+6
View File
@@ -47,6 +47,11 @@ Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo. riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**. Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
Al biliardo del Circolo 4 Maggio, clic o tocco fissa la mira; regola la forza e premi **Tira**.
Le frecce ← → affinano la direzione, con Maiusc per passi ancora più piccoli. La guida usa la stessa fisica
del tiro: mostra sponde, primo contatto e direzioni iniziali dopo l'urto (azzurro per l'avversaria, giallo
per la propria biglia). Tenendo premuto sul tavolo o Spazio si carica il colpo, con la mira bloccata.
## In Docker ## In Docker
```zsh ```zsh
@@ -71,5 +76,6 @@ npm test # logica, storia coerente, soluzione percorsa dall'inizio alla
npm install && npx playwright install chromium-headless-shell npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
``` ```
Binary file not shown.
+38
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+70
View File
@@ -0,0 +1,70 @@
{
"image": "ing-tozzi.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 53,
"h": 120,
"ax": 27.9,
"ay": 120
},
"blink": {
"x": 55,
"y": 0,
"w": 54,
"h": 120,
"ax": 27.8,
"ay": 120
},
"talk": {
"x": 111,
"y": 0,
"w": 77,
"h": 119,
"ax": 29.9,
"ay": 119
},
"explain": {
"x": 190,
"y": 0,
"w": 58,
"h": 119,
"ax": 30.8,
"ay": 119
},
"laugh": {
"x": 250,
"y": 0,
"w": 53,
"h": 119,
"ax": 28.2,
"ay": 119
},
"chalk": {
"x": 305,
"y": 0,
"w": 52,
"h": 119,
"ax": 24.1,
"ay": 119
},
"shrug": {
"x": 359,
"y": 0,
"w": 87,
"h": 120,
"ax": 43.9,
"ay": 120
},
"cheer": {
"x": 448,
"y": 0,
"w": 56,
"h": 123,
"ax": 30.9,
"ay": 123
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+162 -14
View File
@@ -47,6 +47,26 @@
"file": "andrea-dacb32e5c6.mp3", "file": "andrea-dacb32e5c6.mp3",
"sec": 4.01 "sec": 4.01
}, },
"andrea|La Torre dello Scoronconcolo. Era una vecchia torre di guardia, già in disuso nel Cinquecento.": {
"file": "andrea-536c525f5c.mp3",
"sec": 6.64
},
"andrea|Ci si rifugiò Michele del Tavolaccino, detto Scoronconcolo, sanpierino: nel 1537 aiutò Lorenzino de' Medici ad ammazzare il duca Alessandro.": {
"file": "andrea-37eaf3e803.mp3",
"sec": 11.82
},
"andrea|Nel 1539 finì sulle galere dell'ordine gerosolimitano, in guerra contro i Turchi. Altro che Palio.": {
"file": "andrea-5f4c4fca29.mp3",
"sec": 8.04
},
"andrea|Da quassù non ci arrivo. E poi, di nascondigli in quella torre, ce n'è già stato uno di troppo.": {
"file": "andrea-f60ba3ee31.mp3",
"sec": 7.38
},
"andrea|Una torre in tasca? Nemmeno lo Scoronconcolo ci è riuscito.": {
"file": "andrea-938fbd1326.mp3",
"sec": 4.59
},
"andrea|Il bar della piazza. Chi c'era dietro il bancone nel 2007? Questo lo sa solo Andrea.": { "andrea|Il bar della piazza. Chi c'era dietro il bancone nel 2007? Questo lo sa solo Andrea.": {
"file": "andrea-4b733def4f.mp3", "file": "andrea-4b733def4f.mp3",
"sec": 7.27 "sec": 7.27
@@ -467,6 +487,10 @@
"file": "andrea-f798fa755d.mp3", "file": "andrea-f798fa755d.mp3",
"sec": 4.9 "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.": { "andrea|Una gomma da masticare ancora incartata, persa davanti al palco. Qualcuno ha perso l'appetito.": {
"file": "andrea-f2ea95922d.mp3", "file": "andrea-f2ea95922d.mp3",
"sec": 6.54 "sec": 6.54
@@ -655,13 +679,9 @@
"file": "andrea-2d04be65fb.mp3", "file": "andrea-2d04be65fb.mp3",
"sec": 5.97 "sec": 5.97
}, },
"ing_tozzi|Una partita ai birilli? Si va a venti.": { "andrea|Il tavolo è dell'Ingegnere: prima gli chiedo una partita.": {
"file": "ing_tozzi-7aef4a5420.mp3", "file": "andrea-c43b158842.mp3",
"sec": 3.3 "sec": 3.27
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
}, },
"ing_tozzi|Si parla col panno, qui. Prendi la stecca.": { "ing_tozzi|Si parla col panno, qui. Prendi la stecca.": {
"file": "ing_tozzi-4814e97759.mp3", "file": "ing_tozzi-4814e97759.mp3",
@@ -671,6 +691,10 @@
"file": "andrea-98b3e4a969.mp3", "file": "andrea-98b3e4a969.mp3",
"sec": 3.49 "sec": 3.49
}, },
"andrea|L'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.": {
"file": "andrea-127a90af53.mp3",
"sec": 9.37
},
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": { "andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
"file": "andrea-1eae1a8577.mp3", "file": "andrea-1eae1a8577.mp3",
"sec": 6.1 "sec": 6.1
@@ -1499,6 +1523,70 @@
"file": "andrea-60cdae2e35.mp3", "file": "andrea-60cdae2e35.mp3",
"sec": 5.1 "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.": { "andrea|Una Fanta.": {
"file": "andrea-8927b87738.mp3", "file": "andrea-8927b87738.mp3",
"sec": 0.65 "sec": 0.65
@@ -1795,10 +1883,6 @@
"file": "andrea-83b1337b4d.mp3", "file": "andrea-83b1337b4d.mp3",
"sec": 3.31 "sec": 3.31
}, },
"andrea|La rivincita?": {
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
"andrea|Allora, Brento Sanico?": { "andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3", "file": "andrea-2add39ff10.mp3",
"sec": 1.29 "sec": 1.29
@@ -1815,14 +1899,62 @@
"file": "mariano-0eeb942ca2.mp3", "file": "mariano-0eeb942ca2.mp3",
"sec": 3.52 "sec": 3.52
}, },
"mariano|Il campione non scappa. Tira!": { "mariano|Come la vuoi?": {
"file": "mariano-bdf58ba1be.mp3", "file": "mariano-0288ec460b.mp3",
"sec": 3.26 "sec": 0.7
},
"mariano|Il campione non scappa. Come la vuoi?": {
"file": "mariano-1d8388d631.mp3",
"sec": 3.55
}, },
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": { "mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
"file": "mariano-1c9cd0c325.mp3", "file": "mariano-1c9cd0c325.mp3",
"sec": 5.96 "sec": 5.96
}, },
"mariano|Alla bona: tiro con gli occhi chiusi.": {
"file": "mariano-b1b1ca273b.mp3",
"sec": 2.2
},
"mariano|Seria. Così mi piace.": {
"file": "mariano-accff65835.mp3",
"sec": 2.27
},
"mariano|A fassi male? Poi non piangere, eh.": {
"file": "mariano-d65fb6e871.mp3",
"sec": 3.42
},
"andrea|Ingegnere, una partita ai birilli?": {
"file": "andrea-dbcf4d1083.mp3",
"sec": 2.49
},
"andrea|Arrivederci, Ingegnere.": {
"file": "andrea-8f63435d69.mp3",
"sec": 1.38
},
"ing_tozzi|Una partita ai birilli? Si va a venti. Come la vuoi?": {
"file": "ing_tozzi-82e7cc88aa.mp3",
"sec": 4.58
},
"ing_tozzi|Ci mancherebbe. Stavolta guardo bene. Come la vuoi?": {
"file": "ing_tozzi-344ac1021f.mp3",
"sec": 4.67
},
"ing_tozzi|Alla bona: tiro senza occhiali.": {
"file": "ing_tozzi-17e489814e.mp3",
"sec": 2.19
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
},
"ing_tozzi|Seria. Dammi il gesso.": {
"file": "ing_tozzi-4b3adfa04a.mp3",
"sec": 2.28
},
"ing_tozzi|A fassi male? Allora mi tolgo la giacca. Ah no, non ce l'ho.": {
"file": "ing_tozzi-c365e9f760.mp3",
"sec": 6.05
},
"andrea|Sono bloccato. Mi dai un consiglio?": { "andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3", "file": "andrea-42b02eb9ea.mp3",
"sec": 3.9 "sec": 3.9
@@ -1935,6 +2067,22 @@
"file": "castello-dc8a873d86.mp3", "file": "castello-dc8a873d86.mp3",
"sec": 4.36 "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...": { "mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3", "file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62 "sec": 1.62
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
# Primo piano del biliardo
Il tavolo in `assets/closeups/biliardo-tavolo.png` è generato con lo strumento immagini integrato
di Codex, senza API. Prompt completo in `prompts/closeups/biliardo-tavolo.integrated.txt`
(cartella esclusa da git). Specifica: vista ortogonale dall'alto, pixel art da avventura anni '90,
mogano scuro a fasce chiare e scure, diamanti d'ottone, panno verde, sala in penombra;
senza buche, biglie, birilli, persone, stecche o testi. Riferimento di stile: `circolo-4-maggio.png`.
L'Ingegnere usa il foglio esistente `ing-tozzi.json`, coerente con la fotografia di riferimento.
Il master misura 1672×941. `tablePatches()` in `biliardo-render.js` mappa il panno sorgente
(190, 214)–(1484, 722) su `(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k)`; i ritagli
esterni mantengono una cornice larga 16 pixel. La geometria della simulazione resta invariata.
Il caricamento passa da `story.closeups.biliardo.picture`, con la stessa cache delle bocce.
La luce calda è disegnata sul panno con una piccola tela memorizzata, ingrandita senza smoothing.
Biglie e birilli usano blocchi di pixel; le ombre delle biglie si allontanano dalla lampada centrale.
I segnapunti sono cifre rosse a sette segmenti. Le quattro pose di Tozzi rimangono nel margine sinistro.
`biliardo.js` aggiunge soltanto stato visivo: orologio, posizione iniziale della steccata,
breve cronologia delle posizioni e istante della caduta dei birilli. La presentazione del moto
è ritardata di 180 ms, per mostrare arretramento e scatto della stecca; la fisica parte subito.
La caduta dei birilli dura 240 ms sul medesimo orologio visivo. Previsione e CPU non scrivono
questa cronologia. Un confronto con la versione precedente ha verificato 6626 aggiornamenti
identici, escludendo i soli campi visivi.
Controlli: `tests/biliardo-render.test.mjs` verifica allineamento, presentazione, cadute,
pose e immutabilità del disegno. `npm run browser:biliardo` conserva i controlli dei comandi
su desktop, touch e telefono verticale e aggiunge controlli dei pixel e catture delle animazioni
in `/tmp/regolo-biliardo`.
Verifica di questa modifica: test JavaScript e Python superati; anteprima locale del renderer
ispezionata. Restano da completare `make -s verify` e `npm run browser:biliardo` in un ambiente
che permetta server locale e Chromium: nella sandbox della sessione entrambi sono bloccati
da `Operation not permitted`. Le vecchie schermate browser non validano questa modifica.
+129
View File
@@ -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.
+7
View File
@@ -18,6 +18,13 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati ## 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 - **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. 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 Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
+26 -3
View File
@@ -103,6 +103,24 @@
<div id="inventory" aria-label="Inventario"></div> <div id="inventory" aria-label="Inventario"></div>
</div> </div>
<ol id="choices" hidden></ol> <ol id="choices" hidden></ol>
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
<p id="briscola-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea">
<button id="briscola-card-0" type="button"></button>
<button id="briscola-card-1" type="button"></button>
<button id="briscola-card-2" type="button"></button>
</div>
<details><summary>Come si gioca</summary>
<p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca.
Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p>
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
di uscita. Ordine: Asso, 3, Re, Cavallo, Fante, 7, 6, 5, 4, 2.
Asso 11 punti, 3 dieci, Re 4, Cavallo 3, Fante 2; le altre zero.</p>
<p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo;
il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese.
Durante la partita i punti non si dicono!</p>
</details>
</section>
<section id="bocce-controls" hidden aria-label="Comandi delle bocce"> <section id="bocce-controls" hidden aria-label="Comandi delle bocce">
<p id="bocce-status" role="status"></p> <p id="bocce-status" role="status"></p>
<div class="bocce-adjust"> <div class="bocce-adjust">
@@ -122,9 +140,14 @@
Vince chi fa più punti nella mano.</p> Vince chi fa più punti nella mano.</p>
</details> </details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary> <details id="biliardo-help" hidden><summary>Come si gioca</summary>
<p>Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita. <p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare. La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.</p> il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
la tua. Le frecce mostrano solo il primo tratto, fino al prossimo ostacolo o al massimo 50 cm.
Senza urti, il cerchietto indica dove ti fermi. La guida arancione segnala un primo contatto falloso.</p>
<p>Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare:
durante la carica la mira resta ferma. Frecce ← →: mira fine; Maiusc + ← →: regolazione ancora più precisa;
↑ ↓: forza. Esc esce dalla partita. I colpi sono centrali, senza effetti di rotazione.</p>
<p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti, <p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti,
rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima
il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p> il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p>
+3 -1
View File
@@ -1,11 +1,13 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.8", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.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", "browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000" "serve": "python3 -m http.server 8000"
}, },
+191 -83
View File
@@ -1,115 +1,223 @@
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js'; import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] }; const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k; const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono. // Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
const rr = R * VIEW.k * 1.3; const rr = R * VIEW.k;
function sphere(ctx, role, x, y, r) { // Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
export const SHOT_DELAY = WINDUP;
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
export function tablePatches() {
const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
]));
}
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
function sphere(ctx, role, x, y, r, shadow = true) {
const [dark, mid, light] = BALL[role]; const [dark, mid, light] = BALL[role];
ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill(); const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05); const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark); if (shadow) {
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); const dx = (x - sx(LEN / 2)) / (LEN * VIEW.k / 2) * 3;
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1); const dy = (y - sy(WID / 2)) / (WID * VIEW.k / 2) * 3 + 1;
} ctx.fillStyle = '#103b2bcc';
function pin(ctx, p) { ctx.fillRect(Math.round(x - r + dx), Math.round(y - r / 2 + dy), Math.round(r * 2), Math.round(r));
const x = sx(p.x), y = sy(p.y), h = 7, w = 3;
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4'];
if (p.down) {
// Birillo a terra, dalla parte in cui è caduto.
ctx.strokeStyle = top; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke();
ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2);
return;
} }
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2); // Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico.
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1); const left = Math.floor(x - r), top = Math.floor(y - r);
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3); for (let py = top; py < y + r; py++) for (let px = left; px < x + r; px++) {
const dx = px + 0.5 - x, dy = py + 0.5 - y, d = Math.hypot(dx, dy);
if (d > r) continue;
ctx.fillStyle = d > r - 1 ? '#302a1e' : dx * lx + dy * ly < -r * 0.2 ? dark : mid;
ctx.fillRect(px, py, 1, 1);
}
ctx.fillStyle = light; ctx.fillRect(Math.round(x + lx * r * 0.4) - 1, Math.round(y + ly * r * 0.4) - 1, 2, 2);
} }
function table(ctx) { export function pinFall(p, time) {
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k; if (!p.down) return 0;
// Cornice di legno con i diamanti, sponda, panno. if (p.visualDownAt === undefined) return 1;
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32); return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26); }
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3); function pin(ctx, p, time) {
ctx.fillStyle = '#e7d7a8'; const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); } const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); } const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10); const ty = y + Math.sin(p.dir ?? 0) * 7 * tilt - 7 * lift;
ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h); const [base, top] = p.red ? ['#8d1f1a', '#ff7060'] : ['#b3a57f', '#fff8e4'];
for (let i = 0; i < 420; i++) { ctx.fillStyle = '#123c2bb0'; ctx.fillRect(Math.round(x - 1), Math.round(y + 1), 5 + Math.round(tilt * 3), 2);
ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f'; // Piccoli blocchi seguono la rotazione senza linee levigate.
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1); for (let i = 0; i <= 7; i++) {
ctx.fillStyle = i > 4 ? top : base;
ctx.fillRect(Math.round(x + (tx - x) * i / 7) - 1, Math.round(y + (ty - y) * i / 7) - 1, i > 4 ? 3 : 2, 2);
} }
// La linea di metà tavolo e il punto del castello. }
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h);
let lighting;
function table(ctx, bg) {
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
else {
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#51321c'; ctx.fillRect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#14674d'; ctx.fillRect(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
}
// La sala scompare in penombra, ma si riconoscono pavimento e boiserie.
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 640, 360);
ctx.rect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#100f10bb'; ctx.fill('evenodd'); ctx.restore();
if (!lighting) {
lighting = document.createElement('canvas'); lighting.width = 125; lighting.height = 63;
const light = lighting.getContext('2d');
for (let y = 0; y < 63; y++) for (let x = 0; x < 125; x++) {
const d = Math.hypot((x - 62) / 69, (y - 31) / 38);
const warm = Math.max(0, 1 - d) * 0.22;
const shade = Math.max(0, d - 0.35) * 0.28;
light.fillStyle = `rgba(255,215,128,${Math.round(warm * 32) / 32})`; light.fillRect(x, y, 1, 1);
light.fillStyle = `rgba(4,25,20,${Math.round(shade * 32) / 32})`; light.fillRect(x, y, 1, 1);
}
}
ctx.drawImage(lighting, VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
} }
function aimGuide(ctx, c) { function aimGuide(ctx, c) {
const p = predict(c), me = c.balls[c.current]; const p = predict(c);
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); const foul = p.hit === 'pin' || p.hit === 'pallino';
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,248,220,0.85)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1])); ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y)); for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
ctx.stroke(); ctx.setLineDash([]); ctx.stroke(); ctx.setLineDash([]);
if (p.ghost) { if (p.ghost) {
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
if (p.dir) { for (const track of p.outgoing) {
const o = c.balls[p.hit], L = 22; const [start, end] = track.points;
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)'; if (!end || Math.hypot(end[0] - start[0], end[1] - start[1]) < 1) continue;
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke(); const x = sx(end[0]), y = sy(end[1]), a = Math.atan2(end[1] - start[1], end[0] - start[0]);
ctx.strokeStyle = track.role === c.current ? '#f4df8d' : track.role === 'pallino' ? '#ffae8b' : '#96dfff';
ctx.setLineDash(track.role === c.current ? [2, 3] : []);
ctx.beginPath(); ctx.moveTo(sx(start[0]), sy(start[1])); ctx.lineTo(x, y); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(x - Math.cos(a - 0.5) * 5, y - Math.sin(a - 0.5) * 5);
ctx.lineTo(x, y); ctx.lineTo(x - Math.cos(a + 0.5) * 5, y - Math.sin(a + 0.5) * 5); ctx.stroke();
} }
} else {
// Il cerchietto finale è l'arresto effettivo con la forza scelta.
const [x, y] = p.points.at(-1);
ctx.strokeStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(sx(x), sy(y), 2, 0, Math.PI * 2); ctx.stroke();
} }
// La stecca dietro la biglia, tirata indietro quanto la forza.
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back;
ctx.lineCap = 'round';
ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke();
ctx.lineCap = 'butt';
} }
export function drawBiliardo(ctx, c, def) { function cue(ctx, x, y, angle, back, alpha = 1) {
ctx.save(); ctx.save(); ctx.globalAlpha = alpha;
// La sala, in penombra, e il lume sopra il tavolo. ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420); ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2);
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)'); ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3);
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1);
table(ctx); ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3);
for (const p of c.pins) pin(ctx, p); ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3);
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c); ctx.restore();
for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); } }
// Punteggio: i due nomi, i punti, chi deve tirare.
panel(ctx, 18, 8, 604, 62); export function tozziPose(c) {
const last = c.last;
if (last && last.age < 2.4) {
if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug';
if (last.to === 'cpu' && last.points > 0) return 'cheer';
}
return c.step === 'aim' && c.current === 'cpu' ? 'chalk' : 'stand';
}
export const TOZZI = { x: 26, y: 258, scale: 0.46 };
function tozzi(ctx, c, sheet) {
if (!sheet) return;
const pose = tozziPose(c), frame = sheet.atlas.frames[pose];
const { x, y, scale } = TOZZI;
const bob = pose === 'chalk' ? Math.floor(Math.sin(c.visual.time * 9) * 0.8) : 0;
ctx.fillStyle = '#080a09aa'; ctx.fillRect(10, y - 1, 33, 4);
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale) + bob,
Math.round(frame.w * scale), Math.round(frame.h * scale));
}
const SEGMENTS = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f];
function digits(ctx, value, x, y) {
const segments = [[2, 0, 7, 2], [9, 2, 2, 6], [9, 10, 2, 6], [2, 16, 7, 2],
[0, 10, 2, 6], [0, 2, 2, 6], [2, 8, 7, 2]];
for (const [i, digit] of String(value).padStart(2, '0').split('').entries()) {
segments.forEach(([dx, dy, w, h], bit) => {
ctx.fillStyle = SEGMENTS[Number(digit)] & (1 << bit) ? '#ff5a3d' : '#381b17';
ctx.fillRect(x + i * 15 + dx, y + dy, w, h);
});
}
}
function scoreboard(ctx, c, def) {
for (const [i, who] of ['player', 'cpu'].entries()) { for (const [i, who] of ['player', 'cpu'].entries()) {
const x = 30 + i * 300, y = 24; const x = 70 + i * 352, active = c.step === 'aim' && c.current === who;
sphere(ctx, who, x + 4, y, 4); ctx.fillStyle = '#0e0e0c'; ctx.fillRect(x - 2, 7, 152, 43);
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]); ctx.fillStyle = '#705039'; ctx.fillRect(x, 7, 148, 1);
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2'); ctx.fillStyle = '#36281d'; ctx.fillRect(x, 49, 148, 2);
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3'); ctx.fillStyle = '#b09359'; ctx.fillRect(x + 3, 10, 1, 1); ctx.fillRect(x + 144, 46, 1, 1);
text(ctx, def.names[who], x + 10, 20, 8, '#ddcfad');
sphere(ctx, who, x + 13, 36, 3, false);
text(ctx, active ? 'AL TIRO' : 'PUNTI', x + 24, 36, 8, active ? '#eac27d' : '#8e8064');
digits(ctx, c.points[who], x + 105, 22);
} }
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : ''; text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center');
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
if (canThrow(c)) { if (canThrow(c)) {
panel(ctx, 18, 318, 176, 34); text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 29, 8, '#eee0ba', 'center');
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7); ctx.fillStyle = '#151c16'; ctx.fillRect(245, 39, 150, 5);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5); for (let i = 0; i < 30; i++) {
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5); ctx.fillStyle = i / 30 < c.power ? i > 23 ? '#c19554' : '#9fbc82' : '#303b2d';
ctx.fillRect(246 + i * 5, 40, 3, 3);
} }
} else text(ctx, `A ${c.target} PUNTI`, 320, 33, 8, '#b0a080', 'center');
}
export function drawBiliardo(ctx, c, def, bg, sheet) {
ctx.save(); ctx.imageSmoothingEnabled = false;
table(ctx, bg);
tozzi(ctx, c, sheet);
const view = visualFrame(c);
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
if (c.step === 'aim' && (c.current === 'player' || c.plan)) {
aimGuide(ctx, c);
const me = c.balls[c.current];
cue(ctx, me.x, me.y, c.angle, rr + 6 + c.power * 26);
} else if (c.visual.shot) {
const shot = c.visual.shot, age = c.visual.time - shot.at;
if (age < SHOT_DELAY + 0.12) {
const pull = 6 + shot.power * 26;
const back = age < 0.12 ? pull + age / 0.12 * 10 : Math.max(0, (pull + 10) * (1 - (age - 0.12) / 0.06));
cue(ctx, shot.x, shot.y, shot.angle, rr + back, age < SHOT_DELAY ? 1 : 1 - (age - SHOT_DELAY) / 0.12);
}
}
for (const p of view.pins) pin(ctx, p, view.time);
for (const role of ['pallino', 'cpu', 'player']) { const b = view.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
ctx.restore();
scoreboard(ctx, c, def);
const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
// Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6.
const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names);
text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
if (c.step === 'result') { if (c.step === 'result') {
panel(ctx, 170, 140, 300, 80); panel(ctx, 170, 140, 300, 80);
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center'); text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center');
text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center'); text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center'); text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 8, '#d7ccb4', 'center');
} }
ctx.restore(); ctx.restore();
} }
+159 -50
View File
@@ -7,10 +7,14 @@
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20; export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78; export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
export const SWING = 1.4, FIXED_STEP = 1 / 240; export const SWING = 1.4, FIXED_STEP = 1 / 240;
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
export const WINDUP = 0.18;
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5; const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
export const OTHER = { player: 'cpu', cpu: 'player' }; export const OTHER = { player: 'cpu', cpu: 'player' };
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro. // Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
export const VIEW = { x: 57, y: 78, k: 1.85 }; export const VIEW = { x: 70, y: 88, k: 1.76 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
@@ -35,11 +39,21 @@ export function reach(v) {
export const canThrow = c => c.step === 'aim' && c.current === 'player'; export const canThrow = c => c.step === 'aim' && c.current === 'player';
export const moving = c => list(c).some(b => b.vx || b.vy); export const moving = c => list(c).some(b => b.vx || b.vy);
// Quanto è bravo l'Ingegnere: quanti colpi prova a vuoto (direzioni intorno al tavolo, ritocchi vicino alla biglia
// avversaria, forze) e quanto gli trema la mano. «A fassi male» è il campione vero: vince quasi sempre.
export const LEVELS = {
bona: { angles: 0, fine: 0.15, powers: [0.6], wobble: 0.16, powerNoise: 0.2 },
seria: { angles: 14, fine: 0.09, powers: [0.5, 0.8], wobble: 0.072, powerNoise: 0.1 },
male: { angles: 64, fine: 0.025, powers: [0.3, 0.5, 0.7, 0.9], wobble: 0.03, powerNoise: 0.05 },
};
export function newBiliardo(seed = Date.now() >>> 0, def = {}) { export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0, const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null }; holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) }; c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
c.pins = castle(); c.pins = castle();
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
c.visual = { time: 0, shot: null };
c.angle = angleTo(c.balls.player, c.balls.cpu); c.angle = angleTo(c.balls.player, c.balls.cpu);
return c; return c;
} }
@@ -72,12 +86,12 @@ function cushions(b) {
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte. // Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
export function collide(a, b) { export function collide(a, b) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 2 * R) return 0; if (d > 2 * R + CONTACT_EPS) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2; const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (2 * R - d) / 2);
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap; a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny; const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (closing <= 0) return 0; if (closing <= CONTACT_EPS) return 0;
const j = closing * 0.975; const j = closing * (1 + BALL_RESTITUTION) / 2;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny; a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
return closing; return closing;
} }
@@ -87,38 +101,81 @@ function touch(shot, a, b) {
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter); if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true; if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
} }
function step(w, dt) { // Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
let hit = 0, rail = 0; // Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
for (const b of w.balls) { function contactTime(dx, dy, vx, vy, radius) {
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
const gap = dx * dx + dy * dy - radius * radius;
if (gap <= CONTACT_EPS) return 0;
const discriminant = closing * closing - speed2 * gap;
if (discriminant <= 0) return Infinity;
return gap / (-closing + Math.sqrt(discriminant));
}
function slow(b, dt) {
const v = Math.hypot(b.vx, b.vy); const v = Math.hypot(b.vx, b.vy);
if (v < EPS) { b.vx = b.vy = 0; continue; } if (!v) return;
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt; const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
b.x += b.vx / v * travel; b.y += b.vy / v * travel; const scale = nv < EPS ? 0 : nv / v;
b.vx *= nv / v; b.vy *= nv / v; b.vx *= scale; b.vy *= scale;
rail = Math.max(rail, cushions(b)); }
for (const p of w.pins) { function advance(balls, dt) {
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue; for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio. }
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) }); function step(w, dt) {
b.vx *= 0.88; b.vy *= 0.88; // Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
w.shot?.pins.push(p); // Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
for (const b of w.balls) slow(b, dt / 2);
let left = dt, hit = 0, rail = 0;
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
for (let events = 0; left > 1e-10 && events < 64; events++) {
let time = left, event = null;
const consider = (t, kind, a, b, axis, edge) => {
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
};
for (let i = 0; i < w.balls.length; i++) {
const a = w.balls[i];
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
for (let j = i + 1; j < w.balls.length; j++) {
const b = w.balls[j];
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
}
if (!a.vx && !a.vy) continue;
for (const p of w.pins) if (!p.down)
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
}
advance(w.balls, time); left -= time;
if (!event) break;
const { kind, a, b, axis, edge } = event;
if (kind === 'rail') {
const v = axis === 'x' ? 'vx' : 'vy';
a[axis] = edge;
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
} else if (kind === 'ball') {
const impact = collide(a, b);
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
} else {
const v = Math.hypot(a.vx, a.vy);
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
a.vx *= 0.88; a.vy *= 0.88;
w.shot?.pins.push(b);
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) }); if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
} }
w.onContact?.(event);
} }
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) { for (const b of w.balls) slow(b, dt / 2);
const s = collide(w.balls[i], w.balls[j]);
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
}
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12)) if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) }); w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
} }
function strike(c, who, angle, power) { function strike(c, who, angle, power) {
const b = c.balls[who], v = speedFor(power); const b = c.balls[who], v = speedFor(power);
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v; c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
c.windup = WINDUP;
c.shot = { shooter: who, first: null, pallino: false, pins: [] }; c.shot = { shooter: who, first: null, pallino: false, pins: [] };
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null; c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
c.events.push({ kind: 'biliardo-cue', power });
return true; return true;
} }
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere. // Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
@@ -133,6 +190,7 @@ export function simulate(c, who, angle, power) {
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta. // I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
function reset(c) { function reset(c) {
for (const p of c.pins) delete p.visualDownAt;
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; } for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
for (let pass = 0; pass < 4; pass++) { for (let pass = 0; pass < 4; pass++) {
for (const b of list(c)) for (const p of c.pins) { for (const b of list(c)) for (const p of c.pins) {
@@ -162,10 +220,10 @@ function finish(c) {
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco. // L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo. // Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
function cpuCandidates(c) { function cpuCandidates(c) {
const base = angleTo(c.balls.cpu, c.balls.player), angles = []; const k = c.skill ?? LEVELS.male, base = angleTo(c.balls.cpu, c.balls.player), angles = [];
for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2); for (let i = 0; i < k.angles; i++) angles.push(i / k.angles * Math.PI * 2);
for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d); for (let d = -0.3; d <= 0.3; d += k.fine) angles.push(base + d);
return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power }))); return angles.flatMap(angle => k.powers.map(power => ({ angle, power })));
} }
function cpuThink(c, budget = 12) { function cpuThink(c, budget = 12) {
const t = c.thinking ??= { todo: cpuCandidates(c), best: null }; const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
@@ -174,16 +232,28 @@ function cpuThink(c, budget = 12) {
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain }; if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
} }
if (t.todo.length) return; if (t.todo.length) return;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) }; const k = c.skill ?? LEVELS.male;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? k.wobble), power: clamp(t.best.power + deviation(c) * k.powerNoise, 0.05, 1) };
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null; c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
} }
export function update(c, dt) { export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return; if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; dt = Math.min(dt, 0.25); c.t += dt;
c.visual.time += dt;
if (c.last) c.last.age += dt; if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling') { if (c.step === 'rolling' && c.windup > 0) {
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
c.windup -= dt;
if (c.windup <= 0) {
const { ball, vx, vy, power } = c.pending;
ball.vx = vx; ball.vy = vy; c.pending = null;
c.events.push({ kind: 'biliardo-cue', power });
dt = -c.windup; c.windup = 0;
} else dt = 0;
}
if (c.step === 'rolling' && !c.windup) {
c.accumulator += dt; c.accumulator += dt;
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events }; const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; } while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
@@ -193,17 +263,26 @@ export function update(c, dt) {
if (!c.plan && c.t >= CPU_THINK) cpuThink(c); if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power); if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
} }
if (c.step === 'rolling' || c.step === 'settle')
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
} }
// --- Comandi del giocatore: gli stessi delle bocce. // --- Comandi del giocatore: gli stessi delle bocce.
export function aimAt(c, x, y) { export function aimAt(c, x, y) {
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false; if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const b = c.balls.player; const b = c.balls.player;
if (Math.hypot(x - b.x, y - b.y) < R) return false; if (Math.hypot(x - b.x, y - b.y) < R) return false;
c.angle = Math.atan2(y - b.y, x - b.x); return true; c.angle = Math.atan2(y - b.y, x - b.x); return true;
} }
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); } export function aimScreen(c, [sx, sy]) {
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; } const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
return aimAt(c, x, y);
}
export function nudge(c, dir, fine = false) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.0005 : 0.002); return true;
}
export function setPower(c, value) { export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true; c.power = clamp(value, 0, 1); return true;
@@ -223,24 +302,54 @@ export function release(c) {
c.holding = false; return strike(c, 'player', c.angle, c.power); c.holding = false; return strike(c, 'player', c.angle, c.power);
} }
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza), // La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita. // Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
const predictions = new WeakMap();
export function predict(c) { export function predict(c) {
const me = c.balls[c.current], others = list(c).filter(b => b !== me); const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power)); ...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
const points = [[x, y]], STEP = 0.5; const cached = predictions.get(c);
for (let n = 0; left > 0 && n < 6000; n++) { if (cached?.key === key) return cached.result;
x += dx * STEP; y += dy * STEP; left -= STEP; const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); } const v = speedFor(c.power);
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); } me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
for (const o of others) { const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const d = Math.hypot(o.x - x, o.y - y); const tracks = [];
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; } const point = b => [b.x, b.y];
const endTrack = track => {
track.points.push(point(track.ball)); track.done = true;
};
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
const { kind, a, b } = event;
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
if (result.hit || (a !== me && b !== me)) return;
result.points.push(point(me));
if (kind === 'rail') return;
result.ghost = { x: me.x, y: me.y };
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
if (kind === 'pin') return;
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
for (const ball of [me, other]) {
const points = [point(ball)];
tracks.push({ ball, points, done: false });
result.outgoing.push({ role: ball.role, points });
} }
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; } } };
for (let i = 0; i < 15 / FIXED_STEP; i++) {
step(w, FIXED_STEP);
for (const track of tracks) {
if (track.done) continue;
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
} }
points.push([x, y]); if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
return { points, ghost: null, hit: null, dir: null }; }
if (!result.hit) result.points.push(point(me));
predictions.set(c, { key, result });
return result;
} }
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' }; const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
+14 -5
View File
@@ -19,8 +19,16 @@ export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS); export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0]; export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
export function newBocce(seed = Date.now() >>> 0) { // Quanto è bravo Mariano: quanto si allarga il suo tiro e quanto spesso prova a bocciare la boccia migliore.
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5, // «A fassi male» è il campione vero del pallaio.
export const LEVELS = {
bona: { spread: 3, attack: 0.1 },
seria: { spread: 1.15, attack: 0.4 },
male: { spread: 0.22, attack: 0.85 },
};
export function newBocce(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], balls: [], turn: 0, current: 'player', power: 0.5,
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null }; holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 }; c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
c.aim = { u: c.jack.u, w: c.jack.w }; c.aim = { u: c.jack.u, w: c.jack.w };
@@ -134,10 +142,11 @@ function nextTurn(c) {
function cpuThrow(c) { function cpuThrow(c) {
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può // Mariano cerca un accosto libero; con un avversario attaccato al pallino può
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore. // tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45; const k = c.skill ?? LEVELS.male;
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < k.attack;
const target = attack ? other : c.jack; const target = attack ? other : c.jack;
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2); const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2) * k.spread, 10, LANE - 2);
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL); const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8) * k.spread, -HALF + BALL, HALF - BALL);
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35)); launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
} }
export function update(c, dt) { export function update(c, dt) {
+295
View File
@@ -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();
}
+199
View File
@@ -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…`;
}
+14 -8
View File
@@ -3,11 +3,13 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js'; import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js'; import * as bocce from './bocce.js';
import * as biliardo from './biliardo.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 = { export const MINIGAMES = {
bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen }, briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen }, 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 },
}; };
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
@@ -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). // 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; } 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); } closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); } closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
closeupCancel() { const m = this.minigame(); return !!m && m.cancel(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: // 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. // uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) { closeupAim([x, y]) {
const c = this.closeup; const c = this.closeup;
const m = this.minigame(); 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; 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))]; c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true; return true;
@@ -439,7 +442,10 @@ export class Game {
else if (e.closeup) { else if (e.closeup) {
const type = this.story.closeups[e.closeup].type; 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] } this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) } // `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `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 }; : { id: e.closeup, step: 'cover', t: 0, age: 0 };
} }
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
+7 -1
View File
@@ -5,6 +5,7 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js'; import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js'; import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js'; import { drawBiliardo } from './biliardo-render.js';
import { drawBriscola } from './briscola-render.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; 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) { function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id]; const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return; if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'torch') { if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture)); drawTorch(ctx, c, def, backgrounds.get(def.picture));
@@ -676,7 +678,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; return;
} }
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; } if (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
return;
}
ctx.save(); ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.fillRect(0, 0, WIDTH, HEIGHT);
+11
View File
@@ -101,6 +101,12 @@ export class Soundscape {
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) { play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
if (!this.audible) return; if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true }); 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-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-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 }); else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
@@ -109,6 +115,11 @@ export class Soundscape {
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan }); else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); } else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan }); else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
this.tone(1700, 900, 0.04, 0.025, 0, pan);
this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan);
}
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan); else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') { else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan); for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
+79 -14
View File
@@ -10,6 +10,8 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js'; import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js'; import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.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'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -399,8 +401,18 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return; if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { 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;
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
}
press = { closeup: true }; press = { closeup: true };
game.closeupAim(scenePoint(e));
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(); if (!game.skip()) game.closeupPress();
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
@@ -411,6 +423,11 @@ canvas.addEventListener('pointerdown', e => {
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (!press) return; 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; } if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer); clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
@@ -423,7 +440,7 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); 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 => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -431,23 +448,32 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); // Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
canvas.addEventListener('pointerleave', () => { mouse = null; }); canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi dei minigiochi (bocce e biliardo) separati dai verbi dell'avventura: mira a passi, forza regolabile e // Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
// lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata.
const MINI = { 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.' }, 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: tocca il tavolo per mirare. Regola la forza e premi Tira.' }, 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 miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type]; 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-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-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]]) for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]])
$(id).addEventListener('click', () => { const c = miniState(); if (c) mini().nudge(c, dir); }); $(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. // --- Verbi.
for (const verb of VERBS) { for (const verb of VERBS) {
@@ -534,12 +560,32 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; } 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. // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) { 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 (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
const c = game.closeup, m = mini(); const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault(); e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1); if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey)) c.aimLocked = true; }
else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01)); else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return; return;
} }
@@ -620,9 +666,25 @@ function updateUi() {
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id]; 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; $('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; $('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names); const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -632,8 +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-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'; $('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo'; $('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. // 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; $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running; $('pause-button').hidden = !running;
@@ -644,6 +707,8 @@ function updateUi() {
$('panel').inert = !!c; $('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg); 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"); 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. // La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null; const step = game.closeup?.step ?? null;
+3
View File
@@ -5,6 +5,8 @@ export const soundscapes = {
acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] }, acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] },
pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] }, pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] },
circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] }, circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] },
// Gli altri tavoli della sala: una stecca, una biglia che ne tocca un'altra, ogni tanto un bicchiere al bar.
biliardo: { bed: null, level: 0, day: ['tavolo-lontano', 'tavolo-lontano', 'glass'], night: ['tavolo-lontano', 'tavolo-lontano', 'glass'], gap: [4, 10] },
fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] }, fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] },
fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] }, fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] },
}; };
@@ -15,6 +17,7 @@ const groups = {
pieve: ['pieve', 'pieve_interno', 'oratorio'], pieve: ['pieve', 'pieve_interno', 'oratorio'],
circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'], circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'],
fortezza: ['fortezza'], fortezza: ['fortezza'],
biliardo: ['circolo_4_maggio'],
}; };
export function soundscapeFor(scene, closeup) { export function soundscapeFor(scene, closeup) {
+107 -13
View File
@@ -107,6 +107,14 @@ export const story = {
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura. // 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. // Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: { 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. // Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: { bocce: {
type: 'bocce', type: 'bocce',
@@ -127,6 +135,7 @@ export const story = {
// Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute] // Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute]
biliardo: { biliardo: {
type: 'biliardo', type: 'biliardo',
picture: 'assets/closeups/biliardo-tavolo.png',
names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' }, names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' },
win: [ win: [
{ say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' }, { say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' },
@@ -177,6 +186,8 @@ export const story = {
}, },
}, },
characters: { 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' }, andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' }, vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
@@ -186,7 +197,7 @@ export const story = {
tosca: { name: 'La Tosca', color: '#ffb0d0' }, tosca: { name: 'La Tosca', color: '#ffb0d0' },
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' }, arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt). // L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a' }, ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.json' },
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' }, tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' }, mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
@@ -347,6 +358,20 @@ export const story = {
use: [{ say: 'Arrampicarmi? Il palio lassù non c\'è comunque.' }], use: [{ say: 'Arrampicarmi? Il palio lassù non c\'è comunque.' }],
}, },
}, },
{
// La Torre dello Scoronconcolo (aneddoto trovato da Andrea su Wikipedia, «Michele del Tavolaccino»):
// il sanpierino che aiutò Lorenzino de' Medici ad ammazzare il duca Alessandro, nel 1537.
id: 'torre_scoronconcolo', name: 'Torre dello Scoronconcolo', box: [474, 58, 62, 70], at: [505, 290], face: 'up',
actions: {
look: [
{ say: 'La Torre dello Scoronconcolo. Era una vecchia torre di guardia, già in disuso nel Cinquecento.' },
{ say: 'Ci si rifugiò Michele del Tavolaccino, detto Scoronconcolo, sanpierino: nel 1537 aiutò Lorenzino de\' Medici ad ammazzare il duca Alessandro.' },
{ say: 'Nel 1539 finì sulle galere dell\'ordine gerosolimitano, in guerra contro i Turchi. Altro che Palio.' },
],
open: [{ say: 'Da quassù non ci arrivo. E poi, di nascondigli in quella torre, ce n\'è già stato uno di troppo.' }],
take: [{ say: 'Una torre in tasca? Nemmeno lo Scoronconcolo ci è riuscito.' }],
},
},
{ {
id: 'bar', name: 'La Colonna', box: [428, 208, 30, 40], at: [443, 252], face: 'up', id: 'bar', name: 'La Colonna', box: [428, 208, 30, 40], at: [443, 252], face: 'up',
actions: { actions: {
@@ -723,6 +748,8 @@ export const story = {
name: 'Oratorio della Pieve', name: 'Oratorio della Pieve',
map: [82, 45], map: [82, 45],
background: 'assets/scenes/oratorio.png', 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]], walk: [[0, 236], [300, 226], [480, 222], [500, 232], [640, 254], [640, 360], [0, 360]],
depth: [[222, 0.5], [360, 1.15]], depth: [[222, 0.5], [360, 1.15]],
entry: [320, 345], entry: [320, 345],
@@ -747,6 +774,15 @@ export const story = {
use: [{ say: 'Mi siedo e aspetto che arrivi la pastasciutta? Ci vorrebbe una settimana.' }], 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'], 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' }], art: [{ rect: [137, 233, 10, 4], fill: '#f27ab0' }, { rect: [138, 233, 3, 1], fill: '#ffd0e8' }],
@@ -757,8 +793,9 @@ export const story = {
}, },
{ {
// Dopo le tavolate: dove si sovrappongono, vince l'ultimo della lista. // Dopo le tavolate: dove si sovrappongono, vince l'ultimo della lista.
id: 'libro_dimenticato', name: 'libro dimenticato', box: [562, 198, 18, 12], at: [560, 268], face: 'up', when: ['!has:libro', '!libro_preso'], // Sulla prima tavolata, a sinistra del palo: lontano dal giocatore di carte (Andrea: «troppo vicini»).
art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }], id: 'libro_dimenticato', name: 'libro dimenticato', box: [512, 198, 22, 14], at: [522, 262], face: 'up', when: ['!has:libro', '!libro_preso'],
art: [{ rect: [517, 203, 11, 5], fill: '#c46a3a' }, { rect: [517, 207, 11, 1], fill: '#f4efe4' }, { rect: [521, 204, 3, 3], fill: '#e8c25a' }],
actions: { actions: {
look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }], look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }],
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Ricordo di aver visto il film da piccolo... era stupendo!' }, { closeup: 'libro' }], take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Ricordo di aver visto il film da piccolo... era stupendo!' }, { closeup: 'libro' }],
@@ -1056,26 +1093,32 @@ export const story = {
name: 'Circolo 4 Maggio, sala dei biliardi', name: 'Circolo 4 Maggio, sala dei biliardi',
map: [29, 48], map: [29, 48],
background: 'assets/scenes/circolo-4-maggio.png', background: 'assets/scenes/circolo-4-maggio.png',
music: 'assets/audio/biliardo.mp3', // trio jazz da circolo (tools/jazz_biliardo.py, 25 ottobre)
indoor: true, indoor: true,
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]], walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
depth: [[190, 0.95], [360, 1.95]], depth: [[190, 1.2], [360, 2.5]],
entry: [590, 202], entry: [590, 202],
speakers: { ing_tozzi: [420, 150] }, speakers: { ing_tozzi: [452, 150] },
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
people: [{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }], enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
hotspots: [ hotspots: [
{ {
id: 'biliardo', name: 'biliardo', box: [0, 120, 358, 230], at: [380, 300], face: 'left', default: 'use', id: 'biliardo', name: 'biliardo', box: [0, 120, 358, 230], at: [380, 300], face: 'left', default: 'use',
actions: { actions: {
look: [{ say: 'Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.' }, { say: 'L\'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.' }], look: [{ say: 'Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.' }, { say: 'L\'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.' }],
use: [ use: [{ say: 'Il tavolo è dell\'Ingegnere: prima gli chiedo una partita.' }],
{ say: 'Una partita ai birilli? Si va a venti.', who: 'ing_tozzi' },
{ say: 'Gnamo.' },
{ closeup: 'biliardo' },
],
talk: [{ say: 'Si parla col panno, qui. Prendi la stecca.', who: 'ing_tozzi' }], talk: [{ say: 'Si parla col panno, qui. Prendi la stecca.', who: 'ing_tozzi' }],
take: [{ say: 'Una biglia in tasca? Mi sbattono fuori dal circolo.' }], take: [{ say: 'Una biglia in tasca? Mi sbattono fuori dal circolo.' }],
}, },
}, },
{
id: 'ing_tozzi', name: 'l\'Ing. Tozzi', box: [432, 196, 40, 100], at: [410, 304], face: 'right', default: 'talk',
actions: {
look: [{ say: 'L\'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.' }],
talk: [{ dialogue: 'ing_tozzi' }],
},
},
{ {
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up', id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
actions: { actions: {
@@ -2097,6 +2140,27 @@ export const story = {
}, },
}, },
dialogues: { 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). // Il chiosco della Tosca: le bibite si bevono subito; ghiacciolo e cedrata si portano via (una volta per tipo).
tosca: { tosca: {
nodes: { nodes: {
@@ -2249,18 +2313,48 @@ export const story = {
start: { start: {
options: [ options: [
{ {
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'end', text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'livello',
then: [ then: [
{ say: 'Con me? Al pallaio sono il campione.', who: 'mariano', pose: 'explain' }, { say: 'Con me? Al pallaio sono il campione.', who: 'mariano', pose: 'explain' },
{ say: 'Facciamo così: se mi batti, ti porto a Brento Sanico.', who: 'mariano' }, { say: 'Facciamo così: se mi batti, ti porto a Brento Sanico.', who: 'mariano' },
{ closeup: 'bocce' }, { say: 'Come la vuoi?', who: 'mariano' },
], ],
}, },
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'end', then: [{ say: 'Il campione non scappa. Tira!', who: 'mariano', pose: 'throw' }, { closeup: 'bocce' }] }, { text: 'La rivincita?', when: 'bocce_vinte', next: 'livello', then: [{ say: 'Il campione non scappa. Come la vuoi?', who: 'mariano', pose: 'throw' }] },
{ text: 'Allora, Brento Sanico?', when: 'bocce_vinte', then: [{ say: 'Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.', who: 'mariano', pose: 'explain' }] }, { text: 'Allora, Brento Sanico?', when: 'bocce_vinte', then: [{ say: 'Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.', who: 'mariano', pose: 'explain' }] },
{ text: 'Ciao Mariano.', next: 'end' }, { text: 'Ciao Mariano.', next: 'end' },
], ],
}, },
// La difficoltà (Andrea, 25 ottobre): quanto si impegna Mariano.
livello: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: tiro con gli occhi chiusi.', who: 'mariano', pose: 'laugh' }, { closeup: 'bocce', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Così mi piace.', who: 'mariano' }, { closeup: 'bocce', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Poi non piangere, eh.', who: 'mariano', pose: 'cheer' }, { closeup: 'bocce', level: 'male' }] },
],
},
},
},
ing_tozzi: {
nodes: {
start: {
options: [
{
text: 'Ingegnere, una partita ai birilli?', when: '!biliardo_vinto', next: 'livello',
then: [{ say: 'Una partita ai birilli? Si va a venti. Come la vuoi?', who: 'ing_tozzi', pose: 'chalk' }],
},
{ text: 'La rivincita?', when: 'biliardo_vinto', next: 'livello', then: [{ say: 'Ci mancherebbe. Stavolta guardo bene. Come la vuoi?', who: 'ing_tozzi', pose: 'explain' }] },
{ text: 'Arrivederci, Ingegnere.', next: 'end' },
],
},
// La difficoltà: quanti colpi prova a vuoto l'Ingegnere, e quanto gli trema la mano.
livello: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: tiro senza occhiali.', who: 'ing_tozzi', pose: 'laugh' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Dammi il gesso.', who: 'ing_tozzi', pose: 'chalk' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Allora mi tolgo la giacca. Ah no, non ce l\'ho.', who: 'ing_tozzi', pose: 'explain' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'male' }] },
],
},
}, },
}, },
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca. // Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.8'; export const VERSION = '0.1.12';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+23 -4
View File
@@ -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 { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; } #scene.torch { touch-action: none; }
#scene.bocce { 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-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-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; } .bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
@@ -34,16 +42,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; } .bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } #bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary { cursor: pointer; padding: 6px 0; } #bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p { max-width: 76em; line-height: 1.7; } #bocce-help p, #biliardo-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #bocce-throw { flex-basis: 100%; }
#bocce-help { font-size: 9px; } #bocce-help, #biliardo-help { font-size: 9px; }
} }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); } #effects-volume { width: 130px; accent-color: var(--accent); }
@@ -56,6 +64,13 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* Il primo piano del libro si chiude da qui (o con Esc). */ /* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font); #closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); } padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
#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). */ /* 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; #sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
@@ -265,6 +280,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: landscape) and (max-height: 520px) { @media (orientation: landscape) and (max-height: 520px) {
#app { width: 100vw; flex-direction: row; } #app { width: 100vw; flex-direction: row; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; } #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); } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; } #sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */ /* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
+113
View File
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { newBiliardo, update, shoot, predict, simulate, judge, LEN, WID, VIEW, FIXED_STEP } from '../src/engine/biliardo.js';
import { drawBiliardo, visualFrame, pinFall, tozziPose, tablePatches, TOZZI, SHOT_DELAY } from '../src/engine/biliardo-render.js';
import { story } from '../src/story/story.js';
test('il panno generato coincide con VIEW, senza buchi fra i ritagli', () => {
const png = readFileSync(new URL('../' + story.closeups.biliardo.picture, import.meta.url));
const patches = tablePatches();
assert.equal(png.readUInt32BE(16), 1672);
assert.equal(png.readUInt32BE(20), 941);
for (const [i, expected] of [VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k].entries())
assert.ok(Math.abs(patches[12][i + 4] - expected) < 1e-10);
let area = 0;
for (const [i, p] of patches.entries()) {
const [x, y, w, h, dx, dy, dw, dh] = p;
assert.ok(w > 0 && h > 0 && dw > 0 && dh > 0);
assert.ok(x >= 0 && y >= 0 && x + w <= 1672 && y + h <= 941);
assert.ok(dx >= 0 && dy >= 0 && dx + dw <= 640 && dy + dh <= 360);
if (i % 5 < 4) assert.equal(dx + dw, patches[i + 1][4]);
if (i < 20) assert.equal(dy + dh, patches[i + 5][5]);
area += dw * dh;
}
assert.ok(Math.abs(area - 640 * 360) < 1e-8);
});
test('la steccata: la fisica parte quando la stecca tocca la biglia, e il disegno mostra sempre le posizioni vere', () => {
const c = newBiliardo(42), start = { ...c.balls.player };
const expected = simulate(c, 'player', c.angle, c.power);
shoot(c);
update(c, SHOT_DELAY / 2);
assert.equal(c.balls.player.x, start.x, 'la stecca non è ancora arrivata');
assert.ok(!c.events.some(e => e.kind === 'biliardo-cue'), 'niente suono del colpo prima del contatto');
update(c, SHOT_DELAY);
assert.ok(c.events.some(e => e.kind === 'biliardo-cue'), 'il suono del colpo arriva col contatto');
assert.ok(c.balls.player.x !== start.x || c.balls.player.y !== start.y, 'la biglia è partita');
const before = structuredClone(c);
assert.equal(visualFrame(c).balls, c.balls, 'il disegno usa le posizioni vere, senza ritardo (Codex: urti e suoni sfasati)');
assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato');
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
assert.deepEqual(judge(c.shot), expected);
});
test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => {
const c = newBiliardo(9);
Object.assign(c.balls.player, { x: LEN / 2 - 60, y: WID / 2 });
Object.assign(c.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 });
Object.assign(c.balls.pallino, { x: 20, y: 20 });
c.angle = 0; c.power = 0.5;
const expected = simulate(c, 'player', c.angle, c.power), before = structuredClone(c);
predict(c);
assert.deepEqual(c, before, 'la previsione non fa partire animazioni');
shoot(c);
for (let i = 0; i < 1000 && !c.pins.some(p => p.down); i++) update(c, FIXED_STEP);
const p = c.pins.find(p => p.down);
assert.ok(p && Number.isFinite(p.visualDownAt));
assert.equal(pinFall(p, p.visualDownAt), 0);
assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1);
assert.equal(pinFall(p, p.visualDownAt + 0.3), 1);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
assert.deepEqual(judge(c.shot), expected);
while (c.step === 'settle') update(c, FIXED_STEP);
assert.ok(c.pins.every(p => !p.down && p.visualDownAt === undefined));
});
test('Tozzi pensa, esulta e sbaglia; tutte le pose restano fuori da tavolo e comandi', () => {
const c = newBiliardo(3);
assert.equal(tozziPose(c), 'stand');
c.current = 'cpu'; assert.equal(tozziPose(c), 'chalk');
for (const [last, pose] of [
[{ shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }, 'cheer'],
[{ shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }, 'shrug'],
[{ shooter: 'cpu', to: 'cpu', points: 0, foul: null, age: 0 }, 'shrug'],
[{ shooter: 'player', to: 'cpu', points: 2, foul: 'own', age: 0 }, 'cheer'],
]) { c.last = last; assert.equal(tozziPose(c), pose); }
c.last.age = 3; assert.equal(tozziPose(c), 'chalk');
const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url)));
for (const pose of ['stand', 'chalk', 'cheer', 'shrug']) {
const f = atlas.frames[pose], left = Math.round(TOZZI.x - f.ax * TOZZI.scale);
assert.ok(left >= 0 && left + Math.round(f.w * TOZZI.scale) < VIEW.x - 16, pose);
assert.ok(TOZZI.y - f.ay * TOZZI.scale > 72 && TOZZI.y < 360);
}
});
test('il disegno usa immagine e atlas, funziona durante il caricamento e non modifica la partita', () => {
const calls = [], ctx = new Proxy({}, { get: (o, key) => key in o ? o[key] : (...args) => calls.push([key, ...args]) });
const previous = globalThis.document;
globalThis.document = { createElement: () => ({ getContext: () => ctx }) };
const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url)));
const bg = {}, sheet = { atlas, img: {} }, c = newBiliardo(42);
try {
const render = (background, sprite) => {
const before = structuredClone(c);
drawBiliardo(ctx, c, story.closeups.biliardo, background, sprite);
assert.deepEqual(c, before);
};
render(); // Il file può non essere ancora arrivato dalla rete.
calls.length = 0;
render(bg, sheet);
assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === bg));
assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === sheet.img));
assert.ok(!calls.some(([op]) => op === 'createRadialGradient'), 'biglie senza gradienti lisci');
shoot(c); update(c, 0.1); render(bg, sheet);
update(c, 0.07); render(bg, sheet);
while (c.step === 'rolling') update(c, 1 / 60);
render(bg, sheet);
c.step = 'result'; c.result = { winner: 'cpu' }; render(bg, sheet);
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});
+191
View File
@@ -0,0 +1,191 @@
// Regressioni della mira con veri eventi browser, su desktop e touch.
// npm run browser:biliardo — schermate in /tmp/regolo-biliardo.
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
// Fotogrammi ripetibili del renderer, oltre alle schermate dell'interfaccia reale.
async function checkArtwork(page, name) {
const captures = await page.evaluate(async () => {
const { story } = await import('/src/story/story.js');
const { newBiliardo, shoot, update, LEN, WID, VIEW } = await import('/src/engine/biliardo.js');
const { drawBiliardo } = await import('/src/engine/biliardo-render.js');
const def = story.closeups.biliardo;
const load = async src => { const img = new Image(); img.src = src; await img.decode(); return img; };
const bg = await load('/' + def.picture);
const atlas = await (await fetch('/assets/characters/ing-tozzi.json')).json();
const sheet = { atlas, img: await load('/assets/characters/' + atlas.image) };
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 360;
const ctx = canvas.getContext('2d'), shots = [], used = [];
const drawImage = ctx.drawImage.bind(ctx);
ctx.drawImage = (image, ...args) => { used.push(image); drawImage(image, ...args); };
const c = newBiliardo(42);
const draw = label => {
const before = JSON.stringify(c);
drawBiliardo(ctx, c, def, bg, sheet);
if (before !== JSON.stringify(c)) throw new Error('Il renderer modifica la partita');
shots.push({ label, png: canvas.toDataURL('image/png').split(',')[1] });
};
c.step = 'settle'; draw('riposo');
if (!used.includes(bg) || !used.includes(sheet.img)) throw new Error('Tavolo o Ingegnere non disegnati');
const pixel = (x, y) => [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3);
const bright = p => p.reduce((s, v) => s + v, 0);
if (bright(pixel(320, 180)) <= bright(pixel(85, 180))) throw new Error('Manca la luce centrale');
if (pixel(177, 22).join() !== '255,90,61') throw new Error('Cifra rossa a sette segmenti assente');
if (pixel(177, 30).join() !== '56,27,23') throw new Error('Lo zero accende il segmento centrale');
const b = c.balls.player, bx = VIEW.x + b.x * VIEW.k, by = VIEW.y + b.y * VIEW.k;
const colors = new Set();
for (let y = Math.ceil(by - 3); y <= by + 3; y++) for (let x = Math.ceil(bx - 3); x <= bx + 3; x++) colors.add(pixel(x, y).join());
if (colors.size > 4 || !colors.has('255,253,244')) throw new Error('Biglia senza palette ridotta o riflesso quadrato');
c.step = 'aim'; c.current = 'cpu'; draw('tozzi-chalk');
c.last = { shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }; c.points.cpu = 4; draw('tozzi-cheer');
c.last = { shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }; draw('tozzi-shrug');
c.last = null; c.current = 'player'; c.points.cpu = 0;
shoot(c); update(c, 0.1); draw('stecca-arretrata'); update(c, 0.07); draw('stecca-avanti'); update(c, 0.08); draw('biglie-partite');
const pins = newBiliardo(9);
Object.assign(pins.balls.player, { x: LEN / 2 - 60, y: WID / 2 });
Object.assign(pins.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 });
Object.assign(pins.balls.pallino, { x: 20, y: 20 });
pins.angle = 0; pins.power = 0.5; shoot(pins);
for (let n = 0; n < 1000 && !pins.pins.some(p => p.down); n++) update(pins, 1 / 240);
update(pins, 0.25); update(pins, 0.04);
drawBiliardo(ctx, pins, def, bg, sheet);
shots.push({ label: 'birilli-caduta', png: canvas.toDataURL('image/png').split(',')[1] });
return shots;
});
for (const { label, png } of captures) writeFileSync(`${out}/${name}-${label}.png`, Buffer.from(png, 'base64'));
}
try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [
['desktop', false, { width: 1280, height: 860 }],
['touch', true, { width: 844, height: 390 }],
['portrait', true, { width: 390, height: 844 }],
]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
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('circolo_4_maggio'); settle(game);
game.act('talk', 'ing_tozzi'); settle(game);
game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Seria.')); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => {
const { hasBackground } = await import('/src/engine/render.js');
return hasBackground('assets/closeups/biliardo-tavolo.png');
});
const canvas = await page.locator('#scene').boundingBox();
for (const id of ['#sound', '#corner']) {
const box = await page.locator(id).boundingBox();
assert.ok(box.y + box.height <= canvas.y, `${id} non deve coprire il punteggio`);
}
const exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= canvas.y + canvas.height, 'uscita fuori dal tavolo');
await page.waitForTimeout(1600); // completa la dissolvenza della sala prima delle schermate
const reset = () => page.evaluate(async () => {
const { newBiliardo } = await import('/src/engine/biliardo.js');
game.closeup = { ...newBiliardo(42), id: 'biliardo' };
});
const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step }));
const position = async () => {
const r = await page.locator('#scene').boundingBox();
const [x, y] = await page.evaluate(async () => {
const { VIEW } = await import('/src/engine/biliardo.js');
const b = game.closeup.balls.cpu;
return [VIEW.x + b.x * VIEW.k, VIEW.y + (b.y + 3) * VIEW.k];
});
return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r };
};
const target = await position();
if (!mobile) {
// Prima del clic la stecca segue il mouse (Andrea, 25 ottobre: «non riesco più a girare la stecca»).
const before = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.5, target.r.y + target.r.height * 0.85, { steps: 4 });
assert.notEqual((await state()).angle, before, 'senza clic la stecca gira col mouse');
}
if (mobile) await page.touchscreen.tap(target.x, target.y);
else await page.mouse.click(target.x, target.y);
assert.equal((await state()).step, 'aim', 'un tocco sceglie la mira senza tirare');
assert.equal((await state()).power, 0.45, 'il tocco conserva la forza impostata');
// Il passaggio del mouse verso i comandi attraversa il panno.
const fixed = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
await page.keyboard.press('ArrowRight');
const nudged = (await state()).angle;
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
await page.keyboard.press('Shift+ArrowLeft');
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
await page.locator('#bocce-power').fill('60');
const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` });
await checkArtwork(page, name);
const expected = await page.evaluate(async () => {
const { simulate } = await import('/src/engine/biliardo.js');
const c = game.closeup;
return simulate(c, 'player', c.angle, c.power);
});
if (mobile) await page.tap('#bocce-throw'); else await page.click('#bocce-throw');
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, prepared.angle);
const score = await page.evaluate(async () => {
const { update, judge, FIXED_STEP } = await import('/src/engine/biliardo.js');
const c = game.closeup;
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
return judge(c.shot);
});
assert.deepEqual(score, expected, 'il tiro dai comandi segue la simulazione');
await reset();
const hold = await position();
const cdp = mobile ? await page.context().newCDPSession(page) : null;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
const heldAngle = (await state()).angle;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
assert.equal((await state()).angle, heldAngle, 'il movimento durante la carica non sposta la mira');
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
else await page.mouse.up();
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, heldAngle);
// Un gesto interrotto non deve lasciare un tiro in carica.
await reset();
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding);
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
else { await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); }
assert.equal((await state()).step, 'aim');
assert.equal((await state()).power, 0.45);
assert.equal(await page.evaluate(() => game.closeup.holding), false);
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => game.closeup), null);
await page.close();
console.log(`${name}: mira, ritocchi, forza, tiro, carica e annullamento OK`);
}
assert.deepEqual(errors, [], 'nessun errore JavaScript nel browser');
console.log(`Schermate: ${out}`);
} finally {
await browser?.close();
server.kill();
}
+143 -3
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimScreen, canThrow, LEN, WID, R, VIEW, SWING, powerAt } from '../src/engine/biliardo.js'; import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP } from '../src/engine/biliardo.js';
import { story } from '../src/story/story.js'; import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js'; import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
@@ -86,6 +86,8 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
assert.equal(c.step, 'rolling', 'l\'avversario ha tirato'); assert.equal(c.step, 'rolling', 'l\'avversario ha tirato');
assert.ok(frames > 20, 'non tira subito: ci pensa'); assert.ok(frames > 20, 'non tira subito: ci pensa');
for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) { for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) {
// Una mano comprende anche il tempo in cui la CPU pensa prima di tirare.
for (let i = 0; i < 600 && c.step === 'aim' && c.current === 'cpu'; i++) update(c, 1 / 60);
still(c); still(c);
if (c.step === 'aim' && c.current === 'player') { if (c.step === 'aim' && c.current === 'player') {
const me = c.balls.player, other = c.balls.cpu; const me = c.balls.player, other = c.balls.cpu;
@@ -99,6 +101,127 @@ test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita fin
assert.equal(press(c), false, 'a partita finita non si tira più'); assert.equal(press(c), false, 'a partita finita non si tira più');
}); });
const emptyTable = () => {
const c = newBiliardo(123);
c.pins = [];
Object.assign(c.balls.player, { x: 30, y: 30 });
Object.assign(c.balls.cpu, { x: 250, y: 120 });
Object.assign(c.balls.pallino, { x: 20, y: 120 });
return c;
};
const roll = (c, dt = FIXED_STEP) => {
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, dt);
assert.equal(c.step, 'settle', 'tutte le biglie devono fermarsi');
};
const near = (actual, expected, tolerance = 1e-6) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected} (tolleranza ${tolerance})`);
test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique e a diverse forze', () => {
for (const [x, y, angle, power] of [[30, 30, 0, 0.2], [270, 30, 0.65, 0.85], [260, 100, 0.1, 1], [30, 10, -1.2, 0.6]]) {
const c = emptyTable();
Object.assign(c.balls.player, { x, y });
c.angle = angle; c.power = power;
const before = JSON.stringify(c), p = predict(c);
assert.equal(JSON.stringify(c), before, 'la previsione non altera il tavolo né il seme casuale');
assert.equal(p.hit, null);
const end = p.points.at(-1);
shoot(c); roll(c);
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
}
});
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
const c = emptyTable();
Object.assign(c.balls.player, { x: 270, y: 30 });
c.angle = Math.PI / 4; c.power = 0.3;
const p = predict(c), [start, rail, end] = p.points;
assert.equal(p.points.length, 3);
near(rail[0], LEN - R);
near((rail[1] - start[1]) / (rail[0] - start[0]), 1);
near((end[1] - rail[1]) / (end[0] - rail[0]), -1 / CUSHION);
});
test('i colpi sottili arrivano al punto di contatto corretto anche a piena forza', () => {
for (const offset of [0, R, 2 * R * 0.9, 2 * R - 0.0001, 2 * R + 0.0001]) {
const c = emptyTable();
Object.assign(c.balls.player, { x: 30, y: 50 + offset });
Object.assign(c.balls.cpu, { x: 100, y: 50 });
c.angle = 0; c.power = 1;
const p = predict(c);
shoot(c);
for (let i = 0; i < 1000 && c.balls.player.x < 110 && !c.shot.first; i++) update(c, FIXED_STEP);
if (offset > 2 * R) {
assert.equal(c.shot.first, null, 'un passaggio esterno non diventa un urto');
continue;
}
assert.equal(p.hit, 'cpu');
assert.equal(c.shot.first, 'cpu', 'non deve attraversare la biglia nei colpi radenti');
near(p.ghost.x, 100 - Math.sqrt((2 * R) ** 2 - offset ** 2));
near(p.ghost.y, 50 + offset);
const speed = Math.hypot(c.balls.cpu.vx, c.balls.cpu.vy);
near(c.balls.cpu.vx / speed, p.dir[0]); near(c.balls.cpu.vy / speed, p.dir[1]);
const target = p.outgoing.find(t => t.role === 'cpu').points;
near((target[1][0] - target[0][0]) / Math.hypot(target[1][0] - target[0][0], target[1][1] - target[0][1]), p.dir[0]);
}
});
test('gli urti conservano la quantità di moto e non creano energia; le biglie che si separano non rimbalzano di nuovo', () => {
const a = { x: 40, y: 50, vx: 300, vy: 80 }, b = { x: 40 + 2 * R, y: 50, vx: -20, vy: -15 };
const energy = a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2;
assert.ok(collide(a, b) > 0);
near(a.vx + b.vx, 280); near(a.vy + b.vy, 65);
assert.ok(a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2 < energy);
const after = JSON.stringify([a, b]);
assert.equal(collide(a, b), 0);
assert.equal(JSON.stringify([a, b]), after);
});
test('la mira è bloccata durante la carica, il tocco breve ripristina la forza e le frecce consentono ritocchi fini', () => {
const c = newBiliardo(1), angle = c.angle, power = c.power;
press(c);
assert.equal(aimAt(c, 100, 100), false);
assert.equal(nudge(c, 1), false);
update(c, 0.05); release(c);
assert.equal(c.step, 'aim'); assert.equal(c.angle, angle); assert.equal(c.power, power);
nudge(c, 1); near(c.angle - angle, 0.002);
nudge(c, -1, true); near(c.angle - angle, 0.0015);
const fixed = c.angle;
assert.equal(aimScreen(c, [320, 30]), false, 'il pannello superiore non è una superficie di mira');
assert.equal(c.angle, fixed);
press(c); update(c, 0.2); cancel(c);
assert.equal(c.power, power);
press(c); update(c, 0.2); release(c);
update(c, WINDUP + 1 / 240); // la stecca arriva alla biglia
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
});
test('anteprima, CPU e tiro reale condividono ordine degli urti e punteggio', () => {
for (const [angle, power] of [[0, 1], [0.15, 0.5], [-0.6, 0.8], [2.4, 0.6]]) {
const c = newBiliardo(1);
Object.assign(c.balls.player, { x: 100, y: 71 });
Object.assign(c.balls.cpu, { x: 126, y: 71 });
c.angle = angle; c.power = power;
const p = predict(c), expected = simulate(c, 'player', angle, power);
shoot(c); roll(c);
assert.deepEqual(judge(c.shot), expected);
if (p.hit === 'cpu' || p.hit === 'pallino') assert.equal(c.shot.first, p.hit);
if (angle === 0) assert.ok(c.shot.pins.every(pin => pin.by === 'cpu'));
}
});
test('la fisica resta identica a 30, 60, 144 fps e con fotogrammi irregolari', () => {
const states = [];
for (const frames of [[1 / 30], [1 / 60], [1 / 144], [0.008, 0.042, 0.016, 0.09]]) {
const c = newBiliardo(1);
c.angle += 0.02; c.power = 0.9;
shoot(c);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, frames[i % frames.length]);
assert.equal(c.step, 'settle');
states.push({ balls: c.balls, score: judge(c.shot) });
}
for (const s of states.slice(1)) assert.deepEqual(s, states[0]);
});
test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => { test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => {
const c = newBiliardo(1); const c = newBiliardo(1);
const me = c.balls.player; const me = c.balls.player;
@@ -124,10 +247,16 @@ test('al Circolo 4 Maggio: dal portone in piazza si entra nella sala, e al tavol
assert.ok(g.act('walk', 'circolo')); assert.ok(g.act('walk', 'circolo'));
settle(g); settle(g);
assert.equal(g.state.scene, 'circolo_4_maggio'); assert.equal(g.state.scene, 'circolo_4_maggio');
assert.ok(g.act('use', 'biliardo')); assert.ok(g.act('talk', 'ing_tozzi'));
settle(g);
g.choose(g.options().findIndex(o => o.text === 'Ingegnere, una partita ai birilli?'));
const before = settle(g).map(s => s.text); const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Si va a venti'))); assert.ok(before.some(t => t.includes('Si va a venti')));
assert.deepEqual(g.options().map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.'], 'prima si sceglie quanto fa sul serio');
g.choose(g.options().findIndex(o => o.text === 'A fassi male.'));
settle(g);
assert.equal(g.closeup?.id, 'biliardo'); assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'male');
assert.equal(g.act('look', 'stecche'), false, 'durante la partita la scena non risponde'); assert.equal(g.act('look', 'stecche'), false, 'durante la partita la scena non risponde');
const c = g.closeup; const c = g.closeup;
assert.ok(g.closeupAim([VIEW.x + c.balls.cpu.x * VIEW.k, VIEW.y + c.balls.cpu.y * VIEW.k]), 'si mira sulla biglia avversaria'); assert.ok(g.closeupAim([VIEW.x + c.balls.cpu.x * VIEW.k, VIEW.y + c.balls.cpu.y * VIEW.k]), 'si mira sulla biglia avversaria');
@@ -143,8 +272,19 @@ test('al Circolo 4 Maggio: dal portone in piazza si entra nella sala, e al tavol
assert.ok(after.some(t => t.startsWith('ing_tozzi: Venti. Hai vinto te'))); assert.ok(after.some(t => t.startsWith('ing_tozzi: Venti. Hai vinto te')));
assert.ok(g.state.flags.biliardo_vinto); assert.ok(g.state.flags.biliardo_vinto);
// Lasciare a metà non segna niente. // Lasciare a metà non segna niente.
g.act('use', 'biliardo'); settle(g); g.act('talk', 'ing_tozzi'); settle(g);
assert.ok(g.options().some(o => o.text === 'La rivincita?'), 'vinto, si chiede la rivincita');
g.choose(g.options().findIndex(o => o.text === 'La rivincita?')); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g);
assert.equal(g.closeup?.id, 'biliardo'); assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'bona');
assert.ok(g.closeupClose()); assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?'))); assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?')));
}); });
test('i livelli dell\'Ingegnere: «Alla bona» prova pochi colpi e trema, «A fassi male» li prova tutti', () => {
const bona = newBiliardo(1, { level: 'bona' }), male = newBiliardo(1, { level: 'male' }), none = newBiliardo(1);
assert.equal(none.level, 'seria', 'senza scelta si gioca sul serio');
assert.ok(bona.skill.wobble > male.skill.wobble * 3);
assert.ok(bona.skill.angles < male.skill.angles);
});
+14 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { newBocce, press, release, update, powerAt, score, thrower, left, project, SWING, LANE, BALLS } from '../src/engine/bocce.js'; import { LEVELS, newBocce, press, release, update, powerAt, score, thrower, left, project, SWING, LANE, BALLS } from '../src/engine/bocce.js';
import { story } from '../src/story/story.js'; import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js'; import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
@@ -83,7 +83,11 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale
g.choose(g.options().findIndex(o => o.text.startsWith('Mariano! Facciamo'))); g.choose(g.options().findIndex(o => o.text.startsWith('Mariano! Facciamo')));
const before = settle(g).map(s => s.text); const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Brento Sanico')), 'la posta la dice prima'); assert.ok(before.some(t => t.includes('Brento Sanico')), 'la posta la dice prima');
assert.deepEqual(g.options().map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.'], 'prima si sceglie quanto fa sul serio');
g.choose(g.options().findIndex(o => o.text === 'Seria.'));
settle(g);
assert.equal(g.closeup?.id, 'bocce'); assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.closeup.level, 'seria');
assert.equal(g.act('look', 'pallaio'), false, 'durante la partita la scena non risponde'); assert.equal(g.act('look', 'pallaio'), false, 'durante la partita la scena non risponde');
// Si gioca la mano (le bocce di Andrea mirano al pallino); a mano finita il risultato si forza a favore. // Si gioca la mano (le bocce di Andrea mirano al pallino); a mano finita il risultato si forza a favore.
const c = g.closeup; const c = g.closeup;
@@ -108,7 +112,16 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale
assert.ok(g.options().some(o => o.text === 'La rivincita?') && g.options().some(o => o.text === 'Allora, Brento Sanico?')); assert.ok(g.options().some(o => o.text === 'La rivincita?') && g.options().some(o => o.text === 'Allora, Brento Sanico?'));
g.choose(g.options().findIndex(o => o.text === 'La rivincita?')); g.choose(g.options().findIndex(o => o.text === 'La rivincita?'));
settle(g); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.'));
settle(g);
assert.equal(g.closeup?.id, 'bocce'); assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.closeup.level, 'male');
assert.ok(g.closeupClose()); assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?'))); assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?')));
}); });
test('i livelli di Mariano: alla bona si allarga, a fassi male boccia spesso', () => {
assert.equal(newBocce(1).level, 'seria');
assert.ok(LEVELS.bona.spread > LEVELS.seria.spread && LEVELS.seria.spread > LEVELS.male.spread);
assert.ok(LEVELS.male.attack > LEVELS.bona.attack);
});
+41
View File
@@ -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);
});
+174
View File
@@ -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(); }
+183
View File
@@ -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)));
});
+8 -2
View File
@@ -375,6 +375,8 @@ try {
await clickScene(bocce, await hotspotCenter(bocce, 'mariano')); await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click(); await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
await bocce.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await bocce.locator('#choices li', { hasText: 'Seria.' }).click();
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await bocce.locator('#scene').boundingBox(); const r = await bocce.locator('#scene').boundingBox();
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2); await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
@@ -402,10 +404,14 @@ try {
await settle(b); await settle(b);
await b.waitForTimeout(1500); // la dissolvenza del cambio scena await b.waitForTimeout(1500); // la dissolvenza del cambio scena
await b.screenshot({ path: `${OUT}/03d-circolo.png` }); await b.screenshot({ path: `${OUT}/03d-circolo.png` });
await clickScene(b, await hotspotCenter(b, 'biliardo')); await clickScene(b, await hotspotCenter(b, 'ing_tozzi'));
await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await b.locator('#choices li', { hasText: 'Seria.' }).click();
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await b.locator('#scene').boundingBox(); const r = await b.locator('#scene').boundingBox();
const [tx, ty] = await b.evaluate(() => { const c = game.closeup, V = c.balls.cpu; return [57 + V.x * 1.85, 78 + V.y * 1.85]; }); const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height); await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
await b.mouse.down(); await b.mouse.down();
await b.waitForTimeout(500); await b.waitForTimeout(500);
+1 -1
View File
@@ -102,7 +102,7 @@ export async function checkMultimedia(browser, port, out, errors) {
const levels = await page.evaluate(async () => { const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js'); const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {}; 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 ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx }); const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true }); s.unlock(); s.configure({ active: true });
+12
View File
@@ -83,6 +83,7 @@ test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo ri
test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => { test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => {
assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura'); assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura');
assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno')); assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno'));
assert.equal(soundscapeFor('circolo_4_maggio'), 'biliardo', 'nella sala si sentono gli altri tavoli');
for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird')); for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird'));
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 }); const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true }); s.unlock(); s.configure({ active: true });
@@ -100,3 +101,14 @@ test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } }); 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'); }); 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);
}
});
+3 -2
View File
@@ -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`. // Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() { function* effectLists() {
for (const [id, c] of Object.entries(story.closeups ?? {})) { 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 t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
} }
for (const [id, scene] of Object.entries(story.scenes)) { for (const [id, scene] of Object.entries(story.scenes)) {
@@ -205,7 +205,8 @@ test('il libro dell\'oratorio: il primo piano si sfoglia, l\'ultima pagina è «
const g = new Game(story); const g = new Game(story);
g.go('oratorio', [320, 345]); g.go('oratorio', [320, 345]);
settle(g); settle(g);
assert.ok(g.hotspotAt([570, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate'); assert.ok(g.hotspotAt([522, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate');
assert.ok(g.hotspotAt([598, 215])?.id === 'giocatore_carte', 'il giocatore di carte resta cliccabile, lontano dal libro');
assert.ok(g.act('take', 'libro_dimenticato')); assert.ok(g.act('take', 'libro_dimenticato'));
const before = settle(g).map(s => s.text); const before = settle(g).map(s => s.text);
assert.deepEqual(before, ['Ricordo di aver visto il film da piccolo... era stupendo!']); assert.deepEqual(before, ['Ricordo di aver visto il film da piccolo... era stupendo!']);
+25
View File
@@ -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));
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""La musica della sala dei biliardi del Circolo 4 Maggio (assets/audio/biliardo.mp3), sintetizzata da zero: un trio
jazz da circolo in si bemolle, con lo swing: contrabbasso che cammina, piano elettrico che accompagna, piatto e spazzole,
e una melodia originale di vibrafono. Sedici battute che chiudono sul giro di ritorno: il loop non si sente.
python3 tools/jazz_biliardo.py # scrive assets/audio/biliardo.mp3 (serve ffmpeg)
"""
import math
import random
import subprocess
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
BPM = 104
BEAT = 60 / BPM
SWING = 2 / 3 # la croma in levare cade ai due terzi del movimento
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "biliardo.mp3"
# Il giro: un accordo per battuta. Basso (fondamentale), qualità, voci del piano (senza fondamentale).
CHORDS = [
("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("Bb1", "dom", ["D3", "Ab3", "C4", "F4"]),
("Eb2", "maj", ["G3", "Bb3", "D4", "F4"]), ("E2", "dim", ["E3", "G3", "Bb3", "Db4"]),
("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
]
THIRD_FIFTH = {"maj": (4, 7), "dom": (4, 7), "min": (3, 7), "dim": (3, 6)}
# La melodia del vibrafono: (movimento dall'inizio, nota, durata in movimenti). I levare cadono a .66.
S = SWING
MELODY = [
(0, "F5", 1.5), (1 + S, "D5", 1 - S), (2, "C5", S), (2 + S, "Bb4", 1.3),
(5, "D5", 1), (6, "F5", S), (6 + S, "E5", 1.3),
(8, "Eb5", 1.5), (9 + S, "G5", 1 - S), (10, "Bb5", 2),
(12 + S, "A5", 1 - S), (13, "G5", S), (13 + S, "F5", 1 - S), (14, "Eb5", 1), (15, "C5", 1),
(16, "D5", 1), (17, "F5", S), (17 + S, "A5", 1.3), (19 + S, "G5", 1 - S),
(20, "B4", 1), (21, "D5", 1), (22, "F5", 1.5),
(24, "Eb5", S), (24 + S, "D5", 1 - S), (25, "C5", 1), (26, "G4", 2),
(29, "A4", S), (29 + S, "C5", 1 - S), (30, "Eb5", 1), (31, "D5", 1),
(33 + S, "F5", 1 - S), (34, "D5", 1.6),
(36, "Ab5", 1.5), (37 + S, "G5", 1 - S), (38, "F5", 2),
(40, "G5", 1), (41, "Bb5", 1), (42, "F5", 2),
(44, "E5", 1), (45, "G5", 1), (46, "Bb5", 1.8),
(48, "F5", 1.5), (49 + S, "E5", 1 - S), (50, "D5", 2),
(52, "B4", 1), (53, "D5", S), (53 + S, "F5", 1.3),
(56, "Eb5", 1.5), (57 + S, "D5", 1 - S), (58, "C5", 2),
(60, "A4", 1), (61, "C5", 1), (62, "F5", 1.8),
]
DUR = len(CHORDS) * 4 * BEAT
buf = [0.0] * int(SR * DUR)
rng = random.Random(4)
def midi(note: str) -> int:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
semis = names[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
return semis + 12 * (int(note[-1]) + 1)
def hz(m: int) -> float:
return 440 * 2 ** ((m - 69) / 12)
def add(start: float, samples) -> None:
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
n0 = int(start * SR)
for i, v in enumerate(samples):
buf[(n0 + i) % len(buf)] += v
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.008,
tremolo: float = 0.0, rate: float = 5.0, release: float = 0.08):
n = int((length + release) * SR)
out = []
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
trem = 1 - tremolo * (0.5 + 0.5 * math.sin(2 * math.pi * rate * t))
out.append(vol * env * trem * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) for k, a, d in partials))
return out
def noise(length: float, vol: float, decay: float, bright: bool, attack: float = 0.002):
"""Rumore filtrato alla buona: la differenza fra campioni vicini è il piatto, la media le spazzole."""
n, out, prev, low = int(length * SR), [], 0.0, 0.0
for i in range(n):
t = i / SR
x = rng.uniform(-1, 1)
if bright:
v, prev = x - prev, x
else:
low += 0.08 * (x - low)
v = low * 3
out.append(vol * min(1.0, t / attack) * math.exp(-t / decay) * v)
return out
at = lambda beat: beat * BEAT # noqa: E731
LONG = 99
for bar, (root, quality, voices) in enumerate(CHORDS):
b0 = bar * 4
# Contrabbasso: fondamentale, terza, quinta, poi un semitono vicino alla fondamentale dopo.
r = midi(root)
third, fifth = THIRD_FIFTH[quality]
nxt = midi(CHORDS[(bar + 1) % len(CHORDS)][0])
walk = [r, r + third, r + fifth, nxt + (1 if bar % 2 else -1)]
for k, m in enumerate(walk):
add(at(b0 + k), tone(hz(m), BEAT * 0.92, 0.26, ((1, 1.0, LONG), (2, 0.45, 0.5), (3, 0.12, 0.25)), decay=0.7))
# Piano elettrico: colpi corti sul levare del due e sul quattro, una battuta sì e una no un po' diverso.
hits = [(1 + SWING, 0.5), (3, 0.9)] if bar % 2 == 0 else [(0 + SWING, 0.4), (2 + SWING, 1.2)]
for beat, length in hits:
for v in voices:
add(at(b0 + beat), tone(hz(midi(v)), BEAT * length, 0.045, ((1, 1.0, LONG), (2, 0.22, 0.6), (7, 0.05, 0.05)),
decay=1.1, tremolo=0.2, rate=4.6))
# Piatto: ogni movimento, più il levare del due e del quattro; spazzole sul due e sul quattro; cassa leggerissima.
for k in range(4):
add(at(b0 + k), noise(0.35, 0.05 if k % 2 else 0.035, 0.18, bright=True))
if k % 2:
add(at(b0 + k + SWING), noise(0.25, 0.03, 0.12, bright=True))
add(at(b0 + k) - 0.04, noise(0.2, 0.05, 0.09, bright=False, attack=0.05))
else:
add(at(b0 + k), tone(55, 0.12, 0.05, ((1, 1.0, LONG),), decay=0.08))
for beat, note, length in MELODY:
# Vibrafono: la nota, un armonico alto che sparisce subito, il tremolo del motore.
add(at(beat), tone(hz(midi(note)), BEAT * length, 0.11, ((1, 1.0, LONG), (4, 0.18, 0.12), (10, 0.05, 0.03)),
decay=1.6, tremolo=0.25, rate=5.6, release=0.4))
peak = max(abs(x) for x in buf) or 1
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "biliardo.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes())
# Una saletta piccola: poco riverbero, niente alti taglienti.
af = "aecho=0.8:0.6:45|95:0.18|0.1,lowpass=f=9000,volume=1.3"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
print(f"-> {OUT} ({DUR:.0f} s)")
+4
View File
@@ -3,6 +3,10 @@
"andrea": { "andrea": {
"speaker": "Andrew Chipper" "speaker": "Andrew Chipper"
}, },
"giocatore_carte": {
"speaker": "Viktor Eka",
"filtro": "asetrate=24000*1.04,aresample=24000,atempo=0.98"
},
"vecchietta": { "vecchietta": {
"speaker": "Tammy Grit" "speaker": "Tammy Grit"
}, },
+12 -1
View File
@@ -25,7 +25,18 @@ if lavori:
import torch import torch
import torchaudio import torchaudio
from TTS.api import TTS 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 sr = tts.synthesizer.output_sample_rate
for i, j in enumerate(lavori, 1): for i, j in enumerate(lavori, 1):
pezzi = [] pezzi = []