Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5a80d7af | ||
|
|
88f0439f79 | ||
|
|
c244424af9 | ||
|
|
c26d888263 | ||
|
|
f8dbd01358 | ||
|
|
e3d5d8b0d3 | ||
|
|
1b7511f6ca | ||
|
|
9d2cc2fcb3 | ||
|
|
44480221db | ||
|
|
4d13a4ef25 | ||
|
|
22f408cc60 | ||
|
|
927459344b | ||
|
|
08ab9ba069 | ||
|
|
1918c8d9c7 | ||
|
|
381254ed4d | ||
|
|
a11a6ef59f | ||
|
|
086e4682d4 | ||
|
|
bf22dc2511 |
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "bertovo.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"stand": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 53,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 27.4,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"x": 55,
|
||||||
|
"y": 0,
|
||||||
|
"w": 52,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 26.1,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 109,
|
||||||
|
"y": 0,
|
||||||
|
"w": 79,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 31.3,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"x": 190,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 30.2,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"laugh": {
|
||||||
|
"x": 250,
|
||||||
|
"y": 0,
|
||||||
|
"w": 54,
|
||||||
|
"h": 122,
|
||||||
|
"ax": 28.6,
|
||||||
|
"ay": 122
|
||||||
|
},
|
||||||
|
"chalk": {
|
||||||
|
"x": 306,
|
||||||
|
"y": 0,
|
||||||
|
"w": 55,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 25.0,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"shrug": {
|
||||||
|
"x": 363,
|
||||||
|
"y": 0,
|
||||||
|
"w": 91,
|
||||||
|
"h": 122,
|
||||||
|
"ax": 45.4,
|
||||||
|
"ay": 122
|
||||||
|
},
|
||||||
|
"cheer": {
|
||||||
|
"x": 456,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 125,
|
||||||
|
"ax": 31.0,
|
||||||
|
"ay": 125
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "chiaru.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"explain": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 56,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 31.5,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 58,
|
||||||
|
"y": 0,
|
||||||
|
"w": 64,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 38.5,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"x": 124,
|
||||||
|
"y": 0,
|
||||||
|
"w": 44,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 24.2,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"stand": {
|
||||||
|
"x": 170,
|
||||||
|
"y": 0,
|
||||||
|
"w": 44,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 24.2,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"cheer": {
|
||||||
|
"x": 216,
|
||||||
|
"y": 0,
|
||||||
|
"w": 66,
|
||||||
|
"h": 118,
|
||||||
|
"ax": 31.5,
|
||||||
|
"ay": 118
|
||||||
|
},
|
||||||
|
"shrug": {
|
||||||
|
"x": 284,
|
||||||
|
"y": 0,
|
||||||
|
"w": 79,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 42.2,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"x": 365,
|
||||||
|
"y": 0,
|
||||||
|
"w": 63,
|
||||||
|
"h": 98,
|
||||||
|
"ax": 30.3,
|
||||||
|
"ay": 98
|
||||||
|
},
|
||||||
|
"laugh": {
|
||||||
|
"x": 430,
|
||||||
|
"y": 0,
|
||||||
|
"w": 52,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 30.3,
|
||||||
|
"ay": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1006 B |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 235 KiB |
@@ -935,6 +935,10 @@
|
|||||||
"file": "andrea-127a90af53.mp3",
|
"file": "andrea-127a90af53.mp3",
|
||||||
"sec": 9.37
|
"sec": 9.37
|
||||||
},
|
},
|
||||||
|
"andrea|Trappizzio Berti, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.": {
|
||||||
|
"file": "andrea-089ce1d608.mp3",
|
||||||
|
"sec": 7.92
|
||||||
|
},
|
||||||
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
|
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
|
||||||
"file": "andrea-1eae1a8577.mp3",
|
"file": "andrea-1eae1a8577.mp3",
|
||||||
"sec": 6.1
|
"sec": 6.1
|
||||||
@@ -1603,6 +1607,18 @@
|
|||||||
"file": "andrea-a93c65234e.mp3",
|
"file": "andrea-a93c65234e.mp3",
|
||||||
"sec": 4.28
|
"sec": 4.28
|
||||||
},
|
},
|
||||||
|
"andrea|Di chi è quell'Audi nera?": {
|
||||||
|
"file": "andrea-b9a784137e.mp3",
|
||||||
|
"sec": 1.63
|
||||||
|
},
|
||||||
|
"arcu|Let it be.": {
|
||||||
|
"file": "arcu-6ba2f5285a.mp3",
|
||||||
|
"sec": 0.69
|
||||||
|
},
|
||||||
|
"andrea|Non è mica mia. Mi garba, però, nera fiammante.": {
|
||||||
|
"file": "andrea-ae928089b9.mp3",
|
||||||
|
"sec": 4.03
|
||||||
|
},
|
||||||
"andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
|
"andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
|
||||||
"file": "andrea-a9e7f3f9ad.mp3",
|
"file": "andrea-a9e7f3f9ad.mp3",
|
||||||
"sec": 3.11
|
"sec": 3.11
|
||||||
@@ -1623,6 +1639,10 @@
|
|||||||
"file": "andrea-3d15dd47bb.mp3",
|
"file": "andrea-3d15dd47bb.mp3",
|
||||||
"sec": 4.82
|
"sec": 4.82
|
||||||
},
|
},
|
||||||
|
"andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": {
|
||||||
|
"file": "andrea-8670e6ce99.mp3",
|
||||||
|
"sec": 7.6
|
||||||
|
},
|
||||||
"andrea|Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.": {
|
"andrea|Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.": {
|
||||||
"file": "andrea-6c9e7f684e.mp3",
|
"file": "andrea-6c9e7f684e.mp3",
|
||||||
"sec": 8.72
|
"sec": 8.72
|
||||||
@@ -1651,6 +1671,10 @@
|
|||||||
"file": "andrea-f6c10fd559.mp3",
|
"file": "andrea-f6c10fd559.mp3",
|
||||||
"sec": 6.28
|
"sec": 6.28
|
||||||
},
|
},
|
||||||
|
"andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": {
|
||||||
|
"file": "andrea-8d5911c8e7.mp3",
|
||||||
|
"sec": 4.53
|
||||||
|
},
|
||||||
"andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
|
"andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
|
||||||
"file": "andrea-0a073729fd.mp3",
|
"file": "andrea-0a073729fd.mp3",
|
||||||
"sec": 7.3
|
"sec": 7.3
|
||||||
@@ -2411,6 +2435,142 @@
|
|||||||
"file": "mariano-d65fb6e871.mp3",
|
"file": "mariano-d65fb6e871.mp3",
|
||||||
"sec": 3.42
|
"sec": 3.42
|
||||||
},
|
},
|
||||||
|
"andrea|Si fa una gara alle macchinine?!?!": {
|
||||||
|
"file": "andrea-17216997e2.mp3",
|
||||||
|
"sec": 2.74
|
||||||
|
},
|
||||||
|
"andrea|Che pista hai disegnato?": {
|
||||||
|
"file": "andrea-3dd737ed1b.mp3",
|
||||||
|
"sec": 1.46
|
||||||
|
},
|
||||||
|
"andrea|Le regole sono quelle solite?": {
|
||||||
|
"file": "andrea-ee03433b9c.mp3",
|
||||||
|
"sec": 1.6
|
||||||
|
},
|
||||||
|
"andrea|Vado a fare un girino alle case rosse.": {
|
||||||
|
"file": "andrea-cb2fa74266.mp3",
|
||||||
|
"sec": 1.97
|
||||||
|
},
|
||||||
|
"chiaru|Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?": {
|
||||||
|
"file": "chiaru-df4b1e3a0e.mp3",
|
||||||
|
"sec": 7.32
|
||||||
|
},
|
||||||
|
"chiaru|Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.": {
|
||||||
|
"file": "chiaru-a7d83e9454.mp3",
|
||||||
|
"sec": 7.35
|
||||||
|
},
|
||||||
|
"chiaru|Il tombino l'ho lasciato in pista: porta fortuna.": {
|
||||||
|
"file": "chiaru-3810b7d228.mp3",
|
||||||
|
"sec": 3.56
|
||||||
|
},
|
||||||
|
"chiaru|Le solite. Chi accompagna la macchinina col dito: accompagnè.": {
|
||||||
|
"file": "chiaru-bb17a500b5.mp3",
|
||||||
|
"sec": 4.39
|
||||||
|
},
|
||||||
|
"chiaru|Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.": {
|
||||||
|
"file": "chiaru-1db3c53c04.mp3",
|
||||||
|
"sec": 7.9
|
||||||
|
},
|
||||||
|
"chiaru|In tutti i casi, si torna dov'eri prima.": {
|
||||||
|
"file": "chiaru-a7ab8d241f.mp3",
|
||||||
|
"sec": 2.01
|
||||||
|
},
|
||||||
|
"chiaru|Vorrai dire un giochino!": {
|
||||||
|
"file": "chiaru-c106f9e9c4.mp3",
|
||||||
|
"sec": 1.14
|
||||||
|
},
|
||||||
|
"andrea|Ciao Mattè.": {
|
||||||
|
"file": "andrea-fc5a08bbd6.mp3",
|
||||||
|
"sec": 0.73
|
||||||
|
},
|
||||||
|
"andrea|No, aspetta.": {
|
||||||
|
"file": "andrea-04890c1d8e.mp3",
|
||||||
|
"sec": 1.39
|
||||||
|
},
|
||||||
|
"chiaru|Alla bona: ti faccio vedere dove si ferma. E io vado piano.": {
|
||||||
|
"file": "chiaru-d31cc223f6.mp3",
|
||||||
|
"sec": 5.08
|
||||||
|
},
|
||||||
|
"chiaru|Seria. Così mi piace.": {
|
||||||
|
"file": "chiaru-89bf2bb9f9.mp3",
|
||||||
|
"sec": 2.31
|
||||||
|
},
|
||||||
|
"chiaru|A fassi male. Poi non piangere.": {
|
||||||
|
"file": "chiaru-f551f6a268.mp3",
|
||||||
|
"sec": 2.5
|
||||||
|
},
|
||||||
|
"chiaru|Io intanto ripasso il gesso.": {
|
||||||
|
"file": "chiaru-a0d7dbb4c1.mp3",
|
||||||
|
"sec": 2.15
|
||||||
|
},
|
||||||
|
"andrea|Fabri! Che fai, giochi da solo?": {
|
||||||
|
"file": "andrea-44037129d8.mp3",
|
||||||
|
"sec": 2.71
|
||||||
|
},
|
||||||
|
"andrea|Come fai a prendere le biglie dove nessuno ci arriva?": {
|
||||||
|
"file": "andrea-c6670ffb48.mp3",
|
||||||
|
"sec": 2.52
|
||||||
|
},
|
||||||
|
"andrea|Dai, fammi vedere un rinquarto.": {
|
||||||
|
"file": "andrea-728f589a77.mp3",
|
||||||
|
"sec": 1.68
|
||||||
|
},
|
||||||
|
"andrea|Si fa una sfida a rinquarti?": {
|
||||||
|
"file": "andrea-fe39c8f30c.mp3",
|
||||||
|
"sec": 1.49
|
||||||
|
},
|
||||||
|
"andrea|Ci si vede, Fabri.": {
|
||||||
|
"file": "andrea-d48ea04521.mp3",
|
||||||
|
"sec": 1.37
|
||||||
|
},
|
||||||
|
"bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": {
|
||||||
|
"file": "bertovo-5e771069ff.mp3",
|
||||||
|
"sec": 5.67
|
||||||
|
},
|
||||||
|
"bertovo|Geometria. La biglia va dove le dici, basta chiederglielo per bene.": {
|
||||||
|
"file": "bertovo-914554e2ce.mp3",
|
||||||
|
"sec": 4.82
|
||||||
|
},
|
||||||
|
"bertovo|E con un po' di gesso.": {
|
||||||
|
"file": "bertovo-6c52f3fa0d.mp3",
|
||||||
|
"sec": 1.52
|
||||||
|
},
|
||||||
|
"bertovo|Guarda bene: prima lo tiro io, poi lo rifai te. Tre posizioni, una sponda.": {
|
||||||
|
"file": "bertovo-ad20035f2e.mp3",
|
||||||
|
"sec": 6.6
|
||||||
|
},
|
||||||
|
"bertovo|Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?": {
|
||||||
|
"file": "bertovo-80c18678cd.mp3",
|
||||||
|
"sec": 8.82
|
||||||
|
},
|
||||||
|
"bertovo|Occhio alle sponde, fuori.": {
|
||||||
|
"file": "bertovo-4d4d630038.mp3",
|
||||||
|
"sec": 1.43
|
||||||
|
},
|
||||||
|
"andrea|Alla bona, una sponda e basta.": {
|
||||||
|
"file": "andrea-8b3082981a.mp3",
|
||||||
|
"sec": 2.47
|
||||||
|
},
|
||||||
|
"andrea|No, mi tremano le mani.": {
|
||||||
|
"file": "andrea-28610c8178.mp3",
|
||||||
|
"sec": 1.23
|
||||||
|
},
|
||||||
|
"bertovo|Una sponda. Roba da principianti, ma va bene.": {
|
||||||
|
"file": "bertovo-d14be7e027.mp3",
|
||||||
|
"sec": 3.6
|
||||||
|
},
|
||||||
|
"bertovo|Due sponde. Così mi piace.": {
|
||||||
|
"file": "bertovo-7e1952241d.mp3",
|
||||||
|
"sec": 2.55
|
||||||
|
},
|
||||||
|
"bertovo|Tre sponde e niente aiuti. Qui nascono le geometrie celestiali.": {
|
||||||
|
"file": "bertovo-b582e39e44.mp3",
|
||||||
|
"sec": 4.86
|
||||||
|
},
|
||||||
|
"bertovo|Il tavolo è qui. Le sponde non scappano.": {
|
||||||
|
"file": "bertovo-f0d200f449.mp3",
|
||||||
|
"sec": 2.92
|
||||||
|
},
|
||||||
"andrea|Ingegnere, una partita ai birilli?": {
|
"andrea|Ingegnere, una partita ai birilli?": {
|
||||||
"file": "andrea-dbcf4d1083.mp3",
|
"file": "andrea-dbcf4d1083.mp3",
|
||||||
"sec": 2.49
|
"sec": 2.49
|
||||||
@@ -2675,6 +2835,38 @@
|
|||||||
"file": "ing_tozzi-282062c119.mp3",
|
"file": "ing_tozzi-282062c119.mp3",
|
||||||
"sec": 3.64
|
"sec": 3.64
|
||||||
},
|
},
|
||||||
|
"bertovo|M'hai battuto ai rinquarti. Però un lo dire a nessuno.": {
|
||||||
|
"file": "bertovo-9aa247cffe.mp3",
|
||||||
|
"sec": 4.2
|
||||||
|
},
|
||||||
|
"bertovo|Le sponde ci sono per qualcosa! Basta chiederglielo per bene.": {
|
||||||
|
"file": "bertovo-5d93de3623.mp3",
|
||||||
|
"sec": 4.13
|
||||||
|
},
|
||||||
|
"andrea|Questo me lo tengo per la prossima.": {
|
||||||
|
"file": "andrea-c64bb3be5a.mp3",
|
||||||
|
"sec": 1.82
|
||||||
|
},
|
||||||
|
"bertovo|Visto? Geometrie celestiali. Si riprova quando vuoi.": {
|
||||||
|
"file": "bertovo-ca941bee91.mp3",
|
||||||
|
"sec": 5.41
|
||||||
|
},
|
||||||
|
"chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": {
|
||||||
|
"file": "chiaru-91a4b23b22.mp3",
|
||||||
|
"sec": 4.44
|
||||||
|
},
|
||||||
|
"chiaru|Visto? La blu non perde mai. Mai.": {
|
||||||
|
"file": "chiaru-f8afa97da1.mp3",
|
||||||
|
"sec": 4.95
|
||||||
|
},
|
||||||
|
"chiaru|Ti ritiri? Allora la vinco a tavolino.": {
|
||||||
|
"file": "chiaru-3e949c1519.mp3",
|
||||||
|
"sec": 4.06
|
||||||
|
},
|
||||||
|
"chiaru|Pari. Si rifà, ma la pista la ridisegno io.": {
|
||||||
|
"file": "chiaru-91c549bb83.mp3",
|
||||||
|
"sec": 3.39
|
||||||
|
},
|
||||||
"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.": {
|
"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",
|
"file": "andrea-6484641c58.mp3",
|
||||||
"sec": 8.67
|
"sec": 8.67
|
||||||
|
|||||||
@@ -158,3 +158,20 @@ equitazione, minigolf. Risulta invece il circuito cittadino della «I Coppa di S
|
|||||||
11 giugno 1967: 5.600 m in senso orario attorno al poggio della Fortezza, partenza davanti alla scuola in Via
|
11 giugno 1967: 5.600 m in senso orario attorno al poggio della Fortezza, partenza davanti alla scuola in Via
|
||||||
Provinciale, con la strada di Massorondinaio asfaltata apposta come bretella (fonte: CAMET, «storia circuito San
|
Provinciale, con la strada di Massorondinaio asfaltata apposta come bretella (fonte: CAMET, «storia circuito San
|
||||||
Piero»). Il disegno della pista del campeggio va preso dai ricordi di Andrea **[DA ANDREA]**.
|
Piero»). Il disegno della pista del campeggio va preso dai ricordi di Andrea **[DA ANDREA]**.
|
||||||
|
|
||||||
|
## La pista col gesso delle Micro Machines (Andrea, 28 ottobre)
|
||||||
|
|
||||||
|
Sul parcheggio sotto casa, a Cardetole (le righe gialle), si disegnava col gesso la pista delle macchinine; si davano
|
||||||
|
spinte a turno e la macchinina doveva restare in pista: tutta fuori, si torna da dove si era partiti. Si gioca contro
|
||||||
|
Tozzino. Le macchinine: Micro Machines, le Formula 1 degli anni 80/90 con gli alettoni e i loro colori (senza marchi
|
||||||
|
né nomi veri). Proposta: vista dall'alto sull'asfalto, mira e spinta come al biliardo (riusarne la fisica), a turno,
|
||||||
|
vince chi taglia per primo il traguardo. Il quadro della pista e le macchinine da Codex (crediti finiti il 28: domani).
|
||||||
|
|
||||||
|
## Il libro di Fabrizio Berti: le cartoline dei vecchi posti (idea di Fabrizio Berti, 29 ottobre)
|
||||||
|
|
||||||
|
Fabrizio Berti (Bertovo nel gioco) ha scritto «Memorie di sport e di paese». Nel gioco il libro diventa un oggetto
|
||||||
|
dell'inventario: aprendolo, a seconda della scena in cui si è, compare la cartolina d'epoca di quel posto (San Piero
|
||||||
|
com'era). Si può riusare il primo piano del libro che c'è già (`libro-chiuso.png` / `libro-aperto.png`, effetto
|
||||||
|
`closeup`). Servono le cartoline **[DA ANDREA]** (dal libro, con il permesso dell'autore) e la scelta di quali scene ne
|
||||||
|
hanno una; dove non c'è, una battuta («Questo posto nel libro non c'è»). Come lo si ottiene: proposta, lo regala
|
||||||
|
Bertovo dopo una sfida ai rinquarti. Fabrizio Berti e il libro sono già nei Ringraziamenti.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
|
|||||||
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
|
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
|
||||||
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
|
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
|
||||||
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
|
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
|
||||||
|
| `closeups/micro-parcheggio.png`, `closeups/micro/*.png` (macchinine) | Codex, solo testo | nessuna | — | sì |
|
||||||
|
| `characters/chiaru.png` | Codex | `foto/chiaru.jpg` | Andrea (29 ottobre) | sì (il Chiaru consenziente) |
|
||||||
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
|
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
|
||||||
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
|
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
|
||||||
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
|
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
|
|
||||||
## Dove siamo arrivati
|
## Dove siamo arrivati
|
||||||
|
|
||||||
|
- **GP di Fortezza, guida semplice (29 ottobre)**: alla bona e Seria si tiene premuto per il gas e lasciando si
|
||||||
|
frena; lo sterzo segue la strada da solo (scansa le buche, sorpassa, prende la miscela), ma oltre la velocità che la
|
||||||
|
curva regge il motorino scivola fuori verso la paglia (`SIMPLE` in `gara.js`). Ginocchio in terra resta con lo
|
||||||
|
sterzo vero. Il pannello nasconde Sterza, Frena, swipe e sensibilità. Test in `gara.test.mjs` e
|
||||||
|
`npm run browser:gara`. Seria più difficile (parere di Codex, simulato): sopra il 110% del limite in curva si esce
|
||||||
|
di sicuro (stun 1,2 s), la curva pulita dà una spinta, rivali a 0,81; CURVA! (preavviso) e LASCIA! sul motorino.
|
||||||
|
In simulazione: chi frena giusto 1° in 46 s, tutto gas 4°-5° in 55-59 s.
|
||||||
|
- **La pista col gesso (29 ottobre, modifiche locali)**: le Micro Machines sul parcheggio di Cardetole contro il
|
||||||
|
Chiaru (Matteo Chiarusi, sprite di Codex dalla foto di Andrea, accanto alla pista). Motore in `micro.js`, disegno in
|
||||||
|
`micro-render.js`: pista disegnata a gesso dal codice sopra l'asfalto di Codex (`micro-parcheggio.png`), un giro,
|
||||||
|
una spinta a testa (mira e carica come al biliardo), le macchinine di Codex (`assets/closeups/micro/`). Penalità
|
||||||
|
alla francese, per tutte si torna dov'eri: fuori, tagliè (curva presa dalla parte di dentro o erba saltata),
|
||||||
|
cappottè (urto forte), accompagnè (dito tenuto oltre la carica piena). Tre livelli: alla bona la guida mostra dove
|
||||||
|
ci si ferma. Test in `tests/micro.test.mjs` e nel browser (`npm run browser:micro`). Battute del Chiaru provvisorie
|
||||||
|
[DA ANDREA]; il Chiaru ha dato il consenso (29 ottobre).
|
||||||
|
Poi: si sceglie la macchinina (sei, il Chiaru tiene la blu), la forza parte da zero appena si preme e la spinta
|
||||||
|
parte quando si lascia; saluto «Vado a fare un girino alle case rosse» / «Vorrai dire un giochino!» / «Ciao Mattè»;
|
||||||
|
Andrea chiama Bertovo «Fabri». Musica in prestito dal GP (gara.mp3): [DA ANDREA] la sua, con Suno.
|
||||||
|
- **Bertovo ora si chiama «Trappizzio Berti»** in tutte le scritte (Andrea, 29 ottobre); l'id resta `bertovo`.
|
||||||
|
Fabrizio Berti e il suo libro «Memorie di sport e di paese» nei Ringraziamenti; l'idea delle cartoline in `IDEE.md`.
|
||||||
|
- **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e
|
||||||
|
`rinquarto-render.js`, con fisica, mira, carica e suoni del biliardo. Sei posizioni riproducibili dal seme,
|
||||||
|
diretto coperto da una biglia e almeno una soluzione di sponda verificata con `simulate` per ciascuna.
|
||||||
|
Un tiro per posizione: punto al primo contatto col bersaglio dopo almeno 1/2/3 sponde (bona/seria/male),
|
||||||
|
senza prima toccare biglie viola o birilli. Guide: fino alla seconda sponda / 65 cm e prima sponda / nessuna;
|
||||||
|
vittoria con 3/3/2 punti su 6. Esito del tiro, contatore delle sponde e riepilogo finale persistente.
|
||||||
|
Integrati primo piano, pannello, aiuto e dialogo `bertovo` con scelta del livello; Bertovo resta senza sprite
|
||||||
|
e senza scena: **Andrea decide dove collocarlo**. Battute provvisorie segnate `[DA ANDREA]`.
|
||||||
|
Ripresa verificata con `node --test tests/*.test.mjs` (22 file, nessun rosso, comprese le voci già presenti)
|
||||||
|
e 14 test mirati in `tests/rinquarto.test.mjs`. Completata l'animazione dei birilli durante l'esito
|
||||||
|
e nel riepilogo finale: anche un urto appena prima dell'arresto termina la caduta senza cambiare punti o risultato.
|
||||||
|
Verifica visiva Playwright/SwiftShader ancora da eseguire: in questa sessione la sandbox impedisce l'avvio
|
||||||
|
del server locale e di Chromium (`Operation not permitted`). Nessun `make voci`, commit o pubblicazione.
|
||||||
|
|
||||||
- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
|
- **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
|
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
|
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
|
||||||
@@ -257,7 +291,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
`assets/raw/fortezza-planimetria.png`, satellite in `assets/raw/fortezza-satellite.png`); il tracciato è ricalcato
|
`assets/raw/fortezza-planimetria.png`, satellite in `assets/raw/fortezza-satellite.png`); il tracciato è ricalcato
|
||||||
sulla strada dipinta. Disegno `gara-render.js`, test `tests/gara.test.mjs`. I piloti sono dati in
|
sulla strada dipinta. Disegno `gara-render.js`, test `tests/gara.test.mjs`. I piloti sono dati in
|
||||||
`src/story/piloti.js`: una riga per amico, `motore` e `testa` da 1 a 5, colori dalle foto. Corrono Puntella (SR
|
`src/story/piloti.js`: una riga per amico, `motore` e `testa` da 1 a 5, colori dalle foto. Corrono Puntella (SR
|
||||||
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), il Porno (Andrea
|
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
|
||||||
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
|
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
|
||||||
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
|
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
|
||||||
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
||||||
@@ -268,6 +302,12 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
ponticino della Carza, a sinistra il Trappola. Il campo del Giannini: a destra le villette, a sinistra il Trappola;
|
ponticino della Carza, a sinistra il Trappola. Il campo del Giannini: a destra le villette, a sinistra il Trappola;
|
||||||
dal ponticino non ci si va. Via Liguri Magelli: lassù vicino al Betino si torna a casa, laggiù a destra il campo.
|
dal ponticino non ci si va. Via Liguri Magelli: lassù vicino al Betino si torna a casa, laggiù a destra il campo.
|
||||||
Da sapere: dove si gira, in fondo al ponticino, per Cardetole [DA ANDREA].
|
Da sapere: dove si gira, in fondo al ponticino, per Cardetole [DA ANDREA].
|
||||||
|
- Bertovo (Fabrizio Berti, consenso del 28 ottobre) al tavolo di mezzo del Circolo 4 Maggio, sprite di Codex dalla sua
|
||||||
|
foto (`prompts/personaggi/bertovo-sheet.txt`): parlandoci, i suoi rinquarti (`src/engine/rinquarto.js`). Dopo la
|
||||||
|
revisione di Codex: gara a punti (sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira
|
||||||
|
Bertovo, preciso secondo il livello), allenamento («Fammi vedere un rinquarto»: tira prima lui), posizioni misurate
|
||||||
|
(finestra di angoli buoni sempre più stretta), forza sempre piena, traiettoria con le sponde numerate e Prossima,
|
||||||
|
tocco che mira senza tirare, un dito solo per volta, frecce che girano finché tenute.
|
||||||
- Via Liguri Magelli (Street View di Andrea, 28 ottobre), a sinistra dal campo del Giannini: a destra la casa salmone
|
- Via Liguri Magelli (Street View di Andrea, 28 ottobre), a sinistra dal campo del Giannini: a destra la casa salmone
|
||||||
con la tenda del Trappola (il Barrettino, «Vai dal Trappola»: niente interno, [DA ANDREA]), l'Arcu sotto la tenda
|
con la tenda del Trappola (il Barrettino, «Vai dal Trappola»: niente interno, [DA ANDREA]), l'Arcu sotto la tenda
|
||||||
(scherzo: «ti ho visto du' minuti fa a Campomigliaio» col Betino, poi la sfida del GP), davanti la targa «Qui riposa la
|
(scherzo: «ti ho visto du' minuti fa a Campomigliaio» col Betino, poi la sfida del GP), davanti la targa «Qui riposa la
|
||||||
@@ -286,6 +326,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
6. Il nonno: confermare il nome (Settimo Villani) e decidere come usare la sua leggenda nel finale.
|
6. Il nonno: confermare il nome (Settimo Villani) e decidere come usare la sua leggenda nel finale.
|
||||||
7. Il Lele: come e quanto farlo comparire (con calma).
|
7. Il Lele: come e quanto farlo comparire (con calma).
|
||||||
8. Battute e modi di dire del paese.
|
8. Battute e modi di dire del paese.
|
||||||
|
9. ~~Consenso del Chiaru~~ Dato il 29 ottobre. Le sue battute vanno bene?
|
||||||
|
|
||||||
### Grafica
|
### Grafica
|
||||||
- Sfondi puliti 16:9 di Pieve, Cardetole, Campomigliaio, interno della Fortezza (sotterranei).
|
- Sfondi puliti 16:9 di Pieve, Cardetole, Campomigliaio, interno della Fortezza (sotterranei).
|
||||||
|
|||||||
@@ -192,6 +192,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||||
<p id="bocce-status" role="status"></p>
|
<p id="bocce-status" role="status"></p>
|
||||||
|
<!-- Il GP di Fortezza sul telefono: lo swipe qui, così il dito non copre la pista. -->
|
||||||
|
<div id="gara-pad" hidden role="application" aria-label="Zona dello swipe: tieni premuto per il gas e trascina verso dove vuoi andare">
|
||||||
|
Tieni premuto qui per il gas e trascina verso dove vuoi andare
|
||||||
|
</div>
|
||||||
<div class="bocce-adjust">
|
<div class="bocce-adjust">
|
||||||
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
||||||
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
||||||
@@ -221,13 +225,21 @@
|
|||||||
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
|
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
|
||||||
</details>
|
</details>
|
||||||
<details id="gara-help" hidden><summary>Come si gioca</summary>
|
<details id="gara-help" hidden><summary>Come si gioca</summary>
|
||||||
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. Con un solo dito: tieni premuto sulla scena per
|
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. <b>Alla bona e Seria</b> la guida è semplice:
|
||||||
|
tieni premuto sulla scena (o Spazio, o il pulsante Gas) per accelerare e lascia per frenare; lo sterzo segue la
|
||||||
|
strada da solo, scansa le buche e sorpassa. Ma se entri in curva troppo forte scivoli fuori, e nella paglia ti
|
||||||
|
fermi: molla prima delle curve. Sopra il motorino «CURVA!» avvisa che ne arriva una, «LASCIA!» dice quando
|
||||||
|
mollare. A Seria chi entra in curva troppo forte va fuori di sicuro, e chi la prende giusta fa la «curva pulita»
|
||||||
|
ed esce con una spinta in più.</p>
|
||||||
|
<p><b>Ginocchio in terra</b> si guida davvero. Con un solo dito: tieni premuto sulla scena per
|
||||||
il gas e trascina verso la direzione in cui vuoi andare sullo schermo. A destra vai verso destra, anche quando
|
il gas e trascina verso la direzione in cui vuoi andare sullo schermo. A destra vai verso destra, anche quando
|
||||||
il motorino scende. I piccoli movimenti non sterzano; il cerchietto segue gli swipe lunghi. Alza il dito per
|
il motorino scende. I piccoli movimenti non sterzano; il cerchietto segue gli swipe lunghi. Alza il dito per
|
||||||
mollare il gas e raddrizzare lo sterzo. Funziona anche col mouse premuto.</p>
|
mollare il gas e raddrizzare lo sterzo. Funziona anche col mouse premuto.</p>
|
||||||
<p>Con la tastiera: Spazio, Invio o ↑ per il gas, ← → per sterzare; oppure tieni premuti i pulsanti Sterza.
|
<p>Con la tastiera: Spazio, Invio o ↑ per il gas, ← → per sterzare; oppure tieni premuti i pulsanti Sterza.
|
||||||
↓ (o il pulsante Frena, tenuto premuto) frena, anche col gas dato. Dal pannello il
|
↓ (o il pulsante Frena, tenuto premuto) frena, anche col gas dato. Dal pannello il
|
||||||
pulsante alterna Gas e Molla. Durante la presentazione dei piloti un tocco passa al prossimo.</p>
|
pulsante alterna Gas e Molla. Durante la presentazione dei piloti un tocco passa al prossimo.</p>
|
||||||
|
<p>Sul telefono lo swipe si fa anche nel riquadro sotto la pista, così il dito non la copre. Il cursore
|
||||||
|
Sensibilità decide quanto sterza lo swipe: parte basso, alzalo se il motorino gira poco.</p>
|
||||||
<p>Le frecce gialle in terra sono acceleratori; le lattine rosse di miscela danno uno sprint (e ricompaiono); nelle
|
<p>Le frecce gialle in terra sono acceleratori; le lattine rosse di miscela danno uno sprint (e ricompaiono); nelle
|
||||||
buche, se ci passi forte, caschi. Fuori strada l'erba frena, e nella paglia ci si ferma. Alla bona la pista mostra
|
buche, se ci passi forte, caschi. Fuori strada l'erba frena, e nella paglia ci si ferma. Alla bona la pista mostra
|
||||||
in rosso dove frenare, Seria solo i coni, Ginocchio in terra niente. Esc esce.</p>
|
in rosso dove frenare, Seria solo i coni, Ginocchio in terra niente. Esc esce.</p>
|
||||||
@@ -243,6 +255,32 @@
|
|||||||
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
|
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
|
||||||
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
||||||
</details>
|
</details>
|
||||||
|
<details id="micro-help" hidden><summary>Come si gioca</summary>
|
||||||
|
<p>La pista col gesso sul parcheggio di Cardetole: un giro contro il Chiaru, una spinta a testa (prima tu, poi
|
||||||
|
lui). La tua macchinina è la rossa. Vince chi taglia per primo il traguardo a scacchi; se ci arrivate tutti e due
|
||||||
|
nello stesso giro di spinte, vince chi è andato più in là.</p>
|
||||||
|
<p>Se durante la corsa la macchinina esce <b>tutta</b> dal gesso, torna dov'era prima della spinta: vale anche se
|
||||||
|
ce la manda l'altra urtandola, e vale per i tornanti, dove non si salta dall'altra parte. Alla bona vedi dove si
|
||||||
|
ferma la spinta (verde dentro, rossa fuori), Seria solo la direzione, A fassi male una freccina. Il Chiaru è più
|
||||||
|
preciso a ogni livello.</p>
|
||||||
|
<p>Muovi il mouse o tocca il parcheggio per mirare: un tocco fissa solo la mira. Per spingere tieni premuto (o
|
||||||
|
Spazio) finché il dito si carica e lascia, oppure regola la forza e premi Spingi. Frecce ← → o i pulsanti Mira
|
||||||
|
girano la mira (Maiusc per i passi fini); Esc esce.</p>
|
||||||
|
</details>
|
||||||
|
<details id="rinquarto-help" hidden><summary>Come si gioca</summary>
|
||||||
|
<p>I rinquarti di Trappizzio Berti: sei posizioni. Su ognuna tiri prima tu, poi il tavolo torna com'era e tira Trappizzio Berti; un
|
||||||
|
punto per ogni rinquarto riuscito, vince chi ne fa di più (si può anche pareggiare). La tua biglia è la bianca, il
|
||||||
|
bersaglio la gialla con l'anello. Le biglie viola con la croce e i birilli sono disturbi: toccarne uno prima del
|
||||||
|
bersaglio annulla il tiro. La diretta è sempre coperta, ma c'è sempre un rinquarto possibile.</p>
|
||||||
|
<p>Punto se la bianca tocca la gialla dopo almeno le sponde richieste: conta il primo contatto. Alla bona una
|
||||||
|
sponda e la guida fino al secondo rimbalzo, Seria due sponde e la guida corta, A fassi male tre sponde e niente
|
||||||
|
guida. Le posizioni si fanno più strette man mano, e Trappizzio Berti è più preciso a ogni livello. La forza è sempre piena:
|
||||||
|
è un rompicapo di angoli. Nell'allenamento («Fammi vedere un rinquarto») tira prima lui, poi lo rifai tu.</p>
|
||||||
|
<p>Muovi il mouse o tocca il tavolo per mirare: un tocco fissa solo la mira. Per tirare premi Tira, oppure tieni
|
||||||
|
premuto sul tavolo (o Spazio) finché la stecca si carica e lascia. Frecce ← → o i pulsanti Mira (tenuti
|
||||||
|
premuti) girano la stecca, sempre più svelti; con Maiusc a passi fini. Dopo il tiro la traiettoria resta sul tavolo
|
||||||
|
con le sponde numerate e il punto del contatto: tocca o premi Prossima per andare avanti. Esc esce.</p>
|
||||||
|
</details>
|
||||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||||
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||||
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.35",
|
"version": "0.1.43",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"browser": "node tests/browser.mjs",
|
"browser": "node tests/browser.mjs",
|
||||||
"browser:biliardo": "node tests/biliardo.browser.mjs",
|
"browser:biliardo": "node tests/biliardo.browser.mjs",
|
||||||
|
"browser:micro": "node tests/micro.browser.mjs",
|
||||||
|
"browser:gara": "node tests/gara.browser.mjs",
|
||||||
"browser:tressette": "node tests/tressette.browser.mjs",
|
"browser:tressette": "node tests/tressette.browser.mjs",
|
||||||
"browser:scopa": "node tests/scopa.browser.mjs",
|
"browser:scopa": "node tests/scopa.browser.mjs",
|
||||||
"browser:briscola": "node tests/briscola.browser.mjs",
|
"browser:briscola": "node tests/briscola.browser.mjs",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function tablePatches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
|
||||||
function sphere(ctx, role, x, y, r, shadow = true) {
|
export function sphere(ctx, role, x, y, r, shadow = true) {
|
||||||
const [dark, mid, light] = BALL[role];
|
const [dark, mid, light] = BALL[role] ?? ['#43285f', '#bd83ef', '#f7e4ff'];
|
||||||
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
|
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;
|
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
|
||||||
if (shadow) {
|
if (shadow) {
|
||||||
@@ -50,7 +50,7 @@ export function pinFall(p, time) {
|
|||||||
if (p.visualDownAt === undefined) return 1;
|
if (p.visualDownAt === undefined) return 1;
|
||||||
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
|
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
|
||||||
}
|
}
|
||||||
function pin(ctx, p, time) {
|
export function pin(ctx, p, time) {
|
||||||
const x = sx(p.x), y = sy(p.y), fall = pinFall(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 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 tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
|
||||||
@@ -65,7 +65,7 @@ function pin(ctx, p, time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lighting;
|
let lighting;
|
||||||
function table(ctx, bg) {
|
export function table(ctx, bg) {
|
||||||
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
|
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
|
||||||
else {
|
else {
|
||||||
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
|
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
|
||||||
@@ -117,7 +117,7 @@ function aimGuide(ctx, c) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cue(ctx, x, y, angle, back, alpha = 1) {
|
export function cue(ctx, x, y, angle, back, alpha = 1) {
|
||||||
ctx.save(); ctx.globalAlpha = alpha;
|
ctx.save(); ctx.globalAlpha = alpha;
|
||||||
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
|
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
|
||||||
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
|
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
|
||||||
@@ -206,7 +206,7 @@ export function drawBiliardo(ctx, c, def, bg, sheet) {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
scoreboard(ctx, c, def);
|
scoreboard(ctx, c, def);
|
||||||
const hint = canThrow(c) ? predict(c).hit : null;
|
const hint = canThrow(c) ? predict(c).hit : null;
|
||||||
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
|
const instruction = c.holding ? c.charging ? 'Carica iniziata: rilascia per tirare.' : 'Tieni 0,6 s per caricare; un tocco fissa la mira.' : hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
|
||||||
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (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.';
|
: 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);
|
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHIO
|
|||||||
export const SWING = 1.4, FIXED_STEP = 1 / 240;
|
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).
|
// 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.
|
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
|
||||||
export const WINDUP = 0.18;
|
export const WINDUP = 0.18, LONG_PRESS = 0.6;
|
||||||
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
||||||
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
||||||
export const OTHER = { player: 'cpu', cpu: 'player' };
|
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||||||
@@ -20,7 +20,7 @@ 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; }
|
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 deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||||
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
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 list = c => Object.values(c.balls);
|
||||||
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
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.
|
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
||||||
@@ -30,7 +30,10 @@ export function castle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||||
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
|
// Il tiro più forte: al biliardo dell'Ing. Tozzi si arriva a STRONG (Andrea, 29 ottobre: «più potenza»); i rinquarti
|
||||||
|
// restano a MAX_SPEED, perché le loro posizioni sono misurate a quella forza.
|
||||||
|
export const STRONG = 720;
|
||||||
|
export const speedFor = (power, max = MAX_SPEED) => MIN_SPEED + (max - MIN_SPEED) * power;
|
||||||
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
|
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
|
||||||
export function reach(v) {
|
export function reach(v) {
|
||||||
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
|
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
|
||||||
@@ -48,7 +51,7 @@ export const LEVELS = {
|
|||||||
};
|
};
|
||||||
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
||||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
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,
|
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, maxSpeed: def.maxSpeed ?? STRONG, power: 0.45, angle: 0,
|
||||||
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
|
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.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();
|
c.pins = castle();
|
||||||
@@ -169,8 +172,9 @@ function step(w, dt) {
|
|||||||
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
|
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) {
|
export function strike(c, who, angle, power) {
|
||||||
const b = c.balls[who], v = speedFor(power);
|
c.turn = null;
|
||||||
|
const b = c.balls[who], v = speedFor(power, c.maxSpeed);
|
||||||
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
|
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.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
|
||||||
c.windup = WINDUP;
|
c.windup = WINDUP;
|
||||||
@@ -179,12 +183,12 @@ function strike(c, who, angle, power) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||||
export function simulate(c, who, angle, power) {
|
export function simulate(c, who, angle, power, { onContact, stop } = {}) {
|
||||||
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
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 w = { balls, pins, onContact, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
||||||
const b = balls.find(x => x.role === who), v = speedFor(power);
|
const b = balls.find(x => x.role === who), v = speedFor(power, c.maxSpeed);
|
||||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
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);
|
for (let i = 0; i < 15 / FIXED_STEP && !stop?.() && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
||||||
return judge(w.shot);
|
return judge(w.shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,12 +241,20 @@ function cpuThink(c, budget = 12) {
|
|||||||
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function update(c, dt) {
|
export function update(c, dt, onContact) {
|
||||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
dt = Math.min(dt, 0.25); c.t += dt;
|
dt = Math.min(dt, 0.25); c.t += dt;
|
||||||
c.visual.time += dt;
|
c.visual.time += dt;
|
||||||
if (c.last) c.last.age += dt;
|
if (c.last) c.last.age += dt;
|
||||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
if (c.turn && canThrow(c) && !c.holding) {
|
||||||
|
c.turn.time += dt;
|
||||||
|
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.03 : 0.6 + 1.8 * Math.min(1, c.turn.time / 1.2));
|
||||||
|
}
|
||||||
|
if (c.holding) {
|
||||||
|
c.holdTime += dt;
|
||||||
|
c.charging = c.holdTime >= c.holdDelay;
|
||||||
|
if (c.charging) c.power = c.fixedPower ?? (c.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime));
|
||||||
|
}
|
||||||
if (c.step === 'rolling' && c.windup > 0) {
|
if (c.step === 'rolling' && c.windup > 0) {
|
||||||
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
|
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
|
||||||
c.windup -= dt;
|
c.windup -= dt;
|
||||||
@@ -255,7 +267,7 @@ export function update(c, dt) {
|
|||||||
}
|
}
|
||||||
if (c.step === 'rolling' && !c.windup) {
|
if (c.step === 'rolling' && !c.windup) {
|
||||||
c.accumulator += dt;
|
c.accumulator += dt;
|
||||||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events, onContact };
|
||||||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||||
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
||||||
@@ -288,18 +300,40 @@ export function setPower(c, value) {
|
|||||||
c.power = clamp(value, 0, 1); return true;
|
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 shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
|
||||||
export function press(c) {
|
// Una pressione continua ruota senza dipendere dalla ripetizione della tastiera.
|
||||||
if (!canThrow(c) || c.holding) return false;
|
export function turn(c, dir, owner, fine = false) {
|
||||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
if (!canThrow(c) || c.holding || c.turn) return false;
|
||||||
|
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.0005 : 0.03);
|
||||||
|
c.aimLocked = true; return true;
|
||||||
}
|
}
|
||||||
export function cancel(c) {
|
export function stopTurn(c, owner) {
|
||||||
|
if (c.turn?.owner !== owner) return false;
|
||||||
|
c.turn = null; return true;
|
||||||
|
}
|
||||||
|
export function key(c, key, down, fine = false) {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||||||
|
if (down) { if (c.turn?.owner === key) c.turn.fine = fine; else turn(c, key === 'ArrowLeft' ? -1 : 1, key, fine); }
|
||||||
|
else stopTurn(c, key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function press(c, pointerId) {
|
||||||
|
if (!canThrow(c) || c.holding) return false;
|
||||||
|
c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS;
|
||||||
|
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0;
|
||||||
|
if (!c.holdDelay && c.fixedPower === undefined) c.power = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function cancel(c, pointerId) {
|
||||||
|
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
|
||||||
|
c.turn = null; c.charging = false;
|
||||||
if (!c.holding) return false;
|
if (!c.holding) return false;
|
||||||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||||
}
|
}
|
||||||
export function release(c) {
|
export function release(c, pointerId) {
|
||||||
|
if (c.pointerId !== pointerId) return false;
|
||||||
if (!c.holding) return false;
|
if (!c.holding) return false;
|
||||||
if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
|
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
|
||||||
c.holding = false; return strike(c, 'player', c.angle, c.power);
|
c.holding = c.charging = 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.
|
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
|
||||||
@@ -312,7 +346,7 @@ export function predict(c) {
|
|||||||
const cached = predictions.get(c);
|
const cached = predictions.get(c);
|
||||||
if (cached?.key === key) return cached.result;
|
if (cached?.key === key) return cached.result;
|
||||||
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
|
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
|
||||||
const v = speedFor(c.power);
|
const v = speedFor(c.power, c.maxSpeed);
|
||||||
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
|
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 result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
@@ -363,6 +397,7 @@ 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 === '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 === 'rolling') return 'Biglie in movimento…';
|
||||||
if (c.step === 'settle') return 'Si contano i birilli…';
|
if (c.step === 'settle') return 'Si contano i birilli…';
|
||||||
|
if (c.holding) return c.charging ? 'Carica iniziata: rilascia per tirare.' : 'Mira fissata. Tieni 0,6 s per iniziare la carica.';
|
||||||
const last = lastLine(c, names);
|
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})`;
|
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||||
import * as bocce from './bocce.js';
|
import * as bocce from './bocce.js';
|
||||||
import * as biliardo from './biliardo.js';
|
import * as biliardo from './biliardo.js';
|
||||||
|
import * as rinquarto from './rinquarto.js';
|
||||||
import * as basket from './basket.js';
|
import * as basket from './basket.js';
|
||||||
import * as pesca from './pesca.js';
|
import * as pesca from './pesca.js';
|
||||||
import * as briscola from './briscola.js';
|
import * as briscola from './briscola.js';
|
||||||
import * as tressette from './tressette.js';
|
import * as tressette from './tressette.js';
|
||||||
import * as scopa from './scopa.js';
|
import * as scopa from './scopa.js';
|
||||||
import * as gara from './gara.js';
|
import * as gara from './gara.js';
|
||||||
|
import * as micro from './micro.js';
|
||||||
|
|
||||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||||
export const MINIGAMES = {
|
export const MINIGAMES = {
|
||||||
@@ -17,8 +19,10 @@ export const MINIGAMES = {
|
|||||||
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||||
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||||
|
rinquarto: { create: (seed, level) => rinquarto.newRinquarto(seed, { level }), update: rinquarto.update, press: rinquarto.press, release: rinquarto.release, cancel: rinquarto.cancel, aim: rinquarto.aimScreen },
|
||||||
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
|
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
|
||||||
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
|
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
|
||||||
|
micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen },
|
||||||
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,9 +315,9 @@ export class Game {
|
|||||||
|
|
||||||
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||||
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
|
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
|
||||||
closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
|
closeupPress(pointerId) { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup, pointerId); }
|
||||||
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
|
closeupRelease(pointerId) { return !!this.minigame()?.release?.(this.closeup, pointerId); }
|
||||||
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
|
closeupCancel(pointerId) { return !!this.minigame()?.cancel?.(this.closeup, pointerId); }
|
||||||
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
|
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
|
||||||
|
|
||||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, SWIPE, pointAt, standings, player, lapOf, presenting, placeOf, INTRO } from './gara.js';
|
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, SWIPE, pointAt, standings, player, lapOf, presenting, placeOf, INTRO, mustLift, PREAVVISO } from './gara.js';
|
||||||
import { text, panel } from './bocce-render.js';
|
import { text, panel } from './bocce-render.js';
|
||||||
import { ambientHash as hash } from './ambient.js';
|
import { ambientHash as hash } from './ambient.js';
|
||||||
|
|
||||||
@@ -216,6 +216,12 @@ function result(ctx, c) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Una scritta sopra il motorino di Andrea, su un fondo scuro perché si legga anche sullo sterrato e sul telefono.
|
||||||
|
function tag(ctx, line, r, color) {
|
||||||
|
const x = Math.round(r.x), y = Math.round(r.y) - 20, w = line.length * 9 + 10;
|
||||||
|
ctx.fillStyle = 'rgba(23,22,15,0.85)'; ctx.fillRect(x - w / 2, y - 7, w, 14);
|
||||||
|
text(ctx, line, x, y, 10, color, 'center');
|
||||||
|
}
|
||||||
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
|
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||||
@@ -232,6 +238,10 @@ export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null)
|
|||||||
const me = player(c);
|
const me = player(c);
|
||||||
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
|
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
|
||||||
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
|
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
|
||||||
|
// La guida semplice: LASCIA! sopra il motorino quando serve frenare, e la curva pulita.
|
||||||
|
if (c.step === 'race' && c.holding && mustLift(c)) { if (Math.floor(c.age * 8) % 2) tag(ctx, 'LASCIA!', me, '#ff6a4a'); }
|
||||||
|
else if (c.step === 'race' && c.holding && mustLift(c, PREAVVISO)) tag(ctx, 'CURVA!', me, '#ffe28a');
|
||||||
|
if (c.step === 'race' && c.clean !== undefined && c.t - c.clean < 1.2) text(ctx, 'CURVA PULITA!', 320, 180, 9, '#b9e5b3', 'center');
|
||||||
if (c.step === 'result') result(ctx, c);
|
if (c.step === 'result') result(ctx, c);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP
|
|||||||
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
|
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
|
||||||
// Sterzo (rad/s a velocità piena di sterzo), freno, rallentamento senza gas, e quanto si scivola di lato in curva.
|
// Sterzo (rad/s a velocità piena di sterzo), freno, rallentamento senza gas, e quanto si scivola di lato in curva.
|
||||||
export const TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
|
export const TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
|
||||||
export const SWIPE = { dead: 10, reach: 60, gain: 2.6 };
|
// Lo swipe: zona morta e portata in pixel della scena; la sensibilità (0..1, cursore del pannello) va dal guadagno
|
||||||
|
// `soft` al `hard`, e lo sterzo del dito insegue quello voluto a `follow` al secondo (niente scatti). Sul telefono
|
||||||
|
// la prima versione era ingiocabile: si parte poco sensibili.
|
||||||
|
export const SWIPE = { dead: 14, reach: 60, soft: 1.2, hard: 3, follow: 7, sens: 0.3 };
|
||||||
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
|
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
|
||||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
|
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
|
||||||
@@ -47,12 +50,20 @@ export const RIDERS = [
|
|||||||
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
|
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
|
||||||
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
|
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
|
||||||
];
|
];
|
||||||
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
|
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista. `simple`: la
|
||||||
|
// guida semplice (Andrea, 29 ottobre: dal telefono lo sterzo era ingiocabile): si tiene premuto per il gas e lasciando
|
||||||
|
// si frena, lo sterzo segue la strada da solo, e chi entra in curva troppo forte scivola fuori. Ginocchio in terra
|
||||||
|
// tiene lo sterzo vero.
|
||||||
export const LEVELS = {
|
export const LEVELS = {
|
||||||
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
|
bona: { rivals: 0.74, stun: 0.4, guide: 'zone', simple: true },
|
||||||
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
|
// Seria (Andrea, 29 ottobre: «chi non rallenta in curva deve andare fuori»; valori proposti da Codex e simulati):
|
||||||
|
// si scivola di più, sopra il limite in curva si esce per forza, e la curva pulita dà una spinta.
|
||||||
|
seria: { rivals: 0.81, stun: 1.2, guide: 'coni', simple: true,
|
||||||
|
tune: { slide: 12, clean: { min: 0.85, slack: 2, time: 0.6, accel: 1.2 }, force: { over: 1.1, hold: 0.18, apex: 1.15, time: 0.3, guard: 0.6 } } },
|
||||||
male: { rivals: 0.93, stun: 1.4, guide: null },
|
male: { rivals: 0.93, stun: 1.4, guide: null },
|
||||||
};
|
};
|
||||||
|
// `def.simple` la forza (i test della guida vera la spengono anche alla bona).
|
||||||
|
export const simple = c => !!c.simple;
|
||||||
|
|
||||||
function catmull(p0, p1, p2, p3, t) {
|
function catmull(p0, p1, p2, p3, t) {
|
||||||
const t2 = t * t, t3 = t2 * t;
|
const t2 = t * t, t3 = t2 * t;
|
||||||
@@ -151,17 +162,19 @@ export function newGara(seed = Date.now() >>> 0, def = {}) {
|
|||||||
});
|
});
|
||||||
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
|
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
|
||||||
for (const r of riders) { r.grid = r.s; r.shown = false; }
|
for (const r of riders) { r.grid = r.s; r.shown = false; }
|
||||||
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
|
return { step: 'intro', t: 0, age: 0, seed, level, skill, simple: def.simple ?? !!skill.simple, tune: { ...SIMPLE, ...skill.tune }, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
|
||||||
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null,
|
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null, sens: SWIPE.sens,
|
||||||
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
|
power: SWIPE.sens, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
|
||||||
}
|
}
|
||||||
export const player = c => c.riders.find(r => r.id === 'andrea');
|
export const player = c => c.riders.find(r => r.id === 'andrea');
|
||||||
export const lapOf = r => clamp(Math.floor(Math.max(0, r.s) / TRACK.length) + 1, 1, LAPS);
|
export const lapOf = r => clamp(Math.floor(Math.max(0, r.s) / TRACK.length) + 1, 1, LAPS);
|
||||||
export const canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
|
export const canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
|
||||||
export const canAdjust = c => c.step === 'race'; // lo sterzo del pannello
|
// Lo sterzo e il freno del pannello, e il cursore della sensibilità (regolabile già prima del via).
|
||||||
|
export const canAdjust = c => c.step !== 'result';
|
||||||
export const aimFree = () => true;
|
export const aimFree = () => true;
|
||||||
export const nudge = () => false;
|
export const nudge = () => false;
|
||||||
export const setPower = () => false;
|
// Il cursore del pannello, nella gara, è la sensibilità dello sterzo col dito.
|
||||||
|
export function setPower(c, value) { c.sens = c.power = clamp(Number(value) || 0, 0, 1); return true; }
|
||||||
|
|
||||||
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
|
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
|
||||||
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
|
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
|
||||||
@@ -238,23 +251,90 @@ export function key(c, name, down) {
|
|||||||
export function steerButton(c, dir) { c.buttons = dir; return true; }
|
export function steerButton(c, dir) { c.buttons = dir; return true; }
|
||||||
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
|
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
|
||||||
export function steering(c) {
|
export function steering(c) {
|
||||||
c.drag = 0;
|
let want = 0;
|
||||||
if (c.pointer !== null && c.anchor && c.finger) {
|
if (c.pointer !== null && c.anchor && c.finger) {
|
||||||
const dx = c.finger[0] - c.anchor[0], dy = c.finger[1] - c.anchor[1];
|
const dx = c.finger[0] - c.anchor[0], dy = c.finger[1] - c.anchor[1];
|
||||||
|
const gain = SWIPE.soft + (SWIPE.hard - SWIPE.soft) * (c.sens ?? SWIPE.sens);
|
||||||
// Si ricalcola anche col dito fermo: il motorino intanto cambia direzione.
|
// Si ricalcola anche col dito fermo: il motorino intanto cambia direzione.
|
||||||
if (Math.hypot(dx, dy) >= SWIPE.dead) c.drag = clamp(wrapAngle(Math.atan2(dy, dx) - player(c).a) * SWIPE.gain, -1, 1);
|
if (Math.hypot(dx, dy) >= SWIPE.dead) want = clamp(wrapAngle(Math.atan2(dy, dx) - player(c).a) * gain, -1, 1);
|
||||||
}
|
}
|
||||||
|
// Lo sterzo del dito si avvicina a quello voluto un po' per volta (a ogni passo fisso), e si azzera subito alzandolo.
|
||||||
|
c.drag = c.pointer === null ? 0 : c.drag + (want - c.drag) * Math.min(1, SWIPE.follow * FIXED_STEP);
|
||||||
|
if (Math.abs(want - c.drag) < 1e-6) c.drag = want;
|
||||||
return clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
|
return clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
|
||||||
}
|
}
|
||||||
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
|
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
|
||||||
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
|
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
|
||||||
|
|
||||||
|
// Lo sterzo che segue la strada: si guarda un punto più avanti sulla propria corsia e ci si gira (gli avversari, e
|
||||||
|
// Andrea nella guida semplice). Andando troppo forte in curva lo sterzo non basta e si scivola fuori.
|
||||||
|
const follow = r => { const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6); return clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1); };
|
||||||
|
// La guida semplice di Andrea: lo sterzo scansa le buche, sorpassa e va sulle lattine di miscela; ma oltre la velocità
|
||||||
|
// che la curva regge (la più stretta nei SIMPLE.window pixel davanti) il motorino scivola verso l'esterno, tanto più
|
||||||
|
// quanto si esagera (SIMPLE.slide): a tutto gas, nelle curve vere si finisce nella paglia.
|
||||||
|
// Ogni livello può stringere (LEVELS.*.tune). `clean`: il premio della curva pulita, presa senza mai passare il
|
||||||
|
// limite (`slack` px/s di tolleranza) e senza frenare troppo (almeno `min` del limite): per `time` secondi in uscita
|
||||||
|
// si accelera di più (`accel`), senza superare la velocità massima. `force`: l'uscita sicura, sopra `over` volte il
|
||||||
|
// limite per `hold` secondi (o `apex` volte nel punto più stretto) si va nella paglia in `time` secondi, una volta per
|
||||||
|
// curva; rientrati, `guard` secondi di tregua.
|
||||||
|
export const SIMPLE = { grip: 1, slide: 8, window: 50, clean: null, force: null };
|
||||||
|
export function simpleLimit(r, s = r.s, tune = SIMPLE) {
|
||||||
|
let low = Infinity;
|
||||||
|
for (let d = -10; d <= tune.window; d += STEP) low = Math.min(low, limitAt(s + d));
|
||||||
|
return low * r.grip * tune.grip;
|
||||||
|
}
|
||||||
|
// La curva in corso: dentro si tiene il peggio dell'eccesso e la velocità più bassa (per il premio), e si guarda se
|
||||||
|
// si è andati troppo forte abbastanza a lungo (per l'uscita sicura).
|
||||||
|
function judgeCurve(c, r, dt) {
|
||||||
|
const tune = c.tune, s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length;
|
||||||
|
const k = CURVES.find(k => s >= k.start - 20 && s <= k.end + 10);
|
||||||
|
if (k) {
|
||||||
|
const w = r.curve?.k === k ? r.curve : (r.curve = { k, excess: 0, slow: Infinity, over: 0, fault: false });
|
||||||
|
w.excess = Math.max(w.excess, r.excess); w.slow = Math.min(w.slow, r.v);
|
||||||
|
const f = tune.force, limit = limitAt(k.apex) * r.grip * tune.grip;
|
||||||
|
if (f && !w.fault && !(r.guard > 0) && r.out === 0) {
|
||||||
|
w.over = r.v > limit * f.over ? w.over + dt : 0;
|
||||||
|
if (w.over >= f.hold || (Math.abs(s - k.apex) < STEP * 2 && r.v > limit * f.apex)) { w.fault = true; r.forced = f.time; }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = r.curve; r.curve = null;
|
||||||
|
if (!w || !tune.clean || w.fault) return;
|
||||||
|
const limit = limitAt(w.k.apex) * r.grip * tune.grip;
|
||||||
|
if (w.excess <= tune.clean.slack && w.slow >= limit * tune.clean.min && r.out === 0) {
|
||||||
|
r.push = tune.clean.time; r.cleans = (r.cleans ?? 0) + 1;
|
||||||
|
c.events.push({ kind: 'gara-giro', who: r.id, power: 1 }); c.clean = c.t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Andrea, nella guida semplice, deve lasciare entro `lead` secondi per arrivare alle curve davanti sotto il limite.
|
||||||
|
// Sopra il motorino: CURVA! in giallo con PREAVVISO secondi d'anticipo, poi LASCIA! in rosso quando è il momento (sul
|
||||||
|
// telefono non serve l'istante preciso, e lasciare troppo presto, che qui vuol dire frenare, non conviene).
|
||||||
|
export const PREAVVISO = 0.7, LIFT = 0;
|
||||||
|
export function mustLift(c, lead = LIFT) {
|
||||||
|
const r = player(c);
|
||||||
|
if (!simple(c) || c.step !== 'race' || r.out > 0) return false;
|
||||||
|
const horizon = r.v * r.v / (2 * BRAKE) + r.v * lead + 10;
|
||||||
|
for (let d = 0; d <= horizon; d += 4) {
|
||||||
|
const a = simpleLimit(r, r.s + d, c.tune), room = Math.max(0, d - r.v * lead);
|
||||||
|
if (r.v > a && r.v * r.v > a * a + 2 * BRAKE * room) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function autopilot(c, r) {
|
||||||
|
const next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < 30 + r.v * 0.6);
|
||||||
|
// Chi sta davanti più lento si passa dall'altra parte; le buche si scansano, la miscela si prende.
|
||||||
|
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 26 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 8);
|
||||||
|
if (next?.kind === 'buca') r.lane = next.lane > 0 ? -5 : 5;
|
||||||
|
else if (slow) r.lane = slow.lat > 0 ? -6 : 6;
|
||||||
|
else if (next?.kind === 'miscela') r.lane = next.lane;
|
||||||
|
r.excess = Math.max(0, r.v - simpleLimit(r, r.s, c.tune));
|
||||||
|
return follow(r);
|
||||||
|
}
|
||||||
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
|
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
|
||||||
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
|
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
|
||||||
// si fida troppo.
|
// si fida troppo.
|
||||||
function drive(c, r) {
|
function drive(c, r) {
|
||||||
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6);
|
r.steer = follow(r);
|
||||||
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
|
|
||||||
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
|
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
|
||||||
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
|
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
|
||||||
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
|
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
|
||||||
@@ -274,22 +354,28 @@ function drive(c, r) {
|
|||||||
}
|
}
|
||||||
function stepRider(c, r, dt) {
|
function stepRider(c, r, dt) {
|
||||||
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
|
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
|
||||||
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; }
|
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; if (!r.out && c.tune.force) r.guard = c.tune.force.guard; return; }
|
||||||
|
else if (r.id === 'andrea' && simple(c)) { r.gas = c.holding; r.brake = !c.holding; r.steer = autopilot(c, r); judgeCurve(c, r, dt); }
|
||||||
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
|
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
|
||||||
else drive(c, r);
|
else drive(c, r);
|
||||||
if (r.finished !== null) r.gas = false;
|
if (r.finished !== null) r.gas = false;
|
||||||
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
|
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
|
||||||
// Il freno vince sul gas: chi li tiene tutti e due frena.
|
// Il freno vince sul gas: chi li tiene tutti e due frena.
|
||||||
if (r.brake) r.v -= BRAKE * dt;
|
if (r.brake) { r.v -= BRAKE * dt; r.push = 0; }
|
||||||
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1);
|
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1) * (r.push > 0 ? c.tune.clean.accel : 1);
|
||||||
else r.v -= COAST * dt;
|
else r.v -= COAST * dt;
|
||||||
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
|
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
|
||||||
r.v = Math.max(0, r.v);
|
r.v = Math.max(0, r.v);
|
||||||
r.boost = Math.max(0, r.boost - dt);
|
r.boost = Math.max(0, r.boost - dt);
|
||||||
|
if (r.push) r.push = Math.max(0, r.push - dt);
|
||||||
|
if (r.guard) r.guard = Math.max(0, r.guard - dt);
|
||||||
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
|
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
|
||||||
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
|
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
|
||||||
r.a = wrapAngle(r.a + turn);
|
r.a = wrapAngle(r.a + turn);
|
||||||
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
|
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
|
||||||
|
if (r.excess) r.u -= TRACK.side[r.idx] * r.excess * c.tune.slide * dt; // la guida semplice: troppo forte, si allarga
|
||||||
|
// L'uscita sicura: verso l'esterno della curva, e lo sterzo non la salva.
|
||||||
|
if (r.forced > 0) { r.forced = Math.max(0, r.forced - dt); r.u = -(r.curve?.k.side ?? TRACK.side[r.idx]) * 90; r.v = Math.max(r.v, 30); }
|
||||||
r.u *= Math.exp(-GRIP * r.grip * dt);
|
r.u *= Math.exp(-GRIP * r.grip * dt);
|
||||||
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
|
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
|
||||||
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
|
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
|
||||||
@@ -298,7 +384,8 @@ function stepRider(c, r, dt) {
|
|||||||
if (Math.abs(r.lat) > VERGE) {
|
if (Math.abs(r.lat) > VERGE) {
|
||||||
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
|
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
|
||||||
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
|
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
|
||||||
if (r.v > 40 && r.finished === null) {
|
if ((r.v > 40 || r.forced > 0) && r.finished === null) {
|
||||||
|
r.forced = 0;
|
||||||
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
|
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
|
||||||
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
|
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
|
||||||
}
|
}
|
||||||
@@ -397,7 +484,7 @@ export function status(c) {
|
|||||||
const r = presenting(c);
|
const r = presenting(c);
|
||||||
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
|
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
|
||||||
}
|
}
|
||||||
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!';
|
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, ${simple(c) ? 'lascia per frenare' : 'sterza con ← →'}.` : 'Via!';
|
||||||
if (c.step === 'result') {
|
if (c.step === 'result') {
|
||||||
const who = c.riders.find(r => r.id === c.result.order[0]).name;
|
const who = c.riders.find(r => r.id === c.result.order[0]).name;
|
||||||
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
|
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
|
||||||
@@ -405,6 +492,9 @@ export function status(c) {
|
|||||||
const me = player(c), place = placeOf(c);
|
const me = player(c), place = placeOf(c);
|
||||||
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
|
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
|
||||||
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
|
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
|
||||||
|
if (simple(c)) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.clean !== undefined && c.t - c.clean < 1.5 ? 'Curva pulita!' : mustLift(c) && c.holding ? 'Lascia!' : mustLift(c, PREAVVISO) && c.holding ? 'Curva!' : c.holding ? 'Gas!' : 'Freno.'}`;
|
||||||
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
|
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
|
||||||
}
|
}
|
||||||
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas';
|
// Nella guida semplice il pulsante si tiene premuto: la scritta resta Gas (su iPhone un pulsante che cambia sotto il dito
|
||||||
|
// perde il tocco).
|
||||||
|
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : simple(c) ? 'Gas' : c.holding ? 'Molla' : 'Gas';
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { HALF, CAR, CARS, canThrow, guide, done, overHeld, carImage, choiceAt, why, rank } from './micro.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
const NAME = { player: 'Andrea', cpu: 'Chiaru' };
|
||||||
|
// Il gesso: i due bordi della pista e il traguardo, calcolati una volta per pista. Il tratto ha dei buchi, come il
|
||||||
|
// gesso vero sull'asfalto ruvido (sempre gli stessi, da un piccolo hash: non tremano da un fotogramma all'altro).
|
||||||
|
const chalks = new WeakMap();
|
||||||
|
const hash = i => { let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); h ^= h >>> 13; return ((Math.imul(h, 0xc2b2ae35) ^ (h >>> 16)) >>> 0) / 4294967296; };
|
||||||
|
function chalk(track) {
|
||||||
|
if (chalks.has(track)) return chalks.get(track);
|
||||||
|
const edges = [-1, 1].map(side => track.pts.map(([x, y], i) => [x - track.tan[i][1] * HALF * side, y + track.tan[i][0] * HALF * side]));
|
||||||
|
const [tx, ty] = track.tan[0], [x0, y0] = track.pts[0];
|
||||||
|
const out = { edges, finish: { x: x0, y: y0, nx: -ty, ny: tx, tx, ty } };
|
||||||
|
chalks.set(track, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function drawChalk(ctx, track) {
|
||||||
|
const { edges, finish } = chalk(track);
|
||||||
|
for (const edge of edges) for (const [width, color, jitter] of [[3, 'rgba(236,236,226,0.28)', 0.8], [1.5, 'rgba(246,246,238,0.85)', 0]]) {
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath();
|
||||||
|
let open = false;
|
||||||
|
edge.forEach(([x, y], i) => {
|
||||||
|
if (hash(i + width * 1000) < 0.06) { open = false; return; } // un buco nel gesso
|
||||||
|
const j = jitter * (hash(i * 7 + 3) - 0.5);
|
||||||
|
if (!open) { ctx.moveTo(x + j, y - j); open = true; } else ctx.lineTo(x + j, y - j);
|
||||||
|
});
|
||||||
|
ctx.closePath(); ctx.stroke();
|
||||||
|
}
|
||||||
|
// Il traguardo: la scacchiera di gesso di traverso alla pista, due file.
|
||||||
|
const { x, y, nx, ny, tx, ty } = finish, cell = 4;
|
||||||
|
for (let k = -HALF; k < HALF; k += cell) for (let row = 0; row < 2; row++) {
|
||||||
|
if ((Math.round(k / cell) + row) % 2) continue;
|
||||||
|
const cx = x + nx * (k + cell / 2) + tx * (row - 0.5) * cell, cy = y + ny * (k + cell / 2) + ty * (row - 0.5) * cell;
|
||||||
|
ctx.fillStyle = 'rgba(246,246,238,0.85)'; ctx.fillRect(Math.round(cx - cell / 2), Math.round(cy - cell / 2), cell, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function car(ctx, img, x, y, heading, alpha = 1, flipped = false) {
|
||||||
|
ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y); ctx.rotate(heading);
|
||||||
|
if (flipped) ctx.scale(1, -1);
|
||||||
|
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(-CAR.len / 2 + 1, -CAR.wid / 2 + 2, CAR.len, CAR.wid); // l'ombra
|
||||||
|
if (img) ctx.drawImage(img, -CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid);
|
||||||
|
else { ctx.fillStyle = '#d8402f'; ctx.fillRect(-CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid); }
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function dashed(ctx, x0, y0, x1, y1, color) {
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
// La mira: alla bona tutto il tiro, con la macchinina fantasma dove si ferma (rossa se esce); seria solo la linea; a
|
||||||
|
// fassi male una freccina. Quando si carica, il dito si tira indietro; se resta troppo alla carica piena diventa rosso
|
||||||
|
// (sta per essere un accompagnè).
|
||||||
|
function aim(ctx, c, img) {
|
||||||
|
const me = c.cars.player, ux = Math.cos(c.angle), uy = Math.sin(c.angle), kind = c.skill.guide;
|
||||||
|
if (kind === 'full') {
|
||||||
|
const g = guide(c), color = g.out ? '#ff7a5c' : '#b9f0a8';
|
||||||
|
dashed(ctx, me.x, me.y, g.x, g.y, color);
|
||||||
|
car(ctx, img, g.x, g.y, c.angle, 0.35);
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(g.x, g.y, CAR.r + 3, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
} else {
|
||||||
|
const length = kind === 'line' ? 70 : 24;
|
||||||
|
dashed(ctx, me.x, me.y, me.x + ux * length, me.y + uy * length, '#fff8dc');
|
||||||
|
const hx = me.x + ux * length, hy = me.y + uy * length;
|
||||||
|
ctx.fillStyle = '#fff8dc'; ctx.beginPath();
|
||||||
|
ctx.moveTo(hx + ux * 4, hy + uy * 4); ctx.lineTo(hx - uy * 3, hy + ux * 3); ctx.lineTo(hx + uy * 3, hy - ux * 3); ctx.fill();
|
||||||
|
}
|
||||||
|
const back = CAR.len / 2 + 4 + (c.charging ? c.power * 14 : 0), fx = me.x - ux * back, fy = me.y - uy * back;
|
||||||
|
const over = overHeld(c);
|
||||||
|
ctx.fillStyle = over ? '#ff6a4a' : '#e8b48f'; ctx.strokeStyle = '#6b3d26'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#f7d9c4'; ctx.fillRect(Math.round(fx + ux * 1.5) - 1, Math.round(fy + uy * 1.5) - 1, 2, 2); // l'unghia
|
||||||
|
// La forza che sale mentre si tiene premuto: una barretta sopra la macchinina.
|
||||||
|
if (c.holding) {
|
||||||
|
const x = Math.round(me.x - 15), y = Math.round(me.y - 19);
|
||||||
|
ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 1, y - 1, 32, 6);
|
||||||
|
ctx.fillStyle = over ? '#ff6a4a' : c.power > 0.75 ? '#ffd23c' : '#9be07c'; ctx.fillRect(x, y, Math.round(30 * c.power), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dove una macchinina ha fatto il fallo: una croce di gesso rosso che sfuma, e la macchinina (ribaltata, se ha cappottato)
|
||||||
|
// che sparisce per tornare al suo posto.
|
||||||
|
function returns(ctx, c, images) {
|
||||||
|
for (const r of c.returns) {
|
||||||
|
const age = c.visual.time - r.at;
|
||||||
|
if (age > 3) continue;
|
||||||
|
const a = 1 - age / 3;
|
||||||
|
ctx.strokeStyle = `rgba(255,110,80,${a})`; ctx.lineWidth = 2; ctx.beginPath();
|
||||||
|
ctx.moveTo(r.x - 5, r.y - 5); ctx.lineTo(r.x + 5, r.y + 5); ctx.moveTo(r.x + 5, r.y - 5); ctx.lineTo(r.x - 5, r.y + 5); ctx.stroke();
|
||||||
|
const fade = r.fault === 'cappotte' ? 1.2 : 0.6;
|
||||||
|
if (age < fade) car(ctx, images[r.role], r.x, r.y, r.heading + (r.fault === 'cappotte' ? 0.5 : 0), 0.7 * (1 - age / fade), r.fault === 'cappotte');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La scelta della macchinina: le sei in grande sul parcheggio, quella scelta cerchiata di gesso e un po' sollevata.
|
||||||
|
function choice(ctx, c, image) {
|
||||||
|
ctx.fillStyle = '#17160fc8'; ctx.fillRect(60, 56, 520, 250);
|
||||||
|
text(ctx, 'SCEGLI LA TUA MACCHININA', 320, 76, 12, '#efd49e', 'center');
|
||||||
|
CARS.forEach((car, i) => {
|
||||||
|
const [x, y] = choiceAt(i), on = i === c.choice, lift = on ? Math.sin(c.visual.time * 5) * 1.5 - 2 : 0, img = image?.(carImage(car.id, true));
|
||||||
|
if (on) { ctx.strokeStyle = 'rgba(246,246,238,0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, 50, 28, 0, 0, Math.PI * 2); ctx.stroke(); }
|
||||||
|
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(x - 37, y - 16, 78, 38); // l'ombra
|
||||||
|
if (img) ctx.drawImage(img, Math.round(x - img.width / 2), Math.round(y - img.height / 2 + lift));
|
||||||
|
text(ctx, car.name, x, y + 34, 8, on ? '#fff0c8' : '#a99d86', 'center');
|
||||||
|
});
|
||||||
|
const cpu = CARS[c.choice].id === 'blu' ? 'la rossa' : 'la blu e gialla';
|
||||||
|
text(ctx, `Il Chiaru prende ${cpu}. Tocca la tua, o frecce e Scegli.`, 320, 292, 8, '#d9ccba', 'center');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawMicro(ctx, c, def, bg, image) {
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); }
|
||||||
|
drawChalk(ctx, c.track);
|
||||||
|
if (c.step === 'choose') { choice(ctx, c, image); ctx.restore(); return; }
|
||||||
|
const images = { player: image?.(carImage(c.colors.player)), cpu: image?.(carImage(c.colors.cpu)) };
|
||||||
|
if (c.step === 'rolling' && c.trail.length > 1) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,248,220,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...c.trail[0]);
|
||||||
|
for (const p of c.trail.slice(1)) ctx.lineTo(...p);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
returns(ctx, c, images);
|
||||||
|
if (canThrow(c)) aim(ctx, c, images.player);
|
||||||
|
for (const who of ['cpu', 'player']) { const x = c.cars[who]; car(ctx, images[who], x.x, x.y, x.heading); }
|
||||||
|
// In alto: quanta pista ha fatto ciascuno (una barra col colore della macchinina) e cosa succede.
|
||||||
|
ctx.fillStyle = '#17160fe8'; ctx.fillRect(0, 0, 640, 22);
|
||||||
|
text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e');
|
||||||
|
const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level];
|
||||||
|
text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba');
|
||||||
|
const tint = id => CARS.find(x => x.id === id).color;
|
||||||
|
for (const [who, x0, color] of [['player', 200, tint(c.colors.player)], ['cpu', 430, tint(c.colors.cpu)]]) {
|
||||||
|
// Primo o secondo, mentre si va (Andrea, 29 ottobre).
|
||||||
|
const place = rank(c, who);
|
||||||
|
text(ctx, `${place}°`, x0 - 4, 11, 9, place === 1 ? '#ffe28a' : '#a99d86', 'right');
|
||||||
|
text(ctx, NAME[who], x0, 11, 8, '#fff0c8');
|
||||||
|
ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 140, 8);
|
||||||
|
ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(140 * done(c, who)), 8);
|
||||||
|
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 139, 7);
|
||||||
|
}
|
||||||
|
const turn = c.step === 'result' ? null : c.current === 'cpu' ? 'Spinge il Chiaru…' : c.step === 'aim' ? 'Tocca a te: mira e spingi' : null;
|
||||||
|
const line = overHeld(c) ? 'Lascia! Sennò è accompagnè' : c.line ?? turn;
|
||||||
|
if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, /^(Fuori|Tagliè|Cappottè|Accompagnè)!/.test(c.line ?? '') ? '#ffbf9f' : '#9fd0ff', 'center'); }
|
||||||
|
if (c.step === 'result') {
|
||||||
|
const w = c.result.winner, title = w === 'player' ? 'HAI BATTUTO IL CHIARU!' : w === 'cpu' ? 'HA VINTO IL CHIARU' : 'ARRIVATI PARI';
|
||||||
|
// Il perché, in due righe: chi è arrivato a quale spinta, e di quanto è andato più in là.
|
||||||
|
const [first, second] = why(c);
|
||||||
|
panel(ctx, 130, 132, 380, 92);
|
||||||
|
text(ctx, title, 320, 150, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center');
|
||||||
|
text(ctx, first, 320, 172, 8, '#efd49e', 'center');
|
||||||
|
text(ctx, second, 320, 186, 8, '#efd49e', 'center');
|
||||||
|
text(ctx, 'Torna dal Chiaru per uscire.', 320, 208, 8, '#d9ccba', 'center');
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// Le macchinine sulla pista col gesso (Andrea, 28 ottobre): sul parcheggio sotto casa, a Cardetole, si disegnava col
|
||||||
|
// gesso la pista e si davano spinte a turno alle Micro Machines; la macchinina doveva restare in pista: tutta fuori, si
|
||||||
|
// torna da dove si era partiti. Si gioca contro il Chiaru, un giro; vince chi taglia per primo il traguardo.
|
||||||
|
//
|
||||||
|
// Regole: una spinta a testa, prima Andrea e poi il Chiaru, così a fine giro hanno spinto lo stesso numero di volte.
|
||||||
|
// Se tagliano il traguardo tutti e due nello stesso giro di spinte vince chi è andato più in là. Le penalità, coi nomi
|
||||||
|
// alla francese che si usavano (Andrea, 29 ottobre), e per tutte si torna dove si era prima della spinta:
|
||||||
|
// - fuori: la macchinina esce tutta dal gesso (anche se ce la manda l'altra, urtandola);
|
||||||
|
// - tagliè: esce dalla parte di dentro di una curva, o salta l'erba di mezzo per tagliare la strada;
|
||||||
|
// - cappottè: un urto forte la ribalta;
|
||||||
|
// - accompagnè: il dito l'ha accompagnata invece di darle il colpetto (tenuto premuto oltre la carica piena).
|
||||||
|
// Unità: i pixel del primo piano 640×360; la pista si disegna sopra al parcheggio di Codex.
|
||||||
|
|
||||||
|
// La linea di mezzo, in senso orario a partire dal traguardo; la pista è larga 2·HALF, col gesso sui due bordi.
|
||||||
|
export const CONTROL = [
|
||||||
|
[150, 68], [300, 62], [450, 66], [540, 80], [586, 122], [580, 172], [536, 196], [470, 196], [414, 178],
|
||||||
|
[356, 168], [318, 196], [326, 240], [388, 262], [480, 260], [556, 262], [598, 290], [588, 324], [530, 334],
|
||||||
|
[400, 334], [250, 332], [130, 326], [74, 296], [58, 230], [62, 150], [88, 96],
|
||||||
|
];
|
||||||
|
export const HALF = 22, CAR = { len: 26, wid: 13, r: 6 }, LAPS = 1;
|
||||||
|
// La spinta: velocità di partenza da MIN_SPEED a MAX_SPEED, e l'asfalto che frena di DECEL px/s².
|
||||||
|
export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, TAP = 0.14, FIXED_STEP = 1 / 120;
|
||||||
|
const REST = 0.7, THINK = 1, RESTITUTION = 0.6, OUT = HALF + CAR.r, WINDOW = 30;
|
||||||
|
// FLIP: la velocità d'urto (px/s) che ribalta la macchinina colpita; ACCOMPAGNA: quanti secondi si può restare alla
|
||||||
|
// carica piena prima che diventi un accompagnè.
|
||||||
|
export const FLIP = 260, ACCOMPAGNA = 0.45;
|
||||||
|
export const FAULTS = { fuori: 'Fuori', taglie: 'Tagliè', cappotte: 'Cappottè', accompagne: 'Accompagnè' };
|
||||||
|
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||||||
|
|
||||||
|
// Quanto è bravo il Chiaru: quanto gli trema il dito (radianti e forza) e fin dove osa spingere (px). `guide`: cosa
|
||||||
|
// vede Andrea mentre mira (tutto il tiro col punto d'arrivo, solo la linea, solo la freccia).
|
||||||
|
export const LEVELS = {
|
||||||
|
bona: { wobble: 0.1, powerNoise: 0.16, ahead: 120, guide: 'full' },
|
||||||
|
seria: { wobble: 0.05, powerNoise: 0.08, ahead: 170, guide: 'line' },
|
||||||
|
male: { wobble: 0.022, powerNoise: 0.035, ahead: 200, guide: 'arrow' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||||
|
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * clamp(power, 0, 1);
|
||||||
|
// Quanta strada fa una macchinina spinta a velocità v (l'asfalto frena sempre uguale).
|
||||||
|
export const reach = v => v * v / (2 * DECEL);
|
||||||
|
export const powerFor = distance => clamp((Math.sqrt(2 * DECEL * Math.max(0, distance)) - MIN_SPEED) / (MAX_SPEED - MIN_SPEED), 0, 1);
|
||||||
|
|
||||||
|
// --- La pista: una curva chiusa (Catmull-Rom sui punti di controllo), ricampionata a passo costante.
|
||||||
|
export function buildTrack(control = CONTROL, step = 2) {
|
||||||
|
const n = control.length, dense = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [p0, p1, p2, p3] = [control[(i - 1 + n) % n], control[i], control[(i + 1) % n], control[(i + 2) % n]];
|
||||||
|
for (let k = 0; k < 24; k++) {
|
||||||
|
const t = k / 24, t2 = t * t, t3 = t2 * t;
|
||||||
|
const f = j => 0.5 * (2 * p1[j] + (p2[j] - p0[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2 + (3 * p1[j] - p0[j] - 3 * p2[j] + p3[j]) * t3);
|
||||||
|
dense.push([f(0), f(1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cum = [0];
|
||||||
|
for (let i = 1; i <= dense.length; i++) cum.push(cum[i - 1] + Math.hypot(dense[i % dense.length][0] - dense[i - 1][0], dense[i % dense.length][1] - dense[i - 1][1]));
|
||||||
|
const total = cum.at(-1), count = Math.round(total / step), pts = [];
|
||||||
|
for (let j = 0, i = 0; j < count; j++) {
|
||||||
|
const at = j * total / count;
|
||||||
|
while (cum[i + 1] < at) i++;
|
||||||
|
const a = dense[i], b = dense[(i + 1) % dense.length], t = (at - cum[i]) / (cum[i + 1] - cum[i] || 1);
|
||||||
|
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
||||||
|
}
|
||||||
|
const tan = pts.map((p, i) => {
|
||||||
|
const a = pts[(i - 1 + count) % count], b = pts[(i + 1) % count], d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
|
||||||
|
return [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
|
||||||
|
});
|
||||||
|
return { pts, tan, n: count, step: total / count, length: total };
|
||||||
|
}
|
||||||
|
export const TRACK = buildTrack();
|
||||||
|
|
||||||
|
// Il punto della linea di mezzo più vicino: vicino all'ultimo noto (`hint`), così nei tornanti non si salta dall'altra
|
||||||
|
// parte; senza `hint`, su tutta la pista. `s` è la distanza lungo la pista dal traguardo, `d` quella dalla linea.
|
||||||
|
export function project(track, x, y, hint) {
|
||||||
|
const { pts, n } = track;
|
||||||
|
const from = hint === undefined ? 0 : hint - WINDOW, to = hint === undefined ? n : hint + WINDOW;
|
||||||
|
let best = null;
|
||||||
|
for (let k = from; k < to; k++) {
|
||||||
|
const i = ((k % n) + n) % n, a = pts[i], b = pts[(i + 1) % n];
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1], len2 = dx * dx + dy * dy || 1;
|
||||||
|
const t = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / len2, 0, 1);
|
||||||
|
const d = Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
|
||||||
|
if (!best || d < best.d) best = { i, t, d };
|
||||||
|
}
|
||||||
|
return { i: best.i, d: best.d, s: (best.i + best.t) * track.step };
|
||||||
|
}
|
||||||
|
// Tutta fuori: nessun pezzo della macchinina tocca più la pista.
|
||||||
|
export const isOut = d => d > OUT;
|
||||||
|
// Uscita dalla parte di dentro di una curva (verso dove gira la pista): è un tagliè.
|
||||||
|
function inside(track, i, x, y) {
|
||||||
|
const a = track.tan[(i - 6 + track.n) % track.n], b = track.tan[(i + 6) % track.n], turn = a[0] * b[1] - a[1] * b[0];
|
||||||
|
const [px, py] = track.pts[i], [tx, ty] = track.tan[i], side = tx * (y - py) - ty * (x - px);
|
||||||
|
return Math.abs(turn) > 0.05 && Math.sign(turn) === Math.sign(side);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La macchinina tiene il conto della strada fatta (`progress`, anche oltre il giro) seguendo la pista da vicino.
|
||||||
|
function follow(track, car) {
|
||||||
|
const p = project(track, car.x, car.y, car.idx);
|
||||||
|
let delta = p.s - car.s;
|
||||||
|
if (delta > track.length / 2) delta -= track.length; else if (delta < -track.length / 2) delta += track.length;
|
||||||
|
car.progress += delta; car.s = p.s; car.idx = p.i;
|
||||||
|
if (isOut(p.d) && !car.fault) car.fault = inside(track, p.i, car.x, car.y) ? 'taglie' : 'fuori';
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCar(track, role, back, side) {
|
||||||
|
const i = (track.n - Math.round(back / track.step)) % track.n, [x, y] = track.pts[i], [tx, ty] = track.tan[i];
|
||||||
|
const car = { role, x: x - ty * side, y: y + tx * side, vx: 0, vy: 0, heading: Math.atan2(ty, tx), idx: i, s: 0, progress: 0, fault: null };
|
||||||
|
const p = project(track, car.x, car.y);
|
||||||
|
car.idx = p.i; car.s = p.s; car.progress = p.s > track.length / 2 ? p.s - track.length : p.s;
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
const cars = c => [c.cars.player, c.cars.cpu];
|
||||||
|
export const moving = c => cars(c).some(car => car.vx || car.vy);
|
||||||
|
|
||||||
|
// L'urto fra le due macchinine: due dischi uguali, un po' di rimbalzo. Se l'urto è forte, quella colpita (la più
|
||||||
|
// lenta delle due) si ribalta.
|
||||||
|
function collide(a, b, events) {
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy), min = CAR.r * 2;
|
||||||
|
if (d >= min || d < 1e-6) return;
|
||||||
|
const nx = dx / d, ny = dy / d, push = (min - d) / 2;
|
||||||
|
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
|
||||||
|
const rel = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||||
|
if (rel <= 0) return;
|
||||||
|
const j = rel * (1 + RESTITUTION) / 2;
|
||||||
|
const hit = Math.hypot(a.vx, a.vy) < Math.hypot(b.vx, b.vy) ? a : b;
|
||||||
|
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
|
||||||
|
if (rel > FLIP) hit.fault = 'cappotte';
|
||||||
|
events?.push({ kind: 'micro-urto', power: Math.min(1, rel / 300) });
|
||||||
|
}
|
||||||
|
function step(track, list, dt, events) {
|
||||||
|
for (const car of list) {
|
||||||
|
const v = Math.hypot(car.vx, car.vy);
|
||||||
|
if (!v) continue;
|
||||||
|
const nv = Math.max(0, v - DECEL * dt);
|
||||||
|
if (nv < 2) { car.vx = car.vy = 0; continue; }
|
||||||
|
car.vx *= nv / v; car.vy *= nv / v;
|
||||||
|
car.x += car.vx * dt; car.y += car.vy * dt;
|
||||||
|
}
|
||||||
|
collide(list[0], list[1], events);
|
||||||
|
for (const car of list) if (car.vx || car.vy) follow(track, car);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le macchinine fra cui scegliere (Andrea, 29 ottobre: «quando si parte fammi scegliere la macchinina»): il disegno di
|
||||||
|
// Codex in `assets/closeups/micro/<id>.png` (e `<id>-grande.png` per la scelta) e il colore della barra in alto.
|
||||||
|
// Il Chiaru tiene la sua blu e gialla; se la prende Andrea, lui prende la rossa.
|
||||||
|
export const CARS = [
|
||||||
|
{ id: 'rossa', name: 'Rossa e bianca', color: '#e0543c' },
|
||||||
|
{ id: 'blu', name: 'Blu e gialla', color: '#3c66d8' },
|
||||||
|
{ id: 'nera', name: 'Nera e oro', color: '#c9a23a' },
|
||||||
|
{ id: 'azzurra', name: 'Bianca e azzurra', color: '#6fb7ff' },
|
||||||
|
{ id: 'arancio', name: 'Arancione', color: '#f08a2c' },
|
||||||
|
{ id: 'viola', name: 'Viola e argento', color: '#9a5ad8' },
|
||||||
|
];
|
||||||
|
export const carImage = (id, big = false) => `assets/closeups/micro/${id}${big ? '-grande' : ''}.png`;
|
||||||
|
// Dove stanno le macchinine nella scelta: due file da tre, al centro del parcheggio.
|
||||||
|
export const choiceAt = i => [160 + (i % 3) * 160, 132 + Math.floor(i / 3) * 88];
|
||||||
|
export function pick(c, i = c.choice) {
|
||||||
|
if (c.step !== 'choose' || !CARS[i]) return false;
|
||||||
|
const mine = CARS[i].id;
|
||||||
|
c.colors = { player: mine, cpu: mine === 'blu' ? 'rossa' : 'blu' };
|
||||||
|
c.step = 'aim'; c.t = 0; c.aimLocked = false; // il clic che sceglie non deve fissare anche la mira
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function choose(c, i) {
|
||||||
|
if (c.step !== 'choose' || !Number.isFinite(i)) return false;
|
||||||
|
c.choice = (i + CARS.length) % CARS.length; return true;
|
||||||
|
}
|
||||||
|
const carUnder = (x, y) => CARS.findIndex((_, i) => { const [cx, cy] = choiceAt(i); return Math.abs(x - cx) < 44 && Math.abs(y - cy) < 30; });
|
||||||
|
|
||||||
|
export function newMicro(seed = Date.now() >>> 0, def = {}) {
|
||||||
|
const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK;
|
||||||
|
const c = { step: 'choose', choice: 0, colors: null, t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0,
|
||||||
|
holding: false, holdTime: 0, accumulator: 0, events: [], track, laps: LAPS, flicks: { player: 0, cpu: 0 },
|
||||||
|
line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } };
|
||||||
|
// In griglia: Andrea all'esterno, il Chiaru all'interno, poco prima del traguardo.
|
||||||
|
c.cars = { player: newCar(track, 'player', 18, -11), cpu: newCar(track, 'cpu', 18, 11) };
|
||||||
|
c.angle = c.cars.player.heading;
|
||||||
|
// Si parte scegliendo la macchinina, se non è già data (`def.car`).
|
||||||
|
if (def.car) pick(c, CARS.findIndex(x => x.id === def.car));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canThrow = c => c.step === 'aim' && c.current === 'player';
|
||||||
|
export const finished = (c, car) => car.progress >= c.laps * c.track.length;
|
||||||
|
const snapshot = car => ({ x: car.x, y: car.y, heading: car.heading, idx: car.idx, s: car.s, progress: car.progress });
|
||||||
|
|
||||||
|
export function strike(c, who, angle, power) {
|
||||||
|
c.turn = null;
|
||||||
|
const car = c.cars[who], v = speedFor(power);
|
||||||
|
c.before = { player: snapshot(c.cars.player), cpu: snapshot(c.cars.cpu) };
|
||||||
|
for (const x of cars(c)) x.fault = null;
|
||||||
|
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v; car.heading = angle;
|
||||||
|
c.returns = c.returns.filter(r => c.visual.time - r.at < 4);
|
||||||
|
if (who === 'player') c.line = null;
|
||||||
|
c.shooter = who; c.angle = angle; c.power = power; c.plan = null; c.trail = [[car.x, car.y]];
|
||||||
|
c.step = 'rolling'; c.t = c.accumulator = 0; c.flicks[who]++;
|
||||||
|
c.events.push({ kind: 'micro-spinta', power }, { kind: 'micro-strisciata', power });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La spinta provata a vuoto su una copia: dove arriva ciascuna macchinina e se qualcuna esce.
|
||||||
|
export function simulate(c, who, angle, power) {
|
||||||
|
const list = cars(c).map(x => ({ ...x, fault: null })), car = list.find(x => x.role === who), v = speedFor(power);
|
||||||
|
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v;
|
||||||
|
for (let i = 0; i < 6 / FIXED_STEP && list.some(x => x.vx || x.vy); i++) step(c.track, list, FIXED_STEP);
|
||||||
|
return Object.fromEntries(list.map(x => [x.role, x]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il Chiaru guarda la pista davanti a sé: prova dei punti più o meno lontani, al centro e verso i bordi, e per ognuno
|
||||||
|
// anche le spinte storte che gli possono scappare; sceglie quello che in media lo porta più avanti senza uscire.
|
||||||
|
// Le prove si fanno a pezzi (BUDGET per fotogramma): tutte insieme fermerebbero il gioco per un decimo di secondo.
|
||||||
|
const BUDGET = 20;
|
||||||
|
function cpuCandidates(c) {
|
||||||
|
const me = c.cars.cpu, k = c.skill, list = [];
|
||||||
|
for (let ahead = 30; ahead <= k.ahead; ahead += 10) for (const side of [-10, 0, 10]) {
|
||||||
|
const i = (me.idx + Math.round(ahead / c.track.step)) % c.track.n, [px, py] = c.track.pts[i], [tx, ty] = c.track.tan[i];
|
||||||
|
const x = px - ty * side, y = py + tx * side, angle = Math.atan2(y - me.y, x - me.x), power = powerFor(Math.hypot(x - me.x, y - me.y));
|
||||||
|
for (const [da, dp] of [[0, 0], [k.wobble, 0], [-k.wobble, 0], [0, k.powerNoise], [0, -k.powerNoise]])
|
||||||
|
list.push({ angle, power, try: [angle + da, clamp(power + dp, 0, 1)] });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
export function cpuThink(c, budget = BUDGET) {
|
||||||
|
const t = c.thinking ??= { todo: cpuCandidates(c), scores: new Map() }, me = c.cars.cpu;
|
||||||
|
for (let i = 0; i < budget && t.todo.length; i++) {
|
||||||
|
const x = t.todo.pop(), r = simulate(c, 'cpu', ...x.try).cpu, key = x.angle + '|' + x.power;
|
||||||
|
t.scores.set(key, { angle: x.angle, power: x.power, score: (t.scores.get(key)?.score ?? 0) + (r.fault ? -80 : r.progress - me.progress) });
|
||||||
|
}
|
||||||
|
if (t.todo.length) return false;
|
||||||
|
const best = [...t.scores.values()].reduce((a, b) => b.score > a.score ? b : a, { score: -Infinity, angle: me.heading, power: 0.3 });
|
||||||
|
const k = c.skill;
|
||||||
|
c.plan = { angle: best.angle + deviation(c) * k.wobble, power: clamp(best.power + deviation(c) * k.powerNoise, 0.02, 1) };
|
||||||
|
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finita la spinta: chi ha fatto un fallo torna al suo posto, e alla fine di ogni giro di spinte si guarda il traguardo.
|
||||||
|
function settle(c) {
|
||||||
|
const names = { player: 'Andrea', cpu: 'il Chiaru' }, outs = cars(c).filter(car => car.fault);
|
||||||
|
// Uscita e ripresa la pista più in là: ha saltato l'erba di mezzo, è un tagliè.
|
||||||
|
for (const car of outs) if (car.fault === 'fuori' && !isOut(project(c.track, car.x, car.y).d)) car.fault = 'taglie';
|
||||||
|
// Le scritte di un giro di spinte restano insieme fino alla spinta dopo di Andrea: così vede anche le sue.
|
||||||
|
const said = outs.map(car => `${FAULTS[car.fault]}! ${names[car.role][0].toUpperCase() + names[car.role].slice(1)} torna dov'era.`);
|
||||||
|
// Dove è uscita resta una croce, e la macchinina si vede tornare indietro (solo disegno).
|
||||||
|
for (const car of outs) {
|
||||||
|
c.returns.push({ role: car.role, x: car.x, y: car.y, heading: car.heading, fault: car.fault, at: c.visual.time });
|
||||||
|
Object.assign(car, c.before[car.role], { vx: 0, vy: 0, fault: null });
|
||||||
|
c.events.push({ kind: 'micro-fuori', power: car.role === 'player' ? 1 : 0.5 });
|
||||||
|
}
|
||||||
|
// Rimessa al suo posto, non deve stare sopra all'altra.
|
||||||
|
const [a, b] = cars(c), d = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
if (outs.length === 1 && d < CAR.r * 2) {
|
||||||
|
const back = outs[0], other = back === a ? b : a, nx = d ? (back.x - other.x) / d : 1, ny = d ? (back.y - other.y) / d : 0;
|
||||||
|
back.x = other.x + nx * CAR.r * 2; back.y = other.y + ny * CAR.r * 2;
|
||||||
|
}
|
||||||
|
const mine = c.cars[c.shooter];
|
||||||
|
if (!outs.length && finished(c, mine)) said.push(`${c.shooter === 'player' ? 'Andrea' : 'Il Chiaru'} taglia il traguardo!`);
|
||||||
|
c.line = [c.line, ...said].filter(Boolean).join(' ') || null;
|
||||||
|
if (!outs.includes(mine) && finished(c, mine)) c.events.push({ kind: 'micro-arrivo', power: c.shooter === 'player' ? 1 : 0.5 });
|
||||||
|
// Il giro di spinte si chiude col Chiaru: solo allora si decide, così hanno spinto tutti e due lo stesso numero di volte.
|
||||||
|
const end = c.laps * c.track.length, over = { player: c.cars.player.progress - end, cpu: c.cars.cpu.progress - end };
|
||||||
|
if (c.shooter === 'cpu' && (over.player >= 0 || over.cpu >= 0)) {
|
||||||
|
const winner = over.player >= 0 && over.cpu >= 0 ? (Math.abs(over.player - over.cpu) < 1 ? 'draw' : over.player > over.cpu ? 'player' : 'cpu') : over.player >= 0 ? 'player' : 'cpu';
|
||||||
|
c.step = 'result'; c.result = { winner, flicks: { ...c.flicks }, over };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Chi ha già tagliato il traguardo aspetta; spinge l'altro.
|
||||||
|
c.current = OTHER[c.shooter];
|
||||||
|
c.step = 'aim'; c.t = 0;
|
||||||
|
if (c.current === 'player') c.angle = aimAhead(c);
|
||||||
|
}
|
||||||
|
// Quando tocca ad Andrea la mira parte già lungo la pista, un po' più avanti.
|
||||||
|
function aimAhead(c) {
|
||||||
|
const car = c.cars.player, i = (car.idx + Math.round(60 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i];
|
||||||
|
return Math.atan2(y - car.y, x - car.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.turn && canThrow(c) && !c.holding) {
|
||||||
|
c.turn.time += dt;
|
||||||
|
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.05 : 0.6 + 1.8 * Math.min(1, c.turn.time / 1.2));
|
||||||
|
}
|
||||||
|
if (c.holding) {
|
||||||
|
c.holdTime += dt;
|
||||||
|
c.charging = c.holdTime >= c.holdDelay;
|
||||||
|
if (c.charging) c.power = c.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime);
|
||||||
|
// Alla carica piena il dito non deve restare: si conta quanto ci sta, per l'accompagnè.
|
||||||
|
c.overHold = c.charging && c.power >= 1 ? (c.overHold ?? 0) + dt : 0;
|
||||||
|
}
|
||||||
|
if (c.step === 'rolling') {
|
||||||
|
c.accumulator += dt;
|
||||||
|
const list = cars(c), car = c.cars[c.shooter];
|
||||||
|
while (c.accumulator >= FIXED_STEP) { step(c.track, list, FIXED_STEP, c.events); c.accumulator -= FIXED_STEP; }
|
||||||
|
const last = c.trail.at(-1);
|
||||||
|
if (!last || Math.hypot(last[0] - car.x, last[1] - car.y) > 3) c.trail.push([car.x, car.y]);
|
||||||
|
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||||
|
} else if (c.step === 'settle' && c.t >= REST) settle(c);
|
||||||
|
else if (c.step === 'aim' && c.current === 'cpu') {
|
||||||
|
if (!c.plan && c.t >= THINK * 0.3) cpuThink(c);
|
||||||
|
if (c.plan && c.t >= THINK) strike(c, 'cpu', c.plan.angle, c.plan.power);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Comandi del giocatore: gli stessi del biliardo (mira col mouse o col dito, si tiene premuto per la forza).
|
||||||
|
export function aimAt(c, x, y) {
|
||||||
|
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||||
|
const car = c.cars.player;
|
||||||
|
if (Math.hypot(x - car.x, y - car.y) < CAR.r) return false;
|
||||||
|
c.angle = Math.atan2(y - car.y, x - car.x); return true;
|
||||||
|
}
|
||||||
|
export function aimScreen(c, [x, y]) {
|
||||||
|
if (c.step === 'choose') { const i = carUnder(x, y); return i >= 0 && choose(c, i); }
|
||||||
|
return x >= 0 && x <= 640 && y >= 24 && y <= 360 && 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.004 : 0.03); 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 c.step === 'choose' ? pick(c) : canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
|
||||||
|
export function turn(c, dir, owner, fine = false) {
|
||||||
|
if (c.step === 'choose') { choose(c, c.choice + dir); return false; } // nella scelta frecce e pulsanti cambiano macchinina
|
||||||
|
if (!canThrow(c) || c.holding || c.turn) return false;
|
||||||
|
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03);
|
||||||
|
c.aimLocked = true; return true;
|
||||||
|
}
|
||||||
|
export function stopTurn(c, owner) {
|
||||||
|
if (c.turn?.owner !== owner) return false;
|
||||||
|
c.turn = null; return true;
|
||||||
|
}
|
||||||
|
export function key(c, key, down, fine = false) {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||||||
|
if (down) { if (c.turn?.owner === key) c.turn.fine = fine; else turn(c, key === 'ArrowLeft' ? -1 : 1, key, fine); }
|
||||||
|
else stopTurn(c, key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function press(c, pointerId) {
|
||||||
|
if (c.step === 'choose') return pick(c);
|
||||||
|
if (!canThrow(c) || c.holding) return false;
|
||||||
|
// Premuto, la forza parte da zero e sale finché non si lascia (Andrea, 29 ottobre): come il dito che si carica.
|
||||||
|
c.turn = null; c.pointerId = pointerId; c.holdDelay = 0;
|
||||||
|
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function cancel(c, pointerId) {
|
||||||
|
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
|
||||||
|
c.turn = null; c.charging = false;
|
||||||
|
if (!c.holding) return false;
|
||||||
|
c.holding = false; c.power = c.previousPower; c.holdTime = 0; c.overHold = 0; return true;
|
||||||
|
}
|
||||||
|
export function release(c, pointerId) {
|
||||||
|
if (c.pointerId !== pointerId || !c.holding) return false;
|
||||||
|
if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto
|
||||||
|
const accompagne = (c.overHold ?? 0) > ACCOMPAGNA;
|
||||||
|
c.holding = c.charging = false; c.overHold = 0;
|
||||||
|
strike(c, 'player', c.angle, c.power);
|
||||||
|
if (accompagne) c.cars.player.fault = 'accompagne';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Sta tenendo il dito oltre la carica piena: il disegno lo avvisa prima che diventi un accompagnè.
|
||||||
|
export const overHeld = c => c.holding && (c.overHold ?? 0) > 0;
|
||||||
|
|
||||||
|
// La guida: dove si fermerebbe la macchinina, se nessuno la tocca, e se ci arriva restando in pista.
|
||||||
|
export function guide(c) {
|
||||||
|
const car = c.cars.player, length = reach(speedFor(c.power)), ux = Math.cos(c.angle), uy = Math.sin(c.angle);
|
||||||
|
let idx = car.idx, out = false;
|
||||||
|
for (let d = 0; d <= length; d += 2) {
|
||||||
|
const p = project(c.track, car.x + ux * d, car.y + uy * d, idx);
|
||||||
|
idx = p.i; if (isOut(p.d)) { out = true; break; }
|
||||||
|
}
|
||||||
|
return { x: car.x + ux * length, y: car.y + uy * length, length, out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il pulsante del pannello vale anche nella scelta della macchinina.
|
||||||
|
export const canPress = c => canThrow(c) || c.step === 'choose';
|
||||||
|
export const canAdjust = canPress;
|
||||||
|
export function button(c) {
|
||||||
|
if (c.step === 'choose') return 'Scegli';
|
||||||
|
if (c.step === 'result') return 'Finita';
|
||||||
|
if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…';
|
||||||
|
return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi';
|
||||||
|
}
|
||||||
|
// Il perché del risultato, da scrivere (Andrea, 29 ottobre: «ho tagliato prima io, ma ha vinto il Chiaru»). Le
|
||||||
|
// macchinine vere sono lunghe 4 centimetri e mezzo: un pixel è quasi due millimetri.
|
||||||
|
export const CM = 4.5 / CAR.len;
|
||||||
|
export function why(c) {
|
||||||
|
const { winner, flicks, over } = c.result, n = flicks.player;
|
||||||
|
if (over.player >= 0 && over.cpu >= 0) {
|
||||||
|
const cm = Math.max(1, Math.round(Math.abs(over.player - over.cpu) * CM));
|
||||||
|
if (winner === 'draw') return [`Tutti e due al traguardo alla spinta ${n},`, 'pari al millimetro.'];
|
||||||
|
return [`Tutti e due al traguardo alla spinta ${n}:`, `vince chi va più in là, ${winner === 'player' ? 'Andrea' : 'il Chiaru'} di ${cm} ${cm === 1 ? 'centimetro' : 'centimetri'}.`];
|
||||||
|
}
|
||||||
|
return winner === 'player' ? [`Andrea al traguardo alla spinta ${n};`, 'al Chiaru non è bastata la sua.'] : [`Il Chiaru al traguardo alla spinta ${n};`, 'Andrea non c\'è arrivato.'];
|
||||||
|
}
|
||||||
|
// Chi è davanti adesso (1 o 2), per strada fatta.
|
||||||
|
export const rank = (c, who) => c.cars[who].progress >= c.cars[OTHER[who]].progress ? 1 : 2;
|
||||||
|
// Quanta pista manca al traguardo, in percentuale, per il tabellone.
|
||||||
|
export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1);
|
||||||
|
export function status(c) {
|
||||||
|
if (c.step === 'choose') return `Scegli la macchinina: ${CARS[c.choice].name}. Tocca quella che vuoi, o frecce e Scegli.`;
|
||||||
|
const where = `Andrea ${rank(c, 'player')}° (${Math.round(done(c, 'player') * 100)}%), Chiaru ${rank(c, 'cpu')}° (${Math.round(done(c, 'cpu') * 100)}%).`;
|
||||||
|
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'} ${why(c).join(' ')}`;
|
||||||
|
if (c.step === 'rolling' || c.step === 'settle') return `${where} ${c.line ?? ''}`.trim();
|
||||||
|
if (c.current === 'cpu') return `${where} Spinge il Chiaru…`;
|
||||||
|
return `${where} ${c.line ? c.line + ' ' : ''}Tocca a te: mira e spingi.`;
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import { ambientEffects, ambientHash } from './ambient.js';
|
|||||||
import { drawTorch } from './torch.js';
|
import { drawTorch } from './torch.js';
|
||||||
import { drawBocce } from './bocce-render.js';
|
import { drawBocce } from './bocce-render.js';
|
||||||
import { drawBiliardo } from './biliardo-render.js';
|
import { drawBiliardo } from './biliardo-render.js';
|
||||||
|
import { drawRinquarto } from './rinquarto-render.js';
|
||||||
import { drawBasket } from './basket-render.js';
|
import { drawBasket } from './basket-render.js';
|
||||||
import { drawPesca } from './pesca-render.js';
|
import { drawPesca } from './pesca-render.js';
|
||||||
import { drawGara } from './gara-render.js';
|
import { drawGara } from './gara-render.js';
|
||||||
|
import { drawMicro } from './micro-render.js';
|
||||||
import { drawBriscola } from './briscola-render.js';
|
import { drawBriscola } from './briscola-render.js';
|
||||||
import { drawTressette } from './tressette-render.js';
|
import { drawTressette } from './tressette-render.js';
|
||||||
import { drawScopa } from './scopa-render.js';
|
import { drawScopa } from './scopa-render.js';
|
||||||
@@ -685,6 +687,11 @@ function drawCloseup(ctx, game, time) {
|
|||||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (def.type === 'micro') {
|
||||||
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
|
drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (def.type === 'gara') {
|
if (def.type === 'gara') {
|
||||||
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
|
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
|
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
|
||||||
@@ -701,6 +708,11 @@ function drawCloseup(ctx, game, time) {
|
|||||||
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
|
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (def.type === 'rinquarto') {
|
||||||
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
|
drawRinquarto(ctx, c, def, backgrounds.get(def.picture));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (def.type === 'biliardo') {
|
if (def.type === 'biliardo') {
|
||||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { LEN, WID, R, VIEW, WINDUP } from './biliardo.js';
|
||||||
|
import { table, sphere, pin, cue } from './biliardo-render.js';
|
||||||
|
import { guide, canThrow, shooter } from './rinquarto.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
|
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
||||||
|
const NAME = { andrea: 'Andrea', bertovo: 'Trappizzio Berti' };
|
||||||
|
// La traiettoria del tiro: resta sul tavolo finché non si va avanti, con le sponde numerate e il contatto che decide.
|
||||||
|
function trail(ctx, c) {
|
||||||
|
if (c.trail.length < 2) return;
|
||||||
|
const good = c.attempt.point, done = c.step === 'review';
|
||||||
|
ctx.strokeStyle = done ? (good ? '#b9e5b3' : '#ffbf9f') : '#fff8dccc'; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx(c.trail[0][0]), sy(c.trail[0][1]));
|
||||||
|
for (const [x, y] of c.trail.slice(1)) ctx.lineTo(sx(x), sy(y));
|
||||||
|
ctx.stroke();
|
||||||
|
c.attempt.marks.forEach((m, i) => {
|
||||||
|
ctx.fillStyle = '#17160f'; ctx.beginPath(); ctx.arc(sx(m.x), sy(m.y), 5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
text(ctx, String(i + 1), sx(m.x), sy(m.y) + 0.5, 7, '#ffe28a', 'center');
|
||||||
|
});
|
||||||
|
if (done && c.attempt.hit) {
|
||||||
|
const { x, y } = c.attempt.hit;
|
||||||
|
ctx.strokeStyle = good ? '#b9e5b3' : '#ff6a4a'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.arc(sx(x), sy(y), R * VIEW.k + 4, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function drawRinquarto(ctx, c, def, bg) {
|
||||||
|
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||||
|
table(ctx, bg);
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
|
||||||
|
if (canThrow(c)) {
|
||||||
|
const points = guide(c);
|
||||||
|
if (points.length) {
|
||||||
|
ctx.strokeStyle = '#fff8dc'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx(points[0][0]), sy(points[0][1]));
|
||||||
|
for (const [x, y] of points.slice(1)) ctx.lineTo(sx(x), sy(y));
|
||||||
|
ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
const b = c.balls.player;
|
||||||
|
cue(ctx, b.x, b.y, c.angle, R * VIEW.k + 6 + (c.charging ? 26 : 10));
|
||||||
|
} else if (c.visual.shot && c.windup > 0) {
|
||||||
|
const s = c.visual.shot;
|
||||||
|
cue(ctx, s.x, s.y, s.angle, R * VIEW.k + 32 * c.windup / WINDUP);
|
||||||
|
}
|
||||||
|
trail(ctx, c);
|
||||||
|
for (const p of c.pins) pin(ctx, p, c.visual.time);
|
||||||
|
// Il bersaglio ha un anello che pulsa; i disturbi una croce grossa, che si vede anche sul telefono.
|
||||||
|
const tgt = c.balls.cpu, pulse = 1 + Math.sin(c.visual.time * 4) * 0.5;
|
||||||
|
ctx.strokeStyle = '#ffe28a'; ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.arc(sx(tgt.x), sy(tgt.y), R * VIEW.k + 3 + pulse, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
for (const b of Object.values(c.balls)) {
|
||||||
|
sphere(ctx, b.role, sx(b.x), sy(b.y), R * VIEW.k);
|
||||||
|
if (!['player', 'cpu'].includes(b.role)) {
|
||||||
|
const k = R * VIEW.k;
|
||||||
|
ctx.strokeStyle = '#1b0d29'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx(b.x) - k, sy(b.y) - k); ctx.lineTo(sx(b.x) + k, sy(b.y) + k);
|
||||||
|
ctx.moveTo(sx(b.x) + k, sy(b.y) - k); ctx.lineTo(sx(b.x) - k, sy(b.y) + k); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
// In alto: chi tira, il punteggio, le sponde; l'esito sta qui, così non copre il tavolo.
|
||||||
|
ctx.fillStyle = '#17160ff0'; ctx.fillRect(54, 4, 532, 64);
|
||||||
|
text(ctx, c.skill.training ? 'I RINQUARTI DI TRAPPIZZIO BERTI · ALLENAMENTO' : 'I RINQUARTI DI TRAPPIZZIO BERTI', 320, 13, 9, '#efd49e', 'center');
|
||||||
|
const score = c.skill.training ? 'senza punti' : `Andrea ${c.score.andrea} Trappizzio Berti ${c.score.bertovo}`;
|
||||||
|
text(ctx, `Posizione ${c.position}/${c.skill.positions} ${score} Sponde ${c.attempt.cushions}/${c.skill.cushions}`, 320, 29, 9, '#fff0c8', 'center');
|
||||||
|
const who = NAME[shooter(c)];
|
||||||
|
const line = c.step === 'review' ? c.line : c.step === 'think' ? 'Tira Trappizzio Berti…' : c.step === 'rolling' ? `Tira ${who}…` : `Tocca ad ${who}: «Oh tirami questo rinquarto!?»`;
|
||||||
|
text(ctx, line, 320, 45, 8, c.step === 'review' ? (c.attempt.point ? '#b9e5b3' : '#ffbf9f') : '#9fd0ff', 'center');
|
||||||
|
if (c.step === 'review') text(ctx, c.last ? 'Tocca per il risultato' : 'Tocca o premi Prossima', 320, 59, 7, '#d9ccba', 'center');
|
||||||
|
if (c.step === 'result') {
|
||||||
|
const r = c.result, title = r.training ? 'ALLENAMENTO FINITO' : r.winner === 'player' ? 'HAI BATTUTO TRAPPIZZIO BERTI!' : r.winner === 'cpu' ? 'HA VINTO TRAPPIZZIO BERTI' : 'PARI';
|
||||||
|
panel(ctx, 155, 145, 330, 80);
|
||||||
|
text(ctx, title, 320, 166, 12, r.winner === 'player' ? '#b9e5b3' : '#efd49e', 'center');
|
||||||
|
if (!r.training) text(ctx, `Andrea ${r.points.andrea} · Trappizzio Berti ${r.points.bertovo}, su ${c.skill.positions} posizioni`, 320, 190, 8, '#efd49e', 'center');
|
||||||
|
text(ctx, 'Torna da Trappizzio Berti per uscire.', 320, 210, 8, '#d9ccba', 'center');
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// I rinquarti di Trappizzio Berti (Andrea, 28 ottobre; revisione di Codex): Trappizzio Berti mette le biglie in modo che la diretta sia
|
||||||
|
// coperta, e si deve arrivare al bersaglio (la gialla) dopo almeno tante sponde senza toccare prima i disturbi.
|
||||||
|
// Sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira Trappizzio Berti: un punto per ogni rinquarto
|
||||||
|
// riuscito, sei posizioni, vince chi ne fa di più. Nell'allenamento tira prima Trappizzio Berti, per far vedere, e senza punti.
|
||||||
|
//
|
||||||
|
// La forza è sempre piena: conta solo il primo contatto, quindi tirare piano non serve mai (revisione di Codex,
|
||||||
|
// rilievo 5). Il gioco è un rompicapo di angoli.
|
||||||
|
import * as billiard from './biliardo.js';
|
||||||
|
const { LEN, WID, R, PIN, simulate, predict } = billiard;
|
||||||
|
export { aimAt, aimScreen, nudge, release, cancel, turn, stopTurn, key } from './biliardo.js';
|
||||||
|
|
||||||
|
// `cushions`: sponde da fare prima del bersaglio. `width`: la finestra di angoli buoni (in gradi, a forza piena) che
|
||||||
|
// una posizione deve avere, dalla prima all'ultima: le posizioni si fanno più strette man mano. `bertovo`: quanto è
|
||||||
|
// preciso lui (la probabilità di stare dentro la finestra), dal più distratto al più spietato.
|
||||||
|
export const LEVELS = {
|
||||||
|
allenamento: { positions: 3, cushions: 1, width: [5, 4], bertovo: 1, guideBounces: 2, guideLength: Infinity, training: true },
|
||||||
|
bona: { positions: 6, cushions: 1, width: [5, 2.5], bertovo: 0.45, guideBounces: 2, guideLength: Infinity },
|
||||||
|
seria: { positions: 6, cushions: 2, width: [3, 1.5], bertovo: 0.65, guideBounces: 1, guideLength: 65 },
|
||||||
|
male: { positions: 6, cushions: 3, width: [2, 1], bertovo: 0.88, guideBounces: 0, guideLength: 0 },
|
||||||
|
};
|
||||||
|
// BUDGET: prove a vuoto per fotogramma mentre si prepara la posizione dopo (una costa circa 0,2 ms su un PC).
|
||||||
|
const SCAN = 720, STEP_ANGLE = Math.PI * 2 / SCAN, BUDGET = 15, THINK = 1.1;
|
||||||
|
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||||
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
|
const gauss = c => Math.sqrt(-2 * Math.log(1 - random(c))) * Math.cos(2 * Math.PI * random(c));
|
||||||
|
// z tale che una normale stia entro ±z con probabilità p: quanto deve essere stretto l'errore di Trappizzio Berti.
|
||||||
|
const zFor = p => p >= 1 ? Infinity : Math.sqrt(2) * erfinv(p);
|
||||||
|
function erfinv(x) {
|
||||||
|
const a = 0.147, l = Math.log(1 - x * x), t = 2 / (Math.PI * a) + l / 2;
|
||||||
|
return Math.sign(x) * Math.sqrt(Math.sqrt(t * t - l / a) - t);
|
||||||
|
}
|
||||||
|
const attempt = () => ({ cushions: 0, decided: false, point: false, reason: null, marks: [], hit: null });
|
||||||
|
|
||||||
|
export const shooter = c => c.order[c.round ?? 0];
|
||||||
|
// Si può mirare e tirare solo quando tocca ad Andrea (la biglia, per il biliardo, è sempre quella «player»).
|
||||||
|
export const canThrow = c => c.step === 'aim' && shooter(c) === 'andrea';
|
||||||
|
export const canAdjust = c => canThrow(c) && !c.holding;
|
||||||
|
// Il pulsante del pannello: Tira quando tocca ad Andrea, Prossima mentre si guarda l'esito.
|
||||||
|
export const canPress = c => canThrow(c) || c.step === 'review';
|
||||||
|
export const aimFree = c => !canThrow(c);
|
||||||
|
export const setPower = () => false; // la forza è sempre piena
|
||||||
|
export const button = c => c.step === 'review' ? (c.last ? 'Finita' : 'Prossima') : c.step === 'result' ? 'Finita'
|
||||||
|
: c.step === 'think' ? 'Tira Trappizzio Berti…' : c.step === 'aim' ? 'Tira' : 'In movimento…';
|
||||||
|
|
||||||
|
// Conta solo le sponde della bianca prima del primo ostacolo; il primo contatto decide.
|
||||||
|
function contact(result, required, { kind, a, b }) {
|
||||||
|
if (result.decided || (a.role !== 'player' && b?.role !== 'player')) return;
|
||||||
|
if (kind === 'rail') { result.cushions++; result.marks.push({ x: a.x, y: a.y }); return; }
|
||||||
|
result.decided = true;
|
||||||
|
const white = a.role === 'player' ? a : b, other = white === a ? b : a;
|
||||||
|
result.hit = { x: white.x, y: white.y };
|
||||||
|
result.point = kind === 'ball' && other.role === 'cpu' && result.cushions >= required;
|
||||||
|
result.reason = result.point ? null : kind === 'ball' && other.role === 'cpu' ? 'poche sponde' : 'disturbo toccato';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il tiro provato a vuoto, su una disposizione qualunque (quella in gioco se non se ne passa un'altra).
|
||||||
|
export function tryShot(c, angle, power = 1, layout = null) {
|
||||||
|
const w = layout ? { ...c, balls: layout.balls, pins: layout.pins } : c, result = attempt();
|
||||||
|
simulate(w, 'player', angle, power, { onContact: event => contact(result, c.skill.cushions, event), stop: () => result.decided });
|
||||||
|
if (!result.decided) result.reason = 'bersaglio mancato';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coperta: lungo la linea dei centri il primo urto della bianca non è il bersaglio (si confrontano le distanze del
|
||||||
|
// primo impatto, non solo la vicinanza alla linea: revisione di Codex, rilievo 8).
|
||||||
|
export function directCovered(c, layout = c) {
|
||||||
|
const a = layout.balls.player, b = layout.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||||
|
if (!d) return false;
|
||||||
|
const ux = dx / d, uy = dy / d;
|
||||||
|
const impact = (p, radius) => {
|
||||||
|
const t = (p.x - a.x) * ux + (p.y - a.y) * uy, off = Math.hypot(p.x - a.x - t * ux, p.y - a.y - t * uy), reach = R + radius;
|
||||||
|
return t > 0 && off < reach ? t - Math.sqrt(reach * reach - off * off) : Infinity;
|
||||||
|
};
|
||||||
|
const target = impact(b, R);
|
||||||
|
const obstacles = [...Object.values(layout.balls).filter(x => !['player', 'cpu'].includes(x.role)).map(x => impact(x, R)),
|
||||||
|
...layout.pins.filter(p => !p.down).map(p => impact(p, PIN))];
|
||||||
|
return obstacles.some(t => t < target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Una disposizione a caso: bianca e gialla lontane, un disturbo in mezzo, un altro e due birilli sparsi.
|
||||||
|
function candidate(c) {
|
||||||
|
const place = role => ball(role, 20 + random(c) * (LEN - 40), 18 + random(c) * (WID - 36));
|
||||||
|
const player = place('player'), cpu = place('cpu');
|
||||||
|
if (Math.hypot(cpu.x - player.x, cpu.y - player.y) < 70) return null;
|
||||||
|
const t = 0.3 + random(c) * 0.4;
|
||||||
|
const blocker = ball('blocker', player.x + (cpu.x - player.x) * t, player.y + (cpu.y - player.y) * t);
|
||||||
|
const layout = { balls: { player, cpu, blocker, extra: place('extra') },
|
||||||
|
pins: Array.from({ length: 2 }, () => ({ x: 20 + random(c) * (LEN - 40), y: 18 + random(c) * (WID - 36), red: false, down: false })) };
|
||||||
|
const objects = [...Object.values(layout.balls).map(b => ({ ...b, radius: R })), ...layout.pins.map(p => ({ ...p, radius: PIN }))];
|
||||||
|
if (objects.some((a, i) => objects.slice(i + 1).some(b => Math.hypot(a.x - b.x, a.y - b.y) < a.radius + b.radius + 2))) return null;
|
||||||
|
return directCovered(c, layout) ? layout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le finestre di angoli buoni (a forza piena), dalle prove fatte: gruppi di angoli vicini che fanno punto.
|
||||||
|
function windows(hits) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < SCAN; i++) {
|
||||||
|
if (!hits[i] || hits[(i - 1 + SCAN) % SCAN]) continue;
|
||||||
|
let n = 0; const cushions = [];
|
||||||
|
while (hits[(i + n) % SCAN] && n < SCAN) { cushions.push(hits[(i + n) % SCAN]); n++; }
|
||||||
|
const middle = cushions[Math.floor(n / 2)];
|
||||||
|
out.push({ angle: (i + (n - 1) / 2) * STEP_ANGLE, width: n * 360 / SCAN, cushions: middle });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// La finestra che deve avere la posizione numero k (da 0): dalla più larga alla più stretta.
|
||||||
|
const widthFor = (skill, k) => skill.width[0] + (skill.width[1] - skill.width[0]) * (skill.positions > 1 ? k / (skill.positions - 1) : 0);
|
||||||
|
// Il generatore lavora a pezzi (BUDGET prove alla volta), così il gioco non si ferma mentre prepara la posizione dopo.
|
||||||
|
function generate(c, budget) {
|
||||||
|
let g = c.gen;
|
||||||
|
while (budget > 0) {
|
||||||
|
if (!g) {
|
||||||
|
const layout = candidate(c);
|
||||||
|
if (!layout) { budget--; continue; }
|
||||||
|
g = c.gen = { layout, i: 0, hits: new Array(SCAN).fill(0) };
|
||||||
|
}
|
||||||
|
for (; g.i < SCAN && budget > 0; g.i++, budget--) {
|
||||||
|
const r = tryShot(c, g.i * STEP_ANGLE, 1, g.layout);
|
||||||
|
if (r.point) g.hits[g.i] = r.cushions;
|
||||||
|
}
|
||||||
|
if (g.i < SCAN) return null;
|
||||||
|
c.gen = null;
|
||||||
|
const k = c.ready.length + c.position, need = widthFor(c.skill, k);
|
||||||
|
// Si preferisce una finestra col numero giusto di sponde; alla bona deve esserci per forza.
|
||||||
|
const all = windows(g.hits), exact = all.filter(w => w.cushions === c.skill.cushions);
|
||||||
|
const pool = exact.length || c.skill.cushions === 1 ? exact : all;
|
||||||
|
const best = pool.sort((a, b) => b.width - a.width)[0];
|
||||||
|
if (best && best.width >= need) return { layout: g.layout, solution: { angle: best.angle, power: 1, width: best.width, cushions: best.cushions } };
|
||||||
|
g = null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const copy = layout => ({ balls: Object.fromEntries(Object.entries(layout.balls).map(([k, b]) => [k, { ...b }])), pins: layout.pins.map(p => ({ ...p })) });
|
||||||
|
|
||||||
|
// Prepara il tavolo per chi tira adesso, sempre dalla disposizione di partenza della posizione.
|
||||||
|
function setTable(c) {
|
||||||
|
const fresh = copy(c.start);
|
||||||
|
c.balls = fresh.balls; c.pins = fresh.pins;
|
||||||
|
c.step = shooter(c) === 'andrea' ? 'aim' : 'think';
|
||||||
|
c.t = c.accumulator = 0; c.power = 1; c.holding = false; c.charging = false; c.aimLocked = false; c.plan = null;
|
||||||
|
c.shot = c.pending = c.visual.shot = null; c.windup = 0; c.trail = [];
|
||||||
|
c.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x);
|
||||||
|
c.attempt = attempt();
|
||||||
|
c.line = shooter(c) === 'andrea' ? 'Trappizzio Berti: «Oh tirami questo rinquarto!?»' : 'Tira Trappizzio Berti…';
|
||||||
|
}
|
||||||
|
function nextPosition(c) {
|
||||||
|
while (!c.ready.length) { const p = generate(c, 4000); if (p) c.ready.push(p); }
|
||||||
|
const p = c.ready.shift();
|
||||||
|
c.position++; c.start = copy(p.layout); c.solution = p.solution; c.round = 0;
|
||||||
|
setTable(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newRinquarto(seed = Date.now() >>> 0, def = {}) {
|
||||||
|
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||||
|
const c = { ...billiard.newBiliardo(seed, { maxSpeed: billiard.MAX_SPEED }), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [],
|
||||||
|
order: LEVELS[level].training ? ['bertovo', 'andrea'] : ['andrea', 'bertovo'], ready: [], gen: null, fixedPower: 1, trail: [], last: false };
|
||||||
|
nextPosition(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trappizzio Berti mira al centro della finestra buona, con un errore che dipende dal livello: dentro la finestra con la
|
||||||
|
// probabilità `bertovo`. Il tiro poi si fa davvero: se sbaglia, sbaglia sul panno.
|
||||||
|
export function bertovoPlan(c) {
|
||||||
|
const half = c.solution.width / 2 * Math.PI / 180, z = zFor(c.skill.bertovo);
|
||||||
|
return { angle: c.solution.angle + (Number.isFinite(z) ? gauss(c) * half / z : 0), power: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dopo l'esito, un tocco (o Prossima) va avanti: tocca all'altro sulla stessa posizione, o alla posizione dopo.
|
||||||
|
export function advance(c) {
|
||||||
|
if (c.step !== 'review') return false;
|
||||||
|
if (c.round < c.order.length - 1) { c.round++; setTable(c); return true; }
|
||||||
|
if (c.position >= c.skill.positions) { finish(c); return true; }
|
||||||
|
nextPosition(c);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function finish(c) {
|
||||||
|
const { andrea, bertovo } = c.score;
|
||||||
|
c.step = 'result';
|
||||||
|
c.result = { winner: c.skill.training ? 'draw' : andrea > bertovo ? 'player' : andrea < bertovo ? 'cpu' : 'draw', points: { ...c.score }, training: !!c.skill.training };
|
||||||
|
}
|
||||||
|
// Mentre si guarda l'esito, Spazio, Invio, un tocco sul tavolo o il pulsante vanno avanti.
|
||||||
|
export function press(c, pointerId) { return c.step === 'review' ? advance(c) : billiard.press(c, pointerId); }
|
||||||
|
export function shoot(c) { return c.step === 'review' ? advance(c) : canThrow(c) && !c.holding && billiard.strike(c, 'player', c.angle, 1); }
|
||||||
|
|
||||||
|
export function update(c, dt) {
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
|
dt = Math.min(dt, 0.25);
|
||||||
|
// Intanto si prepara la posizione dopo, un po' per volta.
|
||||||
|
if (c.position + c.ready.length < c.skill.positions) { const p = generate(c, BUDGET); if (p) c.ready.push(p); }
|
||||||
|
// L'ultimo birillo può essere ancora in caduta: l'animazione va avanti anche fermi sull'esito.
|
||||||
|
if (c.step === 'result' || c.step === 'review') { c.visual.time += dt; return; }
|
||||||
|
if (c.step === 'think') {
|
||||||
|
c.t += dt; c.visual.time += dt;
|
||||||
|
if (c.t >= THINK) { const plan = bertovoPlan(c); billiard.strike(c, 'player', plan.angle, plan.power); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const before = c.step;
|
||||||
|
billiard.update(c, dt, event => contact(c.attempt, c.skill.cushions, event));
|
||||||
|
if (c.step === 'rolling' && !c.windup) {
|
||||||
|
const b = c.balls.player, last = c.trail.at(-1);
|
||||||
|
if (!last || Math.hypot(last[0] - b.x, last[1] - b.y) > 1.5) c.trail.push([b.x, b.y]);
|
||||||
|
}
|
||||||
|
if (before !== 'rolling' || c.step !== 'settle') return;
|
||||||
|
// Il tiro è finito: l'esito resta sul tavolo, con la traiettoria, finché non si va avanti.
|
||||||
|
if (!c.attempt.decided) c.attempt.reason = 'bersaglio mancato';
|
||||||
|
const who = shooter(c);
|
||||||
|
if (!c.skill.training) c.score[who] += Number(c.attempt.point);
|
||||||
|
c.history.push({ position: c.position, who, ...c.attempt });
|
||||||
|
c.line = `${who === 'andrea' ? 'Andrea' : 'Trappizzio Berti'}: ${c.attempt.point ? 'punto!' : `niente, ${c.attempt.reason}.`}`;
|
||||||
|
c.step = 'review';
|
||||||
|
c.last = c.round === c.order.length - 1 && c.position >= c.skill.positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La guida si ferma al contatto, al limite di lunghezza o alla sponda consentita.
|
||||||
|
export function guide(c) {
|
||||||
|
if (!c.skill.guideLength) return [];
|
||||||
|
const source = predict(c).points, points = [source[0]];
|
||||||
|
let remaining = c.skill.guideLength;
|
||||||
|
for (const end of source.slice(1, c.skill.guideBounces + 1)) {
|
||||||
|
const start = points.at(-1), dx = end[0] - start[0], dy = end[1] - start[1], distance = Math.hypot(dx, dy);
|
||||||
|
if (distance > remaining) { points.push([start[0] + dx * remaining / distance, start[1] + dy * remaining / distance]); break; }
|
||||||
|
points.push(end); remaining -= distance;
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const need = c => c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`;
|
||||||
|
export function status(c) {
|
||||||
|
const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Trappizzio Berti ${c.score.bertovo}.`;
|
||||||
|
const prefix = `Posizione ${c.position} di ${c.skill.positions}. ${score}`;
|
||||||
|
if (c.step === 'result') {
|
||||||
|
if (c.result.training) return 'Allenamento finito. Quando vuoi, la sfida vera.';
|
||||||
|
return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Trappizzio Berti!' : c.result.winner === 'cpu' ? 'Ha vinto Trappizzio Berti.' : 'Pari.'}`;
|
||||||
|
}
|
||||||
|
if (c.step === 'review') return `${prefix} ${c.line} ${c.last ? 'Tocca per il risultato.' : 'Tocca o premi Prossima.'}`;
|
||||||
|
if (c.step === 'think') return `${prefix} Tira Trappizzio Berti…`;
|
||||||
|
if (c.step === 'rolling') return `${prefix} Sponde: ${c.attempt.cushions}/${c.skill.cushions}.`;
|
||||||
|
return `${prefix} ${c.line} ${need(c)}`;
|
||||||
|
}
|
||||||
@@ -136,6 +136,12 @@ export class Soundscape {
|
|||||||
else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan);
|
else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan);
|
||||||
else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan);
|
else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan);
|
||||||
else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
|
else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
|
||||||
|
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
|
||||||
|
else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); }
|
||||||
|
else if (kind === 'micro-strisciata') this.noiseSound(0.3 + strength * 0.7, 900, 0.03 + strength * 0.04, { pan });
|
||||||
|
else if (kind === 'micro-urto') { this.tone(3000, 1800, 0.03, 0.03 + strength * 0.06, 0, pan); this.noiseSound(0.03, 3600, 0.03, { pan }); }
|
||||||
|
else if (kind === 'micro-fuori') { this.tone(420, 160, 0.25, 0.05, 0, pan); this.tone(300, 110, 0.3, 0.04, 0.12, pan); }
|
||||||
|
else if (kind === 'micro-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
|
||||||
// La pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva.
|
// La pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva.
|
||||||
else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan });
|
else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan });
|
||||||
else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
|
else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Soundscape } from './engine/soundscape.js';
|
|||||||
import { soundscapeFor } from './story/soundscapes.js';
|
import { soundscapeFor } from './story/soundscapes.js';
|
||||||
import * as bocce from './engine/bocce.js';
|
import * as bocce from './engine/bocce.js';
|
||||||
import * as biliardo from './engine/biliardo.js';
|
import * as biliardo from './engine/biliardo.js';
|
||||||
|
import * as rinquarto from './engine/rinquarto.js';
|
||||||
|
import * as micro from './engine/micro.js';
|
||||||
import * as basket from './engine/basket.js';
|
import * as basket from './engine/basket.js';
|
||||||
import * as pesca from './engine/pesca.js';
|
import * as pesca from './engine/pesca.js';
|
||||||
import * as gara from './engine/gara.js';
|
import * as gara from './engine/gara.js';
|
||||||
@@ -506,15 +508,16 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (press?.closeup) return;
|
||||||
const aimed = game.closeupAim(scenePoint(e));
|
const aimed = game.closeupAim(scenePoint(e));
|
||||||
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
|
if (['biliardo', 'rinquarto', 'basket', 'micro'].includes(story.closeups[game.closeup.id].type)) {
|
||||||
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
|
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
|
||||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||||
}
|
}
|
||||||
if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true;
|
if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true;
|
||||||
press = { closeup: true };
|
press = { closeup: true, pointerId: e.pointerId, state: game.closeup };
|
||||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||||
if (!game.skip()) game.closeupPress();
|
if (!game.skip()) game.closeupPress(e.pointerId);
|
||||||
canvas.setPointerCapture(e.pointerId);
|
canvas.setPointerCapture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -542,7 +545,12 @@ canvas.addEventListener('pointerup', e => {
|
|||||||
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
|
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
|
if (press.closeup) {
|
||||||
|
if (e.pointerId !== press.pointerId) return;
|
||||||
|
const state = press.state; press = null;
|
||||||
|
if (running && game.closeup === state && !game.closeupRelease(e.pointerId) && !game.skip()) game.closeupTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(press.timer);
|
clearTimeout(press.timer);
|
||||||
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
||||||
if (!press.long) {
|
if (!press.long) {
|
||||||
@@ -553,8 +561,13 @@ canvas.addEventListener('pointerup', e => {
|
|||||||
}
|
}
|
||||||
press = null;
|
press = null;
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
|
canvas.addEventListener('pointercancel', e => {
|
||||||
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
|
if (press?.closeup && e.pointerId !== press.pointerId) return;
|
||||||
|
if (press) clearTimeout(press.timer);
|
||||||
|
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
|
||||||
|
press = null;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('lostpointercapture', e => { if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
|
||||||
canvas.addEventListener('contextmenu', e => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||||
@@ -569,12 +582,19 @@ canvas.addEventListener('pointermove', e => {
|
|||||||
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
|
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
|
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro'].includes(story.closeups[game.closeup.id].type);
|
||||||
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||||
|
|
||||||
|
// Il pannello si aggiorna a ogni fotogramma: si scrive nel DOM solo quando qualcosa cambia davvero. Su iPhone
|
||||||
|
// Safari, se la pagina cambia subito dopo un tocco, il tocco vale come «passaggio» e il clic non arriva: i pulsanti
|
||||||
|
// dei minigiochi, riscritti a ogni fotogramma, non rispondevano (un amico di Andrea, 28 ottobre).
|
||||||
|
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
|
||||||
|
const setAttr = (el, name, value) => { if (el.getAttribute(name) !== value) el.setAttribute(name, value); };
|
||||||
|
const setValue = (el, value) => { if (el.value !== String(value)) el.value = value; };
|
||||||
|
const setProp = (el, name, value) => { if (typeof el[name] === 'boolean') value = !!value; if (el[name] !== value) el[name] = value; };
|
||||||
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
||||||
const MINI = {
|
const MINI = {
|
||||||
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
||||||
@@ -582,15 +602,49 @@ 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.' },
|
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
||||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||||
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
||||||
|
rinquarto: { ...rinquarto, back: 'Torna da Trappizzio Berti', label: 'I rinquarti di Trappizzio Berti: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' },
|
||||||
|
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
||||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||||
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||||
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
|
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza. Alla bona e Seria: tieni premuto sulla scena (o Spazio) per il gas e lascia per frenare, lo sterzo va da solo; in curva troppo forte si esce. Ginocchio in terra: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
|
||||||
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
|
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
|
||||||
};
|
};
|
||||||
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
|
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
|
||||||
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||||
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
|
$('bocce-power').addEventListener('input', e => {
|
||||||
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
|
const c = miniState(); if (!c) return;
|
||||||
|
mini().setPower?.(c, Number(e.target.value) / 100);
|
||||||
|
// Nel GP di Fortezza il cursore è la sensibilità dello sterzo: il gioco se la ricorda.
|
||||||
|
if (story.closeups[c.id].type === 'gara') try { localStorage.setItem(GARA_SENS, e.target.value); } catch { /* senza memoria, pazienza */ }
|
||||||
|
});
|
||||||
|
const GARA_SENS = 'regolo-gara-sensibilita';
|
||||||
|
// Il riquadro dello swipe sotto la pista: come il dito sulla scena, con le distanze in pixel della scena.
|
||||||
|
const padPoint = e => { const r = canvas.getBoundingClientRect(), k = 640 / r.width; return [(e.clientX - r.left) * k, (e.clientY - r.top) * k]; };
|
||||||
|
let padPointer = null;
|
||||||
|
$('gara-pad').addEventListener('pointerdown', e => {
|
||||||
|
const c = miniState(); if (!c || padPointer !== null || game.skip()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId);
|
||||||
|
if (gara.press(c, 'pad' + e.pointerId)) { padPointer = e.pointerId; $('gara-pad').setPointerCapture(e.pointerId); $('gara-pad').classList.add('active'); }
|
||||||
|
});
|
||||||
|
$('gara-pad').addEventListener('pointermove', e => { const c = miniState(); if (c && e.pointerId === padPointer) gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId); });
|
||||||
|
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-pad').addEventListener(ev, e => {
|
||||||
|
if (e.pointerId !== padPointer) return;
|
||||||
|
const c = miniState(); if (c) gara.release(c, 'pad' + e.pointerId);
|
||||||
|
padPointer = null; $('gara-pad').classList.remove('active');
|
||||||
|
});
|
||||||
|
$('gara-pad').addEventListener('contextmenu', e => e.preventDefault());
|
||||||
|
// GP, guida semplice: il pulsante Gas si tiene premuto (giù gas, su freno) invece di accendersi e spegnersi al clic;
|
||||||
|
// sul telefono, alla bona, il clic arrivava solo alzando il dito (Andrea, 29 ottobre).
|
||||||
|
const holdGas = c => !!c && story.closeups[c.id].type === 'gara' && gara.simple(c) && c.step !== 'intro' && c.step !== 'result';
|
||||||
|
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech && !holdGas(c)) mini().shoot?.(c); });
|
||||||
|
$('bocce-throw').addEventListener('pointerdown', e => {
|
||||||
|
const c = miniState(); if (!holdGas(c) || game.speech) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (gara.press(c, 'gas-button')) try { $('bocce-throw').setPointerCapture(e.pointerId); } catch { /* un tocco sintetico: va bene lo stesso */ }
|
||||||
|
});
|
||||||
|
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('bocce-throw').addEventListener(ev, () => { const c = miniState(); if (holdGas(c)) gara.release(c, 'gas-button'); });
|
||||||
|
$('bocce-throw').addEventListener('contextmenu', e => { if (holdGas(miniState())) e.preventDefault(); });
|
||||||
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
||||||
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -614,7 +668,16 @@ for (let i = 0; i < 3; i++) {
|
|||||||
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
|
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
|
||||||
}
|
}
|
||||||
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
|
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
|
||||||
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
|
$(id).addEventListener('click', e => { const c = miniState(); if (!c) return;
|
||||||
|
if (mini().turn) { if (!e.detail) { mini().turn(c, dir, 'click'); mini().stopTurn(c, 'click'); } }
|
||||||
|
else if (mini().nudge(c, dir)) c.aimLocked = true;
|
||||||
|
});
|
||||||
|
$(id).addEventListener('pointerdown', e => { const c = miniState();
|
||||||
|
if (c && mini().turn?.(c, dir, e.pointerId)) $(id).setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $(id).addEventListener(ev, e => {
|
||||||
|
const c = miniState(); if (c) mini().stopTurn?.(c, e.pointerId);
|
||||||
|
});
|
||||||
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
|
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
|
||||||
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
|
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
|
||||||
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
|
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
|
||||||
@@ -680,8 +743,11 @@ function updateInventory() {
|
|||||||
let choicesKey = '';
|
let choicesKey = '';
|
||||||
function updateChoices() {
|
function updateChoices() {
|
||||||
const options = game.options(), inDialogue = !!game.dialogue;
|
const options = game.options(), inDialogue = !!game.dialogue;
|
||||||
$('panel').hidden = inDialogue;
|
// Con un minigioco aperto il suo pannello prende il posto di verbi e scelte: si decide qui, una volta sola, senza
|
||||||
$('choices').hidden = !inDialogue;
|
// mostrarli e nasconderli a ogni fotogramma.
|
||||||
|
const covered = !!miniState();
|
||||||
|
setProp($('panel'), 'hidden', inDialogue || covered);
|
||||||
|
setProp($('choices'), 'hidden', !inDialogue || covered);
|
||||||
const key = inDialogue + '|' + options.map(o => o.text).join('|');
|
const key = inDialogue + '|' + options.map(o => o.text).join('|');
|
||||||
if (key === choicesKey) return;
|
if (key === choicesKey) return;
|
||||||
choicesKey = key;
|
choicesKey = key;
|
||||||
@@ -787,8 +853,8 @@ window.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Escape') { game.closeupClose(); return; }
|
if (e.key === 'Escape') { game.closeupClose(); return; }
|
||||||
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
|
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
|
||||||
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
|
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
|
||||||
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
|
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; }
|
||||||
if (onControl) return;
|
if (onControl && [' ', 'Enter'].includes(e.key)) return;
|
||||||
const c = game.closeup, m = mini();
|
const c = game.closeup, m = mini();
|
||||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -834,7 +900,7 @@ window.addEventListener('keydown', e => {
|
|||||||
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
|
||||||
window.addEventListener('keyup', e => {
|
window.addEventListener('keyup', e => {
|
||||||
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
|
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
|
||||||
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; }
|
if (miniState() && mini().key?.(game.closeup, e.key, false)) return;
|
||||||
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
|
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -853,21 +919,22 @@ function updateSpeech() {
|
|||||||
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
||||||
const [x, y] = game.speakerPoint(sp.who);
|
const [x, y] = game.speakerPoint(sp.who);
|
||||||
if (box.textContent !== sp.text) box.textContent = sp.text;
|
if (box.textContent !== sp.text) box.textContent = sp.text;
|
||||||
box.style.color = story.characters[sp.who]?.color ?? '#fff';
|
const color = story.characters[sp.who]?.color ?? '#fff';
|
||||||
|
if (box.style.color !== color && box.dataset.color !== color) { box.style.color = color; box.dataset.color = color; }
|
||||||
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
|
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
|
||||||
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
|
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
|
||||||
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
|
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
|
||||||
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
|
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
|
||||||
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
|
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
|
||||||
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
|
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
|
||||||
box.style.left = px + 'px';
|
if (box.style.left !== px + 'px') box.style.left = px + 'px';
|
||||||
box.style.top = py + 'px';
|
if (box.style.top !== py + 'px') box.style.top = py + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUi() {
|
function updateUi() {
|
||||||
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
||||||
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
||||||
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
|
for (const b of $('verbs').children) setAttr(b, 'aria-pressed', String(command.verb === b.dataset.verb));
|
||||||
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
|
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
|
||||||
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
|
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
|
||||||
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
|
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
|
||||||
@@ -878,22 +945,25 @@ function updateUi() {
|
|||||||
updateSpeech();
|
updateSpeech();
|
||||||
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
||||||
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
||||||
$('bocce-controls').hidden = !mg || cards;
|
// La presentazione dei piloti del GP a tutto schermo: i comandi arrivano col semaforo (Andrea, 29 ottobre).
|
||||||
$('bocce-controls').inert = !running;
|
const presenta = !!mg && story.closeups[c.id].type === 'gara' && c.step === 'intro';
|
||||||
$('briscola-controls').hidden = def?.type !== 'briscola';
|
$('app').classList.toggle('presenta', presenta);
|
||||||
$('briscola-controls').inert = !running;
|
setProp($('bocce-controls'), 'hidden', !mg || cards || presenta);
|
||||||
$('scopa-controls').hidden = def?.type !== 'scopa';
|
setProp($('bocce-controls'), 'inert', !running);
|
||||||
$('scopa-controls').inert = !running;
|
setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola');
|
||||||
|
setProp($('briscola-controls'), 'inert', !running);
|
||||||
|
setProp($('scopa-controls'), 'hidden', def?.type !== 'scopa');
|
||||||
|
setProp($('scopa-controls'), 'inert', !running);
|
||||||
if (def?.type === 'scopa') {
|
if (def?.type === 'scopa') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = scopa.status(c, def.names);
|
const line = scopa.status(c, def.names);
|
||||||
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
||||||
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
|
setProp(button, 'disabled', !scopa.canPlay(c) || !card || !!game.speech);
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
||||||
}
|
}
|
||||||
// Le prese fra cui scegliere, una per pulsante.
|
// Le prese fra cui scegliere, una per pulsante.
|
||||||
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
||||||
@@ -905,14 +975,14 @@ function updateUi() {
|
|||||||
b.dataset.option = k; return b;
|
b.dataset.option = k; return b;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
box.hidden = !options.length;
|
setProp(box, 'hidden', !options.length);
|
||||||
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
|
[...box.children].forEach((b, k) => setAttr(b, 'aria-pressed', String(c.choice?.selected === k)));
|
||||||
$('scopa-next').hidden = c.step !== 'smazzata';
|
setProp($('scopa-next'), 'hidden', c.step !== 'smazzata');
|
||||||
}
|
}
|
||||||
$('tressette-controls').hidden = def?.type !== 'tressette';
|
setProp($('tressette-controls'), 'hidden', def?.type !== 'tressette');
|
||||||
$('tressette-controls').inert = !running;
|
setProp($('tressette-controls'), 'inert', !running);
|
||||||
if (def?.type === 'tressette') {
|
if (def?.type === 'tressette') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = tressette.status(c, def.seats);
|
const line = tressette.status(c, def.seats);
|
||||||
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
|
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
|
||||||
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
|
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
|
||||||
@@ -920,66 +990,83 @@ function updateUi() {
|
|||||||
const button = $('tressette-card-' + i), card = c.hands[0][i];
|
const button = $('tressette-card-' + i), card = c.hands[0][i];
|
||||||
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
|
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.hidden = !card;
|
setProp(button, 'hidden', !card);
|
||||||
button.disabled = !ok.includes(i);
|
setProp(button, 'disabled', !ok.includes(i));
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
|
||||||
}
|
}
|
||||||
for (const signal of tressette.SIGNALS) {
|
for (const signal of tressette.SIGNALS) {
|
||||||
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
|
setProp($('tressette-' + signal), 'disabled', !mine || c.table.length > 0);
|
||||||
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
|
setAttr($('tressette-' + signal), 'aria-pressed', String(c.signal === signal));
|
||||||
}
|
}
|
||||||
$('tressette-next').hidden = c.step !== 'smazzata';
|
setProp($('tressette-next'), 'hidden', c.step !== 'smazzata');
|
||||||
}
|
}
|
||||||
if (def?.type === 'briscola') {
|
if (def?.type === 'briscola') {
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = briscola.status(c, def.names);
|
const line = briscola.status(c, def.names);
|
||||||
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const button = $('briscola-card-' + i), card = c.hands.player[i];
|
const button = $('briscola-card-' + i), card = c.hands.player[i];
|
||||||
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
|
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
|
||||||
if (button.textContent !== label) button.textContent = label;
|
if (button.textContent !== label) button.textContent = label;
|
||||||
button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
|
setProp(button, 'disabled', !briscola.canPlay(c) || !card || !!game.speech);
|
||||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
|
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mg && !cards) {
|
if (mg && !cards) {
|
||||||
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
|
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine' }[def.type] ?? 'Comandi delle bocce');
|
||||||
$('panel').hidden = $('choices').hidden = true;
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = mg.status(c, def.names);
|
const line = mg.status(c, def.names);
|
||||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||||
$('bocce-power').value = Math.round(c.power * 100);
|
setValue($('bocce-power'), Math.round(c.power * 100));
|
||||||
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
|
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
|
||||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech;
|
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
||||||
$('bocce-throw').disabled = !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech;
|
setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
||||||
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
|
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
|
||||||
$('bocce-power').closest('label').hidden = def.type === 'gara';
|
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
|
||||||
$('bocce-left').textContent = def.type === 'gara' ? '← Sterza' : '← Mira';
|
const powerLabel = $('bocce-power').closest('label');
|
||||||
$('bocce-right').textContent = def.type === 'gara' ? 'Sterza →' : 'Mira →';
|
// Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
|
||||||
$('gara-brake').hidden = def.type !== 'gara';
|
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
|
||||||
$('gara-brake').disabled = !(mg.canAdjust?.(c) ?? false) || !!game.speech;
|
const easy = def.type === 'gara' && gara.simple(c);
|
||||||
$('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira');
|
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || easy);
|
||||||
$('bocce-help').hidden = def.type !== 'bocce';
|
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy);
|
||||||
$('biliardo-help').hidden = def.type !== 'biliardo';
|
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
|
||||||
$('basket-help').hidden = def.type !== 'basket';
|
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
|
||||||
$('pesca-help').hidden = def.type !== 'pesca';
|
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
|
||||||
$('gara-help').hidden = def.type !== 'gara';
|
setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
|
||||||
|
if (def.type === 'gara' && c.sensLoaded === undefined) {
|
||||||
|
c.sensLoaded = true;
|
||||||
|
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
|
||||||
|
}
|
||||||
|
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
|
||||||
|
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
|
||||||
|
setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
|
||||||
|
$('bocce-throw').classList.toggle('hold', easy);
|
||||||
|
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
|
||||||
|
setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'));
|
||||||
|
setProp($('bocce-help'), 'hidden', def.type !== 'bocce');
|
||||||
|
setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo');
|
||||||
|
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
|
||||||
|
setProp($('micro-help'), 'hidden', def.type !== 'micro');
|
||||||
|
setProp($('basket-help'), 'hidden', def.type !== 'basket');
|
||||||
|
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
|
||||||
|
setProp($('gara-help'), 'hidden', def.type !== 'gara');
|
||||||
if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
|
if (mg.aimFree?.(c) ?? !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 });
|
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
|
||||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||||
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
setProp($('map-button'), 'hidden', !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2);
|
||||||
$('pause-button').hidden = !running;
|
setProp($('pause-button'), 'hidden', !running);
|
||||||
$('spots-button').hidden = !running || !!game.closeup;
|
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
|
||||||
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
|
||||||
$('closeup-close').hidden = !running || !game.closeup;
|
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
|
||||||
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
|
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
|
||||||
$('panel').inert = !!c;
|
setProp($('panel'), 'inert', !!c);
|
||||||
canvas.classList.toggle('torch', !!torch);
|
canvas.classList.toggle('torch', !!torch);
|
||||||
canvas.classList.toggle('bocce', !!mg);
|
canvas.classList.toggle('bocce', !!mg);
|
||||||
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
||||||
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'gara'].includes(story.closeups[c.id].type));
|
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro'].includes(story.closeups[c.id].type));
|
||||||
$('stage').classList.toggle('briscola', !!cards);
|
$('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");
|
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
||||||
// La pagina che comincia a girare fa il suo fruscio.
|
// La pagina che comincia a girare fa il suo fruscio.
|
||||||
const step = game.closeup?.step ?? null;
|
const step = game.closeup?.step ?? null;
|
||||||
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
||||||
@@ -989,7 +1076,7 @@ function updateUi() {
|
|||||||
reveal = nextReveal;
|
reveal = nextReveal;
|
||||||
if (game.state.ended && !game.speech && !game.queue.length) {
|
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||||
running = false;
|
running = false;
|
||||||
$('ending').hidden = false;
|
setProp($('ending'), 'hidden', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ export const piloti = [
|
|||||||
{ id: 'cistola', name: 'Cistola', bike: 'il CR 50 Z', motore: 4, testa: 3, color: '#1d2440', trim: '#a9adb5', moto: 'assets/closeups/gp/cr50z.png', face: 'assets/closeups/gp/cistola.png', consenso: '2026-10-27' },
|
{ id: 'cistola', name: 'Cistola', bike: 'il CR 50 Z', motore: 4, testa: 3, color: '#1d2440', trim: '#a9adb5', moto: 'assets/closeups/gp/cr50z.png', face: 'assets/closeups/gp/cistola.png', consenso: '2026-10-27' },
|
||||||
// Il Malaguti Fifty di Andrea: rosso con le rifiniture nere.
|
// Il Malaguti Fifty di Andrea: rosso con le rifiniture nere.
|
||||||
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
|
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
|
||||||
// Il Porno (Andrea Bini, consenso del 28 ottobre) col suo Italjet Formula giallo, che va fortissimo (foto di Andrea);
|
// Bini (Andrea Bini, consenso del 28 ottobre; preferisce «Bini») col suo Italjet Formula giallo, che va fortissimo
|
||||||
// il ritratto dalla sua foto.
|
// (foto di Andrea); il ritratto dalla sua foto.
|
||||||
{ id: 'porno', name: 'Il Porno', bike: 'il Formula', motore: 5, testa: 3, color: '#f2c81e', trim: '#2a2a2e', moto: 'assets/closeups/gp/formula.png', face: 'assets/closeups/gp/porno.png', consenso: '2026-10-28' },
|
{ id: 'bini', name: 'Bini', bike: 'il Formula con la Zirri', motore: 5, testa: 3, color: '#f2c81e', trim: '#2a2a2e', moto: 'assets/closeups/gp/formula.png', face: 'assets/closeups/gp/bini.png', consenso: '2026-10-28' },
|
||||||
// Tozzino (Francesco Tozzi) col suo Booster blu scuro, di serie, dalle foto che ha mandato (l'adesivo sul manubrio compreso).
|
// Tozzino (Francesco Tozzi) col suo Booster blu scuro, di serie, dalle foto che ha mandato (l'adesivo sul manubrio compreso).
|
||||||
{ id: 'tozzino', name: 'Tozzino', bike: 'il Booster', motore: 1, testa: 3, color: '#1a2a52', trim: '#111318', moto: 'assets/closeups/gp/booster.png', sprite: 'tozzino',
|
{ id: 'tozzino', name: 'Tozzino', bike: 'il Booster', motore: 1, testa: 3, color: '#1a2a52', trim: '#111318', moto: 'assets/closeups/gp/booster.png', sprite: 'tozzino',
|
||||||
// Il casco alla Damon Hill: blu notte con poche pale di remo bianche, strette e a punta (foto di Andrea, 28 ottobre).
|
// Il casco alla Damon Hill: blu notte con poche pale di remo bianche, strette e a punta (foto di Andrea, 28 ottobre).
|
||||||
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
|
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
|
||||||
|
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
|
||||||
|
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
|
||||||
|
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
|
||||||
|
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
|
||||||
|
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
|
||||||
|
{ id: 'brauda', name: 'La Brauda', bike: 'il Rally', motore: 3, testa: 3, color: '#151518', trim: '#2c3f9e', moto: 'assets/closeups/gp/rally.png', face: 'assets/closeups/gp/brauda.png', consenso: '2026-10-28' },
|
||||||
// Il Betino dell'Arcu: verde e crema, lento ma non cade mai.
|
// Il Betino dell'Arcu: verde e crema, lento ma non cade mai.
|
||||||
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
|
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const story = {
|
|||||||
credits: [
|
credits: [
|
||||||
{ role: 'Un gioco di', names: [{ name: 'Manzolo (Andrea Manzi)', face: 'assets/credits/manzolo.png' }] },
|
{ role: 'Un gioco di', names: [{ name: 'Manzolo (Andrea Manzi)', face: 'assets/credits/manzolo.png' }] },
|
||||||
{ role: 'Idee e consigli', names: [{ name: 'Baldoria (Yuri Baldi)', face: 'assets/credits/baldoria.png' }, { name: 'BaronDeDuke (Gabriele Rossi)', face: 'assets/credits/barondeduke.png' }, { name: 'Tozzino (Francesco Tozzi)', face: 'assets/credits/tozzino.png' }, { name: 'l\'Arcu (Marco Arcuri)', face: 'assets/credits/arcu.png' }, { name: 'Eriketta (Erika Fenaroli)', face: 'assets/credits/eriketta.png' }] },
|
{ role: 'Idee e consigli', names: [{ name: 'Baldoria (Yuri Baldi)', face: 'assets/credits/baldoria.png' }, { name: 'BaronDeDuke (Gabriele Rossi)', face: 'assets/credits/barondeduke.png' }, { name: 'Tozzino (Francesco Tozzi)', face: 'assets/credits/tozzino.png' }, { name: 'l\'Arcu (Marco Arcuri)', face: 'assets/credits/arcu.png' }, { name: 'Eriketta (Erika Fenaroli)', face: 'assets/credits/eriketta.png' }] },
|
||||||
{ role: 'Storia e aneddoti di San Piero', names: [{ name: 'Francesco Bacci', face: 'assets/credits/bacci.png' }] },
|
{ role: 'Storia e aneddoti di San Piero', names: [{ name: 'Francesco Bacci', face: 'assets/credits/bacci.png' }, 'Fabrizio Berti, «Memorie di sport e di paese»'] },
|
||||||
{ role: 'Musica', names: ['Manzolo, con Suno'] },
|
{ role: 'Musica', names: ['Manzolo, con Suno'] },
|
||||||
{ role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] },
|
{ role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] },
|
||||||
// Chi ci ha mandato foto, ricordi o idee: una riga per contributo, si allunga man mano.
|
// Chi ci ha mandato foto, ricordi o idee: una riga per contributo, si allunga man mano.
|
||||||
@@ -223,6 +223,27 @@ export const story = {
|
|||||||
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
|
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
|
||||||
draw: [],
|
draw: [],
|
||||||
},
|
},
|
||||||
|
rinquarto: {
|
||||||
|
type: 'rinquarto',
|
||||||
|
picture: 'assets/closeups/biliardo-tavolo.png',
|
||||||
|
names: { player: 'Andrea', cpu: 'Trappizzio Berti' },
|
||||||
|
// Andrea contro Trappizzio Berti, sulle stesse posizioni; nell'allenamento il risultato è sempre «pari». [DA ANDREA: le battute]
|
||||||
|
win: [{ say: 'M\'hai battuto ai rinquarti. Però un lo dire a nessuno.', who: 'bertovo', pose: 'shrug' }, { set: 'rinquarti_vinti' }],
|
||||||
|
lose: [{ say: 'Le sponde ci sono per qualcosa! Basta chiederglielo per bene.', who: 'bertovo', pose: 'laugh' }],
|
||||||
|
quit: [{ say: 'Questo me lo tengo per la prossima.' }],
|
||||||
|
draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }],
|
||||||
|
},
|
||||||
|
// Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre).
|
||||||
|
// [DA ANDREA: le battute sono provvisorie]
|
||||||
|
micro: {
|
||||||
|
type: 'micro', music: 'assets/audio/gara.mp3', // in prestito dal GP di Fortezza, finché non c'è la sua
|
||||||
|
picture: 'assets/closeups/micro-parcheggio.png',
|
||||||
|
names: { player: 'Andrea', cpu: 'Chiaru' },
|
||||||
|
win: [{ say: 'Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.', who: 'chiaru', pose: 'shrug' }, { set: 'micro_vinto' }],
|
||||||
|
lose: [{ say: 'Visto? La blu non perde mai. Mai.', who: 'chiaru', pose: 'cheer' }],
|
||||||
|
quit: [{ say: 'Ti ritiri? Allora la vinco a tavolino.', who: 'chiaru', pose: 'laugh' }],
|
||||||
|
draw: [{ say: 'Pari. Si rifà, ma la pista la ridisegno io.', who: 'chiaru', pose: 'shrug' }],
|
||||||
|
},
|
||||||
fessura: {
|
fessura: {
|
||||||
type: 'torch',
|
type: 'torch',
|
||||||
picture: 'assets/closeups/palio-2007.jpg',
|
picture: 'assets/closeups/palio-2007.jpg',
|
||||||
@@ -285,8 +306,13 @@ export const story = {
|
|||||||
tosca: { name: 'La Tosca', color: '#ffb0d0' },
|
tosca: { name: 'La Tosca', color: '#ffb0d0' },
|
||||||
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
||||||
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
|
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
|
||||||
|
// Trappizzio Berti (Fabrizio Berti, consenso dato ad Andrea il 28 ottobre), il re dei rinquarti (sprite di Codex dalla sua foto).
|
||||||
|
bertovo: { name: 'Trappizzio Berti', color: '#9fd0ff', sprite: 'assets/characters/bertovo.json' },
|
||||||
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.json' },
|
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a', sprite: 'assets/characters/ing-tozzi.json' },
|
||||||
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
||||||
|
// Il Chiaru (Matteo Chiarusi, consenso dato ad Andrea il 29 ottobre), alla pista col gesso di Cardetole: sprite di
|
||||||
|
// Codex dalla foto di Andrea.
|
||||||
|
chiaru: { name: 'Il Chiaru', color: '#ffd6a0', sprite: 'assets/characters/chiaru.json' },
|
||||||
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
||||||
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
|
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
|
||||||
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
|
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
|
||||||
@@ -1667,9 +1693,13 @@ export const story = {
|
|||||||
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
|
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
|
||||||
depth: [[190, 1.2], [360, 2.5]],
|
depth: [[190, 1.2], [360, 2.5]],
|
||||||
entry: [590, 202],
|
entry: [590, 202],
|
||||||
speakers: { ing_tozzi: [452, 150] },
|
speakers: { ing_tozzi: [452, 150], bertovo: [396, 132] },
|
||||||
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
|
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
|
||||||
people: [{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
|
people: [
|
||||||
|
{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 },
|
||||||
|
// Trappizzio Berti al tavolo di mezzo, coi suoi rinquarti (Andrea, 28 ottobre).
|
||||||
|
{ who: 'bertovo', at: [396, 238], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 5.1 },
|
||||||
|
],
|
||||||
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
|
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
{
|
{
|
||||||
@@ -1688,6 +1718,13 @@ export const story = {
|
|||||||
talk: [{ dialogue: 'ing_tozzi' }],
|
talk: [{ dialogue: 'ing_tozzi' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'bertovo', name: 'Trappizzio Berti', box: [382, 162, 30, 76], at: [360, 244], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Trappizzio Berti, al tavolo di mezzo. Le biglie le mette dove nessuno ci arriva, e poi ci arriva lui.' }],
|
||||||
|
talk: [{ dialogue: 'bertovo' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
|
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -2673,7 +2710,8 @@ export const story = {
|
|||||||
name: 'Via Liguri Magelli',
|
name: 'Via Liguri Magelli',
|
||||||
map: [76, 97],
|
map: [76, 97],
|
||||||
background: 'assets/scenes/via-liguri-magelli.png',
|
background: 'assets/scenes/via-liguri-magelli.png',
|
||||||
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [400, 250], [580, 318], [640, 330], [640, 360]],
|
// La strada gira intorno all'Audi nera parcheggiata davanti al Trappola.
|
||||||
|
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [300, 242], [370, 246], [400, 252], [580, 318], [640, 330], [640, 360]],
|
||||||
depth: [[198, 0.3], [360, 1.1]],
|
depth: [[198, 0.3], [360, 1.1]],
|
||||||
entry: [320, 340],
|
entry: [320, 340],
|
||||||
speakers: { arcu: [374, 176] },
|
speakers: { arcu: [374, 176] },
|
||||||
@@ -2691,12 +2729,21 @@ export const story = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// «Vai dal Trappola, la tenda è il Trappola» (Andrea). [DA ANDREA: com'è dentro, e chi c'è]
|
// «Vai dal Trappola, la tenda è il Trappola» (Andrea). [DA ANDREA: com'è dentro, e chi c'è]
|
||||||
id: 'trappola', name: 'dal Trappola', box: [316, 150, 46, 72], at: [340, 238], face: 'right', default: 'walk',
|
id: 'trappola', name: 'dal Trappola', box: [316, 150, 46, 46], at: [340, 252], face: 'right', default: 'walk',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
|
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
|
||||||
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
|
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// «Di chi è quell'Audi nera?» «Let it be» (che si legge «L'è di Bi»: è di Bini). Andrea, 28 ottobre.
|
||||||
|
id: 'audi', name: 'Audi nera', box: [292, 198, 68, 40], at: [330, 254], face: 'up', default: 'look',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Di chi è quell\'Audi nera?' }, { say: 'Let it be.', who: 'arcu', pose: 'explain' }],
|
||||||
|
talk: [{ say: 'Di chi è quell\'Audi nera?' }, { say: 'Let it be.', who: 'arcu', pose: 'explain' }],
|
||||||
|
use: [{ say: 'Non è mica mia. Mi garba, però, nera fiammante.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
|
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -2729,7 +2776,16 @@ export const story = {
|
|||||||
walk: [[0, 360], [0, 344], [120, 332], [200, 304], [262, 292], [330, 292], [372, 296], [640, 266], [640, 360]],
|
walk: [[0, 360], [0, 344], [120, 332], [200, 304], [262, 292], [330, 292], [372, 296], [640, 266], [640, 360]],
|
||||||
depth: [[266, 0.55], [360, 1.15]],
|
depth: [[266, 0.55], [360, 1.15]],
|
||||||
entry: [300, 350],
|
entry: [300, 350],
|
||||||
|
// Il Chiaru (Matteo Chiarusi) accanto alla pista col gesso, sul parcheggio sotto casa.
|
||||||
|
speakers: { chiaru: [520, 214] },
|
||||||
|
people: [{ who: 'chiaru', at: [520, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 3.7 }],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: 'parcheggio', name: 'parcheggio', box: [356, 262, 280, 60], at: [450, 330], face: 'right',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up',
|
id: 'casa', name: 'casa di Andrea', box: [330, 20, 220, 212], at: [470, 290], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -2766,6 +2822,14 @@ export const story = {
|
|||||||
{
|
{
|
||||||
id: 'al_trappola', name: 'a sinistra, dal Trappola', box: [0, 236, 50, 104], at: [20, 352], exit: { scene: 'via_liguri_magelli', at: [264, 202], face: 'down' },
|
id: 'al_trappola', name: 'a sinistra, dal Trappola', box: [0, 236, 50, 104], at: [20, 352], exit: { scene: 'via_liguri_magelli', at: [264, 202], face: 'down' },
|
||||||
},
|
},
|
||||||
|
// In fondo all'elenco, così sta sopra a parcheggio e casa: il clic sul Chiaru è suo (l'ultimo della lista vince).
|
||||||
|
{
|
||||||
|
id: 'chiaru', name: 'il Chiaru', box: [498, 212, 46, 106], at: [470, 330], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.' }],
|
||||||
|
talk: [{ dialogue: 'chiaru' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// L'ingresso della scuola, giù per la strada dalla salita di Villa Adami: dal cancello alle porte a vetri.
|
// L'ingresso della scuola, giù per la strada dalla salita di Villa Adami: dal cancello alle porte a vetri.
|
||||||
@@ -3184,6 +3248,71 @@ export const story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
|
||||||
|
chiaru: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Si fa una gara alle macchinine?!?!', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] },
|
||||||
|
{ text: 'Che pista hai disegnato?', then: [
|
||||||
|
{ say: 'Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.', who: 'chiaru', pose: 'explain' },
|
||||||
|
{ say: 'Il tombino l\'ho lasciato in pista: porta fortuna.', who: 'chiaru', pose: 'talk' },
|
||||||
|
] },
|
||||||
|
// I nomi alla francese delle penalità (Andrea, 29 ottobre): per tutte si torna indietro.
|
||||||
|
{ text: 'Le regole sono quelle solite?', then: [
|
||||||
|
{ say: 'Le solite. Chi accompagna la macchinina col dito: accompagnè.', who: 'chiaru', pose: 'explain' },
|
||||||
|
{ say: 'Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.', who: 'chiaru', pose: 'talk' },
|
||||||
|
{ say: 'In tutti i casi, si torna dov\'eri prima.', who: 'chiaru', pose: 'shrug' },
|
||||||
|
] },
|
||||||
|
// Il saluto (Andrea, 29 ottobre).
|
||||||
|
{ text: 'Vado a fare un girino alle case rosse.', next: 'end', then: [{ say: 'Vorrai dire un giochino!', who: 'chiaru', pose: 'laugh' }, { say: 'Ciao Mattè.' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
livello: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: ti faccio vedere dove si ferma. E io vado piano.', who: 'chiaru', pose: 'talk' }, { closeup: 'micro', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Così mi piace.', who: 'chiaru', pose: 'explain' }, { closeup: 'micro', level: 'seria' }] },
|
||||||
|
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male. Poi non piangere.', who: 'chiaru', pose: 'laugh' }, { closeup: 'micro', level: 'male' }] },
|
||||||
|
{ text: 'No, aspetta.', next: 'start', then: [{ say: 'Io intanto ripasso il gesso.', who: 'chiaru', pose: 'stand' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Trappizzio Berti al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre).
|
||||||
|
// [DA ANDREA: le battute sono provvisorie, tranne la sua sfida]
|
||||||
|
// Andrea, parlandogli, lo chiama Fabri (29 ottobre).
|
||||||
|
bertovo: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Fabri! Che fai, giochi da solo?', then: [
|
||||||
|
{ say: 'Da solo? Io gioco contro le sponde. E vinco quasi sempre.', who: 'bertovo', pose: 'explain' },
|
||||||
|
] },
|
||||||
|
{ text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [
|
||||||
|
{ say: 'Geometria. La biglia va dove le dici, basta chiederglielo per bene.', who: 'bertovo', pose: 'chalk' },
|
||||||
|
{ say: 'E con un po\' di gesso.', who: 'bertovo', pose: 'laugh' },
|
||||||
|
] },
|
||||||
|
// L'allenamento: lui tira per primo e fa vedere, poi lo rifai te, senza punti.
|
||||||
|
{ text: 'Dai, fammi vedere un rinquarto.', next: 'end', then: [
|
||||||
|
{ say: 'Guarda bene: prima lo tiro io, poi lo rifai te. Tre posizioni, una sponda.', who: 'bertovo', pose: 'chalk' },
|
||||||
|
{ closeup: 'rinquarto', level: 'allenamento' },
|
||||||
|
] },
|
||||||
|
{ text: 'Si fa una sfida a rinquarti?', next: 'sfida', then: [
|
||||||
|
{ say: 'Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' },
|
||||||
|
] },
|
||||||
|
{ text: 'Ci si vede, Fabri.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sfida: {
|
||||||
|
options: [
|
||||||
|
{ text: 'Alla bona, una sponda e basta.', next: 'end', then: [{ say: 'Una sponda. Roba da principianti, ma va bene.', who: 'bertovo', pose: 'shrug' }, { closeup: 'rinquarto', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Due sponde. Così mi piace.', who: 'bertovo', pose: 'talk' }, { closeup: 'rinquarto', level: 'seria' }] },
|
||||||
|
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Tre sponde e niente aiuti. Qui nascono le geometrie celestiali.', who: 'bertovo', pose: 'cheer' }, { closeup: 'rinquarto', level: 'male' }] },
|
||||||
|
{ text: 'No, mi tremano le mani.', next: 'start', then: [{ say: 'Il tavolo è qui. Le sponde non scappano.', who: 'bertovo', pose: 'laugh' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ing_tozzi: {
|
ing_tozzi: {
|
||||||
nodes: {
|
nodes: {
|
||||||
start: {
|
start: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.35';
|
export const VERSION = '0.1.43';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -51,18 +51,27 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
||||||
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
||||||
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
|
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
|
||||||
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
/* Il riquadro dello swipe del GP di Fortezza; niente lente né selezione col dito fermo sul telefono. */
|
||||||
|
#gara-pad { touch-action: none; margin: 0 0 10px; padding: 18px 10px; min-height: 64px; display: flex; align-items: center; justify-content: center;
|
||||||
|
text-align: center; color: #c5c6b6; border: 2px dashed #897b57; border-radius: 10px; background: #1d2320; }
|
||||||
|
#gara-pad.active { border-style: solid; color: #f3dcac; }
|
||||||
|
#stage, #bocce-controls, #gara-pad { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
||||||
|
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; touch-action: none; }
|
||||||
|
/* La presentazione dei piloti del GP: la scena da sola, i comandi arrivano col semaforo. */
|
||||||
|
#app.presenta #side { display: none; }
|
||||||
|
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
|
||||||
|
#bocce-throw.hold { min-height: 72px; font-size: 14px; }
|
||||||
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
||||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||||
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary { cursor: pointer; padding: 6px 0; }
|
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary { cursor: pointer; padding: 6px 0; }
|
||||||
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p { max-width: 76em; line-height: 1.7; }
|
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p { max-width: 76em; line-height: 1.7; }
|
||||||
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
||||||
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||||
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||||
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||||
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||||
#bocce-throw { flex-basis: 100%; }
|
#bocce-throw { flex-basis: 100%; }
|
||||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help { font-size: 9px; }
|
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help { font-size: 9px; }
|
||||||
}
|
}
|
||||||
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
||||||
#effects-volume { width: 130px; accent-color: var(--accent); }
|
#effects-volume { width: 130px; accent-color: var(--accent); }
|
||||||
@@ -313,6 +322,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#app { width: 100vw; flex-direction: row; }
|
#app { width: 100vw; flex-direction: row; }
|
||||||
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
|
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
|
||||||
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
|
#app.presenta { justify-content: center; }
|
||||||
|
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
|
||||||
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
||||||
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
|
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
|
||||||
|
|||||||
@@ -128,11 +128,13 @@ try {
|
|||||||
const fixed = (await state()).angle;
|
const fixed = (await state()).angle;
|
||||||
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
|
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
|
||||||
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
|
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
|
||||||
|
// Le frecce girano finché sono giù (sempre più svelte); un colpo secco è un passo, con Maiusc un passo fine.
|
||||||
await page.keyboard.press('ArrowRight');
|
await page.keyboard.press('ArrowRight');
|
||||||
const nudged = (await state()).angle;
|
const nudged = (await state()).angle;
|
||||||
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
|
assert.ok(nudged - fixed >= 0.03 - 1e-9 && nudged - fixed < 0.1, `passo: ${nudged - fixed}`);
|
||||||
await page.keyboard.press('Shift+ArrowLeft');
|
await page.keyboard.press('Shift+ArrowLeft');
|
||||||
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
|
const fine = nudged - (await state()).angle;
|
||||||
|
assert.ok(fine >= 0.0005 - 1e-9 && fine < 0.01, `passo fine: ${fine}`);
|
||||||
await page.locator('#bocce-power').fill('60');
|
await page.locator('#bocce-power').fill('60');
|
||||||
const prepared = await state();
|
const prepared = await state();
|
||||||
await page.screenshot({ path: `${out}/${name}-mira.png` });
|
await page.screenshot({ path: `${out}/${name}-mira.png` });
|
||||||
@@ -158,7 +160,8 @@ try {
|
|||||||
const cdp = mobile ? await page.context().newCDPSession(page) : null;
|
const cdp = mobile ? await page.context().newCDPSession(page) : null;
|
||||||
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
|
||||||
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
|
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
|
||||||
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
|
// Si tiene premuto: dopo LONG_PRESS la stecca comincia a caricare.
|
||||||
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.charging);
|
||||||
const heldAngle = (await state()).angle;
|
const heldAngle = (await state()).angle;
|
||||||
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
|
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
|
||||||
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
|
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP } from '../src/engine/biliardo.js';
|
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP, STRONG } from '../src/engine/biliardo.js';
|
||||||
import { story } from '../src/story/story.js';
|
import { story } from '../src/story/story.js';
|
||||||
import { Game } from '../src/engine/game.js';
|
import { Game } from '../src/engine/game.js';
|
||||||
import { settle } from './helpers.mjs';
|
import { settle } from './helpers.mjs';
|
||||||
@@ -34,6 +34,9 @@ test('la forza: sale finché si tiene premuto; a metà la biglia fa circa un tav
|
|||||||
assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`);
|
assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`);
|
||||||
assert.ok(reach(speedFor(1)) > LEN * 2);
|
assert.ok(reach(speedFor(1)) > LEN * 2);
|
||||||
assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena');
|
assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena');
|
||||||
|
// All'Ing. Tozzi il tiro pieno va più forte (Andrea, 29 ottobre); i rinquarti restano alla forza di prima.
|
||||||
|
assert.ok(reach(speedFor(1, STRONG)) > reach(speedFor(1)) * 1.3);
|
||||||
|
assert.equal(newBiliardo(1).maxSpeed, STRONG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => {
|
test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => {
|
||||||
@@ -126,14 +129,14 @@ test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique
|
|||||||
const end = p.points.at(-1);
|
const end = p.points.at(-1);
|
||||||
shoot(c); roll(c);
|
shoot(c); roll(c);
|
||||||
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
|
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
|
||||||
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
|
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power, c.maxSpeed)), 0.01);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
|
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
|
||||||
const c = emptyTable();
|
const c = emptyTable();
|
||||||
Object.assign(c.balls.player, { x: 270, y: 30 });
|
Object.assign(c.balls.player, { x: 270, y: 30 });
|
||||||
c.angle = Math.PI / 4; c.power = 0.3;
|
c.angle = Math.PI / 4; c.power = 0.2; // la stessa velocità di 0,3 prima del tiro più forte
|
||||||
const p = predict(c), [start, rail, end] = p.points;
|
const p = predict(c), [start, rail, end] = p.points;
|
||||||
assert.equal(p.points.length, 3);
|
assert.equal(p.points.length, 3);
|
||||||
near(rail[0], LEN - R);
|
near(rail[0], LEN - R);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Il GP di Fortezza nel browser vero: alla bona e seria la guida semplice (solo gas, lasciando si frena), a ginocchio
|
||||||
|
// in terra lo sterzo vero. npm run browser:gara — schermate in /tmp/regolo-gara.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { staticServer } from './helpers.mjs';
|
||||||
|
|
||||||
|
const out = process.env.OUT ?? '/tmp/regolo-gara';
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const server = await staticServer();
|
||||||
|
let browser;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
for (const [name, mobile, viewport, level] of [
|
||||||
|
['touch', true, { width: 844, height: 390 }, 'seria'],
|
||||||
|
['portrait', true, { width: 390, height: 844 }, 'bona'],
|
||||||
|
['desktop', false, { width: 1280, height: 860 }, 'male'],
|
||||||
|
]) {
|
||||||
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
await page.click('#splash');
|
||||||
|
await page.click('#new-game');
|
||||||
|
await page.click('#rione [data-rione="campomigliaio"]');
|
||||||
|
await page.click('#skip-intro');
|
||||||
|
await page.evaluate(async level => {
|
||||||
|
const { settle } = await import('/tests/helpers.mjs');
|
||||||
|
settle(game); game.run({ closeup: 'gara', level }); settle(game);
|
||||||
|
}, level);
|
||||||
|
// La presentazione dei piloti a tutto schermo: niente comandi finché non c'è il semaforo.
|
||||||
|
await page.waitForFunction(() => game.closeup?.step === 'intro');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
assert.equal(await page.locator('#bocce-controls').isVisible(), false, `${name}: comandi nascosti nella presentazione`);
|
||||||
|
assert.equal(await page.locator('#side').isVisible(), false, `${name}: la scena da sola`);
|
||||||
|
await page.screenshot({ path: `${out}/${name}-presentazione.png` });
|
||||||
|
// Salta la presentazione dei piloti e aspetta il via.
|
||||||
|
await page.evaluate(async () => { const g = await import('/src/engine/gara.js'); while (game.closeup.step === 'intro') g.press(game.closeup); });
|
||||||
|
await page.waitForFunction(() => game.closeup.step === 'race', null, { timeout: 15000 });
|
||||||
|
await page.waitForSelector('#bocce-controls:not([hidden])');
|
||||||
|
const easy = level !== 'male';
|
||||||
|
for (const id of ['#bocce-left', '#bocce-right', '#gara-brake', '#gara-pad'])
|
||||||
|
assert.equal(await page.locator(id).isVisible(), !easy, `${name}: ${id} ${easy ? 'nascosto' : 'visibile'}`);
|
||||||
|
assert.equal(await page.locator('#bocce-power').isVisible(), !easy, 'la sensibilità solo con lo sterzo vero');
|
||||||
|
// Nella guida semplice il pulsante Gas si tiene premuto (giù gas, su freno) e la scritta non cambia; con lo
|
||||||
|
// sterzo vero un tocco accende il gas e un altro lo spegne.
|
||||||
|
const gas = page.locator('#bocce-throw');
|
||||||
|
if (easy) await gas.dispatchEvent('pointerdown', { pointerId: 5, pointerType: mobile ? 'touch' : 'mouse', isPrimary: true });
|
||||||
|
else await gas.click();
|
||||||
|
await page.waitForFunction(() => game.closeup.riders.find(r => r.id === 'andrea').v > 40, null, { timeout: 15000 });
|
||||||
|
assert.equal(await gas.textContent(), easy ? 'Gas' : 'Molla');
|
||||||
|
await page.screenshot({ path: `${out}/${name}-${level}.png` });
|
||||||
|
if (easy) await gas.dispatchEvent('pointerup', { pointerId: 5, pointerType: mobile ? 'touch' : 'mouse', isPrimary: true });
|
||||||
|
else await gas.click();
|
||||||
|
const v = await page.evaluate(() => game.closeup.riders.find(r => r.id === 'andrea').v);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const after = await page.evaluate(() => game.closeup.riders.find(r => r.id === 'andrea').v);
|
||||||
|
assert.ok(after < v, `${name}: senza gas rallenta (${v.toFixed(0)} → ${after.toFixed(0)})`);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(`ok: schermate in ${out}`);
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||