Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a9db6d109 | ||
|
|
797eb80980 | ||
|
|
00e9f7c4d7 | ||
|
|
77a8777dcd | ||
|
|
b660bbeb96 | ||
|
|
bbce40b014 | ||
|
|
0ec45c8745 | ||
|
|
dfa70a9620 | ||
|
|
3079c6ea8a | ||
|
|
4d4aa8aeea | ||
|
|
721b6de930 | ||
|
|
7d419be27a | ||
|
|
07b6a381c1 | ||
|
|
45b8c3d98e | ||
|
|
0a8b0310d3 | ||
|
|
a50847da2b | ||
|
|
1fc446d02a | ||
|
|
7076123fcf | ||
|
|
449c9511e1 | ||
|
|
0ec49c4fe7 | ||
|
|
b41cadd95b | ||
|
|
0bd3e2255f | ||
|
|
dc17a7f963 | ||
|
|
995ad09cca | ||
|
|
b9e9976d34 | ||
|
|
d37dafd58c | ||
|
|
4d1611c6eb | ||
|
|
7268585ed9 | ||
|
|
1b929f1333 | ||
|
|
3ba108cf88 | ||
|
|
70f4e641dc | ||
|
|
23aa9c4788 | ||
|
|
04f9a5cb12 |
@@ -22,3 +22,6 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p
|
|||||||
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
|
- Prompt delle immagini in `prompts/` e fogli grezzi in `assets/raw/`: esclusi da git.
|
||||||
- Sfondi nuovi: li genera **Codex** con il suo strumento immagini integrato, gratis e senza chiave API
|
- 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.
|
(`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.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ CROP ?=
|
|||||||
COLORS ?= 64
|
COLORS ?= 64
|
||||||
|
|
||||||
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
.PHONY: help serve open test browser verify check-public bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \
|
||||||
build up down restart logs ps shell image-size deploy deploy-vps deploy-logs clean distclean
|
build up down restart logs ps shell image-size deploy deploy-logs clean distclean
|
||||||
|
|
||||||
## ------------------------------------------------------------ sviluppo
|
## ------------------------------------------------------------ sviluppo
|
||||||
|
|
||||||
@@ -121,47 +121,50 @@ image-size: ## Dimensione dell'immagine
|
|||||||
|
|
||||||
## ------------------------------------------------------------ deploy su un server proprio
|
## ------------------------------------------------------------ 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_HOST = root@mio-server
|
||||||
# DEPLOY_URL = https://gioco.esempio
|
# DEPLOY_URL = https://gioco.esempio
|
||||||
# VPS_HOST = mio-vps (facoltativo: una seconda copia, con make deploy-vps)
|
# DEPLOY_DIR = docker/leggenda-regolo (la cartella del compose sul server)
|
||||||
# VPS_DIR = docker/leggenda-regolo
|
# DEPLOY_IMAGE = registry.esempio/utente/leggenda-regolo
|
||||||
-include deploy.local.mk
|
-include deploy.local.mk
|
||||||
DEPLOY_DIR ?= /root/leggenda-regolo
|
DEPLOY_DIR ?= /root/leggenda-regolo
|
||||||
DEPLOY_TMP := $(shell mktemp -d -u /tmp/regolo-deploy.XXXXXX)
|
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
|
# Si pubblica il tag della versione dichiarata dall'ultimo commit (git archive vX.Y.Z, quello di make bump), mai la
|
||||||
# metà. Test su quella copia, immagine costruita e provata qui, poi spedita già pronta (docker save | docker load):
|
# cartella di lavoro: un'altra sessione può avere modifiche a metà, e i commit dopo il tag aspettano la versione dopo.
|
||||||
# sul server non si compila niente.
|
# 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
|
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; }
|
@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
|
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 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 \
|
@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; \
|
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; }
|
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)'
|
docker push -q $(DEPLOY_IMAGE):$(DEPLOY_VER)
|
||||||
scp -q $(DEPLOY_TMP)/docker-compose.homeserver.yml $(DEPLOY_HOST):$(DEPLOY_DIR)/docker-compose.yml
|
docker push -q $(DEPLOY_IMAGE):latest
|
||||||
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'
|
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)
|
rm -rf $(DEPLOY_TMP)
|
||||||
@# Sul server il prune toglie l'immagine vecchia; qui restano solo latest e quella appena pubblicata.
|
@# Qui restano solo latest e la versione appena pubblicata.
|
||||||
@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $$(git rev-parse --short HEAD) | \
|
@docker image ls $(DEPLOY_IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $(DEPLOY_VER) | \
|
||||||
xargs -r -I{} docker image rm -f $(IMAGE):{} >/dev/null
|
xargs -r -I{} docker image rm -f $(DEPLOY_IMAGE):{} >/dev/null
|
||||||
@echo "→ $(DEPLOY_URL) ($$(git log -1 --format='%h %s'))"
|
@# L'indirizzo pubblico deve servire la versione appena spedita.
|
||||||
|
@v=$$(git show $(DEPLOY_VER):src/version.js | grep -o "VERSION = '[^']*'"); \
|
||||||
deploy-vps: ## Spedisce al VPS (VPS_HOST, VPS_DIR in deploy.local.mk) l'immagine dell'ultimo commit già provata da make deploy
|
for i in 1 2 3 4 5; do curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" && break; sleep 2; done; \
|
||||||
@test -n "$(VPS_HOST)" -a -n "$(VPS_DIR)" || { echo "mancano VPS_HOST e VPS_DIR: scrivili in deploy.local.mk"; exit 2; }
|
curl -s $(DEPLOY_URL)/src/version.js | grep -qF "$$v" || { echo "$(DEPLOY_URL) non serve $$v"; exit 1; }
|
||||||
@docker image inspect $(IMAGE):$$(git rev-parse --short HEAD) >/dev/null 2>&1 || \
|
@echo "→ $(DEPLOY_URL) ($(DEPLOY_IMAGE):$(DEPLOY_VER), $$(git log -1 --format='%h %s' $(DEPLOY_VER)))"
|
||||||
{ echo "manca l'immagine dell'ultimo commit ($$(git rev-parse --short HEAD)): prima make deploy"; exit 1; }
|
|
||||||
ssh $(VPS_HOST) 'mkdir -p $(VPS_DIR)'
|
|
||||||
git show HEAD:docker-compose.homeserver.yml | ssh $(VPS_HOST) 'cat > $(VPS_DIR)/docker-compose.yml'
|
|
||||||
docker save $(IMAGE):$$(git rev-parse --short HEAD) $(IMAGE):latest | gzip | \
|
|
||||||
ssh $(VPS_HOST) 'gunzip | docker load -q && cd $(VPS_DIR) && docker compose up -d && docker image prune -f >/dev/null'
|
|
||||||
@ssh $(VPS_HOST) "docker exec $(IMAGE) grep -o \"VERSION = '[^']*'\" /usr/share/nginx/html/src/version.js" | sed 's/^/→ $(VPS_HOST): /'
|
|
||||||
|
|
||||||
deploy-logs: ## Log del container sul server di deploy
|
deploy-logs: ## Log del container sul server di deploy
|
||||||
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
@test -n "$(DEPLOY_HOST)" || { echo "manca DEPLOY_HOST: scrivilo in deploy.local.mk"; exit 2; }
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ Si gioca online su **https://50037.manzolo.it** (50037 è il CAP di San Piero),
|
|||||||
|
|
||||||
## Giocare in locale
|
## 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
|
```zsh
|
||||||
python3 -m http.server 8000 # poi http://localhost:8000
|
python3 -m http.server 8000 # poi http://localhost:8000
|
||||||
@@ -49,7 +51,7 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con
|
|||||||
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
|
make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione
|
||||||
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
make voci # le voci delle battute nuove (XTTS-v2 su GPU)
|
||||||
make bump # nuova versione: verifica, commit e tag
|
make bump # nuova versione: verifica, commit e tag
|
||||||
make deploy # pubblica 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
|
## Chi ha fatto cosa
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "andrea-birra.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"walk_1": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 68,
|
||||||
|
"h": 114,
|
||||||
|
"ax": 40.2,
|
||||||
|
"ay": 114
|
||||||
|
},
|
||||||
|
"walk_2": {
|
||||||
|
"x": 70,
|
||||||
|
"y": 0,
|
||||||
|
"w": 41,
|
||||||
|
"h": 114,
|
||||||
|
"ax": 20.7,
|
||||||
|
"ay": 114
|
||||||
|
},
|
||||||
|
"walk_3": {
|
||||||
|
"x": 113,
|
||||||
|
"y": 0,
|
||||||
|
"w": 67,
|
||||||
|
"h": 114,
|
||||||
|
"ax": 38.4,
|
||||||
|
"ay": 114
|
||||||
|
},
|
||||||
|
"walk_4": {
|
||||||
|
"x": 182,
|
||||||
|
"y": 0,
|
||||||
|
"w": 46,
|
||||||
|
"h": 114,
|
||||||
|
"ax": 25.3,
|
||||||
|
"ay": 114
|
||||||
|
},
|
||||||
|
"cheer_1": {
|
||||||
|
"x": 230,
|
||||||
|
"y": 0,
|
||||||
|
"w": 51,
|
||||||
|
"h": 133,
|
||||||
|
"ax": 21.1,
|
||||||
|
"ay": 133
|
||||||
|
},
|
||||||
|
"cheer_2": {
|
||||||
|
"x": 283,
|
||||||
|
"y": 0,
|
||||||
|
"w": 55,
|
||||||
|
"h": 130,
|
||||||
|
"ax": 24.5,
|
||||||
|
"ay": 130
|
||||||
|
},
|
||||||
|
"cheer_3": {
|
||||||
|
"x": 340,
|
||||||
|
"y": 0,
|
||||||
|
"w": 62,
|
||||||
|
"h": 130,
|
||||||
|
"ax": 23.8,
|
||||||
|
"ay": 130
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 404,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 21.7,
|
||||||
|
"ay": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 286 KiB |
@@ -691,9 +691,13 @@
|
|||||||
"file": "andrea-b830f3f8ca.mp3",
|
"file": "andrea-b830f3f8ca.mp3",
|
||||||
"sec": 6.87
|
"sec": 6.87
|
||||||
},
|
},
|
||||||
"andrea|Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.": {
|
"andrea|Un ghiacciolo ci starebbe. Ma prima... il flipper.": {
|
||||||
"file": "andrea-2669d0fea1.mp3",
|
"file": "andrea-737d8c7f64.mp3",
|
||||||
"sec": 5.23
|
"sec": 4.56
|
||||||
|
},
|
||||||
|
"andrea|Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.": {
|
||||||
|
"file": "andrea-61a0d27bbb.mp3",
|
||||||
|
"sec": 6.67
|
||||||
},
|
},
|
||||||
"andrea|La farmacia, con la croce verde. Mia nonna ci veniva per le pasticche e per le chiacchiere. Più per le chiacchiere.": {
|
"andrea|La farmacia, con la croce verde. Mia nonna ci veniva per le pasticche e per le chiacchiere. Più per le chiacchiere.": {
|
||||||
"file": "andrea-1447e3515d.mp3",
|
"file": "andrea-1447e3515d.mp3",
|
||||||
@@ -1667,6 +1671,22 @@
|
|||||||
"file": "zio_mario-b3e99c241a.mp3",
|
"file": "zio_mario-b3e99c241a.mp3",
|
||||||
"sec": 2.68
|
"sec": 2.68
|
||||||
},
|
},
|
||||||
|
"andrea|E con la Ghiacciomobile nel muro, come si fa?": {
|
||||||
|
"file": "andrea-e964cb256d.mp3",
|
||||||
|
"sec": 2.69
|
||||||
|
},
|
||||||
|
"zio_mario|Siamo a cavallo al ciuco!": {
|
||||||
|
"file": "zio_mario-e74a044fb6.mp3",
|
||||||
|
"sec": 1.22
|
||||||
|
},
|
||||||
|
"andrea|Menomale tu ci sei te, Zio!": {
|
||||||
|
"file": "andrea-eea2bd2097.mp3",
|
||||||
|
"sec": 2.29
|
||||||
|
},
|
||||||
|
"zio_mario|Alla terza tu capisci subito, al volo!": {
|
||||||
|
"file": "zio_mario-4317f4b5f3.mp3",
|
||||||
|
"sec": 2.03
|
||||||
|
},
|
||||||
"andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": {
|
"andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": {
|
||||||
"file": "andrea-8670e6ce99.mp3",
|
"file": "andrea-8670e6ce99.mp3",
|
||||||
"sec": 7.6
|
"sec": 7.6
|
||||||
@@ -1915,6 +1935,22 @@
|
|||||||
"file": "zio_mario-5773e3350f.mp3",
|
"file": "zio_mario-5773e3350f.mp3",
|
||||||
"sec": 1.13
|
"sec": 1.13
|
||||||
},
|
},
|
||||||
|
"andrea|Sai, Zio? Son stato in California!": {
|
||||||
|
"file": "andrea-d8f8c38415.mp3",
|
||||||
|
"sec": 3.33
|
||||||
|
},
|
||||||
|
"zio_mario|E dov'è?": {
|
||||||
|
"file": "zio_mario-1cb1e2ca51.mp3",
|
||||||
|
"sec": 0.43
|
||||||
|
},
|
||||||
|
"andrea|In America!": {
|
||||||
|
"file": "andrea-8c8a45db81.mp3",
|
||||||
|
"sec": 0.85
|
||||||
|
},
|
||||||
|
"zio_mario|Ahhh... sopra Firenzola!": {
|
||||||
|
"file": "zio_mario-67279d2518.mp3",
|
||||||
|
"sec": 2.73
|
||||||
|
},
|
||||||
"andrea|Le case coi balconi pieni di vasi: qui ogni finestra è un giardino.": {
|
"andrea|Le case coi balconi pieni di vasi: qui ogni finestra è un giardino.": {
|
||||||
"file": "andrea-ec81875c47.mp3",
|
"file": "andrea-ec81875c47.mp3",
|
||||||
"sec": 5.22
|
"sec": 5.22
|
||||||
@@ -1939,9 +1975,9 @@
|
|||||||
"file": "andrea-2603f4db73.mp3",
|
"file": "andrea-2603f4db73.mp3",
|
||||||
"sec": 5.93
|
"sec": 5.93
|
||||||
},
|
},
|
||||||
"andrea|Si gira piano, sennò le bruciate si bruciano davvero.": {
|
"andrea|Posso girare io la manovella?": {
|
||||||
"file": "andrea-675499a046.mp3",
|
"file": "andrea-8febc84325.mp3",
|
||||||
"sec": 3.07
|
"sec": 1.75
|
||||||
},
|
},
|
||||||
"andrea|Una bruciata calda, presa al volo. Scotta! Ma è buonissima.": {
|
"andrea|Una bruciata calda, presa al volo. Scotta! Ma è buonissima.": {
|
||||||
"file": "andrea-ebba87b32c.mp3",
|
"file": "andrea-ebba87b32c.mp3",
|
||||||
@@ -1971,18 +2007,6 @@
|
|||||||
"file": "andrea-bfd09556ea.mp3",
|
"file": "andrea-bfd09556ea.mp3",
|
||||||
"sec": 5.63
|
"sec": 5.63
|
||||||
},
|
},
|
||||||
"andrea|Che Iddio ve ne renda merito!! A voi e agli alpini!": {
|
|
||||||
"file": "andrea-6933735b75.mp3",
|
|
||||||
"sec": 3.95
|
|
||||||
},
|
|
||||||
"volontari|E a te, figliolo. To', un cartoccio: son belle calde.": {
|
|
||||||
"file": "volontari-e8705fe901.mp3",
|
|
||||||
"sec": 5.14
|
|
||||||
},
|
|
||||||
"andrea|Scottano! Ma son buonissime.": {
|
|
||||||
"file": "andrea-ff81f048f5.mp3",
|
|
||||||
"sec": 2.79
|
|
||||||
},
|
|
||||||
"andrea|La fanfara degli alpini. Quando sfilavano per il paese suonando, s'affacciava tutto San Piero.": {
|
"andrea|La fanfara degli alpini. Quando sfilavano per il paese suonando, s'affacciava tutto San Piero.": {
|
||||||
"file": "andrea-a372a71849.mp3",
|
"file": "andrea-a372a71849.mp3",
|
||||||
"sec": 6.3
|
"sec": 6.3
|
||||||
@@ -2647,6 +2671,54 @@
|
|||||||
"file": "vecchietto-c8eae2e59e.mp3",
|
"file": "vecchietto-c8eae2e59e.mp3",
|
||||||
"sec": 4.88
|
"sec": 4.88
|
||||||
},
|
},
|
||||||
|
"andrea|Che Iddio ve ne renda merito!! A voi e agli alpini!": {
|
||||||
|
"file": "andrea-6933735b75.mp3",
|
||||||
|
"sec": 3.95
|
||||||
|
},
|
||||||
|
"andrea|Vi do una mano alla manovella?": {
|
||||||
|
"file": "andrea-1308ef4494.mp3",
|
||||||
|
"sec": 1.53
|
||||||
|
},
|
||||||
|
"andrea|Buon lavoro!": {
|
||||||
|
"file": "andrea-ade8043e82.mp3",
|
||||||
|
"sec": 0.71
|
||||||
|
},
|
||||||
|
"volontari|E a te, figliolo. To', un cartoccio: son belle calde.": {
|
||||||
|
"file": "volontari-e8705fe901.mp3",
|
||||||
|
"sec": 5.14
|
||||||
|
},
|
||||||
|
"andrea|Scottano! Ma son buonissime.": {
|
||||||
|
"file": "andrea-ff81f048f5.mp3",
|
||||||
|
"sec": 2.79
|
||||||
|
},
|
||||||
|
"volontari|Su, giovanotto, che noi s'ha le braccia stanche!": {
|
||||||
|
"file": "volontari-98d33dbfbd.mp3",
|
||||||
|
"sec": 2.43
|
||||||
|
},
|
||||||
|
"volontari|Si gira sempre uguale: piano piano sotto si bruciano, a furia volan nel fuoco. Quando son cotte, nel sacco.": {
|
||||||
|
"file": "volontari-7db485651b.mp3",
|
||||||
|
"sec": 8.77
|
||||||
|
},
|
||||||
|
"volontari|Come te la senti?": {
|
||||||
|
"file": "volontari-a104846cbf.mp3",
|
||||||
|
"sec": 0.88
|
||||||
|
},
|
||||||
|
"andrea|No, dopo.": {
|
||||||
|
"file": "andrea-78ad61a67a.mp3",
|
||||||
|
"sec": 1.22
|
||||||
|
},
|
||||||
|
"volontari|Alla bona: ti si guarda noi, e se si bruciano un si dice a nessuno.": {
|
||||||
|
"file": "volontari-5761a9776b.mp3",
|
||||||
|
"sec": 4.82
|
||||||
|
},
|
||||||
|
"volontari|Seria: la gente è in fila, e il cartoccio lo vole bello pieno.": {
|
||||||
|
"file": "volontari-dad04d4161.mp3",
|
||||||
|
"sec": 3.99
|
||||||
|
},
|
||||||
|
"volontari|A fassi male? Allora ci si mette anche il fuoco forte.": {
|
||||||
|
"file": "volontari-839aa118ab.mp3",
|
||||||
|
"sec": 4.1
|
||||||
|
},
|
||||||
"andrea|Barone! Che ci fai qui?": {
|
"andrea|Barone! Che ci fai qui?": {
|
||||||
"file": "andrea-83abae1419.mp3",
|
"file": "andrea-83abae1419.mp3",
|
||||||
"sec": 2.91
|
"sec": 2.91
|
||||||
@@ -2899,6 +2971,30 @@
|
|||||||
"file": "arcu-26a5fb0d0c.mp3",
|
"file": "arcu-26a5fb0d0c.mp3",
|
||||||
"sec": 3.17
|
"sec": 3.17
|
||||||
},
|
},
|
||||||
|
"andrea|Una partita alla bona, tanto per sentire la molla.": {
|
||||||
|
"file": "andrea-ad00ae1145.mp3",
|
||||||
|
"sec": 3.38
|
||||||
|
},
|
||||||
|
"andrea|Una partita seria.": {
|
||||||
|
"file": "andrea-3479aca550.mp3",
|
||||||
|
"sec": 1.16
|
||||||
|
},
|
||||||
|
"andrea|Un'altra volta.": {
|
||||||
|
"file": "andrea-1b517ab1ef.mp3",
|
||||||
|
"sec": 0.9
|
||||||
|
},
|
||||||
|
"andrea|Sessantamila punti e sono contento.": {
|
||||||
|
"file": "andrea-74e53fb53a.mp3",
|
||||||
|
"sec": 2.65
|
||||||
|
},
|
||||||
|
"andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
|
||||||
|
"file": "andrea-82ab821c37.mp3",
|
||||||
|
"sec": 4.43
|
||||||
|
},
|
||||||
|
"andrea|Duecentomila. Il record del River, o quasi.": {
|
||||||
|
"file": "andrea-00b37d42f9.mp3",
|
||||||
|
"sec": 4.32
|
||||||
|
},
|
||||||
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
|
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
|
||||||
"file": "andrea-d564365fc2.mp3",
|
"file": "andrea-d564365fc2.mp3",
|
||||||
"sec": 2.53
|
"sec": 2.53
|
||||||
@@ -2987,10 +3083,6 @@
|
|||||||
"file": "andrea-9cc0991d39.mp3",
|
"file": "andrea-9cc0991d39.mp3",
|
||||||
"sec": 3.32
|
"sec": 3.32
|
||||||
},
|
},
|
||||||
"andrea|No, dopo.": {
|
|
||||||
"file": "andrea-78ad61a67a.mp3",
|
|
||||||
"sec": 1.22
|
|
||||||
},
|
|
||||||
"andrea|C'è posto per uno?": {
|
"andrea|C'è posto per uno?": {
|
||||||
"file": "andrea-f7481e5519.mp3",
|
"file": "andrea-f7481e5519.mp3",
|
||||||
"sec": 1.81
|
"sec": 1.81
|
||||||
@@ -3619,6 +3711,38 @@
|
|||||||
"file": "andrea-356a1afe8d.mp3",
|
"file": "andrea-356a1afe8d.mp3",
|
||||||
"sec": 5.91
|
"sec": 5.91
|
||||||
},
|
},
|
||||||
|
"andrea|Traguardo! Se c'era la classifica, oggi ci mettevo le mie iniziali.": {
|
||||||
|
"file": "andrea-6fd4fcd345.mp3",
|
||||||
|
"sec": 4.64
|
||||||
|
},
|
||||||
|
"andrea|Tre palline, e tutte nella Sieve. Il flipper del River non perdona.": {
|
||||||
|
"file": "andrea-5c306f1589.mp3",
|
||||||
|
"sec": 5.57
|
||||||
|
},
|
||||||
|
"andrea|Lascio la pallina lì. Tanto il River non chiude.": {
|
||||||
|
"file": "andrea-dcbb849b12.mp3",
|
||||||
|
"sec": 4.02
|
||||||
|
},
|
||||||
|
"andrea|Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.": {
|
||||||
|
"file": "andrea-145f20bff9.mp3",
|
||||||
|
"sec": 5.11
|
||||||
|
},
|
||||||
|
"volontari|Bravo, figliolo! Se ti va, l'anno prossimo la manovella è tua.": {
|
||||||
|
"file": "volontari-067aa2d701.mp3",
|
||||||
|
"sec": 4.42
|
||||||
|
},
|
||||||
|
"andrea|Un po' crude, un po' carbone... Le mangio io, va'.": {
|
||||||
|
"file": "andrea-6f3339acb0.mp3",
|
||||||
|
"sec": 4.06
|
||||||
|
},
|
||||||
|
"volontari|Ci vuole il polso, figliolo. Il polso e la pazienza.": {
|
||||||
|
"file": "volontari-3bb4fe1dc0.mp3",
|
||||||
|
"sec": 4.28
|
||||||
|
},
|
||||||
|
"andrea|Vi rendo la manovella. Mi fumano anche gli occhi.": {
|
||||||
|
"file": "andrea-367ddde0fe.mp3",
|
||||||
|
"sec": 3.61
|
||||||
|
},
|
||||||
"andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": {
|
"andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": {
|
||||||
"file": "andrea-0c26e34815.mp3",
|
"file": "andrea-0c26e34815.mp3",
|
||||||
"sec": 7.53
|
"sec": 7.53
|
||||||
|
|||||||
@@ -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 l'NPM interno di home-server 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
|
|
||||||
@@ -151,7 +151,7 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
|
|||||||
Al bar e al circolo:
|
Al bar e al circolo:
|
||||||
- *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già
|
- *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già
|
||||||
la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già.
|
la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già.
|
||||||
- *Flipper del River*: più lavoro, ma la fisica delle biglie c'è già.
|
- *Flipper del River*: fatto il 31 ottobre (vedi STATO.md).
|
||||||
- *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice.
|
- *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice.
|
||||||
- *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS.
|
- *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS.
|
||||||
Sport:
|
Sport:
|
||||||
|
|||||||
@@ -41,7 +41,47 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
merito!! A voi e agli alpini!»), la fanfara degli alpini, gli alpini della polenta e del vino, la gente coi cartocci
|
merito!! A voi e agli alpini!»), la fanfara degli alpini, gli alpini della polenta e del vino, la gente coi cartocci
|
||||||
(anche nel tendone), le coppie del liscio che girano (`flourish` continuo).
|
(anche nel tendone), le coppie del liscio che girano (`flourish` continuo).
|
||||||
La musica di Andrea (Suno): il liscio fuori, più forte vicino alle coppie (`stages`), la mazurca nel tendone.
|
La musica di Andrea (Suno): il liscio fuori, più forte vicino alle coppie (`stages`), la mazurca nel tendone.
|
||||||
[DA ANDREA] le battute; un minigioco delle bruciate (girare il cilindro sul fuoco).
|
[DA ANDREA] le battute.
|
||||||
|
- **Il minigioco delle bruciate (31 ottobre)**: dai volontari della Misericordia («Vi do una mano alla manovella?»), o
|
||||||
|
usando il cilindro, si sceglie il livello e si gira la manovella a mano (`src/engine/bruciate.js`, disegno in
|
||||||
|
`bruciate-render.js`, sfondo di Codex `assets/closeups/smarronata-bruciatore.png`). Il gesto: tenendo premuto si
|
||||||
|
fanno giri, grandi o piccoli e dovunque (conta quanto gira la direzione del dito); Spazio, frecce: una spinta a
|
||||||
|
colpo. Troppo piano le castagne di sotto si carbonizzano e quelle di sopra restano crude, troppo forte volano nel
|
||||||
|
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 / 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
|
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
|
||||||
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
|
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
|
||||||
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
|
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
|
||||||
@@ -81,6 +121,13 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
pieni zeppi di gente fino in primo piano (Andrea); la prima versione, con la piazza vuota davanti, in
|
pieni zeppi di gente fino in primo piano (Andrea); la prima versione, con la piazza vuota davanti, in
|
||||||
`assets/raw/ingorgo-v1/`. La voce legge «console» come «consòl» (`tools/pronuncia.json`).
|
`assets/raw/ingorgo-v1/`. La voce legge «console» come «consòl» (`tools/pronuncia.json`).
|
||||||
[DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere.
|
[DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere.
|
||||||
|
Il 31 ottobre: la folla davanti ad Andrea (dalla vita in giù, `crowdFront` in render.js), Andrea con la birra alla
|
||||||
|
festa (foglio di Codex `andrea-birra`: cammina col bicchiere, fermo lo alza a tempo), sotto una console si sente solo
|
||||||
|
quella (`mix`), in cima a Via dell'Antica Posta si arriva sotto l'underground. Via Calimara entra nella festa (sfondo di
|
||||||
|
Codex, console nel giardino con la palla a specchi) con la musica anni '90 di Andrea (Suno, `ingorgo-anni90.mp3`);
|
||||||
|
da lontano la house della Piazza e il latino del Comune.
|
||||||
|
Anche l'inizio di Via Cipriani (sfondo di Codex, console sul campo da basket, laser rossi e strobo, folla che poga a
|
||||||
|
180 battiti) con la hardcore techno di Andrea (Suno, `ingorgo-hardcore.mp3`).
|
||||||
- **Baldoria al campo sportivo (30 ottobre)**: Yuri Baldi, dei Ringraziamenti, al Campo principale con la maglia
|
- **Baldoria al campo sportivo (30 ottobre)**: Yuri Baldi, dei Ringraziamenti, al Campo principale con la maglia
|
||||||
blucerchiata e un bandierone della Sampdoria grande come lui, che ogni tanto sventola (`flourish` delle persone).
|
blucerchiata e un bandierone della Sampdoria grande come lui, che ogni tanto sventola (`flourish` delle persone).
|
||||||
Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più
|
Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più
|
||||||
@@ -220,8 +267,10 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
|
|
||||||
**Si gioca** in Docker su http://localhost:8080 (`make up`; Manzolo Rush è stato fermato per liberare la porta) e,
|
**Si gioca** in Docker su http://localhost:8080 (`make up`; Manzolo Rush è stato fermato per liberare la porta) e,
|
||||||
**online dal 2026-10-23**, su https://50037.manzolo.it (50037 è il CAP di San Piero), finché non va su GitHub Pages.
|
**online dal 2026-10-23**, su https://50037.manzolo.it (50037 è il CAP di San Piero), finché non va su GitHub Pages.
|
||||||
Si aggiorna con `make deploy`: pubblica l'ultimo commit, immagine costruita e provata sul PC e spedita al container
|
Dal 2026-10-31 il sito pubblico è sul **VPS Contabo** (CNAME `50037` → `vps.manzolo.it`, NPM del VPS col certificato
|
||||||
`leggenda-regolo` di home-server (NPM centrale → NPM interno di home-server → container).
|
Let's Encrypt → container `leggenda-regolo`); la copia su home-server è stata tolta. Si aggiorna con `make deploy`:
|
||||||
|
test, immagine costruita e provata sul PC, spedita al VPS, e il controllo che https://50037.manzolo.it serva la
|
||||||
|
versione nuova.
|
||||||
|
|
||||||
- **Motore** (`src/engine/`): camminata con percorsi su aree concave, profondità, sette verbi + clic intelligente,
|
- **Motore** (`src/engine/`): camminata con percorsi su aree concave, profondità, sette verbi + clic intelligente,
|
||||||
clic destro / pressione lunga = guarda, inventario con "usa… con" e "dai… a", dialoghi a scelte (anche una
|
clic destro / pressione lunga = guarda, inventario con "usa… con" e "dai… a", dialoghi a scelte (anche una
|
||||||
@@ -373,8 +422,8 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
|||||||
Il nome del sentiero è provvisorio.
|
Il nome del sentiero è provvisorio.
|
||||||
- La salita dei cipressi (foto di Andrea, 28 ottobre, sfondo di Codex): la strada bianca fra la casa del maestro
|
- La salita dei cipressi (foto di Andrea, 28 ottobre, sfondo di Codex): la strada bianca fra la casa del maestro
|
||||||
Bacci (alle spalle) e il viale dei cipressi (lassù spuntano i cipressi). Nome provvisorio, battute provvisorie.
|
Bacci (alle spalle) e il viale dei cipressi (lassù spuntano i cipressi). Nome provvisorio, battute provvisorie.
|
||||||
- Seconda copia del gioco sul VPS Contabo con `make deploy-vps` (dopo `make deploy`). Per renderla pubblica serve
|
- Il gioco pubblico sta sul VPS Contabo (dal 31 ottobre: regola in NPM del VPS, DNS spostato su Aruba, certificato
|
||||||
nel proxy del VPS la regola per 50037.manzolo.it; poi si sposta il DNS su Aruba.
|
Let's Encrypt); la copia su home-server è stata tolta (Andrea: «la tengo fuori e basta»).
|
||||||
- Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
|
- Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
|
||||||
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
|
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
|
||||||
(`src/engine/basket.js`, vista di lato, sfondo e sprite di Andrea col pallone da Codex): da solo, dieci palloni, a ogni
|
(`src/engine/basket.js`, vista di lato, sfondo e sprite di Andrea col pallone da Codex): da solo, dieci palloni, a ogni
|
||||||
@@ -417,7 +466,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
|
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
|
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
|
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
|
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).
|
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
|
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
|
||||||
|
|||||||
@@ -295,6 +295,36 @@
|
|||||||
sinistra per la forza. I salti: la lancetta dell'equilibrio attraversa la barra; premi (o Spazio, o Salta) quando è
|
sinistra per la forza. I salti: la lancetta dell'equilibrio attraversa la barra; premi (o Spazio, o Salta) quando è
|
||||||
nel verde. Troppo presto metti giù l'altro piede, troppo tardi pesti la riga.</p>
|
nel verde. Troppo presto metti giù l'altro piede, troppo tardi pesti la riga.</p>
|
||||||
</details>
|
</details>
|
||||||
|
<details id="bruciate-help" hidden><summary>Come si gioca</summary>
|
||||||
|
<p>Le bruciate della Smarronata, coi volontari della Misericordia: le castagne sono nel cilindro a rete sopra il
|
||||||
|
fuoco e si gira la manovella. Tieni premuto e fai dei giri, grandi o piccoli, dove vuoi, col mouse o col dito:
|
||||||
|
il cilindro gira con te. Oppure Spazio o le frecce: una spinta a ogni colpo.</p>
|
||||||
|
<p>In alto, GIRO: tienilo nel verde. Troppo piano le castagne non si rimescolano, quelle di sotto si carbonizzano e
|
||||||
|
quelle di sopra restano crude; troppo forte volano fuori, nel fuoco. COTTURA: quando è nel verde, Rovescia nel
|
||||||
|
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 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>
|
<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
|
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
|
||||||
di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria
|
di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.63",
|
"version": "0.1.67",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
"browser:cerbottana": "node tests/cerbottana.browser.mjs",
|
"browser:cerbottana": "node tests/cerbottana.browser.mjs",
|
||||||
"browser:condor": "node tests/condor.browser.mjs",
|
"browser:condor": "node tests/condor.browser.mjs",
|
||||||
"browser:campana": "node tests/campana.browser.mjs",
|
"browser:campana": "node tests/campana.browser.mjs",
|
||||||
|
"browser:bruciate": "node tests/bruciate.browser.mjs",
|
||||||
|
"browser:flipper": "node tests/flipper.browser.mjs",
|
||||||
"browser:ingorgo": "node tests/ingorgo.browser.mjs",
|
"browser:ingorgo": "node tests/ingorgo.browser.mjs",
|
||||||
"browser:tressette": "node tests/tressette.browser.mjs",
|
"browser:tressette": "node tests/tressette.browser.mjs",
|
||||||
"browser:scopa": "node tests/scopa.browser.mjs",
|
"browser:scopa": "node tests/scopa.browser.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Le bruciate in primo piano (bruciate.js): il cilindro a rete sopra il fuoco, visto di fronte, con le castagne dentro
|
||||||
|
// che ricascano, la manovella a destra, le fiamme che lo lambiscono; in basso il giro della manovella e la cottura.
|
||||||
|
// Niente filtri e niente passate trasparenti a tutto schermo (vedi risveglio.js): solo forme piccole.
|
||||||
|
import { DRUM, CRANK, BAR, N, COOKED, BURNT, POUR, MAX_SPEED, inside, doneness } from './bruciate.js';
|
||||||
|
import { text, panel } from './bocce-render.js';
|
||||||
|
import { ambientHash as hash } from './ambient.js';
|
||||||
|
|
||||||
|
const SACK = { x: 604, y: 268 }, FIRE = 252, RODS = 18, RINGS = 9;
|
||||||
|
const lerp = (a, b, k) => a + (b - a) * k;
|
||||||
|
const mix = (p, q, k) => `rgb(${p.map((v, i) => Math.round(lerp(v, q[i], k))).join(',')})`;
|
||||||
|
// Il colore della buccia: lucida e rossiccia da cruda, scura da cotta, nera da carbone.
|
||||||
|
const RAW = [168, 96, 50], DONE = [118, 64, 30], COAL = [26, 20, 18];
|
||||||
|
const shell = cook => cook < COOKED ? mix(RAW, DONE, Math.max(0, cook) / COOKED) : mix(DONE, COAL, Math.min(1, (cook - COOKED) / (BURNT - COOKED)));
|
||||||
|
|
||||||
|
// Dove sta una castagna sullo schermo, e quanto è davanti (per l'ordine e la luce).
|
||||||
|
function place(k) {
|
||||||
|
return { x: lerp(DRUM.x0 + 8, DRUM.x1 - 8, k.x), y: DRUM.y + Math.sin(k.a) * DRUM.r * k.r, z: Math.cos(k.a) * k.r };
|
||||||
|
}
|
||||||
|
function chestnut(ctx, x, y, cook, z = 0) {
|
||||||
|
// Il bordino, perché si stacchino dal fondo del cilindro (chiaro su quelle carbone).
|
||||||
|
ctx.fillStyle = cook > BURNT ? '#6a5a4e' : '#1e120a';
|
||||||
|
ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y), 7, 6, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = shell(cook);
|
||||||
|
ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y), 6, 5, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
// La punta chiara e, da cotte, l'incisione aperta col giallo della castagna.
|
||||||
|
ctx.fillStyle = cook > BURNT ? '#2a2420' : '#d9b48a';
|
||||||
|
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 4, 2, 1);
|
||||||
|
if (cook >= COOKED * 0.8 && cook <= BURNT) { ctx.fillStyle = '#e8c070'; ctx.fillRect(Math.round(x) - 2, Math.round(y) - 1, 4, 1); }
|
||||||
|
// Il riflesso del fuoco, più forte su quelle davanti.
|
||||||
|
if (cook < BURNT) { ctx.fillStyle = `rgba(255,190,120,${0.25 + 0.3 * Math.max(0, z)})`; ctx.fillRect(Math.round(x) + 1, Math.round(y) - 2, 2, 1); }
|
||||||
|
}
|
||||||
|
// Le fiamme che lambiscono il cilindro: lingue basse e storte, ognuna col suo passo, che ogni tanto si allungano.
|
||||||
|
function flames(ctx, time, front) {
|
||||||
|
for (let i = 0; i < 17; i++) {
|
||||||
|
if ((i % 3 === 0) !== front) continue;
|
||||||
|
const r = hash(i * 7.3), x = DRUM.x0 + 6 + i * (DRUM.x1 - DRUM.x0 - 12) / 16 + 6 * (hash(i * 1.9) - 0.5);
|
||||||
|
const lick = Math.max(0, Math.sin(time * (1.3 + r) + i * 2.1)) ** 4, h = front ? 5 + 6 * r + 2 * Math.sin(time * (6 + r * 5) + i) + 7 * lick : 18 + 16 * r + 7 * Math.sin(time * (6 + r * 5) + i) + 22 * lick;
|
||||||
|
const w = 5 + 5 * hash(i * 3.1), sway = 2.5 * Math.sin(time * (3 + r) + i * 1.7);
|
||||||
|
for (const [k, color] of [[1, '#b8301a'], [0.7, '#ea7a1a'], [0.4, '#ffd04a']]) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - w * k, FIRE);
|
||||||
|
ctx.quadraticCurveTo(x - w * k * 0.2 + sway, FIRE - h * k * 0.6, x + sway * 2, FIRE - h * k);
|
||||||
|
ctx.quadraticCurveTo(x + w * k * 0.5 + sway, FIRE - h * k * 0.45, x + w * k, FIRE);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Il cilindro appeso: la sbarra sulle due forcelle e i due ferri che lo reggono per l'asse.
|
||||||
|
function hanger(ctx) {
|
||||||
|
ctx.fillStyle = '#2a2420'; ctx.fillRect(150, BAR - 3, 336, 6);
|
||||||
|
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(150, BAR - 3, 336, 1);
|
||||||
|
for (const x of [DRUM.x0 - 12, DRUM.x1 + 12]) {
|
||||||
|
ctx.fillStyle = '#2a2420'; ctx.fillRect(x - 2, BAR, 4, DRUM.y - BAR + 4);
|
||||||
|
ctx.fillStyle = '#5a4a3e'; ctx.fillRect(x - 2, BAR, 1, DRUM.y - BAR + 4);
|
||||||
|
ctx.fillStyle = '#2a2420'; ctx.beginPath(); ctx.arc(x, DRUM.y, 5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// La rete: gli anelli e i tondini che girano col cilindro (quelli davanti più chiari).
|
||||||
|
function mesh(ctx, c, front) {
|
||||||
|
const { x0, x1, y, r } = DRUM;
|
||||||
|
for (let j = 0; j < RODS; j++) {
|
||||||
|
const a = c.drum + j * Math.PI * 2 / RODS, z = Math.cos(a);
|
||||||
|
if (front !== z > 0) continue;
|
||||||
|
ctx.fillStyle = front ? '#9a9088' : '#3a302a';
|
||||||
|
ctx.fillRect(x0, Math.round(y + Math.sin(a) * r), x1 - x0, 1);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = front ? '#8a8078' : '#3a302a'; ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= RINGS; i++) {
|
||||||
|
const x = Math.round(lerp(x0, x1, i / RINGS)) + 0.5, end = i === 0 || i === RINGS;
|
||||||
|
ctx.beginPath(); ctx.ellipse(x, y, end ? 9 : 4, r, 0, front ? -Math.PI / 2 : Math.PI / 2, front ? Math.PI / 2 : Math.PI * 1.5); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function crank(ctx, c, time) {
|
||||||
|
const { x, y, rx, ry } = CRANK, a = c.drum;
|
||||||
|
// L'asse, da una forcella all'altra e fino alla manovella.
|
||||||
|
ctx.fillStyle = '#2a2420'; ctx.fillRect(DRUM.x0 - 16, y - 2, x - DRUM.x0 + 18, 4);
|
||||||
|
ctx.fillStyle = '#6a625a'; ctx.fillRect(DRUM.x0 - 16, y - 2, x - DRUM.x0 + 18, 1);
|
||||||
|
// Il giro da fare: un'ellisse tratteggiata che pulsa, finché il cilindro è fermo.
|
||||||
|
if (c.step === 'turn' && Math.abs(c.speed) < 0.5) {
|
||||||
|
ctx.strokeStyle = `rgba(255,224,140,${0.5 + 0.4 * Math.sin(time * 5)})`; ctx.setLineDash([3, 3]);
|
||||||
|
ctx.beginPath(); ctx.ellipse(x, y, rx + 6, ry + 6, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
text(ctx, 'GIRA!', x, y + ry + 18, 6, '#ffe08c', 'center');
|
||||||
|
}
|
||||||
|
const hx = x + Math.cos(a) * rx, hy = y + Math.sin(a) * ry;
|
||||||
|
ctx.strokeStyle = '#5a5450'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(hx, hy); ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#b0a698'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(hx, hy - 1); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#4a4440'; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill();
|
||||||
|
// L'impugnatura di legno, consumata dalle mani.
|
||||||
|
ctx.fillStyle = '#3a2414'; ctx.fillRect(Math.round(hx) - 4, Math.round(hy) - 8, 9, 16);
|
||||||
|
ctx.fillStyle = '#a0703c'; ctx.fillRect(Math.round(hx) - 3, Math.round(hy) - 7, 7, 14);
|
||||||
|
ctx.fillStyle = '#d6a062'; ctx.fillRect(Math.round(hx) - 2, Math.round(hy) - 6, 2, 12);
|
||||||
|
}
|
||||||
|
// In basso a sinistra (in alto ci sono i cursori del volume): il giro della manovella (rosso troppo piano, verde giusto, rosso troppo forte) e la cottura.
|
||||||
|
function gauges(ctx, c) {
|
||||||
|
panel(ctx, 10, 296, 196, 54);
|
||||||
|
const bar = (y, label, k, zones, mark) => {
|
||||||
|
text(ctx, label, 18, y, 6, '#efd49e');
|
||||||
|
const x0 = 82, w = 116;
|
||||||
|
for (const [a, b, color] of zones) { ctx.fillStyle = color; ctx.fillRect(x0 + Math.round(a * w), y - 3, Math.max(1, Math.round((b - a) * w)), 7); }
|
||||||
|
ctx.fillStyle = mark; ctx.fillRect(x0 + Math.round(Math.max(0, Math.min(1, k)) * w) - 1, y - 6, 3, 13);
|
||||||
|
};
|
||||||
|
const s = c.skill, lo = s.lo / MAX_SPEED, hi = s.hi / MAX_SPEED;
|
||||||
|
bar(312, 'GIRO', Math.abs(c.speed) / MAX_SPEED, [[0, lo, '#8a3a2a'], [lo, hi, '#4f8a3a'], [hi, 1, '#b3402a']], '#fff4c8');
|
||||||
|
const top = BURNT * 1.15;
|
||||||
|
bar(334, 'COTTURA', doneness(c) / top, [[0, COOKED / top, '#b07a4a'], [COOKED / top, BURNT / top, '#6a8a3a'], [BURNT / top, 1, '#2a2420']], '#fff4c8');
|
||||||
|
}
|
||||||
|
function result(ctx, c) {
|
||||||
|
const r = c.result;
|
||||||
|
panel(ctx, 170, 120, 300, 104);
|
||||||
|
text(ctx, r.winner === 'player' ? 'BRUCIATE COI FIOCCHI!' : 'BRUCIATE COSÌ COSÌ', 320, 140, 8, '#efd49e', 'center');
|
||||||
|
const rows = [['Buone', r.good, '#9fd27a'], ['Crude', r.raw, '#e0b07a'], ['Carbonizzate', r.burnt, '#c0b0a0'], ['Nel fuoco', r.spilled, '#f08a5a']];
|
||||||
|
rows.forEach(([label, n, color], i) => { text(ctx, label, 200, 162 + i * 13, 6, color); text(ctx, `${n}`, 440, 162 + i * 13, 6, color, 'right'); });
|
||||||
|
text(ctx, `Ne servivano ${r.goal} su ${N}`, 320, 216, 5, '#d7ccb4', 'center');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBruciate(ctx, c, def, bg) {
|
||||||
|
const time = c.visual.time;
|
||||||
|
ctx.save();
|
||||||
|
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||||
|
else { ctx.fillStyle = '#6a5a40'; ctx.fillRect(0, 0, 640, 360); }
|
||||||
|
hanger(ctx);
|
||||||
|
// Il fondo del cilindro, scuro, col bagliore della brace sotto; poi le fiamme dietro e la rete di dietro.
|
||||||
|
ctx.fillStyle = 'rgba(74,46,30,0.72)'; ctx.fillRect(DRUM.x0, DRUM.y - DRUM.r, DRUM.x1 - DRUM.x0, DRUM.r * 2);
|
||||||
|
ctx.fillStyle = 'rgba(240,120,40,0.18)'; ctx.fillRect(DRUM.x0, DRUM.y + DRUM.r * 0.3, DRUM.x1 - DRUM.x0, DRUM.r * 0.7);
|
||||||
|
flames(ctx, time, false);
|
||||||
|
mesh(ctx, c, false);
|
||||||
|
// Le castagne: da dietro a davanti. Rovesciandole, scivolano tutte verso il sacco.
|
||||||
|
const pour = c.step === 'pour' ? Math.min(1, c.t / POUR) : c.step === 'result' ? 1 : 0;
|
||||||
|
const ks = (pour ? c.chestnuts.filter(k => !k.out) : inside(c)).map(k => ({ k, ...place(k) })).sort((p, q) => p.z - q.z);
|
||||||
|
for (const p of ks) {
|
||||||
|
if (pour) {
|
||||||
|
const d = Math.min(1, Math.max(0, pour * 1.6 - p.k.x * 0.6)), x = lerp(p.x, SACK.x, d), y = lerp(p.y, SACK.y, d) - Math.sin(d * Math.PI) * 40;
|
||||||
|
if (d < 1) chestnut(ctx, x, y, p.k.cook, 1);
|
||||||
|
} else chestnut(ctx, p.x, p.y, p.k.cook, p.z);
|
||||||
|
}
|
||||||
|
mesh(ctx, c, true);
|
||||||
|
flames(ctx, time, true);
|
||||||
|
crank(ctx, c, time);
|
||||||
|
// Quelle che volano nel fuoco, e lo sbuffo quando ci cascano.
|
||||||
|
for (const f of c.flying) {
|
||||||
|
const k = Math.min(1, f.t / 0.6), x = f.x + f.dir * 60 * k, y = lerp(f.y, FIRE - 6, k) - Math.sin(k * Math.PI) * 50;
|
||||||
|
if (f.t < 0.6) chestnut(ctx, x, y, 0.5);
|
||||||
|
else for (let i = 0; i < 4; i++) {
|
||||||
|
const s = (f.t - 0.6) / 0.6;
|
||||||
|
ctx.fillStyle = `rgba(200,195,185,${0.6 * (1 - s)})`;
|
||||||
|
ctx.beginPath(); ctx.arc(x + (i - 1.5) * 5 * (1 + s), FIRE - 10 - s * 30 - i * 3, 4 + s * 6, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.step === 'result') result(ctx, c); else gauges(ctx, c);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Le bruciate della Smarronata (Andrea, 31 ottobre: «a manovella, a mano, fallo così»): coi volontari della
|
||||||
|
// Misericordia si gira la manovella del cilindro a rete sopra il fuoco. Il gesto: tenendo premuto si fanno dei giri,
|
||||||
|
// grandi o piccoli, dove si vuole (conta quanto gira la direzione del dito, non dove sta), come la manovella vera.
|
||||||
|
// Le castagne stanno in fondo al cilindro: girando, la rete le alza e loro ricascano e si rimescolano. Troppo piano,
|
||||||
|
// quelle di sotto restano sulla brace e si carbonizzano mentre quelle di sopra restano crude; troppo forte, la rete
|
||||||
|
// le porta in cima e qualcuna salta fuori nel fuoco («mica si fa la centrifuga!»). Quando paiono cotte si rovesciano
|
||||||
|
// nel sacco: contano quelle buone, né crude né bruciate. Per chi non può fare il gesto: Spazio, le frecce o il
|
||||||
|
// pulsante Gira danno una spinta a ogni colpo. Unità: i pixel del primo piano 640×360, visto di fronte (sfondo di
|
||||||
|
// Codex: assets/closeups/smarronata-bruciatore.png).
|
||||||
|
|
||||||
|
// Il cilindro (fra le due forcelle dello sfondo) e la manovella, a destra: un giro visto di sbieco, un'ellisse.
|
||||||
|
export const DRUM = { x0: 182, x1: 456, y: 190, r: 46 }, CRANK = { x: 506, y: 190, rx: 14, ry: 30 }, BAR = 92;
|
||||||
|
export const N = 18, BOTTOM = Math.PI / 2;
|
||||||
|
// Tempi in secondi; velocità in radianti al secondo.
|
||||||
|
export const MIN_POUR = 4, POUR = 1.4, SPILL = 0.45, PUSH = 2.6, FRICTION = 1.4, LINE = 2.4, MAX_SPEED = 22;
|
||||||
|
// Quando una castagna è cotta e quando è carbonizzata.
|
||||||
|
export const COOKED = 0.85, BURNT = 1.6;
|
||||||
|
// I livelli: sotto `lo` il cilindro gira troppo piano (le castagne non si rimescolano abbastanza), sopra `hi` le
|
||||||
|
// castagne volano fuori; `time`, in quanti secondi cuociono girando bene; `goal`, quante buone ci vogliono.
|
||||||
|
export const LEVELS = {
|
||||||
|
bona: { lo: 1.6, hi: 10, time: 20, goal: 10 },
|
||||||
|
seria: { lo: 2.2, hi: 8, time: 17, goal: 15 },
|
||||||
|
male: { lo: 2.8, hi: 6.5, time: 14, goal: 16 },
|
||||||
|
};
|
||||||
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
|
const wrap = a => Math.atan2(Math.sin(a), Math.cos(a));
|
||||||
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
|
const between = (c, lo, hi) => lo + (hi - lo) * random(c);
|
||||||
|
|
||||||
|
// Dove ricasca una castagna: di nuovo in fondo, a un'altezza a caso nel mucchio, con la sua voglia di farsi alzare.
|
||||||
|
function drop(c, k, side = 1) { Object.assign(k, { a: BOTTOM - side * between(c, 0, 0.55), r: between(c, 0.5, 0.93), lift: between(c, 0.55, 1.5) }); }
|
||||||
|
|
||||||
|
export function newBruciate(seed = Date.now() >>> 0, def = {}) {
|
||||||
|
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||||
|
const c = { step: 'turn', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0, drum: 0, speed: 0,
|
||||||
|
pending: 0, grab: null, cursor: null, spin: 0, chestnuts: [], flying: [], line: null, lineT: 0, events: [], visual: { time: 0 } };
|
||||||
|
for (let i = 0; i < N; i++) { const k = { x: between(c, 0.06, 0.94), cook: between(c, 0, 0.08), out: false }; drop(c, k, random(c) < 0.5 ? 1 : -1); c.chestnuts.push(k); }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
export const inside = c => c.chestnuts.filter(k => !k.out);
|
||||||
|
// Quanto stanno a contatto con la rete sopra la brace: le castagne in fondo e di fuori; quelle di sopra quasi niente.
|
||||||
|
export function contact(k) { return Math.max(0, Math.cos(k.a - BOTTOM)) ** 3 * clamp((k.r - 0.62) / 0.3, 0, 1); }
|
||||||
|
// La cottura media delle castagne ancora nel cilindro, da 0 (crude) in su (1: cotte).
|
||||||
|
export const doneness = c => { const ks = inside(c); return ks.length ? ks.reduce((s, k) => s + k.cook, 0) / ks.length : 0; };
|
||||||
|
export const tally = c => {
|
||||||
|
const t = { good: 0, raw: 0, burnt: 0, spilled: 0 };
|
||||||
|
for (const k of c.chestnuts) t[k.out ? 'spilled' : k.cook < COOKED ? 'raw' : k.cook > BURNT ? 'burnt' : 'good']++;
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
function say(c, line) { c.line = line; c.lineT = LINE; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
for (const f of c.flying) f.t += dt;
|
||||||
|
c.flying = c.flying.filter(f => f.t < 1.2);
|
||||||
|
if (c.step === 'pour') { if (c.t >= POUR) finish(c); return; }
|
||||||
|
if (c.step !== 'turn') return;
|
||||||
|
// La velocità: tenendo la manovella è quella del gesto (smussata); lasciata, il cilindro rallenta da sé.
|
||||||
|
if (c.grab) c.speed += (c.pending / dt - c.speed) * (1 - Math.exp(-dt * 10));
|
||||||
|
else c.speed *= Math.exp(-FRICTION * dt);
|
||||||
|
c.pending = 0;
|
||||||
|
c.speed = clamp(c.speed, -MAX_SPEED, MAX_SPEED);
|
||||||
|
const w = c.speed, fast = Math.abs(w) > c.skill.hi, side = Math.sign(w) || 1, before = c.drum;
|
||||||
|
c.drum += w * dt;
|
||||||
|
// Il cigolio della manovella, a ogni mezzo giro.
|
||||||
|
if (Math.floor(before / Math.PI) !== Math.floor(c.drum / Math.PI)) c.events.push({ kind: 'bruciate-manovella', power: clamp(Math.abs(w) / 8, 0.3, 1) });
|
||||||
|
const rate = 1 / (c.skill.time * 0.45);
|
||||||
|
for (const k of inside(c)) {
|
||||||
|
// La rete se le porta dietro finché non ricascano in fondo; troppo forte le porta fino in cima e oltre.
|
||||||
|
k.a += w * dt * k.r;
|
||||||
|
if (!fast && (k.a - BOTTOM) * side > k.lift) drop(c, k, side);
|
||||||
|
const before = k.cook;
|
||||||
|
k.cook += dt * rate * (0.15 + 1.9 * contact(k));
|
||||||
|
if (before < COOKED && k.cook >= COOKED) c.events.push({ kind: 'bruciate-scoppio', power: 0.5 + 0.5 * random(c) });
|
||||||
|
}
|
||||||
|
// La centrifuga: ogni tanto ne vola fuori una, nel fuoco.
|
||||||
|
if (fast) {
|
||||||
|
c.spin += dt;
|
||||||
|
if (c.spin >= SPILL) {
|
||||||
|
c.spin = 0;
|
||||||
|
const k = inside(c).sort((p, q) => Math.cos(p.a - BOTTOM) - Math.cos(q.a - BOTTOM))[0];
|
||||||
|
if (k) { k.out = true; c.flying.push({ x: DRUM.x0 + k.x * (DRUM.x1 - DRUM.x0), y: DRUM.y + Math.sin(k.a) * DRUM.r * k.r, dir: side, t: 0 }); c.events.push({ kind: 'bruciate-cade', power: 0.8 }); }
|
||||||
|
say(c, random(c) < 0.5 ? 'Oh! Mica si fa la centrifuga!' : 'Piano, che le si butta nel fuoco!');
|
||||||
|
}
|
||||||
|
} else c.spin = Math.max(0, c.spin - dt);
|
||||||
|
if (!c.line && Math.abs(w) < c.skill.lo && c.t > 3 && inside(c).some(k => k.cook > BURNT - 0.3)) say(c, 'Gira, che sotto si bruciano!');
|
||||||
|
// Tutte nel fuoco o tutte carbone: si rovescia comunque.
|
||||||
|
if (!inside(c).length || inside(c).every(k => k.cook > BURNT)) pour(c);
|
||||||
|
}
|
||||||
|
function finish(c) {
|
||||||
|
const t = tally(c);
|
||||||
|
c.step = 'result'; c.speed = 0;
|
||||||
|
c.result = { winner: t.good >= c.skill.goal ? 'player' : 'cpu', ...t, goal: c.skill.goal };
|
||||||
|
}
|
||||||
|
export function pour(c) {
|
||||||
|
if (c.step !== 'turn') return false;
|
||||||
|
Object.assign(c, { step: 'pour', t: 0, grab: null });
|
||||||
|
c.events.push({ kind: 'bruciate-sacco', power: 0.8 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Comandi. Il gesto: la direzione del movimento, a ogni tratto, quanto ha girato rispetto al tratto prima.
|
||||||
|
const STROKE = 3;
|
||||||
|
export function aimScreen(c, [x, y]) {
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||||
|
const g = c.grab;
|
||||||
|
if (g && c.step === 'turn') {
|
||||||
|
const [px, py] = g.last ?? [x, y], dx = x - px, dy = y - py;
|
||||||
|
if (Math.hypot(dx, dy) >= STROKE) {
|
||||||
|
const heading = Math.atan2(dy, dx);
|
||||||
|
if (g.heading !== null) c.pending += clamp(wrap(heading - g.heading), -1.2, 1.2);
|
||||||
|
g.heading = heading; g.last = [x, y];
|
||||||
|
} else if (!g.last) g.last = [x, y];
|
||||||
|
}
|
||||||
|
c.cursor = [x, y];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function press(c, pointerId) {
|
||||||
|
if (c.step !== 'turn' || c.grab) return false;
|
||||||
|
c.grab = { pointerId, heading: null, last: c.cursor };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function release(c, pointerId) {
|
||||||
|
if (!c.grab || c.grab.pointerId !== pointerId) return false;
|
||||||
|
c.grab = null; return true;
|
||||||
|
}
|
||||||
|
export const cancel = (c, pointerId) => (pointerId === undefined || c.grab?.pointerId === pointerId) && release(c, c.grab?.pointerId);
|
||||||
|
// Una spinta alla manovella (Spazio, frecce): per chi non fa il gesto.
|
||||||
|
export function push(c) {
|
||||||
|
if (c.step !== 'turn' || c.grab) return false;
|
||||||
|
c.speed = (Math.sign(c.speed) || 1) * (Math.abs(c.speed) + PUSH);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function key(c, k, down) {
|
||||||
|
if (k === 'Enter') { if (down) shoot(c); return true; }
|
||||||
|
if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(k)) return false;
|
||||||
|
if (down) push(c);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Il pulsante: rovescia nel sacco (dopo i primi secondi).
|
||||||
|
export const canThrow = c => c.step === 'turn' && c.t >= MIN_POUR;
|
||||||
|
export const canAdjust = () => false;
|
||||||
|
export const aimFree = () => true;
|
||||||
|
export const shoot = c => canThrow(c) && pour(c);
|
||||||
|
export const button = c => c.step === 'result' ? 'Finita' : c.step === 'pour' ? 'Nel sacco…' : 'Rovescia nel sacco';
|
||||||
|
export function speedWord(c) {
|
||||||
|
const w = Math.abs(c.speed);
|
||||||
|
return w < 0.3 ? 'ferma' : w < c.skill.lo ? 'troppo piano' : w > c.skill.hi ? 'troppo forte' : 'giusta';
|
||||||
|
}
|
||||||
|
export function status(c) {
|
||||||
|
if (c.step === 'result') {
|
||||||
|
const r = c.result, extra = [r.raw && `${r.raw} crude`, r.burnt && `${r.burnt} carbonizzate`, r.spilled && `${r.spilled} nel fuoco`].filter(Boolean).join(', ');
|
||||||
|
return `${r.good} bruciate buone su ${N}${extra ? ` (${extra})` : ''}. ${r.winner === 'player' ? 'I volontari approvano!' : `Ne servivano ${r.goal}.`}`;
|
||||||
|
}
|
||||||
|
if (c.step === 'pour') return 'Nel sacco di iuta, a sudare...';
|
||||||
|
const cooked = `Cottura ${Math.round(doneness(c) * 100)}%.`;
|
||||||
|
if (c.line) return `${c.line} ${cooked}`;
|
||||||
|
const hint = c.t < 3 && Math.abs(c.speed) < 0.3 ? 'Tieni premuto e fai dei giri per girare la manovella.' : `Manovella ${speedWord(c)}.`;
|
||||||
|
return `${hint} ${cooked} Quando son cotte: Rovescia nel sacco.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
// 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 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();
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
|
||||||
|
poly(g, [[cx - 6, y - 30], [cx + 6, y - 30], [x1, y + 2], [x0, y + 2]], '#030910');
|
||||||
|
poly(g, [[cx - 5, y - 30], [cx + 5, y - 30], [x1 - 1, y], [x0 + 1, y]], gradient(g, 0, y - 30, 0, 30, [[0, '#172b38'], [0.8, '#3c5d6b'], [1, '#a6c4c8']]), '#7795a2');
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
|
||||||
|
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
|
||||||
|
}
|
||||||
|
text(g, r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
|
||||||
|
}
|
||||||
|
function rampLights(g, c, r, time) {
|
||||||
|
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
|
||||||
|
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
|
||||||
|
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
|
||||||
|
if (hot) {
|
||||||
|
for (let k = 0; k < 7; k++) {
|
||||||
|
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
|
||||||
|
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
|
||||||
|
if (lit) glow(g, x - 8 + i * 8, y + 6, 7, color, 0.7);
|
||||||
|
disc(g, x - 8 + i * 8, y + 6, 1.8, lit ? color : '#102a36', lit ? '#d6faff' : '#46616a');
|
||||||
|
}
|
||||||
|
const pulse = 0.6 + 0.4 * Math.sin(time * (invited || combo ? 8 : 3));
|
||||||
|
if (invited || combo) glow(g, x, y + 16, 23, color, pulse);
|
||||||
|
poly(g, [[x, y + 10], [x + 4, y + 16], [x + 1.5, y + 16], [x + 1.5, y + 22], [x - 1.5, y + 22], [x - 1.5, y + 16], [x - 4, y + 16]], invited ? WHITE : color);
|
||||||
|
box(g, x - 23, y + 24, 46, 10, '#08151e', color, 3);
|
||||||
|
text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center');
|
||||||
|
}
|
||||||
|
function bumper(g, b, time) {
|
||||||
|
const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec';
|
||||||
|
glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24);
|
||||||
|
g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill();
|
||||||
|
disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b');
|
||||||
|
disc(g, b.x, b.y, b.r - 2, '#172835', color);
|
||||||
|
const v = g.createRadialGradient(b.x - 3, b.y - 4, 0, b.x, b.y, b.r - 3);
|
||||||
|
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
|
||||||
|
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
|
||||||
|
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
|
||||||
|
for (const dx of [-8, 8]) screw(g, b.x + dx, b.y);
|
||||||
|
if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); }
|
||||||
|
}
|
||||||
|
function target(g, c) {
|
||||||
|
for (const t of c.targets) {
|
||||||
|
const [x, 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);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»). Visto dall'alto, la tavola
|
||||||
|
// in verticale al centro del primo piano 640×360; la pallina parte dalla corsia di destra con la molla, fa il giro
|
||||||
|
// dell'arco in cima, passa in una delle quattro corsie dei rioni, rimbalza sui tre funghi (i bastioni della Fortezza),
|
||||||
|
// abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo, e giù dalle sponde elastiche ai
|
||||||
|
// due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al traguardo di
|
||||||
|
// punti del livello (e poi si può continuare per il record). Unità: i pixel del primo piano. La fisica va a passo
|
||||||
|
// 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. Valvola di lancio a senso unico.
|
||||||
|
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
|
||||||
|
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
|
||||||
|
export const PIVOT = { left: [280, 314], right: [360, 314] };
|
||||||
|
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
|
||||||
|
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, 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: 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));
|
||||||
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
|
const seg = (a, b, extra = {}) => ({ a, b, e: 0.55, touching: false, ...extra });
|
||||||
|
|
||||||
|
// La tavola: sponde, corsie, sponde elastiche, funghi, bersagli. Le coordinate seguono il disegno di Codex
|
||||||
|
// (assets/closeups/river-flipper.png): i tre dischi dei funghi, le quattro bandiere dei rioni, la corsia della molla.
|
||||||
|
export function layout() {
|
||||||
|
const arc = [[200, 72], [203, 50], [212, 32], [228, 18], [252, 10], [288, 6], [324, 5], [360, 6], [396, 10], [420, 18], [436, 32], [445, 50], [448, 72]];
|
||||||
|
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. 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).
|
||||||
|
// 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 }));
|
||||||
|
// Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro.
|
||||||
|
const regolo = { x: 404, y: 62, r: 7, hit: 0, touching: false };
|
||||||
|
// 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 }));
|
||||||
|
// 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 = {}) {
|
||||||
|
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||||
|
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, 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 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;
|
||||||
|
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
|
||||||
|
export const canAdjust = canThrow;
|
||||||
|
export const canFlip = c => c.step === 'play';
|
||||||
|
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;
|
||||||
|
// 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 (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il lancio: la molla spinge la pallina su per la corsia. Anche la molla più fiacca arriva all'arco (la velocità
|
||||||
|
// minima dipende dalla gravità del livello); da lì in su, la forza decide quanto gira.
|
||||||
|
export function launchSpeed(c, power) {
|
||||||
|
const min = Math.sqrt(2 * c.skill.g * 340), max = Math.max(min + 150, 900);
|
||||||
|
return min + (max - min) * clamp(power, 0, 1);
|
||||||
|
}
|
||||||
|
export function launch(c, power) {
|
||||||
|
if (!waiting(c)) return false;
|
||||||
|
// La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale.
|
||||||
|
c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0;
|
||||||
|
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
|
||||||
|
c.saveUntil = c.saved ? 0 : c.sim + c.skill.save; c.holding = false; c.holdTime = 0; c.pointerId = null;
|
||||||
|
c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * clamp(power, 0, 1) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
|
||||||
|
// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda.
|
||||||
|
export function hold(c, side, who, down) {
|
||||||
|
const f = c.flippers[side]; if (!f) return false;
|
||||||
|
const was = f.held.length > 0;
|
||||||
|
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
|
||||||
|
if (who === 'click' && down) f.click = CLICK_HOLD;
|
||||||
|
if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function flipperSeg(c, side) {
|
||||||
|
const f = c.flippers[side], [px, py] = PIVOT[side];
|
||||||
|
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// La fisica, a passi fissi.
|
||||||
|
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 = 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']) {
|
||||||
|
const f = c.flippers[side];
|
||||||
|
if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click');
|
||||||
|
const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
|
||||||
|
const previous = f.angle;
|
||||||
|
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
|
||||||
|
f.omega = (f.angle - previous) / h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function step(c) {
|
||||||
|
c.sim += STEP;
|
||||||
|
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
|
||||||
|
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
|
||||||
|
if (c.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]; }
|
||||||
|
// 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);
|
||||||
|
collideRegolo(c, ball);
|
||||||
|
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
|
||||||
|
limit(ball);
|
||||||
|
}
|
||||||
|
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
|
||||||
|
function crossLanes(c, from, ball) {
|
||||||
|
if (!(from.y < LANE_LINE && ball.y >= LANE_LINE)) return;
|
||||||
|
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, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
|
||||||
|
if (c.lanes.every(k => k.lit)) {
|
||||||
|
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
|
||||||
|
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
|
||||||
|
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
|
||||||
|
// indietro e ricasca dall'imbocco.
|
||||||
|
function enterRamps(c, from, ball) {
|
||||||
|
for (const r of c.ramps) {
|
||||||
|
if (!rampOpen(c, r)) continue;
|
||||||
|
const [[x0, y], [x1]] = r.mouth;
|
||||||
|
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
|
||||||
|
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||||||
|
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) { 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 = 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.
|
||||||
|
const hit = hitSeg(ball, w, null);
|
||||||
|
if (!hit || hit.vn <= 0) return;
|
||||||
|
if (hit.fresh && hit.vn > IMPACT) {
|
||||||
|
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 }); 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, [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)) { 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) { 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 = 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 };
|
||||||
|
}
|
||||||
|
function collideBumper(c, ball, b) {
|
||||||
|
const hit = hitCircle(ball, b, 0.2);
|
||||||
|
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, [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, [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) {
|
||||||
|
const f = c.flippers[side], { a, b } = flipperSeg(c, side), [ax, ay] = a, dx = b[0] - ax, dy = b[1] - ay, len2 = dx * dx + dy * dy;
|
||||||
|
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), min = R + FLIP.r;
|
||||||
|
if (d >= min) return false;
|
||||||
|
if (d < 1e-6) { nx = 0; ny = -1; } else { nx /= d; ny /= d; }
|
||||||
|
ball.x = px + nx * min; ball.y = py + ny * min;
|
||||||
|
// La velocità della superficie: ω × r, col raggio dal perno al punto toccato.
|
||||||
|
const rx = px - ax, ry = py - ay, sx = -f.omega * ry, sy = f.omega * rx;
|
||||||
|
const vn = (ball.vx - sx) * nx + (ball.vy - sy) * ny;
|
||||||
|
if (vn < 0) { ball.vx -= (1 + 0.45) * vn * nx; ball.vy -= (1 + 0.45) * vn * ny; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
|
||||||
|
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
|
||||||
|
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
|
||||||
|
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, 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));
|
||||||
|
let any = false;
|
||||||
|
for (const side of ['left', 'right']) if (c.flippers[side].held.includes(pointerId)) any = hold(c, side, pointerId, false) || any;
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
|
||||||
|
export function cancel(c, pointerId) {
|
||||||
|
const all = pointerId === undefined;
|
||||||
|
if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; }
|
||||||
|
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce.
|
||||||
|
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
|
||||||
|
export function stopTurn(c, who = 'button') {
|
||||||
|
if (who === 'click') return false; // il colpo breve finisce da sé
|
||||||
|
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
|
||||||
|
return l || r;
|
||||||
|
}
|
||||||
|
export const nudge = () => false;
|
||||||
|
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
|
||||||
|
export function key(c, k, down) {
|
||||||
|
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
||||||
|
if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; }
|
||||||
|
if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
|
||||||
|
if (k === 'Enter') { if (down) shoot(c); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Il pulsante Lancia: con la forza del cursore.
|
||||||
|
export const shoot = c => canThrow(c) && launch(c, c.power);
|
||||||
|
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
|
||||||
|
export function status(c) {
|
||||||
|
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
||||||
|
const goal = c.result ? 'traguardo fatto, 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}`;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import { newRisveglio, updateRisveglio } from './risveglio.js';
|
|||||||
import { newTelefono, updateTelefono, tapTelefono } from './telefono.js';
|
import { newTelefono, updateTelefono, tapTelefono } from './telefono.js';
|
||||||
import * as condor from './condor.js';
|
import * as condor from './condor.js';
|
||||||
import * as campana from './campana.js';
|
import * as campana from './campana.js';
|
||||||
|
import * as bruciate from './bruciate.js';
|
||||||
|
import * as flipper from './flipper.js';
|
||||||
import * as poker from './poker.js';
|
import * as poker from './poker.js';
|
||||||
|
|
||||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||||
@@ -34,6 +36,8 @@ export const MINIGAMES = {
|
|||||||
cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.aimScreen },
|
cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.aimScreen },
|
||||||
condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen },
|
condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen },
|
||||||
campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen },
|
campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen },
|
||||||
|
bruciate: { create: (seed, level) => bruciate.newBruciate(seed, { level }), update: bruciate.update, press: bruciate.press, release: bruciate.release, cancel: bruciate.cancel, aim: bruciate.aimScreen },
|
||||||
|
flipper: { create: (seed, level) => flipper.newFlipper(seed, { level }), update: flipper.update, press: flipper.press, release: flipper.release, cancel: flipper.cancel, aim: flipper.aimScreen },
|
||||||
micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen },
|
micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen },
|
||||||
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ export function trackFor(story, { screen, scene, phase, closeup, at, ok = () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa
|
// Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa
|
||||||
// si sente sempre). La più forte per prima.
|
// si sente sempre). Sotto una console (entro `near`, sfumando fino a una volta e mezzo) le altre calano fino al loro
|
||||||
|
// filo: lì si sente bene la sola musica di quella (Andrea, 31 ottobre, in cima a Via dell'Antica Posta). La più forte
|
||||||
|
// per prima.
|
||||||
export function mix(stages, [x, y]) {
|
export function mix(stages, [x, y]) {
|
||||||
return stages.map(st => {
|
const near = st => st.near ?? 90, floor = st => st.floor ?? 0.08;
|
||||||
const d = Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5), near = st.near ?? 90, far = st.far ?? 520;
|
const d = stages.map(st => Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5));
|
||||||
const k = Math.max(0, Math.min(1, (d - near) / (far - near)));
|
const under = stages.map((st, i) => Math.max(0, Math.min(1, (near(st) * 1.5 - d[i]) / (near(st) * 0.5))));
|
||||||
return { src: st.music, gain: Math.max(st.floor ?? 0.08, (1 - k) ** 1.6) };
|
return stages.map((st, i) => {
|
||||||
|
const far = st.far ?? 520, k = Math.max(0, Math.min(1, (d[i] - near(st)) / (far - near(st))));
|
||||||
|
const others = Math.max(0, ...under.filter((_, j) => j !== i));
|
||||||
|
return { src: st.music, gain: Math.max(floor(st), (1 - k) ** 1.6 * (1 - others)) };
|
||||||
}).sort((a, b) => b.gain - a.gain);
|
}).sort((a, b) => b.gain - a.gain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { drawCerbottana } from './cerbottana-render.js';
|
|||||||
import { drawCorsa } from './corsa.js';
|
import { drawCorsa } from './corsa.js';
|
||||||
import { drawCondor } from './condor-render.js';
|
import { drawCondor } from './condor-render.js';
|
||||||
import { drawCampana } from './campana-render.js';
|
import { drawCampana } from './campana-render.js';
|
||||||
|
import { drawBruciate } from './bruciate-render.js';
|
||||||
|
import { drawFlipper } from './flipper-render.js';
|
||||||
import { drawPoker } from './poker-render.js';
|
import { drawPoker } from './poker-render.js';
|
||||||
import { drawViaggio } from './viaggio.js';
|
import { drawViaggio } from './viaggio.js';
|
||||||
import { drawIncanto } from './incanto.js';
|
import { drawIncanto } from './incanto.js';
|
||||||
@@ -164,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() } = {}) {
|
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);
|
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
|
||||||
if (!bg && backdrop) loadBackground(backdrop);
|
if (!bg && backdrop) loadBackground(backdrop);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
@@ -178,7 +186,8 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
|
|||||||
for (const shape of art) if (!shape.top) drawShape(ctx, shape);
|
for (const shape of art) if (!shape.top) drawShape(ctx, shape);
|
||||||
// Andrea e i personaggi della scena, dal più lontano al più vicino.
|
// Andrea e i personaggi della scena, dal più lontano al più vicino.
|
||||||
const andrea = sprites.get('andrea');
|
const andrea = sprites.get('andrea');
|
||||||
const drawAndreaNow = () => andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
|
const party = festa(game, time), drawAndreaNow = () => party ? drawSprite(ctx, party.sheet, party.pose, s.x, s.y, game.scaleAt(s.y), s.facing)
|
||||||
|
: andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
|
||||||
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
|
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
|
||||||
// Durante il risveglio si guarda dagli occhi di Andrea: lui non si vede.
|
// Durante il risveglio si guarda dagli occhi di Andrea: lui non si vede.
|
||||||
const pov = game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'risveglio';
|
const pov = game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'risveglio';
|
||||||
@@ -188,6 +197,7 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
|
|||||||
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
|
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
|
||||||
}
|
}
|
||||||
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
|
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
|
||||||
|
if (!pov) crowdFront(ctx, game, time);
|
||||||
drawApparitions(ctx, game, time);
|
drawApparitions(ctx, game, time);
|
||||||
drawPhase(ctx, game);
|
drawPhase(ctx, game);
|
||||||
drawEffects(ctx, game, time);
|
drawEffects(ctx, game, time);
|
||||||
@@ -763,6 +773,38 @@ export const EFFECTS = {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// All'Ingorgo Andrea ha la sua birra (Andrea, 31 ottobre): camminando la tiene davanti al petto, parlando pure; fermo
|
||||||
|
// alza il braccio col bicchiere e lo muove a tempo con la folla. Prendere e voltarsi restano quelli di sempre.
|
||||||
|
const CHEER = ['cheer_1', 'cheer_2', 'cheer_1', 'cheer_3'];
|
||||||
|
export function festa(game, time) {
|
||||||
|
const sheet = sprites.get('andrea_birra'), band = (game.scene.effects ?? []).find(e => e.type === 'crowd' && onNow(e, game));
|
||||||
|
if (!sheet || !band) return null;
|
||||||
|
const pose = game.pose(), beat = Math.floor(time / 60000 * (band.bpm ?? 120)) % CHEER.length;
|
||||||
|
const mine = pose === 'stand' ? CHEER[beat] : pose;
|
||||||
|
return sheet.atlas.frames[mine] ? { sheet, pose: mine } : null;
|
||||||
|
}
|
||||||
|
// Andrea dentro la folla dell'Ingorgo (Andrea, 31 ottobre: «sembra di volare fra le persone»): sopra di lui, dalla
|
||||||
|
// vita in giù, si ridisegna la gente dello sfondo che gli sta davanti. A strisce come l'effetto `crowd`, con lo stesso
|
||||||
|
// sobbalzo a tempo (così le teste davanti saltano con le altre) e il bordo in alto a teste, mai dritto.
|
||||||
|
export function crowdFront(ctx, game, time, bg = backgrounds.get(game.backdrop())) {
|
||||||
|
const bands = (game.scene.effects ?? []).filter(e => e.type === 'crowd' && onNow(e, game));
|
||||||
|
if (!bg || !bands.length) return;
|
||||||
|
const s = game.state, h = PERSON * game.scaleAt(s.y), k = bg.width / WIDTH, waist = s.y - h * 0.5, half = Math.ceil(h * 0.3);
|
||||||
|
for (const e of bands) {
|
||||||
|
const [ax, ay, aw, ah] = e.area, step = e.step ?? 5, amount = e.amount ?? 3, beats = time / 60000 * (e.bpm ?? 120);
|
||||||
|
for (let x = ax + Math.floor((s.x - half - ax) / step) * step; x < s.x + half; x += step) {
|
||||||
|
if (x < ax || x >= ax + aw) continue;
|
||||||
|
const i = (x - ax) / step, b = (beats / 2 + hash(i * 17 + ay)) % 1, jump = b < 0.5 ? Math.sin(b * Math.PI) : 0;
|
||||||
|
const dy = Math.round(amount * (0.4 + 0.6 * hash(i * 31 + 5)) * jump);
|
||||||
|
// La testa di chi sta davanti: ogni striscia alla sua altezza, e salta col battito.
|
||||||
|
const edge = Math.round(waist - h * 0.14 * hash(i * 7 + 3)) - dy;
|
||||||
|
const from = Math.max(edge, ay), to = Math.min(s.y + 3, ay + ah);
|
||||||
|
if (to <= from) continue;
|
||||||
|
const w = Math.min(step, ax + aw - x);
|
||||||
|
ctx.drawImage(bg, x * k, (from + dy) * k, w * k, (to - from) * k, x, from, w, to - from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', 'crowd', ...Object.keys(ambientEffects)]);
|
const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', 'crowd', ...Object.keys(ambientEffects)]);
|
||||||
function drawEffects(ctx, game, time, background = false) {
|
function drawEffects(ctx, game, time, background = false) {
|
||||||
for (const e of game.scene.effects ?? []) {
|
for (const e of game.scene.effects ?? []) {
|
||||||
@@ -1020,6 +1062,16 @@ function drawCloseup(ctx, game, time) {
|
|||||||
drawCampana(ctx, c, def, backgrounds.get(def.picture));
|
drawCampana(ctx, c, def, backgrounds.get(def.picture));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (def.type === 'flipper') {
|
||||||
|
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') {
|
||||||
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
|
drawBruciate(ctx, c, def, backgrounds.get(def.picture));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (def.type === 'micro') {
|
if (def.type === 'micro') {
|
||||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); });
|
drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); });
|
||||||
|
|||||||
@@ -156,6 +156,27 @@ export class Soundscape {
|
|||||||
else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); }
|
else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); }
|
||||||
else if (kind === 'campana-salto') { this.tone(110, 60, 0.07, 0.07, 0, pan); this.noiseSound(0.05, 500, 0.04, { pan }); }
|
else if (kind === 'campana-salto') { this.tone(110, 60, 0.07, 0.07, 0, pan); this.noiseSound(0.05, 500, 0.04, { pan }); }
|
||||||
else if (kind === 'campana-riga') { this.tone(300, 180, 0.12, 0.05, 0, pan); this.tone(220, 120, 0.16, 0.05, 0.1, pan); }
|
else if (kind === 'campana-riga') { this.tone(300, 180, 0.12, 0.05, 0, pan); this.tone(220, 120, 0.16, 0.05, 0.1, pan); }
|
||||||
|
// Le bruciate: il cigolio della manovella, la castagna che scoppietta cuocendo, quella che casca nel fuoco, il sacco.
|
||||||
|
else if (kind === 'bruciate-manovella') { this.tone(420, 380, 0.08, 0.025 * strength, 0, pan); this.noiseSound(0.06, 900, 0.02, { pan }); }
|
||||||
|
else if (kind === 'bruciate-scoppio') { this.noiseSound(0.04, 2600, 0.05 * strength, { pan }); this.tone(900, 500, 0.03, 0.03, 0, pan); }
|
||||||
|
else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); }
|
||||||
|
else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan });
|
||||||
|
// Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco.
|
||||||
|
else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); }
|
||||||
|
else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); }
|
||||||
|
else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); }
|
||||||
|
else if (kind === 'flipper-sponda') { this.tone(520, 260, 0.05, 0.05, 0, pan); this.noiseSound(0.03, 1600, 0.03, { pan }); }
|
||||||
|
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); }
|
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.
|
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
|
||||||
else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); }
|
else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); }
|
||||||
|
|||||||
@@ -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 { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
|
||||||
import { Command } from './engine/command.js';
|
import { Command } from './engine/command.js';
|
||||||
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.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 { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
|
||||||
import { Music, trackFor } from './engine/music.js';
|
import { Music, trackFor } from './engine/music.js';
|
||||||
import { Voices } from './engine/voices.js';
|
import { Voices } from './engine/voices.js';
|
||||||
@@ -15,6 +16,8 @@ import * as micro from './engine/micro.js';
|
|||||||
import * as cerbottana from './engine/cerbottana.js';
|
import * as cerbottana from './engine/cerbottana.js';
|
||||||
import * as condor from './engine/condor.js';
|
import * as condor from './engine/condor.js';
|
||||||
import * as campana from './engine/campana.js';
|
import * as campana from './engine/campana.js';
|
||||||
|
import * as bruciate from './engine/bruciate.js';
|
||||||
|
import * as flipper from './engine/flipper.js';
|
||||||
import * as poker from './engine/poker.js';
|
import * as poker from './engine/poker.js';
|
||||||
import { cardAt as pokerAt } from './engine/poker-render.js';
|
import { cardAt as pokerAt } from './engine/poker-render.js';
|
||||||
import * as basket from './engine/basket.js';
|
import * as basket from './engine/basket.js';
|
||||||
@@ -329,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»).
|
// --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»).
|
||||||
function openPause() {
|
function openPause() {
|
||||||
|
dropPointers();
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
running = false;
|
running = false;
|
||||||
game.closeupCancel();
|
game.closeupCancel();
|
||||||
@@ -490,9 +494,22 @@ const debugView = () => debug && debugShown && game && { mouse, points: debugPoi
|
|||||||
const TOUCH_REACH = 22;
|
const TOUCH_REACH = 22;
|
||||||
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
|
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
|
||||||
let press = null;
|
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-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 => {
|
canvas.addEventListener('pointerdown', e => {
|
||||||
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
|
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
|
||||||
if (!running || e.button > 0) return;
|
if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return;
|
||||||
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||||
if (game.closeup) {
|
if (game.closeup) {
|
||||||
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
|
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
|
||||||
@@ -518,6 +535,13 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 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); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (press?.closeup) return;
|
if (press?.closeup) return;
|
||||||
const aimed = game.closeupAim(scenePoint(e));
|
const aimed = game.closeupAim(scenePoint(e));
|
||||||
if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) {
|
if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) {
|
||||||
@@ -535,6 +559,8 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
|
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
|
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;
|
if (!press) return;
|
||||||
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
|
// 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.
|
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
|
||||||
@@ -572,12 +598,14 @@ canvas.addEventListener('pointerup', e => {
|
|||||||
press = null;
|
press = null;
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointercancel', e => {
|
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?.closeup && e.pointerId !== press.pointerId) return;
|
||||||
if (press) clearTimeout(press.timer);
|
if (press) clearTimeout(press.timer);
|
||||||
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
|
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
|
||||||
press = null;
|
press = null;
|
||||||
});
|
});
|
||||||
canvas.addEventListener('lostpointercapture', e => { 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 => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||||
@@ -585,6 +613,9 @@ canvas.addEventListener('contextmenu', e => {
|
|||||||
canvas.addEventListener('pointermove', e => {
|
canvas.addEventListener('pointermove', e => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||||
|
// 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,
|
// 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.
|
// 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.)
|
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||||
@@ -617,6 +648,8 @@ const MINI = {
|
|||||||
cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' },
|
cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' },
|
||||||
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
||||||
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
||||||
|
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
|
||||||
|
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper o le frecce sinistra e destra.' },
|
||||||
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
||||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||||
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||||
@@ -704,7 +737,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).
|
// 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); });
|
$('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); });
|
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.
|
// --- Verbi.
|
||||||
for (const verb of VERBS) {
|
for (const verb of VERBS) {
|
||||||
@@ -1046,13 +1079,15 @@ function updateUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mg && !cards) {
|
if (mg && !cards) {
|
||||||
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana' }[def.type] ?? 'Comandi delle bocce');
|
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate', flipper: 'Comandi del flipper' }[def.type] ?? 'Comandi delle bocce');
|
||||||
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||||
const line = mg.status(c, def.names);
|
const line = mg.status(c, def.names);
|
||||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||||
setValue($('bocce-power'), Math.round(c.power * 100));
|
setValue($('bocce-power'), Math.round(c.power * 100));
|
||||||
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
|
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
|
||||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
||||||
|
// Al flipper i due pulsanti valgono per tutta la partita, il cursore della molla solo prima del lancio.
|
||||||
|
if (mg.canFlip) for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'disabled', !mg.canFlip(c) || !!game.speech);
|
||||||
setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
|
||||||
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
|
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
|
||||||
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
|
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
|
||||||
@@ -1060,9 +1095,9 @@ function updateUi() {
|
|||||||
// Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
|
// Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
|
||||||
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
|
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
|
||||||
const easy = def.type === 'gara' && gara.simple(c);
|
const easy = def.type === 'gara' && gara.simple(c);
|
||||||
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy);
|
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy);
|
||||||
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana');
|
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate');
|
||||||
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza ';
|
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : def.type === 'flipper' ? 'Molla ' : 'Forza ';
|
||||||
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
|
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
|
||||||
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
|
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
|
||||||
setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
|
setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
|
||||||
@@ -1070,8 +1105,8 @@ function updateUi() {
|
|||||||
c.sensLoaded = true;
|
c.sensLoaded = true;
|
||||||
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
|
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
|
||||||
}
|
}
|
||||||
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
|
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : def.type === 'flipper' ? '← Flipper' : '← Mira');
|
||||||
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
|
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : def.type === 'flipper' ? 'Flipper →' : 'Mira →');
|
||||||
setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
|
setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
|
||||||
$('bocce-throw').classList.toggle('hold', easy);
|
$('bocce-throw').classList.toggle('hold', easy);
|
||||||
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
|
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
|
||||||
@@ -1083,6 +1118,8 @@ function updateUi() {
|
|||||||
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
|
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
|
||||||
setProp($('condor-help'), 'hidden', def.type !== 'condor');
|
setProp($('condor-help'), 'hidden', def.type !== 'condor');
|
||||||
setProp($('campana-help'), 'hidden', def.type !== 'campana');
|
setProp($('campana-help'), 'hidden', def.type !== 'campana');
|
||||||
|
setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate');
|
||||||
|
setProp($('flipper-help'), 'hidden', def.type !== 'flipper');
|
||||||
setProp($('basket-help'), 'hidden', def.type !== 'basket');
|
setProp($('basket-help'), 'hidden', def.type !== 'basket');
|
||||||
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
|
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
|
||||||
setProp($('gara-help'), 'hidden', def.type !== 'gara');
|
setProp($('gara-help'), 'hidden', def.type !== 'gara');
|
||||||
@@ -1114,7 +1151,7 @@ function updateUi() {
|
|||||||
canvas.classList.toggle('torch', !!torch);
|
canvas.classList.toggle('torch', !!torch);
|
||||||
canvas.classList.toggle('bocce', !!mg);
|
canvas.classList.toggle('bocce', !!mg);
|
||||||
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
||||||
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[c.id].type));
|
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
|
||||||
$('stage').classList.toggle('briscola', !!cards);
|
$('stage').classList.toggle('briscola', !!cards);
|
||||||
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
||||||
// La pagina che comincia a girare fa il suo fruscio.
|
// La pagina che comincia a girare fa il suo fruscio.
|
||||||
@@ -1153,6 +1190,15 @@ function frame(now) {
|
|||||||
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
|
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
|
||||||
if (game && (!still || still !== drawnStill)) {
|
if (game && (!still || still !== drawnStill)) {
|
||||||
if (running) game.update(dt);
|
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() });
|
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.
|
// 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; }
|
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' },
|
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).
|
// 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' },
|
{ 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).
|
// 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' },
|
{ 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]
|
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const story = {
|
|||||||
// col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie]
|
// col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie]
|
||||||
zones: [
|
zones: [
|
||||||
{
|
{
|
||||||
flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta'],
|
flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta', 'via_calimara', 'via_cipriani'],
|
||||||
exitsOnly: true, // alla festa si clicca solo per spostarsi (Andrea)
|
exitsOnly: true, // alla festa si clicca solo per spostarsi (Andrea)
|
||||||
leave: [
|
leave: [
|
||||||
{ say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' },
|
{ say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' },
|
||||||
@@ -360,6 +360,29 @@ export const story = {
|
|||||||
draw: [],
|
draw: [],
|
||||||
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
|
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
|
||||||
},
|
},
|
||||||
|
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex,
|
||||||
|
// fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie]
|
||||||
|
flipper: {
|
||||||
|
type: 'flipper',
|
||||||
|
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.' }],
|
||||||
|
draw: [],
|
||||||
|
quit: [{ say: 'Lascio la pallina lì. Tanto il River non chiude.' }],
|
||||||
|
},
|
||||||
|
// Le bruciate della Smarronata, 1993: la manovella del cilindro sul fuoco, coi volontari della Misericordia
|
||||||
|
// (Andrea, 31 ottobre: «a manovella, a mano»). [DA ANDREA: le battute sono provvisorie]
|
||||||
|
bruciate: {
|
||||||
|
type: 'bruciate',
|
||||||
|
picture: 'assets/closeups/smarronata-bruciatore.png',
|
||||||
|
names: { player: 'Andrea', cpu: 'i volontari' },
|
||||||
|
win: [{ say: 'Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.' }, { say: 'Bravo, figliolo! Se ti va, l\'anno prossimo la manovella è tua.', who: 'volontari', pose: 'give' }, { set: 'bruciate_fatte' }],
|
||||||
|
lose: [{ say: 'Un po\' crude, un po\' carbone... Le mangio io, va\'.' }, { say: 'Ci vuole il polso, figliolo. Il polso e la pazienza.', who: 'volontari', pose: 'explain' }],
|
||||||
|
draw: [],
|
||||||
|
quit: [{ say: 'Vi rendo la manovella. Mi fumano anche gli occhi.' }],
|
||||||
|
},
|
||||||
// Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta
|
// Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta
|
||||||
// l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio]
|
// l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio]
|
||||||
manifesto_ingorgo: {
|
manifesto_ingorgo: {
|
||||||
@@ -520,6 +543,7 @@ export const story = {
|
|||||||
// Andrea col pallone, per «Fai due tiri» (foglio di Codex dal suo sprite): non parla, lo disegna basket-render.
|
// Andrea col pallone, per «Fai due tiri» (foglio di Codex dal suo sprite): non parla, lo disegna basket-render.
|
||||||
andrea_basket: { name: 'Andrea', sprite: 'assets/characters/andrea-basket.json' },
|
andrea_basket: { name: 'Andrea', sprite: 'assets/characters/andrea-basket.json' },
|
||||||
andrea_pesca: { name: 'Andrea', sprite: 'assets/characters/andrea-pesca.json' },
|
andrea_pesca: { name: 'Andrea', sprite: 'assets/characters/andrea-pesca.json' },
|
||||||
|
andrea_birra: { name: 'Andrea', sprite: 'assets/characters/andrea-birra.json' }, // all'Ingorgo, con la birra
|
||||||
// Quattro ragazzi inventati (sprite di Codex, idea di Andrea del 27 ottobre): nessuna persona reale.
|
// Quattro ragazzi inventati (sprite di Codex, idea di Andrea del 27 ottobre): nessuna persona reale.
|
||||||
pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' },
|
pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' },
|
||||||
// La gente della Smarronata del 1993 (Andrea, 31 ottobre): tutti inventati, vestiti come nei fotogrammi del video.
|
// La gente della Smarronata del 1993 (Andrea, 31 ottobre): tutti inventati, vestiti come nei fotogrammi del video.
|
||||||
@@ -1240,6 +1264,19 @@ export const story = {
|
|||||||
name: 'Via Pietro Cipriani',
|
name: 'Via Pietro Cipriani',
|
||||||
map: [22, 64],
|
map: [22, 64],
|
||||||
background: 'assets/scenes/via-cipriani.png',
|
background: 'assets/scenes/via-cipriani.png',
|
||||||
|
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-via-cipriani.png' }], // Codex, la notte dell'Ingorgo (31 ottobre)
|
||||||
|
// All'Ingorgo anche l'inizio di Via Cipriani (Andrea, 31 ottobre): la console sul campo da basket, le casse in
|
||||||
|
// cima alle scale, i laser rossi e lo strobo, e giù la gente che poga (la folla che salta, forte davanti).
|
||||||
|
// La hardcore techno di Andrea (Suno, 31 ottobre) alla console del campo; da lontano la house della Piazza e la tecno.
|
||||||
|
effects: [{ type: 'crowd', phase: 'ingorgo', area: [0, 235, 640, 55], bpm: 180, amount: 3 }, { type: 'crowd', phase: 'ingorgo', area: [0, 290, 640, 70], bpm: 180, amount: 6 },
|
||||||
|
{ type: 'lasers', phase: 'ingorgo', at: [300, 160], count: 9, color: '255,40,40', len: 440 },
|
||||||
|
{ type: 'stagelights', phase: 'ingorgo', from: [[262, 152], [338, 152]], wash: [300, 172, 160], bpm: 180, beams: 2, strobe: 8, pulse: 0.1, colors: ['255,40,40', '255,255,255', '255,90,40'] },
|
||||||
|
{ type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 180, count: 7, colors: ['255,40,40', '255,255,255', '255,90,40'] }],
|
||||||
|
stages: [
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-hardcore.mp3', at: [300, 250], near: 150, far: 800 },
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [140, 60], far: 700 },
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [-250, 100], far: 800 },
|
||||||
|
],
|
||||||
walk: [[40, 360], [40, 320], [60, 280], [100, 272], [150, 276], [300, 300], [360, 318], [640, 345], [640, 360]],
|
walk: [[40, 360], [40, 320], [60, 280], [100, 272], [150, 276], [300, 300], [360, 318], [640, 345], [640, 360]],
|
||||||
depth: [[272, 0.45], [360, 1.15]],
|
depth: [[272, 0.45], [360, 1.15]],
|
||||||
entry: [300, 345],
|
entry: [300, 345],
|
||||||
@@ -1591,6 +1628,20 @@ export const story = {
|
|||||||
name: 'Via Calimara',
|
name: 'Via Calimara',
|
||||||
map: [36, 58],
|
map: [36, 58],
|
||||||
background: 'assets/scenes/via-calimara.png',
|
background: 'assets/scenes/via-calimara.png',
|
||||||
|
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-via-calimara.png' }], // Codex, la notte dell'Ingorgo (31 ottobre)
|
||||||
|
// All'Ingorgo anche Via Calimara (Andrea, 31 ottobre): la console nel giardino sotto i cedri, con la palla a
|
||||||
|
// specchi appesa al ramo, e la strada piena fino in fondo, con la folla che salta (Andrea: «mi raccomando»).
|
||||||
|
// La musica anni '90 di Andrea (Suno, 31 ottobre) alla console del giardino; da lontano la house della Piazza
|
||||||
|
// e il latino del Comune.
|
||||||
|
effects: [{ type: 'crowd', phase: 'ingorgo', area: [0, 190, 640, 75], bpm: 130, amount: 3 }, { type: 'crowd', phase: 'ingorgo', area: [0, 265, 640, 95], bpm: 130, amount: 5 },
|
||||||
|
{ type: 'mirrorball', phase: 'ingorgo', at: [170, 123], hang: 96, from: [104, 178], surfaces: [[0, 40, 300, 140], [0, 190, 640, 170], [560, 90, 80, 110]], bpm: 130, count: 64 },
|
||||||
|
{ type: 'stagelights', phase: 'ingorgo', from: [[54, 168], [154, 168]], wash: [104, 185, 150], bpm: 130, beams: 2, strobe: 4, pulse: 0.08, colors: ['255,90,180', '255,170,60', '255,220,120', '200,90,255'] },
|
||||||
|
{ type: 'floorspots', phase: 'ingorgo', area: [40, 220, 560, 130], bpm: 130, count: 7, colors: ['255,90,180', '255,170,60', '255,220,120', '200,90,255'] }],
|
||||||
|
stages: [
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-anni90.mp3', at: [104, 230], near: 150, far: 800 },
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [365, 60], far: 700 },
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-300, 250], far: 800 },
|
||||||
|
],
|
||||||
walk: [[0, 360], [0, 318], [330, 206], [400, 206], [560, 300], [600, 360]],
|
walk: [[0, 360], [0, 318], [330, 206], [400, 206], [560, 300], [600, 360]],
|
||||||
depth: [[206, 0.3], [360, 1.15]],
|
depth: [[206, 0.3], [360, 1.15]],
|
||||||
entry: [300, 350],
|
entry: [300, 350],
|
||||||
@@ -1625,10 +1676,13 @@ export const story = {
|
|||||||
entry: [140, 340],
|
entry: [140, 340],
|
||||||
hotspots: [
|
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: {
|
actions: {
|
||||||
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
|
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
|
||||||
open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.' }],
|
// Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo.
|
||||||
|
open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima... il flipper.' }, { dialogue: 'flipper' }],
|
||||||
|
use: [{ say: 'Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.' }, { dialogue: 'flipper' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1901,11 +1955,13 @@ export const story = {
|
|||||||
{ type: 'pixelspots', phase: 'ingorgo', area: [130, 242, 490, 108], bpm: 122, count: 5, size: 20, colors: ['155,65,255', '65,110,255'] },
|
{ type: 'pixelspots', phase: 'ingorgo', area: [130, 242, 490, 108], bpm: 122, count: 5, size: 20, colors: ['155,65,255', '65,110,255'] },
|
||||||
],
|
],
|
||||||
stages: [
|
stages: [
|
||||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235] },
|
// Giù a destra, sul marciapiede sotto il giardinetto, si arriva sotto la console: lì solo l'underground.
|
||||||
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235], near: 190 },
|
||||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [270, 120], far: 600 },
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [270, 120], far: 600 },
|
||||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [240, 640], far: 750 },
|
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [240, 640], far: 750 },
|
||||||
],
|
],
|
||||||
walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]],
|
// Col marciapiede di destra, fra le catenelle e il giardinetto, fino in fondo (Andrea, 31 ottobre).
|
||||||
|
walk: [[0, 360], [0, 322], [190, 226], [283, 222], [350, 236], [452, 272], [640, 335], [640, 360]],
|
||||||
depth: [[222, 0.35], [360, 1.15]],
|
depth: [[222, 0.35], [360, 1.15]],
|
||||||
entry: [240, 350],
|
entry: [240, 350],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
@@ -3169,8 +3225,16 @@ export const story = {
|
|||||||
id: 'zio_mario', name: 'lo zio Mario', box: [418, 206, 46, 124], at: [362, 334], face: 'right', default: 'look', when: '!indizio_regolo',
|
id: 'zio_mario', name: 'lo zio Mario', box: [418, 206, 46, 124], at: [362, 334], face: 'right', default: 'look', when: '!indizio_regolo',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'Lo zio Mario. Guarda il muro, guarda la macchina, e si gratta la testa sotto il berretto.' }],
|
look: [{ say: 'Lo zio Mario. Guarda il muro, guarda la macchina, e si gratta la testa sotto il berretto.' }],
|
||||||
// Il primo scambio con lo zio (Andrea, 29 ottobre).
|
// Il primo scambio con lo zio (Andrea, 29 ottobre); «Siamo a cavallo al ciuco», «Menomale tu ci sei te» e
|
||||||
talk: [{ say: 'Zio, hai problemi?' }, { say: 'I problemi io gli avevo quando andavo a scuola!', who: 'zio_mario', pose: 'laugh' }],
|
// «Alla terza tu capisci subito» sono di Andrea (31 ottobre). [DA ANDREA: la domanda sulla Ghiacciomobile]
|
||||||
|
talk: [
|
||||||
|
{ say: 'Zio, hai problemi?' },
|
||||||
|
{ say: 'I problemi io gli avevo quando andavo a scuola!', who: 'zio_mario', pose: 'laugh' },
|
||||||
|
{ say: 'E con la Ghiacciomobile nel muro, come si fa?' },
|
||||||
|
{ say: 'Siamo a cavallo al ciuco!', who: 'zio_mario', pose: 'explain' },
|
||||||
|
{ say: 'Menomale tu ci sei te, Zio!' },
|
||||||
|
{ say: 'Alla terza tu capisci subito, al volo!', who: 'zio_mario', pose: 'laugh' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3661,6 +3725,11 @@ export const story = {
|
|||||||
{ say: 'No, dormo!', who: 'zio_mario', pose: 'laugh' },
|
{ say: 'No, dormo!', who: 'zio_mario', pose: 'laugh' },
|
||||||
{ say: 'Zio, a me mi garba Michael Jackson!' },
|
{ say: 'Zio, a me mi garba Michael Jackson!' },
|
||||||
{ say: 'Mai col gesso!', who: 'zio_mario', pose: 'laugh' },
|
{ say: 'Mai col gesso!', who: 'zio_mario', pose: 'laugh' },
|
||||||
|
// Il posto lontanissimo dello zio (Andrea, 31 ottobre).
|
||||||
|
{ say: 'Sai, Zio? Son stato in California!' },
|
||||||
|
{ say: 'E dov\'è?', who: 'zio_mario', pose: 'think' },
|
||||||
|
{ say: 'In America!' },
|
||||||
|
{ say: 'Ahhh... sopra Firenzola!', who: 'zio_mario', pose: 'point' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3734,7 +3803,8 @@ export const story = {
|
|||||||
id: 'bruciatore', name: 'il cilindro delle bruciate', box: [192, 188, 68, 54], at: [226, 262], face: 'up',
|
id: 'bruciatore', name: 'il cilindro delle bruciate', box: [192, 188, 68, 54], at: [226, 262], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'Il cilindro delle bruciate: si gira la manovella sopra il fuoco, e le castagne ci ballano dentro.' }],
|
look: [{ say: 'Il cilindro delle bruciate: si gira la manovella sopra il fuoco, e le castagne ci ballano dentro.' }],
|
||||||
use: [{ say: 'Si gira piano, sennò le bruciate si bruciano davvero.' }],
|
// Il minigioco delle bruciate: la manovella si gira coi volontari (Andrea, 31 ottobre).
|
||||||
|
use: [{ say: 'Posso girare io la manovella?' }, { dialogue: 'volontari' }],
|
||||||
take: [{ say: 'Una bruciata calda, presa al volo. Scotta! Ma è buonissima.' }],
|
take: [{ say: 'Una bruciata calda, presa al volo. Scotta! Ma è buonissima.' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3763,11 +3833,7 @@ export const story = {
|
|||||||
id: 'volontari', name: 'i volontari della Misericordia', box: [130, 148, 97, 114], at: [200, 272], face: 'left', default: 'talk',
|
id: 'volontari', name: 'i volontari della Misericordia', box: [130, 148, 97, 114], at: [200, 272], face: 'left', default: 'talk',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'I volontari della Misericordia, alle bruciate: uno rimescola, l\'altro riempie i cartocci.' }],
|
look: [{ say: 'I volontari della Misericordia, alle bruciate: uno rimescola, l\'altro riempie i cartocci.' }],
|
||||||
talk: [
|
talk: [{ dialogue: 'volontari' }],
|
||||||
{ say: 'Che Iddio ve ne renda merito!! A voi e agli alpini!' },
|
|
||||||
{ say: 'E a te, figliolo. To\', un cartoccio: son belle calde.', who: 'volontari', pose: 'give' },
|
|
||||||
{ say: 'Scottano! Ma son buonissime.' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4237,6 +4303,30 @@ export const story = {
|
|||||||
},
|
},
|
||||||
// Il BaronDeDuke nella discesa verso la polisportiva: le mutande perse a poker, e il Muro del Sanpierino sul
|
// Il BaronDeDuke nella discesa verso la polisportiva: le mutande perse a poker, e il Muro del Sanpierino sul
|
||||||
// telefonino (Andrea, 31 ottobre: lui è il webmaster di sanpiero50037.it).
|
// telefonino (Andrea, 31 ottobre: lui è il webmaster di sanpiero50037.it).
|
||||||
|
// I volontari della Misericordia alle bruciate della Smarronata, 1993: il ringraziamento di Andrea e la manovella
|
||||||
|
// (il minigioco delle bruciate, Andrea, 31 ottobre). [DA ANDREA: le battute sono provvisorie]
|
||||||
|
volontari: {
|
||||||
|
nodes: {
|
||||||
|
start: { options: [
|
||||||
|
{ text: 'Che Iddio ve ne renda merito!! A voi e agli alpini!', then: [
|
||||||
|
{ say: 'E a te, figliolo. To\', un cartoccio: son belle calde.', who: 'volontari', pose: 'give' },
|
||||||
|
{ say: 'Scottano! Ma son buonissime.' },
|
||||||
|
] },
|
||||||
|
{ text: 'Vi do una mano alla manovella?', next: 'livello', then: [
|
||||||
|
{ say: 'Su, giovanotto, che noi s\'ha le braccia stanche!', who: 'volontari', pose: 'talk' },
|
||||||
|
{ say: 'Si gira sempre uguale: piano piano sotto si bruciano, a furia volan nel fuoco. Quando son cotte, nel sacco.', who: 'volontari', pose: 'explain' },
|
||||||
|
{ say: 'Come te la senti?', who: 'volontari', pose: 'talk' },
|
||||||
|
] },
|
||||||
|
{ text: 'Buon lavoro!', next: 'end' },
|
||||||
|
] },
|
||||||
|
livello: { options: [
|
||||||
|
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: ti si guarda noi, e se si bruciano un si dice a nessuno.', who: 'volontari', pose: 'talk' }, { closeup: 'bruciate', level: 'bona' }] },
|
||||||
|
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria: la gente è in fila, e il cartoccio lo vole bello pieno.', who: 'volontari', pose: 'explain' }, { closeup: 'bruciate', level: 'seria' }] },
|
||||||
|
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Allora ci si mette anche il fuoco forte.', who: 'volontari', pose: 'talk' }, { closeup: 'bruciate', level: 'male' }] },
|
||||||
|
{ text: 'No, dopo.', next: 'start' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
},
|
||||||
barondeduke: {
|
barondeduke: {
|
||||||
nodes: {
|
nodes: {
|
||||||
start: { options: [
|
start: { options: [
|
||||||
@@ -4365,6 +4455,19 @@ export const story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Da solo al campo del Circolo: Andrea decide quanto farsi male.
|
// Da solo al campo del Circolo: Andrea decide quanto farsi male.
|
||||||
|
// Il flipper del River: Andrea da solo davanti alla tavola, sceglie il livello. [DA ANDREA: battute provvisorie]
|
||||||
|
flipper: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{ 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
basket: {
|
basket: {
|
||||||
nodes: {
|
nodes: {
|
||||||
start: {
|
start: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.63';
|
export const VERSION = '0.1.67';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
|
|
||||||
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||||
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
|
#scene.pinball-hd { image-rendering: auto; }
|
||||||
#scene.torch { touch-action: none; }
|
#scene.torch { touch-action: none; }
|
||||||
#scene.bocce { touch-action: none; }
|
#scene.bocce { touch-action: none; }
|
||||||
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
|
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
|
||||||
@@ -62,16 +63,17 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
|
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
|
||||||
#bocce-throw.hold { min-height: 72px; font-size: 14px; }
|
#bocce-throw.hold { min-height: 72px; font-size: 14px; }
|
||||||
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
||||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
#bocce-left, #bocce-right { touch-action: none; }
|
||||||
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary { cursor: pointer; padding: 6px 0; }
|
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||||
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p { max-width: 76em; line-height: 1.7; }
|
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary, #flipper-help summary { cursor: pointer; padding: 6px 0; }
|
||||||
|
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p, #flipper-help p { max-width: 76em; line-height: 1.7; }
|
||||||
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
||||||
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||||
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||||
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||||
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||||
#bocce-throw { flex-basis: 100%; }
|
#bocce-throw { flex-basis: 100%; }
|
||||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { font-size: 9px; }
|
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { font-size: 9px; }
|
||||||
}
|
}
|
||||||
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
||||||
#effects-volume { width: 130px; accent-color: var(--accent); }
|
#effects-volume { width: 130px; accent-color: var(--accent); }
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Le bruciate nel browser vero: dai volontari della Smarronata, la manovella girata col mouse e col dito, il sacco.
|
||||||
|
// npm run browser:bruciate — schermate in /tmp/regolo-bruciate.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { staticServer } from './helpers.mjs';
|
||||||
|
|
||||||
|
const out = process.env.OUT ?? '/tmp/regolo-bruciate';
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const server = await staticServer();
|
||||||
|
let browser;
|
||||||
|
const errors = [];
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { settle } = await import('/tests/helpers.mjs');
|
||||||
|
settle(game); game.go('smarronata_1993'); settle(game);
|
||||||
|
game.act('talk', 'volontari'); settle(game);
|
||||||
|
game.choose(game.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(game);
|
||||||
|
game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game);
|
||||||
|
});
|
||||||
|
await page.waitForSelector('#bocce-controls:not([hidden])');
|
||||||
|
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/smarronata-bruciatore.png'));
|
||||||
|
assert.equal(await page.locator('#bocce-throw').textContent(), 'Rovescia nel sacco');
|
||||||
|
assert.ok(await page.locator('#bruciate-help').isVisible());
|
||||||
|
assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira');
|
||||||
|
assert.ok(!(await page.locator('#bocce-power').isVisible()), 'niente forza');
|
||||||
|
await page.screenshot({ path: `${out}/${name}-ferma.png` });
|
||||||
|
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
|
||||||
|
// I giri attorno alla manovella: col mouse tenendo premuto, col dito trascinando (eventi di puntatore veri).
|
||||||
|
const loops = async (turns, steps = 16) => {
|
||||||
|
const at = i => { const a = i / steps * Math.PI * 2; return P(506 + Math.cos(a) * 40, 190 + Math.sin(a) * 40); };
|
||||||
|
if (mobile) {
|
||||||
|
const cdp = await page.context().newCDPSession(page), touch = (type, [x, y]) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] });
|
||||||
|
await touch('touchStart', at(0));
|
||||||
|
for (let i = 1; i <= turns * steps; i++) await touch('touchMove', at(i));
|
||||||
|
await touch('touchEnd', at(0));
|
||||||
|
} else {
|
||||||
|
await page.mouse.move(...at(0)); await page.mouse.down();
|
||||||
|
for (let i = 1; i <= turns * steps; i++) await page.mouse.move(...at(i));
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await loops(3);
|
||||||
|
const turned = await page.evaluate(() => Math.abs(game.closeup.drum));
|
||||||
|
assert.ok(turned > 6, `il cilindro ha girato: ${turned.toFixed(1)} rad`);
|
||||||
|
await page.screenshot({ path: `${out}/${name}-gira.png` });
|
||||||
|
// Il resto della cottura lo si fa fare al gioco a velocità giusta (le schermate con swiftshader sono lente).
|
||||||
|
await page.evaluate(() => { const c = game.closeup; c.speed = 4.5; for (const k of c.chestnuts) k.cook = 1.05; c.t = Math.max(c.t, 5); });
|
||||||
|
await page.waitForFunction(() => !document.querySelector('#bocce-throw').disabled);
|
||||||
|
await page.screenshot({ path: `${out}/${name}-cotte.png` });
|
||||||
|
if (mobile) await page.locator('#bocce-throw').tap(); else await page.locator('#bocce-throw').click();
|
||||||
|
await page.waitForFunction(() => game.closeup?.step === 'result', null, { timeout: 10000 });
|
||||||
|
assert.match(await page.locator('#bocce-status').textContent(), /bruciate buone su 18/);
|
||||||
|
await page.screenshot({ path: `${out}/${name}-sacco.png` });
|
||||||
|
await page.locator('#closeup-close').click();
|
||||||
|
await page.waitForFunction(() => game.state.flags.bruciate_fatte);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(`ok: schermate in ${out}`);
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as m from '../src/engine/bruciate.js';
|
||||||
|
import { drawBruciate } from '../src/engine/bruciate-render.js';
|
||||||
|
import { Game } from '../src/engine/game.js';
|
||||||
|
import { story } from '../src/story/story.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Gira la manovella tenendola, a `w` radianti al secondo, finché le castagne non paiono cotte (o per `secs` secondi).
|
||||||
|
function crank(c, w, { secs = 60, until = 1.2 } = {}) {
|
||||||
|
c.grab ??= { pointerId: 1, heading: null, last: null };
|
||||||
|
for (let i = 0; i < secs * 60 && c.step === 'turn' && m.doneness(c) < until; i++) { c.pending = w / 60; m.update(c, 1 / 60); }
|
||||||
|
}
|
||||||
|
function pourAndWait(c) {
|
||||||
|
assert.ok(m.shoot(c), 'si rovescia nel sacco');
|
||||||
|
for (let i = 0; i < 200 && c.step === 'pour'; i++) m.update(c, 1 / 60);
|
||||||
|
assert.equal(c.step, 'result');
|
||||||
|
return c.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('girando a modo le bruciate vengono tutte buone, nel tempo del livello', () => {
|
||||||
|
for (const level of ['bona', 'seria', 'male']) {
|
||||||
|
const c = m.newBruciate(42, { level });
|
||||||
|
crank(c, 4.5);
|
||||||
|
assert.ok(c.t > 6 && c.t < m.LEVELS[level].time, `${level}: cotte in ${c.t.toFixed(1)} s`);
|
||||||
|
const r = pourAndWait(c);
|
||||||
|
assert.equal(r.winner, 'player', `${level}: ${JSON.stringify(r)}`);
|
||||||
|
assert.equal(r.spilled, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ferme sul fuoco: quelle di sotto carbone, quelle di sopra crude', () => {
|
||||||
|
const c = m.newBruciate(42, { level: 'seria' });
|
||||||
|
crank(c, 0);
|
||||||
|
const r = pourAndWait(c);
|
||||||
|
assert.equal(r.winner, 'cpu');
|
||||||
|
assert.ok(r.burnt > 0 && r.raw > 0, JSON.stringify(r));
|
||||||
|
assert.match(m.status(c), /Ne servivano 15/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('troppo forte è una centrifuga: le castagne volano nel fuoco', () => {
|
||||||
|
const c = m.newBruciate(42, { level: 'seria' });
|
||||||
|
crank(c, 12, { secs: 4, until: 9 });
|
||||||
|
assert.ok(c.chestnuts.some(k => k.out), 'qualcuna è volata fuori');
|
||||||
|
assert.ok(c.flying.length > 0);
|
||||||
|
assert.match(c.line, /centrifuga|fuoco/);
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'bruciate-cade'));
|
||||||
|
assert.equal(m.speedWord(c), 'troppo forte');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rovesciarle troppo presto: crude; e il pulsante aspetta i primi secondi', () => {
|
||||||
|
const c = m.newBruciate(42, { level: 'bona' });
|
||||||
|
assert.ok(!m.canThrow(c) && !m.shoot(c), 'appena cominciato non si rovescia');
|
||||||
|
crank(c, 4.5, { until: 0.4 });
|
||||||
|
const r = pourAndWait(c);
|
||||||
|
assert.equal(r.winner, 'cpu'); assert.ok(r.raw > 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il gesto: i giri, grandi o piccoli e dovunque, fanno girare il cilindro; una riga dritta no', () => {
|
||||||
|
const circles = (c, cx, cy, radius, turns, secs) => {
|
||||||
|
assert.ok(m.press(c, 7));
|
||||||
|
const steps = Math.round(secs * 60);
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const a = i / steps * turns * Math.PI * 2;
|
||||||
|
m.aimScreen(c, [cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]);
|
||||||
|
m.update(c, 1 / 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const [x, y, r] of [[506, 190, 30], [200, 300, 80], [320, 180, 15]]) {
|
||||||
|
const c = m.newBruciate(1);
|
||||||
|
m.aimScreen(c, [x + r, y]);
|
||||||
|
circles(c, x, y, r, 2, 2);
|
||||||
|
assert.ok(Math.abs(c.speed) > 4 && Math.abs(c.speed) < 9, `giri di raggio ${r}: ${c.speed.toFixed(2)} rad/s`);
|
||||||
|
assert.ok(Math.abs(c.drum) > 6);
|
||||||
|
// Lasciata la manovella, il cilindro rallenta da sé.
|
||||||
|
assert.ok(m.release(c, 7));
|
||||||
|
const w = Math.abs(c.speed);
|
||||||
|
for (let i = 0; i < 60; i++) m.update(c, 1 / 60);
|
||||||
|
assert.ok(Math.abs(c.speed) < w * 0.5);
|
||||||
|
}
|
||||||
|
const c = m.newBruciate(1);
|
||||||
|
m.aimScreen(c, [100, 200]); m.press(c, 1);
|
||||||
|
for (let i = 0; i < 120; i++) { m.aimScreen(c, [100 + i * 3, 200]); m.update(c, 1 / 60); }
|
||||||
|
assert.ok(Math.abs(c.speed) < 0.2, 'trascinare dritto non gira');
|
||||||
|
assert.ok(!m.release(c, 99), 'un altro dito non lascia la manovella');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('senza gesto: Spazio e le frecce danno una spinta, Invio rovescia', () => {
|
||||||
|
const c = m.newBruciate(3);
|
||||||
|
assert.ok(m.key(c, ' ', true));
|
||||||
|
assert.ok(Math.abs(c.speed) >= m.PUSH);
|
||||||
|
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(Math.abs(c.speed) >= m.PUSH * 1.5);
|
||||||
|
assert.ok(m.key(c, ' ', false), 'anche lasciando il tasto, il tasto è suo');
|
||||||
|
assert.ok(!m.key(c, 'x', true));
|
||||||
|
for (let i = 0; i < 60 * m.MIN_POUR + 2; i++) m.update(c, 1 / 60);
|
||||||
|
m.key(c, 'Enter', true);
|
||||||
|
assert.equal(c.step, 'pour');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il disegno delle bruciate non cambia lo stato', () => {
|
||||||
|
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
|
||||||
|
const c = m.newBruciate(7, { level: 'bona' }), bg = {};
|
||||||
|
const draw = () => { const before = structuredClone(c); drawBruciate(ctx, c, story.closeups.bruciate, 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 === 'GIRA!'), 'da fermo, l\'invito a girare');
|
||||||
|
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'COTTURA'));
|
||||||
|
crank(c, 12, { secs: 2, until: 9 }); draw();
|
||||||
|
crank(c, 4.5); pourAndWait(c); calls.length = 0; draw();
|
||||||
|
assert.ok(calls.some(([op, t]) => op === 'fillText' && /BRUCIATE/.test(t)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alla Smarronata: dai volontari, «Vi do una mano alla manovella?», il livello e le bruciate', () => {
|
||||||
|
const g = new Game(story); g.go('smarronata_1993'); settle(g);
|
||||||
|
g.act('talk', 'volontari'); settle(g);
|
||||||
|
assert.ok(g.options().some(o => o.text.startsWith('Che Iddio ve ne renda merito')), 'il ringraziamento resta');
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g);
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
|
||||||
|
assert.equal(g.closeup?.id, 'bruciate'); assert.equal(g.closeup.level, 'seria');
|
||||||
|
crank(g.closeup, 4.5); pourAndWait(g.closeup);
|
||||||
|
g.closeupClose(); settle(g);
|
||||||
|
assert.ok(g.state.flags.bruciate_fatte);
|
||||||
|
// Anche usando il cilindro si chiede la manovella; uscendo prima, la battuta di chi lascia.
|
||||||
|
g.act('use', 'bruciatore'); settle(g);
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g);
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g);
|
||||||
|
assert.equal(g.closeup?.id, 'bruciate');
|
||||||
|
g.closeupClose();
|
||||||
|
assert.equal(g.queue[0]?.say, story.closeups.bruciate.quit[0].say);
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper con due dita sulla scena,
|
||||||
|
// coi pulsanti e con le frecce, la pausa che lascia tutto, il risultato. npm run browser:flipper — schermate in
|
||||||
|
// /tmp/regolo-flipper.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { staticServer } from './helpers.mjs';
|
||||||
|
|
||||||
|
const out = process.env.OUT ?? '/tmp/regolo-flipper';
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const server = await staticServer();
|
||||||
|
let browser;
|
||||||
|
const errors = [];
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||||
|
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||||
|
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { settle } = await import('/tests/helpers.mjs');
|
||||||
|
settle(game); game.go('via_provinciale'); settle(game);
|
||||||
|
game.act('use', 'river'); settle(game);
|
||||||
|
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
|
||||||
|
});
|
||||||
|
await page.waitForSelector('#bocce-controls:not([hidden])');
|
||||||
|
await page.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.
|
||||||
|
if (mobile) {
|
||||||
|
await touch('touchStart', [P(440, 320)]);
|
||||||
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
||||||
|
await touch('touchEnd', []);
|
||||||
|
} else {
|
||||||
|
await page.mouse.move(...P(440, 320)); await page.mouse.down();
|
||||||
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
|
||||||
|
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
|
||||||
|
if (mobile) {
|
||||||
|
await touch('touchStart', [P(250, 200)]);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
||||||
|
await touch('touchStart', [P(250, 200), P(400, 200)]);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0 && game.closeup.flippers.left.held.length > 0, null, { timeout: 3000 });
|
||||||
|
await page.screenshot({ path: `${out}/${name}-flipper.png` });
|
||||||
|
await touch('touchEnd', [P(400, 200)]);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
|
||||||
|
await touch('touchEnd', []);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
||||||
|
// I pulsanti tenuti premuti.
|
||||||
|
const b = await page.locator('#bocce-left').boundingBox();
|
||||||
|
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
||||||
|
await touch('touchEnd', []);
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
|
||||||
|
} else {
|
||||||
|
await page.mouse.move(...P(400, 200)); await page.mouse.down();
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
|
||||||
|
await page.mouse.down({ button: 'right' });
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 1, null, { timeout: 3000 });
|
||||||
|
await page.screenshot({ path: `${out}/${name}-flipper.png` });
|
||||||
|
await page.mouse.up(); await page.mouse.up({ button: 'right' });
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
||||||
|
await page.keyboard.down('ArrowLeft');
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
||||||
|
// La pausa lascia tutto: tornando, il flipper è giù anche se il tasto era premuto.
|
||||||
|
await page.click('#pause-button');
|
||||||
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
|
||||||
|
await page.keyboard.up('ArrowLeft');
|
||||||
|
await page.click('#resume');
|
||||||
|
await page.waitForFunction(() => document.querySelector('#pause').hidden);
|
||||||
|
}
|
||||||
|
// 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.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, []);
|
||||||
|
console.log(`ok: schermate in ${out}`);
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as m from '../src/engine/flipper.js';
|
||||||
|
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++) {
|
||||||
|
if (m.waiting(c)) m.launch(c, 0.75);
|
||||||
|
const b = c.ball;
|
||||||
|
for (const side of ['left', 'right']) {
|
||||||
|
const [px, py] = m.PIVOT[side], dir = side === 'left' ? 1 : -1, rel = (b ? b.x - px : 0) * dir;
|
||||||
|
const near = b && !b.waiting && b.vy > 0 && b.y > py - 34 && b.y < py + 14 && rel > -6 && rel < 44;
|
||||||
|
if (near !== c.flippers[side].held.includes('ai')) m.hold(c, side, 'ai', near);
|
||||||
|
}
|
||||||
|
m.update(c, 1 / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('la tavola: tutto sta dentro il campo, le corsie coprono tutta la larghezza sotto l\'arco, i funghi sui dischi del disegno', () => {
|
||||||
|
const c = m.newFlipper(1);
|
||||||
|
for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`);
|
||||||
|
// I tre dischi dipinti da Codex (misurati sull'immagine): i funghi ci stanno sopra, a meno di 3 pixel.
|
||||||
|
for (const [b, [x, y]] of c.bumpers.map((b, i) => [b, [[281, 120], [360, 120], [320, 153]][i]])) assert.ok(Math.hypot(b.x - x, b.y - y) < 3);
|
||||||
|
assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI);
|
||||||
|
assert.equal(c.lanes[0].x0, m.FIELD.x0); assert.equal(c.lanes[3].x1, m.LANE.x0);
|
||||||
|
for (let i = 1; i < 4; i++) assert.equal(c.lanes[i].x0, c.lanes[i - 1].x1, 'corsie contigue');
|
||||||
|
assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS);
|
||||||
|
assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il lancio: anche la molla più fiacca arriva in campo, quella forte fa il giro dell\'arco; la ricaduta si rilancia', () => {
|
||||||
|
for (const level of ['bona', 'male']) for (const power of [0, 0.5, 1]) {
|
||||||
|
const c = m.newFlipper(2, { level }); assert.ok(m.launch(c, power)); assert.ok(!m.waiting(c));
|
||||||
|
let top = 999, out = false;
|
||||||
|
for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) out = true; }
|
||||||
|
assert.ok(top < 50, `${level} forza ${power}: in cima (${top.toFixed(0)})`);
|
||||||
|
assert.ok(out, `${level} forza ${power}: entrata in campo`);
|
||||||
|
}
|
||||||
|
// Una pallina ricaduta nella corsia si assesta e si rilancia, senza perderla.
|
||||||
|
const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0.5); put(d, { x: 440, y: 200, vx: 0, vy: 50 }); run(d, 3);
|
||||||
|
assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola e non dipende dal fotogramma', () => {
|
||||||
|
const c = m.newFlipper(4, { level: 'seria' });
|
||||||
|
for (let n = 3; n >= 1; n--) {
|
||||||
|
assert.equal(c.balls, n);
|
||||||
|
m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto
|
||||||
|
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 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/);
|
||||||
|
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).
|
||||||
|
for (const dts of [[1 / 240, 60], [1 / 60, 15], [0.25, 1]]) {
|
||||||
|
const e = m.newFlipper(6, { level: 'seria' }); m.launch(e, 0.8); e.sim = 2.9; e.saveUntil = 3; put(e, { x: 320, y: 344, vx: 0, vy: 100 });
|
||||||
|
for (let i = 0; i < dts[1]; i++) m.update(e, dts[0]);
|
||||||
|
assert.equal(e.balls, 3, `salvata con passi da ${dts[0]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il flipper alzato rimanda su la pallina, anche a tutta velocità (niente attraversamenti); abbassato la lascia scendere', () => {
|
||||||
|
const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9);
|
||||||
|
put(up, { x: 300, y: 300, vx: 0, vy: 150 }); m.hold(up, 'left', 'test', true);
|
||||||
|
let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); }
|
||||||
|
assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`);
|
||||||
|
assert.ok(up.events.some(e => e.kind === 'flipper-flip'));
|
||||||
|
// Il caso riprodotto da Codex: pallina a 980 px/s sulla punta del flipper che si alza.
|
||||||
|
const fast = m.newFlipper(6, { level: 'seria' }); m.launch(fast, 0.9);
|
||||||
|
put(fast, { x: 313.438, y: 323.71, vx: -469.837, vy: 860.031 }); m.hold(fast, 'left', 'test', true);
|
||||||
|
m.update(fast, m.STEP);
|
||||||
|
assert.ok(fast.ball.vy < 0 && fast.ball.y < 324, `respinta, non passata sotto (y ${fast.ball.y.toFixed(1)}, vy ${fast.ball.vy.toFixed(0)})`);
|
||||||
|
const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9);
|
||||||
|
put(down, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; run(down, 2);
|
||||||
|
assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le quattro corsie insieme il Palio', () => {
|
||||||
|
const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9);
|
||||||
|
const b = c.bumpers[0];
|
||||||
|
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
|
||||||
|
assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta');
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'flipper-fungo'));
|
||||||
|
// La pallina appoggiata sul Regolo (Codex: 240.000 punti in un secondo): non conta.
|
||||||
|
c.score = 0; put(c, { x: c.regolo.x, y: c.regolo.y - c.regolo.r - m.R, vx: 0, vy: 0 });
|
||||||
|
for (let i = 0; i < 240; i++) m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score, 0, 'il contatto che dura non fa punti');
|
||||||
|
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
|
||||||
|
assert.equal(c.score, m.SCORE.regolo);
|
||||||
|
c.score = 0;
|
||||||
|
for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; put(c, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); }
|
||||||
|
assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank);
|
||||||
|
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.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]]) {
|
||||||
|
put(c, { x, y: c.lanes[i].y - 3, vx: 0, vy: 150 }); run(c, 0.1);
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => {
|
||||||
|
const c = m.newFlipper(8, { level: 'bona' });
|
||||||
|
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
|
||||||
|
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
|
||||||
|
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
|
||||||
|
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
|
||||||
|
m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
|
||||||
|
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
|
||||||
|
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
|
||||||
|
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
|
||||||
|
m.cancel(c); assert.ok(!c.flippers.right.held.length, 'annullare tutto (pausa) lascia i flipper');
|
||||||
|
// I pulsanti e le frecce; il pulsante da tastiera dà un colpo breve che la fisica vede.
|
||||||
|
assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); assert.ok(m.stopTurn(c, 'button')); assert.ok(!c.flippers.left.held.length);
|
||||||
|
m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click'));
|
||||||
|
const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length);
|
||||||
|
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
|
||||||
|
assert.ok(!m.key(c, 'x', true));
|
||||||
|
assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale');
|
||||||
|
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
|
||||||
|
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
|
||||||
|
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
|
||||||
|
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => {
|
||||||
|
const c = m.newFlipper(11, { level: 'bona' }); m.launch(c, 0.9);
|
||||||
|
c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
|
||||||
|
assert.ok(c.result && c.result.winner === 'player' && c.step === 'play', 'traguardo fatto, si continua');
|
||||||
|
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 = 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.
|
||||||
|
const h = m.newFlipper(12, { level: 'bona' }); m.launch(h, 0.9); m.hold(h, 'left', 't', true); run(h, 0.5);
|
||||||
|
put(h, { x: 296, y: 300, vx: 0, vy: 0 }); run(h, 4);
|
||||||
|
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++) {
|
||||||
|
const c = m.newFlipper(seed * 7919, { level: 'bona' });
|
||||||
|
autoplay(c);
|
||||||
|
assert.equal(c.step, 'result', `seme ${seed}: finita`);
|
||||||
|
if (c.result.winner === 'player') won++;
|
||||||
|
}
|
||||||
|
assert.ok(won >= 5, `vinte ${won} su 8`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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, 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: 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);
|
||||||
|
assert.equal(g.closeup?.id, 'flipper'); assert.equal(g.closeup.level, 'seria');
|
||||||
|
g.closeupClose();
|
||||||
|
assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say);
|
||||||
|
settle(g);
|
||||||
|
g.act('open', 'river'); settle(g);
|
||||||
|
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)})`);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/eng
|
|||||||
import { Game, check, POSTER_READY } from '../src/engine/game.js';
|
import { Game, check, POSTER_READY } from '../src/engine/game.js';
|
||||||
import { story } from '../src/story/story.js';
|
import { story } from '../src/story/story.js';
|
||||||
import { settle } from './helpers.mjs';
|
import { settle } from './helpers.mjs';
|
||||||
|
import { inside as inPolygon, findPath } from '../src/engine/geometry.js';
|
||||||
import { render } from '../src/engine/render.js';
|
import { render } from '../src/engine/render.js';
|
||||||
|
|
||||||
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
|
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
|
||||||
@@ -36,6 +37,18 @@ test('la miscela delle console: vicino a una si sente quella, fra due tutte e du
|
|||||||
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
|
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sotto una console si sente la sola musica di quella: in cima a Via dell\'Antica Posta, il marciapiede sotto l\'underground', () => {
|
||||||
|
const sc = story.scenes.antica_posta_alta, ok = cond => cond === 'ingorgo';
|
||||||
|
const spot = [620, 350];
|
||||||
|
assert.ok(inPolygon(sc.walk, spot) && findPath(sc.walk, sc.entry, spot), 'ci si arriva a piedi');
|
||||||
|
const under = trackFor(story, { screen: 'game', scene: 'antica_posta_alta', at: spot, ok });
|
||||||
|
assert.equal(under[0].src, 'assets/audio/ingorgo-underground.mp3'); assert.equal(under[0].gain, 1);
|
||||||
|
for (const t of under.slice(1)) assert.ok(t.gain <= 0.1, `${t.src} solo un filo: ${t.gain.toFixed(2)}`);
|
||||||
|
// A metà della strada, invece, la miscela resta.
|
||||||
|
const mid = trackFor(story, { screen: 'game', scene: 'antica_posta_alta', at: [240, 300], ok });
|
||||||
|
assert.ok(mid.filter(t => t.gain > 0.15).length >= 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
|
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
|
||||||
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
|
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
|
||||||
const m = new Music({ volume: 0.5 });
|
const m = new Music({ volume: 0.5 });
|
||||||
@@ -82,9 +95,9 @@ test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; us
|
|||||||
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
|
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
|
||||||
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
|
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
|
||||||
}
|
}
|
||||||
g.go('via_cipriani'); settle(g);
|
g.go('via_massorondinaio'); settle(g);
|
||||||
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
|
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
|
||||||
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
|
assert.equal(g.backdrop(), story.scenes.via_massorondinaio.background);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
|
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
|
||||||
@@ -137,7 +150,7 @@ test('alla festa le luci da palco si muovono, e solo alla festa', () => {
|
|||||||
const frame = time => { ops.length = 0; render(ctx, g, { time }); return JSON.stringify(ops.filter(([op]) => op === 'lineTo' || op === 'fillRect')); };
|
const frame = time => { ops.length = 0; render(ctx, g, { time }); return JSON.stringify(ops.filter(([op]) => op === 'lineTo' || op === 'fillRect')); };
|
||||||
assert.notEqual(frame(1000), frame(1700), `${scene}: le luci si muovono`);
|
assert.notEqual(frame(1000), frame(1700), `${scene}: le luci si muovono`);
|
||||||
}
|
}
|
||||||
g.go('via_cipriani'); settle(g); g.go('piazza'); settle(g);
|
g.go('via_massorondinaio'); settle(g); g.go('piazza'); settle(g);
|
||||||
ops.length = 0; render(ctx, g, { time: 1000 });
|
ops.length = 0; render(ctx, g, { time: 1000 });
|
||||||
assert.ok(!ops.some(([op, x, y]) => op === 'moveTo' && x === 116 && y === 146), 'di giorno niente teste mobili');
|
assert.ok(!ops.some(([op, x, y]) => op === 'moveTo' && x === 116 && y === 146), 'di giorno niente teste mobili');
|
||||||
} finally { delete globalThis.Image; }
|
} finally { delete globalThis.Image; }
|
||||||
@@ -155,6 +168,36 @@ test('le musiche e gli sfondi della festa esistono', () => {
|
|||||||
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all\'Ingorgo Andrea ha la birra: il foglio coi passi, il braccio alzato a tempo e la battuta', async () => {
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
const atlas = JSON.parse(readFileSync(new URL('../assets/characters/andrea-birra.json', import.meta.url)));
|
||||||
|
for (const f of ['walk_1', 'walk_2', 'walk_3', 'walk_4', 'cheer_1', 'cheer_2', 'cheer_3', 'talk']) assert.ok(atlas.frames[f], f);
|
||||||
|
assert.equal(story.characters.andrea_birra.sprite, 'assets/characters/andrea-birra.json');
|
||||||
|
const { festa } = await import('../src/engine/render.js');
|
||||||
|
const g = new Game(story); g.go('piazza'); settle(g);
|
||||||
|
assert.equal(festa(g, 0), null, 'senza la festa (e senza il foglio caricato) Andrea è quello di sempre');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alla festa Andrea sta dentro la folla: dalla vita in giù la gente dello sfondo gli passa davanti', async () => {
|
||||||
|
const { crowdFront } = await import('../src/engine/render.js');
|
||||||
|
const g = new Game(story); g.go('piazzetta', [330, 320]); settle(g);
|
||||||
|
const calls = [], ctx = { drawImage: (...a) => calls.push(a) }, bg = { width: 1280 };
|
||||||
|
crowdFront(ctx, g, 0, bg);
|
||||||
|
assert.equal(calls.length, 0, 'di giorno, senza la festa, niente');
|
||||||
|
g.state.flags.ingorgo = true;
|
||||||
|
const { x, y } = g.state, h = 62 * g.scaleAt(y);
|
||||||
|
for (const time of [0, 250, 500]) {
|
||||||
|
calls.length = 0; crowdFront(ctx, g, time, bg);
|
||||||
|
assert.ok(calls.length > 3, 'strisce di folla sopra Andrea');
|
||||||
|
for (const [img, , , , , dx, dy, dw, dh] of calls) {
|
||||||
|
assert.equal(img, bg);
|
||||||
|
assert.ok(dx + dw > x - h * 0.4 && dx < x + h * 0.4, 'solo davanti a lui');
|
||||||
|
assert.ok(dy >= y - h * 0.75 && dy + dh <= y + 3, 'dalla vita in giù, fino ai piedi');
|
||||||
|
}
|
||||||
|
assert.ok(new Set(calls.map(c => c[6])).size > 1, 'il bordo in alto a teste, mai dritto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('alla festa la folla dipinta salta a tempo: l\'effetto crowd in tutte e cinque le scene, dietro piano e davanti forte', async () => {
|
test('alla festa la folla dipinta salta a tempo: l\'effetto crowd in tutte e cinque le scene, dietro piano e davanti forte', async () => {
|
||||||
const { EFFECTS } = await import('../src/engine/render.js');
|
const { EFFECTS } = await import('../src/engine/render.js');
|
||||||
assert.equal(typeof EFFECTS.crowd, 'function');
|
assert.equal(typeof EFFECTS.crowd, 'function');
|
||||||
|
|||||||