Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2e49036d | ||
|
|
1e5f66c2b6 | ||
|
|
b1640f1f12 | ||
|
|
0e7a50493c | ||
|
|
c2cb9b0194 | ||
|
|
5392f042b3 | ||
|
|
ca3035e33f | ||
|
|
22be1c306e | ||
|
|
add5a5fe77 | ||
|
|
3019a6e32f | ||
|
|
111b892916 | ||
|
|
f1781afea8 | ||
|
|
b44eeffd2a |
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
|
||||
COPY assets/credits /usr/share/nginx/html/assets/credits
|
||||
COPY assets/effects /usr/share/nginx/html/assets/effects
|
||||
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.
|
||||
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 ?=
|
||||
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
|
||||
|
||||
```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.
|
||||
|
||||
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
|
||||
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
|
||||
|
||||
```zsh
|
||||
make up # http://localhost:8080 (DOCKER_PORT=… per cambiarla)
|
||||
make down
|
||||
python3 -m http.server 8000 # poi http://localhost:8000
|
||||
```
|
||||
|
||||
## 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
|
||||
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).
|
||||
## Come è fatto
|
||||
|
||||
## 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
|
||||
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
|
||||
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)
|
||||
```
|
||||
|
||||
## 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: 772 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.3 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 |
@@ -307,26 +307,6 @@
|
||||
"file": "andrea-68a845881b.mp3",
|
||||
"sec": 1.94
|
||||
},
|
||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
|
||||
"file": "andrea-97d75e2a9d.mp3",
|
||||
"sec": 5.38
|
||||
},
|
||||
"andrea|Buongiorno, Fra Peppino!": {
|
||||
"file": "andrea-94988dc8c6.mp3",
|
||||
"sec": 1.74
|
||||
},
|
||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||
"file": "fra_peppino-651c4d95ab.mp3",
|
||||
"sec": 3.72
|
||||
},
|
||||
"andrea|Il palio. È sparito.": {
|
||||
"file": "andrea-c9c068f507.mp3",
|
||||
"sec": 2.39
|
||||
},
|
||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||
"sec": 4.47
|
||||
},
|
||||
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
||||
"file": "andrea-43fd5c2b27.mp3",
|
||||
"sec": 4.46
|
||||
@@ -543,6 +523,26 @@
|
||||
"file": "andrea-43d3435d61.mp3",
|
||||
"sec": 2.99
|
||||
},
|
||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
|
||||
"file": "andrea-6797d70153.mp3",
|
||||
"sec": 8.45
|
||||
},
|
||||
"andrea|Buongiorno, Fra Peppino!": {
|
||||
"file": "andrea-94988dc8c6.mp3",
|
||||
"sec": 1.74
|
||||
},
|
||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||
"file": "fra_peppino-651c4d95ab.mp3",
|
||||
"sec": 3.72
|
||||
},
|
||||
"andrea|Il palio. È sparito.": {
|
||||
"file": "andrea-c9c068f507.mp3",
|
||||
"sec": 2.39
|
||||
},
|
||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||
"sec": 4.47
|
||||
},
|
||||
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
||||
"file": "andrea-486c980f52.mp3",
|
||||
"sec": 7.48
|
||||
@@ -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.
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
||||
<title>La Leggenda del Regolo</title>
|
||||
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
|
||||
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -28,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>
|
||||
@@ -89,7 +95,7 @@
|
||||
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
|
||||
<h2 id="tutorial-title">Come si gioca</h2>
|
||||
<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 id="credits" class="overlay" hidden aria-label="Ringraziamenti">
|
||||
<h2>Ringraziamenti</h2>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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"
|
||||
@@ -457,6 +457,7 @@ canvas.addEventListener('pointerdown', e => {
|
||||
if (!aimed && !game.speech) return;
|
||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||
}
|
||||
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
|
||||
press = { closeup: true };
|
||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||
if (!game.skip()) game.closeupPress();
|
||||
@@ -503,10 +504,11 @@ canvas.addEventListener('contextmenu', e => {
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!running) return;
|
||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
|
||||
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
|
||||
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
|
||||
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
|
||||
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
|
||||
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||
const aiming = game.closeup && ['biliardo', 'bocce'].includes(story.closeups[game.closeup.id].type);
|
||||
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||
});
|
||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||
@@ -944,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;
|
||||
@@ -952,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}`;
|
||||
|
||||
@@ -135,7 +135,7 @@ export const story = {
|
||||
},
|
||||
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
|
||||
bocce: {
|
||||
type: 'bocce',
|
||||
type: 'bocce', music: 'assets/audio/bocce.mp3', // la polka del pallaio (tools/polka_bocce.py, Andrea: «un sound suo»)
|
||||
picture: 'assets/closeups/pallaio.png',
|
||||
names: { player: 'Andrea', cpu: 'Mariano' },
|
||||
win: [
|
||||
@@ -203,6 +203,14 @@ export const story = {
|
||||
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: {
|
||||
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
|
||||
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.
|
||||
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: {
|
||||
@@ -576,23 +586,13 @@ export const story = {
|
||||
map: [56, 45],
|
||||
background: 'assets/scenes/pieve-dalla-salita.png',
|
||||
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
|
||||
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] },
|
||||
// Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre).
|
||||
people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
||||
speakers: { san_pietro: [300, 100] },
|
||||
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
|
||||
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
||||
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
||||
depth: [[215, 0.42], [360, 1.15]],
|
||||
entry: [30, 320],
|
||||
hotspots: [
|
||||
{
|
||||
// Battute provvisorie: le vere le scrive Andrea.
|
||||
id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }],
|
||||
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
|
||||
actions: {
|
||||
@@ -900,7 +900,19 @@ export const story = {
|
||||
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
||||
depth: [[292, 0.78], [360, 1.1]],
|
||||
entry: [320, 345],
|
||||
// Fra Peppino da Gricignano (sprite di Codex dalla foto di Andrea, 25 ottobre): stava sul sagrato della Pieve,
|
||||
// ma è un francescano, e il Bosco ai Frati è il loro convento (Andrea, 26 ottobre).
|
||||
speakers: { fra_peppino: [470, 238] },
|
||||
people: [{ who: 'fra_peppino', at: [470, 304], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
||||
hotspots: [
|
||||
{
|
||||
// Battute provvisorie: le vere le scrive Andrea.
|
||||
id: 'fra_peppino', name: 'Fra Peppino', box: [458, 248, 26, 58], at: [430, 306], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.' }],
|
||||
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
|
||||
actions: {
|
||||
@@ -1525,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: {
|
||||
@@ -2158,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,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.13';
|
||||
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).
|
||||
|
||||
@@ -113,8 +113,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
||||
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||
|
||||
#choices { margin: 0; padding: 10px 14px 12px 2.4em; background: var(--panel); min-height: 9.5em; }
|
||||
#choices li { padding: .25em 0; font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
||||
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
|
||||
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||
|
||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||
@@ -163,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)); }
|
||||
@@ -201,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; }
|
||||
#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ò. */
|
||||
#tutorial { z-index: 4; background: rgba(10, 8, 14, .96); padding: 8px; }
|
||||
#tutorial video { max-width: 100%; max-height: calc(100% - 7em); background: #000; }
|
||||
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
|
||||
#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; }
|
||||
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
|
||||
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
|
||||
@@ -283,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; } }
|
||||
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
|
||||
la spiegazione e la versione non finiscono sopra i verbi. */
|
||||
@media (orientation: portrait) and (max-width: 700px) { #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); } }
|
||||
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
||||
@media (max-width: 600px) {
|
||||
@@ -308,7 +316,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#speech { max-width: 72%; font-size: 11px; }
|
||||
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
||||
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
|
||||
#verbs button, #inventory button, #choices li { font-size: 10.08px; }
|
||||
#verbs button, #inventory button, #choices { font-size: 10.08px; }
|
||||
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
|
||||
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
|
||||
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -324,7 +332,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#map-board button { width: 68px; }
|
||||
#map-board button img { width: 60px; }
|
||||
#pause .hint { font-size: 8px; }
|
||||
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
||||
#choices { min-height: 0; padding: 6px 8px 6px 3.6em; }
|
||||
}
|
||||
|
||||
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
||||
|
||||
@@ -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 } },
|
||||
@@ -91,6 +91,20 @@ try {
|
||||
await page.waitForSelector('#briscola-controls:not([hidden])');
|
||||
// Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio.
|
||||
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'));
|
||||
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');
|
||||
|
||||
@@ -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`);
|
||||
@@ -387,6 +407,12 @@ try {
|
||||
await bocce.mouse.up();
|
||||
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
||||
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
||||
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
|
||||
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
|
||||
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
|
||||
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
|
||||
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
|
||||
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
|
||||
await bocce.keyboard.press('Escape');
|
||||
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
||||
await bocce.close();
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.'));
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disegna il Regolino, pixel per pixel, senza immagini sorgenti.
|
||||
|
||||
python3 tools/favicon.py
|
||||
|
||||
Scrive assets/favicon/ e /tmp/regolo-shots/favicon-anteprima.png.
|
||||
La matrice 16×16 è il disegno originale: ogni simbolo è un pixel.
|
||||
Tutti gli ingrandimenti sono interi e usano NEAREST, anche nell'ICO.
|
||||
Colori e carattere riprendono regolo() in src/engine/briscola-render.js.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUTPUT = ROOT / "assets" / "favicon"
|
||||
PREVIEW = Path("/tmp/regolo-shots/favicon-anteprima.png")
|
||||
BACKGROUND = "#1b1420"
|
||||
PALETTE = {
|
||||
".": (0, 0, 0, 0),
|
||||
"#": "#38271f", # contorno del dorso
|
||||
"G": "#dba32b", # oro
|
||||
"H": "#ffe396", # luce sulla corona
|
||||
"R": "#aa4034", # rubino
|
||||
"L": "#94b365", # testa verde salvia
|
||||
"S": "#487348", # ombra / spira
|
||||
"V": "#a3bc72", # luce sulla spira
|
||||
"B": "#d0cd83", # ventre
|
||||
"W": "#fff0cd", # occhi crema
|
||||
}
|
||||
|
||||
# Corona a tre punte, grande testa sorridente, collo e coda arrotolata.
|
||||
# Nessun tratto viene interpolato: anche gli occhi sono singoli pixel.
|
||||
PIXELS = (
|
||||
"....#..##..#....",
|
||||
"...#H##HH##H#...",
|
||||
"...#HGGGGGGH#...",
|
||||
"....#GGRGGG#....",
|
||||
"...##########...",
|
||||
"..#LLLLLLLLLL#..",
|
||||
".#LLWWLLWWLLLL#.",
|
||||
".#LLW#LL#WLLLS#.",
|
||||
".#LLLLLLLLLLLS#.",
|
||||
"..#L#LLLLL#LS#..",
|
||||
"...#L#####LS#...",
|
||||
"....###BS###....",
|
||||
"..###SSBSLSS##..",
|
||||
".#SVV##BS##SSS#.",
|
||||
".#SVVVVVVVVSS#..",
|
||||
"..###########...",
|
||||
)
|
||||
|
||||
|
||||
def sprite():
|
||||
"""Interpreta la matrice letterale, mantenendo solo alpha 0 o 255."""
|
||||
if len(PIXELS) != 16 or any(len(row) != 16 for row in PIXELS):
|
||||
raise ValueError("Il disegno deve contenere esattamente 16×16 pixel")
|
||||
result = Image.new("RGBA", (16, 16))
|
||||
for y, row in enumerate(PIXELS):
|
||||
for x, symbol in enumerate(row):
|
||||
color = PALETTE[symbol]
|
||||
if isinstance(color, str):
|
||||
color = tuple(bytes.fromhex(color[1:])) + (255,)
|
||||
result.putpixel((x, y), color)
|
||||
return result
|
||||
|
||||
|
||||
def enlarge(source, factor):
|
||||
return source.resize(
|
||||
(source.width * factor, source.height * factor), Image.Resampling.NEAREST
|
||||
)
|
||||
|
||||
|
||||
def preview(icons):
|
||||
"""Mostra ogni PNG a 1× e 8×, sia su chiaro sia su scuro.
|
||||
|
||||
Le sei righe includono anche le icone grandi: il foglio è volutamente
|
||||
ampio, così la scala dichiarata resta esatta per tutti i file.
|
||||
"""
|
||||
margin, gap, label_height = 24, 24, 32
|
||||
largest = max(icon.width for _, icon in icons)
|
||||
panel_width = margin * 2 + largest + gap + largest * 8
|
||||
height = margin + sum(label_height + icon.height * 8 + gap for _, icon in icons)
|
||||
sheet = Image.new("RGB", (panel_width * 2, height))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.load_default(size=16)
|
||||
for panel, (background, foreground) in enumerate(
|
||||
(("#f5f1e9", "#38271f"), (BACKGROUND, "#fff0cd"))
|
||||
):
|
||||
left = panel * panel_width
|
||||
draw.rectangle((left, 0, left + panel_width - 1, height - 1), fill=background)
|
||||
y = margin
|
||||
for name, icon in icons:
|
||||
draw.text(
|
||||
(left + margin, y),
|
||||
f"{name} | {icon.width} x {icon.height} | 1x e 8x",
|
||||
fill=foreground,
|
||||
font=font,
|
||||
)
|
||||
y += label_height
|
||||
sheet.paste(icon, (left + margin, y), icon)
|
||||
magnified = enlarge(icon, 8)
|
||||
sheet.paste(magnified, (left + margin + icon.width + gap, y), magnified)
|
||||
y += icon.height * 8 + gap
|
||||
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(PREVIEW, optimize=True)
|
||||
|
||||
|
||||
def main():
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
base = sprite()
|
||||
icons = []
|
||||
for size in (16, 32, 48):
|
||||
icon = enlarge(base, size // 16)
|
||||
name = f"favicon-{size}.png"
|
||||
icon.save(OUTPUT / name, optimize=True)
|
||||
icons.append((name, icon))
|
||||
|
||||
# 16×9 = 144: margini di 18 px, centratura esatta sul fondo opaco.
|
||||
apple = Image.new("RGBA", (180, 180), BACKGROUND)
|
||||
apple.alpha_composite(enlarge(base, 9), (18, 18))
|
||||
apple = apple.convert("RGB")
|
||||
apple.save(OUTPUT / "apple-touch-icon.png", optimize=True)
|
||||
icons.append(("apple-touch-icon.png", apple.convert("RGBA")))
|
||||
|
||||
for size in (192, 512):
|
||||
icon = enlarge(base, size // 16)
|
||||
name = f"icon-{size}.png"
|
||||
icon.save(OUTPUT / name, optimize=True)
|
||||
icons.append((name, icon))
|
||||
|
||||
# Fornire tutti i frame evita il ridimensionamento implicito di Pillow.
|
||||
icons[2][1].save(
|
||||
OUTPUT / "favicon.ico",
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
append_images=[icons[0][1], icons[1][1]],
|
||||
)
|
||||
preview(icons)
|
||||
for name, _ in icons:
|
||||
print(OUTPUT / name)
|
||||
print(OUTPUT / "favicon.ico")
|
||||
print(PREVIEW)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""La musica delle bocce al pallaio (assets/audio/bocce.mp3), sintetizzata da zero come il valzer delle carte: una
|
||||
polka da circolo in do maggiore, in due quarti. Basso «um-pa» sui due movimenti, fisarmonica a contrattempo e una
|
||||
melodia originale di clarinetto, con un filo di vibrato. Trentadue battute che chiudono sul sol settima: il loop non
|
||||
si sente. (Andrea, 26 ottobre: «anche le bocce avrebbero bisogno di un sound suo».)
|
||||
python3 tools/polka_bocce.py # scrive assets/audio/bocce.mp3 (serve ffmpeg)
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
from array import array
|
||||
from pathlib import Path
|
||||
|
||||
SR = 22050
|
||||
BPM = 116
|
||||
BEAT = 60 / BPM
|
||||
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "bocce.mp3"
|
||||
|
||||
HARMONY = {"C": ["E4", "G4", "C5"], "G7": ["F4", "G4", "B4"], "F": ["F4", "A4", "C5"], "A7": ["E4", "G4", "C#5"],
|
||||
"Dm": ["F4", "A4", "D5"]}
|
||||
BASS = {"C": ("C2", "G2"), "G7": ("G1", "D2"), "F": ("F1", "C2"), "A7": ("A1", "E2"), "Dm": ("D2", "A1")}
|
||||
CHORDS = ("C C G7 G7 G7 G7 C C C C F F C G7 C C "
|
||||
"F F C C G7 G7 C C F F C A7 Dm G7 C G7").split()
|
||||
h = 0.5
|
||||
MELODY = [
|
||||
[("E5", h), ("G5", h), ("C6", 1)], [("B5", h), ("A5", h), ("G5", 1)], [("F5", h), ("G5", h), ("A5", h), ("B5", h)], [("D6", 1), ("B5", 1)],
|
||||
[("A5", h), ("B5", h), ("A5", h), ("G5", h)], [("F5", 1), ("D5", 1)], [("E5", h), ("F5", h), ("G5", 1)], [("C5", 2)],
|
||||
[("E5", h), ("G5", h), ("C6", 1)], [("E6", h), ("D6", h), ("C6", 1)], [("A5", h), ("C6", h), ("A5", h), ("F5", h)], [("A5", 1.5), ("G5", h)],
|
||||
[("G5", h), ("E5", h), ("C5", h), ("E5", h)], [("D5", h), ("F5", h), ("B5", 1)], [("C6", 1), ("G5", 1)], [("C6", 1), (None, 1)],
|
||||
[("C6", h), ("A5", h), ("F5", 1)], [("A5", h), ("G5", h), ("F5", 1)], [("G5", h), ("E5", h), ("C5", 1)], [("E5", h), ("G5", h), ("C6", 1)],
|
||||
[("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("D5", 1), ("G5", 1)], [("E5", h), ("G5", h), ("E5", h), ("C5", h)], [("G4", 2)],
|
||||
[("A4", h), ("C5", h), ("F5", 1)], [("A5", 1), ("F5", 1)], [("E5", h), ("G5", h), ("C6", 1)], [("C#6", 1), ("A5", 1)],
|
||||
[("D6", h), ("C6", h), ("A5", 1)], [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("E5", 1), ("C5", 1)], [("D5", h), ("E5", h), ("F5", h), ("G5", h)],
|
||||
]
|
||||
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 2 for bar in MELODY)
|
||||
DUR = len(CHORDS) * 2 * BEAT
|
||||
buf = [0.0] * int(SR * DUR)
|
||||
rng = random.Random(11)
|
||||
|
||||
|
||||
def midi(note: str) -> int:
|
||||
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
semis = names[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
|
||||
return semis + 12 * (int(note[-1]) + 1)
|
||||
|
||||
|
||||
def hz(note: str) -> float:
|
||||
return 440 * 2 ** ((midi(note) - 69) / 12)
|
||||
|
||||
|
||||
def add(start: float, samples) -> None:
|
||||
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
|
||||
n0 = int(start * SR)
|
||||
for i, v in enumerate(samples):
|
||||
buf[(n0 + i) % len(buf)] += v
|
||||
|
||||
|
||||
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.006, release: float = 0.06,
|
||||
vibrato: float = 0.0):
|
||||
n = int((length + release) * SR)
|
||||
out, phase = [], 0.0
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
env = min(1.0, t / attack) * math.exp(-t / decay)
|
||||
if t > length:
|
||||
env *= max(0.0, 1 - (t - length) / release)
|
||||
# Il vibrato arriva dopo l'attacco, come quello di un fiato.
|
||||
phase += 2 * math.pi * freq * (1 + vibrato * min(1.0, t / 0.25) * math.sin(2 * math.pi * 5.2 * t)) / SR
|
||||
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(k * phase) for k, a, d in partials))
|
||||
return out
|
||||
|
||||
|
||||
at = lambda beat: beat * BEAT # noqa: E731
|
||||
LONG = 99
|
||||
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
|
||||
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
|
||||
# Clarinetto: armonici dispari, il suono di legno.
|
||||
REEDWOOD = ((1, 1.0, LONG), (3, 0.42, LONG), (5, 0.2, LONG), (7, 0.09, LONG), (2, 0.06, LONG))
|
||||
|
||||
for bar, name in enumerate(CHORDS):
|
||||
b0 = bar * 2
|
||||
# Basso «um-pa»: fondamentale sull'uno, quinta sul due.
|
||||
for k, note in enumerate(BASS[name]):
|
||||
add(at(b0 + k), tone(hz(note), BEAT * 0.55, 0.32, ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.2, 0.12)), decay=0.35))
|
||||
# Fisarmonica a contrattempo, corta e frizzante.
|
||||
for k in (0.5, 1.5):
|
||||
for note in HARMONY[name]:
|
||||
for detune, vol in ((1.0, 0.026), (1.004, 0.019)):
|
||||
add(at(b0 + k), tone(hz(note) * detune, BEAT * 0.3, vol, REED, decay=LONG, attack=0.012, release=0.04))
|
||||
# Un piatto leggero sul due, quello della banda.
|
||||
add(at(b0 + 1), [0.03 * math.exp(-i / SR / 0.08) * rng.uniform(-1, 1) for i in range(int(0.2 * SR))])
|
||||
|
||||
beat = 0.0
|
||||
for bar in MELODY:
|
||||
for note, length in bar:
|
||||
if note:
|
||||
add(at(beat), tone(hz(note), BEAT * length * 0.9, 0.09, REEDWOOD, decay=LONG, attack=0.03, release=0.06, vibrato=0.006))
|
||||
beat += length
|
||||
|
||||
peak = max(abs(x) for x in buf) or 1
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
w = Path(tmp) / "bocce.wav"
|
||||
with wave.open(str(w), "wb") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(SR)
|
||||
f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes())
|
||||
# All'aperto, accanto al campo: poca eco, alti morbidi.
|
||||
af = "aecho=0.8:0.5:70|150:0.12|0.07,lowpass=f=8500,volume=1.25"
|
||||
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
|
||||
print(f"-> {OUT} ({DUR:.0f} s)")
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Controlla che nel video «Come si gioca» il narratore non parli sopra i personaggi del gioco.
|
||||
python3 video/sovrapposte.py tutorial-briscola (dopo la ripresa: cues.json, registro audio, narrazione)
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VIDEO = Path(__file__).resolve().parent
|
||||
clip = sys.argv[1]
|
||||
cues = json.loads((Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out" / clip / "cues.json").read_text())["cues"]
|
||||
narr = {v["id"]: v["sec"] for v in json.loads((VIDEO / "out" / f"{clip}-narrazione.json").read_text())["voci"]}
|
||||
voci, aperta = [], None
|
||||
for e in json.loads((VIDEO / "out" / f"{clip}-audio.json").read_text())["eventi"]:
|
||||
if "voice" in e:
|
||||
aperta = [e["t"], None, e["voice"]]
|
||||
voci.append(aperta)
|
||||
elif "voiceEnd" in e and aperta:
|
||||
aperta[1], aperta = e["t"], None
|
||||
male = [(c["id"], v[2]) for c in cues for v in voci if v[1] and v[0] < c["start"] + narr[c["id"]] and v[1] > c["start"]]
|
||||
for id_, voce in male:
|
||||
print(f"✗ «{id_}» sopra {voce}")
|
||||
print(f"{clip}: {len(voci)} voci del gioco, {len(cues)} frasi del narratore, {len(male)} sovrapposte")
|
||||
sys.exit(1 if male else 0)
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copione del video «Come si gioca: il biliardo» (video/SCALETTA-CARTE.md, clip 5).
|
||||
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||
|
||||
export const title = { it: "Come si gioca · il biliardo", en: "Come si gioca · il biliardo" };
|
||||
export const cues = [
|
||||
c("dove", "Il biliardo è al Circolo 4 Maggio, in piazza. L'Ingegner Tozzi gioca ai cinque birilli, e si va a venti"),
|
||||
c("livello", "Anche qui si sceglie quanto deve giocare forte"),
|
||||
c("tavolo", "Il tavolo dall'alto: la tua biglia, quella dell'avversario, il pallino, e in mezzo il castello dei birilli"),
|
||||
c("scopo", "Si colpisce la propria biglia e la si manda su quella dell'avversario: i birilli che lei abbatte fanno punti a chi tira"),
|
||||
c("mira", "La stecca segue il mouse, e un clic fissa la mira. La linea tratteggiata è la strada della biglia; le frecce, dove vanno le due biglie dopo l'urto"),
|
||||
c("tira", "Con le frecce si gira la stecca, con Maiuscolo ancora più piano. Poi si regola la forza, e si preme Tira"),
|
||||
c("valori", "Il birillo bianco vale due, il rosso quattro, il rosso da solo dieci. Il pallino toccato dalla biglia avversaria, tre"),
|
||||
c("fallo", "Attenzione ai falli: mancare la biglia avversaria, toccare prima il pallino, o buttare giù birilli con la propria. I punti vanno all'avversario"),
|
||||
c("fine", "Un colpo a testa, finché qualcuno arriva a venti. Buona partita!"),
|
||||
];
|
||||
|
||||
export const setup = base;
|
||||
|
||||
export async function run(d) {
|
||||
const p = d.page, r = regista(d, "tutorial-biliardo");
|
||||
await r.ascolta();
|
||||
await r.vaiA("circolo_4_maggio", [470, 300]);
|
||||
await r.cue("dove");
|
||||
await r.parlaCon("ing_tozzi");
|
||||
await r.scegli("una partita ai birilli");
|
||||
await r.cue("livello");
|
||||
await r.scegli("Alla bona.");
|
||||
// Una partita fissa: un colpo da otto, un fallo voluto per farlo vedere, poi quattro e tre; vince Andrea 21 a 2.
|
||||
await p.evaluate(async () => {
|
||||
const { newBiliardo } = await import("/src/engine/biliardo.js");
|
||||
game.closeup = { id: "biliardo", ...newBiliardo(2, { level: "bona" }) };
|
||||
});
|
||||
await sleep(800);
|
||||
await r.cue("tavolo");
|
||||
await r.muovi([200, 200], 0.9); await sleep(300); await r.muovi([400, 180], 0.9);
|
||||
await r.cue("scopo");
|
||||
|
||||
// Il colpo: il migliore fra quelli provati a vuoto (o, la seconda volta, un fallo apposta). Si mira col clic sul
|
||||
// tavolo lungo la direzione scelta, poi forza col cursore e Tira.
|
||||
const colpo = (sbaglia) => p.evaluate(async (sbaglia) => {
|
||||
const L = await import("/src/engine/biliardo.js"), c = game.closeup, b = c.balls.player, o = c.balls.cpu;
|
||||
let top = null;
|
||||
if (sbaglia) for (let a = 0; a < Math.PI * 2 && !top; a += 0.05) { if (L.simulate(c, "player", a, 0.12).foul === "miss") top = { angle: a, power: 0.12 }; }
|
||||
else {
|
||||
const base = Math.atan2(o.y - b.y, o.x - b.x);
|
||||
for (let d = -0.35; d <= 0.35; d += 0.01) for (const power of [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]) {
|
||||
const j = L.simulate(c, "player", base + d, power), gain = j.to === "player" ? j.points : -j.points;
|
||||
if (!top || gain > top.gain) top = { angle: base + d, power, gain };
|
||||
}
|
||||
}
|
||||
const x = b.x + Math.cos(top.angle) * 45, y = b.y + Math.sin(top.angle) * 45;
|
||||
return { ...top, at: [L.VIEW.x + x * L.VIEW.k, L.VIEW.y + y * L.VIEW.k] };
|
||||
}, sbaglia);
|
||||
const tocca = async () => {
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const s = await p.evaluate(async () => {
|
||||
const { canThrow } = await import("/src/engine/biliardo.js");
|
||||
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||
});
|
||||
if (s !== "aspetta") return s === "tocca";
|
||||
await sleep(150);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let n = 0;
|
||||
while (await tocca()) {
|
||||
n++;
|
||||
if (n === 1) await r.cue("mira");
|
||||
if (n === 2) await r.cue("fallo");
|
||||
const m = await colpo(n === 2);
|
||||
await r.muovi(m.at, 0.9); await sleep(400);
|
||||
await r.clicca(m.at, 0.3);
|
||||
await p.evaluate(({ angle }) => { game.closeup.angle = angle; game.closeup.aimLocked = true; }, m);
|
||||
if (n === 1) await r.cue("tira");
|
||||
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||
await p.evaluate((v) => { game.closeup.power = v; }, m.power);
|
||||
await sleep(500);
|
||||
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||
if (n === 1) await r.cue("valori");
|
||||
}
|
||||
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 120000 });
|
||||
await sleep(1500);
|
||||
await r.cue("fine");
|
||||
await r.aspetta();
|
||||
await d.click(p.locator("#closeup-close"), 0.6);
|
||||
await r.fermi(800);
|
||||
await r.fine();
|
||||
await r.salvaAudio();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copione del video «Come si gioca: le bocce» (video/SCALETTA-CARTE.md, clip 4).
|
||||
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||
|
||||
export const title = { it: "Come si gioca · le bocce", en: "Come si gioca · le bocce" };
|
||||
export const cues = [
|
||||
c("dove", "Le bocce si giocano al pallaio, accanto al tennis della chiesa. Mariano, il campione, sfida chiunque"),
|
||||
c("livello", "Si sceglie quanto deve impegnarsi: alla bona, seria, o a fassi male"),
|
||||
c("campo", "Il campo visto da dietro: in fondo c'è il pallino, e a sinistra il dettaglio, per vedere da vicino chi è più accosto"),
|
||||
c("mira", "Per mirare si tocca il campo, o il dettaglio vicino al pallino. Il cerchio azzurro dice dove si fermerebbe la boccia"),
|
||||
c("forza", "Poi si regola la forza col cursore, e si preme Lancia"),
|
||||
c("turno", "Tira sempre chi non ha il punto. Chi è dietro può accostare, oppure bocciare: colpire la boccia dell'altro per toglierla di lì"),
|
||||
c("tieni", "C'è anche un altro modo: si tiene premuto sul campo, la forza sale, e lasciando parte la boccia. Vanno bene anche le frecce e la barra spaziatrice"),
|
||||
c("punti", "Tre bocce a testa. Alla fine fa un punto ogni boccia più vicina al pallino della migliore dell'avversario"),
|
||||
c("fine", "E se batti Mariano, ti porta a Brento Sanico. Buona partita!"),
|
||||
];
|
||||
|
||||
export const setup = base;
|
||||
|
||||
export async function run(d) {
|
||||
const p = d.page, r = regista(d, "tutorial-bocce");
|
||||
await r.ascolta();
|
||||
await r.vaiA("tennis_chiesa", [430, 310]);
|
||||
await r.cue("dove");
|
||||
await r.parlaCon("mariano");
|
||||
await r.scegli("Facciamo una partita");
|
||||
await r.cue("livello");
|
||||
await r.scegli("Alla bona.");
|
||||
// Una mano fissa: accostando bene, Andrea fa tre punti (anche se il tiro a pressione viene un po' corto o lungo).
|
||||
await p.evaluate(async () => {
|
||||
const { newBocce } = await import("/src/engine/bocce.js");
|
||||
game.closeup = { id: "bocce", ...newBocce(9, { level: "bona" }) };
|
||||
});
|
||||
await sleep(800);
|
||||
await p.waitForFunction(() => window.music?.want === "assets/audio/bocce.mp3", null, { timeout: 10000 });
|
||||
await r.cue("campo");
|
||||
await r.muovi([300, 200], 1.0); await sleep(400); await r.muovi([90, 170], 1.0);
|
||||
|
||||
// Dove mirare: poco prima del pallino, così la boccia si ferma accosto (il cerchio azzurro ci finisce sopra).
|
||||
const mira = () => p.evaluate(async () => {
|
||||
const B = await import("/src/engine/bocce.js"), c = game.closeup, u = c.jack.u - 0.55, w = c.jack.w;
|
||||
const s = B.project(u, w);
|
||||
return { at: [s.x, s.y], u, w, power: B.powerFor(Math.hypot(u, w) - 0.29) };
|
||||
});
|
||||
const tocca = async () => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const s = await p.evaluate(async () => {
|
||||
const { canThrow } = await import("/src/engine/bocce.js");
|
||||
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||
});
|
||||
if (s !== "aspetta") return s === "tocca";
|
||||
await sleep(150);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let tiro = 0;
|
||||
while (await tocca()) {
|
||||
tiro++;
|
||||
const m = await mira();
|
||||
if (tiro === 1) await r.cue("mira");
|
||||
if (tiro < 3) {
|
||||
// Mira col clic, forza col cursore, poi Lancia.
|
||||
await r.clicca(m.at, 0.8);
|
||||
await p.evaluate(async ({ u, w }) => (await import("/src/engine/bocce.js")).aim(game.closeup, u, w), m);
|
||||
if (tiro === 1) await r.cue("forza");
|
||||
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||
await p.evaluate(async (v) => (await import("/src/engine/bocce.js")).setPower(game.closeup, v), m.power);
|
||||
await sleep(600);
|
||||
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||
if (tiro === 1) await r.cue("turno");
|
||||
} else {
|
||||
// L'ultima tenendo premuto: la forza sale finché non si lascia.
|
||||
await r.cue("tieni");
|
||||
await r.muovi(m.at, 0.8);
|
||||
const [a, b] = await r.punto(...m.at);
|
||||
d.vm(`DISPLAY=:0 xdotool mousemove ${Math.round(a)} ${Math.round(b)} mousedown 1 sleep ${(m.power * 1.6).toFixed(2)} mouseup 1`);
|
||||
}
|
||||
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||
}
|
||||
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 90000 });
|
||||
await sleep(1000);
|
||||
await r.cue("punti");
|
||||
await r.cue("fine");
|
||||
await r.aspetta();
|
||||
await d.click(p.locator("#closeup-close"), 0.6);
|
||||
await r.fermi(800);
|
||||
await r.fine();
|
||||
await r.salvaAudio();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copione del video «Come si gioca: la scopa» (video/SCALETTA-CARTE.md, clip 3).
|
||||
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||
|
||||
export const title = { it: "Come si gioca · la scopa", en: "Come si gioca · la scopa" };
|
||||
export const cues = [
|
||||
c("dove", "Allo stesso tavolo dell'oratorio si gioca anche a scopa, in due: basta chiedere «Una scopa?»"),
|
||||
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||
c("valori", "Quattro carte in tavola e tre in mano. L'Asso vale uno, le altre il loro numero, il gobbo otto, la donna nove e il regio dieci"),
|
||||
c("presa", "Con una carta prendi quella dello stesso valore; se non c'è, un gruppo di carte che fa la stessa somma. Se puoi prendere, devi"),
|
||||
c("scelta", "Quando le prese possibili sono più d'una, scegli tu: tocca una carta in tavola, oppure usa i pulsanti qui sotto"),
|
||||
c("scopa", "Se con la tua carta pulisci tutta la tavola, è scopa: un punto in più"),
|
||||
c("resto", "Finite le tre carte se ne danno altre tre, finché c'è mazzo. Quel che resta in tavola alla fine va all'ultimo che ha preso"),
|
||||
c("conto", "A fine smazzata un punto a chi ha più carte, uno a chi ha più mattoni, uno per il settebello, il sette di mattoni, e uno per la primiera"),
|
||||
c("primiera", "La primiera somma la carta migliore di ogni seme: il sette vale ventuno, il sei diciotto, l'Asso sedici"),
|
||||
c("fine", "Si va agli undici punti, una smazzata dopo l'altra. Buona scopa!"),
|
||||
];
|
||||
|
||||
export const setup = base;
|
||||
|
||||
export async function run(d) {
|
||||
const p = d.page, r = regista(d, "tutorial-scopa");
|
||||
await r.ascolta();
|
||||
await r.vaiA("oratorio", [470, 300]);
|
||||
await r.cue("dove");
|
||||
await r.parlaCon("giocatore_carte");
|
||||
await r.scegli("Una scopa?");
|
||||
await r.cue("mazzo");
|
||||
await r.scegli("Con le toscane.");
|
||||
await r.scegli("Seria.");
|
||||
// Una smazzata fissa: Andrea sceglie fra due prese, fa due scope e la vince 5 a 1.
|
||||
await p.evaluate(async () => {
|
||||
const { newScopa } = await import("/src/engine/scopa.js");
|
||||
game.closeup = { id: "scopa", ...newScopa(73, { level: "seria", deck: "toscane" }) };
|
||||
});
|
||||
await sleep(800);
|
||||
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||
await r.cue("valori");
|
||||
await r.muovi([320, 160], 1.0); await sleep(500); await r.muovi([320, 300], 1.0);
|
||||
await r.cue("presa");
|
||||
|
||||
// Una mossa di Andrea, come la sceglierebbe un giocatore attento; la scelta fra più prese si fa col pulsante.
|
||||
let k = 1, scelta = false, scopa = false;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const s = await p.evaluate(async () => {
|
||||
const { canPlay } = await import("/src/engine/scopa.js");
|
||||
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||
});
|
||||
if (s === "fine") break;
|
||||
if (s === "aspetta") { await sleep(150); continue; }
|
||||
const m = await p.evaluate(async (k) => {
|
||||
const S = await import("/src/engine/scopa.js"), c = game.closeup, m = S.choose(S.observation(c, "player"), "male", k);
|
||||
const card = c.hands.player[m.index], many = S.captures(c.table, card).length > 1;
|
||||
const clean = !!m.captured && m.captured.length === c.table.length;
|
||||
const { handX, HAND } = await import("/src/engine/scopa-render.js");
|
||||
return { ...m, many, clean, at: [handX(c.hands.player.length, m.index) + 32, HAND.y + 46] };
|
||||
}, k++);
|
||||
if (m.many && !scelta) { scelta = true; await r.cue("scelta"); }
|
||||
if (m.clean && !scopa) { scopa = true; await r.cue("scopa"); }
|
||||
await r.clicca(m.at, 0.7);
|
||||
if (m.many) {
|
||||
await p.waitForFunction(() => game.closeup.step === "choose", null, { timeout: 5000 });
|
||||
await sleep(900);
|
||||
await d.click(p.locator(`#scopa-options [data-option="${m.option}"]`), 0.7);
|
||||
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||
}
|
||||
if (k === 9) await r.cue("resto");
|
||||
}
|
||||
await p.waitForFunction(() => game.closeup.step === "smazzata", null, { timeout: 60000 });
|
||||
await sleep(1200);
|
||||
await r.cue("conto");
|
||||
await r.cue("primiera");
|
||||
await r.cue("fine");
|
||||
await r.aspetta();
|
||||
await d.click(p.locator("#closeup-close"), 0.6);
|
||||
await r.fermi(800);
|
||||
await r.fine();
|
||||
await r.salvaAudio();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copione del video «Come si gioca: il tressette ai 21» (video/SCALETTA-CARTE.md, clip 2).
|
||||
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
|
||||
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
|
||||
|
||||
export const title = { it: "Come si gioca · il tressette ai 21", en: "Come si gioca · il tressette ai 21" };
|
||||
export const cues = [
|
||||
c("dove", "Per il tressette ai ventuno servono quattro giocatori: il giocatore di carte chiama due amici, e uno fa da socio ad Andrea"),
|
||||
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||
c("tavolo", "Il socio è di fronte, gli avversari ai lati. Dieci carte a testa, e si gira verso destra"),
|
||||
c("accuse", "In alto le accuse: la napoletana, cioè Asso, due e tre dello stesso seme, oppure tre Assi, tre due o tre tre. Valgono punti subito"),
|
||||
c("seme", "Chi esce gioca quel che vuole; gli altri devono rispondere al seme, se ce l'hanno. Le carte che non si possono giocare diventano scure"),
|
||||
c("ordine", "Prende la più alta del seme di uscita: il tre, poi il due, l'Asso, il regio, la donna, il gobbo, e giù fino al quattro"),
|
||||
c("segni", "Chi esce può fare un segno al socio. Si sceglie coi pulsanti qui sotto, o coi tasti B, S e V, e poi si gioca la carta"),
|
||||
c("striscio", "Striscio vuol dire: di questo seme ne ho ancora, non è l'ultima"),
|
||||
c("busso", "Busso: prendi con la migliore che hai, e poi ritornami il seme"),
|
||||
c("volo", "Volo: era l'ultima carta di questo seme"),
|
||||
c("punti", "L'Asso vale un punto; il tre, il due e le figure un terzo di punto. L'ultima presa vale un punto in più"),
|
||||
c("smazzata", "A fine smazzata si contano i punti interi, e le accuse si sommano"),
|
||||
c("fine", "Si gioca smazzata dopo smazzata: la coppia che arriva per prima a ventuno è fuori, e ha vinto. Buon tressette!"),
|
||||
];
|
||||
|
||||
export const setup = base;
|
||||
|
||||
export async function run(d) {
|
||||
const p = d.page, r = regista(d, "tutorial-tressette");
|
||||
await r.ascolta();
|
||||
await r.vaiA("oratorio", [470, 300]);
|
||||
await r.cue("dove");
|
||||
await r.parlaCon("giocatore_carte");
|
||||
await r.scegli("Un tressette ai 21?");
|
||||
await r.cue("mazzo");
|
||||
await r.scegli("Con le toscane.");
|
||||
await r.scegli("Seria.");
|
||||
// Una smazzata fissa: accuse, e Andrea che usa striscio, busso e volo.
|
||||
await p.evaluate(async () => {
|
||||
const { newTressette } = await import("/src/engine/tressette.js");
|
||||
game.closeup = { id: "tressette", ...newTressette(11, { level: "seria", deck: "toscane" }) };
|
||||
});
|
||||
await sleep(800);
|
||||
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||
await r.cue("tavolo");
|
||||
await r.muovi([320, 60], 0.9); await sleep(300); await r.muovi([600, 150], 0.9); await sleep(300); await r.muovi([40, 150], 0.9);
|
||||
|
||||
let k = 1, detto = new Set(), seme = false, accuse = false;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const s = await p.evaluate(async () => {
|
||||
const { canPlay } = await import("/src/engine/tressette.js");
|
||||
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||
});
|
||||
if (s === "fine") break;
|
||||
if (s === "aspetta") { await sleep(150); continue; }
|
||||
// Le accuse si dicono giocando la prima carta: se ne parla quando sono scritte in alto.
|
||||
if (!accuse && await p.evaluate(() => game.closeup.accuse.length > 0)) { accuse = true; await r.cue("accuse"); }
|
||||
const m = await p.evaluate(async (k) => {
|
||||
const T = await import("/src/engine/tressette.js"), c = game.closeup, m = T.choose(T.observation(c, 0), "male", k);
|
||||
const { handX, CARD } = await import("/src/engine/tressette-render.js");
|
||||
return { ...m, follow: c.table.length > 0, at: [handX(c.hands[0].length, m.index) + 21, CARD.y + 30] };
|
||||
}, k++);
|
||||
if (m.follow && !seme) { seme = true; await r.cue("seme"); await r.cue("ordine"); }
|
||||
if (m.signal && !detto.has(m.signal)) {
|
||||
if (!detto.size) await r.cue("segni");
|
||||
detto.add(m.signal);
|
||||
await r.cue(m.signal);
|
||||
}
|
||||
if (m.signal) await d.click(p.locator(`#tressette-${m.signal}`), 0.6);
|
||||
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||
await r.clicca(m.at, 0.7);
|
||||
if (k === 7) await r.cue("punti");
|
||||
}
|
||||
await p.waitForFunction(() => ["smazzata", "result"].includes(game.closeup.step), null, { timeout: 60000 });
|
||||
await sleep(1200);
|
||||
await r.cue("smazzata");
|
||||
await r.cue("fine");
|
||||
await r.aspetta();
|
||||
await d.click(p.locator("#closeup-close"), 0.6);
|
||||
await r.fermi(800);
|
||||
await r.fine();
|
||||
await r.salvaAudio();
|
||||
}
|
||||