Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3035e33f | ||
|
|
22be1c306e | ||
|
|
add5a5fe77 | ||
|
|
3019a6e32f | ||
|
|
111b892916 | ||
|
|
f1781afea8 | ||
|
|
b44eeffd2a | ||
|
|
89ebdd40bb | ||
|
|
538bf6cedb | ||
|
|
7e1e6f6144 | ||
|
|
30e324d7e2 | ||
|
|
e8152563d8 | ||
|
|
fef236dc54 | ||
|
|
c82bef70b2 |
@@ -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 \
|
||||||
|
|||||||
|
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 |
@@ -307,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
|
||||||
@@ -543,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
|
||||||
@@ -1527,9 +1527,17 @@
|
|||||||
"file": "andrea-d2f285f610.mp3",
|
"file": "andrea-d2f285f610.mp3",
|
||||||
"sec": 1.04
|
"sec": 1.04
|
||||||
},
|
},
|
||||||
"andrea|La rivincita?": {
|
"andrea|Rivincita a briscola?": {
|
||||||
"file": "andrea-98e4366a56.mp3",
|
"file": "andrea-6807209a9e.mp3",
|
||||||
"sec": 0.83
|
"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.": {
|
"andrea|Ora cerco il palio. A dopo.": {
|
||||||
"file": "andrea-6e63dafda1.mp3",
|
"file": "andrea-6e63dafda1.mp3",
|
||||||
@@ -1543,6 +1551,14 @@
|
|||||||
"file": "giocatore_carte-8dfadb4acc.mp3",
|
"file": "giocatore_carte-8dfadb4acc.mp3",
|
||||||
"sec": 3.4
|
"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.": {
|
"andrea|Con le toscane.": {
|
||||||
"file": "andrea-e8599cc8b6.mp3",
|
"file": "andrea-e8599cc8b6.mp3",
|
||||||
"sec": 1.03
|
"sec": 1.03
|
||||||
@@ -1883,6 +1899,10 @@
|
|||||||
"file": "andrea-83b1337b4d.mp3",
|
"file": "andrea-83b1337b4d.mp3",
|
||||||
"sec": 3.31
|
"sec": 3.31
|
||||||
},
|
},
|
||||||
|
"andrea|La rivincita?": {
|
||||||
|
"file": "andrea-98e4366a56.mp3",
|
||||||
|
"sec": 0.83
|
||||||
|
},
|
||||||
"andrea|Allora, Brento Sanico?": {
|
"andrea|Allora, Brento Sanico?": {
|
||||||
"file": "andrea-2add39ff10.mp3",
|
"file": "andrea-2add39ff10.mp3",
|
||||||
"sec": 1.29
|
"sec": 1.29
|
||||||
@@ -2083,6 +2103,22 @@
|
|||||||
"file": "giocatore_carte-729b3344da.mp3",
|
"file": "giocatore_carte-729b3344da.mp3",
|
||||||
"sec": 3.3
|
"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
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 vo
|
|||||||
|
|
||||||
Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
|
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
|
## Grafica e audio
|
||||||
|
|
||||||
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
|
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -23,7 +23,11 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
|
(«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),
|
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`.
|
disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
|
||||||
Prossimo: il **tressette a coppie «ai 21»** con busso, striscio e volo (da chiedere ad Andrea: le accuse?).
|
**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.
|
||||||
|
|||||||
@@ -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,19 +113,77 @@
|
|||||||
<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">
|
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
|
||||||
<p id="briscola-status" role="status"></p>
|
<p id="briscola-status" role="status"></p>
|
||||||
<div class="briscola-hand" aria-label="La mano di Andrea">
|
<div class="card-hand" aria-label="La mano di Andrea">
|
||||||
<button id="briscola-card-0" type="button"></button>
|
<button id="briscola-card-0" type="button"></button>
|
||||||
<button id="briscola-card-1" type="button"></button>
|
<button id="briscola-card-1" type="button"></button>
|
||||||
<button id="briscola-card-2" type="button"></button>
|
<button id="briscola-card-2" type="button"></button>
|
||||||
</div>
|
</div>
|
||||||
<details><summary>Come si gioca</summary>
|
<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.
|
<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>
|
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
|
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
|
||||||
di uscita. Ordine: Asso, 3, Re, Cavallo, Fante, 7, 6, 5, 4, 2.
|
di uscita. Ordine: Asso, 3, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
|
||||||
Asso 11 punti, 3 dieci, Re 4, Cavallo 3, Fante 2; le altre zero.</p>
|
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;
|
<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.
|
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>
|
Durante la partita i punti non si dicono!</p>
|
||||||
@@ -132,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.
|
||||||
@@ -140,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,14 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.11",
|
"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: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"
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { canPlay, cardName, counted, countRows } from './briscola.js';
|
import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js';
|
||||||
import { text, panel } from './bocce-render.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.
|
// 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 const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
|
||||||
export function cardAt(c, [x, y]) {
|
export function cardAt(c, [x, y]) {
|
||||||
@@ -249,8 +255,8 @@ function countTable(ctx, c, names) {
|
|||||||
}
|
}
|
||||||
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');
|
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
|
||||||
}
|
}
|
||||||
export function drawBriscola(ctx, c, def) {
|
// Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
|
||||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
export function drawTable(ctx, title) {
|
||||||
rect(ctx, 0, 0, 640, 360, '#573720');
|
rect(ctx, 0, 0, 640, 360, '#573720');
|
||||||
for (let y = 0; y < 360; y += 24) {
|
for (let y = 0; y < 360; y += 24) {
|
||||||
rect(ctx, 0, y, 640, 2, '#38271e');
|
rect(ctx, 0, y, 640, 2, '#38271e');
|
||||||
@@ -258,7 +264,11 @@ export function drawBriscola(ctx, c, def) {
|
|||||||
}
|
}
|
||||||
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
|
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');
|
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
|
||||||
text(ctx, 'BRISCOLA ALL’ORATORIO', 320, 15, 9, '#ffe0a0', 'center');
|
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; }
|
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');
|
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));
|
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
|
||||||
@@ -281,9 +291,13 @@ export function drawBriscola(ctx, c, def) {
|
|||||||
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
|
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
|
||||||
}
|
}
|
||||||
c.table.forEach((p, i) => {
|
c.table.forEach((p, i) => {
|
||||||
// Il brevissimo scorrimento precede il fermo della presa.
|
// La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso.
|
||||||
const slide = i === c.table.length - 1 ? Math.max(0, 1 - c.t / 0.18) : 0;
|
const spot = [242 + i * 86, 130, 64, 92];
|
||||||
drawCard(ctx, p.card, 242 + i * 86, 130 + slide * (p.who === 'player' ? 60 : -40), 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) => {
|
c.hands.player.forEach((card, i) => {
|
||||||
const x = HAND.x + i * HAND.gap;
|
const x = HAND.x + i * HAND.gap;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export const DECKS = {
|
|||||||
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
|
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
|
||||||
};
|
};
|
||||||
export const LEVELS = ['bona', 'seria', 'male'];
|
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 VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
|
||||||
const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
|
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) =>
|
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||||
@@ -54,7 +57,7 @@ export function nudge(c, direction) {
|
|||||||
export function play(c, index = c.selected, who = 'player') {
|
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;
|
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||||
const [card] = c.hands[who].splice(index, 1);
|
const [card] = c.hands[who].splice(index, 1);
|
||||||
c.table.push({ who, card }); c.t = 0;
|
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.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||||
c.events.push({ kind: 'card-slide' }); c.landing = true;
|
c.events.push({ kind: 'card-slide' }); c.landing = true;
|
||||||
if (c.table.length === 2) {
|
if (c.table.length === 2) {
|
||||||
@@ -74,6 +77,8 @@ export function collect(c) {
|
|||||||
}
|
}
|
||||||
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||||
c.step = c.hands.player.length ? 'play' : 'count';
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
|
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
|
||||||
@@ -169,8 +174,8 @@ export function chooseCard(v, level = 'seria', seed = 1) {
|
|||||||
export function update(c, dt) {
|
export function update(c, dt) {
|
||||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||||
c.t += dt;
|
c.t += dt;
|
||||||
if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||||
if (c.step === 'trick' && c.t >= 1.25) collect(c);
|
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
|
||||||
else if (c.step === 'count') {
|
else if (c.step === 'count') {
|
||||||
// Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
|
// 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));
|
c.count = Math.min(40, Math.floor(c.t / 0.35));
|
||||||
@@ -195,5 +200,7 @@ export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
|||||||
return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
|
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]}.`;
|
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||||
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} sceglie…`;
|
// 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…`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ 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 briscola from './briscola.js';
|
||||||
|
import * as tressette from './tressette.js';
|
||||||
|
import * as scopa from './scopa.js';
|
||||||
|
|
||||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
// 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 },
|
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 },
|
||||||
};
|
};
|
||||||
@@ -247,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;
|
||||||
@@ -257,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`).
|
||||||
@@ -405,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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 { 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;
|
||||||
|
|
||||||
@@ -668,6 +670,8 @@ 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 === '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…`;
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@ export class Soundscape {
|
|||||||
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-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
|
||||||
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
|
else if (kind === 'card-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') {
|
else if (kind === 'card-shuffle') {
|
||||||
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
|
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);
|
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, 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.`;
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ 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 * as briscola from './engine/briscola.js';
|
||||||
import { cardAt } from './engine/briscola-render.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);
|
||||||
@@ -285,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) {
|
||||||
@@ -401,9 +437,18 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
if (!running || e.button > 0) return;
|
if (!running || e.button > 0) return;
|
||||||
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||||
if (game.closeup) {
|
if (game.closeup) {
|
||||||
if (story.closeups[game.closeup.id].type === 'briscola') {
|
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
|
||||||
|
if (deck) {
|
||||||
if (game.skip()) return;
|
if (game.skip()) return;
|
||||||
press = { card: cardAt(game.closeup, scenePoint(e)), state: game.closeup };
|
// 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);
|
canvas.setPointerCapture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -412,6 +457,7 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
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();
|
||||||
@@ -423,9 +469,19 @@ 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) {
|
if (press.card !== undefined) {
|
||||||
const p = press; press = null;
|
const p = press; press = null;
|
||||||
if (running && game.closeup === p.state && p.card >= 0 && cardAt(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
|
if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
|
||||||
return;
|
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; }
|
||||||
@@ -440,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?.card !== undefined) { 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 });
|
||||||
@@ -448,16 +504,19 @@ 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 separati dai verbi: carte oppure mira, forza e lancio.
|
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
||||||
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.' },
|
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.' },
|
||||||
@@ -466,6 +525,23 @@ const miniState = () => running && game?.closeup && MINI[story.closeups[game.clo
|
|||||||
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++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const button = $('briscola-card-' + i);
|
const button = $('briscola-card-' + i);
|
||||||
button.addEventListener('click', () => game?.closeupPlay(i));
|
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||||
@@ -554,17 +630,66 @@ 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') {
|
if (story.closeups[game.closeup.id].type === 'briscola') {
|
||||||
const inControls = e.target.closest?.('#briscola-controls');
|
const inControls = e.target.closest?.('#briscola-controls');
|
||||||
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
|
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 === 'SUMMARY' && e.key === 'Enter') return;
|
||||||
if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && e.key === 'Enter' && briscola.canPlay(game.closeup)) 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)) {
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.repeat || game.skip()) return;
|
if (e.repeat || game.skip()) return;
|
||||||
@@ -572,7 +697,7 @@ window.addEventListener('keydown', e => {
|
|||||||
if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
|
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 (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
|
||||||
else if (c.step === 'result') game.closeupClose();
|
else if (c.step === 'result') game.closeupClose();
|
||||||
else if (e.key === 'Enter') game.closeupPlay();
|
else game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
|
||||||
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
|
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -666,12 +791,60 @@ 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];
|
||||||
const cards = def?.type === 'briscola';
|
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
||||||
$('bocce-controls').hidden = !mg || cards;
|
$('bocce-controls').hidden = !mg || cards;
|
||||||
$('bocce-controls').inert = !running;
|
$('bocce-controls').inert = !running;
|
||||||
$('briscola-controls').hidden = !cards;
|
$('briscola-controls').hidden = def?.type !== 'briscola';
|
||||||
$('briscola-controls').inert = !running;
|
$('briscola-controls').inert = !running;
|
||||||
if (cards) {
|
$('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;
|
$('panel').hidden = $('choices').hidden = true;
|
||||||
const line = briscola.status(c, def.names);
|
const line = briscola.status(c, def.names);
|
||||||
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
||||||
@@ -735,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 });
|
||||||
|
|||||||
@@ -109,15 +109,33 @@ export const story = {
|
|||||||
closeups: {
|
closeups: {
|
||||||
// [DA ANDREA: chi è] Partita facoltativa; battute provvisorie.
|
// [DA ANDREA: chi è] Partita facoltativa; battute provvisorie.
|
||||||
briscola: {
|
briscola: {
|
||||||
type: 'briscola', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
|
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' }],
|
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' }],
|
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' }],
|
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' }],
|
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: [
|
||||||
@@ -185,6 +203,14 @@ 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.
|
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
|
||||||
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
|
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
|
||||||
@@ -558,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: {
|
||||||
@@ -793,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' }],
|
||||||
@@ -881,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: {
|
||||||
@@ -2143,7 +2172,9 @@ export const story = {
|
|||||||
nodes: {
|
nodes: {
|
||||||
start: { options: [
|
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: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
|
||||||
{ text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
|
{ text: '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' },
|
{ 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.
|
// Il mazzo (idea di Andrea): le toscane di casa, o le napoletane. Stesse regole, cambiano i disegni.
|
||||||
@@ -2158,6 +2189,30 @@ export const story = {
|
|||||||
{ 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: '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' },
|
{ 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).
|
||||||
|
|||||||
@@ -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.11';
|
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,12 +26,23 @@ 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-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
|
||||||
#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
|
#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
|
||||||
.briscola-hand { display: flex; gap: 8px; }
|
.card-hand { display: flex; gap: 8px; }
|
||||||
.briscola-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; }
|
.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; }
|
||||||
.briscola-hand button[aria-pressed="true"] { border-color: #ffdb76; }
|
.card-hand button[aria-pressed="true"] { border-color: #ffdb76; }
|
||||||
.briscola-hand button:disabled { opacity: .5; cursor: default; }
|
.card-hand button:disabled { opacity: .5; cursor: default; }
|
||||||
#briscola-controls summary { cursor: pointer; padding: 10px 0; }
|
#briscola-controls summary { cursor: pointer; padding: 10px 0; }
|
||||||
#briscola-controls p { max-width: 76em; }
|
#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; }
|
||||||
@@ -102,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;
|
||||||
@@ -189,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)); }
|
||||||
@@ -268,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; }
|
||||||
@@ -282,15 +299,17 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#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); }
|
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
#briscola-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
||||||
.briscola-hand { flex-direction: column; gap: 4px; }
|
/* 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)); }
|
||||||
@@ -306,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; }
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ try {
|
|||||||
game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) };
|
game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) };
|
||||||
}, deck);
|
}, deck);
|
||||||
await page.waitForSelector('#briscola-controls:not([hidden])');
|
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'));
|
assert.ok(await page.isHidden('#bocce-controls'));
|
||||||
const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
|
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');
|
assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
|
||||||
@@ -160,7 +176,7 @@ try {
|
|||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
|
const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
|
||||||
});
|
});
|
||||||
await page.locator('#choices li', { hasText: 'La rivincita?' }).click();
|
await page.locator('#choices li', { hasText: 'Rivincita a briscola?' }).click();
|
||||||
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
|
await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
|
||||||
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test('nessun obbligo di seme; la presa resta sul tavolo, chi prende pesca prima
|
|||||||
assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1));
|
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);
|
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, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length);
|
||||||
update(c, 0.26);
|
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.player.at(-1).id, stock[0].id);
|
||||||
assert.equal(c.hands.cpu.at(-1).id, stock[1].id);
|
assert.equal(c.hands.cpu.at(-1).id, stock[1].id);
|
||||||
assert.equal(c.current, 'player'); assert.equal(c.table.length, 0);
|
assert.equal(c.current, 'player'); assert.equal(c.table.length, 0);
|
||||||
@@ -60,7 +60,7 @@ test('l’ultima pescata è la briscola, poi tre prese senza mazzo; totale e con
|
|||||||
}
|
}
|
||||||
assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20);
|
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.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0);
|
||||||
assert.equal(c.result, undefined, 'risultato solo dopo il conteggio');
|
assert.equal(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.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40);
|
||||||
assert.deepEqual(counted(c), { player: 0, cpu: 0 });
|
assert.deepEqual(counted(c), { player: 0, cpu: 0 });
|
||||||
update(c, 0.36); assert.equal(c.count, 1);
|
update(c, 0.36); assert.equal(c.count, 1);
|
||||||
@@ -165,7 +165,7 @@ test('oratorio: dialogo, livelli, abbandono, risultati, rivincita e salvataggio
|
|||||||
g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
|
g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
|
||||||
assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
|
assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
|
||||||
g.act('talk', 'giocatore_carte'); settle(g);
|
g.act('talk', 'giocatore_carte'); settle(g);
|
||||||
assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'La rivincita?');
|
assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'Rivincita a briscola?');
|
||||||
assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
|
assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,3 +181,25 @@ test('due mazzi, stesse regole: le napoletane cambiano solo i nomi', () => {
|
|||||||
// L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane.
|
// 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)));
|
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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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,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)")
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Porta i video «Come si gioca» nel gioco (assets/video/, story.tutorials): da quando si arriva nel posto del gioco
|
||||||
|
(la prima battuta del narratore), più piccoli e compressi per il web, con una copertina.
|
||||||
|
|
||||||
|
python3 video/pubblica.py briscola tressette scopa bocce biliardo
|
||||||
|
|
||||||
|
Prima la ripresa e il montaggio (video/SCALETTA-CARTE.md): legge video/out/tutorial-<gioco>.mp4 e il cues.json della
|
||||||
|
ripresa. Il titolo del gioco all'inizio della ripresa non serve, e col ritaglio dei tavoli verrebbe tagliato ai lati.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VIDEO = Path(__file__).resolve().parent
|
||||||
|
GIOCO = VIDEO.parent
|
||||||
|
RIPRESE = Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
for gioco in sys.argv[1:]:
|
||||||
|
ripresa = RIPRESE / f"tutorial-{gioco}"
|
||||||
|
cues = json.loads((ripresa / "cues.json").read_text())["cues"]
|
||||||
|
# Lo stesso scarto di monta.py: la ripresa parte un po' prima del copione.
|
||||||
|
scarto = max(0.0, durata(ripresa / "raw.mkv") - (cues[-1]["end"] + 1.2))
|
||||||
|
inizio = max(0.0, cues[0]["start"] + scarto - 0.4)
|
||||||
|
dentro, fuori = VIDEO / "out" / f"tutorial-{gioco}.mp4", GIOCO / "assets" / "video" / f"{gioco}.mp4"
|
||||||
|
fuori.parent.mkdir(exist_ok=True)
|
||||||
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{inizio:.2f}", "-i", str(dentro),
|
||||||
|
"-vf", "scale=trunc(iw*0.75/2)*2:-2", "-c:v", "libx264", "-preset", "slow", "-crf", "27", "-pix_fmt", "yuv420p",
|
||||||
|
"-af", "afade=t=in:d=0.4", "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(fuori)], check=True)
|
||||||
|
# La copertina: un momento al tavolo, a un terzo del video.
|
||||||
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{durata(fuori) / 3:.1f}", "-i", str(fuori), "-frames:v", "1",
|
||||||
|
"-q:v", "4", str(fuori.with_suffix(".jpg"))], check=True)
|
||||||
|
for f in (fuori, fuori.with_suffix(".jpg")):
|
||||||
|
f.chmod(0o644)
|
||||||
|
print(f"-> {fuori} ({durata(fuori):.0f} s, {fuori.stat().st_size / 1e6:.1f} MB), da {inizio:.1f} s")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Controlla che nel video «Come si gioca» il narratore non parli sopra i personaggi del gioco.
|
||||||
|
python3 video/sovrapposte.py tutorial-briscola (dopo la ripresa: cues.json, registro audio, narrazione)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VIDEO = Path(__file__).resolve().parent
|
||||||
|
clip = sys.argv[1]
|
||||||
|
cues = json.loads((Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out" / clip / "cues.json").read_text())["cues"]
|
||||||
|
narr = {v["id"]: v["sec"] for v in json.loads((VIDEO / "out" / f"{clip}-narrazione.json").read_text())["voci"]}
|
||||||
|
voci, aperta = [], None
|
||||||
|
for e in json.loads((VIDEO / "out" / f"{clip}-audio.json").read_text())["eventi"]:
|
||||||
|
if "voice" in e:
|
||||||
|
aperta = [e["t"], None, e["voice"]]
|
||||||
|
voci.append(aperta)
|
||||||
|
elif "voiceEnd" in e and aperta:
|
||||||
|
aperta[1], aperta = e["t"], None
|
||||||
|
male = [(c["id"], v[2]) for c in cues for v in voci if v[1] and v[0] < c["start"] + narr[c["id"]] and v[1] > c["start"]]
|
||||||
|
for id_, voce in male:
|
||||||
|
print(f"✗ «{id_}» sopra {voce}")
|
||||||
|
print(f"{clip}: {len(voci)} voci del gioco, {len(cues)} frasi del narratore, {len(male)} sovrapposte")
|
||||||
|
sys.exit(1 if male else 0)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Copione del video «Come si gioca: il biliardo» (video/SCALETTA-CARTE.md, clip 5).
|
||||||
|
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||||
|
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||||
|
|
||||||
|
export const title = { it: "Come si gioca · il biliardo", en: "Come si gioca · il biliardo" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Il biliardo è al Circolo 4 Maggio, in piazza. L'Ingegner Tozzi gioca ai cinque birilli, e si va a venti"),
|
||||||
|
c("livello", "Anche qui si sceglie quanto deve giocare forte"),
|
||||||
|
c("tavolo", "Il tavolo dall'alto: la tua biglia, quella dell'avversario, il pallino, e in mezzo il castello dei birilli"),
|
||||||
|
c("scopo", "Si colpisce la propria biglia e la si manda su quella dell'avversario: i birilli che lei abbatte fanno punti a chi tira"),
|
||||||
|
c("mira", "La stecca segue il mouse, e un clic fissa la mira. La linea tratteggiata è la strada della biglia; le frecce, dove vanno le due biglie dopo l'urto"),
|
||||||
|
c("tira", "Con le frecce si gira la stecca, con Maiuscolo ancora più piano. Poi si regola la forza, e si preme Tira"),
|
||||||
|
c("valori", "Il birillo bianco vale due, il rosso quattro, il rosso da solo dieci. Il pallino toccato dalla biglia avversaria, tre"),
|
||||||
|
c("fallo", "Attenzione ai falli: mancare la biglia avversaria, toccare prima il pallino, o buttare giù birilli con la propria. I punti vanno all'avversario"),
|
||||||
|
c("fine", "Un colpo a testa, finché qualcuno arriva a venti. Buona partita!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-biliardo");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("circolo_4_maggio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("ing_tozzi");
|
||||||
|
await r.scegli("una partita ai birilli");
|
||||||
|
await r.cue("livello");
|
||||||
|
await r.scegli("Alla bona.");
|
||||||
|
// Una partita fissa: un colpo da otto, un fallo voluto per farlo vedere, poi quattro e tre; vince Andrea 21 a 2.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newBiliardo } = await import("/src/engine/biliardo.js");
|
||||||
|
game.closeup = { id: "biliardo", ...newBiliardo(2, { level: "bona" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await r.cue("tavolo");
|
||||||
|
await r.muovi([200, 200], 0.9); await sleep(300); await r.muovi([400, 180], 0.9);
|
||||||
|
await r.cue("scopo");
|
||||||
|
|
||||||
|
// Il colpo: il migliore fra quelli provati a vuoto (o, la seconda volta, un fallo apposta). Si mira col clic sul
|
||||||
|
// tavolo lungo la direzione scelta, poi forza col cursore e Tira.
|
||||||
|
const colpo = (sbaglia) => p.evaluate(async (sbaglia) => {
|
||||||
|
const L = await import("/src/engine/biliardo.js"), c = game.closeup, b = c.balls.player, o = c.balls.cpu;
|
||||||
|
let top = null;
|
||||||
|
if (sbaglia) for (let a = 0; a < Math.PI * 2 && !top; a += 0.05) { if (L.simulate(c, "player", a, 0.12).foul === "miss") top = { angle: a, power: 0.12 }; }
|
||||||
|
else {
|
||||||
|
const base = Math.atan2(o.y - b.y, o.x - b.x);
|
||||||
|
for (let d = -0.35; d <= 0.35; d += 0.01) for (const power of [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]) {
|
||||||
|
const j = L.simulate(c, "player", base + d, power), gain = j.to === "player" ? j.points : -j.points;
|
||||||
|
if (!top || gain > top.gain) top = { angle: base + d, power, gain };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const x = b.x + Math.cos(top.angle) * 45, y = b.y + Math.sin(top.angle) * 45;
|
||||||
|
return { ...top, at: [L.VIEW.x + x * L.VIEW.k, L.VIEW.y + y * L.VIEW.k] };
|
||||||
|
}, sbaglia);
|
||||||
|
const tocca = async () => {
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canThrow } = await import("/src/engine/biliardo.js");
|
||||||
|
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s !== "aspetta") return s === "tocca";
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let n = 0;
|
||||||
|
while (await tocca()) {
|
||||||
|
n++;
|
||||||
|
if (n === 1) await r.cue("mira");
|
||||||
|
if (n === 2) await r.cue("fallo");
|
||||||
|
const m = await colpo(n === 2);
|
||||||
|
await r.muovi(m.at, 0.9); await sleep(400);
|
||||||
|
await r.clicca(m.at, 0.3);
|
||||||
|
await p.evaluate(({ angle }) => { game.closeup.angle = angle; game.closeup.aimLocked = true; }, m);
|
||||||
|
if (n === 1) await r.cue("tira");
|
||||||
|
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||||
|
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||||
|
await p.evaluate((v) => { game.closeup.power = v; }, m.power);
|
||||||
|
await sleep(500);
|
||||||
|
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
if (n === 1) await r.cue("valori");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 120000 });
|
||||||
|
await sleep(1500);
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copione del video «Come si gioca: le bocce» (video/SCALETTA-CARTE.md, clip 4).
|
||||||
|
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||||
|
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||||
|
|
||||||
|
export const title = { it: "Come si gioca · le bocce", en: "Come si gioca · le bocce" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Le bocce si giocano al pallaio, accanto al tennis della chiesa. Mariano, il campione, sfida chiunque"),
|
||||||
|
c("livello", "Si sceglie quanto deve impegnarsi: alla bona, seria, o a fassi male"),
|
||||||
|
c("campo", "Il campo visto da dietro: in fondo c'è il pallino, e a sinistra il dettaglio, per vedere da vicino chi è più accosto"),
|
||||||
|
c("mira", "Per mirare si tocca il campo, o il dettaglio vicino al pallino. Il cerchio azzurro dice dove si fermerebbe la boccia"),
|
||||||
|
c("forza", "Poi si regola la forza col cursore, e si preme Lancia"),
|
||||||
|
c("turno", "Tira sempre chi non ha il punto. Chi è dietro può accostare, oppure bocciare: colpire la boccia dell'altro per toglierla di lì"),
|
||||||
|
c("tieni", "C'è anche un altro modo: si tiene premuto sul campo, la forza sale, e lasciando parte la boccia. Vanno bene anche le frecce e la barra spaziatrice"),
|
||||||
|
c("punti", "Tre bocce a testa. Alla fine fa un punto ogni boccia più vicina al pallino della migliore dell'avversario"),
|
||||||
|
c("fine", "E se batti Mariano, ti porta a Brento Sanico. Buona partita!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-bocce");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("tennis_chiesa", [430, 310]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("mariano");
|
||||||
|
await r.scegli("Facciamo una partita");
|
||||||
|
await r.cue("livello");
|
||||||
|
await r.scegli("Alla bona.");
|
||||||
|
// Una mano fissa: accostando bene, Andrea fa tre punti (anche se il tiro a pressione viene un po' corto o lungo).
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newBocce } = await import("/src/engine/bocce.js");
|
||||||
|
game.closeup = { id: "bocce", ...newBocce(9, { level: "bona" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/bocce.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("campo");
|
||||||
|
await r.muovi([300, 200], 1.0); await sleep(400); await r.muovi([90, 170], 1.0);
|
||||||
|
|
||||||
|
// Dove mirare: poco prima del pallino, così la boccia si ferma accosto (il cerchio azzurro ci finisce sopra).
|
||||||
|
const mira = () => p.evaluate(async () => {
|
||||||
|
const B = await import("/src/engine/bocce.js"), c = game.closeup, u = c.jack.u - 0.55, w = c.jack.w;
|
||||||
|
const s = B.project(u, w);
|
||||||
|
return { at: [s.x, s.y], u, w, power: B.powerFor(Math.hypot(u, w) - 0.29) };
|
||||||
|
});
|
||||||
|
const tocca = async () => {
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canThrow } = await import("/src/engine/bocce.js");
|
||||||
|
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s !== "aspetta") return s === "tocca";
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let tiro = 0;
|
||||||
|
while (await tocca()) {
|
||||||
|
tiro++;
|
||||||
|
const m = await mira();
|
||||||
|
if (tiro === 1) await r.cue("mira");
|
||||||
|
if (tiro < 3) {
|
||||||
|
// Mira col clic, forza col cursore, poi Lancia.
|
||||||
|
await r.clicca(m.at, 0.8);
|
||||||
|
await p.evaluate(async ({ u, w }) => (await import("/src/engine/bocce.js")).aim(game.closeup, u, w), m);
|
||||||
|
if (tiro === 1) await r.cue("forza");
|
||||||
|
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||||
|
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||||
|
await p.evaluate(async (v) => (await import("/src/engine/bocce.js")).setPower(game.closeup, v), m.power);
|
||||||
|
await sleep(600);
|
||||||
|
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||||
|
if (tiro === 1) await r.cue("turno");
|
||||||
|
} else {
|
||||||
|
// L'ultima tenendo premuto: la forza sale finché non si lascia.
|
||||||
|
await r.cue("tieni");
|
||||||
|
await r.muovi(m.at, 0.8);
|
||||||
|
const [a, b] = await r.punto(...m.at);
|
||||||
|
d.vm(`DISPLAY=:0 xdotool mousemove ${Math.round(a)} ${Math.round(b)} mousedown 1 sleep ${(m.power * 1.6).toFixed(2)} mouseup 1`);
|
||||||
|
}
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 90000 });
|
||||||
|
await sleep(1000);
|
||||||
|
await r.cue("punti");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Copione del video «Come si gioca: la briscola» (video/SCALETTA-CARTE.md, clip 1).
|
||||||
|
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||||
|
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||||
|
|
||||||
|
export const title = { it: "Come si gioca · la briscola", en: "Come si gioca · la briscola" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "All'oratorio della Pieve, alle tavolate, c'è il giocatore di carte: per giocare basta parlarci"),
|
||||||
|
c("mazzo", "Prima si sceglie il mazzo: le carte toscane, con gobbo, donna e regio, oppure le napoletane"),
|
||||||
|
c("livello", "Poi quanto deve giocare forte: alla bona, seria, o a fassi male"),
|
||||||
|
c("tavolo", "In basso le tue tre carte, in alto quelle coperte dell'avversario. A sinistra il mazzo, con la briscola girata sotto: quel seme comanda"),
|
||||||
|
c("presa", "Si gioca una carta per uno. Prende la briscola più alta; se non ce ne sono, la carta più alta del seme di chi è uscito"),
|
||||||
|
c("punti", "Contano l'Asso, undici punti, il tre, dieci, il regio quattro, la donna tre e il gobbo due. Le altre sono lisci e non valgono niente"),
|
||||||
|
c("pesca", "Chi prende pesca per primo ed esce alla mano dopo. E non c'è l'obbligo di rispondere al seme"),
|
||||||
|
c("consiglio", "Un consiglio: su un liscio rispondi con un liscio, e i carichi dell'avversario prendili con una briscola piccola"),
|
||||||
|
c("tastiera", "Si gioca toccando la carta, oppure coi pulsanti sotto il tavolo, o con le frecce e Invio"),
|
||||||
|
c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti; sessanta a sessanta è pari, e non vince nessuno"),
|
||||||
|
c("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-briscola");
|
||||||
|
// Il registro dell'audio parte con la ripresa: monta.py rimette le voci del gioco al loro istante.
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("oratorio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("giocatore_carte");
|
||||||
|
await r.scegli("Una briscola?");
|
||||||
|
await r.cue("mazzo");
|
||||||
|
await r.scegli("Con le toscane.");
|
||||||
|
await r.cue("livello");
|
||||||
|
await r.scegli("Seria.");
|
||||||
|
// Una mano fissa, la stessa a ogni ripresa: giocando attento, Andrea vince 68 a 52.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newBriscola } = await import("/src/engine/briscola.js");
|
||||||
|
game.closeup = { id: "briscola", ...newBriscola(8, { level: "seria", deck: "toscane" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
// Il tavolo ha la sua musica: se non parte, il gioco servito nella VM è vecchio.
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("tavolo");
|
||||||
|
await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0);
|
||||||
|
|
||||||
|
// Una carta di Andrea: quella che sceglierebbe un giocatore attento, cliccata davvero sul tavolo.
|
||||||
|
const gioca = async () => {
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canPlay } = await import("/src/engine/briscola.js");
|
||||||
|
return game.closeup?.step === "result" || game.closeup?.step === "count" ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s === "fine") return false;
|
||||||
|
if (s === "tocca") break;
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
const i = await p.evaluate(async () => {
|
||||||
|
const { observation, attentive } = await import("/src/engine/briscola.js");
|
||||||
|
return attentive(observation(game.closeup, "player"));
|
||||||
|
});
|
||||||
|
await r.clicca([176 + i * 102 + 42, 290], 0.7);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const giocane = async (n) => { for (let k = 0; k < n; k++) if (!(await gioca())) return; };
|
||||||
|
|
||||||
|
await r.cue("presa"); await giocane(2);
|
||||||
|
await r.cue("punti"); await giocane(2);
|
||||||
|
await r.cue("pesca"); await giocane(2);
|
||||||
|
await r.cue("consiglio"); await giocane(2);
|
||||||
|
await r.cue("tastiera"); await giocane(20);
|
||||||
|
await p.waitForFunction(() => game.closeup?.step === "count", null, { timeout: 60000 });
|
||||||
|
await r.cue("conto");
|
||||||
|
await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 });
|
||||||
|
await sleep(2500);
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta(); // la battuta del giocatore, uscendo, dopo quella del narratore
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Pezzi comuni dei copioni «Come si gioca» (video/SCALETTA-CARTE.md): briscola, tressette, scopa, bocce, biliardo.
|
||||||
|
// Si girano come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»); prima `python3 video/narra.py <copione>`
|
||||||
|
// per la voce del narratore, dopo `python3 video/monta.py <cartella> --tutorial`.
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export const URL_GIOCO = "http://127.0.0.1:8000/";
|
||||||
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
export const c = (id, it) => ({ id, it, en: it, top: true });
|
||||||
|
|
||||||
|
// La durata di ogni frase del narratore (video/narra.py): la battuta resta finché la voce non ha finito.
|
||||||
|
export function narrazione(clip) {
|
||||||
|
const f = new URL(`./out/${clip}-narrazione.json`, import.meta.url);
|
||||||
|
return existsSync(f) ? Object.fromEntries(JSON.parse(readFileSync(f, "utf8")).voci.map((v) => [v.id, v.sec * 1000])) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setup(d) {
|
||||||
|
const p = d.page;
|
||||||
|
// Niente cache: il Chromium della VM teneva i moduli JavaScript di una ripresa prima (il valzer non partiva).
|
||||||
|
const cdp = await p.context().newCDPSession(p);
|
||||||
|
await cdp.send("Network.enable");
|
||||||
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
||||||
|
await cdp.send("Network.clearBrowserCache");
|
||||||
|
await p.goto(URL_GIOCO);
|
||||||
|
await p.evaluate(() => localStorage.clear());
|
||||||
|
await p.reload();
|
||||||
|
await p.waitForSelector("#title:not([hidden])");
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regista(d, clip) {
|
||||||
|
const p = d.page, voce = narrazione(clip);
|
||||||
|
let inizio = 0, dura = 0;
|
||||||
|
const r = {
|
||||||
|
// Una battuta del narratore: prima si lascia finire quella di prima, e chi sta parlando nel gioco.
|
||||||
|
async cue(id) {
|
||||||
|
await r.aspetta();
|
||||||
|
for (let i = 0; i < 300 && (await p.evaluate(() => !!game.speech || !!(window.voices?.audio && !window.voices.audio.paused && !window.voices.audio.ended))); i++) await sleep(100);
|
||||||
|
await sleep(250);
|
||||||
|
await d.cue(id);
|
||||||
|
inizio = Date.now(); dura = voce[id] ?? 0;
|
||||||
|
},
|
||||||
|
// Prima di far parlare un personaggio, il narratore deve aver finito: le due voci non si sovrappongono.
|
||||||
|
async aspetta() { const resto = inizio + dura + 350 - Date.now(); if (resto > 0) await sleep(resto); },
|
||||||
|
async fine() { const resto = inizio + dura + 600 - Date.now(); if (resto > 0) await sleep(resto); },
|
||||||
|
// Puntatore vero sulla scena (coordinate 640×360).
|
||||||
|
async punto(x, y) {
|
||||||
|
const b = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
|
||||||
|
return [(b.x + (x / 640) * b.width) * k, (b.y + (y / 360) * b.height) * k];
|
||||||
|
},
|
||||||
|
async clicca([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); },
|
||||||
|
async muovi([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur}`); await sleep(200); },
|
||||||
|
// Aspetta che l'omino abbia finito di camminare e di parlare; un dialogo o un primo piano aperti fermano l'attesa.
|
||||||
|
async fermi(pausa = 500) {
|
||||||
|
await sleep(150);
|
||||||
|
for (let i = 0; i < 1500; i++) {
|
||||||
|
const ok = await p.evaluate(() => {
|
||||||
|
if (game.speech || (game.queue.length && !game.closeup) || game.walking || game.reach > 0) return false;
|
||||||
|
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
|
||||||
|
});
|
||||||
|
if (ok) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
await sleep(pausa);
|
||||||
|
},
|
||||||
|
async scegli(testo) { await r.aspetta(); await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); },
|
||||||
|
// Partita nuova per Campomigliaio, introduzione saltata, dritti nella scena del gioco.
|
||||||
|
async vaiA(scena, at) {
|
||||||
|
await d.click(p.locator("#new-game"));
|
||||||
|
await sleep(1200);
|
||||||
|
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
|
||||||
|
await sleep(800);
|
||||||
|
if (await p.isVisible("#skip-intro")) await d.click(p.locator("#skip-intro"), 0.5);
|
||||||
|
// Le battute d'apertura di Andrea (il palio sparito) qui non servono: si saltano tutte.
|
||||||
|
await p.evaluate(([s, a]) => { for (let i = 0; i < 50 && (game.speech || game.queue.length); i++) { game.skip(); game.update(0.05); } game.go(s, a); }, [scena, at]);
|
||||||
|
await r.fermi(1200);
|
||||||
|
},
|
||||||
|
async parlaCon(id) {
|
||||||
|
await r.aspetta();
|
||||||
|
const [x, y] = await p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
|
||||||
|
await r.clicca([x, y]);
|
||||||
|
await r.fermi();
|
||||||
|
},
|
||||||
|
// L'audio annotato dentro la pagina (musica voluta e voci del gioco), per rimontarlo (monta.py).
|
||||||
|
async ascolta() {
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.__t0 = Date.now(); window.__audio = [];
|
||||||
|
let music = undefined, voice = null;
|
||||||
|
const t = () => (Date.now() - window.__t0) / 1000;
|
||||||
|
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
|
||||||
|
const look = () => {
|
||||||
|
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
|
||||||
|
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
|
||||||
|
if (now !== voice) {
|
||||||
|
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
|
||||||
|
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
|
||||||
|
voice = now;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(look);
|
||||||
|
};
|
||||||
|
look();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async salvaAudio() {
|
||||||
|
const audio = await p.evaluate(() => window.__audio);
|
||||||
|
const out = new URL("./out/", import.meta.url);
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
writeFileSync(new URL(`${clip}-audio.json`, out), JSON.stringify({ eventi: audio }, null, 2));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copione del video «Come si gioca: la scopa» (video/SCALETTA-CARTE.md, clip 3).
|
||||||
|
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||||
|
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||||
|
|
||||||
|
export const title = { it: "Come si gioca · la scopa", en: "Come si gioca · la scopa" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Allo stesso tavolo dell'oratorio si gioca anche a scopa, in due: basta chiedere «Una scopa?»"),
|
||||||
|
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||||
|
c("valori", "Quattro carte in tavola e tre in mano. L'Asso vale uno, le altre il loro numero, il gobbo otto, la donna nove e il regio dieci"),
|
||||||
|
c("presa", "Con una carta prendi quella dello stesso valore; se non c'è, un gruppo di carte che fa la stessa somma. Se puoi prendere, devi"),
|
||||||
|
c("scelta", "Quando le prese possibili sono più d'una, scegli tu: tocca una carta in tavola, oppure usa i pulsanti qui sotto"),
|
||||||
|
c("scopa", "Se con la tua carta pulisci tutta la tavola, è scopa: un punto in più"),
|
||||||
|
c("resto", "Finite le tre carte se ne danno altre tre, finché c'è mazzo. Quel che resta in tavola alla fine va all'ultimo che ha preso"),
|
||||||
|
c("conto", "A fine smazzata un punto a chi ha più carte, uno a chi ha più mattoni, uno per il settebello, il sette di mattoni, e uno per la primiera"),
|
||||||
|
c("primiera", "La primiera somma la carta migliore di ogni seme: il sette vale ventuno, il sei diciotto, l'Asso sedici"),
|
||||||
|
c("fine", "Si va agli undici punti, una smazzata dopo l'altra. Buona scopa!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-scopa");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("oratorio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("giocatore_carte");
|
||||||
|
await r.scegli("Una scopa?");
|
||||||
|
await r.cue("mazzo");
|
||||||
|
await r.scegli("Con le toscane.");
|
||||||
|
await r.scegli("Seria.");
|
||||||
|
// Una smazzata fissa: Andrea sceglie fra due prese, fa due scope e la vince 5 a 1.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newScopa } = await import("/src/engine/scopa.js");
|
||||||
|
game.closeup = { id: "scopa", ...newScopa(73, { level: "seria", deck: "toscane" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("valori");
|
||||||
|
await r.muovi([320, 160], 1.0); await sleep(500); await r.muovi([320, 300], 1.0);
|
||||||
|
await r.cue("presa");
|
||||||
|
|
||||||
|
// Una mossa di Andrea, come la sceglierebbe un giocatore attento; la scelta fra più prese si fa col pulsante.
|
||||||
|
let k = 1, scelta = false, scopa = false;
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canPlay } = await import("/src/engine/scopa.js");
|
||||||
|
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s === "fine") break;
|
||||||
|
if (s === "aspetta") { await sleep(150); continue; }
|
||||||
|
const m = await p.evaluate(async (k) => {
|
||||||
|
const S = await import("/src/engine/scopa.js"), c = game.closeup, m = S.choose(S.observation(c, "player"), "male", k);
|
||||||
|
const card = c.hands.player[m.index], many = S.captures(c.table, card).length > 1;
|
||||||
|
const clean = !!m.captured && m.captured.length === c.table.length;
|
||||||
|
const { handX, HAND } = await import("/src/engine/scopa-render.js");
|
||||||
|
return { ...m, many, clean, at: [handX(c.hands.player.length, m.index) + 32, HAND.y + 46] };
|
||||||
|
}, k++);
|
||||||
|
if (m.many && !scelta) { scelta = true; await r.cue("scelta"); }
|
||||||
|
if (m.clean && !scopa) { scopa = true; await r.cue("scopa"); }
|
||||||
|
await r.clicca(m.at, 0.7);
|
||||||
|
if (m.many) {
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "choose", null, { timeout: 5000 });
|
||||||
|
await sleep(900);
|
||||||
|
await d.click(p.locator(`#scopa-options [data-option="${m.option}"]`), 0.7);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
if (k === 9) await r.cue("resto");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "smazzata", null, { timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
await r.cue("conto");
|
||||||
|
await r.cue("primiera");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copione del video «Come si gioca: il tressette ai 21» (video/SCALETTA-CARTE.md, clip 2).
|
||||||
|
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||||
|
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||||
|
|
||||||
|
export const title = { it: "Come si gioca · il tressette ai 21", en: "Come si gioca · il tressette ai 21" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Per il tressette ai ventuno servono quattro giocatori: il giocatore di carte chiama due amici, e uno fa da socio ad Andrea"),
|
||||||
|
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||||
|
c("tavolo", "Il socio è di fronte, gli avversari ai lati. Dieci carte a testa, e si gira verso destra"),
|
||||||
|
c("accuse", "In alto le accuse: la napoletana, cioè Asso, due e tre dello stesso seme, oppure tre Assi, tre due o tre tre. Valgono punti subito"),
|
||||||
|
c("seme", "Chi esce gioca quel che vuole; gli altri devono rispondere al seme, se ce l'hanno. Le carte che non si possono giocare diventano scure"),
|
||||||
|
c("ordine", "Prende la più alta del seme di uscita: il tre, poi il due, l'Asso, il regio, la donna, il gobbo, e giù fino al quattro"),
|
||||||
|
c("segni", "Chi esce può fare un segno al socio. Si sceglie coi pulsanti qui sotto, o coi tasti B, S e V, e poi si gioca la carta"),
|
||||||
|
c("striscio", "Striscio vuol dire: di questo seme ne ho ancora, non è l'ultima"),
|
||||||
|
c("busso", "Busso: prendi con la migliore che hai, e poi ritornami il seme"),
|
||||||
|
c("volo", "Volo: era l'ultima carta di questo seme"),
|
||||||
|
c("punti", "L'Asso vale un punto; il tre, il due e le figure un terzo di punto. L'ultima presa vale un punto in più"),
|
||||||
|
c("smazzata", "A fine smazzata si contano i punti interi, e le accuse si sommano"),
|
||||||
|
c("fine", "Si gioca smazzata dopo smazzata: la coppia che arriva per prima a ventuno è fuori, e ha vinto. Buon tressette!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-tressette");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("oratorio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("giocatore_carte");
|
||||||
|
await r.scegli("Un tressette ai 21?");
|
||||||
|
await r.cue("mazzo");
|
||||||
|
await r.scegli("Con le toscane.");
|
||||||
|
await r.scegli("Seria.");
|
||||||
|
// Una smazzata fissa: accuse, e Andrea che usa striscio, busso e volo.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newTressette } = await import("/src/engine/tressette.js");
|
||||||
|
game.closeup = { id: "tressette", ...newTressette(11, { level: "seria", deck: "toscane" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("tavolo");
|
||||||
|
await r.muovi([320, 60], 0.9); await sleep(300); await r.muovi([600, 150], 0.9); await sleep(300); await r.muovi([40, 150], 0.9);
|
||||||
|
|
||||||
|
let k = 1, detto = new Set(), seme = false, accuse = false;
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canPlay } = await import("/src/engine/tressette.js");
|
||||||
|
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s === "fine") break;
|
||||||
|
if (s === "aspetta") { await sleep(150); continue; }
|
||||||
|
// Le accuse si dicono giocando la prima carta: se ne parla quando sono scritte in alto.
|
||||||
|
if (!accuse && await p.evaluate(() => game.closeup.accuse.length > 0)) { accuse = true; await r.cue("accuse"); }
|
||||||
|
const m = await p.evaluate(async (k) => {
|
||||||
|
const T = await import("/src/engine/tressette.js"), c = game.closeup, m = T.choose(T.observation(c, 0), "male", k);
|
||||||
|
const { handX, CARD } = await import("/src/engine/tressette-render.js");
|
||||||
|
return { ...m, follow: c.table.length > 0, at: [handX(c.hands[0].length, m.index) + 21, CARD.y + 30] };
|
||||||
|
}, k++);
|
||||||
|
if (m.follow && !seme) { seme = true; await r.cue("seme"); await r.cue("ordine"); }
|
||||||
|
if (m.signal && !detto.has(m.signal)) {
|
||||||
|
if (!detto.size) await r.cue("segni");
|
||||||
|
detto.add(m.signal);
|
||||||
|
await r.cue(m.signal);
|
||||||
|
}
|
||||||
|
if (m.signal) await d.click(p.locator(`#tressette-${m.signal}`), 0.6);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
await r.clicca(m.at, 0.7);
|
||||||
|
if (k === 7) await r.cue("punti");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => ["smazzata", "result"].includes(game.closeup.step), null, { timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
await r.cue("smazzata");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||