Compare commits

..
16 Commits
Author SHA1 Message Date
manzolo d51f5e56ea Versione 0.1.9 2026-10-26 00:03:14 +01:00
manzoloandClaude Opus 5.5 7b7e0031d1 Biliardo: immagini e suoni insieme. Durante la steccata aspetta la fisica, non il disegno: la biglia parte quando la stecca la tocca, col suo suono, e si disegnano sempre le posizioni vere (segnalato da Codex: urti che sembravano senza contatto e suoni in anticipo di 180 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:55:31 +01:00
manzoloandClaude Opus 5.5 94e9da4009 Difficoltà alle bocce e al biliardo, scelta nel dialogo con l'avversario: «Alla bona», «Seria», «A fassi male». Tarata con partite simulate contro un giocatore attento: al biliardo vince 9 su 12, circa 4 su 10 e quasi mai; alle bocce 72%, 50% e 35% delle mani. Battute nuove per Mariano e l'Ingegnere, test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:46:46 +01:00
manzoloandClaude Opus 5.5 bd66ea45ad Biliardo più bello (Codex): tavolo in pixel art visto dall'alto con cornice a fasce e diamanti, sala in penombra e luce delle lampade sul panno, segnapunti a cifre rosse, steccata e birilli che cadono animati, biglie in pixel, l'Ing. Tozzi a bordo tavolo che dà il gesso, esulta o allarga le braccia. Più: scritte a 8 punti (a 6 la B sembrava una D) e la stecca che torna a seguire il mouse finché un clic non fissa la mira (Andrea: «non riesco più a girare la stecca»)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:36:23 +01:00
manzoloandClaude Opus 5.5 9eabd5c6e1 Biliardo più preciso (revisione di Codex): urti calcolati nel punto esatto di contatto, anche nei colpi di taglio e veloci; la guida simula il tiro vero e mostra le due biglie dopo l'urto e i falli; la mira resta ferma andando sui comandi; frecce e Maiusc per la mira fine; comandi che non coprono il tavolo; test nel browser con mouse, touch e telefono. Più un tetto agli urti per passo, così due biglie a contatto non possono bloccare il gioco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:12:46 +01:00
manzoloandClaude Opus 5.5 ddc877a2d1 Sala dei biliardi: trio jazz da circolo sintetizzato da zero (tools/jazz_biliardo.py: contrabbasso, piano elettrico, spazzole, vibrafono, swing in si bemolle) e un sottofondo con gli altri tavoli e il bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:59:43 +01:00
manzoloandClaude Opus 5.5 14a1e6e5b8 L'Ing. Tozzi nella sala dei biliardi (sprite di Codex dalla foto di Andrea): la sfida parte parlandogli, come con Mariano, con la rivincita dopo una vittoria; la sala in scala più alta, Andrea non sta più sotto il tavolo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:56:55 +01:00
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
86 changed files with 2087 additions and 84 deletions
+6
View File
@@ -47,6 +47,11 @@ Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
Al biliardo del Circolo 4 Maggio, clic o tocco fissa la mira; regola la forza e premi **Tira**.
Le frecce ← → affinano la direzione, con Maiusc per passi ancora più piccoli. La guida usa la stessa fisica
del tiro: mostra sponde, primo contatto e direzioni iniziali dopo l'urto (azzurro per l'avversaria, giallo
per la propria biglia). Tenendo premuto sul tavolo o Spazio si carica il colpo, con la mira bloccata.
## In Docker
```zsh
@@ -71,5 +76,6 @@ npm test # logica, storia coerente, soluzione percorsa dall'inizio alla
npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
```
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
{
"image": "ing-tozzi.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 53,
"h": 120,
"ax": 27.9,
"ay": 120
},
"blink": {
"x": 55,
"y": 0,
"w": 54,
"h": 120,
"ax": 27.8,
"ay": 120
},
"talk": {
"x": 111,
"y": 0,
"w": 77,
"h": 119,
"ax": 29.9,
"ay": 119
},
"explain": {
"x": 190,
"y": 0,
"w": 58,
"h": 119,
"ax": 30.8,
"ay": 119
},
"laugh": {
"x": 250,
"y": 0,
"w": 53,
"h": 119,
"ax": 28.2,
"ay": 119
},
"chalk": {
"x": 305,
"y": 0,
"w": 52,
"h": 119,
"ax": 24.1,
"ay": 119
},
"shrug": {
"x": 359,
"y": 0,
"w": 87,
"h": 120,
"ax": 43.9,
"ay": 120
},
"cheer": {
"x": 448,
"y": 0,
"w": 56,
"h": 123,
"ax": 30.9,
"ay": 123
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

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.
+190 -6
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
@@ -635,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
},
"andrea|Il tavolo è dell'Ingegnere: prima gli chiedo una partita.": {
"file": "andrea-c43b158842.mp3",
"sec": 3.27
},
"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|L'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.": {
"file": "andrea-127a90af53.mp3",
"sec": 9.37
},
"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",
@@ -1675,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
@@ -1719,6 +1787,10 @@
"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
@@ -1743,14 +1815,74 @@
"file": "mariano-0eeb942ca2.mp3",
"sec": 3.52
},
"mariano|Il campione non scappa. Tira!": {
"file": "mariano-bdf58ba1be.mp3",
"sec": 3.26
"mariano|Come la vuoi?": {
"file": "mariano-0288ec460b.mp3",
"sec": 0.7
},
"mariano|Il campione non scappa. Come la vuoi?": {
"file": "mariano-1d8388d631.mp3",
"sec": 3.55
},
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
"file": "mariano-1c9cd0c325.mp3",
"sec": 5.96
},
"andrea|Alla bona.": {
"file": "andrea-67afa54e05.mp3",
"sec": 0.69
},
"andrea|Seria.": {
"file": "andrea-f6ef96032e.mp3",
"sec": 0.58
},
"andrea|A fassi male.": {
"file": "andrea-f52137b691.mp3",
"sec": 0.78
},
"mariano|Alla bona: tiro con gli occhi chiusi.": {
"file": "mariano-b1b1ca273b.mp3",
"sec": 2.2
},
"mariano|Seria. Così mi piace.": {
"file": "mariano-accff65835.mp3",
"sec": 2.27
},
"mariano|A fassi male? Poi non piangere, eh.": {
"file": "mariano-d65fb6e871.mp3",
"sec": 3.42
},
"andrea|Ingegnere, una partita ai birilli?": {
"file": "andrea-dbcf4d1083.mp3",
"sec": 2.49
},
"andrea|Arrivederci, Ingegnere.": {
"file": "andrea-8f63435d69.mp3",
"sec": 1.38
},
"ing_tozzi|Una partita ai birilli? Si va a venti. Come la vuoi?": {
"file": "ing_tozzi-82e7cc88aa.mp3",
"sec": 4.58
},
"ing_tozzi|Ci mancherebbe. Stavolta guardo bene. Come la vuoi?": {
"file": "ing_tozzi-344ac1021f.mp3",
"sec": 4.67
},
"ing_tozzi|Alla bona: tiro senza occhiali.": {
"file": "ing_tozzi-17e489814e.mp3",
"sec": 2.19
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
},
"ing_tozzi|Seria. Dammi il gesso.": {
"file": "ing_tozzi-4b3adfa04a.mp3",
"sec": 2.28
},
"ing_tozzi|A fassi male? Allora mi tolgo la giacca. Ah no, non ce l'ho.": {
"file": "ing_tozzi-c365e9f760.mp3",
"sec": 6.05
},
"andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3",
"sec": 3.9
@@ -1759,6 +1891,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
@@ -1823,6 +1963,34 @@
"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
@@ -1847,6 +2015,22 @@
"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.
+34
View File
@@ -0,0 +1,34 @@
# Primo piano del biliardo
Il tavolo in `assets/closeups/biliardo-tavolo.png` è generato con lo strumento immagini integrato
di Codex, senza API. Prompt completo in `prompts/closeups/biliardo-tavolo.integrated.txt`
(cartella esclusa da git). Specifica: vista ortogonale dall'alto, pixel art da avventura anni '90,
mogano scuro a fasce chiare e scure, diamanti d'ottone, panno verde, sala in penombra;
senza buche, biglie, birilli, persone, stecche o testi. Riferimento di stile: `circolo-4-maggio.png`.
L'Ingegnere usa il foglio esistente `ing-tozzi.json`, coerente con la fotografia di riferimento.
Il master misura 1672×941. `tablePatches()` in `biliardo-render.js` mappa il panno sorgente
(190, 214)–(1484, 722) su `(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k)`; i ritagli
esterni mantengono una cornice larga 16 pixel. La geometria della simulazione resta invariata.
Il caricamento passa da `story.closeups.biliardo.picture`, con la stessa cache delle bocce.
La luce calda è disegnata sul panno con una piccola tela memorizzata, ingrandita senza smoothing.
Biglie e birilli usano blocchi di pixel; le ombre delle biglie si allontanano dalla lampada centrale.
I segnapunti sono cifre rosse a sette segmenti. Le quattro pose di Tozzi rimangono nel margine sinistro.
`biliardo.js` aggiunge soltanto stato visivo: orologio, posizione iniziale della steccata,
breve cronologia delle posizioni e istante della caduta dei birilli. La presentazione del moto
è ritardata di 180 ms, per mostrare arretramento e scatto della stecca; la fisica parte subito.
La caduta dei birilli dura 240 ms sul medesimo orologio visivo. Previsione e CPU non scrivono
questa cronologia. Un confronto con la versione precedente ha verificato 6626 aggiornamenti
identici, escludendo i soli campi visivi.
Controlli: `tests/biliardo-render.test.mjs` verifica allineamento, presentazione, cadute,
pose e immutabilità del disegno. `npm run browser:biliardo` conserva i controlli dei comandi
su desktop, touch e telefono verticale e aggiunge controlli dei pixel e catture delle animazioni
in `/tmp/regolo-biliardo`.
Verifica di questa modifica: test JavaScript e Python superati; anteprima locale del renderer
ispezionata. Restano da completare `make -s verify` e `npm run browser:biliardo` in un ambiente
che permetta server locale e Chromium: nella sandbox della sessione entrambi sono bloccati
da `Operation not permitted`. Le vecchie schermate browser non validano questa modifica.
+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`.
+13
View File
@@ -121,6 +121,19 @@
<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>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
la tua. Le frecce mostrano solo il primo tratto, fino al prossimo ostacolo o al massimo 50 cm.
Senza urti, il cerchietto indica dove ti fermi. La guida arancione segnala un primo contatto falloso.</p>
<p>Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare:
durante la carica la mira resta ferma. Frecce ← →: mira fine; Maiusc + ← →: regolazione ancora più precisa;
↑ ↓: forza. Esc esce dalla partita. I colpi sono centrali, senza effetti di rotazione.</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>
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.7",
"version": "0.1.9",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
+223
View File
@@ -0,0 +1,223 @@
import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
import { text, panel } from './bocce-render.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;
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
const rr = R * VIEW.k;
// Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
export const SHOT_DELAY = WINDUP;
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
export function tablePatches() {
const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
]));
}
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
function sphere(ctx, role, x, y, r, shadow = true) {
const [dark, mid, light] = BALL[role];
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
if (shadow) {
const dx = (x - sx(LEN / 2)) / (LEN * VIEW.k / 2) * 3;
const dy = (y - sy(WID / 2)) / (WID * VIEW.k / 2) * 3 + 1;
ctx.fillStyle = '#103b2bcc';
ctx.fillRect(Math.round(x - r + dx), Math.round(y - r / 2 + dy), Math.round(r * 2), Math.round(r));
}
// Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico.
const left = Math.floor(x - r), top = Math.floor(y - r);
for (let py = top; py < y + r; py++) for (let px = left; px < x + r; px++) {
const dx = px + 0.5 - x, dy = py + 0.5 - y, d = Math.hypot(dx, dy);
if (d > r) continue;
ctx.fillStyle = d > r - 1 ? '#302a1e' : dx * lx + dy * ly < -r * 0.2 ? dark : mid;
ctx.fillRect(px, py, 1, 1);
}
ctx.fillStyle = light; ctx.fillRect(Math.round(x + lx * r * 0.4) - 1, Math.round(y + ly * r * 0.4) - 1, 2, 2);
}
export function pinFall(p, time) {
if (!p.down) return 0;
if (p.visualDownAt === undefined) return 1;
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
}
function pin(ctx, p, time) {
const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
const ty = y + Math.sin(p.dir ?? 0) * 7 * tilt - 7 * lift;
const [base, top] = p.red ? ['#8d1f1a', '#ff7060'] : ['#b3a57f', '#fff8e4'];
ctx.fillStyle = '#123c2bb0'; ctx.fillRect(Math.round(x - 1), Math.round(y + 1), 5 + Math.round(tilt * 3), 2);
// Piccoli blocchi seguono la rotazione senza linee levigate.
for (let i = 0; i <= 7; i++) {
ctx.fillStyle = i > 4 ? top : base;
ctx.fillRect(Math.round(x + (tx - x) * i / 7) - 1, Math.round(y + (ty - y) * i / 7) - 1, i > 4 ? 3 : 2, 2);
}
}
let lighting;
function table(ctx, bg) {
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
else {
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#51321c'; ctx.fillRect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#14674d'; ctx.fillRect(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
}
// La sala scompare in penombra, ma si riconoscono pavimento e boiserie.
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 640, 360);
ctx.rect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#100f10bb'; ctx.fill('evenodd'); ctx.restore();
if (!lighting) {
lighting = document.createElement('canvas'); lighting.width = 125; lighting.height = 63;
const light = lighting.getContext('2d');
for (let y = 0; y < 63; y++) for (let x = 0; x < 125; x++) {
const d = Math.hypot((x - 62) / 69, (y - 31) / 38);
const warm = Math.max(0, 1 - d) * 0.22;
const shade = Math.max(0, d - 0.35) * 0.28;
light.fillStyle = `rgba(255,215,128,${Math.round(warm * 32) / 32})`; light.fillRect(x, y, 1, 1);
light.fillStyle = `rgba(4,25,20,${Math.round(shade * 32) / 32})`; light.fillRect(x, y, 1, 1);
}
}
ctx.drawImage(lighting, VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
}
function aimGuide(ctx, c) {
const p = predict(c);
const foul = p.hit === 'pin' || p.hit === 'pallino';
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,248,220,0.85)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
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 = foul ? '#ffae8b' : '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();
for (const track of p.outgoing) {
const [start, end] = track.points;
if (!end || Math.hypot(end[0] - start[0], end[1] - start[1]) < 1) continue;
const x = sx(end[0]), y = sy(end[1]), a = Math.atan2(end[1] - start[1], end[0] - start[0]);
ctx.strokeStyle = track.role === c.current ? '#f4df8d' : track.role === 'pallino' ? '#ffae8b' : '#96dfff';
ctx.setLineDash(track.role === c.current ? [2, 3] : []);
ctx.beginPath(); ctx.moveTo(sx(start[0]), sy(start[1])); ctx.lineTo(x, y); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(x - Math.cos(a - 0.5) * 5, y - Math.sin(a - 0.5) * 5);
ctx.lineTo(x, y); ctx.lineTo(x - Math.cos(a + 0.5) * 5, y - Math.sin(a + 0.5) * 5); ctx.stroke();
}
} else {
// Il cerchietto finale è l'arresto effettivo con la forza scelta.
const [x, y] = p.points.at(-1);
ctx.strokeStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(sx(x), sy(y), 2, 0, Math.PI * 2); ctx.stroke();
}
}
function cue(ctx, x, y, angle, back, alpha = 1) {
ctx.save(); ctx.globalAlpha = alpha;
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2);
ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3);
ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1);
ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3);
ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3);
ctx.restore();
}
export function tozziPose(c) {
const last = c.last;
if (last && last.age < 2.4) {
if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug';
if (last.to === 'cpu' && last.points > 0) return 'cheer';
}
return c.step === 'aim' && c.current === 'cpu' ? 'chalk' : 'stand';
}
export const TOZZI = { x: 26, y: 258, scale: 0.46 };
function tozzi(ctx, c, sheet) {
if (!sheet) return;
const pose = tozziPose(c), frame = sheet.atlas.frames[pose];
const { x, y, scale } = TOZZI;
const bob = pose === 'chalk' ? Math.floor(Math.sin(c.visual.time * 9) * 0.8) : 0;
ctx.fillStyle = '#080a09aa'; ctx.fillRect(10, y - 1, 33, 4);
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale) + bob,
Math.round(frame.w * scale), Math.round(frame.h * scale));
}
const SEGMENTS = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f];
function digits(ctx, value, x, y) {
const segments = [[2, 0, 7, 2], [9, 2, 2, 6], [9, 10, 2, 6], [2, 16, 7, 2],
[0, 10, 2, 6], [0, 2, 2, 6], [2, 8, 7, 2]];
for (const [i, digit] of String(value).padStart(2, '0').split('').entries()) {
segments.forEach(([dx, dy, w, h], bit) => {
ctx.fillStyle = SEGMENTS[Number(digit)] & (1 << bit) ? '#ff5a3d' : '#381b17';
ctx.fillRect(x + i * 15 + dx, y + dy, w, h);
});
}
}
function scoreboard(ctx, c, def) {
for (const [i, who] of ['player', 'cpu'].entries()) {
const x = 70 + i * 352, active = c.step === 'aim' && c.current === who;
ctx.fillStyle = '#0e0e0c'; ctx.fillRect(x - 2, 7, 152, 43);
ctx.fillStyle = '#705039'; ctx.fillRect(x, 7, 148, 1);
ctx.fillStyle = '#36281d'; ctx.fillRect(x, 49, 148, 2);
ctx.fillStyle = '#b09359'; ctx.fillRect(x + 3, 10, 1, 1); ctx.fillRect(x + 144, 46, 1, 1);
text(ctx, def.names[who], x + 10, 20, 8, '#ddcfad');
sphere(ctx, who, x + 13, 36, 3, false);
text(ctx, active ? 'AL TIRO' : 'PUNTI', x + 24, 36, 8, active ? '#eac27d' : '#8e8064');
digits(ctx, c.points[who], x + 105, 22);
}
text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center');
if (canThrow(c)) {
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 29, 8, '#eee0ba', 'center');
ctx.fillStyle = '#151c16'; ctx.fillRect(245, 39, 150, 5);
for (let i = 0; i < 30; i++) {
ctx.fillStyle = i / 30 < c.power ? i > 23 ? '#c19554' : '#9fbc82' : '#303b2d';
ctx.fillRect(246 + i * 5, 40, 3, 3);
}
} else text(ctx, `A ${c.target} PUNTI`, 320, 33, 8, '#b0a080', 'center');
}
export function drawBiliardo(ctx, c, def, bg, sheet) {
ctx.save(); ctx.imageSmoothingEnabled = false;
table(ctx, bg);
tozzi(ctx, c, sheet);
const view = visualFrame(c);
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
if (c.step === 'aim' && (c.current === 'player' || c.plan)) {
aimGuide(ctx, c);
const me = c.balls[c.current];
cue(ctx, me.x, me.y, c.angle, rr + 6 + c.power * 26);
} else if (c.visual.shot) {
const shot = c.visual.shot, age = c.visual.time - shot.at;
if (age < SHOT_DELAY + 0.12) {
const pull = 6 + shot.power * 26;
const back = age < 0.12 ? pull + age / 0.12 * 10 : Math.max(0, (pull + 10) * (1 - (age - 0.12) / 0.06));
cue(ctx, shot.x, shot.y, shot.angle, rr + back, age < SHOT_DELAY ? 1 : 1 - (age - SHOT_DELAY) / 0.12);
}
}
for (const p of view.pins) pin(ctx, p, view.time);
for (const role of ['pallino', 'cpu', 'player']) { const b = view.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
ctx.restore();
scoreboard(ctx, c, def);
const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
// Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6.
const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names);
text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
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, 8, '#d7ccb4', 'center');
}
ctx.restore();
}
+368
View File
@@ -0,0 +1,368 @@
// 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;
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
export const WINDUP = 0.18;
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
export const OTHER = { player: 'cpu', cpu: 'player' };
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
export const VIEW = { x: 70, y: 88, k: 1.76 };
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);
// Quanto è bravo l'Ingegnere: quanti colpi prova a vuoto (direzioni intorno al tavolo, ritocchi vicino alla biglia
// avversaria, forze) e quanto gli trema la mano. «A fassi male» è il campione vero: vince quasi sempre.
export const LEVELS = {
bona: { angles: 0, fine: 0.15, powers: [0.6], wobble: 0.16, powerNoise: 0.2 },
seria: { angles: 14, fine: 0.09, powers: [0.5, 0.8], wobble: 0.072, powerNoise: 0.1 },
male: { angles: 64, fine: 0.025, powers: [0.3, 0.5, 0.7, 0.9], wobble: 0.03, powerNoise: 0.05 },
};
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
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();
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
c.visual = { time: 0, shot: null };
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 + CONTACT_EPS) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (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 <= CONTACT_EPS) return 0;
const j = closing * (1 + BALL_RESTITUTION) / 2;
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;
}
// Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
// Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
function contactTime(dx, dy, vx, vy, radius) {
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
const gap = dx * dx + dy * dy - radius * radius;
if (gap <= CONTACT_EPS) return 0;
const discriminant = closing * closing - speed2 * gap;
if (discriminant <= 0) return Infinity;
return gap / (-closing + Math.sqrt(discriminant));
}
function slow(b, dt) {
const v = Math.hypot(b.vx, b.vy);
if (!v) return;
const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
const scale = nv < EPS ? 0 : nv / v;
b.vx *= scale; b.vy *= scale;
}
function advance(balls, dt) {
for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
}
function step(w, dt) {
// Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
// Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
for (const b of w.balls) slow(b, dt / 2);
let left = dt, hit = 0, rail = 0;
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
for (let events = 0; left > 1e-10 && events < 64; events++) {
let time = left, event = null;
const consider = (t, kind, a, b, axis, edge) => {
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
};
for (let i = 0; i < w.balls.length; i++) {
const a = w.balls[i];
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
for (let j = i + 1; j < w.balls.length; j++) {
const b = w.balls[j];
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
}
if (!a.vx && !a.vy) continue;
for (const p of w.pins) if (!p.down)
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
}
advance(w.balls, time); left -= time;
if (!event) break;
const { kind, a, b, axis, edge } = event;
if (kind === 'rail') {
const v = axis === 'x' ? 'vx' : 'vy';
a[axis] = edge;
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
} else if (kind === 'ball') {
const impact = collide(a, b);
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
} else {
const v = Math.hypot(a.vx, a.vy);
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
a.vx *= 0.88; a.vy *= 0.88;
w.shot?.pins.push(b);
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
}
w.onContact?.(event);
}
for (const b of w.balls) slow(b, dt / 2);
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);
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
c.windup = WINDUP;
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;
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) delete p.visualDownAt;
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 k = c.skill ?? LEVELS.male, base = angleTo(c.balls.cpu, c.balls.player), angles = [];
for (let i = 0; i < k.angles; i++) angles.push(i / k.angles * Math.PI * 2);
for (let d = -0.3; d <= 0.3; d += k.fine) angles.push(base + d);
return angles.flatMap(angle => k.powers.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;
const k = c.skill ?? LEVELS.male;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? k.wobble), power: clamp(t.best.power + deviation(c) * k.powerNoise, 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;
c.visual.time += dt;
if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling' && c.windup > 0) {
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
c.windup -= dt;
if (c.windup <= 0) {
const { ball, vx, vy, power } = c.pending;
ball.vx = vx; ball.vy = vy; c.pending = null;
c.events.push({ kind: 'biliardo-cue', power });
dt = -c.windup; c.windup = 0;
} else dt = 0;
}
if (c.step === 'rolling' && !c.windup) {
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);
}
if (c.step === 'rolling' || c.step === 'settle')
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
}
// --- Comandi del giocatore: gli stessi delle bocce.
export function aimAt(c, x, y) {
if (!canThrow(c) || c.holding || !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]) {
const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
return aimAt(c, x, y);
}
export function nudge(c, dir, fine = false) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.0005 : 0.002); 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);
}
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
// Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
const predictions = new WeakMap();
export function predict(c) {
const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
const cached = predictions.get(c);
if (cached?.key === key) return cached.result;
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
const v = speedFor(c.power);
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const tracks = [];
const point = b => [b.x, b.y];
const endTrack = track => {
track.points.push(point(track.ball)); track.done = true;
};
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
const { kind, a, b } = event;
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
if (result.hit || (a !== me && b !== me)) return;
result.points.push(point(me));
if (kind === 'rail') return;
result.ghost = { x: me.x, y: me.y };
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
if (kind === 'pin') return;
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
for (const ball of [me, other]) {
const points = [point(ball)];
tracks.push({ ball, points, done: false });
result.outgoing.push({ role: ball.role, points });
}
} };
for (let i = 0; i < 15 / FIXED_STEP; i++) {
step(w, FIXED_STEP);
for (const track of tracks) {
if (track.done) continue;
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
}
if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
}
if (!result.hit) result.points.push(point(me));
predictions.set(c, { key, result });
return result;
}
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})`;
}
+2 -2
View File
@@ -3,10 +3,10 @@ import { ambientHash as hash } from './ambient.js';
const FONT = '"Press Start 2P", monospace';
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
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);
}
function panel(ctx, x, y, w, h) {
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);
}
+14 -5
View File
@@ -19,8 +19,16 @@ 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,
// Quanto è bravo Mariano: quanto si allarga il suo tiro e quanto spesso prova a bocciare la boccia migliore.
// «A fassi male» è il campione vero del pallaio.
export const LEVELS = {
bona: { spread: 3, attack: 0.1 },
seria: { spread: 1.15, attack: 0.4 },
male: { spread: 0.22, attack: 0.85 },
};
export function newBocce(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], 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 };
@@ -134,10 +142,11 @@ function nextTurn(c) {
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 k = c.skill ?? LEVELS.male;
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < k.attack;
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);
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2) * k.spread, 10, LANE - 2);
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8) * k.spread, -HALF + BALL, HALF - BALL);
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
}
export function update(c, dt) {
+24 -13
View File
@@ -1,7 +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 { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.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, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
};
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' };
@@ -230,7 +237,7 @@ export class Game {
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano
if (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;
@@ -248,8 +255,8 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete);
return true;
}
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
if (def?.type === 'bocce') {
// 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;
}
@@ -258,16 +265,18 @@ export class Game {
return true;
}
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
// 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;
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
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;
@@ -390,9 +399,10 @@ export class Game {
const c = this.closeup;
if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'bocce') {
bocceUpdate(c, dt);
// Il risultato resta leggibile finché il giocatore torna al pallaio.
const m = this.minigame();
if (m) {
m.update(c, dt);
// Il risultato resta leggibile finché il giocatore non esce.
return;
}
c.t += dt;
@@ -429,7 +439,8 @@ export class Game {
else if (e.closeup) {
const type = this.story.closeups[e.closeup].type;
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level) }
: { 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;
}
+6
View File
@@ -4,6 +4,7 @@ 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;
@@ -675,6 +676,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
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);
+9
View File
@@ -104,7 +104,16 @@ export class Soundscape {
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 === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
this.tone(1700, 900, 0.04, 0.025, 0, pan);
this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan);
}
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
+44 -25
View File
@@ -8,7 +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 { aim as bocceAim, shoot as bocceShoot, setPower as boccePower, canThrow as bocceReady, status as bocceStatus } from './engine/bocce.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);
@@ -398,8 +399,12 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
const aimed = game.closeupAim(scenePoint(e));
if (story.closeups[game.closeup.id].type === 'biliardo') {
if (!aimed && !game.speech) return;
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
}
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);
@@ -430,17 +435,26 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => {
if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio
// con un solo clic, anche per chi non vuole usare la pressione temporizzata.
const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null;
$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); });
$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); });
for (const [id, move] of [['bocce-left', -0.3], ['bocce-right', 0.3]])
$(id).addEventListener('click', () => { const c = bocceState(); if (c) bocceAim(c, c.aim.u, c.aim.w + move); });
// 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: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. 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)) c.aimLocked = true; });
addEventListener('blur', () => game?.closeupCancel());
// --- Verbi.
@@ -528,13 +542,13 @@ 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 (game.closeup.id === 'bocce') {
if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
const c = game.closeup;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3));
else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey)) c.aimLocked = true; }
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; }
@@ -554,7 +568,7 @@ window.addEventListener('keydown', e => {
return;
}
if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter'].includes(e.key) && story.closeups[game.closeup.id].type === 'bocce') { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
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;
@@ -572,7 +586,7 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
});
// Alle bocce, lasciando spazio o Invio parte il tiro.
// 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).
@@ -613,18 +627,22 @@ function updateUi() {
updateInventory();
updateChoices();
updateSpeech();
const bocce = c?.id === 'bocce';
$('bocce-controls').hidden = !bocce;
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
$('bocce-controls').hidden = !mg;
$('bocce-controls').inert = !running;
if (bocce) {
if (mg) {
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true;
const line = bocceStatus(c, story.closeups.bocce.names);
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 = !bocceReady(c) || c.holding || !!game.speech;
$('bocce-throw').textContent = c.step === 'result' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia';
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 });
if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
}
// 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;
@@ -632,11 +650,12 @@ function updateUi() {
$('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' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : '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.classList.toggle('bocce', bocce);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura");
canvas.classList.toggle('bocce', !!mg);
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo');
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');
+3
View File
@@ -5,6 +5,8 @@ export const soundscapes = {
acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] },
pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] },
circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] },
// Gli altri tavoli della sala: una stecca, una biglia che ne tocca un'altra, ogni tanto un bicchiere al bar.
biliardo: { bed: null, level: 0, day: ['tavolo-lontano', 'tavolo-lontano', 'glass'], night: ['tavolo-lontano', 'tavolo-lontano', 'glass'], gap: [4, 10] },
fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] },
fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] },
};
@@ -15,6 +17,7 @@ const groups = {
pieve: ['pieve', 'pieve_interno', 'oratorio'],
circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'],
fortezza: ['fortezza'],
biliardo: ['circolo_4_maggio'],
};
export function soundscapeFor(scene, closeup) {
+145 -5
View File
@@ -124,6 +124,22 @@ export const story = {
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',
picture: 'assets/closeups/biliardo-tavolo.png',
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',
@@ -170,6 +186,8 @@ 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', sprite: 'assets/characters/ing-tozzi.json' },
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' },
@@ -345,6 +363,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' }],
@@ -1022,6 +1051,63 @@ 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',
music: 'assets/audio/biliardo.mp3', // trio jazz da circolo (tools/jazz_biliardo.py, 25 ottobre)
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, 1.2], [360, 2.5]],
entry: [590, 202],
speakers: { ing_tozzi: [452, 150] },
// L'Ing. Tozzi aspetta accanto al primo tavolo, con la stecca: si parla con lui per giocare, come con Mariano.
people: [{ who: 'ing_tozzi', at: [452, 296], facing: 1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
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: 'Il tavolo è dell\'Ingegnere: prima gli chiedo una partita.' }],
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: 'ing_tozzi', name: 'l\'Ing. Tozzi', box: [432, 196, 40, 100], at: [410, 304], face: 'right', default: 'talk',
actions: {
look: [{ say: 'L\'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.' }],
talk: [{ dialogue: 'ing_tozzi' }],
},
},
{
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',
@@ -1032,9 +1118,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.' },
],
},
},
{
@@ -2153,6 +2244,7 @@ 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' },
],
},
@@ -2164,18 +2256,48 @@ export const story = {
start: {
options: [
{
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'end',
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'livello',
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' },
{ say: 'Come la vuoi?', who: 'mariano' },
],
},
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'end', then: [{ say: 'Il campione non scappa. Tira!', who: 'mariano', pose: 'throw' }, { closeup: 'bocce' }] },
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'livello', then: [{ say: 'Il campione non scappa. Come la vuoi?', who: 'mariano', pose: 'throw' }] },
{ 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' },
],
},
// La difficoltà (Andrea, 25 ottobre): quanto si impegna Mariano.
livello: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: tiro con gli occhi chiusi.', who: 'mariano', pose: 'laugh' }, { closeup: 'bocce', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Così mi piace.', who: 'mariano' }, { closeup: 'bocce', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Poi non piangere, eh.', who: 'mariano', pose: 'cheer' }, { closeup: 'bocce', level: 'male' }] },
],
},
},
},
ing_tozzi: {
nodes: {
start: {
options: [
{
text: 'Ingegnere, una partita ai birilli?', when: '!biliardo_vinto', next: 'livello',
then: [{ say: 'Una partita ai birilli? Si va a venti. Come la vuoi?', who: 'ing_tozzi', pose: 'chalk' }],
},
{ text: 'La rivincita?', when: 'biliardo_vinto', next: 'livello', then: [{ say: 'Ci mancherebbe. Stavolta guardo bene. Come la vuoi?', who: 'ing_tozzi', pose: 'explain' }] },
{ text: 'Arrivederci, Ingegnere.', next: 'end' },
],
},
// La difficoltà: quanti colpi prova a vuoto l'Ingegnere, e quanto gli trema la mano.
livello: {
options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: tiro senza occhiali.', who: 'ing_tozzi', pose: 'laugh' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Dammi il gesso.', who: 'ing_tozzi', pose: 'chalk' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Allora mi tolgo la giacca. Ah no, non ce l\'ho.', who: 'ing_tozzi', pose: 'explain' }, { say: 'Gnamo.' }, { closeup: 'biliardo', level: 'male' }] },
],
},
},
},
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
@@ -2206,6 +2328,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.7';
export const VERSION = '0.1.9';
// 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).
+12 -4
View File
@@ -34,16 +34,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help { 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; }
#bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-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; }
#bocce-help, #biliardo-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); }
@@ -56,6 +56,10 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
#stage.biliardo #scene { height: auto; }
#stage.biliardo #closeup-close { position: static; display: block; margin: 6px 10px 6px auto; min-height: 32px; }
/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
@@ -251,6 +255,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; }
@@ -262,6 +269,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: landscape) and (max-height: 520px) {
#app { width: 100vw; flex-direction: row; }
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
+113
View File
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { newBiliardo, update, shoot, predict, simulate, judge, LEN, WID, VIEW, FIXED_STEP } from '../src/engine/biliardo.js';
import { drawBiliardo, visualFrame, pinFall, tozziPose, tablePatches, TOZZI, SHOT_DELAY } from '../src/engine/biliardo-render.js';
import { story } from '../src/story/story.js';
test('il panno generato coincide con VIEW, senza buchi fra i ritagli', () => {
const png = readFileSync(new URL('../' + story.closeups.biliardo.picture, import.meta.url));
const patches = tablePatches();
assert.equal(png.readUInt32BE(16), 1672);
assert.equal(png.readUInt32BE(20), 941);
for (const [i, expected] of [VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k].entries())
assert.ok(Math.abs(patches[12][i + 4] - expected) < 1e-10);
let area = 0;
for (const [i, p] of patches.entries()) {
const [x, y, w, h, dx, dy, dw, dh] = p;
assert.ok(w > 0 && h > 0 && dw > 0 && dh > 0);
assert.ok(x >= 0 && y >= 0 && x + w <= 1672 && y + h <= 941);
assert.ok(dx >= 0 && dy >= 0 && dx + dw <= 640 && dy + dh <= 360);
if (i % 5 < 4) assert.equal(dx + dw, patches[i + 1][4]);
if (i < 20) assert.equal(dy + dh, patches[i + 5][5]);
area += dw * dh;
}
assert.ok(Math.abs(area - 640 * 360) < 1e-8);
});
test('la steccata: la fisica parte quando la stecca tocca la biglia, e il disegno mostra sempre le posizioni vere', () => {
const c = newBiliardo(42), start = { ...c.balls.player };
const expected = simulate(c, 'player', c.angle, c.power);
shoot(c);
update(c, SHOT_DELAY / 2);
assert.equal(c.balls.player.x, start.x, 'la stecca non è ancora arrivata');
assert.ok(!c.events.some(e => e.kind === 'biliardo-cue'), 'niente suono del colpo prima del contatto');
update(c, SHOT_DELAY);
assert.ok(c.events.some(e => e.kind === 'biliardo-cue'), 'il suono del colpo arriva col contatto');
assert.ok(c.balls.player.x !== start.x || c.balls.player.y !== start.y, 'la biglia è partita');
const before = structuredClone(c);
assert.equal(visualFrame(c).balls, c.balls, 'il disegno usa le posizioni vere, senza ritardo (Codex: urti e suoni sfasati)');
assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato');
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
assert.deepEqual(judge(c.shot), expected);
});
test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => {
const c = newBiliardo(9);
Object.assign(c.balls.player, { x: LEN / 2 - 60, y: WID / 2 });
Object.assign(c.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 });
Object.assign(c.balls.pallino, { x: 20, y: 20 });
c.angle = 0; c.power = 0.5;
const expected = simulate(c, 'player', c.angle, c.power), before = structuredClone(c);
predict(c);
assert.deepEqual(c, before, 'la previsione non fa partire animazioni');
shoot(c);
for (let i = 0; i < 1000 && !c.pins.some(p => p.down); i++) update(c, FIXED_STEP);
const p = c.pins.find(p => p.down);
assert.ok(p && Number.isFinite(p.visualDownAt));
assert.equal(pinFall(p, p.visualDownAt), 0);
assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1);
assert.equal(pinFall(p, p.visualDownAt + 0.3), 1);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
assert.deepEqual(judge(c.shot), expected);
while (c.step === 'settle') update(c, FIXED_STEP);
assert.ok(c.pins.every(p => !p.down && p.visualDownAt === undefined));
});
test('Tozzi pensa, esulta e sbaglia; tutte le pose restano fuori da tavolo e comandi', () => {
const c = newBiliardo(3);
assert.equal(tozziPose(c), 'stand');
c.current = 'cpu'; assert.equal(tozziPose(c), 'chalk');
for (const [last, pose] of [
[{ shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }, 'cheer'],
[{ shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }, 'shrug'],
[{ shooter: 'cpu', to: 'cpu', points: 0, foul: null, age: 0 }, 'shrug'],
[{ shooter: 'player', to: 'cpu', points: 2, foul: 'own', age: 0 }, 'cheer'],
]) { c.last = last; assert.equal(tozziPose(c), pose); }
c.last.age = 3; assert.equal(tozziPose(c), 'chalk');
const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url)));
for (const pose of ['stand', 'chalk', 'cheer', 'shrug']) {
const f = atlas.frames[pose], left = Math.round(TOZZI.x - f.ax * TOZZI.scale);
assert.ok(left >= 0 && left + Math.round(f.w * TOZZI.scale) < VIEW.x - 16, pose);
assert.ok(TOZZI.y - f.ay * TOZZI.scale > 72 && TOZZI.y < 360);
}
});
test('il disegno usa immagine e atlas, funziona durante il caricamento e non modifica la partita', () => {
const calls = [], ctx = new Proxy({}, { get: (o, key) => key in o ? o[key] : (...args) => calls.push([key, ...args]) });
const previous = globalThis.document;
globalThis.document = { createElement: () => ({ getContext: () => ctx }) };
const atlas = JSON.parse(readFileSync(new URL('../assets/characters/ing-tozzi.json', import.meta.url)));
const bg = {}, sheet = { atlas, img: {} }, c = newBiliardo(42);
try {
const render = (background, sprite) => {
const before = structuredClone(c);
drawBiliardo(ctx, c, story.closeups.biliardo, background, sprite);
assert.deepEqual(c, before);
};
render(); // Il file può non essere ancora arrivato dalla rete.
calls.length = 0;
render(bg, sheet);
assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === bg));
assert.ok(calls.some(([op, image]) => op === 'drawImage' && image === sheet.img));
assert.ok(!calls.some(([op]) => op === 'createRadialGradient'), 'biglie senza gradienti lisci');
shoot(c); update(c, 0.1); render(bg, sheet);
update(c, 0.07); render(bg, sheet);
while (c.step === 'rolling') update(c, 1 / 60);
render(bg, sheet);
c.step = 'result'; c.result = { winner: 'cpu' }; render(bg, sheet);
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});
+191
View File
@@ -0,0 +1,191 @@
// Regressioni della mira con veri eventi browser, su desktop e touch.
// npm run browser:biliardo — schermate in /tmp/regolo-biliardo.
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
// Fotogrammi ripetibili del renderer, oltre alle schermate dell'interfaccia reale.
async function checkArtwork(page, name) {
const captures = await page.evaluate(async () => {
const { story } = await import('/src/story/story.js');
const { newBiliardo, shoot, update, LEN, WID, VIEW } = await import('/src/engine/biliardo.js');
const { drawBiliardo } = await import('/src/engine/biliardo-render.js');
const def = story.closeups.biliardo;
const load = async src => { const img = new Image(); img.src = src; await img.decode(); return img; };
const bg = await load('/' + def.picture);
const atlas = await (await fetch('/assets/characters/ing-tozzi.json')).json();
const sheet = { atlas, img: await load('/assets/characters/' + atlas.image) };
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 360;
const ctx = canvas.getContext('2d'), shots = [], used = [];
const drawImage = ctx.drawImage.bind(ctx);
ctx.drawImage = (image, ...args) => { used.push(image); drawImage(image, ...args); };
const c = newBiliardo(42);
const draw = label => {
const before = JSON.stringify(c);
drawBiliardo(ctx, c, def, bg, sheet);
if (before !== JSON.stringify(c)) throw new Error('Il renderer modifica la partita');
shots.push({ label, png: canvas.toDataURL('image/png').split(',')[1] });
};
c.step = 'settle'; draw('riposo');
if (!used.includes(bg) || !used.includes(sheet.img)) throw new Error('Tavolo o Ingegnere non disegnati');
const pixel = (x, y) => [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3);
const bright = p => p.reduce((s, v) => s + v, 0);
if (bright(pixel(320, 180)) <= bright(pixel(85, 180))) throw new Error('Manca la luce centrale');
if (pixel(177, 22).join() !== '255,90,61') throw new Error('Cifra rossa a sette segmenti assente');
if (pixel(177, 30).join() !== '56,27,23') throw new Error('Lo zero accende il segmento centrale');
const b = c.balls.player, bx = VIEW.x + b.x * VIEW.k, by = VIEW.y + b.y * VIEW.k;
const colors = new Set();
for (let y = Math.ceil(by - 3); y <= by + 3; y++) for (let x = Math.ceil(bx - 3); x <= bx + 3; x++) colors.add(pixel(x, y).join());
if (colors.size > 4 || !colors.has('255,253,244')) throw new Error('Biglia senza palette ridotta o riflesso quadrato');
c.step = 'aim'; c.current = 'cpu'; draw('tozzi-chalk');
c.last = { shooter: 'cpu', to: 'cpu', points: 4, foul: null, age: 0 }; c.points.cpu = 4; draw('tozzi-cheer');
c.last = { shooter: 'cpu', to: 'player', points: 2, foul: 'miss', age: 0 }; draw('tozzi-shrug');
c.last = null; c.current = 'player'; c.points.cpu = 0;
shoot(c); update(c, 0.1); draw('stecca-arretrata'); update(c, 0.07); draw('stecca-avanti'); update(c, 0.08); draw('biglie-partite');
const pins = newBiliardo(9);
Object.assign(pins.balls.player, { x: LEN / 2 - 60, y: WID / 2 });
Object.assign(pins.balls.cpu, { x: LEN / 2 - 30, y: WID / 2 });
Object.assign(pins.balls.pallino, { x: 20, y: 20 });
pins.angle = 0; pins.power = 0.5; shoot(pins);
for (let n = 0; n < 1000 && !pins.pins.some(p => p.down); n++) update(pins, 1 / 240);
update(pins, 0.25); update(pins, 0.04);
drawBiliardo(ctx, pins, def, bg, sheet);
shots.push({ label: 'birilli-caduta', png: canvas.toDataURL('image/png').split(',')[1] });
return shots;
});
for (const { label, png } of captures) writeFileSync(`${out}/${name}-${label}.png`, Buffer.from(png, 'base64'));
}
try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [
['desktop', false, { width: 1280, height: 860 }],
['touch', true, { width: 844, height: 390 }],
['portrait', true, { width: 390, height: 844 }],
]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash');
await page.click('#new-game');
await page.click('#rione [data-rione="campomigliaio"]');
await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
settle(game);
game.go('circolo_4_maggio'); settle(game);
game.act('talk', 'ing_tozzi'); settle(game);
game.choose(game.options().findIndex(o => o.text.includes('una partita ai birilli'))); settle(game);
game.choose(game.options().findIndex(o => o.text === 'Seria.')); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => {
const { hasBackground } = await import('/src/engine/render.js');
return hasBackground('assets/closeups/biliardo-tavolo.png');
});
const canvas = await page.locator('#scene').boundingBox();
for (const id of ['#sound', '#corner']) {
const box = await page.locator(id).boundingBox();
assert.ok(box.y + box.height <= canvas.y, `${id} non deve coprire il punteggio`);
}
const exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= canvas.y + canvas.height, 'uscita fuori dal tavolo');
await page.waitForTimeout(1600); // completa la dissolvenza della sala prima delle schermate
const reset = () => page.evaluate(async () => {
const { newBiliardo } = await import('/src/engine/biliardo.js');
game.closeup = { ...newBiliardo(42), id: 'biliardo' };
});
const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step }));
const position = async () => {
const r = await page.locator('#scene').boundingBox();
const [x, y] = await page.evaluate(async () => {
const { VIEW } = await import('/src/engine/biliardo.js');
const b = game.closeup.balls.cpu;
return [VIEW.x + b.x * VIEW.k, VIEW.y + (b.y + 3) * VIEW.k];
});
return { x: r.x + x / 640 * r.width, y: r.y + y / 360 * r.height, r };
};
const target = await position();
if (!mobile) {
// Prima del clic la stecca segue il mouse (Andrea, 25 ottobre: «non riesco più a girare la stecca»).
const before = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.5, target.r.y + target.r.height * 0.85, { steps: 4 });
assert.notEqual((await state()).angle, before, 'senza clic la stecca gira col mouse');
}
if (mobile) await page.touchscreen.tap(target.x, target.y);
else await page.mouse.click(target.x, target.y);
assert.equal((await state()).step, 'aim', 'un tocco sceglie la mira senza tirare');
assert.equal((await state()).power, 0.45, 'il tocco conserva la forza impostata');
// Il passaggio del mouse verso i comandi attraversa il panno.
const fixed = (await state()).angle;
await page.mouse.move(target.r.x + target.r.width * 0.55, target.r.y + target.r.height * 0.8, { steps: 8 });
assert.equal((await state()).angle, fixed, 'spostarsi verso Tira non deve cambiare la mira');
await page.keyboard.press('ArrowRight');
const nudged = (await state()).angle;
assert.ok(Math.abs(nudged - fixed - 0.002) < 1e-10);
await page.keyboard.press('Shift+ArrowLeft');
assert.ok(Math.abs((await state()).angle - nudged + 0.0005) < 1e-10);
await page.locator('#bocce-power').fill('60');
const prepared = await state();
await page.screenshot({ path: `${out}/${name}-mira.png` });
await checkArtwork(page, name);
const expected = await page.evaluate(async () => {
const { simulate } = await import('/src/engine/biliardo.js');
const c = game.closeup;
return simulate(c, 'player', c.angle, c.power);
});
if (mobile) await page.tap('#bocce-throw'); else await page.click('#bocce-throw');
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, prepared.angle);
const score = await page.evaluate(async () => {
const { update, judge, FIXED_STEP } = await import('/src/engine/biliardo.js');
const c = game.closeup;
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
return judge(c.shot);
});
assert.deepEqual(score, expected, 'il tiro dai comandi segue la simulazione');
await reset();
const hold = await position();
const cdp = mobile ? await page.context().newCDPSession(page) : null;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.25);
const heldAngle = (await state()).angle;
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: hold.x - 30, y: hold.y + 20 }] });
else await page.mouse.move(hold.x - 30, hold.y + 20, { steps: 4 });
assert.equal((await state()).angle, heldAngle, 'il movimento durante la carica non sposta la mira');
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
else await page.mouse.up();
assert.equal((await state()).step, 'rolling');
assert.equal((await state()).angle, heldAngle);
// Un gesto interrotto non deve lasciare un tiro in carica.
await reset();
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: hold.x, y: hold.y }] });
else { await page.mouse.move(hold.x, hold.y); await page.mouse.down(); }
await page.waitForFunction(() => game.closeup.holding);
if (mobile) await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
else { await page.locator('#scene').dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); }
assert.equal((await state()).step, 'aim');
assert.equal((await state()).power, 0.45);
assert.equal(await page.evaluate(() => game.closeup.holding), false);
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => game.closeup), null);
await page.close();
console.log(`${name}: mira, ritocchi, forza, tiro, carica e annullamento OK`);
}
assert.deepEqual(errors, [], 'nessun errore JavaScript nel browser');
console.log(`Schermate: ${out}`);
} finally {
await browser?.close();
server.kill();
}
+290
View File
@@ -0,0 +1,290 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP } from '../src/engine/biliardo.js';
import { 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++) {
// Una mano comprende anche il tempo in cui la CPU pensa prima di tirare.
for (let i = 0; i < 600 && c.step === 'aim' && c.current === 'cpu'; i++) update(c, 1 / 60);
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ù');
});
const emptyTable = () => {
const c = newBiliardo(123);
c.pins = [];
Object.assign(c.balls.player, { x: 30, y: 30 });
Object.assign(c.balls.cpu, { x: 250, y: 120 });
Object.assign(c.balls.pallino, { x: 20, y: 120 });
return c;
};
const roll = (c, dt = FIXED_STEP) => {
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, dt);
assert.equal(c.step, 'settle', 'tutte le biglie devono fermarsi');
};
const near = (actual, expected, tolerance = 1e-6) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≠ ${expected} (tolleranza ${tolerance})`);
test('la guida e il tiro finiscono nello stesso punto, anche dopo sponde oblique e a diverse forze', () => {
for (const [x, y, angle, power] of [[30, 30, 0, 0.2], [270, 30, 0.65, 0.85], [260, 100, 0.1, 1], [30, 10, -1.2, 0.6]]) {
const c = emptyTable();
Object.assign(c.balls.player, { x, y });
c.angle = angle; c.power = power;
const before = JSON.stringify(c), p = predict(c);
assert.equal(JSON.stringify(c), before, 'la previsione non altera il tavolo né il seme casuale');
assert.equal(p.hit, null);
const end = p.points.at(-1);
shoot(c); roll(c);
near(c.balls.player.x, end[0]); near(c.balls.player.y, end[1]);
if (angle === 0) near(c.balls.player.x - x, reach(speedFor(power)), 0.01);
}
});
test('la sponda smorza la componente normale: anche la guida cambia angolo al rimbalzo', () => {
const c = emptyTable();
Object.assign(c.balls.player, { x: 270, y: 30 });
c.angle = Math.PI / 4; c.power = 0.3;
const p = predict(c), [start, rail, end] = p.points;
assert.equal(p.points.length, 3);
near(rail[0], LEN - R);
near((rail[1] - start[1]) / (rail[0] - start[0]), 1);
near((end[1] - rail[1]) / (end[0] - rail[0]), -1 / CUSHION);
});
test('i colpi sottili arrivano al punto di contatto corretto anche a piena forza', () => {
for (const offset of [0, R, 2 * R * 0.9, 2 * R - 0.0001, 2 * R + 0.0001]) {
const c = emptyTable();
Object.assign(c.balls.player, { x: 30, y: 50 + offset });
Object.assign(c.balls.cpu, { x: 100, y: 50 });
c.angle = 0; c.power = 1;
const p = predict(c);
shoot(c);
for (let i = 0; i < 1000 && c.balls.player.x < 110 && !c.shot.first; i++) update(c, FIXED_STEP);
if (offset > 2 * R) {
assert.equal(c.shot.first, null, 'un passaggio esterno non diventa un urto');
continue;
}
assert.equal(p.hit, 'cpu');
assert.equal(c.shot.first, 'cpu', 'non deve attraversare la biglia nei colpi radenti');
near(p.ghost.x, 100 - Math.sqrt((2 * R) ** 2 - offset ** 2));
near(p.ghost.y, 50 + offset);
const speed = Math.hypot(c.balls.cpu.vx, c.balls.cpu.vy);
near(c.balls.cpu.vx / speed, p.dir[0]); near(c.balls.cpu.vy / speed, p.dir[1]);
const target = p.outgoing.find(t => t.role === 'cpu').points;
near((target[1][0] - target[0][0]) / Math.hypot(target[1][0] - target[0][0], target[1][1] - target[0][1]), p.dir[0]);
}
});
test('gli urti conservano la quantità di moto e non creano energia; le biglie che si separano non rimbalzano di nuovo', () => {
const a = { x: 40, y: 50, vx: 300, vy: 80 }, b = { x: 40 + 2 * R, y: 50, vx: -20, vy: -15 };
const energy = a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2;
assert.ok(collide(a, b) > 0);
near(a.vx + b.vx, 280); near(a.vy + b.vy, 65);
assert.ok(a.vx ** 2 + a.vy ** 2 + b.vx ** 2 + b.vy ** 2 < energy);
const after = JSON.stringify([a, b]);
assert.equal(collide(a, b), 0);
assert.equal(JSON.stringify([a, b]), after);
});
test('la mira è bloccata durante la carica, il tocco breve ripristina la forza e le frecce consentono ritocchi fini', () => {
const c = newBiliardo(1), angle = c.angle, power = c.power;
press(c);
assert.equal(aimAt(c, 100, 100), false);
assert.equal(nudge(c, 1), false);
update(c, 0.05); release(c);
assert.equal(c.step, 'aim'); assert.equal(c.angle, angle); assert.equal(c.power, power);
nudge(c, 1); near(c.angle - angle, 0.002);
nudge(c, -1, true); near(c.angle - angle, 0.0015);
const fixed = c.angle;
assert.equal(aimScreen(c, [320, 30]), false, 'il pannello superiore non è una superficie di mira');
assert.equal(c.angle, fixed);
press(c); update(c, 0.2); cancel(c);
assert.equal(c.power, power);
press(c); update(c, 0.2); release(c);
update(c, WINDUP + 1 / 240); // la stecca arriva alla biglia
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
});
test('anteprima, CPU e tiro reale condividono ordine degli urti e punteggio', () => {
for (const [angle, power] of [[0, 1], [0.15, 0.5], [-0.6, 0.8], [2.4, 0.6]]) {
const c = newBiliardo(1);
Object.assign(c.balls.player, { x: 100, y: 71 });
Object.assign(c.balls.cpu, { x: 126, y: 71 });
c.angle = angle; c.power = power;
const p = predict(c), expected = simulate(c, 'player', angle, power);
shoot(c); roll(c);
assert.deepEqual(judge(c.shot), expected);
if (p.hit === 'cpu' || p.hit === 'pallino') assert.equal(c.shot.first, p.hit);
if (angle === 0) assert.ok(c.shot.pins.every(pin => pin.by === 'cpu'));
}
});
test('la fisica resta identica a 30, 60, 144 fps e con fotogrammi irregolari', () => {
const states = [];
for (const frames of [[1 / 30], [1 / 60], [1 / 144], [0.008, 0.042, 0.016, 0.09]]) {
const c = newBiliardo(1);
c.angle += 0.02; c.power = 0.9;
shoot(c);
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, frames[i % frames.length]);
assert.equal(c.step, 'settle');
states.push({ balls: c.balls, score: judge(c.shot) });
}
for (const s of states.slice(1)) assert.deepEqual(s, states[0]);
});
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('talk', 'ing_tozzi'));
settle(g);
g.choose(g.options().findIndex(o => o.text === 'Ingegnere, una partita ai birilli?'));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Si va a venti')));
assert.deepEqual(g.options().map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.'], 'prima si sceglie quanto fa sul serio');
g.choose(g.options().findIndex(o => o.text === 'A fassi male.'));
settle(g);
assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'male');
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('talk', 'ing_tozzi'); settle(g);
assert.ok(g.options().some(o => o.text === 'La rivincita?'), 'vinto, si chiede la rivincita');
g.choose(g.options().findIndex(o => o.text === 'La rivincita?')); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g);
assert.equal(g.closeup?.id, 'biliardo');
assert.equal(g.closeup.level, 'bona');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?')));
});
test('i livelli dell\'Ingegnere: «Alla bona» prova pochi colpi e trema, «A fassi male» li prova tutti', () => {
const bona = newBiliardo(1, { level: 'bona' }), male = newBiliardo(1, { level: 'male' }), none = newBiliardo(1);
assert.equal(none.level, 'seria', 'senza scelta si gioca sul serio');
assert.ok(bona.skill.wobble > male.skill.wobble * 3);
assert.ok(bona.skill.angles < male.skill.angles);
});
+14 -1
View File
@@ -1,6 +1,6 @@
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 { LEVELS, 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';
@@ -83,7 +83,11 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale
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.deepEqual(g.options().map(o => o.text), ['Alla bona.', 'Seria.', 'A fassi male.'], 'prima si sceglie quanto fa sul serio');
g.choose(g.options().findIndex(o => o.text === 'Seria.'));
settle(g);
assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.closeup.level, 'seria');
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;
@@ -108,7 +112,16 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale
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);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.'));
settle(g);
assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.closeup.level, 'male');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?')));
});
test('i livelli di Mariano: alla bona si allarga, a fassi male boccia spesso', () => {
assert.equal(newBocce(1).level, 'seria');
assert.ok(LEVELS.bona.spread > LEVELS.seria.spread && LEVELS.seria.spread > LEVELS.male.spread);
assert.ok(LEVELS.male.attack > LEVELS.bona.attack);
});
+53
View File
@@ -375,6 +375,8 @@ try {
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.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await bocce.locator('#choices li', { hasText: 'Seria.' }).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);
@@ -390,6 +392,40 @@ try {
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, 'ing_tozzi'));
await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
await b.locator('#choices li', { hasText: 'Seria.' }).click();
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(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
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');
@@ -416,6 +452,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"]');
+1
View File
@@ -83,6 +83,7 @@ test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo ri
test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => {
assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura');
assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno'));
assert.equal(soundscapeFor('circolo_4_maggio'), 'biliardo', 'nella sala si sentono gli altri tavoli');
for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird'));
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true });
+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;
});
+16 -1
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.picture ? [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}`);
}
@@ -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);
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""La musica della sala dei biliardi del Circolo 4 Maggio (assets/audio/biliardo.mp3), sintetizzata da zero: un trio
jazz da circolo in si bemolle, con lo swing: contrabbasso che cammina, piano elettrico che accompagna, piatto e spazzole,
e una melodia originale di vibrafono. Sedici battute che chiudono sul giro di ritorno: il loop non si sente.
python3 tools/jazz_biliardo.py # scrive assets/audio/biliardo.mp3 (serve ffmpeg)
"""
import math
import random
import subprocess
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
BPM = 104
BEAT = 60 / BPM
SWING = 2 / 3 # la croma in levare cade ai due terzi del movimento
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "biliardo.mp3"
# Il giro: un accordo per battuta. Basso (fondamentale), qualità, voci del piano (senza fondamentale).
CHORDS = [
("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
("Bb1", "maj", ["D3", "F3", "A3", "C4"]), ("Bb1", "dom", ["D3", "Ab3", "C4", "F4"]),
("Eb2", "maj", ["G3", "Bb3", "D4", "F4"]), ("E2", "dim", ["E3", "G3", "Bb3", "Db4"]),
("D2", "min", ["F3", "A3", "C4", "E4"]), ("G1", "dom", ["F3", "B3", "D4", "E4"]),
("C2", "min", ["Eb3", "G3", "Bb3", "D4"]), ("F1", "dom", ["Eb3", "A3", "C4", "D4"]),
]
THIRD_FIFTH = {"maj": (4, 7), "dom": (4, 7), "min": (3, 7), "dim": (3, 6)}
# La melodia del vibrafono: (movimento dall'inizio, nota, durata in movimenti). I levare cadono a .66.
S = SWING
MELODY = [
(0, "F5", 1.5), (1 + S, "D5", 1 - S), (2, "C5", S), (2 + S, "Bb4", 1.3),
(5, "D5", 1), (6, "F5", S), (6 + S, "E5", 1.3),
(8, "Eb5", 1.5), (9 + S, "G5", 1 - S), (10, "Bb5", 2),
(12 + S, "A5", 1 - S), (13, "G5", S), (13 + S, "F5", 1 - S), (14, "Eb5", 1), (15, "C5", 1),
(16, "D5", 1), (17, "F5", S), (17 + S, "A5", 1.3), (19 + S, "G5", 1 - S),
(20, "B4", 1), (21, "D5", 1), (22, "F5", 1.5),
(24, "Eb5", S), (24 + S, "D5", 1 - S), (25, "C5", 1), (26, "G4", 2),
(29, "A4", S), (29 + S, "C5", 1 - S), (30, "Eb5", 1), (31, "D5", 1),
(33 + S, "F5", 1 - S), (34, "D5", 1.6),
(36, "Ab5", 1.5), (37 + S, "G5", 1 - S), (38, "F5", 2),
(40, "G5", 1), (41, "Bb5", 1), (42, "F5", 2),
(44, "E5", 1), (45, "G5", 1), (46, "Bb5", 1.8),
(48, "F5", 1.5), (49 + S, "E5", 1 - S), (50, "D5", 2),
(52, "B4", 1), (53, "D5", S), (53 + S, "F5", 1.3),
(56, "Eb5", 1.5), (57 + S, "D5", 1 - S), (58, "C5", 2),
(60, "A4", 1), (61, "C5", 1), (62, "F5", 1.8),
]
DUR = len(CHORDS) * 4 * BEAT
buf = [0.0] * int(SR * DUR)
rng = random.Random(4)
def midi(note: str) -> int:
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 len(note) > 2 and note[1] == "b" else 0)
return semis + 12 * (int(note[-1]) + 1)
def hz(m: int) -> float:
return 440 * 2 ** ((m - 69) / 12)
def add(start: float, samples) -> None:
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
n0 = int(start * SR)
for i, v in enumerate(samples):
buf[(n0 + i) % len(buf)] += v
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.008,
tremolo: float = 0.0, rate: float = 5.0, release: float = 0.08):
n = int((length + release) * SR)
out = []
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
trem = 1 - tremolo * (0.5 + 0.5 * math.sin(2 * math.pi * rate * t))
out.append(vol * env * trem * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) for k, a, d in partials))
return out
def noise(length: float, vol: float, decay: float, bright: bool, attack: float = 0.002):
"""Rumore filtrato alla buona: la differenza fra campioni vicini è il piatto, la media le spazzole."""
n, out, prev, low = int(length * SR), [], 0.0, 0.0
for i in range(n):
t = i / SR
x = rng.uniform(-1, 1)
if bright:
v, prev = x - prev, x
else:
low += 0.08 * (x - low)
v = low * 3
out.append(vol * min(1.0, t / attack) * math.exp(-t / decay) * v)
return out
at = lambda beat: beat * BEAT # noqa: E731
LONG = 99
for bar, (root, quality, voices) in enumerate(CHORDS):
b0 = bar * 4
# Contrabbasso: fondamentale, terza, quinta, poi un semitono vicino alla fondamentale dopo.
r = midi(root)
third, fifth = THIRD_FIFTH[quality]
nxt = midi(CHORDS[(bar + 1) % len(CHORDS)][0])
walk = [r, r + third, r + fifth, nxt + (1 if bar % 2 else -1)]
for k, m in enumerate(walk):
add(at(b0 + k), tone(hz(m), BEAT * 0.92, 0.26, ((1, 1.0, LONG), (2, 0.45, 0.5), (3, 0.12, 0.25)), decay=0.7))
# Piano elettrico: colpi corti sul levare del due e sul quattro, una battuta sì e una no un po' diverso.
hits = [(1 + SWING, 0.5), (3, 0.9)] if bar % 2 == 0 else [(0 + SWING, 0.4), (2 + SWING, 1.2)]
for beat, length in hits:
for v in voices:
add(at(b0 + beat), tone(hz(midi(v)), BEAT * length, 0.045, ((1, 1.0, LONG), (2, 0.22, 0.6), (7, 0.05, 0.05)),
decay=1.1, tremolo=0.2, rate=4.6))
# Piatto: ogni movimento, più il levare del due e del quattro; spazzole sul due e sul quattro; cassa leggerissima.
for k in range(4):
add(at(b0 + k), noise(0.35, 0.05 if k % 2 else 0.035, 0.18, bright=True))
if k % 2:
add(at(b0 + k + SWING), noise(0.25, 0.03, 0.12, bright=True))
add(at(b0 + k) - 0.04, noise(0.2, 0.05, 0.09, bright=False, attack=0.05))
else:
add(at(b0 + k), tone(55, 0.12, 0.05, ((1, 1.0, LONG),), decay=0.08))
for beat, note, length in MELODY:
# Vibrafono: la nota, un armonico alto che sparisce subito, il tremolo del motore.
add(at(beat), tone(hz(midi(note)), BEAT * length, 0.11, ((1, 1.0, LONG), (4, 0.18, 0.12), (10, 0.05, 0.03)),
decay=1.6, tremolo=0.25, rate=5.6, release=0.4))
peak = max(abs(x) for x in buf) or 1
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "biliardo.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.85 * 32767) for x in buf)).tobytes())
# Una saletta piccola: poco riverbero, niente alti taglienti.
af = "aecho=0.8:0.6:45|95:0.18|0.1,lowpass=f=9000,volume=1.3"
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 -13
View File
@@ -1,16 +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" },
"mariano": { "speaker": "Gilberto Mathias" },
"fra_peppino": { "speaker": "Ludvig Milivoj" },
"macchina": { "speaker": "Andrew Chipper" }
"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"
}
}