Compare commits

...
16 Commits
Author SHA1 Message Date
manzolo f6fb8922af Versione 0.1.21 2026-10-27 23:56:31 +01:00
manzoloandClaude Fable 5.1 bd7fbc3003 Basket: alla gloria Andrea sfida Jordan, non Cistola (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:50:10 +01:00
manzoloandClaude Fable 5.1 8b957c7cf7 Basket: la revisione di Codex, i punti gravi
Dalla mira iniziale (52°) da sotto non entrava con nessuna forza: ora 64°, forza minima più bassa e prima postazione
un passo indietro, così da ogni postazione entra in una fascia di forza contigua di almeno l'8% (test). Il palo
fermava anche sopra la sua cima e contava come tiro pulito; il tabellone lasciava passare da dietro. Il canestro
conta appena entra (anche il risultato all'ultimo pallone), la pausa fra un pallone e l'altro è più corta, la palla
entrata non rotola fino in fondo. Andrea resta a braccia alzate finché la palla vola (niente doppio pallone), il
rilascio parte sopra la sua testa. La guida si ferma al primo urto e ha un bordo scuro con la punta; il lampo del
canestro si spegne; l'obiettivo del livello è sempre in vista; i microurti sul ferro non tintinnano; dietro Andrea
non si carica; l'aiuto spiega il clic che fissa la mira e le soglie.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:39:30 +01:00
manzoloandClaude Fable 5.1 f1de62f01e «Fai due tiri» al campo da basket del Circolo 4 Maggio
Minigioco nuovo (idea di Andrea, 27 ottobre): vista di lato, Andrea da solo con dieci palloni; il mouse o il dito
danno l'angolo, tenendo premuto sale la forza, lasciando la palla vola in parabola, rimbalza sul ferro (davanti e
dietro dell'anello), sul tabellone e per terra, entra anche sporca; da sotto non vale. A ogni canestro si arretra
di una postazione fino a dietro la linea da tre (tre punti). Tre livelli: quanti canestri su dieci per la gloria
(3, 5, 7) e quanta parabola mostra la guida (quasi tutta, l'inizio, niente). Sfondo del campo e foglio di Andrea col
pallone di Codex; suoni di palla, ferro, tabellone e retina; stessi comandi delle bocce (pannello, tastiera, pressione);
dialogo di scelta dal campo di Via Cipriani, battute provvisorie e voci; test del motore (da ogni postazione esiste
un tiro che entra) e prova nel browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:16:35 +01:00
manzolo 64dc8e18e7 Versione 0.1.20 2026-10-27 23:02:27 +01:00
manzoloandClaude Fable 5.1 b262977117 Il carosello dei Ringraziamenti si ferma, si sfoglia e ingrandisce le foto
Tutti gli sfondi del paese (game.reelPictures, uno per scena, col nome del posto) e ogni ritratto una volta sola
(prima tre si ripetevano). Scorre da solo ma si ferma sotto il mouse, si trascina col mouse o col dito, si spinge
con la rotella, con le frecce ai lati e con quelle della tastiera; un tocco su una foto la mostra grande col nome
(il tocco si legge al rilascio: con la cattura del puntatore il clic non arriva all'immagine). Test della fila e
prova nel browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 22:57:01 +01:00
manzolo 3762f2b465 Versione 0.1.19 2026-10-27 22:40:04 +01:00
manzoloandClaude Fable 5.1 9f7a9812ed Via Cipriani col Cinema Taiuti e il Rattattù, Via Massorondinaio, i ragazzi delle canne in Fortezza, le Mozzète fino a Gabbiano
- Via Pietro Cipriani fra la Piazza e Via Massorondinaio (poi il campeggio): il Cinema Taiuti aperto come nel 2007
  con la locandina del Rattattù (stile della locandina vera di Andrea, scaletta illeggibile), il campo da basket
  del Circolo 4 Maggio (gioco da fare).
- In Fortezza quattro ragazzi inventati (sprite di Codex) si fanno le canne da pesca col bambù per il lago
  dell'Anatre dietro Cardetole; battuta di Andrea sulla mamma che aveva ragione.
- Piazzetta Cambray Digny: a sinistra è il muro di Villa Schifanoia, non la Fortezza (Andrea, con Wikipedia).
- Dal campo secondario, a destra: le Mozzète, il ponticino, Coldaia, la falegnameria, Gabbiano.
Sfondi di Codex da Street View (in IMMAGINI.md: da rifare con foto di Andrea prima di Pages); battute provvisorie e voci.

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

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

After

Width:  |  Height:  |  Size: 47 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"image": "betino.png",
"height": 104,
"frames": {
"idle": {
"x": 0,
"y": 0,
"w": 161,
"h": 104,
"ax": 80.5,
"ay": 104
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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

After

Width:  |  Height:  |  Size: 59 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"image": "pescatori.png",
"height": 110,
"frames": {
"work": {
"x": 0,
"y": 0,
"w": 170,
"h": 110,
"ax": 84.0,
"ay": 110
},
"talk": {
"x": 172,
"y": 0,
"w": 166,
"h": 112,
"ax": 79.2,
"ay": 112
},
"laugh": {
"x": 340,
"y": 0,
"w": 164,
"h": 113,
"ax": 79.7,
"ay": 113
},
"proud": {
"x": 506,
"y": 0,
"w": 166,
"h": 121,
"ax": 80.7,
"ay": 121
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+176
View File
@@ -203,6 +203,26 @@
"file": "andrea-69337973d0.mp3",
"sec": 5.94
},
"andrea|Quattro ragazzi che affilano canne di bambù col coltellino e ci legano le lenze.": {
"file": "andrea-98ff3bf0a3.mp3",
"sec": 6.41
},
"andrea|Oh, che fate?": {
"file": "andrea-22b8efe94b.mp3",
"sec": 1.75
},
"pescatori|Si preparano le canne: domattina si va a pescare al lago dell'Anatre, dietro Cardetole!": {
"file": "pescatori-486103d189.mp3",
"sec": 3.74
},
"pescatori|Guarda questa: la lenza la regge anche una carpa di dieci chili.": {
"file": "pescatori-afda722e0c.mp3",
"sec": 3.2
},
"andrea|(Vedi, mia mamma aveva ragione: in Fortezza si viene a farsi le canne.)": {
"file": "andrea-e0c6360b96.mp3",
"sec": 4.35
},
"andrea|Nove bastioni, li volle Cosimo I. Per tenere fuori i nemici, dicono. Secondo me per tenere dentro il Regolo.": {
"file": "andrea-d31f6e291b.mp3",
"sec": 8.71
@@ -507,6 +527,62 @@
"file": "andrea-4d6871c326.mp3",
"sec": 4.11
},
"andrea|Il Rattattù: la gara di canto del paese. Si sale sul palco e ci si sfida tutti, chi stona e chi no.": {
"file": "andrea-a9903d3ae0.mp3",
"sec": 8.33
},
"andrea|Quest'anno mi ci iscrivo. Forse.": {
"file": "andrea-146000c293.mp3",
"sec": 2.98
},
"andrea|Il Cinema Taiuti. Una sala, un proiettore e le poltroncine che cigolano a tempo.": {
"file": "andrea-3b310a5164.mp3",
"sec": 6.39
},
"andrea|A quest'ora è chiuso. Apre la sera, per il film o per il Rattattù.": {
"file": "andrea-5e37f70197.mp3",
"sec": 5.35
},
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.": {
"file": "andrea-3c4ee692ad.mp3",
"sec": 7.55
},
"andrea|La fermata della corriera. Da qui si va dappertutto. Se passa.": {
"file": "andrea-ccd735c926.mp3",
"sec": 5.72
},
"andrea|Il ponticino delle Mozzète. Sotto ci passa un fossino che d'estate fa più zanzare che acqua.": {
"file": "andrea-98dc291bd3.mp3",
"sec": 7.21
},
"andrea|Terra appena arata. Profuma di campagna e di trattore.": {
"file": "andrea-a07bf26ea7.mp3",
"sec": 4.41
},
"andrea|La falegnameria. L'odore di segatura arriva fin qui.": {
"file": "andrea-1ccd8e2127.mp3",
"sec": 3.84
},
"andrea|Bussare a una falegnameria? Mi danno una scopa e mi fanno spazzare.": {
"file": "andrea-09ec24ef60.mp3",
"sec": 4.63
},
"andrea|Un fienile pieno di rotoballe. Fossi una mucca, sarebbe il paradiso.": {
"file": "andrea-5dfec13174.mp3",
"sec": 4.81
},
"andrea|La campagna verso Gabbiano. Da quassù si vede mezzo Mugello.": {
"file": "andrea-06e1b17635.mp3",
"sec": 5.31
},
"andrea|Il palio, però, no.": {
"file": "andrea-0e9c7a48de.mp3",
"sec": 1.6
},
"andrea|Un casolare vecchio, coi mattoni che spuntano dall'intonaco. Come le mie ginocchia.": {
"file": "andrea-06c5f35df7.mp3",
"sec": 5.66
},
"andrea|L'ingresso del campeggio. A giudicare dalle bandiere, ci dorme mezzo mondo.": {
"file": "andrea-72dede01c0.mp3",
"sec": 5.13
@@ -627,6 +703,42 @@
"file": "andrea-e24cb31cbf.mp3",
"sec": 2.62
},
"andrea|Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.": {
"file": "andrea-60f39f0dd7.mp3",
"sec": 6.48
},
"andrea|Cistola. Il talento di via del Pignone.": {
"file": "andrea-2b47355c2c.mp3",
"sec": 3.81
},
"andrea|Col dieci, come i grandi. Se c'è un muro che se lo merita, è questo.": {
"file": "andrea-1bd111f29d.mp3",
"sec": 5.59
},
"andrea|Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.": {
"file": "andrea-0e558e54fe.mp3",
"sec": 4.18
},
"andrea|Il muro di Villa Schifanoia, la villa dei Medici, mezzo coperto dall'edera.": {
"file": "andrea-6a873beff6.mp3",
"sec": 5.07
},
"andrea|Lassù una volta c'era una torre, per tenere d'occhio chi passava sulla strada per Bologna. Ne ha viste più di me.": {
"file": "andrea-d9ba9bd3ad.mp3",
"sec": 7.34
},
"andrea|E la piazzetta si chiama come i Cambray-Digny, gli ultimi signori della villa.": {
"file": "andrea-9d3ec66f60.mp3",
"sec": 4.64
},
"andrea|Una casetta piccina, appoggiata al muro della villa come un gatto al sole.": {
"file": "andrea-3cef067df6.mp3",
"sec": 4.1
},
"andrea|Chiusa. E poi non è casa mia.": {
"file": "andrea-7fd73fbb77.mp3",
"sec": 2.66
},
"andrea|L'area feste. D'estate ci montano la pista da ballo, le tavolate e lo stand della pasta.": {
"file": "andrea-a942b2723e.mp3",
"sec": 6.77
@@ -943,6 +1055,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 +1651,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
@@ -1895,6 +2035,30 @@
"file": "arcu-406cd02197.mp3",
"sec": 4.46
},
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3",
"sec": 2.53
},
"andrea|Due tiri seri.": {
"file": "andrea-ec8f0db709.mp3",
"sec": 1.32
},
"andrea|Lascia stare, ho il palio da trovare.": {
"file": "andrea-f5519518f2.mp3",
"sec": 1.99
},
"andrea|Tre su dieci e sono contento.": {
"file": "andrea-5e3315a040.mp3",
"sec": 2.33
},
"andrea|Cinque su dieci. Come ai tempi del Circolino.": {
"file": "andrea-1bc731bcdf.mp3",
"sec": 3.59
},
"andrea|Sette su dieci, senza guardare la parabola. Vediamo chi sono.": {
"file": "andrea-fa062d00bd.mp3",
"sec": 4.95
},
"andrea|Mariano! Facciamo una partita a bocce?": {
"file": "andrea-83b1337b4d.mp3",
"sec": 3.31
@@ -2139,6 +2303,18 @@
"file": "mariano-39e27158b7.mp3",
"sec": 2.83
},
"andrea|Che mano! Jordan, fatti da parte.": {
"file": "andrea-3a52789f04.mp3",
"sec": 2.98
},
"andrea|Il ferro oggi era stretto. Riprovo dopo.": {
"file": "andrea-2e9d8549c8.mp3",
"sec": 3.36
},
"andrea|Lascio il pallone lì. Tanto lo sanno tutti che è mio.": {
"file": "andrea-ee42022092.mp3",
"sec": 3.98
},
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
"file": "ing_tozzi-51f855478a.mp3",
"sec": 4.42
Binary file not shown.
Binary file not shown.
+2
View File
@@ -27,6 +27,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `bosco-ai-frati.png` | Codex | `bosco-ai-frati.png` | [DA ANDREA] sembra una foto da Wikipedia/Wikimedia: verificare licenza | ? |
| `campeggio.png` | Codex | `campeggio-ingresso.png` | [DA ANDREA] probabilmente dal sito del campeggio | ? |
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
+84
View File
@@ -0,0 +1,84 @@
# Coqui Public Model License 1.0.0
https://coqui.ai/cpml.txt
This license allows only non-commercial use of a machine learning model and its outputs.
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Licenses
The licensor grants you a copyright license to do everything you might do with the model that would otherwise infringe the licensor's copyright in it, for any non-commercial purpose. The licensor grants you a patent license that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the model in the form provided by
the licensor, for any non-commercial purpose.
## Non-commercial Purpose
Non-commercial purposes include any of the following uses of the model or its output, but only so far as you do not receive any direct or indirect payment arising from the use of the model or its output.
### Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious
observance.
### Use by commercial or for-profit entities for testing, evaluation, or non-commercial research and development. Use of the model to train other models for commercial use is not a non-commercial purpose.
### Use by any charitable organization for charitable purposes, or for testing or evaluation. Use for revenue-generating activity, including projects directly funded by government grants, is not a non-commercial purpose.
## Notices
You must ensure that anyone who gets a copy of any part of the model, or any modification of the model, or their output, from you also gets a copy of these terms or the URL for them above.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the model infringes or contributes to infringement of any patent, your licenses for the model granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the model or its output that is not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 30 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the model and its output come as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the model or its output, under any kind of legal claim. If this provision is not enforceable in your jurisdiction, your licenses are void.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **model** is the model the licensor makes available under these terms, including any documentation or similar information about the model.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you under these terms.
**Use** means anything you do with the model or its output requiring one of your licenses.
+19
View File
@@ -199,6 +199,25 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
sfondi da foto prese da internet (`docs/IMMAGINI.md`), Cardetole (dice dove abita Andrea).
- Vecchietta e vecchietto sono in gioco sulla panchina della Piazza (`assets/characters/`, da
`assets/raw/vecchiett?-sheet.png`): consenso delle persone ritratte confermato da Andrea il 2026-10-23; mancano i loro nomi.
- Via Provinciale (dalla rotonda verso la Pieve) → a destra Via del Pignone → in cima Piazzetta Cambray Digny, che porta
in Piazza (a sinistra, dove prima c'era l'uscita per il Comune) e in Via de' Medici. Battute provvisorie sul murale.
Via del Pignone (sfondo di Codex da `assets/raw/foto/via-del-pignone.png`): sul muro di destra il
murale di Cistola (Davide Poggi), «il talento di via del Pignone», alla maniera dei murali di Maradona a Napoli
(volto da `assets/raw/riferimenti/cistola.png`). Consenso di Davide confermato da Andrea il 2026-10-27.
- Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
(`src/engine/basket.js`, vista di lato, sfondo e sprite di Andrea col pallone da Codex): da solo, dieci palloni, a ogni
canestro si arretra fino alla linea da tre; tre livelli (quanti canestri per la gloria, quanta parabola mostra la
guida). Battute provvisorie. Idea di Andrea del 27 ottobre. Revisione critica di Codex (27 ottobre) applicata per
i punti gravi (angolo iniziale 64° con fascia di forza buona da ogni postazione, palo e tabellone da entrambi i lati,
canestro che conta subito, pose senza doppio pallone, guida fino al primo urto, obiettivo sempre in vista). Restano
le sue idee: «Riprova» dal risultato e record per livello; Andrea che arretra camminando; battute che dicono perché
è uscita (corto, lungo, ferro); l'angolo anche nei comandi HTML; testi del canvas piccoli sul telefono;
`basket_vinto` che lasci traccia nel paese; pannelli più compatti.
- Fuori paese, dal campo secondario (a destra): le Mozzète → il ponticino → Coldaia → la falegnameria → Gabbiano;
da Via Cipriani, indietro, Via Massorondinaio → campeggio. Sfondi da Street View, battute provvisorie.
- In Fortezza quattro ragazzi inventati si fanno le canne da pesca col bambù per il lago dell'Anatre (sprite di Codex,
`assets/raw/pescatori-sheet.png`, ritagliato 2x2 a mano: work, talk, laugh, proud); battuta finale di Andrea.
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
+12 -1
View File
@@ -33,6 +33,7 @@
<p class="splash-name">Manzolo</p>
<p class="splash-art"><span>(Poca)</span> Art</p>
<p class="splash-present">Presents</p>
<p class="splash-hint">Tocca per cominciare</p>
</section>
<section id="title" class="overlay" hidden>
<h1>La Leggenda<br>del <span>Regolo</span></h1>
@@ -100,7 +101,7 @@
<h2>Ringraziamenti</h2>
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
<dl id="credits-list"></dl>
<div id="credits-reel" aria-hidden="true"><div class="track"></div></div>
<div id="credits-reel"><button class="arrow prev" type="button" aria-label="Foto precedente">‹</button><div class="track" aria-label="Le immagini del gioco: toccane una per vederla grande"></div><button class="arrow next" type="button" aria-label="Foto successiva">›</button></div>
<p class="legal">Monkey Island è un marchio di Lucasfilm Ltd., citato solo nella dedica: questo gioco non è affiliato né approvato da Lucasfilm.</p>
<div class="buttons"><button id="close-credits">Chiudi</button></div>
<figure id="face" hidden><img alt=""><figcaption></figcaption></figure>
@@ -208,6 +209,16 @@
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria.
Vince chi fa più punti nella mano.</p>
</details>
<details id="basket-help" hidden><summary>Come si gioca</summary>
<p>Muovi il mouse sul campo per scegliere l’angolo del tiro: la freccia parte dalle mani di Andrea. Un clic (o un
tocco) fissa la mira, così puoi raggiungere i comandi senza cambiarla; un altro clic la sposta. Tieni premuto sul
campo (o Spazio) e la forza riparte da zero e sale: lascia per tirare. Oppure regola la forza e premi Tira.
Frecce ← →: angolo più teso o più alto; Maiusc + ← →: a mezzo grado; ↑ ↓: forza. Esc esce.</p>
<p>Dieci palloni. Si comincia da sotto; a ogni canestro si arretra di una postazione: tiro libero, media distanza,
da tre e da lontanissimo, dove un canestro vale tre punti invece di due. Il ferro e il tabellone rimandano la
palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
</details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
+3 -1
View File
@@ -26,6 +26,8 @@ server {
audio/mpeg mp3;
image/svg+xml svg;
image/x-icon ico;
image/jpeg jpg jpeg;
video/mp4 mp4;
font/woff2 woff2;
}
@@ -37,7 +39,7 @@ server {
location ~* \.(css|js|mjs|json)$ {
add_header Cache-Control "no-cache";
}
location ~* \.(png|webp|mp3|woff2)$ {
location ~* \.(png|webp|jpg|mp3|mp4|woff2|ico)$ {
add_header Cache-Control $media_cache;
}
location = /index.html {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.15",
"version": "0.1.21",
"private": true,
"type": "module",
"scripts": {
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Prima di pubblicare: niente prompt, fogli grezzi, log di Codex, segreti o nomi della rete di casa.
# Guarda tutti i percorsi raggiungibili da tutti i ref (la storia intera, non solo HEAD) e il testo di HEAD.
# `--self-test` costruisce un repo usa e getta, inietta un prompt e un host .lan e pretende che siano rifiutati:
# un controllo che non fallisce mai non prova niente.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
FORBIDDEN_PATHS='(^|/)(PROMPT[A-Z_]*\.md|prompts/|assets/raw/|assets/generated/|codex[a-z.-]*\.log|deploy\.local\.mk)'
FORBIDDEN_PATHS+='|(^|/)\.env$|(^|/)id_(rsa|ed25519|ecdsa)(\.pub)?$|\.(pem|key|p12|pfx)$'
FORBIDDEN_TEXT='[a-z0-9-]+\.lan([^a-z0-9.-]|$)|192\.168\.[0-9]+\.[0-9]+|BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY'
scan_repo() {
local repo=$1 rc=0 hits
hits=$(git -C "$repo" rev-list --all --objects | awk 'NF>1{print $2}' | sort -u | grep -E "$FORBIDDEN_PATHS" || true)
if [[ -n $hits ]]; then
echo "FAIL: percorsi vietati nella storia (va riscritta, non basta cancellarli):"
echo "$hits" | head -40 | sed 's/^/ /'
rc=1
fi
hits=$(git -C "$repo" grep -nIE "$FORBIDDEN_TEXT" HEAD -- . ':!scripts/check-public.sh' 2>/dev/null | sed 's/^HEAD://' || true)
if [[ -n $hits ]]; then
echo "FAIL: testo vietato in HEAD:"
echo "$hits" | head -20 | sed 's/^/ /'
rc=1
fi
[[ $rc -eq 0 ]] && echo "PASS: niente prompt, fogli grezzi, segreti o host privati in $repo (tutti i ref)"
return $rc
}
self_test() {
local tmp
tmp=$(mktemp -d /tmp/check-public.XXXXXX)
trap 'rm -rf -- "$tmp"' RETURN
local -a g=(git -C "$tmp" -c user.name=check -c user.email=check@example.invalid -c commit.gpgsign=false)
git init -q "$tmp"
printf '# pulito\n' > "$tmp/README.md"
"${g[@]}" add README.md && "${g[@]}" commit -qm pulito
scan_repo "$tmp" >/dev/null || { echo 'self-test FAIL: un repo pulito deve passare'; return 1; }
mkdir -p "$tmp/prompts" && printf 'foto di famiglia\n' > "$tmp/prompts/character_x.md"
"${g[@]}" add prompts && "${g[@]}" commit -qm 'prompt iniettato'
"${g[@]}" rm -rq prompts && "${g[@]}" commit -qm 'cancellato (resta nella storia)'
if scan_repo "$tmp" >/dev/null 2>&1; then echo 'self-test FAIL: un prompt nella storia deve essere rifiutato'; return 1; fi
rm -rf "$tmp/.git" && git init -q "$tmp" && printf 'DEPLOY_HOST = root@casa.lan\n' > "$tmp/README.md"
"${g[@]}" add README.md && "${g[@]}" commit -qm 'host privato'
if scan_repo "$tmp" >/dev/null 2>&1; then echo 'self-test FAIL: un host .lan deve essere rifiutato'; return 1; fi
echo 'self-test PASS: repo pulito accettato, prompt nella storia e host .lan rifiutati'
}
[[ ${1:-} == --self-test ]] && { self_test; exit; }
scan_repo "$ROOT"
+113
View File
@@ -0,0 +1,113 @@
import { FLOOR, RIM, BALL, BALLS, SPOTS, canThrow, left, spot, releasePoint, guide as guidePoints, status } from './basket.js';
import { text, panel } from './bocce-render.js';
const ANDREA = { scale: 0.66 };
// Quale posa: carica mentre tiene premuto; finché la palla vola Andrea resta a braccia alzate (l'unica posa senza
// pallone), esulta quando entra, e a palla ferma ce l'ha di nuovo in mano.
export function andreaPose(c) {
if (c.holding) return 'load';
if (c.step === 'flight') return c.ball.scored ? 'cheer' : 'shoot';
if (c.step === 'settle' && c.ball?.scored) return 'cheer';
return 'hold';
}
function andrea(ctx, c, sheet) {
const s = spot(c), x = s.x, y = FLOOR;
ctx.fillStyle = 'rgba(8,10,9,0.5)'; ctx.beginPath(); ctx.ellipse(x + 4, y, 16, 3, 0, 0, Math.PI * 2); ctx.fill();
if (!sheet) return;
const pose = andreaPose(c), frame = sheet.atlas.frames[pose] ?? sheet.atlas.frames.hold, k = ANDREA.scale;
// Nel salto i piedi si staccano da terra per un attimo.
const lift = pose === 'shoot' ? Math.round(Math.sin(Math.min(1, c.ball.time / 0.45) * Math.PI) * 10) : 0;
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
Math.round(x - frame.ax * k), Math.round(y - frame.ay * k) - lift, Math.round(frame.w * k), Math.round(frame.h * k));
}
function ball(ctx, b, r = BALL) {
// L'ombra sul campo si stringe quando la palla è alta.
const h = Math.max(0, FLOOR - b.y - r), k = Math.max(0.35, 1 - h / 260);
ctx.fillStyle = `rgba(8,10,9,${0.45 * k})`; ctx.beginPath(); ctx.ellipse(b.x, FLOOR - 1, r * 1.2 * k, r * 0.4 * k, 0, 0, Math.PI * 2); ctx.fill();
const shade = ctx.createRadialGradient(b.x - r * 0.35, b.y - r * 0.35, 0, b.x, b.y, r * 1.05);
shade.addColorStop(0, '#ffb067'); shade.addColorStop(0.5, '#e8702a'); shade.addColorStop(1, '#8a3512');
ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2); ctx.fill();
// Le cuciture girano con la palla.
ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, r - 0.3, 0, Math.PI * 2); ctx.clip();
ctx.strokeStyle = 'rgba(60,20,8,0.75)'; ctx.lineWidth = 1;
const a = b.spin ?? 0;
ctx.beginPath(); ctx.ellipse(b.x, b.y, r, Math.abs(Math.cos(a)) * r, 0, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.ellipse(b.x, b.y, Math.abs(Math.sin(a)) * r, r, 0, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
ctx.fillStyle = '#ffd9b0'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.45), 1, 1);
}
function guide(ctx, c) {
const r = releasePoint(c);
// La freccia della mira c'è sempre, con la punta e un bordo scuro per staccarsi da rete e alberi; la parabola per
// quanto concede il livello, fino al primo urto.
const a = c.angle * Math.PI / 180, len = 22 + c.power * 18, tip = { x: r.x + Math.cos(a) * len, y: r.y - Math.sin(a) * len };
const arrow = () => {
ctx.beginPath(); ctx.moveTo(r.x, r.y); ctx.lineTo(tip.x, tip.y);
for (const s of [-1, 1]) { ctx.moveTo(tip.x, tip.y); ctx.lineTo(tip.x - Math.cos(a - s * 0.5) * 6, tip.y + Math.sin(a - s * 0.5) * 6); }
ctx.stroke();
};
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(20,16,10,0.8)'; arrow();
ctx.lineWidth = 1; ctx.strokeStyle = '#fff6d2'; arrow();
if (c.skill.preview > 0) {
const pts = guidePoints(c, c.skill.preview);
for (const [width, color] of [[3, 'rgba(20,16,10,0.6)'], [1, 'rgba(143,199,255,0.95)']]) {
ctx.lineWidth = width; ctx.strokeStyle = color; ctx.setLineDash([2, 4]);
ctx.beginPath();
for (const [i, p] of pts.entries()) { if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
ctx.stroke(); ctx.setLineDash([]);
}
}
ctx.fillStyle = 'rgba(20,16,10,0.6)'; ctx.fillRect(r.x - 34, r.y - 16, 30, 11);
text(ctx, `${c.angle.toFixed(c.angle % 1 ? 1 : 0)}°`, r.x - 6, r.y - 10, 6, '#fff4c9', 'right');
}
function swish(ctx, c) {
// Un lampo di righe attorno al ferro quando la palla è appena entrata.
const b = c.ball; if (c.step !== 'flight' || !b?.scored || b.time - b.scoredAt > 0.3) return;
const cx = (RIM.x1 + RIM.x2) / 2;
ctx.strokeStyle = 'rgba(255,240,170,0.8)';
for (let i = 0; i < 6; i++) { const t = i / 6 * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx + Math.cos(t) * 20, RIM.y + Math.sin(t) * 8); ctx.lineTo(cx + Math.cos(t) * 30, RIM.y + Math.sin(t) * 13); ctx.stroke(); }
}
export function drawBasket(ctx, c, def, bg, sheet) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else { ctx.fillStyle = '#6d7a5a'; ctx.fillRect(0, 0, 640, 360); }
// Le postazioni sul campo: un segno chiaro dove si tira ora, uno spento dove si è già stati.
for (const [i, s] of SPOTS.entries()) {
ctx.fillStyle = i === c.spot ? 'rgba(255,244,201,0.9)' : i < c.spot ? 'rgba(255,244,201,0.35)' : 'rgba(255,244,201,0.15)';
ctx.fillRect(s.x - 1, FLOOR + 2, 3, 3);
}
andrea(ctx, c, sheet);
for (const [i, t] of c.trails.entries()) {
ctx.fillStyle = `rgba(255,190,120,${0.3 * (1 - t.age / 0.5)})`;
ctx.fillRect(Math.round(t.x) - 1, Math.round(t.y) - 1, i % 2 ? 2 : 3, i % 2 ? 2 : 3);
}
if (canThrow(c) || c.holding) guide(ctx, c);
if (c.ball && c.step === 'flight') ball(ctx, c.ball); // ferma, la palla è già tornata in mano ad Andrea
swish(ctx, c);
panel(ctx, 182, 50, 440, 24);
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
// I palloni rimasti e il conto.
panel(ctx, 18, 86, 196, 58);
text(ctx, 'PALLONI', 26, 98, 6, '#eed5a0');
for (let n = 0; n < BALLS; n++) {
const x = 30 + n * 14, y = 113;
if (n < left(c)) ball(ctx, { x, y, spin: 0 }, 4);
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); }
}
text(ctx, `${c.baskets} su ${c.skill.goal} per la gloria · ${c.points} punti`, 26, 131, 6, c.baskets >= c.skill.goal ? '#b9e5b3' : '#d7ccb4');
// La forza in basso a sinistra: a destra c'è il pulsante per uscire.
if (canThrow(c) || c.holding) {
panel(ctx, 18, 306, 176, 37);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 318, 7);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 331, Math.round(c.power * 150), 5);
}
if (c.step === 'result') {
panel(ctx, 206, 91, 397, 70);
text(ctx, c.result.winner === 'player' ? 'CHE MANO!' : 'DUE TIRI FATTI', 404, 108, 9, '#efd49e', 'center');
text(ctx, `${c.result.baskets} canestri su ${BALLS}, ${c.result.points} punti`, 404, 132, 9, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
text(ctx, c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto.` : `Per la gloria ne servivano ${c.result.goal}.`, 404, 150, 6, '#d6c7af', 'center');
}
ctx.restore();
}
+170
View File
@@ -0,0 +1,170 @@
// «Fai due tiri»: Andrea da solo al campo del Circolo 4 Maggio (idea di Andrea, 27 ottobre). Vista di lato, nelle
// coordinate dello schermo (640x360): x verso il canestro, y verso il basso. Un pixel è circa 2,3 cm: il ferro, a
// 3,05 m, sta 133 px sopra il campo. Dieci palloni; a ogni canestro si arretra di una postazione, fino a quella da
// tre. Niente avversario: il risultato è quanti canestri su dieci, e i punti (due, tre da dietro la linea).
export const FLOOR = 296, RIM = { x1: 482, x2: 518, y: 163, r: 2 }, BOARD = { x: 519, top: 118, bottom: 178, depth: 8 }, NET = 190, POLE = { x: 562, top: 112 };
export const BALL = 8, G = 560, V_MIN = 140, V_MAX = 590, SWING = 1.4, BALLS = 10;
export const FIXED_STEP = 1 / 240, REST = 0.55, FLIGHT_MAX = 7;
export const RELEASE = { dx: 18, dy: -72 }; // da dove parte la palla rispetto ai piedi di Andrea: sopra la testa, nel salto
export const ANGLE = { min: 25, max: 78 };
export const SPOTS = [
{ x: 400, name: 'da sotto', points: 2 },
{ x: 320, name: 'dal tiro libero', points: 2 },
{ x: 240, name: 'da media distanza', points: 2 },
{ x: 150, name: 'da tre', points: 3 },
{ x: 90, name: 'da lontanissimo', points: 3 },
];
// I livelli: quanti canestri su dieci valgono la gloria, e per quanto tempo di volo la guida mostra la parabola
// (alla bona fino al primo urto, che è quasi tutta).
export const LEVELS = {
bona: { goal: 3, preview: 3 },
seria: { goal: 5, preview: 0.4 },
male: { goal: 7, preview: 0 },
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const rad = deg => deg * Math.PI / 180;
export function newBasket(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
return { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], current: 'player', spot: 0, thrown: 0, baskets: 0, points: 0,
angle: 64, power: 0.5, holding: false, holdTime: 0, accumulator: 0, ball: null, events: [], trails: [], history: [] };
}
export const canThrow = c => c.step === 'aim' && c.thrown < BALLS;
export const left = c => Math.max(0, BALLS - c.thrown);
export const spot = c => SPOTS[c.spot];
export const powerAt = time => clamp(time / SWING, 0, 1);
export const speedFor = power => V_MIN + (V_MAX - V_MIN) * clamp(power, 0, 1);
export function releasePoint(c) { const s = spot(c); return { x: s.x + RELEASE.dx, y: FLOOR + RELEASE.dy }; }
export function velocity(c) { const v = speedFor(c.power), a = rad(c.angle); return { vx: v * Math.cos(a), vy: -v * Math.sin(a) }; }
export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true;
}
export function setAngle(c, deg) {
if (!canThrow(c) || c.holding || !Number.isFinite(deg)) return false;
c.angle = clamp(deg, ANGLE.min, ANGLE.max); return true;
}
export const nudge = (c, dir, fine = false) => setAngle(c, c.angle + dir * (fine ? 0.5 : 2));
// La mira segue il punto toccato: l'angolo fra la mano di Andrea e il dito. Dietro di lui non si tira.
export function aimScreen(c, [x, y]) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
const r = releasePoint(c);
if (x <= r.x + 4) return false;
return setAngle(c, Math.atan2(r.y - y, x - r.x) * 180 / Math.PI);
}
// La parabola libera (senza urti) per `seconds` di volo: la guida la mostra per il tempo che il livello concede.
export function arc(c, seconds, every = 0.04) {
const r = releasePoint(c), v = velocity(c), out = [];
for (let t = 0; t <= seconds; t += every) out.push({ x: r.x + v.vx * t, y: r.y + v.vy * t + 0.5 * G * t * t });
return out;
}
// La guida: la parabola libera finché non tocca qualcosa (ferro, tabellone, palo, campo), al massimo `seconds`.
export function guide(c, seconds) {
const out = [];
for (const p of arc(c, seconds)) {
const touches = p.y + BALL > FLOOR || [RIM.x1, RIM.x2].some(x => Math.hypot(p.x - x, p.y - RIM.y) < BALL + RIM.r)
|| (p.x + BALL > BOARD.x && p.y + BALL > BOARD.top && p.y - BALL < BOARD.bottom) || (p.x + BALL > POLE.x && p.y + BALL > POLE.top);
out.push(p);
if (touches) break;
}
return out;
}
export function shoot(c) {
if (!canThrow(c) || c.holding) return false;
const r = releasePoint(c), v = velocity(c);
c.ball = { x: r.x, y: r.y, vx: v.vx, vy: v.vy, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, scoredAt: null, dead: false, spin: 0 };
c.step = 'flight'; c.t = c.accumulator = 0; c.thrown++; c.trails = [];
c.events.push({ kind: 'basket-throw', power: c.power });
return true;
}
export function press(c) {
if (!canThrow(c) || c.holding) return false;
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
}
export function cancel(c) {
if (!c.holding) return false;
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
}
export function release(c) {
if (!c.holding) return false;
if (c.holdTime < 0.14) return cancel(c); // un tocco breve serve a mirare, non spreca un pallone
c.holding = false; return shoot(c);
}
function hit(c, kind, strength) { if (c.events.length < 12) c.events.push({ kind, power: clamp(strength, 0, 1) }); }
// Il ferro visto di lato sono due punti: il davanti e il dietro dell'anello. La palla ci rimbalza come su un tondino.
function rimEnd(c, b, px, py) {
const dx = b.x - px, dy = b.y - py, d = Math.hypot(dx, dy), r = BALL + RIM.r;
if (d >= r || d < 1e-9) return;
const nx = dx / d, ny = dy / d, closing = b.vx * nx + b.vy * ny;
b.x = px + nx * r; b.y = py + ny * r;
if (closing < 0) {
const tx = -ny, ty = nx, vt = (b.vx * tx + b.vy * ty) * 0.92, vn = -closing * 0.58;
b.vx = vn * nx + vt * tx; b.vy = vn * ny + vt * ty;
b.rim = true;
if (-closing > 25) hit(c, 'basket-rim', -closing / 400); // i microurti di una palla che si posa non tintinnano
}
}
function physics(c, dt) {
const b = c.ball, before = b.y;
b.vy += G * dt; b.x += b.vx * dt; b.y += b.vy * dt; b.time += dt; b.spin += b.vx * dt / BALL;
// Il tabellone: una lastra verticale che guarda Andrea, ma rimanda anche una palla che torna da dietro.
// Il palo dietro ferma quel che passa oltre, dalla sua cima in giù: sopra, la palla vola via e ricade.
if (b.y + BALL > BOARD.top && b.y - BALL < BOARD.bottom) {
if (b.vx > 0 && b.x + BALL > BOARD.x && b.x - BALL < BOARD.x + BOARD.depth) { b.x = BOARD.x - BALL; b.vx = -b.vx * 0.6; b.board = true; hit(c, 'basket-board', Math.abs(b.vx) / 300); }
else if (b.vx < 0 && b.x - BALL < BOARD.x + BOARD.depth && b.x + BALL > BOARD.x) { b.x = BOARD.x + BOARD.depth + BALL; b.vx = -b.vx * 0.6; b.board = true; hit(c, 'basket-board', Math.abs(b.vx) / 300); }
}
if (b.x + BALL > POLE.x && b.y + BALL > POLE.top) { b.x = POLE.x - BALL; if (b.vx > 0) { b.vx = -b.vx * 0.5; b.board = true; hit(c, 'basket-board', 0.3); } }
rimEnd(c, b, RIM.x1, RIM.y); rimEnd(c, b, RIM.x2, RIM.y);
// Il canestro: la palla attraversa l'anello scendendo, fra il davanti e il dietro, e non da sotto.
const inside = b.x > RIM.x1 + BALL * 0.6 && b.x < RIM.x2 - BALL * 0.6;
if (inside && before <= RIM.y && b.y > RIM.y && b.vy > 0 && !b.scored && !b.under) {
b.scored = true; b.scoredAt = b.time; b.vx *= 0.35; b.vy *= 0.5;
hit(c, b.rim || b.board ? 'basket-in' : 'basket-swish', 0.7);
// Il canestro conta subito: il punteggio e, all'ultimo pallone, il risultato non aspettano la palla ferma.
c.baskets++; c.points += spot(c).points;
if (c.thrown >= BALLS) finish(c);
} else if (inside && before >= RIM.y && b.y < RIM.y && b.vy < 0) b.under = true;
if (b.scored && b.y < NET + BALL) { b.vx *= 1 - 2.5 * dt; b.vy *= 1 - 1.2 * dt; }
if (b.y + BALL > FLOOR) {
b.y = FLOOR - BALL;
if (b.vy > 40) { b.vy = -b.vy * 0.55; b.vx *= 0.85; b.bounces++; hit(c, 'basket-bounce', b.vy / -400); }
else { b.vy = 0; b.vx *= 1 - 3 * dt; }
}
const still = Math.hypot(b.vx, b.vy) < 10;
// Entrata, la palla è finita: non si aspetta che rotoli. Una palla posata sul ferro, nemmeno.
if (b.x < -20 || b.time > FLIGHT_MAX || b.bounces > 6 || (b.y + BALL >= FLOOR - 0.5 && Math.abs(b.vx) < 6 && b.vy === 0)
|| (b.scored && b.y > NET + 30) || (still && b.time > 1.5)) b.dead = true;
}
function finish(c) {
c.result = { winner: c.baskets >= c.skill.goal ? 'player' : 'cpu', baskets: c.baskets, points: c.points, goal: c.skill.goal };
}
function nextBall(c) {
const b = c.ball;
c.history.push({ spot: c.spot, scored: b.scored, rim: b.rim, board: b.board });
if (b.scored) c.spot = Math.min(c.spot + 1, SPOTS.length - 1);
if (c.thrown >= BALLS) { if (!c.result) finish(c); c.step = 'result'; } else c.step = 'aim';
c.t = 0;
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'flight') {
c.accumulator += dt;
while (c.accumulator >= FIXED_STEP && !c.ball.dead) { physics(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
const b = c.ball;
if (!b.dead && Math.hypot(b.vx, b.vy) > 60) { c.trails.push({ x: b.x, y: b.y, age: 0 }); if (c.trails.length > 14) c.trails.shift(); }
if (b.dead) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) nextBall(c);
for (const t of c.trails) t.age += dt;
c.trails = c.trails.filter(t => t.age < 0.5);
}
export function status(c, names = { player: 'Andrea' }) {
const tally = `Canestri ${c.baskets} (ne servono ${c.skill.goal}), punti ${c.points}.`;
if (c.step === 'result') return `${c.result.baskets} canestri su ${BALLS}, ${c.result.points} punti. ${c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
if (c.step === 'flight') return c.ball.scored ? `Dentro! ${tally}` : 'Palla in aria…';
if (c.step === 'settle') return c.ball.scored ? `Canestro! ${tally}` : `Fuori. ${tally}`;
return `${names.player}, pallone ${c.thrown + 1} di ${BALLS}, ${spot(c).name}. ${tally}`;
}
+18
View File
@@ -3,6 +3,7 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
import * as basket from './basket.js';
import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.js';
@@ -14,6 +15,7 @@ export const MINIGAMES = {
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
};
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
@@ -70,6 +72,22 @@ export function shuffledCredits(credits, random = Math.random) {
});
}
// Le immagini del carosello dei Ringraziamenti: tutti gli sfondi del paese (uno per scena, senza doppioni) col nome
// del posto, e un ritratto dei crediti ogni quattro sfondi; i ritratti che non trovano posto chiudono la fila.
export function reelPictures(story) {
const seen = new Set(), scenes = [];
for (const s of Object.values(story.scenes)) {
if (!s.background || seen.has(s.background)) continue;
seen.add(s.background);
scenes.push({ src: s.background, name: s.name, kind: 'scene' });
}
const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(facePicture);
let next = 0;
const placed = scenes.flatMap((p, i) => i % 4 === 3 && next < faces.length ? [p, faces[next++]] : [p]);
return [...placed, ...faces.slice(next)];
}
export const facePicture = n => ({ src: n.face, name: n.name, kind: n.face.endsWith('palio.png') ? 'palio' : 'face' });
// La ricerca della mappa: ogni parola scritta deve stare nel nome del posto, senza badare a maiuscole, accenti e
// apostrofi («camp» trova Campomigliaio, «de medici» trova Via de' Medici).
const plain = text => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
+6
View File
@@ -5,6 +5,7 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawBasket } from './basket-render.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
@@ -682,6 +683,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'basket') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
return;
}
if (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
+7
View File
@@ -118,6 +118,13 @@ export class Soundscape {
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
// Il basket: il tiro, la palla che batte per terra, il ferro, il tabellone, la retina.
else if (kind === 'basket-throw') this.noiseSound(0.08, 1200, 0.05, { pan });
else if (kind === 'basket-bounce') { this.tone(190, 95, 0.11, 0.05 + strength * 0.12, 0, pan); this.noiseSound(0.06, 500, 0.05, { pan }); }
else if (kind === 'basket-rim') { this.tone(1500, 1100, 0.14, 0.04 + strength * 0.1, 0, pan); this.tone(2300, 1900, 0.08, 0.03, 0, pan); }
else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); }
else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan });
else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); }
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
+85 -31
View File
@@ -1,6 +1,6 @@
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace } from './engine/game.js';
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
@@ -10,6 +10,7 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as basket from './engine/basket.js';
import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js';
@@ -125,7 +126,7 @@ function openCredits() {
if (typeof n === 'string') return Object.assign(document.createElement('span'), { textContent: n });
// Chi ha il ritratto: il nome è un pulsante che lo mostra.
const b = Object.assign(document.createElement('button'), { type: 'button', className: 'credit-face', textContent: n.name });
b.addEventListener('click', () => showFace(n));
b.addEventListener('click', () => showFace(facePicture(n)));
return b;
}));
return [dt, dd];
@@ -133,22 +134,64 @@ function openCredits() {
fillReel();
$('credits').hidden = false;
rollCredits();
rollReel();
}
// Sotto i nomi, un carosello che scorre da solo: gli sfondi del paese e i ritratti, due giri uguali in fila per non
// vedere mai la cucitura.
// Sotto i nomi, il carosello: tutti gli sfondi del paese e i ritratti (game.reelPictures), due giri uguali in fila per
// non vedere mai la cucitura. Scorre da solo; si ferma sotto il mouse, si trascina, si spinge con la rotella, le
// frecce ai lati o quelle della tastiera, e toccando una foto la si vede grande (Andrea, 27 ottobre).
const reelTrack = $('credits-reel').querySelector('.track');
let reeling = 0, reelWait = 0, reelDrag = null, reelDragged = false, reelTap = null;
const reelPics = new WeakMap();
function fillReel() {
const track = $('credits-reel').querySelector('.track');
if (track.children.length) return;
const pictures = [...new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean))];
const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(n => n.face);
// I ritratti sparsi fra gli sfondi, uno ogni quattro.
const all = pictures.flatMap((p, i) => i % 4 === 3 && faces.length ? [p, faces[(i >> 2) % faces.length]] : [p]);
for (const src of [...all, ...all]) {
const img = Object.assign(document.createElement('img'), { src, alt: '', loading: 'lazy', decoding: 'async' });
if (src.includes('/credits/')) img.className = src.endsWith('palio.png') ? 'palio' : 'face';
track.append(img);
if (reelTrack.children.length) return;
const all = reelPictures(story);
for (const p of [...all, ...all]) {
const img = Object.assign(document.createElement('img'), { src: versioned(p.src), alt: p.name, title: p.name, className: p.kind, loading: 'lazy', decoding: 'async', draggable: false });
reelPics.set(img, p);
reelTrack.append(img);
}
}
function rollReel() {
cancelAnimationFrame(reeling);
reelTrack.scrollLeft = 0;
reelWait = 0;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let last = performance.now(), pos = 0;
const step = now => {
if ($('credits').hidden) return;
const dt = now - last; last = now;
const half = reelTrack.scrollWidth / 2;
if (reelTrack.matches(':hover') || reelDrag || !$('face').hidden || (reelWait -= dt) > 0) pos = reelTrack.scrollLeft;
else { pos += dt * 0.05; if (pos >= half) pos -= half; reelTrack.scrollLeft = pos; }
reeling = requestAnimationFrame(step);
};
reeling = requestAnimationFrame(step);
}
// Di una foto alla volta, con le frecce.
function nudgeReel(dir) {
reelWait = 4000;
reelTrack.scrollBy({ left: dir * ((reelTrack.firstElementChild?.offsetWidth ?? 300) + 10), behavior: 'smooth' });
}
for (const b of $('credits-reel').querySelectorAll('.arrow')) b.addEventListener('click', () => nudgeReel(b.classList.contains('next') ? 1 : -1));
// Col dito si trascina da sé (touch-action: pan-x); col mouse lo facciamo noi. Un tocco fermo su una foto la mostra
// grande: si guarda al rilascio, perché con la cattura del puntatore il clic non arriva più all'immagine.
reelTrack.addEventListener('pointerdown', e => {
reelDragged = false;
reelTap = reelPics.get(e.target) ?? null;
if (e.pointerType !== 'mouse') { reelWait = 4000; return; }
reelDrag = { x: e.clientX, left: reelTrack.scrollLeft, id: e.pointerId };
reelTrack.setPointerCapture(e.pointerId);
});
reelTrack.addEventListener('pointermove', e => {
if (!reelDrag) return;
const dx = e.clientX - reelDrag.x;
if (Math.abs(dx) > 6) reelDragged = true;
reelTrack.scrollLeft = reelDrag.left - dx;
});
const endReelDrag = () => { if (reelDrag && reelDragged) reelWait = 4000; reelDrag = null; };
reelTrack.addEventListener('pointerup', () => { if (!reelDragged && reelTap) showFace(reelTap); reelTap = null; endReelDrag(); });
reelTrack.addEventListener('pointercancel', () => { reelTap = null; endReelDrag(); });
reelTrack.addEventListener('wheel', e => { reelWait = 4000; if (e.deltaY && !e.deltaX) { reelTrack.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
// I nomi salgono piano da soli; si fermano se ci si passa sopra o si tocca, e ricominciano dall'alto in fondo.
let rolling = 0, rollWait = 0;
$('credits-list').addEventListener('pointerdown', () => { rollWait = 4000; });
@@ -170,9 +213,10 @@ function rollCredits() {
};
rolling = requestAnimationFrame(step);
}
function showFace({ name, face }) {
$('face').querySelector('img').src = versioned(face);
$('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png'));
function showFace({ name, src, kind }) {
const img = $('face').querySelector('img');
img.src = versioned(src);
img.className = kind;
$('face').querySelector('figcaption').textContent = name;
$('face').hidden = false;
}
@@ -453,11 +497,11 @@ canvas.addEventListener('pointerdown', e => {
return;
}
const aimed = game.closeupAim(scenePoint(e));
if (story.closeups[game.closeup.id].type === 'biliardo') {
if (!aimed && !game.speech) return;
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
}
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
if (['bocce', 'basket'].includes(story.closeups[game.closeup.id].type) && 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();
@@ -507,7 +551,7 @@ canvas.addEventListener('pointermove', 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);
const aiming = game.closeup && ['biliardo', 'bocce', 'basket'].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));
});
@@ -520,6 +564,7 @@ const MINI = {
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
@@ -631,7 +676,11 @@ window.addEventListener('keydown', e => {
}
if (debugKey(e)) return;
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; }
if (!$('credits').hidden) {
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if ($('face').hidden) nudgeReel(e.key === 'ArrowRight' ? 1 : -1); }
return;
}
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
@@ -857,7 +906,7 @@ function updateUi() {
}
}
if (mg && !cards) {
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket' }[def.type] ?? 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -867,6 +916,7 @@ function updateUi() {
$('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira';
$('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo';
$('basket-help').hidden = def.type !== 'basket';
if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
}
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
@@ -946,7 +996,7 @@ $('restart').addEventListener('click', () => {
// incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina.
const SPLASH_FADE = 4300, SPLASH_END = 5000;
function showSplash() {
let done = false, revealed = false;
let done = false, revealed = false, waiting = false;
const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } };
const end = () => {
if (done) return;
@@ -954,18 +1004,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}`;
+363 -17
View File
@@ -150,6 +150,22 @@ export const story = {
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }],
},
// «Fai due tiri» al campo da basket del Circolo 4 Maggio (idea di Andrea, 27 ottobre): da solo, dieci palloni,
// si arretra a ogni canestro. La gloria dipende dal livello (basket.LEVELS). [DA ANDREA: le sue battute]
basket: {
type: 'basket',
picture: 'assets/closeups/basket-campo.png',
names: { player: 'Andrea', cpu: 'il canestro' },
win: [
{ say: 'Che mano! Jordan, fatti da parte.' },
{ set: 'basket_vinto' },
],
lose: [
{ say: 'Il ferro oggi era stretto. Riprovo dopo.' },
],
quit: [{ say: 'Lascio il pallone lì. Tanto lo sanno tutti che è mio.' }],
draw: [],
},
// Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute]
biliardo: {
type: 'biliardo',
@@ -215,6 +231,10 @@ export const story = {
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
// Andrea col pallone, per «Fai due tiri» (foglio di Codex dal suo sprite): non parla, lo disegna basket-render.
andrea_basket: { name: 'Andrea', sprite: 'assets/characters/andrea-basket.json' },
// Quattro ragazzi inventati (sprite di Codex, idea di Andrea del 27 ottobre): nessuna persona reale.
pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
castello: { name: 'Lo spirito del Trebbio', color: '#c8f0ff' },
@@ -230,6 +250,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: {
@@ -475,13 +497,10 @@ export const story = {
},
},
{
id: 'verso_campeggio', name: 'strada verso il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
id: 'verso_campeggio', name: 'Via Cipriani, verso il cinema e il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'via_cipriani', at: [300, 345], face: 'up' },
},
{
id: 'verso_villa_adami', name: 'Via de\' Medici, verso Villa Adami', box: [0, 196, 90, 50], at: [92, 250], exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
},
{
id: 'verso_comune', name: 'a sinistra, verso il Comune', box: [96, 196, 50, 54], at: [120, 252], exit: { scene: 'comune', at: [20, 300], face: 'right' },
id: 'verso_piazzetta', name: 'Piazzetta Cambray Digny, verso Via de\' Medici', box: [0, 196, 146, 54], at: [92, 250], exit: { scene: 'piazzetta', at: [510, 274], face: 'down' },
},
{
id: 'verso_calimara', name: 'Via Calimara, verso la farmacia', box: [180, 334, 380, 26], at: [360, 354], exit: { scene: 'via_calimara', at: [365, 212], face: 'down' },
@@ -502,7 +521,22 @@ export const story = {
walk: [[110, 284], [300, 280], [330, 272], [348, 274], [400, 292], [442, 294], [640, 352], [640, 360], [0, 360], [0, 315], [60, 310], [105, 295]],
depth: [[272, 0.55], [360, 1.15]],
entry: [175, 352],
speakers: { pescatori: [150, 225] },
// Quattro ragazzi sull'erba si fanno le canne da pesca col bambù.
people: [{ who: 'pescatori', at: [150, 322], idle: 'work', talk: ['work', 'talk'], blink: 'laugh', every: 6, size: 1.25 }],
hotspots: [
{
id: 'pescatori', name: 'ragazzi con le canne', box: [92, 236, 120, 86], at: [240, 334], face: 'left', default: 'talk',
actions: {
look: [{ say: 'Quattro ragazzi che affilano canne di bambù col coltellino e ci legano le lenze.' }],
talk: [
{ say: 'Oh, che fate?' },
{ say: 'Si preparano le canne: domattina si va a pescare al lago dell\'Anatre, dietro Cardetole!', who: 'pescatori', pose: 'talk' },
{ say: 'Guarda questa: la lenza la regge anche una carpa di dieci chili.', who: 'pescatori', pose: 'proud' },
{ say: '(Vedi, mia mamma aveva ragione: in Fortezza si viene a farsi le canne.)' },
],
},
},
{
id: 'mura', name: 'bastione', box: [420, 0, 220, 290],
actions: {
@@ -656,7 +690,7 @@ export const story = {
},
},
{
id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' },
id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'provinciale_pignone', at: [268, 216], face: 'down' },
},
{
id: 'verso_forno', name: 'a destra, verso il forno', box: [570, 176, 70, 76], at: [588, 250], exit: { scene: 'forno', at: [628, 300], face: 'left' },
@@ -833,7 +867,7 @@ export const story = {
},
],
},
// Via de' Medici: dalla Piazza sale verso Villa Adami (la Piazza è alle spalle, in fondo si va su).
// Via de' Medici: dalla Piazzetta Cambray Digny sale verso Villa Adami (la Piazzetta è alle spalle, in fondo si va su).
via_de_medici: {
name: 'Via de\' Medici',
map: [22, 32],
@@ -853,11 +887,192 @@ export const story = {
id: 'su_villa_adami', name: 'su verso Villa Adami', box: [272, 186, 60, 34], at: [300, 216], exit: { scene: 'villa_adami', at: [50, 252], face: 'right' },
},
{
id: 'giu_piazza', name: 'giù verso la Piazza', box: [0, 334, 560, 26], at: [300, 354], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
id: 'giu_piazzetta', name: 'giù verso Piazzetta Cambray Digny', box: [0, 334, 545, 26], at: [300, 354], exit: { scene: 'piazzetta', at: [20, 320], face: 'right' },
},
],
},
// L'ingresso del campeggio: dalla Piazza a diritto; poco dopo, a destra, si sale al Bosco ai Frati.
// Via Pietro Cipriani, fra Via Massorondinaio (alle spalle, verso il campeggio) e la Piazza (in fondo, col Circolo 4 Maggio arancione).
// A destra il Cinema Taiuti, aperto come nel 2007, col cartellone del Rattattù; lassù il campo da basket del Circolo.
via_cipriani: {
name: 'Via Pietro Cipriani',
map: [22, 64],
background: 'assets/scenes/via-cipriani.png',
walk: [[40, 360], [40, 320], [60, 280], [100, 272], [150, 276], [300, 300], [360, 318], [640, 345], [640, 360]],
depth: [[272, 0.45], [360, 1.15]],
entry: [300, 345],
hotspots: [
{
id: 'rattattu', name: 'cartellone del Rattattù', box: [460, 152, 58, 110], at: [480, 336], face: 'up',
actions: {
look: [{ say: 'Il Rattattù: la gara di canto del paese. Si sale sul palco e ci si sfida tutti, chi stona e chi no.' }, { say: 'Quest\'anno mi ci iscrivo. Forse.' }],
},
},
{
id: 'cinema', name: 'Cinema Taiuti', box: [420, 40, 220, 220], at: [520, 340], face: 'up',
actions: {
look: [{ say: 'Il Cinema Taiuti. Una sala, un proiettore e le poltroncine che cigolano a tempo.' }],
open: [{ say: 'A quest\'ora è chiuso. Apre la sera, per il film o per il Rattattù.' }],
},
},
{
id: 'campo_basket', name: 'campo da basket del Circolo', box: [250, 110, 170, 80], at: [340, 316], face: 'up', default: 'use',
actions: {
look: [{ say: 'Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.' }],
use: [{ dialogue: 'basket' }],
walk: [{ dialogue: 'basket' }],
},
},
{
id: 'alla_piazza', name: 'in fondo, la Piazza', box: [80, 230, 120, 50], at: [120, 280], exit: { scene: 'piazza', at: [345, 240], face: 'down' },
},
{
id: 'al_campeggio', name: 'indietro, verso il campeggio', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'via_massorondinaio', at: [30, 300], face: 'right' },
},
],
},
// Via Massorondinaio: a sinistra si riprende Via Cipriani (verso la Piazza), a diritto si va al campeggio.
via_massorondinaio: {
name: 'Via Massorondinaio',
map: [17, 74],
background: 'assets/scenes/via-massorondinaio.png',
walk: [[0, 360], [0, 266], [200, 258], [400, 240], [520, 226], [566, 226], [552, 250], [545, 290], [600, 360]],
depth: [[226, 0.4], [360, 1.2]],
entry: [300, 330],
hotspots: [
{
id: 'a_via_cipriani', name: 'a sinistra, Via Cipriani', box: [0, 262, 40, 80], at: [16, 300], face: 'left', exit: { scene: 'via_cipriani', at: [300, 345], face: 'up' },
},
{
id: 'al_campeggio', name: 'a diritto, verso il campeggio', box: [496, 196, 74, 34], at: [535, 232], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
},
],
},
// Le Mozzète (Via Donatello): in fondo il campo secondario, in basso a destra si scende al ponticino.
mozzete: {
name: 'Le Mozzète',
map: [72, 92],
background: 'assets/scenes/mozzete.png',
walk: [[0, 360], [0, 280], [40, 232], [120, 212], [300, 200], [335, 198], [350, 210], [420, 270], [640, 330], [640, 360]],
depth: [[198, 0.3], [360, 1.2]],
entry: [320, 330],
hotspots: [
{
id: 'fermata', name: 'fermata della corriera', box: [450, 170, 110, 90], at: [470, 300], face: 'right',
actions: {
look: [{ say: 'La fermata della corriera. Da qui si va dappertutto. Se passa.' }],
},
},
{
id: 'al_secondario', name: 'in fondo, il campo secondario', box: [300, 176, 60, 30], at: [330, 206], exit: { scene: 'polisportiva', at: [628, 290], face: 'left' },
},
{
id: 'al_ponticino', name: 'giù a destra, il ponticino delle Mozzète', box: [580, 316, 60, 44], at: [612, 345], face: 'right', exit: { scene: 'ponticino_mozzete', at: [300, 196], face: 'down' },
},
],
},
// Il ponticino delle Mozzète: lassù la congiunzione con le Mozzète, indietro si va verso Coldaia.
ponticino_mozzete: {
name: 'Il ponticino delle Mozzète',
map: [78, 88],
background: 'assets/scenes/ponticino-mozzete.png',
walk: [[0, 360], [0, 300], [200, 270], [280, 190], [320, 186], [380, 230], [520, 290], [640, 310], [640, 360]],
depth: [[186, 0.3], [360, 1.2]],
entry: [300, 340],
hotspots: [
{
id: 'parapetto', name: 'parapetto del ponticino', box: [460, 230, 120, 50], at: [470, 300], face: 'right',
actions: {
look: [{ say: 'Il ponticino delle Mozzète. Sotto ci passa un fossino che d\'estate fa più zanzare che acqua.' }],
},
},
{
id: 'alle_mozzete', name: 'lassù, le Mozzète', box: [270, 166, 60, 30], at: [300, 196], exit: { scene: 'mozzete', at: [612, 345], face: 'left' },
},
{
id: 'a_coldaia', name: 'indietro, verso Coldaia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'coldaia', at: [450, 345], face: 'up' },
},
],
},
// Coldaia: dietro il ponticino delle Mozzète; avanti la falegnameria e poi Gabbiano.
coldaia: {
name: 'Coldaia',
map: [84, 89],
background: 'assets/scenes/coldaia.png',
walk: [[230, 360], [300, 250], [318, 200], [290, 166], [330, 162], [460, 205], [640, 268], [640, 360]],
depth: [[162, 0.3], [360, 1.2]],
entry: [450, 345],
hotspots: [
{
id: 'campo_arato', name: 'campo arato', box: [0, 160, 220, 130], at: [280, 330], face: 'left',
actions: {
look: [{ say: 'Terra appena arata. Profuma di campagna e di trattore.' }],
},
},
{
id: 'alla_falegnameria', name: 'avanti, verso la falegnameria', box: [280, 140, 60, 34], at: [305, 168], exit: { scene: 'falegnameria_coldaia', at: [300, 340], face: 'up' },
},
{
id: 'al_ponticino', name: 'indietro, il ponticino delle Mozzète', box: [220, 334, 420, 26], at: [450, 354], exit: { scene: 'ponticino_mozzete', at: [300, 340], face: 'up' },
},
],
},
// La falegnameria di Coldaia, col fienile delle rotoballe; a diritto si va a Gabbiano.
falegnameria_coldaia: {
name: 'La falegnameria',
map: [90, 91],
background: 'assets/scenes/falegnameria-coldaia.png',
walk: [[0, 360], [0, 300], [100, 260], [160, 220], [185, 220], [230, 250], [640, 250], [640, 360]],
depth: [[220, 0.35], [360, 1.2]],
entry: [300, 340],
hotspots: [
{
id: 'falegnameria', name: 'falegnameria', box: [160, 176, 120, 44], at: [220, 260], face: 'up',
actions: {
look: [{ say: 'La falegnameria. L\'odore di segatura arriva fin qui.' }],
open: [{ say: 'Bussare a una falegnameria? Mi danno una scopa e mi fanno spazzare.' }],
},
},
{
id: 'fienile', name: 'fienile', box: [290, 90, 320, 140], at: [440, 280], face: 'up',
actions: {
look: [{ say: 'Un fienile pieno di rotoballe. Fossi una mucca, sarebbe il paradiso.' }],
},
},
{
id: 'a_gabbiano', name: 'a diritto, verso Gabbiano', box: [140, 190, 64, 32], at: [172, 222], exit: { scene: 'gabbiano', at: [300, 340], face: 'up' },
},
{
id: 'a_coldaia', name: 'indietro, verso Coldaia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'coldaia', at: [305, 170], face: 'down' },
},
],
},
// La strada di Gabbiano, in mezzo alla campagna del Mugello.
gabbiano: {
name: 'Gabbiano',
map: [95, 93],
background: 'assets/scenes/gabbiano.png',
walk: [[0, 360], [0, 300], [200, 260], [400, 215], [430, 186], [445, 186], [520, 240], [640, 280], [640, 360]],
depth: [[186, 0.3], [360, 1.2]],
entry: [300, 340],
hotspots: [
{
id: 'valle', name: 'campagna', box: [150, 140, 260, 60], at: [330, 300], face: 'up',
actions: {
look: [{ say: 'La campagna verso Gabbiano. Da quassù si vede mezzo Mugello.' }, { say: 'Il palio, però, no.' }],
},
},
{
id: 'casolare', name: 'casolare', box: [0, 30, 140, 220], at: [150, 320], face: 'left',
actions: {
look: [{ say: 'Un casolare vecchio, coi mattoni che spuntano dall\'intonaco. Come le mie ginocchia.' }],
},
},
{
id: 'alla_falegnameria', name: 'indietro, verso la falegnameria', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'falegnameria_coldaia', at: [172, 224], face: 'down' },
},
],
},
// L'ingresso del campeggio: dalla Piazza per Via Cipriani e Via Massorondinaio; poco dopo, a destra, si sale al Bosco ai Frati.
campeggio: {
name: 'Ingresso del campeggio',
map: [12, 84],
@@ -885,7 +1100,7 @@ export const story = {
id: 'bosco_ai_frati', name: 'salita verso il Bosco ai Frati', box: [570, 250, 70, 110], at: [628, 320], exit: { scene: 'bosco_ai_frati', at: [320, 345], face: 'right' },
},
{
id: 'verso_piazza', name: 'torna in Piazza', box: [150, 290, 100, 70], at: [175, 345], exit: { scene: 'piazza', at: [345, 240], face: 'left' },
id: 'verso_piazza', name: 'Via Massorondinaio, verso la Piazza', box: [150, 290, 100, 70], at: [175, 345], exit: { scene: 'via_massorondinaio', at: [530, 236], face: 'down' },
},
],
},
@@ -937,8 +1152,8 @@ export const story = {
},
],
},
// Il Comune e l'asilo infantile Antonio Frilli: dalla Piazza (a sinistra, prima di Via de' Medici) o dalla
// strada di fronte alla farmacia.
// Il Comune e l'asilo infantile Antonio Frilli: dalla strada di fronte alla farmacia o da Via Calimara
// (a sinistra in Piazza, dove una volta si veniva qui, c'è la Piazzetta Cambray Digny: Andrea, 27 ottobre).
comune: {
name: 'Il Comune',
map: [11, 45],
@@ -967,9 +1182,6 @@ export const story = {
open: [{ say: 'All\'asilo? Ho già dato, grazie.' }],
},
},
{
id: 'alla_piazza', name: 'su, verso la Piazza', box: [0, 250, 40, 110], at: [14, 300], exit: { scene: 'piazza', at: [120, 252], face: 'right' },
},
{
id: 'al_giardino', name: 'verso il giardino di Via Calimara', box: [596, 290, 44, 70], at: [600, 340], exit: { scene: 'via_calimara', at: [160, 290], face: 'right' },
},
@@ -1067,7 +1279,7 @@ export const story = {
id: 'al_ponte', name: 'a sinistra, verso il campo sportivo', box: [0, 216, 60, 100], at: [12, 318], exit: { scene: 'ponte_sieve', at: [300, 350], face: 'up' },
},
{
id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'pieve', at: [24, 312], face: 'right' },
id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'provinciale_pignone', at: [300, 345], face: 'up' },
},
{
id: 'area_feste', name: 'qui sotto, verso l\'area feste', box: [0, 318, 300, 42], at: [120, 352], exit: { scene: 'area_feste', at: [44, 350], face: 'up' },
@@ -1080,6 +1292,90 @@ export const story = {
},
],
},
// Via Provinciale fra la rotonda (alle spalle) e la Pieve (in fondo); a destra sale Via del Pignone.
provinciale_pignone: {
name: 'Via Provinciale',
map: [50, 64],
background: 'assets/scenes/provinciale-pignone.png',
walk: [[30, 360], [30, 330], [130, 270], [245, 212], [290, 212], [340, 228], [540, 232], [540, 258], [640, 300], [640, 360]],
depth: [[212, 0.32], [360, 1.2]],
entry: [300, 345],
hotspots: [
{
id: 'cartelli', name: 'cartelli', box: [570, 162, 52, 28], at: [560, 290], face: 'right',
actions: {
look: [{ say: 'Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.' }],
},
},
{
id: 'alla_pieve', name: 'in fondo, verso la Pieve', box: [236, 150, 64, 64], at: [268, 216], exit: { scene: 'pieve', at: [24, 312], face: 'right' },
},
{
id: 'via_pignone', name: 'a destra, Via del Pignone', box: [440, 196, 100, 56], at: [530, 245], face: 'right', exit: { scene: 'via_pignone', at: [300, 345], face: 'up' },
},
{
id: 'alla_rotonda', name: 'indietro, verso la rotonda della farmacia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' },
},
],
},
// Via del Pignone dal basso: sul muro a destra il murale di Cistola (Davide Poggi, consenso del 27 ottobre).
// In cima si arriva in Piazzetta Cambray Digny.
via_pignone: {
name: 'Via del Pignone',
map: [46, 54],
background: 'assets/scenes/via-del-pignone.png',
walk: [[0, 360], [0, 300], [150, 258], [290, 226], [335, 206], [352, 206], [350, 240], [345, 282], [640, 290], [640, 360]],
depth: [[206, 0.3], [360, 1.2]],
entry: [300, 345],
hotspots: [
{
id: 'murale', name: 'murale di Cistola', box: [430, 36, 170, 240], at: [470, 300], face: 'right',
actions: {
look: [{ say: 'Cistola. Il talento di via del Pignone.' }, { say: 'Col dieci, come i grandi. Se c\'è un muro che se lo merita, è questo.' }],
talk: [{ say: 'Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.' }],
},
},
{
id: 'su_piazzetta', name: 'su, verso Piazzetta Cambray Digny', box: [316, 176, 56, 40], at: [343, 210], exit: { scene: 'piazzetta', at: [610, 345], face: 'left' },
},
{
id: 'giu_provinciale', name: 'giù, in Via Provinciale', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'provinciale_pignone', at: [530, 245], face: 'left' },
},
],
},
// Piazzetta Cambray Digny, in cima a Via del Pignone, sotto Villa Schifanoia: a sinistra Via de' Medici, a diritto la Piazza.
piazzetta: {
name: 'Piazzetta Cambray Digny',
map: [40, 38],
background: 'assets/scenes/piazzetta-cambray-digny.png',
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
depth: [[268, 0.55], [360, 1.15]],
entry: [320, 330],
hotspots: [
{
id: 'villa_schifanoia', name: 'Villa Schifanoia', box: [60, 110, 230, 170], at: [180, 300], face: 'up',
actions: {
look: [{ say: 'Il muro di Villa Schifanoia, la villa dei Medici, mezzo coperto dall\'edera.' }, { say: 'Lassù una volta c\'era una torre, per tenere d\'occhio chi passava sulla strada per Bologna. Ne ha viste più di me.' }, { say: 'E la piazzetta si chiama come i Cambray-Digny, gli ultimi signori della villa.' }],
},
},
{
id: 'casetta_bianca', name: 'casetta bianca', box: [296, 196, 160, 90], at: [380, 292], face: 'up',
actions: {
look: [{ say: 'Una casetta piccina, appoggiata al muro della villa come un gatto al sole.' }],
open: [{ say: 'Chiusa. E poi non è casa mia.' }],
},
},
{
id: 'alla_piazza', name: 'a diritto, verso la Piazza', box: [470, 226, 70, 48], at: [510, 274], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
},
{
id: 'via_de_medici', name: 'a sinistra, Via de\' Medici', box: [0, 280, 40, 80], at: [20, 320], face: 'left', exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
},
{
id: 'giu_pignone', name: 'giù per Via del Pignone', box: [570, 300, 70, 60], at: [610, 345], exit: { scene: 'via_pignone', at: [343, 212], face: 'down' },
},
],
},
// L'area feste (il parco Antonio Berti): dalla rotonda del River, a sinistra.
area_feste: {
name: 'Area feste',
@@ -1273,6 +1569,9 @@ export const story = {
{
id: 'al_principale', name: 'indietro, verso il campo principale', box: [0, 330, 640, 30], at: [100, 354], exit: { scene: 'polisportiva_principale', at: [620, 330], face: 'left' },
},
{
id: 'alle_mozzete', name: 'a destra, verso le Mozzète', box: [610, 226, 30, 100], at: [628, 290], face: 'right', exit: { scene: 'mozzete', at: [330, 206], face: 'down' },
},
{
id: 'barondeduke', name: 'il BaronDeDuke', box: [212, 245, 36, 55], at: [190, 316], face: 'right', default: 'talk',
give: {
@@ -1535,8 +1834,31 @@ export const story = {
entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] },
people: [{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }],
people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk.
{ who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] },
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
],
hotspots: [
{
id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
// Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
},
},
{
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
actions: {
look: [{ say: 'La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
talk: [{ dialogue: 'ghiacciomobile' }],
use: [{ say: 'Con questo sole, di ghiaccio c\'è rimasto giusto il nome. Vo a piedi.' }],
},
},
{
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
actions: {
@@ -2168,6 +2490,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: [
@@ -2362,6 +2695,19 @@ export const story = {
},
},
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
// Da solo al campo del Circolo: Andrea decide quanto farsi male.
basket: {
nodes: {
start: {
options: [
{ text: 'Due tiri alla bona, tanto per scaldarmi.', next: 'end', then: [{ say: 'Tre su dieci e sono contento.' }, { closeup: 'basket', level: 'bona' }] },
{ text: 'Due tiri seri.', next: 'end', then: [{ say: 'Cinque su dieci. Come ai tempi del Circolino.' }, { closeup: 'basket', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Sette su dieci, senza guardare la parabola. Vediamo chi sono.' }, { closeup: 'basket', level: 'male' }] },
{ text: 'Lascia stare, ho il palio da trovare.', next: 'end' },
],
},
},
},
mariano: {
nodes: {
start: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.15';
export const VERSION = '0.1.21';
// 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).
+24 -9
View File
@@ -53,16 +53,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p { max-width: 76em; line-height: 1.7; }
#bocce-help, #biliardo-help, #basket-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary, #basket-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; }
#bocce-help, #biliardo-help { font-size: 9px; }
#bocce-help, #biliardo-help, #basket-help { font-size: 9px; }
}
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); }
@@ -164,6 +164,13 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#splash .splash-name { font-size: clamp(30px, 9vw, 88px); }
#splash .splash-art { font-size: clamp(20px, 5vw, 50px); }
#splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); }
/* Compare solo se il browser aspetta un gesto per il suono (#splash.wait, main.js). */
#splash .splash-hint { display: none; margin-top: 2em; font-size: clamp(9px, 1.6vw, 14px); color: var(--dim); animation: blink 1.6s steps(1) infinite; }
#splash.wait .splash-hint { display: block; opacity: 1; }
/* Finché si aspetta il tocco le scritte restano ferme all'inizio: partono insieme alla musichetta, e la caduta di
«(Poca)» cade sul suo rumorino (Andrea, 26 ottobre). */
#splash.wait .splash-name, #splash.wait .splash-art, #splash.wait .splash-art span, #splash.wait .splash-present { animation-play-state: paused; }
@keyframes blink { 50% { opacity: .25; } }
#title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); }
#title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); }
#intro-text { font-size: max(11px, calc(var(--u) * 2)); }
@@ -252,10 +259,18 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#credits dt { text-align: right; color: var(--dim); font-size: clamp(7.2px, 1.01vw, 10.08px); letter-spacing: .12em; text-transform: uppercase; }
#credits dd span { display: block; }
/* I nomi col ritratto: si toccano e compare il volto a pixel sopra i Ringraziamenti. */
#credits-reel { flex: 0 0 auto; height: clamp(56px, 24vh, 200px); width: 100%; overflow: hidden; margin: .5em 0 .3em;
#credits-reel { flex: 0 0 auto; position: relative; height: clamp(56px, 24vh, 200px); width: 100%; margin: .5em 0 .3em; }
/* La fila scorre da sola (main.js) ed è anche un normale scorrimento orizzontale: dito, rotella, trascinamento col mouse. */
#credits-reel .track { display: flex; gap: 10px; height: 100%; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; touch-action: pan-x; cursor: grab;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
#credits-reel .track { display: flex; gap: 10px; height: 100%; width: max-content; animation: reel 240s linear infinite; }
#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; }
#credits-reel .track::-webkit-scrollbar { display: none; }
#credits-reel .track:active { cursor: grabbing; }
#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; cursor: pointer; user-select: none; -webkit-user-drag: none; }
#credits-reel img:hover { border-color: #fff; }
#credits-reel .arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; width: 1.8em; height: 2.4em; padding: 0; border: 1px solid var(--accent); border-radius: 6px;
background: rgba(15, 10, 20, .8); color: var(--accent); font: inherit; font-size: clamp(12px, 1.8vw, 20px); line-height: 1; cursor: pointer; opacity: .85; }
#credits-reel .arrow:hover, #credits-reel .arrow:focus-visible { opacity: 1; color: #fff; }
#credits-reel .arrow.prev { left: .3em; } #credits-reel .arrow.next { right: .3em; }
#credits-reel img.face { aspect-ratio: 1; border-color: var(--accent); }
#credits-reel img.palio { aspect-ratio: auto; border: 0; }
/* La fine: il quadro del palio accanto alle parole (sotto, sugli schermi stretti). */
@@ -268,14 +283,14 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#ending figure.palio figcaption { font-size: clamp(6.5px, .85vw, 9px); color: var(--dim); max-width: 22em; }
#ending .ending-row > div { max-width: 26em; text-align: center; }
@media (max-aspect-ratio: 1/1) { #ending .ending-row { flex-direction: column; } #ending figure.palio img { height: 30vh; } }
@keyframes reel { to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) { #credits-reel .track { animation: none; } }
#credits .credit-face { all: unset; display: block; cursor: pointer; color: var(--accent); text-decoration: underline dotted; text-underline-offset: .25em; }
#credits .credit-face:hover, #credits .credit-face:focus-visible { color: #fff; }
#face { position: absolute; inset: 0; margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .8em;
background: rgba(15, 10, 20, .92); cursor: pointer; z-index: 1; }
#face[hidden] { display: none; }
#face img { height: min(60vmin, 384px); width: auto; max-width: 90%; image-rendering: pixelated; border: 3px solid var(--accent); border-radius: 8px; }
/* Uno sfondo ingrandito: largo quanto può restando 16:9 dentro lo schermo. */
#face img.scene { height: auto; width: min(92%, 128vh, 1024px); }
#face figcaption { color: var(--ink); font-size: clamp(9px, 1.4vw, 14px); text-align: center; padding: 0 1em; }
#credits dd { margin: 0; text-align: left; color: var(--ink); font-size: clamp(8px, 1.15vw, 12px); line-height: 1.6; font-weight: bold; }
#credits .legal { color: var(--dim); font-size: clamp(7.2px, 0.86vw, 9.36px); opacity: .8; }
+1 -1
View File
@@ -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));
+171
View File
@@ -0,0 +1,171 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBasket, press, release, cancel, update, shoot, setAngle, setPower, aimScreen, nudge, arc, guide, status, canThrow, releasePoint,
powerAt, SWING, BALLS, SPOTS, LEVELS, RIM, BOARD, POLE, FLOOR, BALL, ANGLE } from '../src/engine/basket.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
const fly = c => { for (let i = 0; i < 2000 && c.step === 'flight'; i++) update(c, 1 / 120); return c.ball; };
const rest = c => { for (let i = 0; i < 200 && c.step === 'settle'; i++) update(c, 1 / 60); };
// Cerca un tiro che entra dalla postazione di adesso: la mano ferma dei test.
function goodShot(c) {
for (let angle = 40; angle <= 70; angle += 1) for (let power = 0.2; power <= 1; power += 0.02) {
const probe = structuredClone(c);
setAngle(probe, angle); setPower(probe, power); shoot(probe);
if (fly(probe).scored) return { angle, power };
}
return null;
}
test('la forza sale finché si tiene premuto; un tocco breve non spreca il pallone', () => {
const c = newBasket(1);
assert.equal(powerAt(SWING / 2), 0.5); assert.equal(powerAt(SWING * 2), 1);
assert.ok(press(c)); update(c, 0.05); assert.ok(release(c)); // troppo breve: annullato
assert.equal(c.thrown, 0); assert.equal(c.power, 0.5, 'la forza torna com\'era');
assert.ok(press(c)); update(c, 0.7); assert.ok(release(c));
assert.equal(c.thrown, 1); assert.equal(c.step, 'flight');
assert.ok(Math.abs(c.ball.vx) > 0 && c.ball.vy < 0, 'la palla parte in alto verso il canestro');
assert.ok(!press(c), 'in volo non si carica');
assert.ok(!cancel(c));
});
test('la mira: l\'angolo segue il punto toccato, resta fra i limiti, non si tira all\'indietro', () => {
const c = newBasket(2), r = releasePoint(c);
assert.ok(aimScreen(c, [r.x + 100, r.y - 100])); assert.equal(Math.round(c.angle), 45);
assert.ok(aimScreen(c, [r.x + 10, r.y - 400])); assert.equal(c.angle, ANGLE.max, 'non oltre il massimo');
assert.ok(aimScreen(c, [r.x + 300, r.y])); assert.equal(c.angle, ANGLE.min, 'non sotto il minimo');
assert.ok(!aimScreen(c, [r.x - 50, r.y - 50]), 'dietro di lui niente');
assert.ok(nudge(c, 1)); assert.equal(c.angle, ANGLE.min + 2);
assert.ok(nudge(c, 1, true)); assert.equal(c.angle, ANGLE.min + 2.5);
press(c); assert.ok(!aimScreen(c, [r.x + 100, r.y - 100]), 'mentre si carica la mira è ferma');
assert.ok(!setPower(c, 0.3));
});
test('la parabola della guida comincia dalle mani e scende per la gravità', () => {
const c = newBasket(3), r = releasePoint(c), pts = arc(c, 1.5);
assert.deepEqual(pts[0], r);
assert.ok(pts[5].y < r.y, 'prima sale');
assert.ok(pts.at(-1).y > pts[5].y, 'poi scende');
});
test('da ogni postazione esiste un tiro che entra, e un tiro entrato conta canestro e punti', () => {
for (const [i, s] of SPOTS.entries()) {
const c = newBasket(10 + i); c.spot = i;
const shot = goodShot(c);
assert.ok(shot, `nessun tiro entra ${s.name}`);
setAngle(c, shot.angle); setPower(c, shot.power); shoot(c);
const b = fly(c);
assert.ok(b.scored && b.dead);
assert.ok(c.events.some(e => e.kind === 'basket-swish' || e.kind === 'basket-in'), 'si sente la retina');
rest(c);
assert.equal(c.baskets, 1); assert.equal(c.points, s.points);
assert.equal(c.spot, Math.min(i + 1, SPOTS.length - 1), 'dopo un canestro si arretra');
assert.equal(c.step, 'aim');
}
});
test('con l\'angolo iniziale, da ogni postazione entra in una fascia di forza contigua di almeno l\'8%', () => {
for (const [i, s] of SPOTS.entries()) {
const made = [];
for (let p = 0; p <= 1.0001; p += 0.005) {
const c = newBasket(1); c.spot = i; setPower(c, p); shoot(c);
made.push(fly(c).scored);
}
let best = 0, run = 0;
for (const v of made) { run = v ? run + 1 : 0; best = Math.max(best, run); }
assert.ok(best >= 16, `${s.name}: la fascia migliore è ${(best - 1) * 0.5}%`);
}
});
test('il palo ferma solo dalla cima in giù; il tabellone rimanda anche da dietro; il canestro conta subito', () => {
const c = newBasket(11); setAngle(c, 78); setPower(c, 1); shoot(c);
let b = fly(c);
assert.ok(!c.events.some(e => e.kind === 'basket-swish'), 'sopra il palo la palla vola via: non è un tiro pulito');
const d = newBasket(12);
d.ball = { x: POLE.x - 30, y: FLOOR - 30, vx: 300, vy: 0, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
d.step = 'flight'; for (let i = 0; i < 30; i++) update(d, 1 / 120);
assert.ok(d.ball.board && d.ball.vx < 0, 'il palo la rimanda indietro come un tabellone');
const e = newBasket(13);
e.ball = { x: BOARD.x + BOARD.depth + 30, y: (BOARD.top + BOARD.bottom) / 2, vx: -300, vy: 0, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
e.step = 'flight'; for (let i = 0; i < 30; i++) update(e, 1 / 120);
assert.ok(e.ball.board && e.ball.x > BOARD.x, 'da dietro non lo attraversa');
const f = newBasket(14); f.thrown = BALLS - 1; f.skill = LEVELS.bona; f.baskets = 2;
const shot = goodShot(f); setAngle(f, shot.angle); setPower(f, shot.power); shoot(f);
for (let i = 0; i < 2000 && !f.ball.scored; i++) update(f, 1 / 120);
assert.equal(f.baskets, 3, 'il canestro conta appena entra');
assert.ok(typeof f.ball.scoredAt === 'number');
assert.equal(f.result?.winner, 'player', 'all\'ultimo pallone il risultato c\'è già mentre la palla scende');
fly(f); rest(f);
assert.equal(f.step, 'result'); assert.equal(f.result.baskets, 3);
});
test('la guida si ferma al primo urto', () => {
const c = newBasket(15); c.spot = 1; setAngle(c, 30); setPower(c, 1);
const g = guide(c, 3), last = g.at(-1);
assert.ok(g.length < arc(c, 3).length, 'non arriva in fondo');
assert.ok(last.x + BALL > BOARD.x - 2 || last.y + BALL > FLOOR - 2 || Math.hypot(last.x - RIM.x1, last.y - RIM.y) < BALL + 3, 'si ferma su tabellone, campo o ferro');
});
test('un tiro corto batte sul campo e rimbalza; uno lungo prende il tabellone e torna indietro', () => {
const c = newBasket(4); c.spot = 3; // da tre, piano: non arriva nemmeno al palo
setAngle(c, 45); setPower(c, 0); shoot(c);
let b = fly(c);
assert.ok(!b.scored && b.bounces > 0, 'rimbalza per terra');
assert.ok(c.events.some(e => e.kind === 'basket-bounce'));
assert.ok(b.y + BALL <= FLOOR + 0.01, 'non sprofonda');
const d = newBasket(5); d.spot = 1;
setAngle(d, 34); setPower(d, 1); shoot(d);
b = fly(d);
assert.ok(b.board, 'ha preso il tabellone');
assert.ok(c.events.every(e => e.kind !== 'basket-board') && d.events.some(e => e.kind === 'basket-board'));
assert.ok(b.x < BOARD.x, 'non lo attraversa');
});
test('la palla rimbalza sul ferro, e da sotto non è canestro', () => {
const c = newBasket(6);
c.ball = { x: RIM.x1 - 2, y: RIM.y - 60, vx: 0, vy: 150, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
c.step = 'flight';
for (let i = 0; i < 60; i++) update(c, 1 / 120);
assert.ok(c.ball.rim, 'il davanti del ferro l\'ha toccata');
assert.ok(c.events.some(e => e.kind === 'basket-rim'));
const d = newBasket(7);
d.ball = { x: (RIM.x1 + RIM.x2) / 2, y: RIM.y + 20, vx: 0, vy: -200, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
d.step = 'flight';
fly(d);
assert.ok(d.ball.under && !d.ball.scored, 'sale attraverso l\'anello e ricade: non vale');
});
test('dieci palloni, poi il risultato: la gloria dipende dal livello', () => {
for (const [level, { goal }] of Object.entries(LEVELS)) {
const c = newBasket(8, { level });
let made = 0;
for (let i = 0; i < BALLS; i++) {
assert.ok(canThrow(c));
const shot = made < goal ? goodShot(c) : { angle: 45, power: 0 }; // quanto basta per la gloria, poi a vuoto
setAngle(c, shot.angle); setPower(c, shot.power); shoot(c);
if (fly(c).scored) made++;
rest(c);
}
assert.equal(c.step, 'result'); assert.equal(c.thrown, BALLS); assert.ok(!canThrow(c));
assert.equal(c.result.baskets, goal); assert.equal(c.result.winner, 'player', level);
assert.match(status(c), /canestri su 10/);
assert.ok(!shoot(c), 'finita');
}
const miss = newBasket(9, { level: 'bona' });
for (let i = 0; i < BALLS; i++) { setAngle(miss, 45); setPower(miss, 0); shoot(miss); fly(miss); rest(miss); }
assert.equal(miss.result.winner, 'cpu'); assert.equal(miss.spot, 0, 'senza canestri non ci si muove');
});
test('nella storia: dal campo di Via Cipriani si sceglie il livello e parte il gioco; la gloria lascia il flag', () => {
const g = new Game(story);
g.go('via_cipriani', [340, 316]); settle(g);
g.act('use', 'campo_basket'); settle(g);
assert.ok(g.dialogue, 'si decide come tirare');
g.choose(g.options().findIndex(o => o.text === 'Due tiri seri.')); settle(g);
assert.equal(g.closeup?.id, 'basket'); assert.equal(g.closeup.level, 'seria');
g.closeup.result = { winner: 'player', baskets: 6, points: 13, goal: 5 }; g.closeup.step = 'result';
g.closeupClose(); settle(g);
assert.ok(g.state.flags.basket_vinto, 'la gloria resta');
assert.ok(!g.closeup);
});
+1 -1
View File
@@ -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 }],
+1 -1
View File
@@ -51,7 +51,7 @@ async function artwork(page) {
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
}
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 } },
+87 -2
View File
@@ -6,12 +6,14 @@ import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js';
import { story } from '../src/story/story.js';
import { reelPictures } from '../src/engine/game.js';
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 +66,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 +102,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`);
@@ -100,6 +122,35 @@ try {
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
await page.click('#face');
assert.ok(await page.isHidden('#face'));
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
// foto, un tocco su una foto la mostra grande col nome del posto.
{
const track = page.locator('#credits-reel .track');
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
await page.mouse.move(5, 5);
const x0 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(600);
const x1 = await track.evaluate(t => t.scrollLeft);
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
await track.hover({ position: { x: 20, y: 20 } });
const x2 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(500);
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
await page.click('#credits-reel .arrow.next');
await page.waitForTimeout(800);
const x3 = await track.evaluate(t => t.scrollLeft);
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
const third = track.locator('img').nth(2);
const name = await third.getAttribute('alt');
await third.click();
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
assert.equal(await page.textContent('#face figcaption'), name);
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
assert.ok(await page.isVisible('#credits'));
}
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
@@ -296,7 +347,7 @@ try {
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
await page.fill('#map-search', '');
await page.click('#map-search');
await page.keyboard.type('piaz');
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
assert.ok(await page.isHidden('#map-none'));
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
@@ -398,6 +449,40 @@ try {
await bocce.close();
}
// «Fai due tiri» al campo del Circolo: il mouse mira, tenendo premuto la forza sale, lasciando la palla vola; Esc esce.
{
const k = await open({ width: 1280, height: 860 });
await k.click('#splash');
await k.click('#new-game');
await k.click('#rione [data-rione="campomigliaio"]');
await k.click('#skip-intro');
await settle(k);
await k.evaluate(() => game.go('via_cipriani', [340, 316]));
await settle(k);
await k.evaluate(() => game.act('use', 'campo_basket'));
await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await k.locator('#choices li', { hasText: 'Due tiri seri.' }).click();
await k.waitForFunction(() => game.closeup?.id === 'basket' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await k.locator('#scene').boundingBox();
const at = (x, y) => [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
await k.mouse.move(...at(560, 60));
const high = await k.evaluate(() => game.closeup.angle);
await k.mouse.move(...at(600, 200));
assert.ok(await k.evaluate(() => game.closeup.angle) < high, 'la mira segue il mouse');
await k.mouse.down();
await k.waitForTimeout(500);
assert.ok(await k.evaluate(() => game.closeup.power > 0.2 && game.closeup.holding), 'tenendo premuto la forza sale');
await k.screenshot({ path: `${OUT}/03e-basket-carica.png` });
await k.mouse.up();
assert.equal(await k.evaluate(() => game.closeup.step), 'flight', 'lasciando, la palla parte');
await k.waitForTimeout(350);
await k.screenshot({ path: `${OUT}/03f-basket-volo.png` });
await k.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.thrown === 1, null, { timeout: 20000 });
await k.keyboard.press('Escape');
await k.waitForFunction(() => game.speech?.text.startsWith('Lascio il pallone'), null, { timeout: 5000 });
await k.close();
}
// Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale.
{
const b = await open({ width: 1280, height: 860 });
+1 -1
View File
@@ -15,7 +15,7 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
const { g, c, at } = setup();
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
assert.equal(c.sentence('verso_villa_adami'), 'Vai a Via de\' Medici, verso Villa Adami');
assert.equal(c.sentence('verso_piazzetta'), 'Vai a Piazzetta Cambray Digny, verso Via de\' Medici');
assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g);
assert.equal(g.dialogue?.id, 'vecchietti');
+1 -1
View File
@@ -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 } },

Some files were not shown because too many files have changed in this diff Show More