Compare commits

...
17 Commits
Author SHA1 Message Date
manzolo 49bdfb313a Versione 0.1.8 2026-10-25 22:46:40 +01:00
manzoloandClaude Fable 5.1 416b9d46ef Sala dei biliardi: Andrea in proporzione col tavolo (la profondità era la metà: sembrava una formica)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:41:34 +01:00
manzoloandClaude Fable 5.1 7476c84b76 Il portone del Circolo 4 Maggio: «Apri» fa entrare (prima rispondeva «Non si apre»)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:36:20 +01:00
manzoloandClaude Fable 5.1 053728a086 Circolo 4 Maggio: dal portone in piazza si entra nella sala dei biliardi (sfondo in stile Lucas fatto da Andrea con Codex) e al primo tavolo si gioca a biliardo coi cinque birilli contro l'Ing. Tozzi (battute provvisorie, sprite da fare). Motore nuovo: fisica delle biglie e delle sponde, birilli che cadono, regole e falli del gioco italiano, avversario che prova i colpi a vuoto un po' per fotogramma; disegno dall'alto con stecca, linea di mira e biglia fantasma; stessi comandi delle bocce (pannello, tastiera, pressione); suoni; test; nelle idee la foto per i rigori
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:34:19 +01:00
manzoloandClaude Opus 5.5 394e8bdde3 Musica su iPhone e iPad: il volume lì è di sola lettura, la dissolvenza non arrivava mai a zero e il brano vecchio restava sotto quello nuovo (organo e paese insieme nella Pieve). Il livello si tiene a parte e lo zero diventa muto; test che simula iOS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 21:49:16 +01:00
manzoloandClaude Opus 5.5 ca56a9df17 L'Arcu: «S'ha andà a fallo!?!?» – «Gnamo! Ma prima mi fo una segarettina...»; al Trebbio Andrea ci sale a piedi e ha trovato la sua strada (lui che non sa cosa fare da grande); voci e test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 21:44:20 +01:00
manzoloandClaude Opus 5.5 b0b2fc0ed3 Ponte sulla Sieve: il tabernacolo custodisce la copia dell'Annunciazione di Antonio Berti (originale del 1947), con voci nuove
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 21:29:13 +01:00
manzoloandClaude Opus 5.5 f56c9b5358 Idee: altri minigiochi come le bocce (passamano, carrette, bandierine, caccia al tesoro, briscola, rigori)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 20:12:42 +01:00
manzoloandClaude Opus 5.5 70f328c56d Telefono in verticale: la Pausa prende tutto lo schermo, la versione e la spiegazione non finiscono più sopra i verbi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 20:06:48 +01:00
manzolo 0a0bb7eea4 Versione 0.1.7 2026-10-25 19:59:06 +01:00
manzoloandClaude Opus 5.5 16759006b1 Fra Peppino da Gricignano sul sagrato della Pieve (battute provvisorie); al campo secondario, chi vuole giocare a padel trova una macchina del tempo senza plutonio (Alvarino?)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 19:49:17 +01:00
manzolo 7c49c3e260 Versione 0.1.6 2026-10-25 19:30:39 +01:00
manzoloandClaude Opus 5.5 817cfbd7fd Gli sprite di Fra Peppino da Gricignano (Codex, dalla foto di Andrea): pronti, ancora da mettere sul sagrato
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 19:26:23 +01:00
manzoloandClaude Opus 5.5 44485cff52 Bocce più vere (lavoro di Codex): mira libera sul campo o nel dettaglio del pallino, forza regolabile senza tempismo, urti fra masse diverse, tira chi non ha il punto, distanze in centimetri, suoni di rotolamento e urto, comandi anche a pulsanti e tastiera; test aggiornati alle nuove regole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 19:26:23 +01:00
manzoloandClaude Opus 5.5 a0201fea18 Organo della Pieve: anche la melodia sull'organo (registro principale), senza il flauto che sembrava un theremin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 19:02:24 +01:00
manzoloandClaude Opus 5.5 c4bac08c02 Le bocce al pallaio contro Mariano: si tiene premuto per la forza, tre bocce a testa, vince il più vicino al pallino; se vinci, «Hai vinto, ti ci porto!» a Brento Sanico (sfondo e Mariano di Codex dalle foto di Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 18:59:09 +01:00
manzoloandClaude Opus 5.5 7f0584f0fc Dentro la Pieve un organo soave (tools/organo.py); il Federer del Tozzino si è fermato a Pratolino a mettere le catene
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 18:37:04 +01:00
99 changed files with 1982 additions and 47 deletions
Binary file not shown.
+70
View File
@@ -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

+22
View File
@@ -0,0 +1,22 @@
{
"image": "macchina-tempo.png",
"height": 158,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 361,
"h": 158,
"ax": 180,
"ay": 152
},
"talk": {
"x": 0,
"y": 0,
"w": 361,
"h": 158,
"ax": 180,
"ay": 152
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+70
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+245 -9
View File
@@ -75,6 +75,14 @@
"file": "andrea-a9df2e6bbb.mp3",
"sec": 4.98
},
"andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": {
"file": "andrea-f0aef83c80.mp3",
"sec": 9.43
},
"andrea|Da fuori non si parla con nessuno: al circolo si entra.": {
"file": "andrea-0e0e42a1cd.mp3",
"sec": 3.69
},
"andrea|Il programma del Palio della Fortezza, con la foto del palio: la strada bianca che sale alla Fortezza, i ragazzi in fila, e uno con la maglia rossa che alza le braccia.": {
"file": "andrea-6cc844498a.mp3",
"sec": 10.59
@@ -279,6 +287,26 @@
"file": "andrea-68a845881b.mp3",
"sec": 1.94
},
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
"file": "andrea-97d75e2a9d.mp3",
"sec": 5.38
},
"andrea|Buongiorno, Fra Peppino!": {
"file": "andrea-94988dc8c6.mp3",
"sec": 1.74
},
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
"file": "fra_peppino-651c4d95ab.mp3",
"sec": 3.72
},
"andrea|Il palio. È sparito.": {
"file": "andrea-c9c068f507.mp3",
"sec": 2.39
},
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
"file": "fra_peppino-8b3d0a5b8f.mp3",
"sec": 4.47
},
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
"file": "andrea-43fd5c2b27.mp3",
"sec": 4.46
@@ -615,9 +643,65 @@
"file": "andrea-d193b44288.mp3",
"sec": 3.65
},
"andrea|Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
"file": "andrea-78a5ef65f0.mp3",
"sec": 7.84
"andrea|La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.": {
"file": "andrea-0268742faf.mp3",
"sec": 5.45
},
"andrea|Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.": {
"file": "andrea-015dc16c6d.mp3",
"sec": 5.51
},
"andrea|L'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.": {
"file": "andrea-2d04be65fb.mp3",
"sec": 5.97
},
"ing_tozzi|Una partita ai birilli? Si va a venti.": {
"file": "ing_tozzi-7aef4a5420.mp3",
"sec": 3.3
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
},
"ing_tozzi|Si parla col panno, qui. Prendi la stecca.": {
"file": "ing_tozzi-4814e97759.mp3",
"sec": 3.36
},
"andrea|Una biglia in tasca? Mi sbattono fuori dal circolo.": {
"file": "andrea-98b3e4a969.mp3",
"sec": 3.49
},
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
"file": "andrea-1eae1a8577.mp3",
"sec": 6.1
},
"andrea|La stecca me la dà l'Ingegnere, quando si gioca.": {
"file": "andrea-e393f0bc2a.mp3",
"sec": 2.83
},
"andrea|Un campione di biliardo in posa, con la stecca. Avrà il suo nome, ma da qui non si legge.": {
"file": "andrea-4a03e51d2c.mp3",
"sec": 7.65
},
"andrea|L'armadietto a vetri, con le stecche buone. Chiuso a chiave, naturalmente.": {
"file": "andrea-b1ba7d3072.mp3",
"sec": 5.06
},
"andrea|Chiuso. Le stecche buone sono per chi le sa usare.": {
"file": "andrea-bd95e65b0a.mp3",
"sec": 4.77
},
"andrea|Il tabernacolo del ponte, con l'Annunciazione di Antonio Berti, scultore sanpierino.": {
"file": "andrea-9c0935dc77.mp3",
"sec": 5.48
},
"andrea|Questa è una copia: l'originale lo fece nel 1947. Lo stesso Berti del parco dell'area feste.": {
"file": "andrea-902c4db19c.mp3",
"sec": 8.21
},
"andrea|Chi passa per andare via dal paese, un saluto glielo fa sempre.": {
"file": "andrea-6eeaab479b.mp3",
"sec": 4.03
},
"andrea|Sotto il ponte la Carza si butta nella Sieve, e da qui vanno avanti insieme.": {
"file": "andrea-f322731846.mp3",
@@ -655,6 +739,42 @@
"file": "andrea-e47b3cebb5.mp3",
"sec": 4.87
},
"andrea|Voglio giocare a padel!": {
"file": "andrea-808f67b99f.mp3",
"sec": 1.4
},
"andrea|Toh, guarda: c'è una macchina del tempo.": {
"file": "andrea-1f686a5d7c.mp3",
"sec": 3.2
},
"andrea|Per arrivare al padel mi manca solo un po' di plutonio. Potrei sentire da Alvarino se ce l'ha.": {
"file": "andrea-299c0c38d6.mp3",
"sec": 6.03
},
"andrea|Il padel aspetta. Prima il plutonio: devo sentire Alvarino.": {
"file": "andrea-9f70e8b68e.mp3",
"sec": 5.03
},
"andrea|Acciaio, portiere ad ali di gabbiano, cavi e bobine dappertutto. E un contenitore vuoto, sul retro.": {
"file": "andrea-122877af14.mp3",
"sec": 7.99
},
"andrea|Vuoto di plutonio, si capisce.": {
"file": "andrea-1cb9f950ca.mp3",
"sec": 2.41
},
"andrea|Le portiere sono già su. È il plutonio che manca.": {
"file": "andrea-c1db806968.mp3",
"sec": 4.01
},
"andrea|Senza plutonio non parte. Alvarino, dove sei?": {
"file": "andrea-8f9dcb0bb1.mp3",
"sec": 3.71
},
"andrea|Una macchina in tasca? Neanche nel futuro.": {
"file": "andrea-cac0c82f01.mp3",
"sec": 3.06
},
"barondeduke|Me la giochi? Ho un sette e un due, ma un sacco di fiducia.": {
"file": "barondeduke-52184c7c05.mp3",
"sec": 5.58
@@ -883,9 +1003,9 @@
"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",
@@ -931,6 +1051,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
@@ -1083,9 +1207,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",
@@ -1615,6 +1739,10 @@
"file": "andrea-06f5050907.mp3",
"sec": 1.24
},
"andrea|S'ha andà a fallo!?!?": {
"file": "andrea-b25aeefc97.mp3",
"sec": 1.16
},
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
"file": "andrea-fca6f59a70.mp3",
"sec": 3.41
@@ -1659,6 +1787,42 @@
"file": "arcu-5148dffe36.mp3",
"sec": 1.7
},
"arcu|Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!": {
"file": "arcu-406cd02197.mp3",
"sec": 4.46
},
"andrea|Mariano! Facciamo una partita a bocce?": {
"file": "andrea-83b1337b4d.mp3",
"sec": 3.31
},
"andrea|La rivincita?": {
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
"andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3",
"sec": 1.29
},
"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
@@ -1667,6 +1831,14 @@
"file": "andrea-01d1ccb933.mp3",
"sec": 2.09
},
"andrea|Lo sai che quassù ci vengo sempre a piedi?": {
"file": "andrea-fc4233b1e9.mp3",
"sec": 2.82
},
"andrea|E te, da grande, cosa volevi fare?": {
"file": "andrea-3aacdf79ee.mp3",
"sec": 2.8
},
"andrea|Niente, volevo solo un po' di pace.": {
"file": "andrea-131481b503.mp3",
"sec": 2.79
@@ -1731,10 +1903,74 @@
"file": "castello-a1dfd76497.mp3",
"sec": 6.51
},
"castello|Lo so. Ti sento arrivare dal fiatone, un chilometro prima.": {
"file": "castello-05405f3232.mp3",
"sec": 5.63
},
"andrea|Da casa sono quattro chilometri. E due sono di salita, di quella bella.": {
"file": "andrea-57327d9598.mp3",
"sec": 6.46
},
"castello|Eppure torni sempre. Qualcosa, quassù, l'hai trovato.": {
"file": "castello-1545f0b17d.mp3",
"sec": 6.56
},
"andrea|Ho trovato la mia strada!": {
"file": "andrea-e9ef2a6d39.mp3",
"sec": 1.59
},
"andrea|...Io, che ancora non so cosa fare da grande.": {
"file": "andrea-60611ea4b9.mp3",
"sec": 3.54
},
"castello|Il castello.": {
"file": "castello-e34c381c3c.mp3",
"sec": 0.98
},
"castello|Mi è venuto bene. Non avere fretta: a me ci son voluti seicento anni.": {
"file": "castello-dcd5a7ae63.mp3",
"sec": 6.24
},
"castello|Quella qui non manca mai. Torna quando vuoi.": {
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
"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
},
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
"file": "ing_tozzi-51f855478a.mp3",
"sec": 4.42
},
"ing_tozzi|Venti! Al biliardo ci vuole la mano ferma, mica la corsa.": {
"file": "ing_tozzi-d557d58aec.mp3",
"sec": 4.66
},
"ing_tozzi|Quando vuoi, il tavolo è qui.": {
"file": "ing_tozzi-3d686c8cc5.mp3",
"sec": 2.05
},
"ing_tozzi|Lasci a metà? Ti segno la sconfitta a tavolino, eh.": {
"file": "ing_tozzi-282062c119.mp3",
"sec": 3.64
},
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -105,3 +105,18 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
- *Regia alla fessura*: col meccanismo del primo piano (`closeup`) il buio, la torcia, un istante di silenzio,
poi gli occhi. Due immagini di Codex.
- *Segreti facoltativi del paese* con pagine illustrate nel taccuino: funzione nuova, dopo la storia principale.
- **Altri minigiochi come le bocce** (25 ottobre 2026): ognuno è un primo piano (`closeup` con il suo `type`, come
`bocce`), con comandi per dito, mouse e tastiera, i suoi test e una battuta per chi vince e per chi perde.
Il posto naturale sono le gare del Palio del secondo capitolo:
- *Passamano dei mattoni* (da fare per primo): un gioco di ritmo, i mattoni arrivano lungo la fila e si passano al
momento giusto; sbagliando il tempo ne cade uno. Facile col dito, e chiude il cerchio col finale (i mattoni
portati in Fortezza).
- *Le carrette*: corsa in discesa vista di lato, a destra e sinistra fra buche e balle di paglia. La più
spettacolare, la più lunga da disegnare.
- *Bandierine (lavanderine)*: correre a prendere la bandiera prima dell'avversario, di riflessi.
- *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo.
Fuori dal Palio:
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta.
- *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere. Andrea ha ritrovato la foto di squadra
sul campo in terra (porta, rete, la torre della Pieve dietro, anni '90): lo sfondo del minigioco lo fa Codex da quella,
senza le persone (per i volti serve il consenso). Da salvare in `assets/raw/foto/campo-bettini-squadra.jpg`.
+27
View File
@@ -103,6 +103,33 @@
<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>
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
<p>Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita.
Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare.
Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.</p>
<p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti,
rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima
il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p>
</details>
</section>
</div>
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.5",
"version": "0.1.8",
"private": true,
"type": "module",
"scripts": {
+115
View File
@@ -0,0 +1,115 @@
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono.
const rr = R * VIEW.k * 1.3;
function sphere(ctx, role, x, y, r) {
const [dark, mid, light] = BALL[role];
ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05);
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1);
}
function pin(ctx, p) {
const x = sx(p.x), y = sy(p.y), h = 7, w = 3;
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4'];
if (p.down) {
// Birillo a terra, dalla parte in cui è caduto.
ctx.strokeStyle = top; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke();
ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2);
return;
}
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2);
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1);
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3);
}
function table(ctx) {
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k;
// Cornice di legno con i diamanti, sponda, panno.
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32);
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26);
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3);
ctx.fillStyle = '#e7d7a8';
for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); }
for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); }
ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10);
ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h);
for (let i = 0; i < 420; i++) {
ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f';
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1);
}
// La linea di metà tavolo e il punto del castello.
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h);
}
function aimGuide(ctx, c) {
const p = predict(c), me = c.balls[c.current];
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
ctx.stroke(); ctx.setLineDash([]);
if (p.ghost) {
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
if (p.dir) {
const o = c.balls[p.hit], L = 22;
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)';
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke();
}
}
// La stecca dietro la biglia, tirata indietro quanto la forza.
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back;
ctx.lineCap = 'round';
ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke();
ctx.lineCap = 'butt';
}
export function drawBiliardo(ctx, c, def) {
ctx.save();
// La sala, in penombra, e il lume sopra il tavolo.
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360);
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420);
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)');
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360);
table(ctx);
for (const p of c.pins) pin(ctx, p);
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c);
for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
// Punteggio: i due nomi, i punti, chi deve tirare.
panel(ctx, 18, 8, 604, 62);
for (const [i, who] of ['player', 'cpu'].entries()) {
const x = 30 + i * 300, y = 24;
sphere(ctx, who, x + 4, y, 4);
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]);
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2');
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3');
}
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : '';
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
if (canThrow(c)) {
panel(ctx, 18, 318, 176, 34);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5);
}
if (c.step === 'result') {
panel(ctx, 170, 140, 300, 80);
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center');
text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center');
}
ctx.restore();
}
+259
View File
@@ -0,0 +1,259 @@
// Biliardo all'italiana, cinque birilli, visto dall'alto. Regole da circolo, semplificate:
// si colpisce la propria biglia e la si manda sulla biglia avversaria; i birilli abbattuti dalla biglia avversaria
// o dal pallino fanno punti a chi tira (bianco 2, rosso 4, il rosso da solo 10), il pallino toccato dalla biglia
// avversaria ne vale 3. Fallo, con i punti all'avversario (almeno 2): mancare la biglia avversaria, toccare prima
// il pallino, abbattere birilli con la propria biglia. Un colpo a testa; vince chi arriva prima al traguardo.
// Unità: centimetri. x lungo il tavolo (0..LEN), y di traverso (0..WID).
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
export const SWING = 1.4, FIXED_STEP = 1 / 240;
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
export const OTHER = { player: 'cpu', cpu: 'player' };
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
export const VIEW = { x: 57, y: 78, k: 1.85 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const deviation = c => random(c) + random(c) + random(c) - 1.5;
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino];
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
export function castle() {
const cx = LEN / 2, cy = WID / 2;
return [[0, 0, true], [-GAP, 0], [GAP, 0], [0, -GAP], [0, GAP]].map(([dx, dy, red]) => ({ x: cx + dx, y: cy + dy, red: !!red, down: false }));
}
export const powerAt = time => clamp(time / SWING, 0, 1);
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
export function reach(v) {
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
return (v + k) / DRAG * (1 - Math.exp(-DRAG * t)) - k * t;
}
export const canThrow = c => c.step === 'aim' && c.current === 'player';
export const moving = c => list(c).some(b => b.vx || b.vy);
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
c.pins = castle();
c.angle = angleTo(c.balls.player, c.balls.cpu);
return c;
}
// Valore dei birilli abbattuti in un colpo.
export function value(pins) {
if (pins.length === 1 && pins[0].red) return 10;
return pins.reduce((s, p) => s + (p.red ? 4 : 2), 0);
}
// Chi prende i punti di un colpo, e quanti.
export function judge(shot) {
const shooter = shot.shooter, other = OTHER[shooter];
const own = shot.pins.filter(p => p.by === shooter);
const foul = shot.first === null ? 'miss' : shot.first === 'pallino' ? 'pallino' : own.length ? 'own' : null;
if (foul) return { shooter, to: other, points: Math.max(2, value(shot.pins)), foul };
return { shooter, to: shooter, points: value(shot.pins) + (shot.pallino ? 3 : 0), foul: null };
}
function cushions(b) {
let impact = 0;
for (const [p, v, hi] of [['x', 'vx', LEN - R], ['y', 'vy', WID - R]]) {
if (b[p] < R || b[p] > hi) {
const out = b[p] < R ? b[v] < 0 : b[v] > 0;
b[p] = clamp(b[p], R, hi);
if (out) { impact = Math.max(impact, Math.abs(b[v])); b[v] *= -CUSHION; }
}
}
return impact;
}
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
export function collide(a, b) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 2 * R) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2;
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (closing <= 0) return 0;
const j = closing * 0.975;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
return closing;
}
function touch(shot, a, b) {
if (!shot) return;
const roles = [a.role, b.role], other = OTHER[shot.shooter];
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
}
function step(w, dt) {
let hit = 0, rail = 0;
for (const b of w.balls) {
const v = Math.hypot(b.vx, b.vy);
if (v < EPS) { b.vx = b.vy = 0; continue; }
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt;
b.x += b.vx / v * travel; b.y += b.vy / v * travel;
b.vx *= nv / v; b.vy *= nv / v;
rail = Math.max(rail, cushions(b));
for (const p of w.pins) {
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue;
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio.
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) });
b.vx *= 0.88; b.vy *= 0.88;
w.shot?.pins.push(p);
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
}
}
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) {
const s = collide(w.balls[i], w.balls[j]);
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
}
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
}
function strike(c, who, angle, power) {
const b = c.balls[who], v = speedFor(power);
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
c.events.push({ kind: 'biliardo-cue', power });
return true;
}
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
export function simulate(c, who, angle, power) {
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
const w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } };
const b = balls.find(x => x.role === who), v = speedFor(power);
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
for (let i = 0; i < 15 / FIXED_STEP && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
return judge(w.shot);
}
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
function reset(c) {
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
for (let pass = 0; pass < 4; pass++) {
for (const b of list(c)) for (const p of c.pins) {
const d = Math.hypot(b.x - p.x, b.y - p.y), need = R + PIN + 0.3;
if (d >= need) continue;
const a = d > 1e-6 ? Math.atan2(b.y - p.y, b.x - p.x) : -Math.PI / 2;
b.x = p.x + Math.cos(a) * need; b.y = p.y + Math.sin(a) * need;
}
const all = list(c);
for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) collide(all[i], all[j]);
all.forEach(cushions);
}
}
function finish(c) {
const j = judge(c.shot);
c.points[j.to] += j.points;
c.last = { ...j, age: 0 };
c.shot = null;
reset(c);
c.t = 0;
if (c.points[j.to] >= c.target) { c.step = 'result'; c.result = { winner: j.to, points: { ...c.points } }; return; }
c.current = OTHER[j.shooter];
c.step = 'aim';
c.angle = angleTo(c.balls[c.current], c.balls[OTHER[c.current]]);
}
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
function cpuCandidates(c) {
const base = angleTo(c.balls.cpu, c.balls.player), angles = [];
for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2);
for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d);
return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power })));
}
function cpuThink(c, budget = 12) {
const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
for (let i = 0; i < budget && t.todo.length; i++) {
const { angle, power } = t.todo.pop(), j = simulate(c, 'cpu', angle, power), gain = j.to === 'cpu' ? j.points : -j.points;
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
}
if (t.todo.length) return;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) };
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt;
if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling') {
c.accumulator += dt;
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) finish(c);
else if (c.step === 'aim' && c.current === 'cpu') {
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
}
}
// --- Comandi del giocatore: gli stessi delle bocce.
export function aimAt(c, x, y) {
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const b = c.balls.player;
if (Math.hypot(x - b.x, y - b.y) < R) return false;
c.angle = Math.atan2(y - b.y, x - b.x); return true;
}
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); }
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; }
export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true;
}
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
export function press(c) {
if (!canThrow(c) || c.holding) return false;
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
}
export function cancel(c) {
if (!c.holding) return false;
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
}
export function release(c) {
if (!c.holding) return false;
if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
c.holding = false; return strike(c, 'player', c.angle, c.power);
}
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza),
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita.
export function predict(c) {
const me = c.balls[c.current], others = list(c).filter(b => b !== me);
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power));
const points = [[x, y]], STEP = 0.5;
for (let n = 0; left > 0 && n < 6000; n++) {
x += dx * STEP; y += dy * STEP; left -= STEP;
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); }
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); }
for (const o of others) {
const d = Math.hypot(o.x - x, o.y - y);
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; }
}
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; }
}
points.push([x, y]);
return { points, ghost: null, hit: null, dir: null };
}
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
export function lastLine(c, names) {
const l = c.last;
if (!l) return '';
if (l.foul) return `Fallo di ${names[l.shooter]} (${FOULS[l.foul]}): ${l.points} a ${names[l.to]}.`;
return l.points ? `${names[l.to]} fa ${l.points} punti!` : `${names[l.shooter]}: nessun punto.`;
}
export function status(c, names) {
if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`;
if (c.step === 'rolling') return 'Biglie in movimento…';
if (c.step === 'settle') return 'Si contano i birilli…';
const last = lastLine(c, names);
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
}
+130
View File
@@ -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'] };
export function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
}
export function panel(ctx, x, y, w, h) {
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
}
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();
}
+190
View File
@@ -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.'}`;
}
+33 -3
View File
@@ -1,6 +1,14 @@
// 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 * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi.
export const MINIGAMES = {
bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
};
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
@@ -229,7 +237,7 @@ export class Game {
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
if (this.story.closeups[c.id].type === 'torch') return false;
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
else return false;
@@ -247,15 +255,28 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete);
return true;
}
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
return true;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
return true;
}
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); }
closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); }
// 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;
const m = this.minigame();
if (m) return m.aim(c, [x, y]);
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
@@ -378,6 +399,12 @@ export class Game {
const c = this.closeup;
if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
const m = this.minigame();
if (m) {
m.update(c, dt);
// Il risultato resta leggibile finché il giocatore non esce.
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 ?? [])];
@@ -409,9 +436,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 = this.story.closeups[e.closeup].type === 'torch'
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
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] }
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
}
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
}
+16 -6
View File
@@ -19,6 +19,12 @@ export function trackFor(story, { screen, scene, phase }) {
const FADE = 1.2;
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta.
function setLevel(a) {
a.volume = a.level;
a.muted = a.level === 0;
}
export class Music {
constructor({ muted = false, volume = 0.6 } = {}) {
this.muted = muted;
@@ -46,22 +52,26 @@ export class Music {
if (this.current) this.old.push(this.current.audio);
this.current = src ? { src, audio: this.start(src) } : null;
}
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero.
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano.
// Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e
// resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese).
const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck;
if (this.current) {
const a = this.current.audio;
a.volume = a.volume < target ? Math.min(target, a.volume + step) : Math.max(target, a.volume - step);
if (this.hold && a.volume === 0 && !a.paused) a.pause();
a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step);
setLevel(a);
if (this.hold && a.level === 0 && !a.paused) a.pause();
else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; });
}
for (const a of this.old) a.volume = Math.max(0, a.volume - step);
this.old = this.old.filter(a => a.volume > 0 || (a.pause(), false));
for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); }
this.old = this.old.filter(a => a.level > 0 || (a.pause(), false));
}
start(src) {
const audio = new Audio(versioned(src));
audio.loop = true;
audio.volume = 0;
audio.level = 0;
setLevel(audio);
audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto
return audio;
}
+8
View File
@@ -3,6 +3,8 @@
import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -669,6 +671,12 @@ function drawCloseup(ctx, game, time) {
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;
}
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); 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);
+8 -1
View File
@@ -98,9 +98,16 @@ export class Soundscape {
source.start(at); source.stop(at + duration + 0.03);
}
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
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 === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); }
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
+55 -4
View File
@@ -8,6 +8,8 @@ import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id);
@@ -265,6 +267,7 @@ $('spots-button').addEventListener('click', showSpots);
function openPause() {
if (!running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
@@ -398,6 +401,8 @@ canvas.addEventListener('pointerdown', e => {
if (game.closeup) {
press = { closeup: true };
game.closeupAim(scenePoint(e));
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
canvas.setPointerCapture(e.pointerId);
return;
}
@@ -406,7 +411,7 @@ canvas.addEventListener('pointerdown', e => {
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
if (press.closeup) { press = null; if (running && !game.skip()) game.closeupTap(); 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à.
if (!press.long) {
@@ -417,7 +422,8 @@ canvas.addEventListener('pointerup', e => {
}
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -429,6 +435,20 @@ canvas.addEventListener('pointermove', e => {
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi dei minigiochi (bocce e biliardo) separati dai verbi dell'avventura: mira a passi, forza regolabile e
// lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata.
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: tocca il tavolo per mirare. Regola la forza e premi Tira.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower(c, Number(e.target.value) / 100); });
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot(c); });
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]])
$(id).addEventListener('click', () => { const c = miniState(); if (c) mini().nudge(c, dir); });
addEventListener('blur', () => game?.closeupCancel());
// --- Verbi.
for (const verb of VERBS) {
const b = document.createElement('button');
@@ -514,6 +534,17 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1);
else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return;
}
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
}
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
e.preventDefault();
@@ -529,6 +560,7 @@ window.addEventListener('keydown', e => {
return;
}
if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter'].includes(e.key) && miniState()) { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute();
return;
@@ -546,6 +578,9 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
});
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); });
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, reveal = null;
@@ -584,16 +619,32 @@ function updateUi() {
updateInventory();
updateChoices();
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
$('bocce-controls').hidden = !mg;
$('bocce-controls').inert = !running;
if (mg) {
$('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
$('bocce-power').value = Math.round(c.power * 100);
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !mg.canThrow(c) || c.holding || !!game.speech;
$('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira';
$('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo';
for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
}
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : 'Chiudi';
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
canvas.classList.toggle('bocce', !!mg);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page');
+195 -8
View File
@@ -107,6 +107,38 @@ export const story = {
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: {
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: {
type: 'bocce',
picture: 'assets/closeups/pallaio.png',
names: { player: 'Andrea', cpu: 'Mariano' },
win: [
{ say: 'Aspetta, ci vuole il metro...', who: 'mariano', pose: 'measure' },
{ say: 'Hai vinto, ti ci porto!', who: 'mariano', pose: 'laugh' },
{ set: 'bocce_vinte' },
],
lose: [
{ say: 'Bocciato! Al pallaio comando io.', who: 'mariano', pose: 'cheer' },
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
],
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }],
},
// 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',
names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' },
win: [
{ say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' },
{ set: 'biliardo_vinto' },
],
lose: [
{ say: 'Venti! Al biliardo ci vuole la mano ferma, mica la corsa.', who: 'ing_tozzi' },
{ say: 'Quando vuoi, il tavolo è qui.', who: 'ing_tozzi' },
],
quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }],
draw: [],
},
fessura: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
@@ -153,8 +185,14 @@ export const story = {
san_pietro: { name: 'San Pietro', color: '#e8e0c8' },
tosca: { name: 'La Tosca', color: '#ffb0d0' },
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
// L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt).
ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a' },
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' },
},
items: {
squama: {
@@ -324,6 +362,17 @@ export const story = {
look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }],
},
},
{
// Il Circolo 4 Maggio: il portone ad arco del palazzo arancione porta nella sala dei biliardi.
id: 'circolo', name: 'Circolo 4 Maggio', box: [188, 190, 28, 58], at: [202, 252], face: 'up',
actions: {
look: [{ say: 'Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.' }],
// «Apri» sul portone fa entrare come il clic: nessuno apre una porta per restare fuori.
open: [{ walk: [202, 252] }, { go: 'circolo_4_maggio', at: [590, 202], face: 'left' }],
talk: [{ say: 'Da fuori non si parla con nessuno: al circolo si entra.' }],
},
exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' },
},
{
id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up',
art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }],
@@ -484,13 +533,23 @@ export const story = {
map: [56, 45],
background: 'assets/scenes/pieve-dalla-salita.png',
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
speakers: { san_pietro: [300, 100] },
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] },
// Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre).
people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
depth: [[215, 0.42], [360, 1.15]],
entry: [30, 320],
hotspots: [
{
// Battute provvisorie: le vere le scrive Andrea.
id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }],
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
},
},
{
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
actions: {
@@ -571,6 +630,7 @@ export const story = {
name: 'Dentro la Pieve',
map: [56, 39],
background: 'assets/scenes/pieve-interno.png',
music: 'assets/audio/pieve.mp3', // l'organo soave della chiesa (tools/organo.py, 25 ottobre)
effects: [{ type: 'beam', phase: ['pomeriggio', 'tramonto'], poly: [[22, 122], [32, 118], [250, 360], [150, 360]] }, { type: 'dust', phase: ['pomeriggio', 'tramonto'], area: [60, 200, 90, 110], count: 9 }],
// Penombra, e le sei candele dell'altare accese (Andrea, 24 ottobre).
indoor: true,
@@ -990,6 +1050,57 @@ export const story = {
},
],
},
// La sala dei biliardi del Circolo 4 Maggio (dalla foto di Andrea, 25 ottobre): tre tavoli, il primo è quello
// dove si gioca ai birilli con l'Ing. Tozzi. [DA ANDREA: cosa c'è da ricordare della sala]
circolo_4_maggio: {
name: 'Circolo 4 Maggio, sala dei biliardi',
map: [29, 48],
background: 'assets/scenes/circolo-4-maggio.png',
indoor: true,
walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]],
depth: [[190, 0.95], [360, 1.95]],
entry: [590, 202],
speakers: { ing_tozzi: [420, 150] },
enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }],
hotspots: [
{
id: 'biliardo', name: 'biliardo', box: [0, 120, 358, 230], at: [380, 300], face: 'left', default: 'use',
actions: {
look: [{ say: 'Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.' }, { say: 'L\'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.' }],
use: [
{ say: 'Una partita ai birilli? Si va a venti.', who: 'ing_tozzi' },
{ say: 'Gnamo.' },
{ closeup: 'biliardo' },
],
talk: [{ say: 'Si parla col panno, qui. Prendi la stecca.', who: 'ing_tozzi' }],
take: [{ say: 'Una biglia in tasca? Mi sbattono fuori dal circolo.' }],
},
},
{
id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up',
actions: {
look: [{ say: 'Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.' }],
take: [{ say: 'La stecca me la dà l\'Ingegnere, quando si gioca.' }],
},
},
{
id: 'quadro', name: 'quadro alla parete', box: [300, 30, 36, 55], at: [352, 320], face: 'up',
actions: {
look: [{ say: 'Un campione di biliardo in posa, con la stecca. Avrà il suo nome, ma da qui non si legge.' }],
},
},
{
id: 'armadietto', name: 'armadietto delle stecche', box: [480, 58, 34, 48], at: [496, 242], face: 'up',
actions: {
look: [{ say: 'L\'armadietto a vetri, con le stecche buone. Chiuso a chiave, naturalmente.' }],
open: [{ say: 'Chiuso. Le stecche buone sono per chi le sa usare.' }],
},
},
{
id: 'alla_piazza', name: 'la porta, verso la piazza', box: [550, 30, 80, 160], at: [590, 202], exit: { scene: 'piazza', at: [202, 252], face: 'down' },
},
],
},
// Il ponte sulla Sieve, proseguendo la Provinciale dalla farmacia; di là dal ponte, la polisportiva.
ponte_sieve: {
name: 'Ponte sulla Sieve',
@@ -1000,9 +1111,14 @@ export const story = {
entry: [300, 350],
hotspots: [
{
id: 'tabernacolo', name: 'tabernacolo', box: [138, 148, 36, 60], at: [180, 236], face: 'up',
id: 'tabernacolo', name: 'tabernacolo dell\'Annunciazione', box: [138, 148, 36, 60], at: [180, 236], face: 'up',
actions: {
look: [{ say: 'Il tabernacolo prima del ponte. Chi passa per andare via dal paese, un saluto glielo fa sempre.' }],
// Notizie di Andrea: una copia dell'Annunciazione di Antonio Berti (1904–1990), l'originale è del 1947.
look: [
{ say: 'Il tabernacolo del ponte, con l\'Annunciazione di Antonio Berti, scultore sanpierino.' },
{ say: 'Questa è una copia: l\'originale lo fece nel 1947. Lo stesso Berti del parco dell\'area feste.' },
{ say: 'Chi passa per andare via dal paese, un saluto glielo fa sempre.' },
],
},
},
{
@@ -1028,8 +1144,12 @@ export const story = {
depth: [[210, 0.4], [360, 1.15]],
entry: [100, 340],
// Il BaronDeDuke nella discesa verso la polisportiva: aspetta chi gli riporta le mutande perse a poker.
speakers: { barondeduke: [230, 243] },
people: [{ who: 'barondeduke', at: [230, 300], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.6 }],
speakers: { barondeduke: [230, 243], macchina: [360, 212] },
people: [
{ who: 'barondeduke', at: [230, 300], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.6 },
// Compare solo dopo che Andrea dice di voler giocare a padel (idea di Andrea, 25 ottobre; sprite di Codex).
{ who: 'macchina', at: [360, 262], size: 1.05, idle: 'stand', when: 'macchina_vista' },
],
hotspots: [
{
id: 'campo', name: 'campo secondario', box: [140, 160, 240, 60], at: [330, 254], face: 'up',
@@ -1055,6 +1175,25 @@ export const story = {
actions: {
look: [{ say: 'Il capannone verde del padel.' }, { say: 'Il padel, nel 2007, non so nemmeno cosa sia. Tra qualche anno ci giocheranno tutti, me compreso.' }],
open: [{ say: 'Chiuso. E poi non saprei nemmeno da che parte si tiene la racchetta.' }],
use: [{
if: '!macchina_vista',
then: [
{ say: 'Voglio giocare a padel!' },
{ set: 'macchina_vista' },
{ say: 'Toh, guarda: c\'è una macchina del tempo.' },
{ say: 'Per arrivare al padel mi manca solo un po\' di plutonio. Potrei sentire da Alvarino se ce l\'ha.' },
],
else: [{ say: 'Il padel aspetta. Prima il plutonio: devo sentire Alvarino.' }],
}],
},
},
{
id: 'macchina', name: 'macchina del tempo', box: [311, 219, 98, 43], at: [290, 290], face: 'right', when: 'macchina_vista',
actions: {
look: [{ say: 'Acciaio, portiere ad ali di gabbiano, cavi e bobine dappertutto. E un contenitore vuoto, sul retro.' }, { say: 'Vuoto di plutonio, si capisce.' }],
open: [{ say: 'Le portiere sono già su. È il plutonio che manca.' }],
use: [{ say: 'Senza plutonio non parte. Alvarino, dove sei?' }],
take: [{ say: 'Una macchina in tasca? Neanche nel futuro.' }],
},
},
{
@@ -1423,7 +1562,9 @@ export const story = {
name: 'Il tennis della chiesa',
map: [66, 51],
background: 'assets/scenes/tennis-chiesa.png',
speakers: { tosca: [292, 196] },
speakers: { tosca: [292, 196], mariano: [500, 234] },
// Mariano, il campione del pallaio (sprite di Codex dalla foto di Andrea, 25 ottobre).
people: [{ who: 'mariano', at: [500, 290], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
walk: [[0, 360], [0, 300], [200, 290], [420, 268], [640, 258], [640, 360]],
depth: [[258, 0.7], [360, 1.15]],
entry: [320, 350],
@@ -1432,7 +1573,7 @@ export const story = {
id: 'pallaio', name: 'il pallaio', box: [300, 248, 340, 40], at: [400, 300], face: 'up',
actions: {
look: [{ say: 'Il pallaio, dove si gioca a bocce. Qui un punto si discute più di un rigore.' }],
use: [{ say: 'Senza bocce, e senza un vecchietto che misura col metro, non è una partita.' }],
use: [{ say: 'Una partita? Chiedo a Mariano, il campione.' }, { dialogue: 'mariano' }],
},
},
{
@@ -1463,6 +1604,13 @@ export const story = {
{
id: 'al_cancello', name: 'dal cancello verde, verso il posticino della Carza', box: [470, 140, 120, 130], at: [515, 280], exit: { scene: 'carza', at: [440, 290], face: 'left' },
},
{
id: 'mariano', name: 'Mariano', box: [482, 238, 36, 52], at: [455, 300], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.' }],
talk: [{ dialogue: 'mariano' }],
},
},
],
},
// Fra la Fortezza e i cipressi: la stradina fra le vigne, la casa del maestro Bacci, e lassù il Trebbio.
@@ -1690,7 +1838,7 @@ export const story = {
{ say: 'Tozzino! Che fai lì davanti al cancello?' },
{ say: 'Aspetto Federer. Oggi si fa una partita.', who: 'tozzino', pose: 'explain' },
{ say: 'Federer? Roger Federer?' },
{ say: 'E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.', who: 'tozzino', pose: 'watch' },
{ say: 'E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.', who: 'tozzino', pose: 'watch' },
{ say: 'Se non arriva, la vinco a tavolino.', who: 'tozzino', pose: 'look_far' },
],
},
@@ -2089,11 +2237,32 @@ export const story = {
{ say: '...I pompieri per cosa?', who: 'arcu', pose: 'shrug' },
],
},
{ text: 'S\'ha andà a fallo!?!?', then: [{ say: 'Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!', who: 'arcu', pose: 'laugh' }] },
{ text: 'Ci si becca da i\' Trappola, ai\' Barrettino!', next: 'end' },
],
},
},
},
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
mariano: {
nodes: {
start: {
options: [
{
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'end',
then: [
{ say: 'Con me? Al pallaio sono il campione.', who: 'mariano', pose: 'explain' },
{ say: 'Facciamo così: se mi batti, ti porto a Brento Sanico.', who: 'mariano' },
{ closeup: 'bocce' },
],
},
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'end', then: [{ say: 'Il campione non scappa. Tira!', who: 'mariano', pose: 'throw' }, { closeup: 'bocce' }] },
{ text: 'Allora, Brento Sanico?', when: 'bocce_vinte', then: [{ say: 'Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.', who: 'mariano', pose: 'explain' }] },
{ text: 'Ciao Mariano.', next: 'end' },
],
},
},
},
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
castello: {
nodes: {
@@ -2122,6 +2291,24 @@ export const story = {
text: 'Ma tu ce l\'hai mai visto, il Regolo?',
then: [{ say: 'Io ho visto passare i Medici, la peste e Vespucci. Un serpente in più non mi spaventa.', who: 'castello' }],
},
// Andrea al Trebbio ci sale sempre a piedi: quattro chilometri da casa, due di salita (ricordo suo).
{
text: 'Lo sai che quassù ci vengo sempre a piedi?',
then: [
{ say: 'Lo so. Ti sento arrivare dal fiatone, un chilometro prima.', who: 'castello' },
{ say: 'Da casa sono quattro chilometri. E due sono di salita, di quella bella.' },
{ say: 'Eppure torni sempre. Qualcosa, quassù, l\'hai trovato.', who: 'castello' },
{ say: 'Ho trovato la mia strada!' },
{ say: '...Io, che ancora non so cosa fare da grande.' },
],
},
{
text: 'E te, da grande, cosa volevi fare?',
then: [
{ say: 'Il castello.', who: 'castello' },
{ say: 'Mi è venuto bene. Non avere fretta: a me ci son voluti seicento anni.', who: 'castello' },
],
},
{ text: 'Niente, volevo solo un po\' di pace.', next: 'end', then: [{ say: 'Quella qui non manca mai. Torna quando vuoi.', who: 'castello' }] },
],
},
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.5';
export const VERSION = '0.1.8';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+23
View File
@@ -25,6 +25,26 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary { cursor: pointer; padding: 6px 0; }
#bocce-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 { 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); }
@@ -231,6 +251,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#rotate { display: none; padding: 1em; text-align: center; color: var(--dim); }
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
la spiegazione e la versione non finiscono sopra i verbi. */
@media (orientation: portrait) and (max-width: 700px) { #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) {
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimScreen, canThrow, LEN, WID, R, VIEW, SWING, powerAt } from '../src/engine/biliardo.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); };
const still = c => { for (let i = 0; i < 3000 && !['aim', 'result'].includes(c.step); i++) update(c, 1 / 60); };
const pins = (...which) => castle().filter((_, i) => which.includes(i));
test('il castello: il rosso in mezzo al tavolo, quattro bianchi a croce', () => {
const p = castle();
assert.equal(p.length, 5);
assert.deepEqual(p.filter(x => x.red).map(x => [x.x, x.y]), [[LEN / 2, WID / 2]]);
assert.equal(p.filter(x => !x.red).length, 4);
});
test('il valore dei birilli: bianco 2, rosso 4, rosso da solo 10; e il fallo porta i punti all\'avversario', () => {
assert.equal(value(pins(1)), 2);
assert.equal(value(pins(0, 1)), 6);
assert.equal(value(pins(0)), 10);
assert.equal(value(pins(0, 1, 2, 3, 4)), 12);
const by = (list, who) => list.map(p => ({ ...p, by: who }));
assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: true, pins: by(pins(1, 2), 'cpu') }), { shooter: 'player', to: 'player', points: 7, foul: null });
assert.deepEqual(judge({ shooter: 'player', first: null, pallino: false, pins: [] }), { shooter: 'player', to: 'cpu', points: 2, foul: 'miss' });
assert.deepEqual(judge({ shooter: 'cpu', first: 'pallino', pallino: false, pins: [] }), { shooter: 'cpu', to: 'player', points: 2, foul: 'pallino' });
assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: false, pins: by(pins(0), 'player') }), { shooter: 'player', to: 'cpu', points: 10, foul: 'own' });
});
test('la forza: sale finché si tiene premuto; a metà la biglia fa circa un tavolo, a piena ne fa più di due', () => {
assert.equal(powerAt(0), 0);
assert.equal(powerAt(SWING * 2), 1);
assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`);
assert.ok(reach(speedFor(1)) > LEN * 2);
assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena');
});
test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => {
const c = newBiliardo(3);
const me = c.balls.player, other = c.balls.cpu, before = { ...other };
c.angle = Math.atan2(other.y - me.y, other.x - me.x);
assert.ok(setPower(c, 0.5) && shoot(c));
assert.equal(c.step, 'rolling');
still(c);
assert.ok(other.x !== before.x || other.y !== before.y, 'la biglia avversaria si è mossa');
assert.equal(c.current, 'cpu');
assert.equal(c.last.foul, null);
assert.ok(c.last.points >= 0);
});
test('mancare la biglia avversaria è fallo: due punti all\'altro', () => {
const c = newBiliardo(5);
c.balls.player = { role: 'player', x: 30, y: 20, vx: 0, vy: 0 };
c.balls.cpu = { role: 'cpu', x: 250, y: 120, vx: 0, vy: 0 };
c.balls.pallino = { role: 'pallino', x: 250, y: 30, vx: 0, vy: 0 };
c.angle = 0; // dritto lungo la sponda, lontano da tutto
assert.ok(setPower(c, 0.1) && shoot(c));
still(c);
assert.deepEqual(c.points, { player: 0, cpu: 2 });
assert.equal(c.last.foul, 'miss');
});
test('un birillo abbattuto dalla biglia avversaria vale punti; poi i birilli si rialzano', () => {
const c = newBiliardo(9);
// La mia biglia piazzata dietro a quella avversaria, in linea col castello.
c.balls.cpu = { role: 'cpu', x: LEN / 2 - 30, y: WID / 2, vx: 0, vy: 0 };
c.balls.player = { role: 'player', x: LEN / 2 - 60, y: WID / 2, vx: 0, vy: 0 };
c.balls.pallino = { role: 'pallino', x: 20, y: 20, vx: 0, vy: 0 };
c.angle = 0;
assert.ok(setPower(c, 0.5) && shoot(c));
for (let i = 0; i < 3000 && c.step === 'rolling'; i++) update(c, 1 / 60);
assert.ok(c.pins.some(p => p.down), 'qualche birillo è caduto');
assert.ok(c.pins.filter(p => p.down).every(p => p.by === 'cpu'));
still(c);
assert.equal(c.last.to, 'player');
assert.ok(c.points.player >= 2, `punti: ${c.points.player}`);
assert.ok(c.pins.every(p => !p.down), 'il castello è di nuovo in piedi');
});
test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita finisce a venti', () => {
const c = newBiliardo(42, { target: 6 });
c.current = 'cpu';
let frames = 0;
while (c.step === 'aim' && frames++ < 600) update(c, 1 / 60);
assert.equal(c.step, 'rolling', 'l\'avversario ha tirato');
assert.ok(frames > 20, 'non tira subito: ci pensa');
for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) {
still(c);
if (c.step === 'aim' && c.current === 'player') {
const me = c.balls.player, other = c.balls.cpu;
c.angle = Math.atan2(other.y - me.y, other.x - me.x);
press(c); run(c, 0.6); release(c);
}
}
assert.equal(c.step, 'result');
assert.ok(['player', 'cpu'].includes(c.result.winner));
assert.ok(c.points[c.result.winner] >= 6);
assert.equal(press(c), false, 'a partita finita non si tira più');
});
test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => {
const c = newBiliardo(1);
const me = c.balls.player;
assert.ok(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + (me.y + 40) * VIEW.k]));
assert.ok(Math.abs(c.angle - Math.PI / 2) < 1e-9, 'verso il basso');
assert.equal(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + me.y * VIEW.k]), false, 'sulla biglia stessa non si mira');
c.balls.cpu = { role: 'cpu', x: me.x + 50, y: me.y, vx: 0, vy: 0 };
c.angle = 0;
const p = predict(c);
assert.equal(p.hit, 'cpu');
assert.ok(Math.abs(p.ghost.x - (me.x + 50 - 2 * R)) < 1, 'la biglia fantasma tocca quella avversaria');
assert.ok(canThrow(c));
});
test('al Circolo 4 Maggio: dal portone in piazza si entra nella sala, e al tavolo si gioca; vincere segna la bandierina', () => {
const g = new Game(story);
settle(g);
g.go('piazza'); settle(g);
assert.ok(g.act('open', 'circolo'), '«Apri» sul portone fa entrare (Andrea, 25 ottobre: «non si apre la porta»)');
settle(g);
assert.equal(g.state.scene, 'circolo_4_maggio');
g.go('piazza'); settle(g);
assert.ok(g.act('walk', 'circolo'));
settle(g);
assert.equal(g.state.scene, 'circolo_4_maggio');
assert.ok(g.act('use', 'biliardo'));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Si va a venti')));
assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.act('look', 'stecche'), false, 'durante la partita la scena non risponde');
const c = g.closeup;
assert.ok(g.closeupAim([VIEW.x + c.balls.cpu.x * VIEW.k, VIEW.y + c.balls.cpu.y * VIEW.k]), 'si mira sulla biglia avversaria');
assert.ok(g.closeupPress());
for (let i = 0; i < 30; i++) g.update(1 / 60);
assert.ok(g.closeupRelease());
assert.equal(c.step, 'rolling');
c.step = 'result'; c.result = { winner: 'player', points: { player: 20, cpu: 4 } };
for (let i = 0; i < 10; i++) g.update(0.25);
assert.equal(g.closeup, c, 'il tavolo resta a schermo finché non si esce');
assert.ok(g.closeupClose());
const after = settle(g).map(s => `${s.who}: ${s.text}`);
assert.ok(after.some(t => t.startsWith('ing_tozzi: Venti. Hai vinto te')));
assert.ok(g.state.flags.biliardo_vinto);
// Lasciare a metà non segna niente.
g.act('use', 'biliardo'); settle(g);
assert.equal(g.closeup?.id, 'biliardo');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?')));
});
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBocce, press, release, update, powerAt, score, thrower, left, project, SWING, LANE, BALLS } from '../src/engine/bocce.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); };
// Tiene premuto finché la forza porta la boccia a `u`, poi lascia (nella prima salita della barra).
function throwTo(c, u) {
assert.ok(press(c));
const want = (u - 8) / (LANE - 4);
while (c.power < want && c.holdTime < SWING / 2) update(c, 1 / 240);
assert.ok(release(c));
}
test('la forza sale finché si tiene premuto, fino al massimo: niente tempismo da azzeccare', () => {
assert.equal(powerAt(0), 0);
assert.ok(Math.abs(powerAt(SWING / 2) - 0.5) < 1e-9);
assert.equal(powerAt(SWING), 1);
assert.equal(powerAt(SWING * 3), 1, 'oltre il massimo resta piena, non ricomincia');
});
test('una boccia va lontano quanto dice la forza, verso il pallino', () => {
const c = newBocce(7);
c.jack.u = 95; c.jack.w = 0; // il pallino lontano, così non lo tocca
throwTo(c, 50);
for (let i = 0; i < 600 && c.step === 'rolling'; i++) update(c, 1 / 60); // fino a boccia ferma, prima che tiri il campione
const b = c.balls[0];
assert.ok(Math.abs(b.u - 50) < 2.5, `si ferma a ${b.u.toFixed(1)}, non a 50`);
assert.ok(Math.abs(b.w) < 4, 'va più o meno dritta');
run(c, 1);
assert.equal(thrower(c), 'cpu', 'dopo la pausa, tira il campione');
});
test('una boccia che arriva addosso al pallino lo sposta', () => {
const c = newBocce(3);
c.jack.u = 40; c.jack.w = 0;
c.balls.push({ who: 'player', u: 30, w: 0, vu: 30, vw: 0, r: 1.25 });
c.step = 'rolling';
run(c, 3);
assert.ok(c.jack.u > 41, `il pallino resta a ${c.jack.u.toFixed(1)}`);
});
test('il punto: vince la boccia più vicina, e conta quelle più vicine della migliore avversaria', () => {
const c = newBocce(1);
c.jack.u = 60; c.jack.w = 0;
const put = (who, u) => c.balls.push({ who, u, w: 0, vu: 0, vw: 0, r: 1.25 });
put('player', 61); put('player', 63); put('player', 80);
put('cpu', 64); put('cpu', 70); put('cpu', 30);
assert.deepEqual(score(c), { winner: 'player', points: 2 });
c.balls[0].u = 75; c.balls[1].u = 76;
assert.equal(score(c).winner, 'cpu');
});
test('una mano intera: tre bocce a testa, a turno, poi il risultato', () => {
const c = newBocce(42);
for (let i = 0; i < 200 && c.step !== 'result'; i++) {
if (c.step === 'aim' && thrower(c) === 'player') throwTo(c, c.jack.u);
run(c, 0.5);
}
assert.equal(c.step, 'result');
assert.equal(left(c, 'player'), 0);
assert.equal(left(c, 'cpu'), 0);
assert.equal(c.balls.length, BALLS * 2);
assert.ok(['player', 'cpu'].includes(c.result.winner));
assert.equal(press(c), false, 'a mano finita non si tira più');
assert.ok(['player', 'cpu'].includes(c.result.winner) || c.result.winner === null);
});
test('la prospettiva: lontano è più in alto e più piccolo, al centro della pista', () => {
const near = project(0, 0), far = project(LANE, 0);
assert.ok(far.y < near.y && far.s < near.s);
assert.ok(project(50, 5).x > project(50, -5).x);
});
test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale la gloria e la promessa', () => {
const g = new Game(story);
settle(g);
g.go('tennis_chiesa'); settle(g);
assert.ok(g.act('talk', 'mariano'));
settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('Mariano! Facciamo')));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Brento Sanico')), 'la posta la dice prima');
assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.act('look', 'pallaio'), false, 'durante la partita la scena non risponde');
// Si gioca la mano (le bocce di Andrea mirano al pallino); a mano finita il risultato si forza a favore.
const c = g.closeup;
for (let i = 0; i < 400 && c.step !== 'result'; i++) {
if (c.step === 'aim' && thrower(c) === 'player') {
g.closeupPress();
while (c.power < (c.jack.u - 8) / (LANE - 4) && c.holdTime < SWING / 2) g.update(1 / 240);
g.closeupRelease();
}
g.update(0.25);
}
assert.equal(c.step, 'result');
c.result = { winner: 'player', points: 1 };
for (let i = 0; i < 20; i++) g.update(0.25);
assert.equal(g.closeup, c, 'il risultato resta a schermo finché non si torna al pallaio');
assert.ok(g.closeupClose());
const after = settle(g).map(s => `${s.who}: ${s.text}`);
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
assert.ok(g.state.flags.bocce_vinte);
// Vinto, si chiede la rivincita o Brento Sanico; lasciare la partita a metà non segna niente.
g.act('talk', 'mariano'); settle(g);
assert.ok(g.options().some(o => o.text === 'La rivincita?') && g.options().some(o => o.text === 'Allora, Brento Sanico?'));
g.choose(g.options().findIndex(o => o.text === 'La rivincita?'));
settle(g);
assert.equal(g.closeup?.id, 'bocce');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?')));
});
+75
View File
@@ -362,6 +362,64 @@ try {
await book.close();
}
// Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma.
{
const bocce = await open({ width: 1280, height: 860 });
await bocce.click('#splash');
await bocce.click('#new-game');
await bocce.click('#rione [data-rione="campomigliaio"]');
await bocce.click('#skip-intro');
await settle(bocce);
await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310]));
await settle(bocce);
await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await bocce.locator('#scene').boundingBox();
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
await bocce.mouse.down();
await bocce.waitForTimeout(500);
assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` });
await bocce.mouse.up();
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
await bocce.keyboard.press('Escape');
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
await bocce.close();
}
// Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale.
{
const b = await open({ width: 1280, height: 860 });
await b.click('#splash');
await b.click('#new-game');
await b.click('#rione [data-rione="campomigliaio"]');
await b.click('#skip-intro');
await settle(b);
await b.evaluate(() => game.go('circolo_4_maggio'));
await settle(b);
await b.waitForTimeout(1500); // la dissolvenza del cambio scena
await b.screenshot({ path: `${OUT}/03d-circolo.png` });
await clickScene(b, await hotspotCenter(b, 'biliardo'));
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await b.locator('#scene').boundingBox();
const [tx, ty] = await b.evaluate(() => { const c = game.closeup, V = c.balls.cpu; return [57 + V.x * 1.85, 78 + V.y * 1.85]; });
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
await b.mouse.down();
await b.waitForTimeout(500);
assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` });
await b.mouse.up();
assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte');
await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 });
await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` });
await b.keyboard.press('Escape');
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
await b.close();
}
const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
@@ -388,6 +446,23 @@ try {
await small.click('#pause-button');
assert.ok(await small.evaluate(() => document.getElementById('to-title').getBoundingClientRect().bottom <= document.getElementById('stage').getBoundingClientRect().bottom), 'la pausa esce dalla scena');
await small.screenshot({ path: `${OUT}/08b-telefono-stretto-pausa.png` });
// Telefono in verticale: la Pausa prende tutto lo schermo, e versione e spiegazione non finiscono sopra i verbi.
{
const tall = await browser.newPage({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true });
await tall.goto(`http://127.0.0.1:${PORT}/`);
await tall.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
await tall.click('#new-game');
await tall.click('#rione [data-rione="campomigliaio"]');
await tall.click('#skip-intro');
await settle(tall);
await tall.click('#pause-button');
const fit = await tall.evaluate(() => {
const p = document.getElementById('pause'), v = p.querySelector('.version').getBoundingClientRect();
return { inside: p.scrollHeight <= p.clientHeight + 1, top: document.elementFromPoint(v.x + v.width / 2, v.y + v.height / 2)?.closest('#pause') !== null };
});
assert.deepEqual(fit, { inside: true, top: true }, 'in verticale la pausa esce dallo schermo o finisce sotto i verbi');
await tall.close();
}
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await landscape.click('#rione [data-rione="campomigliaio"]');
+17
View File
@@ -83,3 +83,20 @@ test('passando a un\'altra app la musica si ferma subito, e tornando riparte', (
m.update('b.mp3', 0.1);
assert.ok(now.paused, 'con la pausa aperta resta ferma');
});
test('su iPhone (volume di sola lettura, sempre 1) il brano vecchio si ferma lo stesso: niente organo sopra il paese', () => {
globalThis.Audio = class { constructor(src) { this.src = src; this.paused = true; this.muted = false; }
get volume() { return 1; } set volume(v) {}
play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
const m = new Music({ volume: 0.6 });
for (let i = 0; i < 20; i++) m.update('paese.mp3', 0.1);
const paese = m.current.audio;
for (let i = 0; i < 20; i++) m.update('pieve.mp3', 0.1);
assert.ok(paese.paused, 'il brano del paese, sfumato, si è fermato');
assert.deepEqual(m.old, []);
assert.ok(!m.current.audio.paused && !m.current.audio.muted, 'suona solo l\'organo');
m.muted = true;
for (let i = 0; i < 20; i++) m.update('pieve.mp3', 0.1);
assert.ok(m.current.audio.muted, 'il muto funziona anche dove il volume no');
delete globalThis.Audio;
});
+17 -2
View File
@@ -70,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) {
const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${e.closeup}`);
for (const src of c.type === 'torch' ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
for (const src of c.picture ? [c.picture] : c.closed ? [c.closed, c.open] : []) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
}
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
@@ -519,7 +519,7 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']);
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
assert.equal(g.dialogue, null);
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.'));
const baron = talk('polisportiva', 'barondeduke');
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
@@ -580,6 +580,8 @@ test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => {
assert.ok(!texts().includes('Ho la torcia. Stasera entro nella fessura.'), 'senza torcia non si annuncia la fessura');
g.choose(texts().indexOf('Cosa stavamo dicendo???'));
assert.deepEqual(settle(g).map(s => s.text).slice(-2), ['Ma ti si è bruciata la memoria, fratello???!?', '...I pompieri per cosa?']);
g.choose(texts().indexOf('S\'ha andà a fallo!?!?'));
assert.deepEqual(settle(g).map(s => s.text).slice(-1), ['Gnamo! Ma prima mi fo una segarettina di quelle mi fo io!']);
leave();
talk('tennis_chiesa', 'chiosco');
assert.ok(texts().includes('Tosca, cosa si dice in giro?'));
@@ -590,6 +592,19 @@ test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => {
leave();
});
test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua strada', () => {
const g = new Game(story);
settle(g);
g.go('trebbio'); settle(g);
g.act('talk', 'torre'); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Lo sai che quassù ci vengo sempre a piedi?'));
const said = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`);
assert.ok(said.includes('andrea: Da casa sono quattro chilometri. E due sono di salita, di quella bella.'));
assert.deepEqual(said.slice(-2), ['andrea: Ho trovato la mia strada!', 'andrea: ...Io, che ancora non so cosa fare da grande.']);
g.choose(g.options().findIndex(o => o.text === 'E te, da grande, cosa volevi fare?'));
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""La musica dentro la Pieve (assets/audio/pieve.mp3), sintetizzata da zero: un organo morbido (canne di flauto,
poche armoniche) con una melodia lenta originale, sul registro principale, sopra otto accordi in fa maggiore. Dura un giro intero e ricomincia
sullo stesso accordo, così il loop non si sente; il riverbero della chiesa lo aggiunge ffmpeg.
python3 tools/organo.py # scrive assets/audio/pieve.mp3 (serve ffmpeg)
"""
import math
import subprocess
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
BEAT = 3.0 # secondi per nota della melodia: due note per accordo
CHORD = 2 * BEAT
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "pieve.mp3"
# Accordi (basso + tre voci) e melodia, due note per accordo. Il giro chiude su fa, come comincia.
CHORDS = [
("F2", ["F3", "A3", "C4"]), ("D2", ["D3", "F3", "A3"]), ("Bb1", ["Bb2", "D3", "F3"]), ("C2", ["C3", "E3", "G3"]),
("A1", ["F3", "A3", "C4"]), ("G2", ["G3", "Bb3", "D4"]), ("C2", ["C3", "F3", "G3"]), ("F2", ["F3", "A3", "C4"]),
]
MELODY = ["A4", "C5", "D5", "C5", "Bb4", "A4", "G4", "G4", "A4", "C5", "D5", "Bb4", "A4", "G4", "F4", "F4"]
DUR = CHORD * len(CHORDS)
buf = [0.0] * int(SR * DUR)
def hz(note: str) -> float:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
semis = names[note[0]] + (1 if "#" in note else -1 if note[1] == "b" else 0)
return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12)
def pipe(start: float, length: float, freq: float, vol: float, attack: float, release: float,
partials=((1, 1.0), (2, 0.35), (3, 0.12), (4, 0.06)), vibrato: float = 0.0, wrap: bool = False) -> None:
"""Una canna d'organo: poche armoniche, attacco e rilascio lenti. Con `wrap` la coda che esce dalla fine
rientra all'inizio: il loop resta continuo."""
n0, n = int(start * SR), int((length + release) * SR)
for i in range(n):
t = i / SR
env = min(1.0, t / attack) if t < length else max(0.0, 1 - (t - length) / release)
if env <= 0:
continue
f = freq * (1 + vibrato * math.sin(2 * math.pi * 5.2 * t))
s = sum(a * math.sin(2 * math.pi * f * k * t) for k, a in partials)
j = n0 + i
if j >= len(buf):
if not wrap:
break
j -= len(buf)
buf[j] += vol * env * s
for c, (bass, voices) in enumerate(CHORDS):
at = c * CHORD
pipe(at, CHORD, hz(bass), 0.10, 1.0, 1.6, partials=((1, 1.0), (2, 0.5), (3, 0.15)), wrap=True)
for v in voices:
pipe(at, CHORD, hz(v), 0.045, 1.2, 1.8, wrap=True)
for m, note in enumerate(MELODY):
# La melodia su un altro registro dello stesso organo, il principale: più armoniche, nessun vibrato, note staccate.
# (Su un flauto a onda pura col vibrato sembrava un theremin: Andrea, 25 ottobre.)
pipe(m * BEAT, BEAT * 0.86, hz(note), 0.04, 0.12, 0.7,
partials=((1, 1.0), (2, 0.55), (3, 0.32), (4, 0.18), (5, 0.1), (6, 0.06)), wrap=True)
peak = max(abs(x) for x in buf) or 1
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "pieve.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.8 * 32767) for x in buf)).tobytes())
# Il riverbero della navata: echi lunghi e morbidi, senza alti.
af = "aecho=0.8:0.75:140|310|520|830:0.35|0.26|0.18|0.12,lowpass=f=5000,volume=1.45"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
print(f"-> {OUT} ({DUR:.0f} s)")
+2 -1
View File
@@ -4,5 +4,6 @@
"Cosimo I": "Cosimo primo",
"padel": "pàdel",
"l'A. Bettini": "l'Abbettini",
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino"
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino",
"S'ha andà a fallo": "Si va a farlo"
}
+46 -10
View File
@@ -1,13 +1,49 @@
{
"_nota": "Una voce XTTS-v2 per personaggio (make voci). speaker: uno dei 58 di XTTS; filtro: catena ffmpeg -af applicata dopo (altezza, velocità, eco). Cambiare una voce rigenera solo le sue battute.",
"andrea": { "speaker": "Andrew Chipper" },
"vecchietta": { "speaker": "Tammy Grit" },
"vecchietto": { "speaker": "Viktor Eka" },
"castello": { "speaker": "Damien Black", "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15" },
"leone": { "speaker": "Baldur Sanjin", "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" },
"san_pietro": { "speaker": "Wulf Carlevaro", "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" },
"tosca": { "speaker": "Gitta Nikolina" },
"arcu": { "speaker": "Luis Moray" },
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
"barondeduke": { "speaker": "Craig Gutsy" }
"andrea": {
"speaker": "Andrew Chipper"
},
"vecchietta": {
"speaker": "Tammy Grit"
},
"vecchietto": {
"speaker": "Viktor Eka"
},
"castello": {
"speaker": "Damien Black",
"filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15"
},
"leone": {
"speaker": "Baldur Sanjin",
"filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08"
},
"san_pietro": {
"speaker": "Wulf Carlevaro",
"filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2"
},
"tosca": {
"speaker": "Gitta Nikolina"
},
"arcu": {
"speaker": "Luis Moray"
},
"tozzino": {
"speaker": "Xavier Hayasaka",
"filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92"
},
"barondeduke": {
"speaker": "Craig Gutsy"
},
"mariano": {
"speaker": "Gilberto Mathias"
},
"fra_peppino": {
"speaker": "Ludvig Milivoj"
},
"macchina": {
"speaker": "Andrew Chipper"
},
"ing_tozzi": {
"speaker": "Badr Odhiambo"
}
}