Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4015beb4f2 | ||
|
|
4afac814ee | ||
|
|
37bfe4e15e | ||
|
|
c701edd977 | ||
|
|
a455e3f12f | ||
|
|
be099aa810 | ||
|
|
24e9b8c055 | ||
|
|
cfe7cb7fd6 | ||
|
|
9499c7792b | ||
|
|
3a9db6d109 | ||
|
|
797eb80980 | ||
|
|
00e9f7c4d7 | ||
|
|
77a8777dcd | ||
|
|
b660bbeb96 | ||
|
|
bbce40b014 | ||
|
|
0ec45c8745 | ||
|
|
dfa70a9620 | ||
|
|
3079c6ea8a | ||
|
|
4d4aa8aeea | ||
|
|
721b6de930 | ||
|
|
7d419be27a | ||
|
|
07b6a381c1 | ||
|
|
45b8c3d98e | ||
|
|
0a8b0310d3 |
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
# Il controllo dei file pubblicabili non sta qui (a pipeline partita il push è già pubblico): è l'hook pre-push.
|
||||
name: test
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test unitari
|
||||
run: node --version && node --test tests/*.test.mjs
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release del tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
|
||||
{
|
||||
echo "Si gioca su https://50037.manzolo.it; qui sotto lo zip di questa versione."
|
||||
echo
|
||||
echo "Cosa cambia${prev:+ da $prev}:"
|
||||
echo
|
||||
git log --no-merges --format='- %s' ${prev:+$prev..}"$TAG" | grep -v -e '^- Versione [0-9]' | cut -c1-400
|
||||
} > note.md
|
||||
node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"Versione "+process.env.TAG.slice(1),body:fs.readFileSync("note.md","utf8")}))' > release.json
|
||||
code=$(curl -s -o risposta.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' --data @release.json "$API/releases")
|
||||
cat risposta.json | head -c 400; echo
|
||||
[ "$code" = 201 ] || [ "$code" = 409 ]
|
||||
@@ -7,3 +7,4 @@ assets/generated/
|
||||
.venv/
|
||||
.env
|
||||
deploy.local.mk
|
||||
.verify-ok.json
|
||||
|
||||
@@ -16,9 +16,16 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p
|
||||
(`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages).
|
||||
- Nessun build step: ES modules statici, server locale con `python3 -m http.server`.
|
||||
- Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura.
|
||||
- Le prove nel browser sono a sezioni (`scripts/verify.mjs`, dal 2026-11-01): `make verify` rifà solo quelle i cui ingressi
|
||||
sono cambiati dall'ultimo verde (la briscola se cambia la briscola, la partita intera se cambiano storia, motore, stile
|
||||
o risorse). Una prova nuova in `tests/*.browser.mjs` va aggiunta a `SUITES` (il test `verify.test.mjs` lo pretende).
|
||||
- Verifica visiva nel browser con Playwright e swiftshader prima di dichiarare finito qualcosa di grafico.
|
||||
- Pubblicare (tag/bump/push) solo quando Andrea lo chiede.
|
||||
- Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti.
|
||||
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
|
||||
- Sfondi nuovi: li genera **Codex** con il suo strumento immagini integrato, gratis e senza chiave API
|
||||
(`codex exec`, ricetta in `docs/GENERAZIONE_SFONDI.md`, «Generare con Codex»). Si lancia da qui, senza chiedere la chiave ad Andrea.
|
||||
- **Pubblicare (dal 2026-11-01)**: origin è https://git.manzolo.it/manzolo/leggenda-del-regolo, pubblico: prima `make check-public`, poi `git push origin main --tags`, solo se Andrea lo chiede.
|
||||
`make deploy` costruisce l'immagine dal tag della versione, la spinge su `git.manzolo.it/manzolo/leggenda-regolo:vX.Y.Z` (e `:latest`); sul VPS il compose fissa quel tag e fa `docker compose pull && up -d`.
|
||||
Per tornare indietro: sul VPS (`Workspaces/docker/leggenda-regolo/docker-compose.yml`) si rimette il tag precedente e si rifà pull e up.
|
||||
Hook pre-push (`make hooks`, una volta per clone) lancia check-public; le Gitea Actions (`.gitea/workflows/test.yml`: test, e release sui tag) girano sul runner del PC, che si crea o riavvia con `make runner`.
|
||||
|
||||
@@ -23,8 +23,8 @@ DST ?= assets/scenes/$(notdir $(SRC))
|
||||
CROP ?=
|
||||
COLORS ?= 64
|
||||
|
||||
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
||||
build up down restart logs ps shell image-size deploy deploy-logs clean distclean
|
||||
.PHONY: help serve open test browser verify verify-browser verify-plan check-public hooks bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
||||
build up down restart logs ps shell image-size deploy deploy-logs runner clean distclean
|
||||
|
||||
## ------------------------------------------------------------ sviluppo
|
||||
|
||||
@@ -47,14 +47,26 @@ test: ## Test della logica, della storia, della soluzione completa e del generat
|
||||
$(NODE) --test tests/*.test.mjs
|
||||
$(PYTHON) -m unittest discover -s tests -p "test_*.py" -q
|
||||
|
||||
browser: ## Partita completa nel browser con Playwright
|
||||
browser: ## Partita completa nel browser con Playwright (tre parti in processi paralleli, ~4,5 min)
|
||||
$(NODE) tests/browser.mjs
|
||||
|
||||
verify: test browser check-public ## Tutti i controlli
|
||||
verify: test check-public verify-browser ## Tutti i controlli: test unitari, file pubblicabili, prove nel browser cambiate (ALL=1: tutte)
|
||||
|
||||
# Le prove nel browser a sezioni: si rifanno solo quelle i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json,
|
||||
# fuori da git); make verify-browser ALL=1 le rifà tutte, make verify-plan dice solo cosa rifarebbe; JOBS=2 in parallelo (con 4 vanno in timeout).
|
||||
verify-browser: ## Le prove nel browser da rifare (ALL=1 tutte; PROVE="briscola flipper" solo quelle)
|
||||
$(NODE) scripts/verify.mjs $(if $(ALL),--all,) $(PROVE)
|
||||
|
||||
verify-plan: ## Dice quali prove nel browser rifarebbe make verify, e perché
|
||||
$(NODE) scripts/verify.mjs --plan
|
||||
|
||||
check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia)
|
||||
bash scripts/check-public.sh
|
||||
|
||||
hooks: ## Attiva gli hook del repo (pre-push: check-public prima di ogni push)
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
@echo "hook attivi da scripts/git-hooks: $$(ls scripts/git-hooks | tr '\n' ' ')"
|
||||
|
||||
bump: ## Nuova versione (PARTE=patch|minor|major): verifica, commit e tag v<versione>; poi make deploy
|
||||
scripts/bump.sh $(or $(PARTE),patch)
|
||||
|
||||
@@ -121,40 +133,53 @@ image-size: ## Dimensione dell'immagine
|
||||
|
||||
## ------------------------------------------------------------ deploy su un server proprio
|
||||
|
||||
# Host e indirizzo stanno in deploy.local.mk, fuori da git, per esempio:
|
||||
# Host e cartella stanno in deploy.local.mk, fuori da git, per esempio:
|
||||
# DEPLOY_HOST = root@mio-server
|
||||
# DEPLOY_URL = https://gioco.esempio
|
||||
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
|
||||
# DEPLOY_IMAGE = registry.esempio/utente/leggenda-regolo
|
||||
-include deploy.local.mk
|
||||
DEPLOY_DIR ?= /root/leggenda-regolo
|
||||
DEPLOY_IMAGE ?= git.manzolo.it/manzolo/leggenda-regolo
|
||||
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
|
||||
# La versione dell'ultimo commit (non della cartella di lavoro): il tag git, dell'immagine e del compose.
|
||||
DEPLOY_VER := v$(shell git show HEAD:src/version.js 2>/dev/null | sed -n "s/.*VERSION = '\([^']*\)'.*/\1/p")
|
||||
|
||||
# Si pubblica l'ultimo commit (git archive HEAD), mai la cartella di lavoro: un'altra sessione può avere modifiche a
|
||||
# metà. Test su quella copia, immagine costruita e provata qui, poi spedita già pronta (docker save | docker load):
|
||||
# sul server non si compila niente.
|
||||
# Si pubblica il tag della versione dichiarata dall'ultimo commit (git archive vX.Y.Z, quello di make bump), mai la
|
||||
# cartella di lavoro: un'altra sessione può avere modifiche a metà, e i commit dopo il tag aspettano la versione dopo.
|
||||
# Test su quella copia, immagine costruita e provata qui, spinta sul registry di Gitea come :vX.Y.Z e :latest; il
|
||||
# VPS la scarica con il tag scritto nel suo compose (preso da HEAD, docker-compose.deploy.yml).
|
||||
SMOKE_PORT ?= 18737
|
||||
deploy: ## Pubblica l'ultimo commit: test, immagine costruita e provata qui, spedita al server e riavviata
|
||||
deploy: ## Pubblica la versione dell'ultimo commit: test, immagine provata qui, push sul registry, pull e riavvio sul server
|
||||
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
||||
mkdir -p $(DEPLOY_TMP) && git archive HEAD | tar -x -C $(DEPLOY_TMP)
|
||||
@git rev-parse -q --verify 'refs/tags/$(DEPLOY_VER)' >/dev/null || { echo "manca il tag $(DEPLOY_VER): prima make bump"; exit 2; }
|
||||
mkdir -p $(DEPLOY_TMP) && git archive $(DEPLOY_VER) | tar -x -C $(DEPLOY_TMP)
|
||||
cd $(DEPLOY_TMP) && $(NODE) --test tests/*.test.mjs
|
||||
docker build -q -t $(IMAGE):latest -t $(IMAGE):$$(git rev-parse --short HEAD) $(DEPLOY_TMP)
|
||||
docker build -q -t $(DEPLOY_IMAGE):$(DEPLOY_VER) -t $(DEPLOY_IMAGE):latest $(DEPLOY_TMP)
|
||||
@docker rm -f $(IMAGE)-prova >/dev/null 2>&1 || true
|
||||
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(IMAGE):latest >/dev/null
|
||||
docker run -d --rm --name $(IMAGE)-prova -p 127.0.0.1:$(SMOKE_PORT):80 $(DEPLOY_IMAGE):$(DEPLOY_VER) >/dev/null
|
||||
@ok=1; sleep 1; for u in / /src/main.js /assets/voices/index.json /assets/audio/cartello.mp3; do \
|
||||
c=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$(SMOKE_PORT)$$u); echo " prova $$u → $$c"; [ "$$c" = 200 ] || ok=0; done; \
|
||||
docker rm -f $(IMAGE)-prova >/dev/null; [ $$ok = 1 ] || { echo "l'immagine non risponde come deve: non la spedisco"; exit 1; }
|
||||
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR)'
|
||||
scp -q $(DEPLOY_TMP)/docker-compose.homeserver.yml $(DEPLOY_HOST):$(DEPLOY_DIR)/docker-compose.yml
|
||||
docker save $(IMAGE):latest | gzip | ssh $(DEPLOY_HOST) 'gunzip | docker load -q && cd $(DEPLOY_DIR) && docker compose up -d && docker image prune -f >/dev/null'
|
||||
docker push -q $(DEPLOY_IMAGE):$(DEPLOY_VER)
|
||||
docker push -q $(DEPLOY_IMAGE):latest
|
||||
git show HEAD:docker-compose.deploy.yml | sed 's|$(DEPLOY_IMAGE):VERSIONE|$(DEPLOY_IMAGE):$(DEPLOY_VER)|' | \
|
||||
ssh $(DEPLOY_HOST) 'mkdir -p $(DEPLOY_DIR) && cat > $(DEPLOY_DIR)/docker-compose.yml'
|
||||
ssh $(DEPLOY_HOST) 'cd $(DEPLOY_DIR) && docker compose pull -q && docker compose up -d && \
|
||||
docker image ls $(DEPLOY_IMAGE) --format "{{.Repository}}:{{.Tag}}" | grep -v -x -e $(DEPLOY_IMAGE):$(DEPLOY_VER) | xargs -r docker image rm >/dev/null; \
|
||||
docker image prune -f >/dev/null'
|
||||
rm -rf $(DEPLOY_TMP)
|
||||
@# Sul server il prune toglie l'immagine vecchia; qui restano solo latest e quella appena pubblicata.
|
||||
@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $$(git rev-parse --short HEAD) | \
|
||||
xargs -r -I{} docker image rm -f $(IMAGE):{} >/dev/null
|
||||
@# Qui restano solo latest e la versione appena pubblicata.
|
||||
@docker image ls $(DEPLOY_IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $(DEPLOY_VER) | \
|
||||
xargs -r -I{} docker image rm -f $(DEPLOY_IMAGE):{} >/dev/null
|
||||
@# L'indirizzo pubblico deve servire la versione appena spedita.
|
||||
@v=$$(grep -o "VERSION = '[^']*'" src/version.js); \
|
||||
@v=$$(git show $(DEPLOY_VER):src/version.js | grep -o "VERSION = '[^']*'"); \
|
||||
for i in 1 2 3 4 5; do curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" && break; sleep 2; done; \
|
||||
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
|
||||
@echo "→ $(DEPLOY_URL) ($$(git log -1 --format='%h %s'))"
|
||||
@echo "→ $(DEPLOY_URL) ($(DEPLOY_IMAGE):$(DEPLOY_VER), $$(git log -1 --format='%h %s' $(DEPLOY_VER)))"
|
||||
|
||||
runner: ## Crea (se manca) e avvia sul PC il runner delle Gitea Actions (tools/gitea-runner, scripts/runner.sh)
|
||||
bash scripts/runner.sh
|
||||
|
||||
deploy-logs: ## Log del container sul server di deploy
|
||||
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
||||
|
||||
@@ -23,7 +23,9 @@ Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero),
|
||||
|
||||
## Giocare in locale
|
||||
|
||||
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES.
|
||||
Il gioco si scarica da **https://git.manzolo.it/manzolo/leggenda-del-regolo**: nella pagina dei
|
||||
[tag](https://git.manzolo.it/manzolo/leggenda-del-regolo/tags) c'è lo zip di ogni versione (l'ultima è quella online).
|
||||
Niente da installare e niente da compilare: HTML, CSS e moduli JavaScript ES. Dentro la cartella scompattata:
|
||||
|
||||
```zsh
|
||||
python3 -m http.server 8000 # poi http://localhost:8000
|
||||
@@ -40,16 +42,16 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con
|
||||
- `assets/`: sfondi, sprite, musiche, voci, carattere, video.
|
||||
- `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi).
|
||||
- `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre
|
||||
tutta la soluzione dell'avventura, su desktop e telefono); `npm run browser:briscola`, `:tressette`, `:scopa`,
|
||||
`:biliardo`, `:effects` per le parti singole.
|
||||
tutta la soluzione dell'avventura, su desktop e telefono, in tre processi paralleli; `--parte=` per una sola);
|
||||
`npm run browser:briscola`, `:tressette`, `:scopa`, `:biliardo`, `:effects` per le parti singole.
|
||||
- `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale.
|
||||
- `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca).
|
||||
|
||||
```zsh
|
||||
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
|
||||
make verify # test, file pubblicabili e le prove nel browser cambiate dall'ultimo verde (ALL=1: tutte)
|
||||
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
||||
make bump # nuova versione: verifica, commit e tag
|
||||
make deploy # pubblica l'ultimo commit (immagine Docker spedita al server)
|
||||
make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica
|
||||
```
|
||||
|
||||
## Chi ha fatto cosa
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2983,17 +2983,17 @@
|
||||
"file": "andrea-1b517ab1ef.mp3",
|
||||
"sec": 0.9
|
||||
},
|
||||
"andrea|Seimila punti e sono contento.": {
|
||||
"file": "andrea-b6d9563c93.mp3",
|
||||
"sec": 2.02
|
||||
"andrea|Sessantamila punti e sono contento.": {
|
||||
"file": "andrea-74e53fb53a.mp3",
|
||||
"sec": 2.65
|
||||
},
|
||||
"andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": {
|
||||
"file": "andrea-5811ae33d6.mp3",
|
||||
"sec": 3.91
|
||||
"andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
|
||||
"file": "andrea-82ab821c37.mp3",
|
||||
"sec": 4.43
|
||||
},
|
||||
"andrea|Sedicimila. Il record del River, o quasi.": {
|
||||
"file": "andrea-d420a5e136.mp3",
|
||||
"sec": 4.38
|
||||
"andrea|Duecentomila. Il record del River, o quasi.": {
|
||||
"file": "andrea-00b37d42f9.mp3",
|
||||
"sec": 4.32
|
||||
},
|
||||
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
|
||||
"file": "andrea-d564365fc2.mp3",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Il compose del VPS (make deploy lo copia in DEPLOY_DIR con il tag della versione al posto di VERSIONE).
|
||||
# L'immagine viene dal registry di Gitea; niente porte esposte: il container sta sulla rete esterna nginx-net,
|
||||
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
|
||||
# Per tornare indietro: sul VPS si rimette il tag precedente qui sotto e si rifà `docker compose pull && docker compose up -d`.
|
||||
services:
|
||||
leggenda-regolo:
|
||||
image: git.manzolo.it/manzolo/leggenda-regolo:VERSIONE
|
||||
container_name: leggenda-regolo
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nginx-net
|
||||
|
||||
networks:
|
||||
nginx-net:
|
||||
external: true
|
||||
@@ -1,13 +0,0 @@
|
||||
# Deploy dietro un reverse proxy (make deploy): l'immagine arriva già costruita dal PC (docker load), niente porte esposte, il container sta sulla rete esterna nginx-net,
|
||||
# dove Nginx Proxy Manager del VPS lo pubblica col suo nome (50037.manzolo.it → leggenda-regolo:80).
|
||||
services:
|
||||
leggenda-regolo:
|
||||
image: leggenda-regolo:latest
|
||||
container_name: leggenda-regolo
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nginx-net
|
||||
|
||||
networks:
|
||||
nginx-net:
|
||||
external: true
|
||||
+48
-16
@@ -50,21 +50,38 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
||||
fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16);
|
||||
vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`.
|
||||
[DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?).
|
||||
- **Il flipper del River (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, si sceglie il livello e si gioca
|
||||
(`src/engine/flipper.js`, disegno `flipper-render.js`, tavola di Codex `assets/closeups/river-flipper.png` con San
|
||||
Piero dipinto: Fortezza, Pieve, torre, la Sieve che scende fra i flipper, il ponte, il Regolo con la corona, le
|
||||
bandiere dei rioni). Fisica a passo fisso: molla, arco, quattro corsie dei rioni (tutte e quattro = PALIO), tre
|
||||
funghi, bersagli a caduta della Pieve e della torre, il Regolo, sponde elastiche, due flipper, uscite laterali,
|
||||
salvataggio della pallina una volta sola. Comandi: molla tenendo premuto (o cursore + Lancia, o Spazio), flipper
|
||||
con le due metà della scena, i pulsanti «← Flipper»/«Flipper →» tenuti premuti o le frecce. Tre palline; traguardo
|
||||
6000/12000/25000. Test `flipper.test.mjs` (col giocatore automatico), prova `npm run browser:flipper`.
|
||||
Rivisto con la revisione critica di Codex (31 ottobre, chiesta da Andrea): punti solo a un urto nuovo (la pallina
|
||||
appoggiata sul Regolo faceva 240.000 punti al secondo), sottopassi contro gli attraversamenti del flipper, niente
|
||||
cancelletto-ripiano e molla che arriva sempre in campo, salvataggio col tempo della simulazione (e in vista sul
|
||||
quadro), corsie che contano attraversando la riga, traguardo acquisito appena raggiunto (poi si continua per il
|
||||
record), annullare non lancia, due dita sulla scena e tasto destro del mouse, pulsanti dei flipper sempre attivi.
|
||||
[DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). Da fare (Andrea): un paio di
|
||||
rampe («ponti»), più punti, il tabellone luminoso coi punti che corrono, un ingresso più intuitivo del River.
|
||||
- **Il flipper del River / Il Regolo (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, livello e
|
||||
partita. Motore `src/engine/flipper.js`, disegno `flipper-render.js`. Tavolo rifatto in lacca scura, metallo,
|
||||
rampe con ombre e luci ciano/ambra/viola, riflessi sulle palline e plastiche in rilievo; scena a 1280×720 con
|
||||
coordinate logiche 640×360, gli altri ambienti restano in pixel art. Fondale di Codex conservato nel riquadro
|
||||
illustrato del backglass; niente pannelli sopra il Trebbio. Superfici e bagliori in cache, senza disegnare la
|
||||
strada nascosta sotto il flipper. Pannello a destra con progressione, timer, moltiplicatore e prossimo obiettivo.
|
||||
Alleggerito (1 novembre): il mobile statico sta sullo sfondo CSS della tela (`cabinetBehind`), la tela si pulisce
|
||||
e basta; estremità e giunti arrotondati solo nei livelli in cache. Arretrato grafico da 4,2 s a 0,03 s per 150
|
||||
fotogrammi (regole e misure nella memoria «effetti leggeri»).
|
||||
- Rampe FORTEZZA e TREBBIO: imbocchi centrali da 36 pixel, 27 utili alla pallina, salite accessibili con colpi
|
||||
medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis.
|
||||
Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio
|
||||
nell'altra rampa SPECIAL: jackpot da 100.000; tre passaggi ravvicinati fanno il secondo LOCK.
|
||||
- Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti, una pallina extra in riserva e multiball a
|
||||
tre. Uscita SUPER raccordata alla corsia sinistra sopra la paletta, a velocità ridotta; i due lock liberano
|
||||
le palline dai lati, sfalsati di 0,45 secondi. In multiball le rampe pagano jackpot, e una vita si perde solo
|
||||
all'ultima pallina. Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite.
|
||||
- Completare una serie di bersagli alza il moltiplicatore jackpot fino a ×3, azzerato alla perdita di una vita.
|
||||
Restano PONTI (+50.000 alternando le rampe entro 12 secondi), PALIO (+30.000), respingenti, bersagli e Regolo.
|
||||
Traguardi 60.000/120.000/200.000: una pallina premio una sola volta, vittoria acquisita anche uscendo prima.
|
||||
- Uscite laterali da 14/12 pixel, corsie di ritorno alle palette più ampie. Il canale di 10 pixel dietro la
|
||||
Pieve, che portava la pallina dritta nell'uscita sinistra, è chiuso da due raccordi inclinati (1 novembre). Valvola inclinata alla corsia di
|
||||
lancio: il lancio passa, i ritorni dal campo vengono deviati a sinistra. Ogni pallina ha contatti, scia e
|
||||
anti-incastro propri; urti fra palline, palette mosse una sola volta per passo. Pausa ferma anche i timer.
|
||||
- Luci che scorrono sulle rampe attive, frecce e spie 1/3–2/3–LOCK, rampa SUPER che si alza, fasci luminosi,
|
||||
scintille, particelle e fanfare distinte per jackpot, apertura SUPER e multiball; la pallina resta leggibile.
|
||||
Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca».
|
||||
- Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni,
|
||||
recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch,
|
||||
screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, e la misura dell'arretrato
|
||||
grafico dopo 150 fotogrammi in multiball (deve restare sotto 1,5 s).
|
||||
[DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello.
|
||||
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
|
||||
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
|
||||
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
|
||||
@@ -285,6 +302,21 @@ versione nuova.
|
||||
- **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser`
|
||||
(partita completa con clic veri in Chromium, ripresa del salvataggio, telefono).
|
||||
|
||||
- **Impaginazione (1 novembre, Andrea: «come mai non sfruttiamo tutto lo schermo?»):** la scena prende la finestra. La
|
||||
riserva sotto non è più 250 px fissi ma l'altezza vera del pannello, 13,3 unità `--u` (un centesimo della larghezza
|
||||
del gioco, `cqw`, fra 8,75 e 12,8 px: `#app` è un container). Frase, verbi, inventario, risposte, HUD e fumetti scalano
|
||||
col gioco, non con la finestra: i verbi non si tagliano più nelle finestre piccole. Il pannello è alto sempre uguale
|
||||
(ci stanno quattro risposte; da cinque il carattere cala, da sette ancora) così la scena non si muove nei dialoghi;
|
||||
la frase è una riga sola coi puntini. «Schermo intero» nella Pausa (dove il browser lo permette). A 1040×590 la scena
|
||||
passa da 604 a 842 px di larghezza, a 1366×650 da 711 a 935; sui telefoni niente cambia. Prova in `browser.mjs`.
|
||||
|
||||
- **Prove a sezioni (1 novembre, idea di Andrea):** `scripts/verify.mjs` conosce gli ingressi di ogni prova nel browser
|
||||
(i file da cui dipende: i propri, più il motore comune; per la partita intera tutto il gioco tranne i minigiochi a sé)
|
||||
e rifà solo quelle la cui impronta è cambiata dall'ultimo verde, ricordato in `.verify-ok.json` fuori da git. Così
|
||||
`make bump` dopo una modifica alla briscola riprova la briscola e basta; dopo una modifica alla storia la partita intera
|
||||
e le scene speciali (ambient, veicoli, ingorgo, smarronata), non la briscola; dopo `style.css` o `game.js` tutto.
|
||||
Le prove dei minigiochi, che prima non entravano in `make verify`, ora ci stanno. `make verify-plan` spiega.
|
||||
|
||||
## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git)
|
||||
|
||||
| File | Cosa | Uso previsto |
|
||||
@@ -449,7 +481,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
||||
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
|
||||
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
|
||||
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
|
||||
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
||||
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo NRG bianco e rosso numero 1 e il ritratto di Codex. Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
|
||||
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
|
||||
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
|
||||
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
|
||||
|
||||
+21
-11
@@ -86,6 +86,7 @@
|
||||
<div class="buttons pause-buttons">
|
||||
<button id="resume">Riprendi</button>
|
||||
<button id="pause-map" class="secondary">Mappa</button>
|
||||
<button id="fullscreen" class="secondary" title="Il gioco su tutto lo schermo; Esc per uscire">Schermo intero</button>
|
||||
<button id="save-file" class="secondary" title="Scarica la partita in un file da aprire su un altro dispositivo">Salva su file</button>
|
||||
<button class="open-credits secondary">Ringraziamenti</button>
|
||||
<button id="to-title" class="secondary">Torna al titolo</button>
|
||||
@@ -304,17 +305,26 @@
|
||||
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
|
||||
</details>
|
||||
<details id="flipper-help" hidden><summary>Come si gioca</summary>
|
||||
<p>Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto
|
||||
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e
|
||||
scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (3000 punti). I tre funghi
|
||||
sono i bastioni della Fortezza (100 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
|
||||
destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a
|
||||
destra, è il Regolo (1000).</p>
|
||||
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
|
||||
destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra.
|
||||
Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
|
||||
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 6000 punti, seria 10000, a
|
||||
fassi male 16000; raggiunto il traguardo, si continua per il record.</p>
|
||||
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa
|
||||
Spazio o 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;
|
||||
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
|
||||
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
|
||||
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
|
||||
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
|
||||
passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.</p>
|
||||
<p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti,
|
||||
una pallina extra in riserva e MULTIBALL a tre palline! La SUPER restituisce la pallina nella corsia
|
||||
laterale verso la paletta sinistra; quelle trattenute vengono liberate dai due lati, sfalsate.
|
||||
In multiball le rampe pagano altri jackpot e perdi una vita solo quando perdi l'ultima pallina in gioco.
|
||||
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
|
||||
<p>Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla
|
||||
perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le
|
||||
quattro corsie dei rioni per il PALIO da 30.000. I respingenti valgono 1.000, i bersagli laterali 5.000
|
||||
l'uno e 25.000 la serie, il Regolo coronato 10.000.</p>
|
||||
<p>Parti con tre palline. Appena lanciata, la pallina è protetta una volta: 8 secondi alla bona, 4 seria,
|
||||
1 a fassi male. Il traguardo è 60.000, 120.000 o 200.000 punti: raggiungerlo assegna una pallina premio
|
||||
una sola volta per partita. Poi puoi continuare per il record, i LOCK e la SUPER SPECIAL.</p>
|
||||
</details>
|
||||
<details id="cerbottana-help" hidden><summary>Come si gioca</summary>
|
||||
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.66",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Prima di ogni push: il repo su git.manzolo.it è pubblico, quindi niente che check-public bocci deve partire.
|
||||
# Si attiva una volta con `make hooks` (core.hooksPath = scripts/git-hooks).
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
bash scripts/check-public.sh || { echo; echo "pre-push: push bloccato da check-public (vedi sopra)."; exit 1; }
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Crea (se manca) e avvia il runner delle Gitea Actions su questo PC: `make runner`.
|
||||
# Rifarlo da zero: `docker compose -f tools/gitea-runner/docker-compose.yml down`, cancellare $RUNNER_DATA e il runner
|
||||
# vecchio da Gitea (repo → Impostazioni → Actions → Runner), poi di nuovo `make runner`.
|
||||
# Il token di registrazione si chiede all'API di Gitea con le credenziali di git (serve lo scope write:repository).
|
||||
set -euo pipefail
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
COMPOSE="$ROOT/tools/gitea-runner/docker-compose.yml"
|
||||
REPO=${RUNNER_REPO:-manzolo/leggenda-del-regolo}
|
||||
export RUNNER_DATA=${RUNNER_DATA:-$HOME/.local/share/gitea-runner}
|
||||
export GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-pc-$(hostname | tr '[:upper:]' '[:lower:]')}
|
||||
mkdir -p "$RUNNER_DATA"
|
||||
|
||||
if [[ ! -s $RUNNER_DATA/.runner ]]; then
|
||||
pass=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^password=//p')
|
||||
user=$(printf 'protocol=https\nhost=git.manzolo.it\n\n' | git credential fill | sed -n 's/^username=//p')
|
||||
[[ -n $pass ]] || { echo "nessuna credenziale git per git.manzolo.it"; exit 1; }
|
||||
tok=$(curl -sf -X POST -u "$user:$pass" "https://git.manzolo.it/api/v1/repos/$REPO/actions/runners/registration-token" |
|
||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') ||
|
||||
{ echo "Gitea non ha dato il token di registrazione (scope write:repository?)"; exit 1; }
|
||||
echo "registro il runner $GITEA_RUNNER_NAME su $REPO"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=$tok docker compose -f "$COMPOSE" up -d
|
||||
for _ in $(seq 30); do [[ -s $RUNNER_DATA/.runner ]] && break; sleep 1; done
|
||||
[[ -s $RUNNER_DATA/.runner ]] || { docker logs --tail 20 gitea-runner; echo "registrazione non riuscita"; exit 1; }
|
||||
chmod 600 "$RUNNER_DATA/.runner" 2>/dev/null || true
|
||||
fi
|
||||
# Senza token nell'ambiente del container: da qui in poi si autentica con .runner.
|
||||
docker compose -f "$COMPOSE" up -d
|
||||
sleep 2; docker logs --tail 1 gitea-runner
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// Le prove nel browser a sezioni (idea di Andrea, 1 novembre 2026): ogni prova ha i suoi ingressi, i file da cui dipende;
|
||||
// si rifà solo se la loro impronta (i blob di git, più i file modificati nella cartella di lavoro) è cambiata dall'ultimo
|
||||
// verde, ricordato in .verify-ok.json (fuori da git). Se cambia la briscola si riprova la briscola; se cambia la storia, il
|
||||
// motore, l'impaginazione o le risorse si riprova la partita intera (e le prove delle scene speciali), non la briscola.
|
||||
// node scripts/verify.mjs le prove da rifare, in parallelo (JOBS=2: con quattro Chromium swiftshader insieme le schermate vanno in timeout), e aggiorna i verdi
|
||||
// node scripts/verify.mjs --plan dice solo cosa rifarebbe e perché
|
||||
// node scripts/verify.mjs --all tutte, senza guardare i verdi
|
||||
// node scripts/verify.mjs briscola solo quelle nominate (anche se verdi)
|
||||
// I test unitari e check-public non stanno qui: costano pochi secondi e girano sempre (make verify).
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cpus } from 'node:os';
|
||||
|
||||
// Il motore comune a tutto: cambia questo e si riprova ogni minigioco.
|
||||
export const ENGINE = ['index.html', 'style.css', 'src/main.js', 'src/engine/game.js', 'src/engine/render.js',
|
||||
'tests/helpers.mjs', 'package-lock.json'];
|
||||
// Non toccano il gioco nel browser: documenti, strumenti, pubblicazione, versione (make bump la cambia prima di verificare),
|
||||
// test unitari (girano sempre, a parte), e questo stesso script (lo prova verify.test.mjs).
|
||||
export const IGNORED = [/^docs\//, /^[^/]*\.md$/, /^src\/version\.js$/, /^package\.json$/, /^Makefile$/, /^scripts\//,
|
||||
/^tools\//, /^video\//, /^\.gitea\//, /^prompts\//, /^LICENSE$/, /^Dockerfile$/, /^nginx\.conf$/, /^docker-compose/, /^requirements\.txt$/,
|
||||
/^\.(gitignore|dockerignore|verify-ok\.json)$/, /^tests\/[^/]*\.test\.mjs$/, /^tests\/test_[^/]*\.py$/, /^deploy\.local\.mk$/, /(^|\/)__pycache__\//];
|
||||
|
||||
// Le prove. `core: true` = dipende da tutto il gioco tranne i file dei minigiochi a sé (che hanno la loro prova); altrimenti
|
||||
// solo dal motore e dai propri file: quelli che si chiamano come la prova (src/engine/flipper.js, flipper-render.js,
|
||||
// assets/closeups/flipper-*.png, docs/BRISCOLA.md…) più gli `extra`. `run` sono gli argomenti di node.
|
||||
export const SUITES = {
|
||||
principale: { core: true, run: ['tests/browser.mjs', '--parte=principale'], note: 'la partita intera, pausa e salvataggi' },
|
||||
minigiochi: { core: true, run: ['tests/browser.mjs', '--parte=minigiochi'], note: 'libro, bocce, tiri, pesca e biliardo dalla partita' },
|
||||
telefoni: { core: true, run: ['tests/browser.mjs', '--parte=telefoni'], note: 'multimedia, telefoni, la finestra di Andrea, debug' },
|
||||
ambient: { core: true, run: ['tests/ambient.browser.mjs'], note: 'tutte le scene nelle tre fasi e gli effetti' },
|
||||
veicoli: { core: true, run: ['tests/veicoli.browser.mjs'], note: 'i veicoli nelle scene' },
|
||||
ingorgo: { core: true, run: ['tests/ingorgo.browser.mjs'], note: "l'Ingorgo Sonoro" },
|
||||
smarronata: { core: true, run: ['tests/smarronata.browser.mjs'], note: 'la Smarronata del 1993' },
|
||||
briscola: { run: ['tests/briscola.browser.mjs'] },
|
||||
tressette: { run: ['tests/tressette.browser.mjs'] },
|
||||
scopa: { run: ['tests/scopa.browser.mjs'] },
|
||||
biliardo: { run: ['tests/biliardo.browser.mjs'] },
|
||||
flipper: { run: ['tests/flipper.browser.mjs'] },
|
||||
micro: { run: ['tests/micro.browser.mjs'] },
|
||||
cerbottana: { run: ['tests/cerbottana.browser.mjs'] },
|
||||
condor: { run: ['tests/condor.browser.mjs'] },
|
||||
campana: { run: ['tests/campana.browser.mjs'] },
|
||||
bruciate: { run: ['tests/bruciate.browser.mjs'] },
|
||||
gara: { run: ['tests/gara.browser.mjs'], extra: ['src/engine/corsa.js', 'src/story/piloti.js', /^assets\/[^/]*\/(gara|gp|pilot)/] },
|
||||
telefono: { run: ['tests/telefono.browser.mjs'], extra: [/^assets\/muro\//] },
|
||||
risveglio: { run: ['tests/risveglio.browser.mjs'] },
|
||||
};
|
||||
|
||||
const ownRe = name => new RegExp(`(^|/)${name}([-_.][^/]*)?$`, 'i');
|
||||
const matches = (path, rule) => rule instanceof RegExp ? rule.test(path) : rule === path;
|
||||
export const ignored = path => IGNORED.some(r => r.test(path));
|
||||
export function ownFiles(name, files) {
|
||||
const s = SUITES[name], re = ownRe(name);
|
||||
return files.filter(p => re.test(p) || (s.extra || []).some(r => matches(p, r)));
|
||||
}
|
||||
// Gli ingressi di una prova fra i file dati (tutti quelli di git più quelli nuovi nella cartella).
|
||||
export function inputsOf(name, files) {
|
||||
const s = SUITES[name], live = files.filter(p => !ignored(p));
|
||||
if (!s.core) return [...new Set([...ENGINE.filter(p => files.includes(p)), ...ownFiles(name, live)])].sort();
|
||||
const others = Object.keys(SUITES).filter(n => !SUITES[n].core);
|
||||
// I file degli altri minigiochi, e i file di prova delle altre prove (tests/ambient.browser.mjs non riguarda la partita).
|
||||
const foreign = new Set([...others.flatMap(n => ownFiles(n, live)),
|
||||
...Object.keys(SUITES).filter(n => SUITES[n].run[0] !== s.run[0]).map(n => SUITES[n].run[0])]);
|
||||
return live.filter(p => !foreign.has(p)).sort();
|
||||
}
|
||||
export const fingerprint = (paths, hashOf) => createHash('sha1').update(paths.map(p => `${p} ${hashOf(p)}`).join('\n')).digest('hex');
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
|
||||
|
||||
// I file del progetto con l'impronta di ognuno: il blob dell'indice di git, o il contenuto attuale se modificato o nuovo.
|
||||
export function snapshot() {
|
||||
const hashes = new Map();
|
||||
for (const line of git('ls-files', '-s', '-z').split('\0')) if (line) { const [meta, path] = line.split('\t'); hashes.set(path, meta.split(' ')[1]); }
|
||||
const dirty = [];
|
||||
for (const line of git('status', '--porcelain', '-z', '--untracked-files=all').split('\0')) {
|
||||
if (!line) continue;
|
||||
const code = line.slice(0, 2), path = line.slice(3);
|
||||
if (code.includes('D')) hashes.delete(path); else dirty.push(path);
|
||||
}
|
||||
for (const path of dirty) if (existsSync(`${ROOT}/${path}`)) hashes.set(path, git('hash-object', '--', path).trim());
|
||||
return hashes;
|
||||
}
|
||||
|
||||
const OK_FILE = `${ROOT}/.verify-ok.json`;
|
||||
const readOk = () => { try { return JSON.parse(readFileSync(OK_FILE, 'utf8')); } catch { return {}; } };
|
||||
|
||||
export function plan(hashes, ok, names = Object.keys(SUITES)) {
|
||||
const files = [...hashes.keys()], out = [];
|
||||
for (const name of names) {
|
||||
const inputs = inputsOf(name, files), fp = fingerprint(inputs, p => hashes.get(p));
|
||||
const was = ok[name], changed = was?.fingerprint === fp ? [] : inputs.filter(p => !was?.hashes || was.hashes[p] !== hashes.get(p));
|
||||
out.push({ name, fp, inputs, changed, green: was?.fingerprint === fp, seconds: was?.seconds });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function run(name) {
|
||||
return new Promise(resolve => {
|
||||
const t0 = Date.now(), lines = [];
|
||||
const p = spawn(process.execPath, SUITES[name].run, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let rest = '';
|
||||
const onData = chunk => { const parts = (rest + chunk).split('\n'); rest = parts.pop(); for (const l of parts) { lines.push(l); console.log(`[${name}] ${l}`); } };
|
||||
p.stdout.on('data', onData); p.stderr.on('data', onData);
|
||||
p.on('close', code => { if (rest) console.log(`[${name}] ${rest}`); resolve({ name, code, seconds: Math.round((Date.now() - t0) / 1000) }); });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2), all = args.includes('--all'), onlyPlan = args.includes('--plan');
|
||||
const named = args.filter(a => !a.startsWith('--'));
|
||||
for (const n of named) if (!SUITES[n]) { console.error(`prova sconosciuta: ${n} (${Object.keys(SUITES).join(', ')})`); process.exit(2); }
|
||||
const hashes = snapshot(), ok = readOk();
|
||||
const todo = plan(hashes, ok, named.length ? named : undefined).filter(p => all || named.length || !p.green);
|
||||
const green = Object.keys(SUITES).filter(n => !todo.some(t => t.name === n));
|
||||
if (!todo.length) { console.log(`Prove nel browser: tutte verdi dall'ultimo giro (${green.length}); niente da rifare.`); return; }
|
||||
for (const t of todo) {
|
||||
const why = all ? 'tutte' : named.includes(t.name) ? 'chiesta' : !ok[t.name] ? 'mai provata' : !t.changed.length ? "cambiato l'elenco dei file da cui dipende" : `cambiati ${t.changed.slice(0, 4).join(', ')}${t.changed.length > 4 ? ` e altri ${t.changed.length - 4}` : ''}`;
|
||||
console.log(`da rifare: ${t.name}${t.seconds ? ` (~${t.seconds} s)` : ''}: ${why}`);
|
||||
}
|
||||
if (green.length) console.log(`già verdi: ${green.join(', ')}`);
|
||||
if (onlyPlan) return;
|
||||
const jobs = Math.max(1, Number(process.env.JOBS) || Math.min(2, Math.floor(cpus().length / 4)));
|
||||
const queue = todo.sort((a, b) => (b.seconds || 999) - (a.seconds || 999)), results = [];
|
||||
const worker = async () => { for (let t; (t = queue.shift());) {
|
||||
const r = await run(t.name); results.push(r);
|
||||
if (r.code === 0) {
|
||||
const now = readOk();
|
||||
now[t.name] = { fingerprint: t.fp, seconds: r.seconds, when: new Date().toISOString(), hashes: Object.fromEntries(t.inputs.map(p => [p, hashes.get(p)])) };
|
||||
writeFileSync(OK_FILE, JSON.stringify(now, null, 1) + '\n');
|
||||
}
|
||||
} };
|
||||
await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker));
|
||||
for (const r of results.sort((a, b) => a.name.localeCompare(b.name))) console.log(`${r.code === 0 ? 'OK ' : 'FAIL'} ${r.name} (${r.seconds} s)`);
|
||||
const failed = results.filter(r => r.code !== 0).map(r => r.name);
|
||||
if (failed.length) { console.log(`Prove fallite: ${failed.join(', ')}`); process.exit(1); }
|
||||
console.log(`Prove nel browser OK: ${results.length} rifatte, ${green.length} già verdi.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
|
||||
+405
-96
@@ -1,109 +1,418 @@
|
||||
// Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti
|
||||
// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
|
||||
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper,
|
||||
// molla e pallina. A sinistra, sul fianco nero del mobile, il quadro dei punti. Niente filtri né passate a tutto
|
||||
// schermo (vedi risveglio.js).
|
||||
import { FIELD, LANE, R, PIVOT, flipperSeg, waiting, canThrow, saveLeft } from './flipper.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
|
||||
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
|
||||
import { R, SCORE, RAMP, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
|
||||
const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = dark; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.strokeStyle = light; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
|
||||
const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff' };
|
||||
const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'];
|
||||
const TAU = Math.PI * 2, clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
const number = n => Math.round(n).toLocaleString('it-IT');
|
||||
function text(g, value, x, y, size = 7, color = WHITE, align = 'left', font = 'sans-serif', weight = 600) {
|
||||
g.font = `${weight} ${size}px ${font}`; g.textAlign = align; g.textBaseline = 'middle'; g.fillStyle = color; g.fillText(value, x, y);
|
||||
}
|
||||
function path(g, points, close = false) {
|
||||
g.beginPath(); points.forEach((p, i) => i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])); if (close) g.closePath();
|
||||
}
|
||||
function line(g, points, color, width = 1) { path(g, points); g.strokeStyle = color; g.lineWidth = width; g.stroke(); }
|
||||
function poly(g, points, color, edge) { path(g, points, true); g.fillStyle = color; g.fill(); if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } }
|
||||
function disc(g, x, y, r, color, edge) {
|
||||
g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill();
|
||||
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
|
||||
}
|
||||
function box(g, x, y, w, h, color, edge, radius = 5) {
|
||||
g.beginPath(); g.roundRect(x, y, w, h, radius); g.fillStyle = color; g.fill();
|
||||
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
|
||||
}
|
||||
function gradient(g, x, y, w, h, stops) {
|
||||
const v = g.createLinearGradient(x, y, x + w, y + h); stops.forEach(([at, color]) => v.addColorStop(at, color)); return v;
|
||||
}
|
||||
function metal(g, x, y, w = 20, h = 20) {
|
||||
return gradient(g, x, y, w, h, [[0, '#f1f8f6'], [0.2, '#8b9da6'], [0.46, '#263641'], [0.62, '#c4d5dc'], [1, '#354a58']]);
|
||||
}
|
||||
function screw(g, x, y) { disc(g, x, y, 1.4, '#9caeb3', '#17262f'); line(g, [[x - 0.6, y + 0.6], [x + 0.6, y - 0.6]], '#273b47', 0.7); }
|
||||
const glowCache = new Map();
|
||||
function glow(g, x, y, radius, color, alpha = 1) {
|
||||
if (typeof document === 'undefined') return;
|
||||
let sprite = glowCache.get(color);
|
||||
if (!sprite) {
|
||||
sprite = Object.assign(document.createElement('canvas'), { width: 96, height: 96 });
|
||||
const s = sprite.getContext('2d'), v = s.createRadialGradient(48, 48, 0, 48, 48, 48);
|
||||
v.addColorStop(0, color + 'b0'); v.addColorStop(0.22, color + '50'); v.addColorStop(0.6, color + '15'); v.addColorStop(1, color + '00');
|
||||
s.fillStyle = v; s.fillRect(0, 0, 96, 96); glowCache.set(color, sprite);
|
||||
}
|
||||
g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore();
|
||||
}
|
||||
const layers = new Map();
|
||||
function layerOf(key, paint, bounds) {
|
||||
let layer = layers.get(key);
|
||||
if (!layer) {
|
||||
layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 });
|
||||
const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
function cached(g, key, paint, bounds = [0, 0, 640, 360]) {
|
||||
if (typeof document === 'undefined') { paint(g); return; }
|
||||
g.drawImage(layerOf(key, paint, bounds), ...bounds);
|
||||
}
|
||||
// Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma:
|
||||
// una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La
|
||||
// tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta.
|
||||
const sheets = new Map();
|
||||
function cabinetBehind(g, key, paint) {
|
||||
const canvas = g.canvas;
|
||||
if (typeof document === 'undefined' || !(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return false;
|
||||
let url = sheets.get(key);
|
||||
if (!url) { url = `url(${layerOf(key, paint, [0, 0, 640, 360]).toDataURL()})`; sheets.set(key, url); }
|
||||
if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; }
|
||||
return true;
|
||||
}
|
||||
export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; }
|
||||
function crown(g, x, y, size, color) {
|
||||
poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color);
|
||||
line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1);
|
||||
}
|
||||
function outline(g, c) {
|
||||
path(g, [[200, 336], ...c.walls.slice(0, 12).map(w => w.a), [448, 72], [448, 336]], true);
|
||||
}
|
||||
function cabinet(g, c, bg, glass) {
|
||||
g.fillStyle = gradient(g, 0, 0, 640, 360, [[0, '#0c1924'], [0.5, '#02070d'], [1, '#101a24']]); g.fillRect(0, 0, 640, 360);
|
||||
// Due colonne da backglass, incassate nel mobile. Il paese resta un'illustrazione nel riquadro di sinistra.
|
||||
box(g, 12, 14, 174, 332, '#0c1721', '#243746', 9);
|
||||
box(g, 467, 14, 161, 332, '#0c1721', '#243746', 9);
|
||||
line(g, [[24, 20], [174, 20]], '#355263', 0.7); line(g, [[479, 20], [616, 20]], '#355263', 0.7);
|
||||
for (const x of [19, 179, 474, 621]) for (const y of [21, 339]) screw(g, x, y);
|
||||
if (glass) {
|
||||
g.save(); g.beginPath(); g.roundRect(24, 237, 150, 88, 4); g.clip();
|
||||
g.drawImage(glass, 0, 170, 200, 130, 24, 237, 150, 98);
|
||||
g.fillStyle = 'rgba(6,18,28,0.38)'; g.fillRect(24, 237, 150, 88); g.restore();
|
||||
} else if (bg) {
|
||||
g.save(); g.globalAlpha = 0.25; g.drawImage(bg, 200, 30, 230, 280, 24, 237, 150, 88); g.restore();
|
||||
}
|
||||
text(g, 'SAN PIERO A SIEVE', 99, 333, 6, MUTED, 'center');
|
||||
// Parete esterna, fianco e bisello: l'ombra scende sotto il piano, non sopra gli oggetti.
|
||||
g.save(); g.translate(2, 7); outline(g, c); g.fillStyle = '#010306'; g.fill(); g.lineWidth = 18; g.strokeStyle = '#010306'; g.stroke(); g.restore();
|
||||
outline(g, c); g.lineWidth = 16; g.strokeStyle = '#182430'; g.stroke();
|
||||
g.lineWidth = 11; g.strokeStyle = metal(g, 194, 0, 250, 30); g.stroke();
|
||||
g.lineWidth = 6; g.strokeStyle = '#050c12'; g.stroke();
|
||||
g.fillStyle = gradient(g, 240, 5, 140, 320, [[0, '#10252d'], [0.5, '#102d31'], [1, '#071720']]); g.fill();
|
||||
g.save(); outline(g, c); g.clip();
|
||||
glow(g, 225, 138, 150, TINT.sinistra, 0.17); glow(g, 415, 202, 140, TINT.destra, 0.13);
|
||||
// Incisione della Sieve nel piano in lacca; pochi segni, con spazio per leggere i tiri.
|
||||
for (let i = 0; i < 7; i++) {
|
||||
g.beginPath(); g.moveTo(305 + i * 3, 70); g.bezierCurveTo(230 + i * 3, 160, 414 + i * 2, 198, 302 + i * 4, 328);
|
||||
g.strokeStyle = i === 3 ? '#2c6570' : '#173e47'; g.lineWidth = i === 3 ? 1.4 : 0.6; g.stroke();
|
||||
}
|
||||
for (let i = 0; i < 40; i++) { g.fillStyle = '#28434b'; disc(g, 212 + hash(i * 7) * 211, 20 + hash(i * 13) * 304, 0.35, '#28434b'); }
|
||||
g.strokeStyle = '#27464d'; g.lineWidth = 0.6;
|
||||
for (const radius of [34, 39, 71]) { g.beginPath(); g.ellipse(320, 182, radius, radius * 0.7, 0, 0, TAU); g.stroke(); }
|
||||
crown(g, 320, 182, 12, '#31585b');
|
||||
// I bastioni stilizzati al fondo del piano.
|
||||
poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358');
|
||||
text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center');
|
||||
// La corsia della molla è una scanalatura separata, con il fondo metallico.
|
||||
g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264);
|
||||
line(g, [[440, 86], [440, 306]], '#45606c', 0.5);
|
||||
g.restore();
|
||||
// Plastiche sagomate delle sponde, con spessore e gomma viva sul bordo interno.
|
||||
for (const side of [-1, 1]) {
|
||||
const x = side < 0 ? 236 : 404, tip = x - side * 26, color = side < 0 ? TINT.sinistra : TINT.destra;
|
||||
poly(g, [[x, 248], [x, 287], [tip, 294]], '#02070c');
|
||||
poly(g, [[x, 246], [x, 284], [tip, 290]], gradient(g, x, 246, -side * 25, 40, [[0, '#52717e'], [0.15, '#163140'], [1, '#091723']]), '#7d9ca5');
|
||||
poly(g, [[x - side * 4, 261], [x - side * 4, 281], [tip + side * 8, 284]], '#173c4b', color);
|
||||
screw(g, x, 249); screw(g, x, 282); screw(g, tip + side * 4, 287);
|
||||
}
|
||||
g.lineCap = 'round'; g.lineJoin = 'round';
|
||||
for (const w of c.walls) {
|
||||
if (w.gate) continue;
|
||||
const pts = [w.a, w.b];
|
||||
g.save(); g.translate(1, 2); line(g, pts, '#02070c', 3.5); g.restore();
|
||||
line(g, pts, w.sling ? '#8c453b' : '#526c78', 2.3);
|
||||
line(g, pts, w.sling ? '#ffab7c' : '#bdd2d5', 0.7);
|
||||
}
|
||||
box(g, 198, 336, 252, 17, '#080f16', '#35515c', 2);
|
||||
text(g, 'R I V E R', 218, 345, 7, '#d6c49d');
|
||||
text(g, 'LOCK / JACKPOT / MULTIBALL', 431, 345, 5.5, MUTED, 'right');
|
||||
for (const side of [-1, 1]) for (let i = 0; i < 6; i++) {
|
||||
const x = side < 0 ? 197 : 451, y = 106 + i * 36, color = side < 0 ? TINT.sinistra : TINT.destra;
|
||||
glow(g, x, y, 14, color, 0.4); box(g, x - 0.7, y - 5, 1.4, 10, color, null, 0.7);
|
||||
}
|
||||
}
|
||||
function rampPoint(r, s) {
|
||||
const p = alongRamp(r, s), z = rampHeight(r, s); return { ...p, y: p.y - 1 - 8 * z };
|
||||
}
|
||||
function paintTrack(g, r) {
|
||||
const pts = r.cum.map(s => { const p = rampPoint(r, s); return [p.x, p.y]; }), color = TINT[r.id];
|
||||
g.lineCap = 'round'; g.lineJoin = 'round';
|
||||
// Piedini e ombra portata. La quota è la stessa usata dalla pallina.
|
||||
for (let s = 45; s < r.len; s += 75) {
|
||||
const p = rampPoint(r, s);
|
||||
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
|
||||
screw(g, p.x + 7, p.y + 10);
|
||||
}
|
||||
const track = (color, width) => {
|
||||
g.beginPath(); g.moveTo(...pts[0]);
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
|
||||
const d0 = Math.hypot(x - a[0], y - a[1]), d1 = Math.hypot(b[0] - x, b[1] - y), radius = Math.min(8, d0 / 3, d1 / 3);
|
||||
g.lineTo(x - (x - a[0]) / d0 * radius, y - (y - a[1]) / d0 * radius);
|
||||
g.quadraticCurveTo(x, y, x + (b[0] - x) / d1 * radius, y + (b[1] - y) / d1 * radius);
|
||||
}
|
||||
g.lineTo(...pts.at(-1)); g.strokeStyle = color; g.lineWidth = width; g.stroke();
|
||||
};
|
||||
function disc(ctx, x, y, r, fill, rim) {
|
||||
ctx.fillStyle = rim; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - 2, 0, Math.PI * 2); ctx.fill();
|
||||
g.save(); g.translate(3, 6); track('#02080cbb', 18); g.restore();
|
||||
track(color + '18', 20); track('#02080e', 16); track(color, 14);
|
||||
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
|
||||
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
|
||||
track(color + '24', 4);
|
||||
for (let s = 12; s < r.len - 4; s += 22) {
|
||||
const p = rampPoint(r, s);
|
||||
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
|
||||
for (const side of [-1, 1]) disc(g, p.x + p.dy * 6 * side, p.y - p.dx * 6 * side, 0.7, color);
|
||||
}
|
||||
function bumper(ctx, b) {
|
||||
const r = b.r * (b.hit > 0 ? 1 + b.hit * 1.2 : 1);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, r, 0, Math.PI * 2); ctx.fill();
|
||||
disc(ctx, b.x, b.y, r, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16');
|
||||
disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e');
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.6), 3, 2);
|
||||
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
|
||||
poly(g, [[cx - 6, y - 30], [cx + 6, y - 30], [x1, y + 2], [x0, y + 2]], '#030910');
|
||||
poly(g, [[cx - 5, y - 30], [cx + 5, y - 30], [x1 - 1, y], [x0 + 1, y]], gradient(g, 0, y - 30, 0, 30, [[0, '#172b38'], [0.8, '#3c5d6b'], [1, '#a6c4c8']]), '#7795a2');
|
||||
for (const side of [-1, 1]) {
|
||||
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
|
||||
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
|
||||
}
|
||||
// Il Regolo: un bersaglio tondo d'oro con la corona e il bordo scuro, che non si confonde col drago dipinto.
|
||||
function regolo(ctx, o) {
|
||||
const r = o.r + (o.hit > 0 ? 2 : 0);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.arc(o.x + 1, o.y + 2, r + 1, 0, Math.PI * 2); ctx.fill();
|
||||
disc(ctx, o.x, o.y, r + 1, o.hit > 0 ? '#fff6c8' : '#f0c040', '#3a2a08');
|
||||
ctx.fillStyle = '#3fa34d'; ctx.beginPath(); ctx.arc(o.x, o.y, r - 3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ffd45a';
|
||||
for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h);
|
||||
ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2);
|
||||
text(g, r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
|
||||
}
|
||||
function flipper(ctx, c, side) {
|
||||
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(tx + 1, ty + 3); ctx.stroke();
|
||||
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
|
||||
ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
|
||||
ctx.strokeStyle = '#fff1b8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, py - 1.5); ctx.lineTo(tx, ty - 1.5); ctx.stroke();
|
||||
disc(ctx, px, py, 4, '#8a7a6a', '#2a2622');
|
||||
function rampLights(g, c, r, time) {
|
||||
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
|
||||
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
|
||||
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
|
||||
if (hot) {
|
||||
for (let k = 0; k < 7; k++) {
|
||||
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
|
||||
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
|
||||
}
|
||||
function plunger(ctx, c) {
|
||||
const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0;
|
||||
ctx.fillStyle = '#4a4440'; ctx.fillRect(Math.round(x) - 3, top + pull, 6, 336 - top - pull + 10);
|
||||
ctx.fillStyle = '#9a9088';
|
||||
for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1);
|
||||
ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4);
|
||||
}
|
||||
function ball(ctx, b) {
|
||||
if (!b) return;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, R, 0, Math.PI * 2); ctx.fill();
|
||||
const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, R);
|
||||
g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068');
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, R, 0, Math.PI * 2); ctx.fill();
|
||||
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
|
||||
if (lit) glow(g, x - 8 + i * 8, y + 6, 7, color, 0.7);
|
||||
disc(g, x - 8 + i * 8, y + 6, 1.8, lit ? color : '#102a36', lit ? '#d6faff' : '#46616a');
|
||||
}
|
||||
// Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, il salvataggio, l'avviso.
|
||||
function board(ctx, c) {
|
||||
panel(ctx, 16, 56, 172, 168);
|
||||
text(ctx, 'FLIPPER DEL RIVER', 102, 72, 7, '#efd49e', 'center');
|
||||
text(ctx, String(c.score).padStart(6, '0'), 102, 98, 16, '#fff4c8', 'center');
|
||||
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 28, 124, 7, '#d7ccb4');
|
||||
text(ctx, c.result ? 'TRAGUARDO FATTO' : `TRAGUARDO ${c.skill.goal}`, 28, 140, 7, c.result ? '#9fd27a' : '#d7ccb4');
|
||||
text(ctx, 'RIONI', 28, 160, 7, '#d7ccb4');
|
||||
c.lanes.forEach((l, i) => disc(ctx, 86 + i * 22, 160, 7, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650'));
|
||||
const save = saveLeft(c);
|
||||
if (save > 0) text(ctx, `SALVATA ${Math.ceil(save)} s`, 28, 180, 7, '#9fd27a');
|
||||
else if (c.ball && !c.ball.waiting) text(ctx, c.saved ? 'SALVATAGGIO USATO' : 'SENZA RETE', 28, 180, 6, '#a9a08a');
|
||||
if (c.line) text(ctx, c.line, 102, 206, 8, '#ffe08c', 'center');
|
||||
const pulse = 0.6 + 0.4 * Math.sin(time * (invited || combo ? 8 : 3));
|
||||
if (invited || combo) glow(g, x, y + 16, 23, color, pulse);
|
||||
poly(g, [[x, y + 10], [x + 4, y + 16], [x + 1.5, y + 16], [x + 1.5, y + 22], [x - 1.5, y + 22], [x - 1.5, y + 16], [x - 4, y + 16]], invited ? WHITE : color);
|
||||
box(g, x - 23, y + 24, 46, 10, '#08151e', color, 3);
|
||||
text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center');
|
||||
}
|
||||
function result(ctx, c) {
|
||||
panel(ctx, 16, 236, 172, 56);
|
||||
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 252, 8, '#efd49e', 'center');
|
||||
text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
|
||||
function bumper(g, b, time) {
|
||||
const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec';
|
||||
glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24);
|
||||
g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill();
|
||||
disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b');
|
||||
disc(g, b.x, b.y, b.r - 2, '#172835', color);
|
||||
const v = g.createRadialGradient(b.x - 3, b.y - 4, 0, b.x, b.y, b.r - 3);
|
||||
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
|
||||
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
|
||||
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
|
||||
for (const dx of [-8, 8]) screw(g, b.x + dx, b.y);
|
||||
if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); }
|
||||
}
|
||||
|
||||
export function drawFlipper(ctx, c, def, bg) {
|
||||
const time = c.visual.time;
|
||||
ctx.save();
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||
else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); }
|
||||
// Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
|
||||
for (const l of c.lanes) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7);
|
||||
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050');
|
||||
}
|
||||
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa.
|
||||
for (const w of c.walls) rail(ctx, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6');
|
||||
// I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra.
|
||||
function target(g, c) {
|
||||
for (const t of c.targets) {
|
||||
const [x, y0] = t.a, y1 = t.b[1];
|
||||
if (t.down) { ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(x - 2, y0, 4, y1 - y0); continue; }
|
||||
ctx.fillStyle = '#2a2622'; ctx.fillRect(x - 3, y0 - 1, 6, y1 - y0 + 2);
|
||||
ctx.fillStyle = t.bank === 'pieve' ? '#f2eee2' : '#e8b640'; ctx.fillRect(x - 2, y0, 4, y1 - y0);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(x - 2, y1 - 3, 4, 3);
|
||||
const [x, y] = t.a, h = t.b[1] - y, color = t.bank === 'pieve' ? TINT.sinistra : TINT.destra;
|
||||
box(g, x - 3, y - 1, 6, h + 2, '#030c12', '#45606d', 1);
|
||||
if (t.down) { line(g, [[x - 2, y + h - 1], [x + 2, y + h - 1]], color); continue; }
|
||||
g.fillStyle = metal(g, x - 2, y, 5, 6); g.fillRect(x - 2, y, 4, h);
|
||||
g.fillStyle = color; g.fillRect(x - 1, y + 2, 2, h - 5); glow(g, x, y + h / 2, 8, color, 0.35);
|
||||
}
|
||||
for (const b of c.bumpers) bumper(ctx, b);
|
||||
regolo(ctx, c.regolo);
|
||||
for (const side of ['left', 'right']) flipper(ctx, c, side);
|
||||
plunger(ctx, c);
|
||||
ball(ctx, c.ball);
|
||||
// Da fermo, l'invito: la molla da caricare.
|
||||
if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center');
|
||||
board(ctx, c);
|
||||
if (c.step === 'result') result(ctx, c);
|
||||
ctx.restore();
|
||||
const o = c.regolo;
|
||||
glow(g, o.x, o.y, o.hit > 0 ? 30 : 18, '#99e5aa', o.hit > 0 ? 1 : 0.4);
|
||||
disc(g, o.x + 1, o.y + 3, o.r + 1, '#02090b');
|
||||
disc(g, o.x, o.y, o.r + 1, metal(g, o.x - 6, o.y - 6), '#c5b682');
|
||||
disc(g, o.x, o.y, o.r - 1, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0');
|
||||
text(g, 'REGOLO', o.x, o.y - 14, 5, '#acd6b7', 'center');
|
||||
}
|
||||
function paddle(g, c, side) {
|
||||
const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y);
|
||||
glow(g, x, y, 13, side === 'left' ? TINT.sinistra : TINT.destra, f.held.length ? 0.7 : 0.3);
|
||||
g.save(); g.translate(x, y); g.rotate(Math.atan2(ty - y, tx - x));
|
||||
const shape = () => { g.beginPath(); g.moveTo(0, -3); g.lineTo(len, -2.3); g.quadraticCurveTo(len + 3, 0, len, 2.3); g.lineTo(0, 3); g.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); g.closePath(); };
|
||||
g.save(); g.translate(1, 4); shape(); g.fillStyle = '#00080c'; g.fill(); g.restore();
|
||||
shape(); g.fillStyle = '#e9eee4'; g.fill(); g.strokeStyle = '#d36754'; g.lineWidth = 1.4; g.stroke();
|
||||
line(g, [[3, -1.7], [len - 2, -1.3]], '#ffffff', 0.8); screw(g, 0, 0); g.restore();
|
||||
}
|
||||
function ball(g, b, color = '#bcecff') {
|
||||
const z = b.ramp?.z ?? 0, x = b.x, y = b.y - 1 - z * 8, r = R + z;
|
||||
g.beginPath(); g.ellipse(x + 2 + z * 3, b.y + 3 + z * 2, r + 1, r * 0.55, 0, 0, TAU); g.fillStyle = '#00080dcc'; g.fill();
|
||||
const v = g.createRadialGradient(x - r * 0.35, y - r * 0.45, 0, x, y, r);
|
||||
v.addColorStop(0, '#ffffff'); v.addColorStop(0.2, '#f6ffff'); v.addColorStop(0.42, color); v.addColorStop(0.65, '#667d8e'); v.addColorStop(0.8, '#132435'); v.addColorStop(1, '#b1ccdc');
|
||||
disc(g, x, y, r, v, '#bfdee8'); disc(g, x - r * 0.3, y - r * 0.4, 0.9, '#ffffff');
|
||||
}
|
||||
function lock(g, held, time) {
|
||||
const b = held.ball, color = TINT[held.rampId];
|
||||
glow(g, b.x, b.y, 22, color, 0.5 + 0.15 * Math.sin(time * 4));
|
||||
box(g, b.x - 9, b.y - 11, 18, 22, '#07151e', color, 5); ball(g, b, color);
|
||||
for (const dx of [-6, 6]) line(g, [[b.x + dx, b.y - 8], [b.x + dx, b.y + 7]], metal(g, b.x - 6, b.y, 12, 10), 2);
|
||||
text(g, 'LOCK', b.x, b.y - 16, 5.5, color, 'center');
|
||||
}
|
||||
function plunger(g, c) {
|
||||
const x = 440, pull = c.holding ? c.power * 12 : 0;
|
||||
for (let y = 327 + pull; y < 347; y += 2.5) line(g, [[436, y], [444, y + 1]], '#adbdc4', 0.8);
|
||||
box(g, 435, 324 + pull, 10, 4, metal(g, 435, 324, 10, 4), '#3c687b', 1);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
g.fillStyle = canThrow(c) || c.holding ? (i / 8 < c.power ? TINT.destra : '#1e3844') : '#1e3844';
|
||||
g.fillRect(437, 290 - i * 6, 6, 2);
|
||||
}
|
||||
// La valvola disegnata coincide con il segmento inclinato della fisica.
|
||||
const gate = c.walls.find(w => w.gate);
|
||||
line(g, [gate.a, gate.b], '#061017', 3); line(g, [gate.a, gate.b], '#b0d1d5', 1.2); screw(g, ...gate.a);
|
||||
if (waiting(c)) text(g, 'LANCIA', x, 302, 4.5, TINT.destra, 'center');
|
||||
}
|
||||
const shown = new WeakMap();
|
||||
export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; }
|
||||
function board(g, c, time) {
|
||||
crown(g, 99, 43, 10, '#e7c889');
|
||||
text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500);
|
||||
text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center');
|
||||
box(g, 23, 111, 152, 80, '#030b13', '#2c4a58', 6);
|
||||
text(g, 'PUNTEGGIO', 34, 123, 6, MUTED);
|
||||
let d = shown.get(c);
|
||||
if (!d) { d = { from: c.score, to: c.score, t0: time }; shown.set(c, d); }
|
||||
if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; }
|
||||
const score = c.step === 'result' ? c.score : shownScore(d, time);
|
||||
glow(g, 99, 146, 55, '#6dddf4', 0.2);
|
||||
const digits = String(score).padStart(6, '0');
|
||||
text(g, digits, 99, 146, Math.min(28, 132 / (digits.length * 0.6)), '#c4f6ff', 'center', 'monospace', 500);
|
||||
const message = c.step === 'result' ? (c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA') : c.line ?? (waiting(c) ? 'CARICA LA MOLLA' : 'PUNTA AL LOCK');
|
||||
text(g, message, 99, 176, 7, c.line ? '#ffdc93' : MUTED, 'center');
|
||||
const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score);
|
||||
box(g, 28, 195, 142, 2, '#203641', null, 1);
|
||||
if (progress) box(g, 28, 195, 142 * progress, 2, '#e7c889', null, 1);
|
||||
text(g, remaining ? `MANCANO ${number(remaining)}` : 'TRAGUARDO / PALLINA +1', 99, 206, 6, '#c7c8ac', 'center');
|
||||
text(g, 'PALLINE', 28, 225, 6, MUTED);
|
||||
for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5');
|
||||
if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right');
|
||||
if (c.step === 'result') {
|
||||
box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5);
|
||||
text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center');
|
||||
text(g, `${number(c.result.score)} PUNTI`, 99, 287, 11, WHITE, 'center', 'monospace');
|
||||
}
|
||||
const save = saveLeft(c);
|
||||
if (save > 0) { box(g, 248, 325, 144, 8, '#0b272f', '#4d9b9d', 3); text(g, `BALL SAVE ${Math.ceil(save)}s`, 320, 329, 5, '#a4f7d1', 'center'); }
|
||||
}
|
||||
function mission(g, c, time) {
|
||||
const multi = multiball(c);
|
||||
text(g, 'LA CACCIA AL', 480, 35, 6, MUTED); text(g, 'JACKPOT', 480, 51, 17, '#f0d59d', 'left', 'Georgia, serif');
|
||||
text(g, 'DUE LOCK. UNA SUPER SPECIAL.', 480, 69, 5.5, '#8aabb8');
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const id = i ? 'destra' : 'sinistra', color = TINT[id], y = 83 + i * 56, held = c.locks.some(l => l.rampId === id);
|
||||
const count = held || multi ? 3 : c.rampRun.id === id ? c.rampRun.count : 0;
|
||||
box(g, 479, y, 137, 49, '#102431', c.special === id || multi ? color : '#28424e', 5);
|
||||
text(g, `0${i + 1}`, 489, y + 12, 6, color, 'left', 'monospace');
|
||||
text(g, i ? 'TREBBIO' : 'FORTEZZA', 507, y + 12, 8, '#d4e6eb');
|
||||
for (let k = 0; k < 3; k++) { box(g, 489 + k * 28, y + 25, 22, 4, k < count ? color : '#213e4c', null, 1); }
|
||||
const note = multi ? 'JACKPOT ATTIVO' : held ? 'PALLINA TRATTENUTA' : count ? `${count}/3 · ${Math.max(0, Math.ceil(c.rampRun.until - c.sim))}s PER IL LOCK` : '3 PASSAGGI IN 20 SECONDI';
|
||||
text(g, note, 489, y + 39, 5.5, held ? color : MUTED);
|
||||
if (held) { disc(g, 604, y + 12, 3, color); glow(g, 604, y + 12, 10, color, 0.6); }
|
||||
}
|
||||
const open = c.special === 'super';
|
||||
box(g, 479, 195, 137, 64, open || multi ? '#241d3c' : '#121f2c', open ? TINT.super : '#364052', 5);
|
||||
if (open) glow(g, 547, 222, 52, TINT.super, 0.25 + 0.15 * Math.sin(time * 5));
|
||||
text(g, '03 SUPER SPECIAL', 489, 208, 7.5, open ? '#f1d8ff' : '#a5a5b7');
|
||||
text(g, multi ? `${activeBalls(c).length} PALLINE IN GIOCO` : open ? 'RAMPA CENTRALE APERTA' : 'COMPLETA ENTRAMBI I LOCK', 489, 224, 5.5, open || multi ? '#ead6fc' : MUTED);
|
||||
text(g, '+1 PALLINA + MULTIBALL', 489, 241, 6, '#d3bfdc');
|
||||
text(g, `JACKPOT ×${c.jackpotX}`, 480, 278, 8, '#f2d5a0');
|
||||
text(g, number((open ? SCORE.super : SCORE.jackpot) * c.jackpotX), 615, 278, 10, '#ffe4b2', 'right', 'monospace');
|
||||
text(g, 'I BERSAGLI COMPLETI ALZANO IL ×', 480, 292, 5, MUTED);
|
||||
line(g, [[480, 303], [615, 303]], '#2c424d', 0.6);
|
||||
text(g, multi ? 'LE RAMPE PAGANO JACKPOT' : c.special === 'super' ? 'MIRA ALLA FRECCIA VIOLA' : c.special ? 'L’ALTRO LOCK ACCENDE LA SUPER' : c.lastRamp ? `COMBO +50.000 / ${Math.max(0, Math.ceil(RAMP.combo - (c.sim - c.lastRamp.at)))}s` : 'ALTERNARE LE RAMPE: +50.000', 548, 316, 5.5, '#aec5c9', 'center');
|
||||
text(g, multi ? 'MULTIBALL' : c.special === 'super' ? 'SUPER SPECIAL' : 'LOCK • SPECIAL • JACKPOT', 548, 333, 6, multi || open ? TINT.super : '#6e8f9b', 'center');
|
||||
}
|
||||
function effects(g, c, time) {
|
||||
const latest = c.fx.findLast(f => f.kind === 'celebrate');
|
||||
for (const f of c.fx) {
|
||||
const age = time - f.t;
|
||||
if (age < 0) continue;
|
||||
g.save();
|
||||
if (f.kind === 'spark' && age < 0.5) {
|
||||
g.globalAlpha = 1 - age / 0.5;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
|
||||
line(g, [[f.x + Math.cos(a) * length, f.y + Math.sin(a) * length], [f.x + Math.cos(a) * (length + 3), f.y + Math.sin(a) * (length + 3)]], i % 2 ? '#f9edbc' : '#8de8fa', 1);
|
||||
}
|
||||
} else if (f.kind === 'ring' && age < 0.5) {
|
||||
g.globalAlpha = 1 - age * 2; g.strokeStyle = '#b2f6ff'; g.lineWidth = 1.2;
|
||||
g.beginPath(); g.ellipse(f.x, f.y, 12 + age * 42, 9 + age * 30, 0, 0, TAU); g.stroke();
|
||||
} else if (f.kind === 'pop' && age < 1.1) {
|
||||
g.globalAlpha = Math.min(1, (1.1 - age) * 3);
|
||||
text(g, f.text, f.x, f.y - age * 22, 7, '#fff1c7', 'center', 'monospace');
|
||||
} else if (f.kind === 'burst' && age < 1.5) {
|
||||
g.globalAlpha = Math.min(1, (1.5 - age) * 2);
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const a = hash(i * 9 + f.t) * TAU, speed = 25 + hash(i * 13) * 75;
|
||||
const x = f.x + Math.cos(a) * speed * age, y = f.y + Math.sin(a) * speed * age + 20 * age * age;
|
||||
if (x < 201 || x > 430 || y > 330) continue;
|
||||
disc(g, x, y, 0.7 + hash(i), i % 2 ? '#a8e8ff' : '#ffe3a9');
|
||||
}
|
||||
} else if (f.kind === 'celebrate' && f === latest && age < 2.6) {
|
||||
const color = f.color ?? TINT.super;
|
||||
const fade = Math.min(1, age * 5, (2.6 - age) * 3);
|
||||
g.globalAlpha = fade;
|
||||
// Fasci stretti, limitati al campo: la pallina resta leggibile.
|
||||
g.save(); outline(g, c); g.clip();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const a = -Math.PI / 2 + (i - 2) * 0.3 + Math.sin(time * 1.5) * 0.1;
|
||||
poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18');
|
||||
}
|
||||
g.restore();
|
||||
glow(g, 320, 277, 85, color, 0.45);
|
||||
box(g, 257, 265, 126, 31, '#07111deb', color, 6);
|
||||
text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800);
|
||||
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace');
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
export function drawFlipper(g, c, def, bg, glass) {
|
||||
const time = c.visual.time;
|
||||
// Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la
|
||||
// memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono.
|
||||
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter';
|
||||
const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0);
|
||||
const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass);
|
||||
if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); }
|
||||
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
|
||||
else cached(g, key, paint);
|
||||
for (const [i, l] of c.lanes.entries()) {
|
||||
if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55);
|
||||
disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b');
|
||||
disc(g, l.x, l.y, 2.3, l.lit ? RIONI[i] : '#203a44');
|
||||
}
|
||||
target(g, c); c.bumpers.forEach(b => bumper(g, b, time));
|
||||
if (c.special !== 'super') {
|
||||
box(g, 305, 221, 30, 15, metal(g, 305, 221, 30, 15), '#354a5a', 3);
|
||||
box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center');
|
||||
for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748');
|
||||
}
|
||||
const balls = activeBalls(c);
|
||||
for (const [i, b] of balls.entries()) {
|
||||
if (b.waiting) continue;
|
||||
const color = [TINT.sinistra, TINT.destra, TINT.super][i];
|
||||
b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); });
|
||||
}
|
||||
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
|
||||
for (const r of c.ramps) {
|
||||
if (!rampOpen(c, r)) continue;
|
||||
g.save();
|
||||
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
|
||||
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time);
|
||||
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
|
||||
g.restore();
|
||||
}
|
||||
c.locks.forEach(held => lock(g, held, time));
|
||||
for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time);
|
||||
for (const side of ['left', 'right']) paddle(g, c, side);
|
||||
plunger(g, c); effects(g, c, time);
|
||||
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
|
||||
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
|
||||
board(g, c, time); mission(g, c, time);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
+275
-53
@@ -7,19 +7,23 @@
|
||||
// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte.
|
||||
// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita
|
||||
// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che
|
||||
// contano quando si attraversano, traguardo acquisito appena raggiunto.
|
||||
// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico.
|
||||
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
|
||||
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
|
||||
export const PIVOT = { left: [280, 314], right: [360, 314] };
|
||||
export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 };
|
||||
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
|
||||
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000, super: 250000 };
|
||||
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
|
||||
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
|
||||
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30, lockWindow: 20 };
|
||||
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
|
||||
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
|
||||
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
|
||||
// gravità. La spinta dei funghi è uguale per tutti.
|
||||
export const LEVELS = {
|
||||
bona: { g: 680, save: 8, kick: 340, goal: 6000 },
|
||||
seria: { g: 740, save: 4, kick: 340, goal: 10000 },
|
||||
male: { g: 800, save: 1, kick: 340, goal: 16000 },
|
||||
bona: { g: 680, save: 8, kick: 340, goal: 60000 },
|
||||
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
|
||||
male: { g: 800, save: 1, kick: 340, goal: 200000 },
|
||||
};
|
||||
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
@@ -33,20 +37,27 @@ export function layout() {
|
||||
const walls = [];
|
||||
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 }));
|
||||
walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336]));
|
||||
// La corsia della molla, col fondo. Niente cancelletto: una pallina che ricasca nella corsia si rilancia e basta
|
||||
// (il cancelletto a senso unico faceva da ripiano ai lanci a metà).
|
||||
// La corsia della molla, col fondo. Un lancio che non è ancora entrato in campo si può ritentare;
|
||||
// dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale.
|
||||
walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 }));
|
||||
// Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo.
|
||||
walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 }));
|
||||
// Le corsie dei rioni, in cima, fra le bandiere: tre guide.
|
||||
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
|
||||
// I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
|
||||
// po' lungo il flipper: finendo sul perno la pallina ci restava incastrata).
|
||||
walls.push(seg([222, 240], [222, 290]), seg([222, 290], [292, 314], { e: 0.3 }));
|
||||
walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 }));
|
||||
// Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
|
||||
walls.push(seg([214, 240], [214, 290]), seg([214, 290], [292, 314], { e: 0.3 }));
|
||||
walls.push(seg([420, 240], [420, 290]), seg([420, 290], [348, 314], { e: 0.3 }));
|
||||
// Le sponde elastiche sopra i flipper: il lato lungo spinge.
|
||||
walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true }));
|
||||
walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true }));
|
||||
// I bersagli a caduta: la Pieve a sinistra, la torre a destra, con la sponda dietro.
|
||||
walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198]));
|
||||
// Dietro la Pieve, fra la parete e la sponda, restavano 10 pixel: la pallina (larga 9) che scivolava lungo la
|
||||
// parete sinistra ci entrava e scendeva dritta nell'uscita laterale (Andrea: «a sinistra esce troppo facile»).
|
||||
// A destra il canale è di 3 pixel e non esiste. Due raccordi inclinati lo chiudono sopra e sotto.
|
||||
walls.push(seg([200, 136], [210, 146], { e: 0.5 }), seg([210, 198], [200, 208], { e: 0.5 }));
|
||||
const targets = [];
|
||||
for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false, touching: false });
|
||||
const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0, touching: false }));
|
||||
@@ -55,7 +66,31 @@ export function layout() {
|
||||
// Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in
|
||||
// discesa la riga LANE_LINE.
|
||||
const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false }));
|
||||
return { walls, targets, bumpers, regolo, lanes };
|
||||
// Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in
|
||||
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
|
||||
// in cima, come due ponti.
|
||||
const ramps = [
|
||||
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
|
||||
{ id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
|
||||
{ id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
|
||||
{ id: 'super', mouth: [[303, 232], [337, 232]], path: [[320, 232], [320, 188], [333, 145], [333, 96], [320, 72], [307, 96], [307, 145], [287, 168], [246, 180], [229, 198], [229, 220]], lit: 0, touching: false },
|
||||
];
|
||||
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
|
||||
for (const r of ramps) {
|
||||
r.len = 0; r.cum = [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)];
|
||||
}
|
||||
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
|
||||
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
|
||||
return { walls, targets, bumpers, regolo, lanes, ramps };
|
||||
}
|
||||
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
|
||||
export function alongRamp(r, s) {
|
||||
s = clamp(s, 0, r.len);
|
||||
let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++;
|
||||
const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L;
|
||||
return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L };
|
||||
}
|
||||
|
||||
export function newFlipper(seed = Date.now() >>> 0, def = {}) {
|
||||
@@ -63,11 +98,17 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
|
||||
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
|
||||
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
|
||||
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
|
||||
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, visual: { time: 0 }, ...layout() };
|
||||
ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
|
||||
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
|
||||
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
||||
newBall(c);
|
||||
return c;
|
||||
}
|
||||
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true }; c.still = 0; c.anchor = null; c.rest = 0; }
|
||||
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
|
||||
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
|
||||
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
|
||||
export const multiball = c => c.extraBalls.length > 0;
|
||||
export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super';
|
||||
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
|
||||
export const canThrow = c => waiting(c) && !c.holding;
|
||||
export const canPress = canThrow;
|
||||
@@ -78,11 +119,26 @@ export const aimFree = () => true;
|
||||
export const powerAt = time => clamp(time / CHARGE, 0, 1);
|
||||
// Quanto resta del salvataggio della pallina, in secondi (0: non protetta).
|
||||
export const saveLeft = c => c.ball && !c.ball.waiting ? Math.max(0, c.saveUntil - c.sim) : 0;
|
||||
function say(c, line) { c.line = line; c.lineT = 2; }
|
||||
// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record.
|
||||
function add(c, points) {
|
||||
// Le scritte del tabellone: una alla volta, due secondi l'una; il traguardo passa davanti a tutte.
|
||||
function say(c, line, front = false) {
|
||||
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
|
||||
else c.lines.push(line);
|
||||
}
|
||||
// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che
|
||||
// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE.
|
||||
export const FX_LIFE = 3;
|
||||
function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); }
|
||||
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
|
||||
// continua per il record.
|
||||
function add(c, points, at) {
|
||||
c.score += points;
|
||||
if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); }
|
||||
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
|
||||
if (!c.result && c.score >= c.skill.goal) {
|
||||
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
|
||||
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
|
||||
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
|
||||
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
|
||||
}
|
||||
if (c.result) c.result.score = c.score;
|
||||
}
|
||||
|
||||
@@ -120,11 +176,17 @@ export function flipperSeg(c, side) {
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
|
||||
if (c.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null;
|
||||
if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
|
||||
c.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE);
|
||||
c.accumulator += dt;
|
||||
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
|
||||
// La scia della pallina: le ultime posizioni, per la cometa.
|
||||
for (const b of activeBalls(c)) {
|
||||
if (!b.waiting && b.releaseAt <= c.sim) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
|
||||
}
|
||||
c.trail = c.ball?.trail ?? [];
|
||||
}
|
||||
function moveFlippers(c, h) {
|
||||
for (const side of ['left', 'right']) {
|
||||
@@ -138,17 +200,23 @@ function moveFlippers(c, h) {
|
||||
}
|
||||
function step(c) {
|
||||
c.sim += STEP;
|
||||
const ball = c.ball;
|
||||
if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; }
|
||||
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.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 };
|
||||
if (c.step !== 'play') { moveFlippers(c, STEP); return; }
|
||||
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
|
||||
ball.vy += c.skill.g * STEP;
|
||||
limit(ball);
|
||||
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
|
||||
const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n;
|
||||
const from = { x: ball.x, y: ball.y };
|
||||
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
|
||||
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp && b.releaseAt <= c.sim);
|
||||
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
|
||||
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
|
||||
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
|
||||
const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
|
||||
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
|
||||
for (let i = 0; i < n; i++) {
|
||||
moveFlippers(c, h);
|
||||
for (const ball of ground) {
|
||||
ball.x += ball.vx * h; ball.y += ball.vy * h;
|
||||
if (ball.x < LANE.x0 - R) ball.inPlay = true;
|
||||
for (const w of c.walls) collideSeg(c, ball, w);
|
||||
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
|
||||
for (const b of c.bumpers) collideBumper(c, ball, b);
|
||||
@@ -156,14 +224,34 @@ function step(c) {
|
||||
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
|
||||
limit(ball);
|
||||
}
|
||||
crossLanes(c, from, ball);
|
||||
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è
|
||||
// trattenuta su un flipper alzato: quella è una mossa del giocatore.
|
||||
if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; }
|
||||
else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
|
||||
// Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia.
|
||||
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0;
|
||||
if (ball.y > FIELD.y1 + R * 2) drain(c);
|
||||
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
|
||||
}
|
||||
for (const ball of balls) {
|
||||
if (ball.waiting || ball.releaseAt > c.sim) continue;
|
||||
if (ball.ramp) { rideRamp(c, ball); continue; }
|
||||
crossLanes(c, from.get(ball), ball);
|
||||
enterRamps(c, from.get(ball), ball);
|
||||
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
|
||||
if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
|
||||
else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
|
||||
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
|
||||
if ((ball.rest += STEP) > 0.3) {
|
||||
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
|
||||
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
|
||||
else newBall(c);
|
||||
}
|
||||
} else ball.rest = 0;
|
||||
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
|
||||
}
|
||||
}
|
||||
function collideBalls(a, b) {
|
||||
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||
if (d >= R * 2) return;
|
||||
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
|
||||
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
|
||||
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
|
||||
limit(a); limit(b);
|
||||
}
|
||||
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
|
||||
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
|
||||
@@ -173,32 +261,166 @@ function crossLanes(c, from, ball) {
|
||||
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||||
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
|
||||
if (!l || l.lit) return;
|
||||
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 });
|
||||
if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
|
||||
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
|
||||
if (c.lanes.every(k => k.lit)) {
|
||||
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
|
||||
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
|
||||
}
|
||||
function drain(c) {
|
||||
c.events.push({ kind: 'flipper-buco', power: 0.8 });
|
||||
if (c.sim < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; }
|
||||
c.balls--; c.saved = false;
|
||||
if (c.balls > 0) { say(c, `Persa: ne restano ${c.balls}.`); newBall(c); return; }
|
||||
}
|
||||
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
|
||||
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
|
||||
// indietro e ricasca dall'imbocco.
|
||||
function enterRamps(c, from, ball) {
|
||||
for (const r of c.ramps) {
|
||||
if (!rampOpen(c, r)) continue;
|
||||
const [[x0, y], [x1]] = r.mouth;
|
||||
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
|
||||
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||||
if (x < x0 + R || x > x1 - R) continue;
|
||||
// Conta solo la velocità lungo il ponte (sfiorarlo di traverso non fa salire).
|
||||
const p = alongRamp(r, 0), forward = ball.vx * p.dx + ball.vy * p.dy;
|
||||
if (forward < RAMP.speed) continue;
|
||||
ball.ramp = { id: r.id, s: 0, v: forward * 0.95, off: x - p.x };
|
||||
c.events.push({ kind: 'flipper-rampa', power: 0.7 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Sul ponte: si sale fino alla cima perdendo velocità, si scende riprendendola (la stessa energia, meno un po'
|
||||
// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1).
|
||||
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
|
||||
function rideRamp(c, ball) {
|
||||
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top;
|
||||
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
|
||||
on.s += on.v * STEP;
|
||||
const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend);
|
||||
ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s);
|
||||
if (on.s >= r.len) {
|
||||
// In fondo al ponte: giù dall'altra parte, con la velocità che ha. Gli ostacoli lì sotto, se per caso la
|
||||
// pallina esce sopra uno di loro, non contano come urto nuovo.
|
||||
const v = Math.max(on.v, 150);
|
||||
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
|
||||
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
|
||||
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
|
||||
r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
|
||||
if (rampFeature(c, r, ball)) return;
|
||||
if (c.lastRamp && c.lastRamp.id !== r.id) {
|
||||
add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
|
||||
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
|
||||
}
|
||||
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
|
||||
} else if (on.s <= 0) {
|
||||
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
|
||||
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
|
||||
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
|
||||
}
|
||||
// Tre passaggi completi sulla stessa rampa entro venti secondi. La finestra parte dal primo.
|
||||
function announce(c, line) {
|
||||
// Gli eventi di fase sostituiscono i vecchi annunci dei ponti; il premio del traguardo mantiene la precedenza.
|
||||
const prize = c.lines.includes('PALLINA +1'); c.lines = [];
|
||||
if (c.line === 'TRAGUARDO!') { if (prize) c.lines.push('PALLINA +1'); c.lines.push(line); }
|
||||
else { c.line = null; say(c, line, true); if (prize) c.lines.push('PALLINA +1'); }
|
||||
}
|
||||
function celebrate(c, title, points, color) {
|
||||
if (points) add(c, points, [320, 184]);
|
||||
announce(c, title);
|
||||
fx(c, 'celebrate', 320, 170, { text: title, points, color });
|
||||
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
|
||||
}
|
||||
function rampFeature(c, r, ball) {
|
||||
if (r.id === 'super') {
|
||||
// La rampa centrale esiste solo con entrambi i lock: premio e tre palline sul campo.
|
||||
if (c.locks.length !== 2) return true;
|
||||
for (const [i, lock] of c.locks.entries()) {
|
||||
const released = lock.ball;
|
||||
// Le uscite verticali centrano le corsie interne: nessuna spinta verso le buche laterali.
|
||||
released.vx = 0; released.vy = 170; released.waiting = false;
|
||||
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
|
||||
released.releaseAt = c.sim + i * 0.45; released.releasedFrom = lock.rampId;
|
||||
c.extraBalls.push(released);
|
||||
}
|
||||
// Uscita laterale sopra la paletta sinistra, con velocità moderata; niente tiro dritto nella buca.
|
||||
ball.vx = 0; ball.vy = 160;
|
||||
c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null;
|
||||
c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++;
|
||||
celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff');
|
||||
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
|
||||
c.events.push({ kind: 'flipper-super', power: 1 });
|
||||
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
|
||||
return true;
|
||||
}
|
||||
if (multiball(c)) {
|
||||
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
|
||||
c.events.push({ kind: 'flipper-jackpot', power: 1 }); return true;
|
||||
}
|
||||
if (c.locks.some(lock => lock.rampId === r.id)) { c.rampRun = { id: null, count: 0, until: 0 }; say(c, 'LOCK OCCUPATO'); return true; }
|
||||
if (c.special === r.id && !c.specialPaid) {
|
||||
c.specialPaid = true;
|
||||
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
|
||||
c.events.push({ kind: 'flipper-jackpot', power: 1 });
|
||||
}
|
||||
const continuing = c.rampRun.id === r.id && c.sim <= c.rampRun.until;
|
||||
c.rampRun = { id: r.id, count: continuing ? c.rampRun.count + 1 : 1, until: continuing ? c.rampRun.until : c.sim + RAMP.lockWindow };
|
||||
if (c.rampRun.count < 3) return c.locks.length > 0;
|
||||
c.locks.push({ rampId: r.id, ball }); ball.vx = 0; ball.vy = 0; ball.trail = [];
|
||||
c.special = c.locks.length === 2 ? 'super' : c.ramps.find(other => other.id !== 'super' && other.id !== r.id).id;
|
||||
c.rampRun = { id: null, count: 0, until: 0 }; c.lastRamp = null;
|
||||
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
|
||||
newBall(c);
|
||||
celebrate(c, c.locks.length === 2 ? 'SUPER APERTA' : 'BALL LOCKED', 0, c.locks.length === 2 ? '#e1a0ff' : '#72edff');
|
||||
c.events.push({ kind: 'flipper-lock', power: 1 });
|
||||
if (c.special === 'super') { c.superOpenedAt = c.visual.time; c.events.push({ kind: 'flipper-super-open', power: 1 }); }
|
||||
return true;
|
||||
}
|
||||
function drain(c, ball) {
|
||||
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
|
||||
if (multiball(c)) {
|
||||
if (c.ball === ball) c.ball = c.extraBalls.shift();
|
||||
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
|
||||
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0, until: 0 };
|
||||
say(c, multiball(c) ? 'DUE IN GIOCO' : 'UNA IN GIOCO'); return;
|
||||
}
|
||||
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
|
||||
c.rampRun = { id: null, count: 0, until: 0 };
|
||||
if (c.locks.length) {
|
||||
// Si recupera l'ultimo lock. L'eventuale primo resta acquisito e non si scala la riserva.
|
||||
c.locks.pop(); c.superOpenedAt = null;
|
||||
c.special = c.locks.length ? c.ramps.find(r => r.id !== 'super' && r.id !== c.locks[0].rampId).id : null;
|
||||
if (!c.locks.length) c.specialPaid = false;
|
||||
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
|
||||
announce(c, 'LOCK LIBERO'); newBall(c); return;
|
||||
}
|
||||
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
|
||||
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
|
||||
c.ball = null; c.step = 'result';
|
||||
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
|
||||
}
|
||||
function contact(ball, object, hit) {
|
||||
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
|
||||
if (hit && fresh) ball.contacts.push(object.contactId);
|
||||
else if (!hit && !fresh) ball.contacts.splice(index, 1);
|
||||
object.touching = hit;
|
||||
return fresh;
|
||||
}
|
||||
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
|
||||
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
|
||||
function hitSeg(ball, w, e) {
|
||||
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
|
||||
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
|
||||
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
|
||||
if (d >= R) { w.touching = false; return null; }
|
||||
if (d >= R) { contact(ball, w, false); return null; }
|
||||
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
|
||||
ball.x = px + nx * R; ball.y = py + ny * R;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching;
|
||||
w.touching = true;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
|
||||
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||||
return { vn: -vn, nx, ny, fresh };
|
||||
}
|
||||
function collideSeg(c, ball, w) {
|
||||
if (w.gate) {
|
||||
if (!ball.inPlay) return;
|
||||
const hit = hitSeg(ball, w, w.e);
|
||||
if (hit && hit.vn > 0) ball.vx = Math.min(-100, ball.vx);
|
||||
return;
|
||||
}
|
||||
if (!w.sling) { hitSeg(ball, w, w.e); return; }
|
||||
// La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come
|
||||
// la gomma vera; solo a un urto nuovo, e solo se picchia.
|
||||
@@ -208,24 +430,23 @@ function collideSeg(c, ball, w) {
|
||||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
|
||||
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
|
||||
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
|
||||
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 });
|
||||
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y);
|
||||
} else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; }
|
||||
}
|
||||
function collideTarget(c, ball, t) {
|
||||
const hit = hitSeg(ball, t, 0.5);
|
||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||||
t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 });
|
||||
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
|
||||
const bank = c.targets.filter(k => k.bank === t.bank);
|
||||
if (bank.every(k => k.down)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; }
|
||||
if (bank.every(k => k.down)) { c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
|
||||
}
|
||||
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
|
||||
function hitCircle(ball, o, e) {
|
||||
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
|
||||
if (d >= min) { o.touching = false; return null; }
|
||||
if (d >= min) { contact(ball, o, false); return null; }
|
||||
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
|
||||
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching;
|
||||
o.touching = true;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
|
||||
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||||
return { vn: -vn, nx, ny, fresh };
|
||||
}
|
||||
@@ -234,12 +455,12 @@ function collideBumper(c, ball, b) {
|
||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
|
||||
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
|
||||
b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 });
|
||||
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
|
||||
}
|
||||
function collideRegolo(c, ball) {
|
||||
const hit = hitCircle(ball, c.regolo, 0.7);
|
||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||||
c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 });
|
||||
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
|
||||
}
|
||||
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
|
||||
function collideFlipper(c, ball, side) {
|
||||
@@ -262,7 +483,7 @@ export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFini
|
||||
export function press(c, pointerId = 'ptr') {
|
||||
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; }
|
||||
return hold(c, (c.cursor?.[0] ?? 320) < 320 ? 'left' : 'right', pointerId, true);
|
||||
return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true);
|
||||
}
|
||||
export function release(c, pointerId = 'ptr') {
|
||||
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
|
||||
@@ -298,8 +519,9 @@ export const shoot = c => canThrow(c) && launch(c, c.power);
|
||||
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
|
||||
export function status(c) {
|
||||
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
||||
const goal = c.result ? 'traguardo fatto, ora il record' : `traguardo ${c.skill.goal}`;
|
||||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`;
|
||||
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
|
||||
const feature = c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
|
||||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
|
||||
if (c.line) return `${c.line} ${tally}`;
|
||||
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
|
||||
}
|
||||
|
||||
@@ -166,6 +166,12 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
|
||||
}
|
||||
|
||||
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
|
||||
// Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto.
|
||||
if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') {
|
||||
drawCloseup(ctx, game, time);
|
||||
if (debug) drawDebug(ctx, game, debug);
|
||||
return;
|
||||
}
|
||||
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
|
||||
if (!bg && backdrop) loadBackground(backdrop);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
@@ -1057,8 +1063,8 @@ function drawCloseup(ctx, game, time) {
|
||||
return;
|
||||
}
|
||||
if (def.type === 'flipper') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawFlipper(ctx, c, def, backgrounds.get(def.picture));
|
||||
for (const src of [def.picture, def.backglass]) if (src && !backgrounds.has(src)) loadBackground(src);
|
||||
drawFlipper(ctx, c, def, backgrounds.get(def.picture), def.backglass && backgrounds.get(def.backglass));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'bruciate') {
|
||||
|
||||
@@ -169,6 +169,13 @@ export class Soundscape {
|
||||
else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); }
|
||||
else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); }
|
||||
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
|
||||
else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan);
|
||||
else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); }
|
||||
else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); }
|
||||
else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); }
|
||||
else if (kind === 'flipper-super-open') { this.noiseSound(0.45, 1600, 0.04, { pan }); this.tone(180, 1200, 0.65, 0.06, 0, pan); for (const [i, f] of [659, 880, 1318].entries()) this.tone(f, f, 0.18, 0.05, 0.5 + i * 0.12, pan); }
|
||||
else if (kind === 'flipper-super') { for (const [i, f] of [392, 523, 659, 784, 1046, 1318, 1568, 2093].entries()) this.tone(f, f * 1.005, 0.24, 0.055, i * 0.08, pan); for (const [i, f] of [523, 784, 1046].entries()) this.tone(f, f, 0.9, 0.045, 0.65 + i * 0.04, pan); }
|
||||
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
|
||||
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
|
||||
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
|
||||
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
|
||||
|
||||
+44
-13
@@ -3,6 +3,7 @@ import { story } from './story/story.js';
|
||||
import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
|
||||
import { Command } from './engine/command.js';
|
||||
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { cabinetAway } from './engine/flipper-render.js';
|
||||
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
|
||||
import { Music, trackFor } from './engine/music.js';
|
||||
import { Voices } from './engine/voices.js';
|
||||
@@ -331,6 +332,7 @@ $('spots-button').addEventListener('click', showSpots);
|
||||
|
||||
// --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»).
|
||||
function openPause() {
|
||||
dropPointers();
|
||||
if (!running) return;
|
||||
running = false;
|
||||
game.closeupCancel();
|
||||
@@ -397,6 +399,18 @@ addEventListener('pagehide', () => leave(true));
|
||||
addEventListener('pageshow', () => leave(document.hidden));
|
||||
$('resume').addEventListener('click', closePause);
|
||||
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
||||
// Schermo intero dalla Pausa, dove il browser lo permette (su iPhone no: il pulsante sparisce); Esc o il pulsante per uscire.
|
||||
{
|
||||
const full = $('fullscreen');
|
||||
if (!document.fullscreenEnabled) full.hidden = true;
|
||||
else {
|
||||
full.addEventListener('click', () => {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement ? 'Esci da schermo intero' : 'Schermo intero'; });
|
||||
}
|
||||
}
|
||||
$('to-title').addEventListener('click', () => {
|
||||
$('pause').hidden = true;
|
||||
music.hold = false;
|
||||
@@ -493,8 +507,18 @@ const TOUCH_REACH = 22;
|
||||
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
|
||||
let press = null;
|
||||
// Le dita in più sul flipper (oltre a `press`): ognuna lascia il suo flipper quando si alza. Il tasto destro del
|
||||
// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte.
|
||||
// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte ('mouse-right', sempre il
|
||||
// flipper di destra), e i due tasti si allineano alla maschera `e.buttons` a ogni evento (syncMouse).
|
||||
const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId;
|
||||
function syncMouse(e) {
|
||||
const left = !!(e.buttons & 1), right = !!(e.buttons & 2);
|
||||
if (right && !extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); }
|
||||
if (!right && extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); }
|
||||
if (left && !press) { game.closeupAim(scenePoint(e)); press = { closeup: true, pointerId: e.pointerId, state: game.closeup }; if (!game.skip()) game.closeupPress(e.pointerId); }
|
||||
if (!left && press?.closeup && press.pointerId === e.pointerId) { const state = press.state; press = null; if (game.closeup === state) game.closeupRelease(e.pointerId); }
|
||||
}
|
||||
// Pausa, finestra che perde il fuoco: niente resta premuto, né nel motore né qui.
|
||||
function dropPointers() { press = null; extra.clear(); }
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
|
||||
if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return;
|
||||
@@ -523,7 +547,8 @@ canvas.addEventListener('pointerdown', e => {
|
||||
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
||||
return;
|
||||
}
|
||||
// Il flipper: un secondo dito (o il secondo pulsante del mouse) comanda l'altro flipper, per conto suo.
|
||||
// Il flipper: un secondo dito comanda l'altro flipper, per conto suo; il tasto destro del mouse quello di destra.
|
||||
if (type === 'flipper' && e.pointerType === 'mouse' && e.button === 2) { syncMouse(e); canvas.setPointerCapture(e.pointerId); return; }
|
||||
if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) {
|
||||
game.closeupAim(scenePoint(e));
|
||||
if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); }
|
||||
@@ -546,8 +571,8 @@ canvas.addEventListener('pointerdown', e => {
|
||||
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
const key = e.button === 2 ? 'mouse-right' : e.pointerId;
|
||||
if (extra.has(key)) { extra.delete(key); game?.closeupRelease(key); return; }
|
||||
if (e.pointerType === 'mouse' && game?.closeup && story.closeups[game.closeup.id].type === 'flipper' && (extra.has('mouse-right') || press?.closeup)) { syncMouse(e); return; }
|
||||
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupRelease(e.pointerId); return; }
|
||||
if (!press) return;
|
||||
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
|
||||
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
|
||||
@@ -585,13 +610,14 @@ canvas.addEventListener('pointerup', e => {
|
||||
press = null;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', e => {
|
||||
if (extra.has('mouse-right') && e.pointerType === 'mouse') { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); }
|
||||
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; }
|
||||
if (press?.closeup && e.pointerId !== press.pointerId) return;
|
||||
if (press) clearTimeout(press.timer);
|
||||
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
|
||||
press = null;
|
||||
});
|
||||
canvas.addEventListener('lostpointercapture', e => { if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
|
||||
canvas.addEventListener('lostpointercapture', e => { if (extra.has('mouse-right') && e.pointerType === 'mouse' && !(e.buttons & 2)) { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||
@@ -599,13 +625,9 @@ canvas.addEventListener('contextmenu', e => {
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!running) return;
|
||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||
// Il tasto destro premuto o lasciato mentre il sinistro è giù arriva come pointermove (pulsanti «in accordo»):
|
||||
// al flipper vale come un secondo dito, per il flipper di destra.
|
||||
if (e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') {
|
||||
if (e.buttons & 2) { if (!extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); } }
|
||||
else if (extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); }
|
||||
return;
|
||||
}
|
||||
// Un tasto del mouse premuto o lasciato mentre l'altro è giù arriva come pointermove (pulsanti «in accordo»):
|
||||
// al flipper si legge la maschera dei pulsanti e si allineano i due comandi.
|
||||
if (e.pointerType === 'mouse' && e.button >= 0 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { syncMouse(e); return; }
|
||||
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
|
||||
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
|
||||
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||
@@ -727,7 +749,7 @@ for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
|
||||
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
|
||||
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
|
||||
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
|
||||
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
|
||||
addEventListener('blur', () => { dropPointers(); game?.closeupCancel(); });
|
||||
|
||||
// --- Verbi.
|
||||
for (const verb of VERBS) {
|
||||
@@ -1180,6 +1202,15 @@ function frame(now) {
|
||||
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
|
||||
if (game && (!still || still !== drawnStill)) {
|
||||
if (running) game.update(dt);
|
||||
// Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art.
|
||||
const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper';
|
||||
const sceneScale = pinballHD ? 2 : 1;
|
||||
if (canvas.width !== WIDTH * sceneScale) {
|
||||
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
|
||||
canvas.classList.toggle('pinball-hd', !!pinballHD);
|
||||
if (!pinballHD) cabinetAway(canvas);
|
||||
}
|
||||
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
|
||||
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
|
||||
// Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto.
|
||||
if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; }
|
||||
|
||||
@@ -23,6 +23,9 @@ export const piloti = [
|
||||
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
|
||||
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
|
||||
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
|
||||
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
|
||||
// Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore]
|
||||
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
|
||||
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
|
||||
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
|
||||
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
|
||||
|
||||
+6
-4
@@ -365,6 +365,7 @@ export const story = {
|
||||
flipper: {
|
||||
type: 'flipper',
|
||||
picture: 'assets/closeups/river-flipper.png',
|
||||
backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi)
|
||||
names: { player: 'Andrea', cpu: 'il flipper' },
|
||||
win: [{ say: 'Traguardo! Se c\'era la classifica, oggi ci mettevo le mie iniziali.' }, { set: 'flipper_vinto' }],
|
||||
lose: [{ say: 'Tre palline, e tutte nella Sieve. Il flipper del River non perdona.' }],
|
||||
@@ -1675,7 +1676,8 @@ export const story = {
|
||||
entry: [140, 340],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up',
|
||||
// Un clic sul River entra e va al flipper (Andrea: «Usa» o «Apri» non si capiva).
|
||||
id: 'river', name: 'il River: dentro c\'è il flipper', box: [60, 128, 190, 110], at: [160, 320], face: 'up', default: 'open',
|
||||
actions: {
|
||||
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
|
||||
// Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo.
|
||||
@@ -4458,9 +4460,9 @@ export const story = {
|
||||
nodes: {
|
||||
start: {
|
||||
options: [
|
||||
{ text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Seimila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
|
||||
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Diecimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Sedicimila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
|
||||
{ text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Sessantamila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
|
||||
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Centoventimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Duecentomila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
|
||||
{ text: 'Un\'altra volta.', next: 'end' },
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.66';
|
||||
export const VERSION = '0.1.69';
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -20,10 +20,18 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
||||
button, input { font-family: var(--font); }
|
||||
body { line-height: 1.5; }
|
||||
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
||||
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
||||
/* Il gioco è largo quanto può: tutta la finestra fino a 1280px, oppure quanto ci sta in altezza con il pannello sotto la
|
||||
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
|
||||
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 12,8px: tutto il pannello cresce e cala col gioco, non con
|
||||
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
|
||||
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
|
||||
#app { --u: clamp(8.75px, 1cqw, 12.8px); --side-h: 13.3; container-type: inline-size;
|
||||
width: min(100vw, 1280px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
|
||||
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
||||
|
||||
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||
#scene.pinball-hd { image-rendering: auto; }
|
||||
#scene.torch { touch-action: none; }
|
||||
#scene.bocce { touch-action: none; }
|
||||
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
|
||||
@@ -79,7 +87,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
|
||||
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
|
||||
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
|
||||
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
|
||||
font-size: calc(1.4625 * var(--u)); font-weight: bold; line-height: 1.2; text-align: center;
|
||||
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
||||
#speech:empty { display: none; }
|
||||
#speech { z-index: 3; }
|
||||
@@ -88,7 +96,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
#muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0;
|
||||
transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
|
||||
/* Il primo piano del libro si chiude da qui (o con Esc). */
|
||||
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
|
||||
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold calc(1.17 * var(--u)) var(--font);
|
||||
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
|
||||
/* Al biliardo audio, pausa e uscita hanno spazio proprio: nessun comando copre biglie o punteggio. */
|
||||
#stage.biliardo { padding-top: 44px; aspect-ratio: auto; background: #17110c; }
|
||||
@@ -103,24 +111,27 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
background: rgba(27, 20, 32, .62); border-radius: 18px; }
|
||||
#mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
|
||||
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
|
||||
#volume, #voice-volume { width: clamp(50px, 9vw, 100px); accent-color: var(--accent); cursor: pointer; }
|
||||
#volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
|
||||
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
|
||||
|
||||
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; background: var(--panel); }
|
||||
/* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
|
||||
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--panel); }
|
||||
/* Verbi e inventario: alti sempre uguale (10,8 --u, dove stanno anche quattro risposte di dialogo: vedi #choices). */
|
||||
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: calc(.625 * var(--u)); padding: calc(.625 * var(--u)); height: calc(10.8 * var(--u)); background: var(--panel); }
|
||||
/* Otto verbi su due righe piene, quattro per riga. */
|
||||
#verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; align-content: start; }
|
||||
#verbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: calc(.3125 * var(--u)); align-content: stretch; }
|
||||
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
|
||||
padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; }
|
||||
padding: .35em .2em; font-size: calc(1.069 * var(--u)); font-weight: bold; cursor: pointer; }
|
||||
#verbs button:hover, #inventory button:hover { color: var(--ink); }
|
||||
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
|
||||
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
|
||||
#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: calc(.3125 * var(--u)); align-content: start;
|
||||
min-height: 0; padding: calc(.3125 * var(--u)); background: #231a29; border: 2px solid #4b3a55; border-radius: 4px;
|
||||
/* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */
|
||||
contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; }
|
||||
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
|
||||
font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; }
|
||||
font-size: calc(1.069 * var(--u)); font-weight: bold; letter-spacing: .08em; }
|
||||
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
|
||||
#inventory button { min-width: 0; overflow: hidden; }
|
||||
#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; }
|
||||
@@ -129,8 +140,14 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||
|
||||
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
||||
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
|
||||
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||
/* Le risposte: alte quanto il pannello dei verbi, così la scena non si muove quando parte un dialogo. Quattro risposte ci
|
||||
stanno al carattere pieno; da cinque in su il carattere cala (e se mai non bastasse, si scorre). */
|
||||
#choices { margin: 0; padding: calc(.78 * var(--u)) calc(1.1 * var(--u)) calc(.94 * var(--u)) 3.6em; background: var(--panel); height: calc(10.8 * var(--u));
|
||||
overflow-y: auto; scrollbar-width: thin; font-size: calc(1.2375 * var(--u)); line-height: 1.4; }
|
||||
#choices li { padding: .2em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||
#choices:has(li:nth-child(5)) { font-size: calc(.96 * var(--u)); line-height: 1.25; }
|
||||
#choices:has(li:nth-child(5)) li { padding: .15em 0; }
|
||||
#choices:has(li:nth-child(7)) { font-size: calc(.72 * var(--u)); }
|
||||
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||
|
||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||
@@ -222,10 +239,11 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
||||
/* In alto a sinistra: Pausa e Mappa. */
|
||||
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
|
||||
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
|
||||
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
|
||||
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
||||
#back-button { color: #1b1420; background: #9fd0ff; }
|
||||
#pause { z-index: 3; background: rgba(15, 10, 20, .9); }
|
||||
/* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
|
||||
#pause { z-index: 3; background: rgba(15, 10, 20, .9); justify-content: safe center; }
|
||||
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
|
||||
#tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
|
||||
#tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
|
||||
@@ -322,6 +340,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
|
||||
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
|
||||
@media (max-width: 600px) {
|
||||
#sentence { height: auto; min-height: 1.8em; white-space: normal; }
|
||||
#panel, #choices { height: auto; }
|
||||
#choices { min-height: 6.5em; }
|
||||
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
||||
#verbs button { font-size: 9px; padding: .7em .1em; }
|
||||
#inventory { height: 7.6em; }
|
||||
@@ -340,7 +361,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
.card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
|
||||
.card-hand { flex-direction: column; gap: 4px; }
|
||||
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
|
||||
#sentence { font-size: 10.08px; }
|
||||
#sentence { height: auto; min-height: 1.8em; white-space: normal; font-size: 10.08px; }
|
||||
#panel, #choices { height: auto; }
|
||||
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
|
||||
#speech { max-width: 72%; font-size: 11px; }
|
||||
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
||||
@@ -357,6 +379,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
.buttons { gap: 6px; margin-top: .2em; }
|
||||
.buttons button { font-size: 10px; padding: .45em 1em; }
|
||||
.pause-buttons { min-width: 12em; }
|
||||
#pause .hint { display: none; }
|
||||
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
|
||||
#map-board button { width: 68px; }
|
||||
#map-board button img { width: 60px; }
|
||||
|
||||
+387
-330
@@ -1,6 +1,8 @@
|
||||
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
|
||||
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
||||
// npm run browser
|
||||
// npm run browser: le tre parti (--parte=principale, minigiochi, telefoni) girano insieme come processi separati, ognuna
|
||||
// col suo server e il suo Chromium; la prova passa quasi tutto il tempo ad aspettare animazioni in tempo reale, e così le
|
||||
// attese si sovrappongono. Una parte sola: node tests/browser.mjs --parte=minigiochi (--media-only: solo multimedia).
|
||||
import { chromium } from 'playwright';
|
||||
import { staticServer } from './helpers.mjs';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
@@ -12,6 +14,22 @@ import { checkMultimedia } from './multimedia.browser.mjs';
|
||||
|
||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const PARTI = ['principale', 'minigiochi', 'telefoni'];
|
||||
const PARTE = process.argv.find(a => a.startsWith('--parte='))?.slice('--parte='.length);
|
||||
if (PARTE && !PARTI.includes(PARTE)) { console.error(`parte sconosciuta: ${PARTE} (${PARTI.join(', ')})`); process.exit(2); }
|
||||
if (!PARTE && !process.argv.includes('--media-only')) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const t0 = Date.now();
|
||||
const esiti = await Promise.all(PARTI.map(parte => new Promise(resolve => {
|
||||
const p = spawn(process.execPath, [process.argv[1], `--parte=${parte}`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let resto = '';
|
||||
const riga = chunk => { const righe = (resto + chunk).split('\n'); resto = righe.pop(); for (const r of righe) console.log(`[${parte}] ${r}`); };
|
||||
p.stdout.on('data', riga); p.stderr.on('data', riga);
|
||||
p.on('close', code => { if (resto) console.log(`[${parte}] ${resto}`); resolve({ parte, code, s: Math.round((Date.now() - t0) / 1000) }); });
|
||||
})));
|
||||
for (const e of esiti) console.log(`${e.code === 0 ? 'OK ' : 'FAIL'} ${e.parte} (${e.s} s)`);
|
||||
process.exit(esiti.every(e => e.code === 0) ? 0 : 1);
|
||||
}
|
||||
const server = await staticServer(), PORT = server.port;
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
const errors = [];
|
||||
@@ -19,7 +37,8 @@ const errors = [];
|
||||
async function open(viewport, query = '') {
|
||||
const page = await browser.newPage({ viewport, acceptDownloads: true });
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
|
||||
// ERR_NETWORK_CHANGED è il PC che cambia rete a metà prova (Docker che crea o toglie una rete): non è il gioco.
|
||||
page.on('console', m => m.type() === 'error' && !m.text().includes('ERR_NETWORK_CHANGED') && errors.push(m.text()));
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
|
||||
}
|
||||
@@ -49,332 +68,8 @@ const hotspotCenter = async (page, id) => {
|
||||
return c;
|
||||
};
|
||||
|
||||
try {
|
||||
await checkMultimedia(browser, PORT, OUT, errors);
|
||||
if (!process.argv.includes('--media-only')) {
|
||||
const page = await open({ width: 1280, height: 860 });
|
||||
// Il cartello d'apertura, poi da solo il titolo.
|
||||
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
|
||||
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
|
||||
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
|
||||
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
|
||||
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
|
||||
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
|
||||
await page.waitForTimeout(2800);
|
||||
await page.screenshot({ path: `${OUT}/00-cartello.png` });
|
||||
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
|
||||
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
||||
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
|
||||
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
|
||||
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
|
||||
{
|
||||
const muto = await chromium.launch(), p = await muto.newPage();
|
||||
await p.goto(`http://127.0.0.1:${PORT}/`);
|
||||
await p.waitForTimeout(800);
|
||||
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
|
||||
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
|
||||
await p.waitForTimeout(5500);
|
||||
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
|
||||
await p.click('#splash');
|
||||
assert.ok(await p.isHidden('#splash .splash-hint'));
|
||||
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
|
||||
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
|
||||
await muto.close();
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||||
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
|
||||
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
|
||||
{
|
||||
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
|
||||
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
|
||||
await slow.addInitScript(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.getElementById('title'), splash = document.getElementById('splash');
|
||||
if (title && !title.hidden && splash) {
|
||||
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
|
||||
});
|
||||
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
|
||||
await slow.goto(`http://127.0.0.1:${PORT}/`);
|
||||
await slow.waitForSelector('#splash');
|
||||
await slow.waitForTimeout(300);
|
||||
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
|
||||
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
|
||||
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
||||
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
||||
await slow.close();
|
||||
}
|
||||
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
||||
await page.click('#title .open-credits');
|
||||
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
||||
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
|
||||
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
||||
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
|
||||
await page.click('#credits .credit-face >> text=Mariano');
|
||||
assert.ok(await page.isVisible('#face img'));
|
||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
||||
await page.click('#face');
|
||||
assert.ok(await page.isHidden('#face'));
|
||||
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
|
||||
// foto, un tocco su una foto la mostra grande col nome del posto.
|
||||
{
|
||||
const track = page.locator('#credits-reel .track');
|
||||
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
|
||||
await page.mouse.move(5, 5);
|
||||
const x0 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(600);
|
||||
const x1 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
|
||||
await track.hover({ position: { x: 20, y: 20 } });
|
||||
const x2 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
|
||||
await page.click('#credits-reel .arrow.next');
|
||||
await page.waitForTimeout(800);
|
||||
const x3 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
|
||||
const third = track.locator('img').nth(2);
|
||||
const name = await third.getAttribute('alt');
|
||||
await third.click();
|
||||
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
|
||||
assert.equal(await page.textContent('#face figcaption'), name);
|
||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
|
||||
assert.ok(await page.isVisible('#credits'));
|
||||
}
|
||||
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
||||
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
||||
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
|
||||
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
|
||||
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
|
||||
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
|
||||
await page.click('#new-game');
|
||||
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
|
||||
await page.waitForSelector('#rione:not([hidden])');
|
||||
assert.equal(await page.locator('#rione-buttons button').count(), 4);
|
||||
await page.screenshot({ path: `${OUT}/01a-rione.png` });
|
||||
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
|
||||
await page.click('#rione [data-rione="campomigliaio"]');
|
||||
// L'introduzione: una riga per clic, poi comincia la partita.
|
||||
await page.waitForSelector('#intro:not([hidden])');
|
||||
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
|
||||
await page.screenshot({ path: `${OUT}/01b-intro.png` });
|
||||
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
||||
await page.waitForFunction(() => game.speech);
|
||||
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
|
||||
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
|
||||
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
|
||||
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
|
||||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||||
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
|
||||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||
await settle(page);
|
||||
|
||||
// Doppio clic su un punto della scena: Andrea ci va di corsa.
|
||||
const r = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
|
||||
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
|
||||
await settle(page);
|
||||
// «?» mostra per qualche secondo cosa si può toccare.
|
||||
await page.click('#spots-button');
|
||||
await page.screenshot({ path: `${OUT}/02c-punti.png` });
|
||||
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
|
||||
await page.evaluate(() => {
|
||||
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
|
||||
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
|
||||
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
|
||||
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
|
||||
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
|
||||
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
|
||||
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('#pause:not([hidden])');
|
||||
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
|
||||
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
|
||||
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
|
||||
const savedFile = `${OUT}/partita.json`;
|
||||
await download.saveAs(savedFile);
|
||||
const before = await page.evaluate(() => game.snapshot());
|
||||
await page.click('#to-title');
|
||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
|
||||
await page.waitForSelector('#load-error:not([hidden])');
|
||||
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
|
||||
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
|
||||
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
|
||||
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
|
||||
lost.state.scene = 'luna';
|
||||
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
|
||||
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
|
||||
assert.ok(await page.isVisible('#title'));
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
|
||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||
await page.setInputFiles('#load-file', savedFile);
|
||||
await page.waitForSelector('#title', { state: 'hidden' });
|
||||
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
|
||||
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('#pause:not([hidden])');
|
||||
await page.click('#resume');
|
||||
assert.ok(await page.isHidden('#pause'));
|
||||
await page.click('#pause-button');
|
||||
await page.click('#to-title');
|
||||
await page.waitForSelector('#continue:not([hidden])');
|
||||
await page.click('#continue');
|
||||
await settle(page);
|
||||
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
||||
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
|
||||
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
|
||||
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
|
||||
await page.click('#mute');
|
||||
await page.locator('#volume').fill('30');
|
||||
await page.locator('#voice-volume').fill('70');
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
|
||||
assert.equal(await page.evaluate(() => voices.volume), 0.7);
|
||||
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
|
||||
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
|
||||
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
|
||||
await hide(true);
|
||||
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
|
||||
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
|
||||
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
|
||||
await hide(false);
|
||||
await page.click('#resume');
|
||||
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
|
||||
|
||||
for (const [verb, target, item] of solution) {
|
||||
if (verb === 'choose') {
|
||||
await page.locator('#choices li', { hasText: target }).click();
|
||||
} else {
|
||||
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||||
if (item) {
|
||||
await page.click(`#verbs [data-verb="${verb}"]`);
|
||||
await page.click(`#inventory [data-item="${item}"]`);
|
||||
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
||||
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
|
||||
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
|
||||
else await clickScene(page, await hotspotCenter(page, target));
|
||||
}
|
||||
await page.waitForTimeout(80);
|
||||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||||
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
|
||||
if (target === 'fessura' && item) {
|
||||
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
|
||||
const box = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
|
||||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||||
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(30);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
|
||||
}
|
||||
await page.click('#closeup-close');
|
||||
}
|
||||
await settle(page);
|
||||
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
||||
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
|
||||
}
|
||||
await page.waitForSelector('#ending:not([hidden])');
|
||||
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||||
await page.click('#ending .open-credits');
|
||||
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
|
||||
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
|
||||
await page.click('#close-credits');
|
||||
assert.ok(await page.isHidden('#credits'));
|
||||
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
||||
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
||||
|
||||
// Una partita a metà riprende da dove era rimasta.
|
||||
await page.click('#restart');
|
||||
await settle(page);
|
||||
for (const [, exit] of route('piazza', 'trebbio')) {
|
||||
await clickScene(page, await hotspotCenter(page, exit));
|
||||
await settle(page);
|
||||
}
|
||||
await page.click('#verbs [data-verb="talk"]');
|
||||
await clickScene(page, await hotspotCenter(page, 'torre'));
|
||||
await page.locator('#choices li', { hasText: 'bloccato' }).click();
|
||||
await page.waitForFunction(() => game.speech?.who === 'castello');
|
||||
await page.waitForTimeout(100);
|
||||
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
|
||||
await settle(page);
|
||||
await page.locator('#choices li', { hasText: 'pace' }).click();
|
||||
await settle(page);
|
||||
await page.reload();
|
||||
await page.waitForSelector('#continue:not([hidden])');
|
||||
await page.click('#continue');
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
||||
await settle(page);
|
||||
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
|
||||
await page.keyboard.press('n');
|
||||
assert.ok(await page.isVisible('#notebook'));
|
||||
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
|
||||
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#notebook'));
|
||||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||||
await page.click('#map-button');
|
||||
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
||||
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
||||
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
||||
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
|
||||
await page.fill('#map-search', 'xyz');
|
||||
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
||||
await page.fill('#map-search', '');
|
||||
await page.click('#map-search');
|
||||
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
|
||||
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
||||
assert.ok(await page.isHidden('#map-none'));
|
||||
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
||||
assert.deepEqual(shown, ['piazza']);
|
||||
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
|
||||
await page.keyboard.press('Enter');
|
||||
assert.ok(await page.isHidden('#map'));
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||||
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
||||
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
||||
await page.setViewportSize({ width: 560, height: 340 });
|
||||
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
||||
await page.click('#map-button');
|
||||
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
||||
await page.fill('#map-search', 'campo');
|
||||
const found = await page.evaluate(() => {
|
||||
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
||||
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
||||
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
||||
});
|
||||
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
||||
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
||||
await page.click('#close-map');
|
||||
await page.setViewportSize({ width: 1280, height: 860 });
|
||||
|
||||
// Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima).
|
||||
async function minigiochi() {
|
||||
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
|
||||
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
|
||||
{
|
||||
@@ -551,10 +246,36 @@ try {
|
||||
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
|
||||
await b.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function telefoni() {
|
||||
// La finestra di Andrea (1040×590: Firefox con le sue barre su un portatile): la scena era larga 604 px con 100 px
|
||||
// vuoti sotto, ora prende l'altezza; i verbi stanno nei pulsanti; un dialogo a quattro risposte e uno a cinque
|
||||
// non muovono la scena e stanno nel pannello.
|
||||
{
|
||||
const win = await open({ width: 1040, height: 590 });
|
||||
await win.click('#splash'); await win.click('#new-game'); await win.click('#rione [data-rione="campomigliaio"]'); await win.click('#skip-intro');
|
||||
await settle(win);
|
||||
const box = async () => { const r = await win.locator('#stage').boundingBox(); return [r.x, r.y, r.width, r.height].map(Math.round); };
|
||||
const b0 = await box();
|
||||
assert.ok(b0[2] >= 820 && b0[1] >= 0, `a 1040×590 la scena è larga ${b0[2]} px e comincia a ${b0[1]}`);
|
||||
assert.ok(await win.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre a 1040×590');
|
||||
assert.ok(await win.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1040×590');
|
||||
for (const id of ['giocatore_carte', 'arcu']) {
|
||||
await win.evaluate(id => { game.dialogue = { id, node: 'start' }; }, id);
|
||||
await win.waitForFunction(() => !document.getElementById('choices').hidden);
|
||||
const n = await win.evaluate(() => game.options().length);
|
||||
assert.deepEqual(await box(), b0, `la scena si muove col dialogo ${id}`);
|
||||
assert.ok(await win.evaluate(() => { const c = document.getElementById('choices'); return c.scrollHeight <= c.clientHeight + 1; }), `le ${n} risposte di ${id} non stanno nel pannello`);
|
||||
if (id === 'arcu') await win.screenshot({ path: `${OUT}/11-finestra-1040-dialogo.png` });
|
||||
await win.evaluate(() => { game.dialogue = null; });
|
||||
}
|
||||
await win.screenshot({ path: `${OUT}/11-finestra-1040.png` });
|
||||
await win.close();
|
||||
}
|
||||
const phone = await open({ width: 390, height: 844 });
|
||||
await phone.click('#splash'); // un clic salta il cartello
|
||||
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
|
||||
assert.ok(await phone.waitForSelector('#title:not([hidden])', { timeout: 5000 }).then(() => true, () => false), 'il clic sul cartello porta al titolo');
|
||||
await phone.click('#new-game');
|
||||
await phone.click('#rione [data-rione="campomigliaio"]');
|
||||
await phone.click('#skip-intro');
|
||||
@@ -619,9 +340,345 @@ try {
|
||||
await dbg.waitForTimeout(200);
|
||||
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
|
||||
await dbg.screenshot({ path: `${OUT}/10-debug.png` });
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.argv.includes('--media-only')) await checkMultimedia(browser, PORT, OUT, errors);
|
||||
else if (PARTE === 'minigiochi') { await minigiochi(); console.log('Browser OK: libro, bocce, tiri, pesca, biliardo.'); }
|
||||
else if (PARTE === 'telefoni') { await checkMultimedia(browser, PORT, OUT, errors); await telefoni(); console.log('Browser OK: telefoni e debug.'); }
|
||||
else {
|
||||
const page = await open({ width: 1280, height: 860 });
|
||||
// Il cartello d'apertura, poi da solo il titolo.
|
||||
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
|
||||
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
|
||||
// Il gioco parte con un import() dinamico, che l'evento load della pagina non aspetta.
|
||||
await page.waitForFunction(() => window.jingle, null, { timeout: 10000 });
|
||||
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3\?v=\d+\.\d+\.\d+$/, 'la musichetta del cartello');
|
||||
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
|
||||
await page.waitForTimeout(2800);
|
||||
await page.screenshot({ path: `${OUT}/00-cartello.png` });
|
||||
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
|
||||
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
||||
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
|
||||
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
|
||||
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
|
||||
{
|
||||
const muto = await chromium.launch(), p = await muto.newPage();
|
||||
await p.goto(`http://127.0.0.1:${PORT}/`);
|
||||
await p.waitForTimeout(800);
|
||||
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
|
||||
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
|
||||
await p.waitForTimeout(5500);
|
||||
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
|
||||
await p.click('#splash');
|
||||
assert.ok(await p.isHidden('#splash .splash-hint'));
|
||||
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||||
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
|
||||
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
|
||||
await muto.close();
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||||
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
|
||||
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
|
||||
{
|
||||
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
|
||||
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
|
||||
await slow.addInitScript(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.getElementById('title'), splash = document.getElementById('splash');
|
||||
if (title && !title.hidden && splash) {
|
||||
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
|
||||
});
|
||||
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
|
||||
await slow.goto(`http://127.0.0.1:${PORT}/`);
|
||||
await slow.waitForSelector('#splash');
|
||||
await slow.waitForTimeout(300);
|
||||
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
|
||||
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
|
||||
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
||||
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
||||
await slow.close();
|
||||
}
|
||||
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
||||
await page.click('#title .open-credits');
|
||||
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
||||
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
|
||||
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
||||
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
|
||||
await page.click('#credits .credit-face >> text=Mariano');
|
||||
assert.ok(await page.isVisible('#face img'));
|
||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
||||
await page.click('#face');
|
||||
assert.ok(await page.isHidden('#face'));
|
||||
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
|
||||
// foto, un tocco su una foto la mostra grande col nome del posto.
|
||||
{
|
||||
const track = page.locator('#credits-reel .track');
|
||||
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
|
||||
await page.mouse.move(5, 5);
|
||||
const x0 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(600);
|
||||
const x1 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
|
||||
await track.hover({ position: { x: 20, y: 20 } });
|
||||
const x2 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
|
||||
await page.click('#credits-reel .arrow.next');
|
||||
await page.waitForTimeout(800);
|
||||
const x3 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
|
||||
const third = track.locator('img').nth(2);
|
||||
const name = await third.getAttribute('alt');
|
||||
await third.click();
|
||||
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
|
||||
assert.equal(await page.textContent('#face figcaption'), name);
|
||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
|
||||
assert.ok(await page.isVisible('#credits'));
|
||||
}
|
||||
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
||||
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
||||
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
|
||||
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
|
||||
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
|
||||
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
|
||||
await page.click('#new-game');
|
||||
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
|
||||
await page.waitForSelector('#rione:not([hidden])');
|
||||
assert.equal(await page.locator('#rione-buttons button').count(), 4);
|
||||
await page.screenshot({ path: `${OUT}/01a-rione.png` });
|
||||
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
|
||||
await page.click('#rione [data-rione="campomigliaio"]');
|
||||
// L'introduzione: una riga per clic, poi comincia la partita.
|
||||
await page.waitForSelector('#intro:not([hidden])');
|
||||
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
|
||||
await page.screenshot({ path: `${OUT}/01b-intro.png` });
|
||||
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||||
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
||||
await page.waitForFunction(() => game.speech);
|
||||
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
|
||||
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
|
||||
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3\?v=\d+\.\d+\.\d+$/);
|
||||
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
|
||||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||||
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
|
||||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||
await settle(page);
|
||||
|
||||
// Doppio clic su un punto della scena: Andrea ci va di corsa.
|
||||
const r = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
|
||||
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
|
||||
await settle(page);
|
||||
// «?» mostra per qualche secondo cosa si può toccare.
|
||||
await page.click('#spots-button');
|
||||
await page.screenshot({ path: `${OUT}/02c-punti.png` });
|
||||
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
|
||||
await page.evaluate(() => {
|
||||
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
|
||||
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
|
||||
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
|
||||
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
|
||||
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
|
||||
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
|
||||
// Il gioco prende la finestra: a 1280×860 la scena non si ferma più ai 1084 px di una riserva fissa di 250 px sotto,
|
||||
// la pagina non scorre e i verbi stanno interi nei pulsanti (la finestra di Andrea, 1040×590, è provata più sotto).
|
||||
const stage0 = await page.locator('#stage').boundingBox();
|
||||
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
|
||||
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
|
||||
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('#pause:not([hidden])');
|
||||
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
|
||||
assert.equal(await page.evaluate(() => document.getElementById('fullscreen').hidden), await page.evaluate(() => !document.fullscreenEnabled), 'il pulsante Schermo intero c\'è se il browser lo permette');
|
||||
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
|
||||
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
|
||||
const savedFile = `${OUT}/partita.json`;
|
||||
await download.saveAs(savedFile);
|
||||
const before = await page.evaluate(() => game.snapshot());
|
||||
await page.click('#to-title');
|
||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
|
||||
await page.waitForSelector('#load-error:not([hidden])');
|
||||
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
|
||||
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
|
||||
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
|
||||
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
|
||||
lost.state.scene = 'luna';
|
||||
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
|
||||
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
|
||||
assert.ok(await page.isVisible('#title'));
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
|
||||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||||
await page.setInputFiles('#load-file', savedFile);
|
||||
await page.waitForSelector('#title', { state: 'hidden' });
|
||||
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
|
||||
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForSelector('#pause:not([hidden])');
|
||||
await page.click('#resume');
|
||||
assert.ok(await page.isHidden('#pause'));
|
||||
await page.click('#pause-button');
|
||||
await page.click('#to-title');
|
||||
await page.waitForSelector('#continue:not([hidden])');
|
||||
await page.click('#continue');
|
||||
await settle(page);
|
||||
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
||||
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
|
||||
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
|
||||
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
|
||||
await page.click('#mute');
|
||||
await page.locator('#volume').fill('30');
|
||||
await page.locator('#voice-volume').fill('70');
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
|
||||
assert.equal(await page.evaluate(() => voices.volume), 0.7);
|
||||
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
|
||||
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
|
||||
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
|
||||
await hide(true);
|
||||
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
|
||||
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
|
||||
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
|
||||
await hide(false);
|
||||
await page.click('#resume');
|
||||
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
|
||||
|
||||
for (const [verb, target, item] of solution) {
|
||||
if (verb === 'choose') {
|
||||
await page.locator('#choices li', { hasText: target }).click();
|
||||
} else {
|
||||
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||||
if (item) {
|
||||
await page.click(`#verbs [data-verb="${verb}"]`);
|
||||
await page.click(`#inventory [data-item="${item}"]`);
|
||||
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
||||
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
|
||||
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
|
||||
else await clickScene(page, await hotspotCenter(page, target));
|
||||
}
|
||||
await page.waitForTimeout(80);
|
||||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||||
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
|
||||
if (target === 'fessura' && item) {
|
||||
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
|
||||
const box = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
|
||||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||||
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
|
||||
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
|
||||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(30);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
|
||||
}
|
||||
await page.click('#closeup-close');
|
||||
}
|
||||
await settle(page);
|
||||
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
||||
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
|
||||
}
|
||||
await page.waitForSelector('#ending:not([hidden])');
|
||||
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||||
await page.click('#ending .open-credits');
|
||||
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
|
||||
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
|
||||
await page.click('#close-credits');
|
||||
assert.ok(await page.isHidden('#credits'));
|
||||
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
||||
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
||||
|
||||
// Una partita a metà riprende da dove era rimasta.
|
||||
await page.click('#restart');
|
||||
await settle(page);
|
||||
for (const [, exit] of route('piazza', 'trebbio')) {
|
||||
await clickScene(page, await hotspotCenter(page, exit));
|
||||
await settle(page);
|
||||
}
|
||||
await page.click('#verbs [data-verb="talk"]');
|
||||
await clickScene(page, await hotspotCenter(page, 'torre'));
|
||||
await page.locator('#choices li', { hasText: 'bloccato' }).click();
|
||||
await page.waitForFunction(() => game.speech?.who === 'castello');
|
||||
await page.waitForTimeout(100);
|
||||
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
|
||||
await settle(page);
|
||||
await page.locator('#choices li', { hasText: 'pace' }).click();
|
||||
await settle(page);
|
||||
await page.reload();
|
||||
await page.waitForSelector('#continue:not([hidden])');
|
||||
await page.click('#continue');
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
||||
await settle(page);
|
||||
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
|
||||
await page.keyboard.press('n');
|
||||
assert.ok(await page.isVisible('#notebook'));
|
||||
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
|
||||
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#notebook'));
|
||||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||||
await page.click('#map-button');
|
||||
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
||||
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
||||
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
||||
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
|
||||
await page.fill('#map-search', 'xyz');
|
||||
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
||||
await page.fill('#map-search', '');
|
||||
await page.click('#map-search');
|
||||
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
|
||||
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
||||
assert.ok(await page.isHidden('#map-none'));
|
||||
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
||||
assert.deepEqual(shown, ['piazza']);
|
||||
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
|
||||
await page.keyboard.press('Enter');
|
||||
assert.ok(await page.isHidden('#map'));
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||||
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
||||
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
||||
await page.setViewportSize({ width: 560, height: 340 });
|
||||
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
||||
await page.click('#map-button');
|
||||
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
||||
await page.fill('#map-search', 'campo');
|
||||
const found = await page.evaluate(() => {
|
||||
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
||||
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
||||
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
||||
});
|
||||
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
||||
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
||||
await page.click('#close-map');
|
||||
await page.setViewportSize({ width: 1280, height: 860 });
|
||||
|
||||
assert.deepEqual(errors, [], 'errori nella console');
|
||||
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
|
||||
console.log(`Browser OK: soluzione completa, ripresa del salvataggio. Schermate in ${OUT}`);
|
||||
}
|
||||
assert.deepEqual(errors, [], 'errori nella console');
|
||||
} finally {
|
||||
|
||||
@@ -26,12 +26,17 @@ try {
|
||||
});
|
||||
await page.waitForSelector('#bocce-controls:not([hidden])');
|
||||
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
|
||||
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
|
||||
const readyAt = await page.evaluate(() => game.closeup.visual.time);
|
||||
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
|
||||
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
|
||||
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
|
||||
assert.ok(await page.locator('#flipper-help').isVisible());
|
||||
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
|
||||
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
|
||||
await page.screenshot({ path: `${out}/${name}-tavola.png` });
|
||||
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
|
||||
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
|
||||
const cdp = mobile ? await page.context().newCDPSession(page) : null;
|
||||
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
|
||||
// La molla: tenendo premuto sulla scena, col mouse e col dito.
|
||||
@@ -79,13 +84,82 @@ try {
|
||||
await page.click('#resume');
|
||||
await page.waitForFunction(() => document.querySelector('#pause').hidden);
|
||||
}
|
||||
// La fine: si fa finire la partita e si guarda il quadro.
|
||||
await page.evaluate(() => { const c = game.closeup; c.score = 7350; c.step = 'result'; c.ball = null; c.result = { winner: 'player', score: 7350, goal: c.skill.goal }; });
|
||||
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
||||
const reward = await page.evaluate(async () => {
|
||||
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
||||
m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8);
|
||||
c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0;
|
||||
const b = c.bumpers[0]; b.touching = false;
|
||||
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null });
|
||||
m.update(c, 0.05);
|
||||
return { balls: c.balls, step: c.step, winner: c.result?.winner };
|
||||
});
|
||||
assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' });
|
||||
await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta'));
|
||||
await page.screenshot({ path: `${out}/${name}-premio.png` });
|
||||
// Una sequenza reale di rampe, con il motore: primo lock, secondo lock, rampa centrale e tre palline.
|
||||
await page.evaluate(async () => {
|
||||
const m = await import('/src/engine/flipper.js');
|
||||
Object.assign(game.closeup, m.newFlipper(73, { level: 'bona' }));
|
||||
window.pinballShot = id => {
|
||||
const c = game.closeup, r = c.ramps.find(r => r.id === id);
|
||||
if (m.waiting(c)) m.launch(c, 0.8);
|
||||
Object.assign(c.ball, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null });
|
||||
let entered = false;
|
||||
for (let i = 0; i < 1000; i++) { m.update(c, m.STEP); if (c.ball?.ramp) entered = true; else if (entered) return; }
|
||||
throw new Error(`rampa ${id} non completata`);
|
||||
};
|
||||
for (let i = 0; i < 3; i++) window.pinballShot('sinistra');
|
||||
});
|
||||
assert.equal(await page.evaluate(() => game.closeup.locks.length), 1);
|
||||
await page.screenshot({ path: `${out}/${name}-lock.png` });
|
||||
await page.evaluate(async () => {
|
||||
for (let i = 0; i < 3; i++) window.pinballShot('destra');
|
||||
const m = await import('/src/engine/flipper.js');
|
||||
for (let i = 0; i < 4; i++) m.update(game.closeup, 0.2);
|
||||
});
|
||||
assert.equal(await page.evaluate(() => game.closeup.special), 'super');
|
||||
await page.screenshot({ path: `${out}/${name}-super.png` });
|
||||
const multi = await page.evaluate(async () => {
|
||||
window.pinballShot('super');
|
||||
const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25);
|
||||
// Ferma solo la simulazione per fotografare le tre palline, prima che una possa scendere.
|
||||
window.pinballResume = game.update.bind(game); game.update = dt => { game.closeup.visual.time += dt; };
|
||||
return { balls: m.activeBalls(game.closeup).length, locks: game.closeup.locks.length, reserve: game.closeup.balls, time: game.closeup.visual.time };
|
||||
});
|
||||
assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { 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}-multiball.png` });
|
||||
// L'arretrato grafico (vedi la memoria «effetti leggeri»): 150 fotogrammi disegnati di fila con tre palline e gli
|
||||
// effetti vivi, poi una lettura dei pixel che aspetta la grafica. Il flipper di Codex ne lasciava 4,2 s; deve
|
||||
// restare sotto il secondo e mezzo anche col rendering software di queste prove.
|
||||
const backlog = await page.evaluate(async () => {
|
||||
const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup;
|
||||
const ctx = document.querySelector('#scene').getContext('2d');
|
||||
ctx.getImageData(0, 0, 4, 4); // si aspetta prima l'arretrato di tutta la prova fin qui
|
||||
for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); }
|
||||
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); return Math.round(performance.now() - t);
|
||||
});
|
||||
console.log(`${name}: arretrato grafico dopo 150 fotogrammi ${backlog} ms`);
|
||||
assert.ok(backlog < 1500, `arretrato grafico ${backlog} ms`);
|
||||
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('), null, { timeout: 2000 }); // il mobile sullo sfondo CSS
|
||||
await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; });
|
||||
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
|
||||
const finalScore = await page.evaluate(async () => {
|
||||
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
||||
for (let i = 0; i < 12 && c.step === 'play'; i++) {
|
||||
if (m.waiting(c)) m.launch(c, 0.8);
|
||||
c.saveUntil = 0;
|
||||
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); m.update(c, 0.1);
|
||||
}
|
||||
return c.score;
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
|
||||
assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/);
|
||||
assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`));
|
||||
await page.screenshot({ path: `${out}/${name}-fine.png` });
|
||||
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
|
||||
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
|
||||
await page.waitForFunction(() => document.querySelector('#scene').width === 640 && !document.querySelector('#scene').style.backgroundImage);
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
+285
-11
@@ -5,9 +5,21 @@ import { drawFlipper } from '../src/engine/flipper-render.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); };
|
||||
const put = (c, ball) => Object.assign(c.ball, ball);
|
||||
// Osserva il percorso fino all'uscita, senza includere il tiro successivo sulla tavola.
|
||||
function ride(c, initial) {
|
||||
put(c, initial);
|
||||
let frames = 0, top = Infinity;
|
||||
for (let i = 0; i < 4 / m.STEP; i++) {
|
||||
m.update(c, m.STEP);
|
||||
if (c.ball?.ramp) { frames++; top = Math.min(top, c.ball.y); }
|
||||
else if (frames) return { frames, top, exit: { ...c.ball } };
|
||||
}
|
||||
assert.fail('la pallina non ha completato o abbandonato la rampa entro quattro secondi');
|
||||
}
|
||||
// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
|
||||
function autoplay(c, secs = 600) {
|
||||
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) {
|
||||
@@ -55,11 +67,11 @@ test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una v
|
||||
put(c, { x: 320, y: 320, vx: 0, vy: 200 }); run(c, 1);
|
||||
}
|
||||
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c));
|
||||
assert.match(m.status(c), /Ne servivano 10000/);
|
||||
assert.match(m.status(c), /Ne servivano 120000/);
|
||||
const d = m.newFlipper(5, { level: 'bona' });
|
||||
m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
|
||||
assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla');
|
||||
assert.match(d.line ?? '', /salvata/);
|
||||
assert.match(d.line ?? '', /SALVATA/);
|
||||
m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
|
||||
assert.equal(d.balls, 2, 'la seconda volta è persa davvero');
|
||||
// Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex).
|
||||
@@ -104,7 +116,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
|
||||
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => t.down), 'la fila resta giù un attimo');
|
||||
put(c, { x: 260, y: 230, vx: 0, vy: 0 }); run(c, 1);
|
||||
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'poi si rialza');
|
||||
assert.match(c.line ?? '', /PIEVE/);
|
||||
assert.ok([c.line, ...c.lines].some(l => /PIEVE/.test(l ?? '')), 'la scritta della Pieve (in coda dietro al Regolo)');
|
||||
// Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva).
|
||||
c.score = 0;
|
||||
for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) {
|
||||
@@ -112,7 +124,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
|
||||
assert.ok(c.lanes[i].lit || c.lanes.every(k => !k.lit), c.lanes[i].rione);
|
||||
}
|
||||
assert.ok(c.score >= 4 * m.SCORE.lane + m.SCORE.palio, `corsie e Palio (${c.score}; in più, al massimo, un fungo sfiorato)`);
|
||||
assert.match(c.line, /PALIO/); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro');
|
||||
assert.ok([c.line, ...c.lines].some(l => /PALIO/.test(l ?? ''))); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro');
|
||||
// In salita non conta.
|
||||
put(c, { x: 287, y: c.lanes[1].y + 6, vx: 0, vy: -150 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
|
||||
assert.ok(!c.lanes[1].lit);
|
||||
@@ -139,7 +151,7 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d
|
||||
// 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 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: 10000 }; 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));
|
||||
});
|
||||
|
||||
test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => {
|
||||
@@ -149,7 +161,7 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
|
||||
assert.match(m.status(c), /record/);
|
||||
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);
|
||||
g.closeup.score = 7000; g.closeup.result = { winner: 'player', score: 7000, goal: 6000 };
|
||||
g.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 };
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto');
|
||||
// La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore.
|
||||
@@ -158,6 +170,240 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
|
||||
assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`);
|
||||
});
|
||||
|
||||
test('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => {
|
||||
for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) {
|
||||
const c = m.newFlipper(85, { level }); m.launch(c, 0.8);
|
||||
c.balls = 1; c.score = c.skill.goal - missing;
|
||||
const currentBall = c.ball, b = c.bumpers[0];
|
||||
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
|
||||
assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`);
|
||||
assert.equal(c.ball, currentBall, 'il tiro in corso prosegue');
|
||||
assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player');
|
||||
assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1');
|
||||
assert.match(m.status(c), /pallina premio vinta/);
|
||||
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
|
||||
assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi');
|
||||
c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1);
|
||||
assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio');
|
||||
m.launch(c, 0.8); c.saveUntil = 0;
|
||||
put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1);
|
||||
assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
|
||||
}
|
||||
});
|
||||
|
||||
test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => {
|
||||
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
|
||||
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
|
||||
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola');
|
||||
assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`);
|
||||
assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0);
|
||||
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
|
||||
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati).
|
||||
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 273, y: 240, vx: 0, vy: -470 }); run(f, 0.3);
|
||||
assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1);
|
||||
assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`);
|
||||
// Il secondo ponte subito dopo: i PONTI, col bonus.
|
||||
ride(c, { x: 367, y: 240, vx: 0, vy: -470, ramp: null });
|
||||
assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`);
|
||||
assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? '')));
|
||||
assert.equal(c.lastRamp, null, 'la combo è spesa');
|
||||
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti.
|
||||
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
|
||||
put(d, { x: 273, y: 240, vx: 0, vy: -280 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
|
||||
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 226, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
|
||||
// Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no.
|
||||
const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0;
|
||||
put(e, { x: 273, y: 226.1, vx: 500, vy: -30 }); run(e, 0.5);
|
||||
assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
|
||||
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina.
|
||||
const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 });
|
||||
run(g, 13); assert.equal(g.lastRamp, null, 'scaduta');
|
||||
g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 342, vx: 0, vy: 300, waiting: false }); run(g, 1);
|
||||
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
|
||||
});
|
||||
|
||||
test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => {
|
||||
for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) {
|
||||
const c = m.newFlipper(81, { level }); m.launch(c, 0.8);
|
||||
ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 });
|
||||
assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`);
|
||||
assert.ok(c.ramps[index].lit > 0);
|
||||
for (const offset of [-12, 12]) {
|
||||
const d = m.newFlipper(82, { level }); m.launch(d, 0.8);
|
||||
const r = d.ramps[index];
|
||||
ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 });
|
||||
assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`);
|
||||
}
|
||||
const e = m.newFlipper(83, { level }); m.launch(e, 0.8);
|
||||
const r = e.ramps[index];
|
||||
put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05);
|
||||
assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata');
|
||||
}
|
||||
});
|
||||
|
||||
test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => {
|
||||
for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) {
|
||||
const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0;
|
||||
put(c, { x, y: 250, vx: 0, vy: 100 });
|
||||
let lower = null;
|
||||
for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) {
|
||||
m.update(c, m.STEP);
|
||||
if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x };
|
||||
}
|
||||
assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`);
|
||||
if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`);
|
||||
else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); }
|
||||
}
|
||||
});
|
||||
|
||||
test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => {
|
||||
const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9);
|
||||
const kinds = () => c.fx.map(f => f.kind);
|
||||
const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
|
||||
assert.ok(kinds().includes('ring') && kinds().includes('pop') && kinds().includes('spark'), `fungo: ${kinds()}`);
|
||||
assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000');
|
||||
c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
|
||||
assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`);
|
||||
c.fx.length = 0; ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
|
||||
assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`);
|
||||
assert.ok(c.trail.length > 5, 'la cometa');
|
||||
const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball;
|
||||
assert.equal(c.fx.length, 0, 'spariti');
|
||||
// Disegnarli non cambia niente.
|
||||
const calls = [], gradient = { addColorStop() {} };
|
||||
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
|
||||
put(c, { x: 273, y: 240, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
|
||||
const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before);
|
||||
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
|
||||
});
|
||||
|
||||
function rampShot(c, id) {
|
||||
if (m.waiting(c)) m.launch(c, 0.8);
|
||||
const r = c.ramps.find(r => r.id === id);
|
||||
return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false });
|
||||
}
|
||||
function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); }
|
||||
|
||||
test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => {
|
||||
for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) {
|
||||
const c = m.newFlipper(21, { level }); m.launch(c, 0.9);
|
||||
const superRamp = c.ramps.find(r => r.id === 'super');
|
||||
assert.ok(!m.rampOpen(c, superRamp));
|
||||
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1);
|
||||
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2);
|
||||
rampShot(c, ids[0]);
|
||||
assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]);
|
||||
assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3);
|
||||
const lockedBall = c.locks[0].ball;
|
||||
rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock');
|
||||
const score = c.score; rampShot(c, ids[1]);
|
||||
assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock');
|
||||
assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1);
|
||||
const afterSpecial = c.score; rampShot(c, ids[1]);
|
||||
assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica');
|
||||
rampShot(c, ids[1]);
|
||||
assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp));
|
||||
assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
|
||||
const before = c.score, reserve = c.balls, hadGoal = !!c.result;
|
||||
rampShot(c, 'super');
|
||||
assert.equal(c.score - before, m.SCORE.super);
|
||||
assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato');
|
||||
assert.equal(c.locks.length, 0); assert.equal(c.special, null);
|
||||
assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c));
|
||||
assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte');
|
||||
assert.ok(c.events.some(e => e.kind === 'flipper-super'));
|
||||
const winScore = c.score, lives = c.balls;
|
||||
rampShot(c, ids[0]);
|
||||
assert.ok(c.score - winScore >= m.SCORE.ramp + m.SCORE.jackpot, 'in multiball le rampe pagano jackpot');
|
||||
// Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto.
|
||||
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 });
|
||||
c.saveUntil = 0; m.update(c, m.STEP);
|
||||
assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c));
|
||||
}
|
||||
});
|
||||
|
||||
test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => {
|
||||
const c = m.newFlipper(22); m.launch(c, 0.8);
|
||||
put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02);
|
||||
assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica');
|
||||
rampShot(c, 'sinistra'); rampShot(c, 'sinistra');
|
||||
c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1);
|
||||
assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1);
|
||||
rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1);
|
||||
c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
|
||||
assert.equal(c.rampRun.count, 0);
|
||||
});
|
||||
|
||||
test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => {
|
||||
for (const level of Object.keys(m.LEVELS)) {
|
||||
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
|
||||
const main = c.ball, delayed = c.extraBalls.find(b => b.releaseAt > c.sim);
|
||||
assert.equal(main.x, 229); assert.equal(main.y, 220); assert.equal(main.vx, 0); assert.equal(main.vy, 160);
|
||||
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.ok(delayed, 'una serratura aspetta prima di liberare la terza pallina');
|
||||
const position = [delayed.x, delayed.y]; run(c, 0.2);
|
||||
assert.deepEqual([delayed.x, delayed.y], position, 'durante il ritardo resta nel lock');
|
||||
run(c, 0.3); assert.notDeepEqual([delayed.x, delayed.y], position, 'poi viene liberata');
|
||||
// Isola la pallina della SUPER per verificare che un colpo normale possa recuperarla.
|
||||
c.extraBalls = []; c.ball = main;
|
||||
let recovered = false;
|
||||
for (let i = 0; i < 480; i++) {
|
||||
if (main.x > 284 && main.y > 300) m.hold(c, 'left', 'test', true);
|
||||
m.update(c, m.STEP);
|
||||
if (main.vy < -250) { recovered = true; break; }
|
||||
}
|
||||
assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`);
|
||||
}
|
||||
});
|
||||
|
||||
test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => {
|
||||
const c = m.newFlipper(23, { level: 'male' }); doubleLock(c);
|
||||
const lives = c.balls;
|
||||
for (const remaining of [1, 0]) {
|
||||
m.launch(c, 0.8); c.saveUntil = 0;
|
||||
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
|
||||
assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c));
|
||||
assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null);
|
||||
}
|
||||
assert.equal(c.special, null); assert.equal(c.specialPaid, false);
|
||||
});
|
||||
|
||||
test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => {
|
||||
const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super');
|
||||
const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true);
|
||||
m.update(single, m.STEP); m.update(c, m.STEP);
|
||||
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
|
||||
const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0];
|
||||
spare.waiting = true;
|
||||
Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] });
|
||||
Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] });
|
||||
const score = c.score; m.update(c, m.STEP);
|
||||
assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente');
|
||||
Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 });
|
||||
Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 });
|
||||
m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono');
|
||||
const lives = c.balls;
|
||||
Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||
assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2);
|
||||
});
|
||||
|
||||
test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => {
|
||||
const c = m.newFlipper(25); m.launch(c, 0.8);
|
||||
for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) {
|
||||
const t = c.targets.find(t => t.bank === bank && t.i === i), dir = bank === 'pieve' ? -1 : 1;
|
||||
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, ramp: null }); run(c, 0.02);
|
||||
}
|
||||
assert.equal(c.jackpotX, 3); doubleLock(c);
|
||||
const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3);
|
||||
for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) {
|
||||
const d = m.newFlipper(26); m.launch(d, 0.8);
|
||||
put(d, { x, y, vx: 0, vy: 240, inPlay: true });
|
||||
let enteredShooter = false;
|
||||
for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; }
|
||||
assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
|
||||
let won = 0;
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
@@ -169,21 +415,39 @@ test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita
|
||||
assert.ok(won >= 5, `vinte ${won} su 8`);
|
||||
});
|
||||
|
||||
test('il disegno del flipper non cambia lo stato', () => {
|
||||
test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giusto e le cifre corrono', async () => {
|
||||
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; } });
|
||||
const c = m.newFlipper(11, { level: 'bona' }), bg = {};
|
||||
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); };
|
||||
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER'));
|
||||
assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
|
||||
for (const r of c.ramps.filter(r => r.id !== 'super')) {
|
||||
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
|
||||
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
|
||||
}
|
||||
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso');
|
||||
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
|
||||
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco');
|
||||
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && /^SALVATA \d s$/.test(t)), 'il salvataggio si vede');
|
||||
c.step = 'result'; c.result = { winner: 'player', score: 7000, goal: 6000 }; calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio');
|
||||
c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
|
||||
// Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).
|
||||
const l = m.newFlipper(12, { level: 'bona' }); l.step = 'result'; l.ball = null; l.result = { winner: 'cpu', score: 0, goal: 60000 }; calls.length = 0;
|
||||
drawFlipper(ctx, l, story.closeups.flipper, bg);
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FINE PARTITA'));
|
||||
// Le cifre che corrono: in mezzo secondo da dove erano a dove devono arrivare, a scatti di dieci.
|
||||
const { shownScore } = await import('../src/engine/flipper-render.js');
|
||||
const d = { from: 1000, to: 51000, t0: 10 };
|
||||
assert.equal(shownScore(d, 10), 1000); assert.equal(shownScore(d, 10.25) % 10, 0); assert.ok(shownScore(d, 10.25) > 20000 && shownScore(d, 10.25) < 30000); assert.equal(shownScore(d, 10.5), 51000);
|
||||
});
|
||||
|
||||
test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
|
||||
test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
|
||||
const g = new Game(story); g.go('via_provinciale'); settle(g);
|
||||
assert.equal(g.hotspot('river').default, 'open');
|
||||
const [bx, by, bw, bh] = g.hotspot('river').box;
|
||||
assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'river', 'il River si clicca');
|
||||
g.act('use', 'river'); settle(g);
|
||||
assert.ok(g.options().some(o => o.text === 'Una partita seria.'));
|
||||
g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g);
|
||||
@@ -195,3 +459,13 @@ test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza
|
||||
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
|
||||
assert.equal(g.closeup?.level, 'male');
|
||||
});
|
||||
|
||||
test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita sinistra (Andrea: «a sinistra esce troppo facile»)', () => {
|
||||
const c = m.newFlipper(31); m.launch(c, 0.8); c.saveUntil = 0;
|
||||
// Una pallina che scivola lungo la parete sinistra, sopra la Pieve: viene rimandata in campo, non nel canale.
|
||||
put(c, { x: 205, y: 120, vx: 0, vy: 150, ramp: null });
|
||||
let inChannel = false;
|
||||
for (let i = 0; i < 120; i++) { m.update(c, 1 / 60); if (c.ball.x < 210 && c.ball.y > 146 && c.ball.y < 198) inChannel = true; }
|
||||
assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli');
|
||||
assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function checkMultimedia(browser, port, out, errors) {
|
||||
else await page.mouse.move(...await at(target));
|
||||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||||
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
|
||||
for (let n = 0; n < 200; n++) {
|
||||
for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più
|
||||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(25);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Le prove nel browser a sezioni (scripts/verify.mjs): ogni file del gioco finisce negli ingressi delle prove giuste,
|
||||
// e l'impronta cambia solo se cambia un ingresso.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { SUITES, ENGINE, inputsOf, ignored, fingerprint, plan } from '../scripts/verify.mjs';
|
||||
|
||||
const FILES = ['index.html', 'style.css', 'src/main.js', 'src/version.js', 'package.json', 'package-lock.json', 'Makefile', 'README.md',
|
||||
'src/engine/game.js', 'src/engine/render.js', 'src/engine/ambient.js', 'src/engine/bocce.js', 'src/engine/nuovo.js',
|
||||
'src/engine/briscola.js', 'src/engine/briscola-render.js', 'src/engine/flipper.js', 'src/engine/corsa.js', 'src/story/piloti.js',
|
||||
'src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'assets/closeups/micro-parcheggio.png',
|
||||
'assets/closeups/gp/boccino.png', 'assets/muro/index.html', 'assets/video/briscola.mp4', 'docs/BRISCOLA.md', 'docs/STATO.md',
|
||||
'tests/helpers.mjs', 'tests/browser.mjs', 'tests/briscola.browser.mjs', 'tests/briscola.test.mjs', 'tests/story.test.mjs',
|
||||
'scripts/verify.mjs', 'scripts/bump.sh', 'tools/slice_sprites.py', '.verify-ok.json'];
|
||||
const suitesWith = path => Object.keys(SUITES).filter(n => inputsOf(n, FILES).includes(path));
|
||||
const islands = Object.keys(SUITES).filter(n => !SUITES[n].core), cores = Object.keys(SUITES).filter(n => SUITES[n].core);
|
||||
|
||||
test('ogni prova nel browser di tests/ ha la sua voce, e una sola', () => {
|
||||
const runs = Object.values(SUITES).map(s => s.run[0]);
|
||||
for (const f of readdirSync('tests').filter(f => f.endsWith('.browser.mjs'))) {
|
||||
if (f === 'multimedia.browser.mjs') continue; // la chiama la parte «telefoni» di browser.mjs
|
||||
const n = runs.filter(r => r === `tests/${f}`).length;
|
||||
assert.equal(n, f === 'browser.mjs' ? 3 : 1, `tests/${f} è in ${n} prove`);
|
||||
}
|
||||
});
|
||||
|
||||
test('la briscola si riprova solo se cambia la briscola o il motore', () => {
|
||||
assert.deepEqual(suitesWith('src/engine/briscola.js'), ['briscola']);
|
||||
assert.deepEqual(suitesWith('src/engine/briscola-render.js'), ['briscola']);
|
||||
assert.deepEqual(suitesWith('assets/video/briscola.mp4'), ['briscola']);
|
||||
assert.deepEqual(suitesWith('tests/briscola.browser.mjs'), ['briscola']);
|
||||
assert.deepEqual(suitesWith('docs/BRISCOLA.md'), [], 'i documenti non fanno rifare niente');
|
||||
assert.ok(!inputsOf('briscola', FILES).includes('src/story/story.js'), 'la storia non fa riprovare la briscola');
|
||||
assert.deepEqual(suitesWith('src/engine/corsa.js'), ['gara']);
|
||||
assert.deepEqual(suitesWith('src/story/piloti.js'), ['gara']);
|
||||
assert.deepEqual(suitesWith('assets/closeups/gp/boccino.png'), ['gara']);
|
||||
assert.deepEqual(suitesWith('assets/muro/index.html'), ['telefono']);
|
||||
assert.deepEqual(suitesWith('assets/closeups/micro-parcheggio.png'), ['micro']);
|
||||
});
|
||||
|
||||
test('la storia, le scene, i minigiochi dentro la partita e un modulo nuovo fanno rifare la partita intera, non i minigiochi a sé', () => {
|
||||
assert.deepEqual(suitesWith('tests/browser.mjs'), ['principale', 'minigiochi', 'telefoni'], 'il file di una prova conta solo per lei');
|
||||
for (const path of ['src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'src/engine/bocce.js', 'src/engine/ambient.js', 'src/engine/nuovo.js']) {
|
||||
assert.deepEqual(suitesWith(path), cores, path);
|
||||
}
|
||||
});
|
||||
|
||||
test('il motore comune fa rifare tutto', () => {
|
||||
for (const path of ENGINE) assert.deepEqual(suitesWith(path), Object.keys(SUITES), path);
|
||||
assert.ok(islands.length >= 10 && cores.length >= 3);
|
||||
});
|
||||
|
||||
test('versione, package.json, Makefile, documenti, strumenti e test unitari non fanno rifare niente', () => {
|
||||
for (const path of ['src/version.js', 'package.json', 'Makefile', 'README.md', 'docs/STATO.md', 'scripts/bump.sh', 'scripts/verify.mjs', 'tools/slice_sprites.py', 'tests/briscola.test.mjs', 'tests/story.test.mjs', '.verify-ok.json']) {
|
||||
assert.ok(ignored(path), `${path} dovrebbe essere ignorato`);
|
||||
assert.deepEqual(suitesWith(path), [], path);
|
||||
}
|
||||
});
|
||||
|
||||
test("l'impronta cambia solo se cambia un ingresso, e il piano dice quali", () => {
|
||||
const hashes = new Map(FILES.map(p => [p, 'h-' + p]));
|
||||
const before = plan(hashes, {});
|
||||
assert.ok(before.every(p => !p.green), 'mai provate: tutte da rifare');
|
||||
const ok = Object.fromEntries(before.map(p => [p.name, { fingerprint: p.fp, hashes: Object.fromEntries(p.inputs.map(f => [f, hashes.get(f)])) }]));
|
||||
assert.ok(plan(hashes, ok).every(p => p.green), 'niente cambiato: tutte verdi');
|
||||
hashes.set('docs/STATO.md', 'altro');
|
||||
assert.ok(plan(hashes, ok).every(p => p.green), 'un documento non cambia niente');
|
||||
hashes.set('src/engine/briscola.js', 'altro');
|
||||
const after = plan(hashes, ok);
|
||||
assert.deepEqual(after.filter(p => !p.green).map(p => p.name), ['briscola']);
|
||||
assert.deepEqual(after.find(p => p.name === 'briscola').changed, ['src/engine/briscola.js']);
|
||||
assert.notEqual(fingerprint(['a'], () => '1'), fingerprint(['a'], () => '2'));
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
# Configurazione di act_runner (vedi docker-compose.yml accanto).
|
||||
log:
|
||||
level: info
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 2
|
||||
timeout: 30m
|
||||
container:
|
||||
network: ""
|
||||
privileged: false
|
||||
options: "--memory=4g"
|
||||
valid_volumes: []
|
||||
force_pull: false
|
||||
@@ -0,0 +1,19 @@
|
||||
# Runner delle Gitea Actions di git.manzolo.it, sul PC di Andrea (dal 2026-11-01). Si crea e si avvia con
|
||||
# `make runner` (scripts/runner.sh): registra il runner sul repo se manca e lo lascia acceso (riparte con Docker).
|
||||
# I job girano in container fratelli tramite il socket Docker del PC; le credenziali del runner stanno fuori dal repo.
|
||||
name: gitea-runner
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:0.6.1
|
||||
container_name: gitea-runner
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_INSTANCE_URL: https://git.manzolo.it
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
|
||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-pc-andrea}
|
||||
GITEA_RUNNER_LABELS: ubuntu-latest:docker://node:22-bookworm
|
||||
volumes:
|
||||
- ./config.yaml:/config.yaml:ro
|
||||
- ${RUNNER_DATA:?}:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
Reference in New Issue
Block a user