Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef236dc54 | ||
|
|
c82bef70b2 | ||
|
|
e9907e3e66 | ||
|
|
92e7a289be | ||
|
|
2362fc7741 | ||
|
|
2dd4257fd4 | ||
|
|
d51f5e56ea | ||
|
|
7b7e0031d1 | ||
|
|
94e9da4009 | ||
|
|
bd66ea45ad | ||
|
|
9eabd5c6e1 | ||
|
|
ddc877a2d1 | ||
|
|
14a1e6e5b8 | ||
|
|
49bdfb313a | ||
|
|
416b9d46ef | ||
|
|
7476c84b76 | ||
|
|
053728a086 | ||
|
|
394e8bdde3 | ||
|
|
ca56a9df17 | ||
|
|
b0b2fc0ed3 | ||
|
|
f56c9b5358 | ||
|
|
70f328c56d |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"image": "giocatore-carte.png",
|
||||
"height": 100,
|
||||
"frames": {
|
||||
"sit": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 100,
|
||||
"ax": 25.7,
|
||||
"ay": 100
|
||||
},
|
||||
"talk": {
|
||||
"x": 69,
|
||||
"y": 0,
|
||||
"w": 68,
|
||||
"h": 100,
|
||||
"ax": 27.6,
|
||||
"ay": 100
|
||||
},
|
||||
"laugh": {
|
||||
"x": 139,
|
||||
"y": 0,
|
||||
"w": 72,
|
||||
"h": 99,
|
||||
"ax": 19.3,
|
||||
"ay": 99
|
||||
},
|
||||
"shuffle": {
|
||||
"x": 213,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 97,
|
||||
"ax": 25.2,
|
||||
"ay": 97
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "ing-tozzi.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 120,
|
||||
"ax": 27.9,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 55,
|
||||
"y": 0,
|
||||
"w": 54,
|
||||
"h": 120,
|
||||
"ax": 27.8,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 111,
|
||||
"y": 0,
|
||||
"w": 77,
|
||||
"h": 119,
|
||||
"ax": 29.9,
|
||||
"ay": 119
|
||||
},
|
||||
"explain": {
|
||||
"x": 190,
|
||||
"y": 0,
|
||||
"w": 58,
|
||||
"h": 119,
|
||||
"ax": 30.8,
|
||||
"ay": 119
|
||||
},
|
||||
"laugh": {
|
||||
"x": 250,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 119,
|
||||
"ax": 28.2,
|
||||
"ay": 119
|
||||
},
|
||||
"chalk": {
|
||||
"x": 305,
|
||||
"y": 0,
|
||||
"w": 52,
|
||||
"h": 119,
|
||||
"ax": 24.1,
|
||||
"ay": 119
|
||||
},
|
||||
"shrug": {
|
||||
"x": 359,
|
||||
"y": 0,
|
||||
"w": 87,
|
||||
"h": 120,
|
||||
"ax": 43.9,
|
||||
"ay": 120
|
||||
},
|
||||
"cheer": {
|
||||
"x": 448,
|
||||
"y": 0,
|
||||
"w": 56,
|
||||
"h": 123,
|
||||
"ax": 30.9,
|
||||
"ay": 123
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+282
-10
@@ -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
|
||||
@@ -75,6 +95,14 @@
|
||||
"file": "andrea-a9df2e6bbb.mp3",
|
||||
"sec": 4.98
|
||||
},
|
||||
"andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": {
|
||||
"file": "andrea-f0aef83c80.mp3",
|
||||
"sec": 9.43
|
||||
},
|
||||
"andrea|Da fuori non si parla con nessuno: al circolo si entra.": {
|
||||
"file": "andrea-0e0e42a1cd.mp3",
|
||||
"sec": 3.69
|
||||
},
|
||||
"andrea|Il programma del Palio della Fortezza, con la foto del palio: la strada bianca che sale alla Fortezza, i ragazzi in fila, e uno con la maglia rossa che alza le braccia.": {
|
||||
"file": "andrea-6cc844498a.mp3",
|
||||
"sec": 10.59
|
||||
@@ -459,6 +487,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
|
||||
@@ -635,9 +667,65 @@
|
||||
"file": "andrea-d193b44288.mp3",
|
||||
"sec": 3.65
|
||||
},
|
||||
"andrea|Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
|
||||
"file": "andrea-78a5ef65f0.mp3",
|
||||
"sec": 7.84
|
||||
"andrea|La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.": {
|
||||
"file": "andrea-0268742faf.mp3",
|
||||
"sec": 5.45
|
||||
},
|
||||
"andrea|Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.": {
|
||||
"file": "andrea-015dc16c6d.mp3",
|
||||
"sec": 5.51
|
||||
},
|
||||
"andrea|L'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.": {
|
||||
"file": "andrea-2d04be65fb.mp3",
|
||||
"sec": 5.97
|
||||
},
|
||||
"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",
|
||||
"sec": 3.36
|
||||
},
|
||||
"andrea|Una biglia in tasca? Mi sbattono fuori dal circolo.": {
|
||||
"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
|
||||
},
|
||||
"andrea|La stecca me la dà l'Ingegnere, quando si gioca.": {
|
||||
"file": "andrea-e393f0bc2a.mp3",
|
||||
"sec": 2.83
|
||||
},
|
||||
"andrea|Un campione di biliardo in posa, con la stecca. Avrà il suo nome, ma da qui non si legge.": {
|
||||
"file": "andrea-4a03e51d2c.mp3",
|
||||
"sec": 7.65
|
||||
},
|
||||
"andrea|L'armadietto a vetri, con le stecche buone. Chiuso a chiave, naturalmente.": {
|
||||
"file": "andrea-b1ba7d3072.mp3",
|
||||
"sec": 5.06
|
||||
},
|
||||
"andrea|Chiuso. Le stecche buone sono per chi le sa usare.": {
|
||||
"file": "andrea-bd95e65b0a.mp3",
|
||||
"sec": 4.77
|
||||
},
|
||||
"andrea|Il tabernacolo del ponte, con l'Annunciazione di Antonio Berti, scultore sanpierino.": {
|
||||
"file": "andrea-9c0935dc77.mp3",
|
||||
"sec": 5.48
|
||||
},
|
||||
"andrea|Questa è una copia: l'originale lo fece nel 1947. Lo stesso Berti del parco dell'area feste.": {
|
||||
"file": "andrea-902c4db19c.mp3",
|
||||
"sec": 8.21
|
||||
},
|
||||
"andrea|Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
|
||||
"file": "andrea-6eeaab479b.mp3",
|
||||
"sec": 4.03
|
||||
},
|
||||
"andrea|Sotto il ponte la Carza si butta nella Sieve, e da qui vanno avanti insieme.": {
|
||||
"file": "andrea-f322731846.mp3",
|
||||
@@ -1435,6 +1523,70 @@
|
||||
"file": "andrea-60cdae2e35.mp3",
|
||||
"sec": 5.1
|
||||
},
|
||||
"andrea|Una briscola?": {
|
||||
"file": "andrea-d2f285f610.mp3",
|
||||
"sec": 1.04
|
||||
},
|
||||
"andrea|La rivincita?": {
|
||||
"file": "andrea-98e4366a56.mp3",
|
||||
"sec": 0.83
|
||||
},
|
||||
"andrea|Ora cerco il palio. A dopo.": {
|
||||
"file": "andrea-6e63dafda1.mp3",
|
||||
"sec": 2.61
|
||||
},
|
||||
"giocatore_carte|Gnamo. In due, senza soci e senza scuse.": {
|
||||
"file": "giocatore_carte-1523994a6c.mp3",
|
||||
"sec": 3.78
|
||||
},
|
||||
"giocatore_carte|Il tavolo è qui. Il coraggio ce lo metti te.": {
|
||||
"file": "giocatore_carte-8dfadb4acc.mp3",
|
||||
"sec": 3.4
|
||||
},
|
||||
"andrea|Con le toscane.": {
|
||||
"file": "andrea-e8599cc8b6.mp3",
|
||||
"sec": 1.03
|
||||
},
|
||||
"andrea|Con le napoletane.": {
|
||||
"file": "andrea-be276497e3.mp3",
|
||||
"sec": 1.09
|
||||
},
|
||||
"andrea|Ci ripenso.": {
|
||||
"file": "andrea-b851b9ed26.mp3",
|
||||
"sec": 0.8
|
||||
},
|
||||
"giocatore_carte|Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?": {
|
||||
"file": "giocatore_carte-89686f55a0.mp3",
|
||||
"sec": 6.14
|
||||
},
|
||||
"giocatore_carte|Le napoletane? Le tengo per i villeggianti. Come la vuoi?": {
|
||||
"file": "giocatore_carte-4f39bc172d.mp3",
|
||||
"sec": 5.13
|
||||
},
|
||||
"andrea|Alla bona.": {
|
||||
"file": "andrea-67afa54e05.mp3",
|
||||
"sec": 0.69
|
||||
},
|
||||
"andrea|Seria.": {
|
||||
"file": "andrea-f6ef96032e.mp3",
|
||||
"sec": 0.58
|
||||
},
|
||||
"andrea|A fassi male.": {
|
||||
"file": "andrea-f52137b691.mp3",
|
||||
"sec": 0.78
|
||||
},
|
||||
"giocatore_carte|Alla bona. I carichi li regalo, oggi.": {
|
||||
"file": "giocatore_carte-43e76cf11b.mp3",
|
||||
"sec": 3.43
|
||||
},
|
||||
"giocatore_carte|Allora posa il gomito e guarda le carte.": {
|
||||
"file": "giocatore_carte-f1f219bd1c.mp3",
|
||||
"sec": 2.51
|
||||
},
|
||||
"giocatore_carte|Io le conto tutte. Anche quelle che butti via per sbaglio.": {
|
||||
"file": "giocatore_carte-12f97003ec.mp3",
|
||||
"sec": 4.19
|
||||
},
|
||||
"andrea|Una Fanta.": {
|
||||
"file": "andrea-8927b87738.mp3",
|
||||
"sec": 0.65
|
||||
@@ -1675,6 +1827,10 @@
|
||||
"file": "andrea-06f5050907.mp3",
|
||||
"sec": 1.24
|
||||
},
|
||||
"andrea|S'ha andà a fallo!?!?": {
|
||||
"file": "andrea-b25aeefc97.mp3",
|
||||
"sec": 1.16
|
||||
},
|
||||
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
|
||||
"file": "andrea-fca6f59a70.mp3",
|
||||
"sec": 3.41
|
||||
@@ -1719,14 +1875,14 @@
|
||||
"file": "arcu-5148dffe36.mp3",
|
||||
"sec": 1.7
|
||||
},
|
||||
"arcu|Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!": {
|
||||
"file": "arcu-406cd02197.mp3",
|
||||
"sec": 4.46
|
||||
},
|
||||
"andrea|Mariano! Facciamo una partita a bocce?": {
|
||||
"file": "andrea-83b1337b4d.mp3",
|
||||
"sec": 3.31
|
||||
},
|
||||
"andrea|La rivincita?": {
|
||||
"file": "andrea-98e4366a56.mp3",
|
||||
"sec": 0.83
|
||||
},
|
||||
"andrea|Allora, Brento Sanico?": {
|
||||
"file": "andrea-2add39ff10.mp3",
|
||||
"sec": 1.29
|
||||
@@ -1743,14 +1899,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
|
||||
@@ -1759,6 +1963,14 @@
|
||||
"file": "andrea-01d1ccb933.mp3",
|
||||
"sec": 2.09
|
||||
},
|
||||
"andrea|Lo sai che quassù ci vengo sempre a piedi?": {
|
||||
"file": "andrea-fc4233b1e9.mp3",
|
||||
"sec": 2.82
|
||||
},
|
||||
"andrea|E te, da grande, cosa volevi fare?": {
|
||||
"file": "andrea-3aacdf79ee.mp3",
|
||||
"sec": 2.8
|
||||
},
|
||||
"andrea|Niente, volevo solo un po' di pace.": {
|
||||
"file": "andrea-131481b503.mp3",
|
||||
"sec": 2.79
|
||||
@@ -1823,10 +2035,54 @@
|
||||
"file": "castello-a1dfd76497.mp3",
|
||||
"sec": 6.51
|
||||
},
|
||||
"castello|Lo so. Ti sento arrivare dal fiatone, un chilometro prima.": {
|
||||
"file": "castello-05405f3232.mp3",
|
||||
"sec": 5.63
|
||||
},
|
||||
"andrea|Da casa sono quattro chilometri. E due sono di salita, di quella bella.": {
|
||||
"file": "andrea-57327d9598.mp3",
|
||||
"sec": 6.46
|
||||
},
|
||||
"castello|Eppure torni sempre. Qualcosa, quassù, l'hai trovato.": {
|
||||
"file": "castello-1545f0b17d.mp3",
|
||||
"sec": 6.56
|
||||
},
|
||||
"andrea|Ho trovato la mia strada!": {
|
||||
"file": "andrea-e9ef2a6d39.mp3",
|
||||
"sec": 1.59
|
||||
},
|
||||
"andrea|...Io, che ancora non so cosa fare da grande.": {
|
||||
"file": "andrea-60611ea4b9.mp3",
|
||||
"sec": 3.54
|
||||
},
|
||||
"castello|Il castello.": {
|
||||
"file": "castello-e34c381c3c.mp3",
|
||||
"sec": 0.98
|
||||
},
|
||||
"castello|Mi è venuto bene. Non avere fretta: a me ci son voluti seicento anni.": {
|
||||
"file": "castello-dcd5a7ae63.mp3",
|
||||
"sec": 6.24
|
||||
},
|
||||
"castello|Quella qui non manca mai. Torna quando vuoi.": {
|
||||
"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
|
||||
},
|
||||
"mariano|Aspetta, ci vuole il metro...": {
|
||||
"file": "mariano-e8b4ebf3d9.mp3",
|
||||
"sec": 1.62
|
||||
@@ -1847,6 +2103,22 @@
|
||||
"file": "mariano-39e27158b7.mp3",
|
||||
"sec": 2.83
|
||||
},
|
||||
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
|
||||
"file": "ing_tozzi-51f855478a.mp3",
|
||||
"sec": 4.42
|
||||
},
|
||||
"ing_tozzi|Venti! Al biliardo ci vuole la mano ferma, mica la corsa.": {
|
||||
"file": "ing_tozzi-d557d58aec.mp3",
|
||||
"sec": 4.66
|
||||
},
|
||||
"ing_tozzi|Quando vuoi, il tavolo è qui.": {
|
||||
"file": "ing_tozzi-3d686c8cc5.mp3",
|
||||
"sec": 2.05
|
||||
},
|
||||
"ing_tozzi|Lasci a metà? Ti segno la sconfitta a tavolino, eh.": {
|
||||
"file": "ing_tozzi-282062c119.mp3",
|
||||
"sec": 3.64
|
||||
},
|
||||
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
||||
"file": "andrea-6484641c58.mp3",
|
||||
"sec": 8.67
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,129 @@
|
||||
# Briscola all’oratorio
|
||||
|
||||
Implementazione del 26 ottobre 2026. ES modules statici, nessun build step.
|
||||
|
||||
## Partita e comandi
|
||||
|
||||
Il giocatore di carte è un personaggio inventato e provvisorio, seduto a destra alle tavolate
|
||||
dell’oratorio: **[DA ANDREA: chi è]**. Il dialogo offre «Alla bona», «Seria», «A fassi male»;
|
||||
dopo una partita conclusa offre la rivincita. `briscola_vinta` ricorda una vittoria e
|
||||
`briscola_giocata` abilita la rivincita anche dopo sconfitta o patta. Nessuno dei due flag
|
||||
serve alla soluzione dell’avventura.
|
||||
|
||||
Un clic o tocco su una carta la gioca al rilascio sullo stesso bersaglio. Nessuna carica.
|
||||
Le frecce e 1–2–3 scelgono, Invio gioca, Esc esce. I tre pulsanti con nome e seme
|
||||
offrono bersagli aggiuntivi e accesso tramite Tab. I comandi di tiro degli altri minigiochi
|
||||
rimangono separati; in `MINIGAMES` pressione, rilascio, annullamento e mira sono opzionali.
|
||||
|
||||
Mazzo da 40 carte, 120 punti, distribuzione alternata e mazziere deterministico dal seed.
|
||||
La briscola è in fondo al mazzo e compresa nel contatore. Chi prende pesca prima e riapre;
|
||||
non esiste obbligo di seme. La presa resta visibile 1,25 secondi. Solo dopo la ventesima
|
||||
presa si contano tutte le carte, una ogni 0,35 secondi, mostrando carta ingrandita,
|
||||
incremento e totale. A 60–60 il risultato è patta. Il risultato resta fino all’uscita.
|
||||
|
||||
## Avversari e taratura
|
||||
|
||||
`observation` espone soltanto la propria mano, le carte pubbliche, i conteggi e l’eventuale
|
||||
briscola pescata dall’altro. Non passa la mano avversaria, l’ordine del mazzo o il seed della
|
||||
distribuzione. «Alla bona» sceglie casualmente; «Seria» scarta lisci, conserva briscole e
|
||||
prende carichi con quelle piccole. «A fassi male» ricostruisce le carte non viste, confronta
|
||||
le mosse su 40 distribuzioni possibili e simula seguiti giocati con buon senso. Sono ipotesi,
|
||||
non la distribuzione effettiva. A mazzo finito il complemento delle carte pubbliche e della
|
||||
propria mano identifica l’altra mano: da quel momento usa minimax esatto fino alla fine.
|
||||
|
||||
Comando riproducibile: `node tools/calibrate_briscola.mjs 1000`.
|
||||
Semi `Math.imul(i, 2654435761) >>> 0`, i da 1 a 1000, stessi mazzi per ogni livello.
|
||||
Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 volte su 1000.
|
||||
|
||||
| Livello | Vittorie Andrea | Vittorie avversario | Patte | Punti medi Andrea |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Alla bona | 847 (84,7%) | 144 (14,4%) | 9 (0,9%) | 79,159 |
|
||||
| Seria | 481 (48,1%) | 494 (49,4%) | 25 (2,5%) | 59,952 |
|
||||
| A fassi male | 359 (35,9%) | 621 (62,1%) | 20 (2,0%) | 55,628 |
|
||||
|
||||
Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
|
||||
|
||||
## Grafica e audio
|
||||
|
||||
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
|
||||
nomi e disegni (`DECKS` e `DECK_CARDS` in `briscola.js`; la scelta è il flag `carte_napoletane`).
|
||||
|
||||
- **Toscane** (predefinite): semi francesi cuori, mattoni, fiori, picche; figure gobbo, donna e regio (il regio
|
||||
sempre con la corona). Numerali senza numeri, semi disposti come nel mazzo vero e capovolti nella metà bassa;
|
||||
figure intere col seme e la lettera (G, D, R) in alto a sinistra. Riferimento: `assets/raw/carte/toscane-riferimento.png`.
|
||||
- **Napoletane**: monete, coppe, lame ricurve incrociate, bastoni nodosi; fante, cavallo e re. Valore nell’angolo
|
||||
e seme scritto per non affidarsi solo al colore.
|
||||
|
||||
Tutto disegnato via canvas, in pixel art originale.
|
||||
I bersagli della mano sono 84×120 pixel a 640×360. Dorso con serpentello coronato e cornice
|
||||
simmetrica; tavolino di legno e panno, mano avversaria coperta, mazzo e mucchietti delle prese.
|
||||
|
||||
Sprite generato con lo **strumento immagini integrato**, poi preparato con
|
||||
`slice_sprites.py --alpha --fixed-grid --cols 2 --rows 2` e `prepare_character.py`.
|
||||
Pose: `sit`, `talk`, `laugh`, `shuffle`. Foglio originale in
|
||||
`assets/raw/giocatore-carte-sheet.png`; prompt completo in
|
||||
`prompts/personaggi/giocatore-carte-sheet.txt` (entrambi esclusi da git).
|
||||
|
||||
Effetti Web Audio `card-slide`, `card-tap`, `card-shuffle`, collegati allo stesso volume,
|
||||
muto e pausa degli altri effetti. Il colpo accompagna l’arrivo della carta dopo lo scorrimento.
|
||||
13 nuove battute XTTS (4 Andrea, 9 giocatore), con indice e durate aggiornati.
|
||||
Per generarle senza GPU/rete, `tools/voci_xtts.py` accetta `XTTS_DEVICE=cpu` e
|
||||
`XTTS_MODEL_DIR` con la cartella del modello già scaricato; le impostazioni predefinite
|
||||
rimangono quelle del progetto. Non è stata verificata all’ascolto la pronuncia delle nuove voci.
|
||||
|
||||
## Verifica e limiti della sessione
|
||||
|
||||
**`npm test`: passato.** Regole, determinismo, conservazione delle 40 carte e dei 120 punti,
|
||||
pescata, ultima briscola, fine mazzo, patta, livelli, accesso negato ai dati nascosti,
|
||||
minimax confrontato con un oracolo esaustivo, renderer puro, input geometrico,
|
||||
dialoghi/flag/rivincita e audio. Passano anche i test precedenti e la soluzione completa.
|
||||
|
||||
`npm run browser` e `npm run browser:briscola` sono stati lanciati ma **bloccati dall’ambiente**:
|
||||
`PermissionError: [Errno 1] Operation not permitted` alla creazione del socket del server.
|
||||
Anche l’avvio isolato di Chromium fallisce con `sandbox_host_linux.cc:41`,
|
||||
`shutdown: Operation not permitted`. Non è stato possibile verificare l’interfaccia reale
|
||||
con Playwright né produrre schermate desktop/telefono.
|
||||
|
||||
Il test `tests/briscola.browser.mjs` è pronto: partite complete con mouse, touch e tastiera
|
||||
su desktop e telefono verticale, più touch orizzontale; pausa, muto, gesto annullato,
|
||||
conteggio, risultato e rivincita. Accelera solo le attese tramite `game.update`, senza
|
||||
giocare carte di Andrea direttamente nel motore. Salva in `/tmp/regolo-shots/briscola-*.png`.
|
||||
Le misure Web Audio dei tre nuovi suoni sono comprese nel collaudo browser multimedia.
|
||||
|
||||
Anteprime **offline, non schermate browser**, prodotte e ispezionate nella sessione:
|
||||
|
||||
- `/tmp/regolo-shots/briscola-offline-carte.png`
|
||||
- `/tmp/regolo-shots/briscola-offline-tavolo.png`
|
||||
- `/tmp/regolo-shots/briscola-offline-risultato.png`
|
||||
|
||||
## File
|
||||
|
||||
Nuovi: `src/engine/briscola.js`, `src/engine/briscola-render.js`,
|
||||
`assets/characters/giocatore-carte.png`, `assets/characters/giocatore-carte.json`,
|
||||
`tests/briscola.test.mjs`, `tests/briscola-render.test.mjs`, `tests/briscola.browser.mjs`,
|
||||
`tools/calibrate_briscola.mjs`, questo documento e 13 MP3 elencati qui sotto.
|
||||
|
||||
Modificati: `src/engine/game.js`, `src/engine/render.js`, `src/engine/soundscape.js`,
|
||||
`src/main.js`, `src/story/story.js`, `index.html`, `style.css`, `package.json`,
|
||||
`tests/story.test.mjs`, `tests/multimedia.test.mjs`, `tests/multimedia.browser.mjs`,
|
||||
`tools/voci.json`, `tools/voci_xtts.py`, `assets/voices/index.json`.
|
||||
|
||||
Nuovi file in `assets/voices/`:
|
||||
|
||||
```text
|
||||
andrea-6e63dafda1.mp3
|
||||
andrea-71f3bff8a0.mp3
|
||||
andrea-b851b9ed26.mp3
|
||||
andrea-d2f285f610.mp3
|
||||
giocatore_carte-12f97003ec.mp3
|
||||
giocatore_carte-13af2e2331.mp3
|
||||
giocatore_carte-43e76cf11b.mp3
|
||||
giocatore_carte-6ce98bce81.mp3
|
||||
giocatore_carte-729b3344da.mp3
|
||||
giocatore_carte-8dfadb4acc.mp3
|
||||
giocatore_carte-9c07caaab4.mp3
|
||||
giocatore_carte-a8846bfa7b.mp3
|
||||
giocatore_carte-f1f219bd1c.mp3
|
||||
```
|
||||
|
||||
`docs/STATO.md` invariato. Nessun commit, tag o push.
|
||||
@@ -105,3 +105,18 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
|
||||
- *Regia alla fessura*: col meccanismo del primo piano (`closeup`) il buio, la torcia, un istante di silenzio,
|
||||
poi gli occhi. Due immagini di Codex.
|
||||
- *Segreti facoltativi del paese* con pagine illustrate nel taccuino: funzione nuova, dopo la storia principale.
|
||||
- **Altri minigiochi come le bocce** (25 ottobre 2026): ognuno è un primo piano (`closeup` con il suo `type`, come
|
||||
`bocce`), con comandi per dito, mouse e tastiera, i suoi test e una battuta per chi vince e per chi perde.
|
||||
Il posto naturale sono le gare del Palio del secondo capitolo:
|
||||
- *Passamano dei mattoni* (da fare per primo): un gioco di ritmo, i mattoni arrivano lungo la fila e si passano al
|
||||
momento giusto; sbagliando il tempo ne cade uno. Facile col dito, e chiude il cerchio col finale (i mattoni
|
||||
portati in Fortezza).
|
||||
- *Le carrette*: corsa in discesa vista di lato, a destra e sinistra fra buche e balle di paglia. La più
|
||||
spettacolare, la più lunga da disegnare.
|
||||
- *Bandierine (lavanderine)*: correre a prendere la bandiera prima dell'avversario, di riflessi.
|
||||
- *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo.
|
||||
Fuori dal Palio:
|
||||
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta.
|
||||
- *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere. Andrea ha ritrovato la foto di squadra
|
||||
sul campo in terra (porta, rete, la torre della Pieve dietro, anni '90): lo sfondo del minigioco lo fa Codex da quella,
|
||||
senza le persone (per i volti serve il consenso). Da salvare in `assets/raw/foto/campo-bettini-squadra.jpg`.
|
||||
|
||||
@@ -18,6 +18,13 @@ 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`.
|
||||
Prossimo: il **tressette a coppie «ai 21»** con busso, striscio e volo (da chiedere ad Andrea: le accuse?).
|
||||
|
||||
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
|
||||
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
|
||||
|
||||
+31
@@ -103,6 +103,24 @@
|
||||
<div id="inventory" aria-label="Inventario"></div>
|
||||
</div>
|
||||
<ol id="choices" hidden></ol>
|
||||
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
|
||||
<p id="briscola-status" role="status"></p>
|
||||
<div class="briscola-hand" aria-label="La mano di Andrea">
|
||||
<button id="briscola-card-0" type="button"></button>
|
||||
<button id="briscola-card-1" type="button"></button>
|
||||
<button id="briscola-card-2" type="button"></button>
|
||||
</div>
|
||||
<details><summary>Come si gioca</summary>
|
||||
<p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca.
|
||||
Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p>
|
||||
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
|
||||
di uscita. Ordine: Asso, 3, Re, Cavallo, Fante, 7, 6, 5, 4, 2.
|
||||
Asso 11 punti, 3 dieci, Re 4, Cavallo 3, Fante 2; le altre zero.</p>
|
||||
<p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo;
|
||||
il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese.
|
||||
Durante la partita i punti non si dicono!</p>
|
||||
</details>
|
||||
</section>
|
||||
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||
<p id="bocce-status" role="status"></p>
|
||||
<div class="bocce-adjust">
|
||||
@@ -121,6 +139,19 @@
|
||||
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria.
|
||||
Vince chi fa più punti nella mano.</p>
|
||||
</details>
|
||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||
<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>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
|
||||
|
||||
+3
-1
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"browser": "node tests/browser.mjs",
|
||||
"browser:biliardo": "node tests/biliardo.browser.mjs",
|
||||
"browser:briscola": "node tests/briscola.browser.mjs",
|
||||
"browser:effects": "node tests/ambient.browser.mjs",
|
||||
"serve": "python3 -m http.server 8000"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
|
||||
import { text, panel } from './bocce-render.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;
|
||||
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
|
||||
const rr = R * VIEW.k;
|
||||
|
||||
// Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
|
||||
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
|
||||
export const SHOT_DELAY = WINDUP;
|
||||
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
|
||||
|
||||
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
|
||||
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
|
||||
export function tablePatches() {
|
||||
const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
|
||||
const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
|
||||
const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
|
||||
return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
|
||||
xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
|
||||
xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
|
||||
]));
|
||||
}
|
||||
|
||||
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
||||
function sphere(ctx, role, x, y, r, shadow = true) {
|
||||
const [dark, mid, light] = BALL[role];
|
||||
const 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));
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 = 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();
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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);
|
||||
}
|
||||
text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center');
|
||||
if (canThrow(c)) {
|
||||
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, 8, '#d7ccb4', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Biliardo all'italiana, cinque birilli, visto dall'alto. Regole da circolo, semplificate:
|
||||
// si colpisce la propria biglia e la si manda sulla biglia avversaria; i birilli abbattuti dalla biglia avversaria
|
||||
// o dal pallino fanno punti a chi tira (bianco 2, rosso 4, il rosso da solo 10), il pallino toccato dalla biglia
|
||||
// avversaria ne vale 3. Fallo, con i punti all'avversario (almeno 2): mancare la biglia avversaria, toccare prima
|
||||
// il pallino, abbattere birilli con la propria biglia. Un colpo a testa; vince chi arriva prima al traguardo.
|
||||
// Unità: centimetri. x lungo il tavolo (0..LEN), y di traverso (0..WID).
|
||||
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: 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; }
|
||||
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||
const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino];
|
||||
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
||||
|
||||
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
||||
export function castle() {
|
||||
const cx = LEN / 2, cy = WID / 2;
|
||||
return [[0, 0, true], [-GAP, 0], [GAP, 0], [0, -GAP], [0, GAP]].map(([dx, dy, red]) => ({ x: cx + dx, y: cy + dy, red: !!red, down: false }));
|
||||
}
|
||||
|
||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
|
||||
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
|
||||
export function reach(v) {
|
||||
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
|
||||
return (v + k) / DRAG * (1 - Math.exp(-DRAG * t)) - k * t;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Valore dei birilli abbattuti in un colpo.
|
||||
export function value(pins) {
|
||||
if (pins.length === 1 && pins[0].red) return 10;
|
||||
return pins.reduce((s, p) => s + (p.red ? 4 : 2), 0);
|
||||
}
|
||||
// Chi prende i punti di un colpo, e quanti.
|
||||
export function judge(shot) {
|
||||
const shooter = shot.shooter, other = OTHER[shooter];
|
||||
const own = shot.pins.filter(p => p.by === shooter);
|
||||
const foul = shot.first === null ? 'miss' : shot.first === 'pallino' ? 'pallino' : own.length ? 'own' : null;
|
||||
if (foul) return { shooter, to: other, points: Math.max(2, value(shot.pins)), foul };
|
||||
return { shooter, to: shooter, points: value(shot.pins) + (shot.pallino ? 3 : 0), foul: null };
|
||||
}
|
||||
|
||||
function cushions(b) {
|
||||
let impact = 0;
|
||||
for (const [p, v, hi] of [['x', 'vx', LEN - R], ['y', 'vy', WID - R]]) {
|
||||
if (b[p] < R || b[p] > hi) {
|
||||
const out = b[p] < R ? b[v] < 0 : b[v] > 0;
|
||||
b[p] = clamp(b[p], R, hi);
|
||||
if (out) { impact = Math.max(impact, Math.abs(b[v])); b[v] *= -CUSHION; }
|
||||
}
|
||||
}
|
||||
return impact;
|
||||
}
|
||||
// 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 + 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 <= 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;
|
||||
}
|
||||
function touch(shot, a, b) {
|
||||
if (!shot) return;
|
||||
const roles = [a.role, b.role], other = OTHER[shot.shooter];
|
||||
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
|
||||
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
|
||||
}
|
||||
// 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) {
|
||||
// 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 (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);
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||
export function simulate(c, who, angle, power) {
|
||||
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
||||
const w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
||||
const b = balls.find(x => x.role === who), v = speedFor(power);
|
||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||||
for (let i = 0; i < 15 / FIXED_STEP && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
||||
return judge(w.shot);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const d = Math.hypot(b.x - p.x, b.y - p.y), need = R + PIN + 0.3;
|
||||
if (d >= need) continue;
|
||||
const a = d > 1e-6 ? Math.atan2(b.y - p.y, b.x - p.x) : -Math.PI / 2;
|
||||
b.x = p.x + Math.cos(a) * need; b.y = p.y + Math.sin(a) * need;
|
||||
}
|
||||
const all = list(c);
|
||||
for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) collide(all[i], all[j]);
|
||||
all.forEach(cushions);
|
||||
}
|
||||
}
|
||||
function finish(c) {
|
||||
const j = judge(c.shot);
|
||||
c.points[j.to] += j.points;
|
||||
c.last = { ...j, age: 0 };
|
||||
c.shot = null;
|
||||
reset(c);
|
||||
c.t = 0;
|
||||
if (c.points[j.to] >= c.target) { c.step = 'result'; c.result = { winner: j.to, points: { ...c.points } }; return; }
|
||||
c.current = OTHER[j.shooter];
|
||||
c.step = 'aim';
|
||||
c.angle = angleTo(c.balls[c.current], c.balls[OTHER[c.current]]);
|
||||
}
|
||||
|
||||
// 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 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 };
|
||||
for (let i = 0; i < budget && t.todo.length; i++) {
|
||||
const { angle, power } = t.todo.pop(), j = simulate(c, 'cpu', angle, power), gain = j.to === 'cpu' ? j.points : -j.points;
|
||||
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
|
||||
}
|
||||
if (t.todo.length) return;
|
||||
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' && 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; }
|
||||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
||||
else if (c.step === 'aim' && c.current === 'cpu') {
|
||||
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) || 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]) {
|
||||
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;
|
||||
}
|
||||
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
|
||||
export function press(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||
}
|
||||
export function cancel(c) {
|
||||
if (!c.holding) return false;
|
||||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||
}
|
||||
export function release(c) {
|
||||
if (!c.holding) return false;
|
||||
if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
|
||||
c.holding = false; return strike(c, 'player', c.angle, c.power);
|
||||
}
|
||||
|
||||
// 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 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 (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;
|
||||
}
|
||||
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' };
|
||||
export function lastLine(c, names) {
|
||||
const l = c.last;
|
||||
if (!l) return '';
|
||||
if (l.foul) return `Fallo di ${names[l.shooter]} (${FOULS[l.foul]}): ${l.points} a ${names[l.to]}.`;
|
||||
return l.points ? `${names[l.to]} fa ${l.points} punti!` : `${names[l.shooter]}: nessun punto.`;
|
||||
}
|
||||
export function status(c, names) {
|
||||
if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`;
|
||||
if (c.step === 'rolling') return 'Biglie in movimento…';
|
||||
if (c.step === 'settle') return 'Si contano i birilli…';
|
||||
const last = lastLine(c, names);
|
||||
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const FONT = '"Press Start 2P", monospace';
|
||||
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
|
||||
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||
export function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
|
||||
}
|
||||
function panel(ctx, x, y, w, h) {
|
||||
export function panel(ctx, x, y, w, h) {
|
||||
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
|
||||
+14
-5
@@ -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,295 @@
|
||||
import { canPlay, cardName, counted, countRows } from './briscola.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
// Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono.
|
||||
export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
|
||||
export function cardAt(c, [x, y]) {
|
||||
if (!canPlay(c)) return -1;
|
||||
return c.hands.player.findIndex((_, i) => x >= HAND.x + i * HAND.gap && x < HAND.x + i * HAND.gap + HAND.w && y >= HAND.y && y < HAND.y + HAND.h);
|
||||
}
|
||||
const ink = '#38271f', paper = '#fff0cd', gold = '#dba32b', red = '#aa4034', blue = '#386a89', green = '#487348';
|
||||
function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
|
||||
function poly(ctx, points, color) {
|
||||
ctx.fillStyle = color; ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Simboli originali: moneta incisa, calice, lama ricurva e bastone coi nodi.
|
||||
function suit(ctx, name, x, y) {
|
||||
ctx.save(); ctx.translate(x, y);
|
||||
if (name === 'denari') {
|
||||
rect(ctx, -4, -3, 8, 6, ink); rect(ctx, -3, -4, 6, 8, ink);
|
||||
rect(ctx, -3, -2, 6, 4, gold); rect(ctx, -2, -3, 4, 6, gold);
|
||||
rect(ctx, -2, -2, 1, 4, '#ffe58c'); rect(ctx, 0, -1, 2, 2, '#9e6b25');
|
||||
} else if (name === 'coppe') {
|
||||
rect(ctx, -4, -4, 8, 2, ink); rect(ctx, -3, -2, 6, 3, gold);
|
||||
rect(ctx, -2, 0, 4, 2, red); rect(ctx, -1, 2, 2, 2, ink);
|
||||
rect(ctx, -3, 4, 6, 1, gold); rect(ctx, -3, -3, 6, 1, '#ffdd70');
|
||||
} else if (name === 'spade') {
|
||||
poly(ctx, [[-4,5],[-3,1],[0,-1],[2,-5],[4,-6],[3,-1],[-1,3],[-2,6]], ink);
|
||||
rect(ctx, 1, -3, 2, 3, '#9eb3ba'); rect(ctx, -1, 0, 2, 2, '#c9d8d6');
|
||||
rect(ctx, -5, 3, 5, 1, gold); rect(ctx, -4, 5, 2, 2, red);
|
||||
} else {
|
||||
poly(ctx, [[-3,6],[-4,4],[-1,-1],[0,-6],[3,-5],[3,-2],[0,3],[-1,6]], ink);
|
||||
rect(ctx, 0, -4, 2, 5, '#94632e'); rect(ctx, -2, 1, 2, 4, '#ae7a39');
|
||||
rect(ctx, 2, -3, 2, 2, gold); rect(ctx, -3, 2, 2, 2, gold); rect(ctx, 3, -5, 2, 1, green);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function regolo(ctx) {
|
||||
// Cornice simmetrica e un piccolo re sorridente, avvolto nelle proprie spire.
|
||||
rect(ctx, 3, 3, 36, 54, blue); rect(ctx, 5, 5, 32, 50, gold); rect(ctx, 6, 6, 30, 48, '#203e44');
|
||||
for (let y = 9; y < 54; y += 6) for (const x of [8, 32]) rect(ctx, x, y, 2, 2, '#63867c');
|
||||
poly(ctx, [[15,32],[28,32],[32,36],[32,43],[28,47],[14,47],[10,43],[10,37]], ink);
|
||||
poly(ctx, [[15,34],[27,34],[30,37],[30,42],[27,45],[15,45],[12,42],[12,38]], green);
|
||||
poly(ctx, [[17,37],[26,37],[28,39],[27,41],[16,41],[15,39]], '#203e44');
|
||||
rect(ctx, 14, 42, 13, 2, '#a3bc72'); rect(ctx, 16, 36, 11, 1, '#a3bc72');
|
||||
poly(ctx, [[19,25],[25,25],[25,33],[23,36],[18,36],[17,33],[20,32]], green);
|
||||
rect(ctx, 20, 28, 2, 6, '#d0cd83');
|
||||
poly(ctx, [[15,16],[27,16],[30,19],[30,25],[27,29],[15,29],[12,25],[12,20]], ink);
|
||||
poly(ctx, [[16,17],[26,17],[29,20],[29,24],[26,27],[16,27],[13,24],[13,21]], '#94b365');
|
||||
rect(ctx, 15, 19, 4, 4, paper); rect(ctx, 23, 19, 4, 4, paper);
|
||||
rect(ctx, 17, 20, 2, 3, ink); rect(ctx, 23, 20, 2, 3, ink);
|
||||
rect(ctx, 18, 25, 6, 1, ink); rect(ctx, 16, 24, 2, 1, ink); rect(ctx, 24, 24, 2, 1, ink);
|
||||
rect(ctx, 17, 12, 9, 4, gold); for (const x of [17, 21, 25]) rect(ctx, x, 9, 1, 4, '#ffe396');
|
||||
rect(ctx, 21, 13, 1, 2, red);
|
||||
for (const x of [15, 25]) { rect(ctx, x, 50, 2, 2, gold); rect(ctx, x + 1, 49, 1, 4, gold); }
|
||||
}
|
||||
function figure(ctx, rank, name) {
|
||||
// Figure intere: fante in tunica, cavaliere a cavallo, re con corona e manto.
|
||||
const king = rank === 'Re', horse = rank === 'Cavallo', y = horse ? 15 : 17;
|
||||
if (horse) {
|
||||
poly(ctx, [[8,36],[12,29],[27,30],[30,23],[35,23],[37,29],[32,33],[31,41],[12,41]], ink);
|
||||
rect(ctx, 11, 33, 20, 7, '#b78044'); rect(ctx, 30, 26, 4, 8, '#c79c60');
|
||||
rect(ctx, 12, 40, 3, 10, ink); rect(ctx, 27, 39, 3, 11, ink);
|
||||
rect(ctx, 15, 40, 2, 8, '#a36b3c'); rect(ctx, 32, 27, 1, 1, paper);
|
||||
}
|
||||
if (king) poly(ctx, [[15,24],[29,24],[33,48],[10,48]], red);
|
||||
poly(ctx, [[19,y],[25,y],[27,y+2],[28,y+4],[26,y+5],[26,y+8],[20,y+9],[17,y+6],[17,y+2]], ink);
|
||||
poly(ctx, [[20,y+1],[25,y+1],[25,y+3],[27,y+4],[25,y+5],[25,y+7],[21,y+8],[19,y+5]], '#eab578');
|
||||
rect(ctx, 18, y + 1, 2, 4, '#86502d'); rect(ctx, 20, y + 1, 4, 1, '#ffe0a2');
|
||||
rect(ctx, 24, y + 2, 1, 2, ink); rect(ctx, 24, y + 6, 3, 1, ink);
|
||||
rect(ctx, 17, y - 2, 10, 3, king ? gold : blue);
|
||||
if (king) for (const x of [17, 21, 25]) rect(ctx, x, y - 5, 2, 4, gold);
|
||||
else rect(ctx, 18, y - 5, 2, 4, red);
|
||||
poly(ctx, [[18,y+9],[26,y+9],[29,y+12],[27,y+19],[29,y+24],[15,y+24],[17,y+19],[15,y+12]], ink);
|
||||
poly(ctx, [[19,y+10],[25,y+10],[27,y+12],[25,y+19],[27,y+22],[17,y+22],[19,y+18],[17,y+12]], horse ? red : blue);
|
||||
rect(ctx, 18, y + 10, 3, 10, king ? gold : '#6291a5'); rect(ctx, 17, y + 19, 11, 2, gold);
|
||||
rect(ctx, 23, y + 11, 1, 6, paper); rect(ctx, 23, y + 19, 2, 2, ink);
|
||||
rect(ctx, 12, y + 11, 4, 3, '#eab578'); rect(ctx, 28, y + 10, 4, 4, '#eab578');
|
||||
if (!horse) {
|
||||
poly(ctx, [[17,y+24],[21,y+24],[20,y+32],[16,y+32]], ink);
|
||||
poly(ctx, [[23,y+24],[27,y+24],[29,y+32],[25,y+32]], ink);
|
||||
rect(ctx, 18, y + 24, 2, 5, '#b9884f'); rect(ctx, 24, y + 24, 2, 4, '#b9884f');
|
||||
rect(ctx, 15, y + 32, 6, 2, red); rect(ctx, 24, y + 32, 6, 2, red);
|
||||
}
|
||||
suit(ctx, name, 10, 24);
|
||||
if (king) { rect(ctx, 32, 22, 1, 24, gold); rect(ctx, 31, 20, 3, 3, gold); }
|
||||
}
|
||||
const PIPS = {
|
||||
Asso: [[21,32]], 2: [[21,22],[21,42]], 3: [[21,20],[21,32],[21,44]],
|
||||
4: [[13,23],[29,23],[13,41],[29,41]],
|
||||
5: [[13,21],[29,21],[21,32],[13,43],[29,43]],
|
||||
6: [[13,20],[29,20],[13,32],[29,32],[13,44],[29,44]],
|
||||
7: [[13,19],[29,19],[13,32],[29,32],[13,45],[29,45],[21,32]],
|
||||
};
|
||||
// Le toscane hanno semi francesi, nessun indice sulle numerali e figure intere.
|
||||
const TUSCAN_COLORS = {
|
||||
cuori: { cloth: '#326447', light: '#65936a', cloak: '#ab393b', ground: '#bc7760' },
|
||||
mattoni: { cloth: '#a93832', light: '#d56a46', cloak: '#315f7e', ground: '#b3a05b' },
|
||||
fiori: { cloth: '#315e85', light: '#6995ac', cloak: '#bc842e', ground: '#829968' },
|
||||
picche: { cloth: '#755079', light: '#ac7d9a', cloak: '#376b66', ground: '#9e8271' },
|
||||
};
|
||||
const TUSCAN_PIPS = {
|
||||
Asso: [[21,30]], 2: [[21,17],[21,43]], 3: [[21,15],[21,30],[21,45]],
|
||||
4: [[12,16],[30,16],[12,44],[30,44]],
|
||||
5: [[12,16],[30,16],[21,30],[12,44],[30,44]],
|
||||
6: [[12,15],[30,15],[12,30],[30,30],[12,45],[30,45]],
|
||||
7: [[10,14],[32,14],[21,22],[10,30],[32,30],[10,46],[32,46]],
|
||||
};
|
||||
function tuscanSuit(ctx, name, x, y, inverted = false) {
|
||||
ctx.save(); ctx.translate(x, y);
|
||||
if (inverted) ctx.scale(1, -1);
|
||||
const color = name === 'cuori' || name === 'mattoni' ? '#c12f35' : '#202326';
|
||||
// Sagome a gradini sulla griglia: nessuna sfumatura nei semi.
|
||||
if (name === 'cuori') {
|
||||
poly(ctx, [[-4,-4],[-1,-4],[0,-3],[1,-4],[4,-4],[5,-3],[5,0],[4,1],[4,2],[3,2],[3,3],[2,3],[2,4],[1,4],[1,5],[0,6],[-1,5],[-1,4],[-2,4],[-2,3],[-3,3],[-3,2],[-4,2],[-4,1],[-5,0],[-5,-3]], color);
|
||||
} else if (name === 'mattoni') {
|
||||
poly(ctx, [[0,-6],[1,-4],[2,-3],[3,-2],[5,0],[3,2],[2,3],[1,4],[0,6],[-1,4],[-2,3],[-3,2],[-5,0],[-3,-2],[-2,-3],[-1,-4]], color);
|
||||
} else if (name === 'fiori') {
|
||||
poly(ctx, [[-2,-5],[2,-5],[3,-4],[3,-2],[2,-1],[4,-2],[6,0],[6,2],[4,4],[2,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-2,4],[-4,4],[-6,2],[-6,0],[-4,-2],[-2,-1],[-3,-2],[-3,-4]], color);
|
||||
} else {
|
||||
poly(ctx, [[0,-6],[1,-5],[1,-4],[3,-2],[5,0],[5,3],[3,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-3,4],[-5,3],[-5,0],[-3,-2],[-1,-4],[-1,-5]], color);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function tuscanFigure(ctx, rank, name) {
|
||||
const p = TUSCAN_COLORS[name], skin = '#e9b785', shade = '#bf805d', trim = '#e0b957';
|
||||
const queen = rank === 'Donna', king = rank === 'Regio';
|
||||
// Cornice sottile, terreno e silhouette originali, disegnati su una griglia 42×60.
|
||||
rect(ctx, 3, 3, 36, 54, '#b6a48a'); rect(ctx, 3.5, 3.5, 35, 53, paper);
|
||||
poly(ctx, [[4,51],[14,49],[26,50],[38,48],[38,56],[4,56]], p.ground);
|
||||
if (queen) {
|
||||
// Dama di profilo: diadema, manica a sbuffo, gonna a ventaglio e fazzoletto.
|
||||
poly(ctx, [[21,11],[27,11],[29,14],[28,22],[31,26],[27,29],[19,24],[18,18]], ink);
|
||||
poly(ctx, [[22,12],[26,12],[27,15],[25,19],[22,21],[20,18],[19,17],[21,16]], skin);
|
||||
rect(ctx, 23, 12, 4, 2, '#70452c'); rect(ctx, 26, 14, 2, 7, '#70452c');
|
||||
rect(ctx, 21, 15, 1, 1, ink); rect(ctx, 20, 18, 2, 1, shade);
|
||||
poly(ctx, [[21,12],[21,10],[23,11],[24,9],[25,11],[27,10],[27,13]], trim);
|
||||
rect(ctx, 24, 10, 1, 1, '#c12f35');
|
||||
poly(ctx, [[21,21],[26,20],[30,24],[28,31],[31,39],[37,54],[12,54],[15,43],[18,33],[17,26]], ink);
|
||||
poly(ctx, [[22,22],[26,21],[28,24],[26,31],[29,39],[35,53],[14,53],[17,42],[20,32],[19,26]], p.cloth);
|
||||
poly(ctx, [[21,32],[25,32],[25,40],[31,52],[23,51],[19,46]], p.light);
|
||||
poly(ctx, [[26,34],[29,41],[33,51],[30,48],[27,43]], trim);
|
||||
poly(ctx, [[19,35],[18,45],[16,52],[20,52],[21,49],[19,47]], p.cloak);
|
||||
poly(ctx, [[18,23],[22,25],[20,29],[16,31],[12,29],[13,26]], '#d8dfd7');
|
||||
rect(ctx, 17, 25, 4, 2, '#94a7ad');
|
||||
poly(ctx, [[14,28],[16,30],[12,33],[9,32],[10,30]], skin);
|
||||
poly(ctx, [[9,31],[12,32],[10,35],[8,35],[6,40],[6,35]], '#fcf7df');
|
||||
rect(ctx, 8, 34, 1, 3, '#94a7ad');
|
||||
poly(ctx, [[20,29],[26,29],[27,31],[20,33],[18,32]], trim);
|
||||
rect(ctx, 22, 23, 1, 3, trim); rect(ctx, 16, 54, 7, 1, ink);
|
||||
} else if (king) {
|
||||
// Regio anziano: corona sempre visibile, barba bianca e bordi d'ermellino.
|
||||
poly(ctx, [[15,23],[27,22],[32,27],[36,54],[9,54],[11,31]], ink);
|
||||
poly(ctx, [[15,24],[27,23],[30,28],[34,53],[11,53],[13,31]], p.cloak);
|
||||
poly(ctx, [[18,27],[25,27],[27,52],[16,52]], p.cloth);
|
||||
rect(ctx, 20, 33, 3, 18, p.light); rect(ctx, 16, 48, 11, 2, trim);
|
||||
poly(ctx, [[15,24],[18,25],[16,34],[15,45],[12,52],[11,51],[13,33]], '#f6edcf');
|
||||
poly(ctx, [[26,24],[28,25],[29,35],[32,51],[29,52],[26,35],[24,27]], '#f6edcf');
|
||||
for (const [x,y] of [[14,29],[13,37],[12,46],[27,29],[28,38],[30,47]]) rect(ctx, x, y, 1, 2, ink);
|
||||
poly(ctx, [[17,13],[24,12],[27,16],[26,23],[23,28],[18,26],[15,21],[15,16]], '#b9bfbb');
|
||||
poly(ctx, [[18,14],[24,14],[25,17],[27,19],[24,20],[24,22],[19,22],[17,18]], skin);
|
||||
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 18, 16, 1, 2, shade);
|
||||
poly(ctx, [[16,18],[19,21],[22,20],[25,21],[24,25],[21,28],[18,25],[16,23]], '#f7f0d7');
|
||||
rect(ctx, 21, 22, 3, 1, '#9ba8a3'); rect(ctx, 18, 22, 1, 3, '#c6cdc0');
|
||||
poly(ctx, [[16,14],[15,8],[17,8],[18,11],[20,10],[20,7],[22,7],[22,10],[24,11],[25,8],[27,8],[26,14]], ink);
|
||||
poly(ctx, [[17,13],[16,9],[17,9],[18,12],[21,11],[21,8],[22,12],[25,12],[26,9],[25,13]], trim);
|
||||
rect(ctx, 17, 13, 9, 2, trim); rect(ctx, 21, 13, 2, 1, '#c12f35');
|
||||
rect(ctx, 33, 23, 1, 30, '#9b6a29'); rect(ctx, 33, 24, 1, 27, trim);
|
||||
poly(ctx, [[33,19],[35,21],[35,23],[33,25],[31,23],[31,21]], trim);
|
||||
rect(ctx, 33, 21, 1, 2, '#fff1ae'); rect(ctx, 31, 32, 4, 3, skin);
|
||||
rect(ctx, 10, 33, 4, 3, skin); rect(ctx, 15, 53, 6, 2, ink); rect(ctx, 24, 53, 6, 2, ink);
|
||||
} else {
|
||||
// Gobbo giovane, senza corona: berretto morbido, mantello corto e mazza.
|
||||
poly(ctx, [[15,23],[26,22],[31,27],[33,39],[27,43],[11,40],[10,28]], ink);
|
||||
poly(ctx, [[15,24],[25,23],[29,27],[31,38],[27,41],[12,39],[12,28]], p.cloak);
|
||||
poly(ctx, [[18,37],[23,38],[21,48],[20,53],[15,53],[17,46]], ink);
|
||||
poly(ctx, [[24,37],[28,37],[28,46],[31,53],[26,53],[23,46]], ink);
|
||||
rect(ctx, 18, 41, 2, 9, '#d3ad60'); rect(ctx, 25, 41, 2, 6, '#d3ad60');
|
||||
rect(ctx, 16, 50, 4, 4, '#744531'); rect(ctx, 27, 50, 3, 4, '#744531');
|
||||
rect(ctx, 13, 54, 7, 1, ink); rect(ctx, 26, 54, 7, 1, ink);
|
||||
poly(ctx, [[17,12],[24,12],[27,16],[26,23],[22,25],[17,22],[15,18]], '#70452c');
|
||||
poly(ctx, [[19,14],[24,14],[24,16],[26,18],[24,19],[24,21],[21,23],[18,20]], skin);
|
||||
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 23, 20, 2, 1, shade);
|
||||
rect(ctx, 17, 15, 2, 7, '#70452c'); rect(ctx, 16, 19, 2, 5, '#70452c');
|
||||
poly(ctx, [[16,14],[17,11],[21,10],[25,11],[26,14],[28,15],[16,15]], p.cloth);
|
||||
rect(ctx, 18, 13, 8, 1, p.light);
|
||||
poly(ctx, [[18,24],[24,23],[27,27],[25,33],[28,40],[15,40],[17,33],[15,27]], ink);
|
||||
poly(ctx, [[19,25],[23,24],[25,27],[23,33],[26,39],[17,39],[19,33],[17,27]], p.cloth);
|
||||
rect(ctx, 20, 26, 2, 7, p.light); rect(ctx, 16, 34, 10, 2, trim);
|
||||
poly(ctx, [[18,23],[21,25],[20,29],[17,25]], '#f6edcf');
|
||||
rect(ctx, 21, 34, 2, 2, ink);
|
||||
poly(ctx, [[10,27],[13,29],[12,33],[9,34],[8,32]], skin);
|
||||
poly(ctx, [[27,28],[30,30],[28,34],[25,35],[24,33],[27,32]], skin);
|
||||
poly(ctx, [[7,30],[9,30],[9,46],[11,50],[11,53],[6,53],[6,49],[7,46]], ink);
|
||||
rect(ctx, 8, 33, 1, 17, '#ae783a'); rect(ctx, 7, 49, 3, 3, trim);
|
||||
rect(ctx, 7, 31, 3, 2, skin);
|
||||
}
|
||||
ctx.save(); ctx.translate(9, 8); ctx.scale(0.65, 0.65); tuscanSuit(ctx, name, 0, 0); ctx.restore();
|
||||
text(ctx, queen ? 'D' : king ? 'R' : 'G', 7, 16, 4, ink);
|
||||
}
|
||||
function tuscanCard(ctx, card) {
|
||||
// Angoli arrotondati a gradini e un solo bordo sottile; nessuna scritta al piede.
|
||||
const outline = [[2,0],[40,0],[42,2],[42,58],[40,60],[2,60],[0,58],[0,2]];
|
||||
ctx.save(); ctx.translate(1, 2); poly(ctx, outline, '#15231d'); ctx.restore();
|
||||
poly(ctx, outline, '#89765f');
|
||||
poly(ctx, [[2,1],[40,1],[41,2],[41,58],[40,59],[2,59],[1,58],[1,2]], paper);
|
||||
if (TUSCAN_PIPS[card.rank]) {
|
||||
for (const [x, y] of TUSCAN_PIPS[card.rank]) tuscanSuit(ctx, card.suit, x, y, y > 30);
|
||||
} else tuscanFigure(ctx, card.rank, card.suit);
|
||||
}
|
||||
export function drawCard(ctx, card, x, y, w = 84, h = 120, back = false) {
|
||||
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(w / 42, h / 60);
|
||||
if (!back && TUSCAN_COLORS[card.suit]) { tuscanCard(ctx, card); ctx.restore(); return; }
|
||||
rect(ctx, 1, 2, 42, 60, '#15231d'); rect(ctx, 0, 0, 42, 60, ink);
|
||||
rect(ctx, 1, 1, 40, 58, '#c5a576'); rect(ctx, 2, 2, 38, 56, paper);
|
||||
if (back) regolo(ctx);
|
||||
else {
|
||||
const rank = { Asso: 'A', Fante: 'F', Cavallo: 'C', Re: 'R', Gobbo: 'G', Donna: 'D', Regio: 'R' }[card.rank] ?? card.rank;
|
||||
text(ctx, rank, 4, 7, 6, ink); suit(ctx, card.suit, 34, 8);
|
||||
if (PIPS[card.rank]) {
|
||||
for (const [px, py] of PIPS[card.rank]) {
|
||||
// Una lama per simbolo: le coppie si allungano e s'incrociano al centro.
|
||||
if (card.suit === 'spade' && (px === 13 || px === 29)) {
|
||||
ctx.save(); ctx.translate(px === 13 ? 18 : 24, py); ctx.scale(px === 13 ? 1.8 : -1.8, 1);
|
||||
suit(ctx, card.suit, 0, 0); ctx.restore();
|
||||
} else suit(ctx, card.suit, px, py);
|
||||
}
|
||||
} else figure(ctx, card.rank, card.suit);
|
||||
text(ctx, card.suit.toUpperCase(), 21, 55, 3.7, ink, 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function countTable(ctx, c, names) {
|
||||
panel(ctx, 12, 32, 616, 318);
|
||||
const rows = countRows(c), shown = rows.slice(0, c.count), totals = counted(c);
|
||||
for (const [side, who] of ['player', 'cpu'].entries()) {
|
||||
const x = 25 + side * 310;
|
||||
text(ctx, names[who], x, 48, 7);
|
||||
const cards = shown.filter(p => p.who === who);
|
||||
cards.forEach(({ card }, i) => {
|
||||
const cx = x + (i % 8) * 36, cy = 64 + Math.floor(i / 8) * 49;
|
||||
drawCard(ctx, card, cx, cy, 30, 40);
|
||||
text(ctx, '+' + card.points, cx + 15, cy + 44, 5, '#ffe09b', 'center');
|
||||
});
|
||||
text(ctx, `${totals[who]} punti`, x, 321, 10, '#ffe09b');
|
||||
}
|
||||
const last = shown.at(-1);
|
||||
if (c.step === 'count' && last) {
|
||||
panel(ctx, 270, 115, 100, 140); drawCard(ctx, last.card, 278, 123);
|
||||
}
|
||||
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
|
||||
}
|
||||
export function drawBriscola(ctx, c, def) {
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
rect(ctx, 0, 0, 640, 360, '#573720');
|
||||
for (let y = 0; y < 360; y += 24) {
|
||||
rect(ctx, 0, y, 640, 2, '#38271e');
|
||||
for (let x = 0; x < 640; x += 53) rect(ctx, x + y % 17, y + 6, 29, 1, '#72462a');
|
||||
}
|
||||
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
|
||||
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
|
||||
text(ctx, 'BRISCOLA ALL’ORATORIO', 320, 15, 9, '#ffe0a0', 'center');
|
||||
if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
|
||||
text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
|
||||
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
|
||||
// La briscola sporge sotto il mazzo, girata di traverso: più di metà resta scoperta, anche l'asso che ha
|
||||
// un seme solo nel mezzo (le toscane non hanno numeri negli angoli).
|
||||
if (c.stock.length) {
|
||||
ctx.save(); ctx.translate(154, 150); ctx.rotate(Math.PI / 2);
|
||||
drawCard(ctx, c.trumpCard, 0, 0, 64, 92); ctx.restore();
|
||||
if (c.stock.length > 1) {
|
||||
for (let i = Math.min(5, Math.ceil(c.stock.length / 7)); i >= 0; i--)
|
||||
drawCard(ctx, null, 38 + i, 116 - i, 58, 83, true);
|
||||
}
|
||||
}
|
||||
text(ctx, `Mazzo: ${c.stock.length}`, 34, 243, 7);
|
||||
text(ctx, 'Briscola:', 34, 263, 7); // a 6 punti la B sembra una D
|
||||
text(ctx, c.trump.toUpperCase(), 34, 278, 7, '#ffe09b');
|
||||
for (const [i, who] of ['cpu', 'player'].entries()) {
|
||||
const y = 74 + i * 183;
|
||||
if (c.taken[who].length) for (let n = 0; n < Math.min(4, c.taken[who].length / 2); n++) drawCard(ctx, null, 526 + n * 2, y - n * 2, 42, 60, true);
|
||||
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
|
||||
}
|
||||
c.table.forEach((p, i) => {
|
||||
// Il brevissimo scorrimento precede il fermo della presa.
|
||||
const slide = i === c.table.length - 1 ? Math.max(0, 1 - c.t / 0.18) : 0;
|
||||
drawCard(ctx, p.card, 242 + i * 86, 130 + slide * (p.who === 'player' ? 60 : -40), 64, 92);
|
||||
});
|
||||
c.hands.player.forEach((card, i) => {
|
||||
const x = HAND.x + i * HAND.gap;
|
||||
if (canPlay(c) && c.selected === i) rect(ctx, x - 3, HAND.y - 3, HAND.w + 6, HAND.h + 6, '#ffdb76');
|
||||
drawCard(ctx, card, x, HAND.y, HAND.w, HAND.h);
|
||||
text(ctx, String(i + 1), x + HAND.w / 2, 221, 7, '#fff0cd', 'center');
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Briscola in due: regole e avversari senza DOM. Le carte sono immutabili, lo stato è serializzabile.
|
||||
// Due mazzi, stesse regole, stessi id: cambiano solo nomi e disegni (scelti nel dialogo, idea di Andrea).
|
||||
// Toscane: semi francesi (i quadri in Toscana sono «mattoni»), figure gobbo, donna e regio.
|
||||
export const DECKS = {
|
||||
toscane: { suits: ['cuori', 'mattoni', 'fiori', 'picche'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Gobbo', 'Donna', 'Regio'] },
|
||||
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
|
||||
};
|
||||
export const LEVELS = ['bona', 'seria', 'male'];
|
||||
const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
|
||||
const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
|
||||
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||
Object.freeze({ id: s * 10 + r, suit, rank, points: VALUES[r], strength: ORDER[r] }))));
|
||||
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||
export const CARDS = DECK_CARDS.toscane, SUITS = DECKS.toscane.suits;
|
||||
export const other = who => who === 'player' ? 'cpu' : 'player';
|
||||
export const cardName = card => `${card.rank} di ${card.suit}`;
|
||||
export const points = cards => cards.reduce((n, card) => n + card.points, 0);
|
||||
function rng(seed) { let n = seed >>> 0; return () => ((n = (Math.imul(n, 1664525) + 1013904223) >>> 0) / 4294967296); }
|
||||
function shuffle(cards, random) {
|
||||
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
|
||||
return cards;
|
||||
}
|
||||
// Il secondo vince solo con una briscola o con una carta più alta dello stesso seme.
|
||||
export function beats(second, first, trump) {
|
||||
return second.suit === first.suit ? second.strength > first.strength : second.suit === trump;
|
||||
}
|
||||
export function trickWinner(table, trump) {
|
||||
return beats(table[1].card, table[0].card, trump) ? table[1].who : table[0].who;
|
||||
}
|
||||
export function result(taken) {
|
||||
const player = points(taken.player), cpu = points(taken.cpu);
|
||||
return { player, cpu, winner: player === cpu ? null : player > cpu ? 'player' : 'cpu' };
|
||||
}
|
||||
export function newBriscola(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||
if (!DECKS[deck]) deck = 'toscane';
|
||||
const random = rng(seed), dealer = random() < 0.5 ? 'player' : 'cpu';
|
||||
const stock = shuffle([...DECK_CARDS[deck]], random), hands = { player: [], cpu: [] };
|
||||
// Distribuzione alternata, a partire da chi è di mano.
|
||||
for (let i = 0; i < 3; i++) for (const who of [other(dealer), dealer]) hands[who].push(stock.shift());
|
||||
const trumpCard = stock.shift(); stock.push(trumpCard);
|
||||
return { step: 'play', t: 0, age: 0, level: LEVELS.includes(level) ? level : 'seria', deck, dealer,
|
||||
hands, stock, trumpCard, trump: trumpCard.suit, current: other(dealer), table: [],
|
||||
taken: { player: [], cpu: [] }, history: [], selected: 0, last: null, drawnTrump: null,
|
||||
aiSeed: (seed ^ 0x9e3779b9) >>> 0, events: [{ kind: 'card-shuffle' }], count: 0 };
|
||||
}
|
||||
export const canPlay = c => c.step === 'play' && c.current === 'player';
|
||||
export function select(c, index) {
|
||||
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
|
||||
c.selected = index; return true;
|
||||
}
|
||||
export function nudge(c, direction) {
|
||||
return select(c, (c.selected + direction + c.hands.player.length) % c.hands.player.length);
|
||||
}
|
||||
export function play(c, index = c.selected, who = 'player') {
|
||||
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||
const [card] = c.hands[who].splice(index, 1);
|
||||
c.table.push({ who, card }); c.t = 0;
|
||||
c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||
c.events.push({ kind: 'card-slide' }); c.landing = true;
|
||||
if (c.table.length === 2) {
|
||||
c.step = 'trick'; c.last = { winner: trickWinner(c.table, c.trump), cards: c.table.map(p => p.card) };
|
||||
} else c.current = other(who);
|
||||
return true;
|
||||
}
|
||||
export function collect(c) {
|
||||
if (c.step !== 'trick') return false;
|
||||
const winner = c.last.winner;
|
||||
c.taken[winner].push(...c.last.cards);
|
||||
c.history.push({ winner, cards: [...c.last.cards] }); c.table = [];
|
||||
// Chi prende pesca per primo. La briscola scoperta è davvero l'ultima carta.
|
||||
for (const who of [winner, other(winner)]) if (c.stock.length) {
|
||||
const card = c.stock.shift(); c.hands[who].push(card);
|
||||
if (card.id === c.trumpCard.id) c.drawnTrump = who;
|
||||
}
|
||||
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||
c.step = c.hands.player.length ? 'play' : 'count';
|
||||
return true;
|
||||
}
|
||||
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
|
||||
// Mai il mazzo, la mano avversaria o il seed della distribuzione.
|
||||
export function observation(c, who = 'cpu') {
|
||||
return { who, deck: c.deck, hand: [...c.hands[who]], opponentCount: c.hands[other(who)].length,
|
||||
table: c.table.map(p => ({ ...p })), played: c.history.flatMap(t => t.cards),
|
||||
ownPoints: points(c.taken[who]), otherPoints: points(c.taken[other(who)]),
|
||||
remaining: c.stock.length, trump: c.trump, trumpCard: c.trumpCard,
|
||||
knownOpponent: c.drawnTrump === other(who) && !c.history.some(t => t.cards.some(a => a.id === c.trumpCard.id))
|
||||
&& !c.table.some(p => p.card.id === c.trumpCard.id) ? [c.trumpCard] : [] };
|
||||
}
|
||||
export function unseen(v) {
|
||||
const seen = new Set([...v.hand, ...v.played, ...v.table.map(p => p.card)].map(a => a.id));
|
||||
if (v.remaining) seen.add(v.trumpCard.id);
|
||||
return DECK_CARDS[v.deck ?? 'toscane'].filter(a => !seen.has(a.id));
|
||||
}
|
||||
// Giocatore attento di riferimento: scarta i lisci, conserva le briscole e prende i carichi.
|
||||
export function attentive(v) {
|
||||
const lead = v.table[0]?.card;
|
||||
const cost = a => a.points * 2 + (a.suit === v.trump ? 10 + a.strength : a.strength * 0.15);
|
||||
const sorted = v.hand.map((a, i) => ({ a, i })).sort((a, b) => cost(a.a) - cost(b.a));
|
||||
if (lead) {
|
||||
const wins = sorted.filter(({ a }) => beats(a, lead, v.trump));
|
||||
const take = wins.find(({ a }) => a.suit !== v.trump || lead.points + a.points >= 8 || !v.remaining);
|
||||
if (take) return take.i;
|
||||
}
|
||||
return sorted[0].i;
|
||||
}
|
||||
// Finale a informazione completa: minimax esatto, nessuna euristica né campionamento.
|
||||
// Massimizza i punti finali (quindi anche vittoria/patta), con memoizzazione per i sottogiochi.
|
||||
export function perfect(hand, enemy, table, trump, who, memo = new Map()) {
|
||||
const solve = (own, opp, lead, turn) => {
|
||||
if (!own.length && !opp.length && !lead.length) return 0;
|
||||
const key = `${own.map(a => a.id)}|${opp.map(a => a.id)}|${lead.map(p => p.who + p.card.id)}|${turn}`;
|
||||
if (memo.has(key)) return memo.get(key);
|
||||
const mine = turn === who, choices = mine ? own : opp;
|
||||
let best = mine ? -Infinity : Infinity;
|
||||
choices.forEach((card, i) => {
|
||||
const next = choices.filter((_, j) => i !== j), a = mine ? next : own, b = mine ? opp : next;
|
||||
const pair = [...lead, { who: turn, card }];
|
||||
let value;
|
||||
if (pair.length === 2) {
|
||||
const winner = trickWinner(pair, trump);
|
||||
value = (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(a, b, [], winner);
|
||||
} else value = solve(a, b, pair, other(turn));
|
||||
best = mine ? Math.max(best, value) : Math.min(best, value);
|
||||
});
|
||||
memo.set(key, best); return best;
|
||||
};
|
||||
return hand.map((card, i) => {
|
||||
const own = hand.filter((_, j) => i !== j), pair = [...table, { who, card }];
|
||||
if (pair.length === 1) return solve(own, enemy, pair, other(who));
|
||||
const winner = trickWinner(pair, trump);
|
||||
return (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(own, enemy, [], winner);
|
||||
});
|
||||
}
|
||||
const bestIndex = scores => scores.indexOf(Math.max(...scores));
|
||||
|
||||
// «A fassi male»: mondi possibili compatibili con le carte uscite, mai la distribuzione vera.
|
||||
// Stesse distribuzioni campionate per tutte le mosse, entrambi giocano attenti nei seguiti.
|
||||
// Taratura: 1000 partite/livello, seed imul(1..1000, 2654435761), tools/calibrate_briscola.mjs.
|
||||
// Vittorie di Andrea attento: bona 84,7%, seria 48,1%, male 35,9%; patte 0,9%, 2,5%, 2,0%.
|
||||
// Mazziere Andrea in 496/1000 partite. È una misura contro questa euristica, non contro persone.
|
||||
export function chooseCard(v, level = 'seria', seed = 1) {
|
||||
const random = rng(seed);
|
||||
if (level === 'bona') return Math.floor(random() * v.hand.length);
|
||||
if (level !== 'male') return attentive(v);
|
||||
const unknown = unseen(v);
|
||||
if (!v.remaining) return bestIndex(perfect(v.hand, unknown, v.table, v.trump, v.who));
|
||||
const totals = v.hand.map(() => 0), known = new Set(v.knownOpponent.map(a => a.id));
|
||||
for (let sample = 0; sample < 40; sample++) {
|
||||
const pool = shuffle(unknown.filter(a => !known.has(a.id)), random);
|
||||
const enemy = [...v.knownOpponent, ...pool.splice(0, v.opponentCount - known.size)];
|
||||
const stock = [...pool, v.trumpCard];
|
||||
for (let move = 0; move < v.hand.length; move++) {
|
||||
const hands = { [v.who]: [...v.hand], [other(v.who)]: [...enemy] };
|
||||
let table = [...v.table], turn = v.who, deck = [...stock], delta = 0, first = true;
|
||||
while (hands.player.length || hands.cpu.length || table.length) {
|
||||
const idx = first ? move : attentive({ hand: hands[turn], table, trump: v.trump, remaining: deck.length });
|
||||
first = false;
|
||||
const [card] = hands[turn].splice(idx, 1); table.push({ who: turn, card });
|
||||
if (table.length === 1) { turn = other(turn); continue; }
|
||||
turn = trickWinner(table, v.trump);
|
||||
delta += (turn === v.who ? 1 : -1) * points(table.map(p => p.card)); table = [];
|
||||
for (const who of [turn, other(turn)]) if (deck.length) hands[who].push(deck.shift());
|
||||
}
|
||||
totals[move] += delta;
|
||||
}
|
||||
}
|
||||
return bestIndex(totals);
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||
c.t += dt;
|
||||
if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||
if (c.step === 'trick' && c.t >= 1.25) collect(c);
|
||||
else if (c.step === 'count') {
|
||||
// Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
|
||||
c.count = Math.min(40, Math.floor(c.t / 0.35));
|
||||
if (c.count === 40) { c.result = result(c.taken); c.step = 'result'; }
|
||||
} else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.7) {
|
||||
c.aiSeed = (Math.imul(c.aiSeed, 1664525) + 1013904223) >>> 0;
|
||||
play(c, chooseCard(observation(c), c.level, c.aiSeed), 'cpu');
|
||||
}
|
||||
}
|
||||
export function countRows(c) {
|
||||
return ['player', 'cpu'].flatMap(who => c.taken[who].map(card => ({ who, card })));
|
||||
}
|
||||
export function counted(c) {
|
||||
const totals = { player: 0, cpu: 0 };
|
||||
for (const p of countRows(c).slice(0, c.count)) totals[p.who] += p.card.points;
|
||||
return totals;
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
||||
if (c.step === 'result') return `${c.result.winner ? 'Vince ' + names[c.result.winner] : 'Patta'}! ${c.result.player} a ${c.result.cpu}.`;
|
||||
if (c.step === 'count') {
|
||||
const last = countRows(c)[c.count - 1];
|
||||
return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
|
||||
}
|
||||
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} sceglie…`;
|
||||
}
|
||||
+29
-13
@@ -1,7 +1,16 @@
|
||||
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
||||
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js';
|
||||
import * as bocce from './bocce.js';
|
||||
import * as biliardo from './biliardo.js';
|
||||
import * as briscola from './briscola.js';
|
||||
|
||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||
export const MINIGAMES = {
|
||||
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
|
||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
};
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
||||
@@ -230,7 +239,7 @@ export class Game {
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
else return false;
|
||||
@@ -248,8 +257,8 @@ export class Game {
|
||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||
return true;
|
||||
}
|
||||
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (def?.type === 'bocce') {
|
||||
// Bocce e biliardo: a partita finita, 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 ?? []));
|
||||
return true;
|
||||
}
|
||||
@@ -258,16 +267,19 @@ export class Game {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
|
||||
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
|
||||
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
|
||||
// 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() { 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;
|
||||
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
|
||||
const m = this.minigame();
|
||||
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;
|
||||
@@ -390,9 +402,10 @@ export class Game {
|
||||
const c = this.closeup;
|
||||
if (c) {
|
||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||
if (this.story.closeups[c.id].type === 'bocce') {
|
||||
bocceUpdate(c, dt);
|
||||
// Il risultato resta leggibile finché il giocatore torna al pallaio.
|
||||
const m = this.minigame();
|
||||
if (m) {
|
||||
m.update(c, dt);
|
||||
// Il risultato resta leggibile finché il giocatore non esce.
|
||||
return;
|
||||
}
|
||||
c.t += dt;
|
||||
@@ -429,7 +442,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] }
|
||||
: type === 'bocce' ? { id: e.closeup, ...newBocce(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));
|
||||
|
||||
+16
-6
@@ -19,6 +19,12 @@ export function trackFor(story, { screen, scene, phase }) {
|
||||
|
||||
const FADE = 1.2;
|
||||
|
||||
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta.
|
||||
function setLevel(a) {
|
||||
a.volume = a.level;
|
||||
a.muted = a.level === 0;
|
||||
}
|
||||
|
||||
export class Music {
|
||||
constructor({ muted = false, volume = 0.6 } = {}) {
|
||||
this.muted = muted;
|
||||
@@ -46,22 +52,26 @@ export class Music {
|
||||
if (this.current) this.old.push(this.current.audio);
|
||||
this.current = src ? { src, audio: this.start(src) } : null;
|
||||
}
|
||||
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero.
|
||||
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano.
|
||||
// Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e
|
||||
// resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese).
|
||||
const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck;
|
||||
if (this.current) {
|
||||
const a = this.current.audio;
|
||||
a.volume = a.volume < target ? Math.min(target, a.volume + step) : Math.max(target, a.volume - step);
|
||||
if (this.hold && a.volume === 0 && !a.paused) a.pause();
|
||||
a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step);
|
||||
setLevel(a);
|
||||
if (this.hold && a.level === 0 && !a.paused) a.pause();
|
||||
else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; });
|
||||
}
|
||||
for (const a of this.old) a.volume = Math.max(0, a.volume - step);
|
||||
this.old = this.old.filter(a => a.volume > 0 || (a.pause(), false));
|
||||
for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); }
|
||||
this.old = this.old.filter(a => a.level > 0 || (a.pause(), false));
|
||||
}
|
||||
|
||||
start(src) {
|
||||
const audio = new Audio(versioned(src));
|
||||
audio.loop = true;
|
||||
audio.volume = 0;
|
||||
audio.level = 0;
|
||||
setLevel(audio);
|
||||
audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto
|
||||
return audio;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { check, PAGE_TURN } from './game.js';
|
||||
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 { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -665,6 +667,7 @@ 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 === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
@@ -675,6 +678,11 @@ function drawCloseup(ctx, game, time) {
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
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);
|
||||
|
||||
@@ -101,10 +101,25 @@ 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); }
|
||||
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 });
|
||||
else if (kind === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); }
|
||||
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
|
||||
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
|
||||
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
|
||||
else if (kind === '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);
|
||||
|
||||
+101
-28
@@ -8,7 +8,10 @@ import { Music, trackFor } from './engine/music.js';
|
||||
import { Voices } from './engine/voices.js';
|
||||
import { Soundscape } from './engine/soundscape.js';
|
||||
import { soundscapeFor } from './story/soundscapes.js';
|
||||
import { aim as bocceAim, shoot as bocceShoot, setPower as boccePower, canThrow as bocceReady, status as bocceStatus } from './engine/bocce.js';
|
||||
import * as bocce from './engine/bocce.js';
|
||||
import * as biliardo from './engine/biliardo.js';
|
||||
import * as briscola from './engine/briscola.js';
|
||||
import { cardAt } from './engine/briscola-render.js';
|
||||
import { VERSION, versioned } from './version.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
@@ -398,8 +401,18 @@ canvas.addEventListener('pointerdown', e => {
|
||||
if (!running || e.button > 0) return;
|
||||
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||
if (game.closeup) {
|
||||
if (story.closeups[game.closeup.id].type === 'briscola') {
|
||||
if (game.skip()) return;
|
||||
press = { card: cardAt(game.closeup, scenePoint(e)), state: game.closeup };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
return;
|
||||
}
|
||||
const aimed = game.closeupAim(scenePoint(e));
|
||||
if (story.closeups[game.closeup.id].type === 'biliardo') {
|
||||
if (!aimed && !game.speech) return;
|
||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||
}
|
||||
press = { closeup: true };
|
||||
game.closeupAim(scenePoint(e));
|
||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||
if (!game.skip()) game.closeupPress();
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
@@ -410,6 +423,11 @@ canvas.addEventListener('pointerdown', e => {
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!press) return;
|
||||
if (press.card !== undefined) {
|
||||
const p = press; press = null;
|
||||
if (running && game.closeup === p.state && p.card >= 0 && cardAt(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
|
||||
return;
|
||||
}
|
||||
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
|
||||
clearTimeout(press.timer);
|
||||
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
||||
@@ -422,7 +440,7 @@ canvas.addEventListener('pointerup', e => {
|
||||
press = null;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
|
||||
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } });
|
||||
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined) { press = null; game?.closeupCancel(); } });
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||
@@ -430,18 +448,32 @@ canvas.addEventListener('contextmenu', e => {
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!running) return;
|
||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
|
||||
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
|
||||
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
|
||||
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||
});
|
||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||
|
||||
// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio
|
||||
// con un solo clic, anche per chi non vuole usare la pressione temporizzata.
|
||||
const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null;
|
||||
$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); });
|
||||
$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); });
|
||||
for (const [id, move] of [['bocce-left', -0.3], ['bocce-right', 0.3]])
|
||||
$(id).addEventListener('click', () => { const c = bocceState(); if (c) bocceAim(c, c.aim.u, c.aim.w + move); });
|
||||
addEventListener('blur', () => game?.closeupCancel());
|
||||
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
||||
const MINI = {
|
||||
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
||||
};
|
||||
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
|
||||
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
|
||||
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = $('briscola-card-' + i);
|
||||
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
|
||||
}
|
||||
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]])
|
||||
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
|
||||
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
|
||||
|
||||
// --- Verbi.
|
||||
for (const verb of VERBS) {
|
||||
@@ -528,13 +560,33 @@ window.addEventListener('keydown', e => {
|
||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
|
||||
if (running && game.closeup) {
|
||||
if (game.closeup.id === 'bocce') {
|
||||
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
|
||||
if (story.closeups[game.closeup.id].type === 'briscola') {
|
||||
const inControls = e.target.closest?.('#briscola-controls');
|
||||
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
|
||||
if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return;
|
||||
if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && e.key === 'Enter' && briscola.canPlay(game.closeup)) return;
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.repeat || game.skip()) return;
|
||||
const c = game.closeup;
|
||||
if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
|
||||
else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
|
||||
else if (c.step === 'result') game.closeupClose();
|
||||
else if (e.key === 'Enter') game.closeupPlay();
|
||||
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') game.closeupClose();
|
||||
else if (e.key.toLowerCase() === 'm') toggleMute();
|
||||
return;
|
||||
}
|
||||
if (miniState()) {
|
||||
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
|
||||
const c = game.closeup, m = mini();
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3));
|
||||
else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey)) c.aimLocked = true; }
|
||||
else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
|
||||
return;
|
||||
}
|
||||
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
|
||||
@@ -554,7 +606,7 @@ window.addEventListener('keydown', e => {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') game.closeupClose();
|
||||
else if ([' ', 'Enter'].includes(e.key) && story.closeups[game.closeup.id].type === 'bocce') { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
|
||||
else if ([' ', 'Enter'].includes(e.key) && miniState()) { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
|
||||
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
|
||||
else if (e.key === 'm' || e.key === 'M') toggleMute();
|
||||
return;
|
||||
@@ -572,7 +624,7 @@ window.addEventListener('keydown', e => {
|
||||
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
|
||||
});
|
||||
|
||||
// Alle bocce, lasciando spazio o Invio parte il tiro.
|
||||
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
||||
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); });
|
||||
|
||||
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
|
||||
@@ -613,30 +665,51 @@ function updateUi() {
|
||||
updateInventory();
|
||||
updateChoices();
|
||||
updateSpeech();
|
||||
const bocce = c?.id === 'bocce';
|
||||
$('bocce-controls').hidden = !bocce;
|
||||
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
||||
const cards = def?.type === 'briscola';
|
||||
$('bocce-controls').hidden = !mg || cards;
|
||||
$('bocce-controls').inert = !running;
|
||||
if (bocce) {
|
||||
$('briscola-controls').hidden = !cards;
|
||||
$('briscola-controls').inert = !running;
|
||||
if (cards) {
|
||||
$('panel').hidden = $('choices').hidden = true;
|
||||
const line = bocceStatus(c, story.closeups.bocce.names);
|
||||
const line = briscola.status(c, def.names);
|
||||
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = $('briscola-card-' + i), card = c.hands.player[i];
|
||||
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
|
||||
if (button.textContent !== label) button.textContent = label;
|
||||
button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
|
||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
||||
}
|
||||
}
|
||||
if (mg && !cards) {
|
||||
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
|
||||
$('panel').hidden = $('choices').hidden = true;
|
||||
const line = mg.status(c, def.names);
|
||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||
$('bocce-power').value = Math.round(c.power * 100);
|
||||
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
|
||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !bocceReady(c) || c.holding || !!game.speech;
|
||||
$('bocce-throw').textContent = c.step === 'result' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia';
|
||||
for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
|
||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !mg.canThrow(c) || c.holding || !!game.speech;
|
||||
$('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira';
|
||||
$('bocce-help').hidden = def.type !== 'bocce';
|
||||
$('biliardo-help').hidden = def.type !== 'biliardo';
|
||||
if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
|
||||
}
|
||||
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
|
||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
||||
$('pause-button').hidden = !running;
|
||||
$('spots-button').hidden = !running || !!game.closeup;
|
||||
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
||||
$('closeup-close').hidden = !running || !game.closeup;
|
||||
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : 'Chiudi';
|
||||
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
|
||||
$('panel').inert = !!c;
|
||||
canvas.classList.toggle('torch', !!torch);
|
||||
canvas.classList.toggle('bocce', bocce);
|
||||
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura");
|
||||
canvas.classList.toggle('bocce', !!mg);
|
||||
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo');
|
||||
$('stage').classList.toggle('briscola', !!cards);
|
||||
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
||||
// La pagina che comincia a girare fa il suo fruscio.
|
||||
const step = game.closeup?.step ?? null;
|
||||
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
||||
|
||||
@@ -5,6 +5,8 @@ export const soundscapes = {
|
||||
acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] },
|
||||
pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] },
|
||||
circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] },
|
||||
// Gli altri tavoli della sala: una stecca, una biglia che ne tocca un'altra, ogni tanto un bicchiere al bar.
|
||||
biliardo: { bed: null, level: 0, day: ['tavolo-lontano', 'tavolo-lontano', 'glass'], night: ['tavolo-lontano', 'tavolo-lontano', 'glass'], gap: [4, 10] },
|
||||
fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] },
|
||||
fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] },
|
||||
};
|
||||
@@ -15,6 +17,7 @@ const groups = {
|
||||
pieve: ['pieve', 'pieve_interno', 'oratorio'],
|
||||
circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'],
|
||||
fortezza: ['fortezza'],
|
||||
biliardo: ['circolo_4_maggio'],
|
||||
};
|
||||
|
||||
export function soundscapeFor(scene, closeup) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user