Compare commits

..
10 Commits
Author SHA1 Message Date
manzolo 151017d2e2 Versione 0.1.18 2026-10-27 21:25:07 +01:00
manzoloandClaude Opus 5.5 f193c6020f Via del Pignone col murale di Cistola, Via Provinciale verso la Pieve e Piazzetta Cambray Digny
Sfondi di Codex: Via del Pignone dalla foto di Andrea, con sul muro a destra il murale di Cistola (Davide Poggi,
«il talento di via del Pignone», consenso confermato da Andrea) alla maniera di quelli di Maradona, maglia a strisce
bianche e rosse; Via Provinciale e Piazzetta Cambray Digny da Street View (in IMMAGINI.md: da rifare prima di
pubblicarle su Pages). Il giro come il paese vero: rotonda della farmacia → Via Provinciale → Pieve, e a destra
Via del Pignone → Piazzetta → a sinistra Via de' Medici, a diritto la Piazza. Dalla Piazza, a sinistra, ora si va
in Piazzetta (non più al Comune né direttamente in Via de' Medici). Battute provvisorie e voci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 21:19:45 +01:00
manzolo 8a2e49036d Versione 0.1.17 2026-10-26 23:44:06 +01:00
manzoloandClaude Opus 5.5 1e5f66c2b6 Cartello: finché il browser aspetta il tocco per il suono, le scritte restano ferme e si vede solo «Tocca per cominciare» (prima era invisibile); al tocco partono insieme alla musichetta, e «(Poca)» cade sul suo rumorino (Andrea: «era carino che il suono comparisse appena si vedeva»)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 23:38:57 +01:00
manzolo b1640f1f12 Versione 0.1.16 2026-10-26 23:35:55 +01:00
manzoloandClaude Opus 5.5 0e7a50493c Il cartello d'apertura: online, su un sito mai visitato, il browser bloccava la musichetta senza un gesto (in locale no, ci si gioca da giorni); ora in quel caso il cartello aspetta un tocco («Tocca per cominciare») e poi parte coi suoi tempi. La Ghiacciomobile non ripete più le battute due volte (la scelta la dice già Andrea). Licenza (codice MIT, storia e grafica CC BY-NC-SA, eccezioni per persone vere, palio, Suno, voci XTTS con la CPML, font OFL, Wikipedia), README riscritto, scripts/check-public.sh e make check-public dentro make verify. Prove nel browser con l'autoplay permesso, più una per il caso bloccato
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 23:30:48 +01:00
manzoloandClaude Fable 5.1 c2cb9b0194 A Campomigliaio, accanto all'Arcu, il Betino (motorino verde e crema con la scritta sul serbatoio) e la Ghiacciomobile (la Corsa nera): sprite di Codex dalle foto di riferimento di Andrea, battute di Andrea (guardandoli, e parlando alla macchina che non risponde), voci; «Ghiacciomòbile» nella pronuncia
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 23:06:19 +01:00
manzoloandClaude Fable 5.1 5392f042b3 nginx: tipo MIME per jpg e mp4 (la foto del palio e i video arrivavano come application/octet-stream), cache lunga anche per loro e per il favicon; test che ogni estensione servita abbia il suo tipo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 22:47:32 +01:00
manzolo ca3035e33f Versione 0.1.15 2026-10-26 22:42:15 +01:00
manzoloandClaude Fable 5.1 22be1c306e I cinque video «Come si gioca» dentro il gioco: pulsante «Guarda il video» nell'aiuto di briscola, tressette, scopa, bocce e biliardo, player a tutto schermo con l'audio (il gioco e la musica si fermano, Esc torna al tavolo). video/pubblica.py li taglia dall'arrivo nel posto, li comprime per il web (~4,5 MB l'uno) e fa la copertina. Il favicon e i video copiati anche nell'immagine Docker (il Regolino online dava 404); test che ogni cartella di assets usata dal gioco sia nel Dockerfile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 22:37:19 +01:00
65 changed files with 732 additions and 102 deletions
+2
View File
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects COPY assets/effects /usr/share/nginx/html/assets/effects
COPY assets/closeups /usr/share/nginx/html/assets/closeups COPY assets/closeups /usr/share/nginx/html/assets/closeups
COPY assets/favicon /usr/share/nginx/html/assets/favicon
COPY assets/video /usr/share/nginx/html/assets/video
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice. # Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \ RUN cd /usr/share/nginx/html \
+71
View File
@@ -0,0 +1,71 @@
Licenze de «La Leggenda del Regolo»
Il progetto è un gioco gratuito e non commerciale. Ha due parti con licenze diverse: il codice, e tutto il resto.
================================================================================
1. IL CODICE: licenza MIT
================================================================================
Vale per tutto ciò che sta fuori dalle cartelle assets/, docs/ e video/: i sorgenti in src/, i test, gli strumenti
in tools/, gli script, index.html, style.css, i file di configurazione.
MIT License
Copyright (c) 2026 Andrea Manzi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of
the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
2. LA STORIA, LA GRAFICA, LA MUSICA: Creative Commons BY-NC-SA 4.0
================================================================================
Vale per la storia e i dialoghi (src/story/), i documenti in docs/, gli sfondi, i primi piani, le icone e gli sprite
dei personaggi inventati (assets/scenes, assets/closeups, assets/icons, assets/effects, assets/favicon, e in
assets/characters quelli che non ritraggono persone vere), le musiche composte e sintetizzate dagli script in tools/
(assets/audio/biliardo.mp3, carte.mp3, bocce.mp3, pieve.mp3, cartello.mp3) e i video «Come si gioca» (assets/video).
© 2026 Andrea Manzi. Si possono copiare, ridistribuire e rielaborare citando l'autore, per scopi non commerciali,
e distribuendo le opere derivate con la stessa licenza. Testo completo: https://creativecommons.org/licenses/by-nc-sa/4.0/
Gli sfondi sono stati disegnati con strumenti di generazione di immagini (OpenAI) a partire da fotografie di San
Piero a Sieve, quasi tutte di Andrea Manzi; la provenienza di ognuno è registrata in docs/IMMAGINI.md (la foto
dell'interno della Pieve è di Mariano Mirannalti, citato nei Ringraziamenti).
================================================================================
3. ECCEZIONI: cose che NON si possono riusare, o che hanno una licenza propria
================================================================================
a) Le persone vere. I ritratti in pixel nei Ringraziamenti (assets/credits/) e gli sprite dei personaggi ispirati a
persone vere (vecchietti, Mariano, l'Ing. Tozzi, il Tozzino, il BaronDeDuke, l'Arcu, Fra Peppino e gli altri
indicati nei commenti di src/story/story.js) sono pubblicati con il consenso delle persone ritratte, SOLO per
questo gioco: tutti i diritti riservati, non si possono riusare altrove. Lo stesso vale per i nomi e i ricordi
delle persone del paese che compaiono nella storia.
b) La fotografia del palio del 2007 (assets/closeups/palio-2007.jpg) ritrae il quadro dipinto dai ragazzi delle
scuole medie di San Piero a Sieve: tutti i diritti riservati agli autori, pubblicata qui per il gioco.
c) Le musiche di Andrea Manzi generate con Suno (assets/audio/titolo.mp3, paese.mp3, sera.mp3, fortezza.mp3,
ricordi.mp3) si usano alle condizioni d'uso di Suno e non sono coperte dalla licenza Creative Commons.
d) Le voci dei personaggi (assets/voices/) e la voce narrante dei video (assets/video/) sono sintetizzate con
XTTS-v2 di Coqui, distribuito con la Coqui Public Model License (https://coqui.ai/cpml, testo in
docs/LICENZA-CPML.txt): i file audio che ne derivano si possono usare SOLO per scopi non commerciali.
e) Il carattere Press Start 2P di CodeMan38 (assets/fonts/) è distribuito con la SIL Open Font License 1.1: il testo
della licenza è in assets/fonts/OFL-press-start-2p.txt.
f) L'aneddoto della Torre dello Scoronconcolo riprende, riformulati, i fatti della voce «Michele del Tavolaccino»
di Wikipedia in italiano (CC BY-SA 4.0): https://it.wikipedia.org/wiki/Michele_del_Tavolaccino
Monkey Island e La storia infinita sono citati come ricordi: i marchi e le opere restano dei rispettivi titolari.
+5 -2
View File
@@ -23,7 +23,7 @@ DST ?= assets/scenes/$(notdir $(SRC))
CROP ?= CROP ?=
COLORS ?= 64 COLORS ?= 64
.PHONY: help serve open test browser verify bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \ .PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
build up down restart logs ps shell image-size deploy deploy-logs clean distclean build up down restart logs ps shell image-size deploy deploy-logs clean distclean
## ------------------------------------------------------------ sviluppo ## ------------------------------------------------------------ sviluppo
@@ -50,7 +50,10 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
browser: ## Partita completa nel browser con Playwright browser: ## Partita completa nel browser con Playwright
$(NODE) tests/browser.mjs $(NODE) tests/browser.mjs
verify: test browser ## Tutti i controlli verify: test browser check-public ## Tutti i controlli
check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia)
bash scripts/check-public.sh
bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy
scripts/bump.sh $(or $(PARTE),patch) scripts/bump.sh $(or $(PARTE),patch)
+46 -61
View File
@@ -1,81 +1,66 @@
# La Leggenda del Regolo # La Leggenda del Regolo
Avventura grafica punta-e-clicca ambientata a San Piero a Sieve, nella settimana del Palio della Fortezza del 2007. Un'avventura grafica punta-e-clicca, nello spirito delle avventure anni '90, ambientata a **San Piero a Sieve**
Il palio, il quadro dipinto dai ragazzi delle medie, è sparito: un ragazzo del paese deve ritrovarlo prima della festa. (Mugello) nella settimana del **Palio della Fortezza del 2007**. Il palio, il quadro dipinto dai ragazzi delle
medie, è sparito: Andrea, un ragazzo del paese, deve ritrovarlo prima della festa, fra vecchietti che litigano
sulle due leggende del Regolo, il serpente della Fortezza, e un paese disegnato dalle fotografie vere.
*Al Lele, che mi ha fatto conoscere Monkey Island.* *Al Lele, che mi ha fatto conoscere Monkey Island.*
Progetto in fase di ideazione: il documento di progetto è in [`docs/PROGETTO.md`](docs/PROGETTO.md). Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero), nel browser, anche dal telefono.
Per ora c'è un prototipo del motore con due scene di prova e grafica provvisoria. È gratuito e non commerciale.
## Generare sfondi dalle fotografie ## Cosa c'è
Il generatore Python è `tools/genera.py`; istruzioni e prompt delle due nuove vedute - **Il primo capitolo**, dal pomeriggio alla sera: 38 scene, i dialoghi a scelte, l'inventario, la mappa del
sono descritti in [`docs/GENERAZIONE_SFONDI.md`](docs/GENERAZIONE_SFONDI.md). paese, il taccuino, il salvataggio automatico (e su file, per portarlo su un altro dispositivo).
Lavora sulle foto di **San Piero a Sieve**, conserva i master a 1536×864 e permette - **Le voci** di tutti i personaggi, in toscano, e le **musiche**.
di prepararne copie a 640×360 per il gioco. - **I giochi del paese**: la briscola, il tressette ai 21 e la scopa all'oratorio (con le carte toscane o le
napoletane), le bocce al pallaio e il biliardo a cinque birilli al circolo, ognuno con tre livelli e un video
```zsh «Come si gioca».
python3 tools/genera.py --dry-run # elenco delle foto, nessuna API e nessun file scritto - **Il paese vero**: gli sfondi sono disegnati dalle fotografie di San Piero; i personaggi, con il loro
``` consenso, da persone vere.
## Voci dei personaggi
Ogni battuta ha la sua voce, generata con XTTS-v2 sulla GPU (licenza solo non commerciale): un mp3 per battuta in
`assets/voices/`, la voce di ogni personaggio in `tools/voci.json` (castello e leone con altezza ed eco proprie).
```zsh
make voci # solo le battute nuove o cambiate; i file che non servono più si tolgono
make voci RIFAI='Ci sei?' # ritenta una battuta storpiata (XTTS è casuale: di solito il secondo tentativo va)
make voci-elenco # cosa manca, senza generare
```
Il test `tests/voices.test.mjs` fallisce se una battuta non ha la sua voce: dopo aver cambiato un testo, `make voci`.
## Giocare in locale ## Giocare in locale
```zsh Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
python3 -m http.server 8000 # poi http://localhost:8000
```
Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il
clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo.
Il libro dell'oratorio si apre e si sfoglia con clic o tocco; Esc lo chiude. Nella fessura della Fortezza,
la torcia segue mouse o dito (anche trascinando): si cercano i dettagli tenendo brevemente la luce su di essi.
Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio. «Torna fuori» permette di
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
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 ```zsh
make up # http://localhost:8080 (DOCKER_PORT=… per cambiarla) python3 -m http.server 8000 # poi http://localhost:8000
make down
``` ```
## Struttura Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con `file://`: i moduli vogliono HTTP.
- `src/engine/`: motore senza storia (`game.js` logica e coda delle azioni, `geometry.js` area calpestabile e ## Come è fatto
percorsi, `command.js` verbi e clic, `render.js` disegno, `storage.js` salvataggio);
- `src/story/`: la storia come dati (`story.js`) e la sua soluzione completa (`solution.js`);
- `src/main.js`: collega tutto alla pagina;
- `assets/scenes/`: sfondi a 640×360, preparati con `tools/prepare_background.py` dagli originali in `assets/raw/`.
- `assets/characters/`: fogli dei personaggi, tagliati con `tools/slice_sprites.py` e allineati con
`tools/prepare_character.py` (istruzioni in testa al file).
## Test - `src/engine/`: il motore (camminata con i percorsi, verbi e clic intelligente, dialoghi, inventario,
salvataggi, musica, voci, suoni d'ambiente sintetizzati, effetti animati) e i minigiochi
(`bocce.js`, `biliardo.js`, `briscola.js`, `tressette.js`, `scopa.js`, ognuno col suo `*-render.js`).
- `src/story/`: la storia è dati, non codice: scene, oggetti, personaggi, dialoghi, soluzione (`solution.js`).
- `assets/`: sfondi, sprite, musiche, voci, carattere, video.
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi).
- `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre
tutta la soluzione dell'avventura, su desktop e telefono); `npm run browser:briscola`, `:tressette`, `:scopa`,
`:biliardo`, `:effects` per le parti singole.
- `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale.
- `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
```zsh ```zsh
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
npm install && npx playwright install chromium-headless-shell make voci # le voci delle battute nuove (XTTS-v2 su GPU)
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono make bump # nuova versione: verifica, commit e tag
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono make deploy # pubblica l'ultimo commit (immagine Docker spedita al server)
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
``` ```
## Chi ha fatto cosa
Un gioco di Andrea Manzi (Manzolo). Idee, ricordi, foto e volti: gli amici di San Piero, nei Ringraziamenti del
gioco. Programmazione con Claude e Codex; sfondi dalle fotografie di Andrea con gli strumenti di generazione di
immagini; musiche di Andrea con Suno e, per i giochi, sintetizzate da script; voci con XTTS-v2; carattere Press
Start 2P.
## Licenze
Il codice è MIT; storia, grafica e musiche sintetizzate sono Creative Commons BY-NC-SA 4.0; le persone vere
ritratte, la foto del palio, le musiche di Suno e le voci XTTS (solo uso non commerciale) hanno le loro
condizioni. Tutto in [`LICENSE`](LICENSE).
+14
View File
@@ -0,0 +1,14 @@
{
"image": "betino.png",
"height": 104,
"frames": {
"idle": {
"x": 0,
"y": 0,
"w": 161,
"h": 104,
"ax": 80.5,
"ay": 104
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"image": "ghiacciomobile.png",
"height": 144,
"frames": {
"idle": {
"x": 0,
"y": 0,
"w": 324,
"h": 144,
"ax": 162,
"ay": 144
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 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.
+56
View File
@@ -627,6 +627,34 @@
"file": "andrea-e24cb31cbf.mp3", "file": "andrea-e24cb31cbf.mp3",
"sec": 2.62 "sec": 2.62
}, },
"andrea|Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.": {
"file": "andrea-60f39f0dd7.mp3",
"sec": 6.48
},
"andrea|Cistola. Il talento di via del Pignone.": {
"file": "andrea-2b47355c2c.mp3",
"sec": 3.81
},
"andrea|Col dieci, come i grandi. Se c'è un muro che se lo merita, è questo.": {
"file": "andrea-1bd111f29d.mp3",
"sec": 5.59
},
"andrea|Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.": {
"file": "andrea-0e558e54fe.mp3",
"sec": 4.18
},
"andrea|Le mura della Fortezza medicea, mezze coperte dall'edera. Ne hanno viste più di me.": {
"file": "andrea-7b3b36edc6.mp3",
"sec": 5.93
},
"andrea|Una casetta piccina, appoggiata alle mura come un gatto al sole.": {
"file": "andrea-c3279de3ab.mp3",
"sec": 3.93
},
"andrea|Chiusa. E poi non è casa mia.": {
"file": "andrea-7fd73fbb77.mp3",
"sec": 2.66
},
"andrea|L'area feste. D'estate ci montano la pista da ballo, le tavolate e lo stand della pasta.": { "andrea|L'area feste. D'estate ci montano la pista da ballo, le tavolate e lo stand della pasta.": {
"file": "andrea-a942b2723e.mp3", "file": "andrea-a942b2723e.mp3",
"sec": 6.77 "sec": 6.77
@@ -943,6 +971,22 @@
"file": "andrea-00f2e70a4d.mp3", "file": "andrea-00f2e70a4d.mp3",
"sec": 2.95 "sec": 2.95
}, },
"andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
"file": "andrea-3e85200509.mp3",
"sec": 2.93
},
"andrea|O via, lascialo riposare. A pedalare oggi ci penso io.": {
"file": "andrea-34aad1ffe3.mp3",
"sec": 4.81
},
"andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-7be29434e0.mp3",
"sec": 7.25
},
"andrea|Con questo sole, di ghiaccio c'è rimasto giusto il nome. Vo a piedi.": {
"file": "andrea-def44809bc.mp3",
"sec": 5.08
},
"andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": { "andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": {
"file": "andrea-90bbad0de0.mp3", "file": "andrea-90bbad0de0.mp3",
"sec": 9.59 "sec": 9.59
@@ -1523,6 +1567,18 @@
"file": "andrea-60cdae2e35.mp3", "file": "andrea-60cdae2e35.mp3",
"sec": 5.1 "sec": 5.1
}, },
"andrea|Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?": {
"file": "andrea-2acf574bfa.mp3",
"sec": 5.36
},
"andrea|Riposa, Ghiacciomobile.": {
"file": "andrea-c79d2d1c81.mp3",
"sec": 2.19
},
"andrea|Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.": {
"file": "andrea-6c6e2fd461.mp3",
"sec": 5.69
},
"andrea|Una briscola?": { "andrea|Una briscola?": {
"file": "andrea-d2f285f610.mp3", "file": "andrea-d2f285f610.mp3",
"sec": 1.04 "sec": 1.04
+2
View File
@@ -27,6 +27,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `bosco-ai-frati.png` | Codex | `bosco-ai-frati.png` | [DA ANDREA] sembra una foto da Wikipedia/Wikimedia: verificare licenza | ? | | `bosco-ai-frati.png` | Codex | `bosco-ai-frati.png` | [DA ANDREA] sembra una foto da Wikipedia/Wikimedia: verificare licenza | ? |
| `campeggio.png` | Codex | `campeggio-ingresso.png` | [DA ANDREA] probabilmente dal sito del campeggio | ? | | `campeggio.png` | Codex | `campeggio-ingresso.png` | [DA ANDREA] probabilmente dal sito del campeggio | ? |
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì | | `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita | | `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti | | `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce | | `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
+84
View File
@@ -0,0 +1,84 @@
# Coqui Public Model License 1.0.0
https://coqui.ai/cpml.txt
This license allows only non-commercial use of a machine learning model and its outputs.
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Licenses
The licensor grants you a copyright license to do everything you might do with the model that would otherwise infringe the licensor's copyright in it, for any non-commercial purpose. The licensor grants you a patent license that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the model in the form provided by
the licensor, for any non-commercial purpose.
## Non-commercial Purpose
Non-commercial purposes include any of the following uses of the model or its output, but only so far as you do not receive any direct or indirect payment arising from the use of the model or its output.
### Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious
observance.
### Use by commercial or for-profit entities for testing, evaluation, or non-commercial research and development. Use of the model to train other models for commercial use is not a non-commercial purpose.
### Use by any charitable organization for charitable purposes, or for testing or evaluation. Use for revenue-generating activity, including projects directly funded by government grants, is not a non-commercial purpose.
## Notices
You must ensure that anyone who gets a copy of any part of the model, or any modification of the model, or their output, from you also gets a copy of these terms or the URL for them above.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the model infringes or contributes to infringement of any patent, your licenses for the model granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the model or its output that is not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 30 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the model and its output come as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the model or its output, under any kind of legal claim. If this provision is not enforceable in your jurisdiction, your licenses are void.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **model** is the model the licensor makes available under these terms, including any documentation or similar information about the model.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you under these terms.
**Use** means anything you do with the model or its output requiring one of your licenses.
+5
View File
@@ -199,6 +199,11 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
sfondi da foto prese da internet (`docs/IMMAGINI.md`), Cardetole (dice dove abita Andrea). sfondi da foto prese da internet (`docs/IMMAGINI.md`), Cardetole (dice dove abita Andrea).
- Vecchietta e vecchietto sono in gioco sulla panchina della Piazza (`assets/characters/`, da - Vecchietta e vecchietto sono in gioco sulla panchina della Piazza (`assets/characters/`, da
`assets/raw/vecchiett?-sheet.png`): consenso delle persone ritratte confermato da Andrea il 2026-10-23; mancano i loro nomi. `assets/raw/vecchiett?-sheet.png`): consenso delle persone ritratte confermato da Andrea il 2026-10-23; mancano i loro nomi.
- Via Provinciale (dalla rotonda verso la Pieve) → a destra Via del Pignone → in cima Piazzetta Cambray Digny, che porta
in Piazza (a sinistra, dove prima c'era l'uscita per il Comune) e in Via de' Medici. Battute provvisorie sul murale.
Via del Pignone (sfondo di Codex da `assets/raw/foto/via-del-pignone.png`): sul muro di destra il
murale di Cistola (Davide Poggi), «il talento di via del Pignone», alla maniera dei murali di Maradona a Napoli
(volto da `assets/raw/riferimenti/cistola.png`). Consenso di Davide confermato da Andrea il 2026-10-27.
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`). - Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea. - Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
+2 -1
View File
@@ -33,6 +33,7 @@
<p class="splash-name">Manzolo</p> <p class="splash-name">Manzolo</p>
<p class="splash-art"><span>(Poca)</span> Art</p> <p class="splash-art"><span>(Poca)</span> Art</p>
<p class="splash-present">Presents</p> <p class="splash-present">Presents</p>
<p class="splash-hint">Tocca per cominciare</p>
</section> </section>
<section id="title" class="overlay" hidden> <section id="title" class="overlay" hidden>
<h1>La Leggenda<br>del <span>Regolo</span></h1> <h1>La Leggenda<br>del <span>Regolo</span></h1>
@@ -94,7 +95,7 @@
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title"> <section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
<h2 id="tutorial-title">Come si gioca</h2> <h2 id="tutorial-title">Come si gioca</h2>
<video id="tutorial-video" controls playsinline preload="metadata"></video> <video id="tutorial-video" controls playsinline preload="metadata"></video>
<button id="tutorial-close">Torna al gioco</button> <div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
</section> </section>
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti"> <section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
<h2>Ringraziamenti</h2> <h2>Ringraziamenti</h2>
+3 -1
View File
@@ -26,6 +26,8 @@ server {
audio/mpeg mp3; audio/mpeg mp3;
image/svg+xml svg; image/svg+xml svg;
image/x-icon ico; image/x-icon ico;
image/jpeg jpg jpeg;
video/mp4 mp4;
font/woff2 woff2; font/woff2 woff2;
} }
@@ -37,7 +39,7 @@ server {
location ~* \.(css|js|mjs|json)$ { location ~* \.(css|js|mjs|json)$ {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
} }
location ~* \.(png|webp|mp3|woff2)$ { location ~* \.(png|webp|jpg|mp3|mp4|woff2|ico)$ {
add_header Cache-Control $media_cache; add_header Cache-Control $media_cache;
} }
location = /index.html { location = /index.html {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.14", "version": "0.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Prima di pubblicare: niente prompt, fogli grezzi, log di Codex, segreti o nomi della rete di casa.
# Guarda tutti i percorsi raggiungibili da tutti i ref (la storia intera, non solo HEAD) e il testo di HEAD.
# `--self-test` costruisce un repo usa e getta, inietta un prompt e un host .lan e pretende che siano rifiutati:
# un controllo che non fallisce mai non prova niente.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
FORBIDDEN_PATHS='(^|/)(PROMPT[A-Z_]*\.md|prompts/|assets/raw/|assets/generated/|codex[a-z.-]*\.log|deploy\.local\.mk)'
FORBIDDEN_PATHS+='|(^|/)\.env$|(^|/)id_(rsa|ed25519|ecdsa)(\.pub)?$|\.(pem|key|p12|pfx)$'
FORBIDDEN_TEXT='[a-z0-9-]+\.lan([^a-z0-9.-]|$)|192\.168\.[0-9]+\.[0-9]+|BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY'
scan_repo() {
local repo=$1 rc=0 hits
hits=$(git -C "$repo" rev-list --all --objects | awk 'NF>1{print $2}' | sort -u | grep -E "$FORBIDDEN_PATHS" || true)
if [[ -n $hits ]]; then
echo "FAIL: percorsi vietati nella storia (va riscritta, non basta cancellarli):"
echo "$hits" | head -40 | sed 's/^/ /'
rc=1
fi
hits=$(git -C "$repo" grep -nIE "$FORBIDDEN_TEXT" HEAD -- . ':!scripts/check-public.sh' 2>/dev/null | sed 's/^HEAD://' || true)
if [[ -n $hits ]]; then
echo "FAIL: testo vietato in HEAD:"
echo "$hits" | head -20 | sed 's/^/ /'
rc=1
fi
[[ $rc -eq 0 ]] && echo "PASS: niente prompt, fogli grezzi, segreti o host privati in $repo (tutti i ref)"
return $rc
}
self_test() {
local tmp
tmp=$(mktemp -d /tmp/check-public.XXXXXX)
trap 'rm -rf -- "$tmp"' RETURN
local -a g=(git -C "$tmp" -c user.name=check -c user.email=check@example.invalid -c commit.gpgsign=false)
git init -q "$tmp"
printf '# pulito\n' > "$tmp/README.md"
"${g[@]}" add README.md && "${g[@]}" commit -qm pulito
scan_repo "$tmp" >/dev/null || { echo 'self-test FAIL: un repo pulito deve passare'; return 1; }
mkdir -p "$tmp/prompts" && printf 'foto di famiglia\n' > "$tmp/prompts/character_x.md"
"${g[@]}" add prompts && "${g[@]}" commit -qm 'prompt iniettato'
"${g[@]}" rm -rq prompts && "${g[@]}" commit -qm 'cancellato (resta nella storia)'
if scan_repo "$tmp" >/dev/null 2>&1; then echo 'self-test FAIL: un prompt nella storia deve essere rifiutato'; return 1; fi
rm -rf "$tmp/.git" && git init -q "$tmp" && printf 'DEPLOY_HOST = root@casa.lan\n' > "$tmp/README.md"
"${g[@]}" add README.md && "${g[@]}" commit -qm 'host privato'
if scan_repo "$tmp" >/dev/null 2>&1; then echo 'self-test FAIL: un host .lan deve essere rifiutato'; return 1; fi
echo 'self-test PASS: repo pulito accettato, prompt nella storia e host .lan rifiutati'
}
[[ ${1:-} == --self-test ]] && { self_test; exit; }
scan_repo "$ROOT"
+12 -8
View File
@@ -946,7 +946,7 @@ $('restart').addEventListener('click', () => {
// incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina. // incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina.
const SPLASH_FADE = 4300, SPLASH_END = 5000; const SPLASH_FADE = 4300, SPLASH_END = 5000;
function showSplash() { function showSplash() {
let done = false, revealed = false; let done = false, revealed = false, waiting = false;
const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } }; const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } };
const end = () => { const end = () => {
if (done) return; if (done) return;
@@ -954,18 +954,22 @@ function showSplash() {
$('splash').hidden = true; $('splash').hidden = true;
jingle?.pause(); // saltato il cartello, si salta anche la musichetta jingle?.pause(); // saltato il cartello, si salta anche la musichetta
reveal(); reveal();
removeEventListener('keydown', onKey);
}; };
// La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto. const schedule = () => { setTimeout(reveal, SPLASH_FADE); setTimeout(end, SPLASH_END); };
// La musichetta: su un sito mai visitato il browser non la fa partire senza un gesto (in locale si fida, perché ci
// si gioca da giorni: Andrea, 26 ottobre, «online non sento gli effetti»). Allora il cartello aspetta il primo clic
// o tasto, che fa partire musichetta e conto alla rovescia; il secondo lo salta, come sempre.
const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null; const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null;
const begin = () => { waiting = false; $('splash').classList.remove('wait'); jingle.play().catch(() => {}); schedule(); };
const onKey = () => { if (waiting) begin(); else end(); };
if (jingle) { if (jingle) {
jingle.volume = music.muted ? 0 : music.volume; jingle.volume = music.muted ? 0 : music.volume;
jingle.play().catch(() => {}); jingle.play().then(schedule).catch(() => { waiting = true; $('splash').classList.add('wait'); });
window.jingle = jingle; // per i test nel browser window.jingle = jingle; // per i test nel browser
} } else schedule();
setTimeout(reveal, SPLASH_FADE); $('splash').addEventListener('click', () => { if (waiting) begin(); else end(); });
setTimeout(end, SPLASH_END); addEventListener('keydown', onKey);
$('splash').addEventListener('click', end);
addEventListener('keydown', end, { once: true });
} }
for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`; for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`;
+136 -14
View File
@@ -203,6 +203,14 @@ export const story = {
paper: '#fbe6bc', paper: '#fbe6bc',
}, },
}, },
// I video «Come si gioca» (video/SCALETTA-CARTE.md): il pulsante «Guarda il video» nell'aiuto di ogni gioco.
tutorials: {
briscola: { title: 'Come si gioca: la briscola', src: 'assets/video/briscola.mp4', poster: 'assets/video/briscola.jpg' },
tressette: { title: 'Come si gioca: il tressette ai 21', src: 'assets/video/tressette.mp4', poster: 'assets/video/tressette.jpg' },
scopa: { title: 'Come si gioca: la scopa', src: 'assets/video/scopa.mp4', poster: 'assets/video/scopa.jpg' },
bocce: { title: 'Come si gioca: le bocce', src: 'assets/video/bocce.mp4', poster: 'assets/video/bocce.jpg' },
biliardo: { title: 'Come si gioca: il biliardo', src: 'assets/video/biliardo.mp4', poster: 'assets/video/biliardo.jpg' },
},
characters: { characters: {
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale. // [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' }, giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
@@ -222,6 +230,8 @@ export const story = {
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea. // Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' }, fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' }, macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' },
betino: { name: 'il Betino', sprite: 'assets/characters/betino.json' },
ghiacciomobile: { name: 'la Ghiacciomobile', sprite: 'assets/characters/ghiacciomobile.json' },
}, },
items: { items: {
squama: { squama: {
@@ -470,10 +480,7 @@ export const story = {
id: 'verso_campeggio', name: 'strada verso il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'campeggio', at: [200, 345], face: 'right' }, id: 'verso_campeggio', name: 'strada verso il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
}, },
{ {
id: 'verso_villa_adami', name: 'Via de\' Medici, verso Villa Adami', box: [0, 196, 90, 50], at: [92, 250], exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' }, id: 'verso_piazzetta', name: 'Piazzetta Cambray Digny, verso Via de\' Medici', box: [0, 196, 146, 54], at: [92, 250], exit: { scene: 'piazzetta', at: [510, 274], face: 'down' },
},
{
id: 'verso_comune', name: 'a sinistra, verso il Comune', box: [96, 196, 50, 54], at: [120, 252], exit: { scene: 'comune', at: [20, 300], face: 'right' },
}, },
{ {
id: 'verso_calimara', name: 'Via Calimara, verso la farmacia', box: [180, 334, 380, 26], at: [360, 354], exit: { scene: 'via_calimara', at: [365, 212], face: 'down' }, id: 'verso_calimara', name: 'Via Calimara, verso la farmacia', box: [180, 334, 380, 26], at: [360, 354], exit: { scene: 'via_calimara', at: [365, 212], face: 'down' },
@@ -648,7 +655,7 @@ export const story = {
}, },
}, },
{ {
id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' }, id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'provinciale_pignone', at: [268, 216], face: 'down' },
}, },
{ {
id: 'verso_forno', name: 'a destra, verso il forno', box: [570, 176, 70, 76], at: [588, 250], exit: { scene: 'forno', at: [628, 300], face: 'left' }, id: 'verso_forno', name: 'a destra, verso il forno', box: [570, 176, 70, 76], at: [588, 250], exit: { scene: 'forno', at: [628, 300], face: 'left' },
@@ -825,7 +832,7 @@ export const story = {
}, },
], ],
}, },
// Via de' Medici: dalla Piazza sale verso Villa Adami (la Piazza è alle spalle, in fondo si va su). // Via de' Medici: dalla Piazzetta Cambray Digny sale verso Villa Adami (la Piazzetta è alle spalle, in fondo si va su).
via_de_medici: { via_de_medici: {
name: 'Via de\' Medici', name: 'Via de\' Medici',
map: [22, 32], map: [22, 32],
@@ -845,7 +852,7 @@ export const story = {
id: 'su_villa_adami', name: 'su verso Villa Adami', box: [272, 186, 60, 34], at: [300, 216], exit: { scene: 'villa_adami', at: [50, 252], face: 'right' }, id: 'su_villa_adami', name: 'su verso Villa Adami', box: [272, 186, 60, 34], at: [300, 216], exit: { scene: 'villa_adami', at: [50, 252], face: 'right' },
}, },
{ {
id: 'giu_piazza', name: 'giù verso la Piazza', box: [0, 334, 560, 26], at: [300, 354], exit: { scene: 'piazza', at: [92, 250], face: 'right' }, id: 'giu_piazzetta', name: 'giù verso Piazzetta Cambray Digny', box: [0, 334, 545, 26], at: [300, 354], exit: { scene: 'piazzetta', at: [20, 320], face: 'right' },
}, },
], ],
}, },
@@ -929,8 +936,8 @@ export const story = {
}, },
], ],
}, },
// Il Comune e l'asilo infantile Antonio Frilli: dalla Piazza (a sinistra, prima di Via de' Medici) o dalla // Il Comune e l'asilo infantile Antonio Frilli: dalla strada di fronte alla farmacia o da Via Calimara
// strada di fronte alla farmacia. // (a sinistra in Piazza, dove una volta si veniva qui, c'è la Piazzetta Cambray Digny: Andrea, 27 ottobre).
comune: { comune: {
name: 'Il Comune', name: 'Il Comune',
map: [11, 45], map: [11, 45],
@@ -959,9 +966,6 @@ export const story = {
open: [{ say: 'All\'asilo? Ho già dato, grazie.' }], open: [{ say: 'All\'asilo? Ho già dato, grazie.' }],
}, },
}, },
{
id: 'alla_piazza', name: 'su, verso la Piazza', box: [0, 250, 40, 110], at: [14, 300], exit: { scene: 'piazza', at: [120, 252], face: 'right' },
},
{ {
id: 'al_giardino', name: 'verso il giardino di Via Calimara', box: [596, 290, 44, 70], at: [600, 340], exit: { scene: 'via_calimara', at: [160, 290], face: 'right' }, id: 'al_giardino', name: 'verso il giardino di Via Calimara', box: [596, 290, 44, 70], at: [600, 340], exit: { scene: 'via_calimara', at: [160, 290], face: 'right' },
}, },
@@ -1059,7 +1063,7 @@ export const story = {
id: 'al_ponte', name: 'a sinistra, verso il campo sportivo', box: [0, 216, 60, 100], at: [12, 318], exit: { scene: 'ponte_sieve', at: [300, 350], face: 'up' }, id: 'al_ponte', name: 'a sinistra, verso il campo sportivo', box: [0, 216, 60, 100], at: [12, 318], exit: { scene: 'ponte_sieve', at: [300, 350], face: 'up' },
}, },
{ {
id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'pieve', at: [24, 312], face: 'right' }, id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'provinciale_pignone', at: [300, 345], face: 'up' },
}, },
{ {
id: 'area_feste', name: 'qui sotto, verso l\'area feste', box: [0, 318, 300, 42], at: [120, 352], exit: { scene: 'area_feste', at: [44, 350], face: 'up' }, id: 'area_feste', name: 'qui sotto, verso l\'area feste', box: [0, 318, 300, 42], at: [120, 352], exit: { scene: 'area_feste', at: [44, 350], face: 'up' },
@@ -1072,6 +1076,90 @@ export const story = {
}, },
], ],
}, },
// Via Provinciale fra la rotonda (alle spalle) e la Pieve (in fondo); a destra sale Via del Pignone.
provinciale_pignone: {
name: 'Via Provinciale',
map: [50, 64],
background: 'assets/scenes/provinciale-pignone.png',
walk: [[30, 360], [30, 330], [130, 270], [245, 212], [290, 212], [340, 228], [540, 232], [540, 258], [640, 300], [640, 360]],
depth: [[212, 0.32], [360, 1.2]],
entry: [300, 345],
hotspots: [
{
id: 'cartelli', name: 'cartelli', box: [570, 162, 52, 28], at: [560, 290], face: 'right',
actions: {
look: [{ say: 'Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.' }],
},
},
{
id: 'alla_pieve', name: 'in fondo, verso la Pieve', box: [236, 150, 64, 64], at: [268, 216], exit: { scene: 'pieve', at: [24, 312], face: 'right' },
},
{
id: 'via_pignone', name: 'a destra, Via del Pignone', box: [440, 196, 100, 56], at: [530, 245], face: 'right', exit: { scene: 'via_pignone', at: [300, 345], face: 'up' },
},
{
id: 'alla_rotonda', name: 'indietro, verso la rotonda della farmacia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' },
},
],
},
// Via del Pignone dal basso: sul muro a destra il murale di Cistola (Davide Poggi, consenso del 27 ottobre).
// In cima si arriva in Piazzetta Cambray Digny.
via_pignone: {
name: 'Via del Pignone',
map: [46, 54],
background: 'assets/scenes/via-del-pignone.png',
walk: [[0, 360], [0, 300], [150, 258], [290, 226], [335, 206], [352, 206], [350, 240], [345, 282], [640, 290], [640, 360]],
depth: [[206, 0.3], [360, 1.2]],
entry: [300, 345],
hotspots: [
{
id: 'murale', name: 'murale di Cistola', box: [430, 36, 170, 240], at: [470, 300], face: 'right',
actions: {
look: [{ say: 'Cistola. Il talento di via del Pignone.' }, { say: 'Col dieci, come i grandi. Se c\'è un muro che se lo merita, è questo.' }],
talk: [{ say: 'Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.' }],
},
},
{
id: 'su_piazzetta', name: 'su, verso Piazzetta Cambray Digny', box: [316, 176, 56, 40], at: [343, 210], exit: { scene: 'piazzetta', at: [610, 345], face: 'left' },
},
{
id: 'giu_provinciale', name: 'giù, in Via Provinciale', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'provinciale_pignone', at: [530, 245], face: 'left' },
},
],
},
// Piazzetta Cambray Digny, in cima a Via del Pignone: a sinistra Via de' Medici, a diritto la Piazza.
piazzetta: {
name: 'Piazzetta Cambray Digny',
map: [40, 38],
background: 'assets/scenes/piazzetta-cambray-digny.png',
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
depth: [[268, 0.55], [360, 1.15]],
entry: [320, 330],
hotspots: [
{
id: 'mura', name: 'mura della Fortezza', box: [60, 110, 230, 170], at: [180, 300], face: 'up',
actions: {
look: [{ say: 'Le mura della Fortezza medicea, mezze coperte dall\'edera. Ne hanno viste più di me.' }],
},
},
{
id: 'casetta_bianca', name: 'casetta bianca', box: [296, 196, 160, 90], at: [380, 292], face: 'up',
actions: {
look: [{ say: 'Una casetta piccina, appoggiata alle mura come un gatto al sole.' }],
open: [{ say: 'Chiusa. E poi non è casa mia.' }],
},
},
{
id: 'alla_piazza', name: 'a diritto, verso la Piazza', box: [470, 226, 70, 48], at: [510, 274], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
},
{
id: 'via_de_medici', name: 'a sinistra, Via de\' Medici', box: [0, 280, 40, 80], at: [20, 320], face: 'left', exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
},
{
id: 'giu_pignone', name: 'giù per Via del Pignone', box: [570, 300, 70, 60], at: [610, 345], exit: { scene: 'via_pignone', at: [343, 212], face: 'down' },
},
],
},
// L'area feste (il parco Antonio Berti): dalla rotonda del River, a sinistra. // L'area feste (il parco Antonio Berti): dalla rotonda del River, a sinistra.
area_feste: { area_feste: {
name: 'Area feste', name: 'Area feste',
@@ -1527,8 +1615,31 @@ export const story = {
entry: [320, 330], entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre). // L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] }, speakers: { arcu: [418, 214] },
people: [{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }], people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk.
{ who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] },
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
],
hotspots: [ hotspots: [
{
id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
// Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
},
},
{
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
actions: {
look: [{ say: 'La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
talk: [{ dialogue: 'ghiacciomobile' }],
use: [{ say: 'Con questo sole, di ghiaccio c\'è rimasto giusto il nome. Vo a piedi.' }],
},
},
{ {
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up', id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
actions: { actions: {
@@ -2160,6 +2271,17 @@ export const story = {
}, },
}, },
dialogues: { dialogues: {
// Andrea parla alla Ghiacciomobile (battute di Andrea, 26 ottobre): la macchina resta zitta, e va bene così.
ghiacciomobile: {
nodes: {
start: { options: [
// La scelta la dice già Andrea (game.choose): dopo, solo il silenzio della macchina.
{ text: 'Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?', then: [{ say: '...' }] },
{ text: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri.', say: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.', then: [{ say: '...' }] },
{ text: 'Riposa, Ghiacciomobile.', next: 'end' },
] },
},
},
giocatore_carte: { giocatore_carte: {
nodes: { nodes: {
start: { options: [ start: { options: [
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.14'; export const VERSION = '0.1.18';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // 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). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+10 -3
View File
@@ -164,6 +164,13 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#splash .splash-name { font-size: clamp(30px, 9vw, 88px); } #splash .splash-name { font-size: clamp(30px, 9vw, 88px); }
#splash .splash-art { font-size: clamp(20px, 5vw, 50px); } #splash .splash-art { font-size: clamp(20px, 5vw, 50px); }
#splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); } #splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); }
/* Compare solo se il browser aspetta un gesto per il suono (#splash.wait, main.js). */
#splash .splash-hint { display: none; margin-top: 2em; font-size: clamp(9px, 1.6vw, 14px); color: var(--dim); animation: blink 1.6s steps(1) infinite; }
#splash.wait .splash-hint { display: block; opacity: 1; }
/* Finché si aspetta il tocco le scritte restano ferme all'inizio: partono insieme alla musichetta, e la caduta di
«(Poca)» cade sul suo rumorino (Andrea, 26 ottobre). */
#splash.wait .splash-name, #splash.wait .splash-art, #splash.wait .splash-art span, #splash.wait .splash-present { animation-play-state: paused; }
@keyframes blink { 50% { opacity: .25; } }
#title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); } #title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); }
#title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); } #title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); }
#intro-text { font-size: max(11px, calc(var(--u) * 2)); } #intro-text { font-size: max(11px, calc(var(--u) * 2)); }
@@ -202,8 +209,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; } color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); } #pause { z-index: 3; background: rgba(15, 10, 20, .9); }
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */ /* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
#tutorial { z-index: 4; background: rgba(10, 8, 14, .96); padding: 8px; } #tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
#tutorial video { max-width: 100%; max-height: calc(100% - 7em); background: #000; } #tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
.tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; } .tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; }
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); } .pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; } #pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
@@ -284,7 +291,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } } @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, /* 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. */ la spiegazione e la versione non finiscono sopra i verbi. */
@media (orientation: portrait) and (max-width: 700px) { #tutorial { position: fixed; inset: 0; z-index: 10; } @media (orientation: portrait) and (max-width: 700px) {
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } } #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. */ /* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) { @media (max-width: 600px) {
+1 -1
View File
@@ -11,7 +11,7 @@ mkdirSync(out, { recursive: true });
const server = await staticServer(), port = server.port; const server = await staticServer(), port = server.port;
let browser; let browser;
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 640, height: 360 } }); const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+1 -1
View File
@@ -64,7 +64,7 @@ async function checkArtwork(page, name) {
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [ for (const [name, mobile, viewport] of [
['desktop', false, { width: 1280, height: 860 }], ['desktop', false, { width: 1280, height: 860 }],
['touch', true, { width: 844, height: 390 }], ['touch', true, { width: 844, height: 390 }],
+15 -1
View File
@@ -51,7 +51,7 @@ async function artwork(page) {
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64')); for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [ const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } }, { name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } }, { name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
@@ -91,6 +91,20 @@ try {
await page.waitForSelector('#briscola-controls:not([hidden])'); await page.waitForSelector('#briscola-controls:not([hidden])');
// Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio. // Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio.
await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3'); await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3');
if (name === 'desktop-mouse') {
// Il video «Come si gioca»: dall'aiuto del tavolo si apre il player, il gioco si ferma, Esc torna al tavolo.
await page.click('#briscola-controls summary');
await page.click('#briscola-controls .tutorial-open');
await page.waitForSelector('#tutorial:not([hidden])');
assert.match(await page.locator('#tutorial-video').getAttribute('src'), /assets\/video\/briscola\.mp4\?v=/);
assert.equal(await page.locator('#tutorial-title').textContent(), 'Come si gioca: la briscola');
assert.equal(await page.evaluate(() => music.hold), true, 'la musica del gioco tace');
await page.screenshot({ path: `${out}/briscola-video.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#tutorial'));
assert.equal(await page.evaluate(() => !music.hold && game.closeup?.id), 'briscola', 'si torna al tavolo');
await page.click('#briscola-controls summary');
}
assert.ok(await page.isHidden('#bocce-controls')); assert.ok(await page.isHidden('#bocce-controls'));
const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox(); const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo'); assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
+22 -2
View File
@@ -11,7 +11,7 @@ import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots'; const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const server = await staticServer(), PORT = server.port; const server = await staticServer(), PORT = server.port;
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = []; const errors = [];
async function open(viewport, query = '') { async function open(viewport, query = '') {
@@ -64,6 +64,23 @@ try {
await page.waitForTimeout(350); await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` }); await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 }); await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
{
const muto = await chromium.launch(), p = await muto.newPage();
await p.goto(`http://127.0.0.1:${PORT}/`);
await p.waitForTimeout(800);
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
await p.waitForTimeout(5500);
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
await p.click('#splash');
assert.ok(await p.isHidden('#splash .splash-hint'));
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
await muto.close();
}
await page.screenshot({ path: `${OUT}/01-titolo.png` }); await page.screenshot({ path: `${OUT}/01-titolo.png` });
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo'); assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo. // Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
@@ -83,6 +100,9 @@ try {
}); });
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); }); await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`); await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#splash');
await slow.waitForTimeout(300);
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 }); await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => window.titleRevealOpacity); const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`); assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
@@ -296,7 +316,7 @@ try {
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice'); assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
await page.fill('#map-search', ''); await page.fill('#map-search', '');
await page.click('#map-search'); await page.click('#map-search');
await page.keyboard.type('piaz'); await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa'); assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
assert.ok(await page.isHidden('#map-none')); assert.ok(await page.isHidden('#map-none'));
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene)); const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
+1 -1
View File
@@ -15,7 +15,7 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
const { g, c, at } = setup(); const { g, c, at } = setup();
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti'); assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio'); assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
assert.equal(c.sentence('verso_villa_adami'), 'Vai a Via de\' Medici, verso Villa Adami'); assert.equal(c.sentence('verso_piazzetta'), 'Vai a Piazzetta Cambray Digny, verso Via de\' Medici');
assert.equal(c.clickScene(at('vecchietti')), 'act'); assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g); settle(g);
assert.equal(g.dialogue?.id, 'vecchietti'); assert.equal(g.dialogue?.id, 'vecchietti');
+1 -1
View File
@@ -44,7 +44,7 @@ async function wait(page) {
}); });
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const scenarios = [ const scenarios = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true }, { name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } }, { name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
+27 -1
View File
@@ -182,7 +182,7 @@ test('i personaggi in scena hanno il foglio e tutte le pose che usano', () => {
for (const [id, scene] of Object.entries(story.scenes)) for (const [id, scene] of Object.entries(story.scenes))
for (const p of scene.people ?? []) { for (const p of scene.people ?? []) {
assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`); assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`);
assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`); if ((p.talk ?? ['talk']).length) assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`);
const f = frames(p.who); const f = frames(p.who);
for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`); for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`);
} }
@@ -606,6 +606,32 @@ test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.')); assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
}); });
// Il favicon c'era in pagina ma non nell'immagine Docker (404 online, 26 ottobre): ogni cartella di assets che il gioco
// usa deve essere fra quelle copiate, e ogni video «Come si gioca» deve esistere.
test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', async () => {
const { readdirSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const copied = new Set(readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY assets\/(\S+)\s/gm).map(l => l.split(' ')[1].slice(7)));
const files = ['index.html', 'style.css', ...readdirSync(new URL('src/', root), { recursive: true }).filter(f => f.endsWith('.js')).map(f => 'src/' + f)];
const used = new Set(files.flatMap(f => [...readFileSync(new URL(f, root), 'utf8').matchAll(/assets\/([a-z0-9_-]+)\//g)].map(m => m[1])));
// raw e generated sono cartelle di lavoro, citate solo nei commenti.
assert.deepEqual([...used].filter(d => !copied.has(d) && !['raw', 'generated'].includes(d)).sort(), []);
for (const t of Object.values(story.tutorials)) for (const f of [t.src, t.poster]) assert.ok(existsSync(new URL(f, root)), f);
});
// La foto del palio e i video arrivavano al browser come application/octet-stream (26 ottobre): ogni estensione
// servita deve avere il suo tipo in nginx.conf, sennò nginx (con i soli tipi dichiarati) non sa cos'è.
test('ogni estensione dei file serviti ha il suo tipo MIME in nginx.conf', async () => {
const { readdirSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const types = readFileSync(new URL('nginx.conf', root), 'utf8').match(/types \{([^}]*)\}/)[1];
const known = new Set(types.split(';').flatMap(l => l.trim().split(/\s+/).slice(1)));
const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]);
const ext = new Set(['html', 'css']);
for (const d of dirs) for (const f of readdirSync(new URL(d + '/', root), { recursive: true })) if (/\.\w+$/.test(f)) ext.add(f.split('.').pop());
assert.deepEqual([...ext].filter(e => !known.has(e) && e !== 'txt').sort(), [], 'estensioni senza tipo in nginx.conf');
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => { 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 { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url); const root = new URL('../', import.meta.url);
+1 -1
View File
@@ -46,7 +46,7 @@ async function wait(page) {
}); });
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const scenarios = [ const scenarios = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true }, { name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } }, { name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
+39
View File
@@ -0,0 +1,39 @@
// Verifica visiva mirata: node tests/veicoli.browser.mjs
// Usa lo stesso Chromium/SwiftShader di browser.mjs, senza titolo e audio.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('response', r => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); });
await page.route('**/__veicoli', route => route.fulfill({ contentType: 'text/html', body:
'<style>body{margin:0}canvas{display:block;width:1280px;height:720px;image-rendering:pixelated}</style><canvas width="640" height="360"></canvas>' }));
await page.goto(`http://127.0.0.1:${server.port}/__veicoli`);
await page.evaluate(async () => {
const [{ story }, { Game }, renderer] = await Promise.all([
import('/src/story/story.js'), import('/src/engine/game.js'), import('/src/engine/render.js'),
]);
const loaded = await renderer.loadAssets(story, 'campomigliaio_circolo');
if (loaded.some(ok => !ok)) throw new Error('Uno sfondo/personaggio non è arrivato');
const game = new Game(story);
game.go('campomigliaio_circolo');
game.time = 8;
const ctx = document.querySelector('canvas').getContext('2d');
renderer.render(ctx, game, { time: 8000 });
});
assert.deepEqual(errors, []);
await page.screenshot({ path: `${out}/campomigliaio-veicoli.png` });
console.log(`Schermata: ${out}/campomigliaio-veicoli.png`);
} finally {
await browser?.close();
server.kill();
}
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js';
import { settle } from './helpers.mjs';
test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli altri hotspot', () => {
const g = new Game(story);
g.go('campomigliaio_circolo');
settle(g);
const vehicles = ['betino', 'ghiacciomobile'];
for (const id of vehicles) {
const person = g.people().find(p => p.who === id), h = g.hotspot(id);
const url = new URL('../' + story.characters[id].sprite, import.meta.url);
const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle;
assert.ok(existsSync(new URL(atlas.image, url)));
assert.deepEqual(Object.keys(atlas.frames), ['idle']);
const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height;
const width = frame.w * k;
assert.ok(id === 'betino' ? width >= 70 && width <= 80 : width >= 150 && width <= 170);
// Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile.
for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++)
for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++)
assert.ok(!inside(g.scene.walk, [x, y]), `${id}: invade walk in ${x},${y}`);
for (const other of g.hotspots().filter(o => !vehicles.includes(o.id))) {
const [x, y, w, hgt] = h.box, [ox, oy, ow, oh] = other.box;
assert.ok(x + w <= ox || ox + ow <= x || y + hgt <= oy || oy + oh <= y,
`${id}: copre ${other.id}`);
}
for (const time of [0, 3.9, 4.1, 20]) {
g.time = time;
assert.equal(g.personPose(person), 'idle');
}
}
});
test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => {
const g = new Game(story);
g.go('campomigliaio_circolo');
settle(g);
const flags = { ...g.state.flags }, inventory = [...g.state.inventory];
for (const id of ['betino', 'ghiacciomobile']) {
const h = g.hotspot(id);
assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id);
for (const verb of ['look', 'use']) {
assert.ok(g.act(verb, id));
const said = settle(g);
assert.ok(said.length && said.every(s => s.who === 'andrea'));
assert.deepEqual([g.state.x, g.state.y], h.at);
assert.deepEqual(g.state.flags, flags);
assert.deepEqual(g.state.inventory, inventory);
assert.equal(g.state.scene, 'campomigliaio_circolo');
}
}
});
+2 -1
View File
@@ -5,5 +5,6 @@
"padel": "pàdel", "padel": "pàdel",
"l'A. Bettini": "l'Abbettini", "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" "S'ha andà a fallo": "Si va a farlo",
"Ghiacciomobile": "Ghiacciomòbile"
} }
+3
View File
@@ -42,6 +42,9 @@ export async function setup(d) {
await p.goto(URL_GIOCO); await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear()); await p.evaluate(() => localStorage.clear());
await p.reload(); await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])"); await p.waitForSelector("#title:not([hidden])");
await sleep(1500); await sleep(1500);
} }
+3
View File
@@ -34,6 +34,9 @@ export async function setup(d) {
await p.goto(URL_GIOCO); await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear()); await p.evaluate(() => localStorage.clear());
await p.reload(); await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])"); await p.waitForSelector("#title:not([hidden])");
await sleep(1500); await sleep(1500);
} }
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Porta i video «Come si gioca» nel gioco (assets/video/, story.tutorials): da quando si arriva nel posto del gioco
(la prima battuta del narratore), più piccoli e compressi per il web, con una copertina.
python3 video/pubblica.py briscola tressette scopa bocce biliardo
Prima la ripresa e il montaggio (video/SCALETTA-CARTE.md): legge video/out/tutorial-<gioco>.mp4 e il cues.json della
ripresa. Il titolo del gioco all'inizio della ripresa non serve, e col ritaglio dei tavoli verrebbe tagliato ai lati.
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
RIPRESE = Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out"
def durata(f: Path) -> float:
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
capture_output=True, text=True, check=True).stdout)
for gioco in sys.argv[1:]:
ripresa = RIPRESE / f"tutorial-{gioco}"
cues = json.loads((ripresa / "cues.json").read_text())["cues"]
# Lo stesso scarto di monta.py: la ripresa parte un po' prima del copione.
scarto = max(0.0, durata(ripresa / "raw.mkv") - (cues[-1]["end"] + 1.2))
inizio = max(0.0, cues[0]["start"] + scarto - 0.4)
dentro, fuori = VIDEO / "out" / f"tutorial-{gioco}.mp4", GIOCO / "assets" / "video" / f"{gioco}.mp4"
fuori.parent.mkdir(exist_ok=True)
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{inizio:.2f}", "-i", str(dentro),
"-vf", "scale=trunc(iw*0.75/2)*2:-2", "-c:v", "libx264", "-preset", "slow", "-crf", "27", "-pix_fmt", "yuv420p",
"-af", "afade=t=in:d=0.4", "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(fuori)], check=True)
# La copertina: un momento al tavolo, a un terzo del video.
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{durata(fuori) / 3:.1f}", "-i", str(fuori), "-frames:v", "1",
"-q:v", "4", str(fuori.with_suffix(".jpg"))], check=True)
for f in (fuori, fuori.with_suffix(".jpg")):
f.chmod(0o644)
print(f"-> {fuori} ({durata(fuori):.0f} s, {fuori.stat().st_size / 1e6:.1f} MB), da {inizio:.1f} s")
+3
View File
@@ -23,6 +23,9 @@ export async function setup(d) {
await p.goto(URL_GIOCO); await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear()); await p.evaluate(() => localStorage.clear());
await p.reload(); await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])"); await p.waitForSelector("#title:not([hidden])");
await sleep(1000); await sleep(1000);
} }