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