Compare commits

..
36 Commits
Author SHA1 Message Date
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
manzoloandClaude Opus 5.5 3a9db6d109 Il deploy passa dal registry di Gitea: make deploy costruisce l'immagine dal tag della versione, la spinge su git.manzolo.it/manzolo/leggenda-regolo:vX.Y.Z e :latest, scrive il tag nel compose del VPS e lì fa pull e up; tolto docker save/load. Il compose di produzione si chiama docker-compose.deploy.yml. Nel README il download dagli zip dei tag, in CLAUDE.md il nuovo flusso (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 10:44:58 +01:00
manzolo 797eb80980 Versione 0.1.67 2026-11-01 00:31:17 +01:00
manzoloandClaude Fable 5.1 00e9f7c4d7 Il flipper: chiuso il canale dietro la Pieve fra la parete e il dorso dei bersagli, dove la pallina scendeva dritta nell'uscita sinistra (Andrea: «a sinistra esce troppo facile»); due raccordi inclinati, disegnati come le altre sponde; test (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 00:21:49 +01:00
manzoloandClaude Fable 5.1 77a8777dcd Il flipper alleggerito senza cambiarne l'aspetto: il mobile statico sullo sfondo CSS della tela invece di una copia a tutto schermo per fotogramma, estremità e giunti arrotondati solo nei livelli in cache; arretrato grafico da 4,2 s a 0,03 s per 150 fotogrammi; la misura dell'arretrato nella prova nel browser al posto dell'intervallo fra frame, che non misurava nulla (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 00:14:31 +01:00
manzoloandClaude Fable 5.1 b660bbeb96 Il flipper rifatto da Codex: tavola ad alta risoluzione con lacca, cromature e luci animate; due LOCK (tre passaggi sulla stessa rampa in venti secondi, Fortezza e Trebbio), la SUPER SPECIAL al centro che si apre con entrambi i lock e dà pallina premio e multiball a tre; jackpot fino a x3 coi bersagli, nuove fanfare; la valvola contro il rientro nella molla; display del punteggio e pannello della caccia al jackpot ai lati; test unitari e nel browser (lock, super, multiball) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:47:51 +01:00
manzoloandClaude Fable 5.1 bbce40b014 Test del tabellone: quanto manca al traguardo sta nella riga in basso, non più nel pannello
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:18:09 +01:00
manzoloandClaude Fable 5.1 0ec45c8745 Il flipper col LOCK (idea di Andrea, fatto da Codex e completato): lo stesso ponte tre volte trattiene la pallina e ne dà una nuova, l'altro ponte SPECIAL vale il JACKPOT e il MULTIBALL a due palline; serratura, seconde palline e SPECIAL nel disegno, suoni, aiuto, test; imbocchi delle rampe al centro e uscite laterali più strette (Codex); pallina premio al traguardo; la barra del traguardo sotto la finestra invece del pannello sul Trebbio; il Boccino prudente in curva
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:17:36 +01:00
manzoloandClaude Fable 5.1 dfa70a9620 Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) corre il GP di Fortezza col suo NRG bianco e rosso numero 1 (motorino e ritratto di Codex dalle sue foto) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:52:53 +01:00
manzoloandClaude Fable 5.1 3079c6ea8a Il tabellone del flipper è un vero backglass: il fondale di Codex col Castello del Trebbio, la Fortezza, il Regolo e i fuochi dei rioni; le cifre nella finestra nera, le lampadine sui bordi, i dischi delle palline e dei rioni, i ponti ai lati, il fondale che lampeggia ai colpi grossi (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:10:55 +01:00
manzoloandClaude Fable 5.1 4d4aa8aeea Il flipper più spettacolare: anelli e scintille, coriandoli dei rioni, luci che corrono sui ponti, punti che volano, lampo e scossa, scia della pallina, regia e fanfare sul tabellone; ponti, sponde e griglia dei punti in cache (niente arretrato grafico) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:06:26 +01:00
manzoloandClaude Fable 5.1 721b6de930 Test della combo: la pallina persa è in gioco, non sulla molla
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:47:50 +01:00
54 changed files with 2566 additions and 733 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/
.env
deploy.local.mk
.verify-ok.json
+11 -1
View File
@@ -16,9 +16,19 @@ 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).
- 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.
- 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.
- 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.
- Sfondi nuovi: li genera **Codex** con il suo strumento immagini integrato, gratis e senza chiave API
(`codex exec`, ricetta in `docs/GENERAZIONE_SFONDI.md`, «Generare con Codex»). Si lancia da qui, senza chiedere la chiave ad Andrea.
- **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`.
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`.
+50 -25
View File
@@ -23,8 +23,8 @@ DST ?= assets/scenes/$(notdir $(SRC))
CROP ?=
COLORS ?= 64
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
build up down restart logs ps shell image-size deploy deploy-logs clean distclean
.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 runner clean distclean
## ------------------------------------------------------------ sviluppo
@@ -47,14 +47,26 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
$(NODE) --test tests/*.test.mjs
$(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
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)
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
scripts/bump.sh $(or $(PARTE),patch)
@@ -121,40 +133,53 @@ image-size: ## Dimensione dell'immagine
## ------------------------------------------------------------ deploy su un server proprio
# Host e indirizzo stanno in deploy.local.mk, fuori da git, per esempio:
# DEPLOY_HOST = root@mio-server
# DEPLOY_URL = https://gioco.esempio
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
# Host e cartella stanno in deploy.local.mk, fuori da git, per esempio:
# DEPLOY_HOST = root@mio-server
# DEPLOY_URL = https://gioco.esempio
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
# DEPLOY_IMAGE = registry.esempio/utente/leggenda-regolo
-include deploy.local.mk
DEPLOY_DIR ?= /root/leggenda-regolo
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
DEPLOY_DIR ?= /root/leggenda-regolo
DEPLOY_IMAGE ?= git.manzolo.it/manzolo/leggenda-regolo
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
# La versione dell'ultimo commit (non della cartella di lavoro): il tag git, dell'immagine e del compose.
DEPLOY_VER := v$(shell git show HEAD:src/version.js 2>/dev/null | sed -n "s/.*VERSION = '\([^']*\)'.*/\1/p")
# Si pubblica l'ultimo commit (git archive HEAD), mai la cartella di lavoro: un'altra sessione può avere modifiche a
# metà. Test su quella copia, immagine costruita e provata qui, poi spedita già pronta (docker save | docker load):
# sul server non si compila niente.
# Si pubblica il tag della versione dichiarata dall'ultimo commit (git archive vX.Y.Z, quello di make bump), mai la
# cartella di lavoro: un'altra sessione può avere modifiche a metà, e i commit dopo il tag aspettano la versione dopo.
# Test su quella copia, immagine costruita e provata qui, spinta sul registry di Gitea come :vX.Y.Z e :latest; il
# VPS la scarica con il tag scritto nel suo compose (preso da HEAD, docker-compose.deploy.yml).
SMOKE_PORT ?= 18737
deploy: ## Pubblica l'ultimo commit: test, immagine costruita e provata qui, spedita al server e riavviata
deploy: ## Pubblica la versione dell'ultimo commit: test, immagine provata qui, push sul registry, pull e riavvio sul server
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
mkdir -p $(DEPLOY_TMP) && git archive HEAD | tar -x -C $(DEPLOY_TMP)
@git rev-parse -q --verify 'refs/tags/$(DEPLOY_VER)' >/dev/null || { echo "manca il tag $(DEPLOY_VER): prima make bump"; exit 2; }
mkdir -p $(DEPLOY_TMP) && git archive $(DEPLOY_VER) | tar -x -C $(DEPLOY_TMP)
cd $(DEPLOY_TMP) && $(NODE) --test tests/*.test.mjs
docker build -q -t $(IMAGE):latest -t $(IMAGE):$$(git rev-parse --short HEAD) $(DEPLOY_TMP)
docker build -q -t $(DEPLOY_IMAGE):$(DEPLOY_VER) -t $(DEPLOY_IMAGE):latest $(DEPLOY_TMP)
@docker rm -f $(IMAGE)-prova >/dev/null 2>&1 || true
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(IMAGE):latest >/dev/null
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(DEPLOY_IMAGE):$(DEPLOY_VER) >/dev/null
@ok=1; sleep 1; for u in / /src/main.js /assets/voices/index.json /assets/audio/cartello.mp3; do \
c=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$(SMOKE_PORT)$$u); echo " prova $$u → $$c"; [ "$$c" = 200 ] || ok=0; done; \
docker rm -f $(IMAGE)-prova >/dev/null; [ $$ok = 1 ] || { echo "l'immagine non risponde come deve: non la spedisco"; exit 1; }
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR)'
scp -q $(DEPLOY_TMP)/docker-compose.homeserver.yml $(DEPLOY_HOST):$(DEPLOY_DIR)/docker-compose.yml
docker save $(IMAGE):latest | gzip | ssh $(DEPLOY_HOST) 'gunzip | docker load -q && cd $(DEPLOY_DIR) && docker compose up -d && docker image prune -f >/dev/null'
docker push -q $(DEPLOY_IMAGE):$(DEPLOY_VER)
docker push -q $(DEPLOY_IMAGE):latest
git show HEAD:docker-compose.deploy.yml | sed 's|$(DEPLOY_IMAGE):VERSIONE|$(DEPLOY_IMAGE):$(DEPLOY_VER)|' | \
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR) && cat > $(DEPLOY_DIR)/docker-compose.yml'
ssh $(DEPLOY_HOST) 'cd $(DEPLOY_DIR) && docker compose pull -q && docker compose up -d && \
docker image ls $(DEPLOY_IMAGE) --format "{{.Repository}}:{{.Tag}}" | grep -v -x -e $(DEPLOY_IMAGE):$(DEPLOY_VER) | xargs -r docker image rm >/dev/null; \
docker image prune -f >/dev/null'
rm -rf $(DEPLOY_TMP)
@# Sul server il prune toglie l'immagine vecchia; qui restano solo latest e quella appena pubblicata.
@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $$(git rev-parse --short HEAD) | \
xargs -r -I{} docker image rm -f $(IMAGE):{} >/dev/null
@# Qui restano solo latest e la versione appena pubblicata.
@docker image ls $(DEPLOY_IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $(DEPLOY_VER) | \
xargs -r -I{} docker image rm -f $(DEPLOY_IMAGE):{} >/dev/null
@# L'indirizzo pubblico deve servire la versione appena spedita.
@v=$$(grep -o "VERSION = '[^']*'" src/version.js); \
@v=$$(git show $(DEPLOY_VER):src/version.js | grep -o "VERSION = '[^']*'"); \
for i in 1 2 3 4 5; do curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" && break; sleep 2; done; \
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
@echo "→ $(DEPLOY_URL) ($$(git log -1 --format='%h %s'))"
@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
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
+7 -5
View File
@@ -23,7 +23,9 @@ Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero),
## Giocare in locale
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
Il gioco si scarica da **https://git.manzolo.it/manzolo/leggenda-del-regolo**: nella pagina dei
[tag](https://git.manzolo.it/manzolo/leggenda-del-regolo/tags) c'è lo zip di ogni versione (l'ultima è quella online).
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES. Dentro la cartella scompattata:
```zsh
python3 -m http.server 8000 # poi http://localhost:8000
@@ -40,16 +42,16 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con
- `assets/`: sfondi, sprite, musiche, voci, carattere, video.
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi).
- `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre
tutta la soluzione dell'avventura, su desktop e telefono); `npm run browser:briscola`, `:tressette`, `:scopa`,
`:biliardo`, `:effects` per le parti singole.
tutta la soluzione dell'avventura, su desktop e telefono, in tre processi paralleli; `--parte=` per una sola);
`npm run browser:briscola`, `:tressette`, `:scopa`, `:biliardo`, `:effects` per le parti singole.
- `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale.
- `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
```zsh
make 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 bump # nuova versione: verifica, commit e tag
make deploy # pubblica l'ultimo commit (immagine Docker spedita al server)
make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica
```
## Chi ha fatto cosa
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+15
View File
@@ -0,0 +1,15 @@
# Il compose del VPS (make deploy lo copia in DEPLOY_DIR con il tag della versione al posto di VERSIONE).
# L'immagine viene dal registry di Gitea; niente porte esposte: il container sta sulla rete esterna nginx-net,
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
# Per tornare indietro: sul VPS si rimette il tag precedente qui sotto e si rifà `docker compose pull && docker compose up -d`.
services:
leggenda-regolo:
image: git.manzolo.it/manzolo/leggenda-regolo:VERSIONE
container_name: leggenda-regolo
restart: unless-stopped
networks:
- nginx-net
networks:
nginx-net:
external: true
-13
View File
@@ -1,13 +0,0 @@
# Deploy dietro un reverse proxy (make deploy): l'immagine arriva già costruita dal PC (docker load), niente porte esposte, il container sta sulla rete esterna nginx-net,
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
services:
leggenda-regolo:
image: leggenda-regolo:latest
container_name: leggenda-regolo
restart: unless-stopped
networks:
- nginx-net
networks:
nginx-net:
external: true
+92 -22
View File
@@ -50,27 +50,61 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16);
vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`.
[DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?).
- **Il flipper del River (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, si sceglie il livello e si gioca
(`src/engine/flipper.js`, disegno `flipper-render.js`, tavola di Codex `assets/closeups/river-flipper.png` con San
Piero dipinto: Fortezza, Pieve, torre, la Sieve che scende fra i flipper, il ponte, il Regolo con la corona, le
bandiere dei rioni). Fisica a passo fisso: molla, arco, quattro corsie dei rioni (tutte e quattro = PALIO), tre
funghi, bersagli a caduta della Pieve e della torre, il Regolo, sponde elastiche, due flipper, uscite laterali,
salvataggio della pallina una volta sola. Comandi: molla tenendo premuto (o cursore + Lancia, o Spazio), flipper
con le due metà della scena, i pulsanti «← Flipper»/«Flipper →» tenuti premuti o le frecce. Tre palline; traguardo
6000/12000/25000. Test `flipper.test.mjs` (col giocatore automatico), prova `npm run browser:flipper`.
Rivisto con la revisione critica di Codex (31 ottobre, chiesta da Andrea): punti solo a un urto nuovo (la pallina
appoggiata sul Regolo faceva 240.000 punti al secondo), sottopassi contro gli attraversamenti del flipper, niente
cancelletto-ripiano e molla che arriva sempre in campo, salvataggio col tempo della simulazione (e in vista sul
quadro), corsie che contano attraversando la riga, traguardo acquisito appena raggiunto (poi si continua per il
record), annullare non lancia, due dita sulla scena e tasto destro del mouse, pulsanti dei flipper sempre attivi.
[DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). Poi (Andrea): i due ponti (rampe `ramps`: imbocco in salita, percorso sopra la tavola, uscita dall'altra
parte; tutti e due di fila i PONTI), punti ×10 (traguardi 60.000/120.000/200.000), il tabellone a punti luminosi
(cifre che corrono, scritte che lampeggiano, lampadine che girano; carattere 5×7 in flipper-render.js) e un clic sul
River che entra e va al flipper. Seconda revisione di Codex (31 ottobre): i flipper rispondono anche con la pallina
sul ponte, l'imbocco conta solo la velocità lungo il ponte, salita e discesa con la stessa energia (meno l'attrito),
l'uscita dritta nella corsia di ritorno, la combo che scade e si azzera con la pallina persa, le scritte in coda col
traguardo davanti, il tabellone che dice l'esito giusto, le cifre che corrono in tempo fisso, i due tasti del mouse
dalla maschera `buttons` (il destro è sempre il flipper di destra), i glifi 3 e 5 rifatti.
- **Il flipper del River / Il Regolo (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, livello e
partita. Motore `src/engine/flipper.js`, disegno `flipper-render.js`. Tavolo rifatto in lacca scura, metallo,
rampe con ombre e luci ciano/ambra/viola, riflessi sulle palline e plastiche in rilievo; scena a 1280×720 con
coordinate logiche 640×360, gli altri ambienti restano in pixel art. Fondale di Codex conservato nel riquadro
illustrato del backglass; niente pannelli sopra il Trebbio. Superfici e bagliori in cache, senza disegnare la
strada nascosta sotto il flipper. Pannello a destra con progressione, timer, moltiplicatore e prossimo obiettivo.
Alleggerito (1 novembre): il mobile statico sta sullo sfondo CSS della tela (`cabinetBehind`), la tela si pulisce
e basta; estremità e giunti arrotondati solo nei livelli in cache. Arretrato grafico da 4,2 s a 0,03 s per 150
fotogrammi (regole e misure nella memoria «effetti leggeri»).
- Rampe FORTEZZA e TREBBIO: imbocchi centrali da 36 pixel, 27 utili alla pallina, salite accessibili con colpi
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
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 (× moltiplicatore), una pallina extra in
riserva e, dal 1 novembre, **Il Regolo del futuro**. Un botto sintetico grave, lampo, onde d'urto, schegge
colorate e scossa del mobile precedono l'apertura in due metà del piano classico. Trasformazione di 3,2 s:
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.
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.
- Uscite laterali da 14/12 pixel, corsie di ritorno alle palette più ampie. Il canale di 10 pixel dietro la
Pieve, che portava la pallina dritta nell'uscita sinistra, è chiuso da due raccordi inclinati (1 novembre). Valvola inclinata alla corsia di
lancio: il lancio passa, i ritorni dal campo vengono deviati a sinistra. Ogni pallina ha contatti, scia e
anti-incastro propri; urti fra palline, palette mosse una sola volta per passo. Pausa ferma anche i timer.
- 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.
Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca».
- Test `flipper.test.mjs`: sequenze a tutti i livelli e in entrambi gli ordini, trasformazione protetta,
rilascio sfalsato, fisica e punteggi dei nuovi percorsi, tiri reali dalla paletta, Regolo mobile, finale,
ritorno e nuova SUPER, ball save, stato serializzabile e disegno senza mutazioni. I salvataggi dell'avventura
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.
- **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.»
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
@@ -291,6 +325,42 @@ versione nuova.
- **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).
- **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.
- **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)
| File | Cosa | Uso previsto |
@@ -455,7 +525,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo NRG bianco e rosso numero 1 e il ritratto di Codex. Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
+27 -13
View File
@@ -86,6 +86,7 @@
<div class="buttons pause-buttons">
<button id="resume">Riprendi</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 class="open-credits secondary">Ringraziamenti</button>
<button id="to-title" class="secondary">Torna al titolo</button>
@@ -304,19 +305,32 @@
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
</details>
<details id="flipper-help" hidden><summary>Come si gioca</summary>
<p>Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e
scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (30.000 punti). I tre funghi
sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (10.000). I due ponti: imboccali dal basso con un colpo deciso, la pallina ci sale sopra e
scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un
bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.</p>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra.
Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000,
a fassi male 200.000; raggiunto il traguardo, si continua per il record.</p>
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure tieni
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 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>
<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.
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>
<p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti,
una pallina extra in riserva e una sorpresa esplosiva! Il piano si apre e rivela <strong>Il Regolo del futuro</strong>,
un secondo campo al neon. Durante la trasformazione le tre palline sono al sicuro; poi parte il MULTIBALL,
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>
<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
quattro corsie dei rioni per il PALIO da 30.000. I respingenti valgono 1.000, i bersagli laterali 5.000
l'uno e 25.000 la serie, il Regolo coronato 10.000.</p>
<p>Parti con tre palline. Appena lanciata, la pallina è protetta una volta: 8 secondi alla bona, 4 seria,
1 a fassi male. Il traguardo è 60.000, 120.000 o 200.000 punti: raggiungerlo assegna una pallina premio
una sola volta per partita. Poi puoi continuare per il record, i LOCK e la SUPER SPECIAL.</p>
</details>
<details id="cerbottana-help" hidden><summary>Come si gioca</summary>
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.66",
"version": "0.1.71",
"private": true,
"type": "module",
"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();
+534 -199
View File
@@ -1,212 +1,547 @@
// Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti
// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due
// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js';
import { text, panel } from './bocce-render.js';
// 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.
import { R, SCORE, RAMP, FUTURE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => {
ctx.lineCap = 'round';
ctx.strokeStyle = dark; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.strokeStyle = light; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
};
function disc(ctx, x, y, r, fill, rim) {
ctx.fillStyle = rim; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - 2, 0, Math.PI * 2); ctx.fill();
const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff', orbita: '#58f4e2', ponte: '#c1a0ff', spira: '#ff729e' };
const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'];
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');
function text(g, value, x, y, size = 7, color = WHITE, align = 'left', font = 'sans-serif', weight = 600) {
g.font = `${weight} ${size}px ${font}`; g.textAlign = align; g.textBaseline = 'middle'; g.fillStyle = color; g.fillText(value, x, y);
}
function bumper(ctx, b) {
const r = b.r * (b.hit > 0 ? 1 + b.hit * 1.2 : 1);
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, r, 0, Math.PI * 2); ctx.fill();
disc(ctx, b.x, b.y, r, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16');
disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e');
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.6), 3, 2);
function path(g, points, close = false) {
g.beginPath(); points.forEach((p, i) => i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])); if (close) g.closePath();
}
// Il Regolo: un bersaglio tondo d'oro con la corona e il bordo scuro, che non si confonde col drago dipinto.
function regolo(ctx, o) {
const r = o.r + (o.hit > 0 ? 2 : 0);
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.arc(o.x + 1, o.y + 2, r + 1, 0, Math.PI * 2); ctx.fill();
disc(ctx, o.x, o.y, r + 1, o.hit > 0 ? '#fff6c8' : '#f0c040', '#3a2a08');
ctx.fillStyle = '#3fa34d'; ctx.beginPath(); ctx.arc(o.x, o.y, r - 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffd45a';
for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h);
ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2);
function line(g, points, color, width = 1) { path(g, points); g.strokeStyle = color; g.lineWidth = width; g.stroke(); }
function poly(g, points, color, edge) { path(g, points, true); g.fillStyle = color; g.fill(); if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } }
function disc(g, x, y, r, color, edge) {
g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill();
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
}
// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), un po'
// trasparente perché si veda la tavola sotto, più alta in cima (la quota), col bordo scuro e la luce; accesi, brillano.
const RAMP_LOOK = { sinistra: ['#c9b89a', '#8a7a5a'], destra: ['#b8c0c9', '#6a7a8a'] };
function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
function ramp(ctx, r, time, who) {
const path = () => { ctx.beginPath(); for (let s = 0; s <= r.len; s += 4) { const p = rampPoint(r, Math.min(s, r.len)); s ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y); } };
const [light, dark] = RAMP_LOOK[r.id], invite = who && who !== r.id && Math.sin(time * 6) > 0;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke();
ctx.globalAlpha = 0.8;
ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : invite ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke();
ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke();
ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke();
// Le pietre: trattini scuri a passo regolare lungo il ponte.
ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1;
for (let s = 6; s < r.len; s += 9) { const p = rampPoint(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); }
ctx.globalAlpha = 1;
// L'imbocco: la rampetta che sale, con la freccia.
const [[x0, y], [x1]] = r.mouth;
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6);
ctx.fillStyle = invite ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill();
function box(g, x, y, w, h, color, edge, radius = 5) {
g.beginPath(); g.roundRect(x, y, w, h, radius); g.fillStyle = color; g.fill();
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
}
function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(tx + 1, ty + 3); ctx.stroke();
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
ctx.strokeStyle = '#fff1b8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, py - 1.5); ctx.lineTo(tx, ty - 1.5); ctx.stroke();
disc(ctx, px, py, 4, '#8a7a6a', '#2a2622');
function gradient(g, x, y, w, h, stops) {
const v = g.createLinearGradient(x, y, x + w, y + h); stops.forEach(([at, color]) => v.addColorStop(at, color)); return v;
}
function plunger(ctx, c) {
const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0;
ctx.fillStyle = '#4a4440'; ctx.fillRect(Math.round(x) - 3, top + pull, 6, 336 - top - pull + 10);
ctx.fillStyle = '#9a9088';
for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1);
ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4);
function metal(g, x, y, w = 20, h = 20) {
return gradient(g, x, y, w, h, [[0, '#f1f8f6'], [0.2, '#8b9da6'], [0.46, '#263641'], [0.62, '#c4d5dc'], [1, '#354a58']]);
}
function ball(ctx, b) {
if (!b) return;
// Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana.
const z = b.ramp?.z ?? 0, r = R + 1.5 * z, y = b.y - 2 - 4 * z, sh = 3 + 4 * z;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(b.x - 1.5, y - 1.5, 0.5, b.x, y, r);
g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, y, r, 0, Math.PI * 2); ctx.fill();
function screw(g, x, y) { disc(g, x, y, 1.4, '#9caeb3', '#17262f'); line(g, [[x - 0.6, y + 0.6], [x + 0.6, y - 0.6]], '#273b47', 0.7); }
const glowCache = new Map();
function glow(g, x, y, radius, color, alpha = 1) {
if (typeof document === 'undefined') return;
let sprite = glowCache.get(color);
if (!sprite) {
sprite = Object.assign(document.createElement('canvas'), { width: 96, height: 96 });
const s = sprite.getContext('2d'), v = s.createRadialGradient(48, 48, 0, 48, 48, 48);
v.addColorStop(0, color + 'b0'); v.addColorStop(0.22, color + '50'); v.addColorStop(0.6, color + '15'); v.addColorStop(1, color + '00');
s.fillStyle = v; s.fillRect(0, 0, 96, 96); glowCache.set(color, sprite);
}
g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore();
}
// --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere.
const GLYPHS = {
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '10000', '11110', '00001', '10001', '01110'],
'6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
};
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore.
function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') {
const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch;
const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1);
chars.forEach((ch, i) => {
const g = GLYPHS[ch] ?? GLYPHS[' '];
for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) {
ctx.fillStyle = g[row][col] === '1' ? on : off;
ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size);
const layers = new Map();
function layerOf(key, paint, bounds) {
let layer = layers.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 });
const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer);
}
return layer;
}
function cached(g, key, paint, bounds = [0, 0, 640, 360]) {
if (typeof document === 'undefined') { paint(g); return; }
g.drawImage(layerOf(key, paint, bounds), ...bounds);
}
// 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
// tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta.
const sheets = new Map();
function cabinetBehind(g, key, paint) {
const canvas = g.canvas;
if (typeof document === 'undefined' || !(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return false;
let url = sheets.get(key);
if (!url) { url = `url(${layerOf(key, paint, [0, 0, 640, 360]).toDataURL()})`; sheets.set(key, url); }
if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; }
return true;
}
export function cabinetAway(canvas) { canvas.style.backgroundPosition = ''; canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; }
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);
line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1);
}
function outline(g, c) {
path(g, [[200, 336], ...c.walls.slice(0, 12).map(w => w.a), [448, 72], [448, 336]], true);
}
function cabinet(g, c, bg, glass) {
g.fillStyle = gradient(g, 0, 0, 640, 360, [[0, '#0c1924'], [0.5, '#02070d'], [1, '#101a24']]); g.fillRect(0, 0, 640, 360);
// Due colonne da backglass, incassate nel mobile. Il paese resta un'illustrazione nel riquadro di sinistra.
box(g, 12, 14, 174, 332, '#0c1721', '#243746', 9);
box(g, 467, 14, 161, 332, '#0c1721', '#243746', 9);
line(g, [[24, 20], [174, 20]], '#355263', 0.7); line(g, [[479, 20], [616, 20]], '#355263', 0.7);
for (const x of [19, 179, 474, 621]) for (const y of [21, 339]) screw(g, x, y);
if (glass) {
g.save(); g.beginPath(); g.roundRect(24, 237, 150, 88, 4); g.clip();
g.drawImage(glass, 0, 170, 200, 130, 24, 237, 150, 98);
g.fillStyle = 'rgba(6,18,28,0.38)'; g.fillRect(24, 237, 150, 88); g.restore();
} else if (bg) {
g.save(); g.globalAlpha = 0.25; g.drawImage(bg, 200, 30, 230, 280, 24, 237, 150, 88); g.restore();
}
text(g, 'SAN PIERO A SIEVE', 99, 333, 6, MUTED, 'center');
// Parete esterna, fianco e bisello: l'ombra scende sotto il piano, non sopra gli oggetti.
g.save(); g.translate(2, 7); outline(g, c); g.fillStyle = '#010306'; g.fill(); g.lineWidth = 18; g.strokeStyle = '#010306'; g.stroke(); g.restore();
outline(g, c); g.lineWidth = 16; g.strokeStyle = '#182430'; g.stroke();
g.lineWidth = 11; g.strokeStyle = metal(g, 194, 0, 250, 30); g.stroke();
g.lineWidth = 6; g.strokeStyle = '#050c12'; g.stroke();
g.fillStyle = gradient(g, 240, 5, 140, 320, [[0, '#10252d'], [0.5, '#102d31'], [1, '#071720']]); g.fill();
g.save(); outline(g, c); g.clip();
glow(g, 225, 138, 150, TINT.sinistra, 0.17); glow(g, 415, 202, 140, TINT.destra, 0.13);
// Incisione della Sieve nel piano in lacca; pochi segni, con spazio per leggere i tiri.
for (let i = 0; i < 7; i++) {
g.beginPath(); g.moveTo(305 + i * 3, 70); g.bezierCurveTo(230 + i * 3, 160, 414 + i * 2, 198, 302 + i * 4, 328);
g.strokeStyle = i === 3 ? '#2c6570' : '#173e47'; g.lineWidth = i === 3 ? 1.4 : 0.6; g.stroke();
}
for (let i = 0; i < 40; i++) { g.fillStyle = '#28434b'; disc(g, 212 + hash(i * 7) * 211, 20 + hash(i * 13) * 304, 0.35, '#28434b'); }
g.strokeStyle = '#27464d'; g.lineWidth = 0.6;
for (const radius of [34, 39, 71]) { g.beginPath(); g.ellipse(320, 182, radius, radius * 0.7, 0, 0, TAU); g.stroke(); }
crown(g, 320, 182, 12, '#31585b');
// 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');
text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center');
// 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);
line(g, [[440, 86], [440, 306]], '#45606c', 0.5);
g.restore();
// Plastiche sagomate delle sponde, con spessore e gomma viva sul bordo interno.
for (const side of [-1, 1]) {
const x = side < 0 ? 236 : 404, tip = x - side * 26, color = side < 0 ? TINT.sinistra : TINT.destra;
poly(g, [[x, 248], [x, 287], [tip, 294]], '#02070c');
poly(g, [[x, 246], [x, 284], [tip, 290]], gradient(g, x, 246, -side * 25, 40, [[0, '#52717e'], [0.15, '#163140'], [1, '#091723']]), '#7d9ca5');
poly(g, [[x - side * 4, 261], [x - side * 4, 281], [tip + side * 8, 284]], '#173c4b', color);
screw(g, x, 249); screw(g, x, 282); screw(g, tip + side * 4, 287);
}
g.lineCap = 'round'; g.lineJoin = 'round';
for (const w of c.walls) {
if (w.gate) continue;
const pts = [w.a, w.b];
g.save(); g.translate(1, 2); line(g, pts, '#02070c', 3.5); g.restore();
line(g, pts, w.sling ? '#8c453b' : '#526c78', 2.3);
line(g, pts, w.sling ? '#ffab7c' : '#bdd2d5', 0.7);
}
box(g, 198, 336, 252, 17, '#080f16', '#35515c', 2);
text(g, 'R I V E R', 218, 345, 7, '#d6c49d');
text(g, 'LOCK / JACKPOT / MULTIBALL', 431, 345, 5.5, MUTED, 'right');
for (const side of [-1, 1]) for (let i = 0; i < 6; i++) {
const x = side < 0 ? 197 : 451, y = 106 + i * 36, color = side < 0 ? TINT.sinistra : TINT.destra;
glow(g, x, y, 14, color, 0.4); box(g, x - 0.7, y - 5, 1.4, 10, color, null, 0.7);
}
}
function rampPoint(r, s) {
const p = alongRamp(r, s), z = rampHeight(r, s); return { ...p, y: p.y - 1 - 8 * z };
}
function paintTrack(g, r) {
const pts = r.cum.map(s => { const p = rampPoint(r, s); return [p.x, p.y]; }), color = TINT[r.id];
g.lineCap = 'round'; g.lineJoin = 'round';
// Piedini e ombra portata. La quota è la stessa usata dalla pallina.
for (let s = 45; s < r.len; s += 75) {
const p = rampPoint(r, s);
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
screw(g, p.x + 7, p.y + 10);
}
const track = (color, width) => {
g.beginPath(); g.moveTo(...pts[0]);
for (let i = 1; i < pts.length - 1; i++) {
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
const d0 = Math.hypot(x - a[0], y - a[1]), d1 = Math.hypot(b[0] - x, b[1] - y), radius = Math.min(8, d0 / 3, d1 / 3);
g.lineTo(x - (x - a[0]) / d0 * radius, y - (y - a[1]) / d0 * radius);
g.quadraticCurveTo(x, y, x + (b[0] - x) / d1 * radius, y + (b[1] - y) / d1 * radius);
}
});
}
// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola
// presentazione, fuori dalla partita (così disegnare non cambia niente).
const shown = new WeakMap();
function board(ctx, c, time) {
let d = shown.get(c);
if (!d) { d = { from: c.score, to: c.score, t0: time, flash: 0, lineId: 0 }; shown.set(c, d); }
// Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita
// subito il totale.
if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; }
const score = c.step === 'result' ? c.score : shownScore(d, time), hot = score !== c.score;
if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; }
const x0 = 16, y0 = 50, w = 172, h = 180;
ctx.fillStyle = '#0c0806'; ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, y0 + 1, w - 2, h - 2);
// Le lampadine intorno: tranquille di solito, impazzite per due secondi a ogni scritta.
const party = c.line && time - d.flash < 2, beat = time * (party ? 14 : 2);
let n = 0;
const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); };
for (let x = x0 + 6; x < x0 + w - 4; x += 8) lamp(x, y0 + 4);
for (let y = y0 + 12; y < y0 + h - 4; y += 8) lamp(x0 + w - 5, y);
for (let x = x0 + w - 10; x > x0 + 4; x -= 8) lamp(x, y0 + h - 5);
for (let y = y0 + h - 12; y > y0 + 4; y -= 8) lamp(x0 + 4, y);
text(ctx, 'FLIPPER DEL RIVER', x0 + w / 2, y0 + 16, 7, '#efd49e', 'center');
// I punti, a cifre grosse color ambra col bagliore caldo dietro: sei cifre larghe, da un milione in su più fitte.
ctx.save(); ctx.beginPath(); ctx.rect(x0 + 8, y0 + 24, w - 16, h - 32); ctx.clip();
const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4;
ctx.fillStyle = hot ? 'rgba(255,120,30,0.22)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(x0 + 10, y0 + 26, w - 20, 34);
dots(ctx, digits, x0 + w / 2, y0 + 29 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center');
// La scritta grossa: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo.
const won = c.result?.winner === 'player';
const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`);
const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3;
if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
ctx.restore();
// Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono.
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a');
const save = saveLeft(c);
if (save > 0) text(ctx, `SALVA ${Math.ceil(save)} s`, x0 + w - 10, y0 + 102, 7, '#9fd27a', 'right');
else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + w - 10, y0 + 102, 6, '#a08a60', 'right');
text(ctx, 'RIONI', x0 + 10, y0 + 124, 7, '#f0a24a');
c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 124, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040'));
text(ctx, 'PONTI', x0 + 10, y0 + 146, 7, '#f0a24a');
c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 146, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040'));
if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right');
else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a');
}
// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo.
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 result(ctx, c) {
panel(ctx, 16, 238, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center');
text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 274, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
}
export function drawFlipper(ctx, c, def, bg) {
const time = c.visual.time;
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); }
// Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
for (const l of c.lanes) {
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7);
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050');
g.lineTo(...pts.at(-1)); g.strokeStyle = color; g.lineWidth = width; g.stroke();
};
g.save(); g.translate(3, 6); track('#02080cbb', 18); g.restore();
track(color + '18', 20); track('#02080e', 16); track(color, 14);
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(color + '24', 4);
for (let s = 12; s < r.len - 4; s += 22) {
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);
for (const side of [-1, 1]) disc(g, p.x + p.dy * 6 * side, p.y - p.dx * 6 * side, 0.7, color);
}
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa.
for (const w of c.walls) rail(ctx, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6');
// I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra.
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
poly(g, [[cx - 6, y - 30], [cx + 6, y - 30], [x1, y + 2], [x0, y + 2]], '#030910');
poly(g, [[cx - 5, y - 30], [cx + 5, y - 30], [x1 - 1, y], [x0 + 1, y]], gradient(g, 0, y - 30, 0, 30, [[0, '#172b38'], [0.8, '#3c5d6b'], [1, '#a6c4c8']]), '#7795a2');
for (const side of [-1, 1]) {
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);
}
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
}
function rampLights(g, c, r, time) {
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 invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
if (hot) {
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;
for (let i = 0; i < 3; i++) {
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
if (lit) glow(g, x - 8 + i * 8, y + 6, 7, color, 0.7);
disc(g, x - 8 + i * 8, y + 6, 1.8, lit ? color : '#102a36', lit ? '#d6faff' : '#46616a');
}
const pulse = 0.6 + 0.4 * Math.sin(time * (invited || combo ? 8 : 3));
if (invited || combo) glow(g, x, y + 16, 23, color, pulse);
poly(g, [[x, y + 10], [x + 4, y + 16], [x + 1.5, y + 16], [x + 1.5, y + 22], [x - 1.5, y + 22], [x - 1.5, y + 16], [x - 4, y + 16]], invited ? WHITE : color);
box(g, x - 23, y + 24, 46, 10, '#08151e', color, 3);
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) {
const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec';
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();
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, b.r - 2, '#172835', color);
const v = g.createRadialGradient(b.x - 3, b.y - 4, 0, b.x, b.y, b.r - 3);
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
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(); }
}
function target(g, c) {
for (const t of c.targets) {
const [x, y0] = t.a, y1 = t.b[1];
if (t.down) { ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(x - 2, y0, 4, y1 - y0); continue; }
ctx.fillStyle = '#2a2622'; ctx.fillRect(x - 3, y0 - 1, 6, y1 - y0 + 2);
ctx.fillStyle = t.bank === 'pieve' ? '#f2eee2' : '#e8b640'; ctx.fillRect(x - 2, y0, 4, y1 - y0);
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(x - 2, y1 - 3, 4, 3);
const [x, y] = t.a, h = t.b[1] - y, color = t.bank === 'pieve' ? TINT.sinistra : TINT.destra;
box(g, x - 3, y - 1, 6, h + 2, '#030c12', '#45606d', 1);
if (t.down) { line(g, [[x - 2, y + h - 1], [x + 2, y + h - 1]], color); continue; }
g.fillStyle = metal(g, x - 2, y, 5, 6); g.fillRect(x - 2, y, 4, h);
g.fillStyle = color; g.fillRect(x - 1, y + 2, 2, h - 5); glow(g, x, y + h / 2, 8, color, 0.35);
}
for (const b of c.bumpers) bumper(ctx, b);
regolo(ctx, c.regolo);
for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c);
// La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte.
if (!c.ball?.ramp) ball(ctx, c.ball);
for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id);
if (c.ball?.ramp) ball(ctx, c.ball);
// Da fermo, l'invito: la molla da caricare.
if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center');
board(ctx, c, time);
if (c.step === 'result') result(ctx, c);
ctx.restore();
const o = c.regolo;
glow(g, o.x, o.y, o.hit > 0 ? 30 : 18, '#99e5aa', o.hit > 0 ? 1 : 0.4);
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, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0');
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) {
const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y);
glow(g, x, y, 13, side === 'left' ? TINT.sinistra : TINT.destra, f.held.length ? 0.7 : 0.3);
g.save(); g.translate(x, y); g.rotate(Math.atan2(ty - y, tx - x));
const shape = () => { g.beginPath(); g.moveTo(0, -3); g.lineTo(len, -2.3); g.quadraticCurveTo(len + 3, 0, len, 2.3); g.lineTo(0, 3); g.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); g.closePath(); };
g.save(); g.translate(1, 4); shape(); g.fillStyle = '#00080c'; g.fill(); g.restore();
shape(); g.fillStyle = '#e9eee4'; g.fill(); g.strokeStyle = '#d36754'; g.lineWidth = 1.4; g.stroke();
line(g, [[3, -1.7], [len - 2, -1.3]], '#ffffff', 0.8); screw(g, 0, 0); g.restore();
}
function ball(g, b, color = '#bcecff') {
const z = b.ramp?.z ?? 0, x = b.x, y = b.y - 1 - z * 8, r = R + z;
g.beginPath(); g.ellipse(x + 2 + z * 3, b.y + 3 + z * 2, r + 1, r * 0.55, 0, 0, TAU); g.fillStyle = '#00080dcc'; g.fill();
const v = g.createRadialGradient(x - r * 0.35, y - r * 0.45, 0, x, y, r);
v.addColorStop(0, '#ffffff'); v.addColorStop(0.2, '#f6ffff'); v.addColorStop(0.42, color); v.addColorStop(0.65, '#667d8e'); v.addColorStop(0.8, '#132435'); v.addColorStop(1, '#b1ccdc');
disc(g, x, y, r, v, '#bfdee8'); disc(g, x - r * 0.3, y - r * 0.4, 0.9, '#ffffff');
}
function lock(g, held, time) {
const b = held.ball, color = TINT[held.rampId];
glow(g, b.x, b.y, 22, color, 0.5 + 0.15 * Math.sin(time * 4));
box(g, b.x - 9, b.y - 11, 18, 22, '#07151e', color, 5); ball(g, b, color);
for (const dx of [-6, 6]) line(g, [[b.x + dx, b.y - 8], [b.x + dx, b.y + 7]], metal(g, b.x - 6, b.y, 12, 10), 2);
text(g, 'LOCK', b.x, b.y - 16, 5.5, color, 'center');
}
function plunger(g, c) {
const x = 440, pull = c.holding ? c.power * 12 : 0;
for (let y = 327 + pull; y < 347; y += 2.5) line(g, [[436, y], [444, y + 1]], '#adbdc4', 0.8);
box(g, 435, 324 + pull, 10, 4, metal(g, 435, 324, 10, 4), '#3c687b', 1);
for (let i = 0; i < 8; i++) {
g.fillStyle = canThrow(c) || c.holding ? (i / 8 < c.power ? TINT.destra : '#1e3844') : '#1e3844';
g.fillRect(437, 290 - i * 6, 6, 2);
}
// La valvola disegnata coincide con il segmento inclinato della fisica.
const gate = c.walls.find(w => w.gate);
line(g, [gate.a, gate.b], '#061017', 3); line(g, [gate.a, gate.b], '#b0d1d5', 1.2); screw(g, ...gate.a);
if (waiting(c)) text(g, 'LANCIA', x, 302, 4.5, TINT.destra, 'center');
}
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; }
function board(g, c, time) {
crown(g, 99, 43, 10, '#e7c889');
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');
box(g, 23, 111, 152, 80, '#030b13', '#2c4a58', 6);
text(g, 'PUNTEGGIO', 34, 123, 6, MUTED);
let d = shown.get(c);
if (!d) { d = { from: c.score, to: c.score, t0: time }; shown.set(c, d); }
if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; }
const score = c.step === 'result' ? c.score : shownScore(d, time);
glow(g, 99, 146, 55, '#6dddf4', 0.2);
const digits = String(score).padStart(6, '0');
text(g, digits, 99, 146, Math.min(28, 132 / (digits.length * 0.6)), '#c4f6ff', 'center', 'monospace', 500);
const message = c.step === 'result' ? (c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA') : c.line ?? (waiting(c) ? 'CARICA LA MOLLA' : 'PUNTA AL LOCK');
text(g, message, 99, 176, 7, c.line ? '#ffdc93' : MUTED, 'center');
const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score);
box(g, 28, 195, 142, 2, '#203641', null, 1);
if (progress) box(g, 28, 195, 142 * progress, 2, '#e7c889', null, 1);
text(g, remaining ? `MANCANO ${number(remaining)}` : 'TRAGUARDO / PALLINA +1', 99, 206, 6, '#c7c8ac', 'center');
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');
if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right');
if (c.step === 'result') {
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, `${number(c.result.score)} PUNTI`, 99, 287, 11, WHITE, 'center', 'monospace');
}
const save = saveLeft(c);
if (save > 0) { box(g, 248, 325, 144, 8, '#0b272f', '#4d9b9d', 3); text(g, `BALL SAVE ${Math.ceil(save)}s`, 320, 329, 5, '#a4f7d1', 'center'); }
}
function mission(g, c, time) {
const multi = multiball(c);
text(g, 'LA CACCIA AL', 480, 35, 6, MUTED); text(g, 'JACKPOT', 480, 51, 17, '#f0d59d', 'left', 'Georgia, serif');
text(g, 'DUE LOCK. UNA SUPER SPECIAL.', 480, 69, 5.5, '#8aabb8');
for (let i = 0; i < 2; i++) {
const id = i ? 'destra' : 'sinistra', color = TINT[id], y = 83 + i * 56, held = c.locks.some(l => l.rampId === id);
const count = held || multi ? 3 : c.rampRun.id === id ? c.rampRun.count : 0;
box(g, 479, y, 137, 49, '#102431', c.special === id || multi ? color : '#28424e', 5);
text(g, `0${i + 1}`, 489, y + 12, 6, color, 'left', 'monospace');
text(g, i ? 'TREBBIO' : 'FORTEZZA', 507, y + 12, 8, '#d4e6eb');
for (let k = 0; k < 3; k++) { box(g, 489 + k * 28, y + 25, 22, 4, k < count ? color : '#213e4c', null, 1); }
const note = multi ? 'JACKPOT ATTIVO' : held ? 'PALLINA TRATTENUTA' : count ? `${count}/3 · ${Math.max(0, Math.ceil(c.rampRun.until - c.sim))}s PER IL LOCK` : '3 PASSAGGI IN 20 SECONDI';
text(g, note, 489, y + 39, 5.5, held ? color : MUTED);
if (held) { disc(g, 604, y + 12, 3, color); glow(g, 604, y + 12, 10, color, 0.6); }
}
const open = c.special === 'super';
box(g, 479, 195, 137, 64, open || multi ? '#241d3c' : '#121f2c', open ? TINT.super : '#364052', 5);
if (open) glow(g, 547, 222, 52, TINT.super, 0.25 + 0.15 * Math.sin(time * 5));
text(g, '03 SUPER SPECIAL', 489, 208, 7.5, open ? '#f1d8ff' : '#a5a5b7');
text(g, multi ? `${activeBalls(c).length} PALLINE IN GIOCO` : open ? 'RAMPA CENTRALE APERTA' : 'COMPLETA ENTRAMBI I LOCK', 489, 224, 5.5, open || multi ? '#ead6fc' : MUTED);
text(g, '+1 PALLINA + MULTIBALL', 489, 241, 6, '#d3bfdc');
text(g, `JACKPOT ×${c.jackpotX}`, 480, 278, 8, '#f2d5a0');
text(g, number((open ? SCORE.super : SCORE.jackpot) * c.jackpotX), 615, 278, 10, '#ffe4b2', 'right', 'monospace');
text(g, 'I BERSAGLI COMPLETI ALZANO IL ×', 480, 292, 5, MUTED);
line(g, [[480, 303], [615, 303]], '#2c424d', 0.6);
text(g, multi ? 'LE RAMPE PAGANO JACKPOT' : c.special === 'super' ? 'MIRA ALLA FRECCIA VIOLA' : c.special ? 'L’ALTRO LOCK ACCENDE LA SUPER' : c.lastRamp ? `COMBO +50.000 / ${Math.max(0, Math.ceil(RAMP.combo - (c.sim - c.lastRamp.at)))}s` : 'ALTERNARE LE RAMPE: +50.000', 548, 316, 5.5, '#aec5c9', 'center');
text(g, multi ? 'MULTIBALL' : c.special === 'super' ? 'SUPER SPECIAL' : 'LOCK • SPECIAL • JACKPOT', 548, 333, 6, multi || open ? TINT.super : '#6e8f9b', 'center');
}
function effects(g, c, time) {
const latest = c.fx.findLast(f => f.kind === 'celebrate');
for (const f of c.fx) {
const age = time - f.t;
if (age < 0) continue;
g.save();
if (f.kind === 'spark' && age < 0.5) {
g.globalAlpha = 1 - age / 0.5;
for (let i = 0; i < 8; i++) {
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
line(g, [[f.x + Math.cos(a) * length, f.y + Math.sin(a) * length], [f.x + Math.cos(a) * (length + 3), f.y + Math.sin(a) * (length + 3)]], i % 2 ? '#f9edbc' : '#8de8fa', 1);
}
} else if (f.kind === 'ring' && age < 0.5) {
g.globalAlpha = 1 - age * 2; g.strokeStyle = '#b2f6ff'; g.lineWidth = 1.2;
g.beginPath(); g.ellipse(f.x, f.y, 12 + age * 42, 9 + age * 30, 0, 0, TAU); g.stroke();
} else if (f.kind === 'pop' && age < 1.1) {
g.globalAlpha = Math.min(1, (1.1 - age) * 3);
text(g, f.text, f.x, f.y - age * 22, 7, '#fff1c7', 'center', 'monospace');
} else if (f.kind === 'burst' && age < 1.5) {
g.globalAlpha = Math.min(1, (1.5 - age) * 2);
for (let i = 0; i < 28; i++) {
const a = hash(i * 9 + f.t) * TAU, speed = 25 + hash(i * 13) * 75;
const x = f.x + Math.cos(a) * speed * age, y = f.y + Math.sin(a) * speed * age + 20 * age * age;
if (x < 201 || x > 430 || y > 330) continue;
disc(g, x, y, 0.7 + hash(i), i % 2 ? '#a8e8ff' : '#ffe3a9');
}
} else if (f.kind === 'celebrate' && f === latest && age < 2.6) {
const color = f.color ?? TINT.super;
const fade = Math.min(1, age * 5, (2.6 - age) * 3);
g.globalAlpha = fade;
// Fasci stretti, limitati al campo: la pallina resta leggibile.
g.save(); outline(g, c); g.clip();
for (let i = 0; i < 5; i++) {
const a = -Math.PI / 2 + (i - 2) * 0.3 + Math.sin(time * 1.5) * 0.1;
poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18');
}
g.restore();
glow(g, 320, 277, 85, color, 0.45);
box(g, 257, 265, 126, 31, '#07111deb', color, 6);
text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800);
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace');
}
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');
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');
}
function futureField(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), [207, 5, 221, 250]);
const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1];
for (let k = 0; k < 4; k++) { const p = rampPoint(r, (time * 120 + k * r.len / 4) % r.len); disc(g, p.x, p.y, 1.3, color); }
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) {
const time = c.visual.time;
// 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.
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);
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); 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); }
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
else cached(g, key, paint);
for (const [i, l] of c.lanes.entries()) {
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, 2.3, l.lit ? RIONI[i] : '#203a44');
}
target(g, c); c.bumpers.forEach(b => bumper(g, b, time));
if (c.special !== 'super') {
box(g, 305, 221, 30, 15, metal(g, 305, 221, 30, 15), '#354a5a', 3);
box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center');
for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748');
}
const balls = activeBalls(c);
for (const [i, b] of balls.entries()) {
if (b.waiting) continue;
const color = [TINT.sinistra, TINT.destra, TINT.super][i];
b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); });
}
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) {
if (!rampOpen(c, r)) continue;
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); }
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);
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
g.restore();
}
c.locks.forEach(held => lock(g, held, time));
for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time);
for (const side of ['left', 'right']) paddle(g, c, side);
plunger(g, c); effects(g, c, time);
// 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);
board(g, c, time); mission(g, c, time);
g.restore();
}
+282 -64
View File
@@ -7,15 +7,15 @@
// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte.
// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita
// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che
// contano quando si attraversano, traguardo acquisito appena raggiunto.
// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico.
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
export const PIVOT = { left: [280, 314], right: [360, 314] };
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 };
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000, super: 250000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 300, slope: 260, drag: 30, combo: 12, blend: 30 };
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30, lockWindow: 20 };
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
@@ -37,20 +37,27 @@ export function layout() {
const walls = [];
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 }));
walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336]));
// La corsia della molla, col fondo. Niente cancelletto: una pallina che ricasca nella corsia si rilancia e basta
// (il cancelletto a senso unico faceva da ripiano ai lanci a metà).
// La corsia della molla, col fondo. Un lancio che non è ancora entrato in campo si può ritentare;
// dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale.
walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 }));
// Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo.
walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 }));
// Le corsie dei rioni, in cima, fra le bandiere: tre guide.
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
// I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
// po' lungo il flipper: finendo sul perno la pallina ci restava incastrata).
walls.push(seg([222, 240], [222, 290]), seg([222, 290], [292, 314], { e: 0.3 }));
walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 }));
// Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
walls.push(seg([214, 240], [214, 290]), seg([214, 290], [292, 314], { e: 0.3 }));
walls.push(seg([420, 240], [420, 290]), seg([420, 290], [348, 314], { e: 0.3 }));
// Le sponde elastiche sopra i flipper: il lato lungo spinge.
walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true }));
walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true }));
// I bersagli a caduta: la Pieve a sinistra, la torre a destra, con la sponda dietro.
walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198]));
// Dietro la Pieve, fra la parete e la sponda, restavano 10 pixel: la pallina (larga 9) che scivolava lungo la
// parete sinistra ci entrava e scendeva dritta nell'uscita laterale (Andrea: «a sinistra esce troppo facile»).
// A destra il canale è di 3 pixel e non esiste. Due raccordi inclinati lo chiudono sopra e sotto.
walls.push(seg([200, 136], [210, 146], { e: 0.5 }), seg([210, 198], [200, 208], { e: 0.5 }));
const targets = [];
for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false, touching: false });
const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0, touching: false }));
@@ -63,8 +70,10 @@ export function layout() {
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
// in cima, come due ponti.
const ramps = [
{ id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
{ id: 'destra', mouth: [[382, 236], [404, 236]], path: [[393, 236], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
{ id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
{ id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
{ id: 'super', mouth: [[303, 232], [337, 232]], path: [[320, 232], [320, 188], [333, 145], [333, 96], [320, 72], [307, 96], [307, 145], [287, 168], [246, 180], [229, 198], [229, 220]], lit: 0, touching: false },
];
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) {
@@ -72,8 +81,46 @@ export function layout() {
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)];
}
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
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ì.
export function alongRamp(r, s) {
s = clamp(s, 0, r.len);
@@ -87,11 +134,17 @@ 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,
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 } },
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, visual: { time: 0 }, ...layout() };
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,
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
newBall(c);
return c;
}
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; }
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
export const multiball = c => c.extraBalls.length > 0;
export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super';
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
@@ -107,10 +160,21 @@ function say(c, line, front = false) {
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
else c.lines.push(line);
}
// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record.
function add(c, points) {
// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che
// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE.
export const FX_LIFE = 3;
function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); }
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
// continua per il record.
function add(c, points, at) {
c.score += points;
if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.events.push({ kind: 'flipper-palio', power: 1 }); }
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
if (!c.result && c.score >= c.skill.goal) {
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
}
if (c.result) c.result.score = c.score;
}
@@ -151,8 +215,14 @@ export function update(c, dt) {
if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
c.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE);
c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
// La scia della pallina: le ultime posizioni, per la cometa.
for (const b of activeBalls(c)) {
if (!b.waiting && b.releaseAt <= c.sim) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
}
c.trail = c.ball?.trail ?? [];
}
function moveFlippers(c, h) {
for (const side of ['left', 'right']) {
@@ -166,36 +236,67 @@ function moveFlippers(c, h) {
}
function step(c) {
c.sim += STEP;
const ball = c.ball;
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);
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; }
if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 };
if (c.step !== 'play') { moveFlippers(c, STEP); return; }
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
if (ball.ramp) { moveFlippers(c, STEP); rideRamp(c, ball); return; }
ball.vy += c.skill.g * STEP;
limit(ball);
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n;
const from = { x: ball.x, y: ball.y };
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp && b.releaseAt <= c.sim);
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
for (let i = 0; i < n; i++) {
moveFlippers(c, h);
ball.x += ball.vx * h; ball.y += ball.vy * h;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
for (const b of c.bumpers) collideBumper(c, ball, b);
collideRegolo(c, ball);
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
limit(ball);
for (const ball of ground) {
ball.x += ball.vx * h; ball.y += ball.vy * h;
if (ball.x < LANE.x0 - R) ball.inPlay = true;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
for (const b of c.bumpers) collideBumper(c, ball, b);
collideRegolo(c, ball);
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
limit(ball);
}
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
}
crossLanes(c, from, ball);
enterRamps(c, from, ball);
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è
// trattenuta su un flipper alzato: quella è una mossa del giocatore.
if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; }
else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
// Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia.
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c);
for (const ball of balls) {
if (ball.waiting || ball.releaseAt > c.sim) continue;
if (ball.ramp) { rideRamp(c, ball); continue; }
crossLanes(c, from.get(ball), ball);
enterRamps(c, from.get(ball), ball);
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
if ((ball.rest += STEP) > 0.3) {
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
else newBall(c);
}
} else ball.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
}
if (c.future?.won) returnClassic(c, true);
}
function collideBalls(a, b) {
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= R * 2) return;
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
limit(a); limit(b);
}
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
@@ -205,14 +306,18 @@ function crossLanes(c, from, ball) {
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
if (!l || l.lit) return;
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 });
if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
if (c.lanes.every(k => k.lit)) {
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
}
}
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
// indietro e ricasca dall'imbocco.
function enterRamps(c, from, ball) {
for (const r of c.ramps) {
if (!rampOpen(c, r)) continue;
const [[x0, y], [x1]] = r.mouth;
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
@@ -229,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).
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
function rideRamp(c, ball) {
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top;
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
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 ? -slope : slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
on.s += on.v * STEP;
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);
@@ -239,38 +344,136 @@ function rideRamp(c, ball) {
// pallina esce sopra uno di loro, non contano come urto nuovo.
const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true;
add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 });
if (c.lastRamp && c.lastRamp.id !== r.id) { add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; }
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]);
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 (c.lastRamp && c.lastRamp.id !== r.id) {
add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
}
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
} else if (on.s <= 0) {
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
}
function drain(c) {
c.events.push({ kind: 'flipper-buco', power: 0.8 });
// Tre passaggi completi sulla stessa rampa entro venti secondi. La finestra parte dal primo.
function announce(c, line) {
// Gli eventi di fase sostituiscono i vecchi annunci dei ponti; il premio del traguardo mantiene la precedenza.
const prize = c.lines.includes('PALLINA +1'); c.lines = [];
if (c.line === 'TRAGUARDO!') { if (prize) c.lines.push('PALLINA +1'); c.lines.push(line); }
else { c.line = null; say(c, line, true); if (prize) c.lines.push('PALLINA +1'); }
}
function celebrate(c, title, points, color) {
if (points) add(c, points, [320, 184]);
announce(c, title);
fx(c, 'celebrate', 320, 170, { text: title, points, color });
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
}
function rampFeature(c, r, ball) {
if (r.id === 'super') {
// La rampa centrale esiste solo con entrambi i lock: premio e tre palline sul campo.
if (c.locks.length !== 2) return true;
for (const [i, lock] of c.locks.entries()) {
const released = lock.ball;
// Le uscite verticali centrano le corsie interne: nessuna spinta verso le buche laterali.
released.vx = 0; released.vy = 170; released.waiting = false;
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
released.releaseAt = c.sim + i * 0.45; released.releasedFrom = lock.rampId;
c.extraBalls.push(released);
}
// Uscita laterale sopra la paletta sinistra, con velocità moderata; niente tiro dritto nella buca.
ball.vx = 0; ball.vy = 160;
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++;
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!');
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 }); }
return true;
}
if (multiball(c)) {
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
c.events.push({ kind: 'flipper-jackpot', power: 1 }); return true;
}
if (c.locks.some(lock => lock.rampId === r.id)) { c.rampRun = { id: null, count: 0, until: 0 }; say(c, 'LOCK OCCUPATO'); return true; }
if (c.special === r.id && !c.specialPaid) {
c.specialPaid = true;
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
c.events.push({ kind: 'flipper-jackpot', power: 1 });
}
const continuing = c.rampRun.id === r.id && c.sim <= c.rampRun.until;
c.rampRun = { id: r.id, count: continuing ? c.rampRun.count + 1 : 1, until: continuing ? c.rampRun.until : c.sim + RAMP.lockWindow };
if (c.rampRun.count < 3) return c.locks.length > 0;
c.locks.push({ rampId: r.id, ball }); ball.vx = 0; ball.vy = 0; ball.trail = [];
c.special = c.locks.length === 2 ? 'super' : c.ramps.find(other => other.id !== 'super' && other.id !== r.id).id;
c.rampRun = { id: null, count: 0, until: 0 }; c.lastRamp = null;
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
newBall(c);
celebrate(c, c.locks.length === 2 ? 'SUPER APERTA' : 'BALL LOCKED', 0, c.locks.length === 2 ? '#e1a0ff' : '#72edff');
c.events.push({ kind: 'flipper-lock', power: 1 });
if (c.special === 'super') { c.superOpenedAt = c.visual.time; c.events.push({ kind: 'flipper-super-open', power: 1 }); }
return true;
}
function drain(c, ball) {
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
if (multiball(c)) {
if (c.ball === ball) c.ball = c.extraBalls.shift();
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0, until: 0 };
say(c, multiball(c) ? 'DUE IN GIOCO' : 'UNA IN GIOCO'); return;
}
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.balls--; c.saved = false; c.lastRamp = null;
c.rampRun = { id: null, count: 0, until: 0 };
if (c.locks.length) {
// Si recupera l'ultimo lock. L'eventuale primo resta acquisito e non si scala la riserva.
c.locks.pop(); c.superOpenedAt = null;
c.special = c.locks.length ? c.ramps.find(r => r.id !== 'super' && r.id !== c.locks[0].rampId).id : null;
if (!c.locks.length) c.specialPaid = false;
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
announce(c, 'LOCK LIBERO'); newBall(c); return;
}
if (c.table === 'future') returnClassic(c);
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
c.ball = null; c.step = 'result';
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
}
function contact(ball, object, hit) {
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
if (hit && fresh) ball.contacts.push(object.contactId);
else if (!hit && !fresh) ball.contacts.splice(index, 1);
object.touching = hit;
return fresh;
}
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
function hitSeg(ball, w, e) {
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
if (d >= R) { w.touching = false; return null; }
if (d >= R) { contact(ball, w, false); return null; }
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching;
w.touching = true;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh };
}
function collideSeg(c, ball, w) {
if (w.gate) {
if (!ball.inPlay) return;
const hit = hitSeg(ball, w, w.e);
if (hit && hit.vn > 0) ball.vx = Math.min(-100, ball.vx);
return;
}
if (!w.sling) { hitSeg(ball, w, w.e); return; }
// La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come
// la gomma vera; solo a un urto nuovo, e solo se picchia.
@@ -280,24 +483,23 @@ function collideSeg(c, ball, w) {
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 });
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y);
} else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; }
}
function collideTarget(c, ball, t) {
const hit = hitSeg(ball, t, 0.5);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 });
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
const bank = c.targets.filter(k => k.bank === t.bank);
if (bank.every(k => k.down)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; }
if (bank.every(k => k.down)) { c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
}
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
function hitCircle(ball, o, e) {
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
if (d >= min) { o.touching = false; return null; }
if (d >= min) { contact(ball, o, false); return null; }
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching;
o.touching = true;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh };
}
@@ -306,12 +508,13 @@ function collideBumper(c, ball, b) {
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 });
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
}
function collideRegolo(c, ball) {
const hit = hitCircle(ball, c.regolo, 0.7);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 });
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);
}
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
function collideFlipper(c, ball, side) {
@@ -349,7 +552,7 @@ export function cancel(c, pointerId) {
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
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 function stopTurn(c, who = 'button') {
if (who === 'click') return false; // il colpo breve finisce da sé
@@ -360,18 +563,33 @@ 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 key(c, k, down) {
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 (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
if (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); 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; }
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.
export const shoot = c => canThrow(c) && launch(c, c.power);
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
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}.`}`;
const goal = c.result ? 'traguardo fatto, ora il record' : `traguardo ${c.skill.goal}`;
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`;
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
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 : ''}`;
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}`;
}
+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:
// è la musica della piazza accanto, che arriva da lontano.
// `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 ?? {};
if (screen === 'splash') return null;
if (screen === 'title' || screen === 'intro') return m.title ?? 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 stages = (sc?.stages ?? []).filter(st => ok(st.when));
if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]);
+8 -2
View File
@@ -166,6 +166,12 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
}
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
// Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto.
if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') {
drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
return;
}
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false;
@@ -1057,8 +1063,8 @@ function drawCloseup(ctx, game, time) {
return;
}
if (def.type === 'flipper') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawFlipper(ctx, c, def, backgrounds.get(def.picture));
for (const src of [def.picture, def.backglass]) if (src && !backgrounds.has(src)) loadBackground(src);
drawFlipper(ctx, c, def, backgrounds.get(def.picture), def.backglass && backgrounds.get(def.backglass));
return;
}
if (def.type === 'bruciate') {
+10
View File
@@ -169,6 +169,16 @@ export class Soundscape {
else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); }
else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); }
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan);
else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); }
else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { 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') { 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-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); }
+31 -4
View File
@@ -3,6 +3,7 @@ import { story } from './story/story.js';
import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { cabinetAway } from './engine/flipper-render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
@@ -398,6 +399,18 @@ addEventListener('pagehide', () => leave(true));
addEventListener('pageshow', () => leave(document.hidden));
$('resume').addEventListener('click', closePause);
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
// Schermo intero dalla Pausa, dove il browser lo permette (su iPhone no: il pulsante sparisce); Esc o il pulsante per uscire.
{
const full = $('fullscreen');
if (!document.fullscreenEnabled) full.hidden = true;
else {
full.addEventListener('click', () => {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
});
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement ? 'Esci da schermo intero' : 'Schermo intero'; });
}
}
$('to-title').addEventListener('click', () => {
$('pause').hidden = true;
music.hold = false;
@@ -485,6 +498,9 @@ function debugKey(e) {
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 === '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;
}
const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` };
@@ -648,7 +664,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.' },
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).' },
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.' },
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).
@@ -812,6 +828,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 => {
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.
@@ -904,7 +922,7 @@ window.addEventListener('keydown', e => {
if (e.key === 'Escape') { game.closeupClose(); return; }
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).
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;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
@@ -951,7 +969,7 @@ window.addEventListener('keydown', e => {
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
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.
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();
});
@@ -1179,7 +1197,7 @@ function frame(now) {
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.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.
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 });
@@ -1189,6 +1207,15 @@ function frame(now) {
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt);
// Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art.
const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper';
const sceneScale = pinballHD ? 2 : 1;
if (canvas.width !== WIDTH * sceneScale) {
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
canvas.classList.toggle('pinball-hd', !!pinballHD);
if (!pinballHD) cabinetAway(canvas);
}
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
// Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto.
if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; }
+3
View File
@@ -23,6 +23,9 @@ export const piloti = [
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
// Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore]
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
+3 -1
View File
@@ -363,8 +363,10 @@ export const story = {
// 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]
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',
backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi)
names: { player: 'Andrea', cpu: 'il flipper' },
win: [{ say: 'Traguardo! Se c\'era la classifica, oggi ci mettevo le mie iniziali.' }, { set: 'flipper_vinto' }],
lose: [{ say: 'Tre palline, e tutte nella Sieve. Il flipper del River non perdona.' }],
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.66';
export const VERSION = '0.1.71';
// 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).
+39 -16
View File
@@ -20,10 +20,18 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
button, input { font-family: var(--font); }
body { line-height: 1.5; }
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 1280px, 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 12,8px: 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, 12.8px); --side-h: 13.3; container-type: inline-size;
width: min(100vw, 1280px, 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); }
#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.pinball-hd { image-rendering: auto; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
@@ -79,7 +87,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). */
#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; }
#speech:empty { display: none; }
#speech { z-index: 3; }
@@ -88,7 +96,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;
transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
/* 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); }
/* 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; }
@@ -103,24 +111,27 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
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[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); }
#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. */
#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;
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[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;
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
#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: 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. */
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;
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 { min-width: 0; overflow: hidden; }
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
@@ -129,8 +140,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; }
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
/* Le risposte: alte quanto il pannello dei verbi, così la scena non si muove quando parte un dialogo. Quattro risposte ci
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; }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
@@ -222,10 +239,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. */
/* In alto a sinistra: Pausa e Mappa. */
#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 { 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; }
#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ò. */
#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; }
@@ -322,6 +340,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); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@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; }
#verbs button { font-size: 9px; padding: .7em .1em; }
#inventory { height: 7.6em; }
@@ -340,7 +361,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
.card-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#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. */
#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; }
@@ -357,6 +379,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
.buttons { gap: 6px; margin-top: .2em; }
.buttons button { font-size: 10px; padding: .45em 1em; }
.pause-buttons { min-width: 12em; }
#pause .hint { display: none; }
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
#map-board button { width: 68px; }
#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).
import { chromium } from 'playwright';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict';
@@ -11,7 +12,7 @@ mkdirSync(out, { recursive: true });
const server = await staticServer(), port = server.port;
let browser;
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 errors = [];
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-biliardo';
mkdirSync(out, { recursive: true });
@@ -64,7 +65,7 @@ async function checkArtwork(page, name) {
}
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 }],
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
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'));
}
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 => [
{ name: `desktop-${mode}`, mode, viewport: { width: 1280, height: 860 } },
{ name: `telefono-${mode}`, mode, viewport: { width: 390, height: 844 } },
+389 -331
View File
@@ -1,8 +1,11 @@
// 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).
// 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 { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
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';
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 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 = [];
async function open(viewport, query = '') {
const page = await browser.newPage({ viewport, acceptDownloads: true });
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++) {
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;
};
try {
await checkMultimedia(browser, PORT, OUT, errors);
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 });
// Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima).
async function minigiochi() {
// 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.
{
@@ -551,10 +247,36 @@ try {
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
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 });
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('#rione [data-rione="campomigliaio"]');
await phone.click('#skip-intro');
@@ -619,9 +341,345 @@ try {
await dbg.waitForTimeout(200);
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` });
}
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');
// 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');
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');
} finally {
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-bruciate';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-campana';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-cerbottana';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-condor';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
+153 -6
View File
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-flipper';
mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
@@ -26,12 +27,17 @@ try {
});
await page.waitForSelector('#bocce-controls:not([hidden])');
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.
const readyAt = await page.evaluate(() => game.closeup.visual.time);
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('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible());
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');
await page.screenshot({ path: `${out}/${name}-tavola.png` });
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 } });
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 })) });
// La molla: tenendo premuto sulla scena, col mouse e col dito.
@@ -40,9 +46,10 @@ try {
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await touch('touchEnd', []);
} 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.mouse.up();
await page.keyboard.up('ArrowDown');
}
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.
@@ -78,14 +85,154 @@ try {
await page.keyboard.up('ArrowLeft');
await page.click('#resume');
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);
}
// La fine: si fa finire la partita e si guarda il quadro.
await page.evaluate(() => { const c = game.closeup; c.score = 7350; c.step = 'result'; c.ball = null; c.result = { winner: 'player', score: 7350, goal: c.skill.goal }; });
// Il traguardo aggiunge una pallina e lascia continuare la partita.
const reward = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0;
const b = c.bumpers[0]; b.touching = false;
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null });
m.update(c, 0.05);
return { balls: c.balls, step: c.step, winner: c.result?.winner };
});
assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' });
await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta'));
await page.screenshot({ path: `${out}/${name}-premio.png` });
// Una sequenza reale di rampe, con il motore: primo lock, secondo lock, rampa centrale e tre palline.
await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js');
Object.assign(game.closeup, m.newFlipper(73, { level: 'bona' }));
window.pinballShot = id => {
const c = game.closeup, r = c.ramps.find(r => r.id === id);
if (m.waiting(c)) m.launch(c, 0.8);
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++) { m.update(c, m.STEP); if (c.ball?.ramp) entered = true; else if (entered) return; }
throw new Error(`rampa ${id} non completata`);
};
for (let i = 0; i < 3; i++) window.pinballShot('sinistra');
});
assert.equal(await page.evaluate(() => game.closeup.locks.length), 1);
await page.screenshot({ path: `${out}/${name}-lock.png` });
await page.evaluate(async () => {
for (let i = 0; i < 3; i++) window.pinballShot('destra');
const m = await import('/src/engine/flipper.js');
for (let i = 0; i < 4; i++) m.update(game.closeup, 0.2);
});
assert.equal(await page.evaluate(() => game.closeup.special), 'super');
await page.screenshot({ path: `${out}/${name}-super.png` });
const multi = await page.evaluate(async () => {
window.pinballShot('super');
const m = await import('/src/engine/flipper.js'), c = game.closeup;
window.pinballResume = game.update.bind(game); game.update = () => {};
window.pinballClassic = structuredClone(c); window.pinballClassic.transform = null;
(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(multi, { balls: 3, locks: 0, reserve: 5 });
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` });
// Anche nel nuovo campo: due dita indipendenti, i due Shift e la pausa vera dell'applicazione.
if (mobile) {
await touch('touchStart', [P(250, 200), P(400, 200)]);
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;
Object.assign(c, structuredClone(mode === 'classico' ? window.pinballClassic : mode === 'esplosione' ? window.pinballExplosion : window.pinballFuture));
const ctx = document.querySelector('#scene').getContext('2d');
r.render(ctx, game, { time: performance.now() }); ctx.getImageData(0, 0, 4, 4);
for (let i = 0; i < 150; i++) {
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);
});
assert.deepEqual(await page.evaluate(() => game.closeup.future.hits), { orbita: 2, ponte: 2, spira: 2 });
await page.screenshot({ path: `${out}/${name}-sigilli.png` });
await page.evaluate(async () => {
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.
const finalScore = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
for (let i = 0; i < 12 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.8);
c.saveUntil = 0;
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); m.update(c, 0.1);
}
return c.score;
});
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/);
assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`));
await page.screenshot({ path: `${out}/${name}-fine.png` });
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
await page.waitForFunction(() => document.querySelector('#scene').width === 640 && !document.querySelector('#scene').style.backgroundImage);
await page.close();
}
assert.deepEqual(errors, []);
+361 -10
View File
@@ -5,9 +5,21 @@ import { drawFlipper } from '../src/engine/flipper-render.js';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { existsSync } from 'node:fs';
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); };
const put = (c, ball) => Object.assign(c.ball, ball);
// Osserva il percorso fino all'uscita, senza includere il tiro successivo sulla tavola.
function ride(c, initial) {
put(c, initial);
let frames = 0, top = Infinity;
for (let i = 0; i < 4 / m.STEP; i++) {
m.update(c, m.STEP);
if (c.ball?.ramp) { frames++; top = Math.min(top, c.ball.y); }
else if (frames) return { frames, top, exit: { ...c.ball } };
}
assert.fail('la pallina non ha completato o abbandonato la rampa entro quattro secondi');
}
// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) {
@@ -134,10 +146,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'));
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, '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.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.
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…');
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
});
@@ -158,38 +174,247 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`);
});
test('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) {
const c = m.newFlipper(85, { level }); m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - missing;
const currentBall = c.ball, b = c.bumpers[0];
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`);
assert.equal(c.ball, currentBall, 'il tiro in corso prosegue');
assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player');
assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1');
assert.match(m.status(c), /pallina premio vinta/);
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi');
c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1);
assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio');
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1);
assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
}
});
test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => {
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
put(c, { x: 247, y: 250, vx: 0, vy: -470 });
let onRamp = 0, top = 999, exit = null; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } else if (onRamp && !exit) exit = { x: c.ball.x, y: c.ball.y }; }
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola');
assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`);
assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0);
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati).
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 247, y: 250, vx: 0, vy: -470 }); run(f, 0.3);
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 273, y: 240, vx: 0, vy: -470 }); run(f, 0.3);
assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1);
assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`);
// Il secondo ponte subito dopo: i PONTI, col bonus.
put(c, { x: 393, y: 250, vx: 0, vy: -470, ramp: null }); run(c, 2.5);
ride(c, { x: 367, y: 240, vx: 0, vy: -470, ramp: null });
assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`);
assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? '')));
assert.equal(c.lastRamp, null, 'la combo è spesa');
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti.
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
put(d, { x: 247, y: 250, vx: 0, vy: -335 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
put(d, { x: 273, y: 240, vx: 0, vy: -280 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 226, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
// Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no.
const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0;
put(e, { x: 247, y: 236.1, vx: 500, vy: -30 }); run(e, 0.5);
put(e, { x: 273, y: 226.1, vx: 500, vy: -30 }); run(e, 0.5);
assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina.
const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 });
run(g, 13); assert.equal(g.lastRamp, null, 'scaduta');
g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 342, vx: 0, vy: 300 }); run(g, 1);
g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 342, vx: 0, vy: 300, waiting: false }); run(g, 1);
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
});
test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) {
const c = m.newFlipper(81, { level }); m.launch(c, 0.8);
ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 });
assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`);
assert.ok(c.ramps[index].lit > 0);
for (const offset of [-12, 12]) {
const d = m.newFlipper(82, { level }); m.launch(d, 0.8);
const r = d.ramps[index];
ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 });
assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`);
}
const e = m.newFlipper(83, { level }); m.launch(e, 0.8);
const r = e.ramps[index];
put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05);
assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata');
}
});
test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => {
for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) {
const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y: 250, vx: 0, vy: 100 });
let lower = null;
for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) {
m.update(c, m.STEP);
if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x };
}
assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`);
if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`);
else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); }
}
});
test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => {
const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9);
const kinds = () => c.fx.map(f => f.kind);
const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('ring') && kinds().includes('pop') && kinds().includes('spark'), `fungo: ${kinds()}`);
assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000');
c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`);
c.fx.length = 0; ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`);
assert.ok(c.trail.length > 5, 'la cometa');
const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball;
assert.equal(c.fx.length, 0, 'spariti');
// Disegnarli non cambia niente.
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; } });
put(c, { x: 273, y: 240, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before);
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
function rampShot(c, id) {
if (m.waiting(c)) m.launch(c, 0.8);
const r = c.ramps.find(r => r.id === id);
return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false });
}
function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); }
test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => {
for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) {
const c = m.newFlipper(21, { level }); m.launch(c, 0.9);
const superRamp = c.ramps.find(r => r.id === 'super');
assert.ok(!m.rampOpen(c, superRamp));
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1);
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2);
rampShot(c, ids[0]);
assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]);
assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3);
const lockedBall = c.locks[0].ball;
rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock');
const score = c.score; rampShot(c, ids[1]);
assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock');
assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1);
const afterSpecial = c.score; rampShot(c, ids[1]);
assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica');
rampShot(c, ids[1]);
assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp));
assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
const before = c.score, reserve = c.balls, hadGoal = !!c.result;
rampShot(c, 'super');
assert.equal(c.score - before, m.SCORE.super);
assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato');
assert.equal(c.locks.length, 0); assert.equal(c.special, null);
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.ok(c.events.some(e => e.kind === 'flipper-super'));
assert.ok(c.transform, 'la SUPER avvia la trasformazione');
run(c, m.FUTURE.duration);
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.
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);
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');
}
});
test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => {
const c = m.newFlipper(22); m.launch(c, 0.8);
put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02);
assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica');
rampShot(c, 'sinistra'); rampShot(c, 'sinistra');
c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1);
assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1);
rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1);
c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.rampRun.count, 0);
});
test('la trasformazione protegge le tre palline, poi le libera sfalsate verso le palette', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
const before = m.activeBalls(c).map(b => [b.x, b.y]), score = c.score, lives = c.balls;
run(c, 0.8); assert.deepEqual(m.activeBalls(c).map(b => [b.x, b.y]), before);
assert.equal(c.score, score); assert.equal(c.balls, lives);
run(c, m.FUTURE.duration - 0.8);
assert.equal(c.table, 'future'); assert.equal(c.transform, null);
const main = c.ball, delayed = c.extraBalls.at(-1), position = [delayed.x, delayed.y];
assert.ok(delayed.releaseAt > c.sim);
run(c, 0.2); assert.deepEqual([delayed.x, delayed.y], position);
// Isola la prima pallina per verificare il recupero dalla paletta, senza alterare la geometria.
const d = structuredClone(c); d.extraBalls = [];
let recovered = false;
for (let i = 0; i < 480; i++) {
if (d.ball.x > 284 && d.ball.y > 300) m.hold(d, 'left', 'test', true);
m.update(d, m.STEP);
if (d.ball.vy < -250) { recovered = true; break; }
}
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));
}
});
test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => {
const c = m.newFlipper(23, { level: 'male' }); doubleLock(c);
const lives = c.balls;
for (const remaining of [1, 0]) {
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c));
assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null);
}
assert.equal(c.special, null); assert.equal(c.specialPaid, false);
});
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'); 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);
m.update(single, m.STEP); m.update(c, m.STEP);
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0];
spare.waiting = true;
Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] });
Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] });
const score = c.score; m.update(c, m.STEP);
assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente');
Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 });
Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 });
m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono');
const lives = c.balls;
Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2);
});
test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => {
const c = m.newFlipper(25); m.launch(c, 0.8);
for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) {
const t = c.targets.find(t => t.bank === bank && t.i === i), dir = bank === 'pieve' ? -1 : 1;
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, ramp: null }); run(c, 0.02);
}
assert.equal(c.jackpotX, 3); doubleLock(c);
const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3);
for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) {
const d = m.newFlipper(26); m.launch(d, 0.8);
put(d, { x, y, vx: 0, vy: 240, inPlay: true });
let enteredShooter = false;
for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; }
assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`);
}
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
let won = 0;
for (let seed = 1; seed <= 8; seed++) {
@@ -207,9 +432,16 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu
const c = m.newFlipper(11, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); };
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER'));
assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
for (const r of c.ramps.filter(r => r.id !== 'super')) {
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
}
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso');
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco');
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi');
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio');
c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
// Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).
@@ -238,3 +470,122 @@ test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; u
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.level, 'male');
});
test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita sinistra (Andrea: «a sinistra esce troppo facile»)', () => {
const c = m.newFlipper(31); m.launch(c, 0.8); c.saveUntil = 0;
// Una pallina che scivola lungo la parete sinistra, sopra la Pieve: viene rimandata in campo, non nel canale.
put(c, { x: 205, y: 120, vx: 0, vy: 150, ramp: null });
let inChannel = false;
for (let i = 0; i < 120; i++) { m.update(c, 1 / 60); if (c.ball.x < 210 && c.ball.y > 146 && c.ball.y < 198) inChannel = true; }
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)})`);
});
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-gara';
mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ let browser;
const errors = [];
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 [
['touch', true, { width: 844, height: 390 }, 'seria'],
['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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-micro';
mkdirSync(out, { recursive: true });
@@ -12,7 +13,7 @@ let browser;
const errors = [];
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 }],
+3 -1
View File
@@ -84,7 +84,7 @@ export async function checkMultimedia(browser, port, out, errors) {
else await page.mouse.move(...await at(target));
await page.waitForFunction(() => game.closeup?.step === 'reveal');
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(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(25);
@@ -97,6 +97,8 @@ export async function checkMultimedia(browser, port, out, errors) {
await settle();
await page.waitForSelector('#ending:not([hidden])');
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');
// Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione.
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');
});
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', () => {
const m = new Music({ volume: 0.5, muted: true });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-risveglio';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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.
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 } });
+2 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = [];
@@ -44,7 +45,7 @@ async function wait(page) {
});
}
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 = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ 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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-smarronata';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-telefono';
mkdirSync(out, { recursive: true });
@@ -11,7 +12,7 @@ const server = await staticServer();
let browser;
const errors = [];
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 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`);
+4 -1
View File
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = '/tmp/regolo-shots'; mkdirSync(out, { recursive: true });
const server = await staticServer(), errors = [];
@@ -13,6 +14,8 @@ async function tap(page, mode, selector) {
else await page.click(selector);
}
async function playCard(page, mode, index, signal) {
// Un tasto premuto mentre qualcuno parla salta la battuta e basta (come nel gioco): si aspetta che abbia finito.
if (mode === 'keyboard') await page.waitForFunction(() => !game.speech);
if (signal) {
if (mode === 'keyboard') await page.keyboard.press(signal[0]);
else await tap(page, mode, `#tressette-${signal}`);
@@ -46,7 +49,7 @@ async function wait(page) {
});
}
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 = [
{ name: 'desktop-mouse', mode: 'mouse', viewport: { width: 1280, height: 860 }, full: true },
{ 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 assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
import { ARGS } from './chromium.mjs';
const out = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
browser = await chromium.launch({ args: ARGS });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
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