Compare commits

...
17 Commits
Author SHA1 Message Date
manzolo 4d13a4ef25 Versione 0.1.39 2026-10-29 09:24:43 +01:00
manzoloandClaude Opus 5.5 22f408cc60 Il clic sul Chiaru è suo: stava sotto ai riquadri del parcheggio e della casa (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 09:18:13 +01:00
manzolo 927459344b Versione 0.1.38 2026-10-29 08:53:31 +01:00
manzoloandClaude Opus 5.5 08ab9ba069 La pista col gesso: le macchinine sul parcheggio di Cardetole contro il Chiaru (consenso del 29 ottobre, sprite di Codex dalla foto), un giro a spinte con le penalità alla francese (fuori, tagliè, cappottè, accompagnè: si torna dov'eri); Bertovo diventa Trappizzio Berti; Fabrizio Berti e il suo libro nei Ringraziamenti (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
2026-10-29 08:47:03 +01:00
manzolo 1918c8d9c7 Versione 0.1.37 2026-10-29 00:17:30 +01:00
manzoloandClaude Opus 5.5 381254ed4d Biliardo dell'Ing. Tozzi: il tiro pieno va più forte (720 cm/s; i rinquarti restano a 520) e il test nel browser segue i comandi nuovi (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-29 00:11:02 +01:00
manzolo a11a6ef59f Versione 0.1.36 2026-10-29 00:02:18 +01:00
manzoloandClaude Opus 5.5 086e4682d4 I rinquarti di Bertovo: gara a punti (Bertovo tira dopo Andrea, preciso secondo il livello), allenamento, posizioni misurate, traiettoria con le sponde e Prossima, comandi sicuri col dito (revisione di Codex); la Brauda in gara; voce di Bertovo (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 23:55:47 +01:00
manzoloandClaude Opus 5.5 bf22dc2511 Lavori in corso (non pubblicati): Bertovo al tavolo di mezzo coi rinquarti (Codex), Bini, Yuri, il Gianno e la Brauda in gara, l'Audi «Let it be», sensibilità e riquadro dello swipe, pulsanti su iPhone (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 23:36:05 +01:00
manzolo 6585c9ffcb Versione 0.1.35 2026-10-28 22:21:47 +01:00
manzoloandClaude Opus 5.5 13585491a4 GP di Fortezza: in gara anche il Porno (Andrea Bini, consenso del 28 ottobre) col ritratto e il Formula giallo; la testa di Puntella «non pervenuta»; Andrea in seconda fila (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 22:15:24 +01:00
manzolo 72a629e88b Versione 0.1.34 2026-10-28 22:09:40 +01:00
manzoloandClaude Opus 5.5 f57bec560a GP di Fortezza: Puntella in gara (consenso del 28 ottobre) col ritratto e lo SR truccatissimo viola e giallo, motore 6; il casco di Tozzino alla Damon Hill con le pale di remo (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 22:03:14 +01:00
manzolo 1f49486870 Versione 0.1.33 2026-10-28 21:33:17 +01:00
manzoloandClaude Opus 5.5 06f7cfbfad GP di Fortezza: guida a un dito col telefono (premuto è gas, lo swipe indica dove andare sullo schermo; Codex) e il casco di Tozzino alla Damon Hill (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 21:26:53 +01:00
manzolo e1b92278a5 Versione 0.1.32 2026-10-28 21:07:24 +01:00
manzoloandClaude Opus 5.5 b3c50f4694 Le strade attorno a casa di Andrea: Cardetole (dal Giannini, il ponticino, il Trappola) e il prato del calcio; le villette del Giannini al posto dell'area camper, i camper dietro il campo; in Via Liguri Magelli il campo laggiù a destra e la strada di casa accanto al Betino; «Vai» senza la «a» in più (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
2026-10-28 21:00:55 +01:00
108 changed files with 2324 additions and 188 deletions
+70
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+70
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+203 -3
View File
@@ -935,6 +935,10 @@
"file": "andrea-127a90af53.mp3",
"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.": {
"file": "andrea-1eae1a8577.mp3",
"sec": 6.1
@@ -1583,6 +1587,10 @@
"file": "andrea-ee455fdc33.mp3",
"sec": 3.67
},
"andrea|Qui dietro ci stanno i camper. Ma per noi questo resta il campo del Giannini.": {
"file": "andrea-195cf2b022.mp3",
"sec": 4.86
},
"andrea|Gli scivoli dei bambini. Una volta il pallone finiva lì una volta su tre.": {
"file": "andrea-a3e36c12a1.mp3",
"sec": 5.4
@@ -1599,6 +1607,18 @@
"file": "andrea-a93c65234e.mp3",
"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ì.»": {
"file": "andrea-a9e7f3f9ad.mp3",
"sec": 3.11
@@ -1619,9 +1639,17 @@
"file": "andrea-3d15dd47bb.mp3",
"sec": 4.82
},
"andrea|Casa mia. Terzo piano, quello con la tapparella a metà: è la mia camera.": {
"file": "andrea-acc7949f4c.mp3",
"sec": 5.91
"andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": {
"file": "andrea-8d5911c8e7.mp3",
"sec": 4.53
},
"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.": {
"file": "andrea-6c9e7f684e.mp3",
"sec": 8.72
},
"andrea|Se salgo, mi mettono ad apparecchiare. Prima il palio.": {
"file": "andrea-8945f1786d.mp3",
@@ -1639,6 +1667,14 @@
"file": "andrea-018183c4dd.mp3",
"sec": 5.82
},
"andrea|Su questo campo ci abbiamo passato trentacinque anni, giorno, pomeriggio e sera, a giocare a calcio.": {
"file": "andrea-5550b0890f.mp3",
"sec": 7.74
},
"andrea|Le maglie a fare da pali, e i palloni tagliati che entravano nelle terrazze, ovunque.": {
"file": "andrea-f6c10fd559.mp3",
"sec": 6.28
},
"andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": {
"file": "andrea-0a073729fd.mp3",
"sec": 7.3
@@ -2399,6 +2435,138 @@
"file": "mariano-d65fb6e871.mp3",
"sec": 3.42
},
"andrea|Si corre?": {
"file": "andrea-8052393bfd.mp3",
"sec": 0.7
},
"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|Dopo, Chiaru.": {
"file": "andrea-75b58c99e6.mp3",
"sec": 1.32
},
"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|La pista è qui. Finché non piove.": {
"file": "chiaru-841aa1efd7.mp3",
"sec": 2.39
},
"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|Trappizzio Berti! Che fai, giochi da solo?": {
"file": "andrea-da5fb02d21.mp3",
"sec": 3.4
},
"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, Trappizzio Berti.": {
"file": "andrea-4089f348c4.mp3",
"sec": 2.57
},
"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?": {
"file": "andrea-dbcf4d1083.mp3",
"sec": 2.49
@@ -2663,6 +2831,38 @@
"file": "ing_tozzi-282062c119.mp3",
"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.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
+17
View File
@@ -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
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]**.
## 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.
+2
View File
@@ -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ì |
| `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 |
| `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 |
| `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 |
+41 -3
View File
@@ -18,6 +18,30 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
- **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).
- **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 è],
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
@@ -256,11 +280,24 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
`assets/closeups/gp-fortezza.png` (prompt `prompts/closeups/gp-fortezza.txt`, guida dalla planimetria
`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
`src/story/piloti.js`: una riga per amico, `motore` e `testa` da 1 a 5, colori dalle foto. Corrono Puntella (SR,
colori Aprilia da GP provvisori), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
scuro, di serie), l'Arcu (Betino). **Puntella (Marco Villani) non corre finché manca il suo consenso** (`consenso: null` in piloti.js: quando arriva, la data). Mancano: quanto erano
`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»), 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
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
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
lui; i camper stanno dietro il suo campo). Cardetole: in basso a destra «dal Giannini» (le villette), a destra il
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.
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
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
@@ -279,6 +316,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.
7. Il Lele: come e quanto farlo comparire (con calma).
8. Battute e modi di dire del paese.
9. ~~Consenso del Chiaru~~ Dato il 29 ottobre. Le sue battute vanno bene?
### Grafica
- Sfondi puliti 16:9 di Pieve, Cardetole, Campomigliaio, interno della Fortezza (sotterranei).
+38 -3
View File
@@ -192,6 +192,10 @@
</section>
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
<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">
<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>
@@ -221,10 +225,15 @@
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
</details>
<details id="gara-help" hidden><summary>Come si gioca</summary>
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. Tieni premuto sulla scena (o Spazio, o ↑) per il
gas; sterza con le frecce ← →, oppure tenendo premuto e trascinando di lato col dito o col mouse, oppure tenendo
premuti i pulsanti Sterza. ↓ (o il pulsante Frena, tenuto premuto) frena, anche col gas dato. Dal pannello il
<p>Il GP di Fortezza: tre giri attorno alle mura coi cinquantini. 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 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>
<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
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
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>
@@ -240,6 +249,32 @@
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>
</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>
<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.
+2 -1
View File
@@ -1,12 +1,13 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.31",
"version": "0.1.39",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:micro": "node tests/micro.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
+6 -6
View File
@@ -24,8 +24,8 @@ export function tablePatches() {
}
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
function sphere(ctx, role, x, y, r, shadow = true) {
const [dark, mid, light] = BALL[role];
export function sphere(ctx, role, x, y, r, shadow = true) {
const [dark, mid, light] = BALL[role] ?? ['#43285f', '#bd83ef', '#f7e4ff'];
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
if (shadow) {
@@ -50,7 +50,7 @@ export function pinFall(p, time) {
if (p.visualDownAt === undefined) return 1;
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 tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
@@ -65,7 +65,7 @@ function pin(ctx, p, time) {
}
let lighting;
function table(ctx, bg) {
export function table(ctx, bg) {
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
else {
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
@@ -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.translate(sx(x), sy(y)); ctx.rotate(angle);
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
@@ -206,7 +206,7 @@ export function drawBiliardo(ctx, c, def, bg, sheet) {
ctx.restore();
scoreboard(ctx, c, def);
const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
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).'
: 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);
+56 -21
View File
@@ -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;
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
export const WINDUP = 0.18;
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 CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
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; }
const deviation = c => random(c) + random(c) + random(c) - 1.5;
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino];
const list = c => Object.values(c.balls);
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.
@@ -30,7 +30,10 @@ export function castle() {
}
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).
export function reach(v) {
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 = {}) {
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 };
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();
@@ -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) });
}
function strike(c, who, angle, power) {
const b = c.balls[who], v = speedFor(power);
export function strike(c, who, angle, 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.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
c.windup = WINDUP;
@@ -179,12 +183,12 @@ function strike(c, who, angle, power) {
return true;
}
// 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 w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } };
const b = balls.find(x => x.role === who), v = speedFor(power);
const w = { balls, pins, onContact, shot: { shooter: who, first: null, pallino: false, pins: [] } };
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;
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);
}
@@ -237,12 +241,20 @@ function cpuThink(c, budget = 12) {
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;
dt = Math.min(dt, 0.25); c.t += dt;
c.visual.time += dt;
if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.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) {
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
c.windup -= dt;
@@ -255,7 +267,7 @@ export function update(c, dt) {
}
if (c.step === 'rolling' && !c.windup) {
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; }
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
} 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;
}
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
export function press(c) {
if (!canThrow(c) || c.holding) return false;
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
// Una pressione continua ruota senza dipendere dalla ripetizione della tastiera.
export function turn(c, dir, owner, fine = false) {
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;
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.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
c.holding = false; return strike(c, 'player', c.angle, c.power);
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
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.
@@ -312,7 +346,7 @@ export function predict(c) {
const cached = predictions.get(c);
if (cached?.key === key) return cached.result;
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
const v = speedFor(c.power);
const v = speedFor(c.power, c.maxSpeed);
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const tracks = [];
@@ -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 === 'rolling') return 'Biglie in movimento…';
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);
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
}
+3 -2
View File
@@ -2,8 +2,9 @@
// Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto.
import { VERB_LABELS } from './game.js';
// «Vai a» davanti a un nome che comincia con «dal», «dalla»…: «Vai dal Trappola», non «Vai a dal Trappola».
const verbLabel = (verb, name) => verb === 'walk' && /^da(l|lla|i|gli|ll')?\b/i.test(name ?? '') ? 'Vai' : VERB_LABELS[verb];
// «Vai a» davanti a un nome che dice già dove («dal Trappola», «a destra, …», «verso …», «indietro, …»): solo «Vai».
const WHERE = /^(da(l|lla|i|gli|ll')?|a|verso|indietro|su|giù|in|laggiù|lassù|dietro|oltre)(?=[\s,]|$)/i;
const verbLabel = (verb, name) => verb === 'walk' && WHERE.test(name ?? '') ? 'Vai' : VERB_LABELS[verb];
export class Command {
constructor(game) {
+7 -3
View File
@@ -3,12 +3,14 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
import * as rinquarto from './rinquarto.js';
import * as basket from './basket.js';
import * as pesca from './pesca.js';
import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.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.
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 },
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 },
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 },
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 },
};
@@ -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).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
closeupPress(pointerId) { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup, pointerId); }
closeupRelease(pointerId) { return !!this.minigame()?.release?.(this.closeup, pointerId); }
closeupCancel(pointerId) { return !!this.minigame()?.cancel?.(this.closeup, pointerId); }
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
+25 -3
View File
@@ -1,4 +1,4 @@
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, 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 } from './gara.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
@@ -92,7 +92,8 @@ function moped(ctx, r, c) {
ctx.fillStyle = r.color; ctx.fillRect(-4, -2, 9, 4);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(-4, -0.5, 9, 1);
ctx.fillStyle = '#2a2a2a'; ctx.fillRect(4, -3, 1, 6); // il manubrio
ctx.fillStyle = r.id === 'andrea' ? '#f4e3c2' : '#2a2a2a'; ctx.beginPath(); ctx.arc(-1, 0, 2, 0, Math.PI * 2); ctx.fill(); // il casco
ctx.fillStyle = r.helmet ?? (r.id === 'andrea' ? '#f4e3c2' : '#2a2a2a'); ctx.beginPath(); ctx.arc(-1, 0, 2, 0, Math.PI * 2); ctx.fill(); // il casco
if (r.stripe) { ctx.fillStyle = r.stripe; ctx.fillRect(-2, -0.5, 2, 1); }
// La miscela: una fiammata dietro.
if (r.boost > 0 && Math.floor(c.age * 20) % 2) { ctx.fillStyle = '#ffb030'; ctx.fillRect(-10, -1, 3, 2); }
ctx.restore();
@@ -113,6 +114,21 @@ function hud(ctx, c) {
ctx.fillStyle = me.boost > 0 ? '#ffb030' : k > 0.9 ? '#e0583a' : '#b9d392'; ctx.fillRect(10, bar, Math.round(50 * k), 5);
text(ctx, `${Math.round(me.v * 0.5)} km/h`, 116, bar + 3, 6, '#f4e3c2', 'right');
}
// L'origine mobile dello swipe e la direzione scelta col dito (o col mouse premuto).
function swipe(ctx, c) {
if (c.pointer === null || !c.anchor || !c.finger) return;
const [x, y] = c.anchor, [fx, fy] = c.finger, dx = fx - x, dy = fy - y, length = Math.hypot(dx, dy);
ctx.save();
ctx.strokeStyle = 'rgba(244,227,194,0.55)'; ctx.lineWidth = 1.5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.stroke();
if (length >= SWIPE.dead) {
const ux = dx / length, uy = dy / length;
ctx.beginPath(); ctx.moveTo(x + ux * 6, y + uy * 6); ctx.lineTo(fx, fy);
ctx.moveTo(fx - ux * 6 - uy * 3, fy - uy * 6 + ux * 3); ctx.lineTo(fx, fy);
ctx.lineTo(fx - ux * 6 + uy * 3, fy - uy * 6 - ux * 3); ctx.stroke();
}
ctx.restore();
}
// La presentazione: il cartellino di chi arriva. A sinistra il personaggio (lo sprite del gioco, o il ritratto), a
// destra il suo motorino dipinto, sotto quanto va (motore) e quanto rischia (testa). `look(src)` dà l'immagine caricata,
// `sheet(id)` lo sprite di un personaggio.
@@ -139,6 +155,9 @@ function card(ctx, c, look, sheet) {
panel(ctx, x, y, w, h);
text(ctx, 'GP DI FORTEZZA', x + w / 2, y + 10, 6, '#ffe28a', 'center');
figure(ctx, r, x + 46, y + 14, look, sheet);
// Il casco di chi ce l'ha disegnato, accanto al personaggio.
const casco = r.casco && look(r.casco);
if (casco) { const k = 30 / Math.max(casco.width, casco.height); ctx.drawImage(casco, Math.round(x + 66), Math.round(y + 20), Math.round(casco.width * k), Math.round(casco.height * k)); }
// Il motorino, entra da sinistra e si ferma (come in pista).
const moto = r.moto && look(r.moto), t = Math.min(1, (c.t % INTRO.each) / 0.6), e = 1 - (1 - t) ** 3;
if (moto) {
@@ -155,7 +174,9 @@ function card(ctx, c, look, sheet) {
for (const [k, [label, value]] of [['MOTORE', r.motore], ['TESTA', r.testa]].entries()) {
const yy = y + 116 + k * 12;
text(ctx, label, x + 222, yy, 5, '#c5c6b6');
for (let i = 0; i < 5; i++) { ctx.fillStyle = i < value ? (k ? '#b9d392' : '#e0583a') : '#3a3f3c'; ctx.fillRect(x + 262 + i * 13, yy - 3, 10, 6); }
// Cinque caselle; il motore truccatissimo (6) ne accende una sesta, gialla. Per qualcuno la testa è una scritta.
if (k && r.testaNota) { text(ctx, r.testaNota, x + 262, yy, 5, '#e0583a'); continue; }
for (let i = 0; i < Math.max(5, value); i++) { ctx.fillStyle = i >= 5 ? '#ffe28a' : i < value ? (k ? '#b9d392' : '#e0583a') : '#3a3f3c'; ctx.fillRect(x + 262 + i * 12, yy - 3, 9, 6); }
}
text(ctx, 'TOCCA: IL PROSSIMO', x + w / 2, y + h - 6, 5, '#8f9488', 'center');
}
@@ -204,6 +225,7 @@ export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null)
// Prima chi è dietro, così chi è davanti resta sopra; Andrea sempre sopra a tutti.
for (const r of [...standings(c)].reverse().sort((a, b) => (a.id === 'andrea') - (b.id === 'andrea'))) if (r.shown !== false) moped(ctx, r, c);
if (c.step === 'intro') { card(ctx, c, look, sheet); ctx.restore(); return; }
if (c.step === 'race' || c.step === 'start') swipe(ctx, c);
hud(ctx, c);
if (c.step === 'race') position(ctx, c);
if (c.step === 'start') lights(ctx, c);
+37 -16
View File
@@ -3,7 +3,7 @@
// la planimetria generale 1:500 che ha mandato Andrea (`assets/raw/fortezza-planimetria.png`), schiacciata nel
// formato del gioco (640x360): x = 90 + (x0 - 215)·0,7, y = 20 + (y0 - 42)·0,49.
//
// Si tiene premuto per il gas (mouse, dito, Spazio o ↑), si sterza con ← → (o trascinando di lato col dito, o coi
// Si tiene premuto per il gas (mouse, dito, Spazio o ↑), si sterza con ← → (o indicando la direzione con uno swipe, o coi
// pulsanti), ↓ frena. Sulla pista: acceleratori, buche (andandoci forte si casca) e lattine di miscela da raccogliere.
// Fuori strada c'è l'erba che frena, e poi la paglia: chi ci arriva forte si ferma.
@@ -29,15 +29,20 @@ 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 };
// 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;
// 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 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
// I piloti sono dati (story.closeups.gara.riders, da piloti.js): `motore` da 1 (di serie) a 5 (truccato da far
// paura), `testa` da 1 (matto) a 5 (prudente). Ne vengono velocità massima e accelerazione in px/s, tenuta, e per gli
// paura, 6 truccatissimo), `testa` da 1 (matto) a 5 (prudente). Ne vengono velocità massima e accelerazione in px/s, tenuta, e per gli
// altri quanto si fidano delle curve (`caution`) e quanto spesso rischiano troppo (`slip`).
export function rider({ motore = 3, testa = 3, ...r }) {
const m = clamp(motore, 1, 5), t = clamp(testa, 1, 5);
// Il 6 è il truccatissimo: lo SR di Puntella (Andrea, 28 ottobre).
const m = clamp(motore, 1, 6), t = clamp(testa, 1, 5);
return { ...r, motore: m, testa: t, vmax: 74 + 6.5 * m, accel: 40 + 5 * m, grip: 1.1 - 0.03 * m, caution: 1.0025 - 0.0125 * t, slip: 0.4275 - 0.0775 * t };
}
// Il gruppo di prova, quando la storia non ne dà uno.
@@ -47,7 +52,7 @@ export const RIDERS = [
];
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
export const LEVELS = {
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
bona: { rivals: 0.74, stun: 0.4, guide: 'zone' },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
male: { rivals: 0.93, stun: 1.4, guide: null },
};
@@ -150,16 +155,18 @@ export function newGara(seed = Date.now() >>> 0, def = {}) {
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; }
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 },
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null,
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null, sens: SWIPE.sens,
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 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 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 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
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
@@ -167,7 +174,7 @@ export const setPower = () => false;
function sync(c) {
c.holding = c.keys.gas.size > 0 || c.pointer !== null || c.toggle;
c.braking = c.keys.brake || c.brakeButton;
if (c.pointer === null) c.drag = 0;
if (c.pointer === null) { c.drag = 0; c.anchor = null; c.finger = null; }
}
// Il primo tocco (o tasto) salta la presentazione, senza dare gas.
export function press(c, id = 'press') {
@@ -209,12 +216,14 @@ function place(r, s, shown) {
export const presenting = c => c.step === 'intro' ? c.riders[Math.floor(c.t / INTRO.each)] ?? null : null;
// Il pulsante Gas del pannello: un tocco lo accende, un altro lo spegne (per chi non può tenere premuto).
export function shoot(c) { if (!canThrow(c)) return false; if (c.step === 'intro') return nextIntro(c); c.toggle = !c.toggle; sync(c); return true; }
// Il dito o il mouse premuto sulla scena: quanto ci si sposta di lato da dove si è premuto è lo sterzo. Il mouse che
// passa senza premere, o col gas dato da tastiera o dal pannello, non sterza.
export function aimScreen(c, [x], id = 'press') {
if (!Number.isFinite(x)) return false;
if (c.pointer === null) c.anchor = x;
else if (c.pointer === id) c.drag = clamp((x - c.anchor) / 28, -1, 1);
// Lo swipe indica dove andare sullo schermo. L'origine segue il dito oltre i 60 px.
export function aimScreen(c, [x, y], id = 'press') {
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
if (c.pointer !== null && c.pointer !== id) return false;
if (c.pointer === null || !c.anchor) c.anchor = [x, y];
c.finger = [x, y];
const dx = x - c.anchor[0], dy = y - c.anchor[1], distance = Math.hypot(dx, dy);
if (distance > SWIPE.reach) c.anchor = [x - dx * SWIPE.reach / distance, y - dy * SWIPE.reach / distance];
return false;
}
// La tastiera: ← → sterzano, ↑ Spazio e Invio sono il gas, ↓ il freno; finché si tengono giù.
@@ -233,7 +242,19 @@ export function key(c, name, down) {
// I pulsanti del pannello tenuti premuti: Sterza (-1, 0, 1) e Frena.
export function steerButton(c, dir) { c.buttons = dir; return true; }
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
export const steering = c => clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
export function steering(c) {
let want = 0;
if (c.pointer !== null && c.anchor && c.finger) {
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.
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);
}
// 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);
+121
View File
@@ -0,0 +1,121 @@
import { HALF, CAR, canThrow, guide, done, overHeld } 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
}
// 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');
}
}
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);
const images = { player: image?.(def.cars?.player), cpu: image?.(def.cars?.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');
for (const [who, x0, color] of [['player', 168, '#e0543c'], ['cpu', 404, '#3c66d8']]) {
text(ctx, NAME[who], x0, 11, 8, '#fff0c8');
ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 150, 8);
ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(150 * done(c, who)), 8);
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 149, 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';
panel(ctx, 170, 140, 300, 72);
text(ctx, title, 320, 160, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center');
text(ctx, `Un giro, ${c.result.flicks.player} spinte a testa`, 320, 182, 8, '#efd49e', 'center');
text(ctx, 'Torna dal Chiaru per uscire.', 320, 198, 8, '#d9ccba', 'center');
}
ctx.restore();
}
+362
View File
@@ -0,0 +1,362 @@
// 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, LONG_PRESS = 0.6, 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);
}
export function newMicro(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK;
const c = { step: 'aim', 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;
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 } };
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 const aimScreen = (c, [x, y]) => 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 canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
export function turn(c, dir, owner, fine = false) {
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 (!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.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 < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco 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 };
}
export function button(c) {
if (c.step === 'result') return 'Finita';
if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…';
return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi';
}
// 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) {
const where = `Andrea ${Math.round(done(c, 'player') * 100)}%, Chiaru ${Math.round(done(c, 'cpu') * 100)}% del giro.`;
if (c.step === 'result') return `${where} ${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'}`;
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.`;
}
+12
View File
@@ -5,9 +5,11 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawRinquarto } from './rinquarto-render.js';
import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js';
import { drawMicro } from './micro-render.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-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);
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.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
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'));
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 (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
+77
View File
@@ -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();
}
+239
View File
@@ -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)}`;
}
+6
View File
@@ -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-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); }
// 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.
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 }); }
+141 -71
View File
@@ -10,6 +10,8 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.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 pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
@@ -498,7 +500,7 @@ canvas.addEventListener('pointerdown', e => {
canvas.setPointerCapture(e.pointerId);
return;
}
// Il GP di Fortezza: un dito solo sulla scena dà gas e sterza trascinando; gli altri tocchi non contano
// Il GP di Fortezza: un dito solo dà gas e indica la direzione sullo schermo; gli altri tocchi non contano
// (sterzo e freno hanno i loro pulsanti, che si tengono insieme al gas).
if (type === 'gara') {
if (press || game.skip()) return;
@@ -506,15 +508,16 @@ canvas.addEventListener('pointerdown', e => {
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return;
}
if (press?.closeup) return;
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.closeup.aimLocked = true; // il clic fissa la mira
}
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).
if (!game.skip()) game.closeupPress();
if (!game.skip()) game.closeupPress(e.pointerId);
canvas.setPointerCapture(e.pointerId);
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; }
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);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) {
@@ -553,8 +561,13 @@ canvas.addEventListener('pointerup', e => {
}
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('pointercancel', e => {
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 => {
e.preventDefault();
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);
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)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
});
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.
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.' },
@@ -582,14 +602,38 @@ const MINI = {
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
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.' },
// 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 (mouse, dito, Spazio o ↑) per il gas, sterza con ← → o trascinando di lato; ↓ frena.' },
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.' },
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 mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
$('bocce-power').addEventListener('input', e => {
const c = miniState(); if (!c) 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());
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
@@ -614,7 +658,16 @@ for (let i = 0; i < 3; i++) {
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
}
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
$(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).
$(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); });
@@ -680,8 +733,11 @@ function updateInventory() {
let choicesKey = '';
function updateChoices() {
const options = game.options(), inDialogue = !!game.dialogue;
$('panel').hidden = inDialogue;
$('choices').hidden = !inDialogue;
// Con un minigioco aperto il suo pannello prende il posto di verbi e scelte: si decide qui, una volta sola, senza
// 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('|');
if (key === choicesKey) return;
choicesKey = key;
@@ -787,8 +843,8 @@ window.addEventListener('keydown', e => {
if (e.key === 'Escape') { game.closeupClose(); return; }
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).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
if (onControl) return;
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; }
if (onControl && [' ', 'Enter'].includes(e.key)) return;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
@@ -834,7 +890,7 @@ window.addEventListener('keydown', e => {
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
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.
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();
});
@@ -853,21 +909,22 @@ function updateSpeech() {
if (!sp) { if (box.textContent) box.textContent = ''; return; }
const [x, y] = game.speakerPoint(sp.who);
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
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
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 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));
box.style.left = px + 'px';
box.style.top = py + 'px';
if (box.style.left !== px + 'px') box.style.left = px + 'px';
if (box.style.top !== py + 'px') box.style.top = py + 'px';
}
function updateUi() {
// 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;
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 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.`
@@ -878,22 +935,22 @@ function updateUi() {
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
$('bocce-controls').hidden = !mg || cards;
$('bocce-controls').inert = !running;
$('briscola-controls').hidden = def?.type !== 'briscola';
$('briscola-controls').inert = !running;
$('scopa-controls').hidden = def?.type !== 'scopa';
$('scopa-controls').inert = !running;
setProp($('bocce-controls'), 'hidden', !mg || cards);
setProp($('bocce-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') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = scopa.status(c, def.names);
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
setProp(button, 'disabled', !scopa.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
}
// Le prese fra cui scegliere, una per pulsante.
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
@@ -905,14 +962,14 @@ function updateUi() {
b.dataset.option = k; return b;
}));
}
box.hidden = !options.length;
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
$('scopa-next').hidden = c.step !== 'smazzata';
setProp(box, 'hidden', !options.length);
[...box.children].forEach((b, k) => setAttr(b, 'aria-pressed', String(c.choice?.selected === k)));
setProp($('scopa-next'), 'hidden', c.step !== 'smazzata');
}
$('tressette-controls').hidden = def?.type !== 'tressette';
$('tressette-controls').inert = !running;
setProp($('tressette-controls'), 'hidden', def?.type !== 'tressette');
setProp($('tressette-controls'), 'inert', !running);
if (def?.type === 'tressette') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = tressette.status(c, def.seats);
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
@@ -920,66 +977,79 @@ function updateUi() {
const button = $('tressette-card-' + i), card = c.hands[0][i];
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
if (button.textContent !== label) button.textContent = label;
button.hidden = !card;
button.disabled = !ok.includes(i);
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
setProp(button, 'hidden', !card);
setProp(button, 'disabled', !ok.includes(i));
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
for (const signal of tressette.SIGNALS) {
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
setProp($('tressette-' + signal), 'disabled', !mine || c.table.length > 0);
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') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = briscola.status(c, def.names);
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
setProp(button, 'disabled', !briscola.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
}
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');
$('panel').hidden = $('choices').hidden = true;
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');
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
$('bocce-power').value = Math.round(c.power * 100);
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(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;
setValue($('bocce-power'), Math.round(c.power * 100));
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
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);
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).
$('bocce-power').closest('label').hidden = def.type === 'gara';
$('bocce-left').textContent = def.type === 'gara' ? '← Sterza' : '← Mira';
$('bocce-right').textContent = def.type === 'gara' ? 'Sterza →' : 'Mira →';
$('gara-brake').hidden = def.type !== 'gara';
$('gara-brake').disabled = !(mg.canAdjust?.(c) ?? false) || !!game.speech;
$('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');
$('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo';
$('basket-help').hidden = def.type !== 'basket';
$('pesca-help').hidden = def.type !== 'pesca';
$('gara-help').hidden = def.type !== 'gara';
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
const powerLabel = $('bocce-power').closest('label');
// Ai rinquarti la forza è sempre piena: il cursore non serve.
setProp(powerLabel, 'hidden', def.type === 'rinquarto');
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
setProp($('gara-pad'), 'hidden', def.type !== 'gara');
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');
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) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
$('panel').inert = !!c;
setProp($('map-button'), 'hidden', !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2);
setProp($('pause-button'), 'hidden', !running);
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
setProp($('panel'), 'inert', !!c);
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
// 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);
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.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page');
@@ -989,7 +1059,7 @@ function updateUi() {
reveal = nextReveal;
if (game.state.ended && !game.speech && !game.queue.length) {
running = false;
$('ending').hidden = false;
setProp($('ending'), 'hidden', false);
}
}
+19 -5
View File
@@ -1,18 +1,32 @@
// La gara dei cinquantini (idea di Andrea, 28 ottobre): chi corre, in ordine di griglia (due per fila).
// Ogni amico che manda la foto del suo motorino si aggiunge con una riga. `motore`: da 1 (di serie) a 5 (truccato da
// far paura); `testa`: da 1 (matto, in curva rischia) a 5 (prudente). `color` e `trim`: i colori del motorino in pista.
// far paura; 6 truccatissimo, solo lo SR di Puntella); `testa`: da 1 (matto, in curva rischia) a 5 (prudente). `color` e `trim`: i colori del motorino in pista.
// Per la presentazione: `moto`, il disegno del motorino (Codex, dalla foto); `sprite`, il personaggio del gioco (un
// nome di story.characters), oppure `face`, un ritratto quadrato; senza nessuno dei due, una sagoma.
// nome di story.characters), oppure `face`, un ritratto quadrato; senza nessuno dei due, una sagoma. `casco`: il disegno
// del casco per il cartellino, `helmet` e `stripe` i suoi colori visti dall'alto in pista. `testaNota`: una scritta al
// posto delle caselle della testa.
// Persone vere solo col loro consenso (`consenso`: chi l'ha dato e quando).
export const piloti = [
// Lo SR di Puntella era il più truccato di tutti; per ora i colori dell'Aprilia SR 50 da GP (foto di Andrea).
{ id: 'puntella', name: 'Puntella', bike: 'lo SR', motore: 5, testa: 1, color: '#5a3fb5', trim: '#e8442e', moto: 'assets/closeups/gp/sr.png', consenso: null }, // [DA ANDREA: consenso di Marco Villani]
// Puntella (Marco Villani, consenso del 28 ottobre) con lo SR truccatissimo, viola e giallo come il suo (foto di
// Andrea); il ritratto dalla sua foto.
{ id: 'puntella', name: 'Puntella', bike: 'lo SR truccatissimo', motore: 6, testa: 1, testaNota: 'NON PERVENUTA', color: '#5b2a9e', trim: '#f2c81e', moto: 'assets/closeups/gp/sr.png', face: 'assets/closeups/gp/puntella.png', consenso: '2026-10-28' },
// Cistola (Davide Poggi) col suo CR 50 Z, blu notte e argento, dalla foto che ha mandato; il volto dal suo murale.
{ 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.
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
// Bini (Andrea Bini, consenso del 28 ottobre; preferisce «Bini») col suo Italjet Formula giallo, che va fortissimo
// (foto di Andrea); il ritratto dalla sua foto.
{ 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).
{ 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).
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.
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
];
+183 -18
View File
@@ -97,7 +97,7 @@ export const story = {
credits: [
{ 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: '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: '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.
@@ -223,6 +223,28 @@ export const story = {
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
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',
picture: 'assets/closeups/micro-parcheggio.png',
names: { player: 'Andrea', cpu: 'Chiaru' },
cars: { player: 'assets/closeups/micro/rossa.png', cpu: 'assets/closeups/micro/blu.png' },
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: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
@@ -285,8 +307,13 @@ export const story = {
tosca: { name: 'La Tosca', color: '#ffb0d0' },
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).
// 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' },
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' },
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.
@@ -1667,9 +1694,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]],
depth: [[190, 1.2], [360, 2.5]],
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.
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.' }] }],
hotspots: [
{
@@ -1688,6 +1719,13 @@ export const story = {
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',
actions: {
@@ -2578,10 +2616,11 @@ export const story = {
},
},
{
id: 'all_area_camper', name: 'a sinistra, verso l\'area camper', box: [320, 160, 110, 42], at: [442, 202], exit: { scene: 'area_camper', at: [600, 330], face: 'left' },
id: 'all_area_camper', name: 'a sinistra, verso le villette del Giannini', box: [320, 160, 110, 42], at: [442, 202], exit: { scene: 'area_camper', at: [600, 330], face: 'left' },
},
{
id: 'al_giannini', name: 'in fondo a sinistra, verso il campo del Giannini', box: [380, 165, 58, 30], at: [440, 200], exit: { scene: 'campo_giannini', at: [480, 218], face: 'left' },
// [DA ANDREA: dove si gira per Cardetole, in fondo al ponticino]
id: 'a_cardetole', name: 'in fondo a destra, verso Cardetole', box: [578, 80, 62, 90], at: [476, 204], exit: { scene: 'cardetole', at: [628, 290], face: 'left' },
},
{
id: 'alla_stazione', name: 'a destra, verso la stazione', box: [486, 160, 90, 40], at: [474, 202], exit: { scene: 'stazione', at: [20, 300], face: 'right' },
@@ -2592,7 +2631,8 @@ export const story = {
],
},
area_camper: {
name: 'Area camper',
// Le villette dove abita il Giannini: da qui si va al suo campo (Andrea, 28 ottobre). L'id resta area_camper.
name: 'Le villette del Giannini',
map: [70, 84],
background: 'assets/scenes/area-camper.png',
walk: [[0, 360], [0, 260], [200, 232], [380, 240], [450, 248], [640, 300], [640, 360]],
@@ -2606,13 +2646,18 @@ export const story = {
},
},
{
id: 'villette', name: 'villette', box: [500, 80, 140, 120], at: [560, 280], face: 'right',
id: 'villette', name: 'villette', box: [500, 80, 140, 106], at: [560, 280], face: 'right',
actions: {
look: [{ say: 'Le villette rosa coi balconi bianchi. Da qui a casa mia è un attimo.' }],
},
},
{
id: 'a_cardetole', name: 'verso casa, a Cardetole', box: [0, 230, 80, 130], at: [20, 300], exit: { scene: 'cardetole', at: [300, 350], face: 'up' },
// Casa è a destra, per la strada (Andrea, 28 ottobre).
id: 'a_cardetole', name: 'verso casa, a Cardetole', box: [572, 188, 68, 96], at: [618, 300], exit: { scene: 'cardetole', at: [560, 348], face: 'up' },
},
{
// Laggiù, in fondo al vialetto vicino alla siepe, il campo del Giannini (Andrea, 28 ottobre).
id: 'al_giannini', name: 'laggiù, il campo del Giannini', box: [330, 150, 96, 74], at: [380, 246], exit: { scene: 'campo_giannini', at: [480, 216], face: 'left' },
},
{
id: 'al_ponticino', name: 'indietro, il ponticino', box: [560, 300, 80, 60], at: [620, 330], exit: { scene: 'ponticino', at: [442, 202], face: 'down' },
@@ -2620,7 +2665,8 @@ export const story = {
],
},
// Il prato del parco dei camper visto dall'altra parte: per i ragazzi, «il campo del Giannini». Ci si arriva dal
// ponticino della Carza; niente uscita verso l'area camper, perché è già questa (Andrea, 28 ottobre).
// lato delle villette dove abita il Giannini (fra gli alberi a destra) e dal Trappola; dal ponticino non si viene
// qui. L'area camper è il campo stesso: i camper stanno qui dietro (Andrea, 28 ottobre).
campo_giannini: {
name: 'Il campo del Giannini',
map: [82, 96],
@@ -2636,6 +2682,12 @@ export const story = {
use: [{ say: 'Senza pallone, e senza la squadra, non vale.' }],
},
},
{
id: 'camper', name: 'qui dietro, i camper', box: [520, 320, 120, 40], at: [560, 340], face: 'down', default: 'look',
actions: {
look: [{ say: 'Qui dietro ci stanno i camper. Ma per noi questo resta il campo del Giannini.' }],
},
},
{
id: 'giochi', name: 'giochi dei bambini', box: [220, 170, 80, 40], at: [260, 216], face: 'up',
actions: {
@@ -2643,10 +2695,11 @@ export const story = {
},
},
{
id: 'al_ponticino', name: 'verso il ponticino della Carza', box: [440, 150, 90, 60], at: [480, 216], exit: { scene: 'ponticino', at: [444, 204], face: 'right' },
// Fra gli alberi a destra si arriva alle villette del Giannini, in fondo al vialetto (Andrea, 28 ottobre).
id: 'all_area_camper', name: 'verso le villette del Giannini', box: [440, 150, 90, 60], at: [480, 216], exit: { scene: 'area_camper', at: [380, 246], face: 'down' },
},
{
id: 'via_liguri_magelli', name: 'Via Liguri Magelli', box: [0, 100, 70, 260], at: [30, 260], exit: { scene: 'via_liguri_magelli', at: [320, 340], face: 'up' },
id: 'via_liguri_magelli', name: 'dal Trappola, in Via Liguri Magelli', box: [0, 100, 70, 260], at: [30, 260], exit: { scene: 'via_liguri_magelli', at: [520, 306], face: 'left' },
},
],
},
@@ -2658,7 +2711,8 @@ export const story = {
name: 'Via Liguri Magelli',
map: [76, 97],
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]],
entry: [320, 340],
speakers: { arcu: [374, 176] },
@@ -2676,12 +2730,21 @@ export const story = {
},
{
// «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: {
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
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',
actions: {
@@ -2697,7 +2760,12 @@ export const story = {
},
},
{
id: 'al_giannini', name: 'verso il campo del Giannini', box: [180, 330, 280, 30], at: [320, 345], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
// Casa di Andrea è lassù, in cima alla salita, a destra del Betino (Andrea, 28 ottobre).
id: 'a_cardetole', name: 'lassù, verso casa a Cardetole', box: [274, 148, 40, 50], at: [264, 202], exit: { scene: 'cardetole', at: [20, 352], face: 'right' },
},
{
// Il campo del Giannini è laggiù a destra, oltre la siepe (Andrea, 28 ottobre).
id: 'al_giannini', name: 'verso il campo del Giannini', box: [430, 150, 132, 100], at: [520, 306], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
},
],
},
@@ -2709,25 +2777,59 @@ export const story = {
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]],
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: [
{
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',
actions: {
look: [{ say: 'Casa mia. Terzo piano, quello con la tapparella a metà: è la mia camera.' }],
look: [{ say: 'Casa mia. Secondo piano: quella con la tapparella a metà è la camera di mia sorella, e a destra quella dei miei.' }],
open: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
walk: [{ say: 'Se salgo, mi mettono ad apparecchiare. Prima il palio.' }],
},
},
{
id: 'platano', name: 'platano', box: [90, 0, 170, 216], at: [200, 306], face: 'up',
id: 'platano', name: 'platano', box: [90, 0, 170, 206], at: [200, 306], face: 'up',
actions: {
look: [{ say: 'Il prato sotto casa. Qui le porte le facevamo con le maglie per terra: due maglie, un palo ciascuna.' }, { say: 'E ogni gol era «Era fuori!», «No, era dentro!».' }],
use: [{ say: 'Arrampicarmi? Alla mia età, e davanti a tutto il condominio?' }],
},
},
// Si compare a destra dell'area camper: inquadrature invertite rispetto alle foto (Andrea, 28 ottobre, va bene così).
{
id: 'alla_pieve', name: 'su verso la Pieve', box: [0, 330, 640, 30], at: [300, 354], exit: { scene: 'area_camper', at: [600, 330], face: 'left' },
// Il prato in salita sotto casa: le parole sono di Andrea (28 ottobre).
id: 'prato', name: 'prato', box: [52, 208, 278, 76], at: [262, 300], face: 'up', default: 'look',
actions: {
look: [
{ say: 'Su questo campo ci abbiamo passato trentacinque anni, giorno, pomeriggio e sera, a giocare a calcio.' },
{ say: 'Le maglie a fare da pali, e i palloni tagliati che entravano nelle terrazze, ovunque.' },
],
},
},
// Le uscite di casa (Andrea, 28 ottobre): in basso a destra il Giannini (le sue villette, dalla strada delle
// villette), a destra il ponticino della Carza, a sinistra il Trappola.
{
id: 'al_giannini', name: 'dal Giannini', box: [440, 322, 200, 38], at: [560, 348], exit: { scene: 'area_camper', at: [618, 300], face: 'left' },
},
{
id: 'al_ponticino', name: 'a destra, il ponticino della Carza', box: [596, 150, 44, 110], at: [628, 290], exit: { scene: 'ponticino', at: [476, 204], 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' }],
},
},
],
},
@@ -3147,6 +3249,69 @@ 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 corre?', 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' },
] },
{ text: 'Dopo, Chiaru.', next: 'end', then: [{ say: 'La pista è qui. Finché non piove.', who: 'chiaru', pose: 'stand' }] },
],
},
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]
bertovo: {
nodes: {
start: {
options: [
{ text: 'Trappizzio Berti! 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, Trappizzio Berti.', 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: {
nodes: {
start: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.31';
export const VERSION = '0.1.39';
// 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).
+9 -4
View File
@@ -51,18 +51,23 @@ 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-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; }
/* 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; }
.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 summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-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, #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, #rinquarto-help summary { cursor: pointer; padding: 6px 0; }
#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) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#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-volume { width: 130px; accent-color: var(--accent); }
+6 -3
View File
@@ -128,11 +128,13 @@ try {
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 });
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');
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');
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');
const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` });
@@ -158,7 +160,8 @@ try {
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 }] });
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;
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 });

Some files were not shown because too many files have changed in this diff Show More