Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151017d2e2 | ||
|
|
f193c6020f | ||
|
|
8a2e49036d | ||
|
|
1e5f66c2b6 | ||
|
|
b1640f1f12 | ||
|
|
0e7a50493c | ||
|
|
c2cb9b0194 | ||
|
|
5392f042b3 | ||
|
|
ca3035e33f | ||
|
|
22be1c306e |
@@ -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 \
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"image": "betino.png",
|
||||||
|
"height": 104,
|
||||||
|
"frames": {
|
||||||
|
"idle": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 161,
|
||||||
|
"h": 104,
|
||||||
|
"ax": 80.5,
|
||||||
|
"ay": 104
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"image": "ghiacciomobile.png",
|
||||||
|
"height": 144,
|
||||||
|
"frames": {
|
||||||
|
"idle": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 324,
|
||||||
|
"h": 144,
|
||||||
|
"ax": 162,
|
||||||
|
"ay": 144
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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,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).
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||