Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba2e7a0aa | ||
|
|
94902edc57 | ||
|
|
721df30937 | ||
|
|
40d5780255 | ||
|
|
9f2b045ab0 | ||
|
|
187b4c48cb | ||
|
|
c132e94ed8 | ||
|
|
9cd8027256 | ||
|
|
9f0f60d186 | ||
|
|
5135689293 | ||
|
|
8693bff0c7 | ||
|
|
c6812db291 |
@@ -87,11 +87,11 @@ background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,
|
||||
@test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; }
|
||||
$(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS)
|
||||
|
||||
character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand)
|
||||
character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand, FIX=wave=1.3)
|
||||
$(PYTHON) tools/slice_sprites.py --sheet $(SHEET) --out /tmp/regolo-$(NAME) --cols 4 --rows 2 \
|
||||
--despill --names $(FRAMES) --prefix $(NAME)_
|
||||
$(PYTHON) tools/prepare_character.py /tmp/regolo-$(NAME) $(NAME) assets/characters --height $(HEIGHT) \
|
||||
--frames $(FRAMES) --ref $(REF)
|
||||
--frames $(FRAMES) --ref $(REF) $(if $(FIX),--fix $(FIX))
|
||||
|
||||
## ------------------------------------------------------------ docker
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "baldoria.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 59,
|
||||
"h": 120,
|
||||
"ax": 28.6,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 61,
|
||||
"y": 0,
|
||||
"w": 59,
|
||||
"h": 120,
|
||||
"ax": 28.4,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 122,
|
||||
"y": 0,
|
||||
"w": 59,
|
||||
"h": 120,
|
||||
"ax": 28.6,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 183,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 122,
|
||||
"ax": 29.4,
|
||||
"ay": 122
|
||||
},
|
||||
"wave": {
|
||||
"x": 252,
|
||||
"y": 0,
|
||||
"w": 101,
|
||||
"h": 156,
|
||||
"ax": 48.9,
|
||||
"ay": 156
|
||||
},
|
||||
"wave2": {
|
||||
"x": 355,
|
||||
"y": 0,
|
||||
"w": 127,
|
||||
"h": 156,
|
||||
"ax": 81.8,
|
||||
"ay": 156
|
||||
},
|
||||
"cheer": {
|
||||
"x": 484,
|
||||
"y": 0,
|
||||
"w": 70,
|
||||
"h": 120,
|
||||
"ax": 33.6,
|
||||
"ay": 120
|
||||
},
|
||||
"laugh": {
|
||||
"x": 556,
|
||||
"y": 0,
|
||||
"w": 78,
|
||||
"h": 113,
|
||||
"ax": 31.1,
|
||||
"ay": 113
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -119,6 +119,10 @@
|
||||
"file": "andrea-559fbc00c4.mp3",
|
||||
"sec": 4.2
|
||||
},
|
||||
"andrea|Lo lascio dov'è: è l'unica cosa che ricorda luglio, qui, a maggio.": {
|
||||
"file": "andrea-9f315e91e5.mp3",
|
||||
"sec": 4.4
|
||||
},
|
||||
"andrea|I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.": {
|
||||
"file": "andrea-af4d717814.mp3",
|
||||
"sec": 6.44
|
||||
@@ -1095,6 +1099,14 @@
|
||||
"file": "barondeduke-238f525535.mp3",
|
||||
"sec": 1.65
|
||||
},
|
||||
"andrea|Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.": {
|
||||
"file": "andrea-a85f0e0a9a.mp3",
|
||||
"sec": 6.47
|
||||
},
|
||||
"andrea|Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.": {
|
||||
"file": "andrea-4faef889c8.mp3",
|
||||
"sec": 5.63
|
||||
},
|
||||
"andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": {
|
||||
"file": "andrea-a50071085e.mp3",
|
||||
"sec": 6.84
|
||||
@@ -2827,6 +2839,38 @@
|
||||
"file": "pierino_parigi-8104163151.mp3",
|
||||
"sec": 3.3
|
||||
},
|
||||
"andrea|Baldoria! Ma che ci fai col bandierone al campo?": {
|
||||
"file": "andrea-3433db09f3.mp3",
|
||||
"sec": 3.79
|
||||
},
|
||||
"andrea|Guarda che qui la Sampdoria non ci gioca.": {
|
||||
"file": "andrea-408197f33d.mp3",
|
||||
"sec": 3.85
|
||||
},
|
||||
"andrea|Hai visto passare il palio, per caso?": {
|
||||
"file": "andrea-a0ac302eb3.mp3",
|
||||
"sec": 1.76
|
||||
},
|
||||
"andrea|Ciao, Baldoria.": {
|
||||
"file": "andrea-6697e2ce25.mp3",
|
||||
"sec": 0.97
|
||||
},
|
||||
"baldoria|Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.": {
|
||||
"file": "baldoria-954f26beb7.mp3",
|
||||
"sec": 6.08
|
||||
},
|
||||
"baldoria|Forza Doria!": {
|
||||
"file": "baldoria-97a6835622.mp3",
|
||||
"sec": 1.03
|
||||
},
|
||||
"baldoria|La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.": {
|
||||
"file": "baldoria-1eb6f19203.mp3",
|
||||
"sec": 5.54
|
||||
},
|
||||
"baldoria|L'unico palio che conosco è blu, bianco, rosso e nero. E ce l'ho in mano.": {
|
||||
"file": "baldoria-0accd6c002.mp3",
|
||||
"sec": 6.1
|
||||
},
|
||||
"andrea|Taglia! Si fa una gara con la Condor?": {
|
||||
"file": "andrea-99f683209e.mp3",
|
||||
"sec": 3.41
|
||||
@@ -2835,6 +2879,10 @@
|
||||
"file": "andrea-3f77b83f35.mp3",
|
||||
"sec": 0.87
|
||||
},
|
||||
"andrea|E una partita a campana, Taglia?": {
|
||||
"file": "andrea-595df0a426.mp3",
|
||||
"sec": 2.23
|
||||
},
|
||||
"andrea|Ma quanto sei alto, Taglia?": {
|
||||
"file": "andrea-01ecdf3ed8.mp3",
|
||||
"sec": 1.96
|
||||
@@ -2859,6 +2907,14 @@
|
||||
"file": "taglia-ac012a1066.mp3",
|
||||
"sec": 4.21
|
||||
},
|
||||
"taglia|A campana? Alla nostra età?": {
|
||||
"file": "taglia-0a7f69770d.mp3",
|
||||
"sec": 2.77
|
||||
},
|
||||
"taglia|Va bene: il gesso ce l'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.": {
|
||||
"file": "taglia-c00be84faf.mp3",
|
||||
"sec": 11.54
|
||||
},
|
||||
"taglia|Abbastanza da vedere i pallini arrivare prima di te.": {
|
||||
"file": "taglia-d99c875252.mp3",
|
||||
"sec": 2.57
|
||||
@@ -2875,6 +2931,18 @@
|
||||
"file": "taglia-4117f6613d.mp3",
|
||||
"sec": 4.84
|
||||
},
|
||||
"taglia|Alla bona: le righe un contano. Comincia te.": {
|
||||
"file": "taglia-328d930124.mp3",
|
||||
"sec": 4.12
|
||||
},
|
||||
"taglia|Seria: chi tocca la riga è fuori. Comincia te.": {
|
||||
"file": "taglia-1266463538.mp3",
|
||||
"sec": 4.23
|
||||
},
|
||||
"taglia|A fassi male? Io la campana la facevo a occhi chiusi.": {
|
||||
"file": "taglia-7938d8d5e4.mp3",
|
||||
"sec": 5.05
|
||||
},
|
||||
"andrea|Si fa una gara alle macchinine?!?!": {
|
||||
"file": "andrea-17216997e2.mp3",
|
||||
"sec": 2.74
|
||||
@@ -3343,6 +3411,46 @@
|
||||
"file": "andrea-aba12c5241.mp3",
|
||||
"sec": 3.1
|
||||
},
|
||||
"andrea|Dalla 1 fino in cima, e ritorno. A ventisett'anni, davanti alle poste.": {
|
||||
"file": "andrea-68378e4c21.mp3",
|
||||
"sec": 6.15
|
||||
},
|
||||
"taglia|Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.": {
|
||||
"file": "taglia-1e6660932d.mp3",
|
||||
"sec": 3.34
|
||||
},
|
||||
"andrea|Il Taglia, su un piede solo, è più alto di me su due.": {
|
||||
"file": "andrea-3ed6165062.mp3",
|
||||
"sec": 3.31
|
||||
},
|
||||
"taglia|È questione di gambe lunghe.": {
|
||||
"file": "taglia-9782816264.mp3",
|
||||
"sec": 1.71
|
||||
},
|
||||
"andrea|Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.": {
|
||||
"file": "andrea-356a1afe8d.mp3",
|
||||
"sec": 5.91
|
||||
},
|
||||
"andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": {
|
||||
"file": "andrea-0c26e34815.mp3",
|
||||
"sec": 7.53
|
||||
},
|
||||
"andrea|Quanto darei per essere all'Ingorgo stasera?!?": {
|
||||
"file": "andrea-366c066d68.mp3",
|
||||
"sec": 2.28
|
||||
},
|
||||
"andrea|Mahhh... è l'INGORGOOOO!!!": {
|
||||
"file": "andrea-5694cd3779.mp3",
|
||||
"sec": 3.74
|
||||
},
|
||||
"andrea|Le console, la gente, le spine della birra!": {
|
||||
"file": "andrea-a6bceec795.mp3",
|
||||
"sec": 2.57
|
||||
},
|
||||
"andrea|Il palio può aspettare una sera. O no?": {
|
||||
"file": "andrea-24e5ac53da.mp3",
|
||||
"sec": 3.07
|
||||
},
|
||||
"chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": {
|
||||
"file": "chiaru-91a4b23b22.mp3",
|
||||
"sec": 4.44
|
||||
|
||||
@@ -36,7 +36,11 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
|
||||
| `characters/zio_mario.png` | Codex | `foto/zio-mario.png` | Andrea (29 ottobre) | sì (famiglia di Andrea) |
|
||||
| `terrazza-cardetole.png` | Codex (dalla foto, poi il terrazzo davanti) | `cardetole-finestra.jpg` | Andrea (29 ottobre) | **da decidere**: come `cardetole.png`, dice dove abita Andrea |
|
||||
| `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì |
|
||||
| `closeups/macchina-dietro.png`, `macchina-lampi.png`, `macchina-davanti.png` | Codex, dallo sprite della macchina del tempo | nessuna | — | sì |
|
||||
| `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì |
|
||||
| `closeups/campone-campana.png` | Codex, solo testo (stile dal campone delle poste) | nessuna | — | sì |
|
||||
| `closeups/manifesto-ingorgo.png` | Codex, dal manifesto vero (2014) senza sponsor, loghi e data | `assets/raw/riferimenti/manifesto-ingorgo-2014.png` | Pro Loco San Piero a Sieve (manifesto), Andrea | sì |
|
||||
| `ingorgo-piazza.png`, `ingorgo-antica-posta.png`, `ingorgo-piazzetta-comune.png`, `ingorgo-antica-posta-alta.png`, `ingorgo-piazzetta.png` | Codex, dagli sfondi di giorno delle stesse scene | foto della festa di Andrea (`assets/raw/foto/ingorgo-*.png`), solo per l'atmosfera | Andrea | sì |
|
||||
| `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì |
|
||||
| `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) |
|
||||
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
|
||||
|
||||
@@ -18,6 +18,40 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
||||
|
||||
## Dove siamo arrivati
|
||||
|
||||
- **L'Ingorgo Sonoro (30 ottobre, extra)**: in piazza il vecchio manifesto dell'Ingorgo (`manifesto_ingorgo`); guardandolo
|
||||
si apre ingrandito (primo piano `poster`, `manifesto-ingorgo.png`: Codex dal manifesto vero del 2014, senza sponsor né
|
||||
data); toccandolo, «Quanto darei per essere all'Ingorgo stasera?!?», poi l'effetto speciale (primo piano `incanto`, `incanto.js`: fasci di
|
||||
luce, cassa che accelera, lampo bianco, coriandoli) e il centro diventa la notte della festa (flag `ingorgo`, fase
|
||||
`ingorgo`, sfondi di notte di Codex in `backdrops`). La zona (`story.zones`): Piazza, Via dell'Antica Posta, Piazzetta
|
||||
del Comune, cima di Via dell'Antica Posta, Piazzetta Cambray Digny; uscendo (a piedi o con la mappa) si torna a maggio.
|
||||
Alla festa si clicca solo per spostarsi (zona con `exitsOnly`: panchine, porte e statue non rispondono).
|
||||
Le luci da discoteca animate solo alla festa (`stagelights`, `lasers`, `mirrorball`, `floorspots` in render.js), rinforzate
|
||||
su richiesta di Andrea: colpo di colore a ogni battuta, strobo a raffica a fine frase, macchie che corrono sulla folla. Fasci che spazzano, bagliore a
|
||||
tempo, strobo, laser alla tecno, palla a specchi all'underground. Le console (`stages` delle scene, `music.js` `mix`): più brani insieme, ognuno più forte vicino alla sua console, anche
|
||||
da fuori quadro (la piazza accanto). Generi provvisori: house in Piazza, latino al Comune, underground in cima a Via
|
||||
dell'Antica Posta, tecno a Cambray Digny; i brani sono di Andrea (Suno), portati al volume del gioco. Gli sfondi sono
|
||||
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`).
|
||||
[DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere.
|
||||
- **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).
|
||||
Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più
|
||||
piccole le figure col bandierone in alto). Un dialogo breve. [DA ANDREA: le battute; la somiglianza va rivista con
|
||||
una sua foto]
|
||||
- **La campana al campone delle poste (30 ottobre)**: dal Taglia, «E una partita a campana, Taglia?», poi il livello.
|
||||
Primo piano `campana` visto dall'alto sul vialetto (`campone-campana.png`, Codex); la campana col gesso la disegna il
|
||||
codice (`campana.js`, `campana-render.js`): 1-2-3, coppia 4-5, 6, coppia 7-8 e in cima il mezzo cerchio con scritto
|
||||
CAMPANA (Andrea: non «cielo»), ogni casella tinta tenue col suo gesso. Il sassolino: la mira dà la direzione, tenendo
|
||||
premuto la forza va e torna, si lascia; deve cadere nella casella che tocca senza toccare le righe (alla bona le
|
||||
righe non contano e il puntino giallo mostra dove cade). I salti: la lancetta dell'equilibrio attraversa la barra e
|
||||
si preme nel verde (presto: giù l'altro piede; tardi: pestata la riga); si scavalca la casella col sassolino e
|
||||
tornando lo si raccoglie. Chi ce la fa passa alla casella dopo e ritira, chi sbaglia passa la mano; il Taglia gioca
|
||||
da solo (in simulazione gli servono circa 22, 10 e 6 turni secondo il livello). Vince chi finisce la 8 (flag
|
||||
`campana_vinta`). Test `campana.test.mjs`, prova `npm run browser:campana`. [DA ANDREA] le battute.
|
||||
- **La macchina del tempo da fuori (30 ottobre)**: dopo il cruscotto, la partenza giù per Via Liguri Magelli
|
||||
(primo piano `corsa`, `src/engine/corsa.js`): accelera, il fulmine la prende, il bianco, e restano due sgommate di
|
||||
fuoco che bruciano e si spengono; al ritorno dal 1997 l'arrivo: il fulmine in fondo alla strada, lei viene avanti e
|
||||
ci passa accanto lasciando le strisce. La macchina da dietro e davanti (coi lampi) è di Codex.
|
||||
- **Lo zio Mario negli orti al tramonto (30 ottobre)**: dal tramonto (`indizio_regolo`) non è più davanti alla
|
||||
Ghiacciomobile ma accanto al suo orto (`orti_zio_mario`), con una battuta provvisoria [DA ANDREA].
|
||||
- **La Condor al campone delle poste (29 ottobre)**: la gara col Taglia (Andrea Tagliaferri, consenso dato) con la
|
||||
|
||||
@@ -283,6 +283,17 @@
|
||||
mira un po' più sopra; alla bona il puntino giallo segna dove arriva. Il palloncino e la lattina in aria si
|
||||
muovono: si mira dove saranno. Si può anche premere Spara, senza trattenere il fiato. Frecce per spostare il mirino.</p>
|
||||
</details>
|
||||
<details id="campana-help" hidden><summary>Come si gioca</summary>
|
||||
<p>La campana col gesso sul vialetto del campone, a turni col Taglia. Si tira il sassolino nella casella che tocca,
|
||||
dalla 1 alla 8: deve cadere dentro, senza toccare le righe (alla bona le righe non contano). Poi si salta tutta la
|
||||
campana fino in cima (il mezzo cerchio) e indietro, su un piede nelle caselle sole e a gambe larghe nella 4-5 e nella 7-8, senza mai
|
||||
mettere il piede dove sta il sassolino; tornando lo si raccoglie dalla casella prima. Chi ce la fa passa alla casella
|
||||
dopo e ritira; chi sbaglia passa la mano. Vince chi finisce la 8.</p>
|
||||
<p>Il tiro: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza (sale e scende) e lascia; oppure
|
||||
regola la forza e premi Tira. Alla bona il puntino giallo segna dove cade. Frecce su e giù per la mira, destra e
|
||||
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>
|
||||
</details>
|
||||
<details id="cerbottana-help" hidden><summary>Come si gioca</summary>
|
||||
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
|
||||
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",
|
||||
"version": "0.1.55",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,8 @@
|
||||
"browser:gara": "node tests/gara.browser.mjs",
|
||||
"browser:cerbottana": "node tests/cerbottana.browser.mjs",
|
||||
"browser:condor": "node tests/condor.browser.mjs",
|
||||
"browser:campana": "node tests/campana.browser.mjs",
|
||||
"browser:ingorgo": "node tests/ingorgo.browser.mjs",
|
||||
"browser:tressette": "node tests/tressette.browser.mjs",
|
||||
"browser:scopa": "node tests/scopa.browser.mjs",
|
||||
"browser:briscola": "node tests/briscola.browser.mjs",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { W, CY, START, CIELO, FROM, BOXES, LAST, FLIGHT, HOP, center, landing, needle, canAdjust } from './campana.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); }
|
||||
// Il gesso: tratti a puntini un po' storti, con dei buchi (sempre gli stessi, da un piccolo hash: non tremano).
|
||||
const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
|
||||
function chalkLine(ctx, x1, y1, x2, y2, seed) {
|
||||
const n = Math.max(2, Math.round(Math.hypot(x2 - x1, y2 - y1)));
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const k = i / n, h = hash(seed * 1000 + i);
|
||||
if (h < 0.07) continue;
|
||||
ctx.fillStyle = h > 0.8 ? 'rgba(255,255,250,0.95)' : 'rgba(240,238,228,0.78)';
|
||||
ctx.fillRect(Math.round(x1 + (x2 - x1) * k + (hash(seed + i) - 0.5)), Math.round(y1 + (y2 - y1) * k + (hash(seed * 7 + i) - 0.5)), 2, 2);
|
||||
}
|
||||
}
|
||||
// I gessi colorati: ogni casella ripassata tenue col suo colore, a puntini come il gesso sull'asfalto (idea di Andrea).
|
||||
const TINTS = { 1: [244, 160, 180], 2: [250, 222, 120], 3: [150, 200, 240], 4: [160, 220, 150], 5: [200, 170, 230], 6: [250, 180, 120], 7: [140, 220, 210], 8: [240, 150, 150], top: [255, 240, 200] };
|
||||
function tint(ctx, [r, g, b], x, y, w, h, seed, inside = () => true) {
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},0.16)`;
|
||||
for (let yy = y; yy < y + h; yy += 2) for (let xx = x; xx < x + w; xx += 2) {
|
||||
if (!inside(xx, yy)) continue;
|
||||
const k = hash(seed * 7777 + xx * 13 + yy * 7);
|
||||
if (k < 0.18) continue; // l'asfalto che spunta fra il gesso
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},${k > 0.85 ? 0.42 : 0.24})`; ctx.fillRect(xx, yy, 2, 2);
|
||||
}
|
||||
}
|
||||
// Il gesso non cambia mai: tinte e righe si disegnano una volta su una tela a parte (a puntini, a ogni fotogramma,
|
||||
// rallentavano la pagina). Fuori dal browser (nei test) si disegnano direttamente.
|
||||
let layer = null;
|
||||
function chalkwork(ctx) {
|
||||
let s = 1;
|
||||
for (const [id, b] of Object.entries(BOXES)) tint(ctx, TINTS[id], b.x + 1, b.y + 1, W - 2, W - 2, Number(id));
|
||||
tint(ctx, TINTS.top, CIELO.x + 1, CY - CIELO.r, CIELO.r, CIELO.r * 2, 9, (x, y) => Math.hypot(x + 1 - CIELO.x, y + 1 - CY) < CIELO.r - 2);
|
||||
for (const b of Object.values(BOXES)) {
|
||||
chalkLine(ctx, b.x, b.y, b.x + W, b.y, s++); chalkLine(ctx, b.x, b.y + W, b.x + W, b.y + W, s++);
|
||||
chalkLine(ctx, b.x, b.y, b.x, b.y + W, s++); chalkLine(ctx, b.x + W, b.y, b.x + W, b.y + W, s++);
|
||||
}
|
||||
// In cima la campana: il mezzo cerchio in fondo, dove ci si gira.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const a1 = -Math.PI / 2 + i / 40 * Math.PI, a2 = -Math.PI / 2 + (i + 1) / 40 * Math.PI;
|
||||
chalkLine(ctx, CIELO.x + Math.cos(a1) * CIELO.r, CY + Math.sin(a1) * CIELO.r, CIELO.x + Math.cos(a2) * CIELO.r, CY + Math.sin(a2) * CIELO.r, 200 + i);
|
||||
}
|
||||
// La riga di partenza.
|
||||
chalkLine(ctx, START - 14, CY - W, START - 14, CY + W, 300);
|
||||
}
|
||||
function grid(ctx, c) {
|
||||
if (typeof document === 'undefined') chalkwork(ctx);
|
||||
else {
|
||||
if (!layer) { layer = document.createElement('canvas'); layer.width = 640; layer.height = 360; chalkwork(layer.getContext('2d')); }
|
||||
ctx.drawImage(layer, 0, 0);
|
||||
}
|
||||
// Il numero in alto nella casella: il mezzo resta libero per il sassolino e i piedi. Quella che tocca ha il bordo giallo.
|
||||
for (const [id, b] of Object.entries(BOXES)) {
|
||||
const p = center(id), next = Number(id) === c.target[c.current] && c.step !== 'result';
|
||||
if (next) { ctx.strokeStyle = 'rgba(255,236,140,0.55)'; ctx.lineWidth = 2; ctx.strokeRect(b.x + 4, b.y + 4, W - 8, W - 8); }
|
||||
text(ctx, id, p.x, p.y - 17, 14, next ? '#fff3a8' : 'rgba(245,243,232,0.88)', 'center');
|
||||
}
|
||||
text(ctx, 'CAMPANA', CIELO.x + W * 0.53, CY - 26, 8, 'rgba(245,243,232,0.88)', 'center');
|
||||
}
|
||||
// Il sassolino: grigio, col lume in alto; in volo, più su e con l'ombra sotto.
|
||||
function stone(ctx, x, y, lift = 0) {
|
||||
ctx.fillStyle = 'rgba(30,25,20,0.35)'; ctx.beginPath(); ctx.ellipse(x + 1, y + 2, 5, 3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
const s = 1 + lift / 60, yy = y - lift;
|
||||
ctx.fillStyle = '#4a4744'; ctx.beginPath(); ctx.ellipse(x, yy, 5 * s, 4 * s, 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#8d8984'; ctx.beginPath(); ctx.ellipse(x - 0.5, yy - 0.5, 4 * s, 3 * s, 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
rect(ctx, x - 2 * s, yy - 2 * s, 2, 1, '#cfcac2');
|
||||
}
|
||||
// Dove stanno i piedi: a gambe larghe nelle coppie e nel cielo, su un piede nelle caselle sole, alla partenza tutti e due.
|
||||
function feet(beat) {
|
||||
if (!beat || beat.out) return [{ x: FROM.x + 2, y: CY - 9 }, { x: FROM.x + 2, y: CY + 9 }];
|
||||
if (beat.boxes[0] === 'cielo') { const p = center('cielo'); return [{ x: p.x, y: p.y - 12 }, { x: p.x, y: p.y + 12 }]; }
|
||||
return beat.boxes.map(id => center(id));
|
||||
}
|
||||
// Una scarpa vista dall'alto: la punta verso dove si va.
|
||||
function shoe(ctx, x, y, dir, scale, [sole, upper, lace]) {
|
||||
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(dir * scale, scale);
|
||||
ctx.fillStyle = 'rgba(30,25,20,0.3)'; ctx.fillRect(-9, -3, 21, 9);
|
||||
ctx.fillStyle = sole; ctx.fillRect(-10, -5, 20, 10); ctx.fillRect(10, -4, 2, 8);
|
||||
ctx.fillStyle = upper; ctx.fillRect(-9, -4, 18, 8);
|
||||
ctx.fillStyle = lace; ctx.fillRect(-1, -2, 7, 1); ctx.fillRect(-1, 1, 7, 1);
|
||||
ctx.fillStyle = '#2a2420'; ctx.fillRect(-8, -2, 4, 4); // la caviglia, il buco della scarpa
|
||||
ctx.restore();
|
||||
}
|
||||
const SHOES = { player: ['#e8e6e0', '#3d6fb8', '#ffffff'], cpu: ['#2b2b2b', '#b8322a', '#f0d8a8'] };
|
||||
function walker(ctx, c) {
|
||||
const beat = c.beats?.[c.beat], prev = c.beat > 0 ? c.beats[c.beat - 1] : null, now = feet(c.beat >= 0 ? beat : null);
|
||||
const k = c.hop ? Math.min(1, c.hop.t / HOP) : 1, from = feet(c.beat > 0 ? prev : null);
|
||||
const mid = list => list.reduce((a, p) => ({ x: a.x + p.x / list.length, y: a.y + p.y / list.length }), { x: 0, y: 0 });
|
||||
const a = mid(from), b = mid(now), off = { x: (a.x - b.x) * (1 - k), y: (a.y - b.y) * (1 - k) };
|
||||
const scale = (c.current === 'cpu' ? 1.45 : 1.3) * (1 + 0.35 * Math.sin(Math.PI * k));
|
||||
const dir = beat?.back ? -1 : 1;
|
||||
for (const p of now) shoe(ctx, p.x + off.x, p.y + off.y, dir, scale, SHOES[c.current]);
|
||||
}
|
||||
|
||||
export function drawCampana(ctx, c, def, bg) {
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#9a948a'; ctx.fillRect(0, 0, 640, 360); }
|
||||
grid(ctx, c);
|
||||
if (c.stone) stone(ctx, c.stone.x, c.stone.y);
|
||||
walker(ctx, c);
|
||||
if (c.shot) {
|
||||
const k = Math.min(1, c.shot.t / FLIGHT), { from, to } = c.shot;
|
||||
stone(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k, 40 * Math.sin(Math.PI * k));
|
||||
}
|
||||
// La mira: un cerchietto di gesso dove si punta; alla bona, il puntino dove cade il sassolino con questa forza.
|
||||
if (canAdjust(c)) {
|
||||
ctx.strokeStyle = '#fff8dc'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(c.aim.x, c.aim.y, 6, 0, Math.PI * 2); ctx.stroke();
|
||||
if (c.skill.guide) { const l = landing(c); rect(ctx, l.x - 2, l.y - 2, 4, 4, '#f5d02a'); rect(ctx, l.x - 1, l.y - 1, 2, 2, '#7a5a10'); }
|
||||
// La forza: la barra in basso a sinistra.
|
||||
const x = 16, y = 296, h = 50, f = Math.round(h * c.power);
|
||||
rect(ctx, x - 3, y - 3, 18, h + 6, '#17160fd0');
|
||||
rect(ctx, x, y + h - f, 12, f, c.holding ? '#ffd23c' : '#d9ccba');
|
||||
text(ctx, 'FORZA', x + 6, y - 9, 6, '#efd49e', 'center');
|
||||
}
|
||||
// L'equilibrio: la lancetta attraversa la barra; si salta quando è nel verde.
|
||||
if (c.step === 'hop' && c.meter) {
|
||||
const x = 220, y = 318, w = 200, zone = c.skill.zone * w / 2, u = needle(c);
|
||||
rect(ctx, x - 4, y - 14, w + 8, 30, '#17160fd8');
|
||||
rect(ctx, x, y, w, 8, '#7a3a2a'); rect(ctx, x + w / 2 - zone, y, zone * 2, 8, '#5fae4a');
|
||||
rect(ctx, x + w / 2 + u * w / 2 - 1, y - 3, 3, 14, '#fff3a8');
|
||||
text(ctx, 'EQUILIBRIO', x + w / 2, y - 7, 6, '#efd49e', 'center');
|
||||
}
|
||||
// In alto: a che casella è ciascuno, di chi è il turno, cosa è successo.
|
||||
rect(ctx, 0, 0, 640, 22, '#17160fe8');
|
||||
text(ctx, 'LA CAMPANA', 8, 7, 8, '#efd49e');
|
||||
text(ctx, { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level], 8, 16, 7, '#d9ccba');
|
||||
const at = n => n > LAST ? 'finita' : `alla ${n}`;
|
||||
text(ctx, `Andrea ${at(c.target.player)}`, 220, 11, 9, c.current === 'player' ? '#fff0c8' : '#a8a08c', 'center');
|
||||
text(ctx, `Il Taglia ${at(c.target.cpu)}`, 360, 11, 9, c.current === 'cpu' ? '#9fd0ff' : '#7f95a8', 'center');
|
||||
text(ctx, c.current === 'player' ? 'tocca a te' : 'tocca al Taglia', 560, 11, 8, '#d9ccba', 'center');
|
||||
if (c.line) { rect(ctx, 150, 24, 340, 14, '#17160fd0'); text(ctx, c.line, 320, 31, 8, /riga|fuori|Troppo|equilibrio|piede|non nella/i.test(c.line) ? '#ffbf9f' : '#9fd0ff', 'center'); }
|
||||
if (c.step === 'result') {
|
||||
const won = c.result.winner === 'player';
|
||||
panel(ctx, 170, 140, 300, 76);
|
||||
text(ctx, won ? 'BATTUTO IL TAGLIA!' : 'HA VINTO IL TAGLIA', 320, 160, 12, won ? '#b9e5b3' : '#efd49e', 'center');
|
||||
text(ctx, won ? 'Tutta la campana, dalla 1 alla 8.' : `Tu eri arrivato alla ${c.target.player}.`, 320, 182, 8, '#efd49e', 'center');
|
||||
text(ctx, 'Torna al campone per uscire.', 320, 200, 8, '#d9ccba', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// La campana al campone delle poste (idea di Andrea, 30 ottobre): disegnata col gesso sul vialetto, a turni col Taglia.
|
||||
// Si tira il sassolino nella casella che tocca (dalla 1 alla 8): se cade dentro, senza toccare le righe, si salta tutta
|
||||
// la campana fino in cima e indietro, su un piede nelle caselle sole e a gambe larghe nelle coppie, senza mai mettere
|
||||
// il piede dove sta il sassolino; tornando, lo si raccoglie dalla casella prima. Chi sbaglia (sassolino fuori o sulla
|
||||
// riga, salto fuori tempo) passa la mano; chi ce la fa va avanti alla casella dopo e ritira. Vince chi finisce la 8.
|
||||
// Il tiro: la mira dà la direzione, tenere premuto carica la forza (va e torna), lasciare tira. I salti: a ogni salto
|
||||
// la lancetta dell'equilibrio attraversa la barra; si preme quando passa nel verde. Unità: i pixel del primo piano
|
||||
// 640×360, visto dall'alto (sfondo di Codex: assets/closeups/campone-campana.png).
|
||||
|
||||
export const W = 66, CY = 181, START = 80, CIELO = { x: START + 6 * W, r: W }, FROM = { x: 40, y: CY }, REACH = 520;
|
||||
export const TAP = 0.14, CHARGE = 1.3, FLIGHT = 0.55, HOP = 0.22, REST = 1.6, DONE = 1.1, CPU_WAIT = 0.9, CPU_BEAT = 0.42;
|
||||
// Le caselle: 1, 2, 3 da sole; 4 e 5 in coppia (la 4 a sinistra di chi salta, cioè in alto); la 6; 7 e 8 in coppia;
|
||||
// in fondo la campana (il mezzo cerchio dove ci si gira; nel codice «cielo», come la chiamano in altri posti).
|
||||
export const BOXES = {
|
||||
1: { x: START, y: CY - W / 2 }, 2: { x: START + W, y: CY - W / 2 }, 3: { x: START + 2 * W, y: CY - W / 2 },
|
||||
4: { x: START + 3 * W, y: CY - W }, 5: { x: START + 3 * W, y: CY }, 6: { x: START + 4 * W, y: CY - W / 2 },
|
||||
7: { x: START + 5 * W, y: CY - W }, 8: { x: START + 5 * W, y: CY },
|
||||
};
|
||||
export const GROUPS = [[1], [2], [3], [4, 5], [6], [7, 8], ['cielo']];
|
||||
export const LAST = 8;
|
||||
// I livelli: quanto si sparpaglia il sassolino (px), se si vede dove cade, quanto è larga la riga che non si può
|
||||
// toccare, quanto ci mette la lancetta dell'equilibrio e quanto è largo il verde; il Taglia: quante volte il sassolino
|
||||
// va dove vuole e quanto spesso, a ogni salto, mette giù il piede.
|
||||
export const LEVELS = {
|
||||
bona: { scatter: 4, guide: true, line: 0, sweep: 1.25, zone: 0.32, cpu: { throw: 0.5, slip: 0.045 } },
|
||||
seria: { scatter: 7, guide: false, line: 3, sweep: 1, zone: 0.2, cpu: { throw: 0.7, slip: 0.025 } },
|
||||
male: { scatter: 9, guide: false, line: 4, sweep: 0.8, zone: 0.15, cpu: { throw: 0.8, slip: 0.018 } },
|
||||
};
|
||||
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; }
|
||||
|
||||
export function center(id) {
|
||||
if (id === 'cielo') return { x: CIELO.x + W * 0.42, y: CY };
|
||||
const b = BOXES[id]; return { x: b.x + W / 2, y: b.y + W / 2 };
|
||||
}
|
||||
// In che casella cade un punto, e se tocca la riga (entro `tol` pixel da un bordo).
|
||||
export function boxAt([x, y], tol = 0) {
|
||||
for (const [id, b] of Object.entries(BOXES)) {
|
||||
if (x < b.x || x > b.x + W || y < b.y || y > b.y + W) continue;
|
||||
return { id: Number(id), line: Math.min(x - b.x, b.x + W - x, y - b.y, b.y + W - y) < tol };
|
||||
}
|
||||
const d = Math.hypot(x - CIELO.x, y - CY);
|
||||
if (x >= CIELO.x && d <= CIELO.r) return { id: 'cielo', line: Math.min(x - CIELO.x, CIELO.r - d) < tol };
|
||||
return null;
|
||||
}
|
||||
|
||||
// I salti per la casella `n`: all'andata tutte le caselle, saltando quella col sassolino (nella coppia si mette un
|
||||
// piede solo nell'altra); il cielo; al ritorno, dalla casella prima si raccoglie il sassolino; poi fuori.
|
||||
export function beats(n) {
|
||||
const step = (g, back) => g.includes(n) ? (g.length > 1 ? [{ boxes: g.filter(k => k !== n), back }] : []) : [{ boxes: g, back }];
|
||||
const out = GROUPS.flatMap(g => step(g, false));
|
||||
for (let i = GROUPS.length - 2; i >= 0; i--) {
|
||||
if (GROUPS[i].includes(n)) out.push({ ...out[out.length - 1], back: true, pick: true });
|
||||
out.push(...step(GROUPS[i], true));
|
||||
}
|
||||
out.push({ boxes: [], back: true, out: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newCampana(seed = Date.now() >>> 0, def = {}) {
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: { player: 1, cpu: 1 },
|
||||
aim: center(1), power: 0.2, holding: false, holdTime: 0, charging: false, stone: null, shot: null,
|
||||
beats: null, beat: -1, meter: null, hop: null, line: null, next: null, events: [], visual: { time: 0 } };
|
||||
}
|
||||
export const canThrow = c => c.current === 'player' && (c.step === 'aim' || c.step === 'hop');
|
||||
export const canAdjust = c => c.current === 'player' && c.step === 'aim';
|
||||
export const aimFree = c => !canAdjust(c);
|
||||
|
||||
// Dove cade il sassolino tirato con questa forza, senza lo sparpaglio.
|
||||
export function landing(c, power = c.power) {
|
||||
const dx = c.aim.x - FROM.x, dy = c.aim.y - FROM.y, d = Math.hypot(dx, dy) || 1;
|
||||
return { x: FROM.x + dx / d * power * REACH, y: FROM.y + dy / d * power * REACH };
|
||||
}
|
||||
function toss(c, to) {
|
||||
c.shot = { from: { ...FROM }, to, t: 0 }; c.stone = null;
|
||||
c.step = 'flying'; c.line = null;
|
||||
c.events.push({ kind: 'campana-lancio', power: 0.6 });
|
||||
}
|
||||
function throwStone(c) {
|
||||
const p = landing(c), s = c.skill.scatter, spread = () => (random(c) + random(c) + random(c) - 1.5) * s * 1.4;
|
||||
toss(c, { x: p.x + spread(), y: p.y + spread() });
|
||||
return true;
|
||||
}
|
||||
const who = c => c.current === 'cpu' ? 'Il Taglia: ' : '';
|
||||
const cap = line => line[0].toUpperCase() + line.slice(1);
|
||||
// Il sassolino è arrivato: nella casella giusta si salta, se no passa la mano.
|
||||
function land(c) {
|
||||
const to = c.shot.to, n = c.target[c.current], hit = boxAt([to.x, to.y], c.skill.line);
|
||||
c.shot = null; c.stone = to;
|
||||
c.events.push({ kind: 'campana-sasso', power: 0.8 });
|
||||
if (hit && hit.id === n && !hit.line) {
|
||||
c.line = cap(`${who(c)}il sassolino è nella ${n}. Salta!`);
|
||||
c.beats = beats(n); c.beat = -1; c.hop = null; c.t = 0;
|
||||
c.step = c.current === 'cpu' ? 'cpuHop' : 'hop';
|
||||
if (c.step === 'hop') c.meter = { t: -0.5, dir: 1 };
|
||||
return;
|
||||
}
|
||||
fail(c, `${who(c)}${hit?.line ? 'sulla riga!' : hit ? `nella ${hit.id === 'cielo' ? 'campana in cima' : hit.id}, non nella ${n}.` : 'fuori dalla campana.'}`);
|
||||
}
|
||||
function fail(c, line) {
|
||||
c.line = cap(line); c.step = 'rest'; c.t = 0; c.meter = null;
|
||||
c.next = c.current === 'player' ? 'cpu' : 'player';
|
||||
c.events.push({ kind: 'campana-riga', power: 0.7 });
|
||||
}
|
||||
// Un salto riuscito: la casella dopo, o il sassolino raccolto, o fuori dalla campana (e la casella è fatta).
|
||||
function advance(c) {
|
||||
c.beat++;
|
||||
const b = c.beats[c.beat];
|
||||
c.hop = { t: 0 };
|
||||
if (b.pick) { c.stone = null; c.line = cap(`${who(c)}preso il sassolino!`); c.events.push({ kind: 'campana-sasso', power: 0.5 }); }
|
||||
else c.events.push({ kind: 'campana-salto', power: 0.6 });
|
||||
if (b.out) {
|
||||
const n = c.target[c.current]++;
|
||||
if (n >= LAST) {
|
||||
c.step = 'result'; c.meter = null;
|
||||
c.result = { winner: c.current, player: c.target.player - 1, cpu: c.target.cpu - 1 };
|
||||
return;
|
||||
}
|
||||
c.line = cap(`${who(c)}fatta la ${n}! Ora la ${n + 1}.`); c.step = 'done'; c.t = 0; c.meter = null;
|
||||
return;
|
||||
}
|
||||
if (c.meter) c.meter = { t: 0, dir: -c.meter.dir };
|
||||
}
|
||||
// La lancetta dell'equilibrio, da -1 a 1 (o al contrario, un salto sì e uno no).
|
||||
export function needle(c) {
|
||||
if (!c.meter) return 0;
|
||||
return c.meter.dir * (-1 + 2 * clamp(c.meter.t, 0, c.skill.sweep) / c.skill.sweep);
|
||||
}
|
||||
function jump(c) {
|
||||
if (c.hop && c.hop.t < HOP) return true; // ancora in aria: il tocco non conta
|
||||
const u = needle(c);
|
||||
if (Math.abs(u) <= c.skill.zone) { advance(c); return true; }
|
||||
const early = u * c.meter.dir < 0;
|
||||
fail(c, early ? 'Troppo presto: giù l\'altro piede!' : 'Troppo tardi: pestata la riga!');
|
||||
return true;
|
||||
}
|
||||
// Tocca all'altro, o allo stesso per la casella dopo: dalla partenza, senza sassolino.
|
||||
function turn(c, current) {
|
||||
Object.assign(c, { current, stone: null, beats: null, beat: -1, hop: null, meter: null, t: 0, next: null,
|
||||
step: current === 'player' ? 'aim' : 'cpuAim' });
|
||||
if (current === 'player') c.aim = center(c.target.player);
|
||||
}
|
||||
|
||||
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.hop) c.hop.t += dt;
|
||||
if (c.holding) {
|
||||
c.holdTime += dt; c.charging = c.holdTime >= TAP;
|
||||
const k = (c.holdTime / CHARGE) % 2; c.power = k <= 1 ? k : 2 - k;
|
||||
}
|
||||
if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); }
|
||||
else if (c.step === 'hop') {
|
||||
if (!c.hop || c.hop.t >= HOP) c.meter.t += dt;
|
||||
if (c.meter.t > c.skill.sweep) fail(c, 'Hai perso l\'equilibrio!');
|
||||
} else if (c.step === 'cpuAim' && c.t >= CPU_WAIT) {
|
||||
// Il Taglia: o nella casella, ben dentro, o mezza casella più in là (sulla riga o nell'altra).
|
||||
const n = c.target.cpu, p = center(n), ok = random(c) < c.skill.cpu.throw, j = () => (random(c) - 0.5) * 20;
|
||||
toss(c, ok ? { x: p.x + j(), y: p.y + j() } : { x: p.x + (random(c) < 0.5 ? -1 : 1) * W * (0.5 + random(c) * 0.3), y: p.y + j() });
|
||||
} else if (c.step === 'cpuHop' && c.t >= CPU_BEAT) {
|
||||
c.t = 0;
|
||||
if (random(c) < c.skill.cpu.slip) fail(c, random(c) < 0.5 ? 'Il Taglia ha pestato la riga!' : 'Il Taglia ha messo giù l\'altro piede!');
|
||||
else advance(c);
|
||||
} else if (c.step === 'rest' && c.t >= REST) turn(c, c.next);
|
||||
else if (c.step === 'done' && c.t >= DONE) turn(c, c.current);
|
||||
}
|
||||
|
||||
// --- Comandi: la mira segue il mouse o il dito; tenere premuto carica la forza, lasciare tira; ai salti si preme.
|
||||
export function aimScreen(c, [x, y]) {
|
||||
if (!canAdjust(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
c.aim = { x: clamp(x, FROM.x + 30, 630), y: clamp(y, 80, 285) }; return true;
|
||||
}
|
||||
export function setPower(c, v) { if (!canAdjust(c) || c.holding) return false; c.power = clamp(v, 0, 1); return true; }
|
||||
export function nudge(c, dir) { if (!canAdjust(c) || c.holding) return false; c.aim.y = clamp(c.aim.y + dir * 3, 80, 285); return true; }
|
||||
// Frecce: su e giù la mira, destra e sinistra la forza (più in là, più in qua).
|
||||
export function key(c, key, down) {
|
||||
const move = { ArrowUp: [0, -3], ArrowDown: [0, 3], ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0] }[key];
|
||||
if (!move) return false;
|
||||
if (down && canAdjust(c) && !c.holding) { c.aim.y = clamp(c.aim.y + move[1], 80, 285); c.power = clamp(c.power + move[0], 0, 1); }
|
||||
return true;
|
||||
}
|
||||
export function press(c, pointerId) {
|
||||
if (c.current !== 'player') return false;
|
||||
if (c.step === 'hop') return jump(c);
|
||||
if (!canAdjust(c) || c.holding) return false;
|
||||
c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
|
||||
return true;
|
||||
}
|
||||
export function cancel(c, pointerId) {
|
||||
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
|
||||
if (!c.holding) return false;
|
||||
c.holding = c.charging = false; c.holdTime = 0; return true;
|
||||
}
|
||||
export function release(c, pointerId) {
|
||||
if (c.pointerId !== pointerId || !c.holding) return false;
|
||||
if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto
|
||||
c.holding = c.charging = false; c.holdTime = 0;
|
||||
return throwStone(c);
|
||||
}
|
||||
// Il pulsante: tira con la forza del cursore, o salta.
|
||||
export function shoot(c) {
|
||||
if (c.current !== 'player' || c.holding) return false;
|
||||
if (c.step === 'hop') return jump(c);
|
||||
return canAdjust(c) && throwStone(c);
|
||||
}
|
||||
|
||||
const box = n => n > LAST ? 'finito' : `alla ${n}`;
|
||||
export function button(c) {
|
||||
if (c.step === 'result') return 'Finita';
|
||||
if (c.current === 'cpu') return 'Tocca al Taglia…';
|
||||
return { aim: 'Tira', flying: 'In volo…', hop: 'Salta' }[c.step] ?? 'Aspetta…';
|
||||
}
|
||||
export function status(c) {
|
||||
const score = `Andrea ${box(c.target.player)}, il Taglia ${box(c.target.cpu)}.`;
|
||||
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Campana finita: battuto il Taglia! ' : 'Il Taglia ha finito la campana. '}${score}`;
|
||||
const hint = c.current === 'cpu' ? 'Tocca al Taglia.' : c.step === 'hop' ? 'Salta quando la lancetta è nel verde.'
|
||||
: `Tira il sassolino nella ${c.target.player}: mira, tieni premuto per la forza e lascia.`;
|
||||
return `${score} ${c.line ?? hint}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// La macchina del tempo vista da fuori (story.closeups, type 'corsa'; Andrea, 30 ottobre). In partenza ('parti') va via
|
||||
// giù per la strada accelerando, il fulmine la prende, il bianco, e restano due sgommate di fuoco che bruciano e si
|
||||
// spengono. All'arrivo ('arriva') il fulmine in fondo alla strada, e lei viene avanti e ci passa accanto, lasciando le
|
||||
// stesse strisce (davanti a noi: dietro alla macchina non si vedrebbero). Sopra lo sfondo della scena (`scene`), coi fotogrammi di Codex (`frames`: dietro, dietro coi lampi,
|
||||
// davanti coi lampi). Unità: i pixel della scena 640×360.
|
||||
|
||||
// I tempi, in secondi. Partenza: ferma che si carica, la corsa, il fulmine, il bianco, le fiamme, la fine.
|
||||
export const PARTI = { charge: 0.6, run: 2.2, bolt: 2.3, flash: 0.35, burn: 4.6, end: 5.2 };
|
||||
// Arrivo: il fulmine in fondo, la corsa verso di noi fin fuori dal quadro, le fiamme, la fine.
|
||||
export const ARRIVA = { bolt: 0.3, flash: 0.35, run: 1.5, burn: 4, end: 4.6 };
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = k => Math.max(0, Math.min(1, k));
|
||||
// Dove sta la macchina a un punto `k` del percorso (0 vicina, 1 in fondo): posizione delle ruote e grandezza.
|
||||
export function along(def, k) {
|
||||
const [a, b] = def.path;
|
||||
return { x: lerp(a[0], b[0], k), y: lerp(a[1], b[1], k), scale: lerp(a[2], b[2], k) };
|
||||
}
|
||||
// Il punto del percorso al tempo t: in partenza accelera (da fermo); all'arrivo viene avanti dal fondo fin oltre il
|
||||
// punto 0 (fuori dal quadro, accanto a noi).
|
||||
export function progress(c, def) {
|
||||
if (def.mode === 'arriva') return 1 - clamp01((c.t - ARRIVA.bolt) / ARRIVA.run);
|
||||
const k = clamp01((c.t - PARTI.charge) / (PARTI.run - PARTI.charge));
|
||||
return Math.min(0.92, k * k);
|
||||
}
|
||||
// Si vede la macchina? In partenza fino al fulmine; all'arrivo dal fulmine in poi.
|
||||
export function carShown(c, def) {
|
||||
return def.mode === 'arriva' ? c.t >= ARRIVA.bolt + 0.05 && c.t < ARRIVA.bolt + ARRIVA.run : c.t < PARTI.bolt + 0.05;
|
||||
}
|
||||
// Dove arriva il fulmine: sulla macchina in partenza, in fondo alla strada all'arrivo.
|
||||
export function boltAt(def) { return def.mode === 'arriva' ? along(def, 1) : along(def, progress({ t: PARTI.bolt }, def)); }
|
||||
const T = def => def.mode === 'arriva' ? ARRIVA : PARTI;
|
||||
|
||||
export function newCorsa(id) { return { id, t: 0, age: 0, events: [] }; }
|
||||
// Il tempo che passa e i suoni; vero quando è finita.
|
||||
export function updateCorsa(c, def, dt) {
|
||||
c.t += dt;
|
||||
const times = T(def);
|
||||
if (def.mode !== 'arriva' && !c.charge && c.t >= 0.1) { c.charge = true; c.events.push({ kind: 'gara-turbo', power: 1 }); }
|
||||
if (!c.boom && c.t >= times.bolt) { c.boom = true; c.events.push({ kind: 'corsa-tuono', power: 1 }); }
|
||||
if (!c.fire && c.t >= (def.mode === 'arriva' ? ARRIVA.bolt + 0.2 : PARTI.charge + 0.3)) { c.fire = true; c.events.push({ kind: 'corsa-fuoco', power: 1 }); }
|
||||
return c.t >= times.end;
|
||||
}
|
||||
|
||||
// Le due ruote posteriori (o anteriori) a un punto del percorso: una a sinistra e una a destra.
|
||||
function wheels(def, k) {
|
||||
const p = along(def, k), half = def.track * p.scale;
|
||||
return [[p.x - half, p.y], [p.x + half, p.y]];
|
||||
}
|
||||
// Le sgommate di fuoco: da dove è partita a dove è arrivata, fiammelle che tremano, più piccole in fondo, che si
|
||||
// spengono dopo `burn`.
|
||||
function fire(ctx, c, def) {
|
||||
const times = T(def), shown = def.mode === 'arriva' ? [Math.max(progress(c, def), 0), 1] : [0, progress({ t: Math.min(c.t, times.bolt) }, def)];
|
||||
const fade = c.t < times.burn ? 1 : clamp01(1 - (c.t - times.burn) / (times.end - times.burn));
|
||||
if (fade <= 0 || shown[1] - shown[0] < 0.005) return;
|
||||
for (let side = 0; side < 2; side++) {
|
||||
// Prima il segno nero bruciato, poi le fiamme sopra.
|
||||
const [a, b] = [wheels(def, shown[0])[side], wheels(def, shown[1])[side]];
|
||||
ctx.strokeStyle = `rgba(30,18,10,${0.7 * Math.max(fade, 0.5)})`; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
||||
const n = 70;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const k = lerp(shown[0], shown[1], i / n), [x, y] = wheels(def, k)[side], s = Math.max(0.12, along(def, k).scale);
|
||||
// Lingue di fuoco di altezza diversa, che tremano: rosso fuori, arancio, il cuore giallo.
|
||||
const flick = 0.55 + 0.45 * Math.sin(c.t * 19 + i * 2.3 + side * 2) * Math.sin(c.t * 7 + i * 0.9);
|
||||
const h = (16 + 22 * flick) * s * fade, w = Math.max(2, 9 * s);
|
||||
if (h < 1.5) continue;
|
||||
ctx.fillStyle = `rgba(255,120,30,${0.18 * fade})`; ctx.fillRect(Math.round(x - w), Math.round(y - h * 1.2), Math.ceil(w * 2), Math.ceil(h * 1.2));
|
||||
ctx.fillStyle = `rgba(220,50,20,${0.9 * fade})`; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), Math.ceil(w), Math.ceil(h));
|
||||
ctx.fillStyle = `rgba(255,${Math.round(140 + 60 * flick)},30,${0.95 * fade})`; ctx.fillRect(Math.round(x - w / 3), Math.round(y - h * 0.7), Math.ceil(w * 0.66), Math.ceil(h * 0.7));
|
||||
ctx.fillStyle = `rgba(255,245,170,${fade})`; ctx.fillRect(Math.round(x - w / 6), Math.round(y - h * 0.35), Math.max(1, Math.ceil(w / 3)), Math.ceil(h * 0.35));
|
||||
// Un filo di fumo che sale quando le fiamme calano.
|
||||
if (c.t > times.burn - 1 && i % 8 === 0) {
|
||||
ctx.fillStyle = `rgba(90,90,96,${0.25 * fade})`;
|
||||
ctx.fillRect(Math.round(x - 2 * s), Math.round(y - h - 6 * s - ((c.t * 18 + i) % 14) * s), Math.ceil(4 * s), Math.ceil(4 * s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Il fulmine: una saetta spezzata dal cielo fino al punto, con due rami.
|
||||
function bolt(ctx, c, def) {
|
||||
const times = T(def), dt = c.t - times.bolt;
|
||||
if (dt < -0.12 || dt > 0.28) return;
|
||||
const p = boltAt(def), seed = Math.floor(c.t * 30);
|
||||
ctx.save();
|
||||
for (const [width, color] of [[6, 'rgba(140,200,255,0.5)'], [2, '#ffffff']]) {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath();
|
||||
let x = p.x + 40, y = 0; ctx.moveTo(x, y);
|
||||
const steps = 9;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
x = lerp(p.x + 40, p.x, i / steps) + (i < steps ? ((seed * (i + 5) * 53) % 30) - 15 : 0);
|
||||
y = lerp(0, p.y - 12 * p.scale, i / steps); ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawCorsa(ctx, c, def, { bg, frames, tint }) {
|
||||
const times = T(def);
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
// La strada trema un po' quando la macchina si carica e quando arriva il fulmine.
|
||||
const shake = (def.mode !== 'arriva' && c.t < PARTI.charge + 0.4 ? 1.5 : 0) + (Math.abs(c.t - times.bolt) < 0.25 ? 3 : 0);
|
||||
ctx.translate(Math.round(Math.sin(c.t * 57) * shake), Math.round(Math.cos(c.t * 43) * shake));
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4a4a50'; ctx.fillRect(0, 0, 640, 360); }
|
||||
if (tint) { ctx.fillStyle = tint; ctx.fillRect(0, 0, 640, 360); }
|
||||
fire(ctx, c, def);
|
||||
if (carShown(c, def)) {
|
||||
const k = progress(c, def), p = along(def, k);
|
||||
// Da dietro: coi lampi mentre si carica e quando corre forte; davanti (all'arrivo) coi lampi finché frena.
|
||||
const img = def.mode === 'arriva' ? frames?.davanti : c.t > PARTI.charge * 0.5 && Math.floor(c.t * 14) % 3 !== 0 ? frames?.lampi : frames?.dietro;
|
||||
if (img) {
|
||||
const w = def.width * p.scale, h = w * img.height / img.width;
|
||||
ctx.drawImage(img, Math.round(p.x - w / 2), Math.round(p.y - h), Math.round(w), Math.round(h));
|
||||
} else { ctx.fillStyle = '#c8ccd2'; ctx.fillRect(Math.round(p.x - 40 * p.scale), Math.round(p.y - 40 * p.scale), Math.round(80 * p.scale), Math.round(40 * p.scale)); }
|
||||
}
|
||||
bolt(ctx, c, def);
|
||||
ctx.restore();
|
||||
// Il bianco del fulmine, che sfuma.
|
||||
const f = c.t - times.bolt;
|
||||
if (f >= 0 && f < times.flash) { ctx.fillStyle = `rgba(255,255,255,${1 - f / times.flash})`; ctx.fillRect(0, 0, 640, 360); }
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import * as scopa from './scopa.js';
|
||||
import * as gara from './gara.js';
|
||||
import * as micro from './micro.js';
|
||||
import * as cerbottana from './cerbottana.js';
|
||||
import { newCorsa, updateCorsa } from './corsa.js';
|
||||
import { newIncanto, updateIncanto } from './incanto.js';
|
||||
import * as condor from './condor.js';
|
||||
import * as campana from './campana.js';
|
||||
import * as poker from './poker.js';
|
||||
|
||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||
@@ -28,6 +31,7 @@ export const MINIGAMES = {
|
||||
poker: { create: (seed, level) => poker.newPoker(seed, { level }), update: poker.update, play: poker.play },
|
||||
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 },
|
||||
campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.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 },
|
||||
};
|
||||
@@ -145,6 +149,9 @@ export function restoreState(story, data) {
|
||||
return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true };
|
||||
}
|
||||
|
||||
// Il manifesto ingrandito si apre con uno zoom: un tocco prima che sia aperto non conta.
|
||||
export const POSTER_READY = 0.35;
|
||||
|
||||
export class Game {
|
||||
// voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima).
|
||||
constructor(story, { state, onSave, voiceTime } = {}) {
|
||||
@@ -196,7 +203,13 @@ export class Game {
|
||||
return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended };
|
||||
}
|
||||
|
||||
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
|
||||
// In una zona accesa con `exitsOnly` (la festa dell'Ingorgo: Andrea, 30 ottobre) si clicca solo per andare da un
|
||||
// posto all'altro: panchine, porte e statue restano dove sono, ma non rispondono.
|
||||
hotspots() {
|
||||
const list = (this.scene.hotspots ?? []).filter(h => check(h.when, this.state));
|
||||
const busy = (this.story.zones ?? []).some(z => z.exitsOnly && this.state.flags[z.flag] && z.scenes.includes(this.state.scene));
|
||||
return busy ? list.filter(h => h.exit) : list;
|
||||
}
|
||||
hotspot(id) { return this.hotspots().find(h => h.id === id); }
|
||||
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
|
||||
// Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli
|
||||
@@ -292,6 +305,13 @@ export class Game {
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
// Un manifesto ingrandito (type 'poster'): toccarlo lo chiude e fa partire `tap` (il desiderio dell'Ingorgo).
|
||||
if (this.story.closeups[c.id].type === 'poster') {
|
||||
if (c.t < POSTER_READY) return false;
|
||||
this.closeup = null;
|
||||
this.queue.unshift(...(this.story.closeups[c.id].tap ?? []));
|
||||
return true;
|
||||
}
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
@@ -325,7 +345,13 @@ export class Game {
|
||||
return true;
|
||||
}
|
||||
// Il viaggio si chiude da solo alla fine (o uscendo prima): si arriva comunque.
|
||||
if (def?.type === 'viaggio') { this.queue.unshift(...def.complete); return true; }
|
||||
if (def?.type === 'viaggio' || def?.type === 'corsa') { this.queue.unshift(...def.complete); return true; }
|
||||
// L'incantesimo, chiuso prima del lampo, fa comunque il suo cambio.
|
||||
if (def?.type === 'incanto') {
|
||||
if (!c.swapped) for (const e of def.swap ?? []) this.run(e);
|
||||
this.queue.unshift(...(def.complete ?? []));
|
||||
return true;
|
||||
}
|
||||
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
|
||||
// di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (MINIGAMES[def?.type]) {
|
||||
@@ -427,15 +453,23 @@ export class Game {
|
||||
const talk = p.talk ?? ['talk'];
|
||||
return talk[Math.floor(this.time / 0.3) % talk.length];
|
||||
}
|
||||
// `flourish`: ogni tanto un gesto lungo a più fotogrammi (Baldoria che sventola il bandierone).
|
||||
const f = p.flourish;
|
||||
if (f) {
|
||||
const ft = (this.time + (p.offset ?? 0)) % f.every;
|
||||
if (ft < f.for) return f.poses[Math.floor(ft / (f.step ?? 0.35)) % f.poses.length];
|
||||
}
|
||||
const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every;
|
||||
return p.blink && t > every - 0.25 ? p.blink : idle;
|
||||
}
|
||||
|
||||
// La grandezza a quella altezza della scena; una fase può ingrandire tutti (`scale`: alla notte dell'Ingorgo la folla
|
||||
// dipinta è grande, e Andrea ci si perdeva in mezzo).
|
||||
scaleAt(y) {
|
||||
const d = this.scene.depth;
|
||||
if (!d) return 1;
|
||||
const d = this.scene.depth, k = this.phase()?.scale ?? 1;
|
||||
if (!d) return k;
|
||||
const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0)));
|
||||
return s0 + (s1 - s0) * t;
|
||||
return (s0 + (s1 - s0) * t) * k;
|
||||
}
|
||||
|
||||
speakerPoint(who) {
|
||||
@@ -486,6 +520,13 @@ export class Game {
|
||||
if (c) {
|
||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||
if (this.story.closeups[c.id].type === 'viaggio') { this.updateTrip(dt); return; }
|
||||
if (this.story.closeups[c.id].type === 'corsa') { if (updateCorsa(c, this.story.closeups[c.id], dt)) this.closeupClose(); return; }
|
||||
if (this.story.closeups[c.id].type === 'incanto') {
|
||||
const now = updateIncanto(c, dt);
|
||||
if (now === 'swap') for (const e of this.story.closeups[c.id].swap ?? []) this.run(e);
|
||||
if (now === 'end') this.closeupClose();
|
||||
return;
|
||||
}
|
||||
const m = this.minigame();
|
||||
if (m) {
|
||||
m.update(c, dt);
|
||||
@@ -528,6 +569,9 @@ export class Game {
|
||||
const type = this.story.closeups[e.closeup].type;
|
||||
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
||||
: type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 }
|
||||
: type === 'corsa' ? newCorsa(e.closeup)
|
||||
: type === 'incanto' ? newIncanto(e.closeup)
|
||||
: type === 'poster' ? { id: e.closeup, step: 'poster', t: 0, age: 0 }
|
||||
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
|
||||
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
|
||||
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
|
||||
@@ -581,6 +625,13 @@ export class Game {
|
||||
this.back = false;
|
||||
this.state.flags['visto:' + scene] = true; // per la mappa
|
||||
this.queue.unshift(...(this.scene.enter ?? []));
|
||||
// Le zone (story.zones): un flag che vale solo in certi posti, come la festa dell'Ingorgo. Uscendo (a piedi o
|
||||
// con la mappa) il flag si spegne e partono le battute del risveglio.
|
||||
for (const z of this.story.zones ?? []) {
|
||||
if (!this.state.flags[z.flag] || z.scenes.includes(scene)) continue;
|
||||
delete this.state.flags[z.flag];
|
||||
this.queue.unshift(...(z.leave ?? []));
|
||||
}
|
||||
this.dirty = true; // si salva quando l'ingresso nella scena è finito
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// L'incantesimo (story.closeups, type 'incanto'; Andrea, 30 ottobre: «Quanto darei per essere all'Ingorgo stasera?!?
|
||||
// Effetto speciale e compare»). Sopra la scena di adesso, con Andrea dentro: i fasci delle luci da palco che girano
|
||||
// sempre più svelti, i colpi di cassa che fanno pulsare l'aria, il lampo bianco; nel bianco cambia tutto (`swap`, di
|
||||
// solito il flag della festa: lo sfondo diventa quello della notte), poi il bianco sfuma e piovono i coriandoli.
|
||||
// Unità: i pixel della scena 640×360.
|
||||
|
||||
// I tempi, in secondi: i fasci, il lampo che sale, il cambio (a bianco pieno), il bianco che sfuma, la fine.
|
||||
export const T = { beams: 0.2, flash: 1.9, swap: 2.2, clear: 3.0, end: 3.8 };
|
||||
const COLORS = ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
|
||||
const clamp01 = k => Math.max(0, Math.min(1, k));
|
||||
// Un numero a caso ma sempre lo stesso per lo stesso indice: i coriandoli non tremano da un fotogramma all'altro.
|
||||
const hash = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
||||
|
||||
export function newIncanto(id) { return { id, t: 0, age: 0, events: [], beats: 0, swapped: false }; }
|
||||
|
||||
// Il tempo che passa e i suoni (un colpo di cassa a ogni battito, sempre più fitti; il lampo). Torna 'swap' nel
|
||||
// momento del cambio, 'end' alla fine.
|
||||
export function updateIncanto(c, dt) {
|
||||
c.t += dt;
|
||||
const beats = Math.floor(beatAt(Math.min(c.t, T.flash)));
|
||||
for (; c.beats < beats; c.beats++) c.events.push({ kind: 'incanto-cassa', power: 0.5 + 0.5 * c.beats / 8 });
|
||||
if (!c.boom && c.t >= T.flash) { c.boom = true; c.events.push({ kind: 'incanto-lampo', power: 1 }); }
|
||||
if (!c.swapped && c.t >= T.swap) { c.swapped = true; return 'swap'; }
|
||||
return c.t >= T.end ? 'end' : null;
|
||||
}
|
||||
// Quanti battiti sono passati: accelerano, da uno ogni mezzo secondo a uno ogni decimo.
|
||||
export const beatAt = t => t <= 0 ? 0 : 2 * t + 1.6 * t * t;
|
||||
|
||||
// Quanto è bianco lo schermo: sale fino al cambio, resta pieno un attimo, poi sfuma.
|
||||
export function whiteness(t) {
|
||||
if (t < T.flash) return 0;
|
||||
if (t < T.swap) return clamp01((t - T.flash) / (T.swap - T.flash));
|
||||
return clamp01(1 - (t - T.swap) / (T.clear - T.swap));
|
||||
}
|
||||
|
||||
export function drawIncanto(ctx, c) {
|
||||
const t = c.t, before = t < T.swap;
|
||||
ctx.save();
|
||||
// Il colpo di cassa: un velo scuro che si apre e si chiude, l'aria che pulsa.
|
||||
if (before) {
|
||||
const b = beatAt(t), pulse = 1 - (b - Math.floor(b));
|
||||
ctx.fillStyle = `rgba(10,0,30,${0.25 * clamp01(t / 0.6) * pulse})`;
|
||||
ctx.fillRect(0, 0, 640, 360);
|
||||
}
|
||||
// I fasci: partono da sotto, ai lati, e spazzano il cielo; più passa il tempo, più sono e più girano svelti.
|
||||
const strength = before ? clamp01((t - T.beams) / 0.5) : clamp01(1 - (t - T.swap) / 0.6);
|
||||
if (strength > 0) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const n = 2 + Math.min(6, Math.floor(t * 3));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const from = [i % 2 ? 560 : 80, 372], speed = 0.9 + i * 0.35 + t * 0.9;
|
||||
const a = -Math.PI / 2 + Math.sin(t * speed + i * 1.7) * 0.9, len = 520, half = 0.07;
|
||||
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.22 * strength})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(...from);
|
||||
ctx.lineTo(from[0] + Math.cos(a - half) * len, from[1] + Math.sin(a - half) * len);
|
||||
ctx.lineTo(from[0] + Math.cos(a + half) * len, from[1] + Math.sin(a + half) * len);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
// Le scintille che salgono da terra, a quadratini.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const life = (t * (0.5 + hash(i)) + hash(i + 50)) % 1, x = 20 + hash(i + 100) * 600, y = 360 - life * 300;
|
||||
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.9 * strength * (1 - life)})`;
|
||||
ctx.fillRect(Math.round(x), Math.round(y), 2, 2);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
// Il lampo.
|
||||
const white = whiteness(t);
|
||||
if (white > 0) { ctx.fillStyle = `rgba(255,255,250,${white})`; ctx.fillRect(0, 0, 640, 360); }
|
||||
// Dopo il cambio, i coriandoli che cadono sulla festa.
|
||||
if (!before) {
|
||||
const k = (t - T.swap) / (T.end - T.swap);
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const x = (hash(i) * 640 + Math.sin(t * 3 + i) * 8) % 640, y = -10 + (hash(i + 7) * 120 + k * 420 * (0.6 + hash(i + 3) * 0.6));
|
||||
if (y > 360) continue;
|
||||
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${clamp01(1.3 - k)})`;
|
||||
ctx.fillRect(Math.round(x), Math.round(y), 3, 2);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -9,22 +9,39 @@ import { versioned } from '../version.js';
|
||||
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
|
||||
// altrimenti quello generale del paese.
|
||||
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
|
||||
export function trackFor(story, { screen, scene, phase, closeup }) {
|
||||
// Una scena con le console accese (`stages`, ognuna col suo `when`) non ha un brano solo ma una miscela: ogni console
|
||||
// suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro:
|
||||
// è la musica della piazza accanto, che arriva da lontano.
|
||||
// `ok(cond)` dice se una condizione della storia vale adesso (game.js, check).
|
||||
export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) {
|
||||
const m = story.music ?? {};
|
||||
if (screen === 'splash') return null;
|
||||
if (screen === 'title' || screen === 'intro') return m.title ?? null;
|
||||
if (screen === 'ending') return m.ending ?? m.game ?? null;
|
||||
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
|
||||
const sc = story.scenes[scene];
|
||||
const stages = (sc?.stages ?? []).filter(st => ok(st.when));
|
||||
if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]);
|
||||
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function mix(stages, [x, y]) {
|
||||
return stages.map(st => {
|
||||
const d = Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5), near = st.near ?? 90, far = st.far ?? 520;
|
||||
const k = Math.max(0, Math.min(1, (d - near) / (far - near)));
|
||||
return { src: st.music, gain: Math.max(st.floor ?? 0.08, (1 - k) ** 1.6) };
|
||||
}).sort((a, b) => b.gain - a.gain);
|
||||
}
|
||||
|
||||
const FADE = 1.2;
|
||||
|
||||
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta.
|
||||
function setLevel(a) {
|
||||
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. Lì, nella
|
||||
// miscela delle console, suona solo la più forte (`alone`): le altre a volume pieno coprirebbero tutto.
|
||||
function setLevel(a, alone = false) {
|
||||
a.volume = a.level;
|
||||
a.muted = a.level === 0;
|
||||
a.muted = a.level === 0 || (alone && a.level < 1 && Math.abs(a.volume - a.level) > 0.01);
|
||||
}
|
||||
|
||||
export class Music {
|
||||
@@ -33,40 +50,46 @@ export class Music {
|
||||
this.volume = volume;
|
||||
this.blocked = null;
|
||||
this.want = null;
|
||||
this.current = null; // { src, audio }
|
||||
this.old = [];
|
||||
this.tracks = new Map(); // src -> { src, audio, gain }: quelli voluti e quelli che sfumano
|
||||
this.wanted = []; // i src voluti adesso, il più forte per primo
|
||||
this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno
|
||||
this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era
|
||||
this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito
|
||||
}
|
||||
|
||||
// Il brano principale (il più forte) e quelli che stanno sfumando.
|
||||
get current() { return this.tracks.get(this.wanted[0]) ?? null; }
|
||||
get old() { return [...this.tracks.values()].filter(t => !this.wanted.includes(t.src)).map(t => t.audio); }
|
||||
|
||||
// Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo.
|
||||
// Tornati, update li fa ripartire da dov'erano (se non c'è la pausa).
|
||||
setAway(away) {
|
||||
this.away = away;
|
||||
if (away) for (const a of [this.current?.audio, ...this.old]) a?.pause();
|
||||
if (away) for (const t of this.tracks.values()) t.audio.pause();
|
||||
}
|
||||
|
||||
// Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma.
|
||||
update(src, dt) {
|
||||
this.want = src;
|
||||
if (src !== this.current?.src) {
|
||||
if (this.current) this.old.push(this.current.audio);
|
||||
this.current = src ? { src, audio: this.start(src) } : null;
|
||||
// Chiamata a ogni fotogramma con il brano voluto (o la miscela delle console, [{ src, gain }]): avvia, cambia e sfuma.
|
||||
update(want, dt) {
|
||||
this.want = want;
|
||||
const list = want == null ? [] : typeof want === 'string' ? [{ src: want, gain: 1 }] : want;
|
||||
this.wanted = list.map(w => w.src);
|
||||
for (const w of list) {
|
||||
const t = this.tracks.get(w.src) ?? { src: w.src, audio: this.start(w.src) };
|
||||
t.gain = w.gain;
|
||||
this.tracks.set(w.src, t);
|
||||
}
|
||||
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano.
|
||||
// Ogni brano voluto sale (o scende) verso il suo volume; quelli lasciati sfumano fino a zero, e lì si fermano.
|
||||
// Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e
|
||||
// resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese).
|
||||
const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck;
|
||||
if (this.current) {
|
||||
const a = this.current.audio;
|
||||
const step = dt / FADE, full = this.muted || this.hold ? 0 : this.volume * this.duck;
|
||||
for (const t of [...this.tracks.values()]) {
|
||||
const a = t.audio, on = this.wanted.includes(t.src), target = on ? full * t.gain : 0;
|
||||
a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step);
|
||||
setLevel(a);
|
||||
setLevel(a, list.length > 1 && t.src !== this.wanted[0]);
|
||||
if (!on) { if (a.level === 0) { a.pause(); this.tracks.delete(t.src); } continue; }
|
||||
if (this.hold && a.level === 0 && !a.paused) a.pause();
|
||||
else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; });
|
||||
}
|
||||
for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); }
|
||||
this.old = this.old.filter(a => a.level > 0 || (a.pause(), false));
|
||||
}
|
||||
|
||||
start(src) {
|
||||
@@ -81,7 +104,7 @@ export class Music {
|
||||
unlock() {
|
||||
const a = this.blocked;
|
||||
this.blocked = null;
|
||||
if (a && a === this.current?.audio) a.play().catch(() => {});
|
||||
if (a && [...this.tracks.values()].some(t => t.audio === a && this.wanted.includes(t.src))) a.play().catch(() => {});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
|
||||
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||
import { check, PAGE_TURN } from './game.js';
|
||||
import { check, PAGE_TURN, POSTER_READY } from './game.js';
|
||||
import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { drawBocce } from './bocce-render.js';
|
||||
@@ -11,9 +11,12 @@ import { drawPesca } from './pesca-render.js';
|
||||
import { drawGara } from './gara-render.js';
|
||||
import { drawMicro } from './micro-render.js';
|
||||
import { drawCerbottana } from './cerbottana-render.js';
|
||||
import { drawCorsa } from './corsa.js';
|
||||
import { drawCondor } from './condor-render.js';
|
||||
import { drawCampana } from './campana-render.js';
|
||||
import { drawPoker } from './poker-render.js';
|
||||
import { drawViaggio } from './viaggio.js';
|
||||
import { drawIncanto } from './incanto.js';
|
||||
import { drawBriscola } from './briscola-render.js';
|
||||
import { drawTressette } from './tressette-render.js';
|
||||
import { drawScopa } from './scopa-render.js';
|
||||
@@ -129,13 +132,15 @@ export async function loadAssets(story, first = story.start.scene, { together =
|
||||
}
|
||||
|
||||
// Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili.
|
||||
// Prima tutti gli sfondi veri, poi le varianti (`backdrops`, come la notte dell'Ingorgo): quando servono prima, la
|
||||
// scena se le carica da sé.
|
||||
export function backgroundOrder(story, first) {
|
||||
const seen = new Set([first]), queue = [first];
|
||||
for (let i = 0; i < queue.length; i++)
|
||||
for (const h of story.scenes[queue[i]]?.hotspots ?? [])
|
||||
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
|
||||
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
|
||||
return [...new Set(queue.flatMap(id => [story.scenes[id].background, ...(story.scenes[id].backdrops ?? []).map(b => b.src)]).filter(Boolean))];
|
||||
return [...new Set([...queue.map(id => story.scenes[id].background), ...queue.flatMap(id => (story.scenes[id].backdrops ?? []).map(b => b.src))].filter(Boolean))];
|
||||
}
|
||||
|
||||
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
|
||||
@@ -482,6 +487,108 @@ export const EFFECTS = {
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
// Le luci da palco dell'Ingorgo (Andrea, 30 ottobre): dalle teste mobili (`from`) i fasci colorati che spazzano il
|
||||
// cielo; sul palco un bagliore che pulsa a tempo (`bpm`) e cambia colore a ogni battuta; ogni tanto lo strobo.
|
||||
stagelights(ctx, e, time) {
|
||||
const colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40'], beat = time / (60000 / (e.bpm ?? 124));
|
||||
const hit = 1 - (beat % 1), bar = Math.floor(beat / 4);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
if (e.wash) {
|
||||
const [x, y, r] = e.wash, c = colors[bar % colors.length];
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, `rgba(${c},${0.16 + 0.3 * hit ** 2})`);
|
||||
g.addColorStop(1, `rgba(${c},0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(x - r, y - r, r * 2, r * 2);
|
||||
}
|
||||
for (const [k, [x, y]] of (e.from ?? []).entries()) {
|
||||
for (let i = 0; i < (e.beams ?? 3); i++) {
|
||||
const n = k * 3 + i, swing = Math.sin(time / (700 + n * 150) + n * 1.9) * (e.spread ?? 0.85);
|
||||
const a = -Math.PI / 2 + swing + (k % 2 ? 0.25 : -0.25), len = e.len ?? 340, half = 0.055;
|
||||
const c = colors[(n + bar) % colors.length], tipX = x + Math.cos(a) * len, tipY = y + Math.sin(a) * len;
|
||||
const g = ctx.createLinearGradient(x, y, tipX, tipY);
|
||||
g.addColorStop(0, `rgba(${c},${0.38 + 0.2 * hit})`);
|
||||
g.addColorStop(1, `rgba(${c},0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + Math.cos(a - half) * len, y + Math.sin(a - half) * len);
|
||||
ctx.lineTo(x + Math.cos(a + half) * len, y + Math.sin(a + half) * len);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = `rgba(${c},0.9)`;
|
||||
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3);
|
||||
}
|
||||
}
|
||||
// Il colpo di colore su tutta la scena a ogni battuta (`pulse`: quanto forte).
|
||||
if (e.pulse) {
|
||||
ctx.fillStyle = `rgba(${colors[Math.floor(beat) % colors.length]},${e.pulse * hit ** 3})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
}
|
||||
ctx.restore();
|
||||
// Lo strobo: un lampo bianco brevissimo ogni `strobe` battute, a raffica nell'ultima battuta di ogni frase.
|
||||
const roll = Math.floor(beat) % 16 === 15 && (beat * 4) % 1 < 0.3;
|
||||
if (e.strobe && ((Math.floor(beat) % e.strobe === 0 && beat % 1 < 0.12) || roll)) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
}
|
||||
},
|
||||
// Le macchie di luce che corrono sulla folla (`area`), colorate, a tempo: la pista da ballo.
|
||||
floorspots(ctx, e, time) {
|
||||
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
|
||||
const beat = time / (60000 / (e.bpm ?? 124)), hit = 1 - (beat % 1);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < (e.count ?? 6); i++) {
|
||||
const u = 0.5 + 0.5 * Math.sin(time / (1300 + i * 230) + i * 2.1), v = 0.5 + 0.5 * Math.sin(time / (1700 + i * 190) + i * 1.3);
|
||||
const x = ax + u * aw, y = ay + v * ah, r = (e.size ?? 34) * (0.7 + 0.5 * v);
|
||||
const c = colors[(i + Math.floor(beat / 2)) % colors.length];
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, `rgba(${c},${0.22 + 0.2 * hit})`);
|
||||
g.addColorStop(1, `rgba(${c},0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.save();
|
||||
ctx.translate(x, y); ctx.scale(1, 0.45); ctx.translate(-x, -y);
|
||||
ctx.fillRect(x - r, y - r, r * 2, r * 2);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
// I laser della tecno: righe sottili verdi (o del colore dato) che si aprono a ventaglio e si richiudono.
|
||||
lasers(ctx, e, time) {
|
||||
const [x, y] = e.at, n = e.count ?? 7, open = 0.35 + 0.35 * Math.sin(time / 1300), tilt = Math.sin(time / 2100) * 0.4;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.strokeStyle = `rgba(${e.color ?? '90,255,110'},${0.45 + 0.25 * Math.sin(time / 90) ** 2})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + tilt + (i / (n - 1) - 0.5) * 2 * open * 1.4;
|
||||
ctx.moveTo(x + 0.5, y + 0.5);
|
||||
ctx.lineTo(x + Math.cos(a) * (e.len ?? 400), y + Math.sin(a) * (e.len ?? 400));
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
// La palla a specchi: puntini di luce che girano sui muri intorno (`area`), più chiari vicino alla palla.
|
||||
mirrorball(ctx, e, time) {
|
||||
const [bx, by] = e.at, [ax, ay, aw, ah] = e.area, turn = time / 4200;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (let i = 0; i < (e.count ?? 40); i++) {
|
||||
const u = (hash(i * 13) + turn) % 1, x = ax + Math.round(u * aw), y = ay + Math.round(hash(i * 7 + 3) * ah);
|
||||
const glint = 0.5 + 0.5 * Math.sin(time / 160 + i * 2.3);
|
||||
ctx.fillStyle = `rgba(${e.color ?? '220,200,255'},${0.35 + 0.5 * glint})`;
|
||||
ctx.fillRect(x, y, 2, 2);
|
||||
}
|
||||
const g = ctx.createRadialGradient(bx, by, 0, bx, by, 9);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.8)');
|
||||
g.addColorStop(1, 'rgba(200,180,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(bx - 9, by - 9, 18, 18);
|
||||
ctx.restore();
|
||||
},
|
||||
// Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`).
|
||||
lamps(ctx, e, time) {
|
||||
ctx.save();
|
||||
@@ -686,6 +793,39 @@ function drawCloseup(ctx, game, time) {
|
||||
drawViaggio(ctx, c, def, backgrounds.get(def.picture));
|
||||
return;
|
||||
}
|
||||
// La macchina del tempo da fuori: lo sfondo di adesso della scena, con la luce della fase del giorno.
|
||||
if (def.type === 'corsa') {
|
||||
const bg = game.backdrop(game.story.scenes[def.scene]), frames = {};
|
||||
for (const [k, src] of [[bg, bg], ...Object.entries(def.frames)]) {
|
||||
if (!backgrounds.has(src)) loadBackground(src);
|
||||
if (k !== bg) frames[k] = backgrounds.get(src);
|
||||
}
|
||||
drawCorsa(ctx, c, def, { bg: backgrounds.get(bg), frames, tint: game.phase()?.tint });
|
||||
return;
|
||||
}
|
||||
// L'incantesimo va sopra la scena (con Andrea dentro), già disegnata.
|
||||
if (def.type === 'incanto') { drawIncanto(ctx, c); return; }
|
||||
// Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo.
|
||||
if (def.type === 'poster') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
const img = backgrounds.get(def.picture), k = Math.min(1, c.t / POSTER_READY), ease = 1 - (1 - k) ** 3;
|
||||
ctx.fillStyle = `rgba(8, 4, 16, ${0.75 * ease})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
if (img) {
|
||||
const fit = Math.min(600 / img.width, 300 / img.height) * (0.55 + 0.45 * ease), w = img.width * fit, h = img.height * fit;
|
||||
ctx.globalAlpha = ease;
|
||||
ctx.drawImage(img, Math.round((WIDTH - w) / 2), Math.round((HEIGHT - 26 - h) / 2), Math.round(w), Math.round(h));
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
if (k >= 1 && Math.floor(time / 600) % 2 === 0) {
|
||||
ctx.font = '8px "Press Start 2P", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#ffe9b0';
|
||||
ctx.fillText(def.hint ?? 'Tocca il manifesto', WIDTH / 2, HEIGHT - 12);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (def.type === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
@@ -707,6 +847,11 @@ function drawCloseup(ctx, game, time) {
|
||||
drawCondor(ctx, c, def, backgrounds.get(def.picture));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'campana') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawCampana(ctx, c, def, backgrounds.get(def.picture));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'micro') {
|
||||
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); });
|
||||
|
||||
@@ -145,6 +145,17 @@ export class Soundscape {
|
||||
else if (kind === 'condor-palloncino') { this.noiseSound(0.12, 1800, 0.14, { pan }); this.tone(140, 60, 0.08, 0.08, 0, pan); }
|
||||
else if (kind === 'condor-plastica') { this.tone(1300, 900, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 2600, 0.03, { pan }); }
|
||||
else if (kind === 'condor-lancio') this.noiseSound(0.2, 700, 0.03, { pan });
|
||||
// La macchina del tempo: il tuono del fulmine e lo sfrigolio delle sgommate di fuoco.
|
||||
else if (kind === 'corsa-tuono') { this.noiseSound(0.9, 400, 0.16, { pan }); this.tone(90, 40, 0.8, 0.1, 0, pan); }
|
||||
else if (kind === 'corsa-fuoco') this.noiseSound(1.6, 2400, 0.05, { pan });
|
||||
// L'incantesimo dell'Ingorgo: i colpi di cassa che accelerano, poi il lampo con lo spazzolone del piatto.
|
||||
else if (kind === 'incanto-cassa') this.tone(150, 45, 0.22, 0.12 + strength * 0.1, 0, pan);
|
||||
else if (kind === 'incanto-lampo') { this.noiseSound(1.4, 5200, 0.12, { pan }); this.tone(60, 35, 1.2, 0.16, 0, pan); this.tone(880, 1760, 0.6, 0.05, 0.05, pan); }
|
||||
// La campana: il sassolino che vola e batte sull'asfalto, il salto, il piede giù.
|
||||
else if (kind === 'campana-lancio') this.noiseSound(0.1, 1500, 0.025, { 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-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 === '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.
|
||||
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 }); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
|
||||
import { story } from './story/story.js';
|
||||
import { Game, 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 { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
|
||||
@@ -14,6 +14,7 @@ import * as rinquarto from './engine/rinquarto.js';
|
||||
import * as micro from './engine/micro.js';
|
||||
import * as cerbottana from './engine/cerbottana.js';
|
||||
import * as condor from './engine/condor.js';
|
||||
import * as campana from './engine/campana.js';
|
||||
import * as poker from './engine/poker.js';
|
||||
import { cardAt as pokerAt } from './engine/poker-render.js';
|
||||
import * as basket from './engine/basket.js';
|
||||
@@ -587,7 +588,7 @@ canvas.addEventListener('pointermove', e => {
|
||||
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
|
||||
return;
|
||||
}
|
||||
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type);
|
||||
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[game.closeup.id].type);
|
||||
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||
});
|
||||
@@ -611,6 +612,7 @@ const MINI = {
|
||||
poker: { ...poker, back: 'Torna al Barrettino', label: 'Il pokerino: tocca le carte da cambiare, poi Cambia; nei giri di puntate Lascio, Vedo o Parola, Apro o Rilancio. Dopo la mostra un tocco va avanti.' },
|
||||
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.' },
|
||||
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.' },
|
||||
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||
@@ -1040,7 +1042,7 @@ function updateUi() {
|
||||
}
|
||||
}
|
||||
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' }[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' }[def.type] ?? 'Comandi delle bocce');
|
||||
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
|
||||
const line = mg.status(c, def.names);
|
||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||
@@ -1055,7 +1057,7 @@ function updateUi() {
|
||||
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
|
||||
const easy = def.type === 'gara' && gara.simple(c);
|
||||
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy);
|
||||
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy);
|
||||
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana');
|
||||
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza ';
|
||||
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');
|
||||
@@ -1076,6 +1078,7 @@ function updateUi() {
|
||||
setProp($('micro-help'), 'hidden', def.type !== 'micro');
|
||||
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
|
||||
setProp($('condor-help'), 'hidden', def.type !== 'condor');
|
||||
setProp($('campana-help'), 'hidden', def.type !== 'campana');
|
||||
setProp($('basket-help'), 'hidden', def.type !== 'basket');
|
||||
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
|
||||
setProp($('gara-help'), 'hidden', def.type !== 'gara');
|
||||
@@ -1096,7 +1099,7 @@ function updateUi() {
|
||||
canvas.classList.toggle('torch', !!torch);
|
||||
canvas.classList.toggle('bocce', !!mg);
|
||||
// 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'].includes(story.closeups[c.id].type));
|
||||
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[c.id].type));
|
||||
$('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");
|
||||
// La pagina che comincia a girare fa il suo fruscio.
|
||||
@@ -1124,7 +1127,8 @@ function frame(now) {
|
||||
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
|
||||
const searching = game?.closeup?.id === 'fessura';
|
||||
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
|
||||
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt);
|
||||
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id,
|
||||
at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt);
|
||||
// Un minuto senza toccare niente, a gioco fermo: pausa da sola.
|
||||
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
|
||||
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
|
||||
|
||||
@@ -43,11 +43,19 @@ const torchHints = [{
|
||||
}],
|
||||
}];
|
||||
|
||||
// La macchina del tempo che corre, vista da dietro e (all'arrivo) davanti: larga `width` pixel da vicino, le ruote a
|
||||
// `track` della larghezza dal centro.
|
||||
const MACCHINA_CORSA = {
|
||||
frames: { dietro: 'assets/closeups/macchina-dietro.png', lampi: 'assets/closeups/macchina-lampi.png', davanti: 'assets/closeups/macchina-davanti.png' },
|
||||
width: 220, track: 75,
|
||||
};
|
||||
|
||||
// Il ritorno dal 1997, dalla porta o dal pulsante «Torna al 2007»: la macchina del tempo sparisce, e per rifare il
|
||||
// viaggio si richiede all'Arcu (Andrea, 29 ottobre).
|
||||
const RITORNO_2007 = [
|
||||
{ say: 'Si torna nel 2007. Ciao Vannino, ciao Pierino.' },
|
||||
{ go: 'via_liguri_magelli', at: [212, 280], face: 'down' },
|
||||
{ closeup: 'arrivo' },
|
||||
{ unset: 'macchina_trappola' },
|
||||
{ say: 'Riportata dove l\'ho presa. Se ti riviene voglia di pokerini, chiedi.', who: 'arcu', pose: 'explain' },
|
||||
];
|
||||
@@ -64,6 +72,22 @@ export const story = {
|
||||
{ id: 'pomeriggio' },
|
||||
{ id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' },
|
||||
{ id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)', music: 'assets/audio/sera.mp3' },
|
||||
// La notte dell'Ingorgo Sonoro (Andrea, 30 ottobre): gli sfondi sono già di notte, il velo serve solo ad Andrea.
|
||||
// La musica la fanno le console delle scene (`stages`).
|
||||
{ id: 'ingorgo', when: 'ingorgo', tint: 'rgba(40, 20, 90, 0.16)', scale: 1.6 },
|
||||
],
|
||||
// Le zone: un flag che vale solo in certi posti. L'Ingorgo Sonoro (Andrea, 30 ottobre: «era la festa del paese
|
||||
// fatta una delle prime sere di luglio, da piazza fino a Cambray Digny, compresa piazza del Comune») si accende
|
||||
// col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie]
|
||||
zones: [
|
||||
{
|
||||
flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta'],
|
||||
exitsOnly: true, // alla festa si clicca solo per spostarsi (Andrea)
|
||||
leave: [
|
||||
{ say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' },
|
||||
{ say: 'E il palio è sempre sparito. Però che serata.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua
|
||||
// condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce.
|
||||
@@ -249,8 +273,20 @@ export const story = {
|
||||
picture: 'assets/closeups/cruscotto-tempo.png',
|
||||
display: [220, 158, 212, 62],
|
||||
date: '10/07/1997 18:30',
|
||||
complete: [{ closeup: 'partenza' }],
|
||||
},
|
||||
// La partenza vista da fuori: giù per Via Liguri Magelli, il fulmine e le sgommate di fuoco; e l'arrivo, al
|
||||
// ritorno dal 1997 (Andrea, 30 ottobre; la macchina da dietro e davanti è di Codex).
|
||||
partenza: {
|
||||
type: 'corsa', mode: 'parti', scene: 'via_liguri_magelli', ...MACCHINA_CORSA,
|
||||
path: [[215, 350, 1], [252, 192, 0.05]],
|
||||
complete: [{ go: 'barrettino_1997', at: [320, 330], face: 'up' }],
|
||||
},
|
||||
arrivo: {
|
||||
type: 'corsa', mode: 'arriva', scene: 'via_liguri_magelli', ...MACCHINA_CORSA,
|
||||
path: [[110, 470, 2.2], [252, 192, 0.05]],
|
||||
complete: [],
|
||||
},
|
||||
// Il pokerino del Barrettino con Vannino e Pierino (Andrea, 29 ottobre). [DA ANDREA: le battute]
|
||||
poker: {
|
||||
type: 'poker',
|
||||
@@ -281,6 +317,40 @@ export const story = {
|
||||
draw: [{ say: 'Pari. Si rifà?' }, { say: 'Si rifà.', who: 'taglia', pose: 'explain' }],
|
||||
quit: [{ say: 'La Condor la rendo al Taglia. Per ora.' }],
|
||||
},
|
||||
// La campana col gesso sul vialetto del campone delle poste, a turni col Taglia (Andrea, 30 ottobre).
|
||||
// [DA ANDREA: le battute sono provvisorie]
|
||||
campana: {
|
||||
type: 'campana',
|
||||
picture: 'assets/closeups/campone-campana.png',
|
||||
names: { player: 'Andrea', cpu: 'il Taglia' },
|
||||
win: [{ say: 'Dalla 1 fino in cima, e ritorno. A ventisett\'anni, davanti alle poste.' }, { say: 'Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.', who: 'taglia', pose: 'laugh' }, { set: 'campana_vinta' }],
|
||||
lose: [{ say: 'Il Taglia, su un piede solo, è più alto di me su due.' }, { say: 'È questione di gambe lunghe.', who: 'taglia', pose: 'explain' }],
|
||||
draw: [],
|
||||
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
|
||||
},
|
||||
// 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]
|
||||
manifesto_ingorgo: {
|
||||
type: 'poster',
|
||||
picture: 'assets/closeups/manifesto-ingorgo.png',
|
||||
hint: 'Tocca il manifesto',
|
||||
tap: [
|
||||
{ say: 'Il manifesto dell\'Ingorgo Sonoro dell\'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.' },
|
||||
{ say: 'Quanto darei per essere all\'Ingorgo stasera?!?' },
|
||||
{ closeup: 'ingorgo' },
|
||||
],
|
||||
},
|
||||
// L'incantesimo dell'Ingorgo Sonoro, dal manifesto in piazza: luci, cassa, lampo, ed è la notte della festa.
|
||||
ingorgo: {
|
||||
type: 'incanto',
|
||||
swap: [{ set: 'ingorgo' }],
|
||||
complete: [
|
||||
// Più emozione (Andrea: «MAHHH E' L'INGORGOOOOO!!!!!»); la voce legge la versione di tools/pronuncia.json.
|
||||
{ say: 'Mahhh... è l\'INGORGOOOO!!!' },
|
||||
{ say: 'Le console, la gente, le spine della birra!' },
|
||||
{ say: 'Il palio può aspettare una sera. O no?' },
|
||||
],
|
||||
},
|
||||
// Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre).
|
||||
// [DA ANDREA: le battute sono provvisorie]
|
||||
micro: {
|
||||
@@ -371,6 +441,8 @@ export const story = {
|
||||
// Andrea Tagliaferri, il Taglia (consenso suo, 29 ottobre): sprite di Codex dalla sua foto.
|
||||
taglia: { name: 'Il Taglia', color: '#ffe36a', sprite: 'assets/characters/taglia.json' },
|
||||
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
||||
// Baldoria (Yuri Baldi, nei Ringraziamenti), tifoso della Sampdoria col bandierone (Andrea, 30 ottobre; sprite di Codex).
|
||||
baldoria: { name: 'Baldoria', color: '#7fb2ff', sprite: 'assets/characters/baldoria.json' },
|
||||
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
|
||||
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
|
||||
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
|
||||
@@ -512,15 +584,22 @@ export const story = {
|
||||
name: 'Piazza Colonna',
|
||||
map: [32, 45],
|
||||
background: 'assets/scenes/piazza-colonna.png',
|
||||
effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }],
|
||||
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazza.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
|
||||
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
|
||||
// La console grande davanti al palazzo; da Via dell'Antica Posta, a sinistra, arriva il latino del Comune.
|
||||
stages: [
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [195, 235] },
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-160, 250], far: 600 },
|
||||
],
|
||||
effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[116, 146], [276, 146], [196, 140]], wash: [200, 215, 170], bpm: 124, beams: 3, strobe: 4, pulse: 0.08 }, { type: 'lasers', phase: 'ingorgo', at: [196, 150], count: 7, color: '255,60,200', len: 360 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 124, count: 7 }, { type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true, phase: ['pomeriggio', 'tramonto', 'sera'] }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }],
|
||||
walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]],
|
||||
depth: [[236, 0.5], [360, 1.35]],
|
||||
entry: [345, 240],
|
||||
speakers: { vecchietta: [24, 250], vecchietto: [56, 244] },
|
||||
// I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose).
|
||||
people: [
|
||||
{ who: 'vecchietta', at: [24, 317], size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
|
||||
{ who: 'vecchietto', at: [56, 304], size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
|
||||
{ who: 'vecchietta', at: [24, 317], when: '!ingorgo', size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
|
||||
{ who: 'vecchietto', at: [56, 304], when: '!ingorgo', size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
|
||||
],
|
||||
hotspots: [
|
||||
{
|
||||
@@ -583,7 +662,7 @@ export const story = {
|
||||
exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up',
|
||||
id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', when: '!ingorgo',
|
||||
art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }],
|
||||
actions: {
|
||||
look: [
|
||||
@@ -594,7 +673,20 @@ export const story = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk',
|
||||
// Il manifesto dell'Ingorgo Sonoro dell'estate prima, mezzo strappato: guardandolo, Andrea esprime il
|
||||
// desiderio, e la festa arriva (Andrea, 30 ottobre). [DA ANDREA: le battute sono provvisorie]
|
||||
id: 'manifesto_ingorgo', name: 'vecchio manifesto dell\'Ingorgo Sonoro', box: [223, 195, 22, 26], at: [234, 250], face: 'up', when: '!ingorgo',
|
||||
// Come quello vero (assets/raw/riferimenti/manifesto-ingorgo-2014.png): blu, il cartello bianco e rosso col DJ.
|
||||
art: [{ rect: [226, 198, 16, 22], fill: '#1c2050' }, { rect: [233, 201, 2, 2], fill: '#d8262e' }, { rect: [231, 203, 6, 2], fill: '#d8262e' },
|
||||
{ rect: [229, 205, 10, 4], fill: '#d8262e' }, { rect: [232, 205, 4, 3], fill: '#f4f1e8' }, { rect: [228, 211, 12, 2], fill: '#f4f1e8' },
|
||||
{ rect: [229, 215, 7, 2], fill: '#ffd23a' }, { rect: [238, 198, 4, 4], fill: '#cdbb9a' }],
|
||||
actions: {
|
||||
look: [{ closeup: 'manifesto_ingorgo' }],
|
||||
take: [{ say: 'Lo lascio dov\'è: è l\'unica cosa che ricorda luglio, qui, a maggio.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', when: '!ingorgo',
|
||||
actions: {
|
||||
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
|
||||
talk: [
|
||||
@@ -1532,6 +1624,15 @@ export const story = {
|
||||
name: 'Piazzetta Cambray Digny',
|
||||
map: [40, 38],
|
||||
background: 'assets/scenes/piazzetta-cambray-digny.png',
|
||||
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
|
||||
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
|
||||
// La tecno davanti alla casetta bianca; l'underground arriva da giù, da Via dell'Antica Posta.
|
||||
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
|
||||
effects: [{ type: 'lasers', phase: 'ingorgo', at: [400, 198], count: 13, len: 460 }, { type: 'lasers', phase: 'ingorgo', at: [370, 200], count: 7, color: '60,220,255' }, { type: 'stagelights', phase: 'ingorgo', from: [[356, 192], [460, 192], [408, 186]], wash: [408, 225, 180], bpm: 132, beams: 3, strobe: 2, pulse: 0.1, colors: ['90,255,110', '255,255,255', '60,220,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 132, count: 8, colors: ['90,255,110', '255,255,255', '60,220,255'] }],
|
||||
stages: [
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [495, 245] },
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [700, 300], far: 650 },
|
||||
],
|
||||
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
|
||||
depth: [[268, 0.55], [360, 1.15]],
|
||||
entry: [320, 330],
|
||||
@@ -1569,6 +1670,16 @@ export const story = {
|
||||
name: 'Via dell\'Antica Posta',
|
||||
map: [35, 41],
|
||||
background: 'assets/scenes/via-antica-posta.png',
|
||||
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
|
||||
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
|
||||
// Il bar della birra sui gradini; il latino dal Comune a sinistra, la house giù dalla piazza, l'underground su in cima.
|
||||
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
|
||||
effects: [{ type: 'stagelights', phase: 'ingorgo', wash: [90, 190, 130], bpm: 98, colors: ['255,170,60', '255,90,160'], pulse: 0.05 }, { type: 'stagelights', phase: 'ingorgo', from: [[430, 205], [130, 150]], beams: 2, len: 260, bpm: 98 }, { type: 'floorspots', phase: 'ingorgo', area: [40, 230, 560, 120], bpm: 98, count: 5 }],
|
||||
stages: [
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-110, 250] },
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [320, 560] },
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [384, 40], far: 600 },
|
||||
],
|
||||
walk: [[0, 360], [0, 300], [120, 290], [220, 252], [300, 240], [372, 226], [396, 226], [470, 252], [630, 300], [640, 360]],
|
||||
depth: [[226, 0.35], [360, 1.15]],
|
||||
entry: [320, 345],
|
||||
@@ -1608,6 +1719,15 @@ export const story = {
|
||||
name: 'Piazzetta del Comune',
|
||||
map: [32, 39],
|
||||
background: 'assets/scenes/piazzetta-comune.png',
|
||||
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta-comune.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
|
||||
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
|
||||
// La console del latino davanti al Comune; la house arriva da giù, dalla piazza.
|
||||
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
|
||||
effects: [{ type: 'stagelights', phase: 'ingorgo', from: [[232, 212], [332, 212], [282, 205]], wash: [282, 228, 170], bpm: 98, beams: 3, strobe: 8, pulse: 0.08, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 250, 560, 100], bpm: 98, count: 7, colors: ['255,120,40', '255,60,120', '255,200,60', '200,80,255'] }],
|
||||
stages: [
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [290, 235] },
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [260, 640], far: 700 },
|
||||
],
|
||||
walk: [[0, 360], [0, 318], [130, 292], [470, 288], [540, 300], [520, 360]],
|
||||
depth: [[288, 0.55], [360, 1.15]],
|
||||
entry: [260, 350],
|
||||
@@ -1655,6 +1775,16 @@ export const story = {
|
||||
name: 'In cima a Via dell\'Antica Posta',
|
||||
map: [38, 39],
|
||||
background: 'assets/scenes/via-antica-posta-alta.png',
|
||||
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta-alta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
|
||||
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
|
||||
// L'underground sotto il muro della villa; su, in fondo, la tecno di Cambray Digny; giù, lontano, la house.
|
||||
// Le luci da palco della festa (render.js: stagelights, lasers, mirrorball).
|
||||
effects: [{ type: 'mirrorball', phase: 'ingorgo', at: [550, 131], area: [0, 20, 640, 300], count: 110 }, { type: 'stagelights', phase: 'ingorgo', from: [[520, 200], [600, 200]], wash: [552, 205, 160], bpm: 122, beams: 2, strobe: 8, pulse: 0.07, colors: ['170,60,255', '90,40,220', '220,60,255'] }, { type: 'floorspots', phase: 'ingorgo', area: [40, 240, 560, 110], bpm: 122, count: 6, colors: ['170,60,255', '60,120,255', '220,60,255'] }],
|
||||
stages: [
|
||||
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235] },
|
||||
{ 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 },
|
||||
],
|
||||
walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]],
|
||||
depth: [[222, 0.35], [360, 1.15]],
|
||||
entry: [240, 350],
|
||||
@@ -1967,7 +2097,19 @@ export const story = {
|
||||
walk: [[0, 298], [640, 298], [640, 360], [0, 360]],
|
||||
depth: [[298, 1.0], [360, 1.2]],
|
||||
entry: [40, 330],
|
||||
// Baldoria sotto la tribuna col bandierone della Sampdoria: ogni tanto lo sventola (Andrea, 30 ottobre).
|
||||
speakers: { baldoria: [490, 196] },
|
||||
people: [{ who: 'baldoria', at: [490, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.3,
|
||||
flourish: { poses: ['wave', 'wave2'], every: 9, for: 2.1, step: 0.35 } }],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'baldoria', name: 'Baldoria', box: [462, 200, 56, 124], at: [430, 326], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.' }],
|
||||
talk: [{ dialogue: 'baldoria' }],
|
||||
take: [{ say: 'Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tribuna', name: 'tribuna', box: [254, 172, 116, 40], at: [310, 320], face: 'up',
|
||||
actions: {
|
||||
@@ -3943,6 +4085,26 @@ export const story = {
|
||||
},
|
||||
},
|
||||
// Il Taglia al campone delle poste, con la Condor (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
|
||||
// Baldoria al campo sportivo. [DA ANDREA: le battute sono provvisorie]
|
||||
baldoria: {
|
||||
nodes: {
|
||||
start: {
|
||||
options: [
|
||||
{ text: 'Baldoria! Ma che ci fai col bandierone al campo?', then: [
|
||||
{ say: 'Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.', who: 'baldoria', pose: 'explain' },
|
||||
{ say: 'Forza Doria!', who: 'baldoria', pose: 'wave' },
|
||||
] },
|
||||
{ text: 'Guarda che qui la Sampdoria non ci gioca.', then: [
|
||||
{ say: 'La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.', who: 'baldoria', pose: 'cheer' },
|
||||
] },
|
||||
{ text: 'Hai visto passare il palio, per caso?', then: [
|
||||
{ say: 'L\'unico palio che conosco è blu, bianco, rosso e nero. E ce l\'ho in mano.', who: 'baldoria', pose: 'laugh' },
|
||||
] },
|
||||
{ text: 'Ciao, Baldoria.', next: 'end' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
taglia: {
|
||||
nodes: {
|
||||
start: {
|
||||
@@ -3955,6 +4117,10 @@ export const story = {
|
||||
{ say: 'Il cartone sull\'albero, le lattine sulla panchina, le bottigliette sulla porta e il palloncino all\'altalena.', who: 'taglia', pose: 'explain' },
|
||||
{ say: 'E se la vuoi seria, ogni tanto ti tiro una lattina in aria.', who: 'taglia', pose: 'laugh' },
|
||||
] },
|
||||
{ text: 'E una partita a campana, Taglia?', next: 'campana', then: [
|
||||
{ say: 'A campana? Alla nostra età?', who: 'taglia', pose: 'laugh' },
|
||||
{ say: 'Va bene: il gesso ce l\'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.', who: 'taglia', pose: 'explain' },
|
||||
] },
|
||||
{ text: 'Ma quanto sei alto, Taglia?', then: [{ say: 'Abbastanza da vedere i pallini arrivare prima di te.', who: 'taglia', pose: 'laugh' }] },
|
||||
{ text: 'Ci si vede, Taglia.', next: 'end' },
|
||||
],
|
||||
@@ -3967,6 +4133,14 @@ export const story = {
|
||||
{ text: 'No, dopo.', next: 'start' },
|
||||
],
|
||||
},
|
||||
campana: {
|
||||
options: [
|
||||
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: le righe un contano. Comincia te.', who: 'taglia', pose: 'talk' }, { closeup: 'campana', level: 'bona' }] },
|
||||
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria: chi tocca la riga è fuori. Comincia te.', who: 'taglia', pose: 'explain' }, { closeup: 'campana', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Io la campana la facevo a occhi chiusi.', who: 'taglia', pose: 'laugh' }, { closeup: 'campana', level: 'male' }] },
|
||||
{ text: 'No, dopo.', next: 'start' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.55';
|
||||
export const VERSION = '0.1.60';
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -62,16 +62,16 @@ 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. */
|
||||
#bocce-throw.hold { min-height: 72px; font-size: 14px; }
|
||||
.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 { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||
#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 { 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 { max-width: 76em; line-height: 1.7; }
|
||||
#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-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 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; }
|
||||
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
||||
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||
#bocce-throw { flex-basis: 100%; }
|
||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help { font-size: 9px; }
|
||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-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-volume { width: 130px; accent-color: var(--accent); }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
test('Baldoria al campo principale: ogni tanto sventola il bandierone, sennò sta fermo', () => {
|
||||
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
|
||||
const p = g.people().find(p => p.who === 'baldoria');
|
||||
assert.ok(p, 'Baldoria è in scena');
|
||||
const poses = new Set();
|
||||
for (let t = 0; t < 9; t += 0.05) { g.time = t; poses.add(g.personPose(p)); }
|
||||
assert.ok(poses.has('wave') && poses.has('wave2') && poses.has('stand') && poses.has('blink'));
|
||||
g.time = 3; assert.equal(g.personPose(p), 'stand', 'fuori dallo sventolio, fermo');
|
||||
const sheet = JSON.parse(readFileSync(new URL('../assets/characters/baldoria.json', import.meta.url)));
|
||||
for (const f of ['stand', 'blink', 'talk', 'explain', 'wave', 'wave2', 'cheer', 'laugh']) assert.ok(sheet.frames[f], f);
|
||||
assert.ok(existsSync(new URL('../assets/characters/baldoria.png', import.meta.url)));
|
||||
});
|
||||
|
||||
test('si parla con Baldoria: le risposte e il saluto', () => {
|
||||
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
|
||||
g.act('talk', 'baldoria'); settle(g);
|
||||
assert.equal(g.dialogue?.id, 'baldoria');
|
||||
const said = [];
|
||||
for (const text of ['Baldoria! Ma che ci fai col bandierone al campo?', 'Guarda che qui la Sampdoria non ci gioca.', 'Hai visto passare il palio, per caso?']) {
|
||||
g.choose(g.options().findIndex(o => o.text === text));
|
||||
said.push(...settle(g).filter(s => s.who === 'baldoria').map(s => s.text));
|
||||
}
|
||||
assert.ok(said.includes('Forza Doria!'));
|
||||
assert.equal(said.length, 4);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Ciao, Baldoria.')); settle(g);
|
||||
assert.equal(g.dialogue, null);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// La campana nel browser vero: dal Taglia al campone delle poste, il tiro col mouse e col dito, i salti.
|
||||
// npm run browser:campana — schermate in /tmp/regolo-campana.
|
||||
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-campana';
|
||||
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('giardini_poste'); settle(game);
|
||||
game.act('talk', 'taglia'); settle(game);
|
||||
game.choose(game.options().findIndex(o => o.text.startsWith('E una partita a campana'))); 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/campone-campana.png'));
|
||||
assert.equal(await page.locator('#bocce-throw').textContent(), 'Tira');
|
||||
assert.ok(await page.locator('#campana-help').isVisible());
|
||||
assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira');
|
||||
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
|
||||
// Mira alla 1, poi la forza giusta: col mouse tenendo premuto, col dito il cursore e Tira.
|
||||
const one = await page.evaluate(async () => { const m = window.campanaModule = await import('/src/engine/campana.js'); const p = m.center(1); return [p.x, p.y, Math.hypot(p.x - m.FROM.x, p.y - m.FROM.y) / m.REACH]; });
|
||||
if (mobile) {
|
||||
await page.touchscreen.tap(...P(one[0], one[1]));
|
||||
assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto');
|
||||
await page.evaluate(p => { game.closeup.power = p; game.closeup.skill.scatter = 0; }, one[2]);
|
||||
await page.locator('#bocce-throw').tap();
|
||||
} else {
|
||||
await page.mouse.move(...P(one[0], one[1]));
|
||||
await page.evaluate(() => { game.closeup.skill.scatter = 0; });
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(p => Math.abs(game.closeup.power - p) < 0.02, one[2], { polling: 'raf' });
|
||||
await page.screenshot({ path: `${out}/${name}-forza.png` });
|
||||
await page.mouse.up();
|
||||
// Col mouse il momento giusto dipende da quanto è lesta la pagina: se è andato fuori, si ritira col pulsante.
|
||||
await page.waitForFunction(() => !['aim', 'flying'].includes(game.closeup.step));
|
||||
if (await page.evaluate(() => game.closeup.step) !== 'hop') {
|
||||
await page.evaluate(p => { Object.assign(game.closeup, { step: 'aim', current: 'player', next: null, line: null, power: p }); }, one[2]);
|
||||
await page.locator('#bocce-throw').click();
|
||||
}
|
||||
}
|
||||
await page.waitForFunction(() => game.closeup.step === 'hop', null, { timeout: 5000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ ...game.closeup, events: 0 }))); await page.screenshot({ path: `${out}/${name}-errore.png` }); throw e; });
|
||||
assert.equal(await page.locator('#bocce-throw').textContent(), 'Salta');
|
||||
// Quanti salti riescono: la lancetta dopo una schermata (lenta, con swiftshader) può già essere scappata.
|
||||
let hops = 0;
|
||||
// I salti: si preme quando la lancetta passa nel verde (col dito sul pulsante Salta, col mouse sulla scena).
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.waitForFunction(() => { const m = window.campanaModule, c = game.closeup; return c.step !== 'hop' || ((!c.hop || c.hop.t >= m.HOP) && c.meter.t > 0 && Math.abs(m.needle(c)) < 0.15); }, null, { polling: 'raf', timeout: 5000 });
|
||||
if (await page.evaluate(() => game.closeup.step) !== 'hop') break;
|
||||
if (mobile) await page.locator('#bocce-throw').tap(); else await page.mouse.click(...P(320, 200));
|
||||
hops = Math.max(hops, await page.evaluate(() => game.closeup.beat + 1));
|
||||
if (i === 1) await page.screenshot({ path: `${out}/${name}-salti.png` });
|
||||
}
|
||||
assert.ok(hops >= 2, `salti riusciti: ${hops}`);
|
||||
// Poi tocca al Taglia: lo si fa sbagliare per vederlo saltare.
|
||||
await page.evaluate(() => { const c = game.closeup; c.step = 'rest'; c.t = 0; c.next = 'cpu'; c.line = 'Prova: tocca al Taglia.'; });
|
||||
await page.waitForFunction(() => game.closeup.step === 'cpuHop' && game.closeup.beat >= 3 || game.closeup.current === 'player' && game.closeup.step === 'aim', null, { timeout: 20000 });
|
||||
await page.screenshot({ path: `${out}/${name}-taglia.png` });
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`ok: schermate in ${out}`);
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,125 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as m from '../src/engine/campana.js';
|
||||
import { drawCampana } from '../src/engine/campana-render.js';
|
||||
import { Game, MINIGAMES } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
// Tira senza sparpaglio, con la forza giusta per arrivare al punto, e aspetta che il sassolino cada.
|
||||
function toss(c, [x, y]) {
|
||||
c.skill = { ...c.skill, scatter: 0 }; c.aim = { x, y };
|
||||
c.power = Math.hypot(x - m.FROM.x, y - m.FROM.y) / m.REACH;
|
||||
assert.ok(m.shoot(c));
|
||||
for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60);
|
||||
}
|
||||
// Salta ogni volta che la lancetta passa nel mezzo.
|
||||
function hopAll(c) {
|
||||
for (let i = 0; i < 20000 && c.step === 'hop'; i++) {
|
||||
m.update(c, 1 / 120);
|
||||
if (c.step === 'hop' && (!c.hop || c.hop.t >= m.HOP) && Math.abs(m.needle(c)) < 0.03 && c.meter.t > 0) m.press(c, 1);
|
||||
}
|
||||
}
|
||||
const at = id => { const p = m.center(id); return [p.x, p.y]; };
|
||||
|
||||
test('le caselle: 1-2-3, la coppia 4-5, la 6, la coppia 7-8 e il cielo; le righe', () => {
|
||||
for (let n = 1; n <= 8; n++) assert.equal(m.boxAt(at(n), 3).id, n);
|
||||
assert.equal(m.boxAt(at('cielo')).id, 'cielo');
|
||||
assert.ok(m.center(4).y < m.center(5).y, 'la 4 a sinistra di chi salta');
|
||||
const b = m.BOXES[2];
|
||||
assert.ok(m.boxAt([b.x + 1, m.CY], 3).line, 'sul bordo si tocca la riga');
|
||||
assert.ok(!m.boxAt([b.x + 1, m.CY], 0).line, 'alla bona le righe non contano');
|
||||
assert.equal(m.boxAt([20, 20]), null);
|
||||
});
|
||||
|
||||
test('i salti: si scavalca la casella col sassolino, nella coppia un piede solo; tornando lo si raccoglie', () => {
|
||||
const ids = list => list.map(b => b.pick ? 'prendi' : b.out ? 'fuori' : b.boxes.join('+'));
|
||||
assert.deepEqual(ids(m.beats(1)), ['2', '3', '4+5', '6', '7+8', 'cielo', '7+8', '6', '4+5', '3', '2', 'prendi', 'fuori']);
|
||||
assert.deepEqual(ids(m.beats(5)), ['1', '2', '3', '4', '6', '7+8', 'cielo', '7+8', '6', 'prendi', '4', '3', '2', '1', 'fuori']);
|
||||
assert.deepEqual(ids(m.beats(8)), ['1', '2', '3', '4+5', '6', '7', 'cielo', 'prendi', '7', '6', '4+5', '3', '2', '1', 'fuori']);
|
||||
assert.ok(m.beats(3).every(b => !b.boxes.includes(3)), 'mai il piede dove sta il sassolino');
|
||||
});
|
||||
|
||||
test('il sassolino nella casella giusta fa saltare; tutta la campana fatta, si passa alla dopo', () => {
|
||||
const c = m.newCampana(1, { level: 'seria' });
|
||||
toss(c, at(1));
|
||||
assert.equal(c.step, 'hop'); assert.match(c.line, /nella 1/);
|
||||
hopAll(c);
|
||||
assert.equal(c.step, 'done'); assert.equal(c.target.player, 2); assert.equal(c.stone, null, 'raccolto');
|
||||
for (let i = 0; i < 120 && c.step !== 'aim'; i++) m.update(c, 1 / 60);
|
||||
assert.equal(c.current, 'player', 'chi ce la fa ritira');
|
||||
});
|
||||
|
||||
test('fuori, sulla riga o nella casella sbagliata passa la mano al Taglia', () => {
|
||||
for (const [point, why] of [[at(2), /Nella 2, non nella 1/], [[m.START + 1, m.CY], /riga/], [[60, 40], /fuori/i]]) {
|
||||
const c = m.newCampana(2, { level: 'seria' });
|
||||
toss(c, point);
|
||||
assert.equal(c.step, 'rest'); assert.match(c.line, why);
|
||||
for (let i = 0; i < 200 && c.step === 'rest'; i++) m.update(c, 1 / 60);
|
||||
assert.equal(c.current, 'cpu'); assert.equal(c.target.player, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('l\'equilibrio: troppo presto o troppo tardi si sbaglia', () => {
|
||||
const early = m.newCampana(3, { level: 'male' });
|
||||
toss(early, at(1));
|
||||
while (early.meter.t < 0.05) m.update(early, 1 / 120);
|
||||
m.press(early, 1); assert.equal(early.step, 'rest'); assert.match(early.line, /presto/);
|
||||
const late = m.newCampana(3, { level: 'male' });
|
||||
toss(late, at(1));
|
||||
for (let i = 0; i < 1000 && late.step === 'hop'; i++) m.update(late, 1 / 60);
|
||||
assert.match(late.line, /equilibrio/);
|
||||
});
|
||||
|
||||
test('il tiro col dito: un tocco secco mira soltanto; tenendo premuto la forza va e torna', () => {
|
||||
const c = m.newCampana(4, { level: 'bona' });
|
||||
assert.ok(m.aimScreen(c, [300, 150]));
|
||||
m.press(c, 1); m.update(c, 0.05); m.release(c, 1); assert.equal(c.step, 'aim');
|
||||
m.press(c, 1);
|
||||
for (let i = 0; i < 60 * m.CHARGE; i++) m.update(c, 1 / 60);
|
||||
assert.ok(c.power > 0.95);
|
||||
for (let i = 0; i < 30 * m.CHARGE; i++) m.update(c, 1 / 60);
|
||||
assert.ok(c.power < 0.6, 'e poi torna giù');
|
||||
assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying');
|
||||
assert.ok(m.key(c, 'ArrowUp', true));
|
||||
});
|
||||
|
||||
test('il Taglia gioca da solo; chi finisce la 8 vince', () => {
|
||||
const c = m.newCampana(5, { level: 'seria' });
|
||||
toss(c, [60, 40]);
|
||||
let turns = 0;
|
||||
for (let i = 0; i < 60 * 600 && c.step !== 'result'; i++) {
|
||||
m.update(c, 1 / 60);
|
||||
if (c.current === 'player' && c.step === 'aim') { turns++; toss(c, [60, 40]); }
|
||||
}
|
||||
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.equal(c.target.cpu, 9);
|
||||
assert.ok(turns > 0, 'il Taglia ogni tanto sbaglia e ripassa la mano');
|
||||
const w = m.newCampana(6, { level: 'bona' });
|
||||
w.target.player = 8;
|
||||
toss(w, at(8)); hopAll(w);
|
||||
assert.equal(w.step, 'result'); assert.equal(w.result.winner, 'player');
|
||||
});
|
||||
|
||||
test('il disegno della campana 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.newCampana(7, { level: 'bona' }), bg = {};
|
||||
const draw = () => { const before = structuredClone(c); drawCampana(ctx, c, story.closeups.campana, 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 === 'LA CAMPANA'));
|
||||
toss(c, at(1)); for (let i = 0; i < 40; i++) m.update(c, 1 / 60); draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'EQUILIBRIO'));
|
||||
c.step = 'result'; c.result = { winner: 'player' }; calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'BATTUTO IL TAGLIA!'));
|
||||
assert.ok(MINIGAMES.campana);
|
||||
});
|
||||
|
||||
test('dal Taglia, al campone delle poste: la campana, il livello e la vittoria', () => {
|
||||
const g = new Game(story); g.go('giardini_poste'); settle(g);
|
||||
g.act('talk', 'taglia'); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text.startsWith('E una partita a campana'))); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'campana'); assert.equal(g.closeup.level, 'seria');
|
||||
g.closeup.step = 'result'; g.closeup.result = { winner: 'player' };
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.campana_vinta);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as C from '../src/engine/corsa.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
|
||||
const run = (def, until) => { const c = C.newCorsa('x'); while (c.t < until) C.updateCorsa(c, def, 1 / 60); return c; };
|
||||
|
||||
test('la partenza: accelera giù per la strada, il fulmine la prende e sparisce; restano le sgommate', () => {
|
||||
const def = story.closeups.partenza;
|
||||
assert.equal(def.mode, 'parti');
|
||||
for (const src of Object.values(def.frames)) assert.ok(existsSync(src), src);
|
||||
const at = t => C.progress({ t }, def);
|
||||
assert.equal(at(0), 0);
|
||||
assert.ok(at(1.2) > 0 && at(1.6) - at(1.2) > at(1.2) - at(0.8), 'accelera');
|
||||
assert.ok(C.carShown({ t: 2 }, def) && !C.carShown({ t: C.PARTI.bolt + 0.2 }, def));
|
||||
const c = run(def, C.PARTI.bolt + 0.1);
|
||||
assert.ok(c.events.some(e => e.kind === 'corsa-tuono') && c.events.some(e => e.kind === 'corsa-fuoco'));
|
||||
assert.ok(!C.updateCorsa(c, def, 0.01));
|
||||
assert.ok(C.updateCorsa(run(def, C.PARTI.end - 0.01), def, 0.05), 'finita');
|
||||
});
|
||||
|
||||
test('l\'arrivo: il fulmine in fondo, poi viene avanti e ci passa accanto, fuori dal quadro', () => {
|
||||
const def = story.closeups.arrivo;
|
||||
const at = t => C.progress({ t }, def);
|
||||
assert.equal(at(0), 1); assert.equal(at(C.ARRIVA.bolt + C.ARRIVA.run + 0.1), 0);
|
||||
assert.ok(!C.carShown({ t: 0.1 }, def) && C.carShown({ t: 1 }, def));
|
||||
assert.ok(!C.carShown({ t: C.ARRIVA.bolt + C.ARRIVA.run + 0.1 }, def), 'passata');
|
||||
assert.ok(at(1.2) < at(0.8));
|
||||
});
|
||||
|
||||
test('il disegno della corsa non cambia lo stato e usa lo sfondo e i fotogrammi', () => {
|
||||
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
|
||||
const bg = { width: 640, height: 360 }, frames = { dietro: { width: 220, height: 190 }, lampi: { width: 220, height: 190 }, davanti: { width: 220, height: 190 } };
|
||||
for (const [id, t] of [['partenza', 1.4], ['partenza', 3.5], ['arrivo', 1.2], ['arrivo', 2.5]]) {
|
||||
const c = { ...C.newCorsa(id), t }, before = structuredClone(c);
|
||||
calls.length = 0;
|
||||
C.drawCorsa(ctx, c, story.closeups[id], { bg, frames, tint: null });
|
||||
assert.deepEqual(c, before);
|
||||
assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
|
||||
assert.equal(calls.some(([op, img]) => op === 'drawImage' && Object.values(frames).includes(img)), t < 2.4, 'dopo il fulmine (o passata) la macchina non c\'è');
|
||||
assert.ok(calls.filter(([op]) => op === 'fillRect').length > 20, 'le fiamme');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// L'Ingorgo Sonoro nel browser vero: il manifesto in piazza, l'effetto speciale, la festa nelle cinque scene con le
|
||||
// console che si mescolano, e il ritorno a maggio uscendo dal centro.
|
||||
// npm run browser:ingorgo — schermate in /tmp/regolo-ingorgo.
|
||||
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-ingorgo';
|
||||
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'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
||||
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||
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');
|
||||
window.settle = settle;
|
||||
settle(game); game.go('piazza', [250, 300]); settle(game);
|
||||
game.act('look', 'manifesto_ingorgo');
|
||||
});
|
||||
// Il manifesto si apre ingrandito; un clic sopra, e parte il desiderio.
|
||||
await page.waitForFunction(() => game.closeup?.id === 'manifesto_ingorgo' && game.closeup.t > 0.6, null, { timeout: 15000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ c: game.closeup, sp: game.speech, q: game.queue, task: game.task, walking: game.walking, xy: [game.state.x, game.state.y] }))); throw e; });
|
||||
await page.waitForFunction(() => game.closeup.t > 1.4, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/0-manifesto.png` });
|
||||
const box = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
// Le battute davanti al manifesto, poi l'effetto: a metà i fasci, poi il lampo, poi i coriandoli sulla festa.
|
||||
await page.waitForFunction(() => game.speech?.text.startsWith('Quanto darei'), null, { timeout: 15000 });
|
||||
await page.screenshot({ path: `${out}/1-desiderio.png` });
|
||||
await page.waitForFunction(() => game.closeup?.id === 'ingorgo' && game.closeup.t > 1.2, null, { timeout: 15000 });
|
||||
await page.screenshot({ path: `${out}/2-fasci.png` });
|
||||
await page.waitForFunction(() => game.closeup?.t > 2.5, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/3-coriandoli.png` });
|
||||
await page.waitForFunction(() => !game.closeup && game.state.flags.ingorgo && game.speech?.text.toLowerCase().includes('ingorgo'), null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/3b-festa.png` });
|
||||
await page.evaluate(() => settle(game));
|
||||
// La festa, scena per scena: lo sfondo di notte, e la musica di più console insieme.
|
||||
for (const [scene, at] of [['piazza', [250, 300]], ['via_antica_posta', [300, 300]], ['piazzetta_comune', [300, 330]], ['antica_posta_alta', [330, 300]], ['piazzetta', [330, 320]]]) {
|
||||
await page.evaluate(([s, a]) => { game.go(s, a); settle(game); }, [scene, at]);
|
||||
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground(game.backdrop()), null, { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
const mixNow = await page.evaluate(() => [...music.tracks.values()].map(t => [t.src.split('/').pop(), Math.round(t.gain * 100)]));
|
||||
console.log(scene, JSON.stringify(mixNow));
|
||||
assert.ok(mixNow.length >= 2, `${scene}: più console insieme`);
|
||||
await page.screenshot({ path: `${out}/4-${scene}.png` });
|
||||
}
|
||||
// Uscendo dal centro si torna a maggio.
|
||||
await page.evaluate(() => { game.go('via_de_medici'); });
|
||||
await page.waitForFunction(() => !game.state.flags.ingorgo && game.speech?.text.startsWith('Mi stropiccio'), null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/5-risveglio.png` });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`ok: schermate in ${out}`);
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,156 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { trackFor, mix, Music } from '../src/engine/music.js';
|
||||
import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js';
|
||||
import { Game, check, POSTER_READY } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
import { render } from '../src/engine/render.js';
|
||||
|
||||
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
|
||||
// Fa scorrere il tempo finché l'incantesimo non è finito.
|
||||
const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); };
|
||||
const fromPiazza = () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito');
|
||||
assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta');
|
||||
g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g);
|
||||
assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale');
|
||||
spell(g);
|
||||
return g;
|
||||
};
|
||||
|
||||
test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => {
|
||||
const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }];
|
||||
const near = mix(stages, [110, 300]);
|
||||
assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1);
|
||||
assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è');
|
||||
const mid = mix(stages, [350, 300]);
|
||||
assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari');
|
||||
// Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano.
|
||||
const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } };
|
||||
assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3');
|
||||
const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' });
|
||||
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
|
||||
});
|
||||
|
||||
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; } };
|
||||
const m = new Music({ volume: 0.5 });
|
||||
for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1);
|
||||
const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio];
|
||||
assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9);
|
||||
assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale');
|
||||
for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1);
|
||||
assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano');
|
||||
assert.deepEqual([...m.tracks.keys()], ['c.mp3']);
|
||||
delete globalThis.Audio;
|
||||
});
|
||||
|
||||
test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => {
|
||||
const c = newIncanto('ingorgo'), seen = [];
|
||||
let swapAt = null;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const now = updateIncanto(c, 1 / 60);
|
||||
if (now === 'swap') swapAt = c.t;
|
||||
if (now) seen.push(now);
|
||||
if (now === 'end') break;
|
||||
}
|
||||
assert.deepEqual(seen, ['swap', 'end']);
|
||||
assert.ok(Math.abs(swapAt - T.swap) < 0.02);
|
||||
assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente');
|
||||
assert.equal(whiteness(T.end), 0);
|
||||
const kicks = c.events.filter(e => e.kind === 'incanto-cassa');
|
||||
assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo'));
|
||||
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } });
|
||||
for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); }
|
||||
});
|
||||
|
||||
test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => {
|
||||
const g = fromPiazza();
|
||||
assert.ok(g.state.flags.ingorgo, 'la festa c\'è');
|
||||
assert.equal(g.phase().id, 'ingorgo');
|
||||
assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa');
|
||||
assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve');
|
||||
// Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console.
|
||||
for (const scene of ZONE.scenes) {
|
||||
g.go(scene); settle(g);
|
||||
assert.ok(g.state.flags.ingorgo, scene);
|
||||
assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`);
|
||||
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`);
|
||||
}
|
||||
g.go('via_cipriani'); settle(g);
|
||||
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
|
||||
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
|
||||
});
|
||||
|
||||
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
g.update(POSTER_READY); g.closeupTap(); settle(g);
|
||||
assert.equal(g.closeup?.id, 'ingorgo');
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.ingorgo);
|
||||
});
|
||||
|
||||
test('col dito il manifesto dell\'Ingorgo si prende anche toccando vicino, e non si scambia con quello del Palio', () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
const [x, y, w, h] = g.hotspot('manifesto_ingorgo').box, reach = 28; // 22 pixel di schermo su un iPhone in orizzontale
|
||||
for (const p of [[x + w / 2, y + h / 2], [x - 6, y + h / 2], [x + w + 4, y + h / 2], [x + w / 2, y + h + 6]])
|
||||
assert.equal(g.hotspotAt(p, reach)?.id, 'manifesto_ingorgo', `tocco in ${p}`);
|
||||
const palio = g.hotspot('manifesto').box;
|
||||
assert.equal(g.hotspotAt([palio[0] + palio[2] / 2, palio[1] + palio[3] / 2], reach)?.id, 'manifesto');
|
||||
});
|
||||
|
||||
test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
g.update(1); g.closeupClose(); settle(g);
|
||||
assert.ok(!g.state.flags.ingorgo && !g.closeup);
|
||||
});
|
||||
|
||||
test('alla festa si clicca solo per andare da un posto all\'altro', () => {
|
||||
const g = fromPiazza();
|
||||
for (const scene of ZONE.scenes) {
|
||||
g.go(scene); settle(g);
|
||||
const list = g.hotspots();
|
||||
assert.ok(list.length && list.every(h => h.exit), `${scene}: solo le uscite`);
|
||||
assert.ok(story.scenes[scene].hotspots.some(h => !h.exit), `${scene}: di giorno c'è anche altro`);
|
||||
}
|
||||
g.go('piazza'); settle(g);
|
||||
assert.equal(g.hotspotAt([505, 200]), null, 'la colonna, alla festa, non risponde');
|
||||
});
|
||||
|
||||
test('alla festa le luci da palco si muovono, e solo alla festa', () => {
|
||||
globalThis.Image = class { set src(v) {} };
|
||||
try {
|
||||
const ops = [], gradient = { addColorStop() {} };
|
||||
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { ops.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
|
||||
const g = fromPiazza();
|
||||
for (const scene of ZONE.scenes) {
|
||||
g.go(scene); settle(g);
|
||||
const lights = story.scenes[scene].effects.filter(e => e.phase === 'ingorgo');
|
||||
assert.ok(lights.length, `${scene}: le luci`);
|
||||
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`);
|
||||
}
|
||||
g.go('via_cipriani'); settle(g); g.go('piazza'); settle(g);
|
||||
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');
|
||||
} finally { delete globalThis.Image; }
|
||||
});
|
||||
|
||||
test('le musiche e gli sfondi della festa esistono', () => {
|
||||
const files = new Set();
|
||||
for (const id of ZONE.scenes) {
|
||||
const sc = story.scenes[id];
|
||||
for (const st of sc.stages ?? []) files.add(st.music);
|
||||
for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src);
|
||||
}
|
||||
files.add(story.closeups.manifesto_ingorgo.picture);
|
||||
assert.ok(files.size >= 10);
|
||||
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
||||
});
|
||||
@@ -5,6 +5,18 @@ import { drawPoker, cardAt, HAND } from '../src/engine/poker-render.js';
|
||||
import { Game, MINIGAMES, TRIP } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
import { PARTI, ARRIVA } from '../src/engine/corsa.js';
|
||||
|
||||
// I primi piani che vanno da soli (il cruscotto, la partenza, l'arrivo): si lascia scorrere il tempo finché finiscono.
|
||||
function ride(g, seen = []) {
|
||||
for (let t = 0; t < TRIP.end + PARTI.end + ARRIVA.end + 2; t += 0.05) {
|
||||
if (g.closeup && seen.at(-1) !== g.closeup.id) seen.push(g.closeup.id);
|
||||
if (!g.closeup && !g.queue.length && !g.speech) break;
|
||||
if (g.speech) g.skip(); else g.update(0.05);
|
||||
}
|
||||
settle(g);
|
||||
return seen;
|
||||
}
|
||||
|
||||
const card = s => ({ rank: s.slice(0, -1), suit: { c: 'cuori', q: 'quadri', f: 'fiori', p: 'picche' }[s.at(-1)] });
|
||||
const hand = s => s.split(' ').map(card);
|
||||
@@ -71,8 +83,7 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
|
||||
assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire');
|
||||
g.act('use', 'macchina_tempo'); settle(g);
|
||||
assert.equal(g.closeup?.id, 'viaggio');
|
||||
for (let t = 0; t < TRIP.end + 0.5 && g.closeup; t += 0.05) g.update(0.05);
|
||||
settle(g);
|
||||
assert.deepEqual(ride(g), ['viaggio', 'partenza'], 'il cruscotto, poi la partenza da fuori');
|
||||
assert.equal(g.state.scene, 'barrettino_1997');
|
||||
g.act('talk', 'tavola'); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text.startsWith('C\'è posto'))); settle(g);
|
||||
@@ -82,7 +93,8 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.poker_vinto);
|
||||
assert.ok(g.hotspot('arcu'), 'l\'Arcu è venuto anche lui');
|
||||
g.act('walk', 'porta'); settle(g);
|
||||
g.act('walk', 'porta');
|
||||
assert.deepEqual(ride(g), ['arrivo'], 'al ritorno, l\'arrivo col fulmine');
|
||||
assert.equal(g.state.scene, 'via_liguri_magelli');
|
||||
assert.ok(!g.state.flags.macchina_trappola && !g.hotspot('macchina_tempo'), 'tornati, la macchina sparisce');
|
||||
assert.equal(g.goBack(), false, 'nel 2007 il pulsante non c\'è');
|
||||
@@ -91,11 +103,10 @@ test('la macchina del tempo: l\'Arcu la fa comparire, il viaggio porta al Barret
|
||||
g.choose(g.options().findIndex(o => o.text.startsWith('Bei tempi'))); settle(g);
|
||||
assert.ok(!g.dialogue, 'finito il discorso, si è pronti a partire');
|
||||
g.act('use', 'macchina_tempo'); settle(g);
|
||||
for (let t = 0; t < TRIP.end + 0.5 && g.closeup; t += 0.05) g.update(0.05);
|
||||
settle(g);
|
||||
ride(g);
|
||||
assert.equal(g.state.scene, 'barrettino_1997');
|
||||
assert.equal(g.scene.back.label, 'Torna al 2007');
|
||||
assert.ok(g.goBack()); settle(g);
|
||||
assert.ok(g.goBack()); ride(g);
|
||||
assert.equal(g.state.scene, 'via_liguri_magelli');
|
||||
assert.ok(!g.hotspot('macchina_tempo'));
|
||||
});
|
||||
|
||||
@@ -135,7 +135,8 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
|
||||
const g = new Game(story);
|
||||
g.go(story.start.scene, story.start.at);
|
||||
settle(g);
|
||||
const ids = story.phases.map(p => p.id), seen = [g.phase().id];
|
||||
// La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa.
|
||||
const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id];
|
||||
for (const [verb, target, item] of solution) {
|
||||
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
|
||||
settle(g, { explore: true });
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""I segnaposto delle console dell'Ingorgo Sonoro, sintetizzati da zero: quattro loop da sedici battute, uno per genere.
|
||||
Nel gioco ora suonano i brani di Andrea (Suno, 30 ottobre: assets/raw/musica/ingorgo-*-suno.mp3, portati al volume
|
||||
del gioco in assets/audio/ingorgo-*.mp3); questi restano in assets/raw/musica/ingorgo-*-sintesi.mp3, per prova.
|
||||
python3 tools/ingorgo_musiche.py # tutti e quattro (serve ffmpeg)
|
||||
python3 tools/ingorgo_musiche.py house # uno solo
|
||||
Generi: latino (dembow e piano montuno), house (cassa dritta, accordi d'organo in levare), techno (cassa dura,
|
||||
linea acida che si apre), underground (minimale scuro, basso che rotola e colpi lontani).
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import wave
|
||||
from array import array
|
||||
from pathlib import Path
|
||||
|
||||
SR = 22050
|
||||
AUDIO = Path(__file__).resolve().parent.parent / "assets" / "raw" / "musica"
|
||||
NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
|
||||
|
||||
def hz(note: str) -> float:
|
||||
semis = NAMES[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
|
||||
return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12)
|
||||
|
||||
|
||||
class Track:
|
||||
def __init__(self, bpm: int, bars: int = 16, seed: int = 1):
|
||||
self.beat = 60 / bpm
|
||||
self.buf = [0.0] * int(SR * bars * 4 * self.beat)
|
||||
self.rng = random.Random(seed)
|
||||
self.cache = {}
|
||||
|
||||
def add(self, beat: float, samples):
|
||||
# Il loop gira: quello che sborda oltre la fine rientra dall'inizio.
|
||||
n, i0 = len(self.buf), int(beat * self.beat * SR)
|
||||
for i, x in enumerate(samples):
|
||||
self.buf[(i0 + i) % n] += x
|
||||
|
||||
def once(self, key, make):
|
||||
if key not in self.cache:
|
||||
self.cache[key] = make()
|
||||
return self.cache[key]
|
||||
|
||||
|
||||
# --- I suoni: la batteria si calcola una volta e si timbra, le note una per una.
|
||||
def kick(vol=0.9, top=150.0, bottom=45.0, length=0.32, punch=0.03):
|
||||
out, ph = [], 0.0
|
||||
for i in range(int(length * SR)):
|
||||
t = i / SR
|
||||
f = bottom + (top - bottom) * math.exp(-t / punch)
|
||||
ph += 2 * math.pi * f / SR
|
||||
out.append(vol * math.exp(-t / (length / 3.2)) * math.sin(ph))
|
||||
return out
|
||||
|
||||
|
||||
def noise(rng, vol, length, decay, bright=True):
|
||||
out, prev = [], 0.0
|
||||
for i in range(int(length * SR)):
|
||||
x = rng.uniform(-1, 1)
|
||||
y = x - prev if bright else (prev + x) * 0.5 # differenza: solo alti; media: più corpo
|
||||
prev = x
|
||||
out.append(vol * y * math.exp(-i / SR / decay))
|
||||
return out
|
||||
|
||||
|
||||
def clap(rng, vol=0.35):
|
||||
# Tre colpi ravvicinati di rumore e una coda: il battimani.
|
||||
out = [0.0] * int(0.25 * SR)
|
||||
for k, d in ((0, 0.006), (0.011, 0.006), (0.022, 0.07)):
|
||||
for i, x in enumerate(noise(rng, vol, 0.2, d, bright=False)):
|
||||
j = int(k * SR) + i
|
||||
if j < len(out):
|
||||
out[j] += x
|
||||
return out
|
||||
|
||||
|
||||
def drum(freq, vol, decay, bend=0.0):
|
||||
# Bongo, conga, tom: un seno che cala un poco.
|
||||
out, ph = [], 0.0
|
||||
for i in range(int(decay * 5 * SR)):
|
||||
t = i / SR
|
||||
ph += 2 * math.pi * freq * (1 + bend * math.exp(-t / 0.02)) / SR
|
||||
out.append(vol * math.exp(-t / decay) * math.sin(ph))
|
||||
return out
|
||||
|
||||
|
||||
def synth(freq, length, vol, wave_="saw", cutoff=1200.0, sweep=0.0, decay=9.0, attack=0.004, release=0.03, detune=0.0, res=0.0):
|
||||
"""Un oscillatore (sega o quadra, o due appena stonati) dentro un filtro passa basso a due poli, che può aprirsi
|
||||
e chiudersi (`sweep`, in Hz verso cui va il taglio durante la nota). `res` dà il fischio dell'acido."""
|
||||
n = int((length + release) * SR)
|
||||
out, p1, p2 = [], 0.0, 0.0
|
||||
lo = bp = 0.0
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
env = min(1.0, t / attack) * math.exp(-t / decay)
|
||||
if t > length:
|
||||
env *= max(0.0, 1 - (t - length) / release)
|
||||
p1 = (p1 + freq / SR) % 1
|
||||
p2 = (p2 + freq * (1 + detune) / SR) % 1
|
||||
if wave_ == "square":
|
||||
x = (1 if p1 < 0.5 else -1) * 0.7
|
||||
else:
|
||||
x = (2 * p1 - 1) + ((2 * p2 - 1) if detune else 0)
|
||||
x *= 0.5 if detune else 1
|
||||
cut = cutoff + sweep * min(1.0, t / max(length, 1e-3))
|
||||
f = 2 * math.sin(math.pi * min(cut, SR / 6) / SR)
|
||||
q = 1 - res
|
||||
lo += f * bp
|
||||
hi = x - lo - q * bp
|
||||
bp += f * hi
|
||||
out.append(vol * env * lo)
|
||||
return out
|
||||
|
||||
|
||||
def sine(freq, length, vol, decay=9.0, attack=0.004, release=0.05, partials=((1, 1.0),)):
|
||||
n = int((length + release) * SR)
|
||||
out = []
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
env = min(1.0, t / attack) * math.exp(-t / decay)
|
||||
if t > length:
|
||||
env *= max(0.0, 1 - (t - length) / release)
|
||||
out.append(vol * env * sum(a * math.sin(2 * math.pi * freq * k * t) for k, a in partials))
|
||||
return out
|
||||
|
||||
|
||||
# --- I quattro generi.
|
||||
def house():
|
||||
tr = Track(124, seed=2)
|
||||
K, H, C = kick(), tr.once("h", lambda: noise(tr.rng, 0.16, 0.08, 0.025)), clap(tr.rng, 0.32)
|
||||
OH = tr.once("oh", lambda: noise(tr.rng, 0.12, 0.25, 0.09))
|
||||
chords = [["A3", "C4", "E4", "G4"], ["F3", "A3", "C4", "E4"], ["D3", "F3", "A3", "C4"], ["E3", "G#3", "B3", "D4"]]
|
||||
roots = ["A1", "F1", "D2", "E1"]
|
||||
for bar in range(16):
|
||||
b0, ch, root = bar * 4, chords[bar // 2 % 4], roots[bar // 2 % 4]
|
||||
for k in range(4):
|
||||
tr.add(b0 + k, K)
|
||||
tr.add(b0 + k + 0.5, OH)
|
||||
for s in (0.25, 0.75):
|
||||
tr.add(b0 + k + s, H)
|
||||
for k in (1, 3):
|
||||
tr.add(b0 + k, C)
|
||||
# Gli accordi d'organo in levare, corti, il cuore della house.
|
||||
for s in (0.5, 1.5, 2.75, 3.5):
|
||||
for note in ch:
|
||||
tr.add(b0 + s, synth(hz(note), tr.beat * 0.22, 0.05, cutoff=2600, decay=0.25, detune=0.006))
|
||||
# Il basso: in levare, che salta d'ottava.
|
||||
for s, octv in ((0.5, 1), (1.5, 2), (2.5, 1), (3.25, 1), (3.5, 2)):
|
||||
tr.add(b0 + s, synth(hz(root) * octv, tr.beat * 0.3, 0.32, wave_="square", cutoff=500, decay=0.3))
|
||||
return tr
|
||||
|
||||
|
||||
def techno():
|
||||
tr = Track(132, seed=3)
|
||||
K = kick(1.0, top=170, bottom=48, length=0.36, punch=0.022)
|
||||
H = tr.once("h", lambda: noise(tr.rng, 0.13, 0.05, 0.012))
|
||||
R = tr.once("r", lambda: noise(tr.rng, 0.1, 0.6, 0.25, bright=False)) # il ride lungo, nella seconda metà
|
||||
line = ["A1", "A1", "A2", "A1", "C2", "A1", "G2", "A1", "A1", "E2", "A1", "A2", "A1", "C2", "D2", "A1"]
|
||||
for bar in range(16):
|
||||
b0 = bar * 4
|
||||
for k in range(4):
|
||||
tr.add(b0 + k, K)
|
||||
tr.add(b0 + k + 0.5, H)
|
||||
if bar >= 8:
|
||||
tr.add(b0 + k + 0.5, R)
|
||||
if bar % 4 == 3:
|
||||
tr.add(b0 + 3.75, clap(tr.rng, 0.25))
|
||||
# La linea acida: sedicesimi, il filtro che si apre battuta dopo battuta e poi richiude.
|
||||
open_ = 300 + 1700 * (0.5 - 0.5 * math.cos(2 * math.pi * bar / 16))
|
||||
for s, note in enumerate(line):
|
||||
if s % 4 == 0 or tr.rng.random() < 0.8:
|
||||
tr.add(b0 + s / 4, synth(hz(note), tr.beat * 0.2, 0.2, wave_="saw", cutoff=open_, sweep=-open_ * 0.6, decay=0.15, res=0.85))
|
||||
return tr
|
||||
|
||||
|
||||
def underground():
|
||||
tr = Track(122, seed=4)
|
||||
K = kick(0.95, top=110, bottom=42, length=0.42, punch=0.04)
|
||||
H = tr.once("h", lambda: noise(tr.rng, 0.09, 0.04, 0.01))
|
||||
RIM = drum(1700, 0.12, 0.012)
|
||||
for bar in range(16):
|
||||
b0 = bar * 4
|
||||
for k in range(4):
|
||||
tr.add(b0 + k, K)
|
||||
for s in (0.5, 0.75):
|
||||
tr.add(b0 + k + s, H)
|
||||
for s in (1.75, 3.25) if bar % 2 else (2.75,):
|
||||
tr.add(b0 + s, RIM)
|
||||
# Il basso che rotola sui sedicesimi in levare, sempre la stessa nota: ipnotico.
|
||||
for k in range(4):
|
||||
for s in (0.25, 0.5, 0.75):
|
||||
tr.add(b0 + k + s, synth(hz("F1"), tr.beat * 0.16, 0.24, cutoff=260, decay=0.12))
|
||||
# Un blip lontano ogni tanto, con l'eco.
|
||||
if bar % 2 == 1:
|
||||
note = ["C5", "Ab4", "Eb5", "F4"][bar // 2 % 4]
|
||||
for rep, v in ((0, 0.07), (0.75, 0.04), (1.5, 0.02)):
|
||||
tr.add(b0 + 2.5 + rep, sine(hz(note), 0.06, v, decay=0.08, partials=((1, 1), (3, 0.2))))
|
||||
# Il pad scuro che respira sotto, ogni quattro battute.
|
||||
if bar % 4 == 0:
|
||||
for note in ("F2", "Ab2", "C3"):
|
||||
tr.add(b0, synth(hz(note), tr.beat * 15, 0.03, cutoff=420, sweep=300, decay=99, attack=1.5, release=1.0, detune=0.004))
|
||||
return tr
|
||||
|
||||
|
||||
def latino():
|
||||
tr = Track(98, seed=5)
|
||||
K = kick(0.85, top=130, bottom=50, length=0.3)
|
||||
SN = tr.once("sn", lambda: [a + b for a, b in zip(noise(tr.rng, 0.22, 0.15, 0.05, bright=False), drum(220, 0.18, 0.05) + [0.0] * 9999)])
|
||||
H = tr.once("h", lambda: noise(tr.rng, 0.08, 0.05, 0.015))
|
||||
BONGO = (drum(380, 0.16, 0.06, bend=0.3), drum(520, 0.13, 0.05, bend=0.3))
|
||||
CONGA = drum(210, 0.2, 0.12, bend=0.2)
|
||||
chords = [["A3", "C4", "E4"], ["D4", "F4", "A4"], ["E3", "G#3", "B3"], ["A3", "C4", "E4"]]
|
||||
roots = ["A1", "D2", "E1", "A1"]
|
||||
for bar in range(16):
|
||||
b0, ch, root = bar * 4, chords[bar % 4], roots[bar % 4]
|
||||
# Il dembow: cassa sull'uno e sul tre, rullante sul «tre sedicesimi» e sul due e mezzo.
|
||||
for k in (0, 2):
|
||||
tr.add(b0 + k, K)
|
||||
tr.add(b0 + k + 0.75, SN)
|
||||
tr.add(b0 + k + 1.5, SN)
|
||||
for k in range(8):
|
||||
tr.add(b0 + k / 2, H)
|
||||
for s, b in ((0.5, 0), (1.0, 1), (2.5, 0), (3.0, 1), (3.5, 1)):
|
||||
tr.add(b0 + s, BONGO[b])
|
||||
tr.add(b0 + 3.75, CONGA)
|
||||
# Il piano montuno: accordi sincopati, voce alta raddoppiata all'ottava.
|
||||
for s in (0, 0.75, 1.5, 2.5, 3.25):
|
||||
for note in ch:
|
||||
tr.add(b0 + s, sine(hz(note), tr.beat * 0.3, 0.05, decay=0.35, partials=((1, 1), (2, 0.4), (3, 0.15), (4, 0.08))))
|
||||
# Il basso del tumbao: in anticipo sul due e mezzo e sul quattro.
|
||||
for s, f in ((0, 1), (1.5, 1.5), (3, 1)):
|
||||
tr.add(b0 + s, synth(hz(root) * f, tr.beat * 0.8, 0.3, wave_="square", cutoff=380, decay=0.6))
|
||||
return tr
|
||||
|
||||
|
||||
GENRES = {"house": house, "techno": techno, "underground": underground, "latino": latino}
|
||||
|
||||
|
||||
def write(name: str, tr: Track):
|
||||
peak = max(abs(x) for x in tr.buf) or 1
|
||||
AUDIO.mkdir(parents=True, exist_ok=True)
|
||||
out = AUDIO / f"ingorgo-{name}-sintesi.mp3"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
w = Path(tmp) / "loop.wav"
|
||||
with wave.open(str(w), "wb") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(SR)
|
||||
f.writeframes(array("h", (int(x / peak * 0.9 * 32767) for x in tr.buf)).tobytes())
|
||||
# Una piazza all'aperto: un filo d'eco dai muri, e il compressore delle casse.
|
||||
af = "acompressor=threshold=0.25:ratio=3:attack=5:release=80,aecho=0.85:0.4:90:0.12,loudnorm=I=-18:TP=-2:LRA=7"
|
||||
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(out)], check=True)
|
||||
print(f"-> {out} ({len(tr.buf) / SR:.0f} s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sys.argv[1:] or GENRES:
|
||||
write(name, GENRES[name]())
|
||||
@@ -36,7 +36,10 @@ def main():
|
||||
p.add_argument('--height', type=int, default=120)
|
||||
p.add_argument('--frames', default=','.join(FRAMES), help='nomi dei fotogrammi, separati da virgola')
|
||||
p.add_argument('--ref', default='stand', help='fotogramma la cui altezza diventa --height')
|
||||
p.add_argument('--fix', default='', help='correzioni di scala per fotogramma, quando nel foglio la figura è disegnata '
|
||||
'più piccola o più grande: wave=1.3,cheer=1.05')
|
||||
a = p.parse_args()
|
||||
fix = {k: float(v) for k, v in (kv.split('=') for kv in a.fix.split(',') if kv)}
|
||||
|
||||
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')}
|
||||
ref = frames[a.ref].getchannel('A').getbbox()
|
||||
@@ -45,10 +48,11 @@ def main():
|
||||
for f, im in frames.items():
|
||||
im = im.crop(im.getchannel('A').getbbox())
|
||||
cx, base = torso_center(im)
|
||||
w, h = max(1, round(im.width * factor)), max(1, round(im.height * factor))
|
||||
k = factor * fix.get(f, 1)
|
||||
w, h = max(1, round(im.width * k)), max(1, round(im.height * k))
|
||||
im = im.resize((w, h), Image.LANCZOS)
|
||||
strip.append(im)
|
||||
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * factor, 1), 'ay': h}
|
||||
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * k, 1), 'ay': h}
|
||||
x += w + 2
|
||||
sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0))
|
||||
for im, m in zip(strip, meta.values()):
|
||||
|
||||
@@ -19,5 +19,7 @@
|
||||
"bluffava": "bleffava",
|
||||
"Imposto": "Impòsto",
|
||||
"Libici": "Lìbici",
|
||||
"mancano": "màncano"
|
||||
"mancano": "màncano",
|
||||
"console": "consòl",
|
||||
"Mahhh... è l'INGORGOOOO!!!": "Maaaa, è l'Ingorgo! L'Ingorgooo!"
|
||||
}
|
||||
|
||||
@@ -73,5 +73,8 @@
|
||||
},
|
||||
"taglia": {
|
||||
"speaker": "Aaron Dreschner"
|
||||
},
|
||||
"baldoria": {
|
||||
"speaker": "Marcos Rudaski"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ Copioni `video/trailer.mjs` (circa 1 min 30 s) e `video/trailer-extended.mjs` (c
|
||||
del paese e i giochi giocati da soli). Niente clic: la scena è a tutto schermo, le scritte e gli stacchi al nero
|
||||
sono elementi aggiunti nella pagina, e la musica la sceglie il copione (`out/<clip>-audio.json`). Nel finale si
|
||||
cercano solo gli occhi gialli: il palio non si mostra (Andrea: si capirebbe dov'è).
|
||||
La extended rifatta il 30 ottobre (circa 4 min 15 s), su richiesta di Andrea: non deve mai far capire che il Regolo è
|
||||
in Fortezza. Quindi niente impronte e niente fessura: «la notte prima del Palio» è la piazza scurita con le finestre
|
||||
accese, il finale ha due occhi gialli nel nero, e il titolo sta sopra il panorama, perché l'illustrazione del titolo ha
|
||||
il Regolo fra le mura. In più ci sono Condor, campana, pesca, basket e pokerino: a ogni fotogramma gioca da solo
|
||||
un giocatore automatico (`gioca(…)`).
|
||||
|
||||
- Prova locale, senza VM: `OUT=/tmp/prova COPIONE=./trailer.mjs node video/prova-locale.mjs` (una schermata per
|
||||
battuta, e i tempi).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copione del video breve dell'Ingorgo Sonoro (Andrea, 30 ottobre: «45 secondi, che parta come... ci sono cose che non
|
||||
// tornano..... forse..., e fai vedere il manifesto dell'Ingorgo e girare tra la musica»). Come i trailer
|
||||
// (video/trailer.mjs): la scena a tutto schermo, scritte e stacchi al nero nella pagina, niente clic; la musica la
|
||||
// sceglie il copione, una console per posto (out/ingorgo-sonoro-audio.json).
|
||||
// Prova: OUT=/tmp/prova COPIONE=./ingorgo-sonoro.mjs node video/prova-locale.mjs
|
||||
// Ripresa come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
const c = (id, it) => ({ id, it, en: it, top: true });
|
||||
export const title = { it: "La Leggenda del Regolo · l'Ingorgo Sonoro", en: "La Leggenda del Regolo · l'Ingorgo Sonoro" };
|
||||
export const cues = [
|
||||
c("dubbio", "Ci sono cose che non tornano…"),
|
||||
c("manifesto", "il manifesto"),
|
||||
c("incanto", "l'incantesimo"),
|
||||
c("festa", "la festa"),
|
||||
c("titolo", "La Leggenda del Regolo"),
|
||||
];
|
||||
|
||||
const URL_GIOCO = "http://127.0.0.1:8000/";
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const H = "assets/audio/ingorgo-house.mp3", L = "assets/audio/ingorgo-latino.mp3", U = "assets/audio/ingorgo-underground.mp3",
|
||||
T = "assets/audio/ingorgo-techno.mp3";
|
||||
|
||||
export async function setup(d) {
|
||||
const p = d.page;
|
||||
await p.goto(URL_GIOCO);
|
||||
await p.evaluate(() => localStorage.clear());
|
||||
await p.reload();
|
||||
await sleep(500);
|
||||
if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash");
|
||||
await p.waitForSelector("#title:not([hidden])");
|
||||
await p.click("#new-game");
|
||||
await p.click('#rione [data-rione="campomigliaio"]');
|
||||
await p.click("#skip-intro");
|
||||
await p.addStyleTag({ content: `
|
||||
#side, #corner, #sound, #closeup-close, #bocce-controls, #briscola-controls, #tressette-controls, #scopa-controls,
|
||||
#map-button, #pause-button, #spots-button, #notebook-button, #sentence { display: none !important; }
|
||||
body { background: #000 !important; overflow: hidden; }
|
||||
#app { position: fixed !important; inset: 0 !important; width: 100vw !important; margin: 0 !important; }
|
||||
#stage, #stage.biliardo, #stage.briscola { width: 100vw !important; height: 100vh !important; }
|
||||
#scene { width: 100vw !important; height: 100vh !important; }
|
||||
#title .buttons, #title .version, #title .dedication { display: none !important; }
|
||||
#title::before { background-image: url(assets/scenes/ingorgo-piazza.png) !important; }
|
||||
#title::after { background: linear-gradient(rgba(20, 12, 30, .6), rgba(20, 12, 30, .1) 45%, rgba(20, 12, 30, .75)),
|
||||
url(assets/scenes/ingorgo-piazza.png) center / contain no-repeat !important; }
|
||||
#tr-nero { position: fixed; inset: 0; background: #000; opacity: 1; transition: opacity .7s; z-index: 50; pointer-events: none; }
|
||||
#tr-scritta { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; z-index: 51;
|
||||
font-family: "Press Start 2P", monospace; font-size: 40px; line-height: 1.7; color: #ffe9b0; opacity: 0; white-space: pre-line;
|
||||
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
|
||||
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 30px; }
|
||||
` });
|
||||
await p.evaluate(() => {
|
||||
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
|
||||
});
|
||||
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
|
||||
await p.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); window.voices?.stop?.(); });
|
||||
d.vm("DISPLAY=:0 ~/lab/video/mv.py 1599 899 0.2");
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
export async function run(d) {
|
||||
const p = d.page;
|
||||
// L'orologio dell'audio, come nei trailer: le voci che partono; la musica la decide il copione.
|
||||
await p.evaluate(() => {
|
||||
window.__t0 = Date.now(); window.__audio = [];
|
||||
const t = () => (Date.now() - window.__t0) / 1000;
|
||||
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
|
||||
let voice = null;
|
||||
window.__musica = (src) => window.__audio.push({ t: t(), music: src });
|
||||
const look = () => {
|
||||
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
|
||||
if (now !== voice) {
|
||||
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
|
||||
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
|
||||
voice = now;
|
||||
}
|
||||
requestAnimationFrame(look);
|
||||
};
|
||||
look();
|
||||
});
|
||||
const musica = (src) => p.evaluate((s) => window.__musica(s), src);
|
||||
const nero = (on, s = 0.7) => p.evaluate(([on, s]) => { const e = document.getElementById("tr-nero"); e.style.transition = `opacity ${s}s`; e.style.opacity = on ? 1 : 0; }, [on, s]);
|
||||
const scritta = (testo, basso = false) => p.evaluate(([t, b]) => {
|
||||
const e = document.getElementById("tr-scritta");
|
||||
if (t) { e.textContent = t; e.classList.toggle("basso", b); }
|
||||
e.style.opacity = t ? 1 : 0;
|
||||
}, [testo, basso]);
|
||||
const vai = (scene, at, flags = {}) => p.evaluate(async ([scene, at, flags]) => {
|
||||
const { settle } = await import('/tests/helpers.mjs');
|
||||
game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null;
|
||||
Object.assign(game.state.flags, flags);
|
||||
game.go(scene, at); settle(game);
|
||||
}, [scene, at, flags]);
|
||||
const cammina = (a) => p.evaluate((a) => game.walkTo(a), a);
|
||||
|
||||
// 1. Il nero e il dubbio.
|
||||
await musica("assets/audio/sera.mp3");
|
||||
await d.cue("dubbio");
|
||||
await sleep(500);
|
||||
await scritta("Ci sono cose\nche non tornano…");
|
||||
await sleep(2400);
|
||||
await scritta(null);
|
||||
await sleep(600);
|
||||
await scritta("…forse…");
|
||||
await sleep(1500);
|
||||
await scritta(null);
|
||||
await sleep(400);
|
||||
|
||||
// 2. La piazza di giorno: Andrea va al manifesto, che si apre ingrandito.
|
||||
await d.cue("manifesto");
|
||||
await musica("assets/audio/paese.mp3");
|
||||
await vai("piazza", [380, 320]);
|
||||
await nero(false);
|
||||
await sleep(700);
|
||||
await p.evaluate(() => game.act("look", "manifesto_ingorgo"));
|
||||
await p.waitForFunction(() => game.closeup?.id === "manifesto_ingorgo" && game.closeup.t > 0.5, null, { timeout: 15000 });
|
||||
await sleep(2000);
|
||||
// Il tocco sul manifesto: la battuta sul manifesto si salta, resta il desiderio, a voce.
|
||||
await p.evaluate(() => game.closeupTap());
|
||||
for (let k = 0; k < 200 && !(await p.evaluate(() => game.speech?.text.startsWith("Quanto darei"))); k++) {
|
||||
await p.evaluate(() => { if (game.speech) game.skip(); });
|
||||
await sleep(40);
|
||||
}
|
||||
|
||||
// 3. L'incantesimo: nel lampo parte la house della piazza.
|
||||
await d.cue("incanto");
|
||||
await p.waitForFunction(() => game.closeup?.id === "ingorgo", null, { timeout: 20000 });
|
||||
await p.waitForFunction(() => game.closeup?.swapped, null, { timeout: 10000 });
|
||||
await musica(H);
|
||||
// 4. Il giro della festa: Andrea si accorge della festa camminando fra la gente (la seconda battuta si salta), poi una
|
||||
// console per posto, a stacchi veloci.
|
||||
await p.waitForFunction(() => !game.closeup && game.speech?.text.toLowerCase().includes("ingorgo"), null, { timeout: 10000 });
|
||||
await d.cue("festa");
|
||||
await p.evaluate(() => { game.queue.length = 0; game.walkTo([200, 300]); });
|
||||
await p.waitForFunction(() => !game.speech?.text.toLowerCase().includes("ingorgo"), null, { timeout: 20000 });
|
||||
await p.evaluate(() => { game.queue.length = 0; game.speech = null; });
|
||||
await sleep(300);
|
||||
for (const [scene, da, a, brano] of [["via_antica_posta", [330, 345], [300, 290], H], ["piazzetta_comune", [260, 350], [300, 300], L],
|
||||
["antica_posta_alta", [240, 350], [380, 290], U], ["piazzetta", [330, 352], [420, 290], T]]) {
|
||||
await nero(true, 0.2); await sleep(220);
|
||||
await musica(brano);
|
||||
await vai(scene, da);
|
||||
await nero(false, 0.2);
|
||||
await cammina(a);
|
||||
await sleep(3200);
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
// 5. Il titolo sopra la piazza della festa.
|
||||
await d.cue("titolo");
|
||||
await nero(true, 0.9); await sleep(1000);
|
||||
await p.evaluate(() => { game.closeup = null; document.getElementById("title").hidden = false; });
|
||||
await nero(false, 1.2);
|
||||
await sleep(1200);
|
||||
await scritta("l'Ingorgo Sonoro", true);
|
||||
await sleep(3000);
|
||||
await scritta(null);
|
||||
await nero(true, 1.4);
|
||||
await sleep(1600);
|
||||
|
||||
const audio = await p.evaluate(() => window.__audio);
|
||||
const out = new URL("./out/", import.meta.url);
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(new URL("ingorgo-sonoro-audio.json", out), JSON.stringify({ eventi: audio }, null, 2));
|
||||
}
|
||||
@@ -12,7 +12,7 @@ try {
|
||||
page.goto = url => orig(url.replace('http://127.0.0.1:8000/', `http://127.0.0.1:${server.port}/`));
|
||||
const mod = await import(process.env.COPIONE ?? './trailer.mjs');
|
||||
const t0 = Date.now(); let n = 0;
|
||||
const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : 3000); } };
|
||||
const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : Number(process.env.SCATTO ?? 3000)); } };
|
||||
await mod.setup(d);
|
||||
await mod.run(d);
|
||||
console.log('fine', ((Date.now() - t0) / 1000).toFixed(1));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa tre
|
||||
// minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche secondo:
|
||||
// bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana.
|
||||
// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa
|
||||
// quattro minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche
|
||||
// secondo: bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana, Condor, campana, pesca, basket, pokerino.
|
||||
// Rifatto il 30 ottobre (Andrea): niente che dica che il Regolo è in Fortezza. La notte prima del Palio è la piazza
|
||||
// con le finestre accese, e nel finale ci sono solo due occhi gialli nel buio, senza muro e senza fessura.
|
||||
// Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py <cartella> --trailer .
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
@@ -18,8 +20,13 @@ export const cues = [
|
||||
c("carte", "La briscola all'oratorio"),
|
||||
c("macchinine", "Le macchinine col gesso"),
|
||||
c("cerbottana", "La cerbottana"),
|
||||
c("condor", "La Condor col Taglia"),
|
||||
c("campana", "La campana"),
|
||||
c("pesca", "La pesca al lago"),
|
||||
c("basket", "Fai due tiri"),
|
||||
c("poker", "Il pokerino del Barrettino"),
|
||||
c("amici", "…e di vecchie conoscenze"),
|
||||
c("buio", "la fessura"),
|
||||
c("buio", "due occhi gialli"),
|
||||
c("titolo", "La Leggenda del Regolo"),
|
||||
];
|
||||
|
||||
@@ -52,9 +59,19 @@ export async function setup(d) {
|
||||
font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0;
|
||||
transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; }
|
||||
#tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; }
|
||||
#scene.notte { filter: brightness(.5) saturate(.85) hue-rotate(-8deg); }
|
||||
/* Il titolo sopra il panorama: l'illustrazione del gioco ha il Regolo fra le mura della Fortezza. */
|
||||
#title::before { background-image: url(assets/scenes/panorama-intro.png) !important; }
|
||||
#title::after { background: linear-gradient(rgba(20, 12, 30, .55), rgba(20, 12, 30, .05) 45%, rgba(20, 12, 30, .7)),
|
||||
url(assets/scenes/panorama-intro.png) center / contain no-repeat !important; }
|
||||
#tr-occhi { position: fixed; left: 50%; top: 44%; z-index: 51; display: flex; gap: 46px; transform: translateX(-50%);
|
||||
opacity: 0; transition: opacity 1.4s; pointer-events: none; }
|
||||
#tr-occhi i { width: 22px; height: 22px; background: #ffe14a; box-shadow: 0 0 18px 6px rgba(255, 210, 40, .55); }
|
||||
#tr-occhi.chiusi i { height: 3px; margin-top: 10px; }
|
||||
` });
|
||||
await p.evaluate(() => {
|
||||
for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
|
||||
for (const id of ["tr-nero", "tr-scritta", "tr-occhi"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
|
||||
document.getElementById("tr-occhi").innerHTML = "<i></i><i></i>";
|
||||
});
|
||||
// Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio.
|
||||
await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000));
|
||||
@@ -122,16 +139,16 @@ export async function run(d) {
|
||||
await fuoco(vai("via_cafaggio"));
|
||||
await sleep(3200);
|
||||
|
||||
// 2. La Fortezza al tramonto, le impronte.
|
||||
// 2. La piazza di sera, con le finestre accese (niente Fortezza e niente impronte: si capirebbe dov'è il Regolo).
|
||||
await d.cue("notte");
|
||||
await fuoco(vai("fortezza", null, { indizio_regolo: true }));
|
||||
await fuoco(`async () => { await (${vai("piazza", null, { casetta_aperta: true })})(); document.getElementById("scene").classList.add("notte"); }`);
|
||||
await scritta("La notte prima del Palio…", true);
|
||||
await sleep(3000);
|
||||
await scritta(null);
|
||||
|
||||
// 3. La scuola: il palio non c'è più.
|
||||
await d.cue("sparito");
|
||||
await fuoco(vai("scuola", [300, 330], { indizio_regolo: false }));
|
||||
await fuoco(`async () => { await (${vai("scuola", [300, 330], { casetta_aperta: false })})(); document.getElementById("scene").classList.remove("notte"); }`);
|
||||
await scritta("…il palio è sparito.", true);
|
||||
await sleep(3200);
|
||||
await scritta(null);
|
||||
@@ -145,7 +162,7 @@ export async function run(d) {
|
||||
["orti_zio_mario", [400, 352], [330, 260]], ["stazione", [320, 350], [450, 290]], ["cardetole", [300, 350], [470, 300]], ["terrazza", [300, 330], [420, 320]]]) {
|
||||
await fuoco(vai(scene, da), true);
|
||||
await p.evaluate((a) => game.walkTo(a), a);
|
||||
await sleep(3300);
|
||||
await sleep(2800);
|
||||
}
|
||||
|
||||
// 5. Le sfide, giocate davvero per qualche secondo: ogni gioco col suo nome in basso e la sua musica.
|
||||
@@ -155,7 +172,8 @@ export async function run(d) {
|
||||
await scritta("Un paese pieno di sfide");
|
||||
await sleep(2000);
|
||||
await scritta(null);
|
||||
// Apre il primo piano, poi ogni 150 ms chiama `mossa` (il giocatore che gioca da solo) finché passano i secondi.
|
||||
// Apre il primo piano, poi a ogni fotogramma chiama `mossa` (il giocatore che gioca da solo) finché passano i
|
||||
// secondi. Dentro `mossa`, `pronto(ms)` dice se dall'ultima mossa è passato abbastanza: così non gioca a raffica.
|
||||
const gioca = async (cue, nome, brano, closeup, level, modulo, prima, mossa, secondi) => {
|
||||
await d.cue(cue);
|
||||
await musica(brano);
|
||||
@@ -165,27 +183,51 @@ export async function run(d) {
|
||||
game.run({ closeup: '${closeup}', level: '${level}' }); settle(game);
|
||||
window.__m = await import('${modulo}');
|
||||
const M = window.__m, c = game.closeup; ${prima}
|
||||
const mossa = new Function('M', 'c', 'pronto', ${JSON.stringify(mossa)});
|
||||
let ultima = performance.now();
|
||||
const pronto = (ms) => performance.now() - ultima > ms && (ultima = performance.now(), true);
|
||||
const giro = () => { if (game.closeup !== c) return; mossa(M, c, pronto); requestAnimationFrame(giro); };
|
||||
giro();
|
||||
}`);
|
||||
await scritta(nome, true);
|
||||
const fine = Date.now() + secondi * 1000;
|
||||
while (Date.now() < fine) {
|
||||
await p.evaluate(`(() => { const M = window.__m, c = game.closeup; if (!c) return; ${mossa} })()`);
|
||||
if (Date.now() > fine - (secondi - 3) * 1000) await scritta(null);
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(3000);
|
||||
await scritta(null);
|
||||
await sleep((secondi - 3) * 1000);
|
||||
};
|
||||
await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "",
|
||||
"if (M.canThrow(c)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 14);
|
||||
"if (M.canThrow(c) && pronto(500)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 11);
|
||||
await gioca("biliardo", "Il biliardo del Circolo", "assets/audio/biliardo.mp3", "biliardo", "seria", "/src/engine/biliardo.js", "",
|
||||
"if (c.step === 'aim' && c.current === 'player' && !c.holding) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 14);
|
||||
"if (c.step === 'aim' && c.current === 'player' && !c.holding && pronto(400)) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 11);
|
||||
await gioca("gara", "Il GP di Fortezza", "assets/audio/gara.mp3", "gara", "bona", "/src/engine/gara.js",
|
||||
"while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 16);
|
||||
"while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 13);
|
||||
await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "",
|
||||
"if (M.canPlay(c)) M.play(c, 0);", 12);
|
||||
"if (M.canPlay(c) && pronto(700)) M.play(c, 0);", 10);
|
||||
await gioca("macchinine", "Le macchinine col gesso", "assets/audio/macchinine.mp3", "micro", "bona", "/src/engine/micro.js", "M.pick(c, 0);",
|
||||
"if (M.canThrow(c)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 12);
|
||||
"if (M.canThrow(c) && pronto(400)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 10);
|
||||
await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "",
|
||||
"if (M.canThrow(c)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 10);
|
||||
"if (M.canThrow(c) && pronto(400)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 9);
|
||||
// La Condor: le lattine sulla panchina, le bottiglie, il palloncino; il pallino cala, si mira un po' sopra.
|
||||
await gioca("condor", "La Condor col Taglia", "assets/audio/gara.mp3", "condor", "bona", "/src/engine/condor.js", "",
|
||||
"if (!M.canThrow(c) || c.holding || !pronto(800)) return; const su = x => M.DROP * M.far(x) ** 2; const k = c.cans.find(k => !k.down), b = c.bottles.find(b => !b.down);" +
|
||||
" if (k) c.aim = { x: k.x, y: M.BENCH - M.CAN.h / 2 - su(k.x) }; else if (b) c.aim = { x: b.x, y: M.BAR - M.BOTTLE.h / 2 - su(b.x) };" +
|
||||
" else if (!c.balloon.popped) { const p = M.balloon(c); c.aim = { x: p.x, y: p.y - su(p.x) }; } else c.aim = { x: M.CARTONE.x, y: M.CARTONE.y - su(M.CARTONE.x) }; M.shoot(c);", 10);
|
||||
// La campana: il sassolino nella casella giusta, poi i salti quando la lancetta passa nel verde; poi tocca al Taglia.
|
||||
await gioca("campana", "La campana", "assets/audio/macchinine.mp3", "campana", "bona", "/src/engine/campana.js", "",
|
||||
"if (c.step === 'aim' && c.current === 'player' && pronto(700)) { const p = M.center(c.target.player); c.skill.scatter = 0; c.aim = { x: p.x, y: p.y }; c.power = Math.hypot(p.x - M.FROM.x, p.y - M.FROM.y) / M.REACH; M.shoot(c); }" +
|
||||
" else if (c.step === 'hop' && (!c.hop || c.hop.t >= M.HOP) && c.meter?.t > 0 && Math.abs(M.needle(c)) < 0.08) M.press(c, 1);", 15);
|
||||
// La pesca: il galleggiante abbocca presto (nel trailer non si aspetta), si ferra e si recupera senza strappare.
|
||||
await gioca("pesca", "La pesca al lago", "assets/audio/paese.mp3", "pesca", "bona", "/src/engine/pesca.js", "",
|
||||
"const f = c.float; if (c.step === 'aim' && pronto(800)) { c.power = 0.45; M.shoot(c); }" +
|
||||
" else if (c.step === 'wait' && f) { if (!f.svelto && f.biteAt) { const prima = Math.max(0, f.biteAt - f.t - 1.6); f.biteAt -= prima; f.nibbles.forEach(n => n.at -= prima); f.svelto = true; }" +
|
||||
" if (M.biting(c)) { M.strike(c); c.reeling = c.step === 'fight'; } } else if (c.step === 'fight') c.reeling = c.tension < 0.8;", 15);
|
||||
// Fai due tiri: per ogni postazione si cerca prima, su una copia, un tiro che entra.
|
||||
await gioca("basket", "Fai due tiri", "assets/audio/gara.mp3", "basket", "bona", "/src/engine/basket.js", "",
|
||||
"if (!M.canThrow(c) || c.holding || !pronto(600)) return; let best = { angle: 55, power: 0.7 };" +
|
||||
" cerca: for (let angle = 46; angle <= 66; angle += 4) for (let power = 0.3; power <= 1; power += 0.02) { const p = structuredClone(c); M.setAngle(p, angle); M.setPower(p, power); M.shoot(p);" +
|
||||
" for (let i = 0; i < 2000 && p.step === 'flight'; i++) M.update(p, 1 / 120); if (p.ball.scored) { best = { angle, power }; break cerca; } }" +
|
||||
" M.setAngle(c, best.angle); M.setPower(c, best.power); M.shoot(c);", 11);
|
||||
await gioca("poker", "Il pokerino del Barrettino", "assets/audio/carte.mp3", "poker", "seria", "/src/engine/poker.js", "",
|
||||
"if (M.canDraw(c) && pronto(900)) M.choose(c, 'cambia'); else if (M.myTurn(c) && pronto(900)) M.choose(c, 'vedo'); else if (c.step === 'show' && pronto(2500)) M.advance(c);", 11);
|
||||
await musica("assets/audio/paese.mp3");
|
||||
|
||||
// 6. Le vecchie conoscenze, con le loro battute.
|
||||
@@ -207,40 +249,29 @@ export async function run(d) {
|
||||
await finitoDiDire();
|
||||
await sleep(400);
|
||||
|
||||
// 7. Il buio: la sera, la torcia nella fessura, gli occhi gialli.
|
||||
// 7. Il buio: solo due occhi gialli che si aprono, guardano e sbattono (Andrea, 30 ottobre: niente Fortezza, niente
|
||||
// fessura e niente palio, sennò si capisce dov'è il Regolo).
|
||||
await d.cue("buio");
|
||||
// Sotto il buio una musica bassa di suspense (Andrea: «un po' di musichetta nel finale»).
|
||||
await musica("assets/audio/sera.mp3");
|
||||
await fuoco(vai("fortezza", [462, 310], { indizio_regolo: true, casetta_aperta: true }));
|
||||
await sleep(900);
|
||||
await p.evaluate(() => { game.state.inventory.push("torcia"); game.act("use", "fessura", "torcia"); });
|
||||
// «Accendo la torcia e la punto dentro…» si salta: il primo piano si apre subito.
|
||||
// Il palio non si vede (Andrea: si capirebbe che è in Fortezza): la luce parte da destra, nel buio, e i primi due
|
||||
// ritrovamenti si danno per fatti; si cercano solo gli occhi.
|
||||
for (let k = 0; k < 60 && await p.evaluate(() => game.closeup?.id !== "fessura"); k++) { await p.evaluate(() => game.skip()); await sleep(50); }
|
||||
await p.evaluate(() => { const c = game.closeup; c.found = 2; c.beam = [600, 300]; });
|
||||
await sleep(1600);
|
||||
const targets = await p.evaluate(() => game.story.closeups.fessura.targets.map((t) => t.at));
|
||||
for (let i = 2; i < targets.length; i++) {
|
||||
if (i === 2) await musica("assets/audio/cartello.mp3");
|
||||
// La luce va piano verso il punto, come una mano che cerca: prima in basso, poi su verso gli occhi.
|
||||
const from = await p.evaluate(() => game.closeup.beam);
|
||||
for (let k = 1; k <= 30; k++) {
|
||||
const x = from[0] + (targets[i][0] - from[0]) * k / 30 + Math.sin(k / 4) * 12, y = from[1] + (targets[i][1] - from[1]) * k / 30;
|
||||
await p.evaluate(([x, y]) => game.closeupAim([x, y]), [x, y]);
|
||||
await sleep(50);
|
||||
}
|
||||
// Le battute dei primi ritrovamenti si saltano (partono prima che il ritrovamento conti, e lo fermano); resta solo
|
||||
// l'ultima: «…sono due occhi gialli!».
|
||||
for (let k = 0; k < 300 && await p.evaluate((i) => !(game.closeup?.found > i), i); k++) {
|
||||
if (i < 2) await p.evaluate(() => game.skip());
|
||||
await sleep(100);
|
||||
}
|
||||
await p.waitForFunction(() => !game.speech && !game.closeup?.lines?.length, null, { timeout: 30000 });
|
||||
await sleep(i === 2 ? 1400 : 100);
|
||||
}
|
||||
await p.evaluate(() => { document.getElementById("tr-nero").style.transition = "opacity .9s"; });
|
||||
await nero(true);
|
||||
await sleep(1400);
|
||||
await prepara(`async () => { game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.state.x = -400; }`);
|
||||
await sleep(1800);
|
||||
await musica("assets/audio/cartello.mp3");
|
||||
const occhi = (fn) => p.evaluate(fn);
|
||||
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 1; });
|
||||
await sleep(2200);
|
||||
for (const pausa of [140, 2200, 110, 140, 110]) {
|
||||
await occhi(() => document.getElementById("tr-occhi").classList.toggle("chiusi"));
|
||||
await sleep(pausa);
|
||||
}
|
||||
await dice(null, "E quelli... sono due occhi gialli!");
|
||||
await finitoDiDire();
|
||||
await sleep(900);
|
||||
await occhi(() => { document.getElementById("tr-occhi").classList.add("chiusi"); });
|
||||
await sleep(150);
|
||||
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 0; });
|
||||
await sleep(1500);
|
||||
|
||||
// 8. Il titolo.
|
||||
await d.cue("titolo");
|
||||
|
||||