Compare commits

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