Compare commits

...
29 Commits
Author SHA1 Message Date
manzolo 650d4e9cf8 Versione 0.1.73
test / test (push) Successful in 27s
test / browser (ambient) (push) Successful in 30s
test / browser (biliardo) (push) Successful in 47s
test / browser (cerbottana) (push) Successful in 32s
test / browser (condor) (push) Successful in 24s
test / browser (campana) (push) Successful in 2m10s
test / browser (flipper) (push) Successful in 1m58s
test / browser (micro) (push) Successful in 3m18s
test / browser (ingorgo) (push) Successful in 3m44s
test / browser (briscola) (push) Successful in 8m32s
test / browser (bruciate) (push) Successful in 8m18s
test / browser (minigiochi) (push) Successful in 3m4s
test / browser (gara) (push) Successful in 8m42s
test / browser (peso) (push) Successful in 5m38s
test / browser (telefono) (push) Successful in 31s
test / browser (tressette) (push) Successful in 1m0s
test / browser (telefoni) (push) Successful in 2m50s
test / browser (veicoli) (push) Successful in 24s
test / browser (risveglio) (push) Successful in 7m4s
test / browser (scopa) (push) Successful in 8m3s
test / browser (principale) (push) Successful in 10m6s
test / browser (smarronata) (push) Successful in 11m6s
test / release (push) Successful in 8m11s
2026-11-01 21:57:59 +01:00
manzoloandClaude Opus 5.5 7384726091 Flipper: il tabellone cambia illustrazione con la zona che la pallina attraversa (il Regolo coronato, la Fortezza, la Pieve, la Sieve, il Trebbio, i rioni) e il simbolo della zona sul campo (Codex, flipper-art.js); «TIENI PREMUTO QUI» per la molla solo col dito
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 21:57:17 +01:00
manzolo c68b365df2 Versione 0.1.72
test / test (push) Successful in 50s
test / browser (campana) (push) Successful in 1m22s
test / browser (biliardo) (push) Successful in 1m57s
test / browser (flipper) (push) Successful in 33s
test / browser (condor) (push) Successful in 1m31s
test / browser (ingorgo) (push) Successful in 58s
test / browser (micro) (push) Successful in 1m13s
test / browser (bruciate) (push) Successful in 6m45s
test / browser (ambient) (push) Successful in 6m51s
test / browser (minigiochi) (push) Successful in 2m53s
test / browser (cerbottana) (push) Successful in 9m18s
test / browser (gara) (push) Successful in 7m41s
test / browser (risveglio) (push) Successful in 2m33s
test / browser (smarronata) (push) Successful in 47s
test / browser (principale) (push) Successful in 4m44s
test / browser (telefono) (push) Successful in 39s
test / browser (briscola) (push) Successful in 11m53s
test / browser (peso) (push) Successful in 5m31s
test / browser (telefoni) (push) Successful in 2m11s
test / browser (tressette) (push) Successful in 2m11s
test / browser (scopa) (push) Successful in 7m11s
test / browser (veicoli) (push) Successful in 8m26s
test / release (push) Successful in 5m2s
2026-11-01 21:44:43 +01:00
manzoloandClaude Opus 5.5 531aee18bf Schermo intero (pulsante accanto ai volumi e tasto F) e gioco fino a 2560 px; nei minigiochi la scena lascia lo spazio vero dei comandi; flipper: «Solo tavola», sul telefono senza pulsantiera e «Lascia stare» in alto, molla toccando al centro con «TIENI PREMUTO QUI», ponti rifatti da Codex; prove più salde (tressette)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 21:38:44 +01:00
manzolo 428fb4bbec Versione 0.1.71
test / test (push) Successful in 34s
test / browser (cerbottana) (push) Successful in 50s
test / browser (biliardo) (push) Successful in 52s
test / browser (ambient) (push) Successful in 54s
test / browser (campana) (push) Successful in 1m2s
test / browser (briscola) (push) Successful in 1m2s
test / browser (bruciate) (push) Successful in 1m6s
test / browser (condor) (push) Successful in 32s
test / browser (gara) (push) Successful in 40s
test / browser (flipper) (push) Successful in 48s
test / browser (micro) (push) Successful in 50s
test / browser (risveglio) (push) Successful in 38s
test / browser (ingorgo) (push) Successful in 1m17s
test / browser (scopa) (push) Successful in 26s
test / browser (telefono) (push) Successful in 29s
test / browser (smarronata) (push) Successful in 42s
test / browser (tressette) (push) Successful in 22s
test / browser (veicoli) (push) Successful in 14s
test / browser (minigiochi) (push) Successful in 2m52s
test / browser (telefoni) (push) Successful in 2m9s
test / browser (principale) (push) Successful in 4m25s
test / browser (peso) (push) Successful in 5m45s
test / release (push) Successful in 15s
2026-11-01 19:52:12 +01:00
manzoloandClaude Opus 5.5 14af51668f Flipper: con ?debug il tasto S fa i due LOCK e apre la SUPER (per provare il Regolo del futuro). Prove più salde coi tempi della scheda video: la tressette aspetta la fine della battuta prima dei tasti, il vento della fessura ha un attimo per sfumare; il peso fa un giro di riscaldamento nelle scene, soglia 500 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 19:47:28 +01:00
manzolo 3bae622c2b Il Regolo del futuro (branch di Codex) 2026-11-01 18:54:37 +01:00
manzoloandClaude Opus 5.5 a8b7d599c2 La prova del peso: sempre con swiftshader, come un telefono lento, misura fotogrammi e arretrato grafico di ogni scena nelle tre fasi e di ogni primo piano (la scheda video nasconde gli effetti pesanti); si rifà quando cambia un disegno, nella pipeline sempre; test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 18:54:37 +01:00
manzoloandClaude Opus 5.5 26da5030a6 Il Regolo del futuro con la musica di Andrea (flipper-futuro.mp3, la musica cambia con la tavola: tableMusic) al posto del battito sintetico
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 18:19:27 +01:00
manzoloandClaude Opus 5.5 a9f8f88010 Le musiche del flipper, di Andrea (Suno): flipper.mp3 per la tavola del River (flipper-futuro.mp3 per quella che arriva); test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 18:18:00 +01:00
manzolo f3d3fa1b00 Allineato a main 2026-11-01 18:18:00 +01:00
manzolo f7aa92acc6 Allineato a main 2026-11-01 18:16:42 +01:00
manzoloandClaude Opus 5.5 7045aa9fcc Il Regolo del futuro: alla SUPER SPECIAL un'esplosione a sorpresa apre la tavola futuristica (Orbita del Serpente, Ponte della Fortezza sopraelevato, Spira al Neon, Regolo mobile); si torna alla classica colpendo il Regolo dopo due giri per percorso (Codex)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 18:13:59 +01:00
manzoloandClaude Opus 5.5 efff8623d8 verify: «una prova alla volta», non «1 prove»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 17:56:25 +01:00
manzolo 6fdbef1dd7 Versione 0.1.70
test / test (push) Successful in 30s
test / browser (bruciate) (push) Successful in 47s
test / browser (cerbottana) (push) Successful in 46s
test / browser (ambient) (push) Successful in 48s
test / browser (biliardo) (push) Successful in 53s
test / browser (campana) (push) Successful in 59s
test / browser (briscola) (push) Successful in 1m7s
test / browser (gara) (push) Successful in 41s
test / browser (condor) (push) Successful in 42s
test / browser (flipper) (push) Successful in 48s
test / browser (ingorgo) (push) Successful in 1m2s
test / browser (micro) (push) Successful in 57s
test / browser (risveglio) (push) Successful in 34s
test / browser (scopa) (push) Successful in 28s
test / browser (tressette) (push) Successful in 25s
test / browser (telefono) (push) Successful in 31s
test / browser (smarronata) (push) Successful in 45s
test / browser (veicoli) (push) Successful in 12s
test / browser (minigiochi) (push) Successful in 2m44s
test / browser (telefoni) (push) Successful in 2m9s
test / browser (principale) (push) Successful in 4m24s
test / release (push) Successful in 11s
2026-11-01 17:47:32 +01:00
manzoloandClaude Opus 5.5 a926ad6423 Prove nel browser sulla scheda video: tests/chromium.mjs usa la GPU se c'è (schermata 41 ms invece di 633; tutte e venti in ~4 min invece di ~16, sei alla volta), altrimenti swiftshader; la pipeline dà la GPU ai job, runner a 6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 17:47:10 +01:00
manzoloandClaude Opus 5.5 8319f58f1e Flipper da tastiera: la freccia giù carica la molla come Spazio, i due Shift comandano le palette (main.js passa ai minigiochi ShiftLeft e ShiftRight); test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 17:47:10 +01:00
manzoloandClaude Opus 5.5 21cfe8bb89 STATO: durata vera del primo giro della pipeline
test / test (push) Successful in 19s
test / browser (ambient) (push) Skipped
test / browser (biliardo) (push) Skipped
test / browser (briscola) (push) Skipped
test / browser (bruciate) (push) Skipped
test / browser (campana) (push) Skipped
test / browser (cerbottana) (push) Skipped
test / browser (condor) (push) Skipped
test / browser (flipper) (push) Skipped
test / browser (gara) (push) Skipped
test / browser (ingorgo) (push) Skipped
test / browser (micro) (push) Skipped
test / browser (minigiochi) (push) Skipped
test / browser (principale) (push) Skipped
test / browser (risveglio) (push) Skipped
test / browser (scopa) (push) Skipped
test / browser (smarronata) (push) Skipped
test / browser (telefoni) (push) Skipped
test / browser (telefono) (push) Skipped
test / browser (tressette) (push) Skipped
test / browser (veicoli) (push) Skipped
test / release (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 15:48:35 +01:00
manzoloandClaude Opus 5.5 b5a38531c4 STATO: le prove nel browser nella pipeline, primo giro verde
test / test (push) Successful in 20s
test / browser (ambient) (push) Skipped
test / browser (biliardo) (push) Skipped
test / browser (briscola) (push) Skipped
test / browser (bruciate) (push) Skipped
test / browser (campana) (push) Skipped
test / browser (cerbottana) (push) Skipped
test / browser (condor) (push) Skipped
test / browser (flipper) (push) Skipped
test / browser (gara) (push) Skipped
test / browser (ingorgo) (push) Skipped
test / browser (micro) (push) Skipped
test / browser (minigiochi) (push) Skipped
test / browser (principale) (push) Skipped
test / browser (risveglio) (push) Skipped
test / browser (scopa) (push) Skipped
test / browser (smarronata) (push) Skipped
test / browser (telefoni) (push) Skipped
test / browser (telefono) (push) Skipped
test / browser (tressette) (push) Skipped
test / browser (veicoli) (push) Skipped
test / release (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 15:46:47 +01:00
manzoloandClaude Opus 5.5 666e274140 Le prove nel browser nella pipeline di Gitea: sui tag (o a mano) le venti prove, una per job nell'immagine di Playwright, prima della release; un test pretende che l'elenco coincida con le prove di verify.mjs e che l'immagine abbia il Playwright di package-lock.json
test / test (push) Successful in 20s
test / browser (ambient) (push) Skipped
test / browser (biliardo) (push) Skipped
test / browser (briscola) (push) Skipped
test / browser (bruciate) (push) Skipped
test / browser (campana) (push) Skipped
test / browser (cerbottana) (push) Skipped
test / browser (condor) (push) Skipped
test / browser (flipper) (push) Skipped
test / browser (gara) (push) Skipped
test / browser (ingorgo) (push) Skipped
test / browser (micro) (push) Skipped
test / browser (minigiochi) (push) Skipped
test / browser (principale) (push) Skipped
test / browser (risveglio) (push) Skipped
test / browser (scopa) (push) Skipped
test / browser (smarronata) (push) Skipped
test / browser (telefoni) (push) Skipped
test / browser (telefono) (push) Skipped
test / browser (tressette) (push) Skipped
test / browser (veicoli) (push) Skipped
test / release (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 15:26:37 +01:00
manzolo 4015beb4f2 Versione 0.1.69
test / test (push) Successful in 32s
test / release (push) Successful in 14s
2026-11-01 15:19:51 +01:00
manzoloandClaude Opus 5.5 4afac814ee Prove a sezioni: il file di una prova conta solo per la prova che lo esegue (un cambio in browser.mjs non fa rifare ambient o veicoli); test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 15:13:37 +01:00
manzoloandClaude Opus 5.5 37bfe4e15e Prova nel browser: ERR_NETWORK_CHANGED in console è il PC che cambia rete a metà prova (Docker), non un errore del gioco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 15:13:15 +01:00
manzoloandClaude Opus 5.5 c701edd977 Le prove a sezioni: lo script che le pianifica non è più un ingresso delle prove (cambiarlo non fa rifare il gioco; lo prova verify.test.mjs); il piano dice quando è cambiato l'elenco dei file e non un file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 14:55:56 +01:00
manzoloandClaude Opus 5.5 a455e3f12f Le prove nel browser a sezioni (idea di Andrea): scripts/verify.mjs rifà solo le prove i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json fuori da git); make verify le usa, make verify-plan spiega; i minigiochi entrano in make verify; due alla volta, perché con quattro le schermate vanno in timeout. Test del pianificatore (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 14:51:42 +01:00
manzoloandClaude Fable 5.1 be099aa810 La scena prende la finestra (Andrea: «come mai non sfruttiamo tutto lo schermo?»): la riserva sotto non è più 250 px fissi ma l'altezza vera del pannello, in unità --u legate alla larghezza del gioco (cqw, #app è un container); frase, verbi, inventario, risposte, HUD e fumetti scalano col gioco e non con la finestra, i verbi non si tagliano più; pannello alto sempre uguale (quattro risposte piene, da cinque il carattere cala) così la scena non si muove nei dialoghi; frase su una riga coi puntini; «Schermo intero» nella Pausa; la Pausa parte dall'alto se non ci sta. A 1040×590 la scena da 604 a 842 px. Prove nel browser a 1280×860 e 1040×590, con due dialoghi (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 13:46:28 +01:00
manzoloandClaude Opus 5.5 24e9b8c055 La prova nel browser in tre processi paralleli (principale, minigiochi, telefoni), ognuno col suo server e il suo Chromium: da 525 a circa 275 secondi, tre giri su tre verdi; --parte= per una sola. Le attese della torcia hanno una scadenza di 30 s invece di 200 giri, che con la macchina carica non bastavano (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 12:35:40 +01:00
manzolo cfe7cb7fd6 Versione 0.1.68
test / test (push) Successful in 52s
test / release (push) Successful in 9s
2026-11-01 11:07:36 +01:00
manzoloandClaude Opus 5.5 9499c7792b Le Gitea Actions: test unitari a ogni push su main e sui tag, e sul tag vX.Y.Z la release con i commit dal tag prima (.gitea/workflows/test.yml); il runner sul PC si crea o riavvia con make runner (tools/gitea-runner, scripts/runner.sh, token di registrazione chiesto a Gitea con le credenziali di git); hook pre-push con check-public (make hooks) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 10:58:38 +01:00
49 changed files with 2113 additions and 480 deletions
+68
View File
@@ -0,0 +1,68 @@
# Le Gitea Actions del gioco: girano sul runner del PC di Andrea (make runner; label ubuntu-latest = node:22-bookworm).
# A ogni push su main e a ogni tag i test unitari; su un tag vX.Y.Z (o a mano, «Run workflow») anche le venti prove nel
# browser, una per job nell'immagine di Playwright, sulla scheda video del PC; se passa tutto, la release.
# Il controllo dei file pubblicabili non sta qui (a pipeline partita il push è già pubblico): è l'hook pre-push.
name: test
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test unitari
run: node --version && node --test tests/*.test.mjs
# Le prove nel browser, una per job: l'elenco è SUITES di scripts/verify.mjs (verify.test.mjs pretende che coincidano),
# l'immagine ha la stessa versione di Playwright di package-lock.json (idem).
browser:
needs: test
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
# La scheda video del PC (runtime NVIDIA di Docker): Chromium disegna lì, tests/chromium.mjs la trova da solo.
options: --gpus all -e NVIDIA_DRIVER_CAPABILITIES=all
strategy:
fail-fast: false
matrix:
prova: [principale, minigiochi, telefoni, ambient, veicoli, ingorgo, smarronata, briscola, tressette, scopa, biliardo, flipper, micro, cerbottana, condor, campana, bruciate, gara, telefono, risveglio, peso]
steps:
- uses: actions/checkout@v4
- name: Playwright
run: npm ci --no-audit --no-fund
- name: Prova ${{ matrix.prova }}
run: node scripts/verify.mjs ${{ matrix.prova }}
release:
needs: [test, browser]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Release del tag
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
{
echo "Si gioca su https://50037.manzolo.it; qui sotto lo zip di questa versione."
echo
echo "Cosa cambia${prev:+ da $prev}:"
echo
git log --no-merges --format='- %s' ${prev:+$prev..}"$TAG" | grep -v -e '^- Versione [0-9]' | cut -c1-400
} > note.md
node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"Versione "+process.env.TAG.slice(1),body:fs.readFileSync("note.md","utf8")}))' > release.json
code=$(curl -s -o risposta.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' --data @release.json "$API/releases")
cat risposta.json | head -c 400; echo
[ "$code" = 201 ] || [ "$code" = 409 ]
+1
View File
@@ -7,3 +7,4 @@ assets/generated/
.venv/ .venv/
.env .env
deploy.local.mk deploy.local.mk
.verify-ok.json
+8 -1
View File
@@ -16,7 +16,13 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p
(`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages). (`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages).
- Nessun build step: ES modules statici, server locale con `python3 -m http.server`. - Nessun build step: ES modules statici, server locale con `python3 -m http.server`.
- Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura. - Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura.
- Verifica visiva nel browser con Playwright e swiftshader prima di dichiarare finito qualcosa di grafico. - Le prove nel browser sono a sezioni (`scripts/verify.mjs`, dal 2026-11-01): `make verify` rifà solo quelle i cui ingressi
sono cambiati dall'ultimo verde (la briscola se cambia la briscola, la partita intera se cambiano storia, motore, stile
o risorse). Un `chromium.launch` nuovo nelle prove usa `args: ARGS` di `tests/chromium.mjs`.
Gli effetti pesanti li scopre la prova `peso` (`tests/peso.browser.mjs`): sempre con swiftshader, misura arretrato grafico e
fotogrammi di ogni scena e primo piano; si rifà quando cambia un disegno (`*-render.js`, `render.js`, effetti). Una prova nuova in `tests/*.browser.mjs` va aggiunta a `SUITES` (il test `verify.test.mjs` lo pretende).
- Verifica visiva nel browser con Playwright prima di dichiarare finito qualcosa di grafico. Chromium disegna sulla scheda
video del PC se c'è, altrimenti con swiftshader (`tests/chromium.mjs`, dal 2026-11-01: giro intero ~4 min invece di ~16).
- Pubblicare (tag/bump/push) solo quando Andrea lo chiede. - Pubblicare (tag/bump/push) solo quando Andrea lo chiede.
- Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti. - Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti.
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git. - Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
@@ -25,3 +31,4 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p
- **Pubblicare (dal 2026-11-01)**: origin è https://git.manzolo.it/manzolo/leggenda-del-regolo, pubblico: prima `make check-public`, poi `git push origin main --tags`, solo se Andrea lo chiede. - **Pubblicare (dal 2026-11-01)**: origin è https://git.manzolo.it/manzolo/leggenda-del-regolo, pubblico: prima `make check-public`, poi `git push origin main --tags`, solo se Andrea lo chiede.
`make deploy` costruisce l'immagine dal tag della versione, la spinge su `git.manzolo.it/manzolo/leggenda-regolo:vX.Y.Z` (e `:latest`); sul VPS il compose fissa quel tag e fa `docker compose pull && up -d`. `make deploy` costruisce l'immagine dal tag della versione, la spinge su `git.manzolo.it/manzolo/leggenda-regolo:vX.Y.Z` (e `:latest`); sul VPS il compose fissa quel tag e fa `docker compose pull && up -d`.
Per tornare indietro: sul VPS (`Workspaces/docker/leggenda-regolo/docker-compose.yml`) si rimette il tag precedente e si rifà pull e up. Per tornare indietro: sul VPS (`Workspaces/docker/leggenda-regolo/docker-compose.yml`) si rimette il tag precedente e si rifà pull e up.
Hook pre-push (`make hooks`, una volta per clone) lancia check-public; le Gitea Actions (`.gitea/workflows/test.yml`: test unitari a ogni push; sui tag, o a mano, anche le venti prove nel browser, una per job, poi la release) girano sul runner del PC, che si crea o riavvia con `make runner`.
+19 -4
View File
@@ -23,8 +23,8 @@ DST ?= assets/scenes/$(notdir $(SRC))
CROP ?= CROP ?=
COLORS ?= 64 COLORS ?= 64
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \ .PHONY: help serve open test browser verify verify-browser verify-plan check-public hooks 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 runner clean distclean
## ------------------------------------------------------------ sviluppo ## ------------------------------------------------------------ sviluppo
@@ -47,14 +47,26 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
$(NODE) --test tests/*.test.mjs $(NODE) --test tests/*.test.mjs
$(PYTHON) -m unittest discover -s tests -p "test_*.py" -q $(PYTHON) -m unittest discover -s tests -p "test_*.py" -q
browser: ## Partita completa nel browser con Playwright browser: ## Partita completa nel browser con Playwright (tre parti in processi paralleli, ~4,5 min)
$(NODE) tests/browser.mjs $(NODE) tests/browser.mjs
verify: test browser check-public ## Tutti i controlli verify: test check-public verify-browser ## Tutti i controlli: test unitari, file pubblicabili, prove nel browser cambiate (ALL=1: tutte)
# Le prove nel browser a sezioni: si rifanno solo quelle i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json,
# fuori da git); make verify-browser ALL=1 le rifà tutte, make verify-plan dice solo cosa rifarebbe; JOBS=2 in parallelo (con 4 vanno in timeout).
verify-browser: ## Le prove nel browser da rifare (ALL=1 tutte; PROVE="briscola flipper" solo quelle)
$(NODE) scripts/verify.mjs $(if $(ALL),--all,) $(PROVE)
verify-plan: ## Dice quali prove nel browser rifarebbe make verify, e perché
$(NODE) scripts/verify.mjs --plan
check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia) check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia)
bash scripts/check-public.sh bash scripts/check-public.sh
hooks: ## Attiva gli hook del repo (pre-push: check-public prima di ogni push)
git config core.hooksPath scripts/git-hooks
@echo "hook attivi da scripts/git-hooks: $$(ls scripts/git-hooks | tr '\n' ' ')"
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)
@@ -166,6 +178,9 @@ deploy: ## Pubblica la versione dell'ultimo commit: test, immagine provata qui,
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; } curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
@echo "→ $(DEPLOY_URL) ($(DEPLOY_IMAGE):$(DEPLOY_VER), $$(git log -1 --format='%h %s' $(DEPLOY_VER)))" @echo "→ $(DEPLOY_URL) ($(DEPLOY_IMAGE):$(DEPLOY_VER), $$(git log -1 --format='%h %s' $(DEPLOY_VER)))"
runner: ## Crea (se manca) e avvia sul PC il runner delle Gitea Actions (tools/gitea-runner, scripts/runner.sh)
bash scripts/runner.sh
deploy-logs: ## Log del container sul server di deploy deploy-logs: ## Log del container sul server di deploy
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; } @test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
ssh $(DEPLOY_HOST) 'cd $(DEPLOY_DIR) && docker compose logs -f --tail 50' ssh $(DEPLOY_HOST) 'cd $(DEPLOY_DIR) && docker compose logs -f --tail 50'
+3 -3
View File
@@ -42,13 +42,13 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con
- `assets/`: sfondi, sprite, musiche, voci, carattere, video. - `assets/`: sfondi, sprite, musiche, voci, carattere, video.
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi). - `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 - `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`, tutta la soluzione dell'avventura, su desktop e telefono, in tre processi paralleli; `--parte=` per una sola);
`:biliardo`, `:effects` per le parti singole. `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. - `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). - `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
```zsh ```zsh
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione make verify # test, file pubblicabili e le prove nel browser cambiate dall'ultimo verde (ALL=1: tutte)
make voci # le voci delle battute nuove (XTTS-v2 su GPU) make voci # le voci delle battute nuove (XTTS-v2 su GPU)
make bump # nuova versione: verifica, commit e tag make bump # nuova versione: verifica, commit e tag
make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica
Binary file not shown.
Binary file not shown.
+75 -8
View File
@@ -63,10 +63,21 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis. medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis.
Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio
nell'altra rampa SPECIAL: jackpot da 100.000; tre passaggi ravvicinati fanno il secondo LOCK. nell'altra rampa SPECIAL: jackpot da 100.000; tre passaggi ravvicinati fanno il secondo LOCK.
- Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti, una pallina extra in riserva e multiball a - Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti (× moltiplicatore), una pallina extra in
tre. Uscita SUPER raccordata alla corsia sinistra sopra la paletta, a velocità ridotta; i due lock liberano riserva e, dal 1 novembre, **Il Regolo del futuro**. Un botto sintetico grave, lampo, onde d'urto, schegge
le palline dai lati, sfalsati di 0,45 secondi. In multiball le rampe pagano jackpot, e una vita si perde solo colorate e scossa del mobile precedono l'apertura in due metà del piano classico. Trasformazione di 3,2 s:
all'ultima pallina. Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite. le tre palline restano protette, poi vengono servite sfalsate di 0,55 s verso le palette.
Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite.
- **Secondo campo vero**, disegnato in codice: niente corsie dei rioni o banchi classici; altri respingenti,
raccordi, tre percorsi sopraelevati e Regolo mobile sopra le palette. ORBITA DEL SERPENTE (loop esterno,
35.000 a giro), PONTE DELLA FORTEZZA (ponte incrociato, 50.000), SPIRA AL NEON (spirale più ripida,
65.000). Imbocchi dal basso, salita con perdita d'energia, quota e uscita nelle corsie interne; un tiro
debole ricade senza punti. Tabellone digitale, circuiti e bastioni immaginari, luci ciano/viola/rosa,
pulsazione musicale originale sintetizzata con Web Audio. È fantasia del mobile, nessun fatto nuovo sul paese.
- **Tre sigilli**: completare due volte ciascun percorso, in qualsiasi ordine e senza scadenza, poi colpire
il Regolo mobile: +500.000 e ritorno al classico con pallina sulla molla, senza consumare vite. Anche
perdendo l'ultima pallina si torna al classico, consumando una sola vita; il punteggio rimane. La SUPER
si può riaprire. Durante il multiball perdere una delle altre palline non scala la riserva.
- Completare una serie di bersagli alza il moltiplicatore jackpot fino a ×3, azzerato alla perdita di una vita. - Completare una serie di bersagli alza il moltiplicatore jackpot fino a ×3, azzerato alla perdita di una vita.
Restano PONTI (+50.000 alternando le rampe entro 12 secondi), PALIO (+30.000), respingenti, bersagli e Regolo. Restano PONTI (+50.000 alternando le rampe entro 12 secondi), PALIO (+30.000), respingenti, bersagli e Regolo.
Traguardi 60.000/120.000/200.000: una pallina premio una sola volta, vittoria acquisita anche uscendo prima. Traguardi 60.000/120.000/200.000: una pallina premio una sola volta, vittoria acquisita anche uscendo prima.
@@ -77,10 +88,22 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
- Luci che scorrono sulle rampe attive, frecce e spie 1/3–2/3–LOCK, rampa SUPER che si alza, fasci luminosi, - Luci che scorrono sulle rampe attive, frecce e spie 1/3–2/3–LOCK, rampa SUPER che si alza, fasci luminosi,
scintille, particelle e fanfare distinte per jackpot, apertura SUPER e multiball; la pallina resta leggibile. scintille, particelle e fanfare distinte per jackpot, apertura SUPER e multiball; la pallina resta leggibile.
Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca». Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca».
- Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni, - Test `flipper.test.mjs`: sequenze a tutti i livelli e in entrambi gli ordini, trasformazione protetta,
recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch, rilascio sfalsato, fisica e punteggi dei nuovi percorsi, tiri reali dalla paletta, Regolo mobile, finale,
screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, e la misura dell'arretrato ritorno e nuova SUPER, ball save, stato serializzabile e disegno senza mutazioni. I salvataggi dell'avventura
grafico dopo 150 fotogrammi in multiball (deve restare sotto 1,5 s). continuano a usare il checkpoint prima del minigioco, come gli altri primi piani; la pausa mantiene il
campo in corso e rilascia i comandi senza avanzare il tempo. `npm test`: 326 test verdi.
- `node scripts/verify.mjs flipper`: desktop e touch, comandi e pausa nel futuro, percorso fino al finale;
schermate di esplosione, apertura, futuro, sigilli e ritorno in `/tmp/regolo-flipper`, guardate e corrette.
Arretrato grafico misurato separatamente su 150 fotogrammi classici, esplosione/apertura e futuro, con
lettura dei pixel prima e dopo. Fondali sullo sfondo CSS, binari e pannelli in cache, onde e schegge leggere;
niente filtri o tratti arrotondati nel disegno dinamico. Soglia per ogni misura: 1,5 s.
Verifica finale (1 novembre): `REGOLO_GPU=0`, SwiftShader, classico/esplosione/futuro **31/56/26 ms**
desktop e **27/22/22 ms** touch. Anche il comando senza variabili passa, selezionando SwiftShader.
`REGOLO_GPU=1`: **30/17/25 ms** desktop e **23/18/20 ms** touch, ma il renderer effettivo è
`ANGLE (Mesa, llvmpipe (LLVM 21.1.8 256 bits), OpenGL ES 3.2)`: ancora software. In questo ambiente
non sono esposti dispositivi GPU; la misura su scheda video fisica resta da fare su un host che la esponga.
Schermate del secondo backend in `/tmp/regolo-flipper/llvmpipe`.
[DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello. [DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello.
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
@@ -302,6 +325,50 @@ versione nuova.
- **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser` - **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser`
(partita completa con clic veri in Chromium, ripresa del salvataggio, telefono). (partita completa con clic veri in Chromium, ripresa del salvataggio, telefono).
- **Impaginazione (1 novembre, Andrea: «come mai non sfruttiamo tutto lo schermo?»):** la scena prende la finestra. La
riserva sotto non è più 250 px fissi ma l'altezza vera del pannello, 13,3 unità `--u` (un centesimo della larghezza
del gioco, `cqw`, fra 8,75 e 12,8 px: `#app` è un container). Frase, verbi, inventario, risposte, HUD e fumetti scalano
col gioco, non con la finestra: i verbi non si tagliano più nelle finestre piccole. Il pannello è alto sempre uguale
(ci stanno quattro risposte; da cinque il carattere cala, da sette ancora) così la scena non si muove nei dialoghi;
la frase è una riga sola coi puntini. «Schermo intero» nella Pausa (dove il browser lo permette). A 1040×590 la scena
passa da 604 a 842 px di larghezza, a 1366×650 da 711 a 935; sui telefoni niente cambia. Prova in `browser.mjs`.
- **Prove a sezioni (1 novembre, idea di Andrea):** `scripts/verify.mjs` conosce gli ingressi di ogni prova nel browser
(i file da cui dipende: i propri, più il motore comune; per la partita intera tutto il gioco tranne i minigiochi a sé)
e rifà solo quelle la cui impronta è cambiata dall'ultimo verde, ricordato in `.verify-ok.json` fuori da git. Così
`make bump` dopo una modifica alla briscola riprova la briscola e basta; dopo una modifica alla storia la partita intera
e le scene speciali (ambient, veicoli, ingorgo, smarronata), non la briscola; dopo `style.css` o `game.js` tutto.
Le prove dei minigiochi, che prima non entravano in `make verify`, ora ci stanno. `make verify-plan` spiega.
Nella pipeline di Gitea (runner sul PC) sui tag, o a mano, girano tutte e venti, una per job nell'immagine di
Playwright, prima della release: il primo giro completo (1 novembre, a mano su main) verde in 17 minuti.
- **Prove sulla scheda video (1 novembre, Andrea: «prova con la scheda video»):** Chromium disegnava con swiftshader,
sul processore: una schermata 633 ms. Con la RTX del PC (`--use-angle=gl-egl`) 41 ms; micro da 283 a 27 s, briscola da
259 a 29, flipper da 186 a 10; tutte e venti da ~16 minuti (due alla volta) a ~4 (sei alla volta), tutte verdi. Le
opzioni stanno in `tests/chromium.mjs`: scheda video se c'è (`/dev/dri`, `/dev/nvidia0`), altrimenti swiftshader;
`REGOLO_GPU=0/1` forza. Con 6 o 8 prove insieme il giro dura uguale: il limite è la partita intera (243 s, il tempo
del gioco, non della grafica). La pipeline dà la GPU ai job (`--gpus all`, runtime NVIDIA di Docker); runner a 6 job.
- **Schermo intero e flipper (1 novembre sera, Andrea):** pulsante ⛶ accanto ai volumi e tasto F; il gioco non si ferma
più a 1280 px (fino a 2560: a 1920×1080 la scena è 1553 px e prende tutta l'altezza). Nei minigiochi la scena lascia
sotto lo spazio vero dei comandi (`fitControls` in main.js, `--below`; cresce subito, cala solo oltre 24 px, «Come si
gioca» aperto non conta: una frase che va a capo faceva saltare il piano). Al flipper «Solo tavola» nasconde la
pulsantiera (ricordato nel browser); sul telefono in orizzontale è nascosta sempre e «Lascia stare» sta in alto. La
molla si carica toccando al centro (fascia 256-384), ai lati le palette anche prima del lancio; a pallina ferma la
tavola dice «TIENI PREMUTO QUI». Ponti rifatti da Codex (parapetti, portali, luci che seguono la pallina).
- **La prova del peso (1 novembre, Andrea: «senza GPU scoprivamo anche gli effetti troppo pesanti»):** con la scheda
video le prove non vedono più un effetto pesante. `tests/peso.browser.mjs` usa sempre swiftshader e, nel gioco vero,
in ogni scena nelle tre fasi e in ogni primo piano (il flipper anche in gioco e nel Regolo del futuro) misura
fotogrammi al secondo e arretrato grafico: soglie 30 e 400 ms (una scena normale pochi ms; un `ctx.filter` blur a
tutto schermo 816, quaranta tratti larghi trasparenti 718). Due trappole trovate: leggere i pixel della tela del
gioco la fa passare al processore (Chrome) e la misura cambia ciò che misura, quindi si legge una tela nuova
640×360; la prima visita di una scena paga il caricamento delle immagini, quindi prima si smaltisce. ~5 minuti, in
parallelo con le altre; si rifà quando cambia un disegno (`watch` in verify.mjs), e nella pipeline sempre.
- **Flipper da tastiera (1 novembre, Andrea):** la freccia giù carica la molla come Spazio, i due Shift comandano le
palette (sinistro e destro, `e.code` passato da main.js ai minigiochi). Test unitario e nel browser.
## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git) ## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git)
| File | Cosa | Uso previsto | | File | Cosa | Uso previsto |
+15 -6
View File
@@ -23,12 +23,14 @@
<button id="notebook-button" hidden title="Taccuino (N)">Taccuino</button> <button id="notebook-button" hidden title="Taccuino (N)">Taccuino</button>
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button> <button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
<button id="back-button" hidden></button> <button id="back-button" hidden></button>
<button id="table-only" hidden title="Nasconde la pulsantiera: il flipper a tutta altezza (si gioca con mouse e tastiera)">Solo tavola</button>
</div> </div>
<div id="sound"> <div id="sound">
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button> <button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica"> <input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label> <label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci"> <input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
<button id="fullscreen-hud" aria-label="Schermo intero" title="Schermo intero (F)">⛶</button>
</div> </div>
<section id="splash" class="overlay" aria-label="Manzolo (Poca) Art Presents"> <section id="splash" class="overlay" aria-label="Manzolo (Poca) Art Presents">
<p class="splash-name">Manzolo</p> <p class="splash-name">Manzolo</p>
@@ -86,6 +88,7 @@
<div class="buttons pause-buttons"> <div class="buttons pause-buttons">
<button id="resume">Riprendi</button> <button id="resume">Riprendi</button>
<button id="pause-map" class="secondary">Mappa</button> <button id="pause-map" class="secondary">Mappa</button>
<button id="fullscreen" class="secondary" title="Il gioco su tutto lo schermo; Esc per uscire">Schermo intero</button>
<button id="save-file" class="secondary" title="Scarica la partita in un file da aprire su un altro dispositivo">Salva su file</button> <button id="save-file" class="secondary" title="Scarica la partita in un file da aprire su un altro dispositivo">Salva su file</button>
<button class="open-credits secondary">Ringraziamenti</button> <button class="open-credits secondary">Ringraziamenti</button>
<button id="to-title" class="secondary">Torna al titolo</button> <button id="to-title" class="secondary">Torna al titolo</button>
@@ -304,18 +307,24 @@
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p> sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
</details> </details>
<details id="flipper-help" hidden><summary>Come si gioca</summary> <details id="flipper-help" hidden><summary>Come si gioca</summary>
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa <p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure tieni
Spazio o il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla. premuto Spazio o la freccia giù, o usa il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
Le frecce sinistra e destra, i pulsanti Flipper o le due metà della scena comandano le palette; Le frecce sinistra e destra, i due Shift, i pulsanti Flipper o le due metà della scena comandano le palette;
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p> puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa <p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis. rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.</p> passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.</p>
<p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti, <p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti,
una pallina extra in riserva e MULTIBALL a tre palline! La SUPER restituisce la pallina nella corsia una pallina extra in riserva e una sorpresa esplosiva! Il piano si apre e rivela <strong>Il Regolo del futuro</strong>,
laterale verso la paletta sinistra; quelle trattenute vengono liberate dai due lati, sfalsate. un secondo campo al neon. Durante la trasformazione le tre palline sono al sicuro; poi parte il MULTIBALL,
In multiball le rampe pagano altri jackpot e perdi una vita solo quando perdi l'ultima pallina in gioco. con uscite sfalsate verso le palette. I comandi restano gli stessi.</p>
<p>Nel futuro completa due volte ciascun percorso: il loop ORBITA DEL SERPENTE vale 35.000 a giro,
la rampa sopraelevata PONTE DELLA FORTEZZA 50.000 e la SPIRA AL NEON 65.000. Contano solo i giri
completi: un tiro troppo debole ricade senza premio. Ogni percorso carica un sigillo, senza limite di tempo.
Accesi tutti e tre, colpisci il Regolo coronato che si muove al centro: 500.000 punti e ritorno al tavolo
classico, con una pallina pronta sulla molla senza consumare la riserva. Torni al classico anche perdendo
l'ultima pallina in gioco: in quel caso consumi una sola vita. I punti restano e puoi riaprire la SUPER.
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p> Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
<p>Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla <p>Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla
perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.67", "version": "0.1.73",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Prima di ogni push: il repo su git.manzolo.it è pubblico, quindi niente che check-public bocci deve partire.
# Si attiva una volta con `make hooks` (core.hooksPath = scripts/git-hooks).
cd "$(git rev-parse --show-toplevel)" || exit 1
bash scripts/check-public.sh || { echo; echo "pre-push: push bloccato da check-public (vedi sopra)."; exit 1; }
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Crea (se manca) e avvia il runner delle Gitea Actions su questo PC: `make runner`.
# Rifarlo da zero: `docker compose -f tools/gitea-runner/docker-compose.yml down`, cancellare $RUNNER_DATA e il runner
# vecchio da Gitea (repo → Impostazioni → Actions → Runner), poi di nuovo `make runner`.
# Il token di registrazione si chiede all'API di Gitea con le credenziali di git (serve lo scope write:repository).
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
COMPOSE="$ROOT/tools/gitea-runner/docker-compose.yml"
REPO=${RUNNER_REPO:-manzolo/leggenda-del-regolo}
export RUNNER_DATA=${RUNNER_DATA:-$HOME/.local/share/gitea-runner}
export GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-pc-$(hostname | tr '[:upper:]' '[:lower:]')}
mkdir -p "$RUNNER_DATA"
if [[ ! -s $RUNNER_DATA/.runner ]]; then
pass=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^password=//p')
user=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^username=//p')
[[ -n $pass ]] || { echo "nessuna credenziale git per git.manzolo.it"; exit 1; }
tok=$(curl -sf -X POST -u "$user:$pass" "https://git.manzolo.it/api/v1/repos/$REPO/actions/runners/registration-token" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') ||
{ echo "Gitea non ha dato il token di registrazione (scope write:repository?)"; exit 1; }
echo "registro il runner $GITEA_RUNNER_NAME su $REPO"
GITEA_RUNNER_REGISTRATION_TOKEN=$tok docker compose -f "$COMPOSE" up -d
for _ in $(seq 30); do [[ -s $RUNNER_DATA/.runner ]] && break; sleep 1; done
[[ -s $RUNNER_DATA/.runner ]] || { docker logs --tail 20 gitea-runner; echo "registrazione non riuscita"; exit 1; }
chmod 600 "$RUNNER_DATA/.runner" 2>/dev/null || true
fi
# Senza token nell'ambiente del container: da qui in poi si autentica con .runner.
docker compose -f "$COMPOSE" up -d
# config.yaml si legge solo all'avvio: se è cambiato (capacità, opzioni) il runner riparte.
[[ $(docker inspect -f '{{.State.StartedAt}}' gitea-runner | date -f - +%s) -lt $(stat -c %Y "$(dirname "$COMPOSE")/config.yaml") ]] && docker restart gitea-runner >/dev/null
sleep 2; docker logs --tail 1 gitea-runner
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Le prove nel browser a sezioni (idea di Andrea, 1 novembre 2026): ogni prova ha i suoi ingressi, i file da cui dipende;
// si rifà solo se la loro impronta (i blob di git, più i file modificati nella cartella di lavoro) è cambiata dall'ultimo
// verde, ricordato in .verify-ok.json (fuori da git). Se cambia la briscola si riprova la briscola; se cambia la storia, il
// motore, l'impaginazione o le risorse si riprova la partita intera (e le prove delle scene speciali), non la briscola.
// node scripts/verify.mjs le prove da rifare, in parallelo, e aggiorna i verdi (JOBS: quante insieme; con la
// scheda video JOBS_GPU, senza 2: con quattro Chromium swiftshader le schermate vanno in timeout)
// node scripts/verify.mjs --plan dice solo cosa rifarebbe e perché
// node scripts/verify.mjs --all tutte, senza guardare i verdi
// node scripts/verify.mjs briscola solo quelle nominate (anche se verdi)
// I test unitari e check-public non stanno qui: costano pochi secondi e girano sempre (make verify).
import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { cpus } from 'node:os';
import { GPU } from '../tests/chromium.mjs';
// Il motore comune a tutto: cambia questo e si riprova ogni minigioco.
export const ENGINE = ['index.html', 'style.css', 'src/main.js', 'src/engine/game.js', 'src/engine/render.js',
'tests/helpers.mjs', 'tests/chromium.mjs', 'package-lock.json'];
// Non toccano il gioco nel browser: documenti, strumenti, pubblicazione, versione (make bump la cambia prima di verificare),
// test unitari (girano sempre, a parte), e questo stesso script (lo prova verify.test.mjs).
export const IGNORED = [/^docs\//, /^[^/]*\.md$/, /^src\/version\.js$/, /^package\.json$/, /^Makefile$/, /^scripts\//,
/^tools\//, /^video\//, /^\.gitea\//, /^prompts\//, /^LICENSE$/, /^Dockerfile$/, /^nginx\.conf$/, /^docker-compose/, /^requirements\.txt$/,
/^\.(gitignore|dockerignore|verify-ok\.json)$/, /^tests\/[^/]*\.test\.mjs$/, /^tests\/test_[^/]*\.py$/, /^deploy\.local\.mk$/, /(^|\/)__pycache__\//];
// Le prove. `core: true` = dipende da tutto il gioco tranne i file dei minigiochi a sé (che hanno la loro prova); altrimenti
// solo dal motore e dai propri file: quelli che si chiamano come la prova (src/engine/flipper.js, flipper-render.js,
// assets/closeups/flipper-*.png, docs/BRISCOLA.md…) più gli `extra`. `watch`: file che la prova guarda senza toglierli
// alle altre (il peso della grafica si rifà quando cambia un disegno, ma quel disegno resta anche della sua prova).
// `run` sono gli argomenti di node.
export const SUITES = {
principale: { core: true, run: ['tests/browser.mjs', '--parte=principale'], note: 'la partita intera, pausa e salvataggi' },
minigiochi: { core: true, run: ['tests/browser.mjs', '--parte=minigiochi'], note: 'libro, bocce, tiri, pesca e biliardo dalla partita' },
telefoni: { core: true, run: ['tests/browser.mjs', '--parte=telefoni'], note: 'multimedia, telefoni, la finestra di Andrea, debug' },
ambient: { core: true, run: ['tests/ambient.browser.mjs'], note: 'tutte le scene nelle tre fasi e gli effetti' },
veicoli: { core: true, run: ['tests/veicoli.browser.mjs'], note: 'i veicoli nelle scene' },
ingorgo: { core: true, run: ['tests/ingorgo.browser.mjs'], note: "l'Ingorgo Sonoro" },
smarronata: { core: true, run: ['tests/smarronata.browser.mjs'], note: 'la Smarronata del 1993' },
briscola: { run: ['tests/briscola.browser.mjs'] },
tressette: { run: ['tests/tressette.browser.mjs'] },
scopa: { run: ['tests/scopa.browser.mjs'] },
biliardo: { run: ['tests/biliardo.browser.mjs'] },
flipper: { run: ['tests/flipper.browser.mjs'] },
micro: { run: ['tests/micro.browser.mjs'] },
cerbottana: { run: ['tests/cerbottana.browser.mjs'] },
condor: { run: ['tests/condor.browser.mjs'] },
campana: { run: ['tests/campana.browser.mjs'] },
bruciate: { run: ['tests/bruciate.browser.mjs'] },
gara: { run: ['tests/gara.browser.mjs'], extra: ['src/engine/corsa.js', 'src/story/piloti.js', /^assets\/[^/]*\/(gara|gp|pilot)/] },
telefono: { run: ['tests/telefono.browser.mjs'], extra: [/^assets\/muro\//] },
risveglio: { run: ['tests/risveglio.browser.mjs'] },
// Sempre con swiftshader, come un telefono lento: con la scheda video le altre prove non vedono gli effetti pesanti.
peso: { run: ['tests/peso.browser.mjs'], note: 'il peso della grafica, senza scheda video',
watch: [/-render\.js$/, /^src\/engine\/(ambient|incanto|risveglio|torch|viaggio|corsa|telefono)\.js$/, /^src\/story\/ambience\.js$/, /^assets\/effects\//] },
};
// Prove insieme con la scheda video (misurato il 1° novembre 2026 sul PC di Andrea, 16 thread e RTX 3080 Ti).
const JOBS_GPU = 6; // con 6 e con 8 il giro intero dura uguale (~4 min): da solo «principale» ne prende 243 s
const ownRe = name => new RegExp(`(^|/)${name}([-_.][^/]*)?$`, 'i');
const matches = (path, rule) => rule instanceof RegExp ? rule.test(path) : rule === path;
export const ignored = path => IGNORED.some(r => r.test(path));
export function ownFiles(name, files) {
const s = SUITES[name], re = ownRe(name);
return files.filter(p => re.test(p) || (s.extra || []).some(r => matches(p, r)));
}
// Gli ingressi di una prova fra i file dati (tutti quelli di git più quelli nuovi nella cartella).
export function inputsOf(name, files) {
const s = SUITES[name], live = files.filter(p => !ignored(p));
if (!s.core) return [...new Set([...ENGINE.filter(p => files.includes(p)), ...ownFiles(name, live),
...live.filter(p => (s.watch || []).some(r => matches(p, r)))])].sort();
const others = Object.keys(SUITES).filter(n => !SUITES[n].core);
// I file degli altri minigiochi, e i file di prova delle altre prove (tests/ambient.browser.mjs non riguarda la partita).
const foreign = new Set([...others.flatMap(n => ownFiles(n, live)),
...Object.keys(SUITES).filter(n => SUITES[n].run[0] !== s.run[0]).map(n => SUITES[n].run[0])]);
return live.filter(p => !foreign.has(p)).sort();
}
export const fingerprint = (paths, hashOf) => createHash('sha1').update(paths.map(p => `${p} ${hashOf(p)}`).join('\n')).digest('hex');
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
// I file del progetto con l'impronta di ognuno: il blob dell'indice di git, o il contenuto attuale se modificato o nuovo.
export function snapshot() {
const hashes = new Map();
for (const line of git('ls-files', '-s', '-z').split('\0')) if (line) { const [meta, path] = line.split('\t'); hashes.set(path, meta.split(' ')[1]); }
const dirty = [];
for (const line of git('status', '--porcelain', '-z', '--untracked-files=all').split('\0')) {
if (!line) continue;
const code = line.slice(0, 2), path = line.slice(3);
if (code.includes('D')) hashes.delete(path); else dirty.push(path);
}
for (const path of dirty) if (existsSync(`${ROOT}/${path}`)) hashes.set(path, git('hash-object', '--', path).trim());
return hashes;
}
const OK_FILE = `${ROOT}/.verify-ok.json`;
const readOk = () => { try { return JSON.parse(readFileSync(OK_FILE, 'utf8')); } catch { return {}; } };
export function plan(hashes, ok, names = Object.keys(SUITES)) {
const files = [...hashes.keys()], out = [];
for (const name of names) {
const inputs = inputsOf(name, files), fp = fingerprint(inputs, p => hashes.get(p));
const was = ok[name], changed = was?.fingerprint === fp ? [] : inputs.filter(p => !was?.hashes || was.hashes[p] !== hashes.get(p));
out.push({ name, fp, inputs, changed, green: was?.fingerprint === fp, seconds: was?.seconds });
}
return out;
}
function run(name) {
return new Promise(resolve => {
const t0 = Date.now(), lines = [];
const p = spawn(process.execPath, SUITES[name].run, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
let rest = '';
const onData = chunk => { const parts = (rest + chunk).split('\n'); rest = parts.pop(); for (const l of parts) { lines.push(l); console.log(`[${name}] ${l}`); } };
p.stdout.on('data', onData); p.stderr.on('data', onData);
p.on('close', code => { if (rest) console.log(`[${name}] ${rest}`); resolve({ name, code, seconds: Math.round((Date.now() - t0) / 1000) }); });
});
}
async function main() {
const args = process.argv.slice(2), all = args.includes('--all'), onlyPlan = args.includes('--plan');
const named = args.filter(a => !a.startsWith('--'));
for (const n of named) if (!SUITES[n]) { console.error(`prova sconosciuta: ${n} (${Object.keys(SUITES).join(', ')})`); process.exit(2); }
const hashes = snapshot(), ok = readOk();
const todo = plan(hashes, ok, named.length ? named : undefined).filter(p => all || named.length || !p.green);
const green = Object.keys(SUITES).filter(n => !todo.some(t => t.name === n));
if (!todo.length) { console.log(`Prove nel browser: tutte verdi dall'ultimo giro (${green.length}); niente da rifare.`); return; }
for (const t of todo) {
const why = all ? 'tutte' : named.includes(t.name) ? 'chiesta' : !ok[t.name] ? 'mai provata' : !t.changed.length ? "cambiato l'elenco dei file da cui dipende" : `cambiati ${t.changed.slice(0, 4).join(', ')}${t.changed.length > 4 ? ` e altri ${t.changed.length - 4}` : ''}`;
console.log(`da rifare: ${t.name}${t.seconds ? ` (~${t.seconds} s)` : ''}: ${why}`);
}
if (green.length && !named.length) console.log(`già verdi: ${green.join(', ')}`);
if (onlyPlan) return;
const jobs = Math.max(1, Number(process.env.JOBS) || Math.min(GPU ? JOBS_GPU : 2, Math.floor(cpus().length / (GPU ? 2 : 4))));
console.log(`Chromium ${GPU ? 'sulla scheda video' : 'con swiftshader'}, ${Math.min(jobs, todo.length) === 1 ? "una prova" : `${Math.min(jobs, todo.length)} prove`} alla volta.`);
const queue = todo.sort((a, b) => (b.seconds || 999) - (a.seconds || 999)), results = [];
const worker = async () => { for (let t; (t = queue.shift());) {
const r = await run(t.name); results.push(r);
if (r.code === 0) {
const now = readOk();
now[t.name] = { fingerprint: t.fp, seconds: r.seconds, when: new Date().toISOString(), hashes: Object.fromEntries(t.inputs.map(p => [p, hashes.get(p)])) };
writeFileSync(OK_FILE, JSON.stringify(now, null, 1) + '\n');
}
} };
await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker));
for (const r of results.sort((a, b) => a.name.localeCompare(b.name))) console.log(`${r.code === 0 ? 'OK ' : 'FAIL'} ${r.name} (${r.seconds} s)`);
const failed = results.filter(r => r.code !== 0).map(r => r.name);
if (failed.length) { console.log(`Prove fallite: ${failed.join(', ')}`); process.exit(1); }
console.log(`Prove nel browser OK: ${results.length} rifatte${named.length ? '' : `, ${green.length} già verdi`}.`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
+185
View File
@@ -0,0 +1,185 @@
// Illustrazioni vettoriali del backglass, riusate come inserti in lacca sul campo.
import { activeBalls } from './flipper.js';
export const SCENES = {
regolo: { color: '#91f5b1', title: 'IL REGOLO SI RISVEGLIA', note: 'IL CUSTODE DELLA SIEVE', at: [320, 193] },
fortezza: { color: '#65ddec', title: 'LA FORTEZZA SI ACCENDE', note: 'SOPRA I BASTIONI', at: [320, 119] },
trebbio: { color: '#ffc16c', title: 'LA TORRE DEL TREBBIO', note: 'UN PASSAGGIO NELLA STORIA', at: [365, 164] },
pieve: { color: '#ffaccc', title: 'LE LUCI DELLA PIEVE', note: 'NEL CUORE DEL PAESE', at: [240, 174] },
rioni: { color: '#ffe6a0', title: 'I COLORI DEI RIONI', note: 'QUATTRO COLORI, UN PAESE', at: [320, 88] },
sieve: { color: '#6fcfff', title: 'SULLE ONDE DELLA SIEVE', note: 'IL VIAGGIO CONTINUA', at: [320, 280] },
cosmo: { color: '#dba5ff', title: 'IL REGOLO DEL FUTURO', note: 'OLTRE IL TEMPO', at: [320, 193] },
spira: { color: '#ff729e', title: 'NELLA SPIRA AL NEON', note: 'ENERGIA IN MOVIMENTO', at: [376, 170] },
};
const RAMP_SCENE = { sinistra: 'fortezza', destra: 'trebbio', super: 'cosmo', orbita: 'cosmo', ponte: 'fortezza', spira: 'spira' };
// Solo lettura: nessun evento, punteggio o numero casuale della simulazione viene consumato dal disegno.
export function flipperScene(c) {
const balls = activeBalls(c).filter(b => !b.waiting && b.releaseAt <= c.sim);
if (c.regolo.hit > 0 || balls.some(b => !b.ramp && Math.hypot(b.x - c.regolo.x, b.y - c.regolo.y) < 33)) return 'regolo';
const ramp = Object.keys(RAMP_SCENE).find(id => balls.some(b => b.ramp?.id === id));
if (ramp) return RAMP_SCENE[ramp];
if (!balls.length) return c.table === 'future' ? 'cosmo' : 'regolo';
if (c.bumpers.some(b => b.hit > 0)) return 'fortezza';
// Priorità stabile anche in multiball, indipendente dall'ordine delle palline.
if (balls.some(b => b.y < 106)) return c.table === 'future' ? 'cosmo' : 'rioni';
if (balls.some(b => b.x < 242 && b.y < 218)) return 'pieve';
if (balls.some(b => b.x > 398 && b.x < 432 && b.y < 218)) return 'trebbio';
if (balls.some(b => b.y < 180)) return 'fortezza';
if (balls.some(b => b.y < 244)) return c.table === 'future' ? 'cosmo' : 'regolo';
return 'sieve';
}
const TAU = Math.PI * 2;
function stroke(g, pts, color, width = 1) {
g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y));
g.strokeStyle = color; g.lineWidth = width; g.stroke();
}
function fill(g, pts, color) {
g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y));
g.closePath(); g.fillStyle = color; g.fill();
}
function circle(g, x, y, r, color) { g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill(); }
export function paintRegolo(g, x, y, size, color = '#91f5b1') {
g.save(); g.translate(x, y); g.scale(size / 30, size / 30); g.lineCap = 'round'; g.lineJoin = 'round';
// Un serpente a spire, con ventre dorato, cresta, occhio e corona: leggibile anche come piccolo emblema.
const body = () => {
g.beginPath(); g.moveTo(-29, 22);
g.bezierCurveTo(-8, 28, 29, 23, 23, 7);
g.bezierCurveTo(17, -9, -24, 0, -18, 13);
g.bezierCurveTo(-13, 24, 13, 12, 9, 0);
g.bezierCurveTo(5, -11, -9, -9, -4, -23);
};
body(); g.strokeStyle = '#061d26'; g.lineWidth = 12; g.stroke();
body(); g.strokeStyle = color; g.lineWidth = 8; g.stroke();
body(); g.strokeStyle = '#153f43'; g.lineWidth = 4.6; g.stroke();
g.save(); g.translate(0, 2); body(); g.strokeStyle = '#ffe5a6'; g.lineWidth = 1.1; g.stroke(); g.restore();
for (let i = 0; i < 7; i++) {
const a = i * 0.36;
stroke(g, [[-16 + i * 5, 20 - Math.sin(a) * 3], [-15 + i * 5, 17 - Math.sin(a) * 3]], color, 1);
}
fill(g, [[-9, -26], [-4, -31], [5, -29], [12, -23], [9, -18], [0, -18], [-5, -14]], color);
stroke(g, [[3, -18], [11, -19], [15, -17], [18, -19]], '#ff92a7', 1);
circle(g, 4, -25, 2.1, '#071821'); circle(g, 4.5, -25.6, 0.8, '#ffffff');
fill(g, [[-8, -33], [-11, -43], [-4, -38], [0, -47], [5, -38], [12, -43], [8, -33]], '#ffdc88');
stroke(g, [[-8, -32], [8, -32]], '#fff5d5', 1.6); circle(g, 0, -38, 1.4, '#ff729e');
g.restore();
}
export function paintZoneSymbol(g, id) {
const { color, at: [x, y] } = SCENES[id];
g.save(); g.translate(x, y); g.lineJoin = 'round';
if (id === 'fortezza' || id === 'trebbio' || id === 'pieve') {
const roof = id === 'pieve' ? [[-12, -2], [0, -13], [12, -2]]
: id === 'trebbio' ? [[-7, 8], [-7, -14], [-3, -14], [-3, -10], [3, -10], [3, -14], [7, -14], [7, 8]]
: [[-14, 7], [-14, -7], [-9, -7], [-9, -3], [-4, -3], [-4, -10], [4, -10], [4, -3], [9, -3], [9, -7], [14, -7], [14, 7]];
stroke(g, roof, color, 1.3);
stroke(g, [[-10, 0], [-10, 9], [10, 9], [10, 0]], color, 0.8);
stroke(g, [[-3, 8], [-3, 2], [0, -1], [3, 2], [3, 8]], '#fff1c1', 0.8);
} else if (id === 'rioni') {
for (let i = 0; i < 4; i++) {
const px = -15 + i * 10;
fill(g, [[px, -10], [px + 7, -10], [px + 7, 3], [px + 3.5, 7], [px, 3]], ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'][i]);
}
} else if (id === 'sieve') {
for (let i = 0; i < 3; i++) {
g.beginPath(); g.moveTo(-19, -6 + i * 6); g.bezierCurveTo(-8, -14 + i * 6, 8, 2 + i * 6, 19, -6 + i * 6);
g.strokeStyle = color; g.lineWidth = 1.2; g.stroke();
}
} else if (id === 'spira') {
for (let i = 0; i < 3; i++) {
g.beginPath(); g.ellipse(0, 0, 6 + i * 5, 4 + i * 4, -0.5, 0, TAU);
g.strokeStyle = color; g.lineWidth = 0.9; g.stroke();
}
}
g.restore();
}
function fortress(g, tower, color) {
fill(g, [[18, 69], [18, 43], [28, 39], [28, 32], [35, 32], [35, 41], [51, 40], [51, 29], [61, 29], [61, 39], [87, 37], [87, 26], [97, 26], [97, 36], [119, 34], [119, 29], [129, 29], [129, 65]], '#163648');
stroke(g, [[18, 43], [35, 41], [51, 40], [61, 39], [87, 37], [97, 36], [129, 34]], color, 1.5);
if (tower) {
fill(g, [[61, 63], [61, 14], [66, 14], [66, 19], [73, 19], [73, 12], [79, 12], [79, 19], [86, 19], [86, 14], [91, 14], [91, 63]], '#365062');
stroke(g, [[61, 63], [61, 14], [66, 14]], color, 1.3);
for (const y of [27, 40, 53]) { g.fillStyle = '#ffd590'; g.fillRect(73, y, 5, 7); }
}
for (let y = 46; y < 67; y += 7) stroke(g, [[22, y], [126, y - 6]], '#426076', 0.5);
for (const x of [33, 49, 101, 117]) { g.fillStyle = color; g.fillRect(x, 47, 2, 6); }
fill(g, [[67, 70], [67, 55], [73, 50], [80, 55], [80, 70]], '#071927');
stroke(g, [[14, 72], [74, 65], [135, 70]], color, 1);
}
// Una scena di 150 × 88, interamente in cache nel renderer.
export function paintFlipperScene(g, id) {
const color = SCENES[id].color;
const sky = g.createLinearGradient(0, 0, 150, 88);
sky.addColorStop(0, '#071125'); sky.addColorStop(0.6, id === 'trebbio' ? '#38273b' : '#15364a'); sky.addColorStop(1, '#071925');
g.fillStyle = sky; g.fillRect(0, 0, 150, 88);
circle(g, 120, 19, 13, color + '18'); circle(g, 120, 19, 8, '#fff0be'); circle(g, 117, 16, 7, '#153044');
for (let i = 0; i < 20; i++) circle(g, (i * 47 + 13) % 150, (i * 23 + 9) % 57, i % 3 ? 0.5 : 0.9, color + 'aa');
fill(g, [[0, 59], [21, 37], [44, 53], [77, 27], [108, 51], [139, 33], [150, 40], [150, 88], [0, 88]], '#142b3c');
fill(g, [[0, 74], [39, 53], [67, 64], [99, 44], [150, 62], [150, 88], [0, 88]], '#1f4350');
for (let i = 0; i < 5; i++) {
g.beginPath(); g.moveTo(0, 74 + i * 3); g.bezierCurveTo(52, 58 + i * 3, 78, 91 + i * 2, 150, 67 + i * 4);
g.strokeStyle = i % 2 ? '#69b6ce66' : '#102837'; g.lineWidth = 1; g.stroke();
}
if (id === 'fortezza' || id === 'trebbio') fortress(g, id === 'trebbio', color);
else if (id === 'pieve') {
fill(g, [[30, 69], [30, 43], [59, 25], [87, 43], [87, 69]], '#406077');
fill(g, [[87, 68], [87, 23], [94, 13], [101, 23], [101, 68]], '#29495e');
stroke(g, [[26, 44], [59, 22], [90, 44]], color, 2);
circle(g, 59, 43, 7, '#142a3e'); circle(g, 59, 43, 4, '#ffc8de');
fill(g, [[52, 69], [52, 58], [59, 53], [66, 58], [66, 69]], '#102334');
g.fillStyle = '#ffdfa2'; g.fillRect(91, 28, 6, 9);
} else if (id === 'rioni') {
['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'].forEach((c, i) => {
const x = 24 + i * 29, y = 23 + (i % 2) * 5;
stroke(g, [[x, y - 4], [x, 69]], '#d5c7a1', 1.2);
fill(g, [[x + 2, y], [x + 23, y + 3], [x + 23, y + 30], [x + 12, y + 38], [x + 2, y + 30]], c);
stroke(g, [[x + 12, y + 7], [x + 12, y + 27]], '#213347', 2);
stroke(g, [[x + 6, y + 17], [x + 18, y + 17]], '#213347', 2);
});
} else if (id === 'sieve') {
g.beginPath(); g.moveTo(10, 65); g.quadraticCurveTo(74, 9, 140, 65);
g.strokeStyle = '#0a1b2c'; g.lineWidth = 10; g.stroke(); g.strokeStyle = color; g.lineWidth = 2; g.stroke();
for (const x of [28, 49, 74, 99, 122]) stroke(g, [[x, 49 - Math.sin(x / 150 * Math.PI) * 10], [x, 73]], '#5f8b9f', 3);
paintRegolo(g, 77, 70, 12, '#91f5b1');
} else {
for (let i = 0; i < 3; i++) {
g.beginPath(); g.ellipse(75, 44, 28 + i * 12, 23 + i * 8, -0.4 + i * 0.3, 0, TAU);
g.strokeStyle = color + (i ? '55' : 'aa'); g.lineWidth = 0.8; g.stroke();
}
paintRegolo(g, 75, 51, id === 'spira' ? 26 : 30, color);
}
// Riflessi e retino del vetro inciso.
for (let y = 2; y < 88; y += 3) stroke(g, [[0, y], [150, y]], '#050f1c22', 0.5);
}
export function paintPlayfieldArt(g, future = false) {
const color = future ? '#ad8cff' : '#74c5bd';
g.save();
// Rosone centrale, smalti e serpente coronato sotto il piano di gioco.
for (const r of [30, 33, 43]) {
g.beginPath(); g.ellipse(320, 194, r, r * 0.83, 0, 0, TAU);
g.strokeStyle = color + '55'; g.lineWidth = r === 33 ? 1.4 : 0.6; g.stroke();
}
for (let i = 0; i < 16; i++) {
const a = i / 16 * TAU;
stroke(g, [[320 + Math.cos(a) * 37, 194 + Math.sin(a) * 31], [320 + Math.cos(a) * 41, 194 + Math.sin(a) * 34]], '#d6be7855', 0.8);
}
g.globalAlpha = 0.46; paintRegolo(g, 320, 202, 25, color); g.globalAlpha = 1;
for (const [x, side, tint] of [[239, -1, '#65ddec'], [402, 1, '#ffc16c']]) {
stroke(g, [[x, 217], [x - side * 10, 230], [x - side * 10, 242]], tint + '66', 1);
for (let i = 0; i < 3; i++) fill(g, [[x, 209 + i * 8], [x + 2, 212 + i * 8], [x, 215 + i * 8], [x - 2, 212 + i * 8]], tint + '88');
}
// Filetti dorati delle sponde e frecce smaltate verso le palette.
for (const side of [-1, 1]) {
const x = 320 + side * 67;
stroke(g, [[x, 263], [x - side * 14, 279], [x - side * 22, 300]], '#e7c88955', 0.8);
for (let i = 0; i < 3; i++) {
const cx = x - side * i * 7, y = 274 + i * 10;
stroke(g, [[cx - 3, y - 2], [cx, y + 1], [cx + 3, y - 2]], color + '77', 1);
}
}
g.restore();
}
+294 -18
View File
@@ -1,10 +1,11 @@
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi. // Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia. // Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
import { R, SCORE, RAMP, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; import { R, SCORE, RAMP, FUTURE, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { ambientHash as hash } from './ambient.js'; import { ambientHash as hash } from './ambient.js';
import { SCENES, flipperScene, paintRegolo, paintZoneSymbol, paintFlipperScene, paintPlayfieldArt } from './flipper-art.js';
const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa'; const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff' }; const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff', orbita: '#58f4e2', ponte: '#c1a0ff', spira: '#ff729e' };
const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff']; const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'];
const TAU = Math.PI * 2, clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const TAU = Math.PI * 2, clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const number = n => Math.round(n).toLocaleString('it-IT'); const number = n => Math.round(n).toLocaleString('it-IT');
@@ -56,6 +57,74 @@ function cached(g, key, paint, bounds = [0, 0, 640, 360]) {
if (typeof document === 'undefined') { paint(g); return; } if (typeof document === 'undefined') { paint(g); return; }
g.drawImage(layerOf(key, paint, bounds), ...bounds); g.drawImage(layerOf(key, paint, bounds), ...bounds);
} }
// Il display cambia con il percorso. Una breve permanenza e la dissolvenza evitano il tremolio sui confini.
const apparitions = new WeakMap();
function apparition(c, time) {
const next = flipperScene(c);
let d = apparitions.get(c);
if (!d || time < d.at || d.table !== c.table) {
d = { id: next, previous: next, at: time, pending: next, since: time, table: c.table }; apparitions.set(c, d);
}
if (next !== d.pending) { d.pending = next; d.since = time; }
if (next !== d.id && ((c.regolo.hit > 0) || (time - d.at > 0.65 && time - d.since > 0.12))) {
d.previous = d.id; d.id = next; d.at = time;
}
return d;
}
function livingBackglass(g, c, time) {
const d = apparition(c, time), scene = SCENES[d.id], fade = clamp((time - d.at) / 0.32, 0, 1);
box(g, 22, 236, 154, 91, '#061422', scene.color, 5);
g.save(); g.beginPath(); g.roundRect(24, 238, 150, 87, 4); g.clip();
const paint = id => cached(g, `vision:${id}`, l => { l.save(); l.translate(24, 238); paintFlipperScene(l, id); l.restore(); }, [24, 238, 150, 88]);
paint(d.previous);
g.save(); g.globalAlpha = fade; paint(d.id); g.restore();
// Poche luci nel cielo, animate separatamente dall'illustrazione in cache.
for (let i = 0; i < 5; i++) {
const x = 36 + i * 29, y = 254 + Math.sin(time * 0.8 + i * 2) * 6;
glow(g, x, y, 5, scene.color, 0.35 + Math.sin(time * 2 + i) * 0.2);
}
g.fillStyle = '#030b18c9'; g.fillRect(24, 238, 150, 12); g.fillRect(24, 309, 150, 16);
text(g, scene.title, 99, 244, 5.7, scene.color, 'center');
text(g, scene.note, 99, 317, 5, '#e5e9d8', 'center');
g.restore();
for (const x of [19, 179]) {
glow(g, x, 280, 15, scene.color, 0.4);
line(g, [[x, 256], [x, 304]], scene.color, 1.2);
}
}
function livingPlayfield(g, c, time) {
const d = apparition(c, time), scene = SCENES[d.id], balls = activeBalls(c).filter(b => !b.waiting && b.releaseAt <= c.sim);
const pulse = 0.5 + Math.sin(time * 2.5) * 0.12;
// Il rosone si risveglia sotto gli oggetti: palline, palette e bersagli si disegnano dopo.
glow(g, ...scene.at, 49, scene.color, balls.length ? 0.48 : 0.2);
g.save(); g.globalAlpha = (d.id === 'regolo' || d.id === 'cosmo' ? 0.65 : 0.16) * pulse;
cached(g, `regolo-insert:${scene.color}`, l => paintRegolo(l, 320, 202, 25, scene.color), [290, 160, 60, 70]);
g.restore();
if (balls.length && d.id !== 'regolo' && d.id !== 'cosmo') {
g.save(); g.globalAlpha = clamp((time - d.at) / 0.3, 0, 0.7);
cached(g, `zone-insert:${d.id}`, l => paintZoneSymbol(l, d.id), [scene.at[0] - 22, scene.at[1] - 18, 44, 36]);
g.restore();
}
for (const side of [-1, 1]) for (let i = 0; i < 18; i++) {
const x = side < 0 ? 196 : 452, y = 43 + i * 16;
const near = balls.some(b => Math.abs(b.y - y) < 25 && (side < 0 ? b.x < 325 : b.x > 315));
const chase = (i + Math.floor(time * (balls.length ? 9 : 3))) % 7 === 0;
const color = near ? scene.color : i % 3 === 0 ? '#c9a5ff' : side < 0 ? TINT.sinistra : TINT.destra;
box(g, x - 1, y - 2, 2, 4, near || chase ? color : '#2b4351', null, 0.6);
if (near || chase) glow(g, x, y, near ? 10 : 6, color, near ? 0.6 : 0.3);
}
for (const b of balls) {
if (b.ramp) continue;
glow(g, b.x, b.y, 21, scene.color, 0.25);
// Piccoli inserti a rombo, accesi dalla vicinanza della pallina.
for (const [x, y] of [[272, 175], [367, 175], [292, 274], [348, 274], [320, 297]]) {
const near = Math.hypot(b.x - x, b.y - y) < 48;
if (!near) continue;
glow(g, x, y, 12, scene.color, 0.6);
poly(g, [[x, y - 3], [x + 2, y], [x, y + 3], [x - 2, y]], scene.color);
}
}
}
// Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma: // Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma:
// una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La // una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La
// tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta. // tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta.
@@ -68,7 +137,7 @@ function cabinetBehind(g, key, paint) {
if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; } if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; }
return true; return true;
} }
export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; } export function cabinetAway(canvas) { canvas.style.backgroundPosition = ''; canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; }
function crown(g, x, y, size, color) { function crown(g, x, y, size, color) {
poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color); poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color);
line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1); line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1);
@@ -111,6 +180,11 @@ function cabinet(g, c, bg, glass) {
// I bastioni stilizzati al fondo del piano. // I bastioni stilizzati al fondo del piano.
poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358'); poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358');
text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center'); text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center');
paintPlayfieldArt(g);
for (const [i, x] of [257, 297, 340, 381].entries()) {
poly(g, [[x - 5, 83], [x + 5, 83], [x + 5, 92], [x, 96], [x - 5, 92]], RIONI[i] + '88', '#d8d7ab66');
line(g, [[x, 85], [x, 91]], '#0b263b', 1.2);
}
// La corsia della molla è una scanalatura separata, con il fondo metallico. // La corsia della molla è una scanalatura separata, con il fondo metallico.
g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264); g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264);
line(g, [[440, 86], [440, 306]], '#45606c', 0.5); line(g, [[440, 86], [440, 306]], '#45606c', 0.5);
@@ -151,7 +225,8 @@ function paintTrack(g, r) {
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3); line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
screw(g, p.x + 7, p.y + 10); screw(g, p.x + 7, p.y + 10);
} }
const track = (color, width) => { const track = (color, width, points = pts) => {
const pts = points;
g.beginPath(); g.moveTo(...pts[0]); g.beginPath(); g.moveTo(...pts[0]);
for (let i = 1; i < pts.length - 1; i++) { for (let i = 1; i < pts.length - 1; i++) {
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1]; const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
@@ -165,7 +240,24 @@ function paintTrack(g, r) {
track(color + '18', 20); track('#02080e', 16); track(color, 14); track(color + '18', 20); track('#02080e', 16); track(color, 14);
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10); track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8); track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
track(color + '24', 4); track(color + '38', 4);
// Parapetti rialzati: montanti e tiranti triangolari fanno leggere il ponte anche da fermo.
for (const side of [-1, 1]) {
let previous;
for (let s = 34; s < r.len - 10; s += 18) {
const p = rampPoint(r, s), foot = [p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side];
const top = [foot[0], foot[1] - 5];
line(g, [foot, top], '#a6c6d2', 0.8);
if (previous) line(g, [previous, foot], color + '88', 0.65);
disc(g, ...top, 0.9, color); previous = top;
}
const rail = pts.map(([x, y], i) => {
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
return [x + (b[1] - a[1]) / len * 6.5 * side, y - (b[0] - a[0]) / len * 6.5 * side - 5];
});
track('#06121d', 2.6, rail); track(color, 1.3, rail); track('#ffffff99', 0.45, rail);
}
for (let s = 12; s < r.len - 4; s += 22) { for (let s = 12; s < r.len - 4; s += 22) {
const p = rampPoint(r, s); const p = rampPoint(r, s);
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7); line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
@@ -178,18 +270,50 @@ function paintTrack(g, r) {
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]]; const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y); line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
} }
text(g, r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center'); // Portale d'ingresso in metallo: l'arco e le torrette incorniciano la bocca fisica della rampa.
const arch = [[x0 + 2, y - 2], [x0 + 2, y - 17], [cx - 8, y - 27], [cx + 8, y - 27], [x1 - 2, y - 17], [x1 - 2, y - 2]];
line(g, arch, '#020910', 5); line(g, arch, metal(g, x0, y - 27, x1 - x0, 27), 3.2); line(g, arch, color, 1);
for (const x of [x0 + 2, x1 - 2]) {
box(g, x - 2.5, y - 16, 5, 16, '#112735', '#8aaab7', 1);
box(g, x - 0.8, y - 13, 1.6, 9, color, null, 0.5);
} }
function rampLights(g, c, r, time) { box(g, cx - 10, y - 31, 20, 8, '#0a1b28', color, 2);
crown(g, cx, y - 27, 2.3, color);
for (let i = 0; i < 2; i++) line(g, [[cx - 3, y - 5 - i * 5], [cx, y - 8 - i * 5], [cx + 3, y - 5 - i * 5]], color + 'aa', 0.8);
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 18, 4.5, WHITE, 'center');
}
function trackEnergy(g, c, r, time, hot, balls) {
const color = TINT[r.id], riders = balls.filter(b => b.ramp?.id === r.id);
const charged = hot || riders.length > 0;
// Luci sui bordi: il centro rimane libero per seguire la pallina.
for (let k = 0; k < (charged ? 7 : 3); k++) {
const p = rampPoint(r, (time * (charged ? 155 : 48) + k * r.len / (charged ? 7 : 3)) % r.len);
for (const side of [-1, 1]) {
const x = p.x + p.dy * 6.5 * side, y = p.y - p.dx * 6.5 * side - 5;
if (charged) glow(g, x, y, 7, color, 0.5);
disc(g, x, y, charged ? 1.1 : 0.8, charged ? WHITE : color);
}
}
for (const b of riders) {
// La scia segue la distanza percorsa, anche quando un tiro debole ricade indietro.
for (let i = 7; i > 0; i--) {
const s = b.ramp.s - Math.sign(b.ramp.v) * i * 4;
if (s < 0 || s > r.len) continue;
const p = rampPoint(r, s);
g.save(); g.globalAlpha *= (1 - i / 8) * 0.65;
line(g, [[p.x - p.dy * 3, p.y + p.dx * 3], [p.x + p.dy * 3, p.y - p.dx * 3]], color, 2);
g.restore();
}
const p = rampPoint(r, b.ramp.s); glow(g, b.x, p.y, 14, color, 0.8);
}
const [[x0, y], [x1]] = r.mouth, pulse = 0.45 + Math.sin(time * (charged ? 7 : 2)) * 0.15;
for (const x of [x0 + 2, x1 - 2]) glow(g, x, y - 10, 11, color, charged ? 0.85 : pulse);
}
function rampLights(g, c, r, time, balls) {
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id); const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super'; const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0; const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
if (hot) { trackEnergy(g, c, r, time, hot, balls);
for (let k = 0; k < 7; k++) {
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
}
}
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2; const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i); const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
@@ -203,7 +327,7 @@ function rampLights(g, c, r, time) {
text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center'); text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center');
} }
function bumper(g, b, time) { function bumper(g, b, time) {
const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec'; const hot = b.hit > 0, color = hot ? '#ffce84' : b.x < 310 ? '#76e4ec' : b.x > 330 ? '#ffbf81' : '#91f5b1';
glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24); glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24);
g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill(); g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill();
disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b'); disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b');
@@ -212,6 +336,10 @@ function bumper(g, b, time) {
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e'); v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7'); disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9'); crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
for (let i = 0; i < 8; i++) {
const a = i * TAU / 8;
disc(g, b.x + Math.cos(a) * (b.r + 2.5), b.y + Math.sin(a) * (b.r + 2.5), 0.65, hot || (i + Math.floor(time * 5)) % 8 < 2 ? color : '#315260');
}
for (const dx of [-8, 8]) screw(g, b.x + dx, b.y); for (const dx of [-8, 8]) screw(g, b.x + dx, b.y);
if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); } if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); }
} }
@@ -228,7 +356,7 @@ function target(g, c) {
disc(g, o.x + 1, o.y + 3, o.r + 1, '#02090b'); disc(g, o.x + 1, o.y + 3, o.r + 1, '#02090b');
disc(g, o.x, o.y, o.r + 1, metal(g, o.x - 6, o.y - 6), '#c5b682'); disc(g, o.x, o.y, o.r + 1, metal(g, o.x - 6, o.y - 6), '#c5b682');
disc(g, o.x, o.y, o.r - 1, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0'); disc(g, o.x, o.y, o.r - 1, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0');
text(g, 'REGOLO', o.x, o.y - 14, 5, '#acd6b7', 'center'); text(g, c.future && Object.values(c.future.hits).every(n => n === 2) ? 'LIBERAMI!' : 'REGOLO', o.x, o.y + (c.table === 'future' ? 20 : -14), 5, '#acd6b7', 'center');
} }
function paddle(g, c, side) { function paddle(g, c, side) {
const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y); const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y);
@@ -269,6 +397,7 @@ function plunger(g, c) {
const shown = new WeakMap(); const shown = new WeakMap();
export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; } export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; }
function board(g, c, time) { function board(g, c, time) {
glow(g, 99, 43, 25, SCENES[apparition(c, time).id].color, 0.4);
crown(g, 99, 43, 10, '#e7c889'); crown(g, 99, 43, 10, '#e7c889');
text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500); text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500);
text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center'); text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center');
@@ -290,6 +419,7 @@ function board(g, c, time) {
text(g, 'PALLINE', 28, 225, 6, MUTED); text(g, 'PALLINE', 28, 225, 6, MUTED);
for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5'); for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5');
if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right'); if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right');
livingBackglass(g, c, time);
if (c.step === 'result') { if (c.step === 'result') {
box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5); box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5);
text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center'); text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center');
@@ -371,16 +501,149 @@ function effects(g, c, time) {
g.restore(); g.restore();
} }
} }
// Il secondo campo ha il suo fondale: circuiti incisi, bastioni sospesi e il serpente come costellazione.
// Tutto questo, compresi i binari, si dipinge una volta sola; sul telefono restano solo luci e palline.
function futureCabinet(g, c, bg, glass) {
cabinet(g, c, bg, glass);
g.save(); outline(g, c); g.clip();
g.fillStyle = gradient(g, 200, 0, 230, 340, [[0, '#080c29'], [0.5, '#14112d'], [1, '#061f2a']]); g.fillRect(200, 5, 232, 331);
for (let i = 0; i < 13; i++) {
const x = 205 + i * 18;
line(g, [[x, 320], [320 + (x - 320) * 0.35, 50]], '#18364a', 0.6);
line(g, [[201, 90 + i * 19], [432, 90 + i * 19]], '#18364a', 0.6);
}
glow(g, 320, 105, 115, '#975cff', 0.6); glow(g, 320, 281, 90, '#24dcda', 0.4);
poly(g, [[276, 99], [276, 73], [287, 73], [287, 62], [298, 62], [298, 76], [310, 76], [310, 53], [330, 53], [330, 76], [343, 76], [343, 62], [355, 62], [355, 75], [365, 75], [365, 99]], '#161e41', '#9c86ee');
line(g, [[278, 103], [320, 118], [363, 103]], '#58f4e2', 1);
text(g, 'FORTEZZA / NUCLEO', 320, 91, 5, '#d1c7ff', 'center');
const serpent = [[304, 277], [288, 265], [295, 252], [326, 252], [343, 270], [332, 284], [313, 284], [313, 271], [323, 267]];
line(g, serpent, '#143d50', 8); line(g, serpent, '#398c9c', 1); crown(g, 323, 264, 5, '#58f4e2');
paintPlayfieldArt(g, true);
for (const w of c.walls) if (!w.gate) { line(g, [w.a, w.b], '#2e526a', 3); line(g, [w.a, w.b], '#69b7c7', 0.7); }
g.restore();
// Il mobile stesso cambia: pannelli a circuito, la finestra sul River resta il legame col 2007.
for (const x of [12, 467]) { line(g, [[x + 9, 100], [x + 9, 31], [x + 31, 31]], '#58f4e2', 1.2); }
box(g, 198, 336, 252, 17, '#080f20', '#396c84', 2);
text(g, 'R I V E R / IL REGOLO DEL FUTURO', 320, 345, 6, '#99ffee', 'center');
}
function futureBoard(g, c) {
const ready = Object.values(c.future.hits).every(n => n === 2);
text(g, 'IL REGOLO', 99, 51, 21, WHITE, 'center', 'sans-serif', 800);
text(g, 'D E L F U T U R O', 99, 76, 9, TINT.orbita, 'center');
text(g, 'IL RIVER HA UN SEGRETO.', 99, 94, 5.5, '#a69abf', 'center');
box(g, 23, 111, 152, 82, '#040c1c', '#396c84');
text(g, 'PUNTI / CONNESSIONE ATTIVA', 34, 124, 5.5, TINT.orbita);
text(g, number(c.score), 99, 148, 22, WHITE, 'center', 'monospace');
text(g, c.transform ? 'RISVEGLIO DEL NUCLEO' : ready ? 'COLPISCI IL REGOLO!' : c.line ?? 'CARICA I TRE SIGILLI', 99, 178, 6.5, '#d6c1ff', 'center');
text(g, `${activeBalls(c).length} IN CAMPO / ${c.balls} IN RISERVA`, 99, 210, 6, '#b7e1e7', 'center');
text(g, 'STESSI TASTI. UN ALTRO MONDO.', 99, 225, 5.5, MUTED, 'center');
text(g, 'I TRE SIGILLI', 480, 49, 16, WHITE);
text(g, 'DUE PERCORSI COMPLETI CIASCUNO', 480, 69, 5.5, TINT.orbita);
for (const [i, r] of c.ramps.entries()) {
const y = 85 + i * 57, color = TINT[r.id], n = c.future.hits[r.id];
box(g, 479, y, 137, 49, '#101d34', n === 2 ? color : '#334a68');
text(g, r.name, 487, y + 12, 6.5, color);
for (let k = 0; k < 2; k++) box(g, 488 + k * 34, y + 25, 27, 5, k < n ? color : '#29374a', null, 1);
text(g, `${n}/2`, 603, y + 27, 9, WHITE, 'right', 'monospace');
text(g, `OGNI GIRO +${number(r.points)}`, 488, y + 40, 5.5, MUTED);
}
text(g, ready ? 'IL REGOLO È VULNERABILE' : 'POI COLPISCI IL REGOLO MOBILE', 548, 274, 6, ready ? TINT.orbita : '#d6c1ff', 'center');
text(g, '+500.000', 548, 294, 22, WHITE, 'center', 'monospace');
text(g, 'FINALE O ULTIMA PALLINA PERSA:', 548, 318, 5.5, MUTED, 'center');
text(g, 'SI TORNA AL RIVER', 548, 332, 7, TINT.orbita, 'center');
livingBackglass(g, c, c.visual.time);
}
function futureField(g, c, time) {
livingPlayfield(g, c, time);
for (const b of c.bumpers) bumper(g, b, time);
target(g, c);
const ready = Object.values(c.future.hits).every(n => n === 2);
if (ready) glow(g, c.regolo.x, c.regolo.y, 30, TINT.orbita, 0.6);
const balls = activeBalls(c);
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) {
cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [202, 0, 232, 258]);
const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1];
trackEnergy(g, c, r, time, r.lit > 0 || c.future.hits[r.id] === 2, balls);
poly(g, [[x, y + 7], [x - 4, y + 14], [x + 4, y + 14]], color);
text(g, r.id.toUpperCase(), x, y + 21, 5.5, color, 'center');
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, color);
}
for (const side of ['left', 'right']) paddle(g, c, side);
plunger(g, c); effects(g, c, time); futureBoard(g, c);
}
function oldPlayfield(c, bg, glass) {
return l => {
const classic = { ...c, ...layout(), table: 'classic' };
cabinet(l, classic, bg, glass); target(l, classic);
classic.bumpers.forEach(b => bumper(l, b, 0)); classic.ramps.forEach(r => paintTrack(l, r));
for (const side of ['left', 'right']) paddle(l, classic, side);
};
}
function transformation(g, c, bg, glass) {
const age = c.sim - c.transform.at;
// Le due metà del vecchio piano scorrono via. Sono ritagli piccoli, mai copie dello schermo intero.
if (age >= FUTURE.reveal && age < 2.7 && typeof document !== 'undefined') {
const old = layerOf(`split:${!!bg}:${!!glass}`, oldPlayfield(c, bg, glass), [200, 5, 248, 331]);
const k = clamp((age - FUTURE.reveal) / 1.6, 0, 1), shift = k * k * 150;
g.save(); g.beginPath(); g.rect(200, 5, 248, 331); g.clip();
g.drawImage(old, 0, 0, 248, 662, 200 - shift, 5, 124, 331);
g.drawImage(old, 248, 0, 248, 662, 324 + shift, 5, 124, 331);
line(g, [[324 - shift, 5], [324 - shift, 336]], TINT.orbita, 2);
line(g, [[324 + shift, 5], [324 + shift, 336]], TINT.ponte, 2); g.restore();
}
// Una detonazione sola, poi schegge e onde sottili: nessun filtro o lampeggio continuo.
const blast = Math.min(age, 1.7);
if (age < 1.7) {
glow(g, 320, 165, 60 + blast * 100, '#b9faff', Math.max(0, 1 - blast / 1.7));
for (let i = 0; i < 3; i++) {
const radius = 10 + Math.max(0, blast - i * 0.13) * 210;
g.beginPath(); g.ellipse(320, 165, radius, radius * 0.7, 0, 0, TAU); g.strokeStyle = [WHITE, TINT.orbita, TINT.ponte][i]; g.lineWidth = 1.5; g.stroke();
}
for (let i = 0; i < 20; i++) {
const a = i * TAU / 20 + 0.12, d = 25 + blast * 210;
line(g, [[320 + Math.cos(a) * d, 165 + Math.sin(a) * d * 0.7], [320 + Math.cos(a) * (d + 24), 165 + Math.sin(a) * (d + 24) * 0.7]], i % 2 ? TINT.orbita : '#ffe3a4', 1.2);
}
for (let i = 0; i < 64; i++) {
const a = hash(i * 11) * TAU, speed = 80 + hash(i * 17) * 180, d = speed * blast;
const x = 320 + Math.cos(a) * d, y = 165 + Math.sin(a) * d * 0.7 + blast * blast * 22;
const color = [TINT.orbita, TINT.spira, '#ffe3a4', WHITE][i % 4];
poly(g, [[x, y - 3], [x + 2 + i % 3, y], [x, y + 4], [x - 2, y]], color);
}
if (age < 0.09) { g.fillStyle = '#e4ffff'; g.fillRect(201, 6, 230, 328); }
}
const title = age < FUTURE.reveal ? 'SUPER SPECIAL' : 'IL REGOLO DEL FUTURO';
box(g, 215, 145, 210, 47, '#050e24', age < FUTURE.reveal ? TINT.spira : TINT.orbita, 3);
text(g, title, 320, 163, age < FUTURE.reveal ? 22 : 16, WHITE, 'center', 'sans-serif', 900);
text(g, age < FUTURE.reveal ? 'IL RIVER NON SE L’ASPETTAVA.' : 'IL PIANO SI APRE. IL SERPENTE SI SVEGLIA.', 320, 181, 6, TINT.orbita, 'center');
}
export function drawFlipper(g, c, def, bg, glass) { export function drawFlipper(g, c, def, bg, glass) {
const time = c.visual.time; const time = c.visual.time;
// Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la // Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la
// memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono. // memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono.
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter'; g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter';
const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0); const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0);
const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass); if (g.canvas?.style) g.canvas.style.backgroundPosition = '';
const future = c.table === 'future';
const key = `cabinet:${future}:${!!bg}:${!!glass}`, paint = layer => (future ? futureCabinet : cabinet)(layer, c, bg, glass);
if (c.transform) {
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
const age = c.sim - c.transform.at, force = Math.max(0, 1 - age / 1.1);
const dx = Math.sin(age * 79) * force * 4, dy = Math.cos(age * 67) * force * 3;
g.translate(dx, dy);
if (g.canvas?.style) g.canvas.style.backgroundPosition = `${dx}px ${dy}px`;
if (!future) cached(g, `split:${!!bg}:${!!glass}`, oldPlayfield(c, bg, glass), [200, 5, 248, 331]);
if (future) futureField(g, c, time); else { board(g, c, time); mission(g, c, time); }
transformation(g, c, bg, glass); g.restore(); return;
}
if (future) {
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
futureField(g, c, time); plungerHint(g, c, time); g.restore(); return;
}
if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); } if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); }
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
else cached(g, key, paint); else cached(g, key, paint);
livingPlayfield(g, c, time);
for (const [i, l] of c.lanes.entries()) { for (const [i, l] of c.lanes.entries()) {
if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55); if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55);
disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b'); disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b');
@@ -403,7 +666,7 @@ export function drawFlipper(g, c, def, bg, glass) {
if (!rampOpen(c, r)) continue; if (!rampOpen(c, r)) continue;
g.save(); g.save();
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); } if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time); cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [208, 45, 148, 207] : [208, 0, 225, 238]); rampLights(g, c, r, time, balls);
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]); for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
g.restore(); g.restore();
} }
@@ -413,6 +676,19 @@ export function drawFlipper(g, c, def, bg, glass) {
plunger(g, c); effects(g, c, time); plunger(g, c); effects(g, c, time);
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa. // Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b); for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
board(g, c, time); mission(g, c, time); board(g, c, time); mission(g, c, time); plungerHint(g, c, time);
g.restore();
}
// A pallina ferma, dove toccare per la molla (Andrea, 1 novembre: «sul telefono non è intuitivo premere al centro»): la
// fascia centrale di flipper.js (press), tratteggiata, e la scritta che pulsa; sparisce appena si comincia a caricare.
// Solo col dito (`c.touch`, da main.js): sul computer no, Andrea non la vuole.
function plungerHint(g, c, time) {
if (!c.touch || !waiting(c) || c.holding) return;
const x0 = 320 - PLUNGER_ZONE + 4, x1 = 320 + PLUNGER_ZONE - 4, y0 = 196, y1 = 300, pulse = 0.8 + Math.sin(time * 4) * 0.2;
g.save(); g.globalAlpha = pulse; g.setLineDash([4, 3]);
g.strokeStyle = '#ffe08a'; g.lineWidth = 1.2; g.strokeRect(x0, y0, x1 - x0, y1 - y0); g.setLineDash([]);
box(g, 320 - 58, 262, 116, 32, '#07121c', '#ffe08a', 2);
text(g, 'TIENI PREMUTO QUI', 320, 272, 8.5, '#ffe08a', 'center', 'sans-serif', 800);
text(g, 'e lascia: MOLLA', 320, 285, 7, WHITE, 'center', 'sans-serif', 700);
g.restore(); g.restore();
} }
+84 -9
View File
@@ -85,6 +85,42 @@ export function layout() {
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; }); [...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
return { walls, targets, bumpers, regolo, lanes, ramps }; return { walls, targets, bumpers, regolo, lanes, ramps };
} }
// Sotto il legno c'è una macchina impossibile: fantasia del mobile, non storia del paese.
export const FUTURE = { reveal: 1.1, duration: 3.2, finale: 500000 };
export function futureLayout() {
const base = layout();
// Restano il mobile, la molla e le guide alle palette; spariscono corsie e banchi classici.
const walls = base.walls.filter((w, i) => i < 17 || (i >= 20 && i < 30));
walls.push(seg([205, 90], [228, 125]), seg([428, 92], [409, 120]));
const bumpers = [[280, 157], [368, 169]].map(([x, y]) => ({ x, y, r: 11, hit: 0, touching: false }));
const regolo = { x: 320, y: 276, r: 9, hit: 0, touching: false };
const ramps = [
{ id: 'orbita', name: 'ORBITA DEL SERPENTE', points: 35000, mouth: [[246, 235], [282, 235]], path: [[264, 235], [253, 197], [228, 158], [224, 89], [245, 42], [293, 25], [346, 29], [394, 50], [410, 89], [396, 116], [357, 112], [345, 85], [368, 64], [397, 85], [411, 135], [411, 220]] },
{ id: 'ponte', name: 'PONTE DELLA FORTEZZA', points: 50000, mouth: [[302, 238], [338, 238]], path: [[320, 238], [320, 201], [300, 167], [300, 108], [315, 66], [338, 53], [356, 73], [349, 105], [313, 128], [267, 133], [229, 174], [229, 220]] },
{ id: 'spira', slope: 300, name: 'SPIRA AL NEON', points: 65000, mouth: [[364, 235], [400, 235]], path: [[382, 235], [386, 203], [400, 165], [388, 132], [363, 128], [347, 151], [353, 178], [376, 187], [401, 180], [411, 204], [411, 245]] },
];
for (const r of ramps) {
r.len = 0; r.cum = [0]; r.lit = 0;
for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); }
r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
}
[...walls, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
return { walls, targets: [], lanes: [], bumpers, regolo, ramps };
}
function openFuture(c) {
Object.assign(c, futureLayout()); c.table = 'future'; c.resets = {}; c.fx = [];
c.future = { hits: { orbita: 0, ponte: 0, spira: 0 } };
// La trasformazione parcheggia le tre palline al sicuro, poi le serve una alla volta.
activeBalls(c).forEach((b, i) => Object.assign(b, { x: i === 1 ? 411 : 229, y: 220 - i * 32, vx: 0, vy: 160,
ramp: null, contacts: [], trail: [], releaseAt: c.transform.until + i * 0.55, releasedFrom: i === 1 ? 'spira' : 'orbita' }));
}
function returnClassic(c, won = false) {
Object.assign(c, layout()); c.table = 'classic'; c.future = null; c.transform = null;
c.extraBalls = []; c.resets = {}; c.lastRamp = null; c.jackpotX = 1; c.saveUntil = 0; c.saved = false;
cancel(c); newBall(c);
if (won) { celebrate(c, 'REGOLO LIBERATO!', FUTURE.finale, '#74ffe0'); c.events.push({ kind: 'flipper-futuro-finale', power: 1 }); }
else announce(c, 'RITORNO AL RIVER');
}
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì. // Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
export function alongRamp(r, s) { export function alongRamp(r, s) {
s = clamp(s, 0, r.len); s = clamp(s, 0, r.len);
@@ -98,7 +134,7 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7, const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false, holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } }, flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1, table: 'classic', transform: null, future: null, ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null, rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
newBall(c); newBall(c);
@@ -200,6 +236,14 @@ function moveFlippers(c, h) {
} }
function step(c) { function step(c) {
c.sim += STEP; c.sim += STEP;
if (c.transform) {
if (c.table !== 'future' && c.sim >= c.transform.at + FUTURE.reveal) openFuture(c);
if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; }
c.transform = null; announce(c, 'CARICA I TRE SIGILLI');
}
if (c.table === 'future') {
c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24;
}
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null; if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 }; if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 };
@@ -243,6 +287,7 @@ function step(c) {
} else ball.rest = 0; } else ball.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c, ball); if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
} }
if (c.future?.won) returnClassic(c, true);
} }
function collideBalls(a, b) { function collideBalls(a, b) {
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
@@ -289,8 +334,8 @@ function enterRamps(c, from, ball) {
// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1). // d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1).
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); } export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
function rideRamp(c, ball) { function rideRamp(c, ball) {
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top; const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top, slope = r.slope ?? RAMP.slope;
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP; on.v += (uphill ? -slope : slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
on.s += on.v * STEP; on.s += on.v * STEP;
const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend); const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend);
ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s); ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s);
@@ -300,6 +345,11 @@ function rideRamp(c, ball) {
const v = Math.max(on.v, 150); const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null; ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId); ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
if (c.table === 'future') {
c.future.hits[r.id] = Math.min(2, c.future.hits[r.id] + 1); r.lit = 3;
add(c, r.points, [p.x, p.y]); announce(c, `${r.id.toUpperCase()} ${c.future.hits[r.id]}/2`);
fx(c, 'ring', p.x, p.y); c.events.push({ kind: 'flipper-futuro-giro', power: 1 }); return;
}
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]); if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id }); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
if (rampFeature(c, r, ball)) return; if (rampFeature(c, r, ball)) return;
@@ -343,6 +393,8 @@ function rampFeature(c, r, ball) {
c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null; c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null;
c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++; c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++;
celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff'); celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff');
c.transform = { at: c.sim, until: c.sim + FUTURE.duration };
c.events.push({ kind: 'flipper-esplosione', power: 1 });
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!'); say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
c.events.push({ kind: 'flipper-super', power: 1 }); c.events.push({ kind: 'flipper-super', power: 1 });
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); } for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
@@ -389,6 +441,7 @@ function drain(c, ball) {
c.lastRamp = null; c.saved = false; c.saveUntil = 0; c.lastRamp = null; c.saved = false; c.saveUntil = 0;
announce(c, 'LOCK LIBERO'); newBall(c); return; announce(c, 'LOCK LIBERO'); newBall(c); return;
} }
if (c.table === 'future') returnClassic(c);
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1; c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; } if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
c.ball = null; c.step = 'result'; c.ball = null; c.step = 'result';
@@ -460,6 +513,7 @@ function collideBumper(c, ball, b) {
function collideRegolo(c, ball) { function collideRegolo(c, ball) {
const hit = hitCircle(ball, c.regolo, 0.7); const hit = hitCircle(ball, c.regolo, 0.7);
if (!hit || !hit.fresh || hit.vn < IMPACT) return; if (!hit || !hit.fresh || hit.vn < IMPACT) return;
if (c.table === 'future' && Object.values(c.future.hits).every(n => n === 2)) { c.future.won = true; return; }
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y); c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
} }
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato. // Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
@@ -480,10 +534,17 @@ function collideFlipper(c, ball, side) {
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco // --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita). // la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; } export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
// Un tocco (Andrea, 1 novembre: «il pollice destro sulla metà destra, il sinistro sulla sinistra, al centro per lanciare»):
// al centro della scena, a pallina ferma, carica la molla; ai lati alza la paletta di quel lato, anche prima del lancio.
// La tastiera ('key': Spazio, freccia giù) carica sempre.
export const PLUNGER_ZONE = 64; // mezza larghezza della fascia centrale, in pixel di scena (256-384 su 640)
export function press(c, pointerId = 'ptr') { export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false; if (c.step !== 'play') return false;
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; } const x = c.cursor?.[0] ?? 320;
return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true); if (waiting(c) && pointerId !== 'mouse-right' && (pointerId === 'key' || Math.abs(x - 320) < PLUNGER_ZONE)) {
if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true;
}
return hold(c, pointerId === 'mouse-right' || x >= 320 ? 'right' : 'left', pointerId, true);
} }
export function release(c, pointerId = 'ptr') { export function release(c, pointerId = 'ptr') {
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime)); if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
@@ -498,7 +559,7 @@ export function cancel(c, pointerId) {
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId); for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
return true; return true;
} }
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce. // I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), le frecce e i due Shift.
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true); export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
export function stopTurn(c, who = 'button') { export function stopTurn(c, who = 'button') {
if (who === 'click') return false; // il colpo breve finisce da sé if (who === 'click') return false; // il colpo breve finisce da sé
@@ -509,18 +570,32 @@ export const nudge = () => false;
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; } export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
export function key(c, k, down) { export function key(c, k, down) {
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js) if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; } if (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); return true; }
if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; } // La molla: Spazio, o la freccia giù (si tira indietro come la molla vera).
if (k === ' ' || k === 'ArrowDown') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
if (k === 'Enter') { if (down) shoot(c); return true; } if (k === 'Enter') { if (down) shoot(c); return true; }
return false; return false;
} }
// Per provare (main.js, con ?debug, tasto S): i due LOCK fatti davvero, tre giri a rampa, e la SUPER SPECIAL aperta.
export function openSuper(c) {
for (const id of ['sinistra', 'sinistra', 'sinistra', 'destra', 'destra', 'destra']) {
if (c.special === 'super') break;
const r = c.ramps.find(r => r.id === id);
if (waiting(c)) launch(c, 0.8);
if (!c.ball) return false;
Object.assign(c.ball, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null });
let entered = false;
for (let i = 0; i < 1000; i++) { update(c, STEP); if (c.ball?.ramp) entered = true; else if (entered) break; }
}
return c.special === 'super';
}
// Il pulsante Lancia: con la forza del cursore. // Il pulsante Lancia: con la forza del cursore.
export const shoot = c => canThrow(c) && launch(c, c.power); export const shoot = c => canThrow(c) && launch(c, c.power);
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…'; export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
export function status(c) { export function status(c) {
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`; if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`; const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
const feature = c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.'; const feature = c.transform ? 'SUPER SPECIAL! Il piano si apre: arriva il Regolo del futuro.' : c.table === 'future' ? `FUTURO: Orbita ${c.future.hits.orbita}/2 · Ponte ${c.future.hits.ponte}/2 · Spira ${c.future.hits.spira}/2. Poi Regolo mobile: +500000 e ritorno. Ultima pallina persa: ritorno, −1 vita.` : c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`; const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
if (c.line) return `${c.line} ${tally}`; if (c.line) return `${c.line} ${tally}`;
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`; return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
+4 -2
View File
@@ -13,12 +13,14 @@ import { versioned } from '../version.js';
// suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro: // suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro:
// è la musica della piazza accanto, che arriva da lontano. // è la musica della piazza accanto, che arriva da lontano.
// `ok(cond)` dice se una condizione della storia vale adesso (game.js, check). // `ok(cond)` dice se una condizione della storia vale adesso (game.js, check).
export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) { // `table`: la tavola del primo piano, se ne ha più d'una (il flipper: 'classic' o 'future'), che può avere la sua musica.
export function trackFor(story, { screen, scene, phase, closeup, table, at, ok = () => false }) {
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 cu = closeup && story.closeups?.[closeup];
if (cu?.music) return cu.tableMusic?.[table] ?? cu.music;
const sc = story.scenes[scene]; const sc = story.scenes[scene];
const stages = (sc?.stages ?? []).filter(st => ok(st.when)); const stages = (sc?.stages ?? []).filter(st => ok(st.when));
if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]); if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]);
+4
View File
@@ -175,6 +175,10 @@ export class Soundscape {
else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); } else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); }
else if (kind === 'flipper-super-open') { this.noiseSound(0.45, 1600, 0.04, { pan }); this.tone(180, 1200, 0.65, 0.06, 0, pan); for (const [i, f] of [659, 880, 1318].entries()) this.tone(f, f, 0.18, 0.05, 0.5 + i * 0.12, pan); } else if (kind === 'flipper-super-open') { this.noiseSound(0.45, 1600, 0.04, { pan }); this.tone(180, 1200, 0.65, 0.06, 0, pan); for (const [i, f] of [659, 880, 1318].entries()) this.tone(f, f, 0.18, 0.05, 0.5 + i * 0.12, pan); }
else if (kind === 'flipper-super') { for (const [i, f] of [392, 523, 659, 784, 1046, 1318, 1568, 2093].entries()) this.tone(f, f * 1.005, 0.24, 0.055, i * 0.08, pan); for (const [i, f] of [523, 784, 1046].entries()) this.tone(f, f, 0.9, 0.045, 0.65 + i * 0.04, pan); } else if (kind === 'flipper-super') { for (const [i, f] of [392, 523, 659, 784, 1046, 1318, 1568, 2093].entries()) this.tone(f, f * 1.005, 0.24, 0.055, i * 0.08, pan); for (const [i, f] of [523, 784, 1046].entries()) this.tone(f, f, 0.9, 0.045, 0.65 + i * 0.04, pan); }
// Timbri originali della tavola nascosta: botto grave e scintille (la musica è flipper-futuro.mp3, di Andrea).
else if (kind === 'flipper-esplosione') { this.noiseSound(1.1, 700, 0.14, { pan }); this.tone(130, 32, 1.2, 0.13, 0, pan); this.tone(90, 1800, 1.6, 0.06, 0.7, pan); }
else if (kind === 'flipper-futuro-giro') { this.tone(390, 1560, 0.38, 0.065, 0, pan); this.tone(1174, 1174, 0.25, 0.04, 0.25, pan); }
else if (kind === 'flipper-futuro-finale') { this.noiseSound(0.7, 1600, 0.07, { pan }); for (const [i, f] of [196, 294, 494, 740, 988, 1480].entries()) this.tone(f, f, 0.5, 0.055, i * 0.12, pan); }
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); } else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); } else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
+68 -4
View File
@@ -59,6 +59,7 @@ function showSound() {
$('effects-volume').value = Math.round(ambience.volume * 100); $('effects-volume').value = Math.round(ambience.volume * 100);
} }
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true }); for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true });
addEventListener('pointerdown', e => { if (game?.closeup && 'touch' in game.closeup) game.closeup.touch = e.pointerType !== 'mouse'; }, { capture: true });
// Il muto zittisce subito anche la voce che sta parlando. // Il muto zittisce subito anche la voce che sta parlando.
const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); }; const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
$('mute').addEventListener('click', toggleMute); $('mute').addEventListener('click', toggleMute);
@@ -399,6 +400,54 @@ addEventListener('pagehide', () => leave(true));
addEventListener('pageshow', () => leave(document.hidden)); addEventListener('pageshow', () => leave(document.hidden));
$('resume').addEventListener('click', closePause); $('resume').addEventListener('click', closePause);
$('pause-map').addEventListener('click', () => { closePause(); openMap(); }); $('pause-map').addEventListener('click', () => { closePause(); openMap(); });
// I comandi di un minigioco (molla, pulsanti, carte, «Come si gioca») sono più alti del pannello dei verbi: con quelli
// aperti la scena lascia intorno lo spazio che occupano davvero, fasce comprese (--below, misurato), così su uno schermo grande o a schermo
// intero non finiscono sotto il bordo. Senza comandi vale la riserva del pannello in unità (style.css, #app).
// La misura cambia la scena solo per differenze grandi: una frase di stato che va a capo (PONTE SX al flipper) non deve
// ridimensionarla a ogni colpo, che a schermo sembra un piano che salta di qualche pixel (Andrea, 1 novembre). E «Come si
// gioca» aperto non conta: se lo si apre, la pagina scorre invece di stringere la scena (e poi non torna larga).
function fitControls() {
const app = $('app'), open = !!document.querySelector('#side > section[id$="-controls"]:not([hidden])');
if (!app.classList.toggle('controls', open)) { app.style.removeProperty('--below'); return; }
// Tutto quello che non è la scena 16:9: le fasce di audio, pausa e uscita e la pulsantiera (dalla larghezza, non
// dalla tela, che mentre cambia risoluzione può avere per un attimo un'altezza qualunque).
const box = app.getBoundingClientRect(), help = document.querySelector('#side details[id$="-help"][open]');
const extra = help ? help.getBoundingClientRect().height - help.querySelector('summary').getBoundingClientRect().height : 0;
const below = Math.ceil(box.height - box.width * 9 / 16 - extra), was = parseFloat(app.style.getPropertyValue('--below'));
// Cresce subito (niente deve finire sotto il bordo), cala solo per differenze grandi (niente salti a ogni frase).
if (!(below <= was + 1 && below >= was - 24)) app.style.setProperty('--below', `${below}px`);
}
// «Solo tavola» al flipper (Andrea, 1 novembre): nasconde la pulsantiera, la tavola prende tutta l'altezza; il browser
// se lo ricorda (sul telefono in orizzontale la pulsantiera del flipper è già nascosta, style.css).
let tableOnly = (() => { try { return storage?.getItem('regolo-solo-tavola') === '1'; } catch { return false; } })();
$('table-only').addEventListener('click', e => {
tableOnly = !tableOnly;
try { storage?.setItem('regolo-solo-tavola', tableOnly ? '1' : '0'); } catch { /* niente memoria: vale per questa volta */ }
e.currentTarget.blur();
});
// Schermo intero, dove il browser lo permette (su iPhone no: i pulsanti spariscono): dalla Pausa, dal pulsante accanto ai
// volumi (anche sul titolo) o col tasto F; Esc, il pulsante o F per uscire.
function toggleFullscreen() {
if (!document.fullscreenEnabled) return false;
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
return true;
}
{
const full = $('fullscreen'), hud = $('fullscreen-hud');
if (!document.fullscreenEnabled) full.hidden = hud.hidden = true;
else {
full.addEventListener('click', toggleFullscreen);
hud.addEventListener('click', e => { toggleFullscreen(); e.currentTarget.blur(); });
document.addEventListener('fullscreenchange', () => {
const on = !!document.fullscreenElement;
full.textContent = on ? 'Esci da schermo intero' : 'Schermo intero';
hud.setAttribute('aria-label', on ? 'Esci da schermo intero' : 'Schermo intero');
hud.title = on ? 'Esci da schermo intero (F o Esc)' : 'Schermo intero (F)';
hud.setAttribute('aria-pressed', on);
});
}
}
$('to-title').addEventListener('click', () => { $('to-title').addEventListener('click', () => {
$('pause').hidden = true; $('pause').hidden = true;
music.hold = false; music.hold = false;
@@ -486,6 +535,9 @@ function debugKey(e) {
if (k === 'k') { const text = `[${debugPoints.map(p => `[${p.join(', ')}]`).join(', ')}]`; copy(text); console.log(text); return true; } if (k === 'k') { const text = `[${debugPoints.map(p => `[${p.join(', ')}]`).join(', ')}]`; copy(text); console.log(text); return true; }
if (k === 'x') { debugPoints.length = 0; return true; } if (k === 'x') { debugPoints.length = 0; return true; }
if (k === 'g') { debugShown = !debugShown; return true; } if (k === 'g') { debugShown = !debugShown; return true; }
// Nel flipper, S: tre giri sulla rampa sinistra e tre sulla destra (due LOCK), così la SUPER SPECIAL al centro è
// aperta e si prova subito il Regolo del futuro.
if (k === 's' && story.closeups[game?.closeup?.id]?.type === 'flipper' && game.closeup.table !== 'future') { flipper.openSuper(game.closeup); return true; }
return false; return false;
} }
const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` }; const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` };
@@ -649,7 +701,7 @@ const MINI = {
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' }, campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' }, bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper o le frecce sinistra e destra.' }, flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi colpisci il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro; comandi invariati.' },
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
@@ -813,6 +865,8 @@ function updateChoices() {
})); }));
} }
// Ai minigiochi lo Shift arriva coi due lati distinti (ShiftLeft, ShiftRight): nel flipper comandano le due palette.
const keyName = e => e.key === 'Shift' ? e.code : e.key;
window.addEventListener('keydown', e => { window.addEventListener('keydown', e => {
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return; if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return;
// Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude. // Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
@@ -821,6 +875,8 @@ window.addEventListener('keydown', e => {
return; return;
} }
if (debugKey(e)) return; if (debugKey(e)) return;
// F: schermo intero ovunque (nessun gioco usa la F; nella ricerca della mappa si scrive, sopra).
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat && toggleFullscreen()) { e.preventDefault(); return; }
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; } if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
if (!$('credits').hidden) { if (!$('credits').hidden) {
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
@@ -905,7 +961,7 @@ window.addEventListener('keydown', e => {
if (e.key === 'Escape') { game.closeupClose(); return; } if (e.key === 'Escape') { game.closeupClose(); return; }
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls'); const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono). // Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true, e.shiftKey)) { e.preventDefault(); return; } if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, keyName(e), true, e.shiftKey)) { e.preventDefault(); return; }
if (onControl && [' ', 'Enter'].includes(e.key)) return; if (onControl && [' ', 'Enter'].includes(e.key)) return;
const c = game.closeup, m = mini(); const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
@@ -952,7 +1008,7 @@ window.addEventListener('keydown', e => {
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro. // Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { window.addEventListener('keyup', e => {
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri. // Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
if (miniState() && mini().key?.(game.closeup, e.key, false)) return; if (miniState() && mini().key?.(game.closeup, keyName(e), false)) return;
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
}); });
@@ -1153,6 +1209,14 @@ function updateUi() {
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli). // Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards); $('stage').classList.toggle('briscola', !!cards);
const pinball = story.closeups[c?.id]?.type === 'flipper';
// Si gioca col dito? Dallo schermo all'apertura, poi da ogni tocco (pointerdown): la scritta della molla è per il dito.
if (pinball && c.touch === undefined) c.touch = matchMedia('(hover: none)').matches;
$('app').classList.toggle('flipper', pinball);
$('app').classList.toggle('table-only', pinball && tableOnly);
setProp($('table-only'), 'hidden', !pinball);
setText($('table-only'), tableOnly ? 'Pulsantiera' : 'Solo tavola');
fitControls();
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio. // La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null; const step = game.closeup?.step ?? null;
@@ -1180,7 +1244,7 @@ function frame(now) {
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(), closeup: game?.closeup?.id, music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id,
at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt); table: game?.closeup?.table, at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), 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 });
+2 -1
View File
@@ -363,7 +363,8 @@ export const story = {
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex, // Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex,
// fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie] // fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie]
flipper: { flipper: {
type: 'flipper', // Le musiche di Andrea (Suno, 1 novembre): la classica, e quella del Regolo del futuro dopo la SUPER SPECIAL.
type: 'flipper', music: 'assets/audio/flipper.mp3', tableMusic: { future: 'assets/audio/flipper-futuro.mp3' },
picture: 'assets/closeups/river-flipper.png', picture: 'assets/closeups/river-flipper.png',
backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi) backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi)
names: { player: 'Andrea', cpu: 'il flipper' }, names: { player: 'Andrea', cpu: 'il flipper' },
+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.67'; export const VERSION = '0.1.73';
// 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).
+57 -17
View File
@@ -20,7 +20,24 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
button, input { font-family: var(--font); } button, input { font-family: var(--font); }
body { line-height: 1.5; } body { line-height: 1.5; }
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; } body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); } /* Il gioco è largo quanto può: tutta la finestra (fino a 2560px: a schermo intero su un monitor grande), oppure quanto ci sta in altezza con il pannello sotto la
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 25,6px: tutto il pannello cresce e cala col gioco, non con
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
#app { --u: clamp(8.75px, 1cqw, 25.6px); --side-h: 13.3; container-type: inline-size;
width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
/* Coi comandi di un minigioco aperti (main.js, fitControls) la scena lascia intorno il loro spazio vero, --below; ma non
scende sotto i 700px (nelle finestre basse si scorre un poco per arrivare ai comandi, come prima). */
@media (min-width: 601px) and (min-height: 521px) {
#app.controls { width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9),
max(calc((100dvh - var(--below, 0px)) * 16 / 9), 700px)); }
/* «Solo tavola» (Andrea): la pulsantiera del flipper nascosta, la tavola a tutta altezza. */
#app.controls.table-only { width: min(100vw, 2560px, calc((100dvh - var(--below, 88px)) * 16 / 9)); }
#app.table-only #side { display: none; }
}
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
@@ -47,7 +64,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#poker-controls summary, #briscola-controls summary { cursor: pointer; padding: 10px 0; } #poker-controls summary, #briscola-controls summary { cursor: pointer; padding: 10px 0; }
#poker-controls p, #briscola-controls p { max-width: 76em; } #poker-controls p, #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; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; } #bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 3em; } /* due righe: la scena non salta quando va a capo */
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; } .bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; } .bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; } #bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
@@ -80,7 +97,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */ /* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none; #speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center; font-size: calc(1.4625 * var(--u)); font-weight: bold; line-height: 1.2; text-align: center;
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; } text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; } #speech:empty { display: none; }
#speech { z-index: 3; } #speech { z-index: 3; }
@@ -89,7 +106,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0; #muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0;
transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; } transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
/* Il primo piano del libro si chiude da qui (o con Esc). */ /* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font); #closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold calc(1.17 * var(--u)) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); } padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */ /* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; } #stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
@@ -104,24 +121,29 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
background: rgba(27, 20, 32, .62); border-radius: 18px; } background: rgba(27, 20, 32, .62); border-radius: 18px; }
#mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; } #mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; } #mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
#volume, #voice-volume { width: clamp(50px, 9vw, 100px); accent-color: var(--accent); cursor: pointer; } #volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); } .sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
#fullscreen-hud { width: 30px; height: 30px; padding: 0; font: bold 17px system-ui, sans-serif; line-height: 1; color: var(--accent); background: none; border: 0; cursor: pointer; }
#fullscreen-hud[aria-pressed="true"] { color: var(--blue); }
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; background: var(--panel); } /* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--panel); } /* Verbi e inventario: alti sempre uguale (10,8 --u, dove stanno anche quattro risposte di dialogo: vedi #choices). */
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: calc(.625 * var(--u)); padding: calc(.625 * var(--u)); height: calc(10.8 * var(--u)); background: var(--panel); }
/* Otto verbi su due righe piene, quattro per riga. */ /* Otto verbi su due righe piene, quattro per riga. */
#verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; align-content: start; } #verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: calc(.3125 * var(--u)); align-content: stretch; }
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px; #verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; } padding: .35em .2em; font-size: calc(1.069 * var(--u)); font-weight: bold; cursor: pointer; }
#verbs button:hover, #inventory button:hover { color: var(--ink); } #verbs button:hover, #inventory button:hover { color: var(--ink); }
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); } #verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; #inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: calc(.3125 * var(--u)); align-content: start;
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px; min-height: 0; padding: calc(.3125 * var(--u)); background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */ /* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; } contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45; #inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; } font-size: calc(1.069 * var(--u)); font-weight: bold; letter-spacing: .08em; }
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); } #inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
#inventory button { min-width: 0; overflow: hidden; } #inventory button { min-width: 0; overflow: hidden; }
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; } #inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
@@ -130,8 +152,14 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#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; }
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */ /* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); } /* Le risposte: alte quanto il pannello dei verbi, così la scena non si muove quando parte un dialogo. Quattro risposte ci
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; } stanno al carattere pieno; da cinque in su il carattere cala (e se mai non bastasse, si scorre). */
#choices { margin: 0; padding: calc(.78 * var(--u)) calc(1.1 * var(--u)) calc(.94 * var(--u)) 3.6em; background: var(--panel); height: calc(10.8 * var(--u));
overflow-y: auto; scrollbar-width: thin; font-size: calc(1.2375 * var(--u)); line-height: 1.4; }
#choices li { padding: .2em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
#choices:has(li:nth-child(5)) { font-size: calc(.96 * var(--u)); line-height: 1.25; }
#choices:has(li:nth-child(5)) li { padding: .15em 0; }
#choices:has(li:nth-child(7)) { font-size: calc(.72 * var(--u)); }
#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;
@@ -223,10 +251,11 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */ /* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
/* In alto a sinistra: Pausa e Mappa. */ /* In alto a sinistra: Pausa e Mappa. */
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; } #corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em; #map-button, #pause-button, #notebook-button, #spots-button, #back-button, #table-only { font: bold calc(.9 * var(--u)) 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; }
#back-button { color: #1b1420; background: #9fd0ff; } #back-button { color: #1b1420; background: #9fd0ff; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); } /* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
#pause { z-index: 3; background: rgba(15, 10, 20, .9); justify-content: safe center; }
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */ /* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; } #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 video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
@@ -323,6 +352,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } } #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */ /* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) { @media (max-width: 600px) {
#sentence { height: auto; min-height: 1.8em; white-space: normal; }
#panel, #choices { height: auto; }
#choices { min-height: 6.5em; }
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; } #panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
#verbs button { font-size: 9px; padding: .7em .1em; } #verbs button { font-size: 9px; padding: .7em .1em; }
#inventory { height: 7.6em; } #inventory { height: 7.6em; }
@@ -335,13 +367,20 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#app.presenta { justify-content: center; } #app.presenta { justify-content: center; }
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); } #app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
/* Il flipper sul telefono (Andrea, 1 novembre): pollice sinistro sulla metà sinistra della scena, destro sulla destra,
al centro la molla. La scena prende tutta la larghezza e la pulsantiera sparisce: punti e palline sono sulla tavola. */
#app.flipper { justify-content: center; }
#app.flipper #side, #app.flipper #table-only { display: none; }
#app.flipper #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
#app.flipper #stage.biliardo #closeup-close { position: absolute; top: 6px; bottom: auto; left: 86px; right: auto; margin: 0; min-height: 0; height: 30px; padding: 0 .9em; font-size: 10px; }
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#briscola-controls, #tressette-controls, #scopa-controls, #poker-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } #briscola-controls, #tressette-controls, #scopa-controls, #poker-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */ /* 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-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
.card-hand { flex-direction: column; gap: 4px; } .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 { height: auto; min-height: 1.8em; white-space: normal; font-size: 10.08px; }
#panel, #choices { height: auto; }
/* 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; }
@@ -358,6 +397,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
.buttons { gap: 6px; margin-top: .2em; } .buttons { gap: 6px; margin-top: .2em; }
.buttons button { font-size: 10px; padding: .45em 1em; } .buttons button { font-size: 10px; padding: .45em 1em; }
.pause-buttons { min-width: 12em; } .pause-buttons { min-width: 12em; }
#pause .hint { display: none; }
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */ /* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
#map-board button { width: 68px; } #map-board button { width: 68px; }
#map-board button img { width: 60px; } #map-board button img { width: 60px; }
+2 -1
View File
@@ -3,6 +3,7 @@
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT). // npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -11,7 +12,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: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
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));
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-biliardo'; const out = process.env.OUT ?? '/tmp/regolo-biliardo';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -64,7 +65,7 @@ async function checkArtwork(page, name) {
} }
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
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 }],
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true }); const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = []; const server = await staticServer(), errors = [];
@@ -51,7 +52,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: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
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 } },
+405 -331
View File
@@ -1,8 +1,11 @@
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri // Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots). // fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
// npm run browser // npm run browser: le tre parti (--parte=principale, minigiochi, telefoni) girano insieme come processi separati, ognuna
// col suo server e il suo Chromium; la prova passa quasi tutto il tempo ad aspettare animazioni in tempo reale, e così le
// attese si sovrappongono. Una parte sola: node tests/browser.mjs --parte=minigiochi (--media-only: solo multimedia).
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
import { mkdirSync, readFileSync } from 'node:fs'; import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js'; import { solution, route } from '../src/story/solution.js';
@@ -12,14 +15,31 @@ 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 PARTI = ['principale', 'minigiochi', 'telefoni'];
const PARTE = process.argv.find(a => a.startsWith('--parte='))?.slice('--parte='.length);
if (PARTE && !PARTI.includes(PARTE)) { console.error(`parte sconosciuta: ${PARTE} (${PARTI.join(', ')})`); process.exit(2); }
if (!PARTE && !process.argv.includes('--media-only')) {
const { spawn } = await import('node:child_process');
const t0 = Date.now();
const esiti = await Promise.all(PARTI.map(parte => new Promise(resolve => {
const p = spawn(process.execPath, [process.argv[1], `--parte=${parte}`], { stdio: ['ignore', 'pipe', 'pipe'] });
let resto = '';
const riga = chunk => { const righe = (resto + chunk).split('\n'); resto = righe.pop(); for (const r of righe) console.log(`[${parte}] ${r}`); };
p.stdout.on('data', riga); p.stderr.on('data', riga);
p.on('close', code => { if (resto) console.log(`[${parte}] ${resto}`); resolve({ parte, code, s: Math.round((Date.now() - t0) / 1000) }); });
})));
for (const e of esiti) console.log(`${e.code === 0 ? 'OK ' : 'FAIL'} ${e.parte} (${e.s} s)`);
process.exit(esiti.every(e => e.code === 0) ? 0 : 1);
}
const server = await staticServer(), PORT = server.port; const server = await staticServer(), PORT = server.port;
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const browser = await chromium.launch({ args: ARGS });
const errors = []; const errors = [];
async function open(viewport, query = '') { async function open(viewport, query = '') {
const page = await browser.newPage({ viewport, acceptDownloads: true }); const page = await browser.newPage({ viewport, acceptDownloads: true });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
page.on('console', m => m.type() === 'error' && errors.push(m.text())); // ERR_NETWORK_CHANGED è il PC che cambia rete a metà prova (Docker che crea o toglie una rete): non è il gioco.
page.on('console', m => m.type() === 'error' && !m.text().includes('ERR_NETWORK_CHANGED') && errors.push(m.text()));
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); } try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
} }
@@ -49,332 +69,8 @@ const hotspotCenter = async (page, id) => {
return c; return c;
}; };
try { // Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima).
await checkMultimedia(browser, PORT, OUT, errors); async function minigiochi() {
if (!process.argv.includes('--media-only')) {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
await page.waitForTimeout(2800);
await page.screenshot({ path: `${OUT}/00-cartello.png` });
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
{
const muto = await chromium.launch(), p = await muto.newPage();
await p.goto(`http://127.0.0.1:${PORT}/`);
await p.waitForTimeout(800);
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
await p.waitForTimeout(5500);
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
await p.click('#splash');
assert.ok(await p.isHidden('#splash .splash-hint'));
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
await muto.close();
}
await page.screenshot({ path: `${OUT}/01-titolo.png` });
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#splash');
await slow.waitForTimeout(300);
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close();
}
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
await page.click('#title .open-credits');
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
await page.click('#credits .credit-face >> text=Mariano');
assert.ok(await page.isVisible('#face img'));
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
await page.click('#face');
assert.ok(await page.isHidden('#face'));
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
// foto, un tocco su una foto la mostra grande col nome del posto.
{
const track = page.locator('#credits-reel .track');
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
await page.mouse.move(5, 5);
const x0 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(600);
const x1 = await track.evaluate(t => t.scrollLeft);
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
await track.hover({ position: { x: 20, y: 20 } });
const x2 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(500);
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
await page.click('#credits-reel .arrow.next');
await page.waitForTimeout(800);
const x3 = await track.evaluate(t => t.scrollLeft);
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
const third = track.locator('img').nth(2);
const name = await third.getAttribute('alt');
await third.click();
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
assert.equal(await page.textContent('#face figcaption'), name);
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
assert.ok(await page.isVisible('#credits'));
}
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
await page.click('#new-game');
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
await page.waitForSelector('#rione:not([hidden])');
assert.equal(await page.locator('#rione-buttons button').count(), 4);
await page.screenshot({ path: `${OUT}/01a-rione.png` });
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
await page.click('#rione [data-rione="campomigliaio"]');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
await page.screenshot({ path: `${OUT}/01b-intro.png` });
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
await page.waitForFunction(() => game.speech);
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
await page.screenshot({ path: `${OUT}/02-inizio.png` });
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
await settle(page);
// Doppio clic su un punto della scena: Andrea ci va di corsa.
const r = await page.locator('#scene').boundingBox();
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
await settle(page);
// «?» mostra per qualche secondo cosa si può toccare.
await page.click('#spots-button');
await page.screenshot({ path: `${OUT}/02c-punti.png` });
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
await page.evaluate(() => {
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
});
await page.waitForTimeout(150);
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
const savedFile = `${OUT}/partita.json`;
await download.saveAs(savedFile);
const before = await page.evaluate(() => game.snapshot());
await page.click('#to-title');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
await page.waitForSelector('#load-error:not([hidden])');
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
lost.state.scene = 'luna';
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
assert.ok(await page.isVisible('#title'));
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', savedFile);
await page.waitForSelector('#title', { state: 'hidden' });
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.click('#resume');
assert.ok(await page.isHidden('#pause'));
await page.click('#pause-button');
await page.click('#to-title');
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
await settle(page);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
await page.click('#mute');
await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false);
await page.click('#resume');
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) {
await page.click(`#verbs [data-verb="${verb}"]`);
await page.click(`#inventory [data-item="${item}"]`);
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
else await clickScene(page, await hotspotCenter(page, target));
}
await page.waitForTimeout(80);
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) {
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
}
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
await page.click('#ending .open-credits');
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
await page.click('#close-credits');
assert.ok(await page.isHidden('#credits'));
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const [, exit] of route('piazza', 'trebbio')) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
await page.click('#verbs [data-verb="talk"]');
await clickScene(page, await hotspotCenter(page, 'torre'));
await page.locator('#choices li', { hasText: 'bloccato' }).click();
await page.waitForFunction(() => game.speech?.who === 'castello');
await page.waitForTimeout(100);
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
await settle(page);
await page.locator('#choices li', { hasText: 'pace' }).click();
await settle(page);
await page.reload();
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
await settle(page);
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
await page.keyboard.press('n');
assert.ok(await page.isVisible('#notebook'));
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#notebook'));
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
await page.click('#map-button');
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
await page.fill('#map-search', 'xyz');
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
await page.fill('#map-search', '');
await page.click('#map-search');
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
assert.ok(await page.isHidden('#map-none'));
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
assert.deepEqual(shown, ['piazza']);
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
await page.keyboard.press('Enter');
assert.ok(await page.isHidden('#map'));
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
await page.setViewportSize({ width: 560, height: 340 });
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
await page.click('#map-button');
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
await page.fill('#map-search', 'campo');
const found = await page.evaluate(() => {
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
});
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
await page.click('#close-map');
await page.setViewportSize({ width: 1280, height: 860 });
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica, // Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde. // Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
{ {
@@ -551,10 +247,36 @@ try {
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 }); await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
await b.close(); await b.close();
} }
}
async function telefoni() {
// La finestra di Andrea (1040×590: Firefox con le sue barre su un portatile): la scena era larga 604 px con 100 px
// vuoti sotto, ora prende l'altezza; i verbi stanno nei pulsanti; un dialogo a quattro risposte e uno a cinque
// non muovono la scena e stanno nel pannello.
{
const win = await open({ width: 1040, height: 590 });
await win.click('#splash'); await win.click('#new-game'); await win.click('#rione [data-rione="campomigliaio"]'); await win.click('#skip-intro');
await settle(win);
const box = async () => { const r = await win.locator('#stage').boundingBox(); return [r.x, r.y, r.width, r.height].map(Math.round); };
const b0 = await box();
assert.ok(b0[2] >= 820 && b0[1] >= 0, `a 1040×590 la scena è larga ${b0[2]} px e comincia a ${b0[1]}`);
assert.ok(await win.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre a 1040×590');
assert.ok(await win.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1040×590');
for (const id of ['giocatore_carte', 'arcu']) {
await win.evaluate(id => { game.dialogue = { id, node: 'start' }; }, id);
await win.waitForFunction(() => !document.getElementById('choices').hidden);
const n = await win.evaluate(() => game.options().length);
assert.deepEqual(await box(), b0, `la scena si muove col dialogo ${id}`);
assert.ok(await win.evaluate(() => { const c = document.getElementById('choices'); return c.scrollHeight <= c.clientHeight + 1; }), `le ${n} risposte di ${id} non stanno nel pannello`);
if (id === 'arcu') await win.screenshot({ path: `${OUT}/11-finestra-1040-dialogo.png` });
await win.evaluate(() => { game.dialogue = null; });
}
await win.screenshot({ path: `${OUT}/11-finestra-1040.png` });
await win.close();
}
const phone = await open({ width: 390, height: 844 }); const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo'); assert.ok(await phone.waitForSelector('#title:not([hidden])', { timeout: 5000 }).then(() => true, () => false), 'il clic sul cartello porta al titolo');
await phone.click('#new-game'); await phone.click('#new-game');
await phone.click('#rione [data-rione="campomigliaio"]'); await phone.click('#rione [data-rione="campomigliaio"]');
await phone.click('#skip-intro'); await phone.click('#skip-intro');
@@ -619,9 +341,361 @@ try {
await dbg.waitForTimeout(200); await dbg.waitForTimeout(200);
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea'); assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
await dbg.screenshot({ path: `${OUT}/10-debug.png` }); await dbg.screenshot({ path: `${OUT}/10-debug.png` });
}
try {
if (process.argv.includes('--media-only')) await checkMultimedia(browser, PORT, OUT, errors);
else if (PARTE === 'minigiochi') { await minigiochi(); console.log('Browser OK: libro, bocce, tiri, pesca, biliardo.'); }
else if (PARTE === 'telefoni') { await checkMultimedia(browser, PORT, OUT, errors); await telefoni(); console.log('Browser OK: telefoni e debug.'); }
else {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
await page.waitForTimeout(2800);
await page.screenshot({ path: `${OUT}/00-cartello.png` });
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
{
const muto = await chromium.launch(), p = await muto.newPage();
await p.goto(`http://127.0.0.1:${PORT}/`);
await p.waitForTimeout(800);
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
await p.waitForTimeout(5500);
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
await p.click('#splash');
assert.ok(await p.isHidden('#splash .splash-hint'));
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
await muto.close();
}
await page.screenshot({ path: `${OUT}/01-titolo.png` });
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#splash');
await slow.waitForTimeout(300);
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close();
}
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
await page.click('#title .open-credits');
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
await page.click('#credits .credit-face >> text=Mariano');
assert.ok(await page.isVisible('#face img'));
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
await page.click('#face');
assert.ok(await page.isHidden('#face'));
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
// foto, un tocco su una foto la mostra grande col nome del posto.
{
const track = page.locator('#credits-reel .track');
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
await page.mouse.move(5, 5);
const x0 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(600);
const x1 = await track.evaluate(t => t.scrollLeft);
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
await track.hover({ position: { x: 20, y: 20 } });
const x2 = await track.evaluate(t => t.scrollLeft);
await page.waitForTimeout(500);
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
await page.click('#credits-reel .arrow.next');
await page.waitForTimeout(800);
const x3 = await track.evaluate(t => t.scrollLeft);
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
const third = track.locator('img').nth(2);
const name = await third.getAttribute('alt');
await third.click();
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
assert.equal(await page.textContent('#face figcaption'), name);
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
assert.ok(await page.isVisible('#credits'));
}
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
await page.click('#new-game');
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
await page.waitForSelector('#rione:not([hidden])');
assert.equal(await page.locator('#rione-buttons button').count(), 4);
await page.screenshot({ path: `${OUT}/01a-rione.png` });
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
await page.click('#rione [data-rione="campomigliaio"]');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
await page.screenshot({ path: `${OUT}/01b-intro.png` });
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
await page.waitForFunction(() => game.speech);
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
await page.screenshot({ path: `${OUT}/02-inizio.png` });
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
await settle(page);
// Doppio clic su un punto della scena: Andrea ci va di corsa.
const r = await page.locator('#scene').boundingBox();
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
await settle(page);
// «?» mostra per qualche secondo cosa si può toccare.
await page.click('#spots-button');
await page.screenshot({ path: `${OUT}/02c-punti.png` });
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
await page.evaluate(() => {
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
});
await page.waitForTimeout(150);
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
// Il gioco prende la finestra: a 1280×860 la scena non si ferma più ai 1084 px di una riserva fissa di 250 px sotto,
// la pagina non scorre e i verbi stanno interi nei pulsanti (la finestra di Andrea, 1040×590, è provata più sotto).
const stage0 = await page.locator('#stage').boundingBox();
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
// Su uno schermo grande (1920×1080, a schermo intero) il gioco non si ferma più a 1280 px: prende tutta l'altezza.
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForFunction(() => document.getElementById('stage').getBoundingClientRect().width > 1500);
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight && document.getElementById('app').getBoundingClientRect().height >= innerHeight - 2), 'a 1920×1080 il gioco prende tutta l\'altezza, senza scorrere');
await page.setViewportSize({ width: 1280, height: 860 });
// Schermo intero: il pulsante accanto ai volumi e il tasto F, per entrare e per uscire.
if (await page.evaluate(() => document.fullscreenEnabled)) {
await page.click('#fullscreen-hud');
await page.waitForFunction(() => !!document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'true');
await page.keyboard.press('f');
await page.waitForFunction(() => !document.fullscreenElement);
await page.keyboard.press('F');
await page.waitForFunction(() => !!document.fullscreenElement);
await page.click('#fullscreen-hud');
await page.waitForFunction(() => !document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'false');
} else assert.ok(await page.evaluate(() => document.getElementById('fullscreen-hud').hidden), 'senza schermo intero il pulsante sparisce');
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
assert.equal(await page.evaluate(() => document.getElementById('fullscreen').hidden), await page.evaluate(() => !document.fullscreenEnabled), 'il pulsante Schermo intero c\'è se il browser lo permette');
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
const savedFile = `${OUT}/partita.json`;
await download.saveAs(savedFile);
const before = await page.evaluate(() => game.snapshot());
await page.click('#to-title');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
await page.waitForSelector('#load-error:not([hidden])');
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
lost.state.scene = 'luna';
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
assert.ok(await page.isVisible('#title'));
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
await page.setInputFiles('#load-file', savedFile);
await page.waitForSelector('#title', { state: 'hidden' });
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.click('#resume');
assert.ok(await page.isHidden('#pause'));
await page.click('#pause-button');
await page.click('#to-title');
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
await settle(page);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
await page.click('#mute');
await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false);
await page.click('#resume');
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) {
await page.click(`#verbs [data-verb="${verb}"]`);
await page.click(`#inventory [data-item="${item}"]`);
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
else await clickScene(page, await hotspotCenter(page, target));
}
await page.waitForTimeout(80);
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) {
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
}
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
await page.click('#ending .open-credits');
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
await page.click('#close-credits');
assert.ok(await page.isHidden('#credits'));
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const [, exit] of route('piazza', 'trebbio')) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
await page.click('#verbs [data-verb="talk"]');
await clickScene(page, await hotspotCenter(page, 'torre'));
await page.locator('#choices li', { hasText: 'bloccato' }).click();
await page.waitForFunction(() => game.speech?.who === 'castello');
await page.waitForTimeout(100);
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
await settle(page);
await page.locator('#choices li', { hasText: 'pace' }).click();
await settle(page);
await page.reload();
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
await settle(page);
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
await page.keyboard.press('n');
assert.ok(await page.isVisible('#notebook'));
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#notebook'));
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
await page.click('#map-button');
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
await page.fill('#map-search', 'xyz');
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
await page.fill('#map-search', '');
await page.click('#map-search');
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
assert.ok(await page.isHidden('#map-none'));
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
assert.deepEqual(shown, ['piazza']);
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
await page.keyboard.press('Enter');
assert.ok(await page.isHidden('#map'));
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
await page.setViewportSize({ width: 560, height: 340 });
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
await page.click('#map-button');
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
await page.fill('#map-search', 'campo');
const found = await page.evaluate(() => {
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
});
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
await page.click('#close-map');
await page.setViewportSize({ width: 1280, height: 860 });
assert.deepEqual(errors, [], 'errori nella console'); assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); console.log(`Browser OK: soluzione completa, ripresa del salvataggio. Schermate in ${OUT}`);
} }
assert.deepEqual(errors, [], 'errori nella console'); assert.deepEqual(errors, [], 'errori nella console');
} finally { } finally {
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-bruciate'; const out = process.env.OUT ?? '/tmp/regolo-bruciate';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-campana'; const out = process.env.OUT ?? '/tmp/regolo-campana';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-cerbottana'; const out = process.env.OUT ?? '/tmp/regolo-cerbottana';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+11
View File
@@ -0,0 +1,11 @@
// Come si apre Chromium nelle prove nel browser (solo da node: helpers.mjs lo carica anche la pagina).
// Con una scheda video (/dev/dri o /dev/nvidia0, anche nei contenitori lanciati con --gpus) si disegna lì: una
// schermata costa ~45 ms invece di ~600 con swiftshader, e le prove girano 5-10 volte più in fretta. Senza, swiftshader.
// REGOLO_GPU=0 forza swiftshader, REGOLO_GPU=1 la scheda video.
import { existsSync } from 'node:fs';
export const GPU = process.env.REGOLO_GPU ? process.env.REGOLO_GPU === '1' : ['/dev/dri/renderD128', '/dev/nvidia0'].some(existsSync);
const AUTOPLAY = '--autoplay-policy=no-user-gesture-required';
// Sempre sul processore: la prova del peso (peso.browser.mjs) misura così gli effetti, come su un telefono lento.
export const SWIFTSHADER = [AUTOPLAY, '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
export const ARGS = GPU ? [AUTOPLAY, '--use-gl=angle', '--use-angle=gl-egl', '--ignore-gpu-blocklist', '--enable-gpu-rasterization'] : SWIFTSHADER;
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-condor'; const out = process.env.OUT ?? '/tmp/regolo-condor';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newFlipper, futureLayout } from '../src/engine/flipper.js';
import { flipperScene } from '../src/engine/flipper-art.js';
test('il backglass segue i luoghi attraversati, senza modificare la partita', () => {
const c = newFlipper(91);
assert.equal(flipperScene(c), 'regolo');
for (const [x, y, scene] of [[403, 70, 'regolo'], [297, 90, 'rioni'], [220, 168, 'pieve'], [416, 180, 'trebbio'], [290, 135, 'fortezza'], [320, 202, 'regolo'], [320, 290, 'sieve']]) {
Object.assign(c.ball, { waiting: false, x, y });
const before = structuredClone(c);
assert.equal(flipperScene(c), scene, `zona ${x}, ${y}`);
assert.deepEqual(c, before);
}
for (const [id, scene] of [['sinistra', 'fortezza'], ['destra', 'trebbio'], ['super', 'cosmo'], ['orbita', 'cosmo'], ['ponte', 'fortezza'], ['spira', 'spira']]) {
c.ball.ramp = { id };
assert.equal(flipperScene(c), scene);
}
c.regolo.hit = 0.2;
assert.equal(flipperScene(c), 'regolo', 'il colpo al Regolo ha precedenza sulle rampe');
});
test('palline in attesa o trattenute non attivano immagini, il multiball ha una priorità stabile', () => {
const c = newFlipper(92);
c.extraBalls = [{ ...structuredClone(c.ball), x: 210, y: 170, waiting: false, releaseAt: c.sim + 2 }];
assert.equal(flipperScene(c), 'regolo');
Object.assign(c.ball, { waiting: false, ramp: { id: 'destra' } });
Object.assign(c.extraBalls[0], { releaseAt: 0, ramp: { id: 'sinistra' } });
const first = flipperScene(c);
[c.ball, c.extraBalls[0]] = [c.extraBalls[0], c.ball];
assert.equal(flipperScene(c), first);
c.ball = null; c.extraBalls = []; c.step = 'result';
assert.equal(flipperScene(c), 'regolo');
Object.assign(c, futureLayout(), { table: 'future' });
assert.equal(flipperScene(c), 'cosmo');
});
+140 -28
View File
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-flipper'; const out = process.env.OUT ?? '/tmp/regolo-flipper';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
@@ -24,30 +25,76 @@ try {
game.act('use', 'river'); settle(game); game.act('use', 'river'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game); game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
}); });
await page.waitForSelector('#bocce-controls:not([hidden])'); await page.waitForSelector('#bocce-controls:not([hidden])', { state: 'attached' });
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png')); await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola. // Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
const readyAt = await page.evaluate(() => game.closeup.visual.time); const readyAt = await page.evaluate(() => game.closeup.visual.time);
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt); await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia'); assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia'); assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
if (!mobile) {
// «Solo tavola»: la pulsantiera sparisce e la tavola si allarga; ripremuto, torna tutto com'era.
const before = (await page.locator('#scene').boundingBox()).width;
await page.click('#table-only');
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display === 'none');
await page.waitForFunction(w => document.getElementById('scene').getBoundingClientRect().width > w + 50, before);
assert.equal(await page.locator('#table-only').textContent(), 'Pulsantiera');
await page.click('#table-only');
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display !== 'none');
assert.ok(await page.locator('#flipper-help').isVisible()); assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper'); assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio'); assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
} else {
// Sul telefono in orizzontale (Andrea): niente pulsantiera, la scena larga sotto i due pollici.
assert.ok(!(await page.locator('#side').isVisible()), 'sul telefono la pulsantiera del flipper sparisce');
const w = (await page.locator('#scene').boundingBox()).width;
assert.ok(w > viewport.width * 0.6, `sul telefono la scena del flipper è larga ${w} px`);
}
await page.screenshot({ path: `${out}/${name}-tavola.png` }); await page.screenshot({ path: `${out}/${name}-tavola.png` });
// «TIENI PREMUTO QUI» per la molla solo col dito (Andrea): sul computer la tavola non la dice.
assert.equal(await page.evaluate(() => !!game.closeup.touch), mobile, 'la scritta della molla solo sul telefono');
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } }); if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
// Le apparizioni seguono zone vere del campo e cambiano effettivamente i pixel del backglass.
if (!mobile) {
await page.evaluate(() => {
window.artState = structuredClone(game.closeup); window.artUpdate = game.update;
game.update = () => {};
});
const pictures = new Set();
for (const [x, y, scene] of [[403, 70, 'regolo'], [290, 135, 'fortezza'], [416, 180, 'trebbio'], [220, 168, 'pieve'], [297, 90, 'rioni'], [320, 290, 'sieve']]) {
const result = await page.evaluate(async ({ x, y }) => {
const { render } = await import('/src/engine/render.js');
const { flipperScene } = await import('/src/engine/flipper-art.js');
const c = game.closeup, ctx = document.querySelector('#scene').getContext('2d');
Object.assign(c.ball, { x, y, waiting: false, releaseAt: 0, ramp: null });
for (let i = 0; i < 4; i++) { c.visual.time += 0.4; render(ctx, game, { time: performance.now() }); }
const pixels = ctx.getImageData(48, 476, 300, 176).data;
let signature = 0;
for (let i = 0; i < pixels.length; i++) signature = (Math.imul(signature, 31) + pixels[i]) | 0;
return { scene: flipperScene(c), signature };
}, { x, y });
assert.equal(result.scene, scene); pictures.add(result.signature);
await page.screenshot({ path: `${out}/desktop-visione-${scene}.png` });
}
assert.equal(pictures.size, 6, 'sei illustrazioni diverse nel display');
await page.evaluate(() => {
Object.assign(game.closeup, window.artState); game.update = window.artUpdate;
delete window.artState; delete window.artUpdate;
});
}
const cdp = mobile ? await page.context().newCDPSession(page) : null; const cdp = mobile ? await page.context().newCDPSession(page) : null;
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) }); const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
// La molla: tenendo premuto sulla scena, col mouse e col dito. // La molla: tenendo premuto sulla scena, col mouse e col dito.
if (mobile) { if (mobile) {
await touch('touchStart', [P(440, 320)]); await touch('touchStart', [P(320, 320)]); // al centro: la molla
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await touch('touchEnd', []); await touch('touchEnd', []);
} else { } else {
await page.mouse.move(...P(440, 320)); await page.mouse.down(); // Da tastiera con la freccia giù (il mouse tiene la molla come il dito, provato sul telefono).
await page.keyboard.down('ArrowDown');
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await page.mouse.up(); await page.keyboard.up('ArrowDown');
} }
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 }); await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su. // Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
@@ -61,12 +108,6 @@ try {
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 }); await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
await touch('touchEnd', []); await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0); await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
// I pulsanti tenuti premuti.
const b = await page.locator('#bocce-left').boundingBox();
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]);
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
} else { } else {
await page.mouse.move(...P(400, 200)); await page.mouse.down(); await page.mouse.move(...P(400, 200)); await page.mouse.down();
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0); await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
@@ -83,6 +124,13 @@ try {
await page.keyboard.up('ArrowLeft'); await page.keyboard.up('ArrowLeft');
await page.click('#resume'); await page.click('#resume');
await page.waitForFunction(() => document.querySelector('#pause').hidden); await page.waitForFunction(() => document.querySelector('#pause').hidden);
// I due Shift: il sinistro alza la paletta sinistra, il destro la destra.
await page.keyboard.down('ShiftRight');
await page.waitForFunction(() => game.closeup.flippers.right.held.includes('key-ShiftRight') && !game.closeup.flippers.left.held.length);
await page.keyboard.down('ShiftLeft');
await page.waitForFunction(() => game.closeup.flippers.left.held.includes('key-ShiftLeft'));
await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft');
await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length);
} }
// Il traguardo aggiunge una pallina e lascia continuare la partita. // Il traguardo aggiunge una pallina e lascia continuare la partita.
const reward = await page.evaluate(async () => { const reward = await page.evaluate(async () => {
@@ -122,28 +170,92 @@ try {
await page.screenshot({ path: `${out}/${name}-super.png` }); await page.screenshot({ path: `${out}/${name}-super.png` });
const multi = await page.evaluate(async () => { const multi = await page.evaluate(async () => {
window.pinballShot('super'); window.pinballShot('super');
const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25); const m = await import('/src/engine/flipper.js'), c = game.closeup;
// Ferma solo la simulazione per fotografare le tre palline, prima che una possa scendere. window.pinballResume = game.update.bind(game); game.update = () => {};
window.pinballResume = game.update.bind(game); game.update = dt => { game.closeup.visual.time += dt; }; window.pinballClassic = structuredClone(c); window.pinballClassic.transform = null;
return { balls: m.activeBalls(game.closeup).length, locks: game.closeup.locks.length, reserve: game.closeup.balls, time: game.closeup.visual.time }; (await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() });
m.update(c, 0.25); m.update(c, 0.2);
window.pinballExplosion = structuredClone(c);
return { balls: m.activeBalls(c).length, locks: c.locks.length, reserve: c.balls };
}); });
assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 }); assert.deepEqual(multi, { balls: 3, locks: 0, reserve: 5 });
await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time); await page.screenshot({ path: `${out}/${name}-esplosione.png` });
await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js');
for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25);
});
await page.screenshot({ path: `${out}/${name}-trasformazione.png` });
await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js');
for (let i = 0; i < 6; i++) m.update(game.closeup, 0.25);
window.pinballFuture = structuredClone(game.closeup);
});
assert.equal(await page.evaluate(() => game.closeup.table), 'future');
await page.screenshot({ path: `${out}/${name}-futuro.png` });
await page.screenshot({ path: `${out}/${name}-multiball.png` }); await page.screenshot({ path: `${out}/${name}-multiball.png` });
// L'arretrato grafico (vedi la memoria «effetti leggeri»): 150 fotogrammi disegnati di fila con tre palline e gli // Anche nel nuovo campo: due dita indipendenti, i due Shift e la pausa vera dell'applicazione.
// effetti vivi, poi una lettura dei pixel che aspetta la grafica. Il flipper di Codex ne lasciava 4,2 s; deve if (mobile) {
// restare sotto il secondo e mezzo anche col rendering software di queste prove. await touch('touchStart', [P(250, 200), P(400, 200)]);
const backlog = await page.evaluate(async () => { await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length);
await touch('touchEnd', []);
} else {
await page.keyboard.down('ShiftLeft'); await page.keyboard.down('ShiftRight');
await page.waitForFunction(() => game.closeup.flippers.left.held.length && game.closeup.flippers.right.held.length);
}
await page.evaluate(() => { game.update = window.pinballResume; });
await page.click('#pause-button');
const paused = await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score }));
await page.waitForTimeout(250);
assert.deepEqual(await page.evaluate(() => ({ sim: game.closeup.sim, hits: game.closeup.future.hits, score: game.closeup.score })), paused);
assert.ok(await page.evaluate(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length));
if (!mobile) { await page.keyboard.up('ShiftLeft'); await page.keyboard.up('ShiftRight'); }
await page.click('#resume');
await page.evaluate(() => { game.update = () => {}; Object.assign(game.closeup, structuredClone(window.pinballFuture)); });
// Stessa lettura sincrona dei pixel per classico, detonazione/trasformazione e futuro. Si svuota prima
// l'arretrato precedente; in gioco si riportano in alto solo le palline prossime alla buca per misurare
// tutti i 150 fotogrammi con tre palline, senza finire accidentalmente sul campo classico.
for (const mode of ['classico', 'esplosione', 'futuro']) {
const backlog = await page.evaluate(async mode => {
const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup; const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup;
Object.assign(c, structuredClone(mode === 'classico' ? window.pinballClassic : mode === 'esplosione' ? window.pinballExplosion : window.pinballFuture));
const ctx = document.querySelector('#scene').getContext('2d'); const ctx = document.querySelector('#scene').getContext('2d');
ctx.getImageData(0, 0, 4, 4); // si aspetta prima l'arretrato di tutta la prova fin qui r.render(ctx, game, { time: performance.now() }); ctx.getImageData(0, 0, 4, 4);
for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); } for (let i = 0; i < 150; i++) {
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); return Math.round(performance.now() - t); if (mode !== 'esplosione') for (const [k, b] of m.activeBalls(c).entries()) {
b.releaseAt = 0;
if (b.y > 295) Object.assign(b, { x: 260 + k * 55, y: 200, vx: 20, vy: -150, ramp: null });
}
m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() });
}
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); const ms = Math.round(performance.now() - t);
if (mode === 'futuro' && c.table !== 'future') throw new Error('misura uscita dal futuro');
return ms;
}, mode);
console.log(`${name}: arretrato grafico ${mode} dopo 150 fotogrammi ${backlog} ms`);
assert.ok(backlog < 1500, `${mode}: arretrato grafico ${backlog} ms`);
}
// Percorsi veri fino al finale: la schermata mostra sigilli caricati e il bersaglio mobile da colpire.
await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
Object.assign(c, structuredClone(window.pinballFuture));
for (const b of c.extraBalls) b.releaseAt = c.sim + 1000;
for (const id of ['orbita', 'ponte', 'spira']) for (let i = 0; i < 2; i++) window.pinballShot(id);
}); });
console.log(`${name}: arretrato grafico dopo 150 fotogrammi ${backlog} ms`); assert.deepEqual(await page.evaluate(() => game.closeup.future.hits), { orbita: 2, ponte: 2, spira: 2 });
assert.ok(backlog < 1500, `arretrato grafico ${backlog} ms`); await page.screenshot({ path: `${out}/${name}-sigilli.png` });
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('), null, { timeout: 2000 }); // il mobile sullo sfondo CSS await page.evaluate(async () => {
await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; }); const m = await import('/src/engine/flipper.js'), c = game.closeup;
Object.assign(c.ball, { x: 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] });
m.update(c, m.STEP);
});
assert.equal(await page.evaluate(() => game.closeup.table), 'classic');
await page.evaluate(async () => {
(await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() });
const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25);
});
await page.screenshot({ path: `${out}/${name}-ritorno.png` });
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('));
await page.evaluate(() => { game.update = window.pinballResume; for (const key of ['pinballResume', 'pinballShot', 'pinballClassic', 'pinballExplosion', 'pinballFuture']) delete window[key]; });
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale. // Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
const finalScore = await page.evaluate(async () => { const finalScore = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup; const m = await import('/src/engine/flipper.js'), c = game.closeup;
+145 -21
View File
@@ -130,13 +130,17 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
assert.ok(!c.lanes[1].lit); assert.ok(!c.lanes[1].lit);
}); });
test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => { test('comandi: la molla si carica al centro e si lancia lasciando, ai lati le palette anche prima del lancio; annullare non lancia; due dita, pulsanti e tasti', () => {
const c = m.newFlipper(8, { level: 'bona' }); const c = m.newFlipper(8, { level: 'bona' });
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6); // A pallina ferma i lati alzano le palette, il centro carica la molla.
m.aimScreen(c, [120, 300]); assert.ok(m.press(c, 5)); assert.ok(c.flippers.left.held.includes(5) && !c.holding, 'a sinistra, la paletta');
m.aimScreen(c, [520, 300]); assert.ok(m.press(c, 6)); assert.ok(c.flippers.right.held.includes(6) && !c.holding, 'a destra, la paletta');
m.release(c, 5); m.release(c, 6); assert.ok(m.waiting(c));
m.aimScreen(c, [320, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`); assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
assert.ok(!m.release(c, 2), 'un altro dito non lancia'); assert.ok(!m.release(c, 2), 'un altro dito non lancia');
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio'); m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c)); m.aimScreen(c, [300, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3)); m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4)); m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4)); assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
@@ -146,10 +150,14 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d
m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click')); m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click'));
const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length); const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length);
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length); assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
assert.ok(m.key(c, 'ShiftLeft', true)); assert.ok(c.flippers.left.held.includes('key-ShiftLeft') && !c.flippers.right.held.length);
m.key(c, 'ShiftRight', true); assert.ok(c.flippers.right.held.includes('key-ShiftRight'));
m.key(c, 'ShiftLeft', false); m.key(c, 'ShiftRight', false); assert.ok(!c.flippers.left.held.length && !c.flippers.right.held.length);
assert.ok(!m.key(c, 'x', true)); assert.ok(!m.key(c, 'x', true));
assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale'); assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale');
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi. // Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d)); const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
const g = m.newFlipper(11); assert.ok(m.key(g, 'ArrowDown', true)); run(g, 0.3); assert.ok(g.holding, 'anche la freccia giù carica la molla'); m.key(g, 'ArrowDown', false); assert.ok(!m.waiting(g));
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…'); const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true)); e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
}); });
@@ -312,13 +320,16 @@ test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball real
assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c)); assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c));
assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte'); assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte');
assert.ok(c.events.some(e => e.kind === 'flipper-super')); assert.ok(c.events.some(e => e.kind === 'flipper-super'));
const winScore = c.score, lives = c.balls; assert.ok(c.transform, 'la SUPER avvia la trasformazione');
rampShot(c, ids[0]); run(c, m.FUTURE.duration);
assert.ok(c.score - winScore >= m.SCORE.ramp + m.SCORE.jackpot, 'in multiball le rampe pagano jackpot'); assert.equal(c.table, 'future');
const lives = c.balls;
for (const b of m.activeBalls(c)) b.releaseAt = 0;
// Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto. // Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto.
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 }); for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 });
c.saveUntil = 0; m.update(c, m.STEP); c.saveUntil = 0; m.update(c, m.STEP);
assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c)); assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c));
assert.equal(c.table, 'classic', 'persa l’ultima si torna al River');
} }
}); });
@@ -334,25 +345,28 @@ test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie
assert.equal(c.rampRun.count, 0); assert.equal(c.rampRun.count, 0);
}); });
test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => { test('la trasformazione protegge le tre palline, poi le libera sfalsate verso le palette', () => {
for (const level of Object.keys(m.LEVELS)) { for (const level of Object.keys(m.LEVELS)) {
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super'); const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
const main = c.ball, delayed = c.extraBalls.find(b => b.releaseAt > c.sim); const before = m.activeBalls(c).map(b => [b.x, b.y]), score = c.score, lives = c.balls;
assert.equal(main.x, 229); assert.equal(main.y, 220); assert.equal(main.vx, 0); assert.equal(main.vy, 160); run(c, 0.8); assert.deepEqual(m.activeBalls(c).map(b => [b.x, b.y]), before);
for (const b of c.extraBalls) { assert.ok([229, 411].includes(b.x)); assert.equal(b.vx, 0, 'i lock non sparano verso le buche laterali'); } assert.equal(c.score, score); assert.equal(c.balls, lives);
assert.ok(delayed, 'una serratura aspetta prima di liberare la terza pallina'); run(c, m.FUTURE.duration - 0.8);
const position = [delayed.x, delayed.y]; run(c, 0.2); assert.equal(c.table, 'future'); assert.equal(c.transform, null);
assert.deepEqual([delayed.x, delayed.y], position, 'durante il ritardo resta nel lock'); const main = c.ball, delayed = c.extraBalls.at(-1), position = [delayed.x, delayed.y];
run(c, 0.3); assert.notDeepEqual([delayed.x, delayed.y], position, 'poi viene liberata'); assert.ok(delayed.releaseAt > c.sim);
// Isola la pallina della SUPER per verificare che un colpo normale possa recuperarla. run(c, 0.2); assert.deepEqual([delayed.x, delayed.y], position);
c.extraBalls = []; c.ball = main; // Isola la prima pallina per verificare il recupero dalla paletta, senza alterare la geometria.
const d = structuredClone(c); d.extraBalls = [];
let recovered = false; let recovered = false;
for (let i = 0; i < 480; i++) { for (let i = 0; i < 480; i++) {
if (main.x > 284 && main.y > 300) m.hold(c, 'left', 'test', true); if (d.ball.x > 284 && d.ball.y > 300) m.hold(d, 'left', 'test', true);
m.update(c, m.STEP); m.update(d, m.STEP);
if (main.vy < -250) { recovered = true; break; } if (d.ball.vy < -250) { recovered = true; break; }
} }
assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`); assert.ok(recovered, `${level}: prima pallina ripresa dalla paletta`);
run(c, 1); assert.notDeepEqual([delayed.x, delayed.y], position);
assert.ok(Number.isFinite(main.x));
} }
}); });
@@ -369,7 +383,8 @@ test('perdere una sostitutiva restituisce un lock alla volta senza consumare vit
}); });
test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => { test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => {
const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super'); const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration);
for (const b of m.activeBalls(c)) b.releaseAt = 0;
const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true); const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true);
m.update(single, m.STEP); m.update(c, m.STEP); m.update(single, m.STEP); m.update(c, m.STEP);
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10); assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
@@ -469,3 +484,112 @@ test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita si
assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli'); assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli');
assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`); assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`);
}); });
function inFuture(level = 'seria') {
const c = m.newFlipper(96, { level }); doubleLock(c); rampShot(c, 'super'); run(c, m.FUTURE.duration);
// Gli altri tiri restano parcheggiati mentre si misura un percorso alla volta.
for (const b of c.extraBalls) b.releaseAt = c.sim + 1000;
c.ball.releaseAt = 0; return c;
}
test('il futuro è un altro campo: tre giri reali, quote, punti e sigilli solo a percorso completo', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = inFuture(level), old = m.layout();
assert.notDeepEqual(c.walls, old.walls); assert.notDeepEqual(c.bumpers, old.bumpers);
assert.equal(c.targets.length, 0); assert.equal(c.lanes.length, 0);
for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= 200 && x <= 448 && y >= 5 && y <= 336);
for (const r of c.ramps) {
const score = c.score;
for (let n = 1; n <= 3; n++) {
const result = rampShot(c, r.id);
assert.ok(result.frames > 50); assert.ok(result.top < r.mouth[0][1] - 40);
assert.equal(c.score - score, r.points * n); assert.equal(c.future.hits[r.id], Math.min(n, 2));
assert.ok(result.exit.x === 229 || result.exit.x === 411, 'ritorno nelle corsie interne');
}
// Un ingresso debole sulla salita ricade e non carica il sigillo.
const d = inFuture(level), rd = d.ramps.find(k => k.id === r.id), points = d.score;
const result = ride(d, { x: rd.path[0][0], y: rd.mouth[0][1] + 1, vx: 0, vy: -270, ramp: null });
assert.equal(d.future.hits[r.id], 0); assert.equal(d.score, points); assert.ok(result.exit.y >= rd.mouth[0][1]);
}
const score = c.score, lives = c.balls;
const nextX = 320 + Math.sin((c.sim + m.STEP) * 1.6) * 24;
put(c, { x: nextX, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -250, ramp: null, contacts: [] });
m.update(c, m.STEP);
assert.equal(c.table, 'classic'); assert.equal(c.score, score + m.FUTURE.finale);
assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
assert.deepEqual(c.ramps.map(r => r.id), ['sinistra', 'destra', 'super']);
doubleLock(c); rampShot(c, 'super'); assert.ok(c.transform, 'il viaggio è ripetibile');
}
});
test('nel futuro il Regolo si muove, gli urti e la molla funzionano, niente finale prima dei sigilli', () => {
const c = inFuture(), x = c.regolo.x;
c.ball.waiting = true; run(c, 0.5); assert.notEqual(c.regolo.x, x);
assert.ok(m.key(c, ' ', true)); run(c, 0.4); m.key(c, ' ', false); assert.ok(!m.waiting(c));
// Un respingente devia davvero la pallina.
const b = c.bumpers[0], score = c.score;
put(c, { x: b.x, y: b.y + b.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP);
assert.ok(c.ball.vy > 0); assert.equal(c.score, score + m.SCORE.bumper);
put(c, { x: c.regolo.x, y: c.regolo.y + c.regolo.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP);
assert.equal(c.table, 'future'); assert.equal(c.score, score + m.SCORE.bumper + m.SCORE.regolo);
});
test('futuro e trasformazione sono serializzabili; annullare e disegnare non fanno avanzare la partita', () => {
for (const transforming of [false, true]) {
const c = inFuture(); if (transforming) { doubleLockAfterReturn(c); }
m.hold(c, 'left', 'touch', true); m.cancel(c);
const saved = JSON.parse(JSON.stringify(c));
const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
for (let i = 0; i < 10; i++) drawFlipper(ctx, c, story.closeups.flipper, {});
assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'disegno e pausa non consumano tempo');
assert.ok(!c.flippers.left.held.length);
run(c, 0.5); run(saved, 0.5);
assert.deepEqual(JSON.parse(JSON.stringify(c)), saved, 'ripresa identica dopo il passaggio JSON');
}
});
function doubleLockAfterReturn(c) {
for (const b of m.activeBalls(c)) Object.assign(b, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 });
m.update(c, m.STEP); doubleLock(c); rampShot(c, 'super');
}
test('il ball save nel futuro salva una sola volta, conserva campo e sigilli, poi torna al classico', () => {
const c = inFuture(); c.extraBalls = []; c.future.hits.ponte = 1;
c.saveUntil = c.sim + 2; const lives = c.balls;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.table, 'future'); assert.equal(c.balls, lives); assert.ok(m.waiting(c)); assert.equal(c.future.hits.ponte, 1);
m.launch(c, 0.7); assert.equal(m.saveLeft(c), 0);
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.table, 'classic'); assert.equal(c.balls, lives - 1);
});
test('i tre percorsi del futuro sono raggiungibili con il colpo fisico della paletta', () => {
for (const [id, x, y] of [['orbita', 288, 300], ['ponte', 290, 306], ['spira', 308, 320]]) {
const c = m.newFlipper(9);
Object.assign(c, m.futureLayout(), { table: 'future', future: { hits: { orbita: 0, ponte: 0, spira: 0 } } });
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y, vx: 0, vy: 180, inPlay: true }); m.hold(c, 'left', 'test', true);
let entered = null;
for (let i = 0; i < 160; i++) { m.update(c, m.STEP); if (c.ball?.ramp) { entered = c.ball.ramp.id; break; } }
assert.equal(entered, id, 'il tiro parte dalla paletta, non dall’imbocco');
}
});
test('salvare l’avventura durante il futuro conserva il checkpoint, come negli altri minigiochi', () => {
const g = new Game(story); g.go('via_provinciale'); settle(g);
g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g);
const checkpoint = structuredClone(g.checkpoint);
Object.assign(g.closeup, inFuture('bona'));
const before = structuredClone(g.closeup), save = JSON.parse(JSON.stringify(g.savable()));
assert.deepEqual(save, checkpoint); assert.deepEqual(g.closeup, before, 'salvare non consuma sigilli o palline');
const loaded = new Game(story, { state: save });
assert.equal(loaded.state.scene, 'via_provinciale'); assert.equal(loaded.closeup, null);
g.closeupCancel(); assert.equal(g.closeup.table, 'future');
g.closeupClose(); settle(g); assert.ok(g.state.flags.flipper_vinto, 'il traguardo resta acquisito uscendo dal futuro');
});
test('per provare, openSuper fa davvero i due LOCK e apre la SUPER SPECIAL (main.js, ?debug, tasto S)', () => {
const c = m.newFlipper(73, { level: 'bona' });
assert.ok(m.openSuper(c));
assert.equal(c.special, 'super'); assert.equal(c.locks.length, 2); assert.equal(c.table, 'classic');
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-gara'; const out = process.env.OUT ?? '/tmp/regolo-gara';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
for (const [name, mobile, viewport, level] of [ for (const [name, mobile, viewport, level] of [
['touch', true, { width: 844, height: 390 }, 'seria'], ['touch', true, { width: 844, height: 390 }, 'seria'],
['portrait', true, { width: 390, height: 844 }, 'bona'], ['portrait', true, { width: 390, height: 844 }, 'bona'],
+2 -1
View File
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-ingorgo'; const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } }); const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); }); page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`); await page.goto(`http://127.0.0.1:${server.port}/`);
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-micro'; const out = process.env.OUT ?? '/tmp/regolo-micro';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
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 }],
+3 -1
View File
@@ -84,7 +84,7 @@ export async function checkMultimedia(browser, port, out, errors) {
else await page.mouse.move(...await at(target)); else await page.mouse.move(...await at(target));
await page.waitForFunction(() => game.closeup?.step === 'reveal'); await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` }); await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
for (let n = 0; n < 200; n++) { for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
if (await page.evaluate(i => game.closeup.found > i, i)) break; if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space'); if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(25); await page.waitForTimeout(25);
@@ -97,6 +97,8 @@ export async function checkMultimedia(browser, port, out, errors) {
await settle(); await settle();
await page.waitForSelector('#ending:not([hidden])'); await page.waitForSelector('#ending:not([hidden])');
assert.ok(await page.evaluate(() => game.checkpoint.ended)); assert.ok(await page.evaluate(() => game.checkpoint.ended));
// Il vento sfuma in un attimo: con la scheda video la prova ci arriva prima che abbia finito.
await page.waitForFunction(() => ambience.nodes.size === 0, null, { timeout: 3000 }).catch(() => {});
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'alla fine non resta il vento della fessura'); assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'alla fine non resta il vento della fessura');
// Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione. // Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione.
if (!mobile) { if (!mobile) {
+9
View File
@@ -24,6 +24,15 @@ test('il brano giusto per titolo, fine e scena', () => {
assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'i', phase: sera }), 'g.mp3'); assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'i', phase: sera }), 'g.mp3');
}); });
test('il flipper del River ha la sua musica, e un\'altra la tavola del futuro', () => {
const track = trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper' });
assert.equal(track, 'assets/audio/flipper.mp3');
assert.equal(trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper', table: 'classic' }), track);
const future = trackFor(story, { screen: 'game', scene: 'river', closeup: 'flipper', table: 'future' });
assert.equal(future, 'assets/audio/flipper-futuro.mp3', 'il Regolo del futuro ha la sua musica');
for (const f of [track, future]) assert.ok(existsSync(f), f);
});
test('volume e muto', () => { test('volume e muto', () => {
const m = new Music({ volume: 0.5, muted: true }); const m = new Music({ volume: 0.5, muted: true });
assert.equal(m.setVolume(1.4), 1); assert.equal(m.setVolume(1.4), 1);
+106
View File
@@ -0,0 +1,106 @@
// Il peso della grafica (Andrea, 1 novembre 2026: «prima senza GPU scoprivamo anche gli effetti troppo pesanti»): le
// altre prove disegnano sulla scheda video e un effetto pesante non le rallenta più. Questa usa sempre swiftshader, il
// disegno sul processore, come un telefono lento: nel gioco vero, in ogni scena nelle tre fasi e in ogni primo piano,
// lascia girare il gioco e misura i fotogrammi al secondo e l'arretrato grafico (il disegno chiesto e non ancora fatto).
// node tests/peso.browser.mjs (PESO_TUTTI=1 stampa ogni misura).
//
// Come si misura, e perché così (vedi la memoria «effetti leggeri»): mai leggere i pixel della tela del gioco, perché
// dopo qualche lettura Chrome la passa al processore e la misura cambia ciò che misura; l'arretrato si cronometra
// leggendo una tela nuova, abbastanza grande da stare anche lei sulla grafica, che deve aspettare tutto il disegno
// in coda prima della sua. Tarata il 1° novembre 2026 sul PC di Andrea: una scena normale, a immagini caricate, pochi ms
// di arretrato e 60 fotogrammi; un ctx.filter blur a tutto schermo 816 ms e 34, quaranta tratti larghi trasparenti 718 ms
// e 36, trenta veli trasparenti a tutto schermo 374 ms e 45.
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { SWIFTSHADER } from './chromium.mjs';
const MAX_BACKLOG = 500, MIN_FPS = 30; // la scena più cara, pieve_interno di pomeriggio, fino a 355 ms sotto carico // ms di arretrato dopo un secondo di gioco; fotogrammi al secondo
const server = await staticServer();
let browser;
const errors = [], slow = [], rows = [];
try {
browser = await chromium.launch({ args: SWIFTSHADER });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => errors.push(e.message));
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);
const probe = () => {
const c = document.createElement('canvas'); c.width = 640; c.height = 360;
const g = c.getContext('2d'); g.fillRect(0, 0, 9, 9);
const t = performance.now(); g.getImageData(0, 0, 1, 1); return performance.now() - t;
};
window.peso = async () => {
// Prima si lascia smaltire l'arretrato di prima, compreso il caricamento delle immagini alla prima visita di una
// scena (che costa una volta sola e non è un effetto): finché la lettura non torna svelta, al più due secondi.
await new Promise(r => setTimeout(r, 150));
for (let i = 0; i < 20 && probe() > 30; i++) await new Promise(r => setTimeout(r, 100));
let n = 0; const t0 = performance.now();
await new Promise(done => { const tick = () => { n++; if (performance.now() - t0 < 800) requestAnimationFrame(tick); else done(); }; requestAnimationFrame(tick); });
const backlog = Math.round(probe());
return { fps: Math.round(n * 1000 / (performance.now() - t0)), backlog };
};
});
// Una misura storta per un attimo di carico del PC (le altre prove girano insieme) si ripete dopo un secondo di
// respiro: boccia solo se è pesante due volte.
const measure = async label => {
let m = await page.evaluate(() => peso());
const heavy = m => m.backlog > MAX_BACKLOG || m.fps < MIN_FPS;
if (heavy(m)) { await page.waitForTimeout(1000); m = await page.evaluate(() => peso()); }
rows.push({ label, ...m });
if (heavy(m)) slow.push(`${label}: ${m.fps} fotogrammi al secondo, arretrato ${m.backlog} ms`);
if (process.env.PESO_TUTTI) console.log(`${String(m.fps).padStart(3)} fps ${String(m.backlog).padStart(5)} ms ${label}`);
};
// Le scene, nelle tre fasi (le condizioni delle fasi come in ambient.browser.mjs). Prima un giro in tutte, perché la
// prima visita paga il caricamento delle immagini, che non è un effetto.
const scenes = await page.evaluate(() => Object.keys(game.story.scenes));
for (const id of scenes) {
await page.evaluate(id => { const sc = game.story.scenes[id]; game.state.scene = id; [game.state.x, game.state.y] = sc.entry; }, id);
await page.waitForTimeout(120);
}
for (const [phase, flags] of [['pomeriggio', {}], ['tramonto', { indizio_regolo: true }], ['sera', { casetta_aperta: true }]]) {
for (const id of scenes) {
await page.evaluate(({ id, flags }) => {
const g = game, sc = g.story.scenes[id];
Object.assign(g.state.flags, flags);
g.state.scene = id; [g.state.x, g.state.y] = sc.entry; g.enteredAt = g.time;
}, { id, flags });
await measure(`${id} (${phase})`);
await page.evaluate(flags => { for (const k of Object.keys(flags)) delete game.state.flags[k]; }, flags);
}
}
// I primi piani, aperti come li apre la storia; il flipper anche con la pallina in gioco e nel Regolo del futuro.
const closeups = await page.evaluate(() => Object.keys(game.story.closeups));
for (const id of closeups) {
const opened = await page.evaluate(id => {
game.state.scene = 'piazza';
try { game.run({ closeup: id }); } catch (e) { return String(e); }
return true;
}, id);
if (opened !== true) { console.log(`${id}: non si apre da solo (${opened})`); continue; }
await measure(`primo piano ${id}`);
if (await page.evaluate(() => game.story.closeups[game.closeup.id].type === 'flipper')) {
await page.evaluate(async () => { const m = await import('/src/engine/flipper.js'); m.launch(game.closeup, 0.8); });
await measure(`primo piano ${id} (in gioco)`);
const future = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js');
if (!m.futureLayout) return false;
Object.assign(game.closeup, m.futureLayout(), { table: 'future', future: { hits: { orbita: 0, ponte: 0, spira: 0 } } });
return true;
});
if (future) await measure(`primo piano ${id} (Regolo del futuro)`);
}
await page.evaluate(() => { game.closeup = null; game.queue.length = 0; game.speech = null; });
}
const worst = [...rows].sort((a, b) => b.backlog - a.backlog).slice(0, 4).map(r => `${r.label} ${r.backlog} ms`).join(', ');
console.log(`Peso della grafica con swiftshader: ${rows.length} misure; gli arretrati più alti: ${worst}`);
assert.deepEqual(slow, [], `troppo pesanti con swiftshader (soglie: ${MAX_BACKLOG} ms, ${MIN_FPS} fotogrammi al secondo)`);
assert.deepEqual(errors, []);
console.log('Prova del peso: OK');
} finally {
await browser?.close();
server.kill();
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-risveglio'; const out = process.env.OUT ?? '/tmp/regolo-risveglio';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
// Sul computer la versione con la sfocatura vera, sul telefono (touch) quella leggera senza filtri. // Sul computer la versione con la sfocatura vera, sul telefono (touch) quella leggera senza filtri.
for (const mobile of [false, true]) { for (const mobile of [false, true]) {
const page = await browser.newPage(mobile ? { viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } : { viewport: { width: 1280, height: 860 } }); const page = await browser.newPage(mobile ? { viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } : { viewport: { width: 1280, height: 860 } });
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true }); const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = []; const server = await staticServer(), errors = [];
@@ -44,7 +45,7 @@ async function wait(page) {
}); });
} }
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const scenarios = [ const scenarios = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true }, { name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } }, { name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-smarronata'; const out = process.env.OUT ?? '/tmp/regolo-smarronata';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } }); const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); }); page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`); await page.goto(`http://127.0.0.1:${server.port}/`);
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-telefono'; const out = process.env.OUT ?? '/tmp/regolo-telefono';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser; let browser;
const errors = []; const errors = [];
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } }); const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); }); page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`); await page.goto(`http://127.0.0.1:${server.port}/`);
+21 -4
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true }); const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = []; const server = await staticServer(), errors = [];
@@ -12,16 +13,32 @@ 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); } 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); else await page.click(selector);
} }
// Una battuta che compare proprio in quell'istante (le battute del giocatore partono dal ciclo del gioco) si prende il
// tasto e la salta, come nel gioco: allora, come un giocatore, si ripreme. Al più tre volte.
async function pressUntil(page, key, done, arg) {
for (let i = 0; i < 3; i++) {
await page.keyboard.press(key);
if (await page.evaluate(done, arg)) return;
await page.waitForFunction(() => !game.speech);
}
}
async function playCard(page, mode, index, signal) { async function playCard(page, mode, index, signal) {
// Un tasto premuto mentre qualcuno parla salta la battuta e basta, e il segnale si sceglie solo quando tocca a noi
// (come nel gioco): si aspetta che la battuta sia finita e che si possa giocare.
if (mode === 'keyboard') {
await page.evaluate(async () => { window.tressetteRules ??= await import('/src/engine/tressette.js'); });
await page.waitForFunction(() => !game.speech && tressetteRules.canPlay(game.closeup));
}
if (signal) { if (signal) {
if (mode === 'keyboard') await page.keyboard.press(signal[0]); if (mode === 'keyboard') await pressUntil(page, signal[0], s => game.closeup.signal === s, signal);
else await tap(page, mode, `#tressette-${signal}`); else await tap(page, mode, `#tressette-${signal}`);
assert.equal(await page.evaluate(() => game.closeup.signal), signal); assert.equal(await page.evaluate(() => game.closeup.signal), signal);
} }
if (mode === 'keyboard') { if (mode === 'keyboard') {
await page.keyboard.press(String((index + 1) % 10)); await pressUntil(page, String((index + 1) % 10), i => game.closeup.selected === i, index);
assert.equal(await page.evaluate(() => game.closeup.selected), index); assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('Enter'); const held = await page.evaluate(() => game.closeup.hands[0].length);
await pressUntil(page, 'Enter', n => game.closeup.hands[0].length < n || !!game.closeup.refused, held);
return; return;
} }
const at = await page.evaluate(async i => { const at = await page.evaluate(async i => {
@@ -46,7 +63,7 @@ async function wait(page) {
}); });
} }
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const scenarios = [ const scenarios = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true }, { name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } }, { name: 'telefono-touch', mode: 'touch', viewport: { width: 390, height: 844 } },
+2 -1
View File
@@ -4,13 +4,14 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs'; import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.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(); const server = await staticServer();
let browser; let browser;
try { try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ARGS });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
+90
View File
@@ -0,0 +1,90 @@
// Le prove nel browser a sezioni (scripts/verify.mjs): ogni file del gioco finisce negli ingressi delle prove giuste,
// e l'impronta cambia solo se cambia un ingresso.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { SUITES, ENGINE, inputsOf, ignored, fingerprint, plan } from '../scripts/verify.mjs';
const FILES = ['index.html', 'style.css', 'src/main.js', 'src/version.js', 'package.json', 'package-lock.json', 'Makefile', 'README.md',
'src/engine/game.js', 'src/engine/render.js', 'src/engine/ambient.js', 'src/engine/bocce.js', 'src/engine/nuovo.js',
'src/engine/briscola.js', 'src/engine/briscola-render.js', 'src/engine/flipper.js', 'src/engine/flipper-render.js', 'src/engine/corsa.js', 'src/story/piloti.js',
'src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'assets/closeups/micro-parcheggio.png',
'assets/closeups/gp/boccino.png', 'assets/muro/index.html', 'assets/video/briscola.mp4', 'docs/BRISCOLA.md', 'docs/STATO.md',
'tests/helpers.mjs', 'tests/chromium.mjs', 'tests/browser.mjs', 'tests/briscola.browser.mjs', 'tests/briscola.test.mjs', 'tests/story.test.mjs',
'scripts/verify.mjs', 'scripts/bump.sh', 'tools/slice_sprites.py', '.verify-ok.json'];
const suitesWith = path => Object.keys(SUITES).filter(n => inputsOf(n, FILES).includes(path));
const islands = Object.keys(SUITES).filter(n => !SUITES[n].core), cores = Object.keys(SUITES).filter(n => SUITES[n].core);
test('ogni prova nel browser di tests/ ha la sua voce, e una sola', () => {
const runs = Object.values(SUITES).map(s => s.run[0]);
for (const f of readdirSync('tests').filter(f => f.endsWith('.browser.mjs'))) {
if (f === 'multimedia.browser.mjs') continue; // la chiama la parte «telefoni» di browser.mjs
const n = runs.filter(r => r === `tests/${f}`).length;
assert.equal(n, f === 'browser.mjs' ? 3 : 1, `tests/${f} è in ${n} prove`);
}
});
test('la briscola si riprova solo se cambia la briscola o il motore', () => {
assert.deepEqual(suitesWith('src/engine/briscola.js'), ['briscola']);
assert.deepEqual(suitesWith('src/engine/briscola-render.js'), ['briscola', 'peso'], 'un disegno fa rifare anche il peso della grafica');
assert.deepEqual(suitesWith('assets/video/briscola.mp4'), ['briscola']);
assert.deepEqual(suitesWith('tests/briscola.browser.mjs'), ['briscola']);
assert.deepEqual(suitesWith('docs/BRISCOLA.md'), [], 'i documenti non fanno rifare niente');
assert.ok(!inputsOf('briscola', FILES).includes('src/story/story.js'), 'la storia non fa riprovare la briscola');
assert.deepEqual(suitesWith('src/engine/corsa.js'), ['gara', 'peso']);
assert.deepEqual(suitesWith('src/story/piloti.js'), ['gara']);
assert.deepEqual(suitesWith('assets/closeups/gp/boccino.png'), ['gara']);
assert.deepEqual(suitesWith('assets/muro/index.html'), ['telefono']);
assert.deepEqual(suitesWith('assets/closeups/micro-parcheggio.png'), ['micro']);
});
test('la storia, le scene, i minigiochi dentro la partita e un modulo nuovo fanno rifare la partita intera, non i minigiochi a sé', () => {
assert.deepEqual(suitesWith('tests/browser.mjs'), ['principale', 'minigiochi', 'telefoni'], 'il file di una prova conta solo per lei');
for (const path of ['src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'src/engine/bocce.js', 'src/engine/nuovo.js']) {
assert.deepEqual(suitesWith(path), cores, path);
}
assert.deepEqual(suitesWith('src/engine/ambient.js'), [...cores, 'peso'], 'gli effetti delle scene: la partita e il peso');
});
test('il peso della grafica si rifà quando cambia un disegno o un effetto, non la storia, e non toglie quei file alle altre prove', () => {
const peso = inputsOf('peso', FILES);
for (const path of ['src/engine/render.js', 'src/engine/ambient.js', 'src/engine/briscola-render.js', 'src/engine/flipper-render.js']) assert.ok(peso.includes(path), path);
for (const path of ['src/story/story.js', 'src/engine/briscola.js', 'src/engine/flipper.js']) assert.ok(!peso.includes(path), path);
assert.ok(inputsOf('principale', FILES).includes('src/engine/ambient.js'));
assert.deepEqual(suitesWith('src/engine/flipper-render.js'), ['flipper', 'peso']);
});
test('il motore comune fa rifare tutto', () => {
for (const path of ENGINE) assert.deepEqual(suitesWith(path), Object.keys(SUITES), path);
assert.ok(islands.length >= 10 && cores.length >= 3);
});
test('versione, package.json, Makefile, documenti, strumenti e test unitari non fanno rifare niente', () => {
for (const path of ['src/version.js', 'package.json', 'Makefile', 'README.md', 'docs/STATO.md', 'scripts/bump.sh', 'scripts/verify.mjs', 'tools/slice_sprites.py', 'tests/briscola.test.mjs', 'tests/story.test.mjs', '.verify-ok.json']) {
assert.ok(ignored(path), `${path} dovrebbe essere ignorato`);
assert.deepEqual(suitesWith(path), [], path);
}
});
test("l'impronta cambia solo se cambia un ingresso, e il piano dice quali", () => {
const hashes = new Map(FILES.map(p => [p, 'h-' + p]));
const before = plan(hashes, {});
assert.ok(before.every(p => !p.green), 'mai provate: tutte da rifare');
const ok = Object.fromEntries(before.map(p => [p.name, { fingerprint: p.fp, hashes: Object.fromEntries(p.inputs.map(f => [f, hashes.get(f)])) }]));
assert.ok(plan(hashes, ok).every(p => p.green), 'niente cambiato: tutte verdi');
hashes.set('docs/STATO.md', 'altro');
assert.ok(plan(hashes, ok).every(p => p.green), 'un documento non cambia niente');
hashes.set('src/engine/briscola.js', 'altro');
const after = plan(hashes, ok);
assert.deepEqual(after.filter(p => !p.green).map(p => p.name), ['briscola']);
assert.deepEqual(after.find(p => p.name === 'briscola').changed, ['src/engine/briscola.js']);
assert.notEqual(fingerprint(['a'], () => '1'), fingerprint(['a'], () => '2'));
});
test('la pipeline di Gitea prova le stesse prove, con lo stesso Playwright del progetto', () => {
const wf = readFileSync('.gitea/workflows/test.yml', 'utf8');
const listed = wf.match(/prova: \[([^\]]*)\]/)[1].split(',').map(s => s.trim());
assert.deepEqual([...listed].sort(), Object.keys(SUITES).sort());
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')).packages['node_modules/playwright'].version;
assert.match(wf, new RegExp(`mcr\\.microsoft\\.com/playwright:v${lock.replace(/\./g, '\\.')}-`), `immagine di Playwright diversa da ${lock}`);
});
+13
View File
@@ -0,0 +1,13 @@
# Configurazione di act_runner (vedi docker-compose.yml accanto).
log:
level: info
runner:
file: .runner
capacity: 6
timeout: 30m
container:
network: ""
privileged: false
options: "--memory=4g"
valid_volumes: []
force_pull: false
+19
View File
@@ -0,0 +1,19 @@
# Runner delle Gitea Actions di git.manzolo.it, sul PC di Andrea (dal 2026-11-01). Si crea e si avvia con
# `make runner` (scripts/runner.sh): registra il runner sul repo se manca e lo lascia acceso (riparte con Docker).
# I job girano in container fratelli tramite il socket Docker del PC; le credenziali del runner stanno fuori dal repo.
name: gitea-runner
services:
runner:
image: gitea/act_runner:0.6.1
container_name: gitea-runner
restart: unless-stopped
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: https://git.manzolo.it
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-pc-andrea}
GITEA_RUNNER_LABELS: ubuntu-latest:docker://node:22-bookworm
volumes:
- ./config.yaml:/config.yaml:ro
- ${RUNNER_DATA:?}:/data
- /var/run/docker.sock:/var/run/docker.sock