Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c49c3e260 | ||
|
|
817cfbd7fd | ||
|
|
44485cff52 | ||
|
|
a0201fea18 | ||
|
|
c4bac08c02 | ||
|
|
7f0584f0fc | ||
|
|
447c150974 | ||
|
|
95e7777dbc | ||
|
|
26697cd9c2 | ||
|
|
de303c5825 | ||
|
|
1fabb6e543 | ||
|
|
32a3d8a7c5 |
@@ -41,6 +41,12 @@ python3 -m http.server 8000 # poi http://localhost:8000
|
||||
Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il
|
||||
clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo.
|
||||
|
||||
Il libro dell'oratorio si apre e si sfoglia con clic o tocco; Esc lo chiude. Nella fessura della Fortezza,
|
||||
la torcia segue mouse o dito (anche trascinando): si cercano i dettagli tenendo brevemente la luce su di essi.
|
||||
Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio. «Torna fuori» permette di
|
||||
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
|
||||
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
|
||||
|
||||
## In Docker
|
||||
|
||||
```zsh
|
||||
@@ -64,5 +70,6 @@ make down
|
||||
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
|
||||
npm install && npx playwright install chromium-headless-shell
|
||||
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
|
||||
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "fra-peppino.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 50,
|
||||
"h": 120,
|
||||
"ax": 24.0,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 52,
|
||||
"y": 0,
|
||||
"w": 50,
|
||||
"h": 120,
|
||||
"ax": 23.9,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 104,
|
||||
"y": 0,
|
||||
"w": 50,
|
||||
"h": 120,
|
||||
"ax": 23.7,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 156,
|
||||
"y": 0,
|
||||
"w": 61,
|
||||
"h": 121,
|
||||
"ax": 27.6,
|
||||
"ay": 121
|
||||
},
|
||||
"laugh": {
|
||||
"x": 219,
|
||||
"y": 0,
|
||||
"w": 54,
|
||||
"h": 119,
|
||||
"ax": 24.3,
|
||||
"ay": 119
|
||||
},
|
||||
"bless": {
|
||||
"x": 275,
|
||||
"y": 0,
|
||||
"w": 58,
|
||||
"h": 119,
|
||||
"ax": 27.6,
|
||||
"ay": 119
|
||||
},
|
||||
"shrug": {
|
||||
"x": 335,
|
||||
"y": 0,
|
||||
"w": 91,
|
||||
"h": 119,
|
||||
"ax": 43.3,
|
||||
"ay": 119
|
||||
},
|
||||
"point": {
|
||||
"x": 428,
|
||||
"y": 0,
|
||||
"w": 76,
|
||||
"h": 119,
|
||||
"ax": 31.2,
|
||||
"ay": 119
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "mariano.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 40,
|
||||
"h": 120,
|
||||
"ax": 17.8,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 42,
|
||||
"y": 0,
|
||||
"w": 40,
|
||||
"h": 120,
|
||||
"ax": 17.9,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 84,
|
||||
"y": 0,
|
||||
"w": 40,
|
||||
"h": 120,
|
||||
"ax": 17.9,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 126,
|
||||
"y": 0,
|
||||
"w": 60,
|
||||
"h": 120,
|
||||
"ax": 20.9,
|
||||
"ay": 120
|
||||
},
|
||||
"throw": {
|
||||
"x": 188,
|
||||
"y": 0,
|
||||
"w": 80,
|
||||
"h": 102,
|
||||
"ax": 51.5,
|
||||
"ay": 102
|
||||
},
|
||||
"laugh": {
|
||||
"x": 270,
|
||||
"y": 0,
|
||||
"w": 41,
|
||||
"h": 119,
|
||||
"ax": 16.9,
|
||||
"ay": 119
|
||||
},
|
||||
"measure": {
|
||||
"x": 313,
|
||||
"y": 0,
|
||||
"w": 72,
|
||||
"h": 91,
|
||||
"ax": 38.0,
|
||||
"ay": 91
|
||||
},
|
||||
"cheer": {
|
||||
"x": 387,
|
||||
"y": 0,
|
||||
"w": 63,
|
||||
"h": 118,
|
||||
"ax": 27.5,
|
||||
"ay": 118
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -5,65 +5,65 @@
|
||||
"sit": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 76,
|
||||
"w": 77,
|
||||
"h": 96,
|
||||
"ax": 31.4,
|
||||
"ax": 32.9,
|
||||
"ay": 96
|
||||
},
|
||||
"doze": {
|
||||
"x": 78,
|
||||
"x": 79,
|
||||
"y": 0,
|
||||
"w": 76,
|
||||
"w": 77,
|
||||
"h": 96,
|
||||
"ax": 31.5,
|
||||
"ax": 33.0,
|
||||
"ay": 96
|
||||
},
|
||||
"talk": {
|
||||
"x": 156,
|
||||
"x": 158,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"w": 68,
|
||||
"h": 96,
|
||||
"ax": 25.1,
|
||||
"ax": 26.3,
|
||||
"ay": 96
|
||||
},
|
||||
"explain": {
|
||||
"x": 225,
|
||||
"x": 228,
|
||||
"y": 0,
|
||||
"w": 62,
|
||||
"w": 68,
|
||||
"h": 96,
|
||||
"ax": 27.4,
|
||||
"ax": 29.9,
|
||||
"ay": 96
|
||||
},
|
||||
"point": {
|
||||
"x": 289,
|
||||
"x": 298,
|
||||
"y": 0,
|
||||
"w": 74,
|
||||
"w": 78,
|
||||
"h": 97,
|
||||
"ax": 31.5,
|
||||
"ax": 33.3,
|
||||
"ay": 97
|
||||
},
|
||||
"laugh": {
|
||||
"x": 365,
|
||||
"x": 378,
|
||||
"y": 0,
|
||||
"w": 70,
|
||||
"w": 71,
|
||||
"h": 97,
|
||||
"ax": 23.2,
|
||||
"ax": 22.8,
|
||||
"ay": 97
|
||||
},
|
||||
"cat": {
|
||||
"x": 437,
|
||||
"x": 451,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 95,
|
||||
"ax": 24.1,
|
||||
"ay": 95
|
||||
"w": 68,
|
||||
"h": 96,
|
||||
"ax": 26.1,
|
||||
"ay": 96
|
||||
},
|
||||
"shrug": {
|
||||
"x": 506,
|
||||
"x": 521,
|
||||
"y": 0,
|
||||
"w": 81,
|
||||
"w": 83,
|
||||
"h": 97,
|
||||
"ax": 38.9,
|
||||
"ax": 40.2,
|
||||
"ay": 97
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+326
-34
@@ -91,6 +91,22 @@
|
||||
"file": "andrea-af4d717814.mp3",
|
||||
"sec": 6.44
|
||||
},
|
||||
"vecchietto|Dice che Andrea ha trovato un serpente lungo dodici metri.": {
|
||||
"file": "vecchietto-cc1d61420b.mp3",
|
||||
"sec": 3.64
|
||||
},
|
||||
"vecchietta|Ma quale serpente. Secondo me va in Fortezza solo per farsi le canne: torna sempre con gli occhi rossi!": {
|
||||
"file": "vecchietta-c11979f5e8.mp3",
|
||||
"sec": 6.73
|
||||
},
|
||||
"andrea|Sono qui, eh. Vi sento.": {
|
||||
"file": "andrea-5debd43bf6.mp3",
|
||||
"sec": 2.64
|
||||
},
|
||||
"vecchietta|Oh! Andrea! Si parlava d'un altro.": {
|
||||
"file": "vecchietta-1cbc047e44.mp3",
|
||||
"sec": 4.9
|
||||
},
|
||||
"andrea|Se li porto via, il paese resta senza notizie.": {
|
||||
"file": "andrea-566475abe2.mp3",
|
||||
"sec": 3.48
|
||||
@@ -111,6 +127,26 @@
|
||||
"file": "andrea-eeb5a2273c.mp3",
|
||||
"sec": 1.3
|
||||
},
|
||||
"vecchietta|Al limone! Da ragazza me ne mangiavo tre di fila.": {
|
||||
"file": "vecchietta-15dbd36313.mp3",
|
||||
"sec": 3.24
|
||||
},
|
||||
"vecchietto|E poi tre giorni a letto.": {
|
||||
"file": "vecchietto-6a0dcb4430.mp3",
|
||||
"sec": 1.37
|
||||
},
|
||||
"andrea|Me lo tengo io, allora.": {
|
||||
"file": "andrea-d5aeb501d8.mp3",
|
||||
"sec": 1.16
|
||||
},
|
||||
"vecchietto|«La storia infinita»? Alla mia età, figliolo, meglio qualcosa di corto.": {
|
||||
"file": "vecchietto-27843b8226.mp3",
|
||||
"sec": 5.02
|
||||
},
|
||||
"andrea|Fidati: è più corto di quel che sembra.": {
|
||||
"file": "andrea-20c5ab3680.mp3",
|
||||
"sec": 2.41
|
||||
},
|
||||
"andrea|Qualcosa luccica, proprio sotto i piedi dei vecchietti.": {
|
||||
"file": "andrea-9a8732a3aa.mp3",
|
||||
"sec": 3.97
|
||||
@@ -175,6 +211,10 @@
|
||||
"file": "andrea-fd10e3f083.mp3",
|
||||
"sec": 3.04
|
||||
},
|
||||
"andrea|Di giorno la torcia non serve: le impronte si vedono benissimo. È il buio della fessura, il problema.": {
|
||||
"file": "andrea-36648fc8d5.mp3",
|
||||
"sec": 7.66
|
||||
},
|
||||
"andrea|Ormai lo so: è passato di qui. E le impronte piegano verso il bastione.": {
|
||||
"file": "andrea-5b0cf4f9cf.mp3",
|
||||
"sec": 5.03
|
||||
@@ -231,38 +271,14 @@
|
||||
"file": "andrea-aa9b313a43.mp3",
|
||||
"sec": 2.22
|
||||
},
|
||||
"andrea|Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.": {
|
||||
"file": "andrea-59df1ada58.mp3",
|
||||
"sec": 4.78
|
||||
},
|
||||
"andrea|Accendo la torcia e la punto dentro...": {
|
||||
"file": "andrea-68a845881b.mp3",
|
||||
"sec": 1.94
|
||||
},
|
||||
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
||||
"file": "andrea-6484641c58.mp3",
|
||||
"sec": 8.67
|
||||
},
|
||||
"andrea|È il palio! Il quadro dei ragazzi delle medie!": {
|
||||
"file": "andrea-51ec04c16e.mp3",
|
||||
"sec": 3.54
|
||||
},
|
||||
"andrea|E quelli... sono due occhi gialli!": {
|
||||
"file": "andrea-0f5f2a4b2f.mp3",
|
||||
"sec": 2.63
|
||||
},
|
||||
"andrea|Spariti nel buio.": {
|
||||
"file": "andrea-f34cc00733.mp3",
|
||||
"sec": 1.17
|
||||
},
|
||||
"andrea|Il Regolo. E ha il nostro palio.": {
|
||||
"file": "andrea-1b2601be96.mp3",
|
||||
"sec": 2.77
|
||||
},
|
||||
"andrea|Però non l'ha portato via: l'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.": {
|
||||
"file": "andrea-abeac78e68.mp3",
|
||||
"sec": 7.13
|
||||
},
|
||||
"andrea|Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!": {
|
||||
"file": "andrea-28f672ef5f.mp3",
|
||||
"sec": 5.42
|
||||
},
|
||||
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
||||
"file": "andrea-43fd5c2b27.mp3",
|
||||
"sec": 4.46
|
||||
@@ -279,6 +295,14 @@
|
||||
"file": "andrea-6012288000.mp3",
|
||||
"sec": 7.97
|
||||
},
|
||||
"san_pietro|Figliolo, io tengo le chiavi, non le squame. Portala a chi l'ha persa.": {
|
||||
"file": "san_pietro-28fac8029b.mp3",
|
||||
"sec": 5.6
|
||||
},
|
||||
"san_pietro|Te l'ho data io: usala, non restituirla. Le chiavi servono alle porte.": {
|
||||
"file": "san_pietro-72373bd589.mp3",
|
||||
"sec": 6.13
|
||||
},
|
||||
"andrea|La statua di San Pietro, con le chiavi in mano. Lui il palio l'ha visto passare di sicuro.": {
|
||||
"file": "andrea-0663f64ddf.mp3",
|
||||
"sec": 5.97
|
||||
@@ -587,6 +611,10 @@
|
||||
"file": "andrea-a8dfddbe65.mp3",
|
||||
"sec": 3.45
|
||||
},
|
||||
"andrea|Un lucchetto non si pesca. Ci vuole la sua chiave.": {
|
||||
"file": "andrea-d193b44288.mp3",
|
||||
"sec": 3.65
|
||||
},
|
||||
"andrea|Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
|
||||
"file": "andrea-78a5ef65f0.mp3",
|
||||
"sec": 7.84
|
||||
@@ -627,6 +655,18 @@
|
||||
"file": "andrea-e47b3cebb5.mp3",
|
||||
"sec": 4.87
|
||||
},
|
||||
"barondeduke|Me la giochi? Ho un sette e un due, ma un sacco di fiducia.": {
|
||||
"file": "barondeduke-52184c7c05.mp3",
|
||||
"sec": 5.58
|
||||
},
|
||||
"andrea|No, Barone. A poker le squame non valgono.": {
|
||||
"file": "andrea-40651c0ea4.mp3",
|
||||
"sec": 4.39
|
||||
},
|
||||
"barondeduke|A questo giro non punto niente. Nemmeno un ghiacciolo.": {
|
||||
"file": "barondeduke-eff8685095.mp3",
|
||||
"sec": 4.28
|
||||
},
|
||||
"andrea|Il BaronDeDuke, col cappello da cowboy. Ha l'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.": {
|
||||
"file": "andrea-0bd1b0f32e.mp3",
|
||||
"sec": 8.7
|
||||
@@ -803,6 +843,18 @@
|
||||
"file": "andrea-bb70dea2a6.mp3",
|
||||
"sec": 2.15
|
||||
},
|
||||
"arcu|Che è, la mancia? Una squama? Grazie, la metto in cassaforte.": {
|
||||
"file": "arcu-8c7f1850a3.mp3",
|
||||
"sec": 5.84
|
||||
},
|
||||
"arcu|Tienila te, che io di roba verde ne ho già abbastanza sul lavoro.": {
|
||||
"file": "arcu-bb69d88654.mp3",
|
||||
"sec": 3.23
|
||||
},
|
||||
"arcu|Un libro? A me? Lo sai che io leggo solo le etichette degli armadi.": {
|
||||
"file": "arcu-1da81f6f98.mp3",
|
||||
"sec": 6.44
|
||||
},
|
||||
"andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
|
||||
"file": "andrea-8ddab2d024.mp3",
|
||||
"sec": 5.79
|
||||
@@ -831,14 +883,26 @@
|
||||
"file": "andrea-e94382adb6.mp3",
|
||||
"sec": 5.76
|
||||
},
|
||||
"andrea|Senza bocce, e senza un vecchietto che misura col metro, non è una partita.": {
|
||||
"file": "andrea-d6576896b2.mp3",
|
||||
"sec": 5.36
|
||||
"andrea|Una partita? Chiedo a Mariano, il campione.": {
|
||||
"file": "andrea-609540460a.mp3",
|
||||
"sec": 3.81
|
||||
},
|
||||
"andrea|Di là, dove guarda l'omino di ferro, c'è il campo da tennis della chiesa.": {
|
||||
"file": "andrea-455ac4031a.mp3",
|
||||
"sec": 4.96
|
||||
},
|
||||
"andrea|Tosca, guarda cosa ho trovato.": {
|
||||
"file": "andrea-f5ff3c0ef9.mp3",
|
||||
"sec": 2.27
|
||||
},
|
||||
"tosca|Bella. Ma nella cedrata questa non ce la metto.": {
|
||||
"file": "tosca-7451ea2ef5.mp3",
|
||||
"sec": 3.81
|
||||
},
|
||||
"tosca|Un libro? Andrea, qui si legge solo il listino. E costa meno.": {
|
||||
"file": "tosca-ffc7cea9af.mp3",
|
||||
"sec": 6.46
|
||||
},
|
||||
"andrea|Il chiosco del tennis. D'estate qui c'è la Tosca coi ghiaccioli.": {
|
||||
"file": "andrea-24214bad4e.mp3",
|
||||
"sec": 5.6
|
||||
@@ -867,6 +931,10 @@
|
||||
"file": "andrea-2de291f241.mp3",
|
||||
"sec": 4.09
|
||||
},
|
||||
"andrea|Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.": {
|
||||
"file": "andrea-3e2f9b4424.mp3",
|
||||
"sec": 6.42
|
||||
},
|
||||
"andrea|Giù a destra, oltre le vigne, c'è la casa del maestro Bacci. Mezzo paese ha imparato a leggere e scrivere da lui.": {
|
||||
"file": "andrea-060513a152.mp3",
|
||||
"sec": 8.65
|
||||
@@ -959,6 +1027,26 @@
|
||||
"file": "andrea-468fe91000.mp3",
|
||||
"sec": 3.28
|
||||
},
|
||||
"leone|Ehi! Mi abbagli! Sono di terracotta, mica cieco.": {
|
||||
"file": "leone-4addbac9c7.mp3",
|
||||
"sec": 6
|
||||
},
|
||||
"andrea|Scusa, leone.": {
|
||||
"file": "andrea-66a622cd5e.mp3",
|
||||
"sec": 0.92
|
||||
},
|
||||
"leone|Quella tienila per il buio. Qui c'è il sole, se non l'hai notato.": {
|
||||
"file": "leone-19d904d4e1.mp3",
|
||||
"sec": 5.66
|
||||
},
|
||||
"leone|Una squama? Io di squame ne ho viste... mai. Io faccio la guardia alla salita.": {
|
||||
"file": "leone-7bd7f55e53.mp3",
|
||||
"sec": 8.73
|
||||
},
|
||||
"andrea|Lo so, lo so. Alla salita.": {
|
||||
"file": "andrea-62c970e189.mp3",
|
||||
"sec": 2.65
|
||||
},
|
||||
"andrea|Di là si va alla scuola. Ci ho passato abbastanza anni, grazie.": {
|
||||
"file": "andrea-f6fa5d6d6d.mp3",
|
||||
"sec": 4.03
|
||||
@@ -975,6 +1063,14 @@
|
||||
"file": "andrea-f5d55187e8.mp3",
|
||||
"sec": 5.11
|
||||
},
|
||||
"tozzino|Grazie, ma prima della partita niente zuccheri. Federer non li tocca.": {
|
||||
"file": "tozzino-cfc1b1fa67.mp3",
|
||||
"sec": 4.8
|
||||
},
|
||||
"tozzino|Una torcia? Se Federer arriva di notte, magari. Per ora tienila te.": {
|
||||
"file": "tozzino-18e6d6edff.mp3",
|
||||
"sec": 5.58
|
||||
},
|
||||
"andrea|Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l'orologio ogni dieci secondi.": {
|
||||
"file": "andrea-3c32888a15.mp3",
|
||||
"sec": 8.1
|
||||
@@ -991,9 +1087,9 @@
|
||||
"file": "andrea-70f150bdec.mp3",
|
||||
"sec": 2.67
|
||||
},
|
||||
"tozzino|E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.": {
|
||||
"file": "tozzino-d51159c364.mp3",
|
||||
"sec": 4.08
|
||||
"tozzino|E chi sennò? Ha chiamato ora: s'è fermato a Pratolino a mettere le catene. A breve arriva.": {
|
||||
"file": "tozzino-9fef6eb47d.mp3",
|
||||
"sec": 6.42
|
||||
},
|
||||
"tozzino|Se non arriva, la vinco a tavolino.": {
|
||||
"file": "tozzino-c06cc01612.mp3",
|
||||
@@ -1135,6 +1231,14 @@
|
||||
"file": "andrea-a1dab4073c.mp3",
|
||||
"sec": 3.82
|
||||
},
|
||||
"castello|Luce? In seicento anni ne ho vista tanta. Tienila per il buio, che quello non manca mai.": {
|
||||
"file": "castello-c2cc9224c5.mp3",
|
||||
"sec": 9.1
|
||||
},
|
||||
"castello|Non a me. Portala vicino a chi l'ha persa: le cose parlano solo dove sono cadute.": {
|
||||
"file": "castello-3267e20838.mp3",
|
||||
"sec": 7.74
|
||||
},
|
||||
"andrea|La torre del Trebbio. Da ragazzo ci si rifugiò Amerigo Vespucci, per scappare dalla peste di Firenze.": {
|
||||
"file": "andrea-14cbceece4.mp3",
|
||||
"sec": 6.99
|
||||
@@ -1295,6 +1399,10 @@
|
||||
"file": "andrea-2d0e3c759e.mp3",
|
||||
"sec": 1.39
|
||||
},
|
||||
"andrea|Tosca, cosa si dice in giro?": {
|
||||
"file": "andrea-be96b05f57.mp3",
|
||||
"sec": 2.04
|
||||
},
|
||||
"andrea|Niente, grazie Tosca.": {
|
||||
"file": "andrea-2637918e77.mp3",
|
||||
"sec": 1.32
|
||||
@@ -1319,6 +1427,14 @@
|
||||
"file": "tosca-376fbb9f17.mp3",
|
||||
"sec": 2.41
|
||||
},
|
||||
"tosca|Che sei andato sotto la Fortezza da solo. Il paese parla, Andrea.": {
|
||||
"file": "tosca-ad26d2c8dc.mp3",
|
||||
"sec": 5.16
|
||||
},
|
||||
"tosca|Prenditi una cedrata, che le chiacchiere mettono sete.": {
|
||||
"file": "tosca-940ffeb62c.mp3",
|
||||
"sec": 2.98
|
||||
},
|
||||
"tosca|Quando ti viene sete, sai dove trovarmi.": {
|
||||
"file": "tosca-db2da31cef.mp3",
|
||||
"sec": 2.31
|
||||
@@ -1383,6 +1499,18 @@
|
||||
"file": "andrea-379131645c.mp3",
|
||||
"sec": 1.98
|
||||
},
|
||||
"andrea|Ho trovato una squama, sotto la panchina.": {
|
||||
"file": "andrea-5a36e0de20.mp3",
|
||||
"sec": 2.57
|
||||
},
|
||||
"andrea|Sotto la Fortezza ci sono impronte, e vanno verso il bastione.": {
|
||||
"file": "andrea-dc9addb491.mp3",
|
||||
"sec": 3.56
|
||||
},
|
||||
"andrea|Ho una torcia. Stasera salgo alla Fortezza.": {
|
||||
"file": "andrea-c458e32a56.mp3",
|
||||
"sec": 4.28
|
||||
},
|
||||
"andrea|Ci vediamo dopo.": {
|
||||
"file": "andrea-7e9841c23a.mp3",
|
||||
"sec": 0.95
|
||||
@@ -1423,6 +1551,42 @@
|
||||
"file": "vecchietta-fe3c07afa2.mp3",
|
||||
"sec": 5.11
|
||||
},
|
||||
"vecchietta|L'avevo detto io, che luccicava!": {
|
||||
"file": "vecchietta-8bcd369812.mp3",
|
||||
"sec": 2.3
|
||||
},
|
||||
"vecchietto|Una squama a maggio? Allora il Regolo fa la muta, come le bisce.": {
|
||||
"file": "vecchietto-33b2aadc49.mp3",
|
||||
"sec": 4.64
|
||||
},
|
||||
"vecchietta|O l'ha persa uno della sfilata, col costume. Che ne sai.": {
|
||||
"file": "vecchietta-51d10531a0.mp3",
|
||||
"sec": 4.34
|
||||
},
|
||||
"vecchietto|Lo sapevo. Stanotte ho sentito raspare, dalla parte della Fortezza.": {
|
||||
"file": "vecchietto-7204d5b851.mp3",
|
||||
"sec": 5.29
|
||||
},
|
||||
"vecchietta|Tu stanotte dormivi. L'hai detto tu.": {
|
||||
"file": "vecchietta-8f618fae8c.mp3",
|
||||
"sec": 2.75
|
||||
},
|
||||
"vecchietto|Si può dormire e sentire. Alla mia età si fa tutto insieme.": {
|
||||
"file": "vecchietto-320caa6792.mp3",
|
||||
"sec": 4.92
|
||||
},
|
||||
"vecchietto|Di sera? Lassù? Allora salutaci il Regolo.": {
|
||||
"file": "vecchietto-9d2e14790f.mp3",
|
||||
"sec": 4.08
|
||||
},
|
||||
"vecchietta|E se ti mangia, almeno la festa ha un argomento.": {
|
||||
"file": "vecchietta-7baa7686cd.mp3",
|
||||
"sec": 2.94
|
||||
},
|
||||
"andrea|Grazie dell'incoraggiamento.": {
|
||||
"file": "andrea-f845779a10.mp3",
|
||||
"sec": 1.57
|
||||
},
|
||||
"vecchietta|Salutaci i tuoi!": {
|
||||
"file": "vecchietta-814e558af5.mp3",
|
||||
"sec": 1.47
|
||||
@@ -1439,6 +1603,22 @@
|
||||
"file": "andrea-413cf7673b.mp3",
|
||||
"sec": 1.56
|
||||
},
|
||||
"andrea|Ho trovato una squama del Regolo.": {
|
||||
"file": "andrea-51314c3413.mp3",
|
||||
"sec": 1.69
|
||||
},
|
||||
"andrea|Sotto la Fortezza ci sono le sue impronte.": {
|
||||
"file": "andrea-898cf87dd0.mp3",
|
||||
"sec": 2.49
|
||||
},
|
||||
"andrea|Ho la torcia. Stasera entro nella fessura.": {
|
||||
"file": "andrea-343fb5c806.mp3",
|
||||
"sec": 3.33
|
||||
},
|
||||
"andrea|Cosa stavamo dicendo???": {
|
||||
"file": "andrea-06f5050907.mp3",
|
||||
"sec": 1.24
|
||||
},
|
||||
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
|
||||
"file": "andrea-fca6f59a70.mp3",
|
||||
"sec": 3.41
|
||||
@@ -1451,6 +1631,70 @@
|
||||
"file": "arcu-5d07c15776.mp3",
|
||||
"sec": 2.89
|
||||
},
|
||||
"arcu|Fammi vedere... Verde. Come la Panda di mi' zio.": {
|
||||
"file": "arcu-af7afda339.mp3",
|
||||
"sec": 4.18
|
||||
},
|
||||
"arcu|Verde! Allora il Regolo tifa Cardetole. Tradimento.": {
|
||||
"file": "arcu-85c7d0b8f6.mp3",
|
||||
"sec": 5.57
|
||||
},
|
||||
"arcu|E te ci vai da solo? Io ti aspetto qui.": {
|
||||
"file": "arcu-e157a4b70b.mp3",
|
||||
"sec": 2.73
|
||||
},
|
||||
"arcu|Qualcuno deve raccontarlo, dopo.": {
|
||||
"file": "arcu-5d1fa73d32.mp3",
|
||||
"sec": 1.77
|
||||
},
|
||||
"arcu|Fai una cosa: se vedi due occhi gialli, un sono i fari della Provinciale.": {
|
||||
"file": "arcu-fdd1ab5f5b.mp3",
|
||||
"sec": 4.37
|
||||
},
|
||||
"andrea|Grazie, Arcu. Molto utile.": {
|
||||
"file": "andrea-2caf0f40f2.mp3",
|
||||
"sec": 2.73
|
||||
},
|
||||
"andrea|Ma ti si è bruciata la memoria, fratello???!?": {
|
||||
"file": "andrea-bf8a51895b.mp3",
|
||||
"sec": 2.98
|
||||
},
|
||||
"arcu|...I pompieri per cosa?": {
|
||||
"file": "arcu-5148dffe36.mp3",
|
||||
"sec": 1.7
|
||||
},
|
||||
"andrea|Mariano! Facciamo una partita a bocce?": {
|
||||
"file": "andrea-83b1337b4d.mp3",
|
||||
"sec": 3.31
|
||||
},
|
||||
"andrea|La rivincita?": {
|
||||
"file": "andrea-98e4366a56.mp3",
|
||||
"sec": 0.83
|
||||
},
|
||||
"andrea|Allora, Brento Sanico?": {
|
||||
"file": "andrea-2add39ff10.mp3",
|
||||
"sec": 1.29
|
||||
},
|
||||
"andrea|Ciao Mariano.": {
|
||||
"file": "andrea-76a4a385c7.mp3",
|
||||
"sec": 0.89
|
||||
},
|
||||
"mariano|Con me? Al pallaio sono il campione.": {
|
||||
"file": "mariano-e8bb19cfdf.mp3",
|
||||
"sec": 3.31
|
||||
},
|
||||
"mariano|Facciamo così: se mi batti, ti porto a Brento Sanico.": {
|
||||
"file": "mariano-0eeb942ca2.mp3",
|
||||
"sec": 3.52
|
||||
},
|
||||
"mariano|Il campione non scappa. Tira!": {
|
||||
"file": "mariano-bdf58ba1be.mp3",
|
||||
"sec": 3.26
|
||||
},
|
||||
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
|
||||
"file": "mariano-1c9cd0c325.mp3",
|
||||
"sec": 5.96
|
||||
},
|
||||
"andrea|Sono bloccato. Mi dai un consiglio?": {
|
||||
"file": "andrea-42b02eb9ea.mp3",
|
||||
"sec": 3.9
|
||||
@@ -1527,6 +1771,54 @@
|
||||
"file": "castello-dc8a873d86.mp3",
|
||||
"sec": 4.36
|
||||
},
|
||||
"mariano|Aspetta, ci vuole il metro...": {
|
||||
"file": "mariano-e8b4ebf3d9.mp3",
|
||||
"sec": 1.62
|
||||
},
|
||||
"mariano|Hai vinto, ti ci porto!": {
|
||||
"file": "mariano-f361945994.mp3",
|
||||
"sec": 1.66
|
||||
},
|
||||
"mariano|Bocciato! Al pallaio comando io.": {
|
||||
"file": "mariano-4b1eea73b2.mp3",
|
||||
"sec": 3.38
|
||||
},
|
||||
"mariano|Quando vuoi la rivincita, sono qui.": {
|
||||
"file": "mariano-e582e766cd.mp3",
|
||||
"sec": 2.29
|
||||
},
|
||||
"mariano|Già stanco? Il pallaio ti aspetta.": {
|
||||
"file": "mariano-39e27158b7.mp3",
|
||||
"sec": 2.83
|
||||
},
|
||||
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
||||
"file": "andrea-6484641c58.mp3",
|
||||
"sec": 8.67
|
||||
},
|
||||
"andrea|È il palio! Il quadro dei ragazzi delle medie!": {
|
||||
"file": "andrea-51ec04c16e.mp3",
|
||||
"sec": 3.54
|
||||
},
|
||||
"andrea|E quelli... sono due occhi gialli!": {
|
||||
"file": "andrea-0f5f2a4b2f.mp3",
|
||||
"sec": 2.63
|
||||
},
|
||||
"andrea|Spariti nel buio.": {
|
||||
"file": "andrea-f34cc00733.mp3",
|
||||
"sec": 1.17
|
||||
},
|
||||
"andrea|Il Regolo. E ha il nostro palio.": {
|
||||
"file": "andrea-1b2601be96.mp3",
|
||||
"sec": 2.77
|
||||
},
|
||||
"andrea|Però non l'ha portato via: l'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.": {
|
||||
"file": "andrea-abeac78e68.mp3",
|
||||
"sec": 7.13
|
||||
},
|
||||
"andrea|Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!": {
|
||||
"file": "andrea-28f672ef5f.mp3",
|
||||
"sec": 5.42
|
||||
},
|
||||
"andrea|Mi ricordavo un finale diverso.": {
|
||||
"file": "andrea-d9ff79ed54.mp3",
|
||||
"sec": 1.73
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -96,3 +96,12 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
|
||||
- **La racchetta del Tozzino** (Andrea, 25 ottobre): non la trova, Federer sta arrivando, ha paura di perdere e chiede
|
||||
aiuto ad Andrea. Si potrebbe legare al poker del Barone (persa la stessa sera?). Serve un foglio sprite del Tozzino
|
||||
senza racchetta, più una posa con la racchetta ritrovata.
|
||||
- **Consigli di Codex (25 ottobre), da valutare dopo 5 e 1 (risposte ai tentativi sbagliati e pettegolezzo che
|
||||
cambia, fatti):**
|
||||
- *Colpo di scena a metà*: la squama somiglia al Regolo della sfilata... ma allora chi ha lasciato le impronte?
|
||||
Solo con dialoghi e una riga nel taccuino, senza toccare la catena degli enigmi.
|
||||
- *Un enigma con l'Arcu*: tiene qualcosa, distrae qualcuno o fraintende un'istruzione (gag). Uno solo: tocca
|
||||
soluzione, test, voci e la sua posizione in ogni scena dove compare (le pose ci sono).
|
||||
- *Regia alla fessura*: col meccanismo del primo piano (`closeup`) il buio, la torcia, un istante di silenzio,
|
||||
poi gli occhi. Due immagini di Codex.
|
||||
- *Segreti facoltativi del paese* con pagine illustrate nel taccuino: funzione nuova, dopo la storia principale.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Da dove vengono le immagini
|
||||
|
||||
- `assets/closeups/palio-2007.jpg`: copia della foto `assets/raw/palio-2007-foto.jpg` fornita da Andrea
|
||||
il 24 ottobre 2026. Usata nel primo piano della fessura; il renderer mostra soltanto la tela tramite
|
||||
le coordinate `story.closeups.fessura.crop`, senza modificare la foto originale.
|
||||
|
||||
Da controllare **prima del primo push**: quello che è nella storia di git, una volta pubblicato, resta pubblico
|
||||
anche se poi lo si cancella. Le foto originali (`assets/raw/`) e i prompt (`prompts/`) sono esclusi da git e non sono
|
||||
mai stati committati (verificato il 2026-10-23); qui si parla degli sfondi ridisegnati in `assets/scenes/`, che invece
|
||||
|
||||
@@ -18,6 +18,23 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
||||
|
||||
## Dove siamo arrivati
|
||||
|
||||
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
|
||||
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
|
||||
Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
|
||||
`src/story/soundscapes.js`): vento, acqua, uccelli di giorno e grilli di sera, campane, bicchieri,
|
||||
gocce e scricchiolii distanziati. In **Pausa → Ambiente ed effetti** si regola il volume, salvato nel browser.
|
||||
Muto, pausa e pagina nascosta fermano anche i suoni già avviati.
|
||||
La **torcia nella fessura** apre un primo piano: mouse, tocco/trascinamento o frecce spostano la luce;
|
||||
Invio aiuta a cercare il prossimo dettaglio. Pietra → palio → occhi; la musica si abbassa durante la ricerca.
|
||||
«Torna fuori»/Esc permette di riprovare; soltanto dopo gli occhi «Continua» avvia la conclusione.
|
||||
Nessun progresso parziale viene salvato. La tela usa la foto vera del 2007, ritagliata durante il disegno.
|
||||
Controllo mirato: `npm run browser -- --media-only`; il collaudo normale include anche queste prove.
|
||||
Verificati `npm test` e `npm run browser`: soluzione completa, salvataggi, libro su desktop/telefono,
|
||||
torcia con mouse/tastiera/tocco/trascinamento, pausa e muto, segnale Web Audio senza saturazione.
|
||||
Schermate in `/tmp/regolo-shots/media-*.png` e `/tmp/regolo-shots/05b-fessura-*.png`.
|
||||
I copioni video sono aggiornati ai gesti richiesti dalla torcia; il montaggio audio da file continua a
|
||||
includere solo musica e voci (gli effetti Web Audio non vengono esportati dal copione).
|
||||
|
||||
- **Ambienti animati (25 ottobre)**: alle prove già presenti (bandiere del campeggio, fascio di luce nella Pieve,
|
||||
ombre al Bosco ai Frati) si aggiungono 51 dettagli nelle altre 35 scene. Configurazione in
|
||||
`src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`:
|
||||
|
||||
+23
-1
@@ -19,7 +19,7 @@
|
||||
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
|
||||
</div>
|
||||
<div id="sound">
|
||||
<button id="mute" aria-label="Musica: togli l'audio" title="Musica (M)">♪</button>
|
||||
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
|
||||
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
|
||||
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
|
||||
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
|
||||
@@ -73,6 +73,9 @@
|
||||
</section>
|
||||
<section id="pause" class="overlay" hidden aria-label="Pausa">
|
||||
<h2>Pausa</h2>
|
||||
<label class="effects-control" for="effects-volume">Ambiente ed effetti
|
||||
<input id="effects-volume" type="range" min="0" max="100" step="5" aria-label="Volume ambiente ed effetti">
|
||||
</label>
|
||||
<div class="buttons pause-buttons">
|
||||
<button id="resume">Riprendi</button>
|
||||
<button id="pause-map" class="secondary">Mappa</button>
|
||||
@@ -100,6 +103,25 @@
|
||||
<div id="inventory" aria-label="Inventario"></div>
|
||||
</div>
|
||||
<ol id="choices" hidden></ol>
|
||||
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||
<p id="bocce-status" role="status"></p>
|
||||
<div class="bocce-adjust">
|
||||
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
||||
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
||||
<input id="bocce-power" type="range" min="0" max="100" step="1" value="50" aria-label="Forza del lancio">
|
||||
</label>
|
||||
<button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button>
|
||||
<button id="bocce-throw" type="button">Lancia</button>
|
||||
</div>
|
||||
<details id="bocce-help"><summary>Come si gioca</summary>
|
||||
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
||||
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
||||
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
||||
Frecce ← →: mira; ↑ ↓: forza. Esc esce dalla mano.</p>
|
||||
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria.
|
||||
Vince chi fa più punti nella mano.</p>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { project, detailPoint, DETAIL, HALF, canThrow, landing, left, closest, gap, score, status } from './bocce.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const FONT = '"Press Start 2P", monospace';
|
||||
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
|
||||
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
|
||||
}
|
||||
function panel(ctx, x, y, w, h) {
|
||||
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
function sphere(ctx, b, x, y, r, top = false) {
|
||||
const [dark, mid, light] = COLORS[b.jack ? 'jack' : b.who];
|
||||
ctx.fillStyle = 'rgba(29,22,13,0.4)'; ctx.beginPath();
|
||||
ctx.ellipse(x + r * 0.4, y + r * 0.12, r * 1.25, r * 0.45, 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
const cy = top ? y : y - r;
|
||||
const shade = ctx.createRadialGradient(x - r * 0.35, cy - r * 0.4, 0, x, cy, r * 1.1);
|
||||
shade.addColorStop(0, light); shade.addColorStop(0.28, mid); shade.addColorStop(1, dark);
|
||||
ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(x, cy, r, 0, Math.PI * 2); ctx.fill();
|
||||
if (!b.jack) {
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(x, cy, r - 0.2, 0, Math.PI * 2); ctx.clip();
|
||||
ctx.strokeStyle = 'rgba(240,230,210,0.65)'; ctx.lineWidth = Math.max(0.6, r * 0.1);
|
||||
// Le incisioni si spostano con la distanza percorsa: si vede il rotolamento.
|
||||
const offset = Math.sin(b.spin ?? 0) * r * 0.8;
|
||||
ctx.beginPath(); ctx.ellipse(x + offset, cy, Math.max(0.5, r * 0.35), r * 1.1, -0.3, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.35), Math.round(cy - r * 0.4), 1, 1);
|
||||
}
|
||||
function cross(ctx, p, radius, color) {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.45, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(p.x - radius - 3, p.y); ctx.lineTo(p.x + radius + 3, p.y);
|
||||
ctx.moveTo(p.x, p.y - radius * 0.45 - 3); ctx.lineTo(p.x, p.y + radius * 0.45 + 3); ctx.stroke();
|
||||
}
|
||||
const cm = (c, b) => b ? `${(gap(b, c.jack) * 20).toFixed(1)} cm` : '—';
|
||||
|
||||
function detail(ctx, c, def) {
|
||||
const d = DETAIL;
|
||||
panel(ctx, d.x, d.y, d.w, d.h);
|
||||
text(ctx, 'VICINO AL PALLINO', d.x + d.w / 2, d.y + 13, 6, '#eed5a0', 'center');
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(d.x + 5, d.y + 25, d.w - 10, d.h - 49); ctx.clip();
|
||||
ctx.fillStyle = '#b79a6c'; ctx.fillRect(d.x, d.y + 25, d.w, d.h);
|
||||
for (let i = 0; i < 160; i++) {
|
||||
ctx.fillStyle = i % 2 ? '#c9b17d' : '#9c8059';
|
||||
ctx.fillRect(Math.round(d.x + hash(i + 5) * d.w), Math.round(d.y + 25 + hash(i + 190) * (d.h - 49)), 1, 1);
|
||||
}
|
||||
// Le sponde compaiono anche nel dettaglio quando il pallino si avvicina al bordo.
|
||||
for (const w of [-HALF, HALF]) {
|
||||
const p = detailPoint(c, { u: c.jack.u, w });
|
||||
ctx.fillStyle = '#73513b'; ctx.fillRect(p.x - 2, d.y + 25, 4, d.h);
|
||||
}
|
||||
const jack = detailPoint(c, c.jack), leader = score(c).winner;
|
||||
for (const who of ['player', 'cpu']) {
|
||||
const b = closest(c, who);
|
||||
if (!b) continue;
|
||||
const p = detailPoint(c, b);
|
||||
ctx.strokeStyle = who === 'player' ? '#315d92' : '#923827'; ctx.setLineDash([2, 2]);
|
||||
ctx.beginPath(); ctx.moveTo(jack.x, jack.y); ctx.lineTo(p.x, p.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
if (leader === who) { ctx.strokeStyle = '#fff2a3'; ctx.beginPath(); ctx.arc(p.x, p.y, b.r * d.scale + 3, 0, Math.PI * 2); ctx.stroke(); }
|
||||
}
|
||||
if (canThrow(c)) {
|
||||
const target = detailPoint(c, c.aim), end = detailPoint(c, landing(c));
|
||||
cross(ctx, target, 6, '#fff4c9');
|
||||
ctx.setLineDash([2, 2]); cross(ctx, end, 5, '#315d92'); ctx.setLineDash([]);
|
||||
}
|
||||
for (const b of [...c.balls, c.jack]) {
|
||||
const p = detailPoint(c, b);
|
||||
sphere(ctx, b, p.x, p.y, Math.max(b.jack ? 2 : 3, b.r * d.scale), true);
|
||||
}
|
||||
ctx.restore();
|
||||
text(ctx, 'Tocca qui per mirare', d.x + d.w / 2, d.y + d.h - 12, 6, '#d7ccb4', 'center');
|
||||
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||
text(ctx, `${def.names[who]}: ${cm(c, closest(c, who))}`, d.x + 2, d.y + d.h + 13 + i * 14, 6, COLORS[who][2]);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawBocce(ctx, c, def, bg) {
|
||||
ctx.save();
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||
else { ctx.fillStyle = '#bca16d'; ctx.fillRect(0, 0, 640, 360); }
|
||||
// Polvere breve dietro l'ultima boccia, agganciata al terreno e subito dissolta.
|
||||
for (const [i, t] of c.trails.entries()) {
|
||||
const p = project(t.u, t.w);
|
||||
ctx.fillStyle = `rgba(218,195,145,${0.22 * (1 - t.age / 0.55)})`;
|
||||
ctx.fillRect(Math.round(p.x + (hash(i) - 0.5) * 5), Math.round(p.y), Math.max(1, p.s * 8), 1);
|
||||
}
|
||||
if (canThrow(c)) {
|
||||
const from = project(1, 0), target = project(c.aim.u, c.aim.w), end = landing(c);
|
||||
const to = project(Math.min(99, end.u), Math.max(-HALF, Math.min(HALF, end.w)));
|
||||
ctx.strokeStyle = 'rgba(255,246,210,0.8)'; ctx.lineWidth = 1; ctx.setLineDash([3, 5]);
|
||||
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
cross(ctx, target, 5, '#fff4c9'); cross(ctx, to, 7, '#8fc7ff');
|
||||
}
|
||||
for (const b of [c.jack, ...c.balls].sort((a, b) => b.u - a.u)) {
|
||||
const p = project(b.u, b.w);
|
||||
const r = Math.max(b.jack ? 1.7 : 2.5, b.r * 33.9 * p.s * (1 + 0.65 * (1 - p.s)));
|
||||
sphere(ctx, b, p.x, p.y, r);
|
||||
}
|
||||
// Pallino identificabile anche senza distinguere i colori delle squadre.
|
||||
const j = project(c.jack.u, c.jack.w);
|
||||
text(ctx, 'P', j.x, j.y - 13, 6, '#fff7c7', 'center');
|
||||
detail(ctx, c, def);
|
||||
panel(ctx, 18, 306, 180, 43);
|
||||
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||
const y = 319 + i * 17;
|
||||
text(ctx, def.names[who], 28, y, 7, COLORS[who][2]);
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const x = 139 + n * 18;
|
||||
if (n < left(c, who)) sphere(ctx, { who, spin: 0 }, x, y, 4, true);
|
||||
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); }
|
||||
}
|
||||
}
|
||||
panel(ctx, 182, 50, 440, 24);
|
||||
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
|
||||
if (canThrow(c)) {
|
||||
panel(ctx, 446, 274, 176, 37);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 458, 286, 7);
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(458, 299, 150, 5);
|
||||
ctx.fillStyle = '#b9d392'; ctx.fillRect(458, 299, Math.round(c.power * 150), 5);
|
||||
}
|
||||
if (c.step === 'result') {
|
||||
panel(ctx, 206, 91, 397, 70);
|
||||
text(ctx, 'MANO CONCLUSA', 404, 108, 9, '#efd49e', 'center');
|
||||
text(ctx, status(c, def.names), 404, 132, 10, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||
text(ctx, 'Le distanze restano visibili fino a quando esci.', 404, 150, 6, '#d6c7af', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Mano amichevole da tre bocce: mira libera, forza ripetibile, gioca chi non ha il punto.
|
||||
// Il campo usa coordinate u (lunghezza) e w (larghezza). Un'unità rappresenta 20 cm.
|
||||
// È una versione da circolo: sponde elastiche, senza falli o dichiarazioni arbitrali.
|
||||
export const LANE = 100, HALF = 10, FRICTION = 8, BALL = 0.27, JACK = 0.1, BALLS = 3;
|
||||
export const SWING = 1.6;
|
||||
export const FIXED_STEP = 1 / 240;
|
||||
const REST = 0.75, EPS = 0.005;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||
const distance = (a, b) => Math.hypot(a.u - b.u, a.w - b.w);
|
||||
export const gap = (a, b) => Math.max(0, distance(a, b) - a.r - b.r);
|
||||
export const left = (c, who) => Math.max(0, BALLS - c.balls.filter(b => b.who === who).length);
|
||||
export const thrower = c => c.current;
|
||||
export const canThrow = c => c.step === 'aim' && c.current === 'player' && left(c, 'player') > 0;
|
||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||
export const rangeFor = power => 8 + 96 * power;
|
||||
export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
|
||||
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
|
||||
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
|
||||
|
||||
export function newBocce(seed = Date.now() >>> 0) {
|
||||
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
|
||||
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
|
||||
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
|
||||
c.aim = { u: c.jack.u, w: c.jack.w };
|
||||
return c;
|
||||
}
|
||||
export function setPower(c, value) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||
c.power = clamp(value, 0, 1); return true;
|
||||
}
|
||||
export function aim(c, u, w) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(u) || !Number.isFinite(w)) return false;
|
||||
c.aim = { u: clamp(u, 10, LANE - 2), w: clamp(w, -HALF + BALL, HALF - BALL) }; return true;
|
||||
}
|
||||
export function landing(c) {
|
||||
const d = Math.hypot(c.aim.u, c.aim.w), range = rangeFor(c.power);
|
||||
return { u: BALL + 0.02 + range * c.aim.u / d, w: range * c.aim.w / d };
|
||||
}
|
||||
function launch(c, who, u, w, range) {
|
||||
if (left(c, who) <= 0) return false;
|
||||
const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * range);
|
||||
c.balls.push({ who, number: BALLS - left(c, who) + 1, u: BALL + 0.02, w: 0,
|
||||
vu: v * u / d, vw: v * w / d, r: BALL, mass: 1, spin: 0 });
|
||||
c.step = 'rolling'; c.t = c.accumulator = c.travel = 0; c.lastThrower = who;
|
||||
c.events.push({ kind: 'bocce-roll', power: c.power });
|
||||
return true;
|
||||
}
|
||||
export function shoot(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
return launch(c, 'player', c.aim.u, c.aim.w, rangeFor(c.power));
|
||||
}
|
||||
export function press(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||
}
|
||||
export function cancel(c) {
|
||||
if (!c.holding) return false;
|
||||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||
}
|
||||
export function release(c) {
|
||||
if (!c.holding) return false;
|
||||
// Un tocco breve serve a mirare, non spreca una boccia.
|
||||
if (c.holdTime < 0.14) return cancel(c);
|
||||
c.holding = false; return shoot(c);
|
||||
}
|
||||
|
||||
// Impulso normale fra masse diverse: niente accelerazioni create dagli urti, e
|
||||
// separazione geometrica anche quando due pezzi sono già fermi o sovrapposti.
|
||||
export function collide(a, b) {
|
||||
const du = b.u - a.u, dw = b.w - a.w, d = Math.hypot(du, dw), r = a.r + b.r;
|
||||
if (d >= r) return 0;
|
||||
const nu = d > 1e-9 ? du / d : 1, nw = d > 1e-9 ? dw / d : 0;
|
||||
const ia = 1 / (a.mass ?? (a.jack ? 0.1 : 1)), ib = 1 / (b.mass ?? (b.jack ? 0.1 : 1));
|
||||
const overlap = r - d + 0.00001;
|
||||
a.u -= nu * overlap * ia / (ia + ib); a.w -= nw * overlap * ia / (ia + ib);
|
||||
b.u += nu * overlap * ib / (ia + ib); b.w += nw * overlap * ib / (ia + ib);
|
||||
const closing = (a.vu - b.vu) * nu + (a.vw - b.vw) * nw;
|
||||
if (closing <= 0) return 0;
|
||||
const impulse = 1.68 * closing / (ia + ib);
|
||||
a.vu -= impulse * ia * nu; a.vw -= impulse * ia * nw;
|
||||
b.vu += impulse * ib * nu; b.vw += impulse * ib * nw;
|
||||
return closing;
|
||||
}
|
||||
function bounds(b) {
|
||||
let impact = 0;
|
||||
for (const [pos, vel, low, high] of [['u', 'vu', b.r, LANE - b.r], ['w', 'vw', -HALF + b.r, HALF - b.r]]) {
|
||||
if (b[pos] < low || b[pos] > high) {
|
||||
const outward = b[pos] < low ? b[vel] < 0 : b[vel] > 0;
|
||||
b[pos] = clamp(b[pos], low, high);
|
||||
if (outward) { impact = Math.max(impact, Math.abs(b[vel])); b[vel] *= -0.32; }
|
||||
}
|
||||
}
|
||||
return impact;
|
||||
}
|
||||
function step(c, dt) {
|
||||
const all = [c.jack, ...c.balls];
|
||||
let hit = 0, rail = 0;
|
||||
for (const b of all) {
|
||||
const v = Math.hypot(b.vu, b.vw);
|
||||
if (v <= EPS) { b.vu = b.vw = 0; continue; }
|
||||
const stop = Math.min(dt, v / FRICTION), travel = v * stop - 0.5 * FRICTION * stop * stop;
|
||||
b.u += b.vu / v * travel; b.w += b.vw / v * travel;
|
||||
b.spin = (b.spin ?? 0) + travel / b.r;
|
||||
const next = Math.max(0, v - FRICTION * dt) / v;
|
||||
b.vu *= next; b.vw *= next;
|
||||
rail = Math.max(rail, bounds(b));
|
||||
}
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) hit = Math.max(hit, collide(all[i], all[j]));
|
||||
all.forEach(bounds);
|
||||
}
|
||||
if (hit > 0.8 || rail > 1) {
|
||||
// Un solo evento per urto, limitato anche se il chiamante non consuma l'audio.
|
||||
if (c.events.length < 12) c.events.push({ kind: hit > rail ? 'bocce-hit' : 'bocce-rail', power: Math.min(1, Math.max(hit, rail) / 30) });
|
||||
}
|
||||
}
|
||||
export function score(c) {
|
||||
const a = closest(c, 'player'), b = closest(c, 'cpu');
|
||||
if (!a && !b) return { winner: null, points: 0 };
|
||||
const p = a ? gap(a, c.jack) : Infinity, q = b ? gap(b, c.jack) : Infinity;
|
||||
if (Math.abs(p - q) < EPS) return { winner: null, points: 0 };
|
||||
const winner = p < q ? 'player' : 'cpu', limit = Math.min(p, q) === p ? q : p;
|
||||
return { winner, points: c.balls.filter(ball => ball.who === winner && gap(ball, c.jack) < limit - EPS).length };
|
||||
}
|
||||
function nextTurn(c) {
|
||||
if (!left(c, 'player') && !left(c, 'cpu')) { c.step = 'result'; c.result = score(c); return; }
|
||||
const winner = score(c).winner;
|
||||
const behind = winner ? (winner === 'player' ? 'cpu' : 'player') : c.lastThrower;
|
||||
c.current = left(c, behind) ? behind : behind === 'player' ? 'cpu' : 'player';
|
||||
c.step = 'aim';
|
||||
}
|
||||
function cpuThrow(c) {
|
||||
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può
|
||||
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
|
||||
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45;
|
||||
const target = attack ? other : c.jack;
|
||||
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2);
|
||||
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL);
|
||||
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
if (c.step === 'rolling') {
|
||||
c.accumulator += dt;
|
||||
while (c.accumulator >= FIXED_STEP) { step(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||
const b = c.balls.at(-1);
|
||||
if (b && Math.hypot(b.vu, b.vw) > 1) {
|
||||
c.trails.push({ u: b.u, w: b.w, age: 0 });
|
||||
if (c.trails.length > 18) c.trails.shift();
|
||||
}
|
||||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||
} else if (c.step === 'settle' && c.t >= REST) {
|
||||
c.turn++; c.t = 0; nextTurn(c);
|
||||
} else if (c.step === 'aim' && c.current === 'cpu' && c.t >= 1.25) cpuThrow(c);
|
||||
for (const t of c.trails) t.age += dt;
|
||||
c.trails = c.trails.filter(t => t.age < 0.55);
|
||||
}
|
||||
|
||||
// Prospettiva calibrata sulle sponde dello sfondo esistente.
|
||||
const NEAR = { y: 360, x: 355, half: 339 }, FAR = { y: 187, x: 298, half: 37.5 };
|
||||
const S_FAR = FAR.half / NEAR.half, Q = (1 / S_FAR - 1) / LANE, HORIZON = (FAR.y - NEAR.y * S_FAR) / (1 - S_FAR);
|
||||
export function project(u, w) {
|
||||
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
|
||||
return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
|
||||
}
|
||||
export function unproject(x, y) {
|
||||
const s = clamp((y - HORIZON) / (NEAR.y - HORIZON), S_FAR, 1);
|
||||
const u = (1 / s - 1) / Q, k = (s - S_FAR) / (1 - S_FAR);
|
||||
return { u, w: (x - FAR.x - (NEAR.x - FAR.x) * k) / (NEAR.half * s) * HALF };
|
||||
}
|
||||
export const DETAIL = { x: 18, y: 86, w: 150, h: 170, scale: 11 };
|
||||
export function detailPoint(c, b) { return { x: DETAIL.x + DETAIL.w / 2 + (b.w - c.jack.w) * DETAIL.scale, y: DETAIL.y + 93 - (b.u - c.jack.u) * DETAIL.scale }; }
|
||||
export function aimScreen(c, [x, y]) {
|
||||
const d = DETAIL;
|
||||
if (x >= d.x && x <= d.x + d.w && y >= d.y + 24 && y <= d.y + d.h - 18)
|
||||
return aim(c, c.jack.u + (d.y + 93 - y) / d.scale, c.jack.w + (x - d.x - d.w / 2) / d.scale);
|
||||
if (y < 185 || y > 360) return false;
|
||||
const p = unproject(x, y); return aim(c, p.u, p.w);
|
||||
}
|
||||
export function status(c, names) {
|
||||
if (c.step === 'result') return c.result.winner ? `${names[c.result.winner]}: ${c.result.points} ${c.result.points === 1 ? 'punto' : 'punti'}.` : 'Parità: nessun punto.';
|
||||
if (c.step === 'rolling') return 'Boccia in movimento…';
|
||||
if (c.step === 'settle') return 'Misuriamo il punto…';
|
||||
const leader = score(c).winner;
|
||||
return `${names[c.current]} al tiro. ${leader ? `Il punto è di ${names[leader]}.` : 'Il punto è da prendere.'}`;
|
||||
}
|
||||
+61
-2
@@ -1,6 +1,7 @@
|
||||
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
||||
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js';
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
||||
@@ -229,6 +230,7 @@ export class Game {
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
else return false;
|
||||
@@ -241,11 +243,57 @@ export class Game {
|
||||
if (!c) return false;
|
||||
this.closeup = null;
|
||||
this.speech = null;
|
||||
const def = this.story.closeups?.[c.id];
|
||||
if (def?.type === 'torch') {
|
||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||
return true;
|
||||
}
|
||||
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (def?.type === 'bocce') {
|
||||
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
|
||||
return true;
|
||||
}
|
||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
|
||||
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
|
||||
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
|
||||
|
||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
|
||||
closeupAim([x, y]) {
|
||||
const c = this.closeup;
|
||||
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
|
||||
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
|
||||
return true;
|
||||
}
|
||||
|
||||
updateTorch(dt) {
|
||||
const c = this.closeup, def = this.story.closeups[c.id];
|
||||
c.t += dt;
|
||||
if (c.lines?.length) { this.run(c.lines.shift()); return; }
|
||||
if (c.step === 'reveal') {
|
||||
if (c.t < 1.4) return;
|
||||
c.found++;
|
||||
c.step = c.found === def.targets.length ? 'done' : 'search';
|
||||
c.t = c.dwell = 0;
|
||||
return;
|
||||
}
|
||||
const target = def.targets[c.found];
|
||||
if (c.step !== 'search' || !target) return;
|
||||
c.dwell = Math.hypot(c.beam[0] - target.at[0], c.beam[1] - target.at[1]) < target.radius ? c.dwell + dt : 0;
|
||||
if (c.dwell >= 0.45) {
|
||||
c.step = 'reveal';
|
||||
c.t = 0;
|
||||
c.lines = [...(target.lines ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
options() {
|
||||
if (!this.dialogue || this.busy) return [];
|
||||
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
|
||||
@@ -341,6 +389,12 @@ export class Game {
|
||||
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
|
||||
const c = this.closeup;
|
||||
if (c) {
|
||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||
if (this.story.closeups[c.id].type === 'bocce') {
|
||||
bocceUpdate(c, dt);
|
||||
// Il risultato resta leggibile finché il giocatore torna al pallaio.
|
||||
return;
|
||||
}
|
||||
c.t += dt;
|
||||
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
|
||||
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
|
||||
@@ -348,7 +402,7 @@ export class Game {
|
||||
return;
|
||||
}
|
||||
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
|
||||
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
|
||||
while (!this.speech && !this.walking && !this.closeup && this.queue.length) this.run(this.queue.shift());
|
||||
this.commit();
|
||||
}
|
||||
|
||||
@@ -372,7 +426,12 @@ export class Game {
|
||||
else if (e.node) this.dialogue.node = e.node;
|
||||
else if (e.endDialogue) this.dialogue = null;
|
||||
else if (e.end) s.ended = true;
|
||||
else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
else if (e.closeup) {
|
||||
const type = this.story.closeups[e.closeup].type;
|
||||
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
||||
: type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) }
|
||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
}
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||
import { check, PAGE_TURN } from './game.js';
|
||||
import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { drawBocce } from './bocce-render.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -663,6 +665,16 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
|
||||
function drawCloseup(ctx, game, time) {
|
||||
const c = game.closeup, def = game.story.closeups?.[c.id];
|
||||
if (!def) return;
|
||||
if (def.type === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'bocce') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download,
|
||||
// nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi.
|
||||
import { soundscapes } from '../story/soundscapes.js';
|
||||
|
||||
export class Soundscape {
|
||||
constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) {
|
||||
this.volume = volume;
|
||||
this.createContext = createContext;
|
||||
this.random = random;
|
||||
this.context = null;
|
||||
this.nodes = new Set();
|
||||
this.active = false;
|
||||
this.muted = false;
|
||||
this.key = null;
|
||||
this.elapsed = 0;
|
||||
this.next = 4;
|
||||
this.bed = null;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
try {
|
||||
if (!this.context) {
|
||||
this.context = this.createContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0;
|
||||
this.master.connect(this.context.destination);
|
||||
const rate = this.context.sampleRate;
|
||||
this.noise = this.context.createBuffer(1, rate * 4, rate);
|
||||
const data = this.noise.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1;
|
||||
}
|
||||
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
|
||||
} catch {
|
||||
// Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura.
|
||||
this.master?.disconnect(); this.context = null; this.master = null; this.noise = null;
|
||||
}
|
||||
}
|
||||
|
||||
get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; }
|
||||
|
||||
configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) {
|
||||
this.active = active;
|
||||
this.muted = muted;
|
||||
this.volume = Math.max(0, Math.min(1, volume));
|
||||
if (!this.context) return;
|
||||
const gain = this.audible ? this.volume * duck : 0;
|
||||
this.master.gain.cancelScheduledValues(this.context.currentTime);
|
||||
if (!gain) {
|
||||
this.master.gain.setValueAtTime(0, this.context.currentTime);
|
||||
this.clear();
|
||||
} else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12);
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const node of [...this.nodes]) node.stop();
|
||||
this.bed = null;
|
||||
}
|
||||
|
||||
// Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono.
|
||||
connect(source, gain, filter = null, pan = 0) {
|
||||
const panner = this.context.createStereoPanner?.();
|
||||
if (panner) panner.pan.value = pan;
|
||||
const chain = [source, filter, gain, panner, this.master].filter(Boolean);
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
const node = { stop: () => {
|
||||
if (!this.nodes.delete(node)) return;
|
||||
try { source.stop(); } catch { /* già finito */ }
|
||||
chain.slice(0, -1).forEach(n => n.disconnect());
|
||||
} };
|
||||
source.onended = node.stop;
|
||||
this.nodes.add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) {
|
||||
const c = this.context, now = c.currentTime;
|
||||
const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain();
|
||||
source.buffer = this.noise; source.loop = loop;
|
||||
filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5;
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15));
|
||||
if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||||
const node = this.connect(source, gain, filter, pan);
|
||||
source.start();
|
||||
if (!loop) source.stop(now + duration);
|
||||
return { node, gain, level };
|
||||
}
|
||||
|
||||
tone(frequency, end, duration, level, delay = 0, pan = 0) {
|
||||
const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain();
|
||||
source.type = 'sine';
|
||||
source.frequency.setValueAtTime(frequency, at);
|
||||
source.frequency.exponentialRampToValueAtTime(end, at + duration);
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(level, at + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
|
||||
this.connect(source, gain, null, pan);
|
||||
source.start(at); source.stop(at + duration + 0.03);
|
||||
}
|
||||
|
||||
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
|
||||
if (!this.audible) return;
|
||||
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
||||
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
|
||||
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
|
||||
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
|
||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
||||
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
|
||||
else if (kind === 'bird') {
|
||||
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
|
||||
} else if (kind === 'cricket') {
|
||||
for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan);
|
||||
} else if (kind === 'bell' || kind === 'glass') {
|
||||
const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65;
|
||||
for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]])
|
||||
this.tone(base * ratio, base * ratio, duration, level, 0, pan);
|
||||
}
|
||||
}
|
||||
|
||||
update(profile, phase, dt) {
|
||||
if (!this.audible) return;
|
||||
const key = `${profile}:${phase}`, def = soundscapes[profile];
|
||||
if (!def) return;
|
||||
if (this.key !== key) {
|
||||
this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5;
|
||||
}
|
||||
if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true });
|
||||
this.elapsed += dt;
|
||||
if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6);
|
||||
if (this.elapsed < this.next) return;
|
||||
const events = phase === 'sera' ? def.night : def.day;
|
||||
this.play(events[Math.floor(this.random() * events.length)]);
|
||||
this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ export function readAudio(storage) {
|
||||
try {
|
||||
const a = JSON.parse(storage.getItem(AUDIO_KEY));
|
||||
if (!a || typeof a.volume !== 'number') return null;
|
||||
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }) };
|
||||
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }),
|
||||
...(typeof a.effects === 'number' && Number.isFinite(a.effects) && { effects: Math.max(0, Math.min(1, a.effects)) }) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Primo piano della fessura, disegnato alla risoluzione del gioco. Il quadro è la tela
|
||||
// della foto del 2007 fornita da Andrea; il ritaglio viene eseguito solo nel disegno.
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
export function drawTorch(ctx, c, def, picture) {
|
||||
const [x, y] = c.beam;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#090c10';
|
||||
ctx.fillRect(0, 0, 640, 360);
|
||||
// Muratura irregolare, muschio e giunti profondi.
|
||||
for (let row = 0; row < 7; row++) for (let col = 0; col < 10; col++) {
|
||||
const n = row * 10 + col, px = col * 73 - (row % 2) * 36, py = 24 + row * 47;
|
||||
ctx.fillStyle = ['#4b4940', '#555247', '#3e443c', '#615b4b'][Math.floor(hash(n + 1) * 4)];
|
||||
ctx.fillRect(px + 2, py + 2, 68, 42);
|
||||
ctx.fillStyle = '#73705b';
|
||||
ctx.fillRect(px + 5, py + 3, 61, 2);
|
||||
ctx.fillStyle = '#282e29';
|
||||
ctx.fillRect(px + 3, py + 39, 65, 4);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
ctx.fillStyle = k % 2 ? '#424b35' : '#77715a';
|
||||
ctx.fillRect(px + 7 + Math.floor(hash(n * 9 + k) * 54), py + 7 + Math.floor(hash(n * 13 + k) * 28), 3, 2);
|
||||
}
|
||||
}
|
||||
// Il vano si perde dietro il muro; il quadro è appoggiato a terra, senza cornice dorata.
|
||||
ctx.fillStyle = '#111617';
|
||||
ctx.beginPath();
|
||||
[[250, 84], [470, 67], [533, 135], [527, 288], [247, 300], [271, 231]].forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b));
|
||||
ctx.closePath(); ctx.fill();
|
||||
if (c.found >= 1) {
|
||||
ctx.save();
|
||||
ctx.translate(350, 224); ctx.rotate(-0.09);
|
||||
ctx.fillStyle = '#242320'; ctx.fillRect(-48, -79, 98, 144);
|
||||
if (picture) ctx.drawImage(picture, ...def.crop, -46, -77, 92, 138);
|
||||
else {
|
||||
// Anche se l'immagine non arriva il ritrovamento resta comprensibile e giocabile.
|
||||
ctx.fillStyle = '#a6a67c'; ctx.fillRect(-46, -77, 92, 138);
|
||||
ctx.fillStyle = '#e4d8ac'; ctx.beginPath(); ctx.moveTo(-43, 54); ctx.lineTo(0, -50); ctx.lineTo(10, -50); ctx.lineTo(25, 54); ctx.fill();
|
||||
ctx.fillStyle = '#a83e2f'; ctx.fillRect(-6, 0, 12, 21); ctx.fillRect(-17, 0, 34, 5);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
const eyes = c.found === 2 && c.step === 'reveal';
|
||||
if (eyes) {
|
||||
ctx.fillStyle = '#e5bd51';
|
||||
for (const ex of [447, 470]) {
|
||||
ctx.fillRect(ex, 134, 13, 8); ctx.fillRect(ex + 3, 132, 7, 12);
|
||||
ctx.fillStyle = '#151912'; ctx.fillRect(ex + 6, 133, 2, 10);
|
||||
ctx.fillStyle = '#fff1ad'; ctx.fillRect(ex + 3, 135, 2, 2);
|
||||
ctx.fillStyle = '#e5bd51';
|
||||
}
|
||||
}
|
||||
// Oscuramento radiale: il centro caldo lascia leggibile il dettaglio, fuori è quasi nero.
|
||||
const light = ctx.createRadialGradient(x, y, 16, x, y, 112);
|
||||
light.addColorStop(0, 'rgba(8,10,15,0)');
|
||||
light.addColorStop(0.42, 'rgba(8,10,15,0.12)');
|
||||
light.addColorStop(0.75, 'rgba(4,6,10,0.8)');
|
||||
light.addColorStop(1, 'rgba(3,5,9,0.985)');
|
||||
ctx.fillStyle = light; ctx.fillRect(0, 0, 640, 360);
|
||||
// Qualche granello di polvere attraversa il fascio, senza lampeggiamenti.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const px = (hash(i + 100) * 640 + c.age * (2 + hash(i) * 3)) % 640;
|
||||
const py = (hash(i + 200) * 360 + c.age * 2) % 360;
|
||||
const distance = Math.hypot(px - x, py - y);
|
||||
if (distance < 83) { ctx.fillStyle = `rgba(233,216,165,${0.35 * (1 - distance / 83)})`; ctx.fillRect(Math.round(px), Math.round(py), 1, 1); }
|
||||
}
|
||||
// Pietra vicina ai bordi: il giocatore guarda attraverso una fessura.
|
||||
ctx.fillStyle = '#181b1c';
|
||||
for (const points of [[[0, 0], [77, 0], [60, 97], [85, 178], [53, 278], [95, 360], [0, 360]],
|
||||
[[640, 0], [568, 0], [594, 104], [564, 194], [583, 265], [552, 360], [640, 360]]]) {
|
||||
ctx.beginPath(); points.forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b)); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Torcia impugnata sul bordo inferiore, il puntatore resta libero per cercare.
|
||||
ctx.save();
|
||||
ctx.translate(430, 328); ctx.rotate((x - 320) / 1100);
|
||||
const rect = (color, ...box) => { ctx.fillStyle = color; ctx.fillRect(...box); };
|
||||
rect('#372722', 7, -2, 30, 44); rect('#9b6849', 10, 0, 24, 42);
|
||||
rect('#c18c61', 11, 0, 11, 38); rect('#50382e', 29, 10, 5, 32);
|
||||
// Corpo rosso e bordo metallico, come l'icona della torcia nell'inventario.
|
||||
rect('#171c20', -16, -28, 28, 62); rect('#762e27', -13, -18, 21, 50);
|
||||
rect('#a74330', -11, -17, 7, 46); rect('#4a2925', 4, -17, 4, 49);
|
||||
for (let i = 0; i < 5; i++) rect('#482924', -12, 10 + i * 4, 20, 1);
|
||||
rect('#343b3b', -19, -29, 34, 12); rect('#93998a', -17, -29, 30, 3);
|
||||
rect('#d8ce9f', -15, -31, 26, 3); rect('#777c71', -17, -24, 3, 6);
|
||||
rect('#d0a078', 4, -5, 14, 8); rect('#a37350', 4, 2, 14, 2);
|
||||
for (let i = 0; i < 3; i++) { rect('#bf8b60', 4, 6 + i * 8, 17, 6); rect('#71503c', 5, 12 + i * 8, 16, 2); }
|
||||
rect('#dfb58b', 6, -4, 5, 3);
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user