Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3035e33f | ||
|
|
22be1c306e | ||
|
|
add5a5fe77 | ||
|
|
3019a6e32f | ||
|
|
111b892916 | ||
|
|
f1781afea8 | ||
|
|
b44eeffd2a | ||
|
|
89ebdd40bb | ||
|
|
538bf6cedb | ||
|
|
7e1e6f6144 | ||
|
|
30e324d7e2 | ||
|
|
e8152563d8 | ||
|
|
fef236dc54 | ||
|
|
c82bef70b2 | ||
|
|
e9907e3e66 | ||
|
|
92e7a289be | ||
|
|
2362fc7741 | ||
|
|
2dd4257fd4 |
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
|
|||||||
COPY assets/credits /usr/share/nginx/html/assets/credits
|
COPY assets/credits /usr/share/nginx/html/assets/credits
|
||||||
COPY assets/effects /usr/share/nginx/html/assets/effects
|
COPY assets/effects /usr/share/nginx/html/assets/effects
|
||||||
COPY assets/closeups /usr/share/nginx/html/assets/closeups
|
COPY assets/closeups /usr/share/nginx/html/assets/closeups
|
||||||
|
COPY assets/favicon /usr/share/nginx/html/assets/favicon
|
||||||
|
COPY assets/video /usr/share/nginx/html/assets/video
|
||||||
|
|
||||||
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
|
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
|
||||||
RUN cd /usr/share/nginx/html \
|
RUN cd /usr/share/nginx/html \
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -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
|
||||||
@@ -287,26 +307,6 @@
|
|||||||
"file": "andrea-68a845881b.mp3",
|
"file": "andrea-68a845881b.mp3",
|
||||||
"sec": 1.94
|
"sec": 1.94
|
||||||
},
|
},
|
||||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
|
|
||||||
"file": "andrea-97d75e2a9d.mp3",
|
|
||||||
"sec": 5.38
|
|
||||||
},
|
|
||||||
"andrea|Buongiorno, Fra Peppino!": {
|
|
||||||
"file": "andrea-94988dc8c6.mp3",
|
|
||||||
"sec": 1.74
|
|
||||||
},
|
|
||||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
|
||||||
"file": "fra_peppino-651c4d95ab.mp3",
|
|
||||||
"sec": 3.72
|
|
||||||
},
|
|
||||||
"andrea|Il palio. È sparito.": {
|
|
||||||
"file": "andrea-c9c068f507.mp3",
|
|
||||||
"sec": 2.39
|
|
||||||
},
|
|
||||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
|
||||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
|
||||||
"sec": 4.47
|
|
||||||
},
|
|
||||||
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
||||||
"file": "andrea-43fd5c2b27.mp3",
|
"file": "andrea-43fd5c2b27.mp3",
|
||||||
"sec": 4.46
|
"sec": 4.46
|
||||||
@@ -467,6 +467,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
|
||||||
@@ -519,6 +523,26 @@
|
|||||||
"file": "andrea-43d3435d61.mp3",
|
"file": "andrea-43d3435d61.mp3",
|
||||||
"sec": 2.99
|
"sec": 2.99
|
||||||
},
|
},
|
||||||
|
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
|
||||||
|
"file": "andrea-6797d70153.mp3",
|
||||||
|
"sec": 8.45
|
||||||
|
},
|
||||||
|
"andrea|Buongiorno, Fra Peppino!": {
|
||||||
|
"file": "andrea-94988dc8c6.mp3",
|
||||||
|
"sec": 1.74
|
||||||
|
},
|
||||||
|
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||||
|
"file": "fra_peppino-651c4d95ab.mp3",
|
||||||
|
"sec": 3.72
|
||||||
|
},
|
||||||
|
"andrea|Il palio. È sparito.": {
|
||||||
|
"file": "andrea-c9c068f507.mp3",
|
||||||
|
"sec": 2.39
|
||||||
|
},
|
||||||
|
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||||
|
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||||
|
"sec": 4.47
|
||||||
|
},
|
||||||
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
||||||
"file": "andrea-486c980f52.mp3",
|
"file": "andrea-486c980f52.mp3",
|
||||||
"sec": 7.48
|
"sec": 7.48
|
||||||
@@ -1499,6 +1523,86 @@
|
|||||||
"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|Rivincita a briscola?": {
|
||||||
|
"file": "andrea-6807209a9e.mp3",
|
||||||
|
"sec": 1.37
|
||||||
|
},
|
||||||
|
"andrea|Una scopa?": {
|
||||||
|
"file": "andrea-d8dfb86266.mp3",
|
||||||
|
"sec": 0.65
|
||||||
|
},
|
||||||
|
"andrea|Un tressette ai 21?": {
|
||||||
|
"file": "andrea-4fbe84fada.mp3",
|
||||||
|
"sec": 1.66
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"giocatore_carte|Ai undici. E occhio al settebello: quello è sempre mio.": {
|
||||||
|
"file": "giocatore_carte-af54c11857.mp3",
|
||||||
|
"sec": 4.27
|
||||||
|
},
|
||||||
|
"giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
|
||||||
|
"file": "giocatore_carte-3e2671e35e.mp3",
|
||||||
|
"sec": 4.88
|
||||||
|
},
|
||||||
|
"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
|
||||||
@@ -1827,18 +1931,6 @@
|
|||||||
"file": "mariano-1c9cd0c325.mp3",
|
"file": "mariano-1c9cd0c325.mp3",
|
||||||
"sec": 5.96
|
"sec": 5.96
|
||||||
},
|
},
|
||||||
"andrea|Alla bona.": {
|
|
||||||
"file": "andrea-67afa54e05.mp3",
|
|
||||||
"sec": 0.69
|
|
||||||
},
|
|
||||||
"andrea|Seria.": {
|
|
||||||
"file": "andrea-f6ef96032e.mp3",
|
|
||||||
"sec": 0.58
|
|
||||||
},
|
|
||||||
"andrea|A fassi male.": {
|
|
||||||
"file": "andrea-f52137b691.mp3",
|
|
||||||
"sec": 0.78
|
|
||||||
},
|
|
||||||
"mariano|Alla bona: tiro con gli occhi chiusi.": {
|
"mariano|Alla bona: tiro con gli occhi chiusi.": {
|
||||||
"file": "mariano-b1b1ca273b.mp3",
|
"file": "mariano-b1b1ca273b.mp3",
|
||||||
"sec": 2.2
|
"sec": 2.2
|
||||||
@@ -1995,6 +2087,38 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
"giocatore_carte|Ventuno per primi. Bella coppia, voi due!": {
|
||||||
|
"file": "giocatore_carte-3215efd9dd.mp3",
|
||||||
|
"sec": 3.51
|
||||||
|
},
|
||||||
|
"giocatore_carte|Ventuno! Il tressette è un'arte: si studia da piccini.": {
|
||||||
|
"file": "giocatore_carte-5dbd39a25f.mp3",
|
||||||
|
"sec": 4.44
|
||||||
|
},
|
||||||
|
"giocatore_carte|Settebello, primiera e pure le scope. Oggi l'hai vista bene.": {
|
||||||
|
"file": "giocatore_carte-9a43eb8b2c.mp3",
|
||||||
|
"sec": 5.54
|
||||||
|
},
|
||||||
|
"giocatore_carte|Undici! Il settebello lo sento arrivare da lontano.": {
|
||||||
|
"file": "giocatore_carte-445e962a1b.mp3",
|
||||||
|
"sec": 4.1
|
||||||
|
},
|
||||||
"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
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Revisione di Codex (26 ottobre, i tre giochi insieme)
|
||||||
|
|
||||||
|
Regole promosse. Corretti: uscendo durante il conteggio si perdeva una vittoria già fatta (ora il risultato è
|
||||||
|
deciso all'ultima presa e il conteggio lo mostra soltanto) e, per tutti e tre i giochi, l'esito che non finiva
|
||||||
|
nel salvataggio se dal risultato si tornava al titolo; Spazio sui pulsanti come negli altri giochi; la briscola e
|
||||||
|
la carta in tavola dette anche a parole; l'aiuto coi nomi di tutti e due i mazzi; «Rivincita a briscola?».
|
||||||
|
Proposta non fatta: i seguiti campionati di «A fassi male» col finale esatto, e l'obiettivo vittoria invece dei punti.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Scopa agli 11
|
||||||
|
|
||||||
|
All'oratorio, dal giocatore di carte: «Una scopa?». In due, Andrea contro il giocatore di carte, con le toscane o
|
||||||
|
le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Claude), tavolo `src/engine/scopa-render.js`.
|
||||||
|
|
||||||
|
## Regole
|
||||||
|
|
||||||
|
- Valori: Asso 1, poi i numeri, gobbo (fante) 8, donna (cavallo) 9, regio (re) 10.
|
||||||
|
- Quattro carte in tavola (con tre o quattro re si rimescola), tre a testa; finite le tre, se ne danno altre tre.
|
||||||
|
Gioca per primo chi non fa le carte; il mazziere cambia a ogni smazzata.
|
||||||
|
- Una carta prende quella dello stesso valore (se ce n'è più d'una si sceglie quale); se non c'è, un gruppo di carte
|
||||||
|
che somma il suo valore (più gruppi: si sceglie). Se la presa c'è, è obbligatoria. Se no, la carta resta in tavola.
|
||||||
|
- **Scopa**: chi pulisce la tavola fa un punto; non vale con l'ultima carta della smazzata.
|
||||||
|
- A fine smazzata le carte in tavola vanno all'ultimo che ha preso. Un punto per: **carte** (più di 20), **mattoni**
|
||||||
|
(con le napoletane i denari, più di 5), **settebello** (il 7 di mattoni o di denari), **primiera** (la carta
|
||||||
|
migliore di ogni seme: 7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12, figure 10; senza un seme non
|
||||||
|
si fa), e uno per scopa. A pari, il punto non va a nessuno.
|
||||||
|
- Si va **agli 11**, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), un'altra smazzata.
|
||||||
|
|
||||||
|
## Avversari
|
||||||
|
|
||||||
|
- «Alla bona»: una mossa a caso fra quelle possibili.
|
||||||
|
- «Seria»: sceglie la mossa che vale di più (settebello, mattoni, carte, scope, e la primiera per quanto migliora la
|
||||||
|
propria: un 6 di un seme dove si ha solo una figura vale più di uno dove si ha già il 7) e teme a occhio di
|
||||||
|
lasciare una scopa.
|
||||||
|
- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, non lascia 7 in
|
||||||
|
tavola e, a mazzo finito (le carte dell'altro sono proprio quelle non viste), calcola il finale esatto fino al
|
||||||
|
conteggio.
|
||||||
|
|
||||||
|
Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 96% contro
|
||||||
|
«Alla bona», 76% contro «Seria», 50% contro «A fassi male»; circa quattro smazzate a partita.
|
||||||
|
|
||||||
|
## Revisione di Codex (26 ottobre, i tre giochi insieme)
|
||||||
|
|
||||||
|
Regole promosse (valori, presa obbligatoria, precedenza della carta uguale, scopa non all'ultima, residui
|
||||||
|
all'ultimo, maggioranze e pari, primiera, re in tavola, agli 11). Corretti: frecce e Invio che potevano prendere la
|
||||||
|
presa sbagliata (ora il fuoco segue la scelta); il tocco sulla tavola che agiva alla pressione e non bastava con
|
||||||
|
prese sovrapposte (ora al rilascio: il primo tocco sceglie, il secondo conferma); primiera valutata a occhio;
|
||||||
|
finali sbagliati a mazzo finito; la tavola descritta a parole; aiuto con i valori della primiera e i pari.
|
||||||
|
|
||||||
|
## Prove
|
||||||
|
|
||||||
|
`npm test` (`tests/scopa.test.mjs`) e `npm run browser:scopa`: partita intera col mouse, una smazzata col tocco
|
||||||
|
(telefono e orizzontale) e con la tastiera; in ogni caso una presa da scegliere fra due (toccando la tavola, con
|
||||||
|
un pulsante o con frecce e Invio). Schermate in `/tmp/regolo-shots/scopa-*.png`.
|
||||||
@@ -18,6 +18,17 @@ 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`.
|
||||||
|
**Tressette ai 21** (stesso giocatore, «Un tressette ai 21?»): in quattro a coppie, busso/striscio/volo e accuse,
|
||||||
|
motore e tavolo di Claude, regole e taratura in `docs/TRESSETTE.md`, prova `npm run browser:tressette`.
|
||||||
|
**Scopa agli 11** («Una scopa?»): carte, mattoni (o denari), settebello, primiera e scope, presa a scelta fra
|
||||||
|
più possibili; `docs/SCOPA.md`, prova `npm run browser:scopa`. Con la scopa i giochi di carte sono finiti
|
||||||
|
(decisione di Andrea, 26 ottobre). Tressette rivisto dopo la revisione critica di Codex (in `docs/TRESSETTE.md`).
|
||||||
|
|
||||||
- **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
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Tressette ai 21
|
||||||
|
|
||||||
|
All'oratorio, dal giocatore di carte: «Un tressette ai 21?». Quattro al tavolo, a coppie: Andrea e il socio
|
||||||
|
(di fronte) contro il giocatore di carte e l'altro. [DA ANDREA: chi sono il socio e l'altro.]
|
||||||
|
Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render.js` con le carte della briscola.
|
||||||
|
|
||||||
|
## Regole
|
||||||
|
|
||||||
|
- Mazzo da 40, toscane o napoletane (stessa scelta e stesso flag `carte_napoletane` della briscola). Dieci carte a testa.
|
||||||
|
- Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata.
|
||||||
|
- Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4.
|
||||||
|
- Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi
|
||||||
|
avanzati si perdono: le due coppie fanno sempre 11 punti in tutto.
|
||||||
|
- **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3
|
||||||
|
dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4.
|
||||||
|
Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari.
|
||||||
|
- **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho
|
||||||
|
altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche
|
||||||
|
sul tavolo, la carta strusciata, la carta lasciata cadere.
|
||||||
|
- Si va **ai 21**, presa per presa: il punteggio in alto conta accuse e punti interi già presi, e la coppia che
|
||||||
|
arriva a 21 per prima è fuori e ha vinto, anche a metà smazzata (le accuse contano alla fine della presa in cui
|
||||||
|
si dicono). Il risultato è subito definitivo: uscire dal riepilogo non lo cancella. Il riepilogo di fine
|
||||||
|
smazzata resta finché Andrea non va avanti.
|
||||||
|
|
||||||
|
Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si
|
||||||
|
sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa.
|
||||||
|
|
||||||
|
## Avversari
|
||||||
|
|
||||||
|
- «Alla bona»: una carta a caso fra quelle buone, nessun segno.
|
||||||
|
- «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta
|
||||||
|
quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro.
|
||||||
|
- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse,
|
||||||
|
risponde al busso del socio prendendo con la migliore e poi gli ritorna il seme. Il socio di Andrea gioca sempre così.
|
||||||
|
- Tutti: il carico al socio che prende di sicuro, ma senza buttarci il 3 o il 2; a parità di lunghezza si esce dal
|
||||||
|
seme con meno punti da esporre (niente Asso scoperto).
|
||||||
|
|
||||||
|
Taratura (`node tools/calibrate_tressette.mjs 300`, Andrea che gioca come il socio): Andrea e il socio vincono
|
||||||
|
87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male»; circa tre smazzate a partita.
|
||||||
|
|
||||||
|
## Revisione di Codex (26 ottobre)
|
||||||
|
|
||||||
|
Revisione critica sul primo giro. Corretti: vittoria persa uscendo dal riepilogo; accuse tutte pubbliche prima
|
||||||
|
della prima carta; il seme mancante del tris noto all'IA ma non ad Andrea; striscio troppo stretto e busso con
|
||||||
|
un requisito inventato; il socio che non rispondeva al busso, sprecava il 3 su una presa già sua o usciva
|
||||||
|
d'Asso scoperto; il volo non letto da «A fassi male»; tastiera che poteva giocare la carta sbagliata; il
|
||||||
|
riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole per chi non vede il disegno.
|
||||||
|
Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione
|
||||||
|
precedente dell'IA.
|
||||||
|
|
||||||
|
Seconda revisione (i tre giochi insieme). Corretti: con le accuse tutte e due le coppie potevano arrivare ai 21 e
|
||||||
|
vinceva sempre Andrea (ora esce chi arriva per primo, anche con un'accusa, nel momento in cui la dice); il socio
|
||||||
|
che teneva il 2 anche quando caricarlo voleva dire vincere subito (l'IA vede ora punteggio e terzi del tabellone);
|
||||||
|
Invio su un pulsante che giocava la carta col fuoco invece di quella scelta; il pannello delle accuse che usciva
|
||||||
|
dal tavolo; le convenzioni del tavolo scritte anche nel «Come si gioca». Per tutti e tre i giochi l'esito vale e
|
||||||
|
si salva appena deciso (anche tornando al titolo dalla schermata del risultato).
|
||||||
|
|
||||||
|
## Prove
|
||||||
|
|
||||||
|
`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
|
||||||
|
col tocco (telefono e orizzontale) e con la tastiera, segni con pulsanti e tasti B, S, V, carta che non risponde
|
||||||
|
al seme rifiutata. Schermate in `/tmp/regolo-shots/tressette-*.png`.
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
||||||
<title>La Leggenda del Regolo</title>
|
<title>La Leggenda del Regolo</title>
|
||||||
|
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
|
||||||
|
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -86,6 +91,11 @@
|
|||||||
<p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p>
|
<p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p>
|
||||||
<p class="version"></p>
|
<p class="version"></p>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
|
||||||
|
<h2 id="tutorial-title">Come si gioca</h2>
|
||||||
|
<video id="tutorial-video" controls playsinline preload="metadata"></video>
|
||||||
|
<div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
|
||||||
|
</section>
|
||||||
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
|
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
|
||||||
<h2>Ringraziamenti</h2>
|
<h2>Ringraziamenti</h2>
|
||||||
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||||
@@ -103,6 +113,82 @@
|
|||||||
<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="scopa-controls" hidden aria-label="Comandi della scopa">
|
||||||
|
<p id="scopa-status" role="status"></p>
|
||||||
|
<div class="card-hand" aria-label="La mano di Andrea">
|
||||||
|
<button id="scopa-card-0" type="button"></button>
|
||||||
|
<button id="scopa-card-1" type="button"></button>
|
||||||
|
<button id="scopa-card-2" type="button"></button>
|
||||||
|
</div>
|
||||||
|
<div id="scopa-options" class="card-buttons" hidden aria-label="Quale presa"></div>
|
||||||
|
<button id="scopa-next" type="button" hidden>Prossima smazzata</button>
|
||||||
|
<details><summary>Come si gioca</summary>
|
||||||
|
<button class="tutorial-open" type="button" data-tutorial="scopa" hidden>Guarda il video</button>
|
||||||
|
<p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano
|
||||||
|
il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10.
|
||||||
|
Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p>
|
||||||
|
<p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di
|
||||||
|
mattoni o di denari), la primiera, e uno per ogni scopa. A pari, il punto non va a nessuno.</p>
|
||||||
|
<p>Primiera: si somma la carta migliore di ogni seme (7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12,
|
||||||
|
figure 10); chi non ha almeno una carta per seme non la fa. Si va agli 11, contati a fine smazzata: vince chi ne
|
||||||
|
ha di più; pari, se ne fa un’altra.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section id="tressette-controls" hidden aria-label="Comandi del tressette">
|
||||||
|
<p id="tressette-status" role="status"></p>
|
||||||
|
<div class="card-buttons" aria-label="Segni per il socio, solo quando esci">
|
||||||
|
<button id="tressette-busso" type="button">Busso</button>
|
||||||
|
<button id="tressette-striscio" type="button">Striscio</button>
|
||||||
|
<button id="tressette-volo" type="button">Volo</button>
|
||||||
|
<button id="tressette-next" type="button" hidden>Prossima smazzata</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-hand-many" aria-label="La mano di Andrea">
|
||||||
|
<button id="tressette-card-0" type="button"></button>
|
||||||
|
<button id="tressette-card-1" type="button"></button>
|
||||||
|
<button id="tressette-card-2" type="button"></button>
|
||||||
|
<button id="tressette-card-3" type="button"></button>
|
||||||
|
<button id="tressette-card-4" type="button"></button>
|
||||||
|
<button id="tressette-card-5" type="button"></button>
|
||||||
|
<button id="tressette-card-6" type="button"></button>
|
||||||
|
<button id="tressette-card-7" type="button"></button>
|
||||||
|
<button id="tressette-card-8" type="button"></button>
|
||||||
|
<button id="tressette-card-9" type="button"></button>
|
||||||
|
</div>
|
||||||
|
<details><summary>Come si gioca</summary>
|
||||||
|
<button class="tutorial-open" type="button" data-tutorial="tressette" hidden>Guarda il video</button>
|
||||||
|
<p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra.
|
||||||
|
Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta
|
||||||
|
del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p>
|
||||||
|
<p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21: la coppia che
|
||||||
|
ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p>
|
||||||
|
<p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
|
||||||
|
tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p>
|
||||||
|
<p>Convenzioni di questo tavolo: il «fuori» ai 21 è automatico (un’accusa o una presa che ci porta fa vincere
|
||||||
|
subito); niente cappotto; i segni valgono anche alla prima presa.</p>
|
||||||
|
<p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme),
|
||||||
|
<b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta.
|
||||||
|
Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
|
||||||
|
<p id="briscola-status" role="status"></p>
|
||||||
|
<div class="card-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>
|
||||||
|
<button class="tutorial-open" type="button" data-tutorial="briscola" hidden>Guarda il video</button>
|
||||||
|
<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, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
|
||||||
|
Asso 11 punti, 3 dieci, regio 4, donna 3, gobbo 2; le altre zero. Fra parentesi i nomi delle napoletane.</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">
|
||||||
@@ -114,6 +200,7 @@
|
|||||||
<button id="bocce-throw" type="button">Lancia</button>
|
<button id="bocce-throw" type="button">Lancia</button>
|
||||||
</div>
|
</div>
|
||||||
<details id="bocce-help"><summary>Come si gioca</summary>
|
<details id="bocce-help"><summary>Come si gioca</summary>
|
||||||
|
<button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button>
|
||||||
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
||||||
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
||||||
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
||||||
@@ -122,6 +209,7 @@
|
|||||||
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>
|
||||||
|
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||||
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
||||||
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
|
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
|
||||||
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
|
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.9",
|
"version": "0.1.15",
|
||||||
"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:biliardo": "node tests/biliardo.browser.mjs",
|
||||||
|
"browser:tressette": "node tests/tressette.browser.mjs",
|
||||||
|
"browser:scopa": "node tests/scopa.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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
// Una carta in volo da un rettangolo [x, y, w, h] a un altro, con k da 0 a 1 (parte svelta, arriva piano).
|
||||||
|
// La usano anche tressette e scopa.
|
||||||
|
export function flight(from, to, k) {
|
||||||
|
const e = 1 - (1 - Math.min(1, Math.max(0, k))) ** 3;
|
||||||
|
return from.map((v, i) => v + (to[i] - v) * e);
|
||||||
|
}
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
// Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
|
||||||
|
export function drawTable(ctx, title) {
|
||||||
|
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, title, 320, 15, 9, '#ffe0a0', 'center');
|
||||||
|
}
|
||||||
|
export function drawBriscola(ctx, c, def) {
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
drawTable(ctx, 'BRISCOLA ALL’ORATORIO');
|
||||||
|
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) => {
|
||||||
|
// La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso.
|
||||||
|
const spot = [242 + i * 86, 130, 64, 92];
|
||||||
|
const hand = p.who === 'player' ? [HAND.x + (p.from ?? 1) * HAND.gap, HAND.y, HAND.w, HAND.h] : [229 + (p.from ?? 1) * 62, 49, 52, 74];
|
||||||
|
let r = spot;
|
||||||
|
if (i === c.table.length - 1 && c.step === 'play' || c.step === 'trick' && i === 1 && c.t < PLAY_TIME) r = flight(hand, spot, c.t / PLAY_TIME);
|
||||||
|
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(spot, [526, c.last.winner === 'cpu' ? 74 : 257, 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
|
||||||
|
drawCard(ctx, p.card, ...r);
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// 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'];
|
||||||
|
// I tempi delle animazioni (briscola-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
|
||||||
|
// poi vola sul mucchietto di chi ha preso (Andrea: «non si capisce quando vengono buttate le carte»).
|
||||||
|
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.85, TRICK_TIME = 1.4;
|
||||||
|
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, from: index }); c.t = 0; // `from`: da dove parte la carta nel disegno
|
||||||
|
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';
|
||||||
|
// Il risultato è deciso all'ultima presa: il conteggio carta per carta lo mostra soltanto.
|
||||||
|
if (c.step === 'count') c.result = result(c.taken);
|
||||||
|
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 >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||||
|
if (c.step === 'trick' && c.t >= TRICK_TIME) 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]}.`;
|
||||||
|
// A parole anche quel che si vede sul tavolo: la briscola e la carta già giocata.
|
||||||
|
const table = `Briscola: ${c.trump}.${c.table.length ? ` In tavola: ${cardName(c.table[0].card)}.` : ''}`;
|
||||||
|
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} sceglie…`;
|
||||||
|
}
|
||||||
@@ -3,9 +3,15 @@
|
|||||||
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';
|
||||||
|
import * as tressette from './tressette.js';
|
||||||
|
import * as scopa from './scopa.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 = {
|
||||||
|
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
|
||||||
|
tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
|
||||||
|
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.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 },
|
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||||
};
|
};
|
||||||
@@ -245,6 +251,20 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
|
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
|
||||||
|
outcome(def, c) { return def[c.result.winner === 'player' ? 'win' : c.result.winner === 'cpu' ? 'lose' : 'draw'] ?? []; }
|
||||||
|
// Appena il risultato di un minigioco è deciso, i suoi flag (vittoria, partita giocata) valgono e si salvano:
|
||||||
|
// anche tornando al titolo dalla schermata del risultato, senza passare dall'uscita. Una volta sola.
|
||||||
|
settle(c) {
|
||||||
|
const def = this.story.closeups?.[c.id];
|
||||||
|
if (!c.result || c.settled || !def) return false;
|
||||||
|
c.settled = true;
|
||||||
|
for (const e of this.outcome(def, c)) {
|
||||||
|
if (e.set) { this.state.flags[e.set] = true; if (this.checkpoint) this.checkpoint.flags[e.set] = true; }
|
||||||
|
if (e.unset) { delete this.state.flags[e.unset]; if (this.checkpoint) delete this.checkpoint.flags[e.unset]; }
|
||||||
|
}
|
||||||
|
if (this.checkpoint) this.onSave(this.checkpoint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
closeupClose() {
|
closeupClose() {
|
||||||
const c = this.closeup;
|
const c = this.closeup;
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
@@ -255,9 +275,11 @@ export class Game {
|
|||||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
|
||||||
|
// di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||||
if (MINIGAMES[def?.type]) {
|
if (MINIGAMES[def?.type]) {
|
||||||
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
|
this.settle(c);
|
||||||
|
this.queue.unshift(...(c.result ? this.outcome(def, c) : def.quit ?? []));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||||
@@ -267,16 +289,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;
|
||||||
@@ -402,6 +425,7 @@ export class Game {
|
|||||||
const m = this.minigame();
|
const m = this.minigame();
|
||||||
if (m) {
|
if (m) {
|
||||||
m.update(c, dt);
|
m.update(c, dt);
|
||||||
|
this.settle(c);
|
||||||
// Il risultato resta leggibile finché il giocatore non esce.
|
// Il risultato resta leggibile finché il giocatore non esce.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -440,7 +464,9 @@ export class Game {
|
|||||||
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] }
|
||||||
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
|
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
|
||||||
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level) }
|
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
|
||||||
|
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
|
||||||
|
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) }
|
||||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
: { 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));
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { versioned } from '../version.js';
|
|||||||
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
|
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
|
||||||
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
|
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
|
||||||
// altrimenti quello generale del paese.
|
// altrimenti quello generale del paese.
|
||||||
export function trackFor(story, { screen, scene, phase }) {
|
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
|
||||||
|
export function trackFor(story, { screen, scene, phase, closeup }) {
|
||||||
const m = story.music ?? {};
|
const m = story.music ?? {};
|
||||||
if (screen === 'splash') return null;
|
if (screen === 'splash') return null;
|
||||||
if (screen === 'title' || screen === 'intro') return m.title ?? null;
|
if (screen === 'title' || screen === 'intro') return m.title ?? null;
|
||||||
if (screen === 'ending') return m.ending ?? m.game ?? null;
|
if (screen === 'ending') return m.ending ?? m.game ?? null;
|
||||||
|
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
|
||||||
const sc = story.scenes[scene];
|
const sc = story.scenes[scene];
|
||||||
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
|
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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 { drawTressette } from './tressette-render.js';
|
||||||
|
import { drawScopa } from './scopa-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 +669,9 @@ 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 === 'tressette') { drawTressette(ctx, c, def); return; }
|
||||||
|
if (def.type === 'scopa') { drawScopa(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));
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { canPlay, TARGET, PLAY_TIME, TAKE_HOLD, TAKE_TIME } from './scopa.js';
|
||||||
|
import { drawCard, drawTable, flight } from './briscola-render.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola.
|
||||||
|
export const HAND = { y: 262, w: 64, h: 92, gap: 72 };
|
||||||
|
export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap);
|
||||||
|
const TABLE = { w: 42, h: 60, gap: 47, row: 8 };
|
||||||
|
export function tableXY(n, i) {
|
||||||
|
const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row);
|
||||||
|
return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132];
|
||||||
|
}
|
||||||
|
export function cardAt(c, [x, y]) {
|
||||||
|
if (!canPlay(c)) return -1;
|
||||||
|
return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h);
|
||||||
|
}
|
||||||
|
export function tableAt(c, [x, y]) {
|
||||||
|
return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; });
|
||||||
|
}
|
||||||
|
const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3';
|
||||||
|
const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); };
|
||||||
|
|
||||||
|
function board(ctx, c, names) {
|
||||||
|
// Un giocatore per riga: i nomi lunghi non si accavallano.
|
||||||
|
panel(ctx, 26, 34, 150, 50);
|
||||||
|
for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) {
|
||||||
|
text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right');
|
||||||
|
}
|
||||||
|
text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center');
|
||||||
|
// Il mazzo a sinistra, le prese a destra con le scope.
|
||||||
|
if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true);
|
||||||
|
text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center');
|
||||||
|
for (const [who, y] of [['cpu', 92], ['player', 196]]) {
|
||||||
|
if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true);
|
||||||
|
if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const PILE = { cpu: 92, player: 196 };
|
||||||
|
function table(ctx, c) {
|
||||||
|
// Durante una presa la tavola resta com'era (le carte prese al loro posto, incorniciate) finché non volano via.
|
||||||
|
const take = c.step === 'take' && c.last.captured.length, shown = take ? c.last.before : c.table;
|
||||||
|
const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id));
|
||||||
|
const fly = take && c.t > TAKE_HOLD ? (c.t - TAKE_HOLD) / (TAKE_TIME - TAKE_HOLD - 0.05) : 0;
|
||||||
|
const pile = take ? [560, PILE[c.last.who], 42, 60] : null;
|
||||||
|
// Da dove parte la carta giocata: la mano di Andrea o quella coperta del giocatore.
|
||||||
|
const hand = c.step === 'take' && (c.last.who === 'player'
|
||||||
|
? [handX(c.hands.player.length + 1, c.last.from), HAND.y, HAND.w, HAND.h]
|
||||||
|
: [320 - ((c.hands.cpu.length + 1) * 48 - 6) / 2 + c.last.from * 48, 38, 42, 60]);
|
||||||
|
const arrive = Math.min(1, c.t / PLAY_TIME);
|
||||||
|
shown.forEach((card, i) => {
|
||||||
|
const [x, y] = tableXY(shown.length, i);
|
||||||
|
let r = [x, y, TABLE.w, TABLE.h];
|
||||||
|
if (c.step === 'take' && !take && card.id === c.last.card.id) r = flight(hand, r, arrive);
|
||||||
|
if (fly && marked.has(card.id)) r = flight(r, pile, fly);
|
||||||
|
else if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h);
|
||||||
|
drawCard(ctx, card, ...r);
|
||||||
|
});
|
||||||
|
if (take) {
|
||||||
|
let r = flight(hand, [299, 200, TABLE.w, TABLE.h], arrive);
|
||||||
|
if (fly) r = flight(r, pile, fly);
|
||||||
|
drawCard(ctx, c.last.card, ...r);
|
||||||
|
if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hands(ctx, c) {
|
||||||
|
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true));
|
||||||
|
const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose';
|
||||||
|
c.hands.player.forEach((card, i) => {
|
||||||
|
const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i;
|
||||||
|
const up = chosen ? 8 : 0;
|
||||||
|
if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h);
|
||||||
|
drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function summary(ctx, c, names, title) {
|
||||||
|
const s = c.summary;
|
||||||
|
panel(ctx, 140, 80, 360, 200);
|
||||||
|
text(ctx, title, 320, 96, 8, gold, 'center');
|
||||||
|
text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center');
|
||||||
|
s.rows.forEach((r, i) => {
|
||||||
|
const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v);
|
||||||
|
text(ctx, r.label, 160, y, 7, paper);
|
||||||
|
text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center');
|
||||||
|
text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center');
|
||||||
|
});
|
||||||
|
text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center');
|
||||||
|
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center');
|
||||||
|
}
|
||||||
|
export function drawScopa(ctx, c, def) {
|
||||||
|
const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' };
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
drawTable(ctx, 'SCOPA ALL’ORATORIO');
|
||||||
|
board(ctx, c, names); table(ctx, c); hands(ctx, c);
|
||||||
|
if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`);
|
||||||
|
if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello,
|
||||||
|
// primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari.
|
||||||
|
import { DECKS, cardName } from './briscola.js';
|
||||||
|
|
||||||
|
export { cardName };
|
||||||
|
export const LEVELS = ['bona', 'seria', 'male'];
|
||||||
|
export const TARGET = 11;
|
||||||
|
// I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi
|
||||||
|
// volano sul mucchietto di chi ha preso.
|
||||||
|
export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35;
|
||||||
|
export const COIN = { toscane: 'mattoni', napoletane: 'denari' };
|
||||||
|
// Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14,
|
||||||
|
// 3 13, 2 12, le figure 10.
|
||||||
|
const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10];
|
||||||
|
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||||
|
Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] }))));
|
||||||
|
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||||
|
export const other = who => who === 'player' ? 'cpu' : 'player';
|
||||||
|
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
|
||||||
|
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a
|
||||||
|
// scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria.
|
||||||
|
export function captures(table, card) {
|
||||||
|
const same = table.filter(a => a.value === card.value);
|
||||||
|
if (same.length) return same.map(a => [a]);
|
||||||
|
const out = [];
|
||||||
|
for (let mask = 1; mask < 1 << table.length; mask++) {
|
||||||
|
const pick = table.filter((_, i) => mask & 1 << i);
|
||||||
|
if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7;
|
||||||
|
// Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa.
|
||||||
|
export function primiera(cards, deck) {
|
||||||
|
const suits = DECKS[deck].suits;
|
||||||
|
if (!suits.every(s => cards.some(a => a.suit === s))) return 0;
|
||||||
|
return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0);
|
||||||
|
}
|
||||||
|
// I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa.
|
||||||
|
export function tally(taken, scope, deck) {
|
||||||
|
const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null;
|
||||||
|
const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck);
|
||||||
|
rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) });
|
||||||
|
rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) });
|
||||||
|
const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu';
|
||||||
|
rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven });
|
||||||
|
rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) });
|
||||||
|
const points = { player: 0, cpu: 0 };
|
||||||
|
for (const r of rows) if (r.winner) points[r.winner]++;
|
||||||
|
rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null });
|
||||||
|
points.player += scope.player; points.cpu += scope.cpu;
|
||||||
|
return { rows, points };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deal(c) {
|
||||||
|
// Con tre o quattro re in tavola si rimescola.
|
||||||
|
let stock;
|
||||||
|
do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); }
|
||||||
|
while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3);
|
||||||
|
c.table = stock.splice(0, 4); c.stock = stock;
|
||||||
|
c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 };
|
||||||
|
c.current = other(c.dealer); c.lastTaker = null; c.round++;
|
||||||
|
refill(c); c.events.push({ kind: 'card-shuffle' });
|
||||||
|
}
|
||||||
|
// Tre carte a testa, prima a chi gioca per primo.
|
||||||
|
function refill(c) {
|
||||||
|
for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift());
|
||||||
|
c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null;
|
||||||
|
}
|
||||||
|
export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||||
|
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 },
|
||||||
|
round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null };
|
||||||
|
c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu';
|
||||||
|
deal(c); return c;
|
||||||
|
}
|
||||||
|
export const canPlay = c => c.step === 'play' && c.current === 'player';
|
||||||
|
export function select(c, index) {
|
||||||
|
if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; }
|
||||||
|
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
|
||||||
|
c.selected = index; return true;
|
||||||
|
}
|
||||||
|
export function nudge(c, direction) {
|
||||||
|
if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); }
|
||||||
|
const n = c.hands.player.length;
|
||||||
|
return select(c, (c.selected + direction + n) % n);
|
||||||
|
}
|
||||||
|
// Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»).
|
||||||
|
export function play(c, index = c.selected, who = 'player', option = null) {
|
||||||
|
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||||
|
const options = captures(c.table, c.hands[who][index]);
|
||||||
|
if (options.length > 1 && option === null) {
|
||||||
|
if (who !== 'player') return false;
|
||||||
|
c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true;
|
||||||
|
}
|
||||||
|
return execute(c, who, index, options.length ? options[option ?? 0] : null);
|
||||||
|
}
|
||||||
|
export function take(c, k = c.choice?.selected) {
|
||||||
|
if (c.step !== 'choose' || !c.choice.options[k]) return false;
|
||||||
|
const { index, options } = c.choice; c.step = 'play'; c.choice = null;
|
||||||
|
return execute(c, 'player', index, options[k]);
|
||||||
|
}
|
||||||
|
export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; }
|
||||||
|
function execute(c, who, index, captured) {
|
||||||
|
if (!captured && captures(c.table, c.hands[who][index]).length) return false;
|
||||||
|
const before = [...c.table], [card] = c.hands[who].splice(index, 1);
|
||||||
|
if (captured) {
|
||||||
|
const ids = new Set(captured.map(a => a.id));
|
||||||
|
c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who;
|
||||||
|
} else c.table.push(card);
|
||||||
|
// L'ultima carta della smazzata non fa scopa.
|
||||||
|
const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length;
|
||||||
|
const scopa = !!captured && !c.table.length && !lastPlay;
|
||||||
|
if (scopa) c.scope[who]++;
|
||||||
|
// `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa.
|
||||||
|
c.last = { who, card, captured: captured ?? [], scopa, from: index, before };
|
||||||
|
c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : []));
|
||||||
|
c.step = 'take'; c.t = 0;
|
||||||
|
if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function after(c) {
|
||||||
|
c.current = other(c.last.who); c.t = 0; c.step = 'play';
|
||||||
|
if (c.hands.player.length || c.hands.cpu.length) return;
|
||||||
|
if (c.stock.length) { refill(c); return; }
|
||||||
|
// Fine smazzata: quel che resta in tavola va all'ultimo che ha preso.
|
||||||
|
if (c.lastTaker) c.taken[c.lastTaker].push(...c.table);
|
||||||
|
c.table = [];
|
||||||
|
const t = tally(c.taken, c.scope, c.deck);
|
||||||
|
c.score.player += t.points.player; c.score.cpu += t.points.cpu;
|
||||||
|
c.summary = { ...t, score: { ...c.score } }; c.t = 0;
|
||||||
|
// Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria.
|
||||||
|
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; }
|
||||||
|
else c.step = 'smazzata';
|
||||||
|
}
|
||||||
|
// Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra.
|
||||||
|
export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu;
|
||||||
|
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||||
|
export function advance(c) {
|
||||||
|
if (c.step !== 'smazzata') return false;
|
||||||
|
c.dealer = other(c.dealer); deal(c);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte).
|
||||||
|
export function observation(c, who = 'cpu') {
|
||||||
|
return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] },
|
||||||
|
opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope }, lastTaker: c.lastTaker };
|
||||||
|
}
|
||||||
|
// Le carte che non si sono viste: in mano all'altro o nel mazzo.
|
||||||
|
export function unseen(v) {
|
||||||
|
const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id));
|
||||||
|
return DECK_CARDS[v.deck].filter(a => !seen.has(a.id));
|
||||||
|
}
|
||||||
|
// Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare
|
||||||
|
// una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore
|
||||||
|
// possono essere ancora in mano all'altro.
|
||||||
|
export function score(v, card, captured, counting) {
|
||||||
|
const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount;
|
||||||
|
const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card];
|
||||||
|
// La primiera vale per quanto migliora la propria: un 7 di un seme dove si ha già un 7 non aggiunge niente,
|
||||||
|
// la prima carta di un seme che manca aggiunge parecchio.
|
||||||
|
const mine = v.taken[v.who] ?? [], best = suit => Math.max(0, ...mine.filter(a => a.suit === suit).map(a => a.prime));
|
||||||
|
const prime = a => Math.max(0, a.prime - best(a.suit)) / 4 + (best(a.suit) ? 0 : 1);
|
||||||
|
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + prime(a);
|
||||||
|
let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6;
|
||||||
|
const sum = left.reduce((n, a) => n + a.value, 0);
|
||||||
|
if (left.length && sum <= 10 && v.opponentCount) {
|
||||||
|
let p = 0.5;
|
||||||
|
if (counting) {
|
||||||
|
const pool = unseen(v), same = pool.filter(a => a.value === sum).length;
|
||||||
|
p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0;
|
||||||
|
}
|
||||||
|
value -= p * 20;
|
||||||
|
}
|
||||||
|
// Lasciare in tavola un 7 o il settebello vuol dire regalarli.
|
||||||
|
value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
export function moves(v) {
|
||||||
|
return v.hand.flatMap((card, index) => {
|
||||||
|
const opts = captures(v.table, card);
|
||||||
|
return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più.
|
||||||
|
// Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 96% contro «Alla bona», 76% contro «Seria», 50% contro «A fassi male».
|
||||||
|
// A mazzo finito le carte dell'altro sono proprio quelle non viste: si calcola il finale esatto, fino al conteggio
|
||||||
|
// (carte, mattoni, settebello, primiera, scope, e la tavola all'ultimo che prende).
|
||||||
|
export function endgame(v) {
|
||||||
|
const me = v.who, them = other(me), memo = new Map();
|
||||||
|
const final = (taken, scope, table, last) => {
|
||||||
|
const all = { player: [...taken.player], cpu: [...taken.cpu] };
|
||||||
|
if (last) all[last].push(...table);
|
||||||
|
const t = tally(all, scope, v.deck);
|
||||||
|
return t.points[me] - t.points[them];
|
||||||
|
};
|
||||||
|
const solve = (hands, table, taken, scope, last, turn) => {
|
||||||
|
if (!hands.player.length && !hands.cpu.length) return final(taken, scope, table, last);
|
||||||
|
const key = `${turn}|${hands[me].map(a => a.id)}|${hands[them].map(a => a.id)}|${table.map(a => a.id).sort()}|${last}|${taken.player.length}|${scope.player}${scope.cpu}`;
|
||||||
|
if (memo.has(key)) return memo.get(key);
|
||||||
|
let best = turn === me ? -Infinity : Infinity;
|
||||||
|
for (const m of moves({ hand: hands[turn], table })) {
|
||||||
|
const r = step(hands, table, taken, scope, last, turn, m);
|
||||||
|
const value = solve(...r);
|
||||||
|
best = turn === me ? Math.max(best, value) : Math.min(best, value);
|
||||||
|
}
|
||||||
|
memo.set(key, best); return best;
|
||||||
|
};
|
||||||
|
const step = (hands, table, taken, scope, last, turn, m) => {
|
||||||
|
const card = hands[turn][m.index], rest = { ...hands, [turn]: hands[turn].filter((_, i) => i !== m.index) };
|
||||||
|
if (!m.captured) return [rest, [...table, card], taken, scope, last, other(turn)];
|
||||||
|
const ids = new Set(m.captured.map(a => a.id)), left = table.filter(a => !ids.has(a.id));
|
||||||
|
const end = !rest.player.length && !rest.cpu.length;
|
||||||
|
return [rest, left, { ...taken, [turn]: [...taken[turn], card, ...m.captured] },
|
||||||
|
!left.length && !end ? { ...scope, [turn]: scope[turn] + 1 } : scope, turn, other(turn)];
|
||||||
|
};
|
||||||
|
const hands = { [me]: v.hand, [them]: unseen(v) };
|
||||||
|
const start = [hands, v.table, v.taken, v.scope ?? { player: 0, cpu: 0 }, v.lastTaker ?? null, me];
|
||||||
|
return moves(v).map(m => ({ m, value: solve(...step(...start, m)) })).reduce((a, b) => b.value > a.value ? b : a).m;
|
||||||
|
}
|
||||||
|
export function choose(v, level = 'seria', seed = 1) {
|
||||||
|
const all = moves(v);
|
||||||
|
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
|
||||||
|
const counting = level === 'male';
|
||||||
|
if (counting && !v.stock && unseen(v).length === v.opponentCount) return endgame(v);
|
||||||
|
return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a);
|
||||||
|
}
|
||||||
|
export function update(c, dt) {
|
||||||
|
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||||
|
c.t += dt;
|
||||||
|
if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c);
|
||||||
|
else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) {
|
||||||
|
c.aiSeed = lcg(c.aiSeed);
|
||||||
|
const m = choose(observation(c, 'cpu'), c.level, c.aiSeed);
|
||||||
|
play(c, m.index, 'cpu', m.option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
||||||
|
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`;
|
||||||
|
if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`;
|
||||||
|
if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.';
|
||||||
|
if (c.step === 'take') {
|
||||||
|
const l = c.last;
|
||||||
|
return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`;
|
||||||
|
}
|
||||||
|
// A parole anche la tavola, per chi non vede il disegno.
|
||||||
|
const table = `In tavola: ${c.table.map(cardName).join(', ') || 'niente'}.`;
|
||||||
|
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} pensa…`;
|
||||||
|
}
|
||||||
@@ -101,6 +101,16 @@ 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); }
|
||||||
|
// I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
|
||||||
|
else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
|
||||||
|
else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
|
||||||
|
else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, 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 });
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { canPlay, legal, accuseName, team, NAMES, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './tressette.js';
|
||||||
|
import { drawCard, drawTable, flight } from './briscola-render.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
// La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco.
|
||||||
|
export const CARD = { w: 42, h: 60, y: 288, lift: 8 };
|
||||||
|
export function handX(n, i) {
|
||||||
|
const gap = n > 1 ? Math.min(44, (440 - CARD.w) / (n - 1)) : 0;
|
||||||
|
return Math.round(320 - (gap * (n - 1) + CARD.w) / 2 + i * gap);
|
||||||
|
}
|
||||||
|
// Le carte si coprono: vince quella sopra, cioè la più a destra.
|
||||||
|
export function cardAt(c, [x, y]) {
|
||||||
|
if (!canPlay(c)) return -1;
|
||||||
|
const n = c.hands[0].length;
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
const top = CARD.y - (c.selected === i ? CARD.lift : 0), left = handX(n, i);
|
||||||
|
if (x >= left && x < left + CARD.w && y >= top && y < top + CARD.h + CARD.lift) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Dove cade la carta di ognuno al centro, e da dove arriva.
|
||||||
|
const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]];
|
||||||
|
// I mucchietti delle prese: noi in basso a destra, loro in basso a sinistra.
|
||||||
|
const PILE = [[560, 288], [34, 288]];
|
||||||
|
const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']];
|
||||||
|
const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3';
|
||||||
|
const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' };
|
||||||
|
|
||||||
|
function others(ctx, c, names) {
|
||||||
|
// Il socio di fronte, gli avversari ai lati: carte coperte, quante ne hanno.
|
||||||
|
const n2 = c.hands[2].length, w2 = n2 ? (n2 - 1) * 12 + 26 : 0;
|
||||||
|
for (let i = 0; i < n2; i++) drawCard(ctx, null, 320 - w2 / 2 + i * 12, 46, 26, 37, true);
|
||||||
|
for (const [seat, x] of [[3, 30], [1, 584]]) {
|
||||||
|
for (let i = 0; i < c.hands[seat].length; i++) drawCard(ctx, null, x, 102 + i * 12, 26, 37, true);
|
||||||
|
}
|
||||||
|
for (const seat of [1, 2, 3]) {
|
||||||
|
// Chi deve giocare ha il nome in giallo.
|
||||||
|
const [x, y, align] = LABEL[seat], turn = c.step === 'play' && c.current === seat;
|
||||||
|
text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function piles(ctx, c) {
|
||||||
|
// Quante prese ha fatto ogni coppia, a mucchietto (una carta coperta ogni due prese).
|
||||||
|
for (const t of [0, 1]) {
|
||||||
|
const n = c.tricks.filter(x => team(x.winner) === t).length, [x, y] = PILE[t];
|
||||||
|
for (let k = 0; k < Math.ceil(n / 2); k++) drawCard(ctx, null, x + k, y - k, 42, 60, true);
|
||||||
|
text(ctx, t ? 'Prese loro' : 'Prese noi', x + 21, y - 8, 5, t ? theirs : ours, 'center');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scoreboard(ctx, c) {
|
||||||
|
panel(ctx, 26, 34, 118, 38);
|
||||||
|
text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours);
|
||||||
|
text(ctx, `Loro ${c.score[1]}`, 136, 46, 7, theirs, 'right');
|
||||||
|
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
|
||||||
|
}
|
||||||
|
function accuses(ctx, c, names) {
|
||||||
|
// Le accuse dette restano in vista per tutta la smazzata.
|
||||||
|
if (!c.accuse.length) return;
|
||||||
|
// Largo abbastanza per «Il giocatore: tre Assi, manca mattoni +3» (200 px nel carattere a 5).
|
||||||
|
panel(ctx, 396, 34, 218, 12 + c.accuse.length * 11);
|
||||||
|
c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 403, 41 + i * 11, 5, a.who % 2 ? theirs : ours));
|
||||||
|
}
|
||||||
|
function trick(ctx, c, names) {
|
||||||
|
// Da dove parte la carta: la mano di Andrea, o le carte coperte degli altri.
|
||||||
|
const from = p => p.who === 0 ? [handX(c.hands[0].length + 1, p.from ?? 0), CARD.y, CARD.w, CARD.h]
|
||||||
|
: p.who === 2 ? [300, 46, 26, 37] : [p.who === 1 ? 584 : 30, 102 + (p.from ?? 0) * 12, 26, 37];
|
||||||
|
c.table.forEach((p, i) => {
|
||||||
|
const [x, y] = SPOT[p.who];
|
||||||
|
let r = [x, y, CARD.w, CARD.h];
|
||||||
|
if (i === c.table.length - 1 && c.t < PLAY_TIME && (c.step === 'play' || c.step === 'trick')) r = flight(from(p), r, c.t / PLAY_TIME);
|
||||||
|
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(r, [...PILE[team(c.last.winner)], 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
|
||||||
|
drawCard(ctx, p.card, ...r);
|
||||||
|
if (p.signal) {
|
||||||
|
const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30];
|
||||||
|
panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (c.step === 'trick') text(ctx, `Prende ${names[c.last.winner]}`, 320, 262, 6, gold, 'center');
|
||||||
|
}
|
||||||
|
function hand(ctx, c) {
|
||||||
|
const cards = c.hands[0], n = cards.length, mine = canPlay(c), ok = mine ? legal(cards, c.table) : [];
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const x = handX(n, i), up = mine && c.selected === i ? CARD.lift : 0;
|
||||||
|
if (up) { ctx.fillStyle = '#ffdb76'; ctx.fillRect(x - 2, CARD.y - up - 2, CARD.w + 4, CARD.h + 4); }
|
||||||
|
drawCard(ctx, card, x, CARD.y - up, CARD.w, CARD.h);
|
||||||
|
// Le carte che non rispondono al seme si spengono.
|
||||||
|
if (mine && !ok.includes(i)) { ctx.fillStyle = 'rgba(20,28,24,0.55)'; ctx.fillRect(x, CARD.y, CARD.w, CARD.h); }
|
||||||
|
});
|
||||||
|
if (mine && !c.table.length && c.signal) text(ctx, `Segno: ${SAY[c.signal]}`, 320, 278, 6, gold, 'center');
|
||||||
|
}
|
||||||
|
function summary(ctx, c, title) {
|
||||||
|
const s = c.summary, row = (y, who, t, color) =>
|
||||||
|
text(ctx, `${who}: ${s.thirds[t]} terzi = ${s.points[t]} punti${s.accused[t] ? `, accuse +${s.accused[t]}` : ''}${s.lastTeam === t ? ' (con l’ultima)' : ''}`, 172, y, 6, color);
|
||||||
|
panel(ctx, 160, 96, 320, 132);
|
||||||
|
text(ctx, title, 320, 112, 8, gold, 'center');
|
||||||
|
row(140, 'Noi', 0, ours); row(158, 'Loro', 1, theirs);
|
||||||
|
text(ctx, `Siamo ${c.score[0]} a ${c.score[1]}`, 320, 186, 8, paper, 'center');
|
||||||
|
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 210, 5, paper, 'center');
|
||||||
|
}
|
||||||
|
export function drawTressette(ctx, c, def) {
|
||||||
|
const names = def?.seats ?? NAMES;
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
drawTable(ctx, 'TRESSETTE AI 21');
|
||||||
|
scoreboard(ctx, c); others(ctx, c, names); piles(ctx, c); accuses(ctx, c, names); hand(ctx, c); trick(ctx, c, names);
|
||||||
|
if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
|
||||||
|
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro');
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// Tressette a coppie «ai 21», come si gioca in Mugello: quattro al tavolo, Andrea e il socio contro gli altri due.
|
||||||
|
// Regole e avversari senza DOM, come la briscola. Segni di chi esce (busso, striscio, volo) e accuse a inizio smazzata.
|
||||||
|
import { DECKS, cardName } from './briscola.js';
|
||||||
|
|
||||||
|
export { cardName };
|
||||||
|
export const LEVELS = ['bona', 'seria', 'male'];
|
||||||
|
export const SIGNALS = ['busso', 'striscio', 'volo'];
|
||||||
|
export const TARGET = 21;
|
||||||
|
// I tempi delle animazioni (tressette-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
|
||||||
|
// poi vola sul mucchietto della coppia che ha preso.
|
||||||
|
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.8, TRICK_TIME = 1.35;
|
||||||
|
// Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra.
|
||||||
|
export const SEATS = [0, 1, 2, 3];
|
||||||
|
export const team = seat => seat % 2;
|
||||||
|
export const next = seat => (seat + 1) % 4;
|
||||||
|
export const partner = seat => (seat + 2) % 4;
|
||||||
|
// Ordine di presa 3 > 2 > Asso > Re > Cavallo > Fante > 7 > 6 > 5 > 4 (indici delle carte: Asso, 2, …, 7, figure).
|
||||||
|
const POWER = [7, 8, 9, 0, 1, 2, 3, 4, 5, 6];
|
||||||
|
// Punti in terzi: l'Asso vale un punto, il 2, il 3 e le figure un terzo. 32 terzi più 3 dell'ultima presa: 11 punti.
|
||||||
|
const THIRDS = [3, 1, 1, 0, 0, 0, 0, 1, 1, 1];
|
||||||
|
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||||
|
Object.freeze({ id: s * 10 + r, suit, rank, thirds: THIRDS[r], power: POWER[r] }))));
|
||||||
|
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||||
|
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
|
||||||
|
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 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;
|
||||||
|
}
|
||||||
|
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
|
||||||
|
|
||||||
|
// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
|
||||||
|
// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre.
|
||||||
|
export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) {
|
||||||
|
const out = [], tops = ['Asso', '2', '3'];
|
||||||
|
for (const suit of new Set(hand.map(a => a.suit))) {
|
||||||
|
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
|
||||||
|
if (cards.length === 3) out.push({ kind: 'napoletana', suit, points: 3, cards });
|
||||||
|
}
|
||||||
|
for (const rank of tops) {
|
||||||
|
const cards = hand.filter(a => a.rank === rank);
|
||||||
|
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}`
|
||||||
|
: `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`;
|
||||||
|
|
||||||
|
// Si risponde al seme quando si può; chi esce gioca quel che vuole.
|
||||||
|
export function legal(hand, table) {
|
||||||
|
const led = table[0]?.card.suit, same = hand.flatMap((a, i) => a.suit === led ? [i] : []);
|
||||||
|
return same.length ? same : hand.map((_, i) => i);
|
||||||
|
}
|
||||||
|
export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a);
|
||||||
|
// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e
|
||||||
|
// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi.
|
||||||
|
export function signalOk(hand, index, table, signal) {
|
||||||
|
if (!signal) return true;
|
||||||
|
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
|
||||||
|
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
|
||||||
|
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true;
|
||||||
|
}
|
||||||
|
// Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene.
|
||||||
|
export function reason(c, index, signal = c.signal) {
|
||||||
|
const hand = c.hands[0], card = hand[index];
|
||||||
|
if (!card) return 'Scegli una carta.';
|
||||||
|
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
|
||||||
|
if (!signal || signalOk(hand, index, c.table, signal)) return '';
|
||||||
|
if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.';
|
||||||
|
if (c.table.length) return 'I segni li fa solo chi esce.';
|
||||||
|
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
|
||||||
|
function deal(c) {
|
||||||
|
c.seed = lcg(c.seed);
|
||||||
|
const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
|
||||||
|
c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
|
||||||
|
// Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere.
|
||||||
|
c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
|
||||||
|
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
|
||||||
|
// Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
|
||||||
|
c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a })));
|
||||||
|
c.accuse = [];
|
||||||
|
c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
|
||||||
|
c.events.push({ kind: 'card-shuffle' });
|
||||||
|
}
|
||||||
|
export function newTressette(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||||
|
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: [0, 0], round: 0,
|
||||||
|
seed: seed >>> 0, aiSeed: (seed ^ 0x85ebca6b) >>> 0, age: 0, events: [], last: null, summary: null };
|
||||||
|
c.dealer = Math.floor(rng(seed)() * 4);
|
||||||
|
deal(c); return c;
|
||||||
|
}
|
||||||
|
export const canPlay = c => c.step === 'play' && c.current === 0;
|
||||||
|
export function select(c, index) {
|
||||||
|
if (!canPlay(c) || !Number.isInteger(index) || !c.hands[0][index]) return false;
|
||||||
|
c.selected = index; c.refused = ''; return true;
|
||||||
|
}
|
||||||
|
export function nudge(c, direction) {
|
||||||
|
const n = c.hands[0].length;
|
||||||
|
return select(c, (c.selected + direction + n) % n);
|
||||||
|
}
|
||||||
|
// Il segno di Andrea vale per la prossima carta con cui esce; ripremuto, si toglie.
|
||||||
|
export function setSignal(c, signal) {
|
||||||
|
if (!canPlay(c) || c.table.length || (signal && !SIGNALS.includes(signal))) return false;
|
||||||
|
c.signal = c.signal === signal ? null : signal; c.refused = ''; return true;
|
||||||
|
}
|
||||||
|
export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.signal : null) {
|
||||||
|
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||||
|
if (!legal(c.hands[who], c.table).includes(index) || !signalOk(c.hands[who], index, c.table, signal)) {
|
||||||
|
if (who === 0) c.refused = reason(c, index, signal);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!c.tricks.length && c.pending[who].length) {
|
||||||
|
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
|
||||||
|
c.events.push({ kind: 'accusa', who });
|
||||||
|
// Un'accusa che porta ai 21 fa uscire subito: è il primo evento decisivo, prima ancora della carta.
|
||||||
|
if (out(c)) return true;
|
||||||
|
}
|
||||||
|
const [card] = c.hands[who].splice(index, 1);
|
||||||
|
c.table.push({ who, card, signal: signal || null, from: index }); c.t = 0; c.landing = true; // `from`: per il disegno
|
||||||
|
// Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
|
||||||
|
c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who });
|
||||||
|
if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); }
|
||||||
|
if (c.table.length === 4) { c.step = 'trick'; c.last = { winner: best(c.table).who, cards: c.table.map(p => p.card) }; }
|
||||||
|
else c.current = next(who);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi.
|
||||||
|
const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0);
|
||||||
|
function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); }
|
||||||
|
export function collect(c) {
|
||||||
|
if (c.step !== 'trick') return false;
|
||||||
|
const winner = c.last.winner, end = !c.hands[0].length;
|
||||||
|
// L'ultima presa vale un punto; i terzi avanzati non contano.
|
||||||
|
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0);
|
||||||
|
c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
|
||||||
|
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||||
|
recount(c);
|
||||||
|
if (out(c, end ? team(winner) : null)) return true;
|
||||||
|
if (end) { summarize(c, team(winner)); c.step = 'smazzata'; return true; }
|
||||||
|
c.step = 'play';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function summarize(c, lastTeam = null) {
|
||||||
|
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
|
||||||
|
lastTeam, score: [...c.score] };
|
||||||
|
}
|
||||||
|
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata. Il controllo si fa a ogni evento che
|
||||||
|
// porta punti (un'accusa, una presa), e ognuno ne porta a una coppia sola: non ci si arriva mai insieme.
|
||||||
|
function out(c, lastTeam = null) {
|
||||||
|
const t = [0, 1].find(t => c.score[t] >= TARGET);
|
||||||
|
if (t === undefined) return false;
|
||||||
|
summarize(c, lastTeam); c.summary.out = t;
|
||||||
|
c.step = 'result'; c.result = { winner: t === 0 ? 'player' : 'cpu', score: [...c.score] };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export const finished = c => c.step === 'result';
|
||||||
|
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||||
|
export function advance(c) {
|
||||||
|
if (c.step !== 'smazzata') return false;
|
||||||
|
deal(c); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
|
||||||
|
export function observation(c, seat) {
|
||||||
|
// Anche il punteggio e i terzi presi: sono sul tabellone, li vede chiunque.
|
||||||
|
return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })), score: [...c.score], thirds: [...c.thirds],
|
||||||
|
tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })),
|
||||||
|
accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
|
||||||
|
}
|
||||||
|
// Quel che un giocatore sa. Chi conta davvero («A fassi male») ricorda le carte uscite, i semi finiti di ognuno e le
|
||||||
|
// carte accusate dagli altri.
|
||||||
|
export function knowledge(v, full) {
|
||||||
|
// Chi non conta («Seria») vede solo le carte in tavola e le sue: un 3 già uscito per lui può essere ancora in giro.
|
||||||
|
const played = [...(full ? v.tricks.flatMap(t => t.cards) : []), ...v.table];
|
||||||
|
const gone = new Set([...v.hand, ...played.map(p => p.card)].map(a => a.id));
|
||||||
|
const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
|
||||||
|
const voids = SEATS.map(() => new Set()), holder = new Map();
|
||||||
|
if (full) {
|
||||||
|
// Chi non risponde al seme l'ha finito; chi vola anche.
|
||||||
|
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => {
|
||||||
|
if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit);
|
||||||
|
if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit);
|
||||||
|
});
|
||||||
|
for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who);
|
||||||
|
}
|
||||||
|
const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat);
|
||||||
|
// Qualcuno fra `seats` può ancora battere `card`?
|
||||||
|
const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
|
||||||
|
return { out, voids, could, beatable, full };
|
||||||
|
}
|
||||||
|
// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo.
|
||||||
|
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0));
|
||||||
|
const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
|
||||||
|
function lead(v, k) {
|
||||||
|
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
|
||||||
|
// Il socio ha bussato e la presa l'ho fatta io: gli ritorno il seme con la più bassa.
|
||||||
|
const prev = v.tricks.at(-1), knock = prev?.cards[0];
|
||||||
|
if (k.full && prev?.winner === v.seat && knock?.who === partner(v.seat) && knock.signal === 'busso') {
|
||||||
|
const same = hand.filter(a => a.suit === knock.card.suit);
|
||||||
|
if (same.length) return { index: hand.indexOf(cheap(same)), signal: null };
|
||||||
|
}
|
||||||
|
// Una carta che gli avversari non possono più battere: si fa la presa, e coi punti dentro.
|
||||||
|
const sure = hand.filter(a => !k.beatable(a, foes));
|
||||||
|
if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
|
||||||
|
// Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
|
||||||
|
const count = suit => hand.filter(a => a.suit === suit).length;
|
||||||
|
// A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri).
|
||||||
|
const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit;
|
||||||
|
const card = cheap(hand.filter(a => a.suit === longest));
|
||||||
|
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
|
||||||
|
}
|
||||||
|
function signalFor(hand, card, k) {
|
||||||
|
const rest = hand.filter(a => a !== card && a.suit === card.suit);
|
||||||
|
if (!rest.length) return 'volo';
|
||||||
|
// Busso: la mia più alta è la seconda buona del seme; il socio prenda con la prima e me lo ritorni.
|
||||||
|
const top = rest.reduce((a, b) => b.power > a.power ? b : a);
|
||||||
|
if (top.power > card.power && k.out.filter(a => a.suit === card.suit && a.power > top.power).length === 1) return 'busso';
|
||||||
|
return rest.length >= 2 ? 'striscio' : null;
|
||||||
|
}
|
||||||
|
function follow(v, k, moves) {
|
||||||
|
const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
|
||||||
|
const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
|
||||||
|
const pick = card => v.hand.indexOf(card);
|
||||||
|
// Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme).
|
||||||
|
const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full;
|
||||||
|
if (knock && cards[0].suit === top.card.suit) {
|
||||||
|
const over = cards.filter(a => a.power > top.card.power);
|
||||||
|
if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a));
|
||||||
|
}
|
||||||
|
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). Se un
|
||||||
|
// carico basta per arrivare ai 21, si dà quello, anche il 2 o il 3: si esce e si vince.
|
||||||
|
if (mate && !k.beatable(top.card, after)) {
|
||||||
|
const t = team(v.seat), last = v.hand.length === 1, before = v.score?.[t] ?? 0, thirds = v.thirds?.[t] ?? 0;
|
||||||
|
const sum = v.table.reduce((n, p) => n + p.card.thirds, 0) + (last ? 3 : 0);
|
||||||
|
const wins = cards.filter(a => before - Math.floor(thirds / 3) + Math.floor((thirds + sum + a.thirds) / 3) >= TARGET);
|
||||||
|
return pick(wins.length ? byMin(wins, a => a.power) : rich(cards));
|
||||||
|
}
|
||||||
|
if (cards[0].suit === top.card.suit) {
|
||||||
|
const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
|
||||||
|
if (safe.length) return pick(byMin(safe, a => a.power));
|
||||||
|
}
|
||||||
|
return pick(cheap(cards));
|
||||||
|
}
|
||||||
|
// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
|
||||||
|
// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
|
||||||
|
// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
|
||||||
|
// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md).
|
||||||
|
export function choose(v, level = 'seria', seed = 1) {
|
||||||
|
const moves = legal(v.hand, v.table);
|
||||||
|
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
|
||||||
|
const k = knowledge(v, level === 'male');
|
||||||
|
return v.table.length ? { index: follow(v, k, moves), signal: null } : lead(v, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update(c, dt) {
|
||||||
|
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||||
|
c.t += dt;
|
||||||
|
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||||
|
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
|
||||||
|
else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
|
||||||
|
c.aiSeed = lcg(c.aiSeed);
|
||||||
|
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
|
||||||
|
play(c, index, seat, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
|
||||||
|
export function status(c, names = NAMES) {
|
||||||
|
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
|
||||||
|
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`;
|
||||||
|
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||||
|
// Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
|
||||||
|
const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
|
||||||
|
if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
|
||||||
|
if (c.refused) return c.refused;
|
||||||
|
if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
|
||||||
|
return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ 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 * as tressette from './engine/tressette.js';
|
||||||
|
import { cardAt as tressetteAt } from './engine/tressette-render.js';
|
||||||
|
import * as scopa from './engine/scopa.js';
|
||||||
|
import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js';
|
||||||
import { VERSION, versioned } from './version.js';
|
import { VERSION, versioned } from './version.js';
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
@@ -283,6 +289,38 @@ function closePause() {
|
|||||||
running = true;
|
running = true;
|
||||||
}
|
}
|
||||||
$('pause-button').addEventListener('click', openPause);
|
$('pause-button').addEventListener('click', openPause);
|
||||||
|
// I video «Come si gioca» (story.tutorials): il gioco si ferma come in pausa, la musica tace, il video ha il suo audio.
|
||||||
|
function openTutorial(kind) {
|
||||||
|
const t = story.tutorials?.[kind];
|
||||||
|
if (!t || !running) return;
|
||||||
|
running = false;
|
||||||
|
game.closeupCancel();
|
||||||
|
music.hold = true;
|
||||||
|
voices.pause();
|
||||||
|
ambience.configure({ active: false });
|
||||||
|
$('tutorial-title').textContent = t.title;
|
||||||
|
const video = $('tutorial-video');
|
||||||
|
video.src = versioned(t.src);
|
||||||
|
if (t.poster) video.poster = versioned(t.poster);
|
||||||
|
video.muted = music.muted;
|
||||||
|
$('tutorial').hidden = false;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
$('tutorial-close').focus();
|
||||||
|
}
|
||||||
|
function closeTutorial() {
|
||||||
|
const video = $('tutorial-video');
|
||||||
|
video.pause(); video.removeAttribute('src'); video.load();
|
||||||
|
$('tutorial').hidden = true;
|
||||||
|
music.hold = false;
|
||||||
|
voices.resume();
|
||||||
|
idleSince = performance.now();
|
||||||
|
running = true;
|
||||||
|
}
|
||||||
|
for (const button of document.querySelectorAll('.tutorial-open')) {
|
||||||
|
button.hidden = !story.tutorials?.[button.dataset.tutorial];
|
||||||
|
button.addEventListener('click', () => openTutorial(button.dataset.tutorial));
|
||||||
|
}
|
||||||
|
$('tutorial-close').addEventListener('click', closeTutorial);
|
||||||
// Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello;
|
// Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello;
|
||||||
// la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi).
|
// la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi).
|
||||||
function leave(hidden) {
|
function leave(hidden) {
|
||||||
@@ -399,11 +437,27 @@ 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) {
|
||||||
|
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
|
||||||
|
if (deck) {
|
||||||
|
if (game.skip()) return;
|
||||||
|
// Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
|
||||||
|
if (MINI[type].advance?.(game.closeup)) return;
|
||||||
|
// Scopa, più prese possibili: si tocca una carta in tavola (si decide al rilascio, vedi pointerup).
|
||||||
|
if (type === 'scopa' && game.closeup.step === 'choose') {
|
||||||
|
press = { table: scopaTableAt(game.closeup, scenePoint(e)), state: game.closeup };
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const aimed = game.closeupAim(scenePoint(e));
|
const aimed = game.closeupAim(scenePoint(e));
|
||||||
if (story.closeups[game.closeup.id].type === 'biliardo') {
|
if (story.closeups[game.closeup.id].type === 'biliardo') {
|
||||||
if (!aimed && !game.speech) return;
|
if (!aimed && !game.speech) return;
|
||||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||||
}
|
}
|
||||||
|
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
|
||||||
press = { closeup: true };
|
press = { closeup: true };
|
||||||
// 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();
|
||||||
@@ -415,6 +469,21 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
});
|
});
|
||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
if (!press) return;
|
if (!press) return;
|
||||||
|
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
|
||||||
|
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
|
||||||
|
if (press.table !== undefined) {
|
||||||
|
const p = press; press = null;
|
||||||
|
const c = game.closeup;
|
||||||
|
if (!running || c !== p.state || c.step !== 'choose' || p.table < 0 || scopaTableAt(c, scenePoint(e)) !== p.table) return;
|
||||||
|
const card = c.table[p.table], options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []);
|
||||||
|
if (options.includes(c.choice.selected)) scopa.take(c); else if (options.length) scopa.select(c, options[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (press.card !== undefined) {
|
||||||
|
const p = press; press = null;
|
||||||
|
if (running && game.closeup === p.state && p.card >= 0 && p.at(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à.
|
||||||
@@ -427,7 +496,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?.table !== 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 });
|
||||||
@@ -435,27 +504,52 @@ 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;
|
||||||
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
|
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
|
||||||
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
|
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
|
||||||
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
|
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||||
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
const aiming = game.closeup && ['biliardo', 'bocce'].includes(story.closeups[game.closeup.id].type);
|
||||||
|
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||||
else if (e.pointerType === 'mouse' || press?.closeup) 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 = {
|
||||||
|
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
||||||
|
tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' },
|
||||||
|
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: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. 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); });
|
||||||
|
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
||||||
|
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const button = $('scopa-card-' + i);
|
||||||
|
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||||
|
button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); });
|
||||||
|
}
|
||||||
|
$('scopa-options').addEventListener('focusin', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.step === 'choose') scopa.select(game.closeup, Number(k)); });
|
||||||
|
$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); });
|
||||||
|
$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); });
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const button = $('tressette-card-' + i);
|
||||||
|
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||||
|
button.addEventListener('focus', () => { if (game?.closeup?.id === 'tressette') tressette.select(game.closeup, i); });
|
||||||
|
}
|
||||||
|
for (const signal of tressette.SIGNALS) $('tressette-' + signal).addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.setSignal(game.closeup, signal); });
|
||||||
|
$('tressette-next').addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.advance(game.closeup); });
|
||||||
|
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)) c.aimLocked = true; });
|
$(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) {
|
||||||
@@ -536,12 +630,81 @@ window.addEventListener('keydown', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (debugKey(e)) return;
|
if (debugKey(e)) return;
|
||||||
|
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
|
||||||
if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; }
|
if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; }
|
||||||
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
||||||
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
|
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
|
||||||
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 === 'scopa') {
|
||||||
|
const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase();
|
||||||
|
if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; }
|
||||||
|
// Sui pulsanti delle carte e delle prese Invio e Spazio valgono per quella scelta (una sola fonte: lo stato).
|
||||||
|
const choice = e.target.id?.startsWith('scopa-card-') || e.target.dataset?.option !== undefined;
|
||||||
|
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key) && !choice) return;
|
||||||
|
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat || game.skip()) return;
|
||||||
|
if (/^[1-9]$/.test(key)) scopa.select(c, Number(key) - 1);
|
||||||
|
else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
|
||||||
|
else if (c.step === 'result') game.closeupClose();
|
||||||
|
else if (c.step === 'choose') scopa.take(c);
|
||||||
|
else if (!scopa.advance(c)) game.closeupPlay();
|
||||||
|
// Il fuoco segue la scelta, così quel che si vede scelto è quel che parte.
|
||||||
|
if (inControls && c.step === 'choose') $('scopa-options').querySelector(`[data-option="${c.choice.selected}"]`)?.focus();
|
||||||
|
else if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo.
|
||||||
|
if (e.key === 'Escape') { if (!scopa.cancel(c)) game.closeupClose(); }
|
||||||
|
else if (key === 'm') toggleMute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (story.closeups[game.closeup.id].type === 'tressette') {
|
||||||
|
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
|
||||||
|
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
|
||||||
|
// Sui pulsanti della mano Invio e Spazio giocano la carta scelta (anche se se ne è scelta una che non si
|
||||||
|
// può giocare: allora si legge perché), mai quella che aveva il fuoco prima.
|
||||||
|
const card = e.target.id?.startsWith('tressette-card-');
|
||||||
|
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key) && !card) return;
|
||||||
|
const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key];
|
||||||
|
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat || game.skip()) return;
|
||||||
|
if (signal) tressette.setSignal(c, signal);
|
||||||
|
else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10);
|
||||||
|
else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
|
||||||
|
else if (c.step === 'result') game.closeupClose();
|
||||||
|
else if (!tressette.advance(c)) game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
|
||||||
|
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
|
||||||
|
if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') game.closeupClose();
|
||||||
|
else if (key === 'm') toggleMute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 && ['Enter', ' '].includes(e.key) && 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 game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
|
||||||
|
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();
|
||||||
@@ -628,9 +791,72 @@ 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 = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
||||||
|
$('bocce-controls').hidden = !mg || cards;
|
||||||
$('bocce-controls').inert = !running;
|
$('bocce-controls').inert = !running;
|
||||||
if (mg) {
|
$('briscola-controls').hidden = def?.type !== 'briscola';
|
||||||
|
$('briscola-controls').inert = !running;
|
||||||
|
$('scopa-controls').hidden = def?.type !== 'scopa';
|
||||||
|
$('scopa-controls').inert = !running;
|
||||||
|
if (def?.type === 'scopa') {
|
||||||
|
$('panel').hidden = $('choices').hidden = true;
|
||||||
|
const line = scopa.status(c, def.names);
|
||||||
|
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
||||||
|
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
||||||
|
if (button.textContent !== label) button.textContent = label;
|
||||||
|
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
|
||||||
|
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
||||||
|
}
|
||||||
|
// Le prese fra cui scegliere, una per pulsante.
|
||||||
|
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
||||||
|
const box = $('scopa-options');
|
||||||
|
if (box.dataset.list !== options.join('|')) {
|
||||||
|
box.dataset.list = options.join('|');
|
||||||
|
box.replaceChildren(...options.map((o, k) => {
|
||||||
|
const b = Object.assign(document.createElement('button'), { type: 'button', textContent: `Prendi ${o}` });
|
||||||
|
b.dataset.option = k; return b;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
box.hidden = !options.length;
|
||||||
|
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
|
||||||
|
$('scopa-next').hidden = c.step !== 'smazzata';
|
||||||
|
}
|
||||||
|
$('tressette-controls').hidden = def?.type !== 'tressette';
|
||||||
|
$('tressette-controls').inert = !running;
|
||||||
|
if (def?.type === 'tressette') {
|
||||||
|
$('panel').hidden = $('choices').hidden = true;
|
||||||
|
const line = tressette.status(c, def.seats);
|
||||||
|
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
|
||||||
|
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const button = $('tressette-card-' + i), card = c.hands[0][i];
|
||||||
|
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
|
||||||
|
if (button.textContent !== label) button.textContent = label;
|
||||||
|
button.hidden = !card;
|
||||||
|
button.disabled = !ok.includes(i);
|
||||||
|
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
||||||
|
}
|
||||||
|
for (const signal of tressette.SIGNALS) {
|
||||||
|
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
|
||||||
|
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
|
||||||
|
}
|
||||||
|
$('tressette-next').hidden = c.step !== 'smazzata';
|
||||||
|
}
|
||||||
|
if (def?.type === 'briscola') {
|
||||||
|
$('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');
|
$('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);
|
||||||
@@ -641,9 +867,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.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;
|
||||||
@@ -655,6 +881,7 @@ function updateUi() {
|
|||||||
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('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;
|
||||||
@@ -681,7 +908,7 @@ function frame(now) {
|
|||||||
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
|
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
|
||||||
const searching = game?.closeup?.id === 'fessura';
|
const searching = game?.closeup?.id === 'fessura';
|
||||||
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
|
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
|
||||||
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt);
|
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt);
|
||||||
// Un minuto senza toccare niente, a gioco fermo: pausa da sola.
|
// Un minuto senza toccare niente, a gioco fermo: pausa da sola.
|
||||||
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
|
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
|
||||||
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
|
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
|
||||||
|
|||||||
@@ -107,9 +107,35 @@ 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', music: 'assets/audio/carte.mp3', 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' }],
|
||||||
|
},
|
||||||
|
// I tre tavoli da carte suonano il valzer della balera (tools/valzer_carte.py, Andrea: «una sua melodia»).
|
||||||
|
// Il tressette a coppie ai 21 (idea di Andrea): busso, striscio, volo e accuse. [DA ANDREA: chi sono il socio e l'altro]
|
||||||
|
tressette: {
|
||||||
|
type: 'tressette', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
|
||||||
|
seats: ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'],
|
||||||
|
win: [{ set: 'tressette_vinto' }, { say: 'Ventuno per primi. Bella coppia, voi due!', who: 'giocatore_carte', pose: 'laugh' }],
|
||||||
|
lose: [{ say: 'Ventuno! Il tressette è un\'arte: si studia da piccini.', who: 'giocatore_carte', pose: 'laugh' }],
|
||||||
|
draw: [],
|
||||||
|
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
|
||||||
|
},
|
||||||
|
// La scopa ai 11 (idea di Andrea): carte, mattoni (o denari), settebello, primiera e scope.
|
||||||
|
scopa: {
|
||||||
|
type: 'scopa', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
|
||||||
|
win: [{ set: 'scopa_vinta' }, { say: 'Settebello, primiera e pure le scope. Oggi l\'hai vista bene.', who: 'giocatore_carte', pose: 'talk' }],
|
||||||
|
lose: [{ say: 'Undici! Il settebello lo sento arrivare da lontano.', who: 'giocatore_carte', pose: 'laugh' }],
|
||||||
|
draw: [],
|
||||||
|
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', music: 'assets/audio/bocce.mp3', // la polka del pallaio (tools/polka_bocce.py, Andrea: «un sound suo»)
|
||||||
picture: 'assets/closeups/pallaio.png',
|
picture: 'assets/closeups/pallaio.png',
|
||||||
names: { player: 'Andrea', cpu: 'Mariano' },
|
names: { player: 'Andrea', cpu: 'Mariano' },
|
||||||
win: [
|
win: [
|
||||||
@@ -177,7 +203,17 @@ export const story = {
|
|||||||
paper: '#fbe6bc',
|
paper: '#fbe6bc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// I video «Come si gioca» (video/SCALETTA-CARTE.md): il pulsante «Guarda il video» nell'aiuto di ogni gioco.
|
||||||
|
tutorials: {
|
||||||
|
briscola: { title: 'Come si gioca: la briscola', src: 'assets/video/briscola.mp4', poster: 'assets/video/briscola.jpg' },
|
||||||
|
tressette: { title: 'Come si gioca: il tressette ai 21', src: 'assets/video/tressette.mp4', poster: 'assets/video/tressette.jpg' },
|
||||||
|
scopa: { title: 'Come si gioca: la scopa', src: 'assets/video/scopa.mp4', poster: 'assets/video/scopa.jpg' },
|
||||||
|
bocce: { title: 'Come si gioca: le bocce', src: 'assets/video/bocce.mp4', poster: 'assets/video/bocce.jpg' },
|
||||||
|
biliardo: { title: 'Come si gioca: il biliardo', src: 'assets/video/biliardo.mp4', poster: 'assets/video/biliardo.jpg' },
|
||||||
|
},
|
||||||
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' },
|
||||||
@@ -348,6 +384,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: {
|
||||||
@@ -534,23 +584,13 @@ export const story = {
|
|||||||
map: [56, 45],
|
map: [56, 45],
|
||||||
background: 'assets/scenes/pieve-dalla-salita.png',
|
background: 'assets/scenes/pieve-dalla-salita.png',
|
||||||
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
|
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
|
||||||
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] },
|
speakers: { san_pietro: [300, 100] },
|
||||||
// Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre).
|
|
||||||
people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
|
||||||
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
|
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
|
||||||
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
||||||
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
||||||
depth: [[215, 0.42], [360, 1.15]],
|
depth: [[215, 0.42], [360, 1.15]],
|
||||||
entry: [30, 320],
|
entry: [30, 320],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
{
|
|
||||||
// Battute provvisorie: le vere le scrive Andrea.
|
|
||||||
id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk',
|
|
||||||
actions: {
|
|
||||||
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }],
|
|
||||||
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
|
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -724,6 +764,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],
|
||||||
@@ -748,6 +790,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' }],
|
||||||
@@ -758,8 +809,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' }],
|
||||||
@@ -846,7 +898,19 @@ export const story = {
|
|||||||
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
||||||
depth: [[292, 0.78], [360, 1.1]],
|
depth: [[292, 0.78], [360, 1.1]],
|
||||||
entry: [320, 345],
|
entry: [320, 345],
|
||||||
|
// Fra Peppino da Gricignano (sprite di Codex dalla foto di Andrea, 25 ottobre): stava sul sagrato della Pieve,
|
||||||
|
// ma è un francescano, e il Bosco ai Frati è il loro convento (Andrea, 26 ottobre).
|
||||||
|
speakers: { fra_peppino: [470, 238] },
|
||||||
|
people: [{ who: 'fra_peppino', at: [470, 304], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
|
{
|
||||||
|
// Battute provvisorie: le vere le scrive Andrea.
|
||||||
|
id: 'fra_peppino', name: 'Fra Peppino', box: [458, 248, 26, 58], at: [430, 306], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.' }],
|
||||||
|
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
|
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -2104,6 +2168,53 @@ 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: 'Rivincita a briscola?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
|
||||||
|
{ text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] },
|
||||||
|
{ text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] },
|
||||||
|
{ 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' },
|
||||||
|
] },
|
||||||
|
// La scopa: stesse scelte (e stesse battute) della briscola.
|
||||||
|
mazzo_scopa: { options: [
|
||||||
|
{ text: 'Con le toscane.', next: 'livello_scopa', 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_scopa', 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_scopa: { options: [
|
||||||
|
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'scopa', 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: 'scopa', level: 'male' }] },
|
||||||
|
{ text: 'Ci ripenso.', next: 'end' },
|
||||||
|
] },
|
||||||
|
// Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro.
|
||||||
|
mazzo_tressette: { options: [
|
||||||
|
{ text: 'Con le toscane.', next: 'livello_tressette', 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_tressette', 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_tressette: { options: [
|
||||||
|
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'tressette', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'tressette', 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: 'tressette', 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: {
|
||||||
|
|||||||
@@ -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.9';
|
export const VERSION = '0.1.15';
|
||||||
|
|
||||||
// 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).
|
||||||
|
|||||||
@@ -26,6 +26,25 @@ 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; }
|
||||||
|
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
|
||||||
|
#scopa-status, #tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; }
|
||||||
|
.card-buttons, .card-hand-many { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.card-buttons button, .card-hand-many button { flex: 1 1 18%; overflow-wrap: anywhere; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; }
|
||||||
|
.card-buttons button { flex-basis: 22%; }
|
||||||
|
.card-buttons button[aria-pressed="true"], .card-hand-many button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; }
|
||||||
|
.card-buttons button:disabled, .card-hand-many button:disabled { opacity: .5; cursor: default; }
|
||||||
|
#scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; }
|
||||||
|
#scopa-controls p, #tressette-controls p { max-width: 76em; }
|
||||||
|
#scopa-next { width: 100%; min-height: 40px; margin-bottom: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; cursor: pointer; }
|
||||||
|
.card-hand + .card-buttons { margin-top: 8px; }
|
||||||
|
#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; }
|
||||||
|
.card-hand { display: flex; gap: 8px; }
|
||||||
|
.card-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; }
|
||||||
|
.card-hand button[aria-pressed="true"] { border-color: #ffdb76; }
|
||||||
|
.card-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; }
|
||||||
@@ -60,6 +79,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
|
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
|
||||||
#stage.biliardo #scene { height: auto; }
|
#stage.biliardo #scene { height: auto; }
|
||||||
#stage.biliardo #closeup-close { position: static; display: block; margin: 6px 10px 6px auto; min-height: 32px; }
|
#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;
|
||||||
@@ -91,8 +113,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
||||||
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||||
|
|
||||||
#choices { margin: 0; padding: 10px 14px 12px 2.4em; background: var(--panel); min-height: 9.5em; }
|
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
||||||
#choices li { padding: .25em 0; font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; color: var(--dim); cursor: pointer; }
|
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
|
||||||
|
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||||
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||||
|
|
||||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||||
@@ -178,6 +201,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
|
#map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
|
||||||
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
||||||
#pause { z-index: 3; background: rgba(15, 10, 20, .9); }
|
#pause { z-index: 3; background: rgba(15, 10, 20, .9); }
|
||||||
|
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
|
||||||
|
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
|
||||||
|
#tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
|
||||||
|
.tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; }
|
||||||
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
|
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
|
||||||
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
|
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
|
||||||
.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
|
.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
|
||||||
@@ -257,7 +284,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
|
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
|
||||||
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
|
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
|
||||||
la spiegazione e la versione non finiscono sopra i verbi. */
|
la spiegazione e la versione non finiscono sopra i verbi. */
|
||||||
@media (orientation: portrait) and (max-width: 700px) { #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
|
@media (orientation: portrait) and (max-width: 700px) {
|
||||||
|
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
|
||||||
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
||||||
@@ -270,13 +298,18 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#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.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
|
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
|
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
||||||
|
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
|
||||||
|
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
|
||||||
|
.card-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. */
|
||||||
#speech { max-width: 72%; font-size: 11px; }
|
#speech { max-width: 72%; font-size: 11px; }
|
||||||
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
||||||
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
|
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
|
||||||
#verbs button, #inventory button, #choices li { font-size: 10.08px; }
|
#verbs button, #inventory button, #choices { font-size: 10.08px; }
|
||||||
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
|
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
|
||||||
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
|
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
|
||||||
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -292,7 +325,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#map-board button { width: 68px; }
|
#map-board button { width: 68px; }
|
||||||
#map-board button img { width: 60px; }
|
#map-board button img { width: 60px; }
|
||||||
#pause .hint { font-size: 8px; }
|
#pause .hint { font-size: 8px; }
|
||||||
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
#choices { min-height: 0; padding: 6px 8px 6px 3.6em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// 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])');
|
||||||
|
// Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio.
|
||||||
|
await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3');
|
||||||
|
if (name === 'desktop-mouse') {
|
||||||
|
// Il video «Come si gioca»: dall'aiuto del tavolo si apre il player, il gioco si ferma, Esc torna al tavolo.
|
||||||
|
await page.click('#briscola-controls summary');
|
||||||
|
await page.click('#briscola-controls .tutorial-open');
|
||||||
|
await page.waitForSelector('#tutorial:not([hidden])');
|
||||||
|
assert.match(await page.locator('#tutorial-video').getAttribute('src'), /assets\/video\/briscola\.mp4\?v=/);
|
||||||
|
assert.equal(await page.locator('#tutorial-title').textContent(), 'Come si gioca: la briscola');
|
||||||
|
assert.equal(await page.evaluate(() => music.hold), true, 'la musica del gioco tace');
|
||||||
|
await page.screenshot({ path: `${out}/briscola-video.png` });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.ok(await page.isHidden('#tutorial'));
|
||||||
|
assert.equal(await page.evaluate(() => !music.hold && game.closeup?.id), 'briscola', 'si torna al tavolo');
|
||||||
|
await page.click('#briscola-controls summary');
|
||||||
|
}
|
||||||
|
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: 'Rivincita a briscola?' }).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(); }
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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.41); // TRICK_TIME: 1,4 s, la presa vola sulle prese
|
||||||
|
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.player + c.result.cpu, 120, 'il risultato è già deciso: il conteggio lo mostra soltanto');
|
||||||
|
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?' : 'Rivincita a briscola?');
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Codex (revisione dei tre giochi): l'esito vale appena è deciso. Uscire durante il conteggio della briscola non lo
|
||||||
|
// perde, e i flag finiscono nel salvataggio anche se dal risultato si torna al titolo senza uscire dal tavolo.
|
||||||
|
test('carte all\'oratorio: l\'esito si salva appena deciso, e uscire durante il conteggio non lo perde', () => {
|
||||||
|
const games = [['Una briscola?', 'briscola', 'briscola_vinta'], ['Un tressette ai 21?', 'tressette', 'tressette_vinto'], ['Una scopa?', 'scopa', 'scopa_vinta']];
|
||||||
|
for (const [ask, id, flag] of games) {
|
||||||
|
const saved = [], g = new Game(story, { onSave: s => saved.push(s) });
|
||||||
|
settle(g); g.go('oratorio'); settle(g);
|
||||||
|
g.act('talk', 'giocatore_carte'); settle(g);
|
||||||
|
for (const text of [ask, 'Con le toscane.', 'Seria.']) { g.choose(g.options().findIndex(o => o.text === text)); settle(g); }
|
||||||
|
assert.equal(g.closeup?.id, id);
|
||||||
|
// La briscola durante il conteggio: il risultato c'è già. Gli altri due, a risultato.
|
||||||
|
Object.assign(g.closeup, id === 'briscola' ? { step: 'count', result: { winner: 'player', player: 70, cpu: 50 } } : { step: 'result', result: { winner: 'player' } });
|
||||||
|
g.update(0.01);
|
||||||
|
assert.equal(saved.at(-1).flags[flag], true, `${id}: la vittoria è già nel salvataggio`);
|
||||||
|
assert.equal(g.state.flags[flag], true);
|
||||||
|
g.closeupClose(); const said = settle(g).map(s => s.text);
|
||||||
|
const win = story.closeups[id].win.find(e => e.say).say, quit = story.closeups[id].quit[0].say;
|
||||||
|
assert.ok(said.includes(win) && !said.includes(quit), `${id}: esce con la battuta della vittoria, non con quella di chi lascia`);
|
||||||
|
assert.equal(g.state.flags[flag], true);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -387,6 +387,12 @@ try {
|
|||||||
await bocce.mouse.up();
|
await bocce.mouse.up();
|
||||||
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
||||||
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
||||||
|
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
|
||||||
|
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
|
||||||
|
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
|
||||||
|
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
|
||||||
|
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
|
||||||
|
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
|
||||||
await bocce.keyboard.press('Escape');
|
await bocce.keyboard.press('Escape');
|
||||||
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
||||||
await bocce.close();
|
await bocce.close();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -101,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ test('il brano giusto per titolo, fine e scena', () => {
|
|||||||
assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3');
|
assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3');
|
||||||
assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3');
|
assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3');
|
||||||
assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null);
|
assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null);
|
||||||
|
// Un tavolo da carte aperto ha la sua musica; un primo piano senza musica lascia quella della scena.
|
||||||
|
const tavoli = { ...s, closeups: { carte: { music: 'c.mp3' }, libro: {} } };
|
||||||
|
assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'carte' }), 'c.mp3');
|
||||||
|
assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'libro' }), 'b.mp3');
|
||||||
// La sera ha la sua musica all'aperto; la scena col suo brano e gli interni non cambiano.
|
// La sera ha la sua musica all'aperto; la scena col suo brano e gli interni non cambiano.
|
||||||
const sera = { id: 'sera', music: 's.mp3' };
|
const sera = { id: 'sera', music: 's.mp3' };
|
||||||
assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'a', phase: sera }), 's.mp3');
|
assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'a', phase: sera }), 's.mp3');
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Scopa ai 11 nel browser: carte con mouse, tocco e tastiera, scelta fra più prese; si accelerano solo le attese.
|
||||||
|
// npm run browser:scopa — /tmp/regolo-shots/scopa-*.png
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } 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 tap(page, mode, selector) {
|
||||||
|
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
|
||||||
|
else await page.click(selector);
|
||||||
|
}
|
||||||
|
async function at(page, mode, [px, py]) {
|
||||||
|
// Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa.
|
||||||
|
await page.locator('#scene').scrollIntoViewIfNeeded();
|
||||||
|
const r = await page.locator('#scene').boundingBox(), x = r.x + px / 640 * r.width, y = r.y + py / 360 * r.height;
|
||||||
|
if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
|
||||||
|
}
|
||||||
|
async function playCard(page, mode, index) {
|
||||||
|
if (mode === 'keyboard') {
|
||||||
|
await page.keyboard.press(String(index + 1));
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.selected), index);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = await page.evaluate(async i => {
|
||||||
|
const { handX, HAND } = await import('/src/engine/scopa-render.js');
|
||||||
|
return [handX(game.closeup.hands.player.length, i) + 32, HAND.y + 46];
|
||||||
|
}, index);
|
||||||
|
await at(page, mode, p);
|
||||||
|
}
|
||||||
|
async function wait(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const { canPlay } = await import('/src/engine/scopa.js');
|
||||||
|
// Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui.
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
if (game.speech) game.skip();
|
||||||
|
else if (canPlay(game.closeup) || ['smazzata', 'result', 'choose'].includes(game.closeup.step)) break;
|
||||||
|
else game.update(1.4);
|
||||||
|
}
|
||||||
|
return game.closeup.step;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
const scenarios = [
|
||||||
|
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||||
|
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
||||||
|
{ name: 'desktop-keyboard', mode: 'keyboard', viewport: { width: 1280, height: 860 } },
|
||||||
|
{ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } },
|
||||||
|
];
|
||||||
|
for (const { name, mode, viewport, full } of scenarios) {
|
||||||
|
const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
|
||||||
|
page.on('pageerror', e => errors.push(`${name}: ${e.message}`));
|
||||||
|
page.on('console', e => { if (e.type() === 'error') errors.push(`${name}: ${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); game.act('talk', 'giocatore_carte'); settle(game); });
|
||||||
|
await page.locator('#choices li', { hasText: 'Una scopa?' }).click();
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
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: 'Seria.' }).click();
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
assert.deepEqual(await page.evaluate(() => [game.closeup.deck, game.closeup.level]), [deck, 'seria']);
|
||||||
|
await page.waitForSelector('#scopa-controls:not([hidden])');
|
||||||
|
assert.ok(await page.isHidden('#tressette-controls')); assert.ok(await page.isHidden('#briscola-controls'));
|
||||||
|
await wait(page);
|
||||||
|
await page.screenshot({ path: `${out}/scopa-${name}-inizio.png` });
|
||||||
|
// Una volta per scenario: tavola preparata con due prese possibili per il 7, scelta toccando la tavola,
|
||||||
|
// con un pulsante o con frecce e Invio.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
// Una partita nuova in cui esce Andrea, così in tavola ci sono proprio quattro carte.
|
||||||
|
const { newScopa } = await import('/src/engine/scopa.js'), old = game.closeup;
|
||||||
|
let seed = 1, c;
|
||||||
|
do c = newScopa(seed++, { level: old.level, deck: old.deck }); while (c.current !== 'player');
|
||||||
|
const pool = [...c.stock, ...c.table, c.hands.player[0]];
|
||||||
|
const free = v => pool.splice(pool.findIndex(a => a.value === v), 1)[0];
|
||||||
|
c.table = [free(3), free(4), free(2), free(5)]; c.hands.player[0] = free(7); c.stock = pool;
|
||||||
|
game.closeup = { id: old.id, ...c };
|
||||||
|
});
|
||||||
|
// La tavola preparata non perde né duplica carte.
|
||||||
|
const ids = await page.evaluate(() => { const c = game.closeup; return [...c.stock, ...c.table, ...c.hands.player, ...c.hands.cpu, ...c.taken.player, ...c.taken.cpu].map(a => a.id); });
|
||||||
|
assert.equal(new Set(ids).size, 40); assert.equal(ids.length, 40);
|
||||||
|
await playCard(page, mode, 0);
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.step), 'choose');
|
||||||
|
await page.waitForSelector('#scopa-options:not([hidden])');
|
||||||
|
await page.screenshot({ path: `${out}/scopa-${name}-scelta.png` });
|
||||||
|
// Tastiera (Codex): fuoco sulla prima presa, freccia a destra, Invio: deve partire la seconda.
|
||||||
|
if (mode === 'keyboard') { await page.locator('#scopa-options button:first-child').focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); }
|
||||||
|
else if (mode === 'touch') {
|
||||||
|
// Il 5 in tavola sta solo nella presa 2+5.
|
||||||
|
const p = await page.evaluate(async () => {
|
||||||
|
const { tableXY } = await import('/src/engine/scopa-render.js'), c = game.closeup;
|
||||||
|
const i = c.table.findIndex(a => a.value === 5), [x, y] = tableXY(c.table.length, i); return [x + 21, y + 30];
|
||||||
|
});
|
||||||
|
// Il primo tocco sceglie la presa che lo contiene (evidenziata), il secondo la conferma.
|
||||||
|
await at(page, mode, p);
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.step === 'choose' && game.closeup.choice.selected), 1);
|
||||||
|
await at(page, mode, p);
|
||||||
|
} else await tap(page, mode, '#scopa-options button:nth-child(2)');
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.step), 'take');
|
||||||
|
assert.deepEqual(await page.evaluate(() => game.closeup.last.captured.map(a => a.value).sort()), [2, 5]);
|
||||||
|
await page.screenshot({ path: `${out}/scopa-${name}-presa.png` });
|
||||||
|
let played = 1;
|
||||||
|
for (let steps = 0; steps < 400; steps++) {
|
||||||
|
const step = await wait(page);
|
||||||
|
if (step === 'result') break;
|
||||||
|
if (step === 'smazzata') {
|
||||||
|
await page.screenshot({ path: `${out}/scopa-${name}-smazzata.png` });
|
||||||
|
if (!full) break;
|
||||||
|
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-next');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (step !== 'play' && step !== 'choose') throw new Error('partita ferma in ' + step);
|
||||||
|
if (step === 'choose') {
|
||||||
|
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-options button:first-child');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const move = await page.evaluate(async () => {
|
||||||
|
const { observation, choose } = await import('/src/engine/scopa.js');
|
||||||
|
return choose(observation(game.closeup, 'player'), 'male', 3);
|
||||||
|
});
|
||||||
|
const before = await page.evaluate(() => game.closeup.hands.player.length);
|
||||||
|
await playCard(page, mode, move.index); played++;
|
||||||
|
const after = await page.evaluate(() => game.closeup.step === 'choose' ? -1 : game.closeup.hands.player.length);
|
||||||
|
// Il gioco intanto va avanti da solo: dopo l'ultima carta la mano può essere già ridata.
|
||||||
|
assert.ok(after === -1 || after === before - 1 || (before === 1 && after === 3), `un gesto gioca una carta (${name}: ${before} → ${after})`);
|
||||||
|
}
|
||||||
|
const count = await page.evaluate(() => game.closeup.summary ? game.closeup.summary.rows : null);
|
||||||
|
assert.ok(count, 'conteggio della smazzata');
|
||||||
|
if (full) {
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.step), 'result');
|
||||||
|
await page.screenshot({ path: `${out}/scopa-${name}-risultato.png` });
|
||||||
|
const winner = await page.evaluate(() => game.closeup.result.winner);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup), null);
|
||||||
|
assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), winner === 'player');
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup), null);
|
||||||
|
assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), false);
|
||||||
|
}
|
||||||
|
console.log(`${name}: ${played} carte giocate, presa scelta fra due${full ? ', partita ai 11 finita' : ', una smazzata'} OK`);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []); console.log(`Schermate: ${out}/scopa-*.png`);
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DECK_CARDS, COIN, newScopa, captures, primiera, tally, play, take, cancel, select, nudge, update, advance, finished, observation, unseen, choose, canPlay, status, TARGET } from '../src/engine/scopa.js';
|
||||||
|
import { simulate, calibrate } from '../tools/calibrate_scopa.mjs';
|
||||||
|
|
||||||
|
const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank);
|
||||||
|
|
||||||
|
test('valori: Asso 1, numeri, gobbo 8, donna 9, regio 10; primiera 7 = 21, 6 = 18, Asso 16 … figure 10', () => {
|
||||||
|
assert.deepEqual(['Asso', '5', 'Gobbo', 'Donna', 'Regio'].map(r => card('cuori', r).value), [1, 5, 8, 9, 10]);
|
||||||
|
assert.deepEqual(['7', '6', 'Asso', '5', '4', '3', '2', 'Regio'].map(r => card('fiori', r).prime), [21, 18, 16, 15, 14, 13, 12, 10]);
|
||||||
|
assert.equal(COIN.toscane, 'mattoni'); assert.equal(COIN.napoletane, 'denari');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prese: la carta uguale ha la precedenza; se no ogni somma; più carte uguali, si sceglie', () => {
|
||||||
|
const t = [card('cuori', '3'), card('fiori', '4'), card('picche', '7'), card('mattoni', '2'), card('cuori', '5')];
|
||||||
|
assert.deepEqual(captures(t, card('mattoni', '7')).map(o => o.map(a => a.id)), [[card('picche', '7').id]], 'col 7 si prende il 7, non 3+4 o 2+5');
|
||||||
|
const sums = captures([card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')], card('picche', '7'));
|
||||||
|
assert.equal(sums.length, 2, '3+4 oppure 2+5');
|
||||||
|
assert.deepEqual(captures([card('cuori', '3'), card('fiori', '3')], card('picche', '3')).length, 2);
|
||||||
|
assert.deepEqual(captures([card('cuori', '3')], card('picche', 'Donna')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primiera: la migliore per seme, e senza un seme non si fa', () => {
|
||||||
|
const all = [card('cuori', '7'), card('mattoni', '6'), card('fiori', 'Asso'), card('picche', 'Regio'), card('picche', '2')];
|
||||||
|
assert.equal(primiera(all, 'toscane'), 21 + 18 + 16 + 12);
|
||||||
|
assert.equal(primiera(all.slice(0, 3), 'toscane'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conteggio: carte, mattoni, settebello, primiera e scope; pari non si dà il punto', () => {
|
||||||
|
const deck = DECK_CARDS.toscane, mine = deck.filter(a => a.suit === 'mattoni' || (a.suit === 'cuori' && a.value <= 2));
|
||||||
|
const theirs = deck.filter(a => !mine.includes(a));
|
||||||
|
const t = tally({ player: mine, cpu: theirs }, { player: 2, cpu: 0 }, 'toscane');
|
||||||
|
const row = k => t.rows.find(r => r.key === k);
|
||||||
|
assert.equal(row('carte').winner, 'cpu'); assert.equal(row('coin').winner, 'player'); assert.equal(row('coin').label, 'Mattoni');
|
||||||
|
assert.equal(row('settebello').winner, 'player'); assert.equal(row('primiera').winner, null, 'a nessuno ci sono tutti e quattro i semi');
|
||||||
|
assert.deepEqual(t.points, { player: 4, cpu: 1 });
|
||||||
|
const half = tally({ player: deck.slice(0, 20), cpu: deck.slice(20) }, { player: 0, cpu: 0 }, 'napoletane');
|
||||||
|
assert.equal(half.rows[0].winner, null, '20 a 20: le carte non le prende nessuno');
|
||||||
|
assert.equal(tally({ player: [card('denari', '7', 'napoletane')], cpu: [] }, { player: 0, cpu: 0 }, 'napoletane').rows[2].winner, 'player');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazzata: 4 in tavola (mai tre re), tre a testa, la presa è obbligatoria, la scelta fra più prese', () => {
|
||||||
|
for (let s = 1; s < 60; s++) assert.ok(newScopa(s).table.filter(a => a.value === 10).length < 3);
|
||||||
|
const c = newScopa(3); c.current = 'player';
|
||||||
|
assert.equal(c.table.length, 4); assert.equal(c.hands.player.length, 3); assert.equal(c.stock.length, 30);
|
||||||
|
c.table = [card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')];
|
||||||
|
c.hands.player = [card('picche', '7'), card('picche', 'Regio')];
|
||||||
|
assert.ok(play(c, 0)); assert.equal(c.step, 'choose'); assert.equal(c.choice.options.length, 2);
|
||||||
|
assert.ok(nudge(c, 1)); assert.ok(cancel(c)); assert.equal(c.step, 'play'); assert.equal(c.hands.player.length, 2);
|
||||||
|
assert.ok(play(c, 0)); assert.ok(select(c, 1)); assert.ok(take(c));
|
||||||
|
assert.equal(c.step, 'take'); assert.equal(c.table.length, 2); assert.equal(c.taken.player.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopa: chi pulisce la tavola fa un punto, ma non con l\'ultima carta della smazzata', () => {
|
||||||
|
const c = newScopa(4); c.current = 'player'; c.table = [card('cuori', '3'), card('fiori', '4')];
|
||||||
|
c.hands.player = [card('picche', '7'), card('picche', '2')];
|
||||||
|
assert.ok(play(c, 0)); assert.equal(c.scope.player, 1); assert.equal(c.last.scopa, true); assert.match(status(c), /Scopa di Andrea/);
|
||||||
|
const d = newScopa(4); d.current = 'player'; d.stock = []; d.hands.cpu = []; d.table = [card('cuori', '3')];
|
||||||
|
d.hands.player = [card('picche', '3')];
|
||||||
|
assert.ok(play(d, 0)); assert.equal(d.scope.player, 0, 'all\'ultima non è scopa');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fine smazzata le carte in tavola vanno all\'ultimo che ha preso; tutte le 40 carte si contano', () => {
|
||||||
|
const c = newScopa(5, { level: 'seria' });
|
||||||
|
while (c.step !== 'smazzata') {
|
||||||
|
if (canPlay(c)) { const m = choose(observation(c, 'player'), 'seria'); assert.ok(play(c, m.index, 'player', m.option)); }
|
||||||
|
update(c, 20);
|
||||||
|
}
|
||||||
|
assert.equal(c.taken.player.length + c.taken.cpu.length, 40);
|
||||||
|
assert.equal(c.table.length, 0);
|
||||||
|
const before = c.dealer;
|
||||||
|
if (!finished(c)) { advance(c); assert.notEqual(c.dealer, before); assert.equal(c.current, before, 'gioca per primo chi non fa le carte'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('si va agli 11: a fine smazzata vince chi ne ha di più; pari, un\'altra; il risultato è subito definitivo', () => {
|
||||||
|
const c = newScopa(6); c.stock = []; c.hands = { player: [], cpu: [card('cuori', '4')] };
|
||||||
|
c.table = [card('fiori', '4')]; c.current = 'cpu'; c.score = { player: 9, cpu: 10 };
|
||||||
|
c.taken = { player: DECK_CARDS.toscane.filter(a => a.suit !== 'fiori' || a.value !== 4).slice(0, 30), cpu: [] };
|
||||||
|
assert.ok(play(c, 0, 'cpu')); update(c, 2);
|
||||||
|
assert.equal(c.step, 'result', 'senza passare dal riepilogo: uscire non cancella la vittoria');
|
||||||
|
assert.equal(c.result.winner, 'player');
|
||||||
|
const d = newScopa(6); d.step = 'smazzata'; assert.ok(advance(d)); assert.equal(d.step, 'play');
|
||||||
|
assert.ok(finished({ score: { player: TARGET, cpu: 9 } })); assert.ok(!finished({ score: { player: 12, cpu: 12 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partite deterministiche col seme; l\'avversario vede solo la sua mano, la tavola e le prese', () => {
|
||||||
|
assert.deepEqual(simulate(31, 'male').score, simulate(31, 'male').score);
|
||||||
|
const c = newScopa(12), v = observation(c, 'cpu');
|
||||||
|
assert.equal(v.hands, undefined); assert.equal(v.stock, 30);
|
||||||
|
assert.equal(unseen(v).length, 33, 'mano dell\'altro e mazzo');
|
||||||
|
const other = structuredClone(c); other.hands.player.reverse(); other.stock.reverse();
|
||||||
|
assert.deepEqual(choose(observation(c, 'cpu'), 'male', 3), choose(observation(other, 'cpu'), 'male', 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'avversario prende il settebello e la scopa quando può, e non lascia scope facili', () => {
|
||||||
|
const v = { who: 'cpu', deck: 'toscane', hand: [card('cuori', '7'), card('fiori', 'Regio')], table: [card('mattoni', '7'), card('picche', '4')], taken: { player: [], cpu: [] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } };
|
||||||
|
const m = choose(v, 'seria'); assert.equal(v.hand[m.index].rank, '7', 'prende il settebello');
|
||||||
|
const w = { ...v, hand: [card('cuori', '7'), card('fiori', '4')], table: [card('picche', '3'), card('mattoni', '4')] };
|
||||||
|
const s = choose(w, 'male'); assert.equal(w.hand[s.index].rank, '7', 'scopa con 3+4');
|
||||||
|
const x = { ...v, hand: [card('cuori', '5'), card('fiori', 'Regio')], table: [card('picche', '2')] };
|
||||||
|
const d = choose(x, 'male'); assert.equal(x.hand[d.index].rank, 'Regio', 'col 5 lascerebbe 7 in tavola: meglio il regio (12)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la primiera conta quel che migliora: col 6 si prende quello del seme dove si ha solo una figura (Codex)', () => {
|
||||||
|
const v = { who: 'cpu', deck: 'toscane', hand: [card('picche', '6')], table: [card('cuori', '6'), card('fiori', '6')],
|
||||||
|
taken: { player: [], cpu: [card('cuori', '7'), card('fiori', 'Regio')] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } };
|
||||||
|
for (const level of ['seria', 'male']) assert.equal(choose(v, level).captured[0].suit, 'fiori', level);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mazzo finito «A fassi male» calcola il finale esatto: meglio perdere il settebello che regalare anche la scopa', () => {
|
||||||
|
const deck = DECK_CARDS.toscane, seen = [card('mattoni', '7'), card('cuori', '7'), card('fiori', '4'), card('picche', '3')];
|
||||||
|
// Andrea ha in mano l'ultimo 7. Posando il 4, Andrea prende 3+4 e fa scopa, e il settebello gli resta comunque;
|
||||||
|
// posando il settebello, Andrea lo prende col 7 ma niente scopa.
|
||||||
|
const v = { who: 'cpu', deck: 'toscane', hand: [card('fiori', '4'), card('mattoni', '7')], table: [card('picche', '3')], stock: 0,
|
||||||
|
taken: { player: deck.filter(a => !seen.includes(a)).slice(0, 18), cpu: deck.filter(a => !seen.includes(a)).slice(18) },
|
||||||
|
opponentCount: 1, scope: { player: 0, cpu: 0 }, lastTaker: 'player' };
|
||||||
|
assert.deepEqual(unseen(v).map(a => a.id), [card('cuori', '7').id], 'le carte dell\'altro sono quelle non viste');
|
||||||
|
assert.equal(v.hand[choose(v, 'male').index].rank, '7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taratura: Alla bona < Seria < A fassi male', () => {
|
||||||
|
const runs = calibrate(60);
|
||||||
|
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
|
||||||
|
assert.ok(runs[1].playerPercent > runs[2].playerPercent, JSON.stringify(runs));
|
||||||
|
});
|
||||||
@@ -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!']);
|
||||||
@@ -605,6 +606,19 @@ test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua
|
|||||||
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
|
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Il favicon c'era in pagina ma non nell'immagine Docker (404 online, 26 ottobre): ogni cartella di assets che il gioco
|
||||||
|
// usa deve essere fra quelle copiate, e ogni video «Come si gioca» deve esistere.
|
||||||
|
test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', async () => {
|
||||||
|
const { readdirSync } = await import('node:fs');
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
const copied = new Set(readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY assets\/(\S+)\s/gm).map(l => l.split(' ')[1].slice(7)));
|
||||||
|
const files = ['index.html', 'style.css', ...readdirSync(new URL('src/', root), { recursive: true }).filter(f => f.endsWith('.js')).map(f => 'src/' + f)];
|
||||||
|
const used = new Set(files.flatMap(f => [...readFileSync(new URL(f, root), 'utf8').matchAll(/assets\/([a-z0-9_-]+)\//g)].map(m => m[1])));
|
||||||
|
// raw e generated sono cartelle di lavoro, citate solo nei commenti.
|
||||||
|
assert.deepEqual([...used].filter(d => !copied.has(d) && !['raw', 'generated'].includes(d)).sort(), []);
|
||||||
|
for (const t of Object.values(story.tutorials)) for (const f of [t.src, t.poster]) assert.ok(existsSync(new URL(f, root)), f);
|
||||||
|
});
|
||||||
|
|
||||||
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
|
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
|
||||||
const { readdirSync, statSync } = await import('node:fs');
|
const { readdirSync, statSync } = await import('node:fs');
|
||||||
const root = new URL('../', import.meta.url);
|
const root = new URL('../', import.meta.url);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Tressette ai 21 nel browser: carte e segni con mouse, tocco e tastiera; si accelerano solo le attese.
|
||||||
|
// npm run browser:tressette — /tmp/regolo-shots/tressette-*.png
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } 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 tap(page, mode, selector) {
|
||||||
|
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
|
||||||
|
else await page.click(selector);
|
||||||
|
}
|
||||||
|
async function playCard(page, mode, index, signal) {
|
||||||
|
if (signal) {
|
||||||
|
if (mode === 'keyboard') await page.keyboard.press(signal[0]);
|
||||||
|
else await tap(page, mode, `#tressette-${signal}`);
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.signal), signal);
|
||||||
|
}
|
||||||
|
if (mode === 'keyboard') {
|
||||||
|
await page.keyboard.press(String((index + 1) % 10));
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.selected), index);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = await page.evaluate(async i => {
|
||||||
|
const { handX, CARD } = await import('/src/engine/tressette-render.js');
|
||||||
|
return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30];
|
||||||
|
}, index);
|
||||||
|
// Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa.
|
||||||
|
await page.locator('#scene').scrollIntoViewIfNeeded();
|
||||||
|
const r = await page.locator('#scene').boundingBox(), x = r.x + at[0] / 640 * r.width, y = r.y + at[1] / 360 * r.height;
|
||||||
|
if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
|
||||||
|
}
|
||||||
|
async function wait(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const { canPlay } = await import('/src/engine/tressette.js');
|
||||||
|
// Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui.
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
if (game.speech) game.skip();
|
||||||
|
else if (canPlay(game.closeup) || ['smazzata', 'result'].includes(game.closeup.step)) break;
|
||||||
|
else game.update(1.3);
|
||||||
|
}
|
||||||
|
return game.closeup.step;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
const scenarios = [
|
||||||
|
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||||
|
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
||||||
|
{ name: 'desktop-keyboard', mode: 'keyboard', viewport: { width: 1280, height: 860 } },
|
||||||
|
{ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } },
|
||||||
|
];
|
||||||
|
for (const { name, mode, viewport, full } of scenarios) {
|
||||||
|
const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
|
||||||
|
page.on('pageerror', e => errors.push(`${name}: ${e.message}`));
|
||||||
|
page.on('console', e => { if (e.type() === 'error') errors.push(`${name}: ${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); game.act('talk', 'giocatore_carte'); settle(game); });
|
||||||
|
await page.locator('#choices li', { hasText: 'Un tressette ai 21?' }).click();
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
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: 'Seria.' }).click();
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
assert.deepEqual(await page.evaluate(() => [game.closeup.deck, game.closeup.level]), [deck, 'seria']);
|
||||||
|
await page.waitForSelector('#tressette-controls:not([hidden])');
|
||||||
|
assert.ok(await page.isHidden('#briscola-controls')); assert.ok(await page.isHidden('#bocce-controls'));
|
||||||
|
const scene = await page.locator('#scene').boundingBox();
|
||||||
|
assert.ok(scene.width * 42 / 640 >= 18, 'carte toccabili anche sul telefono');
|
||||||
|
await wait(page);
|
||||||
|
await page.screenshot({ path: `${out}/tressette-${name}-inizio.png` });
|
||||||
|
// Una carta che non risponde al seme non parte: resta in mano e il motivo si legge.
|
||||||
|
let refusedChecked = false, signals = 0, played = 0, shotSignal = false;
|
||||||
|
for (let steps = 0; steps < 400; steps++) {
|
||||||
|
const step = await wait(page);
|
||||||
|
if (step === 'result') break;
|
||||||
|
if (step === 'smazzata') {
|
||||||
|
await page.screenshot({ path: `${out}/tressette-${name}-smazzata.png` });
|
||||||
|
if (!full) break;
|
||||||
|
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#tressette-next');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!refusedChecked) {
|
||||||
|
const bad = await page.evaluate(async () => {
|
||||||
|
const { legal } = await import('/src/engine/tressette.js');
|
||||||
|
const c = game.closeup, ok = legal(c.hands[0], c.table);
|
||||||
|
return c.hands[0].findIndex((_, i) => !ok.includes(i));
|
||||||
|
});
|
||||||
|
if (bad >= 0) {
|
||||||
|
const held = await page.evaluate(() => game.closeup.hands[0].length);
|
||||||
|
// Tastiera (Codex): fuoco su una carta buona, poi si sceglie col numero quella che non risponde e
|
||||||
|
// Invio. Non deve partire la carta che aveva il fuoco.
|
||||||
|
if (mode === 'keyboard') {
|
||||||
|
const good = await page.evaluate(async () => (await import('/src/engine/tressette.js')).legal(game.closeup.hands[0], game.closeup.table)[0]);
|
||||||
|
await page.locator(`#tressette-card-${good}`).focus();
|
||||||
|
}
|
||||||
|
await playCard(page, mode, bad);
|
||||||
|
assert.match(await page.evaluate(() => game.closeup.refused), /Devi rispondere/);
|
||||||
|
await page.waitForFunction(() => /Devi rispondere/.test(document.getElementById('tressette-status').textContent));
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.hands[0].length), held);
|
||||||
|
refusedChecked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const move = await page.evaluate(async () => {
|
||||||
|
const { observation, choose } = await import('/src/engine/tressette.js');
|
||||||
|
return choose(observation(game.closeup, 0), 'male', 7);
|
||||||
|
});
|
||||||
|
const before = await page.evaluate(() => game.closeup.hands[0].length);
|
||||||
|
await playCard(page, mode, move.index, move.signal); played++;
|
||||||
|
if (move.signal) {
|
||||||
|
signals++;
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.table.find(p => p.who === 0)?.signal), move.signal);
|
||||||
|
if (!shotSignal) { await page.waitForTimeout(60); await page.screenshot({ path: `${out}/tressette-${name}-segno.png` }); shotSignal = true; }
|
||||||
|
}
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.hands[0].length), before - 1, 'un gesto gioca una carta');
|
||||||
|
if (played === 1) { await page.evaluate(() => game.update(0.8)); await page.screenshot({ path: `${out}/tressette-${name}-presa.png` }); }
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.step), 'result');
|
||||||
|
await page.screenshot({ path: `${out}/tressette-${name}-risultato.png` });
|
||||||
|
const winner = await page.evaluate(() => game.closeup.result.winner);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup), null);
|
||||||
|
assert.equal(await page.evaluate(() => !!game.state.flags.tressette_vinto), winner === 'player');
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup), null);
|
||||||
|
assert.equal(await page.evaluate(() => !!game.state.flags.tressette_vinto), false, 'chi lascia non vince');
|
||||||
|
}
|
||||||
|
console.log(`${name}: ${played} carte giocate (${signals} con un segno)${full ? ', partita ai 21 finita' : ', una smazzata'} OK`);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []); console.log(`Schermate: ${out}/tressette-*.png`);
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DECK_CARDS, newTressette, accuse, accuseName, legal, best, signalOk, reason, setSignal, play, collect, advance, update, observation, choose, knowledge, canPlay, finished, team, status, TARGET } from '../src/engine/tressette.js';
|
||||||
|
import { simulate, calibrate } from '../tools/calibrate_tressette.mjs';
|
||||||
|
|
||||||
|
const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank);
|
||||||
|
const hand = list => list.map(([s, r]) => card(s, r));
|
||||||
|
|
||||||
|
test('mazzo: 40 carte, 32 terzi di punti, ordine 3 > 2 > Asso > figure > 7 … 4', () => {
|
||||||
|
for (const cards of Object.values(DECK_CARDS)) {
|
||||||
|
assert.equal(cards.length, 40); assert.equal(cards.reduce((n, a) => n + a.thirds, 0), 32);
|
||||||
|
}
|
||||||
|
const order = ['3', '2', 'Asso', 'Regio', 'Donna', 'Gobbo', '7', '6', '5', '4'];
|
||||||
|
for (let i = 1; i < order.length; i++) assert.ok(card('cuori', order[i - 1]).power > card('cuori', order[i]).power, order[i]);
|
||||||
|
assert.equal(card('picche', 'Asso').thirds, 3); assert.equal(card('picche', 'Regio').thirds, 1); assert.equal(card('picche', '7').thirds, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('si risponde al seme; chi non ce l\'ha gioca quel che vuole; prende la più alta del seme di uscita', () => {
|
||||||
|
const h = hand([['cuori', '4'], ['fiori', '3'], ['cuori', 'Regio']]);
|
||||||
|
assert.deepEqual(legal(h, []), [0, 1, 2]);
|
||||||
|
assert.deepEqual(legal(h, [{ who: 1, card: card('cuori', '7') }]), [0, 2]);
|
||||||
|
assert.deepEqual(legal(h, [{ who: 1, card: card('picche', '7') }]), [0, 1, 2]);
|
||||||
|
const table = [{ who: 1, card: card('cuori', '4') }, { who: 2, card: card('fiori', '3') }, { who: 3, card: card('cuori', '2') }, { who: 0, card: card('cuori', 'Asso') }];
|
||||||
|
assert.equal(best(table).who, 3, 'il 3 di fiori non risponde: prende il 2 di cuori');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accuse: napoletana 3 punti, tre o quattro Assi, 2 o 3 per 3 o 4 punti, anche insieme', () => {
|
||||||
|
const h = hand([['cuori', 'Asso'], ['cuori', '2'], ['cuori', '3'], ['picche', '3'], ['fiori', '3'], ['mattoni', '7']]);
|
||||||
|
const a = accuse(h);
|
||||||
|
assert.deepEqual(a.map(x => [x.kind, x.points]), [['napoletana', 3], ['buongioco', 3]]);
|
||||||
|
assert.equal(accuse(hand([['cuori', '2'], ['picche', '2'], ['fiori', '2'], ['mattoni', '2']]))[0].points, 4);
|
||||||
|
assert.deepEqual(accuse(hand([['cuori', 'Asso'], ['cuori', '2'], ['picche', '3']])), []);
|
||||||
|
// Del tris si dice il seme che manca, così Andrea sa quello che sa l'avversario.
|
||||||
|
const tris = accuse(hand([['picche', '3'], ['fiori', '3'], ['mattoni', '3']]), ['cuori', 'mattoni', 'fiori', 'picche'])[0];
|
||||||
|
assert.equal(tris.missing, 'cuori'); assert.equal(accuseName(tris), 'tre 3, manca cuori');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segni: volo con l\'ultima, striscio se ne resta un\'altra, busso sempre; solo chi esce', () => {
|
||||||
|
const h = hand([['cuori', '4'], ['cuori', '5'], ['fiori', '7']]);
|
||||||
|
assert.ok(signalOk(h, 2, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo'));
|
||||||
|
assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 2, [], 'striscio'));
|
||||||
|
assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(signalOk(h, 2, [], 'busso'));
|
||||||
|
assert.ok(!signalOk(h, 0, [{ who: 1, card: card('cuori', '7') }], 'busso'));
|
||||||
|
const c = newTressette(5); c.current = 0; c.table = []; c.hands[0] = [...h]; c.pending[0] = [];
|
||||||
|
assert.ok(setSignal(c, 'volo')); assert.equal(play(c, 0), false); assert.match(c.refused, /Volo solo/);
|
||||||
|
assert.equal(c.hands[0].length, 3, 'la carta resta in mano');
|
||||||
|
assert.equal(play(c, 0, 0, 'xyz'), false); assert.equal(c.refused, 'Segno non riconosciuto.');
|
||||||
|
assert.ok(play(c, 2)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null);
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'card-volo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la carta che non risponde al seme si rifiuta con la ragione', () => {
|
||||||
|
const c = newTressette(9); c.current = 0; c.hands[0] = hand([['cuori', '4'], ['fiori', '3']]);
|
||||||
|
c.table = [{ who: 3, card: card('cuori', '7') }];
|
||||||
|
assert.equal(play(c, 1), false); assert.equal(reason(c, 1), 'Devi rispondere a cuori.');
|
||||||
|
assert.ok(canPlay(c)); assert.ok(play(c, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una smazzata: accuse dette al proprio turno, 10 prese, sempre 11 punti fra terzi e ultima presa', () => {
|
||||||
|
const c = newTressette(1234, { level: 'seria' });
|
||||||
|
assert.deepEqual(c.score, [0, 0]); assert.deepEqual(c.accuse, [], 'prima di giocare nessuno ha accusato');
|
||||||
|
const owed = c.pending.flat();
|
||||||
|
assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere');
|
||||||
|
assert.ok(c.hands.every(h => h.length === 10));
|
||||||
|
const firstDealer = c.dealer;
|
||||||
|
while (!['smazzata', 'result'].includes(c.step)) {
|
||||||
|
if (canPlay(c)) { const m = choose(observation(c, 0), 'seria'); assert.ok(play(c, m.index, 0, m.signal)); }
|
||||||
|
update(c, 20);
|
||||||
|
if (c.tricks.length === 1) assert.equal(c.accuse.length, owed.length, 'dopo la prima presa hanno accusato tutti');
|
||||||
|
}
|
||||||
|
if (c.step === 'smazzata') {
|
||||||
|
assert.equal(c.tricks.length, 10);
|
||||||
|
assert.equal(c.summary.thirds[0] + c.summary.thirds[1], 35);
|
||||||
|
assert.equal(c.summary.points[0] + c.summary.points[1], 11, 'i terzi avanzati sommano sempre a due');
|
||||||
|
assert.deepEqual(c.score, [0, 1].map(t => c.summary.accused[t] + c.summary.points[t]));
|
||||||
|
assert.ok(advance(c)); assert.equal(c.dealer, (firstDealer + 1) % 4); assert.equal(c.step, 'play');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('si va ai 21: chi ci arriva per primo è fuori, anche a metà smazzata, e la vittoria non si perde', () => {
|
||||||
|
const c = newTressette(3); c.pending = [[], [], [], []];
|
||||||
|
c.base = [20, 19]; c.score = [20, 19]; c.thirds = [2, 0];
|
||||||
|
// Una presa da un terzo porta noi a 21 (3 terzi = 1 punto): fuori subito.
|
||||||
|
c.table = [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('cuori', '4') }, { who: 2, card: card('cuori', '5') }, { who: 3, card: card('cuori', '6') }];
|
||||||
|
c.step = 'trick'; c.last = { winner: 0, cards: c.table.map(p => p.card) };
|
||||||
|
assert.ok(collect(c)); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); assert.deepEqual(c.score, [21, 19]);
|
||||||
|
assert.ok(finished(c)); assert.equal(advance(c), false);
|
||||||
|
assert.match(status(c), /Avete vinto voi, 21 a 19/);
|
||||||
|
// Anche le accuse bastano: dette giocando, contano alla fine di quella presa.
|
||||||
|
const d = newTressette(3); d.base = [0, 19]; d.score = [0, 19]; d.current = 1; d.table = []; d.tricks = [];
|
||||||
|
d.pending = [[], [{ who: 1, kind: 'napoletana', suit: 'fiori', points: 3, cards: [] }], [], []];
|
||||||
|
d.hands[1] = [card('fiori', '7'), ...d.hands[1].slice(1)];
|
||||||
|
assert.ok(play(d, 0, 1)); assert.equal(d.score[1], 22);
|
||||||
|
assert.equal(d.step, 'result', 'l\'accusa fa uscire subito'); assert.equal(d.result.winner, 'cpu');
|
||||||
|
// Tutte e due a un passo, tutte e due con un'accusa: esce chi accusa per primo, chiunque sia (Codex, seme 28).
|
||||||
|
const e = newTressette(28); e.base = [18, 18]; e.score = [18, 18]; e.current = 1; e.table = []; e.tricks = [];
|
||||||
|
e.pending = [[{ who: 0, kind: 'buongioco', rank: '3', points: 3, cards: [] }], [{ who: 1, kind: 'buongioco', rank: '2', points: 3, cards: [] }], [], []];
|
||||||
|
assert.ok(play(e, 0, 1)); assert.equal(e.result.winner, 'cpu'); assert.deepEqual(e.score, [18, 21]);
|
||||||
|
assert.equal(play(e, 0, 2), false, 'a partita finita non si gioca più');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => {
|
||||||
|
const a = simulate(77, 'male'), b = simulate(77, 'male');
|
||||||
|
assert.deepEqual(a.score, b.score); assert.ok(Math.max(...a.score) >= TARGET);
|
||||||
|
const t = newTressette(42), n = newTressette(42, { deck: 'napoletane' });
|
||||||
|
assert.deepEqual(t.hands.map(h => h.map(a => a.id)), n.hands.map(h => h.map(a => a.id)));
|
||||||
|
assert.equal(n.hands[0][0].suit === 'denari' || ['coppe', 'spade', 'bastoni'].includes(n.hands[0][0].suit), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gli altri non vedono le carte altrui: l\'osservazione ha solo la propria mano e il tavolo', () => {
|
||||||
|
const c = newTressette(11), v = observation(c, 1);
|
||||||
|
assert.deepEqual(v.hand.map(a => a.id), c.hands[1].map(a => a.id));
|
||||||
|
assert.equal(v.hands, undefined); assert.equal(v.seed, undefined);
|
||||||
|
const k = knowledge(v, true);
|
||||||
|
assert.equal(k.out.length, 30, 'fuori ci sono le 30 carte degli altri tre');
|
||||||
|
assert.ok(!k.could(1, k.out[0]), 'non può pensare di averle lui');
|
||||||
|
// Cambiare le mani degli altri non cambia la scelta.
|
||||||
|
const other = structuredClone(c); other.hands[0].reverse(); other.hands[2].reverse();
|
||||||
|
c.current = other.current = 1;
|
||||||
|
assert.deepEqual(choose(observation(c, 1), 'male', 5), choose(observation(other, 1), 'male', 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('«A fassi male» conta: sa che il 3 è uscito, e col 2 si fa la presa sicura', () => {
|
||||||
|
const c = newTressette(8); c.current = 1; c.table = [];
|
||||||
|
c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]);
|
||||||
|
c.tricks = [{ leader: 0, winner: 0, cards: [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('fiori', '5') }, { who: 2, card: card('cuori', '4') }, { who: 3, card: card('cuori', '5') }] }];
|
||||||
|
assert.equal(choose(observation(c, 1), 'male').index, 0, 'esce col 2, ormai il più alto');
|
||||||
|
const foes = [0, 2];
|
||||||
|
assert.equal(knowledge(observation(c, 1), false).beatable(card('cuori', '2'), foes), true, 'chi non conta ha paura del 3');
|
||||||
|
assert.equal(knowledge(observation(c, 1), true).beatable(card('cuori', '2'), foes), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il socio ritorna il seme a chi ha bussato', () => {
|
||||||
|
const c = newTressette(8); c.current = 2; c.table = [];
|
||||||
|
c.hands[2] = hand([['cuori', '4'], ['fiori', '2'], ['cuori', '7']]);
|
||||||
|
c.tricks = [{ leader: 0, winner: 2, cards: [{ who: 0, card: card('cuori', '5'), signal: 'busso' }, { who: 1, card: card('cuori', '6') }, { who: 2, card: card('cuori', '3') }, { who: 3, card: card('picche', '4') }] }];
|
||||||
|
const m = choose(observation(c, 2), 'male');
|
||||||
|
assert.equal(c.hands[2][m.index].suit, 'cuori'); assert.equal(c.hands[2][m.index].rank, '4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chi riceve il busso prende con la migliore; il socio non spreca il 3 su una presa già sua', () => {
|
||||||
|
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 2;
|
||||||
|
c.table = [{ who: 0, card: card('cuori', '4'), signal: 'busso' }, { who: 1, card: card('cuori', '5') }];
|
||||||
|
c.hands[2] = hand([['cuori', '2'], ['cuori', '6'], ['fiori', '7']]);
|
||||||
|
assert.equal(c.hands[2][choose(observation(c, 2), 'male').index].rank, '2', 'al busso risponde col 2');
|
||||||
|
const d = newTressette(8); d.pending = [[], [], [], []]; d.current = 3;
|
||||||
|
d.table = [{ who: 0, card: card('fiori', '6') }, { who: 1, card: card('fiori', '2') }, { who: 2, card: card('fiori', '4') }];
|
||||||
|
d.hands[3] = hand([['fiori', '3'], ['fiori', '5'], ['cuori', '7']]);
|
||||||
|
// L'avversario del posto 3: il suo socio (1) prende col 2 e lui è l'ultimo; tiene il 3 e dà il 5.
|
||||||
|
assert.equal(d.hands[3][choose(observation(d, 3), 'male').index].rank, '5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('se un carico al socio basta per i 21, si dà anche il 2', () => {
|
||||||
|
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 3;
|
||||||
|
c.base = [0, 20]; c.score = [0, 20]; c.thirds = [0, 1];
|
||||||
|
c.table = [{ who: 0, card: card('cuori', '4') }, { who: 1, card: card('cuori', '3') }, { who: 2, card: card('cuori', '5') }];
|
||||||
|
c.hands[3] = hand([['fiori', '2'], ['picche', '4']]);
|
||||||
|
assert.equal(c.hands[3][choose(observation(c, 3), 'seria').index].rank, '2');
|
||||||
|
assert.equal(c.hands[3][choose(observation(c, 3), 'male').index].rank, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chi vola ha finito il seme: per «A fassi male» non può più avere il 3', () => {
|
||||||
|
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 1;
|
||||||
|
c.tricks = [{ leader: 2, winner: 0, cards: [{ who: 2, card: card('cuori', '4'), signal: 'volo' }, { who: 3, card: card('cuori', '5') }, { who: 0, card: card('cuori', '6') }, { who: 1, card: card('cuori', '7') }] }];
|
||||||
|
c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]); c.table = [];
|
||||||
|
const k = knowledge(observation(c, 1), true);
|
||||||
|
assert.equal(k.could(2, card('cuori', '3')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taratura: Alla bona < Seria < A fassi male, contro Andrea e il socio che giocano attenti', () => {
|
||||||
|
const runs = calibrate(60);
|
||||||
|
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
|
||||||
|
assert.ok(runs[1].playerPercent >= runs[2].playerPercent, JSON.stringify(runs));
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// node tools/calibrate_scopa.mjs [partite per livello, predefinite 300]
|
||||||
|
// Stesse carte per i tre livelli; Andrea gioca come «A fassi male», l'avversario al livello scelto.
|
||||||
|
import { newScopa, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/scopa.js';
|
||||||
|
|
||||||
|
export function simulate(seed, level, me = 'male') {
|
||||||
|
const c = newScopa(seed, { level });
|
||||||
|
for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) {
|
||||||
|
if (canPlay(c)) { const m = choose(observation(c, 'player'), me, steps + 1); if (!play(c, m.index, 'player', m.option)) throw new Error('mossa rifiutata'); }
|
||||||
|
if (!advance(c)) update(c, 20); c.events.length = 0;
|
||||||
|
}
|
||||||
|
if (c.step !== 'result') throw new Error('Partita non conclusa');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
export function calibrate(n = 300, me = 'male') {
|
||||||
|
return LEVELS.map(level => {
|
||||||
|
let wins = 0, rounds = 0;
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const c = simulate(Math.imul(i, 2654435761) >>> 0, level, me);
|
||||||
|
wins += c.result.winner === 'player'; rounds += c.round;
|
||||||
|
}
|
||||||
|
return { level, games: n, playerPercent: 100 * wins / n, rounds: rounds / n };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 300), null, 2));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// node tools/calibrate_tressette.mjs [partite per livello, predefinite 300]
|
||||||
|
// Stesse carte per i tre livelli; Andrea gioca come il suo socio («A fassi male»), gli avversari al livello scelto.
|
||||||
|
import { newTressette, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/tressette.js';
|
||||||
|
|
||||||
|
export function simulate(seed, level, me = 'male') {
|
||||||
|
const c = newTressette(seed, { level });
|
||||||
|
for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) {
|
||||||
|
if (canPlay(c)) { const m = choose(observation(c, 0), me, steps + 1); if (!play(c, m.index, 0, m.signal)) throw new Error('mossa rifiutata: ' + c.refused); }
|
||||||
|
if (!advance(c)) update(c, 20); c.events.length = 0;
|
||||||
|
}
|
||||||
|
if (c.step !== 'result') throw new Error('Partita non conclusa');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
export function calibrate(n = 300, me = 'male') {
|
||||||
|
return LEVELS.map(level => {
|
||||||
|
let wins = 0, rounds = 0;
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const c = simulate(Math.imul(i, 2654435761) >>> 0, level, me);
|
||||||
|
wins += c.result.winner === 'player'; rounds += c.round;
|
||||||
|
}
|
||||||
|
return { level, games: n, playerPercent: 100 * wins / n, rounds: rounds / n };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 300), null, 2));
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disegna il Regolino, pixel per pixel, senza immagini sorgenti.
|
||||||
|
|
||||||
|
python3 tools/favicon.py
|
||||||
|
|
||||||
|
Scrive assets/favicon/ e /tmp/regolo-shots/favicon-anteprima.png.
|
||||||
|
La matrice 16×16 è il disegno originale: ogni simbolo è un pixel.
|
||||||
|
Tutti gli ingrandimenti sono interi e usano NEAREST, anche nell'ICO.
|
||||||
|
Colori e carattere riprendono regolo() in src/engine/briscola-render.js.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUT = ROOT / "assets" / "favicon"
|
||||||
|
PREVIEW = Path("/tmp/regolo-shots/favicon-anteprima.png")
|
||||||
|
BACKGROUND = "#1b1420"
|
||||||
|
PALETTE = {
|
||||||
|
".": (0, 0, 0, 0),
|
||||||
|
"#": "#38271f", # contorno del dorso
|
||||||
|
"G": "#dba32b", # oro
|
||||||
|
"H": "#ffe396", # luce sulla corona
|
||||||
|
"R": "#aa4034", # rubino
|
||||||
|
"L": "#94b365", # testa verde salvia
|
||||||
|
"S": "#487348", # ombra / spira
|
||||||
|
"V": "#a3bc72", # luce sulla spira
|
||||||
|
"B": "#d0cd83", # ventre
|
||||||
|
"W": "#fff0cd", # occhi crema
|
||||||
|
}
|
||||||
|
|
||||||
|
# Corona a tre punte, grande testa sorridente, collo e coda arrotolata.
|
||||||
|
# Nessun tratto viene interpolato: anche gli occhi sono singoli pixel.
|
||||||
|
PIXELS = (
|
||||||
|
"....#..##..#....",
|
||||||
|
"...#H##HH##H#...",
|
||||||
|
"...#HGGGGGGH#...",
|
||||||
|
"....#GGRGGG#....",
|
||||||
|
"...##########...",
|
||||||
|
"..#LLLLLLLLLL#..",
|
||||||
|
".#LLWWLLWWLLLL#.",
|
||||||
|
".#LLW#LL#WLLLS#.",
|
||||||
|
".#LLLLLLLLLLLS#.",
|
||||||
|
"..#L#LLLLL#LS#..",
|
||||||
|
"...#L#####LS#...",
|
||||||
|
"....###BS###....",
|
||||||
|
"..###SSBSLSS##..",
|
||||||
|
".#SVV##BS##SSS#.",
|
||||||
|
".#SVVVVVVVVSS#..",
|
||||||
|
"..###########...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sprite():
|
||||||
|
"""Interpreta la matrice letterale, mantenendo solo alpha 0 o 255."""
|
||||||
|
if len(PIXELS) != 16 or any(len(row) != 16 for row in PIXELS):
|
||||||
|
raise ValueError("Il disegno deve contenere esattamente 16×16 pixel")
|
||||||
|
result = Image.new("RGBA", (16, 16))
|
||||||
|
for y, row in enumerate(PIXELS):
|
||||||
|
for x, symbol in enumerate(row):
|
||||||
|
color = PALETTE[symbol]
|
||||||
|
if isinstance(color, str):
|
||||||
|
color = tuple(bytes.fromhex(color[1:])) + (255,)
|
||||||
|
result.putpixel((x, y), color)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def enlarge(source, factor):
|
||||||
|
return source.resize(
|
||||||
|
(source.width * factor, source.height * factor), Image.Resampling.NEAREST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview(icons):
|
||||||
|
"""Mostra ogni PNG a 1× e 8×, sia su chiaro sia su scuro.
|
||||||
|
|
||||||
|
Le sei righe includono anche le icone grandi: il foglio è volutamente
|
||||||
|
ampio, così la scala dichiarata resta esatta per tutti i file.
|
||||||
|
"""
|
||||||
|
margin, gap, label_height = 24, 24, 32
|
||||||
|
largest = max(icon.width for _, icon in icons)
|
||||||
|
panel_width = margin * 2 + largest + gap + largest * 8
|
||||||
|
height = margin + sum(label_height + icon.height * 8 + gap for _, icon in icons)
|
||||||
|
sheet = Image.new("RGB", (panel_width * 2, height))
|
||||||
|
draw = ImageDraw.Draw(sheet)
|
||||||
|
font = ImageFont.load_default(size=16)
|
||||||
|
for panel, (background, foreground) in enumerate(
|
||||||
|
(("#f5f1e9", "#38271f"), (BACKGROUND, "#fff0cd"))
|
||||||
|
):
|
||||||
|
left = panel * panel_width
|
||||||
|
draw.rectangle((left, 0, left + panel_width - 1, height - 1), fill=background)
|
||||||
|
y = margin
|
||||||
|
for name, icon in icons:
|
||||||
|
draw.text(
|
||||||
|
(left + margin, y),
|
||||||
|
f"{name} | {icon.width} x {icon.height} | 1x e 8x",
|
||||||
|
fill=foreground,
|
||||||
|
font=font,
|
||||||
|
)
|
||||||
|
y += label_height
|
||||||
|
sheet.paste(icon, (left + margin, y), icon)
|
||||||
|
magnified = enlarge(icon, 8)
|
||||||
|
sheet.paste(magnified, (left + margin + icon.width + gap, y), magnified)
|
||||||
|
y += icon.height * 8 + gap
|
||||||
|
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sheet.save(PREVIEW, optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
base = sprite()
|
||||||
|
icons = []
|
||||||
|
for size in (16, 32, 48):
|
||||||
|
icon = enlarge(base, size // 16)
|
||||||
|
name = f"favicon-{size}.png"
|
||||||
|
icon.save(OUTPUT / name, optimize=True)
|
||||||
|
icons.append((name, icon))
|
||||||
|
|
||||||
|
# 16×9 = 144: margini di 18 px, centratura esatta sul fondo opaco.
|
||||||
|
apple = Image.new("RGBA", (180, 180), BACKGROUND)
|
||||||
|
apple.alpha_composite(enlarge(base, 9), (18, 18))
|
||||||
|
apple = apple.convert("RGB")
|
||||||
|
apple.save(OUTPUT / "apple-touch-icon.png", optimize=True)
|
||||||
|
icons.append(("apple-touch-icon.png", apple.convert("RGBA")))
|
||||||
|
|
||||||
|
for size in (192, 512):
|
||||||
|
icon = enlarge(base, size // 16)
|
||||||
|
name = f"icon-{size}.png"
|
||||||
|
icon.save(OUTPUT / name, optimize=True)
|
||||||
|
icons.append((name, icon))
|
||||||
|
|
||||||
|
# Fornire tutti i frame evita il ridimensionamento implicito di Pillow.
|
||||||
|
icons[2][1].save(
|
||||||
|
OUTPUT / "favicon.ico",
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||||
|
append_images=[icons[0][1], icons[1][1]],
|
||||||
|
)
|
||||||
|
preview(icons)
|
||||||
|
for name, _ in icons:
|
||||||
|
print(OUTPUT / name)
|
||||||
|
print(OUTPUT / "favicon.ico")
|
||||||
|
print(PREVIEW)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La musica delle bocce al pallaio (assets/audio/bocce.mp3), sintetizzata da zero come il valzer delle carte: una
|
||||||
|
polka da circolo in do maggiore, in due quarti. Basso «um-pa» sui due movimenti, fisarmonica a contrattempo e una
|
||||||
|
melodia originale di clarinetto, con un filo di vibrato. Trentadue battute che chiudono sul sol settima: il loop non
|
||||||
|
si sente. (Andrea, 26 ottobre: «anche le bocce avrebbero bisogno di un sound suo».)
|
||||||
|
python3 tools/polka_bocce.py # scrive assets/audio/bocce.mp3 (serve ffmpeg)
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SR = 22050
|
||||||
|
BPM = 116
|
||||||
|
BEAT = 60 / BPM
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "bocce.mp3"
|
||||||
|
|
||||||
|
HARMONY = {"C": ["E4", "G4", "C5"], "G7": ["F4", "G4", "B4"], "F": ["F4", "A4", "C5"], "A7": ["E4", "G4", "C#5"],
|
||||||
|
"Dm": ["F4", "A4", "D5"]}
|
||||||
|
BASS = {"C": ("C2", "G2"), "G7": ("G1", "D2"), "F": ("F1", "C2"), "A7": ("A1", "E2"), "Dm": ("D2", "A1")}
|
||||||
|
CHORDS = ("C C G7 G7 G7 G7 C C C C F F C G7 C C "
|
||||||
|
"F F C C G7 G7 C C F F C A7 Dm G7 C G7").split()
|
||||||
|
h = 0.5
|
||||||
|
MELODY = [
|
||||||
|
[("E5", h), ("G5", h), ("C6", 1)], [("B5", h), ("A5", h), ("G5", 1)], [("F5", h), ("G5", h), ("A5", h), ("B5", h)], [("D6", 1), ("B5", 1)],
|
||||||
|
[("A5", h), ("B5", h), ("A5", h), ("G5", h)], [("F5", 1), ("D5", 1)], [("E5", h), ("F5", h), ("G5", 1)], [("C5", 2)],
|
||||||
|
[("E5", h), ("G5", h), ("C6", 1)], [("E6", h), ("D6", h), ("C6", 1)], [("A5", h), ("C6", h), ("A5", h), ("F5", h)], [("A5", 1.5), ("G5", h)],
|
||||||
|
[("G5", h), ("E5", h), ("C5", h), ("E5", h)], [("D5", h), ("F5", h), ("B5", 1)], [("C6", 1), ("G5", 1)], [("C6", 1), (None, 1)],
|
||||||
|
[("C6", h), ("A5", h), ("F5", 1)], [("A5", h), ("G5", h), ("F5", 1)], [("G5", h), ("E5", h), ("C5", 1)], [("E5", h), ("G5", h), ("C6", 1)],
|
||||||
|
[("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("D5", 1), ("G5", 1)], [("E5", h), ("G5", h), ("E5", h), ("C5", h)], [("G4", 2)],
|
||||||
|
[("A4", h), ("C5", h), ("F5", 1)], [("A5", 1), ("F5", 1)], [("E5", h), ("G5", h), ("C6", 1)], [("C#6", 1), ("A5", 1)],
|
||||||
|
[("D6", h), ("C6", h), ("A5", 1)], [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("E5", 1), ("C5", 1)], [("D5", h), ("E5", h), ("F5", h), ("G5", h)],
|
||||||
|
]
|
||||||
|
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 2 for bar in MELODY)
|
||||||
|
DUR = len(CHORDS) * 2 * BEAT
|
||||||
|
buf = [0.0] * int(SR * DUR)
|
||||||
|
rng = random.Random(11)
|
||||||
|
|
||||||
|
|
||||||
|
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(note: str) -> float:
|
||||||
|
return 440 * 2 ** ((midi(note) - 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.006, release: float = 0.06,
|
||||||
|
vibrato: float = 0.0):
|
||||||
|
n = int((length + release) * SR)
|
||||||
|
out, phase = [], 0.0
|
||||||
|
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)
|
||||||
|
# Il vibrato arriva dopo l'attacco, come quello di un fiato.
|
||||||
|
phase += 2 * math.pi * freq * (1 + vibrato * min(1.0, t / 0.25) * math.sin(2 * math.pi * 5.2 * t)) / SR
|
||||||
|
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(k * phase) for k, a, d in partials))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
at = lambda beat: beat * BEAT # noqa: E731
|
||||||
|
LONG = 99
|
||||||
|
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
|
||||||
|
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
|
||||||
|
# Clarinetto: armonici dispari, il suono di legno.
|
||||||
|
REEDWOOD = ((1, 1.0, LONG), (3, 0.42, LONG), (5, 0.2, LONG), (7, 0.09, LONG), (2, 0.06, LONG))
|
||||||
|
|
||||||
|
for bar, name in enumerate(CHORDS):
|
||||||
|
b0 = bar * 2
|
||||||
|
# Basso «um-pa»: fondamentale sull'uno, quinta sul due.
|
||||||
|
for k, note in enumerate(BASS[name]):
|
||||||
|
add(at(b0 + k), tone(hz(note), BEAT * 0.55, 0.32, ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.2, 0.12)), decay=0.35))
|
||||||
|
# Fisarmonica a contrattempo, corta e frizzante.
|
||||||
|
for k in (0.5, 1.5):
|
||||||
|
for note in HARMONY[name]:
|
||||||
|
for detune, vol in ((1.0, 0.026), (1.004, 0.019)):
|
||||||
|
add(at(b0 + k), tone(hz(note) * detune, BEAT * 0.3, vol, REED, decay=LONG, attack=0.012, release=0.04))
|
||||||
|
# Un piatto leggero sul due, quello della banda.
|
||||||
|
add(at(b0 + 1), [0.03 * math.exp(-i / SR / 0.08) * rng.uniform(-1, 1) for i in range(int(0.2 * SR))])
|
||||||
|
|
||||||
|
beat = 0.0
|
||||||
|
for bar in MELODY:
|
||||||
|
for note, length in bar:
|
||||||
|
if note:
|
||||||
|
add(at(beat), tone(hz(note), BEAT * length * 0.9, 0.09, REEDWOOD, decay=LONG, attack=0.03, release=0.06, vibrato=0.006))
|
||||||
|
beat += length
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in buf) or 1
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
w = Path(tmp) / "bocce.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())
|
||||||
|
# All'aperto, accanto al campo: poca eco, alti morbidi.
|
||||||
|
af = "aecho=0.8:0.5:70|150:0.12|0.07,lowpass=f=8500,volume=1.25"
|
||||||
|
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)")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La musica dei tavoli da carte all'oratorio (assets/audio/carte.mp3), sintetizzata da zero come il trio del biliardo:
|
||||||
|
un valzer da balera in sol maggiore. Fisarmonica a due voci un po' scordate fra loro (il «musette») sul due e sul tre,
|
||||||
|
basso pizzicato sull'uno, e una melodia originale di mandolino, col tremolo sulle note lunghe. Trentadue battute che
|
||||||
|
chiudono sul re settima: il loop non si sente. (Andrea, 26 ottobre: «come per il biliardo, una sua melodia».)
|
||||||
|
python3 tools/valzer_carte.py # scrive assets/audio/carte.mp3 (serve ffmpeg)
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SR = 22050
|
||||||
|
BPM = 138
|
||||||
|
BEAT = 60 / BPM
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "carte.mp3"
|
||||||
|
|
||||||
|
# Un accordo per battuta: fondamentale del basso e note della fisarmonica.
|
||||||
|
HARMONY = {"G": ["B3", "D4", "G4"], "D7": ["C4", "D4", "F#4"], "G7": ["B3", "F4", "G4"], "C": ["C4", "E4", "G4"],
|
||||||
|
"Em": ["B3", "E4", "G4"], "Am": ["C4", "E4", "A4"], "E7": ["B3", "D4", "G#4"]}
|
||||||
|
BASS = {"G": ("G2", "D2"), "D7": ("D2", "A1"), "G7": ("G2", "F2"), "C": ("C2", "G1"), "Em": ("E2", "B1"), "Am": ("A1", "E2"),
|
||||||
|
"E7": ("E2", "B1")}
|
||||||
|
CHORDS = ("G G D7 D7 D7 D7 G G G G7 C C G D7 G G "
|
||||||
|
"Em Em Am Am D7 D7 G G C C G E7 Am D7 G D7").split()
|
||||||
|
# La melodia del mandolino, battuta per battuta: (nota, durata in movimenti); None è una pausa.
|
||||||
|
MELODY = [
|
||||||
|
[("B4", 2), ("D5", 1)], [("G5", 3)], [("F#5", 2), ("E5", 1)], [("D5", 2), ("C5", 1)],
|
||||||
|
[("A4", 2), ("C5", 1)], [("F#5", 3)], [("E5", 2), ("D5", 1)], [("B4", 3)],
|
||||||
|
[("B4", 1), ("D5", 1), ("G5", 1)], [("F5", 3)], [("E5", 2), ("G5", 1)], [("C6", 3)],
|
||||||
|
[("B5", 2), ("G5", 1)], [("A5", 1), ("F#5", 1), ("D5", 1)], [("G5", 3)], [(None, 2), ("D5", 1)],
|
||||||
|
[("G5", 2), ("F#5", 1)], [("E5", 3)], [("C5", 1), ("E5", 1), ("A5", 1)], [("A5", 2), ("G5", 1)],
|
||||||
|
[("F#5", 2), ("E5", 1)], [("D5", 1), ("F#5", 1), ("A5", 1)], [("B5", 3)], [("G5", 2), ("B4", 1)],
|
||||||
|
[("C5", 1), ("E5", 1), ("G5", 1)], [("E5", 3)], [("D5", 2), ("G5", 1)], [("G#5", 2), ("B5", 1)],
|
||||||
|
[("A5", 2), ("C6", 1)], [("A5", 1), ("F#5", 1), ("C5", 1)], [("B4", 3)], [("D5", 1), ("E5", 1), ("F#5", 1)],
|
||||||
|
]
|
||||||
|
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 3 for bar in MELODY)
|
||||||
|
DUR = len(CHORDS) * 3 * BEAT
|
||||||
|
buf = [0.0] * int(SR * DUR)
|
||||||
|
rng = random.Random(7)
|
||||||
|
|
||||||
|
|
||||||
|
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(note: str) -> float:
|
||||||
|
return 440 * 2 ** ((midi(note) - 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.006, release: float = 0.06):
|
||||||
|
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)
|
||||||
|
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) for k, a, d in partials))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
at = lambda beat: beat * BEAT # noqa: E731
|
||||||
|
LONG = 99
|
||||||
|
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
|
||||||
|
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
|
||||||
|
# Mandolino: corda pizzicata, armonici alti che muoiono subito.
|
||||||
|
PLUCK = ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.3, 0.12), (4, 0.15, 0.06))
|
||||||
|
|
||||||
|
for bar, name in enumerate(CHORDS):
|
||||||
|
b0 = bar * 3
|
||||||
|
# Basso sull'uno: la fondamentale, una battuta sì e una no la quinta.
|
||||||
|
add(at(b0), tone(hz(BASS[name][bar % 2]), BEAT * 0.9, 0.3, ((1, 1.0, LONG), (2, 0.4, 0.3), (3, 0.15, 0.15)), decay=0.5))
|
||||||
|
# Fisarmonica sul due e sul tre: due ance, una un filo crescente, che battono fra loro.
|
||||||
|
for beat in (1, 2):
|
||||||
|
for note in HARMONY[name]:
|
||||||
|
for detune, vol in ((1.0, 0.03), (1.0035, 0.022)):
|
||||||
|
add(at(b0 + beat), tone(hz(note) * detune, BEAT * 0.62, vol, REED, decay=LONG, attack=0.02, release=0.05))
|
||||||
|
|
||||||
|
beat = 0.0
|
||||||
|
for bar in MELODY:
|
||||||
|
for note, length in bar:
|
||||||
|
if note:
|
||||||
|
if length >= 2:
|
||||||
|
# Le note lunghe col tremolo del plettro: tanti colpi fitti, sempre più piano.
|
||||||
|
picks = int(length * BEAT / 0.068)
|
||||||
|
for k in range(picks):
|
||||||
|
add(at(beat) + k * 0.068, tone(hz(note), 0.068, 0.07 * (1 - 0.35 * k / picks) * (1.15 if k == 0 else 1),
|
||||||
|
PLUCK, decay=0.12, attack=0.002, release=0.02))
|
||||||
|
else:
|
||||||
|
add(at(beat), tone(hz(note), BEAT * length * 0.95, 0.1, PLUCK, decay=0.35, attack=0.002, release=0.05))
|
||||||
|
beat += length
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in buf) or 1
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
w = Path(tmp) / "carte.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())
|
||||||
|
# Il cortile dell'oratorio: un po' d'eco dai muri, niente alti taglienti.
|
||||||
|
af = "aecho=0.8:0.55:60|130:0.2|0.12,lowpass=f=8000,volume=1.25"
|
||||||
|
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)")
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Video «Come si gioca» — briscola, tressette ai 21, scopa, bocce, biliardo (scaletta)
|
||||||
|
|
||||||
|
Cinque clip brevi, una per gioco, con voce italiana (XTTS) e sottotitoli impressi. Si guardano dentro il gioco:
|
||||||
|
un pulsante «Guarda il video» nel «Come si gioca» di ogni gioco apre un player con l'audio.
|
||||||
|
Le mani sono preparate dal copione (semi fissi), così ogni regola si vede nel momento giusto.
|
||||||
|
|
||||||
|
## 1. Briscola (circa 2 minuti)
|
||||||
|
|
||||||
|
| # | Cosa si vede | Voce |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Oratorio, Andrea parla al giocatore di carte, sceglie toscane e «Seria» | Dove si gioca, mazzo e livello |
|
||||||
|
| 2 | Il tavolo: la mano, la briscola sotto il mazzo, il mazzo | Tre carte a testa, la briscola comanda |
|
||||||
|
| 3 | Una presa col seme, una presa con la briscola | Chi prende: la più alta del seme, o la briscola |
|
||||||
|
| 4 | Liscio su un carico, poi un carico preso con una briscolina | Asso 11, 3 dieci, regio 4, donna 3, gobbo 2 |
|
||||||
|
| 5 | Fine mazzo e conteggio carta per carta | Si vince con 61, 60 a 60 è patta |
|
||||||
|
|
||||||
|
## 2. Tressette ai 21 (circa 3 minuti)
|
||||||
|
|
||||||
|
| # | Cosa si vede | Voce |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | «Un tressette ai 21?», i quattro al tavolo | In quattro a coppie, il socio di fronte |
|
||||||
|
| 2 | Una presa in cui si risponde al seme, una carta rifiutata | Obbligo di seme; ordine 3, 2, Asso, figure |
|
||||||
|
| 3 | Un'accusa scritta in alto | Napoletana, tre Assi/2/3, quattro |
|
||||||
|
| 4 | Andrea esce con «Striscio», poi «Busso» e il socio che ritorna il seme, poi un «Volo» | I segni, coi pulsanti o B, S, V |
|
||||||
|
| 5 | Riepilogo di smazzata e il «fuori» ai 21 | Terzi, ultima presa, si va fuori ai 21 |
|
||||||
|
|
||||||
|
## 3. Scopa (circa 2 minuti e mezzo)
|
||||||
|
|
||||||
|
| # | Cosa si vede | Voce |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | «Una scopa?», il tavolo con quattro carte | Valori: Asso 1 … regio 10 |
|
||||||
|
| 2 | Una presa della carta uguale, una presa per somma | La carta uguale viene prima delle somme |
|
||||||
|
| 3 | Due prese possibili: Andrea sceglie | Se le prese sono più d'una, scegli tu |
|
||||||
|
| 4 | Una scopa | Pulire la tavola fa un punto |
|
||||||
|
| 5 | Riepilogo: carte, mattoni, settebello, primiera, scope | Agli 11 |
|
||||||
|
|
||||||
|
## 4. Bocce al pallaio (circa 2 minuti)
|
||||||
|
|
||||||
|
| # | Cosa si vede | Voce |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Brento Sanico, Andrea parla con Mariano, sceglie il livello | Dove si gioca e i livelli |
|
||||||
|
| 2 | Mira toccando il campo e il dettaglio del pallino, il cerchio azzurro | Come si mira |
|
||||||
|
| 3 | La forza col cursore e «Lancia»; poi tenere premuto e lasciare | Due modi di tirare, anche frecce e Spazio |
|
||||||
|
| 4 | Una boccia che si accosta, una bocciata di Mariano | Tira chi non ha il punto |
|
||||||
|
| 5 | Fine mano e punti | Tre bocce a testa, ogni boccia più vicina della migliore avversaria fa un punto |
|
||||||
|
|
||||||
|
## 5. Biliardo, cinque birilli (circa 2 minuti e mezzo)
|
||||||
|
|
||||||
|
| # | Cosa si vede | Voce |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Circolo 4 Maggio, l'Ing. Tozzi, il livello | Dove si gioca |
|
||||||
|
| 2 | Il tavolo: le due biglie, il pallino, il castello dei birilli | Si colpisce la propria biglia e la si manda sull'altra |
|
||||||
|
| 3 | La mira: la linea tratteggiata, le frecce dopo l'urto; clic che fissa la mira | Come si mira, Maiusc per la mira fine |
|
||||||
|
| 4 | Un colpo che abbatte un birillo bianco, poi il rosso | Bianco 2, rosso 4, rosso da solo 10, pallino 3 |
|
||||||
|
| 5 | Un fallo (mancare la biglia) | I falli danno i punti all'avversario |
|
||||||
|
| 6 | Il segnapunti e la vittoria a 20 | Si va a 20 |
|
||||||
|
|
||||||
|
Prima di girare: le correzioni della revisione di Codex sui tre giochi, così il video mostra il gioco definitivo.
|
||||||
@@ -22,10 +22,16 @@ OUT = VIDEO / "out"
|
|||||||
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
|
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
|
||||||
TAGLIO = "crop=1044:760:278:70"
|
TAGLIO = "crop=1044:760:278:70"
|
||||||
FASCIA = 110 # px sopra il gioco per le didascalie
|
FASCIA = 110 # px sopra il gioco per le didascalie
|
||||||
|
# I video «Come si gioca» (--tutorial): ai tavoli la pagina usa tutta l'altezza, comandi sotto compresi; didascalie
|
||||||
|
# lunghe anche su tre righe.
|
||||||
|
TAGLIO_TUTORIAL = "crop=1046:900:268:0"
|
||||||
|
FASCIA_TUTORIAL = 132
|
||||||
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
|
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
|
||||||
VOLUME = 0.5 # musica della demo-prototipo (senza voci)
|
VOLUME = 0.5 # musica della demo-prototipo (senza voci)
|
||||||
VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre)
|
VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre)
|
||||||
VOLUME_VOCI = 1.0
|
VOLUME_VOCI = 1.0
|
||||||
|
VOLUME_NARRATORE = 1.15
|
||||||
|
VOLUME_TUTORIAL = 0.07 # musica dei video «Come si gioca»: sottofondo # il narratore dei video «Come si gioca», sopra la musica bassa
|
||||||
FADE = 1.2
|
FADE = 1.2
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +48,8 @@ def ts(s: float) -> str:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
d = Path(sys.argv[1]).expanduser()
|
d = Path(sys.argv[1]).expanduser()
|
||||||
|
tutorial = "--tutorial" in sys.argv[2:]
|
||||||
|
taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA)
|
||||||
meta = json.loads((d / "cues.json").read_text())
|
meta = json.loads((d / "cues.json").read_text())
|
||||||
nome = meta["clip"]
|
nome = meta["clip"]
|
||||||
registro = OUT / f"{nome}-audio.json"
|
registro = OUT / f"{nome}-audio.json"
|
||||||
@@ -59,6 +67,10 @@ def main() -> None:
|
|||||||
aperta["fine"] = e["t"]
|
aperta["fine"] = e["t"]
|
||||||
aperta = None
|
aperta = None
|
||||||
volume = VOLUME_SOTTO
|
volume = VOLUME_SOTTO
|
||||||
|
# Nei video «Come si gioca» parla il narratore quasi sempre: la musica resta un sottofondo (Andrea, 26 ottobre:
|
||||||
|
# «troppo alta la musica del gioco»).
|
||||||
|
if (OUT / f"{nome}-narrazione.json").exists():
|
||||||
|
volume = VOLUME_TUTORIAL
|
||||||
else:
|
else:
|
||||||
musica = json.loads((OUT / "musica.json").read_text())
|
musica = json.loads((OUT / "musica.json").read_text())
|
||||||
volume = VOLUME
|
volume = VOLUME
|
||||||
@@ -105,9 +117,20 @@ def main() -> None:
|
|||||||
lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else ""
|
lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else ""
|
||||||
catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]")
|
catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]")
|
||||||
pezzi.append(f"[v{len(pezzi)}]")
|
pezzi.append(f"[v{len(pezzi)}]")
|
||||||
|
# Il narratore dei video «Come si gioca» (video/narra.py): ogni frase all'inizio della sua battuta.
|
||||||
|
narrazione = OUT / f"{nome}-narrazione.json"
|
||||||
|
if narrazione.exists():
|
||||||
|
frasi = {v["id"]: v["src"] for v in json.loads(narrazione.read_text())["voci"]}
|
||||||
|
for c in cues:
|
||||||
|
if c["id"] not in frasi:
|
||||||
|
continue
|
||||||
|
ingressi += ["-i", str(GIOCO / frasi[c["id"]])]
|
||||||
|
n = ingressi.count("-i")
|
||||||
|
catene.append(f"[{n}:a]asetpts=PTS-STARTPTS,volume={VOLUME_NARRATORE},adelay={int((c['start'] + scarto) * 1000)}:all=1[n{len(pezzi)}]")
|
||||||
|
pezzi.append(f"[n{len(pezzi)}]")
|
||||||
mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]"
|
mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]"
|
||||||
stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF"
|
stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF"
|
||||||
video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
|
video = f"[0:v]{taglio},pad=iw:ih+{fascia}:0:{fascia}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
|
||||||
finale = OUT / f"{nome}.mp4"
|
finale = OUT / f"{nome}.mp4"
|
||||||
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
|
||||||
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
|
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La voce del narratore dei video «Come si gioca» (video/SCALETTA-CARTE.md), PRIMA di girare.
|
||||||
|
|
||||||
|
python3 video/narra.py video/tutorial-briscola.mjs
|
||||||
|
|
||||||
|
Legge le battute del copione (cues, «it»), le fa dire a XTTS con `mbot parla` (voce del narratore, diversa da
|
||||||
|
quelle dei personaggi), le controlla con `mbot ascolta` e scrive video/out/<clip>-narrazione.json con file e
|
||||||
|
durata di ognuna. Il copione aspetta che la frase sia finita prima di passare alla successiva; monta.py la mette
|
||||||
|
nel video all'inizio della sua battuta. Le frasi già fatte si saltano (per rifarne una: cancellare il file).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VIDEO = Path(__file__).resolve().parent
|
||||||
|
GIOCO = VIDEO.parent
|
||||||
|
OUT = VIDEO / "out"
|
||||||
|
NARRATORE = "Claribel Dervla"
|
||||||
|
PRONUNCIA = GIOCO / "tools" / "pronuncia.json"
|
||||||
|
|
||||||
|
|
||||||
|
def durata(f: Path) -> float:
|
||||||
|
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
|
||||||
|
capture_output=True, text=True, check=True).stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
copione = Path(sys.argv[1]).resolve()
|
||||||
|
clip = copione.stem
|
||||||
|
cues = json.loads(subprocess.run(["node", "--input-type=module", "-e",
|
||||||
|
f"const m = await import({json.dumps(copione.as_uri())}); console.log(JSON.stringify(m.cues))"],
|
||||||
|
capture_output=True, text=True, check=True, cwd=GIOCO).stdout)
|
||||||
|
cartella = OUT / f"{clip}-narrazione"
|
||||||
|
cartella.mkdir(parents=True, exist_ok=True)
|
||||||
|
# XTTS dice «punto» se la frase finisce col punto: si toglie, come fa narrate.py del tour.
|
||||||
|
lavori = [{"text": c["it"].rstrip(". "), "out": str(cartella / f"{c['id']}.wav"), "lang": "it", "speaker": NARRATORE} for c in cues]
|
||||||
|
(cartella / "lavori.json").write_text(json.dumps(lavori, ensure_ascii=False, indent=1))
|
||||||
|
# Le pronunce del gioco più quelle del narratore, in un file solo per `mbot parla`.
|
||||||
|
regole = {k: v for f in (PRONUNCIA, VIDEO / "pronuncia-narratore.json") if f.exists() for k, v in json.loads(f.read_text()).items() if not k.startswith("_")}
|
||||||
|
(cartella / "pronuncia.json").write_text(json.dumps(regole, ensure_ascii=False, indent=1))
|
||||||
|
pron = ["--pronounce", str(cartella / "pronuncia.json")]
|
||||||
|
subprocess.run(["mbot", "parla", "--jobs", str(cartella / "lavori.json"), *pron], check=True)
|
||||||
|
ascolto = [{"file": l["out"], "expect": l["text"]} for l in lavori]
|
||||||
|
(cartella / "ascolto.json").write_text(json.dumps(ascolto, ensure_ascii=False, indent=1))
|
||||||
|
subprocess.run(["mbot", "ascolta", "--jobs", str(cartella / "ascolto.json")])
|
||||||
|
voci = [{"id": c["id"], "src": str(Path(l["out"]).relative_to(GIOCO)), "sec": round(durata(Path(l["out"])), 2)} for c, l in zip(cues, lavori)]
|
||||||
|
(OUT / f"{clip}-narrazione.json").write_text(json.dumps({"voci": voci}, ensure_ascii=False, indent=1))
|
||||||
|
print(f"-> {OUT / f'{clip}-narrazione.json'}: {len(voci)} frasi, {sum(v['sec'] for v in voci):.0f} s di voce")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"_nota": "Pronunce in più per il narratore dei video «Come si gioca» (video/narra.py), oltre a tools/pronuncia.json. L'accento scritto sposta quello di XTTS (Andrea, 26 ottobre: «piccola» e «volta» con l'accento sbagliato).",
|
||||||
|
"piccola": "pìccola",
|
||||||
|
"piccole": "pìccole",
|
||||||
|
"volta": "vòlta",
|
||||||
|
"volte": "vòlte"
|
||||||
|
}
|
||||||