Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3035e33f | ||
|
|
22be1c306e | ||
|
|
add5a5fe77 | ||
|
|
3019a6e32f | ||
|
|
111b892916 | ||
|
|
f1781afea8 | ||
|
|
b44eeffd2a | ||
|
|
89ebdd40bb | ||
|
|
538bf6cedb | ||
|
|
7e1e6f6144 | ||
|
|
30e324d7e2 | ||
|
|
e8152563d8 | ||
|
|
fef236dc54 | ||
|
|
c82bef70b2 | ||
|
|
e9907e3e66 | ||
|
|
92e7a289be | ||
|
|
2362fc7741 | ||
|
|
2dd4257fd4 | ||
|
|
d51f5e56ea | ||
|
|
7b7e0031d1 | ||
|
|
94e9da4009 | ||
|
|
bd66ea45ad | ||
|
|
9eabd5c6e1 | ||
|
|
ddc877a2d1 | ||
|
|
14a1e6e5b8 |
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
|
||||
COPY assets/credits /usr/share/nginx/html/assets/credits
|
||||
COPY assets/effects /usr/share/nginx/html/assets/effects
|
||||
COPY assets/closeups /usr/share/nginx/html/assets/closeups
|
||||
COPY assets/favicon /usr/share/nginx/html/assets/favicon
|
||||
COPY assets/video /usr/share/nginx/html/assets/video
|
||||
|
||||
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
|
||||
RUN cd /usr/share/nginx/html \
|
||||
|
||||
@@ -47,6 +47,11 @@ Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio
|
||||
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
|
||||
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
|
||||
|
||||
Al biliardo del Circolo 4 Maggio, clic o tocco fissa la mira; regola la forza e premi **Tira**.
|
||||
Le frecce ← → affinano la direzione, con Maiusc per passi ancora più piccoli. La guida usa la stessa fisica
|
||||
del tiro: mostra sponde, primo contatto e direzioni iniziali dopo l'urto (azzurro per l'avversaria, giallo
|
||||
per la propria biglia). Tenendo premuto sul tavolo o Spazio si carica il colpo, con la mira bloccata.
|
||||
|
||||
## In Docker
|
||||
|
||||
```zsh
|
||||
@@ -71,5 +76,6 @@ npm test # logica, storia coerente, soluzione percorsa dall'inizio alla
|
||||
npm install && npx playwright install chromium-headless-shell
|
||||
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
|
||||
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
|
||||
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"image": "giocatore-carte.png",
|
||||
"height": 100,
|
||||
"frames": {
|
||||
"sit": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 100,
|
||||
"ax": 25.7,
|
||||
"ay": 100
|
||||
},
|
||||
"talk": {
|
||||
"x": 69,
|
||||
"y": 0,
|
||||
"w": 68,
|
||||
"h": 100,
|
||||
"ax": 27.6,
|
||||
"ay": 100
|
||||
},
|
||||
"laugh": {
|
||||
"x": 139,
|
||||
"y": 0,
|
||||
"w": 72,
|
||||
"h": 99,
|
||||
"ax": 19.3,
|
||||
"ay": 99
|
||||
},
|
||||
"shuffle": {
|
||||
"x": 213,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 97,
|
||||
"ax": 25.2,
|
||||
"ay": 97
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "ing-tozzi.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 120,
|
||||
"ax": 27.9,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 55,
|
||||
"y": 0,
|
||||
"w": 54,
|
||||
"h": 120,
|
||||
"ax": 27.8,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 111,
|
||||
"y": 0,
|
||||
"w": 77,
|
||||
"h": 119,
|
||||
"ax": 29.9,
|
||||
"ay": 119
|
||||
},
|
||||
"explain": {
|
||||
"x": 190,
|
||||
"y": 0,
|
||||
"w": 58,
|
||||
"h": 119,
|
||||
"ax": 30.8,
|
||||
"ay": 119
|
||||
},
|
||||
"laugh": {
|
||||
"x": 250,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 119,
|
||||
"ax": 28.2,
|
||||
"ay": 119
|
||||
},
|
||||
"chalk": {
|
||||
"x": 305,
|
||||
"y": 0,
|
||||
"w": 52,
|
||||
"h": 119,
|
||||
"ax": 24.1,
|
||||
"ay": 119
|
||||
},
|
||||
"shrug": {
|
||||
"x": 359,
|
||||
"y": 0,
|
||||
"w": 87,
|
||||
"h": 120,
|
||||
"ax": 43.9,
|
||||
"ay": 120
|
||||
},
|
||||
"cheer": {
|
||||
"x": 448,
|
||||
"y": 0,
|
||||
"w": 56,
|
||||
"h": 123,
|
||||
"ax": 30.9,
|
||||
"ay": 123
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -47,6 +47,26 @@
|
||||
"file": "andrea-dacb32e5c6.mp3",
|
||||
"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.": {
|
||||
"file": "andrea-4b733def4f.mp3",
|
||||
"sec": 7.27
|
||||
@@ -287,26 +307,6 @@
|
||||
"file": "andrea-68a845881b.mp3",
|
||||
"sec": 1.94
|
||||
},
|
||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
|
||||
"file": "andrea-97d75e2a9d.mp3",
|
||||
"sec": 5.38
|
||||
},
|
||||
"andrea|Buongiorno, Fra Peppino!": {
|
||||
"file": "andrea-94988dc8c6.mp3",
|
||||
"sec": 1.74
|
||||
},
|
||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||
"file": "fra_peppino-651c4d95ab.mp3",
|
||||
"sec": 3.72
|
||||
},
|
||||
"andrea|Il palio. È sparito.": {
|
||||
"file": "andrea-c9c068f507.mp3",
|
||||
"sec": 2.39
|
||||
},
|
||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||
"sec": 4.47
|
||||
},
|
||||
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
||||
"file": "andrea-43fd5c2b27.mp3",
|
||||
"sec": 4.46
|
||||
@@ -467,6 +467,10 @@
|
||||
"file": "andrea-f798fa755d.mp3",
|
||||
"sec": 4.9
|
||||
},
|
||||
"andrea|Ha già mescolato. Mi sa che aspettava proprio un pollo di passaggio.": {
|
||||
"file": "andrea-71f3bff8a0.mp3",
|
||||
"sec": 5.09
|
||||
},
|
||||
"andrea|Una gomma da masticare ancora incartata, persa davanti al palco. Qualcuno ha perso l'appetito.": {
|
||||
"file": "andrea-f2ea95922d.mp3",
|
||||
"sec": 6.54
|
||||
@@ -519,6 +523,26 @@
|
||||
"file": "andrea-43d3435d61.mp3",
|
||||
"sec": 2.99
|
||||
},
|
||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
|
||||
"file": "andrea-6797d70153.mp3",
|
||||
"sec": 8.45
|
||||
},
|
||||
"andrea|Buongiorno, Fra Peppino!": {
|
||||
"file": "andrea-94988dc8c6.mp3",
|
||||
"sec": 1.74
|
||||
},
|
||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||
"file": "fra_peppino-651c4d95ab.mp3",
|
||||
"sec": 3.72
|
||||
},
|
||||
"andrea|Il palio. È sparito.": {
|
||||
"file": "andrea-c9c068f507.mp3",
|
||||
"sec": 2.39
|
||||
},
|
||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||
"sec": 4.47
|
||||
},
|
||||
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
||||
"file": "andrea-486c980f52.mp3",
|
||||
"sec": 7.48
|
||||
@@ -655,13 +679,9 @@
|
||||
"file": "andrea-2d04be65fb.mp3",
|
||||
"sec": 5.97
|
||||
},
|
||||
"ing_tozzi|Una partita ai birilli? Si va a venti.": {
|
||||
"file": "ing_tozzi-7aef4a5420.mp3",
|
||||
"sec": 3.3
|
||||
},
|
||||
"andrea|Gnamo.": {
|
||||
"file": "andrea-a496b0aca9.mp3",
|
||||
"sec": 0.58
|
||||
"andrea|Il tavolo è dell'Ingegnere: prima gli chiedo una partita.": {
|
||||
"file": "andrea-c43b158842.mp3",
|
||||
"sec": 3.27
|
||||
},
|
||||
"ing_tozzi|Si parla col panno, qui. Prendi la stecca.": {
|
||||
"file": "ing_tozzi-4814e97759.mp3",
|
||||
@@ -671,6 +691,10 @@
|
||||
"file": "andrea-98b3e4a969.mp3",
|
||||
"sec": 3.49
|
||||
},
|
||||
"andrea|L'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.": {
|
||||
"file": "andrea-127a90af53.mp3",
|
||||
"sec": 9.37
|
||||
},
|
||||
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
|
||||
"file": "andrea-1eae1a8577.mp3",
|
||||
"sec": 6.1
|
||||
@@ -1499,6 +1523,86 @@
|
||||
"file": "andrea-60cdae2e35.mp3",
|
||||
"sec": 5.1
|
||||
},
|
||||
"andrea|Una briscola?": {
|
||||
"file": "andrea-d2f285f610.mp3",
|
||||
"sec": 1.04
|
||||
},
|
||||
"andrea|Rivincita a briscola?": {
|
||||
"file": "andrea-6807209a9e.mp3",
|
||||
"sec": 1.37
|
||||
},
|
||||
"andrea|Una scopa?": {
|
||||
"file": "andrea-d8dfb86266.mp3",
|
||||
"sec": 0.65
|
||||
},
|
||||
"andrea|Un tressette ai 21?": {
|
||||
"file": "andrea-4fbe84fada.mp3",
|
||||
"sec": 1.66
|
||||
},
|
||||
"andrea|Ora cerco il palio. A dopo.": {
|
||||
"file": "andrea-6e63dafda1.mp3",
|
||||
"sec": 2.61
|
||||
},
|
||||
"giocatore_carte|Gnamo. In due, senza soci e senza scuse.": {
|
||||
"file": "giocatore_carte-1523994a6c.mp3",
|
||||
"sec": 3.78
|
||||
},
|
||||
"giocatore_carte|Il tavolo è qui. Il coraggio ce lo metti te.": {
|
||||
"file": "giocatore_carte-8dfadb4acc.mp3",
|
||||
"sec": 3.4
|
||||
},
|
||||
"giocatore_carte|Ai undici. E occhio al settebello: quello è sempre mio.": {
|
||||
"file": "giocatore_carte-af54c11857.mp3",
|
||||
"sec": 4.27
|
||||
},
|
||||
"giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
|
||||
"file": "giocatore_carte-3e2671e35e.mp3",
|
||||
"sec": 4.88
|
||||
},
|
||||
"andrea|Con le toscane.": {
|
||||
"file": "andrea-e8599cc8b6.mp3",
|
||||
"sec": 1.03
|
||||
},
|
||||
"andrea|Con le napoletane.": {
|
||||
"file": "andrea-be276497e3.mp3",
|
||||
"sec": 1.09
|
||||
},
|
||||
"andrea|Ci ripenso.": {
|
||||
"file": "andrea-b851b9ed26.mp3",
|
||||
"sec": 0.8
|
||||
},
|
||||
"giocatore_carte|Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?": {
|
||||
"file": "giocatore_carte-89686f55a0.mp3",
|
||||
"sec": 6.14
|
||||
},
|
||||
"giocatore_carte|Le napoletane? Le tengo per i villeggianti. Come la vuoi?": {
|
||||
"file": "giocatore_carte-4f39bc172d.mp3",
|
||||
"sec": 5.13
|
||||
},
|
||||
"andrea|Alla bona.": {
|
||||
"file": "andrea-67afa54e05.mp3",
|
||||
"sec": 0.69
|
||||
},
|
||||
"andrea|Seria.": {
|
||||
"file": "andrea-f6ef96032e.mp3",
|
||||
"sec": 0.58
|
||||
},
|
||||
"andrea|A fassi male.": {
|
||||
"file": "andrea-f52137b691.mp3",
|
||||
"sec": 0.78
|
||||
},
|
||||
"giocatore_carte|Alla bona. I carichi li regalo, oggi.": {
|
||||
"file": "giocatore_carte-43e76cf11b.mp3",
|
||||
"sec": 3.43
|
||||
},
|
||||
"giocatore_carte|Allora posa il gomito e guarda le carte.": {
|
||||
"file": "giocatore_carte-f1f219bd1c.mp3",
|
||||
"sec": 2.51
|
||||
},
|
||||
"giocatore_carte|Io le conto tutte. Anche quelle che butti via per sbaglio.": {
|
||||
"file": "giocatore_carte-12f97003ec.mp3",
|
||||
"sec": 4.19
|
||||
},
|
||||
"andrea|Una Fanta.": {
|
||||
"file": "andrea-8927b87738.mp3",
|
||||
"sec": 0.65
|
||||
@@ -1815,14 +1919,62 @@
|
||||
"file": "mariano-0eeb942ca2.mp3",
|
||||
"sec": 3.52
|
||||
},
|
||||
"mariano|Il campione non scappa. Tira!": {
|
||||
"file": "mariano-bdf58ba1be.mp3",
|
||||
"sec": 3.26
|
||||
"mariano|Come la vuoi?": {
|
||||
"file": "mariano-0288ec460b.mp3",
|
||||
"sec": 0.7
|
||||
},
|
||||
"mariano|Il campione non scappa. Come la vuoi?": {
|
||||
"file": "mariano-1d8388d631.mp3",
|
||||
"sec": 3.55
|
||||
},
|
||||
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
|
||||
"file": "mariano-1c9cd0c325.mp3",
|
||||
"sec": 5.96
|
||||
},
|
||||
"mariano|Alla bona: tiro con gli occhi chiusi.": {
|
||||
"file": "mariano-b1b1ca273b.mp3",
|
||||
"sec": 2.2
|
||||
},
|
||||
"mariano|Seria. Così mi piace.": {
|
||||
"file": "mariano-accff65835.mp3",
|
||||
"sec": 2.27
|
||||
},
|
||||
"mariano|A fassi male? Poi non piangere, eh.": {
|
||||
"file": "mariano-d65fb6e871.mp3",
|
||||
"sec": 3.42
|
||||
},
|
||||
"andrea|Ingegnere, una partita ai birilli?": {
|
||||
"file": "andrea-dbcf4d1083.mp3",
|
||||
"sec": 2.49
|
||||
},
|
||||
"andrea|Arrivederci, Ingegnere.": {
|
||||
"file": "andrea-8f63435d69.mp3",
|
||||
"sec": 1.38
|
||||
},
|
||||
"ing_tozzi|Una partita ai birilli? Si va a venti. Come la vuoi?": {
|
||||
"file": "ing_tozzi-82e7cc88aa.mp3",
|
||||
"sec": 4.58
|
||||
},
|
||||
"ing_tozzi|Ci mancherebbe. Stavolta guardo bene. Come la vuoi?": {
|
||||
"file": "ing_tozzi-344ac1021f.mp3",
|
||||
"sec": 4.67
|
||||
},
|
||||
"ing_tozzi|Alla bona: tiro senza occhiali.": {
|
||||
"file": "ing_tozzi-17e489814e.mp3",
|
||||
"sec": 2.19
|
||||
},
|
||||
"andrea|Gnamo.": {
|
||||
"file": "andrea-a496b0aca9.mp3",
|
||||
"sec": 0.58
|
||||
},
|
||||
"ing_tozzi|Seria. Dammi il gesso.": {
|
||||
"file": "ing_tozzi-4b3adfa04a.mp3",
|
||||
"sec": 2.28
|
||||
},
|
||||
"ing_tozzi|A fassi male? Allora mi tolgo la giacca. Ah no, non ce l'ho.": {
|
||||
"file": "ing_tozzi-c365e9f760.mp3",
|
||||
"sec": 6.05
|
||||
},
|
||||
"andrea|Sono bloccato. Mi dai un consiglio?": {
|
||||
"file": "andrea-42b02eb9ea.mp3",
|
||||
"sec": 3.9
|
||||
@@ -1935,6 +2087,38 @@
|
||||
"file": "castello-dc8a873d86.mp3",
|
||||
"sec": 4.36
|
||||
},
|
||||
"giocatore_carte|Oh, bravo! Ora però rimescolo io.": {
|
||||
"file": "giocatore_carte-a8846bfa7b.mp3",
|
||||
"sec": 2.9
|
||||
},
|
||||
"giocatore_carte|I carichi portali qui, mica alla stazione! Rivincita quando vuoi.": {
|
||||
"file": "giocatore_carte-6ce98bce81.mp3",
|
||||
"sec": 4.72
|
||||
},
|
||||
"giocatore_carte|Sessanta pari. Un si litiga nemmeno sul conto!": {
|
||||
"file": "giocatore_carte-13af2e2331.mp3",
|
||||
"sec": 3.86
|
||||
},
|
||||
"giocatore_carte|Vai, ti tengo il posto. Le carte mica scappano.": {
|
||||
"file": "giocatore_carte-729b3344da.mp3",
|
||||
"sec": 3.3
|
||||
},
|
||||
"giocatore_carte|Ventuno per primi. Bella coppia, voi due!": {
|
||||
"file": "giocatore_carte-3215efd9dd.mp3",
|
||||
"sec": 3.51
|
||||
},
|
||||
"giocatore_carte|Ventuno! Il tressette è un'arte: si studia da piccini.": {
|
||||
"file": "giocatore_carte-5dbd39a25f.mp3",
|
||||
"sec": 4.44
|
||||
},
|
||||
"giocatore_carte|Settebello, primiera e pure le scope. Oggi l'hai vista bene.": {
|
||||
"file": "giocatore_carte-9a43eb8b2c.mp3",
|
||||
"sec": 5.54
|
||||
},
|
||||
"giocatore_carte|Undici! Il settebello lo sento arrivare da lontano.": {
|
||||
"file": "giocatore_carte-445e962a1b.mp3",
|
||||
"sec": 4.1
|
||||
},
|
||||
"mariano|Aspetta, ci vuole il metro...": {
|
||||
"file": "mariano-e8b4ebf3d9.mp3",
|
||||
"sec": 1.62
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Primo piano del biliardo
|
||||
|
||||
Il tavolo in `assets/closeups/biliardo-tavolo.png` è generato con lo strumento immagini integrato
|
||||
di Codex, senza API. Prompt completo in `prompts/closeups/biliardo-tavolo.integrated.txt`
|
||||
(cartella esclusa da git). Specifica: vista ortogonale dall'alto, pixel art da avventura anni '90,
|
||||
mogano scuro a fasce chiare e scure, diamanti d'ottone, panno verde, sala in penombra;
|
||||
senza buche, biglie, birilli, persone, stecche o testi. Riferimento di stile: `circolo-4-maggio.png`.
|
||||
L'Ingegnere usa il foglio esistente `ing-tozzi.json`, coerente con la fotografia di riferimento.
|
||||
|
||||
Il master misura 1672×941. `tablePatches()` in `biliardo-render.js` mappa il panno sorgente
|
||||
(190, 214)–(1484, 722) su `(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k)`; i ritagli
|
||||
esterni mantengono una cornice larga 16 pixel. La geometria della simulazione resta invariata.
|
||||
Il caricamento passa da `story.closeups.biliardo.picture`, con la stessa cache delle bocce.
|
||||
|
||||
La luce calda è disegnata sul panno con una piccola tela memorizzata, ingrandita senza smoothing.
|
||||
Biglie e birilli usano blocchi di pixel; le ombre delle biglie si allontanano dalla lampada centrale.
|
||||
I segnapunti sono cifre rosse a sette segmenti. Le quattro pose di Tozzi rimangono nel margine sinistro.
|
||||
|
||||
`biliardo.js` aggiunge soltanto stato visivo: orologio, posizione iniziale della steccata,
|
||||
breve cronologia delle posizioni e istante della caduta dei birilli. La presentazione del moto
|
||||
è ritardata di 180 ms, per mostrare arretramento e scatto della stecca; la fisica parte subito.
|
||||
La caduta dei birilli dura 240 ms sul medesimo orologio visivo. Previsione e CPU non scrivono
|
||||
questa cronologia. Un confronto con la versione precedente ha verificato 6626 aggiornamenti
|
||||
identici, escludendo i soli campi visivi.
|
||||
|
||||
Controlli: `tests/biliardo-render.test.mjs` verifica allineamento, presentazione, cadute,
|
||||
pose e immutabilità del disegno. `npm run browser:biliardo` conserva i controlli dei comandi
|
||||
su desktop, touch e telefono verticale e aggiunge controlli dei pixel e catture delle animazioni
|
||||
in `/tmp/regolo-biliardo`.
|
||||
|
||||
Verifica di questa modifica: test JavaScript e Python superati; anteprima locale del renderer
|
||||
ispezionata. Restano da completare `make -s verify` e `npm run browser:biliardo` in un ambiente
|
||||
che permetta server locale e Chromium: nella sandbox della sessione entrambi sono bloccati
|
||||
da `Operation not permitted`. Le vecchie schermate browser non validano questa modifica.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Briscola all’oratorio
|
||||
|
||||
Implementazione del 26 ottobre 2026. ES modules statici, nessun build step.
|
||||
|
||||
## Partita e comandi
|
||||
|
||||
Il giocatore di carte è un personaggio inventato e provvisorio, seduto a destra alle tavolate
|
||||
dell’oratorio: **[DA ANDREA: chi è]**. Il dialogo offre «Alla bona», «Seria», «A fassi male»;
|
||||
dopo una partita conclusa offre la rivincita. `briscola_vinta` ricorda una vittoria e
|
||||
`briscola_giocata` abilita la rivincita anche dopo sconfitta o patta. Nessuno dei due flag
|
||||
serve alla soluzione dell’avventura.
|
||||
|
||||
Un clic o tocco su una carta la gioca al rilascio sullo stesso bersaglio. Nessuna carica.
|
||||
Le frecce e 1–2–3 scelgono, Invio gioca, Esc esce. I tre pulsanti con nome e seme
|
||||
offrono bersagli aggiuntivi e accesso tramite Tab. I comandi di tiro degli altri minigiochi
|
||||
rimangono separati; in `MINIGAMES` pressione, rilascio, annullamento e mira sono opzionali.
|
||||
|
||||
Mazzo da 40 carte, 120 punti, distribuzione alternata e mazziere deterministico dal seed.
|
||||
La briscola è in fondo al mazzo e compresa nel contatore. Chi prende pesca prima e riapre;
|
||||
non esiste obbligo di seme. La presa resta visibile 1,25 secondi. Solo dopo la ventesima
|
||||
presa si contano tutte le carte, una ogni 0,35 secondi, mostrando carta ingrandita,
|
||||
incremento e totale. A 60–60 il risultato è patta. Il risultato resta fino all’uscita.
|
||||
|
||||
## Avversari e taratura
|
||||
|
||||
`observation` espone soltanto la propria mano, le carte pubbliche, i conteggi e l’eventuale
|
||||
briscola pescata dall’altro. Non passa la mano avversaria, l’ordine del mazzo o il seed della
|
||||
distribuzione. «Alla bona» sceglie casualmente; «Seria» scarta lisci, conserva briscole e
|
||||
prende carichi con quelle piccole. «A fassi male» ricostruisce le carte non viste, confronta
|
||||
le mosse su 40 distribuzioni possibili e simula seguiti giocati con buon senso. Sono ipotesi,
|
||||
non la distribuzione effettiva. A mazzo finito il complemento delle carte pubbliche e della
|
||||
propria mano identifica l’altra mano: da quel momento usa minimax esatto fino alla fine.
|
||||
|
||||
Comando riproducibile: `node tools/calibrate_briscola.mjs 1000`.
|
||||
Semi `Math.imul(i, 2654435761) >>> 0`, i da 1 a 1000, stessi mazzi per ogni livello.
|
||||
Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 volte su 1000.
|
||||
|
||||
| Livello | Vittorie Andrea | Vittorie avversario | Patte | Punti medi Andrea |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Alla bona | 847 (84,7%) | 144 (14,4%) | 9 (0,9%) | 79,159 |
|
||||
| Seria | 481 (48,1%) | 494 (49,4%) | 25 (2,5%) | 59,952 |
|
||||
| A fassi male | 359 (35,9%) | 621 (62,1%) | 20 (2,0%) | 55,628 |
|
||||
|
||||
Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
|
||||
|
||||
## Revisione di Codex (26 ottobre, i tre giochi insieme)
|
||||
|
||||
Regole promosse. Corretti: uscendo durante il conteggio si perdeva una vittoria già fatta (ora il risultato è
|
||||
deciso all'ultima presa e il conteggio lo mostra soltanto) e, per tutti e tre i giochi, l'esito che non finiva
|
||||
nel salvataggio se dal risultato si tornava al titolo; Spazio sui pulsanti come negli altri giochi; la briscola e
|
||||
la carta in tavola dette anche a parole; l'aiuto coi nomi di tutti e due i mazzi; «Rivincita a briscola?».
|
||||
Proposta non fatta: i seguiti campionati di «A fassi male» col finale esatto, e l'obiettivo vittoria invece dei punti.
|
||||
|
||||
## Grafica e audio
|
||||
|
||||
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
|
||||
nomi e disegni (`DECKS` e `DECK_CARDS` in `briscola.js`; la scelta è il flag `carte_napoletane`).
|
||||
|
||||
- **Toscane** (predefinite): semi francesi cuori, mattoni, fiori, picche; figure gobbo, donna e regio (il regio
|
||||
sempre con la corona). Numerali senza numeri, semi disposti come nel mazzo vero e capovolti nella metà bassa;
|
||||
figure intere col seme e la lettera (G, D, R) in alto a sinistra. Riferimento: `assets/raw/carte/toscane-riferimento.png`.
|
||||
- **Napoletane**: monete, coppe, lame ricurve incrociate, bastoni nodosi; fante, cavallo e re. Valore nell’angolo
|
||||
e seme scritto per non affidarsi solo al colore.
|
||||
|
||||
Tutto disegnato via canvas, in pixel art originale.
|
||||
I bersagli della mano sono 84×120 pixel a 640×360. Dorso con serpentello coronato e cornice
|
||||
simmetrica; tavolino di legno e panno, mano avversaria coperta, mazzo e mucchietti delle prese.
|
||||
|
||||
Sprite generato con lo **strumento immagini integrato**, poi preparato con
|
||||
`slice_sprites.py --alpha --fixed-grid --cols 2 --rows 2` e `prepare_character.py`.
|
||||
Pose: `sit`, `talk`, `laugh`, `shuffle`. Foglio originale in
|
||||
`assets/raw/giocatore-carte-sheet.png`; prompt completo in
|
||||
`prompts/personaggi/giocatore-carte-sheet.txt` (entrambi esclusi da git).
|
||||
|
||||
Effetti Web Audio `card-slide`, `card-tap`, `card-shuffle`, collegati allo stesso volume,
|
||||
muto e pausa degli altri effetti. Il colpo accompagna l’arrivo della carta dopo lo scorrimento.
|
||||
13 nuove battute XTTS (4 Andrea, 9 giocatore), con indice e durate aggiornati.
|
||||
Per generarle senza GPU/rete, `tools/voci_xtts.py` accetta `XTTS_DEVICE=cpu` e
|
||||
`XTTS_MODEL_DIR` con la cartella del modello già scaricato; le impostazioni predefinite
|
||||
rimangono quelle del progetto. Non è stata verificata all’ascolto la pronuncia delle nuove voci.
|
||||
|
||||
## Verifica e limiti della sessione
|
||||
|
||||
**`npm test`: passato.** Regole, determinismo, conservazione delle 40 carte e dei 120 punti,
|
||||
pescata, ultima briscola, fine mazzo, patta, livelli, accesso negato ai dati nascosti,
|
||||
minimax confrontato con un oracolo esaustivo, renderer puro, input geometrico,
|
||||
dialoghi/flag/rivincita e audio. Passano anche i test precedenti e la soluzione completa.
|
||||
|
||||
`npm run browser` e `npm run browser:briscola` sono stati lanciati ma **bloccati dall’ambiente**:
|
||||
`PermissionError: [Errno 1] Operation not permitted` alla creazione del socket del server.
|
||||
Anche l’avvio isolato di Chromium fallisce con `sandbox_host_linux.cc:41`,
|
||||
`shutdown: Operation not permitted`. Non è stato possibile verificare l’interfaccia reale
|
||||
con Playwright né produrre schermate desktop/telefono.
|
||||
|
||||
Il test `tests/briscola.browser.mjs` è pronto: partite complete con mouse, touch e tastiera
|
||||
su desktop e telefono verticale, più touch orizzontale; pausa, muto, gesto annullato,
|
||||
conteggio, risultato e rivincita. Accelera solo le attese tramite `game.update`, senza
|
||||
giocare carte di Andrea direttamente nel motore. Salva in `/tmp/regolo-shots/briscola-*.png`.
|
||||
Le misure Web Audio dei tre nuovi suoni sono comprese nel collaudo browser multimedia.
|
||||
|
||||
Anteprime **offline, non schermate browser**, prodotte e ispezionate nella sessione:
|
||||
|
||||
- `/tmp/regolo-shots/briscola-offline-carte.png`
|
||||
- `/tmp/regolo-shots/briscola-offline-tavolo.png`
|
||||
- `/tmp/regolo-shots/briscola-offline-risultato.png`
|
||||
|
||||
## File
|
||||
|
||||
Nuovi: `src/engine/briscola.js`, `src/engine/briscola-render.js`,
|
||||
`assets/characters/giocatore-carte.png`, `assets/characters/giocatore-carte.json`,
|
||||
`tests/briscola.test.mjs`, `tests/briscola-render.test.mjs`, `tests/briscola.browser.mjs`,
|
||||
`tools/calibrate_briscola.mjs`, questo documento e 13 MP3 elencati qui sotto.
|
||||
|
||||
Modificati: `src/engine/game.js`, `src/engine/render.js`, `src/engine/soundscape.js`,
|
||||
`src/main.js`, `src/story/story.js`, `index.html`, `style.css`, `package.json`,
|
||||
`tests/story.test.mjs`, `tests/multimedia.test.mjs`, `tests/multimedia.browser.mjs`,
|
||||
`tools/voci.json`, `tools/voci_xtts.py`, `assets/voices/index.json`.
|
||||
|
||||
Nuovi file in `assets/voices/`:
|
||||
|
||||
```text
|
||||
andrea-6e63dafda1.mp3
|
||||
andrea-71f3bff8a0.mp3
|
||||
andrea-b851b9ed26.mp3
|
||||
andrea-d2f285f610.mp3
|
||||
giocatore_carte-12f97003ec.mp3
|
||||
giocatore_carte-13af2e2331.mp3
|
||||
giocatore_carte-43e76cf11b.mp3
|
||||
giocatore_carte-6ce98bce81.mp3
|
||||
giocatore_carte-729b3344da.mp3
|
||||
giocatore_carte-8dfadb4acc.mp3
|
||||
giocatore_carte-9c07caaab4.mp3
|
||||
giocatore_carte-a8846bfa7b.mp3
|
||||
giocatore_carte-f1f219bd1c.mp3
|
||||
```
|
||||
|
||||
`docs/STATO.md` invariato. Nessun commit, tag o push.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Scopa agli 11
|
||||
|
||||
All'oratorio, dal giocatore di carte: «Una scopa?». In due, Andrea contro il giocatore di carte, con le toscane o
|
||||
le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Claude), tavolo `src/engine/scopa-render.js`.
|
||||
|
||||
## Regole
|
||||
|
||||
- Valori: Asso 1, poi i numeri, gobbo (fante) 8, donna (cavallo) 9, regio (re) 10.
|
||||
- Quattro carte in tavola (con tre o quattro re si rimescola), tre a testa; finite le tre, se ne danno altre tre.
|
||||
Gioca per primo chi non fa le carte; il mazziere cambia a ogni smazzata.
|
||||
- Una carta prende quella dello stesso valore (se ce n'è più d'una si sceglie quale); se non c'è, un gruppo di carte
|
||||
che somma il suo valore (più gruppi: si sceglie). Se la presa c'è, è obbligatoria. Se no, la carta resta in tavola.
|
||||
- **Scopa**: chi pulisce la tavola fa un punto; non vale con l'ultima carta della smazzata.
|
||||
- A fine smazzata le carte in tavola vanno all'ultimo che ha preso. Un punto per: **carte** (più di 20), **mattoni**
|
||||
(con le napoletane i denari, più di 5), **settebello** (il 7 di mattoni o di denari), **primiera** (la carta
|
||||
migliore di ogni seme: 7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12, figure 10; senza un seme non
|
||||
si fa), e uno per scopa. A pari, il punto non va a nessuno.
|
||||
- Si va **agli 11**, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), un'altra smazzata.
|
||||
|
||||
## Avversari
|
||||
|
||||
- «Alla bona»: una mossa a caso fra quelle possibili.
|
||||
- «Seria»: sceglie la mossa che vale di più (settebello, mattoni, carte, scope, e la primiera per quanto migliora la
|
||||
propria: un 6 di un seme dove si ha solo una figura vale più di uno dove si ha già il 7) e teme a occhio di
|
||||
lasciare una scopa.
|
||||
- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, non lascia 7 in
|
||||
tavola e, a mazzo finito (le carte dell'altro sono proprio quelle non viste), calcola il finale esatto fino al
|
||||
conteggio.
|
||||
|
||||
Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 96% contro
|
||||
«Alla bona», 76% contro «Seria», 50% contro «A fassi male»; circa quattro smazzate a partita.
|
||||
|
||||
## Revisione di Codex (26 ottobre, i tre giochi insieme)
|
||||
|
||||
Regole promosse (valori, presa obbligatoria, precedenza della carta uguale, scopa non all'ultima, residui
|
||||
all'ultimo, maggioranze e pari, primiera, re in tavola, agli 11). Corretti: frecce e Invio che potevano prendere la
|
||||
presa sbagliata (ora il fuoco segue la scelta); il tocco sulla tavola che agiva alla pressione e non bastava con
|
||||
prese sovrapposte (ora al rilascio: il primo tocco sceglie, il secondo conferma); primiera valutata a occhio;
|
||||
finali sbagliati a mazzo finito; la tavola descritta a parole; aiuto con i valori della primiera e i pari.
|
||||
|
||||
## Prove
|
||||
|
||||
`npm test` (`tests/scopa.test.mjs`) e `npm run browser:scopa`: partita intera col mouse, una smazzata col tocco
|
||||
(telefono e orizzontale) e con la tastiera; in ogni caso una presa da scegliere fra due (toccando la tavola, con
|
||||
un pulsante o con frecce e Invio). Schermate in `/tmp/regolo-shots/scopa-*.png`.
|
||||
@@ -18,6 +18,17 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
||||
|
||||
## Dove siamo arrivati
|
||||
|
||||
- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
|
||||
sprite e battute provvisorie di Codex). Parlandoci si sceglie il mazzo (toscane o napoletane) e il livello
|
||||
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
|
||||
partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude),
|
||||
disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
|
||||
**Tressette ai 21** (stesso giocatore, «Un tressette ai 21?»): in quattro a coppie, busso/striscio/volo e accuse,
|
||||
motore e tavolo di Claude, regole e taratura in `docs/TRESSETTE.md`, prova `npm run browser:tressette`.
|
||||
**Scopa agli 11** («Una scopa?»): carte, mattoni (o denari), settebello, primiera e scope, presa a scelta fra
|
||||
più possibili; `docs/SCOPA.md`, prova `npm run browser:scopa`. Con la scopa i giochi di carte sono finiti
|
||||
(decisione di Andrea, 26 ottobre). Tressette rivisto dopo la revisione critica di Codex (in `docs/TRESSETTE.md`).
|
||||
|
||||
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
|
||||
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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Tressette ai 21
|
||||
|
||||
All'oratorio, dal giocatore di carte: «Un tressette ai 21?». Quattro al tavolo, a coppie: Andrea e il socio
|
||||
(di fronte) contro il giocatore di carte e l'altro. [DA ANDREA: chi sono il socio e l'altro.]
|
||||
Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render.js` con le carte della briscola.
|
||||
|
||||
## Regole
|
||||
|
||||
- Mazzo da 40, toscane o napoletane (stessa scelta e stesso flag `carte_napoletane` della briscola). Dieci carte a testa.
|
||||
- Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata.
|
||||
- Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4.
|
||||
- Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi
|
||||
avanzati si perdono: le due coppie fanno sempre 11 punti in tutto.
|
||||
- **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3
|
||||
dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4.
|
||||
Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari.
|
||||
- **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho
|
||||
altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche
|
||||
sul tavolo, la carta strusciata, la carta lasciata cadere.
|
||||
- Si va **ai 21**, presa per presa: il punteggio in alto conta accuse e punti interi già presi, e la coppia che
|
||||
arriva a 21 per prima è fuori e ha vinto, anche a metà smazzata (le accuse contano alla fine della presa in cui
|
||||
si dicono). Il risultato è subito definitivo: uscire dal riepilogo non lo cancella. Il riepilogo di fine
|
||||
smazzata resta finché Andrea non va avanti.
|
||||
|
||||
Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si
|
||||
sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa.
|
||||
|
||||
## Avversari
|
||||
|
||||
- «Alla bona»: una carta a caso fra quelle buone, nessun segno.
|
||||
- «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta
|
||||
quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro.
|
||||
- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse,
|
||||
risponde al busso del socio prendendo con la migliore e poi gli ritorna il seme. Il socio di Andrea gioca sempre così.
|
||||
- Tutti: il carico al socio che prende di sicuro, ma senza buttarci il 3 o il 2; a parità di lunghezza si esce dal
|
||||
seme con meno punti da esporre (niente Asso scoperto).
|
||||
|
||||
Taratura (`node tools/calibrate_tressette.mjs 300`, Andrea che gioca come il socio): Andrea e il socio vincono
|
||||
87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male»; circa tre smazzate a partita.
|
||||
|
||||
## Revisione di Codex (26 ottobre)
|
||||
|
||||
Revisione critica sul primo giro. Corretti: vittoria persa uscendo dal riepilogo; accuse tutte pubbliche prima
|
||||
della prima carta; il seme mancante del tris noto all'IA ma non ad Andrea; striscio troppo stretto e busso con
|
||||
un requisito inventato; il socio che non rispondeva al busso, sprecava il 3 su una presa già sua o usciva
|
||||
d'Asso scoperto; il volo non letto da «A fassi male»; tastiera che poteva giocare la carta sbagliata; il
|
||||
riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole per chi non vede il disegno.
|
||||
Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione
|
||||
precedente dell'IA.
|
||||
|
||||
Seconda revisione (i tre giochi insieme). Corretti: con le accuse tutte e due le coppie potevano arrivare ai 21 e
|
||||
vinceva sempre Andrea (ora esce chi arriva per primo, anche con un'accusa, nel momento in cui la dice); il socio
|
||||
che teneva il 2 anche quando caricarlo voleva dire vincere subito (l'IA vede ora punteggio e terzi del tabellone);
|
||||
Invio su un pulsante che giocava la carta col fuoco invece di quella scelta; il pannello delle accuse che usciva
|
||||
dal tavolo; le convenzioni del tavolo scritte anche nel «Come si gioca». Per tutti e tre i giochi l'esito vale e
|
||||
si salva appena deciso (anche tornando al titolo dalla schermata del risultato).
|
||||
|
||||
## Prove
|
||||
|
||||
`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
|
||||
col tocco (telefono e orizzontale) e con la tastiera, segni con pulsanti e tasti B, S, V, carta che non risponde
|
||||
al seme rifiutata. Schermate in `/tmp/regolo-shots/tressette-*.png`.
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
||||
<title>La Leggenda del Regolo</title>
|
||||
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
|
||||
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -86,6 +91,11 @@
|
||||
<p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p>
|
||||
<p class="version"></p>
|
||||
</section>
|
||||
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
|
||||
<h2 id="tutorial-title">Come si gioca</h2>
|
||||
<video id="tutorial-video" controls playsinline preload="metadata"></video>
|
||||
<div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
|
||||
</section>
|
||||
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
|
||||
<h2>Ringraziamenti</h2>
|
||||
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||
@@ -103,6 +113,82 @@
|
||||
<div id="inventory" aria-label="Inventario"></div>
|
||||
</div>
|
||||
<ol id="choices" hidden></ol>
|
||||
<section id="scopa-controls" hidden aria-label="Comandi della scopa">
|
||||
<p id="scopa-status" role="status"></p>
|
||||
<div class="card-hand" aria-label="La mano di Andrea">
|
||||
<button id="scopa-card-0" type="button"></button>
|
||||
<button id="scopa-card-1" type="button"></button>
|
||||
<button id="scopa-card-2" type="button"></button>
|
||||
</div>
|
||||
<div id="scopa-options" class="card-buttons" hidden aria-label="Quale presa"></div>
|
||||
<button id="scopa-next" type="button" hidden>Prossima smazzata</button>
|
||||
<details><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="scopa" hidden>Guarda il video</button>
|
||||
<p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano
|
||||
il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10.
|
||||
Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p>
|
||||
<p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di
|
||||
mattoni o di denari), la primiera, e uno per ogni scopa. A pari, il punto non va a nessuno.</p>
|
||||
<p>Primiera: si somma la carta migliore di ogni seme (7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12,
|
||||
figure 10); chi non ha almeno una carta per seme non la fa. Si va agli 11, contati a fine smazzata: vince chi ne
|
||||
ha di più; pari, se ne fa un’altra.</p>
|
||||
</details>
|
||||
</section>
|
||||
<section id="tressette-controls" hidden aria-label="Comandi del tressette">
|
||||
<p id="tressette-status" role="status"></p>
|
||||
<div class="card-buttons" aria-label="Segni per il socio, solo quando esci">
|
||||
<button id="tressette-busso" type="button">Busso</button>
|
||||
<button id="tressette-striscio" type="button">Striscio</button>
|
||||
<button id="tressette-volo" type="button">Volo</button>
|
||||
<button id="tressette-next" type="button" hidden>Prossima smazzata</button>
|
||||
</div>
|
||||
<div class="card-hand-many" aria-label="La mano di Andrea">
|
||||
<button id="tressette-card-0" type="button"></button>
|
||||
<button id="tressette-card-1" type="button"></button>
|
||||
<button id="tressette-card-2" type="button"></button>
|
||||
<button id="tressette-card-3" type="button"></button>
|
||||
<button id="tressette-card-4" type="button"></button>
|
||||
<button id="tressette-card-5" type="button"></button>
|
||||
<button id="tressette-card-6" type="button"></button>
|
||||
<button id="tressette-card-7" type="button"></button>
|
||||
<button id="tressette-card-8" type="button"></button>
|
||||
<button id="tressette-card-9" type="button"></button>
|
||||
</div>
|
||||
<details><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="tressette" hidden>Guarda il video</button>
|
||||
<p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra.
|
||||
Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta
|
||||
del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p>
|
||||
<p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21: la coppia che
|
||||
ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p>
|
||||
<p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
|
||||
tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p>
|
||||
<p>Convenzioni di questo tavolo: il «fuori» ai 21 è automatico (un’accusa o una presa che ci porta fa vincere
|
||||
subito); niente cappotto; i segni valgono anche alla prima presa.</p>
|
||||
<p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme),
|
||||
<b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta.
|
||||
Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p>
|
||||
</details>
|
||||
</section>
|
||||
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
|
||||
<p id="briscola-status" role="status"></p>
|
||||
<div class="card-hand" aria-label="La mano di Andrea">
|
||||
<button id="briscola-card-0" type="button"></button>
|
||||
<button id="briscola-card-1" type="button"></button>
|
||||
<button id="briscola-card-2" type="button"></button>
|
||||
</div>
|
||||
<details><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="briscola" hidden>Guarda il video</button>
|
||||
<p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca.
|
||||
Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p>
|
||||
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
|
||||
di uscita. Ordine: Asso, 3, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
|
||||
Asso 11 punti, 3 dieci, regio 4, donna 3, gobbo 2; le altre zero. Fra parentesi i nomi delle napoletane.</p>
|
||||
<p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo;
|
||||
il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese.
|
||||
Durante la partita i punti non si dicono!</p>
|
||||
</details>
|
||||
</section>
|
||||
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||
<p id="bocce-status" role="status"></p>
|
||||
<div class="bocce-adjust">
|
||||
@@ -114,6 +200,7 @@
|
||||
<button id="bocce-throw" type="button">Lancia</button>
|
||||
</div>
|
||||
<details id="bocce-help"><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button>
|
||||
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
||||
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
||||
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
||||
@@ -122,9 +209,15 @@
|
||||
Vince chi fa più punti nella mano.</p>
|
||||
</details>
|
||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||
<p>Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita.
|
||||
Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare.
|
||||
Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.</p>
|
||||
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
||||
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
|
||||
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
|
||||
la tua. Le frecce mostrano solo il primo tratto, fino al prossimo ostacolo o al massimo 50 cm.
|
||||
Senza urti, il cerchietto indica dove ti fermi. La guida arancione segnala un primo contatto falloso.</p>
|
||||
<p>Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare:
|
||||
durante la carica la mira resta ferma. Frecce ← →: mira fine; Maiusc + ← →: regolazione ancora più precisa;
|
||||
↑ ↓: forza. Esc esce dalla partita. I colpi sono centrali, senza effetti di rotazione.</p>
|
||||
<p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti,
|
||||
rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima
|
||||
il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"browser": "node tests/browser.mjs",
|
||||
"browser:biliardo": "node tests/biliardo.browser.mjs",
|
||||
"browser:tressette": "node tests/tressette.browser.mjs",
|
||||
"browser:scopa": "node tests/scopa.browser.mjs",
|
||||
"browser:briscola": "node tests/briscola.browser.mjs",
|
||||
"browser:effects": "node tests/ambient.browser.mjs",
|
||||
"serve": "python3 -m http.server 8000"
|
||||
},
|
||||
|
||||
@@ -1,115 +1,223 @@
|
||||
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js';
|
||||
import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
|
||||
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
||||
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono.
|
||||
const rr = R * VIEW.k * 1.3;
|
||||
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
|
||||
const rr = R * VIEW.k;
|
||||
|
||||
function sphere(ctx, role, x, y, r) {
|
||||
// Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
|
||||
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
|
||||
export const SHOT_DELAY = WINDUP;
|
||||
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
|
||||
|
||||
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
|
||||
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
|
||||
export function tablePatches() {
|
||||
const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
|
||||
const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
|
||||
const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
|
||||
return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
|
||||
xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
|
||||
xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
|
||||
]));
|
||||
}
|
||||
|
||||
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
||||
function sphere(ctx, role, x, y, r, shadow = true) {
|
||||
const [dark, mid, light] = BALL[role];
|
||||
ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill();
|
||||
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05);
|
||||
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1);
|
||||
}
|
||||
function pin(ctx, p) {
|
||||
const x = sx(p.x), y = sy(p.y), h = 7, w = 3;
|
||||
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4'];
|
||||
if (p.down) {
|
||||
// Birillo a terra, dalla parte in cui è caduto.
|
||||
ctx.strokeStyle = top; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke();
|
||||
ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2);
|
||||
return;
|
||||
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
|
||||
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
|
||||
if (shadow) {
|
||||
const dx = (x - sx(LEN / 2)) / (LEN * VIEW.k / 2) * 3;
|
||||
const dy = (y - sy(WID / 2)) / (WID * VIEW.k / 2) * 3 + 1;
|
||||
ctx.fillStyle = '#103b2bcc';
|
||||
ctx.fillRect(Math.round(x - r + dx), Math.round(y - r / 2 + dy), Math.round(r * 2), Math.round(r));
|
||||
}
|
||||
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2);
|
||||
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1);
|
||||
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3);
|
||||
// Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico.
|
||||
const left = Math.floor(x - r), top = Math.floor(y - r);
|
||||
for (let py = top; py < y + r; py++) for (let px = left; px < x + r; px++) {
|
||||
const dx = px + 0.5 - x, dy = py + 0.5 - y, d = Math.hypot(dx, dy);
|
||||
if (d > r) continue;
|
||||
ctx.fillStyle = d > r - 1 ? '#302a1e' : dx * lx + dy * ly < -r * 0.2 ? dark : mid;
|
||||
ctx.fillRect(px, py, 1, 1);
|
||||
}
|
||||
ctx.fillStyle = light; ctx.fillRect(Math.round(x + lx * r * 0.4) - 1, Math.round(y + ly * r * 0.4) - 1, 2, 2);
|
||||
}
|
||||
|
||||
function table(ctx) {
|
||||
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k;
|
||||
// Cornice di legno con i diamanti, sponda, panno.
|
||||
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32);
|
||||
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26);
|
||||
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3);
|
||||
ctx.fillStyle = '#e7d7a8';
|
||||
for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); }
|
||||
for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); }
|
||||
ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10);
|
||||
ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h);
|
||||
for (let i = 0; i < 420; i++) {
|
||||
ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f';
|
||||
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1);
|
||||
export function pinFall(p, time) {
|
||||
if (!p.down) return 0;
|
||||
if (p.visualDownAt === undefined) return 1;
|
||||
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
|
||||
}
|
||||
function pin(ctx, p, time) {
|
||||
const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
|
||||
const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
|
||||
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
|
||||
const ty = y + Math.sin(p.dir ?? 0) * 7 * tilt - 7 * lift;
|
||||
const [base, top] = p.red ? ['#8d1f1a', '#ff7060'] : ['#b3a57f', '#fff8e4'];
|
||||
ctx.fillStyle = '#123c2bb0'; ctx.fillRect(Math.round(x - 1), Math.round(y + 1), 5 + Math.round(tilt * 3), 2);
|
||||
// Piccoli blocchi seguono la rotazione senza linee levigate.
|
||||
for (let i = 0; i <= 7; i++) {
|
||||
ctx.fillStyle = i > 4 ? top : base;
|
||||
ctx.fillRect(Math.round(x + (tx - x) * i / 7) - 1, Math.round(y + (ty - y) * i / 7) - 1, i > 4 ? 3 : 2, 2);
|
||||
}
|
||||
// La linea di metà tavolo e il punto del castello.
|
||||
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h);
|
||||
}
|
||||
|
||||
let lighting;
|
||||
function table(ctx, bg) {
|
||||
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
|
||||
else {
|
||||
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
|
||||
ctx.fillStyle = '#51321c'; ctx.fillRect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
|
||||
ctx.fillStyle = '#14674d'; ctx.fillRect(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
|
||||
}
|
||||
// La sala scompare in penombra, ma si riconoscono pavimento e boiserie.
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 640, 360);
|
||||
ctx.rect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
|
||||
ctx.fillStyle = '#100f10bb'; ctx.fill('evenodd'); ctx.restore();
|
||||
if (!lighting) {
|
||||
lighting = document.createElement('canvas'); lighting.width = 125; lighting.height = 63;
|
||||
const light = lighting.getContext('2d');
|
||||
for (let y = 0; y < 63; y++) for (let x = 0; x < 125; x++) {
|
||||
const d = Math.hypot((x - 62) / 69, (y - 31) / 38);
|
||||
const warm = Math.max(0, 1 - d) * 0.22;
|
||||
const shade = Math.max(0, d - 0.35) * 0.28;
|
||||
light.fillStyle = `rgba(255,215,128,${Math.round(warm * 32) / 32})`; light.fillRect(x, y, 1, 1);
|
||||
light.fillStyle = `rgba(4,25,20,${Math.round(shade * 32) / 32})`; light.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
ctx.drawImage(lighting, VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
|
||||
}
|
||||
|
||||
function aimGuide(ctx, c) {
|
||||
const p = predict(c), me = c.balls[c.current];
|
||||
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
|
||||
const p = predict(c);
|
||||
const foul = p.hit === 'pin' || p.hit === 'pallino';
|
||||
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,248,220,0.85)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
|
||||
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
if (p.ghost) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
|
||||
if (p.dir) {
|
||||
const o = c.balls[p.hit], L = 22;
|
||||
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)';
|
||||
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke();
|
||||
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
|
||||
for (const track of p.outgoing) {
|
||||
const [start, end] = track.points;
|
||||
if (!end || Math.hypot(end[0] - start[0], end[1] - start[1]) < 1) continue;
|
||||
const x = sx(end[0]), y = sy(end[1]), a = Math.atan2(end[1] - start[1], end[0] - start[0]);
|
||||
ctx.strokeStyle = track.role === c.current ? '#f4df8d' : track.role === 'pallino' ? '#ffae8b' : '#96dfff';
|
||||
ctx.setLineDash(track.role === c.current ? [2, 3] : []);
|
||||
ctx.beginPath(); ctx.moveTo(sx(start[0]), sy(start[1])); ctx.lineTo(x, y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath(); ctx.moveTo(x - Math.cos(a - 0.5) * 5, y - Math.sin(a - 0.5) * 5);
|
||||
ctx.lineTo(x, y); ctx.lineTo(x - Math.cos(a + 0.5) * 5, y - Math.sin(a + 0.5) * 5); ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// Il cerchietto finale è l'arresto effettivo con la forza scelta.
|
||||
const [x, y] = p.points.at(-1);
|
||||
ctx.strokeStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(sx(x), sy(y), 2, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// La stecca dietro la biglia, tirata indietro quanto la forza.
|
||||
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
|
||||
const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
|
||||
ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
|
||||
ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke();
|
||||
ctx.lineCap = 'butt';
|
||||
}
|
||||
|
||||
export function drawBiliardo(ctx, c, def) {
|
||||
ctx.save();
|
||||
// La sala, in penombra, e il lume sopra il tavolo.
|
||||
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360);
|
||||
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420);
|
||||
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)');
|
||||
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360);
|
||||
table(ctx);
|
||||
for (const p of c.pins) pin(ctx, p);
|
||||
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c);
|
||||
for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
|
||||
// Punteggio: i due nomi, i punti, chi deve tirare.
|
||||
panel(ctx, 18, 8, 604, 62);
|
||||
function cue(ctx, x, y, angle, back, alpha = 1) {
|
||||
ctx.save(); ctx.globalAlpha = alpha;
|
||||
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
|
||||
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
|
||||
ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2);
|
||||
ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3);
|
||||
ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1);
|
||||
ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3);
|
||||
ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function tozziPose(c) {
|
||||
const last = c.last;
|
||||
if (last && last.age < 2.4) {
|
||||
if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug';
|
||||
if (last.to === 'cpu' && last.points > 0) return 'cheer';
|
||||
}
|
||||
return c.step === 'aim' && c.current === 'cpu' ? 'chalk' : 'stand';
|
||||
}
|
||||
export const TOZZI = { x: 26, y: 258, scale: 0.46 };
|
||||
function tozzi(ctx, c, sheet) {
|
||||
if (!sheet) return;
|
||||
const pose = tozziPose(c), frame = sheet.atlas.frames[pose];
|
||||
const { x, y, scale } = TOZZI;
|
||||
const bob = pose === 'chalk' ? Math.floor(Math.sin(c.visual.time * 9) * 0.8) : 0;
|
||||
ctx.fillStyle = '#080a09aa'; ctx.fillRect(10, y - 1, 33, 4);
|
||||
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
|
||||
Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale) + bob,
|
||||
Math.round(frame.w * scale), Math.round(frame.h * scale));
|
||||
}
|
||||
|
||||
const SEGMENTS = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f];
|
||||
function digits(ctx, value, x, y) {
|
||||
const segments = [[2, 0, 7, 2], [9, 2, 2, 6], [9, 10, 2, 6], [2, 16, 7, 2],
|
||||
[0, 10, 2, 6], [0, 2, 2, 6], [2, 8, 7, 2]];
|
||||
for (const [i, digit] of String(value).padStart(2, '0').split('').entries()) {
|
||||
segments.forEach(([dx, dy, w, h], bit) => {
|
||||
ctx.fillStyle = SEGMENTS[Number(digit)] & (1 << bit) ? '#ff5a3d' : '#381b17';
|
||||
ctx.fillRect(x + i * 15 + dx, y + dy, w, h);
|
||||
});
|
||||
}
|
||||
}
|
||||
function scoreboard(ctx, c, def) {
|
||||
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||
const x = 30 + i * 300, y = 24;
|
||||
sphere(ctx, who, x + 4, y, 4);
|
||||
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]);
|
||||
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2');
|
||||
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3');
|
||||
const x = 70 + i * 352, active = c.step === 'aim' && c.current === who;
|
||||
ctx.fillStyle = '#0e0e0c'; ctx.fillRect(x - 2, 7, 152, 43);
|
||||
ctx.fillStyle = '#705039'; ctx.fillRect(x, 7, 148, 1);
|
||||
ctx.fillStyle = '#36281d'; ctx.fillRect(x, 49, 148, 2);
|
||||
ctx.fillStyle = '#b09359'; ctx.fillRect(x + 3, 10, 1, 1); ctx.fillRect(x + 144, 46, 1, 1);
|
||||
text(ctx, def.names[who], x + 10, 20, 8, '#ddcfad');
|
||||
sphere(ctx, who, x + 13, 36, 3, false);
|
||||
text(ctx, active ? 'AL TIRO' : 'PUNTI', x + 24, 36, 8, active ? '#eac27d' : '#8e8064');
|
||||
digits(ctx, c.points[who], x + 105, 22);
|
||||
}
|
||||
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : '';
|
||||
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
|
||||
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
|
||||
text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center');
|
||||
if (canThrow(c)) {
|
||||
panel(ctx, 18, 318, 176, 34);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7);
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5);
|
||||
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 29, 8, '#eee0ba', 'center');
|
||||
ctx.fillStyle = '#151c16'; ctx.fillRect(245, 39, 150, 5);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
ctx.fillStyle = i / 30 < c.power ? i > 23 ? '#c19554' : '#9fbc82' : '#303b2d';
|
||||
ctx.fillRect(246 + i * 5, 40, 3, 3);
|
||||
}
|
||||
} else text(ctx, `A ${c.target} PUNTI`, 320, 33, 8, '#b0a080', 'center');
|
||||
}
|
||||
|
||||
export function drawBiliardo(ctx, c, def, bg, sheet) {
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
table(ctx, bg);
|
||||
tozzi(ctx, c, sheet);
|
||||
const view = visualFrame(c);
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
|
||||
if (c.step === 'aim' && (c.current === 'player' || c.plan)) {
|
||||
aimGuide(ctx, c);
|
||||
const me = c.balls[c.current];
|
||||
cue(ctx, me.x, me.y, c.angle, rr + 6 + c.power * 26);
|
||||
} else if (c.visual.shot) {
|
||||
const shot = c.visual.shot, age = c.visual.time - shot.at;
|
||||
if (age < SHOT_DELAY + 0.12) {
|
||||
const pull = 6 + shot.power * 26;
|
||||
const back = age < 0.12 ? pull + age / 0.12 * 10 : Math.max(0, (pull + 10) * (1 - (age - 0.12) / 0.06));
|
||||
cue(ctx, shot.x, shot.y, shot.angle, rr + back, age < SHOT_DELAY ? 1 : 1 - (age - SHOT_DELAY) / 0.12);
|
||||
}
|
||||
}
|
||||
for (const p of view.pins) pin(ctx, p, view.time);
|
||||
for (const role of ['pallino', 'cpu', 'player']) { const b = view.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
|
||||
ctx.restore();
|
||||
scoreboard(ctx, c, def);
|
||||
const hint = canThrow(c) ? predict(c).hit : null;
|
||||
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
|
||||
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
|
||||
: c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
|
||||
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
|
||||
// Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6.
|
||||
const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names);
|
||||
text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
|
||||
if (c.step === 'result') {
|
||||
panel(ctx, 170, 140, 300, 80);
|
||||
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center');
|
||||
text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center');
|
||||
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 8, '#d7ccb4', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
|
||||
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
|
||||
export const SWING = 1.4, FIXED_STEP = 1 / 240;
|
||||
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
|
||||
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
|
||||
export const WINDUP = 0.18;
|
||||
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
||||
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
||||
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||||
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
|
||||
export const VIEW = { x: 57, y: 78, k: 1.85 };
|
||||
export const VIEW = { x: 70, y: 88, k: 1.76 };
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||
@@ -35,11 +39,21 @@ export function reach(v) {
|
||||
export const canThrow = c => c.step === 'aim' && c.current === 'player';
|
||||
export const moving = c => list(c).some(b => b.vx || b.vy);
|
||||
|
||||
// Quanto è bravo l'Ingegnere: quanti colpi prova a vuoto (direzioni intorno al tavolo, ritocchi vicino alla biglia
|
||||
// avversaria, forze) e quanto gli trema la mano. «A fassi male» è il campione vero: vince quasi sempre.
|
||||
export const LEVELS = {
|
||||
bona: { angles: 0, fine: 0.15, powers: [0.6], wobble: 0.16, powerNoise: 0.2 },
|
||||
seria: { angles: 14, fine: 0.09, powers: [0.5, 0.8], wobble: 0.072, powerNoise: 0.1 },
|
||||
male: { angles: 64, fine: 0.025, powers: [0.3, 0.5, 0.7, 0.9], wobble: 0.03, powerNoise: 0.05 },
|
||||
};
|
||||
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
||||
const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
|
||||
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
|
||||
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
|
||||
c.pins = castle();
|
||||
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
|
||||
c.visual = { time: 0, shot: null };
|
||||
c.angle = angleTo(c.balls.player, c.balls.cpu);
|
||||
return c;
|
||||
}
|
||||
@@ -72,12 +86,12 @@ function cushions(b) {
|
||||
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
|
||||
export function collide(a, b) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||
if (d >= 2 * R) return 0;
|
||||
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2;
|
||||
if (d > 2 * R + CONTACT_EPS) return 0;
|
||||
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (2 * R - d) / 2);
|
||||
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
|
||||
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (closing <= 0) return 0;
|
||||
const j = closing * 0.975;
|
||||
if (closing <= CONTACT_EPS) return 0;
|
||||
const j = closing * (1 + BALL_RESTITUTION) / 2;
|
||||
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
|
||||
return closing;
|
||||
}
|
||||
@@ -87,38 +101,81 @@ function touch(shot, a, b) {
|
||||
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
|
||||
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
|
||||
}
|
||||
// Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
|
||||
// Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
|
||||
function contactTime(dx, dy, vx, vy, radius) {
|
||||
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
|
||||
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
|
||||
const gap = dx * dx + dy * dy - radius * radius;
|
||||
if (gap <= CONTACT_EPS) return 0;
|
||||
const discriminant = closing * closing - speed2 * gap;
|
||||
if (discriminant <= 0) return Infinity;
|
||||
return gap / (-closing + Math.sqrt(discriminant));
|
||||
}
|
||||
function slow(b, dt) {
|
||||
const v = Math.hypot(b.vx, b.vy);
|
||||
if (!v) return;
|
||||
const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
|
||||
const scale = nv < EPS ? 0 : nv / v;
|
||||
b.vx *= scale; b.vy *= scale;
|
||||
}
|
||||
function advance(balls, dt) {
|
||||
for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
|
||||
}
|
||||
function step(w, dt) {
|
||||
let hit = 0, rail = 0;
|
||||
for (const b of w.balls) {
|
||||
const v = Math.hypot(b.vx, b.vy);
|
||||
if (v < EPS) { b.vx = b.vy = 0; continue; }
|
||||
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt;
|
||||
b.x += b.vx / v * travel; b.y += b.vy / v * travel;
|
||||
b.vx *= nv / v; b.vy *= nv / v;
|
||||
rail = Math.max(rail, cushions(b));
|
||||
for (const p of w.pins) {
|
||||
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue;
|
||||
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio.
|
||||
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) });
|
||||
b.vx *= 0.88; b.vy *= 0.88;
|
||||
w.shot?.pins.push(p);
|
||||
// Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
|
||||
// Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
|
||||
for (const b of w.balls) slow(b, dt / 2);
|
||||
let left = dt, hit = 0, rail = 0;
|
||||
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
|
||||
for (let events = 0; left > 1e-10 && events < 64; events++) {
|
||||
let time = left, event = null;
|
||||
const consider = (t, kind, a, b, axis, edge) => {
|
||||
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
|
||||
};
|
||||
for (let i = 0; i < w.balls.length; i++) {
|
||||
const a = w.balls[i];
|
||||
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
|
||||
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
|
||||
for (let j = i + 1; j < w.balls.length; j++) {
|
||||
const b = w.balls[j];
|
||||
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
|
||||
}
|
||||
if (!a.vx && !a.vy) continue;
|
||||
for (const p of w.pins) if (!p.down)
|
||||
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
|
||||
}
|
||||
advance(w.balls, time); left -= time;
|
||||
if (!event) break;
|
||||
const { kind, a, b, axis, edge } = event;
|
||||
if (kind === 'rail') {
|
||||
const v = axis === 'x' ? 'vx' : 'vy';
|
||||
a[axis] = edge;
|
||||
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
|
||||
} else if (kind === 'ball') {
|
||||
const impact = collide(a, b);
|
||||
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
|
||||
} else {
|
||||
const v = Math.hypot(a.vx, a.vy);
|
||||
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
|
||||
a.vx *= 0.88; a.vy *= 0.88;
|
||||
w.shot?.pins.push(b);
|
||||
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
|
||||
}
|
||||
w.onContact?.(event);
|
||||
}
|
||||
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) {
|
||||
const s = collide(w.balls[i], w.balls[j]);
|
||||
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
|
||||
}
|
||||
for (const b of w.balls) slow(b, dt / 2);
|
||||
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
|
||||
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
|
||||
}
|
||||
|
||||
function strike(c, who, angle, power) {
|
||||
const b = c.balls[who], v = speedFor(power);
|
||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||||
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
|
||||
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
|
||||
c.windup = WINDUP;
|
||||
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
|
||||
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
|
||||
c.events.push({ kind: 'biliardo-cue', power });
|
||||
return true;
|
||||
}
|
||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||
@@ -133,6 +190,7 @@ export function simulate(c, who, angle, power) {
|
||||
|
||||
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
|
||||
function reset(c) {
|
||||
for (const p of c.pins) delete p.visualDownAt;
|
||||
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
for (const b of list(c)) for (const p of c.pins) {
|
||||
@@ -162,10 +220,10 @@ function finish(c) {
|
||||
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
|
||||
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
|
||||
function cpuCandidates(c) {
|
||||
const base = angleTo(c.balls.cpu, c.balls.player), angles = [];
|
||||
for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2);
|
||||
for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d);
|
||||
return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power })));
|
||||
const k = c.skill ?? LEVELS.male, base = angleTo(c.balls.cpu, c.balls.player), angles = [];
|
||||
for (let i = 0; i < k.angles; i++) angles.push(i / k.angles * Math.PI * 2);
|
||||
for (let d = -0.3; d <= 0.3; d += k.fine) angles.push(base + d);
|
||||
return angles.flatMap(angle => k.powers.map(power => ({ angle, power })));
|
||||
}
|
||||
function cpuThink(c, budget = 12) {
|
||||
const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
|
||||
@@ -174,16 +232,28 @@ function cpuThink(c, budget = 12) {
|
||||
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
|
||||
}
|
||||
if (t.todo.length) return;
|
||||
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) };
|
||||
const k = c.skill ?? LEVELS.male;
|
||||
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? k.wobble), power: clamp(t.best.power + deviation(c) * k.powerNoise, 0.05, 1) };
|
||||
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||||
}
|
||||
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
c.visual.time += dt;
|
||||
if (c.last) c.last.age += dt;
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
if (c.step === 'rolling') {
|
||||
if (c.step === 'rolling' && c.windup > 0) {
|
||||
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
|
||||
c.windup -= dt;
|
||||
if (c.windup <= 0) {
|
||||
const { ball, vx, vy, power } = c.pending;
|
||||
ball.vx = vx; ball.vy = vy; c.pending = null;
|
||||
c.events.push({ kind: 'biliardo-cue', power });
|
||||
dt = -c.windup; c.windup = 0;
|
||||
} else dt = 0;
|
||||
}
|
||||
if (c.step === 'rolling' && !c.windup) {
|
||||
c.accumulator += dt;
|
||||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
||||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||
@@ -193,17 +263,26 @@ export function update(c, dt) {
|
||||
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
|
||||
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
|
||||
}
|
||||
if (c.step === 'rolling' || c.step === 'settle')
|
||||
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
|
||||
}
|
||||
|
||||
// --- Comandi del giocatore: gli stessi delle bocce.
|
||||
export function aimAt(c, x, y) {
|
||||
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
const b = c.balls.player;
|
||||
if (Math.hypot(x - b.x, y - b.y) < R) return false;
|
||||
c.angle = Math.atan2(y - b.y, x - b.x); return true;
|
||||
}
|
||||
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); }
|
||||
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; }
|
||||
export function aimScreen(c, [sx, sy]) {
|
||||
const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
|
||||
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
|
||||
return aimAt(c, x, y);
|
||||
}
|
||||
export function nudge(c, dir, fine = false) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
|
||||
c.angle += dir * (fine ? 0.0005 : 0.002); return true;
|
||||
}
|
||||
export function setPower(c, value) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||
c.power = clamp(value, 0, 1); return true;
|
||||
@@ -223,24 +302,54 @@ export function release(c) {
|
||||
c.holding = false; return strike(c, 'player', c.angle, c.power);
|
||||
}
|
||||
|
||||
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza),
|
||||
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita.
|
||||
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
|
||||
// Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
|
||||
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
|
||||
const predictions = new WeakMap();
|
||||
export function predict(c) {
|
||||
const me = c.balls[c.current], others = list(c).filter(b => b !== me);
|
||||
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power));
|
||||
const points = [[x, y]], STEP = 0.5;
|
||||
for (let n = 0; left > 0 && n < 6000; n++) {
|
||||
x += dx * STEP; y += dy * STEP; left -= STEP;
|
||||
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); }
|
||||
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); }
|
||||
for (const o of others) {
|
||||
const d = Math.hypot(o.x - x, o.y - y);
|
||||
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; }
|
||||
const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
|
||||
...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
|
||||
const cached = predictions.get(c);
|
||||
if (cached?.key === key) return cached.result;
|
||||
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
|
||||
const v = speedFor(c.power);
|
||||
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
|
||||
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
|
||||
const tracks = [];
|
||||
const point = b => [b.x, b.y];
|
||||
const endTrack = track => {
|
||||
track.points.push(point(track.ball)); track.done = true;
|
||||
};
|
||||
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
|
||||
const { kind, a, b } = event;
|
||||
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
|
||||
if (result.hit || (a !== me && b !== me)) return;
|
||||
result.points.push(point(me));
|
||||
if (kind === 'rail') return;
|
||||
result.ghost = { x: me.x, y: me.y };
|
||||
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
|
||||
if (kind === 'pin') return;
|
||||
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
|
||||
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
|
||||
for (const ball of [me, other]) {
|
||||
const points = [point(ball)];
|
||||
tracks.push({ ball, points, done: false });
|
||||
result.outgoing.push({ role: ball.role, points });
|
||||
}
|
||||
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; }
|
||||
} };
|
||||
for (let i = 0; i < 15 / FIXED_STEP; i++) {
|
||||
step(w, FIXED_STEP);
|
||||
for (const track of tracks) {
|
||||
if (track.done) continue;
|
||||
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
|
||||
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
|
||||
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
|
||||
}
|
||||
if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
|
||||
}
|
||||
points.push([x, y]);
|
||||
return { points, ghost: null, hit: null, dir: null };
|
||||
if (!result.hit) result.points.push(point(me));
|
||||
predictions.set(c, { key, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
|
||||
|
||||
@@ -19,8 +19,16 @@ export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
|
||||
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
|
||||
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
|
||||
|
||||
export function newBocce(seed = Date.now() >>> 0) {
|
||||
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
|
||||
// Quanto è bravo Mariano: quanto si allarga il suo tiro e quanto spesso prova a bocciare la boccia migliore.
|
||||
// «A fassi male» è il campione vero del pallaio.
|
||||
export const LEVELS = {
|
||||
bona: { spread: 3, attack: 0.1 },
|
||||
seria: { spread: 1.15, attack: 0.4 },
|
||||
male: { spread: 0.22, attack: 0.85 },
|
||||
};
|
||||
export function newBocce(seed = Date.now() >>> 0, def = {}) {
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
const c = { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], balls: [], turn: 0, current: 'player', power: 0.5,
|
||||
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
|
||||
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
|
||||
c.aim = { u: c.jack.u, w: c.jack.w };
|
||||
@@ -134,10 +142,11 @@ function nextTurn(c) {
|
||||
function cpuThrow(c) {
|
||||
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può
|
||||
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
|
||||
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45;
|
||||
const k = c.skill ?? LEVELS.male;
|
||||
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < k.attack;
|
||||
const target = attack ? other : c.jack;
|
||||
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2);
|
||||
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL);
|
||||
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2) * k.spread, 10, LANE - 2);
|
||||
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8) * k.spread, -HALF + BALL, HALF - BALL);
|
||||
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
|
||||
}
|
||||
export function update(c, dt) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
// Una carta in volo da un rettangolo [x, y, w, h] a un altro, con k da 0 a 1 (parte svelta, arriva piano).
|
||||
// La usano anche tressette e scopa.
|
||||
export function flight(from, to, k) {
|
||||
const e = 1 - (1 - Math.min(1, Math.max(0, k))) ** 3;
|
||||
return from.map((v, i) => v + (to[i] - v) * e);
|
||||
}
|
||||
// Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono.
|
||||
export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
|
||||
export function cardAt(c, [x, y]) {
|
||||
if (!canPlay(c)) return -1;
|
||||
return c.hands.player.findIndex((_, i) => x >= HAND.x + i * HAND.gap && x < HAND.x + i * HAND.gap + HAND.w && y >= HAND.y && y < HAND.y + HAND.h);
|
||||
}
|
||||
const ink = '#38271f', paper = '#fff0cd', gold = '#dba32b', red = '#aa4034', blue = '#386a89', green = '#487348';
|
||||
function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
|
||||
function poly(ctx, points, color) {
|
||||
ctx.fillStyle = color; ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Simboli originali: moneta incisa, calice, lama ricurva e bastone coi nodi.
|
||||
function suit(ctx, name, x, y) {
|
||||
ctx.save(); ctx.translate(x, y);
|
||||
if (name === 'denari') {
|
||||
rect(ctx, -4, -3, 8, 6, ink); rect(ctx, -3, -4, 6, 8, ink);
|
||||
rect(ctx, -3, -2, 6, 4, gold); rect(ctx, -2, -3, 4, 6, gold);
|
||||
rect(ctx, -2, -2, 1, 4, '#ffe58c'); rect(ctx, 0, -1, 2, 2, '#9e6b25');
|
||||
} else if (name === 'coppe') {
|
||||
rect(ctx, -4, -4, 8, 2, ink); rect(ctx, -3, -2, 6, 3, gold);
|
||||
rect(ctx, -2, 0, 4, 2, red); rect(ctx, -1, 2, 2, 2, ink);
|
||||
rect(ctx, -3, 4, 6, 1, gold); rect(ctx, -3, -3, 6, 1, '#ffdd70');
|
||||
} else if (name === 'spade') {
|
||||
poly(ctx, [[-4,5],[-3,1],[0,-1],[2,-5],[4,-6],[3,-1],[-1,3],[-2,6]], ink);
|
||||
rect(ctx, 1, -3, 2, 3, '#9eb3ba'); rect(ctx, -1, 0, 2, 2, '#c9d8d6');
|
||||
rect(ctx, -5, 3, 5, 1, gold); rect(ctx, -4, 5, 2, 2, red);
|
||||
} else {
|
||||
poly(ctx, [[-3,6],[-4,4],[-1,-1],[0,-6],[3,-5],[3,-2],[0,3],[-1,6]], ink);
|
||||
rect(ctx, 0, -4, 2, 5, '#94632e'); rect(ctx, -2, 1, 2, 4, '#ae7a39');
|
||||
rect(ctx, 2, -3, 2, 2, gold); rect(ctx, -3, 2, 2, 2, gold); rect(ctx, 3, -5, 2, 1, green);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function regolo(ctx) {
|
||||
// Cornice simmetrica e un piccolo re sorridente, avvolto nelle proprie spire.
|
||||
rect(ctx, 3, 3, 36, 54, blue); rect(ctx, 5, 5, 32, 50, gold); rect(ctx, 6, 6, 30, 48, '#203e44');
|
||||
for (let y = 9; y < 54; y += 6) for (const x of [8, 32]) rect(ctx, x, y, 2, 2, '#63867c');
|
||||
poly(ctx, [[15,32],[28,32],[32,36],[32,43],[28,47],[14,47],[10,43],[10,37]], ink);
|
||||
poly(ctx, [[15,34],[27,34],[30,37],[30,42],[27,45],[15,45],[12,42],[12,38]], green);
|
||||
poly(ctx, [[17,37],[26,37],[28,39],[27,41],[16,41],[15,39]], '#203e44');
|
||||
rect(ctx, 14, 42, 13, 2, '#a3bc72'); rect(ctx, 16, 36, 11, 1, '#a3bc72');
|
||||
poly(ctx, [[19,25],[25,25],[25,33],[23,36],[18,36],[17,33],[20,32]], green);
|
||||
rect(ctx, 20, 28, 2, 6, '#d0cd83');
|
||||
poly(ctx, [[15,16],[27,16],[30,19],[30,25],[27,29],[15,29],[12,25],[12,20]], ink);
|
||||
poly(ctx, [[16,17],[26,17],[29,20],[29,24],[26,27],[16,27],[13,24],[13,21]], '#94b365');
|
||||
rect(ctx, 15, 19, 4, 4, paper); rect(ctx, 23, 19, 4, 4, paper);
|
||||
rect(ctx, 17, 20, 2, 3, ink); rect(ctx, 23, 20, 2, 3, ink);
|
||||
rect(ctx, 18, 25, 6, 1, ink); rect(ctx, 16, 24, 2, 1, ink); rect(ctx, 24, 24, 2, 1, ink);
|
||||
rect(ctx, 17, 12, 9, 4, gold); for (const x of [17, 21, 25]) rect(ctx, x, 9, 1, 4, '#ffe396');
|
||||
rect(ctx, 21, 13, 1, 2, red);
|
||||
for (const x of [15, 25]) { rect(ctx, x, 50, 2, 2, gold); rect(ctx, x + 1, 49, 1, 4, gold); }
|
||||
}
|
||||
function figure(ctx, rank, name) {
|
||||
// Figure intere: fante in tunica, cavaliere a cavallo, re con corona e manto.
|
||||
const king = rank === 'Re', horse = rank === 'Cavallo', y = horse ? 15 : 17;
|
||||
if (horse) {
|
||||
poly(ctx, [[8,36],[12,29],[27,30],[30,23],[35,23],[37,29],[32,33],[31,41],[12,41]], ink);
|
||||
rect(ctx, 11, 33, 20, 7, '#b78044'); rect(ctx, 30, 26, 4, 8, '#c79c60');
|
||||
rect(ctx, 12, 40, 3, 10, ink); rect(ctx, 27, 39, 3, 11, ink);
|
||||
rect(ctx, 15, 40, 2, 8, '#a36b3c'); rect(ctx, 32, 27, 1, 1, paper);
|
||||
}
|
||||
if (king) poly(ctx, [[15,24],[29,24],[33,48],[10,48]], red);
|
||||
poly(ctx, [[19,y],[25,y],[27,y+2],[28,y+4],[26,y+5],[26,y+8],[20,y+9],[17,y+6],[17,y+2]], ink);
|
||||
poly(ctx, [[20,y+1],[25,y+1],[25,y+3],[27,y+4],[25,y+5],[25,y+7],[21,y+8],[19,y+5]], '#eab578');
|
||||
rect(ctx, 18, y + 1, 2, 4, '#86502d'); rect(ctx, 20, y + 1, 4, 1, '#ffe0a2');
|
||||
rect(ctx, 24, y + 2, 1, 2, ink); rect(ctx, 24, y + 6, 3, 1, ink);
|
||||
rect(ctx, 17, y - 2, 10, 3, king ? gold : blue);
|
||||
if (king) for (const x of [17, 21, 25]) rect(ctx, x, y - 5, 2, 4, gold);
|
||||
else rect(ctx, 18, y - 5, 2, 4, red);
|
||||
poly(ctx, [[18,y+9],[26,y+9],[29,y+12],[27,y+19],[29,y+24],[15,y+24],[17,y+19],[15,y+12]], ink);
|
||||
poly(ctx, [[19,y+10],[25,y+10],[27,y+12],[25,y+19],[27,y+22],[17,y+22],[19,y+18],[17,y+12]], horse ? red : blue);
|
||||
rect(ctx, 18, y + 10, 3, 10, king ? gold : '#6291a5'); rect(ctx, 17, y + 19, 11, 2, gold);
|
||||
rect(ctx, 23, y + 11, 1, 6, paper); rect(ctx, 23, y + 19, 2, 2, ink);
|
||||
rect(ctx, 12, y + 11, 4, 3, '#eab578'); rect(ctx, 28, y + 10, 4, 4, '#eab578');
|
||||
if (!horse) {
|
||||
poly(ctx, [[17,y+24],[21,y+24],[20,y+32],[16,y+32]], ink);
|
||||
poly(ctx, [[23,y+24],[27,y+24],[29,y+32],[25,y+32]], ink);
|
||||
rect(ctx, 18, y + 24, 2, 5, '#b9884f'); rect(ctx, 24, y + 24, 2, 4, '#b9884f');
|
||||
rect(ctx, 15, y + 32, 6, 2, red); rect(ctx, 24, y + 32, 6, 2, red);
|
||||
}
|
||||
suit(ctx, name, 10, 24);
|
||||
if (king) { rect(ctx, 32, 22, 1, 24, gold); rect(ctx, 31, 20, 3, 3, gold); }
|
||||
}
|
||||
const PIPS = {
|
||||
Asso: [[21,32]], 2: [[21,22],[21,42]], 3: [[21,20],[21,32],[21,44]],
|
||||
4: [[13,23],[29,23],[13,41],[29,41]],
|
||||
5: [[13,21],[29,21],[21,32],[13,43],[29,43]],
|
||||
6: [[13,20],[29,20],[13,32],[29,32],[13,44],[29,44]],
|
||||
7: [[13,19],[29,19],[13,32],[29,32],[13,45],[29,45],[21,32]],
|
||||
};
|
||||
// Le toscane hanno semi francesi, nessun indice sulle numerali e figure intere.
|
||||
const TUSCAN_COLORS = {
|
||||
cuori: { cloth: '#326447', light: '#65936a', cloak: '#ab393b', ground: '#bc7760' },
|
||||
mattoni: { cloth: '#a93832', light: '#d56a46', cloak: '#315f7e', ground: '#b3a05b' },
|
||||
fiori: { cloth: '#315e85', light: '#6995ac', cloak: '#bc842e', ground: '#829968' },
|
||||
picche: { cloth: '#755079', light: '#ac7d9a', cloak: '#376b66', ground: '#9e8271' },
|
||||
};
|
||||
const TUSCAN_PIPS = {
|
||||
Asso: [[21,30]], 2: [[21,17],[21,43]], 3: [[21,15],[21,30],[21,45]],
|
||||
4: [[12,16],[30,16],[12,44],[30,44]],
|
||||
5: [[12,16],[30,16],[21,30],[12,44],[30,44]],
|
||||
6: [[12,15],[30,15],[12,30],[30,30],[12,45],[30,45]],
|
||||
7: [[10,14],[32,14],[21,22],[10,30],[32,30],[10,46],[32,46]],
|
||||
};
|
||||
function tuscanSuit(ctx, name, x, y, inverted = false) {
|
||||
ctx.save(); ctx.translate(x, y);
|
||||
if (inverted) ctx.scale(1, -1);
|
||||
const color = name === 'cuori' || name === 'mattoni' ? '#c12f35' : '#202326';
|
||||
// Sagome a gradini sulla griglia: nessuna sfumatura nei semi.
|
||||
if (name === 'cuori') {
|
||||
poly(ctx, [[-4,-4],[-1,-4],[0,-3],[1,-4],[4,-4],[5,-3],[5,0],[4,1],[4,2],[3,2],[3,3],[2,3],[2,4],[1,4],[1,5],[0,6],[-1,5],[-1,4],[-2,4],[-2,3],[-3,3],[-3,2],[-4,2],[-4,1],[-5,0],[-5,-3]], color);
|
||||
} else if (name === 'mattoni') {
|
||||
poly(ctx, [[0,-6],[1,-4],[2,-3],[3,-2],[5,0],[3,2],[2,3],[1,4],[0,6],[-1,4],[-2,3],[-3,2],[-5,0],[-3,-2],[-2,-3],[-1,-4]], color);
|
||||
} else if (name === 'fiori') {
|
||||
poly(ctx, [[-2,-5],[2,-5],[3,-4],[3,-2],[2,-1],[4,-2],[6,0],[6,2],[4,4],[2,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-2,4],[-4,4],[-6,2],[-6,0],[-4,-2],[-2,-1],[-3,-2],[-3,-4]], color);
|
||||
} else {
|
||||
poly(ctx, [[0,-6],[1,-5],[1,-4],[3,-2],[5,0],[5,3],[3,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-3,4],[-5,3],[-5,0],[-3,-2],[-1,-4],[-1,-5]], color);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function tuscanFigure(ctx, rank, name) {
|
||||
const p = TUSCAN_COLORS[name], skin = '#e9b785', shade = '#bf805d', trim = '#e0b957';
|
||||
const queen = rank === 'Donna', king = rank === 'Regio';
|
||||
// Cornice sottile, terreno e silhouette originali, disegnati su una griglia 42×60.
|
||||
rect(ctx, 3, 3, 36, 54, '#b6a48a'); rect(ctx, 3.5, 3.5, 35, 53, paper);
|
||||
poly(ctx, [[4,51],[14,49],[26,50],[38,48],[38,56],[4,56]], p.ground);
|
||||
if (queen) {
|
||||
// Dama di profilo: diadema, manica a sbuffo, gonna a ventaglio e fazzoletto.
|
||||
poly(ctx, [[21,11],[27,11],[29,14],[28,22],[31,26],[27,29],[19,24],[18,18]], ink);
|
||||
poly(ctx, [[22,12],[26,12],[27,15],[25,19],[22,21],[20,18],[19,17],[21,16]], skin);
|
||||
rect(ctx, 23, 12, 4, 2, '#70452c'); rect(ctx, 26, 14, 2, 7, '#70452c');
|
||||
rect(ctx, 21, 15, 1, 1, ink); rect(ctx, 20, 18, 2, 1, shade);
|
||||
poly(ctx, [[21,12],[21,10],[23,11],[24,9],[25,11],[27,10],[27,13]], trim);
|
||||
rect(ctx, 24, 10, 1, 1, '#c12f35');
|
||||
poly(ctx, [[21,21],[26,20],[30,24],[28,31],[31,39],[37,54],[12,54],[15,43],[18,33],[17,26]], ink);
|
||||
poly(ctx, [[22,22],[26,21],[28,24],[26,31],[29,39],[35,53],[14,53],[17,42],[20,32],[19,26]], p.cloth);
|
||||
poly(ctx, [[21,32],[25,32],[25,40],[31,52],[23,51],[19,46]], p.light);
|
||||
poly(ctx, [[26,34],[29,41],[33,51],[30,48],[27,43]], trim);
|
||||
poly(ctx, [[19,35],[18,45],[16,52],[20,52],[21,49],[19,47]], p.cloak);
|
||||
poly(ctx, [[18,23],[22,25],[20,29],[16,31],[12,29],[13,26]], '#d8dfd7');
|
||||
rect(ctx, 17, 25, 4, 2, '#94a7ad');
|
||||
poly(ctx, [[14,28],[16,30],[12,33],[9,32],[10,30]], skin);
|
||||
poly(ctx, [[9,31],[12,32],[10,35],[8,35],[6,40],[6,35]], '#fcf7df');
|
||||
rect(ctx, 8, 34, 1, 3, '#94a7ad');
|
||||
poly(ctx, [[20,29],[26,29],[27,31],[20,33],[18,32]], trim);
|
||||
rect(ctx, 22, 23, 1, 3, trim); rect(ctx, 16, 54, 7, 1, ink);
|
||||
} else if (king) {
|
||||
// Regio anziano: corona sempre visibile, barba bianca e bordi d'ermellino.
|
||||
poly(ctx, [[15,23],[27,22],[32,27],[36,54],[9,54],[11,31]], ink);
|
||||
poly(ctx, [[15,24],[27,23],[30,28],[34,53],[11,53],[13,31]], p.cloak);
|
||||
poly(ctx, [[18,27],[25,27],[27,52],[16,52]], p.cloth);
|
||||
rect(ctx, 20, 33, 3, 18, p.light); rect(ctx, 16, 48, 11, 2, trim);
|
||||
poly(ctx, [[15,24],[18,25],[16,34],[15,45],[12,52],[11,51],[13,33]], '#f6edcf');
|
||||
poly(ctx, [[26,24],[28,25],[29,35],[32,51],[29,52],[26,35],[24,27]], '#f6edcf');
|
||||
for (const [x,y] of [[14,29],[13,37],[12,46],[27,29],[28,38],[30,47]]) rect(ctx, x, y, 1, 2, ink);
|
||||
poly(ctx, [[17,13],[24,12],[27,16],[26,23],[23,28],[18,26],[15,21],[15,16]], '#b9bfbb');
|
||||
poly(ctx, [[18,14],[24,14],[25,17],[27,19],[24,20],[24,22],[19,22],[17,18]], skin);
|
||||
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 18, 16, 1, 2, shade);
|
||||
poly(ctx, [[16,18],[19,21],[22,20],[25,21],[24,25],[21,28],[18,25],[16,23]], '#f7f0d7');
|
||||
rect(ctx, 21, 22, 3, 1, '#9ba8a3'); rect(ctx, 18, 22, 1, 3, '#c6cdc0');
|
||||
poly(ctx, [[16,14],[15,8],[17,8],[18,11],[20,10],[20,7],[22,7],[22,10],[24,11],[25,8],[27,8],[26,14]], ink);
|
||||
poly(ctx, [[17,13],[16,9],[17,9],[18,12],[21,11],[21,8],[22,12],[25,12],[26,9],[25,13]], trim);
|
||||
rect(ctx, 17, 13, 9, 2, trim); rect(ctx, 21, 13, 2, 1, '#c12f35');
|
||||
rect(ctx, 33, 23, 1, 30, '#9b6a29'); rect(ctx, 33, 24, 1, 27, trim);
|
||||
poly(ctx, [[33,19],[35,21],[35,23],[33,25],[31,23],[31,21]], trim);
|
||||
rect(ctx, 33, 21, 1, 2, '#fff1ae'); rect(ctx, 31, 32, 4, 3, skin);
|
||||
rect(ctx, 10, 33, 4, 3, skin); rect(ctx, 15, 53, 6, 2, ink); rect(ctx, 24, 53, 6, 2, ink);
|
||||
} else {
|
||||
// Gobbo giovane, senza corona: berretto morbido, mantello corto e mazza.
|
||||
poly(ctx, [[15,23],[26,22],[31,27],[33,39],[27,43],[11,40],[10,28]], ink);
|
||||
poly(ctx, [[15,24],[25,23],[29,27],[31,38],[27,41],[12,39],[12,28]], p.cloak);
|
||||
poly(ctx, [[18,37],[23,38],[21,48],[20,53],[15,53],[17,46]], ink);
|
||||
poly(ctx, [[24,37],[28,37],[28,46],[31,53],[26,53],[23,46]], ink);
|
||||
rect(ctx, 18, 41, 2, 9, '#d3ad60'); rect(ctx, 25, 41, 2, 6, '#d3ad60');
|
||||
rect(ctx, 16, 50, 4, 4, '#744531'); rect(ctx, 27, 50, 3, 4, '#744531');
|
||||
rect(ctx, 13, 54, 7, 1, ink); rect(ctx, 26, 54, 7, 1, ink);
|
||||
poly(ctx, [[17,12],[24,12],[27,16],[26,23],[22,25],[17,22],[15,18]], '#70452c');
|
||||
poly(ctx, [[19,14],[24,14],[24,16],[26,18],[24,19],[24,21],[21,23],[18,20]], skin);
|
||||
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 23, 20, 2, 1, shade);
|
||||
rect(ctx, 17, 15, 2, 7, '#70452c'); rect(ctx, 16, 19, 2, 5, '#70452c');
|
||||
poly(ctx, [[16,14],[17,11],[21,10],[25,11],[26,14],[28,15],[16,15]], p.cloth);
|
||||
rect(ctx, 18, 13, 8, 1, p.light);
|
||||
poly(ctx, [[18,24],[24,23],[27,27],[25,33],[28,40],[15,40],[17,33],[15,27]], ink);
|
||||
poly(ctx, [[19,25],[23,24],[25,27],[23,33],[26,39],[17,39],[19,33],[17,27]], p.cloth);
|
||||
rect(ctx, 20, 26, 2, 7, p.light); rect(ctx, 16, 34, 10, 2, trim);
|
||||
poly(ctx, [[18,23],[21,25],[20,29],[17,25]], '#f6edcf');
|
||||
rect(ctx, 21, 34, 2, 2, ink);
|
||||
poly(ctx, [[10,27],[13,29],[12,33],[9,34],[8,32]], skin);
|
||||
poly(ctx, [[27,28],[30,30],[28,34],[25,35],[24,33],[27,32]], skin);
|
||||
poly(ctx, [[7,30],[9,30],[9,46],[11,50],[11,53],[6,53],[6,49],[7,46]], ink);
|
||||
rect(ctx, 8, 33, 1, 17, '#ae783a'); rect(ctx, 7, 49, 3, 3, trim);
|
||||
rect(ctx, 7, 31, 3, 2, skin);
|
||||
}
|
||||
ctx.save(); ctx.translate(9, 8); ctx.scale(0.65, 0.65); tuscanSuit(ctx, name, 0, 0); ctx.restore();
|
||||
text(ctx, queen ? 'D' : king ? 'R' : 'G', 7, 16, 4, ink);
|
||||
}
|
||||
function tuscanCard(ctx, card) {
|
||||
// Angoli arrotondati a gradini e un solo bordo sottile; nessuna scritta al piede.
|
||||
const outline = [[2,0],[40,0],[42,2],[42,58],[40,60],[2,60],[0,58],[0,2]];
|
||||
ctx.save(); ctx.translate(1, 2); poly(ctx, outline, '#15231d'); ctx.restore();
|
||||
poly(ctx, outline, '#89765f');
|
||||
poly(ctx, [[2,1],[40,1],[41,2],[41,58],[40,59],[2,59],[1,58],[1,2]], paper);
|
||||
if (TUSCAN_PIPS[card.rank]) {
|
||||
for (const [x, y] of TUSCAN_PIPS[card.rank]) tuscanSuit(ctx, card.suit, x, y, y > 30);
|
||||
} else tuscanFigure(ctx, card.rank, card.suit);
|
||||
}
|
||||
export function drawCard(ctx, card, x, y, w = 84, h = 120, back = false) {
|
||||
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(w / 42, h / 60);
|
||||
if (!back && TUSCAN_COLORS[card.suit]) { tuscanCard(ctx, card); ctx.restore(); return; }
|
||||
rect(ctx, 1, 2, 42, 60, '#15231d'); rect(ctx, 0, 0, 42, 60, ink);
|
||||
rect(ctx, 1, 1, 40, 58, '#c5a576'); rect(ctx, 2, 2, 38, 56, paper);
|
||||
if (back) regolo(ctx);
|
||||
else {
|
||||
const rank = { Asso: 'A', Fante: 'F', Cavallo: 'C', Re: 'R', Gobbo: 'G', Donna: 'D', Regio: 'R' }[card.rank] ?? card.rank;
|
||||
text(ctx, rank, 4, 7, 6, ink); suit(ctx, card.suit, 34, 8);
|
||||
if (PIPS[card.rank]) {
|
||||
for (const [px, py] of PIPS[card.rank]) {
|
||||
// Una lama per simbolo: le coppie si allungano e s'incrociano al centro.
|
||||
if (card.suit === 'spade' && (px === 13 || px === 29)) {
|
||||
ctx.save(); ctx.translate(px === 13 ? 18 : 24, py); ctx.scale(px === 13 ? 1.8 : -1.8, 1);
|
||||
suit(ctx, card.suit, 0, 0); ctx.restore();
|
||||
} else suit(ctx, card.suit, px, py);
|
||||
}
|
||||
} else figure(ctx, card.rank, card.suit);
|
||||
text(ctx, card.suit.toUpperCase(), 21, 55, 3.7, ink, 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function countTable(ctx, c, names) {
|
||||
panel(ctx, 12, 32, 616, 318);
|
||||
const rows = countRows(c), shown = rows.slice(0, c.count), totals = counted(c);
|
||||
for (const [side, who] of ['player', 'cpu'].entries()) {
|
||||
const x = 25 + side * 310;
|
||||
text(ctx, names[who], x, 48, 7);
|
||||
const cards = shown.filter(p => p.who === who);
|
||||
cards.forEach(({ card }, i) => {
|
||||
const cx = x + (i % 8) * 36, cy = 64 + Math.floor(i / 8) * 49;
|
||||
drawCard(ctx, card, cx, cy, 30, 40);
|
||||
text(ctx, '+' + card.points, cx + 15, cy + 44, 5, '#ffe09b', 'center');
|
||||
});
|
||||
text(ctx, `${totals[who]} punti`, x, 321, 10, '#ffe09b');
|
||||
}
|
||||
const last = shown.at(-1);
|
||||
if (c.step === 'count' && last) {
|
||||
panel(ctx, 270, 115, 100, 140); drawCard(ctx, last.card, 278, 123);
|
||||
}
|
||||
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
|
||||
}
|
||||
// Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
|
||||
export function drawTable(ctx, title) {
|
||||
rect(ctx, 0, 0, 640, 360, '#573720');
|
||||
for (let y = 0; y < 360; y += 24) {
|
||||
rect(ctx, 0, y, 640, 2, '#38271e');
|
||||
for (let x = 0; x < 640; x += 53) rect(ctx, x + y % 17, y + 6, 29, 1, '#72462a');
|
||||
}
|
||||
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
|
||||
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
|
||||
text(ctx, title, 320, 15, 9, '#ffe0a0', 'center');
|
||||
}
|
||||
export function drawBriscola(ctx, c, def) {
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
drawTable(ctx, 'BRISCOLA ALL’ORATORIO');
|
||||
if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
|
||||
text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
|
||||
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
|
||||
// La briscola sporge sotto il mazzo, girata di traverso: più di metà resta scoperta, anche l'asso che ha
|
||||
// un seme solo nel mezzo (le toscane non hanno numeri negli angoli).
|
||||
if (c.stock.length) {
|
||||
ctx.save(); ctx.translate(154, 150); ctx.rotate(Math.PI / 2);
|
||||
drawCard(ctx, c.trumpCard, 0, 0, 64, 92); ctx.restore();
|
||||
if (c.stock.length > 1) {
|
||||
for (let i = Math.min(5, Math.ceil(c.stock.length / 7)); i >= 0; i--)
|
||||
drawCard(ctx, null, 38 + i, 116 - i, 58, 83, true);
|
||||
}
|
||||
}
|
||||
text(ctx, `Mazzo: ${c.stock.length}`, 34, 243, 7);
|
||||
text(ctx, 'Briscola:', 34, 263, 7); // a 6 punti la B sembra una D
|
||||
text(ctx, c.trump.toUpperCase(), 34, 278, 7, '#ffe09b');
|
||||
for (const [i, who] of ['cpu', 'player'].entries()) {
|
||||
const y = 74 + i * 183;
|
||||
if (c.taken[who].length) for (let n = 0; n < Math.min(4, c.taken[who].length / 2); n++) drawCard(ctx, null, 526 + n * 2, y - n * 2, 42, 60, true);
|
||||
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
|
||||
}
|
||||
c.table.forEach((p, i) => {
|
||||
// La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso.
|
||||
const spot = [242 + i * 86, 130, 64, 92];
|
||||
const hand = p.who === 'player' ? [HAND.x + (p.from ?? 1) * HAND.gap, HAND.y, HAND.w, HAND.h] : [229 + (p.from ?? 1) * 62, 49, 52, 74];
|
||||
let r = spot;
|
||||
if (i === c.table.length - 1 && c.step === 'play' || c.step === 'trick' && i === 1 && c.t < PLAY_TIME) r = flight(hand, spot, c.t / PLAY_TIME);
|
||||
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(spot, [526, c.last.winner === 'cpu' ? 74 : 257, 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
|
||||
drawCard(ctx, p.card, ...r);
|
||||
});
|
||||
c.hands.player.forEach((card, i) => {
|
||||
const x = HAND.x + i * HAND.gap;
|
||||
if (canPlay(c) && c.selected === i) rect(ctx, x - 3, HAND.y - 3, HAND.w + 6, HAND.h + 6, '#ffdb76');
|
||||
drawCard(ctx, card, x, HAND.y, HAND.w, HAND.h);
|
||||
text(ctx, String(i + 1), x + HAND.w / 2, 221, 7, '#fff0cd', 'center');
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Briscola in due: regole e avversari senza DOM. Le carte sono immutabili, lo stato è serializzabile.
|
||||
// Due mazzi, stesse regole, stessi id: cambiano solo nomi e disegni (scelti nel dialogo, idea di Andrea).
|
||||
// Toscane: semi francesi (i quadri in Toscana sono «mattoni»), figure gobbo, donna e regio.
|
||||
export const DECKS = {
|
||||
toscane: { suits: ['cuori', 'mattoni', 'fiori', 'picche'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Gobbo', 'Donna', 'Regio'] },
|
||||
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
|
||||
};
|
||||
export const LEVELS = ['bona', 'seria', 'male'];
|
||||
// I tempi delle animazioni (briscola-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
|
||||
// poi vola sul mucchietto di chi ha preso (Andrea: «non si capisce quando vengono buttate le carte»).
|
||||
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.85, TRICK_TIME = 1.4;
|
||||
const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
|
||||
const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
|
||||
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||
Object.freeze({ id: s * 10 + r, suit, rank, points: VALUES[r], strength: ORDER[r] }))));
|
||||
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||
export const CARDS = DECK_CARDS.toscane, SUITS = DECKS.toscane.suits;
|
||||
export const other = who => who === 'player' ? 'cpu' : 'player';
|
||||
export const cardName = card => `${card.rank} di ${card.suit}`;
|
||||
export const points = cards => cards.reduce((n, card) => n + card.points, 0);
|
||||
function rng(seed) { let n = seed >>> 0; return () => ((n = (Math.imul(n, 1664525) + 1013904223) >>> 0) / 4294967296); }
|
||||
function shuffle(cards, random) {
|
||||
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
|
||||
return cards;
|
||||
}
|
||||
// Il secondo vince solo con una briscola o con una carta più alta dello stesso seme.
|
||||
export function beats(second, first, trump) {
|
||||
return second.suit === first.suit ? second.strength > first.strength : second.suit === trump;
|
||||
}
|
||||
export function trickWinner(table, trump) {
|
||||
return beats(table[1].card, table[0].card, trump) ? table[1].who : table[0].who;
|
||||
}
|
||||
export function result(taken) {
|
||||
const player = points(taken.player), cpu = points(taken.cpu);
|
||||
return { player, cpu, winner: player === cpu ? null : player > cpu ? 'player' : 'cpu' };
|
||||
}
|
||||
export function newBriscola(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||
if (!DECKS[deck]) deck = 'toscane';
|
||||
const random = rng(seed), dealer = random() < 0.5 ? 'player' : 'cpu';
|
||||
const stock = shuffle([...DECK_CARDS[deck]], random), hands = { player: [], cpu: [] };
|
||||
// Distribuzione alternata, a partire da chi è di mano.
|
||||
for (let i = 0; i < 3; i++) for (const who of [other(dealer), dealer]) hands[who].push(stock.shift());
|
||||
const trumpCard = stock.shift(); stock.push(trumpCard);
|
||||
return { step: 'play', t: 0, age: 0, level: LEVELS.includes(level) ? level : 'seria', deck, dealer,
|
||||
hands, stock, trumpCard, trump: trumpCard.suit, current: other(dealer), table: [],
|
||||
taken: { player: [], cpu: [] }, history: [], selected: 0, last: null, drawnTrump: null,
|
||||
aiSeed: (seed ^ 0x9e3779b9) >>> 0, events: [{ kind: 'card-shuffle' }], count: 0 };
|
||||
}
|
||||
export const canPlay = c => c.step === 'play' && c.current === 'player';
|
||||
export function select(c, index) {
|
||||
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
|
||||
c.selected = index; return true;
|
||||
}
|
||||
export function nudge(c, direction) {
|
||||
return select(c, (c.selected + direction + c.hands.player.length) % c.hands.player.length);
|
||||
}
|
||||
export function play(c, index = c.selected, who = 'player') {
|
||||
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||
const [card] = c.hands[who].splice(index, 1);
|
||||
c.table.push({ who, card, from: index }); c.t = 0; // `from`: da dove parte la carta nel disegno
|
||||
c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||
c.events.push({ kind: 'card-slide' }); c.landing = true;
|
||||
if (c.table.length === 2) {
|
||||
c.step = 'trick'; c.last = { winner: trickWinner(c.table, c.trump), cards: c.table.map(p => p.card) };
|
||||
} else c.current = other(who);
|
||||
return true;
|
||||
}
|
||||
export function collect(c) {
|
||||
if (c.step !== 'trick') return false;
|
||||
const winner = c.last.winner;
|
||||
c.taken[winner].push(...c.last.cards);
|
||||
c.history.push({ winner, cards: [...c.last.cards] }); c.table = [];
|
||||
// Chi prende pesca per primo. La briscola scoperta è davvero l'ultima carta.
|
||||
for (const who of [winner, other(winner)]) if (c.stock.length) {
|
||||
const card = c.stock.shift(); c.hands[who].push(card);
|
||||
if (card.id === c.trumpCard.id) c.drawnTrump = who;
|
||||
}
|
||||
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||
c.step = c.hands.player.length ? 'play' : 'count';
|
||||
// Il risultato è deciso all'ultima presa: il conteggio carta per carta lo mostra soltanto.
|
||||
if (c.step === 'count') c.result = result(c.taken);
|
||||
return true;
|
||||
}
|
||||
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
|
||||
// Mai il mazzo, la mano avversaria o il seed della distribuzione.
|
||||
export function observation(c, who = 'cpu') {
|
||||
return { who, deck: c.deck, hand: [...c.hands[who]], opponentCount: c.hands[other(who)].length,
|
||||
table: c.table.map(p => ({ ...p })), played: c.history.flatMap(t => t.cards),
|
||||
ownPoints: points(c.taken[who]), otherPoints: points(c.taken[other(who)]),
|
||||
remaining: c.stock.length, trump: c.trump, trumpCard: c.trumpCard,
|
||||
knownOpponent: c.drawnTrump === other(who) && !c.history.some(t => t.cards.some(a => a.id === c.trumpCard.id))
|
||||
&& !c.table.some(p => p.card.id === c.trumpCard.id) ? [c.trumpCard] : [] };
|
||||
}
|
||||
export function unseen(v) {
|
||||
const seen = new Set([...v.hand, ...v.played, ...v.table.map(p => p.card)].map(a => a.id));
|
||||
if (v.remaining) seen.add(v.trumpCard.id);
|
||||
return DECK_CARDS[v.deck ?? 'toscane'].filter(a => !seen.has(a.id));
|
||||
}
|
||||
// Giocatore attento di riferimento: scarta i lisci, conserva le briscole e prende i carichi.
|
||||
export function attentive(v) {
|
||||
const lead = v.table[0]?.card;
|
||||
const cost = a => a.points * 2 + (a.suit === v.trump ? 10 + a.strength : a.strength * 0.15);
|
||||
const sorted = v.hand.map((a, i) => ({ a, i })).sort((a, b) => cost(a.a) - cost(b.a));
|
||||
if (lead) {
|
||||
const wins = sorted.filter(({ a }) => beats(a, lead, v.trump));
|
||||
const take = wins.find(({ a }) => a.suit !== v.trump || lead.points + a.points >= 8 || !v.remaining);
|
||||
if (take) return take.i;
|
||||
}
|
||||
return sorted[0].i;
|
||||
}
|
||||
// Finale a informazione completa: minimax esatto, nessuna euristica né campionamento.
|
||||
// Massimizza i punti finali (quindi anche vittoria/patta), con memoizzazione per i sottogiochi.
|
||||
export function perfect(hand, enemy, table, trump, who, memo = new Map()) {
|
||||
const solve = (own, opp, lead, turn) => {
|
||||
if (!own.length && !opp.length && !lead.length) return 0;
|
||||
const key = `${own.map(a => a.id)}|${opp.map(a => a.id)}|${lead.map(p => p.who + p.card.id)}|${turn}`;
|
||||
if (memo.has(key)) return memo.get(key);
|
||||
const mine = turn === who, choices = mine ? own : opp;
|
||||
let best = mine ? -Infinity : Infinity;
|
||||
choices.forEach((card, i) => {
|
||||
const next = choices.filter((_, j) => i !== j), a = mine ? next : own, b = mine ? opp : next;
|
||||
const pair = [...lead, { who: turn, card }];
|
||||
let value;
|
||||
if (pair.length === 2) {
|
||||
const winner = trickWinner(pair, trump);
|
||||
value = (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(a, b, [], winner);
|
||||
} else value = solve(a, b, pair, other(turn));
|
||||
best = mine ? Math.max(best, value) : Math.min(best, value);
|
||||
});
|
||||
memo.set(key, best); return best;
|
||||
};
|
||||
return hand.map((card, i) => {
|
||||
const own = hand.filter((_, j) => i !== j), pair = [...table, { who, card }];
|
||||
if (pair.length === 1) return solve(own, enemy, pair, other(who));
|
||||
const winner = trickWinner(pair, trump);
|
||||
return (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(own, enemy, [], winner);
|
||||
});
|
||||
}
|
||||
const bestIndex = scores => scores.indexOf(Math.max(...scores));
|
||||
|
||||
// «A fassi male»: mondi possibili compatibili con le carte uscite, mai la distribuzione vera.
|
||||
// Stesse distribuzioni campionate per tutte le mosse, entrambi giocano attenti nei seguiti.
|
||||
// Taratura: 1000 partite/livello, seed imul(1..1000, 2654435761), tools/calibrate_briscola.mjs.
|
||||
// Vittorie di Andrea attento: bona 84,7%, seria 48,1%, male 35,9%; patte 0,9%, 2,5%, 2,0%.
|
||||
// Mazziere Andrea in 496/1000 partite. È una misura contro questa euristica, non contro persone.
|
||||
export function chooseCard(v, level = 'seria', seed = 1) {
|
||||
const random = rng(seed);
|
||||
if (level === 'bona') return Math.floor(random() * v.hand.length);
|
||||
if (level !== 'male') return attentive(v);
|
||||
const unknown = unseen(v);
|
||||
if (!v.remaining) return bestIndex(perfect(v.hand, unknown, v.table, v.trump, v.who));
|
||||
const totals = v.hand.map(() => 0), known = new Set(v.knownOpponent.map(a => a.id));
|
||||
for (let sample = 0; sample < 40; sample++) {
|
||||
const pool = shuffle(unknown.filter(a => !known.has(a.id)), random);
|
||||
const enemy = [...v.knownOpponent, ...pool.splice(0, v.opponentCount - known.size)];
|
||||
const stock = [...pool, v.trumpCard];
|
||||
for (let move = 0; move < v.hand.length; move++) {
|
||||
const hands = { [v.who]: [...v.hand], [other(v.who)]: [...enemy] };
|
||||
let table = [...v.table], turn = v.who, deck = [...stock], delta = 0, first = true;
|
||||
while (hands.player.length || hands.cpu.length || table.length) {
|
||||
const idx = first ? move : attentive({ hand: hands[turn], table, trump: v.trump, remaining: deck.length });
|
||||
first = false;
|
||||
const [card] = hands[turn].splice(idx, 1); table.push({ who: turn, card });
|
||||
if (table.length === 1) { turn = other(turn); continue; }
|
||||
turn = trickWinner(table, v.trump);
|
||||
delta += (turn === v.who ? 1 : -1) * points(table.map(p => p.card)); table = [];
|
||||
for (const who of [turn, other(turn)]) if (deck.length) hands[who].push(deck.shift());
|
||||
}
|
||||
totals[move] += delta;
|
||||
}
|
||||
}
|
||||
return bestIndex(totals);
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||
c.t += dt;
|
||||
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
|
||||
else if (c.step === 'count') {
|
||||
// Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
|
||||
c.count = Math.min(40, Math.floor(c.t / 0.35));
|
||||
if (c.count === 40) { c.result = result(c.taken); c.step = 'result'; }
|
||||
} else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.7) {
|
||||
c.aiSeed = (Math.imul(c.aiSeed, 1664525) + 1013904223) >>> 0;
|
||||
play(c, chooseCard(observation(c), c.level, c.aiSeed), 'cpu');
|
||||
}
|
||||
}
|
||||
export function countRows(c) {
|
||||
return ['player', 'cpu'].flatMap(who => c.taken[who].map(card => ({ who, card })));
|
||||
}
|
||||
export function counted(c) {
|
||||
const totals = { player: 0, cpu: 0 };
|
||||
for (const p of countRows(c).slice(0, c.count)) totals[p.who] += p.card.points;
|
||||
return totals;
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
||||
if (c.step === 'result') return `${c.result.winner ? 'Vince ' + names[c.result.winner] : 'Patta'}! ${c.result.player} a ${c.result.cpu}.`;
|
||||
if (c.step === 'count') {
|
||||
const last = countRows(c)[c.count - 1];
|
||||
return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
|
||||
}
|
||||
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||
// A parole anche quel che si vede sul tavolo: la briscola e la carta già giocata.
|
||||
const table = `Briscola: ${c.trump}.${c.table.length ? ` In tavola: ${cardName(c.table[0].card)}.` : ''}`;
|
||||
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} sceglie…`;
|
||||
}
|
||||
@@ -3,11 +3,17 @@
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
import * as bocce from './bocce.js';
|
||||
import * as biliardo from './biliardo.js';
|
||||
import * as briscola from './briscola.js';
|
||||
import * as tressette from './tressette.js';
|
||||
import * as scopa from './scopa.js';
|
||||
|
||||
// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi.
|
||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||
export const MINIGAMES = {
|
||||
bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
|
||||
tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
|
||||
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
};
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
@@ -245,6 +251,20 @@ export class Game {
|
||||
}
|
||||
|
||||
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
|
||||
outcome(def, c) { return def[c.result.winner === 'player' ? 'win' : c.result.winner === 'cpu' ? 'lose' : 'draw'] ?? []; }
|
||||
// Appena il risultato di un minigioco è deciso, i suoi flag (vittoria, partita giocata) valgono e si salvano:
|
||||
// anche tornando al titolo dalla schermata del risultato, senza passare dall'uscita. Una volta sola.
|
||||
settle(c) {
|
||||
const def = this.story.closeups?.[c.id];
|
||||
if (!c.result || c.settled || !def) return false;
|
||||
c.settled = true;
|
||||
for (const e of this.outcome(def, c)) {
|
||||
if (e.set) { this.state.flags[e.set] = true; if (this.checkpoint) this.checkpoint.flags[e.set] = true; }
|
||||
if (e.unset) { delete this.state.flags[e.unset]; if (this.checkpoint) delete this.checkpoint.flags[e.unset]; }
|
||||
}
|
||||
if (this.checkpoint) this.onSave(this.checkpoint);
|
||||
return true;
|
||||
}
|
||||
closeupClose() {
|
||||
const c = this.closeup;
|
||||
if (!c) return false;
|
||||
@@ -255,9 +275,11 @@ export class Game {
|
||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||
return true;
|
||||
}
|
||||
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
|
||||
// di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (MINIGAMES[def?.type]) {
|
||||
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
|
||||
this.settle(c);
|
||||
this.queue.unshift(...(c.result ? this.outcome(def, c) : def.quit ?? []));
|
||||
return true;
|
||||
}
|
||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||
@@ -267,16 +289,17 @@ export class Game {
|
||||
|
||||
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
|
||||
closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
|
||||
closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); }
|
||||
closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); }
|
||||
closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
|
||||
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
|
||||
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
|
||||
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
|
||||
|
||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
|
||||
closeupAim([x, y]) {
|
||||
const c = this.closeup;
|
||||
const m = this.minigame();
|
||||
if (m) return m.aim(c, [x, y]);
|
||||
if (m) return !!m.aim?.(c, [x, y]);
|
||||
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
|
||||
return true;
|
||||
@@ -402,6 +425,7 @@ export class Game {
|
||||
const m = this.minigame();
|
||||
if (m) {
|
||||
m.update(c, dt);
|
||||
this.settle(c);
|
||||
// Il risultato resta leggibile finché il giocatore non esce.
|
||||
return;
|
||||
}
|
||||
@@ -439,7 +463,10 @@ export class Game {
|
||||
else if (e.closeup) {
|
||||
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] }
|
||||
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) }
|
||||
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
|
||||
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
|
||||
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
|
||||
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) }
|
||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
}
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
|
||||
@@ -8,11 +8,13 @@ import { versioned } from '../version.js';
|
||||
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
|
||||
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
|
||||
// altrimenti quello generale del paese.
|
||||
export function trackFor(story, { screen, scene, phase }) {
|
||||
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
|
||||
export function trackFor(story, { screen, scene, phase, closeup }) {
|
||||
const m = story.music ?? {};
|
||||
if (screen === 'splash') return null;
|
||||
if (screen === 'title' || screen === 'intro') return m.title ?? null;
|
||||
if (screen === 'ending') return m.ending ?? m.game ?? null;
|
||||
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
|
||||
const sc = story.scenes[scene];
|
||||
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { drawBocce } from './bocce-render.js';
|
||||
import { drawBiliardo } from './biliardo-render.js';
|
||||
import { drawBriscola } from './briscola-render.js';
|
||||
import { drawTressette } from './tressette-render.js';
|
||||
import { drawScopa } from './scopa-render.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -666,6 +669,9 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
|
||||
function drawCloseup(ctx, game, time) {
|
||||
const c = game.closeup, def = game.story.closeups?.[c.id];
|
||||
if (!def) return;
|
||||
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
|
||||
if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
|
||||
if (def.type === 'scopa') { drawScopa(ctx, c, def); return; }
|
||||
if (def.type === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
@@ -676,7 +682,11 @@ function drawCloseup(ctx, game, time) {
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
return;
|
||||
}
|
||||
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; }
|
||||
if (def.type === 'biliardo') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { canPlay, TARGET, PLAY_TIME, TAKE_HOLD, TAKE_TIME } from './scopa.js';
|
||||
import { drawCard, drawTable, flight } from './briscola-render.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola.
|
||||
export const HAND = { y: 262, w: 64, h: 92, gap: 72 };
|
||||
export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap);
|
||||
const TABLE = { w: 42, h: 60, gap: 47, row: 8 };
|
||||
export function tableXY(n, i) {
|
||||
const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row);
|
||||
return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132];
|
||||
}
|
||||
export function cardAt(c, [x, y]) {
|
||||
if (!canPlay(c)) return -1;
|
||||
return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h);
|
||||
}
|
||||
export function tableAt(c, [x, y]) {
|
||||
return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; });
|
||||
}
|
||||
const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3';
|
||||
const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); };
|
||||
|
||||
function board(ctx, c, names) {
|
||||
// Un giocatore per riga: i nomi lunghi non si accavallano.
|
||||
panel(ctx, 26, 34, 150, 50);
|
||||
for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) {
|
||||
text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right');
|
||||
}
|
||||
text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center');
|
||||
// Il mazzo a sinistra, le prese a destra con le scope.
|
||||
if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true);
|
||||
text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center');
|
||||
for (const [who, y] of [['cpu', 92], ['player', 196]]) {
|
||||
if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true);
|
||||
if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center');
|
||||
}
|
||||
}
|
||||
const PILE = { cpu: 92, player: 196 };
|
||||
function table(ctx, c) {
|
||||
// Durante una presa la tavola resta com'era (le carte prese al loro posto, incorniciate) finché non volano via.
|
||||
const take = c.step === 'take' && c.last.captured.length, shown = take ? c.last.before : c.table;
|
||||
const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id));
|
||||
const fly = take && c.t > TAKE_HOLD ? (c.t - TAKE_HOLD) / (TAKE_TIME - TAKE_HOLD - 0.05) : 0;
|
||||
const pile = take ? [560, PILE[c.last.who], 42, 60] : null;
|
||||
// Da dove parte la carta giocata: la mano di Andrea o quella coperta del giocatore.
|
||||
const hand = c.step === 'take' && (c.last.who === 'player'
|
||||
? [handX(c.hands.player.length + 1, c.last.from), HAND.y, HAND.w, HAND.h]
|
||||
: [320 - ((c.hands.cpu.length + 1) * 48 - 6) / 2 + c.last.from * 48, 38, 42, 60]);
|
||||
const arrive = Math.min(1, c.t / PLAY_TIME);
|
||||
shown.forEach((card, i) => {
|
||||
const [x, y] = tableXY(shown.length, i);
|
||||
let r = [x, y, TABLE.w, TABLE.h];
|
||||
if (c.step === 'take' && !take && card.id === c.last.card.id) r = flight(hand, r, arrive);
|
||||
if (fly && marked.has(card.id)) r = flight(r, pile, fly);
|
||||
else if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h);
|
||||
drawCard(ctx, card, ...r);
|
||||
});
|
||||
if (take) {
|
||||
let r = flight(hand, [299, 200, TABLE.w, TABLE.h], arrive);
|
||||
if (fly) r = flight(r, pile, fly);
|
||||
drawCard(ctx, c.last.card, ...r);
|
||||
if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); }
|
||||
}
|
||||
}
|
||||
function hands(ctx, c) {
|
||||
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true));
|
||||
const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose';
|
||||
c.hands.player.forEach((card, i) => {
|
||||
const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i;
|
||||
const up = chosen ? 8 : 0;
|
||||
if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h);
|
||||
drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h);
|
||||
});
|
||||
}
|
||||
function summary(ctx, c, names, title) {
|
||||
const s = c.summary;
|
||||
panel(ctx, 140, 80, 360, 200);
|
||||
text(ctx, title, 320, 96, 8, gold, 'center');
|
||||
text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center');
|
||||
s.rows.forEach((r, i) => {
|
||||
const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v);
|
||||
text(ctx, r.label, 160, y, 7, paper);
|
||||
text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center');
|
||||
text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center');
|
||||
});
|
||||
text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center');
|
||||
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center');
|
||||
}
|
||||
export function drawScopa(ctx, c, def) {
|
||||
const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' };
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
drawTable(ctx, 'SCOPA ALL’ORATORIO');
|
||||
board(ctx, c, names); table(ctx, c); hands(ctx, c);
|
||||
if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`);
|
||||
if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello,
|
||||
// primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari.
|
||||
import { DECKS, cardName } from './briscola.js';
|
||||
|
||||
export { cardName };
|
||||
export const LEVELS = ['bona', 'seria', 'male'];
|
||||
export const TARGET = 11;
|
||||
// I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi
|
||||
// volano sul mucchietto di chi ha preso.
|
||||
export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35;
|
||||
export const COIN = { toscane: 'mattoni', napoletane: 'denari' };
|
||||
// Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14,
|
||||
// 3 13, 2 12, le figure 10.
|
||||
const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10];
|
||||
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||
Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] }))));
|
||||
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||
export const other = who => who === 'player' ? 'cpu' : 'player';
|
||||
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
|
||||
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
|
||||
function shuffle(cards, random) {
|
||||
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
|
||||
return cards;
|
||||
}
|
||||
|
||||
// Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a
|
||||
// scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria.
|
||||
export function captures(table, card) {
|
||||
const same = table.filter(a => a.value === card.value);
|
||||
if (same.length) return same.map(a => [a]);
|
||||
const out = [];
|
||||
for (let mask = 1; mask < 1 << table.length; mask++) {
|
||||
const pick = table.filter((_, i) => mask & 1 << i);
|
||||
if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7;
|
||||
// Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa.
|
||||
export function primiera(cards, deck) {
|
||||
const suits = DECKS[deck].suits;
|
||||
if (!suits.every(s => cards.some(a => a.suit === s))) return 0;
|
||||
return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0);
|
||||
}
|
||||
// I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa.
|
||||
export function tally(taken, scope, deck) {
|
||||
const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null;
|
||||
const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck);
|
||||
rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) });
|
||||
rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) });
|
||||
const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu';
|
||||
rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven });
|
||||
rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) });
|
||||
const points = { player: 0, cpu: 0 };
|
||||
for (const r of rows) if (r.winner) points[r.winner]++;
|
||||
rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null });
|
||||
points.player += scope.player; points.cpu += scope.cpu;
|
||||
return { rows, points };
|
||||
}
|
||||
|
||||
function deal(c) {
|
||||
// Con tre o quattro re in tavola si rimescola.
|
||||
let stock;
|
||||
do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); }
|
||||
while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3);
|
||||
c.table = stock.splice(0, 4); c.stock = stock;
|
||||
c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 };
|
||||
c.current = other(c.dealer); c.lastTaker = null; c.round++;
|
||||
refill(c); c.events.push({ kind: 'card-shuffle' });
|
||||
}
|
||||
// Tre carte a testa, prima a chi gioca per primo.
|
||||
function refill(c) {
|
||||
for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift());
|
||||
c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null;
|
||||
}
|
||||
export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 },
|
||||
round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null };
|
||||
c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu';
|
||||
deal(c); return c;
|
||||
}
|
||||
export const canPlay = c => c.step === 'play' && c.current === 'player';
|
||||
export function select(c, index) {
|
||||
if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; }
|
||||
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
|
||||
c.selected = index; return true;
|
||||
}
|
||||
export function nudge(c, direction) {
|
||||
if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); }
|
||||
const n = c.hands.player.length;
|
||||
return select(c, (c.selected + direction + n) % n);
|
||||
}
|
||||
// Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»).
|
||||
export function play(c, index = c.selected, who = 'player', option = null) {
|
||||
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||
const options = captures(c.table, c.hands[who][index]);
|
||||
if (options.length > 1 && option === null) {
|
||||
if (who !== 'player') return false;
|
||||
c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true;
|
||||
}
|
||||
return execute(c, who, index, options.length ? options[option ?? 0] : null);
|
||||
}
|
||||
export function take(c, k = c.choice?.selected) {
|
||||
if (c.step !== 'choose' || !c.choice.options[k]) return false;
|
||||
const { index, options } = c.choice; c.step = 'play'; c.choice = null;
|
||||
return execute(c, 'player', index, options[k]);
|
||||
}
|
||||
export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; }
|
||||
function execute(c, who, index, captured) {
|
||||
if (!captured && captures(c.table, c.hands[who][index]).length) return false;
|
||||
const before = [...c.table], [card] = c.hands[who].splice(index, 1);
|
||||
if (captured) {
|
||||
const ids = new Set(captured.map(a => a.id));
|
||||
c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who;
|
||||
} else c.table.push(card);
|
||||
// L'ultima carta della smazzata non fa scopa.
|
||||
const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length;
|
||||
const scopa = !!captured && !c.table.length && !lastPlay;
|
||||
if (scopa) c.scope[who]++;
|
||||
// `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa.
|
||||
c.last = { who, card, captured: captured ?? [], scopa, from: index, before };
|
||||
c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : []));
|
||||
c.step = 'take'; c.t = 0;
|
||||
if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||
return true;
|
||||
}
|
||||
function after(c) {
|
||||
c.current = other(c.last.who); c.t = 0; c.step = 'play';
|
||||
if (c.hands.player.length || c.hands.cpu.length) return;
|
||||
if (c.stock.length) { refill(c); return; }
|
||||
// Fine smazzata: quel che resta in tavola va all'ultimo che ha preso.
|
||||
if (c.lastTaker) c.taken[c.lastTaker].push(...c.table);
|
||||
c.table = [];
|
||||
const t = tally(c.taken, c.scope, c.deck);
|
||||
c.score.player += t.points.player; c.score.cpu += t.points.cpu;
|
||||
c.summary = { ...t, score: { ...c.score } }; c.t = 0;
|
||||
// Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria.
|
||||
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; }
|
||||
else c.step = 'smazzata';
|
||||
}
|
||||
// Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra.
|
||||
export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu;
|
||||
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||
export function advance(c) {
|
||||
if (c.step !== 'smazzata') return false;
|
||||
c.dealer = other(c.dealer); deal(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
// La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte).
|
||||
export function observation(c, who = 'cpu') {
|
||||
return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] },
|
||||
opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope }, lastTaker: c.lastTaker };
|
||||
}
|
||||
// Le carte che non si sono viste: in mano all'altro o nel mazzo.
|
||||
export function unseen(v) {
|
||||
const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id));
|
||||
return DECK_CARDS[v.deck].filter(a => !seen.has(a.id));
|
||||
}
|
||||
// Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare
|
||||
// una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore
|
||||
// possono essere ancora in mano all'altro.
|
||||
export function score(v, card, captured, counting) {
|
||||
const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount;
|
||||
const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card];
|
||||
// La primiera vale per quanto migliora la propria: un 7 di un seme dove si ha già un 7 non aggiunge niente,
|
||||
// la prima carta di un seme che manca aggiunge parecchio.
|
||||
const mine = v.taken[v.who] ?? [], best = suit => Math.max(0, ...mine.filter(a => a.suit === suit).map(a => a.prime));
|
||||
const prime = a => Math.max(0, a.prime - best(a.suit)) / 4 + (best(a.suit) ? 0 : 1);
|
||||
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + prime(a);
|
||||
let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6;
|
||||
const sum = left.reduce((n, a) => n + a.value, 0);
|
||||
if (left.length && sum <= 10 && v.opponentCount) {
|
||||
let p = 0.5;
|
||||
if (counting) {
|
||||
const pool = unseen(v), same = pool.filter(a => a.value === sum).length;
|
||||
p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0;
|
||||
}
|
||||
value -= p * 20;
|
||||
}
|
||||
// Lasciare in tavola un 7 o il settebello vuol dire regalarli.
|
||||
value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0);
|
||||
return value;
|
||||
}
|
||||
export function moves(v) {
|
||||
return v.hand.flatMap((card, index) => {
|
||||
const opts = captures(v.table, card);
|
||||
return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }];
|
||||
});
|
||||
}
|
||||
// «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più.
|
||||
// Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 96% contro «Alla bona», 76% contro «Seria», 50% contro «A fassi male».
|
||||
// A mazzo finito le carte dell'altro sono proprio quelle non viste: si calcola il finale esatto, fino al conteggio
|
||||
// (carte, mattoni, settebello, primiera, scope, e la tavola all'ultimo che prende).
|
||||
export function endgame(v) {
|
||||
const me = v.who, them = other(me), memo = new Map();
|
||||
const final = (taken, scope, table, last) => {
|
||||
const all = { player: [...taken.player], cpu: [...taken.cpu] };
|
||||
if (last) all[last].push(...table);
|
||||
const t = tally(all, scope, v.deck);
|
||||
return t.points[me] - t.points[them];
|
||||
};
|
||||
const solve = (hands, table, taken, scope, last, turn) => {
|
||||
if (!hands.player.length && !hands.cpu.length) return final(taken, scope, table, last);
|
||||
const key = `${turn}|${hands[me].map(a => a.id)}|${hands[them].map(a => a.id)}|${table.map(a => a.id).sort()}|${last}|${taken.player.length}|${scope.player}${scope.cpu}`;
|
||||
if (memo.has(key)) return memo.get(key);
|
||||
let best = turn === me ? -Infinity : Infinity;
|
||||
for (const m of moves({ hand: hands[turn], table })) {
|
||||
const r = step(hands, table, taken, scope, last, turn, m);
|
||||
const value = solve(...r);
|
||||
best = turn === me ? Math.max(best, value) : Math.min(best, value);
|
||||
}
|
||||
memo.set(key, best); return best;
|
||||
};
|
||||
const step = (hands, table, taken, scope, last, turn, m) => {
|
||||
const card = hands[turn][m.index], rest = { ...hands, [turn]: hands[turn].filter((_, i) => i !== m.index) };
|
||||
if (!m.captured) return [rest, [...table, card], taken, scope, last, other(turn)];
|
||||
const ids = new Set(m.captured.map(a => a.id)), left = table.filter(a => !ids.has(a.id));
|
||||
const end = !rest.player.length && !rest.cpu.length;
|
||||
return [rest, left, { ...taken, [turn]: [...taken[turn], card, ...m.captured] },
|
||||
!left.length && !end ? { ...scope, [turn]: scope[turn] + 1 } : scope, turn, other(turn)];
|
||||
};
|
||||
const hands = { [me]: v.hand, [them]: unseen(v) };
|
||||
const start = [hands, v.table, v.taken, v.scope ?? { player: 0, cpu: 0 }, v.lastTaker ?? null, me];
|
||||
return moves(v).map(m => ({ m, value: solve(...step(...start, m)) })).reduce((a, b) => b.value > a.value ? b : a).m;
|
||||
}
|
||||
export function choose(v, level = 'seria', seed = 1) {
|
||||
const all = moves(v);
|
||||
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
|
||||
const counting = level === 'male';
|
||||
if (counting && !v.stock && unseen(v).length === v.opponentCount) return endgame(v);
|
||||
return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a);
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||
c.t += dt;
|
||||
if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c);
|
||||
else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) {
|
||||
c.aiSeed = lcg(c.aiSeed);
|
||||
const m = choose(observation(c, 'cpu'), c.level, c.aiSeed);
|
||||
play(c, m.index, 'cpu', m.option);
|
||||
}
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
||||
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`;
|
||||
if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`;
|
||||
if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.';
|
||||
if (c.step === 'take') {
|
||||
const l = c.last;
|
||||
return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`;
|
||||
}
|
||||
// A parole anche la tavola, per chi non vede il disegno.
|
||||
const table = `In tavola: ${c.table.map(cardName).join(', ') || 'niente'}.`;
|
||||
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} pensa…`;
|
||||
}
|
||||
@@ -101,6 +101,16 @@ export class Soundscape {
|
||||
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
|
||||
if (!this.audible) return;
|
||||
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
||||
else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
|
||||
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
|
||||
// I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
|
||||
else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
|
||||
else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
|
||||
else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, pan); }
|
||||
else if (kind === 'card-shuffle') {
|
||||
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
|
||||
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
|
||||
}
|
||||
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
|
||||
else if (kind === 'bocce-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 });
|
||||
@@ -109,6 +119,11 @@ export class Soundscape {
|
||||
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
|
||||
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
|
||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
||||
else if (kind === 'tavolo-lontano') {
|
||||
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
|
||||
this.tone(1700, 900, 0.04, 0.025, 0, pan);
|
||||
this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan);
|
||||
}
|
||||
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
|
||||
else if (kind === 'bird') {
|
||||
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { canPlay, legal, accuseName, team, NAMES, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './tressette.js';
|
||||
import { drawCard, drawTable, flight } from './briscola-render.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
// La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco.
|
||||
export const CARD = { w: 42, h: 60, y: 288, lift: 8 };
|
||||
export function handX(n, i) {
|
||||
const gap = n > 1 ? Math.min(44, (440 - CARD.w) / (n - 1)) : 0;
|
||||
return Math.round(320 - (gap * (n - 1) + CARD.w) / 2 + i * gap);
|
||||
}
|
||||
// Le carte si coprono: vince quella sopra, cioè la più a destra.
|
||||
export function cardAt(c, [x, y]) {
|
||||
if (!canPlay(c)) return -1;
|
||||
const n = c.hands[0].length;
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const top = CARD.y - (c.selected === i ? CARD.lift : 0), left = handX(n, i);
|
||||
if (x >= left && x < left + CARD.w && y >= top && y < top + CARD.h + CARD.lift) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// Dove cade la carta di ognuno al centro, e da dove arriva.
|
||||
const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]];
|
||||
// I mucchietti delle prese: noi in basso a destra, loro in basso a sinistra.
|
||||
const PILE = [[560, 288], [34, 288]];
|
||||
const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']];
|
||||
const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3';
|
||||
const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' };
|
||||
|
||||
function others(ctx, c, names) {
|
||||
// Il socio di fronte, gli avversari ai lati: carte coperte, quante ne hanno.
|
||||
const n2 = c.hands[2].length, w2 = n2 ? (n2 - 1) * 12 + 26 : 0;
|
||||
for (let i = 0; i < n2; i++) drawCard(ctx, null, 320 - w2 / 2 + i * 12, 46, 26, 37, true);
|
||||
for (const [seat, x] of [[3, 30], [1, 584]]) {
|
||||
for (let i = 0; i < c.hands[seat].length; i++) drawCard(ctx, null, x, 102 + i * 12, 26, 37, true);
|
||||
}
|
||||
for (const seat of [1, 2, 3]) {
|
||||
// Chi deve giocare ha il nome in giallo.
|
||||
const [x, y, align] = LABEL[seat], turn = c.step === 'play' && c.current === seat;
|
||||
text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align);
|
||||
}
|
||||
}
|
||||
function piles(ctx, c) {
|
||||
// Quante prese ha fatto ogni coppia, a mucchietto (una carta coperta ogni due prese).
|
||||
for (const t of [0, 1]) {
|
||||
const n = c.tricks.filter(x => team(x.winner) === t).length, [x, y] = PILE[t];
|
||||
for (let k = 0; k < Math.ceil(n / 2); k++) drawCard(ctx, null, x + k, y - k, 42, 60, true);
|
||||
text(ctx, t ? 'Prese loro' : 'Prese noi', x + 21, y - 8, 5, t ? theirs : ours, 'center');
|
||||
}
|
||||
}
|
||||
function scoreboard(ctx, c) {
|
||||
panel(ctx, 26, 34, 118, 38);
|
||||
text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours);
|
||||
text(ctx, `Loro ${c.score[1]}`, 136, 46, 7, theirs, 'right');
|
||||
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
|
||||
}
|
||||
function accuses(ctx, c, names) {
|
||||
// Le accuse dette restano in vista per tutta la smazzata.
|
||||
if (!c.accuse.length) return;
|
||||
// Largo abbastanza per «Il giocatore: tre Assi, manca mattoni +3» (200 px nel carattere a 5).
|
||||
panel(ctx, 396, 34, 218, 12 + c.accuse.length * 11);
|
||||
c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 403, 41 + i * 11, 5, a.who % 2 ? theirs : ours));
|
||||
}
|
||||
function trick(ctx, c, names) {
|
||||
// Da dove parte la carta: la mano di Andrea, o le carte coperte degli altri.
|
||||
const from = p => p.who === 0 ? [handX(c.hands[0].length + 1, p.from ?? 0), CARD.y, CARD.w, CARD.h]
|
||||
: p.who === 2 ? [300, 46, 26, 37] : [p.who === 1 ? 584 : 30, 102 + (p.from ?? 0) * 12, 26, 37];
|
||||
c.table.forEach((p, i) => {
|
||||
const [x, y] = SPOT[p.who];
|
||||
let r = [x, y, CARD.w, CARD.h];
|
||||
if (i === c.table.length - 1 && c.t < PLAY_TIME && (c.step === 'play' || c.step === 'trick')) r = flight(from(p), r, c.t / PLAY_TIME);
|
||||
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(r, [...PILE[team(c.last.winner)], 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
|
||||
drawCard(ctx, p.card, ...r);
|
||||
if (p.signal) {
|
||||
const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30];
|
||||
panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center');
|
||||
}
|
||||
});
|
||||
if (c.step === 'trick') text(ctx, `Prende ${names[c.last.winner]}`, 320, 262, 6, gold, 'center');
|
||||
}
|
||||
function hand(ctx, c) {
|
||||
const cards = c.hands[0], n = cards.length, mine = canPlay(c), ok = mine ? legal(cards, c.table) : [];
|
||||
cards.forEach((card, i) => {
|
||||
const x = handX(n, i), up = mine && c.selected === i ? CARD.lift : 0;
|
||||
if (up) { ctx.fillStyle = '#ffdb76'; ctx.fillRect(x - 2, CARD.y - up - 2, CARD.w + 4, CARD.h + 4); }
|
||||
drawCard(ctx, card, x, CARD.y - up, CARD.w, CARD.h);
|
||||
// Le carte che non rispondono al seme si spengono.
|
||||
if (mine && !ok.includes(i)) { ctx.fillStyle = 'rgba(20,28,24,0.55)'; ctx.fillRect(x, CARD.y, CARD.w, CARD.h); }
|
||||
});
|
||||
if (mine && !c.table.length && c.signal) text(ctx, `Segno: ${SAY[c.signal]}`, 320, 278, 6, gold, 'center');
|
||||
}
|
||||
function summary(ctx, c, title) {
|
||||
const s = c.summary, row = (y, who, t, color) =>
|
||||
text(ctx, `${who}: ${s.thirds[t]} terzi = ${s.points[t]} punti${s.accused[t] ? `, accuse +${s.accused[t]}` : ''}${s.lastTeam === t ? ' (con l’ultima)' : ''}`, 172, y, 6, color);
|
||||
panel(ctx, 160, 96, 320, 132);
|
||||
text(ctx, title, 320, 112, 8, gold, 'center');
|
||||
row(140, 'Noi', 0, ours); row(158, 'Loro', 1, theirs);
|
||||
text(ctx, `Siamo ${c.score[0]} a ${c.score[1]}`, 320, 186, 8, paper, 'center');
|
||||
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 210, 5, paper, 'center');
|
||||
}
|
||||
export function drawTressette(ctx, c, def) {
|
||||
const names = def?.seats ?? NAMES;
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
drawTable(ctx, 'TRESSETTE AI 21');
|
||||
scoreboard(ctx, c); others(ctx, c, names); piles(ctx, c); accuses(ctx, c, names); hand(ctx, c); trick(ctx, c, names);
|
||||
if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
|
||||
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro');
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Tressette a coppie «ai 21», come si gioca in Mugello: quattro al tavolo, Andrea e il socio contro gli altri due.
|
||||
// Regole e avversari senza DOM, come la briscola. Segni di chi esce (busso, striscio, volo) e accuse a inizio smazzata.
|
||||
import { DECKS, cardName } from './briscola.js';
|
||||
|
||||
export { cardName };
|
||||
export const LEVELS = ['bona', 'seria', 'male'];
|
||||
export const SIGNALS = ['busso', 'striscio', 'volo'];
|
||||
export const TARGET = 21;
|
||||
// I tempi delle animazioni (tressette-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
|
||||
// poi vola sul mucchietto della coppia che ha preso.
|
||||
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.8, TRICK_TIME = 1.35;
|
||||
// Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra.
|
||||
export const SEATS = [0, 1, 2, 3];
|
||||
export const team = seat => seat % 2;
|
||||
export const next = seat => (seat + 1) % 4;
|
||||
export const partner = seat => (seat + 2) % 4;
|
||||
// Ordine di presa 3 > 2 > Asso > Re > Cavallo > Fante > 7 > 6 > 5 > 4 (indici delle carte: Asso, 2, …, 7, figure).
|
||||
const POWER = [7, 8, 9, 0, 1, 2, 3, 4, 5, 6];
|
||||
// Punti in terzi: l'Asso vale un punto, il 2, il 3 e le figure un terzo. 32 terzi più 3 dell'ultima presa: 11 punti.
|
||||
const THIRDS = [3, 1, 1, 0, 0, 0, 0, 1, 1, 1];
|
||||
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||
Object.freeze({ id: s * 10 + r, suit, rank, thirds: THIRDS[r], power: POWER[r] }))));
|
||||
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
|
||||
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
|
||||
function shuffle(cards, random) {
|
||||
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
|
||||
return cards;
|
||||
}
|
||||
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
|
||||
|
||||
// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
|
||||
// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre.
|
||||
export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) {
|
||||
const out = [], tops = ['Asso', '2', '3'];
|
||||
for (const suit of new Set(hand.map(a => a.suit))) {
|
||||
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
|
||||
if (cards.length === 3) out.push({ kind: 'napoletana', suit, points: 3, cards });
|
||||
}
|
||||
for (const rank of tops) {
|
||||
const cards = hand.filter(a => a.rank === rank);
|
||||
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}`
|
||||
: `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`;
|
||||
|
||||
// Si risponde al seme quando si può; chi esce gioca quel che vuole.
|
||||
export function legal(hand, table) {
|
||||
const led = table[0]?.card.suit, same = hand.flatMap((a, i) => a.suit === led ? [i] : []);
|
||||
return same.length ? same : hand.map((_, i) => i);
|
||||
}
|
||||
export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a);
|
||||
// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e
|
||||
// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi.
|
||||
export function signalOk(hand, index, table, signal) {
|
||||
if (!signal) return true;
|
||||
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
|
||||
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
|
||||
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true;
|
||||
}
|
||||
// Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene.
|
||||
export function reason(c, index, signal = c.signal) {
|
||||
const hand = c.hands[0], card = hand[index];
|
||||
if (!card) return 'Scegli una carta.';
|
||||
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
|
||||
if (!signal || signalOk(hand, index, c.table, signal)) return '';
|
||||
if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.';
|
||||
if (c.table.length) return 'I segni li fa solo chi esce.';
|
||||
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
|
||||
}
|
||||
|
||||
const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
|
||||
function deal(c) {
|
||||
c.seed = lcg(c.seed);
|
||||
const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
|
||||
c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
|
||||
// Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere.
|
||||
c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
|
||||
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
|
||||
// Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
|
||||
c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a })));
|
||||
c.accuse = [];
|
||||
c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
|
||||
c.events.push({ kind: 'card-shuffle' });
|
||||
}
|
||||
export function newTressette(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: [0, 0], round: 0,
|
||||
seed: seed >>> 0, aiSeed: (seed ^ 0x85ebca6b) >>> 0, age: 0, events: [], last: null, summary: null };
|
||||
c.dealer = Math.floor(rng(seed)() * 4);
|
||||
deal(c); return c;
|
||||
}
|
||||
export const canPlay = c => c.step === 'play' && c.current === 0;
|
||||
export function select(c, index) {
|
||||
if (!canPlay(c) || !Number.isInteger(index) || !c.hands[0][index]) return false;
|
||||
c.selected = index; c.refused = ''; return true;
|
||||
}
|
||||
export function nudge(c, direction) {
|
||||
const n = c.hands[0].length;
|
||||
return select(c, (c.selected + direction + n) % n);
|
||||
}
|
||||
// Il segno di Andrea vale per la prossima carta con cui esce; ripremuto, si toglie.
|
||||
export function setSignal(c, signal) {
|
||||
if (!canPlay(c) || c.table.length || (signal && !SIGNALS.includes(signal))) return false;
|
||||
c.signal = c.signal === signal ? null : signal; c.refused = ''; return true;
|
||||
}
|
||||
export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.signal : null) {
|
||||
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||
if (!legal(c.hands[who], c.table).includes(index) || !signalOk(c.hands[who], index, c.table, signal)) {
|
||||
if (who === 0) c.refused = reason(c, index, signal);
|
||||
return false;
|
||||
}
|
||||
if (!c.tricks.length && c.pending[who].length) {
|
||||
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
|
||||
c.events.push({ kind: 'accusa', who });
|
||||
// Un'accusa che porta ai 21 fa uscire subito: è il primo evento decisivo, prima ancora della carta.
|
||||
if (out(c)) return true;
|
||||
}
|
||||
const [card] = c.hands[who].splice(index, 1);
|
||||
c.table.push({ who, card, signal: signal || null, from: index }); c.t = 0; c.landing = true; // `from`: per il disegno
|
||||
// Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
|
||||
c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who });
|
||||
if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); }
|
||||
if (c.table.length === 4) { c.step = 'trick'; c.last = { winner: best(c.table).who, cards: c.table.map(p => p.card) }; }
|
||||
else c.current = next(who);
|
||||
return true;
|
||||
}
|
||||
// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi.
|
||||
const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0);
|
||||
function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); }
|
||||
export function collect(c) {
|
||||
if (c.step !== 'trick') return false;
|
||||
const winner = c.last.winner, end = !c.hands[0].length;
|
||||
// L'ultima presa vale un punto; i terzi avanzati non contano.
|
||||
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0);
|
||||
c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
|
||||
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||
recount(c);
|
||||
if (out(c, end ? team(winner) : null)) return true;
|
||||
if (end) { summarize(c, team(winner)); c.step = 'smazzata'; return true; }
|
||||
c.step = 'play';
|
||||
return true;
|
||||
}
|
||||
function summarize(c, lastTeam = null) {
|
||||
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
|
||||
lastTeam, score: [...c.score] };
|
||||
}
|
||||
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata. Il controllo si fa a ogni evento che
|
||||
// porta punti (un'accusa, una presa), e ognuno ne porta a una coppia sola: non ci si arriva mai insieme.
|
||||
function out(c, lastTeam = null) {
|
||||
const t = [0, 1].find(t => c.score[t] >= TARGET);
|
||||
if (t === undefined) return false;
|
||||
summarize(c, lastTeam); c.summary.out = t;
|
||||
c.step = 'result'; c.result = { winner: t === 0 ? 'player' : 'cpu', score: [...c.score] };
|
||||
return true;
|
||||
}
|
||||
export const finished = c => c.step === 'result';
|
||||
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||
export function advance(c) {
|
||||
if (c.step !== 'smazzata') return false;
|
||||
deal(c); return true;
|
||||
}
|
||||
|
||||
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
|
||||
export function observation(c, seat) {
|
||||
// Anche il punteggio e i terzi presi: sono sul tabellone, li vede chiunque.
|
||||
return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })), score: [...c.score], thirds: [...c.thirds],
|
||||
tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })),
|
||||
accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
|
||||
}
|
||||
// Quel che un giocatore sa. Chi conta davvero («A fassi male») ricorda le carte uscite, i semi finiti di ognuno e le
|
||||
// carte accusate dagli altri.
|
||||
export function knowledge(v, full) {
|
||||
// Chi non conta («Seria») vede solo le carte in tavola e le sue: un 3 già uscito per lui può essere ancora in giro.
|
||||
const played = [...(full ? v.tricks.flatMap(t => t.cards) : []), ...v.table];
|
||||
const gone = new Set([...v.hand, ...played.map(p => p.card)].map(a => a.id));
|
||||
const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
|
||||
const voids = SEATS.map(() => new Set()), holder = new Map();
|
||||
if (full) {
|
||||
// Chi non risponde al seme l'ha finito; chi vola anche.
|
||||
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => {
|
||||
if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit);
|
||||
if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit);
|
||||
});
|
||||
for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who);
|
||||
}
|
||||
const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat);
|
||||
// Qualcuno fra `seats` può ancora battere `card`?
|
||||
const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
|
||||
return { out, voids, could, beatable, full };
|
||||
}
|
||||
// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo.
|
||||
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0));
|
||||
const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
|
||||
function lead(v, k) {
|
||||
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
|
||||
// Il socio ha bussato e la presa l'ho fatta io: gli ritorno il seme con la più bassa.
|
||||
const prev = v.tricks.at(-1), knock = prev?.cards[0];
|
||||
if (k.full && prev?.winner === v.seat && knock?.who === partner(v.seat) && knock.signal === 'busso') {
|
||||
const same = hand.filter(a => a.suit === knock.card.suit);
|
||||
if (same.length) return { index: hand.indexOf(cheap(same)), signal: null };
|
||||
}
|
||||
// Una carta che gli avversari non possono più battere: si fa la presa, e coi punti dentro.
|
||||
const sure = hand.filter(a => !k.beatable(a, foes));
|
||||
if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
|
||||
// Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
|
||||
const count = suit => hand.filter(a => a.suit === suit).length;
|
||||
// A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri).
|
||||
const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit;
|
||||
const card = cheap(hand.filter(a => a.suit === longest));
|
||||
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
|
||||
}
|
||||
function signalFor(hand, card, k) {
|
||||
const rest = hand.filter(a => a !== card && a.suit === card.suit);
|
||||
if (!rest.length) return 'volo';
|
||||
// Busso: la mia più alta è la seconda buona del seme; il socio prenda con la prima e me lo ritorni.
|
||||
const top = rest.reduce((a, b) => b.power > a.power ? b : a);
|
||||
if (top.power > card.power && k.out.filter(a => a.suit === card.suit && a.power > top.power).length === 1) return 'busso';
|
||||
return rest.length >= 2 ? 'striscio' : null;
|
||||
}
|
||||
function follow(v, k, moves) {
|
||||
const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
|
||||
const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
|
||||
const pick = card => v.hand.indexOf(card);
|
||||
// Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme).
|
||||
const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full;
|
||||
if (knock && cards[0].suit === top.card.suit) {
|
||||
const over = cards.filter(a => a.power > top.card.power);
|
||||
if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a));
|
||||
}
|
||||
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). Se un
|
||||
// carico basta per arrivare ai 21, si dà quello, anche il 2 o il 3: si esce e si vince.
|
||||
if (mate && !k.beatable(top.card, after)) {
|
||||
const t = team(v.seat), last = v.hand.length === 1, before = v.score?.[t] ?? 0, thirds = v.thirds?.[t] ?? 0;
|
||||
const sum = v.table.reduce((n, p) => n + p.card.thirds, 0) + (last ? 3 : 0);
|
||||
const wins = cards.filter(a => before - Math.floor(thirds / 3) + Math.floor((thirds + sum + a.thirds) / 3) >= TARGET);
|
||||
return pick(wins.length ? byMin(wins, a => a.power) : rich(cards));
|
||||
}
|
||||
if (cards[0].suit === top.card.suit) {
|
||||
const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
|
||||
if (safe.length) return pick(byMin(safe, a => a.power));
|
||||
}
|
||||
return pick(cheap(cards));
|
||||
}
|
||||
// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
|
||||
// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
|
||||
// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
|
||||
// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md).
|
||||
export function choose(v, level = 'seria', seed = 1) {
|
||||
const moves = legal(v.hand, v.table);
|
||||
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
|
||||
const k = knowledge(v, level === 'male');
|
||||
return v.table.length ? { index: follow(v, k, moves), signal: null } : lead(v, k);
|
||||
}
|
||||
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||
c.t += dt;
|
||||
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
|
||||
else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
|
||||
c.aiSeed = lcg(c.aiSeed);
|
||||
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
|
||||
play(c, index, seat, signal);
|
||||
}
|
||||
}
|
||||
export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
|
||||
export function status(c, names = NAMES) {
|
||||
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
|
||||
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`;
|
||||
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||
// Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
|
||||
const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
|
||||
if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
|
||||
if (c.refused) return c.refused;
|
||||
if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
|
||||
return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
|
||||
}
|
||||