diff --git a/assets/audio/gara.mp3 b/assets/audio/gara.mp3
new file mode 100644
index 0000000..c336b83
Binary files /dev/null and b/assets/audio/gara.mp3 differ
diff --git a/assets/closeups/gp-fortezza.png b/assets/closeups/gp-fortezza.png
new file mode 100644
index 0000000..1c27bbf
Binary files /dev/null and b/assets/closeups/gp-fortezza.png differ
diff --git a/assets/closeups/gp/betino.png b/assets/closeups/gp/betino.png
new file mode 100644
index 0000000..04abd86
Binary files /dev/null and b/assets/closeups/gp/betino.png differ
diff --git a/assets/closeups/gp/booster.png b/assets/closeups/gp/booster.png
new file mode 100644
index 0000000..f463d30
Binary files /dev/null and b/assets/closeups/gp/booster.png differ
diff --git a/assets/closeups/gp/cistola.png b/assets/closeups/gp/cistola.png
new file mode 100644
index 0000000..16e385d
Binary files /dev/null and b/assets/closeups/gp/cistola.png differ
diff --git a/assets/closeups/gp/cr50z.png b/assets/closeups/gp/cr50z.png
new file mode 100644
index 0000000..e063103
Binary files /dev/null and b/assets/closeups/gp/cr50z.png differ
diff --git a/assets/closeups/gp/fifty.png b/assets/closeups/gp/fifty.png
new file mode 100644
index 0000000..82e32ca
Binary files /dev/null and b/assets/closeups/gp/fifty.png differ
diff --git a/assets/closeups/gp/sr.png b/assets/closeups/gp/sr.png
new file mode 100644
index 0000000..8c6cfe4
Binary files /dev/null and b/assets/closeups/gp/sr.png differ
diff --git a/assets/scenes/mini/via-liguri-magelli.png b/assets/scenes/mini/via-liguri-magelli.png
new file mode 100644
index 0000000..bf2c508
Binary files /dev/null and b/assets/scenes/mini/via-liguri-magelli.png differ
diff --git a/assets/scenes/via-liguri-magelli.png b/assets/scenes/via-liguri-magelli.png
new file mode 100644
index 0000000..52402ee
Binary files /dev/null and b/assets/scenes/via-liguri-magelli.png differ
diff --git a/assets/voices/andrea-191021ad7c.mp3 b/assets/voices/andrea-191021ad7c.mp3
new file mode 100644
index 0000000..cd0e953
Binary files /dev/null and b/assets/voices/andrea-191021ad7c.mp3 differ
diff --git a/assets/voices/andrea-33e57caee9.mp3 b/assets/voices/andrea-33e57caee9.mp3
new file mode 100644
index 0000000..badb4f6
Binary files /dev/null and b/assets/voices/andrea-33e57caee9.mp3 differ
diff --git a/assets/voices/andrea-34aad1ffe3.mp3 b/assets/voices/andrea-34aad1ffe3.mp3
deleted file mode 100644
index efb5c62..0000000
Binary files a/assets/voices/andrea-34aad1ffe3.mp3 and /dev/null differ
diff --git a/assets/voices/andrea-3648b1b08e.mp3 b/assets/voices/andrea-3648b1b08e.mp3
new file mode 100644
index 0000000..34d783d
Binary files /dev/null and b/assets/voices/andrea-3648b1b08e.mp3 differ
diff --git a/assets/voices/andrea-3d15dd47bb.mp3 b/assets/voices/andrea-3d15dd47bb.mp3
new file mode 100644
index 0000000..e58c527
Binary files /dev/null and b/assets/voices/andrea-3d15dd47bb.mp3 differ
diff --git a/assets/voices/andrea-4ca44c5405.mp3 b/assets/voices/andrea-4ca44c5405.mp3
new file mode 100644
index 0000000..9994a4e
Binary files /dev/null and b/assets/voices/andrea-4ca44c5405.mp3 differ
diff --git a/assets/voices/andrea-75c55bf1e7.mp3 b/assets/voices/andrea-75c55bf1e7.mp3
new file mode 100644
index 0000000..2dbfbda
Binary files /dev/null and b/assets/voices/andrea-75c55bf1e7.mp3 differ
diff --git a/assets/voices/andrea-7c17aa384c.mp3 b/assets/voices/andrea-7c17aa384c.mp3
new file mode 100644
index 0000000..e2bdb70
Binary files /dev/null and b/assets/voices/andrea-7c17aa384c.mp3 differ
diff --git a/assets/voices/andrea-96de1a155d.mp3 b/assets/voices/andrea-96de1a155d.mp3
new file mode 100644
index 0000000..38f0e2d
Binary files /dev/null and b/assets/voices/andrea-96de1a155d.mp3 differ
diff --git a/assets/voices/andrea-98f155a986.mp3 b/assets/voices/andrea-98f155a986.mp3
new file mode 100644
index 0000000..1440afc
Binary files /dev/null and b/assets/voices/andrea-98f155a986.mp3 differ
diff --git a/assets/voices/andrea-a837c17bc7.mp3 b/assets/voices/andrea-a837c17bc7.mp3
new file mode 100644
index 0000000..b28d091
Binary files /dev/null and b/assets/voices/andrea-a837c17bc7.mp3 differ
diff --git a/assets/voices/andrea-a93c65234e.mp3 b/assets/voices/andrea-a93c65234e.mp3
new file mode 100644
index 0000000..8c84540
Binary files /dev/null and b/assets/voices/andrea-a93c65234e.mp3 differ
diff --git a/assets/voices/andrea-a960f89e7a.mp3 b/assets/voices/andrea-a960f89e7a.mp3
new file mode 100644
index 0000000..90f6164
Binary files /dev/null and b/assets/voices/andrea-a960f89e7a.mp3 differ
diff --git a/assets/voices/andrea-a9e7f3f9ad.mp3 b/assets/voices/andrea-a9e7f3f9ad.mp3
new file mode 100644
index 0000000..79824bf
Binary files /dev/null and b/assets/voices/andrea-a9e7f3f9ad.mp3 differ
diff --git a/assets/voices/andrea-b35a4d8cc0.mp3 b/assets/voices/andrea-b35a4d8cc0.mp3
new file mode 100644
index 0000000..09ffbab
Binary files /dev/null and b/assets/voices/andrea-b35a4d8cc0.mp3 differ
diff --git a/assets/voices/andrea-c2dca9d75b.mp3 b/assets/voices/andrea-c2dca9d75b.mp3
new file mode 100644
index 0000000..48f9ece
Binary files /dev/null and b/assets/voices/andrea-c2dca9d75b.mp3 differ
diff --git a/assets/voices/andrea-e2ed3ea2f2.mp3 b/assets/voices/andrea-e2ed3ea2f2.mp3
new file mode 100644
index 0000000..2f8552f
Binary files /dev/null and b/assets/voices/andrea-e2ed3ea2f2.mp3 differ
diff --git a/assets/voices/andrea-f3d1a2d353.mp3 b/assets/voices/andrea-f3d1a2d353.mp3
new file mode 100644
index 0000000..f0e0765
Binary files /dev/null and b/assets/voices/andrea-f3d1a2d353.mp3 differ
diff --git a/assets/voices/arcu-19aa71408a.mp3 b/assets/voices/arcu-19aa71408a.mp3
new file mode 100644
index 0000000..bb953bb
Binary files /dev/null and b/assets/voices/arcu-19aa71408a.mp3 differ
diff --git a/assets/voices/arcu-1a64606a56.mp3 b/assets/voices/arcu-1a64606a56.mp3
new file mode 100644
index 0000000..6d432ff
Binary files /dev/null and b/assets/voices/arcu-1a64606a56.mp3 differ
diff --git a/assets/voices/arcu-1c6016d941.mp3 b/assets/voices/arcu-1c6016d941.mp3
new file mode 100644
index 0000000..143a8ce
Binary files /dev/null and b/assets/voices/arcu-1c6016d941.mp3 differ
diff --git a/assets/voices/arcu-1da81f6f98.mp3 b/assets/voices/arcu-1da81f6f98.mp3
deleted file mode 100644
index 26cc730..0000000
Binary files a/assets/voices/arcu-1da81f6f98.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-26a5fb0d0c.mp3 b/assets/voices/arcu-26a5fb0d0c.mp3
new file mode 100644
index 0000000..bd6e100
Binary files /dev/null and b/assets/voices/arcu-26a5fb0d0c.mp3 differ
diff --git a/assets/voices/arcu-3b540fe807.mp3 b/assets/voices/arcu-3b540fe807.mp3
new file mode 100644
index 0000000..4301eea
Binary files /dev/null and b/assets/voices/arcu-3b540fe807.mp3 differ
diff --git a/assets/voices/arcu-406cd02197.mp3 b/assets/voices/arcu-406cd02197.mp3
deleted file mode 100644
index 358ec76..0000000
Binary files a/assets/voices/arcu-406cd02197.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-5148dffe36.mp3 b/assets/voices/arcu-5148dffe36.mp3
deleted file mode 100644
index cd53b56..0000000
Binary files a/assets/voices/arcu-5148dffe36.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-56966d2a60.mp3 b/assets/voices/arcu-56966d2a60.mp3
new file mode 100644
index 0000000..fdc9fc2
Binary files /dev/null and b/assets/voices/arcu-56966d2a60.mp3 differ
diff --git a/assets/voices/arcu-5990b22d1d.mp3 b/assets/voices/arcu-5990b22d1d.mp3
new file mode 100644
index 0000000..11a7754
Binary files /dev/null and b/assets/voices/arcu-5990b22d1d.mp3 differ
diff --git a/assets/voices/arcu-5d07c15776.mp3 b/assets/voices/arcu-5d07c15776.mp3
deleted file mode 100644
index e96c2cd..0000000
Binary files a/assets/voices/arcu-5d07c15776.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-5d1fa73d32.mp3 b/assets/voices/arcu-5d1fa73d32.mp3
deleted file mode 100644
index 19676dc..0000000
Binary files a/assets/voices/arcu-5d1fa73d32.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-5fa87608b0.mp3 b/assets/voices/arcu-5fa87608b0.mp3
new file mode 100644
index 0000000..1e7327f
Binary files /dev/null and b/assets/voices/arcu-5fa87608b0.mp3 differ
diff --git a/assets/voices/arcu-62e981592d.mp3 b/assets/voices/arcu-62e981592d.mp3
new file mode 100644
index 0000000..c9ad11c
Binary files /dev/null and b/assets/voices/arcu-62e981592d.mp3 differ
diff --git a/assets/voices/arcu-64735a5925.mp3 b/assets/voices/arcu-64735a5925.mp3
deleted file mode 100644
index d1146a8..0000000
Binary files a/assets/voices/arcu-64735a5925.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-6ecbd65952.mp3 b/assets/voices/arcu-6ecbd65952.mp3
new file mode 100644
index 0000000..f32a827
Binary files /dev/null and b/assets/voices/arcu-6ecbd65952.mp3 differ
diff --git a/assets/voices/arcu-747a365229.mp3 b/assets/voices/arcu-747a365229.mp3
new file mode 100644
index 0000000..bea7fea
Binary files /dev/null and b/assets/voices/arcu-747a365229.mp3 differ
diff --git a/assets/voices/arcu-7c69d6366a.mp3 b/assets/voices/arcu-7c69d6366a.mp3
new file mode 100644
index 0000000..6e2447f
Binary files /dev/null and b/assets/voices/arcu-7c69d6366a.mp3 differ
diff --git a/assets/voices/arcu-838aefc39e.mp3 b/assets/voices/arcu-838aefc39e.mp3
new file mode 100644
index 0000000..9196713
Binary files /dev/null and b/assets/voices/arcu-838aefc39e.mp3 differ
diff --git a/assets/voices/arcu-85c7d0b8f6.mp3 b/assets/voices/arcu-85c7d0b8f6.mp3
deleted file mode 100644
index fe4a21b..0000000
Binary files a/assets/voices/arcu-85c7d0b8f6.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-8c7f1850a3.mp3 b/assets/voices/arcu-8c7f1850a3.mp3
deleted file mode 100644
index 64c55f0..0000000
Binary files a/assets/voices/arcu-8c7f1850a3.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-98e193584b.mp3 b/assets/voices/arcu-98e193584b.mp3
new file mode 100644
index 0000000..f930bd3
Binary files /dev/null and b/assets/voices/arcu-98e193584b.mp3 differ
diff --git a/assets/voices/arcu-9e5b7c6e95.mp3 b/assets/voices/arcu-9e5b7c6e95.mp3
new file mode 100644
index 0000000..e99dced
Binary files /dev/null and b/assets/voices/arcu-9e5b7c6e95.mp3 differ
diff --git a/assets/voices/arcu-a17af74745.mp3 b/assets/voices/arcu-a17af74745.mp3
new file mode 100644
index 0000000..811bb99
Binary files /dev/null and b/assets/voices/arcu-a17af74745.mp3 differ
diff --git a/assets/voices/arcu-ac54e4bfa2.mp3 b/assets/voices/arcu-ac54e4bfa2.mp3
new file mode 100644
index 0000000..13d28a4
Binary files /dev/null and b/assets/voices/arcu-ac54e4bfa2.mp3 differ
diff --git a/assets/voices/arcu-af7afda339.mp3 b/assets/voices/arcu-af7afda339.mp3
deleted file mode 100644
index 0d0566b..0000000
Binary files a/assets/voices/arcu-af7afda339.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-b1332b8d66.mp3 b/assets/voices/arcu-b1332b8d66.mp3
new file mode 100644
index 0000000..b4e1477
Binary files /dev/null and b/assets/voices/arcu-b1332b8d66.mp3 differ
diff --git a/assets/voices/arcu-bb69d88654.mp3 b/assets/voices/arcu-bb69d88654.mp3
deleted file mode 100644
index 7e47b60..0000000
Binary files a/assets/voices/arcu-bb69d88654.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-d2b7d66066.mp3 b/assets/voices/arcu-d2b7d66066.mp3
new file mode 100644
index 0000000..53e130e
Binary files /dev/null and b/assets/voices/arcu-d2b7d66066.mp3 differ
diff --git a/assets/voices/arcu-e157a4b70b.mp3 b/assets/voices/arcu-e157a4b70b.mp3
deleted file mode 100644
index 38de1e7..0000000
Binary files a/assets/voices/arcu-e157a4b70b.mp3 and /dev/null differ
diff --git a/assets/voices/arcu-f067aab43c.mp3 b/assets/voices/arcu-f067aab43c.mp3
new file mode 100644
index 0000000..28b5a0b
Binary files /dev/null and b/assets/voices/arcu-f067aab43c.mp3 differ
diff --git a/assets/voices/arcu-f086ae9a20.mp3 b/assets/voices/arcu-f086ae9a20.mp3
new file mode 100644
index 0000000..cec81b6
Binary files /dev/null and b/assets/voices/arcu-f086ae9a20.mp3 differ
diff --git a/assets/voices/arcu-f19f64320f.mp3 b/assets/voices/arcu-f19f64320f.mp3
new file mode 100644
index 0000000..9aac23c
Binary files /dev/null and b/assets/voices/arcu-f19f64320f.mp3 differ
diff --git a/assets/voices/arcu-fcaee733df.mp3 b/assets/voices/arcu-fcaee733df.mp3
new file mode 100644
index 0000000..0ebe4c7
Binary files /dev/null and b/assets/voices/arcu-fcaee733df.mp3 differ
diff --git a/assets/voices/arcu-fdd1ab5f5b.mp3 b/assets/voices/arcu-fdd1ab5f5b.mp3
deleted file mode 100644
index bc3d51c..0000000
Binary files a/assets/voices/arcu-fdd1ab5f5b.mp3 and /dev/null differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index dddd296..63f2bd8 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -1183,14 +1183,6 @@
"file": "andrea-00f2e70a4d.mp3",
"sec": 2.95
},
- "andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
- "file": "andrea-3e85200509.mp3",
- "sec": 2.93
- },
- "andrea|O via, lascialo riposare. A pedalare oggi ci penso io.": {
- "file": "andrea-34aad1ffe3.mp3",
- "sec": 4.81
- },
"andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-7be29434e0.mp3",
"sec": 7.25
@@ -1244,16 +1236,16 @@
"sec": 2.15
},
"arcu|Che è, la mancia? Una squama? Grazie, la metto in cassaforte.": {
- "file": "arcu-8c7f1850a3.mp3",
- "sec": 5.84
+ "file": "arcu-b1332b8d66.mp3",
+ "sec": 6.34
},
"arcu|Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.": {
- "file": "arcu-bb69d88654.mp3",
- "sec": 3.23
+ "file": "arcu-7c69d6366a.mp3",
+ "sec": 3.61
},
"arcu|Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.": {
- "file": "arcu-1da81f6f98.mp3",
- "sec": 6.44
+ "file": "arcu-f067aab43c.mp3",
+ "sec": 6.47
},
"andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
"file": "andrea-8ddab2d024.mp3",
@@ -1595,6 +1587,38 @@
"file": "andrea-a3e36c12a1.mp3",
"sec": 5.4
},
+ "andrea|L'Arcu, sotto la tenda del Trappola. Due minuti fa era a Campomigliaio.": {
+ "file": "andrea-a837c17bc7.mp3",
+ "sec": 5.75
+ },
+ "andrea|La tenda è il Trappola: sotto c'è il Barrettino.": {
+ "file": "andrea-3648b1b08e.mp3",
+ "sec": 2.98
+ },
+ "andrea|Dal Trappola ci si passa dopo. Prima il palio.": {
+ "file": "andrea-a93c65234e.mp3",
+ "sec": 4.28
+ },
+ "andrea|«Qui riposa la Ghiacciomobile, che si è schiantata lì.»": {
+ "file": "andrea-a9e7f3f9ad.mp3",
+ "sec": 3.11
+ },
+ "andrea|C'è anche la fotina. Pareva sempre nuova.": {
+ "file": "andrea-7c17aa384c.mp3",
+ "sec": 3.2
+ },
+ "andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
+ "file": "andrea-3e85200509.mp3",
+ "sec": 2.93
+ },
+ "andrea|Lassù in cima, di traverso: per partire ha bisogno della discesa. E intanto ferma il traffico.": {
+ "file": "andrea-96de1a155d.mp3",
+ "sec": 7.66
+ },
+ "andrea|È il Betino dell'Arcu. Senza una discesa davanti, un si muove.": {
+ "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
@@ -2152,32 +2176,32 @@
"sec": 3.41
},
"arcu|E chi lo sposta???": {
- "file": "arcu-64735a5925.mp3",
- "sec": 0.97
+ "file": "arcu-6ecbd65952.mp3",
+ "sec": 0.83
},
"arcu|Mah... un si fa una sega...": {
- "file": "arcu-5d07c15776.mp3",
- "sec": 2.89
+ "file": "arcu-f086ae9a20.mp3",
+ "sec": 4.42
},
"arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": {
- "file": "arcu-af7afda339.mp3",
- "sec": 4.18
+ "file": "arcu-62e981592d.mp3",
+ "sec": 6.29
},
"arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": {
- "file": "arcu-85c7d0b8f6.mp3",
- "sec": 5.57
+ "file": "arcu-9e5b7c6e95.mp3",
+ "sec": 4.75
},
"arcu|E te ci vai da solo? Io ti aspetto qui.": {
- "file": "arcu-e157a4b70b.mp3",
- "sec": 2.73
+ "file": "arcu-838aefc39e.mp3",
+ "sec": 4.03
},
"arcu|Qualcuno deve raccontarlo, dopo.": {
- "file": "arcu-5d1fa73d32.mp3",
- "sec": 1.77
+ "file": "arcu-f19f64320f.mp3",
+ "sec": 2.57
},
"arcu|Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.": {
- "file": "arcu-fdd1ab5f5b.mp3",
- "sec": 4.37
+ "file": "arcu-56966d2a60.mp3",
+ "sec": 5.27
},
"andrea|Grazie, Arcu. Molto utile.": {
"file": "andrea-2caf0f40f2.mp3",
@@ -2188,12 +2212,12 @@
"sec": 2.98
},
"arcu|...I pompieri per cosa?": {
- "file": "arcu-5148dffe36.mp3",
- "sec": 1.7
+ "file": "arcu-5fa87608b0.mp3",
+ "sec": 1.26
},
"arcu|Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!": {
- "file": "arcu-406cd02197.mp3",
- "sec": 4.46
+ "file": "arcu-ac54e4bfa2.mp3",
+ "sec": 4.81
},
"andrea|S'ha andà a fallo?!?": {
"file": "andrea-c80714aae7.mp3",
@@ -2239,6 +2263,70 @@
"file": "pescatori-befa87eee8.mp3",
"sec": 4.17
},
+ "andrea|Ma ti ho visto du' minuti fa a Campomigliaio!": {
+ "file": "andrea-c2dca9d75b.mp3",
+ "sec": 3.99
+ },
+ "andrea|Si fa una gara coi motorini? Il giro della Fortezza!": {
+ "file": "andrea-a960f89e7a.mp3",
+ "sec": 3.45
+ },
+ "andrea|Ci si vede dopo, Arcu.": {
+ "file": "andrea-191021ad7c.mp3",
+ "sec": 1.68
+ },
+ "arcu|E allora? Col Betino ci metto un attimo.": {
+ "file": "arcu-3b540fe807.mp3",
+ "sec": 3.02
+ },
+ "arcu|In discesa.": {
+ "file": "arcu-a17af74745.mp3",
+ "sec": 0.67
+ },
+ "andrea|E in salita?": {
+ "file": "andrea-75c55bf1e7.mp3",
+ "sec": 1.57
+ },
+ "arcu|In salita si spinge.": {
+ "file": "arcu-747a365229.mp3",
+ "sec": 1.6
+ },
+ "arcu|Col Betino? Io un perdo mai: un vo mai forte abbastanza da cadere.": {
+ "file": "arcu-d2b7d66066.mp3",
+ "sec": 6
+ },
+ "arcu|Come la si fa?": {
+ "file": "arcu-fcaee733df.mp3",
+ "sec": 0.92
+ },
+ "andrea|Alla bona, un giro di paese.": {
+ "file": "andrea-b35a4d8cc0.mp3",
+ "sec": 2.15
+ },
+ "andrea|Ginocchio in terra.": {
+ "file": "andrea-e2ed3ea2f2.mp3",
+ "sec": 1.19
+ },
+ "andrea|No, lascia perdere.": {
+ "file": "andrea-98f155a986.mp3",
+ "sec": 1.09
+ },
+ "arcu|Alla bona. Chi arriva ultimo paga le paste.": {
+ "file": "arcu-19aa71408a.mp3",
+ "sec": 3.33
+ },
+ "arcu|Aspettate: prendo la rincorsa, che il Betino s'accende solo in discesa.": {
+ "file": "arcu-1a64606a56.mp3",
+ "sec": 4.74
+ },
+ "arcu|Seria. Tre giri, e la paglia in curva è vera.": {
+ "file": "arcu-5990b22d1d.mp3",
+ "sec": 3.95
+ },
+ "arcu|Ginocchio in terra? Puntella un vede l'ora.": {
+ "file": "arcu-26a5fb0d0c.mp3",
+ "sec": 3.17
+ },
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3",
"sec": 2.53
@@ -2539,6 +2627,26 @@
"file": "andrea-72bf22a3f9.mp3",
"sec": 4.49
},
+ "andrea|Primo! E col Fifty di serie, o quasi.": {
+ "file": "andrea-f3d1a2d353.mp3",
+ "sec": 4.13
+ },
+ "arcu|Si rifà! Questa un vale, avevo la catena lenta.": {
+ "file": "arcu-98e193584b.mp3",
+ "sec": 5.34
+ },
+ "andrea|Ho preso tutta la polvere di quelli davanti.": {
+ "file": "andrea-4ca44c5405.mp3",
+ "sec": 2.18
+ },
+ "arcu|Il Fifty è bono. È chi ci sta sopra.": {
+ "file": "arcu-1c6016d941.mp3",
+ "sec": 3.2
+ },
+ "andrea|Lascio stare. Il Fifty mi serve intero, per il palio.": {
+ "file": "andrea-33e57caee9.mp3",
+ "sec": 4.61
+ },
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
"file": "ing_tozzi-51f855478a.mp3",
"sec": 4.42
diff --git a/docs/STATO.md b/docs/STATO.md
index 71e4cb8..887ed43 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -75,7 +75,7 @@ Si aggiorna con `make deploy`: pubblica l'ultimo commit, immagine costruita e pr
- **Altre scene** (ottobre 2026, dalle foto di Andrea con Codex): Via de' Medici, Oratorio, Ingresso del campeggio,
Bosco ai Frati, viale dei cipressi. Schermata del titolo (Fortezza al tramonto col Regolo) e introduzione a righe sul
panorama del Mugello.
-- **Musica** di Andrea (Suno) in `assets/audio/`: titolo e introduzione, paese, ricordi (Trebbio e fine); volume e muto.
+- **Musica** di Andrea (Suno) in `assets/audio/`: titolo e introduzione, paese, ricordi (Trebbio e fine), il GP di Fortezza (`gara.mp3`); volume e muto.
- **Andrea vero** dal foglio sprite (`assets/characters/`, `tools/slice_sprites.py` + `tools/prepare_character.py`):
cammina, parla, si china a prendere, di spalle davanti alle porte.
- **Storia di prova** (`src/story/story.js`): il palio è sparito, i vecchietti litigano sulle due leggende, la squama
@@ -243,6 +243,29 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
risultato deciso all'ultimo pesce). Restano le sue idee: «Porta a casa la pescata» a gloria raggiunta; premio per
peso o varietà; i ragazzi che reagiscono (stivale, carpa, lenza rotta); paesaggio sonoro del lago (ora quello della
Fortezza); abboccata e tensione anche nei comandi HTML e un richiamo grande sul telefono; Esc col fuoco sui pulsanti.
+- Il GP di Fortezza (Andrea, 28 ottobre): la gara dei cinquantini attorno alle mura della Fortezza, vista dall'alto
+ come le vecchie gare da sala giochi. Dall'Arcu sotto la tenda del Trappola in Via Liguri Magelli «Si fa una gara coi
+ motorini?», poi Alla bona, Seria o Ginocchio in terra; prima del via l'Arcu prende la rincorsa per accendere il Betino
+ in discesa. Minigioco `src/engine/gara.js`: gas tenendo premuto (Spazio, ↑, mouse, dito), sterzo con ← →,
+ trascinando di lato o coi pulsanti Sterza, ↓ frena; erba che frena, paglia che ferma, acceleratori, lattine di miscela
+ (sprint), buche (forte si casca); gli avversari sorpassano e scansano le buche. Prima del semaforo i piloti
+ arrivano in griglia uno alla volta col cartellino: il personaggio (sprite del gioco, o ritratto: Cistola dal murale,
+ Puntella sagoma finché manca il consenso), il motorino dipinto da Codex dalle foto (`assets/closeups/gp/`, foglio
+ `assets/raw/motorini-gp-sheet.png`, prompt `prompts/personaggi/motorini-gp-sheet.txt`), motore e testa; un tocco la salta. Gas, freno e sterzo hanno
+ fonti indipendenti (tasti, un dito sulla scena, pulsanti Gas, Sterza e Frena), il freno vince sul gas. Quadro di Codex
+ `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
+ 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).
+- 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
+ Ghiacciomobile, che si è schiantata lì», in cima alla salita il Betino dell'Arcu (tolto dal circolino di
+ Campomigliaio), che per partire ha bisogno della discesa. Prompt `prompts/backgrounds/via-liguri-magelli.txt`.
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
diff --git a/index.html b/index.html
index 1993fca..bc7ba00 100644
--- a/index.html
+++ b/index.html
@@ -199,6 +199,7 @@
+
Come si gioca
@@ -219,6 +220,15 @@
palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.
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 + pulsante alterna Gas e Molla. Durante la presentazione dei piloti un tocco passa al prossimo.
+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.
+Un tocco sull’acqua sceglie dove lanciare (o regola la forza: più lontano, pesci più grossi, ma attese più lunghe). Tieni premuto sulla scena (o Spazio): la forza riparte da zero e sale, lascia per lanciare; oppure premi diff --git a/src/engine/command.js b/src/engine/command.js index 6521784..ea307d3 100644 --- a/src/engine/command.js +++ b/src/engine/command.js @@ -2,6 +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]; + export class Command { constructor(game) { this.game = game; @@ -21,10 +24,10 @@ export class Command { const g = this.game, name = target && g.targetName(target); if (this.item && target === this.item && this.verb === 'use') return `${VERB_LABELS.use} ${g.itemName(this.item)}`; if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`; - if (this.verb) return `${VERB_LABELS[this.verb]}${name ? ' ' + name : '…'}`; + if (this.verb) return `${verbLabel(this.verb, name)}${name ? ' ' + name : '…'}`; if (!target) return ''; const isItem = !!g.story.items[target]; - return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`; + return isItem ? `Usa ${name} con…` : `${verbLabel(g.defaultVerb(target), name)} ${name}`; } // Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio), diff --git a/src/engine/game.js b/src/engine/game.js index 2922ee0..279a496 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -8,6 +8,7 @@ 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'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. export const MINIGAMES = { @@ -18,6 +19,7 @@ export const MINIGAMES = { biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.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 }, + 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 }, }; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; @@ -486,7 +488,7 @@ export class Game { // `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario. // `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane). : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level, - Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) } + Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]]), this.story.closeups[e.closeup]) } : { id: e.closeup, step: 'cover', t: 0, age: 0 }; } else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); diff --git a/src/engine/gara-render.js b/src/engine/gara-render.js new file mode 100644 index 0000000..1ec895f --- /dev/null +++ b/src/engine/gara-render.js @@ -0,0 +1,215 @@ +import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, pointAt, standings, player, lapOf, presenting, placeOf, INTRO } from './gara.js'; +import { text, panel } from './bocce-render.js'; +import { ambientHash as hash } from './ambient.js'; + +// Il GP di Fortezza visto dall'alto. Col quadro di Codex (def.picture) la strada e la Fortezza sono già dipinte; senza, +// si disegnano qui dai dati del tracciato. Sopra, sempre: la guida, gli acceleratori, le buche, le lattine, i motorini. +function stroke(ctx, from, to, width, color, lane = 0) { + ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; + ctx.beginPath(); + for (let s = from; s <= to; s += 2) { const p = pointAt(s, lane); s === from ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); } + ctx.stroke(); +} +function loop(ctx, width, color) { + ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round'; + ctx.beginPath(); + TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + ctx.closePath(); ctx.stroke(); +} +function ground(ctx) { + ctx.fillStyle = '#6f8f3e'; ctx.fillRect(0, 0, 640, 360); + for (let i = 0; i < 260; i++) { + ctx.fillStyle = hash(i * 13 + 1) > 0.5 ? '#7c9b47' : '#638236'; + ctx.fillRect(Math.floor(hash(i * 13 + 2) * 640), Math.floor(hash(i * 13 + 3) * 360), 2 + Math.floor(hash(i * 13 + 4) * 6), 2); + } + // Dentro il giro, le mura della Fortezza e il prato dei bastioni. + ctx.beginPath(); + TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); + ctx.closePath(); + ctx.fillStyle = '#8a7a5a'; ctx.fill(); + loop(ctx, VERGE * 2 + 8, '#6f8f3e'); + loop(ctx, VERGE * 2, '#7c9b47'); + loop(ctx, ROAD * 2 + 2, '#7a6446'); + loop(ctx, ROAD * 2, '#b59b72'); + for (let i = 0; i < TRACK.pts.length; i += 3) { + const [x, y] = TRACK.pts[i], o = (hash(i) - 0.5) * ROAD * 1.6; + ctx.fillStyle = hash(i + 7) > 0.5 ? '#a88d64' : '#c2a87e'; ctx.fillRect(Math.round(x + o * 0.5), Math.round(y + o * 0.5), 2, 1); + } + // La paglia fuori dalle curve. + for (const k of CURVES) for (let s = k.start; s <= k.end; s += 8) { + const p = pointAt(s, -(VERGE + 3) * k.side); + ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading); + ctx.fillStyle = '#d9b75a'; ctx.fillRect(-3.5, -2.5, 7, 5); ctx.fillStyle = '#b8953f'; ctx.fillRect(-3.5, -0.5, 7, 1); + ctx.restore(); + } + const p = pointAt(0); + ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading); + for (let i = -6; i < 6; i++) for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#f4f4f4'; ctx.fillRect(j * 2 - 2, i * 2, 2, 2); } + ctx.restore(); +} +function guide(ctx, c) { + const v = player(c).vmax; + // Alla bona, in rosso dove si frena per arrivare giusti in curva; Seria, un cono all'inizio della frenata. + for (const k of CURVES) { + const from = k.apex - (v * v - k.speed * k.speed * 0.7) / (2 * BRAKE); + if (c.skill.guide === 'zone') stroke(ctx, from, k.apex, 4, 'rgba(214,64,47,0.6)'); + if (c.skill.guide === 'coni') { + const p = pointAt(from, (ROAD + 3) * k.side); + ctx.fillStyle = '#f07a1a'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 5); ctx.lineTo(p.x + 3, p.y + 2); ctx.lineTo(p.x - 3, p.y + 2); ctx.fill(); + ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 1.5, p.y - 2, 3, 1); + } + } +} +function things(ctx, c) { + for (const p of PICKUPS) { + ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.rotate(p.heading); + if (p.kind === 'boost') { + // L'acceleratore: tre frecce gialle che lampeggiano in fila. + for (let k = 0; k < 3; k++) { + ctx.fillStyle = Math.floor(c.age * 6 - k) % 3 === 0 ? '#fff27a' : '#e0b020'; + ctx.beginPath(); ctx.moveTo(-6 + k * 5, -5); ctx.lineTo(-2 + k * 5, 0); ctx.lineTo(-6 + k * 5, 5); ctx.lineTo(-4 + k * 5, 0); ctx.fill(); + } + } else if (p.kind === 'buca') { + ctx.fillStyle = '#5a4630'; ctx.beginPath(); ctx.ellipse(0, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#2e2418'; ctx.beginPath(); ctx.ellipse(0.5, 0.5, 4.5, 3, 0, 0, Math.PI * 2); ctx.fill(); + } else if ((c.taken[p.id] ?? -Infinity) <= c.t - CAN.respawn) { + // La lattina della miscela, che saltella. + const y = Math.round(Math.sin(c.age * 5 + p.id) * 1); + ctx.rotate(-p.heading); + ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-3, -4 + y, 6, 8); + ctx.fillStyle = '#c8321e'; ctx.fillRect(-2, -3 + y, 4, 6); + ctx.fillStyle = '#f0c23a'; ctx.fillRect(-2, -1 + y, 4, 1); ctx.fillRect(-1, -5 + y, 2, 1); + } + ctx.restore(); + } +} +function moped(ctx, r, c) { + const out = r.out > 0; + ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.a + (out ? Math.sin(c.age * 22) * 0.6 : 0)); + ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(-6, -1, 13, 4); + ctx.fillStyle = '#151515'; ctx.fillRect(-7, -1, 3, 2); ctx.fillRect(5, -1, 3, 2); // le ruote + ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-5, -3, 11, 6); // il bordo chiaro: anche i motorini scuri si vedono + 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 + // 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(); + if (r.id === 'andrea') { ctx.strokeStyle = '#f4e3c2'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(r.x, r.y, 10, 0, Math.PI * 2); ctx.stroke(); } +} +// In basso a sinistra, lontano dalla pausa e dal pulsante per uscire: il giro, la classifica e il tachimetro. +function hud(ctx, c) { + const me = player(c), order = standings(c), h = 34 + order.length * 11, y = 354 - h; + panel(ctx, 4, y, 116, h); + text(ctx, `GIRO ${lapOf(me)}/${LAPS}`, 10, y + 9, 7); + order.forEach((r, i) => { + ctx.fillStyle = '#f4e3c2'; ctx.fillRect(9, y + 18 + i * 11, 8, 8); + ctx.fillStyle = r.color; ctx.fillRect(10, y + 19 + i * 11, 6, 6); + text(ctx, `${i + 1}. ${r.name}`, 21, y + 23 + i * 11, 6, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2'); + }); + const k = Math.min(1, me.v / me.vmax), bar = y + h - 11; + ctx.fillStyle = '#2a2f2c'; ctx.fillRect(10, bar, 50, 5); + 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'); +} +// 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. +function figure(ctx, r, x, y, look, sheet) { + const s = r.sprite && sheet(r.sprite); + if (s) { + const f = s.atlas.frames.stand ?? Object.values(s.atlas.frames)[0], k = Math.min(1, 84 / f.h); + ctx.drawImage(s.img, f.x, f.y, f.w, f.h, Math.round(x - f.w * k / 2), Math.round(y + 88 - f.h * k), Math.round(f.w * k), Math.round(f.h * k)); + return; + } + const img = r.face && look(r.face); + ctx.fillStyle = '#f4e3c2'; ctx.fillRect(x - 31, y + 13, 62, 62); + if (img) { ctx.drawImage(img, x - 30, y + 14, 60, 60); return; } + // Nessun ritratto (ancora): la sagoma. + ctx.fillStyle = '#2a2f2c'; ctx.fillRect(x - 30, y + 14, 60, 60); + ctx.fillStyle = '#4a504c'; ctx.beginPath(); ctx.arc(x, y + 36, 11, 0, Math.PI * 2); ctx.fill(); + ctx.fillRect(x - 18, y + 52, 36, 22); + text(ctx, '?', x, y + 37, 10, '#8f9488', 'center'); +} +function card(ctx, c, look, sheet) { + const r = presenting(c); + if (!r) { panel(ctx, 220, 130, 200, 40); text(ctx, 'GP DI FORTEZZA', 320, 142, 7, '#ffe28a', 'center'); text(ctx, 'TUTTI IN GRIGLIA!', 320, 158, 7, '#f4e3c2', 'center'); return; } + const x = 150, y = 92, w = 340, h = 150; + 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 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) { + const k = Math.min(150 / moto.width, 80 / moto.height), mw = moto.width * k, mh = moto.height * k; + ctx.save(); ctx.beginPath(); ctx.rect(x + 92, y + 18, w - 100, 92); ctx.clip(); + ctx.drawImage(moto, Math.round(x + 92 + (w - 100 - mw) / 2 - (1 - e) * 120), Math.round(y + 104 - mh), Math.round(mw), Math.round(mh)); + ctx.restore(); + } else { + ctx.fillStyle = r.color; ctx.fillRect(x + 160, y + 70, 100, 22); + ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(x + 160, y + 80, 100, 3); + } + text(ctx, r.name, x + 100, y + 118, 8, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2'); + text(ctx, r.bike, x + 100, y + 130, 6, '#c5c6b6'); + 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); } + } + text(ctx, 'TOCCA: IL PROSSIMO', x + w / 2, y + h - 6, 5, '#8f9488', 'center'); +} +// La posizione in corsa, grande in alto a sinistra, e un cartellino sopra il Fifty. +function position(ctx, c) { + const me = player(c), place = placeOf(c), n = c.riders.length, first = place === 1; + panel(ctx, 4, 4, 70, 40); + text(ctx, 'POSIZIONE', 39, 11, 5, '#c5c6b6', 'center'); + text(ctx, `${place}°`, 11, 29, 14, first ? '#ffe28a' : '#f4e3c2'); + text(ctx, `su ${n}`, 68, 33, 6, '#c5c6b6', 'right'); + if (me.shown === false) return; + const x = Math.round(me.x), y = Math.round(me.y) - 17; + ctx.fillStyle = 'rgba(22,25,23,0.85)'; ctx.fillRect(x - 8, y - 5, 16, 10); + ctx.fillStyle = first ? '#ffe28a' : '#f4e3c2'; ctx.fillRect(x - 1, y + 5, 2, 2); + text(ctx, `${place}°`, x, y + 0.5, 6, first ? '#ffe28a' : '#f4e3c2', 'center'); +} +function lights(ctx, c) { + const lit = Math.min(3, Math.floor(c.t) + 1); + panel(ctx, 270, 140, 100, 50); + text(ctx, 'GP DI FORTEZZA', 320, 150, 6, '#ffe28a', 'center'); + for (let i = 0; i < 3; i++) { + ctx.fillStyle = i < lit ? '#e0402a' : '#3a2a28'; + ctx.beginPath(); ctx.arc(300 + i * 20, 170, 7, 0, Math.PI * 2); ctx.fill(); + } + if (c.t >= COUNTDOWN - 0.01) text(ctx, 'VIA!', 320, 200, 10, '#b9d392', 'center'); +} +function result(ctx, c) { + const order = c.result.order.map(id => c.riders.find(r => r.id === id)); + panel(ctx, 200, 96, 240, 44 + order.length * 14); + text(ctx, 'GP DI FORTEZZA', 320, 108, 6, '#c5c6b6', 'center'); + text(ctx, c.result.place === 1 ? 'PRIMO!' : `${c.result.place}° POSTO`, 320, 122, 10, c.result.place === 1 ? '#ffe28a' : '#f4e3c2', 'center'); + order.forEach((r, i) => { + ctx.fillStyle = '#f4e3c2'; ctx.fillRect(213, 137 + i * 14, 10, 10); + ctx.fillStyle = r.color; ctx.fillRect(214, 138 + i * 14, 8, 8); + text(ctx, `${i + 1}. ${r.name}`, 228, 142 + i * 14, 7, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2'); + text(ctx, `${c.result.times[r.id].toFixed(1)}s`, 428, 142 + i * 14, 7, '#c5c6b6', 'right'); + }); +} + +export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) { + ctx.save(); + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); + else ground(ctx); + guide(ctx, c); + things(ctx, c); + // 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; } + hud(ctx, c); + if (c.step === 'race') position(ctx, c); + if (c.step === 'start') lights(ctx, c); + const me = player(c); + if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center'); + if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center'); + if (c.step === 'result') result(ctx, c); + ctx.restore(); +} diff --git a/src/engine/gara.js b/src/engine/gara.js new file mode 100644 index 0000000..97562bf --- /dev/null +++ b/src/engine/gara.js @@ -0,0 +1,398 @@ +// Il GP di Fortezza (idea di Andrea, 28 ottobre): la gara dei cinquantini sul giro delle mura della Fortezza di San +// Martino, vista dall'alto come le vecchie gare da sala giochi con tutta la pista in una schermata. Il tracciato segue +// 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 +// 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. + +// La linea di mezzo della strada, in senso orario a partire dal traguardo sul rettilineo delle mura a levante: prima +// tracciata sulla planimetria, poi ricalcata sulla strada del quadro di Codex (assets/closeups/gp-fortezza.png). +export const CONTROL = [ + [570, 210], [572, 222], [574, 233], [577, 245], [580, 256], [583, 268], [585, 280], [589, 291], [589, 303], [589, 315], + [585, 327], [576, 335], [565, 340], [553, 341], [541, 340], [529, 339], [518, 336], [506, 334], [495, 330], [483, 327], + [471, 324], [460, 321], [448, 318], [436, 316], [424, 315], [412, 314], [400, 314], [389, 317], [378, 320], [372, 331], + [363, 339], [352, 345], [340, 344], [328, 341], [317, 337], [305, 334], [294, 329], [284, 324], [273, 319], [262, 314], + [251, 308], [241, 303], [231, 296], [220, 291], [210, 284], [199, 280], [188, 274], [178, 269], [167, 263], [157, 256], + [148, 248], [143, 237], [147, 225], [149, 214], [148, 203], [142, 192], [135, 182], [127, 174], [118, 166], [110, 156], + [104, 146], [100, 135], [98, 123], [101, 112], [106, 100], [113, 91], [124, 86], [136, 81], [147, 77], [157, 70], + [165, 61], [173, 53], [185, 49], [197, 47], [208, 45], [220, 41], [232, 39], [243, 36], [255, 33], [266, 30], + [278, 26], [290, 24], [301, 22], [313, 20], [325, 20], [337, 19], [349, 20], [361, 23], [372, 26], [384, 29], + [396, 32], [407, 36], [418, 40], [430, 43], [441, 47], [453, 50], [464, 55], [475, 59], [486, 63], [498, 67], + [509, 71], [521, 74], [532, 76], [544, 75], [557, 77], [566, 85], [570, 96], [572, 108], [574, 120], [574, 132], + [574, 144], [573, 156], [573, 168], [572, 180], [571, 192], [570, 204], +]; +export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP = 1 / 120; +// La presentazione prima del semaforo: uno alla volta i piloti arrivano in griglia col loro motorino (INTRO.each +// secondi ciascuno, da INTRO.from pixel di strada più indietro, in INTRO.ride secondi); un tocco passa al prossimo. +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; +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 +// 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); + 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. +export const RIDERS = [ + { id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' }, + { id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' }, +]; +// 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' }, + seria: { rivals: 0.84, stun: 0.9, guide: 'coni' }, + male: { rivals: 0.93, stun: 1.4, guide: null }, +}; + +function catmull(p0, p1, p2, p3, t) { + const t2 = t * t, t3 = t2 * t; + return [0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3)); +} +// Il tracciato campionato ogni STEP pixel: posizione, direzione, raggio di curva e velocità massima di ogni punto. +function buildTrack() { + const dense = [], n = CONTROL.length; + for (let i = 0; i < n; i++) { + const p = [CONTROL[(i - 1 + n) % n], CONTROL[i], CONTROL[(i + 1) % n], CONTROL[(i + 2) % n]]; + for (let k = 0; k < 40; k++) dense.push(catmull(...p, k / 40)); + } + const pts = [dense[0]]; + let carry = 0; + for (let i = 1; i <= dense.length; i++) { + const a = dense[i - 1], b = dense[i % dense.length]; + let seg = Math.hypot(b[0] - a[0], b[1] - a[1]), t = 0; + while (carry + seg >= STEP) { + const need = STEP - carry; t += need / Math.hypot(b[0] - a[0], b[1] - a[1]); seg -= need; carry = 0; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + carry += seg; + } + if (Math.hypot(pts.at(-1)[0] - pts[0][0], pts.at(-1)[1] - pts[0][1]) < STEP * 0.5) pts.pop(); + const m = pts.length, heading = pts.map((p, i) => { const q = pts[(i + 1) % m]; return Math.atan2(q[1] - p[1], q[0] - p[0]); }); + const W = 6, radius = pts.map((_, i) => (2 * W * STEP) / Math.max(Math.abs(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])), 1e-6)); + // A sterzo pieno si gira di TURN rad/s: in una curva di raggio R si passa al massimo a TURN·R. + const limit = radius.map(r => TURN * r); + // Da che parte gira (+1 destra, -1 sinistra). + const side = pts.map((_, i) => Math.sign(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])) || 1); + return { pts, heading, radius, limit, side, length: m * STEP }; +} +export const TRACK = buildTrack(); +const N = TRACK.pts.length; +const at = s => Math.floor((((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP) % N; +export const limitAt = s => TRACK.limit[at(s)]; +// Il punto dello schermo a distanza s dalla partenza, spostato di lato (positivo: a destra della direzione di marcia). +export function pointAt(s, lane = 0) { + const f = (((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP, i = Math.floor(f) % N, t = f - Math.floor(f); + const a = TRACK.pts[i], b = TRACK.pts[(i + 1) % N], h = TRACK.heading[i]; + return { x: a[0] + (b[0] - a[0]) * t - Math.sin(h) * lane, y: a[1] + (b[1] - a[1]) * t + Math.cos(h) * lane, heading: h }; +} +// Le curve vere (dove il Fifty a tutto gas non passa): per la guida, i coni e la paglia. +export const CURVES = (() => { + const out = [], v = rider({ motore: 3 }).vmax * 0.85; + let inside = false, start = 0, min = Infinity, apex = 0; + TRACK.limit.forEach((l, i) => { + if (l < v && !inside) { inside = true; start = i; min = l; apex = i; } + else if (l < v) { if (l < min) { min = l; apex = i; } } + else if (inside) { inside = false; out.push({ start: start * STEP, apex: apex * STEP, end: i * STEP, speed: min, side: TRACK.side[apex] }); } + }); + if (inside) out.push({ start: start * STEP, apex: apex * STEP, end: TRACK.length, speed: min, side: TRACK.side[apex] }); + return out; +})(); +// Le cose in pista, a distanza dal traguardo (s) e di lato (lane): acceleratori e buche fissi, lattine che ricompaiono. +// Sui rettilinei, lontano dalle curve, perché si vedano arrivare. +function straights() { + const out = []; + let from = 0; + for (const k of [...CURVES, { start: TRACK.length + (CURVES[0]?.start ?? 0) }]) { if (k.start - from > 70) out.push([from + 20, k.start - 20]); from = k.end ?? from; } + return out; +} +export const PICKUPS = (() => { + const list = [], parts = straights(); + parts.forEach(([a, b], i) => { + const mid = (a + b) / 2, len = b - a; + if (i % 3 === 0) list.push({ kind: 'boost', s: a + len * 0.3, lane: 0 }); + else if (i % 3 === 1) list.push({ kind: 'buca', s: mid, lane: i % 2 ? 4 : -4 }); + else list.push({ kind: 'miscela', s: mid, lane: i % 2 ? -4 : 4 }); + if (len > 160) list.push({ kind: i % 2 ? 'buca' : 'miscela', s: a + len * 0.75, lane: i % 2 ? -4 : 4 }); + }); + return list.map((p, k) => ({ ...p, s: ((p.s % TRACK.length) + TRACK.length) % TRACK.length, id: k, ...pointAt(p.s, p.lane) })); +})(); + +function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } +// La posizione sulla strada: il punto del tracciato più vicino (cercato vicino all'ultimo, così non si salta di lato). +function locate(r) { + let best = r.idx, bd = Infinity; + for (let d = -12; d <= 24; d++) { + const i = (r.idx + d + N) % N, p = TRACK.pts[i], q = (p[0] - r.x) ** 2 + (p[1] - r.y) ** 2; + if (q < bd) { bd = q; best = i; } + } + let delta = best - r.idx; if (delta > N / 2) delta -= N; if (delta < -N / 2) delta += N; + r.idx = best; r.s += delta * STEP; + const h = TRACK.heading[best], p = TRACK.pts[best]; + r.lat = -Math.sin(h) * (r.x - p[0]) + Math.cos(h) * (r.y - p[1]); +} + +export function newGara(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria', skill = LEVELS[level]; + // In griglia nell'ordine della lista, due per fila, dietro il traguardo. + const riders = (def.riders ?? RIDERS).map((r, k) => { + const base = rider(r), factor = r.id === 'andrea' ? 1 : skill.rivals, s = -16 - 14 * Math.floor(k / 2), lane = k % 2 ? 4 : -4, p = pointAt(s, lane); + return { ...base, vmax: base.vmax * factor, accel: base.accel * factor, lane, x: p.x, y: p.y, a: p.heading, v: 0, u: 0, s, idx: at(s), lat: lane, + gas: false, brake: false, steer: 0, out: 0, boost: 0, laps: 0, finished: null, offs: 0, falls: 0, risk: 1 }; + }); + 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: {} }; +} +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 +export const aimFree = () => true; +export const nudge = () => false; +export const setPower = () => false; + +// 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. +// Lasciarne una non spegne le altre. `holding` è la loro somma. +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; +} +// Il primo tocco (o tasto) salta la presentazione, senza dare gas. +export function press(c, id = 'press') { + if (!canThrow(c)) return false; + if (c.step === 'intro') return nextIntro(c); + if (c.pointer !== null && c.pointer !== id) return false; // un secondo dito sulla scena non conta + c.pointer = id; sync(c); + return true; +} +export function release(c, id) { + if (c.pointer === null || (id !== undefined && id !== c.pointer)) return false; + c.pointer = null; sync(c); + return true; +} +export function cancel(c) { + Object.assign(c, { pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0 }); + Object.assign(c.keys, { left: false, right: false, brake: false }); c.keys.gas.clear(); + sync(c); + return true; +} +// Un tocco durante la presentazione: chi arrivava è già al suo posto, e tocca al prossimo (dopo l'ultimo, il semaforo). +function nextIntro(c) { + const k = Math.floor(c.t / INTRO.each) + 1; + if (k >= c.riders.length) return skipIntro(c); + c.t = k * INTRO.each; + return true; +} +// Finita (o saltata) la presentazione tutti sono in griglia, e si va al semaforo. +function skipIntro(c) { + for (const r of c.riders) place(r, r.grid, true); + c.step = 'start'; c.t = 0; + return true; +} +function place(r, s, shown) { + const p = pointAt(s, r.lane); + Object.assign(r, { s, x: p.x, y: p.y, a: p.heading, idx: at(s), lat: r.lane, shown }); +} +// Chi si sta presentando adesso (o null fra uno e l'altro, e alla fine). +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); + return false; +} +// La tastiera: ← → sterzano, ↑ Spazio e Invio sono il gas, ↓ il freno; finché si tengono giù. +const GAS_KEYS = ['ArrowUp', ' ', 'Enter']; +export function key(c, name, down) { + if (name === 'ArrowLeft') c.keys.left = down; + else if (name === 'ArrowRight') c.keys.right = down; + else if (name === 'ArrowDown') c.keys.brake = down; + else if (GAS_KEYS.includes(name)) { + if (down && c.step === 'intro') { nextIntro(c); return true; } + if (down && canThrow(c)) c.keys.gas.add(name); else c.keys.gas.delete(name); + } else return false; + sync(c); + return true; +} +// 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); +// 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); + +// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui, +// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto +// si fida troppo. +function drive(c, r) { + const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6); + r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1); + const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; }); + if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; } + const horizon = (r.v * r.v) / (2 * BRAKE) + 20; + r.gas = true; r.brake = false; + for (let d = 0; d <= horizon; d += STEP * 2) { + const allowed = limitAt(r.s + d) * 0.82 * r.grip * r.caution * r.risk; + if (r.v * r.v > allowed * allowed + 2 * BRAKE * d) { r.gas = false; r.brake = d < 30; break; } + } + // Le buche si scansano (chi ha testa); una lattina ogni tanto la vanno a prendere (deciso una volta per lattina). + const look = 30 + r.v * 0.6, next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < look); + if (next && r.seen !== next.id) { r.seen = next.id; r.wants = random(c) < 0.35; } + // Chi ha davanti uno più lento lo passa dall'altra parte. + const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 22 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 5); + if (slow) r.lane = slow.lat > 0 ? -5 : 5; + if (next?.kind === 'buca' && r.testa >= 3) r.lane = next.lane > 0 ? -5 : 5; + else if (next?.kind === 'miscela' && r.wants) r.lane = next.lane; +} +function stepRider(c, r, dt) { + if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; } + else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; } + else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); } + else drive(c, r); + if (r.finished !== null) r.gas = false; + const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1); + // Il freno vince sul gas: chi li tiene tutti e due frena. + if (r.brake) r.v -= BRAKE * dt; + else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1); + else r.v -= COAST * dt; + if (r.v > top) r.v = Math.max(top, r.v - 120 * dt); + r.v = Math.max(0, r.v); + r.boost = Math.max(0, r.boost - dt); + // Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori. + const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt; + r.a = wrapAngle(r.a + turn); + r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip; + r.u *= Math.exp(-GRIP * r.grip * dt); + r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt; + r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt; + locate(r); + // La paglia (fuori) e le mura (dentro): si rimbalza in strada; arrivandoci forte ci si ferma. + if (Math.abs(r.lat) > VERGE) { + const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1); + r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0; + if (r.v > 40 && r.finished === null) { + r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1; + c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); + } + r.v *= 0.35; r.a = wrapAngle(h + clamp(wrapAngle(r.a - h), -0.5, 0.5)); + } + if (r.finished === null) pickups(c, r); + const laps = Math.floor(Math.max(0, r.s) / TRACK.length); + if (laps > r.laps && r.finished === null) { + r.laps = laps; + if (laps >= LAPS) { r.finished = c.t; c.events.push({ kind: 'gara-arrivo', who: r.id, power: r.id === 'andrea' ? 1 : 0.5 }); } + else if (r.id === 'andrea') c.events.push({ kind: 'gara-giro', who: r.id, power: 0.6 }); + } +} +// Acceleratori (una spinta), lattine di miscela (spinta più lunga, poi la lattina ricompare) e buche (forte si casca). +function pickups(c, r) { + for (const p of PICKUPS) { + if (Math.hypot(p.x - r.x, p.y - r.y) > 6) continue; + if (p.kind === 'boost') { if (r.boost < BOOST.time * 0.5) { r.boost = BOOST.time; c.events.push({ kind: 'gara-turbo', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 }); } } + else if (p.kind === 'miscela') { + if ((c.taken[p.id] ?? -Infinity) > c.t - CAN.respawn) continue; + c.taken[p.id] = c.t; r.boost = CAN.time; + c.events.push({ kind: 'gara-miscela', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 }); + } else if (p.kind === 'buca' && r.lastHole !== p.id) { + r.lastHole = p.id; + if (r.v > HOLE.fast) { r.out = HOLE.out; r.falls++; r.v = 0; c.events.push({ kind: 'gara-buca', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); } + else { r.v *= 0.7; c.events.push({ kind: 'gara-sobbalzo', who: r.id, power: 0.3 }); } + } + } + if (r.lastHole !== undefined && Math.hypot(PICKUPS[r.lastHole].x - r.x, PICKUPS[r.lastHole].y - r.y) > 14) r.lastHole = undefined; +} +// I motorini si urtano: si allontanano un po', e chi tampona perde velocità. +function bump(c) { + for (let i = 0; i < c.riders.length; i++) for (let j = i + 1; j < c.riders.length; j++) { + const a = c.riders[i], b = c.riders[j], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); + if (d >= 7 || d < 1e-6) continue; + const push = (7 - d) / 2, nx = dx / d, ny = dy / d; + a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push; + const behind = a.s < b.s ? a : b; behind.v *= 0.97; + } +} +// La classifica: chi è arrivato, in ordine d'arrivo; poi chi corre ancora, per strada fatta. +export function standings(c) { + return [...c.riders].sort((a, b) => (a.finished ?? Infinity) - (b.finished ?? Infinity) || b.s - a.s); +} +export const placeOf = (c, id = 'andrea') => standings(c).findIndex(r => r.id === id) + 1; + +export function update(c, dt) { + c.age += dt; + if (c.step === 'intro') { + c.t += dt; + c.riders.forEach((r, k) => { + const t = (c.t - k * INTRO.each) / INTRO.ride; + if (t < 0) return; + if (!r.shown) c.events.push({ kind: 'gara-motore', who: r.id, power: 0.3 + 0.14 * r.motore }); + const e = 1 - (1 - Math.min(1, t)) ** 2; // arriva forte e rallenta + place(r, r.grid - INTRO.from * (1 - e), true); + r.v = t < 1 ? INTRO.from * 2 * (1 - Math.min(1, t)) / INTRO.ride : 0; + }); + if (c.t >= c.riders.length * INTRO.each + INTRO.after) { skipIntro(c); for (const r of c.riders) r.v = 0; } + return; + } + if (c.step === 'start') { + const before = Math.ceil(COUNTDOWN - c.t); + c.t += dt; + const now = Math.ceil(COUNTDOWN - c.t); + if (now !== before && now > 0) c.events.push({ kind: 'gara-semaforo', power: 0.4 }); + if (c.t >= COUNTDOWN) { c.step = 'race'; c.t = 0; c.events.push({ kind: 'gara-via', power: 1 }); } + return; + } + if (c.step !== 'race') return; + c.accumulator += Math.min(dt, 0.1); + while (c.accumulator >= FIXED_STEP) { + c.accumulator -= FIXED_STEP; + c.t += FIXED_STEP; + for (const r of c.riders) stepRider(c, r, FIXED_STEP); + bump(c); + for (const r of c.riders) locate(r); + if (player(c).finished !== null) { finish(c); return; } + } + c.order = standings(c).map(r => r.id); +} +function finish(c) { + const order = standings(c); + // Chi non è ancora arrivato: il tempo stimato con la sua media fin qui. + const times = Object.fromEntries(order.map(r => [r.id, r.finished ?? c.t + (LAPS * TRACK.length - r.s) / Math.max(20, r.s / Math.max(c.t, 1))])); + const ranked = [...order].sort((a, b) => times[a.id] - times[b.id]); + const place = ranked.findIndex(r => r.id === 'andrea') + 1; + c.step = 'result'; + cancel(c); + c.order = ranked.map(r => r.id); + c.result = { winner: place === 1 ? 'player' : 'cpu', place, times, order: c.order, offs: player(c).offs, falls: player(c).falls }; +} + +export function status(c) { + if (c.step === 'intro') { + const r = presenting(c); + return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!'; + } + if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!'; + if (c.step === 'result') { + const who = c.riders.find(r => r.id === c.result.order[0]).name; + return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`; + } + const me = player(c), place = placeOf(c); + if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…'; + if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`; + return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`; +} +export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas'; diff --git a/src/engine/render.js b/src/engine/render.js index fbae840..012d3c4 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js'; import { drawBiliardo } from './biliardo-render.js'; import { drawBasket } from './basket-render.js'; import { drawPesca } from './pesca-render.js'; +import { drawGara } from './gara-render.js'; import { drawBriscola } from './briscola-render.js'; import { drawTressette } from './tressette-render.js'; import { drawScopa } from './scopa-render.js'; @@ -684,6 +685,12 @@ function drawCloseup(ctx, game, time) { drawBocce(ctx, c, def, backgrounds.get(def.picture), time); 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, + src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }, id => sprites.get(id)); + return; + } if (def.type === 'pesca') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori')); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 39765c9..e2f398f 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -125,6 +125,17 @@ export class Soundscape { else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); } else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan }); else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); } + // Il GP di Fortezza: il semaforo, il via, la paglia, acceleratori, miscela, buche, il giro, il traguardo. + else if (kind === 'gara-semaforo') this.tone(440, 440, 0.18, 0.06, 0, pan); + else if (kind === 'gara-via') this.tone(880, 880, 0.4, 0.08, 0, pan); + else if (kind === 'gara-fuori') { this.noiseSound(0.35, 700, 0.1, { pan }); this.tone(300, 120, 0.2, 0.05, 0, pan); } + else if (kind === 'gara-motore') { this.tone(110, 190 + strength * 160, 0.9, 0.03 + strength * 0.03, 0, pan); this.tone(220, 380 + strength * 320, 0.9, 0.015 + strength * 0.015, 0, pan); } + else if (kind === 'gara-turbo') this.tone(300, 900, 0.25, 0.04 + strength * 0.04, 0, pan); + else if (kind === 'gara-miscela') { this.tone(500, 1200, 0.2, 0.04 + strength * 0.04, 0, pan); this.tone(800, 1600, 0.2, 0.03 * strength, 0.12, pan); } + else if (kind === 'gara-buca') { this.tone(220, 60, 0.3, 0.05 + strength * 0.07, 0, pan); this.noiseSound(0.3, 500, 0.08 * strength + 0.02, { pan }); } + else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan); + else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan); + else if (kind === 'gara-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 }); } diff --git a/src/main.js b/src/main.js index 284b7c0..5969df3 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import * as bocce from './engine/bocce.js'; import * as biliardo from './engine/biliardo.js'; import * as basket from './engine/basket.js'; import * as pesca from './engine/pesca.js'; +import * as gara from './engine/gara.js'; import * as briscola from './engine/briscola.js'; import { cardAt } from './engine/briscola-render.js'; import * as tressette from './engine/tressette.js'; @@ -497,6 +498,14 @@ 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 + // (sterzo e freno hanno i loro pulsanti, che si tengono insieme al gas). + if (type === 'gara') { + if (press || game.skip()) return; + gara.aimScreen(game.closeup, scenePoint(e), e.pointerId); + if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); } + return; + } const aimed = game.closeupAim(scenePoint(e)); if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) { if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno @@ -529,6 +538,10 @@ canvas.addEventListener('pointerup', e => { if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card); return; } + if (press.gara !== undefined) { + 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; } clearTimeout(press.timer); // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. @@ -541,7 +554,7 @@ canvas.addEventListener('pointerup', e => { press = null; }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); -canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { 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('contextmenu', e => { e.preventDefault(); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); @@ -552,6 +565,10 @@ canvas.addEventListener('pointermove', e => { // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) + if (game.closeup && story.closeups[game.closeup.id].type === 'gara') { + 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); 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)); @@ -566,6 +583,8 @@ const MINI = { 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.' }, 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.' }, 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; @@ -594,8 +613,15 @@ for (let i = 0; i < 3; i++) { button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); }); } -for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) +for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) { $(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; }); + // 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); }); +} +// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza). +$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); }); +for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); }); addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); // --- Verbi. @@ -757,7 +783,12 @@ window.addEventListener('keydown', e => { return; } if (miniState()) { - if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; + // Esc esce anche col fuoco sui pulsanti del pannello. + 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; const c = game.closeup, m = mini(); if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { e.preventDefault(); @@ -801,7 +832,11 @@ window.addEventListener('keydown', e => { }); // Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro. -window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); }); +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 (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); +}); // --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio). $('closeup-close').addEventListener('click', () => game?.closeupClose()); @@ -908,19 +943,26 @@ function updateUi() { } } if (mg && !cards) { - $('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca' }[def.type] ?? 'Comandi delle bocce'); + $('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; 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 || !!game.speech; - $('bocce-throw').disabled = !mg.canThrow(c) || c.holding || !!game.speech; + 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; + // 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'; 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 }); @@ -934,7 +976,8 @@ function updateUi() { $('panel').inert = !!c; canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('bocce', !!mg); - $('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo'); + // 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('briscola', !!cards); canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); // La pagina che comincia a girare fa il suo fruscio. diff --git a/src/story/piloti.js b/src/story/piloti.js new file mode 100644 index 0000000..0580301 --- /dev/null +++ b/src/story/piloti.js @@ -0,0 +1,18 @@ +// 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. +// 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. +// 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] + // 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' }, + // 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' }, + // 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' }, +]; diff --git a/src/story/story.js b/src/story/story.js index 2b5453e..2246478 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -2,10 +2,13 @@ // Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md). // Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite. import { ambience } from './ambience.js'; +import { piloti } from './piloti.js'; // Gli indizi del Trebbio per la catena della torcia (dopo le impronte, finché la torcia non c'è): uno per tappa, // dalla più avanti alla prima. const castello = say => [{ say, who: 'castello' }]; +// Prima del GP di Fortezza l'Arcu va a prendere il Betino in cima alla salita (Andrea, 28 ottobre). +const rincorsa = level => [{ say: 'Aspettate: prendo la rincorsa, che il Betino s\'accende solo in discesa.', who: 'arcu', pose: 'point' }, { closeup: 'gara', level }]; const torchHints = [{ if: 'has:chiavetta', then: castello('Una chiavetta piccola apre una cosa piccola. All\'area feste c\'è una casetta che aspetta.'), @@ -184,6 +187,26 @@ export const story = { quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }], draw: [], }, + // Il GP di Fortezza (idea di Andrea, 28 ottobre): tre giri coi cinquantini attorno alle mura, gas e sterzo come + // nelle vecchie gare da sala giochi viste dall'alto. Chi corre sta in piloti.js. [DA ANDREA: le battute; la musica] + gara: { + type: 'gara', music: 'assets/audio/gara.mp3', // di Andrea (Suno) + picture: 'assets/closeups/gp-fortezza.png', // Codex, dalla planimetria di Andrea; la strada è il tracciato + // Corre solo chi ha dato il consenso (`consenso: null` vuol dire «da chiedere»: per ora Puntella). + riders: piloti.filter(p => p.consenso !== null), + names: { player: 'Andrea', cpu: 'gli altri' }, + win: [ + { say: 'Primo! E col Fifty di serie, o quasi.' }, + { say: 'Si rifà! Questa un vale, avevo la catena lenta.', who: 'arcu', pose: 'shrug' }, + { set: 'gara_vinta' }, + ], + lose: [ + { say: 'Ho preso tutta la polvere di quelli davanti.' }, + { say: 'Il Fifty è bono. È chi ci sta sopra.', who: 'arcu', pose: 'laugh' }, + ], + quit: [{ say: 'Lascio stare. Il Fifty mi serve intero, per il palio.' }], + draw: [], + }, // Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute] biliardo: { type: 'biliardo', @@ -2059,19 +2082,11 @@ export const story = { people: [ { who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }, // Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce. - // Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk. - { who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] }, + // Con la depth della scena misura circa 160 px; resta sopra il bordo di walk. (Il Betino dell'Arcu sta in + // cima alla salita di Via Liguri Magelli, Andrea 28 ottobre.) { who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] }, ], hotspots: [ - { - id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look', - // Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua. - actions: { - look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }], - use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }], - }, - }, { id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look', // La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde. @@ -2630,6 +2645,60 @@ 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' }, }, + { + 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' }, + }, + ], + }, + // Via Liguri Magelli (Street View mandato da Andrea, 28 ottobre): a sinistra dal campo del Giannini. A destra la + // casa salmone con la tenda del Trappola (il Barrettino), sul muro giallo la targa della Ghiacciomobile con la sua + // fotina, e in cima alla salita il Betino dell'Arcu (dipinto da Codex nel quadro), di traverso verso la discesa: + // per partire ne ha bisogno, e intanto ferma il traffico. + via_liguri_magelli: { + 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]], + depth: [[198, 0.3], [360, 1.1]], + entry: [320, 340], + speakers: { arcu: [374, 176] }, + people: [ + // L'Arcu sotto la tenda del Trappola: due minuti fa era a Campomigliaio (Andrea, 28 ottobre). + { who: 'arcu', at: [374, 248], facing: -1, size: 1.3, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }, + ], + hotspots: [ + { + id: 'arcu', name: 'l\'Arcu', box: [362, 180, 26, 68], at: [352, 256], face: 'right', default: 'talk', + actions: { + look: [{ say: 'L\'Arcu, sotto la tenda del Trappola. Due minuti fa era a Campomigliaio.' }], + talk: [{ dialogue: 'arcu_trappola' }], + }, + }, + { + // «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', + actions: { + look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }], + walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }], + }, + }, + { + id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look', + actions: { + look: [{ say: '«Qui riposa la Ghiacciomobile, che si è schiantata lì.»' }, { say: 'C\'è anche la fotina. Pareva sempre nuova.' }], + }, + }, + { + // La prima battuta è di Andrea (dal circolino, 26 ottobre). + id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look', + actions: { + look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }, { say: 'Lassù in cima, di traverso: per partire ha bisogno della discesa. E intanto ferma il traffico.' }], + use: [{ say: 'È il Betino dell\'Arcu. Senza una discesa davanti, un si muove.' }], + }, + }, + { + 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' }, + }, ], }, // Cardetole, il rione dei verdi, dove abita Andrea: giù dalla Pieve. @@ -3009,6 +3078,35 @@ export const story = { }, }, }, + // L'Arcu dal Trappola (Andrea, 28 ottobre): lo scherzo sulla velocità del Betino, poi la sfida: «il giro della Fortezza» (Andrea), cioè il GP di Fortezza; + // prima del via prende la rincorsa per accenderlo in discesa. [DA ANDREA: le battute sono provvisorie] + arcu_trappola: { + nodes: { + start: { + options: [ + { text: 'Ma ti ho visto du\' minuti fa a Campomigliaio!', then: [ + { say: 'E allora? Col Betino ci metto un attimo.', who: 'arcu', pose: 'explain' }, + { say: 'In discesa.', who: 'arcu', pose: 'laugh' }, + { say: 'E in salita?' }, + { say: 'In salita si spinge.', who: 'arcu', pose: 'shrug' }, + ] }, + { text: 'Si fa una gara coi motorini? Il giro della Fortezza!', next: 'gara', then: [ + { say: 'Col Betino? Io un perdo mai: un vo mai forte abbastanza da cadere.', who: 'arcu', pose: 'explain' }, + { say: 'Come la si fa?', who: 'arcu', pose: 'talk' }, + ] }, + { text: 'Ci si vede dopo, Arcu.', next: 'end' }, + ], + }, + gara: { + options: [ + { text: 'Alla bona, un giro di paese.', next: 'end', then: [{ say: 'Alla bona. Chi arriva ultimo paga le paste.', who: 'arcu', pose: 'laugh' }, ...rincorsa('bona')] }, + { text: 'Seria.', next: 'end', then: [{ say: 'Seria. Tre giri, e la paglia in curva è vera.', who: 'arcu', pose: 'explain' }, ...rincorsa('seria')] }, + { text: 'Ginocchio in terra.', next: 'end', then: [{ say: 'Ginocchio in terra? Puntella un vede l\'ora.', who: 'arcu', pose: 'shrug' }, ...rincorsa('male')] }, + { text: 'No, lascia perdere.', next: 'start' }, + ], + }, + }, + }, // Da solo al campo del Circolo: Andrea decide quanto farsi male. basket: { nodes: { diff --git a/style.css b/style.css index c7357dc..085e0ff 100644 --- a/style.css +++ b/style.css @@ -53,16 +53,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h .bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; } #bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } .bocce-adjust button:disabled { opacity: .4; cursor: default; } -#bocce-help, #biliardo-help, #basket-help, #pesca-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } -#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary { cursor: pointer; padding: 6px 0; } -#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p { max-width: 76em; line-height: 1.7; } +#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; } @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 { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-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); } diff --git a/tests/gara.test.mjs b/tests/gara.test.mjs new file mode 100644 index 0000000..ee38bee --- /dev/null +++ b/tests/gara.test.mjs @@ -0,0 +1,195 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newGara, update, press, presenting, INTRO, release, shoot, key, steerButton, aimScreen, steering, status, button, standings, player, rider, limitAt, pointAt, TRACK, CURVES, PICKUPS, BRAKE, VERGE, LAPS, COUNTDOWN, RIDERS } from '../src/engine/gara.js'; +import { piloti } from '../src/story/piloti.js'; +import { story } from '../src/story/story.js'; +import { Game } from '../src/engine/game.js'; +import { settle } from './helpers.mjs'; + +// Gli eventi sonori di ogni gara si ricordano nella gara stessa (`c.heard`, «tipo» e «tipo:pilota»): un test non +// vede quelli degli altri. +const run = (c, seconds, drive, dt = 1 / 60) => { + c.heard ??= new Set(); + for (let t = 0; t < seconds && c.step !== 'result'; t += dt) { + if (drive && c.step === 'race') c.holding = drive(c); + update(c, dt); for (const e of c.events.splice(0)) { c.heard.add(e.kind); c.heard.add(`${e.kind}:${e.who}`); } + } + return c.heard; +}; +const skip = c => { while (c.step === 'intro') press(c); return c; }; +const race = c => { skip(c); assert.equal(c.step, 'start'); run(c, COUNTDOWN + 0.01); assert.equal(c.step, 'race'); return c; }; +// Il pilota di giudizio: guarda avanti e ci sterza (scansando buche e chi gli sta davanti), e dà gas finché, +// frenando, arriverebbe a ogni curva un po' sotto il limite. Usa i comandi del giocatore (il trascinamento). +const careful = c => { + const r = player(c), here = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; + const next = PICKUPS.find(p => p.kind !== 'boost' && p.s - here > -8 && p.s - here < 60); + const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 26 && Math.abs(o.lat - r.lat) < 7); + const lane = next?.kind === 'buca' ? (next.lane > 0 ? -6 : 6) : slow ? (slow.lat > 0 ? -6 : 6) : next ? next.lane : 0; + const ahead = pointAt(r.s + 14 + r.v * 0.18, lane), want = Math.atan2(ahead.y - r.y, ahead.x - r.x) - r.a; + // Come un dito premuto che trascina: lo zero dove si è premuto, di lato quanto serve. + if (c.pointer === null) { aimScreen(c, [320, 180], 'bot'); press(c, 'bot'); } + aimScreen(c, [320 + 28 * Math.max(-1, Math.min(1, Math.atan2(Math.sin(want), Math.cos(want)) * 2.6)), 180], 'bot'); + const horizon = r.v * r.v / (2 * BRAKE) + 20; + let brake = false; + for (let d = 0; d <= horizon; d += 4) { const a = limitAt(r.s + d) * 0.85; if (r.v * r.v > a * a + 2 * BRAKE * d) { brake = true; break; } } + key(c, 'ArrowDown', brake); + return c.holding; +}; + +test('i piloti sono dati: motore e testa da 1 a 5 danno velocità, accelerazione e prudenza', () => { + const slow = rider({ motore: 1, testa: 5 }), fast = rider({ motore: 5, testa: 1 }); + assert.ok(fast.vmax > slow.vmax && fast.accel > slow.accel && fast.grip < slow.grip); + assert.ok(fast.slip > slow.slip && fast.caution > slow.caution); + assert.deepEqual(rider({ motore: 9, testa: -2 }).motore, 5, 'fuori scala si ferma ai bordi'); + // Ogni pilota della storia ha nome, motorino e colore; Andrea c'è una volta sola. + for (const p of piloti) { assert.ok(p.id && p.name && p.bike && /^#[0-9a-f]{6}$/i.test(p.color), p.id); } + assert.equal(piloti.filter(p => p.id === 'andrea').length, 1); + assert.throws(() => newGara(1, { riders: [{ id: 'arcu', name: 'L\'Arcu' }] }), /manca Andrea/); + const c = newGara(1, { riders: piloti }); + assert.equal(c.riders.length, piloti.length); + assert.deepEqual(c.riders.map(r => r.s), piloti.map((_, k) => -16 - 14 * Math.floor(k / 2)), 'due per fila, dietro il traguardo'); + assert.deepEqual(c.riders.map(r => Math.sign(r.lat)), piloti.map((_, k) => k % 2 ? 1 : -1)); +}); + +test('il semaforo, poi il gas: tenendo premuto si va, lasciando si rallenta, il freno vince sul gas', () => { + const c = newGara(3, { level: 'seria', riders: RIDERS }); + for (const r of RIDERS) assert.ok(press(c, 7), r.id); + assert.equal(c.step, 'start', 'un tocco per pilota e la presentazione è finita'); + assert.equal(c.holding, false, 'senza dare gas'); + assert.ok(press(c, 7)); assert.equal(c.holding, true); + const start = player(c).s; + const heard = run(c, COUNTDOWN - 0.1); + assert.equal(c.step, 'start'); assert.equal(player(c).s, start, 'prima del via non si muove nessuno'); + run(c, 0.15); + assert.equal(c.step, 'race'); assert.ok(heard.has('gara-semaforo') && heard.has('gara-via')); + run(c, 1); + const v = player(c).v; assert.ok(v > 20); + release(c, 7); run(c, 0.2); + assert.ok(player(c).v < v, 'senza gas rallenta'); + assert.match(status(c), /Giro 1 di 3/); assert.equal(button(c), 'Gas'); + // Gas e freno insieme: frena (P1 della revisione di Codex). + key(c, 'ArrowUp', true); run(c, 0.5); const fast = player(c).v; + key(c, 'ArrowDown', true); run(c, 0.1); + assert.ok(c.holding && c.braking); assert.ok(player(c).v < fast - 10, 'col gas tenuto il freno frena'); + key(c, 'ArrowDown', false); key(c, 'ArrowUp', false); + shoot(c); assert.equal(c.holding, true); assert.equal(button(c), 'Molla'); + shoot(c); assert.equal(c.holding, false); +}); + +test('le fonti del gas sono indipendenti: lasciare un tasto non toglie quello del dito o degli altri tasti', () => { + const c = race(newGara(3, { riders: RIDERS })); + key(c, ' ', true); key(c, 'ArrowUp', true); key(c, 'ArrowUp', false); + assert.equal(c.holding, true, 'Spazio ancora giù'); + press(c, 1); key(c, ' ', false); + assert.equal(c.holding, true, 'il dito ancora giù'); + assert.equal(press(c, 2), false, 'un secondo dito sulla scena non conta'); + release(c, 2); assert.equal(c.holding, true, 'e lasciandolo non toglie il gas al primo'); + release(c, 1); assert.equal(c.holding, false); + shoot(c); key(c, ' ', true); key(c, ' ', false); assert.equal(c.holding, true, 'il pulsante Gas resta acceso'); +}); + +test('lo sterzo: frecce, pulsanti e il dito che trascina; il mouse che passa senza premere non sterza', () => { + const c = race(newGara(3, { riders: RIDERS })); + assert.ok(key(c, 'ArrowLeft', true)); assert.equal(steering(c), -1); + key(c, 'ArrowLeft', false); assert.equal(steering(c), 0); + steerButton(c, 1); assert.equal(steering(c), 1); steerButton(c, 0); + // Gas da tastiera e sinistra, il mouse si muove a destra senza premere: lo sterzo resta a sinistra (P1). + key(c, 'ArrowUp', true); key(c, 'ArrowLeft', true); + aimScreen(c, [300, 100], 5); aimScreen(c, [360, 100], 5); + assert.equal(steering(c), -1); + key(c, 'ArrowUp', false); key(c, 'ArrowLeft', false); + // Il dito premuto: il punto dove preme è lo zero, di lato si sterza; un altro puntatore non sposta niente. + aimScreen(c, [300, 100], 9); press(c, 9); + aimScreen(c, [314, 100], 9); assert.equal(steering(c), 0.5); + aimScreen(c, [250, 100], 4); assert.equal(steering(c), 0.5); + release(c, 9); assert.equal(steering(c), 0, 'lasciando il dito lo sterzo torna dritto'); + const a = player(c).a; key(c, 'ArrowUp', true); key(c, 'ArrowRight', true); run(c, 0.4); assert.notEqual(player(c).a, a, 'sterzando si gira'); + assert.equal(key(c, 'x', true), false, 'gli altri tasti non sono suoi'); +}); + +test('la presentazione: uno alla volta arrivano in griglia col motorino, poi il semaforo', () => { + const c = newGara(2, { riders: piloti }); + assert.equal(c.step, 'intro'); assert.equal(presenting(c).id, piloti[0].id); + assert.ok(c.riders.every(r => !r.shown), 'all\'inizio la griglia è vuota'); + assert.match(status(c), new RegExp(`${piloti[0].name} con ${piloti[0].bike}`)); + run(c, INTRO.each + INTRO.ride / 2); + assert.ok(c.riders[0].shown && c.riders[1].shown && !c.riders[2].shown); + assert.equal(c.riders[0].s, c.riders[0].grid, 'il primo è già al suo posto'); + assert.ok(c.riders[1].s < c.riders[1].grid, 'il secondo sta arrivando'); + assert.equal(presenting(c).id, piloti[1].id); + assert.ok(c.heard.has('gara-motore:' + piloti[0].id) && c.heard.has('gara-motore:' + piloti[1].id)); + run(c, piloti.length * INTRO.each + INTRO.after - (INTRO.each + INTRO.ride / 2) + 0.05); + assert.equal(c.step, 'start'); + assert.ok(c.riders.every(r => r.shown && r.s === r.grid && r.v === 0), 'tutti fermi in griglia'); + // Un tocco: chi arriva è subito al suo posto, e tocca al prossimo. + const d = newGara(2, { riders: piloti }); + run(d, 0.3); assert.ok(d.riders[0].s < d.riders[0].grid); + assert.ok(press(d)); run(d, 0.01); + assert.equal(presenting(d).id, piloti[1].id); assert.equal(d.riders[0].s, d.riders[0].grid); + assert.equal(d.step, 'intro'); assert.equal(button(d), 'Avanti'); +}); + +test('sempre a gas e dritto si finisce nella paglia; con giudizio alla bona si vince', () => { + const wild = newGara(5, { level: 'bona', riders: piloti }); + skip(wild); + run(wild, COUNTDOWN + 60, () => true); + assert.ok(wild.heard.has('gara-fuori:andrea')); + assert.ok(Math.abs(player(wild).lat) <= VERGE, 'la paglia rimanda in strada'); + assert.equal(wild.step, 'race', 'dritto non si arriva'); + const heard = new Set(); + for (const seed of [1, 2, 3, 4]) { + const c = newGara(seed, { level: 'bona', riders: piloti }); + skip(c); + for (const k of run(c, COUNTDOWN + 120, careful)) heard.add(k); + assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player', `seme ${seed}`); + assert.equal(c.result.order[0], 'andrea'); assert.match(status(c), /Primo al GP di Fortezza!/); + assert.equal(Object.keys(c.result.times).length, piloti.length); + } + assert.ok(['gara-giro:andrea', 'gara-arrivo:andrea', 'gara-turbo:andrea', 'gara-miscela:andrea'].every(k => heard.has(k)), [...heard].join()); + assert.ok(CURVES.length >= 2 && TRACK.length > 600, 'una pista con delle curve vere'); +}); + +test('le buche: piano si sobbalza, forte si casca; la lattina di miscela dà lo sprint', () => { + const hole = PICKUPS.find(p => p.kind === 'buca'), can = PICKUPS.find(p => p.kind === 'miscela'); + for (const [speed, fell] of [[20, false], [80, true]]) { + const c = newGara(1, { riders: RIDERS }), r = player(c); + c.step = 'race'; Object.assign(r, { s: hole.s - 4, idx: Math.floor(hole.s / 2), v: speed, a: hole.heading }); + const p = pointAt(hole.s - 4, hole.lane); r.x = p.x; r.y = p.y; + const heard = run(c, 0.2); + assert.equal(r.falls, fell ? 1 : 0, `a ${speed}`); + assert.ok(heard.has(fell ? 'gara-buca:andrea' : 'gara-sobbalzo:andrea')); + } + const c = newGara(1, { riders: RIDERS }), r = player(c); + c.step = 'race'; Object.assign(r, { s: can.s - 3, idx: Math.floor(can.s / 2), v: 40, a: can.heading }); + const p = pointAt(can.s - 3, can.lane); r.x = p.x; r.y = p.y; + run(c, 0.2); + assert.ok(r.boost > 0 && c.taken[can.id] !== undefined, 'la miscela dà lo sprint'); +}); + +test('la classifica: chi è arrivato davanti, poi per strada fatta', () => { + const c = newGara(1, { riders: piloti }); + c.riders[0].s = 50; c.riders[1].s = 80; c.riders[2].finished = 30; + assert.deepEqual(standings(c).slice(0, 3).map(r => r.id), [c.riders[2].id, c.riders[1].id, c.riders[0].id]); +}); + +test('nella storia: dal Trappola l\'Arcu scherza sul Betino, poi la sfida; prima del via prende la rincorsa; la vittoria lascia il flag', () => { + const g = new Game(story); + g.go('via_liguri_magelli', [352, 256]); settle(g); + g.act('talk', 'arcu'); settle(g); + g.choose(g.options().findIndex(o => o.text.startsWith('Ma ti ho visto du'))); + const joke = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); + assert.ok(joke.includes('arcu: In salita si spinge.')); + g.choose(g.options().findIndex(o => o.text.startsWith('Si fa una gara coi motorini?'))); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Seria.')); + const before = settle(g).map(s => s.text); + assert.ok(before.some(t => t.includes('prendo la rincorsa'))); + assert.equal(g.closeup?.id, 'gara'); assert.equal(g.closeup.level, 'seria'); + assert.deepEqual(g.closeup.riders.map(r => r.id), piloti.filter(p => p.consenso !== null).map(p => p.id)); + assert.ok(!g.closeup.riders.some(r => r.id === 'puntella'), 'senza consenso non corre'); + g.closeup.result = { winner: 'player', place: 1, times: {}, order: ['andrea'], offs: 0 }; g.closeup.step = 'result'; + g.closeupClose(); const after = settle(g).map(s => s.text); + assert.ok(g.state.flags.gara_vinta); + assert.ok(after.some(t => t.includes('Primo!'))); + // Il Betino, lassù: solo Andrea ne parla. + g.act('use', 'betino'); assert.ok(settle(g).every(s => s.who === 'andrea')); +}); diff --git a/tests/veicoli.test.mjs b/tests/veicoli.test.mjs index 5a12bc0..feb6883 100644 --- a/tests/veicoli.test.mjs +++ b/tests/veicoli.test.mjs @@ -6,12 +6,16 @@ import { Game } from '../src/engine/game.js'; import { inside } from '../src/engine/geometry.js'; import { settle } from './helpers.mjs'; -test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli altri hotspot', () => { +// La Ghiacciomobile al circolino è uno sprite; il Betino in cima alla salita di Via Liguri Magelli è dipinto nel quadro. +const SPRITES = [['campomigliaio_circolo', 'ghiacciomobile', [150, 170]]]; +const VEHICLES = [...SPRITES, ['via_liguri_magelli', 'betino']]; +test('i veicoli restano fuori dal percorso e lasciano liberi gli altri hotspot', () => { + for (const [scene, id, [lo, hi]] of SPRITES) { const g = new Game(story); - g.go('campomigliaio_circolo'); + g.go(scene); settle(g); - const vehicles = ['betino', 'ghiacciomobile']; - for (const id of vehicles) { + const vehicles = [id]; + { const person = g.people().find(p => p.who === id), h = g.hotspot(id); const url = new URL('../' + story.characters[id].sprite, import.meta.url); const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle; @@ -19,7 +23,7 @@ test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli a assert.deepEqual(Object.keys(atlas.frames), ['idle']); const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height; const width = frame.w * k; - assert.ok(id === 'betino' ? width >= 70 && width <= 80 : width >= 150 && width <= 170); + assert.ok(width >= lo && width <= hi, `${id}: ${width}`); // Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile. for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++) for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++) @@ -34,14 +38,16 @@ test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli a assert.equal(g.personPose(person), 'idle'); } } + } }); test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => { + for (const [scene, id] of VEHICLES) { const g = new Game(story); - g.go('campomigliaio_circolo'); + g.go(scene); settle(g); const flags = { ...g.state.flags }, inventory = [...g.state.inventory]; - for (const id of ['betino', 'ghiacciomobile']) { + { const h = g.hotspot(id); assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id); for (const verb of ['look', 'use']) { @@ -51,7 +57,8 @@ test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () = assert.deepEqual([g.state.x, g.state.y], h.at); assert.deepEqual(g.state.flags, flags); assert.deepEqual(g.state.inventory, inventory); - assert.equal(g.state.scene, 'campomigliaio_circolo'); + assert.equal(g.state.scene, scene); } } + } }); diff --git a/tools/voci.json b/tools/voci.json index 7dec8c2..9ed9b7f 100644 --- a/tools/voci.json +++ b/tools/voci.json @@ -29,7 +29,7 @@ "speaker": "Gitta Nikolina" }, "arcu": { - "speaker": "Luis Moray" + "speaker": "Ferran Simen" }, "tozzino": { "speaker": "Xavier Hayasaka",