Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2e49036d | ||
|
|
1e5f66c2b6 | ||
|
|
b1640f1f12 | ||
|
|
0e7a50493c | ||
|
|
c2cb9b0194 | ||
|
|
5392f042b3 |
@@ -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 ?=
|
||||
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
|
||||
|
||||
## ------------------------------------------------------------ sviluppo
|
||||
@@ -50,7 +50,10 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
|
||||
browser: ## Partita completa nel browser con Playwright
|
||||
$(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
|
||||
scripts/bump.sh $(or $(PARTE),patch)
|
||||
|
||||
@@ -1,81 +1,66 @@
|
||||
# La Leggenda del Regolo
|
||||
|
||||
Avventura grafica punta-e-clicca ambientata a San Piero a Sieve, nella settimana del Palio della Fortezza del 2007.
|
||||
Il palio, il quadro dipinto dai ragazzi delle medie, è sparito: un ragazzo del paese deve ritrovarlo prima della festa.
|
||||
Un'avventura grafica punta-e-clicca, nello spirito delle avventure anni '90, ambientata a **San Piero a Sieve**
|
||||
(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.*
|
||||
|
||||
Progetto in fase di ideazione: il documento di progetto è in [`docs/PROGETTO.md`](docs/PROGETTO.md).
|
||||
Per ora c'è un prototipo del motore con due scene di prova e grafica provvisoria.
|
||||
Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero), nel browser, anche dal telefono.
|
||||
È gratuito e non commerciale.
|
||||
|
||||
## Generare sfondi dalle fotografie
|
||||
## Cosa c'è
|
||||
|
||||
Il generatore Python è `tools/genera.py`; istruzioni e prompt delle due nuove vedute
|
||||
sono descritti in [`docs/GENERAZIONE_SFONDI.md`](docs/GENERAZIONE_SFONDI.md).
|
||||
Lavora sulle foto di **San Piero a Sieve**, conserva i master a 1536×864 e permette
|
||||
di prepararne copie a 640×360 per il gioco.
|
||||
|
||||
```zsh
|
||||
python3 tools/genera.py --dry-run # elenco delle foto, nessuna API e nessun file scritto
|
||||
```
|
||||
|
||||
## 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`.
|
||||
- **Il primo capitolo**, dal pomeriggio alla sera: 38 scene, i dialoghi a scelte, l'inventario, la mappa del
|
||||
paese, il taccuino, il salvataggio automatico (e su file, per portarlo su un altro dispositivo).
|
||||
- **Le voci** di tutti i personaggi, in toscano, e le **musiche**.
|
||||
- **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
|
||||
«Come si gioca».
|
||||
- **Il paese vero**: gli sfondi sono disegnati dalle fotografie di San Piero; i personaggi, con il loro
|
||||
consenso, da persone vere.
|
||||
|
||||
## Giocare in locale
|
||||
|
||||
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
|
||||
|
||||
```zsh
|
||||
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.
|
||||
Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con `file://`: i moduli vogliono HTTP.
|
||||
|
||||
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**.
|
||||
## Come è fatto
|
||||
|
||||
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
|
||||
- `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
|
||||
make up # http://localhost:8080 (DOCKER_PORT=… per cambiarla)
|
||||
make down
|
||||
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
|
||||
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
||||
make bump # nuova versione: verifica, commit e tag
|
||||
make deploy # pubblica l'ultimo commit (immagine Docker spedita al server)
|
||||
```
|
||||
|
||||
## Struttura
|
||||
## Chi ha fatto cosa
|
||||
|
||||
- `src/engine/`: motore senza storia (`game.js` logica e coda delle azioni, `geometry.js` area calpestabile e
|
||||
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).
|
||||
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.
|
||||
|
||||
## Test
|
||||
|
||||
```zsh
|
||||
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
|
||||
npm install && npx playwright install chromium-headless-shell
|
||||
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
|
||||
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
|
||||
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
|
||||
```
|
||||
## 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 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.
@@ -943,6 +943,22 @@
|
||||
"file": "andrea-00f2e70a4d.mp3",
|
||||
"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.": {
|
||||
"file": "andrea-90bbad0de0.mp3",
|
||||
"sec": 9.59
|
||||
@@ -1523,6 +1539,18 @@
|
||||
"file": "andrea-60cdae2e35.mp3",
|
||||
"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?": {
|
||||
"file": "andrea-d2f285f610.mp3",
|
||||
"sec": 1.04
|
||||
|
||||
@@ -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.
|
||||
@@ -33,6 +33,7 @@
|
||||
<p class="splash-name">Manzolo</p>
|
||||
<p class="splash-art"><span>(Poca)</span> Art</p>
|
||||
<p class="splash-present">Presents</p>
|
||||
<p class="splash-hint">Tocca per cominciare</p>
|
||||
</section>
|
||||
<section id="title" class="overlay" hidden>
|
||||
<h1>La Leggenda<br>del <span>Regolo</span></h1>
|
||||
|
||||
+3
-1
@@ -26,6 +26,8 @@ server {
|
||||
audio/mpeg mp3;
|
||||
image/svg+xml svg;
|
||||
image/x-icon ico;
|
||||
image/jpeg jpg jpeg;
|
||||
video/mp4 mp4;
|
||||
font/woff2 woff2;
|
||||
}
|
||||
|
||||
@@ -37,7 +39,7 @@ server {
|
||||
location ~* \.(css|js|mjs|json)$ {
|
||||
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;
|
||||
}
|
||||
location = /index.html {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Executable
+51
@@ -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
@@ -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.
|
||||
const SPLASH_FADE = 4300, SPLASH_END = 5000;
|
||||
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 end = () => {
|
||||
if (done) return;
|
||||
@@ -954,18 +954,22 @@ function showSplash() {
|
||||
$('splash').hidden = true;
|
||||
jingle?.pause(); // saltato il cartello, si salta anche la musichetta
|
||||
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 begin = () => { waiting = false; $('splash').classList.remove('wait'); jingle.play().catch(() => {}); schedule(); };
|
||||
const onKey = () => { if (waiting) begin(); else end(); };
|
||||
if (jingle) {
|
||||
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
|
||||
}
|
||||
setTimeout(reveal, SPLASH_FADE);
|
||||
setTimeout(end, SPLASH_END);
|
||||
$('splash').addEventListener('click', end);
|
||||
addEventListener('keydown', end, { once: true });
|
||||
} else schedule();
|
||||
$('splash').addEventListener('click', () => { if (waiting) begin(); else end(); });
|
||||
addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`;
|
||||
|
||||
+37
-1
@@ -230,6 +230,8 @@ export const story = {
|
||||
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
|
||||
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
|
||||
macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' },
|
||||
betino: { name: 'il Betino', sprite: 'assets/characters/betino.json' },
|
||||
ghiacciomobile: { name: 'la Ghiacciomobile', sprite: 'assets/characters/ghiacciomobile.json' },
|
||||
},
|
||||
items: {
|
||||
squama: {
|
||||
@@ -1535,8 +1537,31 @@ export const story = {
|
||||
entry: [320, 330],
|
||||
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
|
||||
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: [
|
||||
{
|
||||
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',
|
||||
actions: {
|
||||
@@ -2168,6 +2193,17 @@ export const story = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
nodes: {
|
||||
start: { options: [
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.15';
|
||||
export const VERSION = '0.1.17';
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -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-art { font-size: clamp(20px, 5vw, 50px); }
|
||||
#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 .dedication { font-size: max(9px, calc(var(--u) * 1.5)); }
|
||||
#intro-text { font-size: max(11px, calc(var(--u) * 2)); }
|
||||
|
||||
@@ -11,7 +11,7 @@ mkdirSync(out, { recursive: true });
|
||||
const server = await staticServer(), port = server.port;
|
||||
let browser;
|
||||
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 errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
@@ -64,7 +64,7 @@ async function checkArtwork(page, name) {
|
||||
}
|
||||
|
||||
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 [
|
||||
['desktop', false, { width: 1280, height: 860 }],
|
||||
['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'));
|
||||
}
|
||||
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 => [
|
||||
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
|
||||
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
|
||||
|
||||
+21
-1
@@ -11,7 +11,7 @@ import { checkMultimedia } from './multimedia.browser.mjs';
|
||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
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 = [];
|
||||
|
||||
async function open(viewport, query = '') {
|
||||
@@ -64,6 +64,23 @@ try {
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
||||
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` });
|
||||
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.
|
||||
@@ -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.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 });
|
||||
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
||||
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
||||
|
||||
@@ -44,7 +44,7 @@ async function wait(page) {
|
||||
});
|
||||
}
|
||||
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 = [
|
||||
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
|
||||
|
||||
+14
-1
@@ -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 p of scene.people ?? []) {
|
||||
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);
|
||||
for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`);
|
||||
}
|
||||
@@ -619,6 +619,19 @@ test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', as
|
||||
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 () => {
|
||||
const { readdirSync, statSync } = await import('node:fs');
|
||||
const root = new URL('../', import.meta.url);
|
||||
|
||||
@@ -46,7 +46,7 @@ async function wait(page) {
|
||||
});
|
||||
}
|
||||
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 = [
|
||||
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
|
||||
{ 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",
|
||||
"l'A. Bettini": "l'Abbettini",
|
||||
"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.evaluate(() => localStorage.clear());
|
||||
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 sleep(1500);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export async function setup(d) {
|
||||
await p.goto(URL_GIOCO);
|
||||
await p.evaluate(() => localStorage.clear());
|
||||
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 sleep(1500);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ export async function setup(d) {
|
||||
await p.goto(URL_GIOCO);
|
||||
await p.evaluate(() => localStorage.clear());
|
||||
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 sleep(1000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user