Compare commits

..
16 Commits
Author SHA1 Message Date
manzolo b1640f1f12 Versione 0.1.16 2026-10-26 23:35:55 +01:00
manzoloandClaude Opus 5.5 0e7a50493c Il cartello d'apertura: online, su un sito mai visitato, il browser bloccava la musichetta senza un gesto (in locale no, ci si gioca da giorni); ora in quel caso il cartello aspetta un tocco («Tocca per cominciare») e poi parte coi suoi tempi. La Ghiacciomobile non ripete più le battute due volte (la scelta la dice già Andrea). Licenza (codice MIT, storia e grafica CC BY-NC-SA, eccezioni per persone vere, palio, Suno, voci XTTS con la CPML, font OFL, Wikipedia), README riscritto, scripts/check-public.sh e make check-public dentro make verify. Prove nel browser con l'autoplay permesso, più una per il caso bloccato
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 23:30:48 +01:00
manzoloandClaude Fable 5.1 c2cb9b0194 A Campomigliaio, accanto all'Arcu, il Betino (motorino verde e crema con la scritta sul serbatoio) e la Ghiacciomobile (la Corsa nera): sprite di Codex dalle foto di riferimento di Andrea, battute di Andrea (guardandoli, e parlando alla macchina che non risponde), voci; «Ghiacciomòbile» nella pronuncia
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 23:06:19 +01:00
manzoloandClaude Fable 5.1 5392f042b3 nginx: tipo MIME per jpg e mp4 (la foto del palio e i video arrivavano come application/octet-stream), cache lunga anche per loro e per il favicon; test che ogni estensione servita abbia il suo tipo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 22:47:32 +01:00
manzolo ca3035e33f Versione 0.1.15 2026-10-26 22:42:15 +01:00
manzoloandClaude Fable 5.1 22be1c306e I cinque video «Come si gioca» dentro il gioco: pulsante «Guarda il video» nell'aiuto di briscola, tressette, scopa, bocce e biliardo, player a tutto schermo con l'audio (il gioco e la musica si fermano, Esc torna al tavolo). video/pubblica.py li taglia dall'arrivo nel posto, li comprime per il web (~4,5 MB l'uno) e fa la copertina. Il favicon e i video copiati anche nell'immagine Docker (il Regolino online dava 404); test che ogni cartella di assets usata dal gioco sia nel Dockerfile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 22:37:19 +01:00
manzolo add5a5fe77 Versione 0.1.14 2026-10-26 22:03:12 +01:00
manzoloandClaude Opus 5.5 3019a6e32f Bocce: il clic fissa la mira come al biliardo, così andando verso la forza o «Lancia» il puntatore non la sposta più (scoperto girando il video: la boccia partiva a due passi). Copioni dei video «Come si gioca» di scopa, tressette, bocce e biliardo, e video/sovrapposte.py per controllare che il narratore non parli sopra i personaggi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 21:23:23 +01:00
manzoloandClaude Opus 5.5 111b892916 Il favicon: un Regolino in pixel art con la corona (Codex, tools/favicon.py), da 16 a 512 pixel e per l'iPhone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:51:11 +01:00
manzoloandClaude Opus 5.5 f1781afea8 Scelte dei dialoghi: i numeri non escono più a sinistra del pannello (il rientro segue il carattere pixel, largo tre caratteri e mezzo per «1. »; Andrea, schermata)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:50:48 +01:00
manzoloandClaude Opus 5.5 b44eeffd2a Fra Peppino da Gricignano passa dal sagrato della Pieve al Bosco ai Frati, davanti al convento dei francescani (Andrea); la polka del pallaio per le bocce (tools/polka_bocce.py: clarinetto, fisarmonica a contrattempo, basso um-pa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:43:07 +01:00
manzolo 89ebdd40bb Versione 0.1.13 2026-10-26 18:34:08 +01:00
manzoloandClaude Opus 5.5 538bf6cedb Carte che volano: dalla mano al tavolo e, finita la presa, sul mucchietto di chi ha preso (briscola, tressette, scopa; Andrea: «non si capisce quando vengono buttate le carte»). Il valzer della balera ai tavoli da carte (tools/valzer_carte.py: fisarmonica musette, basso, melodia di mandolino col tremolo), musica per primo piano; video «Come si gioca»: cache del Chromium della VM spenta, narratore e personaggi a turno, musica di sottofondo, pronunce del narratore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:17:23 +01:00
manzoloandClaude Opus 5.5 7e1e6f6144 Giochi di carte dopo la revisione di Codex sui tre insieme; player dei video «Come si gioca» e primo copione
Esito che vale e si salva appena deciso, per tutti i minigiochi (anche tornando al titolo dalla schermata del risultato); briscola: uscire durante il conteggio non perde più la vittoria; tressette: si esce ai 21 col primo evento che ci porta, accusa compresa (prima vinceva sempre Andrea se ci arrivavano tutte e due), il carico che fa vincere si dà anche se è il 2, accuse che non escono dal tavolo, convenzioni scritte nell'aiuto; scopa: primiera valutata per quel che migliora, finale esatto a mazzo finito, presa sulla tavola al rilascio con scelta e conferma, frecce e Invio sempre sulla presa scelta. Tastiera: Invio e Spazio giocano sempre la carta scelta. Tavola descritta a parole. Classi CSS comuni ai tre giochi.
Video: player nel gioco (pulsante «Guarda il video» nel «Come si gioca», compare quando il video esiste), video/narra.py per la voce del narratore, pezzi comuni dei copioni e il copione della briscola; scaletta in video/SCALETTA-CARTE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 16:39:22 +01:00
manzoloandClaude Opus 5.5 30e324d7e2 Scopa agli 11 all'oratorio, e il tressette rivisto dopo la revisione critica di Codex
Scopa: toscane o napoletane (mattoni o denari), presa obbligatoria, la carta uguale prima delle somme, scelta fra più prese toccando la tavola, coi pulsanti o con frecce e Invio; scope (non all'ultima carta), carte, mattoni, settebello, primiera; tre livelli tarati (92, 64, 52%); test e prova nel browser.
Tressette: fuori ai 21 presa per presa, vittoria subito definitiva (Esc non la cancella più), accuse dette al proprio turno e col seme che manca del tris, striscio e busso meno rigidi, il socio risponde al busso, tiene il 3 e il 2, non esce d'Asso scoperto, legge il volo; tastiera col fuoco sulla carta scelta, tavola descritta a parole, riepilogo che aspetta Andrea. Stesso Esc corretto anche nella scopa.

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

After

Width:  |  Height:  |  Size: 32 KiB

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

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87 -23
View File
@@ -307,26 +307,6 @@
"file": "andrea-68a845881b.mp3", "file": "andrea-68a845881b.mp3",
"sec": 1.94 "sec": 1.94
}, },
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
"file": "andrea-97d75e2a9d.mp3",
"sec": 5.38
},
"andrea|Buongiorno, Fra Peppino!": {
"file": "andrea-94988dc8c6.mp3",
"sec": 1.74
},
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
"file": "fra_peppino-651c4d95ab.mp3",
"sec": 3.72
},
"andrea|Il palio. È sparito.": {
"file": "andrea-c9c068f507.mp3",
"sec": 2.39
},
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
"file": "fra_peppino-8b3d0a5b8f.mp3",
"sec": 4.47
},
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": { "andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
"file": "andrea-43fd5c2b27.mp3", "file": "andrea-43fd5c2b27.mp3",
"sec": 4.46 "sec": 4.46
@@ -543,6 +523,26 @@
"file": "andrea-43d3435d61.mp3", "file": "andrea-43d3435d61.mp3",
"sec": 2.99 "sec": 2.99
}, },
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
"file": "andrea-6797d70153.mp3",
"sec": 8.45
},
"andrea|Buongiorno, Fra Peppino!": {
"file": "andrea-94988dc8c6.mp3",
"sec": 1.74
},
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
"file": "fra_peppino-651c4d95ab.mp3",
"sec": 3.72
},
"andrea|Il palio. È sparito.": {
"file": "andrea-c9c068f507.mp3",
"sec": 2.39
},
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
"file": "fra_peppino-8b3d0a5b8f.mp3",
"sec": 4.47
},
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": { "andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
"file": "andrea-486c980f52.mp3", "file": "andrea-486c980f52.mp3",
"sec": 7.48 "sec": 7.48
@@ -943,6 +943,22 @@
"file": "andrea-00f2e70a4d.mp3", "file": "andrea-00f2e70a4d.mp3",
"sec": 2.95 "sec": 2.95
}, },
"andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
"file": "andrea-3e85200509.mp3",
"sec": 2.93
},
"andrea|O via, lascialo riposare. A pedalare oggi ci penso io.": {
"file": "andrea-34aad1ffe3.mp3",
"sec": 4.81
},
"andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
"file": "andrea-7be29434e0.mp3",
"sec": 7.25
},
"andrea|Con questo sole, di ghiaccio c'è rimasto giusto il nome. Vo a piedi.": {
"file": "andrea-def44809bc.mp3",
"sec": 5.08
},
"andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": { "andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": {
"file": "andrea-90bbad0de0.mp3", "file": "andrea-90bbad0de0.mp3",
"sec": 9.59 "sec": 9.59
@@ -1523,13 +1539,33 @@
"file": "andrea-60cdae2e35.mp3", "file": "andrea-60cdae2e35.mp3",
"sec": 5.1 "sec": 5.1
}, },
"andrea|Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?": {
"file": "andrea-2acf574bfa.mp3",
"sec": 5.36
},
"andrea|Riposa, Ghiacciomobile.": {
"file": "andrea-c79d2d1c81.mp3",
"sec": 2.19
},
"andrea|Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.": {
"file": "andrea-6c6e2fd461.mp3",
"sec": 5.69
},
"andrea|Una briscola?": { "andrea|Una briscola?": {
"file": "andrea-d2f285f610.mp3", "file": "andrea-d2f285f610.mp3",
"sec": 1.04 "sec": 1.04
}, },
"andrea|La rivincita?": { "andrea|Rivincita a briscola?": {
"file": "andrea-98e4366a56.mp3", "file": "andrea-6807209a9e.mp3",
"sec": 0.83 "sec": 1.37
},
"andrea|Una scopa?": {
"file": "andrea-d8dfb86266.mp3",
"sec": 0.65
},
"andrea|Un tressette ai 21?": {
"file": "andrea-4fbe84fada.mp3",
"sec": 1.66
}, },
"andrea|Ora cerco il palio. A dopo.": { "andrea|Ora cerco il palio. A dopo.": {
"file": "andrea-6e63dafda1.mp3", "file": "andrea-6e63dafda1.mp3",
@@ -1543,6 +1579,14 @@
"file": "giocatore_carte-8dfadb4acc.mp3", "file": "giocatore_carte-8dfadb4acc.mp3",
"sec": 3.4 "sec": 3.4
}, },
"giocatore_carte|Ai undici. E occhio al settebello: quello è sempre mio.": {
"file": "giocatore_carte-af54c11857.mp3",
"sec": 4.27
},
"giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
"file": "giocatore_carte-3e2671e35e.mp3",
"sec": 4.88
},
"andrea|Con le toscane.": { "andrea|Con le toscane.": {
"file": "andrea-e8599cc8b6.mp3", "file": "andrea-e8599cc8b6.mp3",
"sec": 1.03 "sec": 1.03
@@ -1883,6 +1927,10 @@
"file": "andrea-83b1337b4d.mp3", "file": "andrea-83b1337b4d.mp3",
"sec": 3.31 "sec": 3.31
}, },
"andrea|La rivincita?": {
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
"andrea|Allora, Brento Sanico?": { "andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3", "file": "andrea-2add39ff10.mp3",
"sec": 1.29 "sec": 1.29
@@ -2083,6 +2131,22 @@
"file": "giocatore_carte-729b3344da.mp3", "file": "giocatore_carte-729b3344da.mp3",
"sec": 3.3 "sec": 3.3
}, },
"giocatore_carte|Ventuno per primi. Bella coppia, voi due!": {
"file": "giocatore_carte-3215efd9dd.mp3",
"sec": 3.51
},
"giocatore_carte|Ventuno! Il tressette è un'arte: si studia da piccini.": {
"file": "giocatore_carte-5dbd39a25f.mp3",
"sec": 4.44
},
"giocatore_carte|Settebello, primiera e pure le scope. Oggi l'hai vista bene.": {
"file": "giocatore_carte-9a43eb8b2c.mp3",
"sec": 5.54
},
"giocatore_carte|Undici! Il settebello lo sento arrivare da lontano.": {
"file": "giocatore_carte-445e962a1b.mp3",
"sec": 4.1
},
"mariano|Aspetta, ci vuole il metro...": { "mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3", "file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62 "sec": 1.62
+8
View File
@@ -43,6 +43,14 @@ Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 vo
Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani. Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
## Revisione di Codex (26 ottobre, i tre giochi insieme)
Regole promosse. Corretti: uscendo durante il conteggio si perdeva una vittoria già fatta (ora il risultato è
deciso all'ultima presa e il conteggio lo mostra soltanto) e, per tutti e tre i giochi, l'esito che non finiva
nel salvataggio se dal risultato si tornava al titolo; Spazio sui pulsanti come negli altri giochi; la briscola e
la carta in tavola dette anche a parole; l'aiuto coi nomi di tutti e due i mazzi; «Rivincita a briscola?».
Proposta non fatta: i seguiti campionati di «A fassi male» col finale esatto, e l'obiettivo vittoria invece dei punti.
## Grafica e audio ## Grafica e audio
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
+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.
+45
View File
@@ -0,0 +1,45 @@
# Scopa agli 11
All'oratorio, dal giocatore di carte: «Una scopa?». In due, Andrea contro il giocatore di carte, con le toscane o
le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Claude), tavolo `src/engine/scopa-render.js`.
## Regole
- Valori: Asso 1, poi i numeri, gobbo (fante) 8, donna (cavallo) 9, regio (re) 10.
- Quattro carte in tavola (con tre o quattro re si rimescola), tre a testa; finite le tre, se ne danno altre tre.
Gioca per primo chi non fa le carte; il mazziere cambia a ogni smazzata.
- Una carta prende quella dello stesso valore (se ce n'è più d'una si sceglie quale); se non c'è, un gruppo di carte
che somma il suo valore (più gruppi: si sceglie). Se la presa c'è, è obbligatoria. Se no, la carta resta in tavola.
- **Scopa**: chi pulisce la tavola fa un punto; non vale con l'ultima carta della smazzata.
- A fine smazzata le carte in tavola vanno all'ultimo che ha preso. Un punto per: **carte** (più di 20), **mattoni**
(con le napoletane i denari, più di 5), **settebello** (il 7 di mattoni o di denari), **primiera** (la carta
migliore di ogni seme: 7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12, figure 10; senza un seme non
si fa), e uno per scopa. A pari, il punto non va a nessuno.
- Si va **agli 11**, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), un'altra smazzata.
## Avversari
- «Alla bona»: una mossa a caso fra quelle possibili.
- «Seria»: sceglie la mossa che vale di più (settebello, mattoni, carte, scope, e la primiera per quanto migliora la
propria: un 6 di un seme dove si ha solo una figura vale più di uno dove si ha già il 7) e teme a occhio di
lasciare una scopa.
- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, non lascia 7 in
tavola e, a mazzo finito (le carte dell'altro sono proprio quelle non viste), calcola il finale esatto fino al
conteggio.
Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 96% contro
«Alla bona», 76% contro «Seria», 50% contro «A fassi male»; circa quattro smazzate a partita.
## Revisione di Codex (26 ottobre, i tre giochi insieme)
Regole promosse (valori, presa obbligatoria, precedenza della carta uguale, scopa non all'ultima, residui
all'ultimo, maggioranze e pari, primiera, re in tavola, agli 11). Corretti: frecce e Invio che potevano prendere la
presa sbagliata (ora il fuoco segue la scelta); il tocco sulla tavola che agiva alla pressione e non bastava con
prese sovrapposte (ora al rilascio: il primo tocco sceglie, il secondo conferma); primiera valutata a occhio;
finali sbagliati a mazzo finito; la tavola descritta a parole; aiuto con i valori della primiera e i pari.
## Prove
`npm test` (`tests/scopa.test.mjs`) e `npm run browser:scopa`: partita intera col mouse, una smazzata col tocco
(telefono e orizzontale) e con la tastiera; in ogni caso una presa da scegliere fra due (toccando la tavola, con
un pulsante o con frecce e Invio). Schermate in `/tmp/regolo-shots/scopa-*.png`.
+5 -1
View File
@@ -23,7 +23,11 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine («Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude), partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude),
disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`. disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
Prossimo: il **tressette a coppie «ai 21»** con busso, striscio e volo (da chiedere ad Andrea: le accuse?). **Tressette ai 21** (stesso giocatore, «Un tressette ai 21?»): in quattro a coppie, busso/striscio/volo e accuse,
motore e tavolo di Claude, regole e taratura in `docs/TRESSETTE.md`, prova `npm run browser:tressette`.
**Scopa agli 11** («Una scopa?»): carte, mattoni (o denari), settebello, primiera e scope, presa a scelta fra
più possibili; `docs/SCOPA.md`, prova `npm run browser:scopa`. Con la scopa i giochi di carte sono finiti
(decisione di Andrea, 26 ottobre). Tressette rivisto dopo la revisione critica di Codex (in `docs/TRESSETTE.md`).
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e - **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci. riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
+62
View File
@@ -0,0 +1,62 @@
# Tressette ai 21
All'oratorio, dal giocatore di carte: «Un tressette ai 21?». Quattro al tavolo, a coppie: Andrea e il socio
(di fronte) contro il giocatore di carte e l'altro. [DA ANDREA: chi sono il socio e l'altro.]
Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render.js` con le carte della briscola.
## Regole
- Mazzo da 40, toscane o napoletane (stessa scelta e stesso flag `carte_napoletane` della briscola). Dieci carte a testa.
- Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata.
- Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4.
- Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi
avanzati si perdono: le due coppie fanno sempre 11 punti in tutto.
- **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3
dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4.
Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari.
- **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho
altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche
sul tavolo, la carta strusciata, la carta lasciata cadere.
- Si va **ai 21**, presa per presa: il punteggio in alto conta accuse e punti interi già presi, e la coppia che
arriva a 21 per prima è fuori e ha vinto, anche a metà smazzata (le accuse contano alla fine della presa in cui
si dicono). Il risultato è subito definitivo: uscire dal riepilogo non lo cancella. Il riepilogo di fine
smazzata resta finché Andrea non va avanti.
Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si
sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa.
## Avversari
- «Alla bona»: una carta a caso fra quelle buone, nessun segno.
- «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta
quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro.
- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse,
risponde al busso del socio prendendo con la migliore e poi gli ritorna il seme. Il socio di Andrea gioca sempre così.
- Tutti: il carico al socio che prende di sicuro, ma senza buttarci il 3 o il 2; a parità di lunghezza si esce dal
seme con meno punti da esporre (niente Asso scoperto).
Taratura (`node tools/calibrate_tressette.mjs 300`, Andrea che gioca come il socio): Andrea e il socio vincono
87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male»; circa tre smazzate a partita.
## Revisione di Codex (26 ottobre)
Revisione critica sul primo giro. Corretti: vittoria persa uscendo dal riepilogo; accuse tutte pubbliche prima
della prima carta; il seme mancante del tris noto all'IA ma non ad Andrea; striscio troppo stretto e busso con
un requisito inventato; il socio che non rispondeva al busso, sprecava il 3 su una presa già sua o usciva
d'Asso scoperto; il volo non letto da «A fassi male»; tastiera che poteva giocare la carta sbagliata; il
riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole per chi non vede il disegno.
Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione
precedente dell'IA.
Seconda revisione (i tre giochi insieme). Corretti: con le accuse tutte e due le coppie potevano arrivare ai 21 e
vinceva sempre Andrea (ora esce chi arriva per primo, anche con un'accusa, nel momento in cui la dice); il socio
che teneva il 2 anche quando caricarlo voleva dire vincere subito (l'IA vede ora punteggio e terzi del tabellone);
Invio su un pulsante che giocava la carta col fuoco invece di quella scelta; il pannello delle accuse che usciva
dal tavolo; le convenzioni del tavolo scritte anche nel «Come si gioca». Per tutti e tre i giochi l'esito vale e
si salva appena deciso (anche tornando al titolo dalla schermata del risultato).
## Prove
`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
col tocco (telefono e orizzontale) e con la tastiera, segni con pulsanti e tasti B, S, V, carta che non risponde
al seme rifiutata. Schermate in `/tmp/regolo-shots/tressette-*.png`.
+74 -3
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007."> <meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
<title>La Leggenda del Regolo</title> <title>La Leggenda del Regolo</title>
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -28,6 +33,7 @@
<p class="splash-name">Manzolo</p> <p class="splash-name">Manzolo</p>
<p class="splash-art"><span>(Poca)</span> Art</p> <p class="splash-art"><span>(Poca)</span> Art</p>
<p class="splash-present">Presents</p> <p class="splash-present">Presents</p>
<p class="splash-hint">Tocca per cominciare</p>
</section> </section>
<section id="title" class="overlay" hidden> <section id="title" class="overlay" hidden>
<h1>La Leggenda<br>del <span>Regolo</span></h1> <h1>La Leggenda<br>del <span>Regolo</span></h1>
@@ -86,6 +92,11 @@
<p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p> <p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p>
<p class="version"></p> <p class="version"></p>
</section> </section>
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
<h2 id="tutorial-title">Come si gioca</h2>
<video id="tutorial-video" controls playsinline preload="metadata"></video>
<div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
</section>
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti"> <section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
<h2>Ringraziamenti</h2> <h2>Ringraziamenti</h2>
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p> <p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
@@ -103,19 +114,77 @@
<div id="inventory" aria-label="Inventario"></div> <div id="inventory" aria-label="Inventario"></div>
</div> </div>
<ol id="choices" hidden></ol> <ol id="choices" hidden></ol>
<section id="scopa-controls" hidden aria-label="Comandi della scopa">
<p id="scopa-status" role="status"></p>
<div class="card-hand" aria-label="La mano di Andrea">
<button id="scopa-card-0" type="button"></button>
<button id="scopa-card-1" type="button"></button>
<button id="scopa-card-2" type="button"></button>
</div>
<div id="scopa-options" class="card-buttons" hidden aria-label="Quale presa"></div>
<button id="scopa-next" type="button" hidden>Prossima smazzata</button>
<details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="scopa" hidden>Guarda il video</button>
<p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano
il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10.
Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p>
<p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di
mattoni o di denari), la primiera, e uno per ogni scopa. A pari, il punto non va a nessuno.</p>
<p>Primiera: si somma la carta migliore di ogni seme (7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12,
figure 10); chi non ha almeno una carta per seme non la fa. Si va agli 11, contati a fine smazzata: vince chi ne
ha di più; pari, se ne fa un’altra.</p>
</details>
</section>
<section id="tressette-controls" hidden aria-label="Comandi del tressette">
<p id="tressette-status" role="status"></p>
<div class="card-buttons" aria-label="Segni per il socio, solo quando esci">
<button id="tressette-busso" type="button">Busso</button>
<button id="tressette-striscio" type="button">Striscio</button>
<button id="tressette-volo" type="button">Volo</button>
<button id="tressette-next" type="button" hidden>Prossima smazzata</button>
</div>
<div class="card-hand-many" aria-label="La mano di Andrea">
<button id="tressette-card-0" type="button"></button>
<button id="tressette-card-1" type="button"></button>
<button id="tressette-card-2" type="button"></button>
<button id="tressette-card-3" type="button"></button>
<button id="tressette-card-4" type="button"></button>
<button id="tressette-card-5" type="button"></button>
<button id="tressette-card-6" type="button"></button>
<button id="tressette-card-7" type="button"></button>
<button id="tressette-card-8" type="button"></button>
<button id="tressette-card-9" type="button"></button>
</div>
<details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="tressette" hidden>Guarda il video</button>
<p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra.
Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta
del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p>
<p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21: la coppia che
ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p>
<p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p>
<p>Convenzioni di questo tavolo: il «fuori» ai 21 è automatico (un’accusa o una presa che ci porta fa vincere
subito); niente cappotto; i segni valgono anche alla prima presa.</p>
<p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme),
<b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta.
Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p>
</details>
</section>
<section id="briscola-controls" hidden aria-label="Comandi della briscola"> <section id="briscola-controls" hidden aria-label="Comandi della briscola">
<p id="briscola-status" role="status"></p> <p id="briscola-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea"> <div class="card-hand" aria-label="La mano di Andrea">
<button id="briscola-card-0" type="button"></button> <button id="briscola-card-0" type="button"></button>
<button id="briscola-card-1" type="button"></button> <button id="briscola-card-1" type="button"></button>
<button id="briscola-card-2" type="button"></button> <button id="briscola-card-2" type="button"></button>
</div> </div>
<details><summary>Come si gioca</summary> <details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="briscola" hidden>Guarda il video</button>
<p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca. <p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca.
Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p> Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p>
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme <p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
di uscita. Ordine: Asso, 3, Re, Cavallo, Fante, 7, 6, 5, 4, 2. di uscita. Ordine: Asso, 3, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
Asso 11 punti, 3 dieci, Re 4, Cavallo 3, Fante 2; le altre zero.</p> Asso 11 punti, 3 dieci, regio 4, donna 3, gobbo 2; le altre zero. Fra parentesi i nomi delle napoletane.</p>
<p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo; <p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo;
il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese. il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese.
Durante la partita i punti non si dicono!</p> Durante la partita i punti non si dicono!</p>
@@ -132,6 +201,7 @@
<button id="bocce-throw" type="button">Lancia</button> <button id="bocce-throw" type="button">Lancia</button>
</div> </div>
<details id="bocce-help"><summary>Come si gioca</summary> <details id="bocce-help"><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button>
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia. <p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p> Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare. <p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
@@ -140,6 +210,7 @@
Vince chi fa più punti nella mano.</p> Vince chi fa più punti nella mano.</p>
</details> </details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary> <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. <p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
+3 -1
View File
@@ -26,6 +26,8 @@ server {
audio/mpeg mp3; audio/mpeg mp3;
image/svg+xml svg; image/svg+xml svg;
image/x-icon ico; image/x-icon ico;
image/jpeg jpg jpeg;
video/mp4 mp4;
font/woff2 woff2; font/woff2 woff2;
} }
@@ -37,7 +39,7 @@ server {
location ~* \.(css|js|mjs|json)$ { location ~* \.(css|js|mjs|json)$ {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
} }
location ~* \.(png|webp|mp3|woff2)$ { location ~* \.(png|webp|jpg|mp3|mp4|woff2|ico)$ {
add_header Cache-Control $media_cache; add_header Cache-Control $media_cache;
} }
location = /index.html { location = /index.html {
+3 -1
View File
@@ -1,12 +1,14 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.12", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs", "browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs", "browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs", "browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000" "serve": "python3 -m http.server 8000"
+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"
+21 -7
View File
@@ -1,6 +1,12 @@
import { canPlay, cardName, counted, countRows } from './briscola.js'; import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
// Una carta in volo da un rettangolo [x, y, w, h] a un altro, con k da 0 a 1 (parte svelta, arriva piano).
// La usano anche tressette e scopa.
export function flight(from, to, k) {
const e = 1 - (1 - Math.min(1, Math.max(0, k))) ** 3;
return from.map((v, i) => v + (to[i] - v) * e);
}
// Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono. // Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono.
export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 }; export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
export function cardAt(c, [x, y]) { export function cardAt(c, [x, y]) {
@@ -249,8 +255,8 @@ function countTable(ctx, c, names) {
} }
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center'); text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
} }
export function drawBriscola(ctx, c, def) { // Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
ctx.save(); ctx.imageSmoothingEnabled = false; export function drawTable(ctx, title) {
rect(ctx, 0, 0, 640, 360, '#573720'); rect(ctx, 0, 0, 640, 360, '#573720');
for (let y = 0; y < 360; y += 24) { for (let y = 0; y < 360; y += 24) {
rect(ctx, 0, y, 640, 2, '#38271e'); rect(ctx, 0, y, 640, 2, '#38271e');
@@ -258,7 +264,11 @@ export function drawBriscola(ctx, c, def) {
} }
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40'); rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143'); for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
text(ctx, 'BRISCOLA ALL’ORATORIO', 320, 15, 9, '#ffe0a0', 'center'); text(ctx, title, 320, 15, 9, '#ffe0a0', 'center');
}
export function drawBriscola(ctx, c, def) {
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'BRISCOLA ALL’ORATORIO');
if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; } if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
text(ctx, def.names.cpu, 320, 38, 6, paper, 'center'); text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true)); c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
@@ -281,9 +291,13 @@ export function drawBriscola(ctx, c, def) {
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center'); text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
} }
c.table.forEach((p, i) => { c.table.forEach((p, i) => {
// Il brevissimo scorrimento precede il fermo della presa. // La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso.
const slide = i === c.table.length - 1 ? Math.max(0, 1 - c.t / 0.18) : 0; const spot = [242 + i * 86, 130, 64, 92];
drawCard(ctx, p.card, 242 + i * 86, 130 + slide * (p.who === 'player' ? 60 : -40), 64, 92); const hand = p.who === 'player' ? [HAND.x + (p.from ?? 1) * HAND.gap, HAND.y, HAND.w, HAND.h] : [229 + (p.from ?? 1) * 62, 49, 52, 74];
let r = spot;
if (i === c.table.length - 1 && c.step === 'play' || c.step === 'trick' && i === 1 && c.t < PLAY_TIME) r = flight(hand, spot, c.t / PLAY_TIME);
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(spot, [526, c.last.winner === 'cpu' ? 74 : 257, 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
drawCard(ctx, p.card, ...r);
}); });
c.hands.player.forEach((card, i) => { c.hands.player.forEach((card, i) => {
const x = HAND.x + i * HAND.gap; const x = HAND.x + i * HAND.gap;
+11 -4
View File
@@ -6,6 +6,9 @@ export const DECKS = {
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] }, napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
}; };
export const LEVELS = ['bona', 'seria', 'male']; export const LEVELS = ['bona', 'seria', 'male'];
// I tempi delle animazioni (briscola-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
// poi vola sul mucchietto di chi ha preso (Andrea: «non si capisce quando vengono buttate le carte»).
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.85, TRICK_TIME = 1.4;
const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4]; const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7]; const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) => const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
@@ -54,7 +57,7 @@ export function nudge(c, direction) {
export function play(c, index = c.selected, who = 'player') { export function play(c, index = c.selected, who = 'player') {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false; if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
const [card] = c.hands[who].splice(index, 1); const [card] = c.hands[who].splice(index, 1);
c.table.push({ who, card }); c.t = 0; c.table.push({ who, card, from: index }); c.t = 0; // `from`: da dove parte la carta nel disegno
c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1)); c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
c.events.push({ kind: 'card-slide' }); c.landing = true; c.events.push({ kind: 'card-slide' }); c.landing = true;
if (c.table.length === 2) { if (c.table.length === 2) {
@@ -74,6 +77,8 @@ export function collect(c) {
} }
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' }); c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
c.step = c.hands.player.length ? 'play' : 'count'; c.step = c.hands.player.length ? 'play' : 'count';
// Il risultato è deciso all'ultima presa: il conteggio carta per carta lo mostra soltanto.
if (c.step === 'count') c.result = result(c.taken);
return true; return true;
} }
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano. // La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
@@ -169,8 +174,8 @@ export function chooseCard(v, level = 'seria', seed = 1) {
export function update(c, dt) { export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return; if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt; c.t += dt;
if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; } if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
if (c.step === 'trick' && c.t >= 1.25) collect(c); if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
else if (c.step === 'count') { else if (c.step === 'count') {
// Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita. // Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
c.count = Math.min(40, Math.floor(c.t / 0.35)); c.count = Math.min(40, Math.floor(c.t / 0.35));
@@ -195,5 +200,7 @@ export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…'; return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
} }
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`; if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} sceglie…`; // A parole anche quel che si vede sul tavolo: la briscola e la carta già giocata.
const table = `Briscola: ${c.trump}.${c.table.length ? ` In tavola: ${cardName(c.table[0].card)}.` : ''}`;
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} sceglie…`;
} }
+23 -2
View File
@@ -4,10 +4,14 @@ import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js
import * as bocce from './bocce.js'; import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js'; import * as biliardo from './biliardo.js';
import * as briscola from './briscola.js'; import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = { export const MINIGAMES = {
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play }, briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
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 }, 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 }, biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
}; };
@@ -247,6 +251,20 @@ export class Game {
} }
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono. // Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
outcome(def, c) { return def[c.result.winner === 'player' ? 'win' : c.result.winner === 'cpu' ? 'lose' : 'draw'] ?? []; }
// Appena il risultato di un minigioco è deciso, i suoi flag (vittoria, partita giocata) valgono e si salvano:
// anche tornando al titolo dalla schermata del risultato, senza passare dall'uscita. Una volta sola.
settle(c) {
const def = this.story.closeups?.[c.id];
if (!c.result || c.settled || !def) return false;
c.settled = true;
for (const e of this.outcome(def, c)) {
if (e.set) { this.state.flags[e.set] = true; if (this.checkpoint) this.checkpoint.flags[e.set] = true; }
if (e.unset) { delete this.state.flags[e.unset]; if (this.checkpoint) delete this.checkpoint.flags[e.unset]; }
}
if (this.checkpoint) this.onSave(this.checkpoint);
return true;
}
closeupClose() { closeupClose() {
const c = this.closeup; const c = this.closeup;
if (!c) return false; if (!c) return false;
@@ -257,9 +275,11 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete); if (c.step === 'done') this.queue.unshift(...def.complete);
return true; return true;
} }
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. // I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
// di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) { if (MINIGAMES[def?.type]) {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? [])); this.settle(c);
this.queue.unshift(...(c.result ? this.outcome(def, c) : def.quit ?? []));
return true; return true;
} }
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`). // Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
@@ -405,6 +425,7 @@ export class Game {
const m = this.minigame(); const m = this.minigame();
if (m) { if (m) {
m.update(c, dt); m.update(c, dt);
this.settle(c);
// Il risultato resta leggibile finché il giocatore non esce. // Il risultato resta leggibile finché il giocatore non esce.
return; return;
} }
+3 -1
View File
@@ -8,11 +8,13 @@ import { versioned } from '../version.js';
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js). // d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
// altrimenti quello generale del paese. // altrimenti quello generale del paese.
export function trackFor(story, { screen, scene, phase }) { // Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
export function trackFor(story, { screen, scene, phase, closeup }) {
const m = story.music ?? {}; const m = story.music ?? {};
if (screen === 'splash') return null; if (screen === 'splash') return null;
if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null;
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
const sc = story.scenes[scene]; const sc = story.scenes[scene];
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null; return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
} }
+4
View File
@@ -6,6 +6,8 @@ import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js'; import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js'; import { drawBiliardo } from './biliardo-render.js';
import { drawBriscola } from './briscola-render.js'; import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
@@ -668,6 +670,8 @@ function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id]; const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return; if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; } if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
if (def.type === 'scopa') { drawScopa(ctx, c, def); return; }
if (def.type === 'torch') { if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture)); drawTorch(ctx, c, def, backgrounds.get(def.picture));
+97
View File
@@ -0,0 +1,97 @@
import { canPlay, TARGET, PLAY_TIME, TAKE_HOLD, TAKE_TIME } from './scopa.js';
import { drawCard, drawTable, flight } from './briscola-render.js';
import { text, panel } from './bocce-render.js';
// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola.
export const HAND = { y: 262, w: 64, h: 92, gap: 72 };
export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap);
const TABLE = { w: 42, h: 60, gap: 47, row: 8 };
export function tableXY(n, i) {
const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row);
return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132];
}
export function cardAt(c, [x, y]) {
if (!canPlay(c)) return -1;
return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h);
}
export function tableAt(c, [x, y]) {
return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; });
}
const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3';
const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); };
function board(ctx, c, names) {
// Un giocatore per riga: i nomi lunghi non si accavallano.
panel(ctx, 26, 34, 150, 50);
for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) {
text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right');
}
text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center');
// Il mazzo a sinistra, le prese a destra con le scope.
if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true);
text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center');
for (const [who, y] of [['cpu', 92], ['player', 196]]) {
if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true);
if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center');
}
}
const PILE = { cpu: 92, player: 196 };
function table(ctx, c) {
// Durante una presa la tavola resta com'era (le carte prese al loro posto, incorniciate) finché non volano via.
const take = c.step === 'take' && c.last.captured.length, shown = take ? c.last.before : c.table;
const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id));
const fly = take && c.t > TAKE_HOLD ? (c.t - TAKE_HOLD) / (TAKE_TIME - TAKE_HOLD - 0.05) : 0;
const pile = take ? [560, PILE[c.last.who], 42, 60] : null;
// Da dove parte la carta giocata: la mano di Andrea o quella coperta del giocatore.
const hand = c.step === 'take' && (c.last.who === 'player'
? [handX(c.hands.player.length + 1, c.last.from), HAND.y, HAND.w, HAND.h]
: [320 - ((c.hands.cpu.length + 1) * 48 - 6) / 2 + c.last.from * 48, 38, 42, 60]);
const arrive = Math.min(1, c.t / PLAY_TIME);
shown.forEach((card, i) => {
const [x, y] = tableXY(shown.length, i);
let r = [x, y, TABLE.w, TABLE.h];
if (c.step === 'take' && !take && card.id === c.last.card.id) r = flight(hand, r, arrive);
if (fly && marked.has(card.id)) r = flight(r, pile, fly);
else if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h);
drawCard(ctx, card, ...r);
});
if (take) {
let r = flight(hand, [299, 200, TABLE.w, TABLE.h], arrive);
if (fly) r = flight(r, pile, fly);
drawCard(ctx, c.last.card, ...r);
if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); }
}
}
function hands(ctx, c) {
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true));
const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose';
c.hands.player.forEach((card, i) => {
const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i;
const up = chosen ? 8 : 0;
if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h);
drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h);
});
}
function summary(ctx, c, names, title) {
const s = c.summary;
panel(ctx, 140, 80, 360, 200);
text(ctx, title, 320, 96, 8, gold, 'center');
text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center');
s.rows.forEach((r, i) => {
const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v);
text(ctx, r.label, 160, y, 7, paper);
text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center');
text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center');
});
text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center');
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center');
}
export function drawScopa(ctx, c, def) {
const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' };
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'SCOPA ALL’ORATORIO');
board(ctx, c, names); table(ctx, c); hands(ctx, c);
if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`);
if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`);
ctx.restore();
}
+255
View File
@@ -0,0 +1,255 @@
// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello,
// primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari.
import { DECKS, cardName } from './briscola.js';
export { cardName };
export const LEVELS = ['bona', 'seria', 'male'];
export const TARGET = 11;
// I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi
// volano sul mucchietto di chi ha preso.
export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35;
export const COIN = { toscane: 'mattoni', napoletane: 'denari' };
// Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14,
// 3 13, 2 12, le figure 10.
const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] }))));
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
export const other = who => who === 'player' ? 'cpu' : 'player';
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
function shuffle(cards, random) {
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
return cards;
}
// Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a
// scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria.
export function captures(table, card) {
const same = table.filter(a => a.value === card.value);
if (same.length) return same.map(a => [a]);
const out = [];
for (let mask = 1; mask < 1 << table.length; mask++) {
const pick = table.filter((_, i) => mask & 1 << i);
if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick);
}
return out;
}
const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7;
// Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa.
export function primiera(cards, deck) {
const suits = DECKS[deck].suits;
if (!suits.every(s => cards.some(a => a.suit === s))) return 0;
return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0);
}
// I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa.
export function tally(taken, scope, deck) {
const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null;
const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck);
rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) });
rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) });
const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu';
rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven });
rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) });
const points = { player: 0, cpu: 0 };
for (const r of rows) if (r.winner) points[r.winner]++;
rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null });
points.player += scope.player; points.cpu += scope.cpu;
return { rows, points };
}
function deal(c) {
// Con tre o quattro re in tavola si rimescola.
let stock;
do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); }
while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3);
c.table = stock.splice(0, 4); c.stock = stock;
c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 };
c.current = other(c.dealer); c.lastTaker = null; c.round++;
refill(c); c.events.push({ kind: 'card-shuffle' });
}
// Tre carte a testa, prima a chi gioca per primo.
function refill(c) {
for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift());
c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null;
}
export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 },
round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null };
c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu';
deal(c); return c;
}
export const canPlay = c => c.step === 'play' && c.current === 'player';
export function select(c, index) {
if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; }
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
c.selected = index; return true;
}
export function nudge(c, direction) {
if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); }
const n = c.hands.player.length;
return select(c, (c.selected + direction + n) % n);
}
// Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»).
export function play(c, index = c.selected, who = 'player', option = null) {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
const options = captures(c.table, c.hands[who][index]);
if (options.length > 1 && option === null) {
if (who !== 'player') return false;
c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true;
}
return execute(c, who, index, options.length ? options[option ?? 0] : null);
}
export function take(c, k = c.choice?.selected) {
if (c.step !== 'choose' || !c.choice.options[k]) return false;
const { index, options } = c.choice; c.step = 'play'; c.choice = null;
return execute(c, 'player', index, options[k]);
}
export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; }
function execute(c, who, index, captured) {
if (!captured && captures(c.table, c.hands[who][index]).length) return false;
const before = [...c.table], [card] = c.hands[who].splice(index, 1);
if (captured) {
const ids = new Set(captured.map(a => a.id));
c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who;
} else c.table.push(card);
// L'ultima carta della smazzata non fa scopa.
const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length;
const scopa = !!captured && !c.table.length && !lastPlay;
if (scopa) c.scope[who]++;
// `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa.
c.last = { who, card, captured: captured ?? [], scopa, from: index, before };
c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : []));
c.step = 'take'; c.t = 0;
if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
return true;
}
function after(c) {
c.current = other(c.last.who); c.t = 0; c.step = 'play';
if (c.hands.player.length || c.hands.cpu.length) return;
if (c.stock.length) { refill(c); return; }
// Fine smazzata: quel che resta in tavola va all'ultimo che ha preso.
if (c.lastTaker) c.taken[c.lastTaker].push(...c.table);
c.table = [];
const t = tally(c.taken, c.scope, c.deck);
c.score.player += t.points.player; c.score.cpu += t.points.cpu;
c.summary = { ...t, score: { ...c.score } }; c.t = 0;
// Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria.
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; }
else c.step = 'smazzata';
}
// Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra.
export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu;
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) {
if (c.step !== 'smazzata') return false;
c.dealer = other(c.dealer); deal(c);
return true;
}
// La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte).
export function observation(c, who = 'cpu') {
return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] },
opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope }, lastTaker: c.lastTaker };
}
// Le carte che non si sono viste: in mano all'altro o nel mazzo.
export function unseen(v) {
const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id));
return DECK_CARDS[v.deck].filter(a => !seen.has(a.id));
}
// Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare
// una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore
// possono essere ancora in mano all'altro.
export function score(v, card, captured, counting) {
const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount;
const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card];
// La primiera vale per quanto migliora la propria: un 7 di un seme dove si ha già un 7 non aggiunge niente,
// la prima carta di un seme che manca aggiunge parecchio.
const mine = v.taken[v.who] ?? [], best = suit => Math.max(0, ...mine.filter(a => a.suit === suit).map(a => a.prime));
const prime = a => Math.max(0, a.prime - best(a.suit)) / 4 + (best(a.suit) ? 0 : 1);
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + prime(a);
let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6;
const sum = left.reduce((n, a) => n + a.value, 0);
if (left.length && sum <= 10 && v.opponentCount) {
let p = 0.5;
if (counting) {
const pool = unseen(v), same = pool.filter(a => a.value === sum).length;
p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0;
}
value -= p * 20;
}
// Lasciare in tavola un 7 o il settebello vuol dire regalarli.
value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0);
return value;
}
export function moves(v) {
return v.hand.flatMap((card, index) => {
const opts = captures(v.table, card);
return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }];
});
}
// «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più.
// Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 96% contro «Alla bona», 76% contro «Seria», 50% contro «A fassi male».
// A mazzo finito le carte dell'altro sono proprio quelle non viste: si calcola il finale esatto, fino al conteggio
// (carte, mattoni, settebello, primiera, scope, e la tavola all'ultimo che prende).
export function endgame(v) {
const me = v.who, them = other(me), memo = new Map();
const final = (taken, scope, table, last) => {
const all = { player: [...taken.player], cpu: [...taken.cpu] };
if (last) all[last].push(...table);
const t = tally(all, scope, v.deck);
return t.points[me] - t.points[them];
};
const solve = (hands, table, taken, scope, last, turn) => {
if (!hands.player.length && !hands.cpu.length) return final(taken, scope, table, last);
const key = `${turn}|${hands[me].map(a => a.id)}|${hands[them].map(a => a.id)}|${table.map(a => a.id).sort()}|${last}|${taken.player.length}|${scope.player}${scope.cpu}`;
if (memo.has(key)) return memo.get(key);
let best = turn === me ? -Infinity : Infinity;
for (const m of moves({ hand: hands[turn], table })) {
const r = step(hands, table, taken, scope, last, turn, m);
const value = solve(...r);
best = turn === me ? Math.max(best, value) : Math.min(best, value);
}
memo.set(key, best); return best;
};
const step = (hands, table, taken, scope, last, turn, m) => {
const card = hands[turn][m.index], rest = { ...hands, [turn]: hands[turn].filter((_, i) => i !== m.index) };
if (!m.captured) return [rest, [...table, card], taken, scope, last, other(turn)];
const ids = new Set(m.captured.map(a => a.id)), left = table.filter(a => !ids.has(a.id));
const end = !rest.player.length && !rest.cpu.length;
return [rest, left, { ...taken, [turn]: [...taken[turn], card, ...m.captured] },
!left.length && !end ? { ...scope, [turn]: scope[turn] + 1 } : scope, turn, other(turn)];
};
const hands = { [me]: v.hand, [them]: unseen(v) };
const start = [hands, v.table, v.taken, v.scope ?? { player: 0, cpu: 0 }, v.lastTaker ?? null, me];
return moves(v).map(m => ({ m, value: solve(...step(...start, m)) })).reduce((a, b) => b.value > a.value ? b : a).m;
}
export function choose(v, level = 'seria', seed = 1) {
const all = moves(v);
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
const counting = level === 'male';
if (counting && !v.stock && unseen(v).length === v.opponentCount) return endgame(v);
return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt;
if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c);
else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) {
c.aiSeed = lcg(c.aiSeed);
const m = choose(observation(c, 'cpu'), c.level, c.aiSeed);
play(c, m.index, 'cpu', m.option);
}
}
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`;
if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`;
if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.';
if (c.step === 'take') {
const l = c.last;
return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`;
}
// A parole anche la tavola, per chi non vede il disegno.
const table = `In tavola: ${c.table.map(cardName).join(', ') || 'niente'}.`;
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} pensa…`;
}
+4
View File
@@ -103,6 +103,10 @@ export class Soundscape {
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true }); if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan }); else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); } else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
// I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, pan); }
else if (kind === 'card-shuffle') { else if (kind === 'card-shuffle') {
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan }); this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan); for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
+108
View File
@@ -0,0 +1,108 @@
import { canPlay, legal, accuseName, team, NAMES, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './tressette.js';
import { drawCard, drawTable, flight } from './briscola-render.js';
import { text, panel } from './bocce-render.js';
// La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco.
export const CARD = { w: 42, h: 60, y: 288, lift: 8 };
export function handX(n, i) {
const gap = n > 1 ? Math.min(44, (440 - CARD.w) / (n - 1)) : 0;
return Math.round(320 - (gap * (n - 1) + CARD.w) / 2 + i * gap);
}
// Le carte si coprono: vince quella sopra, cioè la più a destra.
export function cardAt(c, [x, y]) {
if (!canPlay(c)) return -1;
const n = c.hands[0].length;
for (let i = n - 1; i >= 0; i--) {
const top = CARD.y - (c.selected === i ? CARD.lift : 0), left = handX(n, i);
if (x >= left && x < left + CARD.w && y >= top && y < top + CARD.h + CARD.lift) return i;
}
return -1;
}
// Dove cade la carta di ognuno al centro, e da dove arriva.
const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]];
// I mucchietti delle prese: noi in basso a destra, loro in basso a sinistra.
const PILE = [[560, 288], [34, 288]];
const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']];
const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3';
const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' };
function others(ctx, c, names) {
// Il socio di fronte, gli avversari ai lati: carte coperte, quante ne hanno.
const n2 = c.hands[2].length, w2 = n2 ? (n2 - 1) * 12 + 26 : 0;
for (let i = 0; i < n2; i++) drawCard(ctx, null, 320 - w2 / 2 + i * 12, 46, 26, 37, true);
for (const [seat, x] of [[3, 30], [1, 584]]) {
for (let i = 0; i < c.hands[seat].length; i++) drawCard(ctx, null, x, 102 + i * 12, 26, 37, true);
}
for (const seat of [1, 2, 3]) {
// Chi deve giocare ha il nome in giallo.
const [x, y, align] = LABEL[seat], turn = c.step === 'play' && c.current === seat;
text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align);
}
}
function piles(ctx, c) {
// Quante prese ha fatto ogni coppia, a mucchietto (una carta coperta ogni due prese).
for (const t of [0, 1]) {
const n = c.tricks.filter(x => team(x.winner) === t).length, [x, y] = PILE[t];
for (let k = 0; k < Math.ceil(n / 2); k++) drawCard(ctx, null, x + k, y - k, 42, 60, true);
text(ctx, t ? 'Prese loro' : 'Prese noi', x + 21, y - 8, 5, t ? theirs : ours, 'center');
}
}
function scoreboard(ctx, c) {
panel(ctx, 26, 34, 118, 38);
text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours);
text(ctx, `Loro ${c.score[1]}`, 136, 46, 7, theirs, 'right');
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
}
function accuses(ctx, c, names) {
// Le accuse dette restano in vista per tutta la smazzata.
if (!c.accuse.length) return;
// Largo abbastanza per «Il giocatore: tre Assi, manca mattoni +3» (200 px nel carattere a 5).
panel(ctx, 396, 34, 218, 12 + c.accuse.length * 11);
c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 403, 41 + i * 11, 5, a.who % 2 ? theirs : ours));
}
function trick(ctx, c, names) {
// Da dove parte la carta: la mano di Andrea, o le carte coperte degli altri.
const from = p => p.who === 0 ? [handX(c.hands[0].length + 1, p.from ?? 0), CARD.y, CARD.w, CARD.h]
: p.who === 2 ? [300, 46, 26, 37] : [p.who === 1 ? 584 : 30, 102 + (p.from ?? 0) * 12, 26, 37];
c.table.forEach((p, i) => {
const [x, y] = SPOT[p.who];
let r = [x, y, CARD.w, CARD.h];
if (i === c.table.length - 1 && c.t < PLAY_TIME && (c.step === 'play' || c.step === 'trick')) r = flight(from(p), r, c.t / PLAY_TIME);
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(r, [...PILE[team(c.last.winner)], 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
drawCard(ctx, p.card, ...r);
if (p.signal) {
const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30];
panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center');
}
});
if (c.step === 'trick') text(ctx, `Prende ${names[c.last.winner]}`, 320, 262, 6, gold, 'center');
}
function hand(ctx, c) {
const cards = c.hands[0], n = cards.length, mine = canPlay(c), ok = mine ? legal(cards, c.table) : [];
cards.forEach((card, i) => {
const x = handX(n, i), up = mine && c.selected === i ? CARD.lift : 0;
if (up) { ctx.fillStyle = '#ffdb76'; ctx.fillRect(x - 2, CARD.y - up - 2, CARD.w + 4, CARD.h + 4); }
drawCard(ctx, card, x, CARD.y - up, CARD.w, CARD.h);
// Le carte che non rispondono al seme si spengono.
if (mine && !ok.includes(i)) { ctx.fillStyle = 'rgba(20,28,24,0.55)'; ctx.fillRect(x, CARD.y, CARD.w, CARD.h); }
});
if (mine && !c.table.length && c.signal) text(ctx, `Segno: ${SAY[c.signal]}`, 320, 278, 6, gold, 'center');
}
function summary(ctx, c, title) {
const s = c.summary, row = (y, who, t, color) =>
text(ctx, `${who}: ${s.thirds[t]} terzi = ${s.points[t]} punti${s.accused[t] ? `, accuse +${s.accused[t]}` : ''}${s.lastTeam === t ? ' (con l’ultima)' : ''}`, 172, y, 6, color);
panel(ctx, 160, 96, 320, 132);
text(ctx, title, 320, 112, 8, gold, 'center');
row(140, 'Noi', 0, ours); row(158, 'Loro', 1, theirs);
text(ctx, `Siamo ${c.score[0]} a ${c.score[1]}`, 320, 186, 8, paper, 'center');
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 210, 5, paper, 'center');
}
export function drawTressette(ctx, c, def) {
const names = def?.seats ?? NAMES;
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'TRESSETTE AI 21');
scoreboard(ctx, c); others(ctx, c, names); piles(ctx, c); accuses(ctx, c, names); hand(ctx, c); trick(ctx, c, names);
if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro');
ctx.restore();
}
+279
View File
@@ -0,0 +1,279 @@
// Tressette a coppie «ai 21», come si gioca in Mugello: quattro al tavolo, Andrea e il socio contro gli altri due.
// Regole e avversari senza DOM, come la briscola. Segni di chi esce (busso, striscio, volo) e accuse a inizio smazzata.
import { DECKS, cardName } from './briscola.js';
export { cardName };
export const LEVELS = ['bona', 'seria', 'male'];
export const SIGNALS = ['busso', 'striscio', 'volo'];
export const TARGET = 21;
// I tempi delle animazioni (tressette-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
// poi vola sul mucchietto della coppia che ha preso.
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.8, TRICK_TIME = 1.35;
// Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra.
export const SEATS = [0, 1, 2, 3];
export const team = seat => seat % 2;
export const next = seat => (seat + 1) % 4;
export const partner = seat => (seat + 2) % 4;
// Ordine di presa 3 > 2 > Asso > Re > Cavallo > Fante > 7 > 6 > 5 > 4 (indici delle carte: Asso, 2, …, 7, figure).
const POWER = [7, 8, 9, 0, 1, 2, 3, 4, 5, 6];
// Punti in terzi: l'Asso vale un punto, il 2, il 3 e le figure un terzo. 32 terzi più 3 dell'ultima presa: 11 punti.
const THIRDS = [3, 1, 1, 0, 0, 0, 0, 1, 1, 1];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
Object.freeze({ id: s * 10 + r, suit, rank, thirds: THIRDS[r], power: POWER[r] }))));
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
function shuffle(cards, random) {
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
return cards;
}
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre.
export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) {
const out = [], tops = ['Asso', '2', '3'];
for (const suit of new Set(hand.map(a => a.suit))) {
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
if (cards.length === 3) out.push({ kind: 'napoletana', suit, points: 3, cards });
}
for (const rank of tops) {
const cards = hand.filter(a => a.rank === rank);
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null });
}
return out;
}
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}`
: `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`;
// Si risponde al seme quando si può; chi esce gioca quel che vuole.
export function legal(hand, table) {
const led = table[0]?.card.suit, same = hand.flatMap((a, i) => a.suit === led ? [i] : []);
return same.length ? same : hand.map((_, i) => i);
}
export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a);
// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e
// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi.
export function signalOk(hand, index, table, signal) {
if (!signal) return true;
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true;
}
// Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene.
export function reason(c, index, signal = c.signal) {
const hand = c.hands[0], card = hand[index];
if (!card) return 'Scegli una carta.';
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
if (!signal || signalOk(hand, index, c.table, signal)) return '';
if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.';
if (c.table.length) return 'I segni li fa solo chi esce.';
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
}
const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
function deal(c) {
c.seed = lcg(c.seed);
const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
// Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere.
c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
// Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a })));
c.accuse = [];
c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
c.events.push({ kind: 'card-shuffle' });
}
export function newTressette(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: [0, 0], round: 0,
seed: seed >>> 0, aiSeed: (seed ^ 0x85ebca6b) >>> 0, age: 0, events: [], last: null, summary: null };
c.dealer = Math.floor(rng(seed)() * 4);
deal(c); return c;
}
export const canPlay = c => c.step === 'play' && c.current === 0;
export function select(c, index) {
if (!canPlay(c) || !Number.isInteger(index) || !c.hands[0][index]) return false;
c.selected = index; c.refused = ''; return true;
}
export function nudge(c, direction) {
const n = c.hands[0].length;
return select(c, (c.selected + direction + n) % n);
}
// Il segno di Andrea vale per la prossima carta con cui esce; ripremuto, si toglie.
export function setSignal(c, signal) {
if (!canPlay(c) || c.table.length || (signal && !SIGNALS.includes(signal))) return false;
c.signal = c.signal === signal ? null : signal; c.refused = ''; return true;
}
export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.signal : null) {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
if (!legal(c.hands[who], c.table).includes(index) || !signalOk(c.hands[who], index, c.table, signal)) {
if (who === 0) c.refused = reason(c, index, signal);
return false;
}
if (!c.tricks.length && c.pending[who].length) {
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
c.events.push({ kind: 'accusa', who });
// Un'accusa che porta ai 21 fa uscire subito: è il primo evento decisivo, prima ancora della carta.
if (out(c)) return true;
}
const [card] = c.hands[who].splice(index, 1);
c.table.push({ who, card, signal: signal || null, from: index }); c.t = 0; c.landing = true; // `from`: per il disegno
// Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who });
if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); }
if (c.table.length === 4) { c.step = 'trick'; c.last = { winner: best(c.table).who, cards: c.table.map(p => p.card) }; }
else c.current = next(who);
return true;
}
// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi.
const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0);
function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); }
export function collect(c) {
if (c.step !== 'trick') return false;
const winner = c.last.winner, end = !c.hands[0].length;
// L'ultima presa vale un punto; i terzi avanzati non contano.
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0);
c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
recount(c);
if (out(c, end ? team(winner) : null)) return true;
if (end) { summarize(c, team(winner)); c.step = 'smazzata'; return true; }
c.step = 'play';
return true;
}
function summarize(c, lastTeam = null) {
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
lastTeam, score: [...c.score] };
}
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata. Il controllo si fa a ogni evento che
// porta punti (un'accusa, una presa), e ognuno ne porta a una coppia sola: non ci si arriva mai insieme.
function out(c, lastTeam = null) {
const t = [0, 1].find(t => c.score[t] >= TARGET);
if (t === undefined) return false;
summarize(c, lastTeam); c.summary.out = t;
c.step = 'result'; c.result = { winner: t === 0 ? 'player' : 'cpu', score: [...c.score] };
return true;
}
export const finished = c => c.step === 'result';
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) {
if (c.step !== 'smazzata') return false;
deal(c); return true;
}
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
export function observation(c, seat) {
// Anche il punteggio e i terzi presi: sono sul tabellone, li vede chiunque.
return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })), score: [...c.score], thirds: [...c.thirds],
tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })),
accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
}
// Quel che un giocatore sa. Chi conta davvero («A fassi male») ricorda le carte uscite, i semi finiti di ognuno e le
// carte accusate dagli altri.
export function knowledge(v, full) {
// Chi non conta («Seria») vede solo le carte in tavola e le sue: un 3 già uscito per lui può essere ancora in giro.
const played = [...(full ? v.tricks.flatMap(t => t.cards) : []), ...v.table];
const gone = new Set([...v.hand, ...played.map(p => p.card)].map(a => a.id));
const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
const voids = SEATS.map(() => new Set()), holder = new Map();
if (full) {
// Chi non risponde al seme l'ha finito; chi vola anche.
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => {
if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit);
if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit);
});
for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who);
}
const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat);
// Qualcuno fra `seats` può ancora battere `card`?
const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
return { out, voids, could, beatable, full };
}
// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo.
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0));
const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
function lead(v, k) {
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
// Il socio ha bussato e la presa l'ho fatta io: gli ritorno il seme con la più bassa.
const prev = v.tricks.at(-1), knock = prev?.cards[0];
if (k.full && prev?.winner === v.seat && knock?.who === partner(v.seat) && knock.signal === 'busso') {
const same = hand.filter(a => a.suit === knock.card.suit);
if (same.length) return { index: hand.indexOf(cheap(same)), signal: null };
}
// Una carta che gli avversari non possono più battere: si fa la presa, e coi punti dentro.
const sure = hand.filter(a => !k.beatable(a, foes));
if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
// Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
const count = suit => hand.filter(a => a.suit === suit).length;
// A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri).
const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit;
const card = cheap(hand.filter(a => a.suit === longest));
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
}
function signalFor(hand, card, k) {
const rest = hand.filter(a => a !== card && a.suit === card.suit);
if (!rest.length) return 'volo';
// Busso: la mia più alta è la seconda buona del seme; il socio prenda con la prima e me lo ritorni.
const top = rest.reduce((a, b) => b.power > a.power ? b : a);
if (top.power > card.power && k.out.filter(a => a.suit === card.suit && a.power > top.power).length === 1) return 'busso';
return rest.length >= 2 ? 'striscio' : null;
}
function follow(v, k, moves) {
const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
const pick = card => v.hand.indexOf(card);
// Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme).
const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full;
if (knock && cards[0].suit === top.card.suit) {
const over = cards.filter(a => a.power > top.card.power);
if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a));
}
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). Se un
// carico basta per arrivare ai 21, si dà quello, anche il 2 o il 3: si esce e si vince.
if (mate && !k.beatable(top.card, after)) {
const t = team(v.seat), last = v.hand.length === 1, before = v.score?.[t] ?? 0, thirds = v.thirds?.[t] ?? 0;
const sum = v.table.reduce((n, p) => n + p.card.thirds, 0) + (last ? 3 : 0);
const wins = cards.filter(a => before - Math.floor(thirds / 3) + Math.floor((thirds + sum + a.thirds) / 3) >= TARGET);
return pick(wins.length ? byMin(wins, a => a.power) : rich(cards));
}
if (cards[0].suit === top.card.suit) {
const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
if (safe.length) return pick(byMin(safe, a => a.power));
}
return pick(cheap(cards));
}
// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md).
export function choose(v, level = 'seria', seed = 1) {
const moves = legal(v.hand, v.table);
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
const k = knowledge(v, level === 'male');
return v.table.length ? { index: follow(v, k, moves), signal: null } : lead(v, k);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt;
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
c.aiSeed = lcg(c.aiSeed);
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
play(c, index, seat, signal);
}
}
export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
export function status(c, names = NAMES) {
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`;
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
// Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
if (c.refused) return c.refused;
if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
}
+199 -22
View File
@@ -12,6 +12,10 @@ import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js'; import * as biliardo from './engine/biliardo.js';
import * as briscola from './engine/briscola.js'; import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js'; import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js';
import { cardAt as tressetteAt } from './engine/tressette-render.js';
import * as scopa from './engine/scopa.js';
import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js';
import { VERSION, versioned } from './version.js'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -285,6 +289,38 @@ function closePause() {
running = true; running = true;
} }
$('pause-button').addEventListener('click', openPause); $('pause-button').addEventListener('click', openPause);
// I video «Come si gioca» (story.tutorials): il gioco si ferma come in pausa, la musica tace, il video ha il suo audio.
function openTutorial(kind) {
const t = story.tutorials?.[kind];
if (!t || !running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
$('tutorial-title').textContent = t.title;
const video = $('tutorial-video');
video.src = versioned(t.src);
if (t.poster) video.poster = versioned(t.poster);
video.muted = music.muted;
$('tutorial').hidden = false;
video.play().catch(() => {});
$('tutorial-close').focus();
}
function closeTutorial() {
const video = $('tutorial-video');
video.pause(); video.removeAttribute('src'); video.load();
$('tutorial').hidden = true;
music.hold = false;
voices.resume();
idleSince = performance.now();
running = true;
}
for (const button of document.querySelectorAll('.tutorial-open')) {
button.hidden = !story.tutorials?.[button.dataset.tutorial];
button.addEventListener('click', () => openTutorial(button.dataset.tutorial));
}
$('tutorial-close').addEventListener('click', closeTutorial);
// Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello; // Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello;
// la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi). // la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi).
function leave(hidden) { function leave(hidden) {
@@ -401,9 +437,18 @@ canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return; if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { if (game.closeup) {
if (story.closeups[game.closeup.id].type === 'briscola') { const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
if (deck) {
if (game.skip()) return; if (game.skip()) return;
press = { card: cardAt(game.closeup, scenePoint(e)), state: game.closeup }; // Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
if (MINI[type].advance?.(game.closeup)) return;
// Scopa, più prese possibili: si tocca una carta in tavola (si decide al rilascio, vedi pointerup).
if (type === 'scopa' && game.closeup.step === 'choose') {
press = { table: scopaTableAt(game.closeup, scenePoint(e)), state: game.closeup };
canvas.setPointerCapture(e.pointerId);
return;
}
press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
return; return;
} }
@@ -412,6 +457,7 @@ canvas.addEventListener('pointerdown', e => {
if (!aimed && !game.speech) return; if (!aimed && !game.speech) return;
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
} }
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
press = { closeup: true }; press = { closeup: true };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(); if (!game.skip()) game.closeupPress();
@@ -423,9 +469,19 @@ canvas.addEventListener('pointerdown', e => {
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (!press) return; if (!press) return;
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
if (press.table !== undefined) {
const p = press; press = null;
const c = game.closeup;
if (!running || c !== p.state || c.step !== 'choose' || p.table < 0 || scopaTableAt(c, scenePoint(e)) !== p.table) return;
const card = c.table[p.table], options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []);
if (options.includes(c.choice.selected)) scopa.take(c); else if (options.length) scopa.select(c, options[0]);
return;
}
if (press.card !== undefined) { if (press.card !== undefined) {
const p = press; press = null; const p = press; press = null;
if (running && game.closeup === p.state && p.card >= 0 && cardAt(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card); if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
return; return;
} }
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; } if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
@@ -440,7 +496,7 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined) { press = null; game?.closeupCancel(); } }); canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -448,16 +504,19 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo, // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera. // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo'; // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); } const aiming = game.closeup && ['biliardo', 'bocce'].includes(story.closeups[game.closeup.id].type);
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
canvas.addEventListener('pointerleave', () => { mouse = null; }); canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio. // Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = { const MINI = {
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' },
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' }, 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.' }, 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.' }, 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.' },
@@ -466,6 +525,23 @@ const miniState = () => running && game?.closeup && MINI[story.closeups[game.clo
const mini = () => MINI[story.closeups[game.closeup.id].type]; const mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); }); $('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); }); $('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); });
}
$('scopa-options').addEventListener('focusin', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.step === 'choose') scopa.select(game.closeup, Number(k)); });
$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); });
$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); });
for (let i = 0; i < 10; i++) {
const button = $('tressette-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'tressette') tressette.select(game.closeup, i); });
}
for (const signal of tressette.SIGNALS) $('tressette-' + signal).addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.setSignal(game.closeup, signal); });
$('tressette-next').addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.advance(game.closeup); });
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i); const button = $('briscola-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('click', () => game?.closeupPlay(i));
@@ -554,17 +630,66 @@ window.addEventListener('keydown', e => {
return; return;
} }
if (debugKey(e)) return; 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; } return; }
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); 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 (!$('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; } if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro. // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) { if (running && game.closeup) {
if (story.closeups[game.closeup.id].type === 'scopa') {
const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase();
if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; }
// Sui pulsanti delle carte e delle prese Invio e Spazio valgono per quella scelta (una sola fonte: lo stato).
const choice = e.target.id?.startsWith('scopa-card-') || e.target.dataset?.option !== undefined;
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key) && !choice) return;
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) {
e.preventDefault();
if (e.repeat || game.skip()) return;
if (/^[1-9]$/.test(key)) scopa.select(c, Number(key) - 1);
else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose();
else if (c.step === 'choose') scopa.take(c);
else if (!scopa.advance(c)) game.closeupPlay();
// Il fuoco segue la scelta, così quel che si vede scelto è quel che parte.
if (inControls && c.step === 'choose') $('scopa-options').querySelector(`[data-option="${c.choice.selected}"]`)?.focus();
else if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus();
return;
}
// Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo.
if (e.key === 'Escape') { if (!scopa.cancel(c)) game.closeupClose(); }
else if (key === 'm') toggleMute();
return;
}
if (story.closeups[game.closeup.id].type === 'tressette') {
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
// Sui pulsanti della mano Invio e Spazio giocano la carta scelta (anche se se ne è scelta una che non si
// può giocare: allora si legge perché), mai quella che aveva il fuoco prima.
const card = e.target.id?.startsWith('tressette-card-');
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key) && !card) return;
const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key];
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) {
e.preventDefault();
if (e.repeat || game.skip()) return;
if (signal) tressette.setSignal(c, signal);
else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10);
else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose();
else if (!tressette.advance(c)) game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus();
return;
}
if (e.key === 'Escape') game.closeupClose();
else if (key === 'm') toggleMute();
return;
}
if (story.closeups[game.closeup.id].type === 'briscola') { if (story.closeups[game.closeup.id].type === 'briscola') {
const inControls = e.target.closest?.('#briscola-controls'); const inControls = e.target.closest?.('#briscola-controls');
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; } if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return; if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return;
if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && e.key === 'Enter' && briscola.canPlay(game.closeup)) return; if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && ['Enter', ' '].includes(e.key) && briscola.canPlay(game.closeup)) return;
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
e.preventDefault(); e.preventDefault();
if (e.repeat || game.skip()) return; if (e.repeat || game.skip()) return;
@@ -572,7 +697,7 @@ window.addEventListener('keydown', e => {
if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1); if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1); else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose(); else if (c.step === 'result') game.closeupClose();
else if (e.key === 'Enter') game.closeupPlay(); else game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus(); if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
return; return;
} }
@@ -666,12 +791,60 @@ function updateUi() {
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id]; const mg = miniState() && mini(), def = mg && story.closeups[c.id];
const cards = def?.type === 'briscola'; const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
$('bocce-controls').hidden = !mg || cards; $('bocce-controls').hidden = !mg || cards;
$('bocce-controls').inert = !running; $('bocce-controls').inert = !running;
$('briscola-controls').hidden = !cards; $('briscola-controls').hidden = def?.type !== 'briscola';
$('briscola-controls').inert = !running; $('briscola-controls').inert = !running;
if (cards) { $('scopa-controls').hidden = def?.type !== 'scopa';
$('scopa-controls').inert = !running;
if (def?.type === 'scopa') {
$('panel').hidden = $('choices').hidden = true;
const line = scopa.status(c, def.names);
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
}
// Le prese fra cui scegliere, una per pulsante.
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
const box = $('scopa-options');
if (box.dataset.list !== options.join('|')) {
box.dataset.list = options.join('|');
box.replaceChildren(...options.map((o, k) => {
const b = Object.assign(document.createElement('button'), { type: 'button', textContent: `Prendi ${o}` });
b.dataset.option = k; return b;
}));
}
box.hidden = !options.length;
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
$('scopa-next').hidden = c.step !== 'smazzata';
}
$('tressette-controls').hidden = def?.type !== 'tressette';
$('tressette-controls').inert = !running;
if (def?.type === 'tressette') {
$('panel').hidden = $('choices').hidden = true;
const line = tressette.status(c, def.seats);
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
for (let i = 0; i < 10; i++) {
const button = $('tressette-card-' + i), card = c.hands[0][i];
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
if (button.textContent !== label) button.textContent = label;
button.hidden = !card;
button.disabled = !ok.includes(i);
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
}
for (const signal of tressette.SIGNALS) {
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
}
$('tressette-next').hidden = c.step !== 'smazzata';
}
if (def?.type === 'briscola') {
$('panel').hidden = $('choices').hidden = true; $('panel').hidden = $('choices').hidden = true;
const line = briscola.status(c, def.names); const line = briscola.status(c, def.names);
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line; if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
@@ -735,7 +908,7 @@ function frame(now) {
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
const searching = game?.closeup?.id === 'fessura'; const searching = game?.closeup?.id === 'fessura';
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt); music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt);
// Un minuto senza toccare niente, a gioco fermo: pausa da sola. // Un minuto senza toccare niente, a gioco fermo: pausa da sola.
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 }); ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
@@ -773,7 +946,7 @@ $('restart').addEventListener('click', () => {
// incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina. // incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina.
const SPLASH_FADE = 4300, SPLASH_END = 5000; const SPLASH_FADE = 4300, SPLASH_END = 5000;
function showSplash() { function showSplash() {
let done = false, revealed = false; let done = false, revealed = false, waiting = false;
const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } }; const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } };
const end = () => { const end = () => {
if (done) return; if (done) return;
@@ -781,18 +954,22 @@ function showSplash() {
$('splash').hidden = true; $('splash').hidden = true;
jingle?.pause(); // saltato il cartello, si salta anche la musichetta jingle?.pause(); // saltato il cartello, si salta anche la musichetta
reveal(); reveal();
removeEventListener('keydown', onKey);
}; };
// La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto. const schedule = () => { setTimeout(reveal, SPLASH_FADE); setTimeout(end, SPLASH_END); };
// La musichetta: su un sito mai visitato il browser non la fa partire senza un gesto (in locale si fida, perché ci
// si gioca da giorni: Andrea, 26 ottobre, «online non sento gli effetti»). Allora il cartello aspetta il primo clic
// o tasto, che fa partire musichetta e conto alla rovescia; il secondo lo salta, come sempre.
const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null; const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null;
const begin = () => { waiting = false; $('splash').classList.remove('wait'); jingle.play().catch(() => {}); schedule(); };
const onKey = () => { if (waiting) begin(); else end(); };
if (jingle) { if (jingle) {
jingle.volume = music.muted ? 0 : music.volume; jingle.volume = music.muted ? 0 : music.volume;
jingle.play().catch(() => {}); jingle.play().then(schedule).catch(() => { waiting = true; $('splash').classList.add('wait'); });
window.jingle = jingle; // per i test nel browser window.jingle = jingle; // per i test nel browser
} } else schedule();
setTimeout(reveal, SPLASH_FADE); $('splash').addEventListener('click', () => { if (waiting) begin(); else end(); });
setTimeout(end, SPLASH_END); addEventListener('keydown', onKey);
$('splash').addEventListener('click', end);
addEventListener('keydown', end, { once: true });
} }
for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`; for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`;
+105 -15
View File
@@ -109,15 +109,33 @@ export const story = {
closeups: { closeups: {
// [DA ANDREA: chi è] Partita facoltativa; battute provvisorie. // [DA ANDREA: chi è] Partita facoltativa; battute provvisorie.
briscola: { briscola: {
type: 'briscola', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, type: 'briscola', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
win: [{ set: 'briscola_vinta' }, { set: 'briscola_giocata' }, { say: 'Oh, bravo! Ora però rimescolo io.', who: 'giocatore_carte', pose: 'laugh' }], win: [{ set: 'briscola_vinta' }, { set: 'briscola_giocata' }, { say: 'Oh, bravo! Ora però rimescolo io.', who: 'giocatore_carte', pose: 'laugh' }],
lose: [{ set: 'briscola_giocata' }, { say: 'I carichi portali qui, mica alla stazione! Rivincita quando vuoi.', who: 'giocatore_carte', pose: 'laugh' }], lose: [{ set: 'briscola_giocata' }, { say: 'I carichi portali qui, mica alla stazione! Rivincita quando vuoi.', who: 'giocatore_carte', pose: 'laugh' }],
draw: [{ set: 'briscola_giocata' }, { say: 'Sessanta pari. Un si litiga nemmeno sul conto!', who: 'giocatore_carte', pose: 'talk' }], draw: [{ set: 'briscola_giocata' }, { say: 'Sessanta pari. Un si litiga nemmeno sul conto!', who: 'giocatore_carte', pose: 'talk' }],
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }], quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
}, },
// I tre tavoli da carte suonano il valzer della balera (tools/valzer_carte.py, Andrea: «una sua melodia»).
// Il tressette a coppie ai 21 (idea di Andrea): busso, striscio, volo e accuse. [DA ANDREA: chi sono il socio e l'altro]
tressette: {
type: 'tressette', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
seats: ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'],
win: [{ set: 'tressette_vinto' }, { say: 'Ventuno per primi. Bella coppia, voi due!', who: 'giocatore_carte', pose: 'laugh' }],
lose: [{ say: 'Ventuno! Il tressette è un\'arte: si studia da piccini.', who: 'giocatore_carte', pose: 'laugh' }],
draw: [],
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
},
// La scopa ai 11 (idea di Andrea): carte, mattoni (o denari), settebello, primiera e scope.
scopa: {
type: 'scopa', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
win: [{ set: 'scopa_vinta' }, { say: 'Settebello, primiera e pure le scope. Oggi l\'hai vista bene.', who: 'giocatore_carte', pose: 'talk' }],
lose: [{ say: 'Undici! Il settebello lo sento arrivare da lontano.', who: 'giocatore_carte', pose: 'laugh' }],
draw: [],
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
},
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria. // Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: { bocce: {
type: 'bocce', type: 'bocce', music: 'assets/audio/bocce.mp3', // la polka del pallaio (tools/polka_bocce.py, Andrea: «un sound suo»)
picture: 'assets/closeups/pallaio.png', picture: 'assets/closeups/pallaio.png',
names: { player: 'Andrea', cpu: 'Mariano' }, names: { player: 'Andrea', cpu: 'Mariano' },
win: [ win: [
@@ -185,6 +203,14 @@ export const story = {
paper: '#fbe6bc', paper: '#fbe6bc',
}, },
}, },
// I video «Come si gioca» (video/SCALETTA-CARTE.md): il pulsante «Guarda il video» nell'aiuto di ogni gioco.
tutorials: {
briscola: { title: 'Come si gioca: la briscola', src: 'assets/video/briscola.mp4', poster: 'assets/video/briscola.jpg' },
tressette: { title: 'Come si gioca: il tressette ai 21', src: 'assets/video/tressette.mp4', poster: 'assets/video/tressette.jpg' },
scopa: { title: 'Come si gioca: la scopa', src: 'assets/video/scopa.mp4', poster: 'assets/video/scopa.jpg' },
bocce: { title: 'Come si gioca: le bocce', src: 'assets/video/bocce.mp4', poster: 'assets/video/bocce.jpg' },
biliardo: { title: 'Come si gioca: il biliardo', src: 'assets/video/biliardo.mp4', poster: 'assets/video/biliardo.jpg' },
},
characters: { characters: {
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale. // [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' }, giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
@@ -204,6 +230,8 @@ export const story = {
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea. // Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' }, fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' }, macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' },
betino: { name: 'il Betino', sprite: 'assets/characters/betino.json' },
ghiacciomobile: { name: 'la Ghiacciomobile', sprite: 'assets/characters/ghiacciomobile.json' },
}, },
items: { items: {
squama: { squama: {
@@ -558,23 +586,13 @@ export const story = {
map: [56, 45], map: [56, 45],
background: 'assets/scenes/pieve-dalla-salita.png', background: 'assets/scenes/pieve-dalla-salita.png',
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea). // Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] }, speakers: { san_pietro: [300, 100] },
// Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre).
people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }], apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra). // La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]], walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
depth: [[215, 0.42], [360, 1.15]], depth: [[215, 0.42], [360, 1.15]],
entry: [30, 320], entry: [30, 320],
hotspots: [ hotspots: [
{
// Battute provvisorie: le vere le scrive Andrea.
id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }],
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
},
},
{ {
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up', id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
actions: { actions: {
@@ -882,7 +900,19 @@ export const story = {
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]], walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
depth: [[292, 0.78], [360, 1.1]], depth: [[292, 0.78], [360, 1.1]],
entry: [320, 345], entry: [320, 345],
// Fra Peppino da Gricignano (sprite di Codex dalla foto di Andrea, 25 ottobre): stava sul sagrato della Pieve,
// ma è un francescano, e il Bosco ai Frati è il loro convento (Andrea, 26 ottobre).
speakers: { fra_peppino: [470, 238] },
people: [{ who: 'fra_peppino', at: [470, 304], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
hotspots: [ hotspots: [
{
// Battute provvisorie: le vere le scrive Andrea.
id: 'fra_peppino', name: 'Fra Peppino', box: [458, 248, 26, 58], at: [430, 306], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.' }],
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
},
},
{ {
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up', id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
actions: { actions: {
@@ -1507,8 +1537,31 @@ export const story = {
entry: [320, 330], entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre). // L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] }, speakers: { arcu: [418, 214] },
people: [{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }], people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk.
{ who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] },
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
],
hotspots: [ hotspots: [
{
id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
// Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
},
},
{
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
actions: {
look: [{ say: 'La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
talk: [{ dialogue: 'ghiacciomobile' }],
use: [{ say: 'Con questo sole, di ghiaccio c\'è rimasto giusto il nome. Vo a piedi.' }],
},
},
{ {
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up', id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
actions: { actions: {
@@ -2140,11 +2193,24 @@ export const story = {
}, },
}, },
dialogues: { dialogues: {
// Andrea parla alla Ghiacciomobile (battute di Andrea, 26 ottobre): la macchina resta zitta, e va bene così.
ghiacciomobile: {
nodes: {
start: { options: [
// La scelta la dice già Andrea (game.choose): dopo, solo il silenzio della macchina.
{ text: 'Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?', then: [{ say: '...' }] },
{ text: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri.', say: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.', then: [{ say: '...' }] },
{ text: 'Riposa, Ghiacciomobile.', next: 'end' },
] },
},
},
giocatore_carte: { giocatore_carte: {
nodes: { nodes: {
start: { options: [ start: { options: [
{ text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] }, { text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] }, { text: 'Rivincita a briscola?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
{ text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] },
{ text: 'Ora cerco il palio. A dopo.', next: 'end' }, { text: 'Ora cerco il palio. A dopo.', next: 'end' },
] }, ] },
// Il mazzo (idea di Andrea): le toscane di casa, o le napoletane. Stesse regole, cambiano i disegni. // Il mazzo (idea di Andrea): le toscane di casa, o le napoletane. Stesse regole, cambiano i disegni.
@@ -2159,6 +2225,30 @@ export const story = {
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] }, { text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] },
{ text: 'Ci ripenso.', next: 'end' }, { text: 'Ci ripenso.', next: 'end' },
] }, ] },
// La scopa: stesse scelte (e stesse battute) della briscola.
mazzo_scopa: { options: [
{ text: 'Con le toscane.', next: 'livello_scopa', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
{ text: 'Con le napoletane.', next: 'livello_scopa', then: [{ set: 'carte_napoletane' }, { say: 'Le napoletane? Le tengo per i villeggianti. Come la vuoi?', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'Ci ripenso.', next: 'end' },
] },
livello_scopa: { options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'scopa', level: 'male' }] },
{ text: 'Ci ripenso.', next: 'end' },
] },
// Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro.
mazzo_tressette: { options: [
{ text: 'Con le toscane.', next: 'livello_tressette', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
{ text: 'Con le napoletane.', next: 'livello_tressette', then: [{ set: 'carte_napoletane' }, { say: 'Le napoletane? Le tengo per i villeggianti. Come la vuoi?', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'Ci ripenso.', next: 'end' },
] },
livello_tressette: { options: [
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'tressette', level: 'bona' }] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'tressette', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'tressette', level: 'male' }] },
{ text: 'Ci ripenso.', next: 'end' },
] },
}, },
}, },
// Il chiosco della Tosca: le bibite si bevono subito; ghiacciolo e cedrata si portano via (una volta per tipo). // Il chiosco della Tosca: le bibite si bevono subito; ghiacciolo e cedrata si portano via (una volta per tipo).
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.12'; export const VERSION = '0.1.16';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+34 -11
View File
@@ -26,12 +26,23 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; } #scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; } #scene.bocce { touch-action: none; }
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
#scopa-status, #tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; }
.card-buttons, .card-hand-many { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.card-buttons button, .card-hand-many button { flex: 1 1 18%; overflow-wrap: anywhere; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; }
.card-buttons button { flex-basis: 22%; }
.card-buttons button[aria-pressed="true"], .card-hand-many button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; }
.card-buttons button:disabled, .card-hand-many button:disabled { opacity: .5; cursor: default; }
#scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; }
#scopa-controls p, #tressette-controls p { max-width: 76em; }
#scopa-next { width: 100%; min-height: 40px; margin-bottom: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; cursor: pointer; }
.card-hand + .card-buttons { margin-top: 8px; }
#briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } #briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; } #briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
.briscola-hand { display: flex; gap: 8px; } .card-hand { display: flex; gap: 8px; }
.briscola-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; } .card-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; }
.briscola-hand button[aria-pressed="true"] { border-color: #ffdb76; } .card-hand button[aria-pressed="true"] { border-color: #ffdb76; }
.briscola-hand button:disabled { opacity: .5; cursor: default; } .card-hand button:disabled { opacity: .5; cursor: default; }
#briscola-controls summary { cursor: pointer; padding: 10px 0; } #briscola-controls summary { cursor: pointer; padding: 10px 0; }
#briscola-controls p { max-width: 76em; } #briscola-controls p { max-width: 76em; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; } #bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
@@ -102,8 +113,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; } #inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; } #inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
#choices { margin: 0; padding: 10px 14px 12px 2.4em; background: var(--panel); min-height: 9.5em; } /* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
#choices li { padding: .25em 0; font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; color: var(--dim); cursor: pointer; } #choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; } #choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em; .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
@@ -152,6 +164,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#splash .splash-name { font-size: clamp(30px, 9vw, 88px); } #splash .splash-name { font-size: clamp(30px, 9vw, 88px); }
#splash .splash-art { font-size: clamp(20px, 5vw, 50px); } #splash .splash-art { font-size: clamp(20px, 5vw, 50px); }
#splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); } #splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); }
/* Compare solo se il browser aspetta un gesto per il suono (#splash.wait, main.js). */
#splash .splash-hint { display: none; margin-top: 2em; font-size: clamp(9px, 1.6vw, 14px); color: var(--dim); animation: blink 1.6s steps(1) infinite; }
#splash.wait .splash-hint { display: block; }
@keyframes blink { 50% { opacity: .25; } }
#title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); } #title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); }
#title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); } #title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); }
#intro-text { font-size: max(11px, calc(var(--u) * 2)); } #intro-text { font-size: max(11px, calc(var(--u) * 2)); }
@@ -189,6 +205,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em; #map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; } color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); } #pause { z-index: 3; background: rgba(15, 10, 20, .9); }
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
#tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
.tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; }
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); } .pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; } #pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); } .load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
@@ -268,7 +288,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } } @media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti, /* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
la spiegazione e la versione non finiscono sopra i verbi. */ la spiegazione e la versione non finiscono sopra i verbi. */
@media (orientation: portrait) and (max-width: 700px) { #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } } @media (orientation: portrait) and (max-width: 700px) {
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */ /* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) { @media (max-width: 600px) {
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; } #panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
@@ -282,15 +303,17 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; } #stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#briscola-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } #briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
.briscola-hand { flex-direction: column; gap: 4px; } /* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
.card-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; } #sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */ /* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
#speech { max-width: 72%; font-size: 11px; } #speech { max-width: 72%; font-size: 11px; }
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; } #panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; } #inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
#verbs button, #inventory button, #choices li { font-size: 10.08px; } #verbs button, #inventory button, #choices { font-size: 10.08px; }
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della /* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */ colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); } #verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -306,7 +329,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#map-board button { width: 68px; } #map-board button { width: 68px; }
#map-board button img { width: 60px; } #map-board button img { width: 60px; }
#pause .hint { font-size: 8px; } #pause .hint { font-size: 8px; }
#choices { min-height: 0; padding: 6px 8px 6px 2em; } #choices { min-height: 0; padding: 6px 8px 6px 3.6em; }
} }
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; } #error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
+1 -1
View File
@@ -11,7 +11,7 @@ mkdirSync(out, { recursive: true });
const server = await staticServer(), port = server.port; const server = await staticServer(), port = server.port;
let browser; let browser;
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 640, height: 360 } }); const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+1 -1
View File
@@ -64,7 +64,7 @@ async function checkArtwork(page, name) {
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [ for (const [name, mobile, viewport] of [
['desktop', false, { width: 1280, height: 860 }], ['desktop', false, { width: 1280, height: 860 }],
['touch', true, { width: 844, height: 390 }], ['touch', true, { width: 844, height: 390 }],
+18 -2
View File
@@ -51,7 +51,7 @@ async function artwork(page) {
for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64')); for (const [name, png] of Object.entries(pictures)) writeFileSync(`${out}/briscola-${name}.png`, Buffer.from(png, 'base64'));
} }
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [ const scenarios = ['mouse', 'touch', 'keyboard'].flatMap(mode => [
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } }, { name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } }, { name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
@@ -89,6 +89,22 @@ try {
game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) }; game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) };
}, deck); }, deck);
await page.waitForSelector('#briscola-controls:not([hidden])'); await page.waitForSelector('#briscola-controls:not([hidden])');
// Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio.
await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3');
if (name === 'desktop-mouse') {
// Il video «Come si gioca»: dall'aiuto del tavolo si apre il player, il gioco si ferma, Esc torna al tavolo.
await page.click('#briscola-controls summary');
await page.click('#briscola-controls .tutorial-open');
await page.waitForSelector('#tutorial:not([hidden])');
assert.match(await page.locator('#tutorial-video').getAttribute('src'), /assets\/video\/briscola\.mp4\?v=/);
assert.equal(await page.locator('#tutorial-title').textContent(), 'Come si gioca: la briscola');
assert.equal(await page.evaluate(() => music.hold), true, 'la musica del gioco tace');
await page.screenshot({ path: `${out}/briscola-video.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#tutorial'));
assert.equal(await page.evaluate(() => !music.hold && game.closeup?.id), 'briscola', 'si torna al tavolo');
await page.click('#briscola-controls summary');
}
assert.ok(await page.isHidden('#bocce-controls')); assert.ok(await page.isHidden('#bocce-controls'));
const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox(); const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo'); assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
@@ -160,7 +176,7 @@ try {
await page.evaluate(async () => { await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game); const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
}); });
await page.locator('#choices li', { hasText: 'La rivincita?' }).click(); await page.locator('#choices li', { hasText: 'Rivincita a briscola?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Con le toscane.' }).click(); await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+25 -3
View File
@@ -39,7 +39,7 @@ test('nessun obbligo di seme; la presa resta sul tavolo, chi prende pesca prima
assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1)); assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1));
assert.equal(c.step, 'trick'); assert.equal(c.last.winner, 'player'); assert.equal(c.table.length, 2); assert.equal(c.step, 'trick'); assert.equal(c.last.winner, 'player'); assert.equal(c.table.length, 2);
update(c, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length); update(c, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length);
update(c, 0.26); update(c, 0.41); // TRICK_TIME: 1,4 s, la presa vola sulle prese
assert.equal(c.hands.player.at(-1).id, stock[0].id); assert.equal(c.hands.player.at(-1).id, stock[0].id);
assert.equal(c.hands.cpu.at(-1).id, stock[1].id); assert.equal(c.hands.cpu.at(-1).id, stock[1].id);
assert.equal(c.current, 'player'); assert.equal(c.table.length, 0); assert.equal(c.current, 'player'); assert.equal(c.table.length, 0);
@@ -60,7 +60,7 @@ test('l’ultima pescata è la briscola, poi tre prese senza mazzo; totale e con
} }
assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20); assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20);
assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0); assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0);
assert.equal(c.result, undefined, 'risultato solo dopo il conteggio'); assert.equal(c.result.player + c.result.cpu, 120, 'il risultato è già deciso: il conteggio lo mostra soltanto');
assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40); assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40);
assert.deepEqual(counted(c), { player: 0, cpu: 0 }); assert.deepEqual(counted(c), { player: 0, cpu: 0 });
update(c, 0.36); assert.equal(c.count, 1); update(c, 0.36); assert.equal(c.count, 1);
@@ -165,7 +165,7 @@ test('oratorio: dialogo, livelli, abbandono, risultati, rivincita e salvataggio
g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win'); g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit'); assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
g.act('talk', 'giocatore_carte'); settle(g); g.act('talk', 'giocatore_carte'); settle(g);
assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'La rivincita?'); assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'Rivincita a briscola?');
assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press); assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
} }
}); });
@@ -181,3 +181,25 @@ test('due mazzi, stesse regole: le napoletane cambiano solo i nomi', () => {
// L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane. // L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane.
assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit))); assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit)));
}); });
// Codex (revisione dei tre giochi): l'esito vale appena è deciso. Uscire durante il conteggio della briscola non lo
// perde, e i flag finiscono nel salvataggio anche se dal risultato si torna al titolo senza uscire dal tavolo.
test('carte all\'oratorio: l\'esito si salva appena deciso, e uscire durante il conteggio non lo perde', () => {
const games = [['Una briscola?', 'briscola', 'briscola_vinta'], ['Un tressette ai 21?', 'tressette', 'tressette_vinto'], ['Una scopa?', 'scopa', 'scopa_vinta']];
for (const [ask, id, flag] of games) {
const saved = [], g = new Game(story, { onSave: s => saved.push(s) });
settle(g); g.go('oratorio'); settle(g);
g.act('talk', 'giocatore_carte'); settle(g);
for (const text of [ask, 'Con le toscane.', 'Seria.']) { g.choose(g.options().findIndex(o => o.text === text)); settle(g); }
assert.equal(g.closeup?.id, id);
// La briscola durante il conteggio: il risultato c'è già. Gli altri due, a risultato.
Object.assign(g.closeup, id === 'briscola' ? { step: 'count', result: { winner: 'player', player: 70, cpu: 50 } } : { step: 'result', result: { winner: 'player' } });
g.update(0.01);
assert.equal(saved.at(-1).flags[flag], true, `${id}: la vittoria è già nel salvataggio`);
assert.equal(g.state.flags[flag], true);
g.closeupClose(); const said = settle(g).map(s => s.text);
const win = story.closeups[id].win.find(e => e.say).say, quit = story.closeups[id].quit[0].say;
assert.ok(said.includes(win) && !said.includes(quit), `${id}: esce con la battuta della vittoria, non con quella di chi lascia`);
assert.equal(g.state.flags[flag], true);
}
});
+27 -1
View File
@@ -11,7 +11,7 @@ import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots'; const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const server = await staticServer(), PORT = server.port; const server = await staticServer(), PORT = server.port;
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = []; const errors = [];
async function open(viewport, query = '') { async function open(viewport, query = '') {
@@ -64,6 +64,23 @@ try {
await page.waitForTimeout(350); await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` }); await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 }); await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
{
const muto = await chromium.launch(), p = await muto.newPage();
await p.goto(`http://127.0.0.1:${PORT}/`);
await p.waitForTimeout(800);
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
await p.waitForTimeout(5500);
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
await p.click('#splash');
assert.ok(await p.isHidden('#splash .splash-hint'));
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
await muto.close();
}
await page.screenshot({ path: `${OUT}/01-titolo.png` }); await page.screenshot({ path: `${OUT}/01-titolo.png` });
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo'); assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo. // Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
@@ -83,6 +100,9 @@ try {
}); });
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); }); await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`); await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#splash');
await slow.waitForTimeout(300);
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 }); await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => window.titleRevealOpacity); const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`); assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
@@ -387,6 +407,12 @@ try {
await bocce.mouse.up(); await bocce.mouse.up();
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte'); assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 }); await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
await bocce.keyboard.press('Escape'); await bocce.keyboard.press('Escape');
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 }); await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
await bocce.close(); await bocce.close();
+4
View File
@@ -13,6 +13,10 @@ test('il brano giusto per titolo, fine e scena', () => {
assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3'); assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3');
assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3'); assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3');
assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null); assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null);
// Un tavolo da carte aperto ha la sua musica; un primo piano senza musica lascia quella della scena.
const tavoli = { ...s, closeups: { carte: { music: 'c.mp3' }, libro: {} } };
assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'carte' }), 'c.mp3');
assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'libro' }), 'b.mp3');
// La sera ha la sua musica all'aperto; la scena col suo brano e gli interni non cambiano. // La sera ha la sua musica all'aperto; la scena col suo brano e gli interni non cambiano.
const sera = { id: 'sera', music: 's.mp3' }; const sera = { id: 'sera', music: 's.mp3' };
assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'a', phase: sera }), 's.mp3'); assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'a', phase: sera }), 's.mp3');
+153
View File
@@ -0,0 +1,153 @@
// Scopa ai 11 nel browser: carte con mouse, tocco e tastiera, scelta fra più prese; si accelerano solo le attese.
// npm run browser:scopa — /tmp/regolo-shots/scopa-*.png
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = [];
let browser;
async function tap(page, mode, selector) {
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
else await page.click(selector);
}
async function at(page, mode, [px, py]) {
// Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa.
await page.locator('#scene').scrollIntoViewIfNeeded();
const r = await page.locator('#scene').boundingBox(), x = r.x + px / 640 * r.width, y = r.y + py / 360 * r.height;
if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
}
async function playCard(page, mode, index) {
if (mode === 'keyboard') {
await page.keyboard.press(String(index + 1));
assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('Enter');
return;
}
const p = await page.evaluate(async i => {
const { handX, HAND } = await import('/src/engine/scopa-render.js');
return [handX(game.closeup.hands.player.length, i) + 32, HAND.y + 46];
}, index);
await at(page, mode, p);
}
async function wait(page) {
return page.evaluate(async () => {
const { canPlay } = await import('/src/engine/scopa.js');
// Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui.
for (let i = 0; i < 60; i++) {
if (game.speech) game.skip();
else if (canPlay(game.closeup) || ['smazzata', 'result', 'choose'].includes(game.closeup.step)) break;
else game.update(1.4);
}
return game.closeup.step;
});
}
try {
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 } },
{ name: 'desktop-keyboard', mode: 'keyboard', viewport: { width: 1280, height: 860 } },
{ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } },
];
for (const { name, mode, viewport, full } of scenarios) {
const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
page.on('pageerror', e => errors.push(`${name}: ${e.message}`));
page.on('console', e => { if (e.type() === 'error') errors.push(`${name}: ${e.text()}`); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game');
await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); game.go('oratorio'); settle(game); game.act('talk', 'giocatore_carte'); settle(game); });
await page.locator('#choices li', { hasText: 'Una scopa?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
const deck = name.startsWith('telefono') ? 'napoletane' : 'toscane';
await page.locator('#choices li', { hasText: deck === 'napoletane' ? 'Con le napoletane.' : 'Con le toscane.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Seria.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
assert.deepEqual(await page.evaluate(() => [game.closeup.deck, game.closeup.level]), [deck, 'seria']);
await page.waitForSelector('#scopa-controls:not([hidden])');
assert.ok(await page.isHidden('#tressette-controls')); assert.ok(await page.isHidden('#briscola-controls'));
await wait(page);
await page.screenshot({ path: `${out}/scopa-${name}-inizio.png` });
// Una volta per scenario: tavola preparata con due prese possibili per il 7, scelta toccando la tavola,
// con un pulsante o con frecce e Invio.
await page.evaluate(async () => {
// Una partita nuova in cui esce Andrea, così in tavola ci sono proprio quattro carte.
const { newScopa } = await import('/src/engine/scopa.js'), old = game.closeup;
let seed = 1, c;
do c = newScopa(seed++, { level: old.level, deck: old.deck }); while (c.current !== 'player');
const pool = [...c.stock, ...c.table, c.hands.player[0]];
const free = v => pool.splice(pool.findIndex(a => a.value === v), 1)[0];
c.table = [free(3), free(4), free(2), free(5)]; c.hands.player[0] = free(7); c.stock = pool;
game.closeup = { id: old.id, ...c };
});
// La tavola preparata non perde né duplica carte.
const ids = await page.evaluate(() => { const c = game.closeup; return [...c.stock, ...c.table, ...c.hands.player, ...c.hands.cpu, ...c.taken.player, ...c.taken.cpu].map(a => a.id); });
assert.equal(new Set(ids).size, 40); assert.equal(ids.length, 40);
await playCard(page, mode, 0);
assert.equal(await page.evaluate(() => game.closeup.step), 'choose');
await page.waitForSelector('#scopa-options:not([hidden])');
await page.screenshot({ path: `${out}/scopa-${name}-scelta.png` });
// Tastiera (Codex): fuoco sulla prima presa, freccia a destra, Invio: deve partire la seconda.
if (mode === 'keyboard') { await page.locator('#scopa-options button:first-child').focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); }
else if (mode === 'touch') {
// Il 5 in tavola sta solo nella presa 2+5.
const p = await page.evaluate(async () => {
const { tableXY } = await import('/src/engine/scopa-render.js'), c = game.closeup;
const i = c.table.findIndex(a => a.value === 5), [x, y] = tableXY(c.table.length, i); return [x + 21, y + 30];
});
// Il primo tocco sceglie la presa che lo contiene (evidenziata), il secondo la conferma.
await at(page, mode, p);
assert.equal(await page.evaluate(() => game.closeup.step === 'choose' && game.closeup.choice.selected), 1);
await at(page, mode, p);
} else await tap(page, mode, '#scopa-options button:nth-child(2)');
assert.equal(await page.evaluate(() => game.closeup.step), 'take');
assert.deepEqual(await page.evaluate(() => game.closeup.last.captured.map(a => a.value).sort()), [2, 5]);
await page.screenshot({ path: `${out}/scopa-${name}-presa.png` });
let played = 1;
for (let steps = 0; steps < 400; steps++) {
const step = await wait(page);
if (step === 'result') break;
if (step === 'smazzata') {
await page.screenshot({ path: `${out}/scopa-${name}-smazzata.png` });
if (!full) break;
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-next');
continue;
}
if (step !== 'play' && step !== 'choose') throw new Error('partita ferma in ' + step);
if (step === 'choose') {
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#scopa-options button:first-child');
continue;
}
const move = await page.evaluate(async () => {
const { observation, choose } = await import('/src/engine/scopa.js');
return choose(observation(game.closeup, 'player'), 'male', 3);
});
const before = await page.evaluate(() => game.closeup.hands.player.length);
await playCard(page, mode, move.index); played++;
const after = await page.evaluate(() => game.closeup.step === 'choose' ? -1 : game.closeup.hands.player.length);
// Il gioco intanto va avanti da solo: dopo l'ultima carta la mano può essere già ridata.
assert.ok(after === -1 || after === before - 1 || (before === 1 && after === 3), `un gesto gioca una carta (${name}: ${before} → ${after})`);
}
const count = await page.evaluate(() => game.closeup.summary ? game.closeup.summary.rows : null);
assert.ok(count, 'conteggio della smazzata');
if (full) {
assert.equal(await page.evaluate(() => game.closeup.step), 'result');
await page.screenshot({ path: `${out}/scopa-${name}-risultato.png` });
const winner = await page.evaluate(() => game.closeup.result.winner);
await page.keyboard.press('Enter');
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
assert.equal(await page.evaluate(() => game.closeup), null);
assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), winner === 'player');
} else {
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => game.closeup), null);
assert.equal(await page.evaluate(() => !!game.state.flags.scopa_vinta), false);
}
console.log(`${name}: ${played} carte giocate, presa scelta fra due${full ? ', partita ai 11 finita' : ', una smazzata'} OK`);
await page.close();
}
assert.deepEqual(errors, []); console.log(`Schermate: ${out}/scopa-*.png`);
} finally { await browser?.close(); server.kill(); }
+125
View File
@@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DECK_CARDS, COIN, newScopa, captures, primiera, tally, play, take, cancel, select, nudge, update, advance, finished, observation, unseen, choose, canPlay, status, TARGET } from '../src/engine/scopa.js';
import { simulate, calibrate } from '../tools/calibrate_scopa.mjs';
const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank);
test('valori: Asso 1, numeri, gobbo 8, donna 9, regio 10; primiera 7 = 21, 6 = 18, Asso 16 … figure 10', () => {
assert.deepEqual(['Asso', '5', 'Gobbo', 'Donna', 'Regio'].map(r => card('cuori', r).value), [1, 5, 8, 9, 10]);
assert.deepEqual(['7', '6', 'Asso', '5', '4', '3', '2', 'Regio'].map(r => card('fiori', r).prime), [21, 18, 16, 15, 14, 13, 12, 10]);
assert.equal(COIN.toscane, 'mattoni'); assert.equal(COIN.napoletane, 'denari');
});
test('prese: la carta uguale ha la precedenza; se no ogni somma; più carte uguali, si sceglie', () => {
const t = [card('cuori', '3'), card('fiori', '4'), card('picche', '7'), card('mattoni', '2'), card('cuori', '5')];
assert.deepEqual(captures(t, card('mattoni', '7')).map(o => o.map(a => a.id)), [[card('picche', '7').id]], 'col 7 si prende il 7, non 3+4 o 2+5');
const sums = captures([card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')], card('picche', '7'));
assert.equal(sums.length, 2, '3+4 oppure 2+5');
assert.deepEqual(captures([card('cuori', '3'), card('fiori', '3')], card('picche', '3')).length, 2);
assert.deepEqual(captures([card('cuori', '3')], card('picche', 'Donna')), []);
});
test('primiera: la migliore per seme, e senza un seme non si fa', () => {
const all = [card('cuori', '7'), card('mattoni', '6'), card('fiori', 'Asso'), card('picche', 'Regio'), card('picche', '2')];
assert.equal(primiera(all, 'toscane'), 21 + 18 + 16 + 12);
assert.equal(primiera(all.slice(0, 3), 'toscane'), 0);
});
test('conteggio: carte, mattoni, settebello, primiera e scope; pari non si dà il punto', () => {
const deck = DECK_CARDS.toscane, mine = deck.filter(a => a.suit === 'mattoni' || (a.suit === 'cuori' && a.value <= 2));
const theirs = deck.filter(a => !mine.includes(a));
const t = tally({ player: mine, cpu: theirs }, { player: 2, cpu: 0 }, 'toscane');
const row = k => t.rows.find(r => r.key === k);
assert.equal(row('carte').winner, 'cpu'); assert.equal(row('coin').winner, 'player'); assert.equal(row('coin').label, 'Mattoni');
assert.equal(row('settebello').winner, 'player'); assert.equal(row('primiera').winner, null, 'a nessuno ci sono tutti e quattro i semi');
assert.deepEqual(t.points, { player: 4, cpu: 1 });
const half = tally({ player: deck.slice(0, 20), cpu: deck.slice(20) }, { player: 0, cpu: 0 }, 'napoletane');
assert.equal(half.rows[0].winner, null, '20 a 20: le carte non le prende nessuno');
assert.equal(tally({ player: [card('denari', '7', 'napoletane')], cpu: [] }, { player: 0, cpu: 0 }, 'napoletane').rows[2].winner, 'player');
});
test('smazzata: 4 in tavola (mai tre re), tre a testa, la presa è obbligatoria, la scelta fra più prese', () => {
for (let s = 1; s < 60; s++) assert.ok(newScopa(s).table.filter(a => a.value === 10).length < 3);
const c = newScopa(3); c.current = 'player';
assert.equal(c.table.length, 4); assert.equal(c.hands.player.length, 3); assert.equal(c.stock.length, 30);
c.table = [card('cuori', '3'), card('fiori', '4'), card('mattoni', '2'), card('cuori', '5')];
c.hands.player = [card('picche', '7'), card('picche', 'Regio')];
assert.ok(play(c, 0)); assert.equal(c.step, 'choose'); assert.equal(c.choice.options.length, 2);
assert.ok(nudge(c, 1)); assert.ok(cancel(c)); assert.equal(c.step, 'play'); assert.equal(c.hands.player.length, 2);
assert.ok(play(c, 0)); assert.ok(select(c, 1)); assert.ok(take(c));
assert.equal(c.step, 'take'); assert.equal(c.table.length, 2); assert.equal(c.taken.player.length, 3);
});
test('scopa: chi pulisce la tavola fa un punto, ma non con l\'ultima carta della smazzata', () => {
const c = newScopa(4); c.current = 'player'; c.table = [card('cuori', '3'), card('fiori', '4')];
c.hands.player = [card('picche', '7'), card('picche', '2')];
assert.ok(play(c, 0)); assert.equal(c.scope.player, 1); assert.equal(c.last.scopa, true); assert.match(status(c), /Scopa di Andrea/);
const d = newScopa(4); d.current = 'player'; d.stock = []; d.hands.cpu = []; d.table = [card('cuori', '3')];
d.hands.player = [card('picche', '3')];
assert.ok(play(d, 0)); assert.equal(d.scope.player, 0, 'all\'ultima non è scopa');
});
test('a fine smazzata le carte in tavola vanno all\'ultimo che ha preso; tutte le 40 carte si contano', () => {
const c = newScopa(5, { level: 'seria' });
while (c.step !== 'smazzata') {
if (canPlay(c)) { const m = choose(observation(c, 'player'), 'seria'); assert.ok(play(c, m.index, 'player', m.option)); }
update(c, 20);
}
assert.equal(c.taken.player.length + c.taken.cpu.length, 40);
assert.equal(c.table.length, 0);
const before = c.dealer;
if (!finished(c)) { advance(c); assert.notEqual(c.dealer, before); assert.equal(c.current, before, 'gioca per primo chi non fa le carte'); }
});
test('si va agli 11: a fine smazzata vince chi ne ha di più; pari, un\'altra; il risultato è subito definitivo', () => {
const c = newScopa(6); c.stock = []; c.hands = { player: [], cpu: [card('cuori', '4')] };
c.table = [card('fiori', '4')]; c.current = 'cpu'; c.score = { player: 9, cpu: 10 };
c.taken = { player: DECK_CARDS.toscane.filter(a => a.suit !== 'fiori' || a.value !== 4).slice(0, 30), cpu: [] };
assert.ok(play(c, 0, 'cpu')); update(c, 2);
assert.equal(c.step, 'result', 'senza passare dal riepilogo: uscire non cancella la vittoria');
assert.equal(c.result.winner, 'player');
const d = newScopa(6); d.step = 'smazzata'; assert.ok(advance(d)); assert.equal(d.step, 'play');
assert.ok(finished({ score: { player: TARGET, cpu: 9 } })); assert.ok(!finished({ score: { player: 12, cpu: 12 } }));
});
test('partite deterministiche col seme; l\'avversario vede solo la sua mano, la tavola e le prese', () => {
assert.deepEqual(simulate(31, 'male').score, simulate(31, 'male').score);
const c = newScopa(12), v = observation(c, 'cpu');
assert.equal(v.hands, undefined); assert.equal(v.stock, 30);
assert.equal(unseen(v).length, 33, 'mano dell\'altro e mazzo');
const other = structuredClone(c); other.hands.player.reverse(); other.stock.reverse();
assert.deepEqual(choose(observation(c, 'cpu'), 'male', 3), choose(observation(other, 'cpu'), 'male', 3));
});
test('l\'avversario prende il settebello e la scopa quando può, e non lascia scope facili', () => {
const v = { who: 'cpu', deck: 'toscane', hand: [card('cuori', '7'), card('fiori', 'Regio')], table: [card('mattoni', '7'), card('picche', '4')], taken: { player: [], cpu: [] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } };
const m = choose(v, 'seria'); assert.equal(v.hand[m.index].rank, '7', 'prende il settebello');
const w = { ...v, hand: [card('cuori', '7'), card('fiori', '4')], table: [card('picche', '3'), card('mattoni', '4')] };
const s = choose(w, 'male'); assert.equal(w.hand[s.index].rank, '7', 'scopa con 3+4');
const x = { ...v, hand: [card('cuori', '5'), card('fiori', 'Regio')], table: [card('picche', '2')] };
const d = choose(x, 'male'); assert.equal(x.hand[d.index].rank, 'Regio', 'col 5 lascerebbe 7 in tavola: meglio il regio (12)');
});
test('la primiera conta quel che migliora: col 6 si prende quello del seme dove si ha solo una figura (Codex)', () => {
const v = { who: 'cpu', deck: 'toscane', hand: [card('picche', '6')], table: [card('cuori', '6'), card('fiori', '6')],
taken: { player: [], cpu: [card('cuori', '7'), card('fiori', 'Regio')] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } };
for (const level of ['seria', 'male']) assert.equal(choose(v, level).captured[0].suit, 'fiori', level);
});
test('a mazzo finito «A fassi male» calcola il finale esatto: meglio perdere il settebello che regalare anche la scopa', () => {
const deck = DECK_CARDS.toscane, seen = [card('mattoni', '7'), card('cuori', '7'), card('fiori', '4'), card('picche', '3')];
// Andrea ha in mano l'ultimo 7. Posando il 4, Andrea prende 3+4 e fa scopa, e il settebello gli resta comunque;
// posando il settebello, Andrea lo prende col 7 ma niente scopa.
const v = { who: 'cpu', deck: 'toscane', hand: [card('fiori', '4'), card('mattoni', '7')], table: [card('picche', '3')], stock: 0,
taken: { player: deck.filter(a => !seen.includes(a)).slice(0, 18), cpu: deck.filter(a => !seen.includes(a)).slice(18) },
opponentCount: 1, scope: { player: 0, cpu: 0 }, lastTaker: 'player' };
assert.deepEqual(unseen(v).map(a => a.id), [card('cuori', '7').id], 'le carte dell\'altro sono quelle non viste');
assert.equal(v.hand[choose(v, 'male').index].rank, '7');
});
test('taratura: Alla bona < Seria < A fassi male', () => {
const runs = calibrate(60);
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
assert.ok(runs[1].playerPercent > runs[2].playerPercent, JSON.stringify(runs));
});
+27 -1
View File
@@ -182,7 +182,7 @@ test('i personaggi in scena hanno il foglio e tutte le pose che usano', () => {
for (const [id, scene] of Object.entries(story.scenes)) for (const [id, scene] of Object.entries(story.scenes))
for (const p of scene.people ?? []) { for (const p of scene.people ?? []) {
assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`); assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`);
assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`); if ((p.talk ?? ['talk']).length) assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`);
const f = frames(p.who); const f = frames(p.who);
for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`); for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`);
} }
@@ -606,6 +606,32 @@ test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.')); assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
}); });
// Il favicon c'era in pagina ma non nell'immagine Docker (404 online, 26 ottobre): ogni cartella di assets che il gioco
// usa deve essere fra quelle copiate, e ogni video «Come si gioca» deve esistere.
test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', async () => {
const { readdirSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const copied = new Set(readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY assets\/(\S+)\s/gm).map(l => l.split(' ')[1].slice(7)));
const files = ['index.html', 'style.css', ...readdirSync(new URL('src/', root), { recursive: true }).filter(f => f.endsWith('.js')).map(f => 'src/' + f)];
const used = new Set(files.flatMap(f => [...readFileSync(new URL(f, root), 'utf8').matchAll(/assets\/([a-z0-9_-]+)\//g)].map(m => m[1])));
// raw e generated sono cartelle di lavoro, citate solo nei commenti.
assert.deepEqual([...used].filter(d => !copied.has(d) && !['raw', 'generated'].includes(d)).sort(), []);
for (const t of Object.values(story.tutorials)) for (const f of [t.src, t.poster]) assert.ok(existsSync(new URL(f, root)), f);
});
// La foto del palio e i video arrivavano al browser come application/octet-stream (26 ottobre): ogni estensione
// servita deve avere il suo tipo in nginx.conf, sennò nginx (con i soli tipi dichiarati) non sa cos'è.
test('ogni estensione dei file serviti ha il suo tipo MIME in nginx.conf', async () => {
const { readdirSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const types = readFileSync(new URL('nginx.conf', root), 'utf8').match(/types \{([^}]*)\}/)[1];
const known = new Set(types.split(';').flatMap(l => l.trim().split(/\s+/).slice(1)));
const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]);
const ext = new Set(['html', 'css']);
for (const d of dirs) for (const f of readdirSync(new URL(d + '/', root), { recursive: true })) if (/\.\w+$/.test(f)) ext.add(f.split('.').pop());
assert.deepEqual([...ext].filter(e => !known.has(e) && e !== 'txt').sort(), [], 'estensioni senza tipo in nginx.conf');
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => { test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs'); const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url); const root = new URL('../', import.meta.url);
+141
View File
@@ -0,0 +1,141 @@
// Tressette ai 21 nel browser: carte e segni con mouse, tocco e tastiera; si accelerano solo le attese.
// npm run browser:tressette — /tmp/regolo-shots/tressette-*.png
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = [];
let browser;
async function tap(page, mode, selector) {
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
else await page.click(selector);
}
async function playCard(page, mode, index, signal) {
if (signal) {
if (mode === 'keyboard') await page.keyboard.press(signal[0]);
else await tap(page, mode, `#tressette-${signal}`);
assert.equal(await page.evaluate(() => game.closeup.signal), signal);
}
if (mode === 'keyboard') {
await page.keyboard.press(String((index + 1) % 10));
assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('Enter');
return;
}
const at = await page.evaluate(async i => {
const { handX, CARD } = await import('/src/engine/tressette-render.js');
return [handX(game.closeup.hands[0].length, i) + 21, CARD.y + 30];
}, index);
// Sul telefono i pulsanti stanno sotto il tavolo: dopo averli toccati la pagina può essere scesa.
await page.locator('#scene').scrollIntoViewIfNeeded();
const r = await page.locator('#scene').boundingBox(), x = r.x + at[0] / 640 * r.width, y = r.y + at[1] / 360 * r.height;
if (mode === 'touch') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y);
}
async function wait(page) {
return page.evaluate(async () => {
const { canPlay } = await import('/src/engine/tressette.js');
// Un tocco mentre qualcuno parla salta la battuta, non gioca: la battuta si salta qui.
for (let i = 0; i < 60; i++) {
if (game.speech) game.skip();
else if (canPlay(game.closeup) || ['smazzata', 'result'].includes(game.closeup.step)) break;
else game.update(1.3);
}
return game.closeup.step;
});
}
try {
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 } },
{ name: 'desktop-keyboard', mode: 'keyboard', viewport: { width: 1280, height: 860 } },
{ name: 'orizzontale-touch', mode: 'touch', viewport: { width: 844, height: 390 } },
];
for (const { name, mode, viewport, full } of scenarios) {
const page = await browser.newPage({ viewport, hasTouch: true, isMobile: viewport.width < 900 });
page.on('pageerror', e => errors.push(`${name}: ${e.message}`));
page.on('console', e => { if (e.type() === 'error') errors.push(`${name}: ${e.text()}`); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game');
await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); game.go('oratorio'); settle(game); game.act('talk', 'giocatore_carte'); settle(game); });
await page.locator('#choices li', { hasText: 'Un tressette ai 21?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
const deck = name.startsWith('telefono') ? 'napoletane' : 'toscane';
await page.locator('#choices li', { hasText: deck === 'napoletane' ? 'Con le napoletane.' : 'Con le toscane.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Seria.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
assert.deepEqual(await page.evaluate(() => [game.closeup.deck, game.closeup.level]), [deck, 'seria']);
await page.waitForSelector('#tressette-controls:not([hidden])');
assert.ok(await page.isHidden('#briscola-controls')); assert.ok(await page.isHidden('#bocce-controls'));
const scene = await page.locator('#scene').boundingBox();
assert.ok(scene.width * 42 / 640 >= 18, 'carte toccabili anche sul telefono');
await wait(page);
await page.screenshot({ path: `${out}/tressette-${name}-inizio.png` });
// Una carta che non risponde al seme non parte: resta in mano e il motivo si legge.
let refusedChecked = false, signals = 0, played = 0, shotSignal = false;
for (let steps = 0; steps < 400; steps++) {
const step = await wait(page);
if (step === 'result') break;
if (step === 'smazzata') {
await page.screenshot({ path: `${out}/tressette-${name}-smazzata.png` });
if (!full) break;
if (mode === 'keyboard') await page.keyboard.press('Enter'); else await tap(page, mode, '#tressette-next');
continue;
}
if (!refusedChecked) {
const bad = await page.evaluate(async () => {
const { legal } = await import('/src/engine/tressette.js');
const c = game.closeup, ok = legal(c.hands[0], c.table);
return c.hands[0].findIndex((_, i) => !ok.includes(i));
});
if (bad >= 0) {
const held = await page.evaluate(() => game.closeup.hands[0].length);
// Tastiera (Codex): fuoco su una carta buona, poi si sceglie col numero quella che non risponde e
// Invio. Non deve partire la carta che aveva il fuoco.
if (mode === 'keyboard') {
const good = await page.evaluate(async () => (await import('/src/engine/tressette.js')).legal(game.closeup.hands[0], game.closeup.table)[0]);
await page.locator(`#tressette-card-${good}`).focus();
}
await playCard(page, mode, bad);
assert.match(await page.evaluate(() => game.closeup.refused), /Devi rispondere/);
await page.waitForFunction(() => /Devi rispondere/.test(document.getElementById('tressette-status').textContent));
assert.equal(await page.evaluate(() => game.closeup.hands[0].length), held);
refusedChecked = true;
}
}
const move = await page.evaluate(async () => {
const { observation, choose } = await import('/src/engine/tressette.js');
return choose(observation(game.closeup, 0), 'male', 7);
});
const before = await page.evaluate(() => game.closeup.hands[0].length);
await playCard(page, mode, move.index, move.signal); played++;
if (move.signal) {
signals++;
assert.equal(await page.evaluate(() => game.closeup.table.find(p => p.who === 0)?.signal), move.signal);
if (!shotSignal) { await page.waitForTimeout(60); await page.screenshot({ path: `${out}/tressette-${name}-segno.png` }); shotSignal = true; }
}
assert.equal(await page.evaluate(() => game.closeup.hands[0].length), before - 1, 'un gesto gioca una carta');
if (played === 1) { await page.evaluate(() => game.update(0.8)); await page.screenshot({ path: `${out}/tressette-${name}-presa.png` }); }
}
if (full) {
assert.equal(await page.evaluate(() => game.closeup.step), 'result');
await page.screenshot({ path: `${out}/tressette-${name}-risultato.png` });
const winner = await page.evaluate(() => game.closeup.result.winner);
await page.keyboard.press('Enter');
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
assert.equal(await page.evaluate(() => game.closeup), null);
assert.equal(await page.evaluate(() => !!game.state.flags.tressette_vinto), winner === 'player');
} else {
await page.keyboard.press('Escape');
assert.equal(await page.evaluate(() => game.closeup), null);
assert.equal(await page.evaluate(() => !!game.state.flags.tressette_vinto), false, 'chi lascia non vince');
}
console.log(`${name}: ${played} carte giocate (${signals} con un segno)${full ? ', partita ai 21 finita' : ', una smazzata'} OK`);
await page.close();
}
assert.deepEqual(errors, []); console.log(`Schermate: ${out}/tressette-*.png`);
} finally { await browser?.close(); server.kill(); }
+174
View File
@@ -0,0 +1,174 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DECK_CARDS, newTressette, accuse, accuseName, legal, best, signalOk, reason, setSignal, play, collect, advance, update, observation, choose, knowledge, canPlay, finished, team, status, TARGET } from '../src/engine/tressette.js';
import { simulate, calibrate } from '../tools/calibrate_tressette.mjs';
const card = (suit, rank, deck = 'toscane') => DECK_CARDS[deck].find(a => a.suit === suit && a.rank === rank);
const hand = list => list.map(([s, r]) => card(s, r));
test('mazzo: 40 carte, 32 terzi di punti, ordine 3 > 2 > Asso > figure > 7 … 4', () => {
for (const cards of Object.values(DECK_CARDS)) {
assert.equal(cards.length, 40); assert.equal(cards.reduce((n, a) => n + a.thirds, 0), 32);
}
const order = ['3', '2', 'Asso', 'Regio', 'Donna', 'Gobbo', '7', '6', '5', '4'];
for (let i = 1; i < order.length; i++) assert.ok(card('cuori', order[i - 1]).power > card('cuori', order[i]).power, order[i]);
assert.equal(card('picche', 'Asso').thirds, 3); assert.equal(card('picche', 'Regio').thirds, 1); assert.equal(card('picche', '7').thirds, 0);
});
test('si risponde al seme; chi non ce l\'ha gioca quel che vuole; prende la più alta del seme di uscita', () => {
const h = hand([['cuori', '4'], ['fiori', '3'], ['cuori', 'Regio']]);
assert.deepEqual(legal(h, []), [0, 1, 2]);
assert.deepEqual(legal(h, [{ who: 1, card: card('cuori', '7') }]), [0, 2]);
assert.deepEqual(legal(h, [{ who: 1, card: card('picche', '7') }]), [0, 1, 2]);
const table = [{ who: 1, card: card('cuori', '4') }, { who: 2, card: card('fiori', '3') }, { who: 3, card: card('cuori', '2') }, { who: 0, card: card('cuori', 'Asso') }];
assert.equal(best(table).who, 3, 'il 3 di fiori non risponde: prende il 2 di cuori');
});
test('accuse: napoletana 3 punti, tre o quattro Assi, 2 o 3 per 3 o 4 punti, anche insieme', () => {
const h = hand([['cuori', 'Asso'], ['cuori', '2'], ['cuori', '3'], ['picche', '3'], ['fiori', '3'], ['mattoni', '7']]);
const a = accuse(h);
assert.deepEqual(a.map(x => [x.kind, x.points]), [['napoletana', 3], ['buongioco', 3]]);
assert.equal(accuse(hand([['cuori', '2'], ['picche', '2'], ['fiori', '2'], ['mattoni', '2']]))[0].points, 4);
assert.deepEqual(accuse(hand([['cuori', 'Asso'], ['cuori', '2'], ['picche', '3']])), []);
// Del tris si dice il seme che manca, così Andrea sa quello che sa l'avversario.
const tris = accuse(hand([['picche', '3'], ['fiori', '3'], ['mattoni', '3']]), ['cuori', 'mattoni', 'fiori', 'picche'])[0];
assert.equal(tris.missing, 'cuori'); assert.equal(accuseName(tris), 'tre 3, manca cuori');
});
test('segni: volo con l\'ultima, striscio se ne resta un\'altra, busso sempre; solo chi esce', () => {
const h = hand([['cuori', '4'], ['cuori', '5'], ['fiori', '7']]);
assert.ok(signalOk(h, 2, [], 'volo')); assert.ok(!signalOk(h, 0, [], 'volo'));
assert.ok(signalOk(h, 0, [], 'striscio')); assert.ok(!signalOk(h, 2, [], 'striscio'));
assert.ok(signalOk(h, 0, [], 'busso')); assert.ok(signalOk(h, 2, [], 'busso'));
assert.ok(!signalOk(h, 0, [{ who: 1, card: card('cuori', '7') }], 'busso'));
const c = newTressette(5); c.current = 0; c.table = []; c.hands[0] = [...h]; c.pending[0] = [];
assert.ok(setSignal(c, 'volo')); assert.equal(play(c, 0), false); assert.match(c.refused, /Volo solo/);
assert.equal(c.hands[0].length, 3, 'la carta resta in mano');
assert.equal(play(c, 0, 0, 'xyz'), false); assert.equal(c.refused, 'Segno non riconosciuto.');
assert.ok(play(c, 2)); assert.equal(c.table[0].signal, 'volo'); assert.equal(c.signal, null);
assert.ok(c.events.some(e => e.kind === 'card-volo'));
});
test('la carta che non risponde al seme si rifiuta con la ragione', () => {
const c = newTressette(9); c.current = 0; c.hands[0] = hand([['cuori', '4'], ['fiori', '3']]);
c.table = [{ who: 3, card: card('cuori', '7') }];
assert.equal(play(c, 1), false); assert.equal(reason(c, 1), 'Devi rispondere a cuori.');
assert.ok(canPlay(c)); assert.ok(play(c, 0));
});
test('una smazzata: accuse dette al proprio turno, 10 prese, sempre 11 punti fra terzi e ultima presa', () => {
const c = newTressette(1234, { level: 'seria' });
assert.deepEqual(c.score, [0, 0]); assert.deepEqual(c.accuse, [], 'prima di giocare nessuno ha accusato');
const owed = c.pending.flat();
assert.equal(c.current, (c.dealer + 1) % 4, 'esce chi sta a destra del mazziere');
assert.ok(c.hands.every(h => h.length === 10));
const firstDealer = c.dealer;
while (!['smazzata', 'result'].includes(c.step)) {
if (canPlay(c)) { const m = choose(observation(c, 0), 'seria'); assert.ok(play(c, m.index, 0, m.signal)); }
update(c, 20);
if (c.tricks.length === 1) assert.equal(c.accuse.length, owed.length, 'dopo la prima presa hanno accusato tutti');
}
if (c.step === 'smazzata') {
assert.equal(c.tricks.length, 10);
assert.equal(c.summary.thirds[0] + c.summary.thirds[1], 35);
assert.equal(c.summary.points[0] + c.summary.points[1], 11, 'i terzi avanzati sommano sempre a due');
assert.deepEqual(c.score, [0, 1].map(t => c.summary.accused[t] + c.summary.points[t]));
assert.ok(advance(c)); assert.equal(c.dealer, (firstDealer + 1) % 4); assert.equal(c.step, 'play');
}
});
test('si va ai 21: chi ci arriva per primo è fuori, anche a metà smazzata, e la vittoria non si perde', () => {
const c = newTressette(3); c.pending = [[], [], [], []];
c.base = [20, 19]; c.score = [20, 19]; c.thirds = [2, 0];
// Una presa da un terzo porta noi a 21 (3 terzi = 1 punto): fuori subito.
c.table = [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('cuori', '4') }, { who: 2, card: card('cuori', '5') }, { who: 3, card: card('cuori', '6') }];
c.step = 'trick'; c.last = { winner: 0, cards: c.table.map(p => p.card) };
assert.ok(collect(c)); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); assert.deepEqual(c.score, [21, 19]);
assert.ok(finished(c)); assert.equal(advance(c), false);
assert.match(status(c), /Avete vinto voi, 21 a 19/);
// Anche le accuse bastano: dette giocando, contano alla fine di quella presa.
const d = newTressette(3); d.base = [0, 19]; d.score = [0, 19]; d.current = 1; d.table = []; d.tricks = [];
d.pending = [[], [{ who: 1, kind: 'napoletana', suit: 'fiori', points: 3, cards: [] }], [], []];
d.hands[1] = [card('fiori', '7'), ...d.hands[1].slice(1)];
assert.ok(play(d, 0, 1)); assert.equal(d.score[1], 22);
assert.equal(d.step, 'result', 'l\'accusa fa uscire subito'); assert.equal(d.result.winner, 'cpu');
// Tutte e due a un passo, tutte e due con un'accusa: esce chi accusa per primo, chiunque sia (Codex, seme 28).
const e = newTressette(28); e.base = [18, 18]; e.score = [18, 18]; e.current = 1; e.table = []; e.tricks = [];
e.pending = [[{ who: 0, kind: 'buongioco', rank: '3', points: 3, cards: [] }], [{ who: 1, kind: 'buongioco', rank: '2', points: 3, cards: [] }], [], []];
assert.ok(play(e, 0, 1)); assert.equal(e.result.winner, 'cpu'); assert.deepEqual(e.score, [18, 21]);
assert.equal(play(e, 0, 2), false, 'a partita finita non si gioca più');
});
test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => {
const a = simulate(77, 'male'), b = simulate(77, 'male');
assert.deepEqual(a.score, b.score); assert.ok(Math.max(...a.score) >= TARGET);
const t = newTressette(42), n = newTressette(42, { deck: 'napoletane' });
assert.deepEqual(t.hands.map(h => h.map(a => a.id)), n.hands.map(h => h.map(a => a.id)));
assert.equal(n.hands[0][0].suit === 'denari' || ['coppe', 'spade', 'bastoni'].includes(n.hands[0][0].suit), true);
});
test('gli altri non vedono le carte altrui: l\'osservazione ha solo la propria mano e il tavolo', () => {
const c = newTressette(11), v = observation(c, 1);
assert.deepEqual(v.hand.map(a => a.id), c.hands[1].map(a => a.id));
assert.equal(v.hands, undefined); assert.equal(v.seed, undefined);
const k = knowledge(v, true);
assert.equal(k.out.length, 30, 'fuori ci sono le 30 carte degli altri tre');
assert.ok(!k.could(1, k.out[0]), 'non può pensare di averle lui');
// Cambiare le mani degli altri non cambia la scelta.
const other = structuredClone(c); other.hands[0].reverse(); other.hands[2].reverse();
c.current = other.current = 1;
assert.deepEqual(choose(observation(c, 1), 'male', 5), choose(observation(other, 1), 'male', 5));
});
test('«A fassi male» conta: sa che il 3 è uscito, e col 2 si fa la presa sicura', () => {
const c = newTressette(8); c.current = 1; c.table = [];
c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]);
c.tricks = [{ leader: 0, winner: 0, cards: [{ who: 0, card: card('cuori', '3') }, { who: 1, card: card('fiori', '5') }, { who: 2, card: card('cuori', '4') }, { who: 3, card: card('cuori', '5') }] }];
assert.equal(choose(observation(c, 1), 'male').index, 0, 'esce col 2, ormai il più alto');
const foes = [0, 2];
assert.equal(knowledge(observation(c, 1), false).beatable(card('cuori', '2'), foes), true, 'chi non conta ha paura del 3');
assert.equal(knowledge(observation(c, 1), true).beatable(card('cuori', '2'), foes), false);
});
test('il socio ritorna il seme a chi ha bussato', () => {
const c = newTressette(8); c.current = 2; c.table = [];
c.hands[2] = hand([['cuori', '4'], ['fiori', '2'], ['cuori', '7']]);
c.tricks = [{ leader: 0, winner: 2, cards: [{ who: 0, card: card('cuori', '5'), signal: 'busso' }, { who: 1, card: card('cuori', '6') }, { who: 2, card: card('cuori', '3') }, { who: 3, card: card('picche', '4') }] }];
const m = choose(observation(c, 2), 'male');
assert.equal(c.hands[2][m.index].suit, 'cuori'); assert.equal(c.hands[2][m.index].rank, '4');
});
test('chi riceve il busso prende con la migliore; il socio non spreca il 3 su una presa già sua', () => {
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 2;
c.table = [{ who: 0, card: card('cuori', '4'), signal: 'busso' }, { who: 1, card: card('cuori', '5') }];
c.hands[2] = hand([['cuori', '2'], ['cuori', '6'], ['fiori', '7']]);
assert.equal(c.hands[2][choose(observation(c, 2), 'male').index].rank, '2', 'al busso risponde col 2');
const d = newTressette(8); d.pending = [[], [], [], []]; d.current = 3;
d.table = [{ who: 0, card: card('fiori', '6') }, { who: 1, card: card('fiori', '2') }, { who: 2, card: card('fiori', '4') }];
d.hands[3] = hand([['fiori', '3'], ['fiori', '5'], ['cuori', '7']]);
// L'avversario del posto 3: il suo socio (1) prende col 2 e lui è l'ultimo; tiene il 3 e dà il 5.
assert.equal(d.hands[3][choose(observation(d, 3), 'male').index].rank, '5');
});
test('se un carico al socio basta per i 21, si dà anche il 2', () => {
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 3;
c.base = [0, 20]; c.score = [0, 20]; c.thirds = [0, 1];
c.table = [{ who: 0, card: card('cuori', '4') }, { who: 1, card: card('cuori', '3') }, { who: 2, card: card('cuori', '5') }];
c.hands[3] = hand([['fiori', '2'], ['picche', '4']]);
assert.equal(c.hands[3][choose(observation(c, 3), 'seria').index].rank, '2');
assert.equal(c.hands[3][choose(observation(c, 3), 'male').index].rank, '2');
});
test('chi vola ha finito il seme: per «A fassi male» non può più avere il 3', () => {
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 1;
c.tricks = [{ leader: 2, winner: 0, cards: [{ who: 2, card: card('cuori', '4'), signal: 'volo' }, { who: 3, card: card('cuori', '5') }, { who: 0, card: card('cuori', '6') }, { who: 1, card: card('cuori', '7') }] }];
c.hands[1] = hand([['cuori', '2'], ['fiori', '4']]); c.table = [];
const k = knowledge(observation(c, 1), true);
assert.equal(k.could(2, card('cuori', '3')), false);
});
test('taratura: Alla bona < Seria < A fassi male, contro Andrea e il socio che giocano attenti', () => {
const runs = calibrate(60);
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
assert.ok(runs[1].playerPercent >= runs[2].playerPercent, JSON.stringify(runs));
});
+39
View File
@@ -0,0 +1,39 @@
// Verifica visiva mirata: node tests/veicoli.browser.mjs
// Usa lo stesso Chromium/SwiftShader di browser.mjs, senza titolo e audio.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('response', r => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); });
await page.route('**/__veicoli', route => route.fulfill({ contentType: 'text/html', body:
'<style>body{margin:0}canvas{display:block;width:1280px;height:720px;image-rendering:pixelated}</style><canvas width="640" height="360"></canvas>' }));
await page.goto(`http://127.0.0.1:${server.port}/__veicoli`);
await page.evaluate(async () => {
const [{ story }, { Game }, renderer] = await Promise.all([
import('/src/story/story.js'), import('/src/engine/game.js'), import('/src/engine/render.js'),
]);
const loaded = await renderer.loadAssets(story, 'campomigliaio_circolo');
if (loaded.some(ok => !ok)) throw new Error('Uno sfondo/personaggio non è arrivato');
const game = new Game(story);
game.go('campomigliaio_circolo');
game.time = 8;
const ctx = document.querySelector('canvas').getContext('2d');
renderer.render(ctx, game, { time: 8000 });
});
assert.deepEqual(errors, []);
await page.screenshot({ path: `${out}/campomigliaio-veicoli.png` });
console.log(`Schermata: ${out}/campomigliaio-veicoli.png`);
} finally {
await browser?.close();
server.kill();
}
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js';
import { settle } from './helpers.mjs';
test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli altri hotspot', () => {
const g = new Game(story);
g.go('campomigliaio_circolo');
settle(g);
const vehicles = ['betino', 'ghiacciomobile'];
for (const id of vehicles) {
const person = g.people().find(p => p.who === id), h = g.hotspot(id);
const url = new URL('../' + story.characters[id].sprite, import.meta.url);
const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle;
assert.ok(existsSync(new URL(atlas.image, url)));
assert.deepEqual(Object.keys(atlas.frames), ['idle']);
const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height;
const width = frame.w * k;
assert.ok(id === 'betino' ? width >= 70 && width <= 80 : width >= 150 && width <= 170);
// Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile.
for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++)
for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++)
assert.ok(!inside(g.scene.walk, [x, y]), `${id}: invade walk in ${x},${y}`);
for (const other of g.hotspots().filter(o => !vehicles.includes(o.id))) {
const [x, y, w, hgt] = h.box, [ox, oy, ow, oh] = other.box;
assert.ok(x + w <= ox || ox + ow <= x || y + hgt <= oy || oy + oh <= y,
`${id}: copre ${other.id}`);
}
for (const time of [0, 3.9, 4.1, 20]) {
g.time = time;
assert.equal(g.personPose(person), 'idle');
}
}
});
test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => {
const g = new Game(story);
g.go('campomigliaio_circolo');
settle(g);
const flags = { ...g.state.flags }, inventory = [...g.state.inventory];
for (const id of ['betino', 'ghiacciomobile']) {
const h = g.hotspot(id);
assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id);
for (const verb of ['look', 'use']) {
assert.ok(g.act(verb, id));
const said = settle(g);
assert.ok(said.length && said.every(s => s.who === 'andrea'));
assert.deepEqual([g.state.x, g.state.y], h.at);
assert.deepEqual(g.state.flags, flags);
assert.deepEqual(g.state.inventory, inventory);
assert.equal(g.state.scene, 'campomigliaio_circolo');
}
}
});
+24
View File
@@ -0,0 +1,24 @@
// node tools/calibrate_scopa.mjs [partite per livello, predefinite 300]
// Stesse carte per i tre livelli; Andrea gioca come «A fassi male», l'avversario al livello scelto.
import { newScopa, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/scopa.js';
export function simulate(seed, level, me = 'male') {
const c = newScopa(seed, { level });
for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) {
if (canPlay(c)) { const m = choose(observation(c, 'player'), me, steps + 1); if (!play(c, m.index, 'player', m.option)) throw new Error('mossa rifiutata'); }
if (!advance(c)) update(c, 20); c.events.length = 0;
}
if (c.step !== 'result') throw new Error('Partita non conclusa');
return c;
}
export function calibrate(n = 300, me = 'male') {
return LEVELS.map(level => {
let wins = 0, rounds = 0;
for (let i = 1; i <= n; i++) {
const c = simulate(Math.imul(i, 2654435761) >>> 0, level, me);
wins += c.result.winner === 'player'; rounds += c.round;
}
return { level, games: n, playerPercent: 100 * wins / n, rounds: rounds / n };
});
}
if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 300), null, 2));
+24
View File
@@ -0,0 +1,24 @@
// node tools/calibrate_tressette.mjs [partite per livello, predefinite 300]
// Stesse carte per i tre livelli; Andrea gioca come il suo socio («A fassi male»), gli avversari al livello scelto.
import { newTressette, observation, choose, play, update, advance, canPlay, LEVELS } from '../src/engine/tressette.js';
export function simulate(seed, level, me = 'male') {
const c = newTressette(seed, { level });
for (let steps = 0; steps < 5000 && c.step !== 'result'; steps++) {
if (canPlay(c)) { const m = choose(observation(c, 0), me, steps + 1); if (!play(c, m.index, 0, m.signal)) throw new Error('mossa rifiutata: ' + c.refused); }
if (!advance(c)) update(c, 20); c.events.length = 0;
}
if (c.step !== 'result') throw new Error('Partita non conclusa');
return c;
}
export function calibrate(n = 300, me = 'male') {
return LEVELS.map(level => {
let wins = 0, rounds = 0;
for (let i = 1; i <= n; i++) {
const c = simulate(Math.imul(i, 2654435761) >>> 0, level, me);
wins += c.result.winner === 'player'; rounds += c.round;
}
return { level, games: n, playerPercent: 100 * wins / n, rounds: rounds / n };
});
}
if (process.argv[1] === new URL(import.meta.url).pathname) console.log(JSON.stringify(calibrate(Number(process.argv[2]) || 300), null, 2));
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Disegna il Regolino, pixel per pixel, senza immagini sorgenti.
python3 tools/favicon.py
Scrive assets/favicon/ e /tmp/regolo-shots/favicon-anteprima.png.
La matrice 16×16 è il disegno originale: ogni simbolo è un pixel.
Tutti gli ingrandimenti sono interi e usano NEAREST, anche nell'ICO.
Colori e carattere riprendono regolo() in src/engine/briscola-render.js.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent.parent
OUTPUT = ROOT / "assets" / "favicon"
PREVIEW = Path("/tmp/regolo-shots/favicon-anteprima.png")
BACKGROUND = "#1b1420"
PALETTE = {
".": (0, 0, 0, 0),
"#": "#38271f", # contorno del dorso
"G": "#dba32b", # oro
"H": "#ffe396", # luce sulla corona
"R": "#aa4034", # rubino
"L": "#94b365", # testa verde salvia
"S": "#487348", # ombra / spira
"V": "#a3bc72", # luce sulla spira
"B": "#d0cd83", # ventre
"W": "#fff0cd", # occhi crema
}
# Corona a tre punte, grande testa sorridente, collo e coda arrotolata.
# Nessun tratto viene interpolato: anche gli occhi sono singoli pixel.
PIXELS = (
"....#..##..#....",
"...#H##HH##H#...",
"...#HGGGGGGH#...",
"....#GGRGGG#....",
"...##########...",
"..#LLLLLLLLLL#..",
".#LLWWLLWWLLLL#.",
".#LLW#LL#WLLLS#.",
".#LLLLLLLLLLLS#.",
"..#L#LLLLL#LS#..",
"...#L#####LS#...",
"....###BS###....",
"..###SSBSLSS##..",
".#SVV##BS##SSS#.",
".#SVVVVVVVVSS#..",
"..###########...",
)
def sprite():
"""Interpreta la matrice letterale, mantenendo solo alpha 0 o 255."""
if len(PIXELS) != 16 or any(len(row) != 16 for row in PIXELS):
raise ValueError("Il disegno deve contenere esattamente 16×16 pixel")
result = Image.new("RGBA", (16, 16))
for y, row in enumerate(PIXELS):
for x, symbol in enumerate(row):
color = PALETTE[symbol]
if isinstance(color, str):
color = tuple(bytes.fromhex(color[1:])) + (255,)
result.putpixel((x, y), color)
return result
def enlarge(source, factor):
return source.resize(
(source.width * factor, source.height * factor), Image.Resampling.NEAREST
)
def preview(icons):
"""Mostra ogni PNG a 1× e 8×, sia su chiaro sia su scuro.
Le sei righe includono anche le icone grandi: il foglio è volutamente
ampio, così la scala dichiarata resta esatta per tutti i file.
"""
margin, gap, label_height = 24, 24, 32
largest = max(icon.width for _, icon in icons)
panel_width = margin * 2 + largest + gap + largest * 8
height = margin + sum(label_height + icon.height * 8 + gap for _, icon in icons)
sheet = Image.new("RGB", (panel_width * 2, height))
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default(size=16)
for panel, (background, foreground) in enumerate(
(("#f5f1e9", "#38271f"), (BACKGROUND, "#fff0cd"))
):
left = panel * panel_width
draw.rectangle((left, 0, left + panel_width - 1, height - 1), fill=background)
y = margin
for name, icon in icons:
draw.text(
(left + margin, y),
f"{name} | {icon.width} x {icon.height} | 1x e 8x",
fill=foreground,
font=font,
)
y += label_height
sheet.paste(icon, (left + margin, y), icon)
magnified = enlarge(icon, 8)
sheet.paste(magnified, (left + margin + icon.width + gap, y), magnified)
y += icon.height * 8 + gap
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
sheet.save(PREVIEW, optimize=True)
def main():
OUTPUT.mkdir(parents=True, exist_ok=True)
base = sprite()
icons = []
for size in (16, 32, 48):
icon = enlarge(base, size // 16)
name = f"favicon-{size}.png"
icon.save(OUTPUT / name, optimize=True)
icons.append((name, icon))
# 16×9 = 144: margini di 18 px, centratura esatta sul fondo opaco.
apple = Image.new("RGBA", (180, 180), BACKGROUND)
apple.alpha_composite(enlarge(base, 9), (18, 18))
apple = apple.convert("RGB")
apple.save(OUTPUT / "apple-touch-icon.png", optimize=True)
icons.append(("apple-touch-icon.png", apple.convert("RGBA")))
for size in (192, 512):
icon = enlarge(base, size // 16)
name = f"icon-{size}.png"
icon.save(OUTPUT / name, optimize=True)
icons.append((name, icon))
# Fornire tutti i frame evita il ridimensionamento implicito di Pillow.
icons[2][1].save(
OUTPUT / "favicon.ico",
format="ICO",
sizes=[(16, 16), (32, 32), (48, 48)],
append_images=[icons[0][1], icons[1][1]],
)
preview(icons)
for name, _ in icons:
print(OUTPUT / name)
print(OUTPUT / "favicon.ico")
print(PREVIEW)
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""La musica delle bocce al pallaio (assets/audio/bocce.mp3), sintetizzata da zero come il valzer delle carte: una
polka da circolo in do maggiore, in due quarti. Basso «um-pa» sui due movimenti, fisarmonica a contrattempo e una
melodia originale di clarinetto, con un filo di vibrato. Trentadue battute che chiudono sul sol settima: il loop non
si sente. (Andrea, 26 ottobre: «anche le bocce avrebbero bisogno di un sound suo».)
python3 tools/polka_bocce.py # scrive assets/audio/bocce.mp3 (serve ffmpeg)
"""
import math
import random
import subprocess
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
BPM = 116
BEAT = 60 / BPM
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "bocce.mp3"
HARMONY = {"C": ["E4", "G4", "C5"], "G7": ["F4", "G4", "B4"], "F": ["F4", "A4", "C5"], "A7": ["E4", "G4", "C#5"],
"Dm": ["F4", "A4", "D5"]}
BASS = {"C": ("C2", "G2"), "G7": ("G1", "D2"), "F": ("F1", "C2"), "A7": ("A1", "E2"), "Dm": ("D2", "A1")}
CHORDS = ("C C G7 G7 G7 G7 C C C C F F C G7 C C "
"F F C C G7 G7 C C F F C A7 Dm G7 C G7").split()
h = 0.5
MELODY = [
[("E5", h), ("G5", h), ("C6", 1)], [("B5", h), ("A5", h), ("G5", 1)], [("F5", h), ("G5", h), ("A5", h), ("B5", h)], [("D6", 1), ("B5", 1)],
[("A5", h), ("B5", h), ("A5", h), ("G5", h)], [("F5", 1), ("D5", 1)], [("E5", h), ("F5", h), ("G5", 1)], [("C5", 2)],
[("E5", h), ("G5", h), ("C6", 1)], [("E6", h), ("D6", h), ("C6", 1)], [("A5", h), ("C6", h), ("A5", h), ("F5", h)], [("A5", 1.5), ("G5", h)],
[("G5", h), ("E5", h), ("C5", h), ("E5", h)], [("D5", h), ("F5", h), ("B5", 1)], [("C6", 1), ("G5", 1)], [("C6", 1), (None, 1)],
[("C6", h), ("A5", h), ("F5", 1)], [("A5", h), ("G5", h), ("F5", 1)], [("G5", h), ("E5", h), ("C5", 1)], [("E5", h), ("G5", h), ("C6", 1)],
[("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("D5", 1), ("G5", 1)], [("E5", h), ("G5", h), ("E5", h), ("C5", h)], [("G4", 2)],
[("A4", h), ("C5", h), ("F5", 1)], [("A5", 1), ("F5", 1)], [("E5", h), ("G5", h), ("C6", 1)], [("C#6", 1), ("A5", 1)],
[("D6", h), ("C6", h), ("A5", 1)], [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("E5", 1), ("C5", 1)], [("D5", h), ("E5", h), ("F5", h), ("G5", h)],
]
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 2 for bar in MELODY)
DUR = len(CHORDS) * 2 * BEAT
buf = [0.0] * int(SR * DUR)
rng = random.Random(11)
def midi(note: str) -> int:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
semis = names[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
return semis + 12 * (int(note[-1]) + 1)
def hz(note: str) -> float:
return 440 * 2 ** ((midi(note) - 69) / 12)
def add(start: float, samples) -> None:
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
n0 = int(start * SR)
for i, v in enumerate(samples):
buf[(n0 + i) % len(buf)] += v
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.006, release: float = 0.06,
vibrato: float = 0.0):
n = int((length + release) * SR)
out, phase = [], 0.0
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
# Il vibrato arriva dopo l'attacco, come quello di un fiato.
phase += 2 * math.pi * freq * (1 + vibrato * min(1.0, t / 0.25) * math.sin(2 * math.pi * 5.2 * t)) / SR
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(k * phase) for k, a, d in partials))
return out
at = lambda beat: beat * BEAT # noqa: E731
LONG = 99
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
# Clarinetto: armonici dispari, il suono di legno.
REEDWOOD = ((1, 1.0, LONG), (3, 0.42, LONG), (5, 0.2, LONG), (7, 0.09, LONG), (2, 0.06, LONG))
for bar, name in enumerate(CHORDS):
b0 = bar * 2
# Basso «um-pa»: fondamentale sull'uno, quinta sul due.
for k, note in enumerate(BASS[name]):
add(at(b0 + k), tone(hz(note), BEAT * 0.55, 0.32, ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.2, 0.12)), decay=0.35))
# Fisarmonica a contrattempo, corta e frizzante.
for k in (0.5, 1.5):
for note in HARMONY[name]:
for detune, vol in ((1.0, 0.026), (1.004, 0.019)):
add(at(b0 + k), tone(hz(note) * detune, BEAT * 0.3, vol, REED, decay=LONG, attack=0.012, release=0.04))
# Un piatto leggero sul due, quello della banda.
add(at(b0 + 1), [0.03 * math.exp(-i / SR / 0.08) * rng.uniform(-1, 1) for i in range(int(0.2 * SR))])
beat = 0.0
for bar in MELODY:
for note, length in bar:
if note:
add(at(beat), tone(hz(note), BEAT * length * 0.9, 0.09, REEDWOOD, decay=LONG, attack=0.03, release=0.06, vibrato=0.006))
beat += length
peak = max(abs(x) for x in buf) or 1
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "bocce.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes())
# All'aperto, accanto al campo: poca eco, alti morbidi.
af = "aecho=0.8:0.5:70|150:0.12|0.07,lowpass=f=8500,volume=1.25"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
print(f"-> {OUT} ({DUR:.0f} s)")
+2 -1
View File
@@ -5,5 +5,6 @@
"padel": "pàdel", "padel": "pàdel",
"l'A. Bettini": "l'Abbettini", "l'A. Bettini": "l'Abbettini",
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino", "da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino",
"S'ha andà a fallo": "Si va a farlo" "S'ha andà a fallo": "Si va a farlo",
"Ghiacciomobile": "Ghiacciomòbile"
} }
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""La musica dei tavoli da carte all'oratorio (assets/audio/carte.mp3), sintetizzata da zero come il trio del biliardo:
un valzer da balera in sol maggiore. Fisarmonica a due voci un po' scordate fra loro (il «musette») sul due e sul tre,
basso pizzicato sull'uno, e una melodia originale di mandolino, col tremolo sulle note lunghe. Trentadue battute che
chiudono sul re settima: il loop non si sente. (Andrea, 26 ottobre: «come per il biliardo, una sua melodia».)
python3 tools/valzer_carte.py # scrive assets/audio/carte.mp3 (serve ffmpeg)
"""
import math
import random
import subprocess
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
BPM = 138
BEAT = 60 / BPM
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "carte.mp3"
# Un accordo per battuta: fondamentale del basso e note della fisarmonica.
HARMONY = {"G": ["B3", "D4", "G4"], "D7": ["C4", "D4", "F#4"], "G7": ["B3", "F4", "G4"], "C": ["C4", "E4", "G4"],
"Em": ["B3", "E4", "G4"], "Am": ["C4", "E4", "A4"], "E7": ["B3", "D4", "G#4"]}
BASS = {"G": ("G2", "D2"), "D7": ("D2", "A1"), "G7": ("G2", "F2"), "C": ("C2", "G1"), "Em": ("E2", "B1"), "Am": ("A1", "E2"),
"E7": ("E2", "B1")}
CHORDS = ("G G D7 D7 D7 D7 G G G G7 C C G D7 G G "
"Em Em Am Am D7 D7 G G C C G E7 Am D7 G D7").split()
# La melodia del mandolino, battuta per battuta: (nota, durata in movimenti); None è una pausa.
MELODY = [
[("B4", 2), ("D5", 1)], [("G5", 3)], [("F#5", 2), ("E5", 1)], [("D5", 2), ("C5", 1)],
[("A4", 2), ("C5", 1)], [("F#5", 3)], [("E5", 2), ("D5", 1)], [("B4", 3)],
[("B4", 1), ("D5", 1), ("G5", 1)], [("F5", 3)], [("E5", 2), ("G5", 1)], [("C6", 3)],
[("B5", 2), ("G5", 1)], [("A5", 1), ("F#5", 1), ("D5", 1)], [("G5", 3)], [(None, 2), ("D5", 1)],
[("G5", 2), ("F#5", 1)], [("E5", 3)], [("C5", 1), ("E5", 1), ("A5", 1)], [("A5", 2), ("G5", 1)],
[("F#5", 2), ("E5", 1)], [("D5", 1), ("F#5", 1), ("A5", 1)], [("B5", 3)], [("G5", 2), ("B4", 1)],
[("C5", 1), ("E5", 1), ("G5", 1)], [("E5", 3)], [("D5", 2), ("G5", 1)], [("G#5", 2), ("B5", 1)],
[("A5", 2), ("C6", 1)], [("A5", 1), ("F#5", 1), ("C5", 1)], [("B4", 3)], [("D5", 1), ("E5", 1), ("F#5", 1)],
]
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 3 for bar in MELODY)
DUR = len(CHORDS) * 3 * BEAT
buf = [0.0] * int(SR * DUR)
rng = random.Random(7)
def midi(note: str) -> int:
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
semis = names[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
return semis + 12 * (int(note[-1]) + 1)
def hz(note: str) -> float:
return 440 * 2 ** ((midi(note) - 69) / 12)
def add(start: float, samples) -> None:
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
n0 = int(start * SR)
for i, v in enumerate(samples):
buf[(n0 + i) % len(buf)] += v
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.006, release: float = 0.06):
n = int((length + release) * SR)
out = []
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) for k, a, d in partials))
return out
at = lambda beat: beat * BEAT # noqa: E731
LONG = 99
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
# Mandolino: corda pizzicata, armonici alti che muoiono subito.
PLUCK = ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.3, 0.12), (4, 0.15, 0.06))
for bar, name in enumerate(CHORDS):
b0 = bar * 3
# Basso sull'uno: la fondamentale, una battuta sì e una no la quinta.
add(at(b0), tone(hz(BASS[name][bar % 2]), BEAT * 0.9, 0.3, ((1, 1.0, LONG), (2, 0.4, 0.3), (3, 0.15, 0.15)), decay=0.5))
# Fisarmonica sul due e sul tre: due ance, una un filo crescente, che battono fra loro.
for beat in (1, 2):
for note in HARMONY[name]:
for detune, vol in ((1.0, 0.03), (1.0035, 0.022)):
add(at(b0 + beat), tone(hz(note) * detune, BEAT * 0.62, vol, REED, decay=LONG, attack=0.02, release=0.05))
beat = 0.0
for bar in MELODY:
for note, length in bar:
if note:
if length >= 2:
# Le note lunghe col tremolo del plettro: tanti colpi fitti, sempre più piano.
picks = int(length * BEAT / 0.068)
for k in range(picks):
add(at(beat) + k * 0.068, tone(hz(note), 0.068, 0.07 * (1 - 0.35 * k / picks) * (1.15 if k == 0 else 1),
PLUCK, decay=0.12, attack=0.002, release=0.02))
else:
add(at(beat), tone(hz(note), BEAT * length * 0.95, 0.1, PLUCK, decay=0.35, attack=0.002, release=0.05))
beat += length
peak = max(abs(x) for x in buf) or 1
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "carte.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes())
# Il cortile dell'oratorio: un po' d'eco dai muri, niente alti taglienti.
af = "aecho=0.8:0.55:60|130:0.2|0.12,lowpass=f=8000,volume=1.25"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
print(f"-> {OUT} ({DUR:.0f} s)")
+58
View File
@@ -0,0 +1,58 @@
# Video «Come si gioca» — briscola, tressette ai 21, scopa, bocce, biliardo (scaletta)
Cinque clip brevi, una per gioco, con voce italiana (XTTS) e sottotitoli impressi. Si guardano dentro il gioco:
un pulsante «Guarda il video» nel «Come si gioca» di ogni gioco apre un player con l'audio.
Le mani sono preparate dal copione (semi fissi), così ogni regola si vede nel momento giusto.
## 1. Briscola (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Oratorio, Andrea parla al giocatore di carte, sceglie toscane e «Seria» | Dove si gioca, mazzo e livello |
| 2 | Il tavolo: la mano, la briscola sotto il mazzo, il mazzo | Tre carte a testa, la briscola comanda |
| 3 | Una presa col seme, una presa con la briscola | Chi prende: la più alta del seme, o la briscola |
| 4 | Liscio su un carico, poi un carico preso con una briscolina | Asso 11, 3 dieci, regio 4, donna 3, gobbo 2 |
| 5 | Fine mazzo e conteggio carta per carta | Si vince con 61, 60 a 60 è patta |
## 2. Tressette ai 21 (circa 3 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Un tressette ai 21?», i quattro al tavolo | In quattro a coppie, il socio di fronte |
| 2 | Una presa in cui si risponde al seme, una carta rifiutata | Obbligo di seme; ordine 3, 2, Asso, figure |
| 3 | Un'accusa scritta in alto | Napoletana, tre Assi/2/3, quattro |
| 4 | Andrea esce con «Striscio», poi «Busso» e il socio che ritorna il seme, poi un «Volo» | I segni, coi pulsanti o B, S, V |
| 5 | Riepilogo di smazzata e il «fuori» ai 21 | Terzi, ultima presa, si va fuori ai 21 |
## 3. Scopa (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Una scopa?», il tavolo con quattro carte | Valori: Asso 1 … regio 10 |
| 2 | Una presa della carta uguale, una presa per somma | La carta uguale viene prima delle somme |
| 3 | Due prese possibili: Andrea sceglie | Se le prese sono più d'una, scegli tu |
| 4 | Una scopa | Pulire la tavola fa un punto |
| 5 | Riepilogo: carte, mattoni, settebello, primiera, scope | Agli 11 |
## 4. Bocce al pallaio (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Brento Sanico, Andrea parla con Mariano, sceglie il livello | Dove si gioca e i livelli |
| 2 | Mira toccando il campo e il dettaglio del pallino, il cerchio azzurro | Come si mira |
| 3 | La forza col cursore e «Lancia»; poi tenere premuto e lasciare | Due modi di tirare, anche frecce e Spazio |
| 4 | Una boccia che si accosta, una bocciata di Mariano | Tira chi non ha il punto |
| 5 | Fine mano e punti | Tre bocce a testa, ogni boccia più vicina della migliore avversaria fa un punto |
## 5. Biliardo, cinque birilli (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Circolo 4 Maggio, l'Ing. Tozzi, il livello | Dove si gioca |
| 2 | Il tavolo: le due biglie, il pallino, il castello dei birilli | Si colpisce la propria biglia e la si manda sull'altra |
| 3 | La mira: la linea tratteggiata, le frecce dopo l'urto; clic che fissa la mira | Come si mira, Maiusc per la mira fine |
| 4 | Un colpo che abbatte un birillo bianco, poi il rosso | Bianco 2, rosso 4, rosso da solo 10, pallino 3 |
| 5 | Un fallo (mancare la biglia) | I falli danno i punti all'avversario |
| 6 | Il segnapunti e la vittoria a 20 | Si va a 20 |
Prima di girare: le correzioni della revisione di Codex sui tre giochi, così il video mostra il gioco definitivo.
+3
View File
@@ -42,6 +42,9 @@ export async function setup(d) {
await p.goto(URL_GIOCO); await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear()); await p.evaluate(() => localStorage.clear());
await p.reload(); await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])"); await p.waitForSelector("#title:not([hidden])");
await sleep(1500); await sleep(1500);
} }
+24 -1
View File
@@ -22,10 +22,16 @@ OUT = VIDEO / "out"
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim». # Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
TAGLIO = "crop=1044:760:278:70" TAGLIO = "crop=1044:760:278:70"
FASCIA = 110 # px sopra il gioco per le didascalie FASCIA = 110 # px sopra il gioco per le didascalie
# I video «Come si gioca» (--tutorial): ai tavoli la pagina usa tutta l'altezza, comandi sotto compresi; didascalie
# lunghe anche su tre righe.
TAGLIO_TUTORIAL = "crop=1046:900:268:0"
FASCIA_TUTORIAL = 132
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
VOLUME = 0.5 # musica della demo-prototipo (senza voci) VOLUME = 0.5 # musica della demo-prototipo (senza voci)
VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre) VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre)
VOLUME_VOCI = 1.0 VOLUME_VOCI = 1.0
VOLUME_NARRATORE = 1.15
VOLUME_TUTORIAL = 0.07 # musica dei video «Come si gioca»: sottofondo # il narratore dei video «Come si gioca», sopra la musica bassa
FADE = 1.2 FADE = 1.2
@@ -42,6 +48,8 @@ def ts(s: float) -> str:
def main() -> None: def main() -> None:
d = Path(sys.argv[1]).expanduser() d = Path(sys.argv[1]).expanduser()
tutorial = "--tutorial" in sys.argv[2:]
taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA)
meta = json.loads((d / "cues.json").read_text()) meta = json.loads((d / "cues.json").read_text())
nome = meta["clip"] nome = meta["clip"]
registro = OUT / f"{nome}-audio.json" registro = OUT / f"{nome}-audio.json"
@@ -59,6 +67,10 @@ def main() -> None:
aperta["fine"] = e["t"] aperta["fine"] = e["t"]
aperta = None aperta = None
volume = VOLUME_SOTTO volume = VOLUME_SOTTO
# Nei video «Come si gioca» parla il narratore quasi sempre: la musica resta un sottofondo (Andrea, 26 ottobre:
# «troppo alta la musica del gioco»).
if (OUT / f"{nome}-narrazione.json").exists():
volume = VOLUME_TUTORIAL
else: else:
musica = json.loads((OUT / "musica.json").read_text()) musica = json.loads((OUT / "musica.json").read_text())
volume = VOLUME volume = VOLUME
@@ -105,9 +117,20 @@ def main() -> None:
lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else "" lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else ""
catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]") catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]")
pezzi.append(f"[v{len(pezzi)}]") pezzi.append(f"[v{len(pezzi)}]")
# Il narratore dei video «Come si gioca» (video/narra.py): ogni frase all'inizio della sua battuta.
narrazione = OUT / f"{nome}-narrazione.json"
if narrazione.exists():
frasi = {v["id"]: v["src"] for v in json.loads(narrazione.read_text())["voci"]}
for c in cues:
if c["id"] not in frasi:
continue
ingressi += ["-i", str(GIOCO / frasi[c["id"]])]
n = ingressi.count("-i")
catene.append(f"[{n}:a]asetpts=PTS-STARTPTS,volume={VOLUME_NARRATORE},adelay={int((c['start'] + scarto) * 1000)}:all=1[n{len(pezzi)}]")
pezzi.append(f"[n{len(pezzi)}]")
mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]" mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]"
stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF" stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF"
video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]" video = f"[0:v]{taglio},pad=iw:ih+{fascia}:0:{fascia}:{FONDO},subtitles={srt}:force_style='{stile}'[v]"
finale = OUT / f"{nome}.mp4" finale = OUT / f"{nome}.mp4"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi, subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]", "-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""La voce del narratore dei video «Come si gioca» (video/SCALETTA-CARTE.md), PRIMA di girare.
python3 video/narra.py video/tutorial-briscola.mjs
Legge le battute del copione (cues, «it»), le fa dire a XTTS con `mbot parla` (voce del narratore, diversa da
quelle dei personaggi), le controlla con `mbot ascolta` e scrive video/out/<clip>-narrazione.json con file e
durata di ognuna. Il copione aspetta che la frase sia finita prima di passare alla successiva; monta.py la mette
nel video all'inizio della sua battuta. Le frasi già fatte si saltano (per rifarne una: cancellare il file).
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
OUT = VIDEO / "out"
NARRATORE = "Claribel Dervla"
PRONUNCIA = GIOCO / "tools" / "pronuncia.json"
def durata(f: Path) -> float:
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
capture_output=True, text=True, check=True).stdout)
def main() -> None:
copione = Path(sys.argv[1]).resolve()
clip = copione.stem
cues = json.loads(subprocess.run(["node", "--input-type=module", "-e",
f"const m = await import({json.dumps(copione.as_uri())}); console.log(JSON.stringify(m.cues))"],
capture_output=True, text=True, check=True, cwd=GIOCO).stdout)
cartella = OUT / f"{clip}-narrazione"
cartella.mkdir(parents=True, exist_ok=True)
# XTTS dice «punto» se la frase finisce col punto: si toglie, come fa narrate.py del tour.
lavori = [{"text": c["it"].rstrip(". "), "out": str(cartella / f"{c['id']}.wav"), "lang": "it", "speaker": NARRATORE} for c in cues]
(cartella / "lavori.json").write_text(json.dumps(lavori, ensure_ascii=False, indent=1))
# Le pronunce del gioco più quelle del narratore, in un file solo per `mbot parla`.
regole = {k: v for f in (PRONUNCIA, VIDEO / "pronuncia-narratore.json") if f.exists() for k, v in json.loads(f.read_text()).items() if not k.startswith("_")}
(cartella / "pronuncia.json").write_text(json.dumps(regole, ensure_ascii=False, indent=1))
pron = ["--pronounce", str(cartella / "pronuncia.json")]
subprocess.run(["mbot", "parla", "--jobs", str(cartella / "lavori.json"), *pron], check=True)
ascolto = [{"file": l["out"], "expect": l["text"]} for l in lavori]
(cartella / "ascolto.json").write_text(json.dumps(ascolto, ensure_ascii=False, indent=1))
subprocess.run(["mbot", "ascolta", "--jobs", str(cartella / "ascolto.json")])
voci = [{"id": c["id"], "src": str(Path(l["out"]).relative_to(GIOCO)), "sec": round(durata(Path(l["out"])), 2)} for c, l in zip(cues, lavori)]
(OUT / f"{clip}-narrazione.json").write_text(json.dumps({"voci": voci}, ensure_ascii=False, indent=1))
print(f"-> {OUT / f'{clip}-narrazione.json'}: {len(voci)} frasi, {sum(v['sec'] for v in voci):.0f} s di voce")
if __name__ == "__main__":
main()
+3
View File
@@ -34,6 +34,9 @@ export async function setup(d) {
await p.goto(URL_GIOCO); await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear()); await p.evaluate(() => localStorage.clear());
await p.reload(); await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])"); await p.waitForSelector("#title:not([hidden])");
await sleep(1500); await sleep(1500);
} }
+7
View File
@@ -0,0 +1,7 @@
{
"_nota": "Pronunce in più per il narratore dei video «Come si gioca» (video/narra.py), oltre a tools/pronuncia.json. L'accento scritto sposta quello di XTTS (Andrea, 26 ottobre: «piccola» e «volta» con l'accento sbagliato).",
"piccola": "pìccola",
"piccole": "pìccole",
"volta": "vòlta",
"volte": "vòlte"
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Porta i video «Come si gioca» nel gioco (assets/video/, story.tutorials): da quando si arriva nel posto del gioco
(la prima battuta del narratore), più piccoli e compressi per il web, con una copertina.
python3 video/pubblica.py briscola tressette scopa bocce biliardo
Prima la ripresa e il montaggio (video/SCALETTA-CARTE.md): legge video/out/tutorial-<gioco>.mp4 e il cues.json della
ripresa. Il titolo del gioco all'inizio della ripresa non serve, e col ritaglio dei tavoli verrebbe tagliato ai lati.
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
RIPRESE = Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out"
def durata(f: Path) -> float:
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
capture_output=True, text=True, check=True).stdout)
for gioco in sys.argv[1:]:
ripresa = RIPRESE / f"tutorial-{gioco}"
cues = json.loads((ripresa / "cues.json").read_text())["cues"]
# Lo stesso scarto di monta.py: la ripresa parte un po' prima del copione.
scarto = max(0.0, durata(ripresa / "raw.mkv") - (cues[-1]["end"] + 1.2))
inizio = max(0.0, cues[0]["start"] + scarto - 0.4)
dentro, fuori = VIDEO / "out" / f"tutorial-{gioco}.mp4", GIOCO / "assets" / "video" / f"{gioco}.mp4"
fuori.parent.mkdir(exist_ok=True)
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{inizio:.2f}", "-i", str(dentro),
"-vf", "scale=trunc(iw*0.75/2)*2:-2", "-c:v", "libx264", "-preset", "slow", "-crf", "27", "-pix_fmt", "yuv420p",
"-af", "afade=t=in:d=0.4", "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(fuori)], check=True)
# La copertina: un momento al tavolo, a un terzo del video.
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{durata(fuori) / 3:.1f}", "-i", str(fuori), "-frames:v", "1",
"-q:v", "4", str(fuori.with_suffix(".jpg"))], check=True)
for f in (fuori, fuori.with_suffix(".jpg")):
f.chmod(0o644)
print(f"-> {fuori} ({durata(fuori):.0f} s, {fuori.stat().st_size / 1e6:.1f} MB), da {inizio:.1f} s")
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Controlla che nel video «Come si gioca» il narratore non parli sopra i personaggi del gioco.
python3 video/sovrapposte.py tutorial-briscola (dopo la ripresa: cues.json, registro audio, narrazione)
"""
import json
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
clip = sys.argv[1]
cues = json.loads((Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out" / clip / "cues.json").read_text())["cues"]
narr = {v["id"]: v["sec"] for v in json.loads((VIDEO / "out" / f"{clip}-narrazione.json").read_text())["voci"]}
voci, aperta = [], None
for e in json.loads((VIDEO / "out" / f"{clip}-audio.json").read_text())["eventi"]:
if "voice" in e:
aperta = [e["t"], None, e["voice"]]
voci.append(aperta)
elif "voiceEnd" in e and aperta:
aperta[1], aperta = e["t"], None
male = [(c["id"], v[2]) for c in cues for v in voci if v[1] and v[0] < c["start"] + narr[c["id"]] and v[1] > c["start"]]
for id_, voce in male:
print(f"✗ «{id_}» sopra {voce}")
print(f"{clip}: {len(voci)} voci del gioco, {len(cues)} frasi del narratore, {len(male)} sovrapposte")
sys.exit(1 if male else 0)
+92
View File
@@ -0,0 +1,92 @@
// Copione del video «Come si gioca: il biliardo» (video/SCALETTA-CARTE.md, clip 5).
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
export const title = { it: "Come si gioca · il biliardo", en: "Come si gioca · il biliardo" };
export const cues = [
c("dove", "Il biliardo è al Circolo 4 Maggio, in piazza. L'Ingegner Tozzi gioca ai cinque birilli, e si va a venti"),
c("livello", "Anche qui si sceglie quanto deve giocare forte"),
c("tavolo", "Il tavolo dall'alto: la tua biglia, quella dell'avversario, il pallino, e in mezzo il castello dei birilli"),
c("scopo", "Si colpisce la propria biglia e la si manda su quella dell'avversario: i birilli che lei abbatte fanno punti a chi tira"),
c("mira", "La stecca segue il mouse, e un clic fissa la mira. La linea tratteggiata è la strada della biglia; le frecce, dove vanno le due biglie dopo l'urto"),
c("tira", "Con le frecce si gira la stecca, con Maiuscolo ancora più piano. Poi si regola la forza, e si preme Tira"),
c("valori", "Il birillo bianco vale due, il rosso quattro, il rosso da solo dieci. Il pallino toccato dalla biglia avversaria, tre"),
c("fallo", "Attenzione ai falli: mancare la biglia avversaria, toccare prima il pallino, o buttare giù birilli con la propria. I punti vanno all'avversario"),
c("fine", "Un colpo a testa, finché qualcuno arriva a venti. Buona partita!"),
];
export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-biliardo");
await r.ascolta();
await r.vaiA("circolo_4_maggio", [470, 300]);
await r.cue("dove");
await r.parlaCon("ing_tozzi");
await r.scegli("una partita ai birilli");
await r.cue("livello");
await r.scegli("Alla bona.");
// Una partita fissa: un colpo da otto, un fallo voluto per farlo vedere, poi quattro e tre; vince Andrea 21 a 2.
await p.evaluate(async () => {
const { newBiliardo } = await import("/src/engine/biliardo.js");
game.closeup = { id: "biliardo", ...newBiliardo(2, { level: "bona" }) };
});
await sleep(800);
await r.cue("tavolo");
await r.muovi([200, 200], 0.9); await sleep(300); await r.muovi([400, 180], 0.9);
await r.cue("scopo");
// Il colpo: il migliore fra quelli provati a vuoto (o, la seconda volta, un fallo apposta). Si mira col clic sul
// tavolo lungo la direzione scelta, poi forza col cursore e Tira.
const colpo = (sbaglia) => p.evaluate(async (sbaglia) => {
const L = await import("/src/engine/biliardo.js"), c = game.closeup, b = c.balls.player, o = c.balls.cpu;
let top = null;
if (sbaglia) for (let a = 0; a < Math.PI * 2 && !top; a += 0.05) { if (L.simulate(c, "player", a, 0.12).foul === "miss") top = { angle: a, power: 0.12 }; }
else {
const base = Math.atan2(o.y - b.y, o.x - b.x);
for (let d = -0.35; d <= 0.35; d += 0.01) for (const power of [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]) {
const j = L.simulate(c, "player", base + d, power), gain = j.to === "player" ? j.points : -j.points;
if (!top || gain > top.gain) top = { angle: base + d, power, gain };
}
}
const x = b.x + Math.cos(top.angle) * 45, y = b.y + Math.sin(top.angle) * 45;
return { ...top, at: [L.VIEW.x + x * L.VIEW.k, L.VIEW.y + y * L.VIEW.k] };
}, sbaglia);
const tocca = async () => {
for (let i = 0; i < 900; i++) {
const s = await p.evaluate(async () => {
const { canThrow } = await import("/src/engine/biliardo.js");
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
});
if (s !== "aspetta") return s === "tocca";
await sleep(150);
}
return false;
};
let n = 0;
while (await tocca()) {
n++;
if (n === 1) await r.cue("mira");
if (n === 2) await r.cue("fallo");
const m = await colpo(n === 2);
await r.muovi(m.at, 0.9); await sleep(400);
await r.clicca(m.at, 0.3);
await p.evaluate(({ angle }) => { game.closeup.angle = angle; game.closeup.aimLocked = true; }, m);
if (n === 1) await r.cue("tira");
await d.hover(p.locator("#bocce-power"), 0.6);
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
await p.evaluate((v) => { game.closeup.power = v; }, m.power);
await sleep(500);
await d.click(p.locator("#bocce-throw"), 0.6);
await p.locator("#scene").scrollIntoViewIfNeeded();
if (n === 1) await r.cue("valori");
}
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 120000 });
await sleep(1500);
await r.cue("fine");
await r.aspetta();
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
await r.salvaAudio();
}
+90
View File
@@ -0,0 +1,90 @@
// Copione del video «Come si gioca: le bocce» (video/SCALETTA-CARTE.md, clip 4).
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
export const title = { it: "Come si gioca · le bocce", en: "Come si gioca · le bocce" };
export const cues = [
c("dove", "Le bocce si giocano al pallaio, accanto al tennis della chiesa. Mariano, il campione, sfida chiunque"),
c("livello", "Si sceglie quanto deve impegnarsi: alla bona, seria, o a fassi male"),
c("campo", "Il campo visto da dietro: in fondo c'è il pallino, e a sinistra il dettaglio, per vedere da vicino chi è più accosto"),
c("mira", "Per mirare si tocca il campo, o il dettaglio vicino al pallino. Il cerchio azzurro dice dove si fermerebbe la boccia"),
c("forza", "Poi si regola la forza col cursore, e si preme Lancia"),
c("turno", "Tira sempre chi non ha il punto. Chi è dietro può accostare, oppure bocciare: colpire la boccia dell'altro per toglierla di lì"),
c("tieni", "C'è anche un altro modo: si tiene premuto sul campo, la forza sale, e lasciando parte la boccia. Vanno bene anche le frecce e la barra spaziatrice"),
c("punti", "Tre bocce a testa. Alla fine fa un punto ogni boccia più vicina al pallino della migliore dell'avversario"),
c("fine", "E se batti Mariano, ti porta a Brento Sanico. Buona partita!"),
];
export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-bocce");
await r.ascolta();
await r.vaiA("tennis_chiesa", [430, 310]);
await r.cue("dove");
await r.parlaCon("mariano");
await r.scegli("Facciamo una partita");
await r.cue("livello");
await r.scegli("Alla bona.");
// Una mano fissa: accostando bene, Andrea fa tre punti (anche se il tiro a pressione viene un po' corto o lungo).
await p.evaluate(async () => {
const { newBocce } = await import("/src/engine/bocce.js");
game.closeup = { id: "bocce", ...newBocce(9, { level: "bona" }) };
});
await sleep(800);
await p.waitForFunction(() => window.music?.want === "assets/audio/bocce.mp3", null, { timeout: 10000 });
await r.cue("campo");
await r.muovi([300, 200], 1.0); await sleep(400); await r.muovi([90, 170], 1.0);
// Dove mirare: poco prima del pallino, così la boccia si ferma accosto (il cerchio azzurro ci finisce sopra).
const mira = () => p.evaluate(async () => {
const B = await import("/src/engine/bocce.js"), c = game.closeup, u = c.jack.u - 0.55, w = c.jack.w;
const s = B.project(u, w);
return { at: [s.x, s.y], u, w, power: B.powerFor(Math.hypot(u, w) - 0.29) };
});
const tocca = async () => {
for (let i = 0; i < 600; i++) {
const s = await p.evaluate(async () => {
const { canThrow } = await import("/src/engine/bocce.js");
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
});
if (s !== "aspetta") return s === "tocca";
await sleep(150);
}
return false;
};
let tiro = 0;
while (await tocca()) {
tiro++;
const m = await mira();
if (tiro === 1) await r.cue("mira");
if (tiro < 3) {
// Mira col clic, forza col cursore, poi Lancia.
await r.clicca(m.at, 0.8);
await p.evaluate(async ({ u, w }) => (await import("/src/engine/bocce.js")).aim(game.closeup, u, w), m);
if (tiro === 1) await r.cue("forza");
await d.hover(p.locator("#bocce-power"), 0.6);
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
await p.evaluate(async (v) => (await import("/src/engine/bocce.js")).setPower(game.closeup, v), m.power);
await sleep(600);
await d.click(p.locator("#bocce-throw"), 0.6);
if (tiro === 1) await r.cue("turno");
} else {
// L'ultima tenendo premuto: la forza sale finché non si lascia.
await r.cue("tieni");
await r.muovi(m.at, 0.8);
const [a, b] = await r.punto(...m.at);
d.vm(`DISPLAY=:0 xdotool mousemove ${Math.round(a)} ${Math.round(b)} mousedown 1 sleep ${(m.power * 1.6).toFixed(2)} mouseup 1`);
}
await p.locator("#scene").scrollIntoViewIfNeeded();
}
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 90000 });
await sleep(1000);
await r.cue("punti");
await r.cue("fine");
await r.aspetta();
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
await r.salvaAudio();
}
+80
View File
@@ -0,0 +1,80 @@
// Copione del video «Come si gioca: la briscola» (video/SCALETTA-CARTE.md, clip 1).
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
export const title = { it: "Come si gioca · la briscola", en: "Come si gioca · la briscola" };
export const cues = [
c("dove", "All'oratorio della Pieve, alle tavolate, c'è il giocatore di carte: per giocare basta parlarci"),
c("mazzo", "Prima si sceglie il mazzo: le carte toscane, con gobbo, donna e regio, oppure le napoletane"),
c("livello", "Poi quanto deve giocare forte: alla bona, seria, o a fassi male"),
c("tavolo", "In basso le tue tre carte, in alto quelle coperte dell'avversario. A sinistra il mazzo, con la briscola girata sotto: quel seme comanda"),
c("presa", "Si gioca una carta per uno. Prende la briscola più alta; se non ce ne sono, la carta più alta del seme di chi è uscito"),
c("punti", "Contano l'Asso, undici punti, il tre, dieci, il regio quattro, la donna tre e il gobbo due. Le altre sono lisci e non valgono niente"),
c("pesca", "Chi prende pesca per primo ed esce alla mano dopo. E non c'è l'obbligo di rispondere al seme"),
c("consiglio", "Un consiglio: su un liscio rispondi con un liscio, e i carichi dell'avversario prendili con una briscola piccola"),
c("tastiera", "Si gioca toccando la carta, oppure coi pulsanti sotto il tavolo, o con le frecce e Invio"),
c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti; sessanta a sessanta è pari, e non vince nessuno"),
c("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"),
];
export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-briscola");
// Il registro dell'audio parte con la ripresa: monta.py rimette le voci del gioco al loro istante.
await r.ascolta();
await r.vaiA("oratorio", [470, 300]);
await r.cue("dove");
await r.parlaCon("giocatore_carte");
await r.scegli("Una briscola?");
await r.cue("mazzo");
await r.scegli("Con le toscane.");
await r.cue("livello");
await r.scegli("Seria.");
// Una mano fissa, la stessa a ogni ripresa: giocando attento, Andrea vince 68 a 52.
await p.evaluate(async () => {
const { newBriscola } = await import("/src/engine/briscola.js");
game.closeup = { id: "briscola", ...newBriscola(8, { level: "seria", deck: "toscane" }) };
});
await sleep(800);
// Il tavolo ha la sua musica: se non parte, il gioco servito nella VM è vecchio.
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
await r.cue("tavolo");
await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0);
// Una carta di Andrea: quella che sceglierebbe un giocatore attento, cliccata davvero sul tavolo.
const gioca = async () => {
for (let i = 0; i < 400; i++) {
const s = await p.evaluate(async () => {
const { canPlay } = await import("/src/engine/briscola.js");
return game.closeup?.step === "result" || game.closeup?.step === "count" ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
});
if (s === "fine") return false;
if (s === "tocca") break;
await sleep(150);
}
const i = await p.evaluate(async () => {
const { observation, attentive } = await import("/src/engine/briscola.js");
return attentive(observation(game.closeup, "player"));
});
await r.clicca([176 + i * 102 + 42, 290], 0.7);
return true;
};
const giocane = async (n) => { for (let k = 0; k < n; k++) if (!(await gioca())) return; };
await r.cue("presa"); await giocane(2);
await r.cue("punti"); await giocane(2);
await r.cue("pesca"); await giocane(2);
await r.cue("consiglio"); await giocane(2);
await r.cue("tastiera"); await giocane(20);
await p.waitForFunction(() => game.closeup?.step === "count", null, { timeout: 60000 });
await r.cue("conto");
await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 });
await sleep(2500);
await r.cue("fine");
await r.aspetta(); // la battuta del giocatore, uscendo, dopo quella del narratore
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
await r.salvaAudio();
}
+114
View File
@@ -0,0 +1,114 @@
// Pezzi comuni dei copioni «Come si gioca» (video/SCALETTA-CARTE.md): briscola, tressette, scopa, bocce, biliardo.
// Si girano come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»); prima `python3 video/narra.py <copione>`
// per la voce del narratore, dopo `python3 video/monta.py <cartella> --tutorial`.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
export const URL_GIOCO = "http://127.0.0.1:8000/";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const c = (id, it) => ({ id, it, en: it, top: true });
// La durata di ogni frase del narratore (video/narra.py): la battuta resta finché la voce non ha finito.
export function narrazione(clip) {
const f = new URL(`./out/${clip}-narrazione.json`, import.meta.url);
return existsSync(f) ? Object.fromEntries(JSON.parse(readFileSync(f, "utf8")).voci.map((v) => [v.id, v.sec * 1000])) : {};
}
export async function setup(d) {
const p = d.page;
// Niente cache: il Chromium della VM teneva i moduli JavaScript di una ripresa prima (il valzer non partiva).
const cdp = await p.context().newCDPSession(p);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.clearBrowserCache");
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
// Se il browser blocca la musichetta senza un gesto, il cartello aspetta un tocco.
await sleep(500);
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
await p.waitForSelector("#title:not([hidden])");
await sleep(1000);
}
export function regista(d, clip) {
const p = d.page, voce = narrazione(clip);
let inizio = 0, dura = 0;
const r = {
// Una battuta del narratore: prima si lascia finire quella di prima, e chi sta parlando nel gioco.
async cue(id) {
await r.aspetta();
for (let i = 0; i < 300 && (await p.evaluate(() => !!game.speech || !!(window.voices?.audio && !window.voices.audio.paused && !window.voices.audio.ended))); i++) await sleep(100);
await sleep(250);
await d.cue(id);
inizio = Date.now(); dura = voce[id] ?? 0;
},
// Prima di far parlare un personaggio, il narratore deve aver finito: le due voci non si sovrappongono.
async aspetta() { const resto = inizio + dura + 350 - Date.now(); if (resto > 0) await sleep(resto); },
async fine() { const resto = inizio + dura + 600 - Date.now(); if (resto > 0) await sleep(resto); },
// Puntatore vero sulla scena (coordinate 640×360).
async punto(x, y) {
const b = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
return [(b.x + (x / 640) * b.width) * k, (b.y + (y / 360) * b.height) * k];
},
async clicca([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); },
async muovi([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur}`); await sleep(200); },
// Aspetta che l'omino abbia finito di camminare e di parlare; un dialogo o un primo piano aperti fermano l'attesa.
async fermi(pausa = 500) {
await sleep(150);
for (let i = 0; i < 1500; i++) {
const ok = await p.evaluate(() => {
if (game.speech || (game.queue.length && !game.closeup) || game.walking || game.reach > 0) return false;
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
});
if (ok) break;
await sleep(100);
}
await sleep(pausa);
},
async scegli(testo) { await r.aspetta(); await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); },
// Partita nuova per Campomigliaio, introduzione saltata, dritti nella scena del gioco.
async vaiA(scena, at) {
await d.click(p.locator("#new-game"));
await sleep(1200);
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
await sleep(800);
if (await p.isVisible("#skip-intro")) await d.click(p.locator("#skip-intro"), 0.5);
// Le battute d'apertura di Andrea (il palio sparito) qui non servono: si saltano tutte.
await p.evaluate(([s, a]) => { for (let i = 0; i < 50 && (game.speech || game.queue.length); i++) { game.skip(); game.update(0.05); } game.go(s, a); }, [scena, at]);
await r.fermi(1200);
},
async parlaCon(id) {
await r.aspetta();
const [x, y] = await p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
await r.clicca([x, y]);
await r.fermi();
},
// L'audio annotato dentro la pagina (musica voluta e voci del gioco), per rimontarlo (monta.py).
async ascolta() {
await p.evaluate(() => {
window.__t0 = Date.now(); window.__audio = [];
let music = undefined, voice = null;
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
const look = () => {
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
},
async salvaAudio() {
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL(`${clip}-audio.json`, out), JSON.stringify({ eventi: audio }, null, 2));
},
};
return r;
}

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