Compare commits

...
17 Commits
Author SHA1 Message Date
manzolo 797eb80980 Versione 0.1.67 2026-11-01 00:31:17 +01:00
manzoloandClaude Fable 5.1 00e9f7c4d7 Il flipper: chiuso il canale dietro la Pieve fra la parete e il dorso dei bersagli, dove la pallina scendeva dritta nell'uscita sinistra (Andrea: «a sinistra esce troppo facile»); due raccordi inclinati, disegnati come le altre sponde; test (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 00:21:49 +01:00
manzoloandClaude Fable 5.1 77a8777dcd Il flipper alleggerito senza cambiarne l'aspetto: il mobile statico sullo sfondo CSS della tela invece di una copia a tutto schermo per fotogramma, estremità e giunti arrotondati solo nei livelli in cache; arretrato grafico da 4,2 s a 0,03 s per 150 fotogrammi; la misura dell'arretrato nella prova nel browser al posto dell'intervallo fra frame, che non misurava nulla (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-11-01 00:14:31 +01:00
manzoloandClaude Fable 5.1 b660bbeb96 Il flipper rifatto da Codex: tavola ad alta risoluzione con lacca, cromature e luci animate; due LOCK (tre passaggi sulla stessa rampa in venti secondi, Fortezza e Trebbio), la SUPER SPECIAL al centro che si apre con entrambi i lock e dà pallina premio e multiball a tre; jackpot fino a x3 coi bersagli, nuove fanfare; la valvola contro il rientro nella molla; display del punteggio e pannello della caccia al jackpot ai lati; test unitari e nel browser (lock, super, multiball) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:47:51 +01:00
manzoloandClaude Fable 5.1 bbce40b014 Test del tabellone: quanto manca al traguardo sta nella riga in basso, non più nel pannello
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:18:09 +01:00
manzoloandClaude Fable 5.1 0ec45c8745 Il flipper col LOCK (idea di Andrea, fatto da Codex e completato): lo stesso ponte tre volte trattiene la pallina e ne dà una nuova, l'altro ponte SPECIAL vale il JACKPOT e il MULTIBALL a due palline; serratura, seconde palline e SPECIAL nel disegno, suoni, aiuto, test; imbocchi delle rampe al centro e uscite laterali più strette (Codex); pallina premio al traguardo; la barra del traguardo sotto la finestra invece del pannello sul Trebbio; il Boccino prudente in curva
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 23:17:36 +01:00
manzoloandClaude Fable 5.1 dfa70a9620 Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) corre il GP di Fortezza col suo NRG bianco e rosso numero 1 (motorino e ritratto di Codex dalle sue foto) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:52:53 +01:00
manzoloandClaude Fable 5.1 3079c6ea8a Il tabellone del flipper è un vero backglass: il fondale di Codex col Castello del Trebbio, la Fortezza, il Regolo e i fuochi dei rioni; le cifre nella finestra nera, le lampadine sui bordi, i dischi delle palline e dei rioni, i ponti ai lati, il fondale che lampeggia ai colpi grossi (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:10:55 +01:00
manzoloandClaude Fable 5.1 4d4aa8aeea Il flipper più spettacolare: anelli e scintille, coriandoli dei rioni, luci che corrono sui ponti, punti che volano, lampo e scossa, scia della pallina, regia e fanfare sul tabellone; ponti, sponde e griglia dei punti in cache (niente arretrato grafico) (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 22:06:26 +01:00
manzoloandClaude Fable 5.1 721b6de930 Test della combo: la pallina persa è in gioco, non sulla molla
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:47:50 +01:00
manzoloandClaude Fable 5.1 7d419be27a Test dei ponti: la pallina persa parte da sotto i flipper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:46:58 +01:00
manzoloandClaude Fable 5.1 07b6a381c1 Il flipper dopo la seconda revisione di Codex: flipper vivi anche sul ponte, imbocco solo in salita, ponti con la stessa energia su e giù e uscita nelle corsie di ritorno, combo che scade, scritte in coda col traguardo davanti, tabellone con l'esito giusto e le cifre che corrono in tempo fisso, tasti del mouse dalla maschera, glifi rifatti
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:43:57 +01:00
manzoloandClaude Fable 5.1 45b8c3d98e STATO.md: i ponti, i punti ×10, il tabellone luminoso e l'ingresso al flipper con un clic
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:21:31 +01:00
manzoloandClaude Fable 5.1 0a8b0310d3 Il flipper coi due ponti (le rampe che si incrociano sopra la tavola, i PONTI se fatti di fila), i punti ×10 come sui flipper degli anni 2000, il tabellone a punti luminosi con le cifre che corrono e le scritte che lampeggiano, e un clic sul River che entra e va al flipper (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:20:53 +01:00
manzolo a50847da2b Versione 0.1.66 2026-10-31 20:32:52 +01:00
manzoloandClaude Fable 5.1 1fc446d02a Il flipper rivisto con la revisione di Codex: punti solo a un urto nuovo, sottopassi contro gli attraversamenti, molla che arriva sempre in campo (senza cancelletto), salvataggio col tempo della simulazione e in vista, corsie che contano attraversando la riga, traguardo acquisito appena raggiunto, annullare non lancia, due dita e tasto destro, pulsanti dei flipper sempre attivi
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 20:24:01 +01:00
manzoloandClaude Fable 5.1 7076123fcf Il flipper del River: la tavola di Codex con San Piero dipinto (Fortezza, Pieve, torre, la Sieve fra i flipper, il Regolo con la corona, le bandiere dei rioni) e sopra le parti meccaniche del gioco: molla, arco, corsie dei rioni (tutte e quattro: PALIO), funghi, bersagli a caduta, Regolo, sponde elastiche, due flipper; fisica a passo fisso, tre palline, tre livelli (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 18:53:04 +01:00
33 changed files with 1805 additions and 21 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -3
View File
@@ -691,9 +691,13 @@
"file": "andrea-b830f3f8ca.mp3", "file": "andrea-b830f3f8ca.mp3",
"sec": 6.87 "sec": 6.87
}, },
"andrea|Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.": { "andrea|Un ghiacciolo ci starebbe. Ma prima... il flipper.": {
"file": "andrea-2669d0fea1.mp3", "file": "andrea-737d8c7f64.mp3",
"sec": 5.23 "sec": 4.56
},
"andrea|Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.": {
"file": "andrea-61a0d27bbb.mp3",
"sec": 6.67
}, },
"andrea|La farmacia, con la croce verde. Mia nonna ci veniva per le pasticche e per le chiacchiere. Più per le chiacchiere.": { "andrea|La farmacia, con la croce verde. Mia nonna ci veniva per le pasticche e per le chiacchiere. Più per le chiacchiere.": {
"file": "andrea-1447e3515d.mp3", "file": "andrea-1447e3515d.mp3",
@@ -2967,6 +2971,30 @@
"file": "arcu-26a5fb0d0c.mp3", "file": "arcu-26a5fb0d0c.mp3",
"sec": 3.17 "sec": 3.17
}, },
"andrea|Una partita alla bona, tanto per sentire la molla.": {
"file": "andrea-ad00ae1145.mp3",
"sec": 3.38
},
"andrea|Una partita seria.": {
"file": "andrea-3479aca550.mp3",
"sec": 1.16
},
"andrea|Un'altra volta.": {
"file": "andrea-1b517ab1ef.mp3",
"sec": 0.9
},
"andrea|Sessantamila punti e sono contento.": {
"file": "andrea-74e53fb53a.mp3",
"sec": 2.65
},
"andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
"file": "andrea-82ab821c37.mp3",
"sec": 4.43
},
"andrea|Duecentomila. Il record del River, o quasi.": {
"file": "andrea-00b37d42f9.mp3",
"sec": 4.32
},
"andrea|Due tiri alla bona, tanto per scaldarmi.": { "andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3", "file": "andrea-d564365fc2.mp3",
"sec": 2.53 "sec": 2.53
@@ -3683,6 +3711,18 @@
"file": "andrea-356a1afe8d.mp3", "file": "andrea-356a1afe8d.mp3",
"sec": 5.91 "sec": 5.91
}, },
"andrea|Traguardo! Se c'era la classifica, oggi ci mettevo le mie iniziali.": {
"file": "andrea-6fd4fcd345.mp3",
"sec": 4.64
},
"andrea|Tre palline, e tutte nella Sieve. Il flipper del River non perdona.": {
"file": "andrea-5c306f1589.mp3",
"sec": 5.57
},
"andrea|Lascio la pallina lì. Tanto il River non chiude.": {
"file": "andrea-dcbb849b12.mp3",
"sec": 4.02
},
"andrea|Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.": { "andrea|Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.": {
"file": "andrea-145f20bff9.mp3", "file": "andrea-145f20bff9.mp3",
"sec": 5.11 "sec": 5.11
+1 -1
View File
@@ -151,7 +151,7 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
Al bar e al circolo: Al bar e al circolo:
- *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già - *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già
la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già. la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già.
- *Flipper del River*: più lavoro, ma la fisica delle biglie c'è già. - *Flipper del River*: fatto il 31 ottobre (vedi STATO.md).
- *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice. - *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice.
- *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS. - *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS.
Sport: Sport:
+33 -1
View File
@@ -50,6 +50,38 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16); fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16);
vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`. vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`.
[DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?). [DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?).
- **Il flipper del River / Il Regolo (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, livello e
partita. Motore `src/engine/flipper.js`, disegno `flipper-render.js`. Tavolo rifatto in lacca scura, metallo,
rampe con ombre e luci ciano/ambra/viola, riflessi sulle palline e plastiche in rilievo; scena a 1280×720 con
coordinate logiche 640×360, gli altri ambienti restano in pixel art. Fondale di Codex conservato nel riquadro
illustrato del backglass; niente pannelli sopra il Trebbio. Superfici e bagliori in cache, senza disegnare la
strada nascosta sotto il flipper. Pannello a destra con progressione, timer, moltiplicatore e prossimo obiettivo.
Alleggerito (1 novembre): il mobile statico sta sullo sfondo CSS della tela (`cabinetBehind`), la tela si pulisce
e basta; estremità e giunti arrotondati solo nei livelli in cache. Arretrato grafico da 4,2 s a 0,03 s per 150
fotogrammi (regole e misure nella memoria «effetti leggeri»).
- Rampe FORTEZZA e TREBBIO: imbocchi centrali da 36 pixel, 27 utili alla pallina, salite accessibili con colpi
medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis.
Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio
nell'altra rampa SPECIAL: jackpot da 100.000; tre passaggi ravvicinati fanno il secondo LOCK.
- Due LOCK alzano la rampa centrale SUPER SPECIAL: 250.000 punti, una pallina extra in riserva e multiball a
tre. Uscita SUPER raccordata alla corsia sinistra sopra la paletta, a velocità ridotta; i due lock liberano
le palline dai lati, sfalsati di 0,45 secondi. In multiball le rampe pagano jackpot, e una vita si perde solo
all'ultima pallina. Prima della SUPER, perdere la sostitutiva recupera l'ultimo lock senza scalare vite.
- Completare una serie di bersagli alza il moltiplicatore jackpot fino a ×3, azzerato alla perdita di una vita.
Restano PONTI (+50.000 alternando le rampe entro 12 secondi), PALIO (+30.000), respingenti, bersagli e Regolo.
Traguardi 60.000/120.000/200.000: una pallina premio una sola volta, vittoria acquisita anche uscendo prima.
- Uscite laterali da 14/12 pixel, corsie di ritorno alle palette più ampie. Il canale di 10 pixel dietro la
Pieve, che portava la pallina dritta nell'uscita sinistra, è chiuso da due raccordi inclinati (1 novembre). Valvola inclinata alla corsia di
lancio: il lancio passa, i ritorni dal campo vengono deviati a sinistra. Ogni pallina ha contatti, scia e
anti-incastro propri; urti fra palline, palette mosse una sola volta per passo. Pausa ferma anche i timer.
- Luci che scorrono sulle rampe attive, frecce e spie 1/3–2/3–LOCK, rampa SUPER che si alza, fasci luminosi,
scintille, particelle e fanfare distinte per jackpot, apertura SUPER e multiball; la pallina resta leggibile.
Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca».
- Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni,
recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch,
screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, e la misura dell'arretrato
grafico dopo 150 fotogrammi in multiball (deve restare sotto 1,5 s).
[DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello.
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere). - **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
@@ -434,7 +466,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo NRG bianco e rosso numero 1 e il ritratto di Codex. Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9). quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita - Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
+22
View File
@@ -303,6 +303,28 @@
quelle di sopra restano crude; troppo forte volano fuori, nel fuoco. COTTURA: quando è nel verde, Rovescia nel quelle di sopra restano crude; troppo forte volano fuori, nel fuoco. COTTURA: quando è nel verde, Rovescia nel
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p> sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
</details> </details>
<details id="flipper-help" hidden><summary>Come si gioca</summary>
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa
Spazio o il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
Le frecce sinistra e destra, i pulsanti Flipper o le due metà della scena comandano le palette;
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.</p>
<p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti,
una pallina extra in riserva e MULTIBALL a tre palline! La SUPER restituisce la pallina nella corsia
laterale verso la paletta sinistra; quelle trattenute vengono liberate dai due lati, sfalsate.
In multiball le rampe pagano altri jackpot e perdi una vita solo quando perdi l'ultima pallina in gioco.
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
<p>Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla
perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le
quattro corsie dei rioni per il PALIO da 30.000. I respingenti valgono 1.000, i bersagli laterali 5.000
l'uno e 25.000 la serie, il Regolo coronato 10.000.</p>
<p>Parti con tre palline. Appena lanciata, la pallina è protetta una volta: 8 secondi alla bona, 4 seria,
1 a fassi male. Il traguardo è 60.000, 120.000 o 200.000 punti: raggiungerlo assegna una pallina premio
una sola volta per partita. Poi puoi continuare per il record, i LOCK e la SUPER SPECIAL.</p>
</details>
<details id="cerbottana-help" hidden><summary>Come si gioca</summary> <details id="cerbottana-help" hidden><summary>Come si gioca</summary>
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio <p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.65", "version": "0.1.67",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,6 +13,7 @@
"browser:condor": "node tests/condor.browser.mjs", "browser:condor": "node tests/condor.browser.mjs",
"browser:campana": "node tests/campana.browser.mjs", "browser:campana": "node tests/campana.browser.mjs",
"browser:bruciate": "node tests/bruciate.browser.mjs", "browser:bruciate": "node tests/bruciate.browser.mjs",
"browser:flipper": "node tests/flipper.browser.mjs",
"browser:ingorgo": "node tests/ingorgo.browser.mjs", "browser:ingorgo": "node tests/ingorgo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs",
+418
View File
@@ -0,0 +1,418 @@
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
import { R, SCORE, RAMP, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff' };
const RIONI = ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'];
const TAU = Math.PI * 2, clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const number = n => Math.round(n).toLocaleString('it-IT');
function text(g, value, x, y, size = 7, color = WHITE, align = 'left', font = 'sans-serif', weight = 600) {
g.font = `${weight} ${size}px ${font}`; g.textAlign = align; g.textBaseline = 'middle'; g.fillStyle = color; g.fillText(value, x, y);
}
function path(g, points, close = false) {
g.beginPath(); points.forEach((p, i) => i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])); if (close) g.closePath();
}
function line(g, points, color, width = 1) { path(g, points); g.strokeStyle = color; g.lineWidth = width; g.stroke(); }
function poly(g, points, color, edge) { path(g, points, true); g.fillStyle = color; g.fill(); if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); } }
function disc(g, x, y, r, color, edge) {
g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill();
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
}
function box(g, x, y, w, h, color, edge, radius = 5) {
g.beginPath(); g.roundRect(x, y, w, h, radius); g.fillStyle = color; g.fill();
if (edge) { g.strokeStyle = edge; g.lineWidth = 0.7; g.stroke(); }
}
function gradient(g, x, y, w, h, stops) {
const v = g.createLinearGradient(x, y, x + w, y + h); stops.forEach(([at, color]) => v.addColorStop(at, color)); return v;
}
function metal(g, x, y, w = 20, h = 20) {
return gradient(g, x, y, w, h, [[0, '#f1f8f6'], [0.2, '#8b9da6'], [0.46, '#263641'], [0.62, '#c4d5dc'], [1, '#354a58']]);
}
function screw(g, x, y) { disc(g, x, y, 1.4, '#9caeb3', '#17262f'); line(g, [[x - 0.6, y + 0.6], [x + 0.6, y - 0.6]], '#273b47', 0.7); }
const glowCache = new Map();
function glow(g, x, y, radius, color, alpha = 1) {
if (typeof document === 'undefined') return;
let sprite = glowCache.get(color);
if (!sprite) {
sprite = Object.assign(document.createElement('canvas'), { width: 96, height: 96 });
const s = sprite.getContext('2d'), v = s.createRadialGradient(48, 48, 0, 48, 48, 48);
v.addColorStop(0, color + 'b0'); v.addColorStop(0.22, color + '50'); v.addColorStop(0.6, color + '15'); v.addColorStop(1, color + '00');
s.fillStyle = v; s.fillRect(0, 0, 96, 96); glowCache.set(color, sprite);
}
g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore();
}
const layers = new Map();
function layerOf(key, paint, bounds) {
let layer = layers.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 });
const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer);
}
return layer;
}
function cached(g, key, paint, bounds = [0, 0, 640, 360]) {
if (typeof document === 'undefined') { paint(g); return; }
g.drawImage(layerOf(key, paint, bounds), ...bounds);
}
// Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma:
// una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La
// tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta.
const sheets = new Map();
function cabinetBehind(g, key, paint) {
const canvas = g.canvas;
if (typeof document === 'undefined' || !(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return false;
let url = sheets.get(key);
if (!url) { url = `url(${layerOf(key, paint, [0, 0, 640, 360]).toDataURL()})`; sheets.set(key, url); }
if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; }
return true;
}
export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; }
function crown(g, x, y, size, color) {
poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color);
line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1);
}
function outline(g, c) {
path(g, [[200, 336], ...c.walls.slice(0, 12).map(w => w.a), [448, 72], [448, 336]], true);
}
function cabinet(g, c, bg, glass) {
g.fillStyle = gradient(g, 0, 0, 640, 360, [[0, '#0c1924'], [0.5, '#02070d'], [1, '#101a24']]); g.fillRect(0, 0, 640, 360);
// Due colonne da backglass, incassate nel mobile. Il paese resta un'illustrazione nel riquadro di sinistra.
box(g, 12, 14, 174, 332, '#0c1721', '#243746', 9);
box(g, 467, 14, 161, 332, '#0c1721', '#243746', 9);
line(g, [[24, 20], [174, 20]], '#355263', 0.7); line(g, [[479, 20], [616, 20]], '#355263', 0.7);
for (const x of [19, 179, 474, 621]) for (const y of [21, 339]) screw(g, x, y);
if (glass) {
g.save(); g.beginPath(); g.roundRect(24, 237, 150, 88, 4); g.clip();
g.drawImage(glass, 0, 170, 200, 130, 24, 237, 150, 98);
g.fillStyle = 'rgba(6,18,28,0.38)'; g.fillRect(24, 237, 150, 88); g.restore();
} else if (bg) {
g.save(); g.globalAlpha = 0.25; g.drawImage(bg, 200, 30, 230, 280, 24, 237, 150, 88); g.restore();
}
text(g, 'SAN PIERO A SIEVE', 99, 333, 6, MUTED, 'center');
// Parete esterna, fianco e bisello: l'ombra scende sotto il piano, non sopra gli oggetti.
g.save(); g.translate(2, 7); outline(g, c); g.fillStyle = '#010306'; g.fill(); g.lineWidth = 18; g.strokeStyle = '#010306'; g.stroke(); g.restore();
outline(g, c); g.lineWidth = 16; g.strokeStyle = '#182430'; g.stroke();
g.lineWidth = 11; g.strokeStyle = metal(g, 194, 0, 250, 30); g.stroke();
g.lineWidth = 6; g.strokeStyle = '#050c12'; g.stroke();
g.fillStyle = gradient(g, 240, 5, 140, 320, [[0, '#10252d'], [0.5, '#102d31'], [1, '#071720']]); g.fill();
g.save(); outline(g, c); g.clip();
glow(g, 225, 138, 150, TINT.sinistra, 0.17); glow(g, 415, 202, 140, TINT.destra, 0.13);
// Incisione della Sieve nel piano in lacca; pochi segni, con spazio per leggere i tiri.
for (let i = 0; i < 7; i++) {
g.beginPath(); g.moveTo(305 + i * 3, 70); g.bezierCurveTo(230 + i * 3, 160, 414 + i * 2, 198, 302 + i * 4, 328);
g.strokeStyle = i === 3 ? '#2c6570' : '#173e47'; g.lineWidth = i === 3 ? 1.4 : 0.6; g.stroke();
}
for (let i = 0; i < 40; i++) { g.fillStyle = '#28434b'; disc(g, 212 + hash(i * 7) * 211, 20 + hash(i * 13) * 304, 0.35, '#28434b'); }
g.strokeStyle = '#27464d'; g.lineWidth = 0.6;
for (const radius of [34, 39, 71]) { g.beginPath(); g.ellipse(320, 182, radius, radius * 0.7, 0, 0, TAU); g.stroke(); }
crown(g, 320, 182, 12, '#31585b');
// I bastioni stilizzati al fondo del piano.
poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358');
text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center');
// La corsia della molla è una scanalatura separata, con il fondo metallico.
g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264);
line(g, [[440, 86], [440, 306]], '#45606c', 0.5);
g.restore();
// Plastiche sagomate delle sponde, con spessore e gomma viva sul bordo interno.
for (const side of [-1, 1]) {
const x = side < 0 ? 236 : 404, tip = x - side * 26, color = side < 0 ? TINT.sinistra : TINT.destra;
poly(g, [[x, 248], [x, 287], [tip, 294]], '#02070c');
poly(g, [[x, 246], [x, 284], [tip, 290]], gradient(g, x, 246, -side * 25, 40, [[0, '#52717e'], [0.15, '#163140'], [1, '#091723']]), '#7d9ca5');
poly(g, [[x - side * 4, 261], [x - side * 4, 281], [tip + side * 8, 284]], '#173c4b', color);
screw(g, x, 249); screw(g, x, 282); screw(g, tip + side * 4, 287);
}
g.lineCap = 'round'; g.lineJoin = 'round';
for (const w of c.walls) {
if (w.gate) continue;
const pts = [w.a, w.b];
g.save(); g.translate(1, 2); line(g, pts, '#02070c', 3.5); g.restore();
line(g, pts, w.sling ? '#8c453b' : '#526c78', 2.3);
line(g, pts, w.sling ? '#ffab7c' : '#bdd2d5', 0.7);
}
box(g, 198, 336, 252, 17, '#080f16', '#35515c', 2);
text(g, 'R I V E R', 218, 345, 7, '#d6c49d');
text(g, 'LOCK / JACKPOT / MULTIBALL', 431, 345, 5.5, MUTED, 'right');
for (const side of [-1, 1]) for (let i = 0; i < 6; i++) {
const x = side < 0 ? 197 : 451, y = 106 + i * 36, color = side < 0 ? TINT.sinistra : TINT.destra;
glow(g, x, y, 14, color, 0.4); box(g, x - 0.7, y - 5, 1.4, 10, color, null, 0.7);
}
}
function rampPoint(r, s) {
const p = alongRamp(r, s), z = rampHeight(r, s); return { ...p, y: p.y - 1 - 8 * z };
}
function paintTrack(g, r) {
const pts = r.cum.map(s => { const p = rampPoint(r, s); return [p.x, p.y]; }), color = TINT[r.id];
g.lineCap = 'round'; g.lineJoin = 'round';
// Piedini e ombra portata. La quota è la stessa usata dalla pallina.
for (let s = 45; s < r.len; s += 75) {
const p = rampPoint(r, s);
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
screw(g, p.x + 7, p.y + 10);
}
const track = (color, width) => {
g.beginPath(); g.moveTo(...pts[0]);
for (let i = 1; i < pts.length - 1; i++) {
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
const d0 = Math.hypot(x - a[0], y - a[1]), d1 = Math.hypot(b[0] - x, b[1] - y), radius = Math.min(8, d0 / 3, d1 / 3);
g.lineTo(x - (x - a[0]) / d0 * radius, y - (y - a[1]) / d0 * radius);
g.quadraticCurveTo(x, y, x + (b[0] - x) / d1 * radius, y + (b[1] - y) / d1 * radius);
}
g.lineTo(...pts.at(-1)); g.strokeStyle = color; g.lineWidth = width; g.stroke();
};
g.save(); g.translate(3, 6); track('#02080cbb', 18); g.restore();
track(color + '18', 20); track('#02080e', 16); track(color, 14);
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
track(color + '24', 4);
for (let s = 12; s < r.len - 4; s += 22) {
const p = rampPoint(r, s);
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
for (const side of [-1, 1]) disc(g, p.x + p.dy * 6 * side, p.y - p.dx * 6 * side, 0.7, color);
}
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
poly(g, [[cx - 6, y - 30], [cx + 6, y - 30], [x1, y + 2], [x0, y + 2]], '#030910');
poly(g, [[cx - 5, y - 30], [cx + 5, y - 30], [x1 - 1, y], [x0 + 1, y]], gradient(g, 0, y - 30, 0, 30, [[0, '#172b38'], [0.8, '#3c5d6b'], [1, '#a6c4c8']]), '#7795a2');
for (const side of [-1, 1]) {
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
}
text(g, r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
}
function rampLights(g, c, r, time) {
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
if (hot) {
for (let k = 0; k < 7; k++) {
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
}
}
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
for (let i = 0; i < 3; i++) {
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
if (lit) glow(g, x - 8 + i * 8, y + 6, 7, color, 0.7);
disc(g, x - 8 + i * 8, y + 6, 1.8, lit ? color : '#102a36', lit ? '#d6faff' : '#46616a');
}
const pulse = 0.6 + 0.4 * Math.sin(time * (invited || combo ? 8 : 3));
if (invited || combo) glow(g, x, y + 16, 23, color, pulse);
poly(g, [[x, y + 10], [x + 4, y + 16], [x + 1.5, y + 16], [x + 1.5, y + 22], [x - 1.5, y + 22], [x - 1.5, y + 16], [x - 4, y + 16]], invited ? WHITE : color);
box(g, x - 23, y + 24, 46, 10, '#08151e', color, 3);
text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center');
}
function bumper(g, b, time) {
const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec';
glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24);
g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill();
disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b');
disc(g, b.x, b.y, b.r - 2, '#172835', color);
const v = g.createRadialGradient(b.x - 3, b.y - 4, 0, b.x, b.y, b.r - 3);
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
for (const dx of [-8, 8]) screw(g, b.x + dx, b.y);
if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); }
}
function target(g, c) {
for (const t of c.targets) {
const [x, y] = t.a, h = t.b[1] - y, color = t.bank === 'pieve' ? TINT.sinistra : TINT.destra;
box(g, x - 3, y - 1, 6, h + 2, '#030c12', '#45606d', 1);
if (t.down) { line(g, [[x - 2, y + h - 1], [x + 2, y + h - 1]], color); continue; }
g.fillStyle = metal(g, x - 2, y, 5, 6); g.fillRect(x - 2, y, 4, h);
g.fillStyle = color; g.fillRect(x - 1, y + 2, 2, h - 5); glow(g, x, y + h / 2, 8, color, 0.35);
}
const o = c.regolo;
glow(g, o.x, o.y, o.hit > 0 ? 30 : 18, '#99e5aa', o.hit > 0 ? 1 : 0.4);
disc(g, o.x + 1, o.y + 3, o.r + 1, '#02090b');
disc(g, o.x, o.y, o.r + 1, metal(g, o.x - 6, o.y - 6), '#c5b682');
disc(g, o.x, o.y, o.r - 1, '#18483e', '#94d7a5'); crown(g, o.x, o.y, 3.4, '#f7dda0');
text(g, 'REGOLO', o.x, o.y - 14, 5, '#acd6b7', 'center');
}
function paddle(g, c, side) {
const f = c.flippers[side], { a: [x, y], b: [tx, ty] } = flipperSeg(c, side), len = Math.hypot(tx - x, ty - y);
glow(g, x, y, 13, side === 'left' ? TINT.sinistra : TINT.destra, f.held.length ? 0.7 : 0.3);
g.save(); g.translate(x, y); g.rotate(Math.atan2(ty - y, tx - x));
const shape = () => { g.beginPath(); g.moveTo(0, -3); g.lineTo(len, -2.3); g.quadraticCurveTo(len + 3, 0, len, 2.3); g.lineTo(0, 3); g.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); g.closePath(); };
g.save(); g.translate(1, 4); shape(); g.fillStyle = '#00080c'; g.fill(); g.restore();
shape(); g.fillStyle = '#e9eee4'; g.fill(); g.strokeStyle = '#d36754'; g.lineWidth = 1.4; g.stroke();
line(g, [[3, -1.7], [len - 2, -1.3]], '#ffffff', 0.8); screw(g, 0, 0); g.restore();
}
function ball(g, b, color = '#bcecff') {
const z = b.ramp?.z ?? 0, x = b.x, y = b.y - 1 - z * 8, r = R + z;
g.beginPath(); g.ellipse(x + 2 + z * 3, b.y + 3 + z * 2, r + 1, r * 0.55, 0, 0, TAU); g.fillStyle = '#00080dcc'; g.fill();
const v = g.createRadialGradient(x - r * 0.35, y - r * 0.45, 0, x, y, r);
v.addColorStop(0, '#ffffff'); v.addColorStop(0.2, '#f6ffff'); v.addColorStop(0.42, color); v.addColorStop(0.65, '#667d8e'); v.addColorStop(0.8, '#132435'); v.addColorStop(1, '#b1ccdc');
disc(g, x, y, r, v, '#bfdee8'); disc(g, x - r * 0.3, y - r * 0.4, 0.9, '#ffffff');
}
function lock(g, held, time) {
const b = held.ball, color = TINT[held.rampId];
glow(g, b.x, b.y, 22, color, 0.5 + 0.15 * Math.sin(time * 4));
box(g, b.x - 9, b.y - 11, 18, 22, '#07151e', color, 5); ball(g, b, color);
for (const dx of [-6, 6]) line(g, [[b.x + dx, b.y - 8], [b.x + dx, b.y + 7]], metal(g, b.x - 6, b.y, 12, 10), 2);
text(g, 'LOCK', b.x, b.y - 16, 5.5, color, 'center');
}
function plunger(g, c) {
const x = 440, pull = c.holding ? c.power * 12 : 0;
for (let y = 327 + pull; y < 347; y += 2.5) line(g, [[436, y], [444, y + 1]], '#adbdc4', 0.8);
box(g, 435, 324 + pull, 10, 4, metal(g, 435, 324, 10, 4), '#3c687b', 1);
for (let i = 0; i < 8; i++) {
g.fillStyle = canThrow(c) || c.holding ? (i / 8 < c.power ? TINT.destra : '#1e3844') : '#1e3844';
g.fillRect(437, 290 - i * 6, 6, 2);
}
// La valvola disegnata coincide con il segmento inclinato della fisica.
const gate = c.walls.find(w => w.gate);
line(g, [gate.a, gate.b], '#061017', 3); line(g, [gate.a, gate.b], '#b0d1d5', 1.2); screw(g, ...gate.a);
if (waiting(c)) text(g, 'LANCIA', x, 302, 4.5, TINT.destra, 'center');
}
const shown = new WeakMap();
export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; }
function board(g, c, time) {
crown(g, 99, 43, 10, '#e7c889');
text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500);
text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center');
box(g, 23, 111, 152, 80, '#030b13', '#2c4a58', 6);
text(g, 'PUNTEGGIO', 34, 123, 6, MUTED);
let d = shown.get(c);
if (!d) { d = { from: c.score, to: c.score, t0: time }; shown.set(c, d); }
if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; }
const score = c.step === 'result' ? c.score : shownScore(d, time);
glow(g, 99, 146, 55, '#6dddf4', 0.2);
const digits = String(score).padStart(6, '0');
text(g, digits, 99, 146, Math.min(28, 132 / (digits.length * 0.6)), '#c4f6ff', 'center', 'monospace', 500);
const message = c.step === 'result' ? (c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA') : c.line ?? (waiting(c) ? 'CARICA LA MOLLA' : 'PUNTA AL LOCK');
text(g, message, 99, 176, 7, c.line ? '#ffdc93' : MUTED, 'center');
const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score);
box(g, 28, 195, 142, 2, '#203641', null, 1);
if (progress) box(g, 28, 195, 142 * progress, 2, '#e7c889', null, 1);
text(g, remaining ? `MANCANO ${number(remaining)}` : 'TRAGUARDO / PALLINA +1', 99, 206, 6, '#c7c8ac', 'center');
text(g, 'PALLINE', 28, 225, 6, MUTED);
for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5');
if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right');
if (c.step === 'result') {
box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5);
text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center');
text(g, `${number(c.result.score)} PUNTI`, 99, 287, 11, WHITE, 'center', 'monospace');
}
const save = saveLeft(c);
if (save > 0) { box(g, 248, 325, 144, 8, '#0b272f', '#4d9b9d', 3); text(g, `BALL SAVE ${Math.ceil(save)}s`, 320, 329, 5, '#a4f7d1', 'center'); }
}
function mission(g, c, time) {
const multi = multiball(c);
text(g, 'LA CACCIA AL', 480, 35, 6, MUTED); text(g, 'JACKPOT', 480, 51, 17, '#f0d59d', 'left', 'Georgia, serif');
text(g, 'DUE LOCK. UNA SUPER SPECIAL.', 480, 69, 5.5, '#8aabb8');
for (let i = 0; i < 2; i++) {
const id = i ? 'destra' : 'sinistra', color = TINT[id], y = 83 + i * 56, held = c.locks.some(l => l.rampId === id);
const count = held || multi ? 3 : c.rampRun.id === id ? c.rampRun.count : 0;
box(g, 479, y, 137, 49, '#102431', c.special === id || multi ? color : '#28424e', 5);
text(g, `0${i + 1}`, 489, y + 12, 6, color, 'left', 'monospace');
text(g, i ? 'TREBBIO' : 'FORTEZZA', 507, y + 12, 8, '#d4e6eb');
for (let k = 0; k < 3; k++) { box(g, 489 + k * 28, y + 25, 22, 4, k < count ? color : '#213e4c', null, 1); }
const note = multi ? 'JACKPOT ATTIVO' : held ? 'PALLINA TRATTENUTA' : count ? `${count}/3 · ${Math.max(0, Math.ceil(c.rampRun.until - c.sim))}s PER IL LOCK` : '3 PASSAGGI IN 20 SECONDI';
text(g, note, 489, y + 39, 5.5, held ? color : MUTED);
if (held) { disc(g, 604, y + 12, 3, color); glow(g, 604, y + 12, 10, color, 0.6); }
}
const open = c.special === 'super';
box(g, 479, 195, 137, 64, open || multi ? '#241d3c' : '#121f2c', open ? TINT.super : '#364052', 5);
if (open) glow(g, 547, 222, 52, TINT.super, 0.25 + 0.15 * Math.sin(time * 5));
text(g, '03 SUPER SPECIAL', 489, 208, 7.5, open ? '#f1d8ff' : '#a5a5b7');
text(g, multi ? `${activeBalls(c).length} PALLINE IN GIOCO` : open ? 'RAMPA CENTRALE APERTA' : 'COMPLETA ENTRAMBI I LOCK', 489, 224, 5.5, open || multi ? '#ead6fc' : MUTED);
text(g, '+1 PALLINA + MULTIBALL', 489, 241, 6, '#d3bfdc');
text(g, `JACKPOT ×${c.jackpotX}`, 480, 278, 8, '#f2d5a0');
text(g, number((open ? SCORE.super : SCORE.jackpot) * c.jackpotX), 615, 278, 10, '#ffe4b2', 'right', 'monospace');
text(g, 'I BERSAGLI COMPLETI ALZANO IL ×', 480, 292, 5, MUTED);
line(g, [[480, 303], [615, 303]], '#2c424d', 0.6);
text(g, multi ? 'LE RAMPE PAGANO JACKPOT' : c.special === 'super' ? 'MIRA ALLA FRECCIA VIOLA' : c.special ? 'L’ALTRO LOCK ACCENDE LA SUPER' : c.lastRamp ? `COMBO +50.000 / ${Math.max(0, Math.ceil(RAMP.combo - (c.sim - c.lastRamp.at)))}s` : 'ALTERNARE LE RAMPE: +50.000', 548, 316, 5.5, '#aec5c9', 'center');
text(g, multi ? 'MULTIBALL' : c.special === 'super' ? 'SUPER SPECIAL' : 'LOCK • SPECIAL • JACKPOT', 548, 333, 6, multi || open ? TINT.super : '#6e8f9b', 'center');
}
function effects(g, c, time) {
const latest = c.fx.findLast(f => f.kind === 'celebrate');
for (const f of c.fx) {
const age = time - f.t;
if (age < 0) continue;
g.save();
if (f.kind === 'spark' && age < 0.5) {
g.globalAlpha = 1 - age / 0.5;
for (let i = 0; i < 8; i++) {
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
line(g, [[f.x + Math.cos(a) * length, f.y + Math.sin(a) * length], [f.x + Math.cos(a) * (length + 3), f.y + Math.sin(a) * (length + 3)]], i % 2 ? '#f9edbc' : '#8de8fa', 1);
}
} else if (f.kind === 'ring' && age < 0.5) {
g.globalAlpha = 1 - age * 2; g.strokeStyle = '#b2f6ff'; g.lineWidth = 1.2;
g.beginPath(); g.ellipse(f.x, f.y, 12 + age * 42, 9 + age * 30, 0, 0, TAU); g.stroke();
} else if (f.kind === 'pop' && age < 1.1) {
g.globalAlpha = Math.min(1, (1.1 - age) * 3);
text(g, f.text, f.x, f.y - age * 22, 7, '#fff1c7', 'center', 'monospace');
} else if (f.kind === 'burst' && age < 1.5) {
g.globalAlpha = Math.min(1, (1.5 - age) * 2);
for (let i = 0; i < 28; i++) {
const a = hash(i * 9 + f.t) * TAU, speed = 25 + hash(i * 13) * 75;
const x = f.x + Math.cos(a) * speed * age, y = f.y + Math.sin(a) * speed * age + 20 * age * age;
if (x < 201 || x > 430 || y > 330) continue;
disc(g, x, y, 0.7 + hash(i), i % 2 ? '#a8e8ff' : '#ffe3a9');
}
} else if (f.kind === 'celebrate' && f === latest && age < 2.6) {
const color = f.color ?? TINT.super;
const fade = Math.min(1, age * 5, (2.6 - age) * 3);
g.globalAlpha = fade;
// Fasci stretti, limitati al campo: la pallina resta leggibile.
g.save(); outline(g, c); g.clip();
for (let i = 0; i < 5; i++) {
const a = -Math.PI / 2 + (i - 2) * 0.3 + Math.sin(time * 1.5) * 0.1;
poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18');
}
g.restore();
glow(g, 320, 277, 85, color, 0.45);
box(g, 257, 265, 126, 31, '#07111deb', color, 6);
text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800);
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace');
}
g.restore();
}
}
export function drawFlipper(g, c, def, bg, glass) {
const time = c.visual.time;
// Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la
// memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono.
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter';
const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0);
const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass);
if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); }
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
else cached(g, key, paint);
for (const [i, l] of c.lanes.entries()) {
if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55);
disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b');
disc(g, l.x, l.y, 2.3, l.lit ? RIONI[i] : '#203a44');
}
target(g, c); c.bumpers.forEach(b => bumper(g, b, time));
if (c.special !== 'super') {
box(g, 305, 221, 30, 15, metal(g, 305, 221, 30, 15), '#354a5a', 3);
box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center');
for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748');
}
const balls = activeBalls(c);
for (const [i, b] of balls.entries()) {
if (b.waiting) continue;
const color = [TINT.sinistra, TINT.destra, TINT.super][i];
b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); });
}
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) {
if (!rampOpen(c, r)) continue;
g.save();
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time);
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
g.restore();
}
c.locks.forEach(held => lock(g, held, time));
for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time);
for (const side of ['left', 'right']) paddle(g, c, side);
plunger(g, c); effects(g, c, time);
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
board(g, c, time); mission(g, c, time);
g.restore();
}
+527
View File
@@ -0,0 +1,527 @@
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»). Visto dall'alto, la tavola
// in verticale al centro del primo piano 640×360; la pallina parte dalla corsia di destra con la molla, fa il giro
// dell'arco in cima, passa in una delle quattro corsie dei rioni, rimbalza sui tre funghi (i bastioni della Fortezza),
// abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo, e giù dalle sponde elastiche ai
// due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al traguardo di
// punti del livello (e poi si può continuare per il record). Unità: i pixel del primo piano. La fisica va a passo
// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte.
// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita
// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che
// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico.
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
export const PIVOT = { left: [280, 314], right: [360, 314] };
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000, super: 250000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30, lockWindow: 20 };
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
// gravità. La spinta dei funghi è uguale per tutti.
export const LEVELS = {
bona: { g: 680, save: 8, kick: 340, goal: 60000 },
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
male: { g: 800, save: 1, kick: 340, goal: 200000 },
};
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const seg = (a, b, extra = {}) => ({ a, b, e: 0.55, touching: false, ...extra });
// La tavola: sponde, corsie, sponde elastiche, funghi, bersagli. Le coordinate seguono il disegno di Codex
// (assets/closeups/river-flipper.png): i tre dischi dei funghi, le quattro bandiere dei rioni, la corsia della molla.
export function layout() {
const arc = [[200, 72], [203, 50], [212, 32], [228, 18], [252, 10], [288, 6], [324, 5], [360, 6], [396, 10], [420, 18], [436, 32], [445, 50], [448, 72]];
const walls = [];
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 }));
walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336]));
// La corsia della molla, col fondo. Un lancio che non è ancora entrato in campo si può ritentare;
// dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale.
walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 }));
// Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo.
walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 }));
// Le corsie dei rioni, in cima, fra le bandiere: tre guide.
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
// I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
// po' lungo il flipper: finendo sul perno la pallina ci restava incastrata).
// Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
walls.push(seg([214, 240], [214, 290]), seg([214, 290], [292, 314], { e: 0.3 }));
walls.push(seg([420, 240], [420, 290]), seg([420, 290], [348, 314], { e: 0.3 }));
// Le sponde elastiche sopra i flipper: il lato lungo spinge.
walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true }));
walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true }));
// I bersagli a caduta: la Pieve a sinistra, la torre a destra, con la sponda dietro.
walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198]));
// Dietro la Pieve, fra la parete e la sponda, restavano 10 pixel: la pallina (larga 9) che scivolava lungo la
// parete sinistra ci entrava e scendeva dritta nell'uscita laterale (Andrea: «a sinistra esce troppo facile»).
// A destra il canale è di 3 pixel e non esiste. Due raccordi inclinati lo chiudono sopra e sotto.
walls.push(seg([200, 136], [210, 146], { e: 0.5 }), seg([210, 198], [200, 208], { e: 0.5 }));
const targets = [];
for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false, touching: false });
const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0, touching: false }));
// Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro.
const regolo = { x: 404, y: 62, r: 7, hit: 0, touching: false };
// Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in
// discesa la riga LANE_LINE.
const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false }));
// Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
// in cima, come due ponti.
const ramps = [
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
{ id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
{ id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
{ id: 'super', mouth: [[303, 232], [337, 232]], path: [[320, 232], [320, 188], [333, 145], [333, 96], [320, 72], [307, 96], [307, 145], [287, 168], [246, 180], [229, 198], [229, 220]], lit: 0, touching: false },
];
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) {
r.len = 0; r.cum = [0];
for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); }
r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
}
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
return { walls, targets, bumpers, regolo, lanes, ramps };
}
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
export function alongRamp(r, s) {
s = clamp(s, 0, r.len);
let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++;
const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L;
return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L };
}
export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
newBall(c);
return c;
}
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
export const multiball = c => c.extraBalls.length > 0;
export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super';
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
export const canAdjust = canThrow;
export const canFlip = c => c.step === 'play';
export const aimFree = () => true;
export const powerAt = time => clamp(time / CHARGE, 0, 1);
// Quanto resta del salvataggio della pallina, in secondi (0: non protetta).
export const saveLeft = c => c.ball && !c.ball.waiting ? Math.max(0, c.saveUntil - c.sim) : 0;
// Le scritte del tabellone: una alla volta, due secondi l'una; il traguardo passa davanti a tutte.
function say(c, line, front = false) {
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
else c.lines.push(line);
}
// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che
// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE.
export const FX_LIFE = 3;
function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); }
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
// continua per il record.
function add(c, points, at) {
c.score += points;
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
if (!c.result && c.score >= c.skill.goal) {
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
}
if (c.result) c.result.score = c.score;
}
// Il lancio: la molla spinge la pallina su per la corsia. Anche la molla più fiacca arriva all'arco (la velocità
// minima dipende dalla gravità del livello); da lì in su, la forza decide quanto gira.
export function launchSpeed(c, power) {
const min = Math.sqrt(2 * c.skill.g * 340), max = Math.max(min + 150, 900);
return min + (max - min) * clamp(power, 0, 1);
}
export function launch(c, power) {
if (!waiting(c)) return false;
// La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale.
c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0;
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
c.saveUntil = c.saved ? 0 : c.sim + c.skill.save; c.holding = false; c.holdTime = 0; c.pointerId = null;
c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * clamp(power, 0, 1) });
return true;
}
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda.
export function hold(c, side, who, down) {
const f = c.flippers[side]; if (!f) return false;
const was = f.held.length > 0;
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
if (who === 'click' && down) f.click = CLICK_HOLD;
if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 });
return true;
}
export function flipperSeg(c, side) {
const f = c.flippers[side], [px, py] = PIVOT[side];
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
}
// La fisica, a passi fissi.
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
c.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE);
c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
// La scia della pallina: le ultime posizioni, per la cometa.
for (const b of activeBalls(c)) {
if (!b.waiting && b.releaseAt <= c.sim) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
}
c.trail = c.ball?.trail ?? [];
}
function moveFlippers(c, h) {
for (const side of ['left', 'right']) {
const f = c.flippers[side];
if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click');
const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
const previous = f.angle;
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
f.omega = (f.angle - previous) / h;
}
}
function step(c) {
c.sim += STEP;
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
if (c.rampRun.count && c.sim > c.rampRun.until) c.rampRun = { id: null, count: 0, until: 0 };
if (c.step !== 'play') { moveFlippers(c, STEP); return; }
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp && b.releaseAt <= c.sim);
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
for (let i = 0; i < n; i++) {
moveFlippers(c, h);
for (const ball of ground) {
ball.x += ball.vx * h; ball.y += ball.vy * h;
if (ball.x < LANE.x0 - R) ball.inPlay = true;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
for (const b of c.bumpers) collideBumper(c, ball, b);
collideRegolo(c, ball);
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
limit(ball);
}
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
}
for (const ball of balls) {
if (ball.waiting || ball.releaseAt > c.sim) continue;
if (ball.ramp) { rideRamp(c, ball); continue; }
crossLanes(c, from.get(ball), ball);
enterRamps(c, from.get(ball), ball);
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
if ((ball.rest += STEP) > 0.3) {
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
else newBall(c);
}
} else ball.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
}
}
function collideBalls(a, b) {
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= R * 2) return;
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
limit(a); limit(b);
}
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
function crossLanes(c, from, ball) {
if (!(from.y < LANE_LINE && ball.y >= LANE_LINE)) return;
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
if (!l || l.lit) return;
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
if (c.lanes.every(k => k.lit)) {
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
}
}
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
// indietro e ricasca dall'imbocco.
function enterRamps(c, from, ball) {
for (const r of c.ramps) {
if (!rampOpen(c, r)) continue;
const [[x0, y], [x1]] = r.mouth;
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
if (x < x0 + R || x > x1 - R) continue;
// Conta solo la velocità lungo il ponte (sfiorarlo di traverso non fa salire).
const p = alongRamp(r, 0), forward = ball.vx * p.dx + ball.vy * p.dy;
if (forward < RAMP.speed) continue;
ball.ramp = { id: r.id, s: 0, v: forward * 0.95, off: x - p.x };
c.events.push({ kind: 'flipper-rampa', power: 0.7 });
return;
}
}
// Sul ponte: si sale fino alla cima perdendo velocità, si scende riprendendola (la stessa energia, meno un po'
// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1).
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
function rideRamp(c, ball) {
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top;
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
on.s += on.v * STEP;
const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend);
ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s);
if (on.s >= r.len) {
// In fondo al ponte: giù dall'altra parte, con la velocità che ha. Gli ostacoli lì sotto, se per caso la
// pallina esce sopra uno di loro, non contano come urto nuovo.
const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
if (rampFeature(c, r, ball)) return;
if (c.lastRamp && c.lastRamp.id !== r.id) {
add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
}
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
} else if (on.s <= 0) {
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
}
// Tre passaggi completi sulla stessa rampa entro venti secondi. La finestra parte dal primo.
function announce(c, line) {
// Gli eventi di fase sostituiscono i vecchi annunci dei ponti; il premio del traguardo mantiene la precedenza.
const prize = c.lines.includes('PALLINA +1'); c.lines = [];
if (c.line === 'TRAGUARDO!') { if (prize) c.lines.push('PALLINA +1'); c.lines.push(line); }
else { c.line = null; say(c, line, true); if (prize) c.lines.push('PALLINA +1'); }
}
function celebrate(c, title, points, color) {
if (points) add(c, points, [320, 184]);
announce(c, title);
fx(c, 'celebrate', 320, 170, { text: title, points, color });
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
}
function rampFeature(c, r, ball) {
if (r.id === 'super') {
// La rampa centrale esiste solo con entrambi i lock: premio e tre palline sul campo.
if (c.locks.length !== 2) return true;
for (const [i, lock] of c.locks.entries()) {
const released = lock.ball;
// Le uscite verticali centrano le corsie interne: nessuna spinta verso le buche laterali.
released.vx = 0; released.vy = 170; released.waiting = false;
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
released.releaseAt = c.sim + i * 0.45; released.releasedFrom = lock.rampId;
c.extraBalls.push(released);
}
// Uscita laterale sopra la paletta sinistra, con velocità moderata; niente tiro dritto nella buca.
ball.vx = 0; ball.vy = 160;
c.locks = []; c.special = null; c.specialPaid = false; c.superOpenedAt = null; c.lastRamp = null;
c.rampRun = { id: null, count: 0, until: 0 }; c.saveUntil = 0; c.saved = true; c.balls++;
celebrate(c, 'SUPER SPECIAL', SCORE.super * c.jackpotX, '#e1a0ff');
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
c.events.push({ kind: 'flipper-super', power: 1 });
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
return true;
}
if (multiball(c)) {
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
c.events.push({ kind: 'flipper-jackpot', power: 1 }); return true;
}
if (c.locks.some(lock => lock.rampId === r.id)) { c.rampRun = { id: null, count: 0, until: 0 }; say(c, 'LOCK OCCUPATO'); return true; }
if (c.special === r.id && !c.specialPaid) {
c.specialPaid = true;
celebrate(c, 'JACKPOT!', SCORE.jackpot * c.jackpotX, '#ffcf75');
c.events.push({ kind: 'flipper-jackpot', power: 1 });
}
const continuing = c.rampRun.id === r.id && c.sim <= c.rampRun.until;
c.rampRun = { id: r.id, count: continuing ? c.rampRun.count + 1 : 1, until: continuing ? c.rampRun.until : c.sim + RAMP.lockWindow };
if (c.rampRun.count < 3) return c.locks.length > 0;
c.locks.push({ rampId: r.id, ball }); ball.vx = 0; ball.vy = 0; ball.trail = [];
c.special = c.locks.length === 2 ? 'super' : c.ramps.find(other => other.id !== 'super' && other.id !== r.id).id;
c.rampRun = { id: null, count: 0, until: 0 }; c.lastRamp = null;
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
newBall(c);
celebrate(c, c.locks.length === 2 ? 'SUPER APERTA' : 'BALL LOCKED', 0, c.locks.length === 2 ? '#e1a0ff' : '#72edff');
c.events.push({ kind: 'flipper-lock', power: 1 });
if (c.special === 'super') { c.superOpenedAt = c.visual.time; c.events.push({ kind: 'flipper-super-open', power: 1 }); }
return true;
}
function drain(c, ball) {
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
if (multiball(c)) {
if (c.ball === ball) c.ball = c.extraBalls.shift();
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0, until: 0 };
say(c, multiball(c) ? 'DUE IN GIOCO' : 'UNA IN GIOCO'); return;
}
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.rampRun = { id: null, count: 0, until: 0 };
if (c.locks.length) {
// Si recupera l'ultimo lock. L'eventuale primo resta acquisito e non si scala la riserva.
c.locks.pop(); c.superOpenedAt = null;
c.special = c.locks.length ? c.ramps.find(r => r.id !== 'super' && r.id !== c.locks[0].rampId).id : null;
if (!c.locks.length) c.specialPaid = false;
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
announce(c, 'LOCK LIBERO'); newBall(c); return;
}
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
c.ball = null; c.step = 'result';
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
}
function contact(ball, object, hit) {
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
if (hit && fresh) ball.contacts.push(object.contactId);
else if (!hit && !fresh) ball.contacts.splice(index, 1);
object.touching = hit;
return fresh;
}
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
function hitSeg(ball, w, e) {
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
if (d >= R) { contact(ball, w, false); return null; }
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh };
}
function collideSeg(c, ball, w) {
if (w.gate) {
if (!ball.inPlay) return;
const hit = hitSeg(ball, w, w.e);
if (hit && hit.vn > 0) ball.vx = Math.min(-100, ball.vx);
return;
}
if (!w.sling) { hitSeg(ball, w, w.e); return; }
// La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come
// la gomma vera; solo a un urto nuovo, e solo se picchia.
const hit = hitSeg(ball, w, null);
if (!hit || hit.vn <= 0) return;
if (hit.fresh && hit.vn > IMPACT) {
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y);
} else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; }
}
function collideTarget(c, ball, t) {
const hit = hitSeg(ball, t, 0.5);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
const bank = c.targets.filter(k => k.bank === t.bank);
if (bank.every(k => k.down)) { c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
}
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
function hitCircle(ball, o, e) {
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
if (d >= min) { contact(ball, o, false); return null; }
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh };
}
function collideBumper(c, ball, b) {
const hit = hitCircle(ball, b, 0.2);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
}
function collideRegolo(c, ball) {
const hit = hitCircle(ball, c.regolo, 0.7);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
}
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
function collideFlipper(c, ball, side) {
const f = c.flippers[side], { a, b } = flipperSeg(c, side), [ax, ay] = a, dx = b[0] - ax, dy = b[1] - ay, len2 = dx * dx + dy * dy;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny), min = R + FLIP.r;
if (d >= min) return false;
if (d < 1e-6) { nx = 0; ny = -1; } else { nx /= d; ny /= d; }
ball.x = px + nx * min; ball.y = py + ny * min;
// La velocità della superficie: ω × r, col raggio dal perno al punto toccato.
const rx = px - ax, ry = py - ay, sx = -f.omega * ry, sy = f.omega * rx;
const vn = (ball.vx - sx) * nx + (ball.vy - sy) * ny;
if (vn < 0) { ball.vx -= (1 + 0.45) * vn * nx; ball.vy -= (1 + 0.45) * vn * ny; }
return true;
}
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false;
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; }
return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true);
}
export function release(c, pointerId = 'ptr') {
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
let any = false;
for (const side of ['left', 'right']) if (c.flippers[side].held.includes(pointerId)) any = hold(c, side, pointerId, false) || any;
return any;
}
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
export function cancel(c, pointerId) {
const all = pointerId === undefined;
if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; }
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
return true;
}
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce.
export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true);
export function stopTurn(c, who = 'button') {
if (who === 'click') return false; // il colpo breve finisce da sé
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
return l || r;
}
export const nudge = () => false;
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
export function key(c, k, down) {
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; }
if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
if (k === 'Enter') { if (down) shoot(c); return true; }
return false;
}
// Il pulsante Lancia: con la forza del cursore.
export const shoot = c => canThrow(c) && launch(c, c.power);
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
export function status(c) {
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
const feature = c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
if (c.line) return `${c.line} ${tally}`;
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
}
+2
View File
@@ -19,6 +19,7 @@ import { newTelefono, updateTelefono, tapTelefono } from './telefono.js';
import * as condor from './condor.js'; import * as condor from './condor.js';
import * as campana from './campana.js'; import * as campana from './campana.js';
import * as bruciate from './bruciate.js'; import * as bruciate from './bruciate.js';
import * as flipper from './flipper.js';
import * as poker from './poker.js'; import * as poker from './poker.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
@@ -36,6 +37,7 @@ export const MINIGAMES = {
condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen }, condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen },
campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen }, campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen },
bruciate: { create: (seed, level) => bruciate.newBruciate(seed, { level }), update: bruciate.update, press: bruciate.press, release: bruciate.release, cancel: bruciate.cancel, aim: bruciate.aimScreen }, bruciate: { create: (seed, level) => bruciate.newBruciate(seed, { level }), update: bruciate.update, press: bruciate.press, release: bruciate.release, cancel: bruciate.cancel, aim: bruciate.aimScreen },
flipper: { create: (seed, level) => flipper.newFlipper(seed, { level }), update: flipper.update, press: flipper.press, release: flipper.release, cancel: flipper.cancel, aim: flipper.aimScreen },
micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen }, micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen },
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen }, gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
}; };
+12
View File
@@ -15,6 +15,7 @@ import { drawCorsa } from './corsa.js';
import { drawCondor } from './condor-render.js'; import { drawCondor } from './condor-render.js';
import { drawCampana } from './campana-render.js'; import { drawCampana } from './campana-render.js';
import { drawBruciate } from './bruciate-render.js'; import { drawBruciate } from './bruciate-render.js';
import { drawFlipper } from './flipper-render.js';
import { drawPoker } from './poker-render.js'; import { drawPoker } from './poker-render.js';
import { drawViaggio } from './viaggio.js'; import { drawViaggio } from './viaggio.js';
import { drawIncanto } from './incanto.js'; import { drawIncanto } from './incanto.js';
@@ -165,6 +166,12 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
} }
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) { export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
// Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto.
if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') {
drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
return;
}
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop); const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && backdrop) loadBackground(backdrop); if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
@@ -1055,6 +1062,11 @@ function drawCloseup(ctx, game, time) {
drawCampana(ctx, c, def, backgrounds.get(def.picture)); drawCampana(ctx, c, def, backgrounds.get(def.picture));
return; return;
} }
if (def.type === 'flipper') {
for (const src of [def.picture, def.backglass]) if (src && !backgrounds.has(src)) loadBackground(src);
drawFlipper(ctx, c, def, backgrounds.get(def.picture), def.backglass && backgrounds.get(def.backglass));
return;
}
if (def.type === 'bruciate') { if (def.type === 'bruciate') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBruciate(ctx, c, def, backgrounds.get(def.picture)); drawBruciate(ctx, c, def, backgrounds.get(def.picture));
+16
View File
@@ -161,6 +161,22 @@ export class Soundscape {
else if (kind === 'bruciate-scoppio') { this.noiseSound(0.04, 2600, 0.05 * strength, { pan }); this.tone(900, 500, 0.03, 0.03, 0, pan); } else if (kind === 'bruciate-scoppio') { this.noiseSound(0.04, 2600, 0.05 * strength, { pan }); this.tone(900, 500, 0.03, 0.03, 0, pan); }
else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); } else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); }
else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan }); else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan });
// Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco.
else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); }
else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); }
else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); }
else if (kind === 'flipper-sponda') { this.tone(520, 260, 0.05, 0.05, 0, pan); this.noiseSound(0.03, 1600, 0.03, { pan }); }
else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); }
else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); }
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan);
else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); }
else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); }
else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); }
else if (kind === 'flipper-super-open') { this.noiseSound(0.45, 1600, 0.04, { pan }); this.tone(180, 1200, 0.65, 0.06, 0, pan); for (const [i, f] of [659, 880, 1318].entries()) this.tone(f, f, 0.18, 0.05, 0.5 + i * 0.12, pan); }
else if (kind === 'flipper-super') { for (const [i, f] of [392, 523, 659, 784, 1046, 1318, 1568, 2093].entries()) this.tone(f, f * 1.005, 0.24, 0.055, i * 0.08, pan); for (const [i, f] of [523, 784, 1046].entries()) this.tone(f, f, 0.9, 0.045, 0.65 + i * 0.04, pan); }
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); } else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); }
+51 -8
View File
@@ -3,6 +3,7 @@ import { story } from './story/story.js';
import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js'; import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js'; import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { cabinetAway } from './engine/flipper-render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js'; import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js'; import { Voices } from './engine/voices.js';
@@ -16,6 +17,7 @@ import * as cerbottana from './engine/cerbottana.js';
import * as condor from './engine/condor.js'; import * as condor from './engine/condor.js';
import * as campana from './engine/campana.js'; import * as campana from './engine/campana.js';
import * as bruciate from './engine/bruciate.js'; import * as bruciate from './engine/bruciate.js';
import * as flipper from './engine/flipper.js';
import * as poker from './engine/poker.js'; import * as poker from './engine/poker.js';
import { cardAt as pokerAt } from './engine/poker-render.js'; import { cardAt as pokerAt } from './engine/poker-render.js';
import * as basket from './engine/basket.js'; import * as basket from './engine/basket.js';
@@ -330,6 +332,7 @@ $('spots-button').addEventListener('click', showSpots);
// --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»). // --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»).
function openPause() { function openPause() {
dropPointers();
if (!running) return; if (!running) return;
running = false; running = false;
game.closeupCancel(); game.closeupCancel();
@@ -491,9 +494,22 @@ const debugView = () => debug && debugShown && game && { mouse, points: debugPoi
const TOUCH_REACH = 22; const TOUCH_REACH = 22;
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0; const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
let press = null; let press = null;
// Le dita in più sul flipper (oltre a `press`): ognuna lascia il suo flipper quando si alza. Il tasto destro del
// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte ('mouse-right', sempre il
// flipper di destra), e i due tasti si allineano alla maschera `e.buttons` a ogni evento (syncMouse).
const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId;
function syncMouse(e) {
const left = !!(e.buttons & 1), right = !!(e.buttons & 2);
if (right && !extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); }
if (!right && extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); }
if (left && !press) { game.closeupAim(scenePoint(e)); press = { closeup: true, pointerId: e.pointerId, state: game.closeup }; if (!game.skip()) game.closeupPress(e.pointerId); }
if (!left && press?.closeup && press.pointerId === e.pointerId) { const state = press.state; press = null; if (game.closeup === state) game.closeupRelease(e.pointerId); }
}
// Pausa, finestra che perde il fuoco: niente resta premuto, né nel motore né qui.
function dropPointers() { press = null; extra.clear(); }
canvas.addEventListener('pointerdown', e => { canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) return; if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { if (game.closeup) {
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type]; const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
@@ -519,6 +535,13 @@ canvas.addEventListener('pointerdown', e => {
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); } if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return; return;
} }
// Il flipper: un secondo dito comanda l'altro flipper, per conto suo; il tasto destro del mouse quello di destra.
if (type === 'flipper' && e.pointerType === 'mouse' && e.button === 2) { syncMouse(e); canvas.setPointerCapture(e.pointerId); return; }
if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) {
game.closeupAim(scenePoint(e));
if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); }
return;
}
if (press?.closeup) return; if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) { if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) {
@@ -536,6 +559,8 @@ canvas.addEventListener('pointerdown', e => {
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) }; press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (e.pointerType === 'mouse' && game?.closeup && story.closeups[game.closeup.id].type === 'flipper' && (extra.has('mouse-right') || press?.closeup)) { syncMouse(e); return; }
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupRelease(e.pointerId); return; }
if (!press) return; if (!press) return;
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no // Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente. // si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
@@ -573,12 +598,14 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', e => { canvas.addEventListener('pointercancel', e => {
if (extra.has('mouse-right') && e.pointerType === 'mouse') { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); }
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; }
if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup && e.pointerId !== press.pointerId) return;
if (press) clearTimeout(press.timer); if (press) clearTimeout(press.timer);
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId); if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
press = null; press = null;
}); });
canvas.addEventListener('lostpointercapture', e => { if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } }); canvas.addEventListener('lostpointercapture', e => { if (extra.has('mouse-right') && e.pointerType === 'mouse' && !(e.buttons & 2)) { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -586,6 +613,9 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
// Un tasto del mouse premuto o lasciato mentre l'altro è giù arriva come pointermove (pulsanti «in accordo»):
// al flipper si legge la maschera dei pulsanti e si allineano i due comandi.
if (e.pointerType === 'mouse' && e.button >= 0 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { syncMouse(e); return; }
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
@@ -619,6 +649,7 @@ const MINI = {
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' }, campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' }, bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper o le frecce sinistra e destra.' },
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
@@ -706,7 +737,7 @@ for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza). // Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); }); $('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); }); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); addEventListener('blur', () => { dropPointers(); game?.closeupCancel(); });
// --- Verbi. // --- Verbi.
for (const verb of VERBS) { for (const verb of VERBS) {
@@ -1048,13 +1079,15 @@ function updateUi() {
} }
} }
if (mg && !cards) { if (mg && !cards) {
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate' }[def.type] ?? 'Comandi delle bocce'); setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate', flipper: 'Comandi del flipper' }[def.type] ?? 'Comandi delle bocce');
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true); setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = mg.status(c, def.names); const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
setValue($('bocce-power'), Math.round(c.power * 100)); setValue($('bocce-power'), Math.round(c.power * 100));
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`); setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech); for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
// Al flipper i due pulsanti valgono per tutta la partita, il cursore della molla solo prima del lancio.
if (mg.canFlip) for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'disabled', !mg.canFlip(c) || !!game.speech);
setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech); setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti). // Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata). // Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
@@ -1064,7 +1097,7 @@ function updateUi() {
const easy = def.type === 'gara' && gara.simple(c); const easy = def.type === 'gara' && gara.simple(c);
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy); setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy);
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate'); for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate');
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza '; const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : def.type === 'flipper' ? 'Molla ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio'); setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy); setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy);
@@ -1072,8 +1105,8 @@ function updateUi() {
c.sensLoaded = true; c.sensLoaded = true;
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ } try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
} }
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira'); setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : def.type === 'flipper' ? '← Flipper' : '← Mira');
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →'); setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : def.type === 'flipper' ? 'Flipper →' : 'Mira →');
setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy); setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
$('bocce-throw').classList.toggle('hold', easy); $('bocce-throw').classList.toggle('hold', easy);
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech); setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
@@ -1086,6 +1119,7 @@ function updateUi() {
setProp($('condor-help'), 'hidden', def.type !== 'condor'); setProp($('condor-help'), 'hidden', def.type !== 'condor');
setProp($('campana-help'), 'hidden', def.type !== 'campana'); setProp($('campana-help'), 'hidden', def.type !== 'campana');
setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate'); setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate');
setProp($('flipper-help'), 'hidden', def.type !== 'flipper');
setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('basket-help'), 'hidden', def.type !== 'basket');
setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
setProp($('gara-help'), 'hidden', def.type !== 'gara'); setProp($('gara-help'), 'hidden', def.type !== 'gara');
@@ -1117,7 +1151,7 @@ function updateUi() {
canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg); canvas.classList.toggle('bocce', !!mg);
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli). // Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards); $('stage').classList.toggle('briscola', !!cards);
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio. // La pagina che comincia a girare fa il suo fruscio.
@@ -1156,6 +1190,15 @@ function frame(now) {
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`; const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
if (game && (!still || still !== drawnStill)) { if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt); if (running) game.update(dt);
// Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art.
const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper';
const sceneScale = pinballHD ? 2 : 1;
if (canvas.width !== WIDTH * sceneScale) {
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
canvas.classList.toggle('pinball-hd', !!pinballHD);
if (!pinballHD) cabinetAway(canvas);
}
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
// Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto. // Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto.
if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; } if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; }
+3
View File
@@ -23,6 +23,9 @@ export const piloti = [
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' }, casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea). // Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' }, { id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
// Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore]
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea). // Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' }, { id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa] // La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
+30 -2
View File
@@ -360,6 +360,18 @@ export const story = {
draw: [], draw: [],
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }], quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
}, },
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex,
// fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie]
flipper: {
type: 'flipper',
picture: 'assets/closeups/river-flipper.png',
backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi)
names: { player: 'Andrea', cpu: 'il flipper' },
win: [{ say: 'Traguardo! Se c\'era la classifica, oggi ci mettevo le mie iniziali.' }, { set: 'flipper_vinto' }],
lose: [{ say: 'Tre palline, e tutte nella Sieve. Il flipper del River non perdona.' }],
draw: [],
quit: [{ say: 'Lascio la pallina lì. Tanto il River non chiude.' }],
},
// Le bruciate della Smarronata, 1993: la manovella del cilindro sul fuoco, coi volontari della Misericordia // Le bruciate della Smarronata, 1993: la manovella del cilindro sul fuoco, coi volontari della Misericordia
// (Andrea, 31 ottobre: «a manovella, a mano»). [DA ANDREA: le battute sono provvisorie] // (Andrea, 31 ottobre: «a manovella, a mano»). [DA ANDREA: le battute sono provvisorie]
bruciate: { bruciate: {
@@ -1664,10 +1676,13 @@ export const story = {
entry: [140, 340], entry: [140, 340],
hotspots: [ hotspots: [
{ {
id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up', // Un clic sul River entra e va al flipper (Andrea: «Usa» o «Apri» non si capiva).
id: 'river', name: 'il River: dentro c\'è il flipper', box: [60, 128, 190, 110], at: [160, 320], face: 'up', default: 'open',
actions: { actions: {
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }], look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.' }], // Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo.
open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima... il flipper.' }, { dialogue: 'flipper' }],
use: [{ say: 'Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.' }, { dialogue: 'flipper' }],
}, },
}, },
{ {
@@ -4440,6 +4455,19 @@ export const story = {
}, },
}, },
// Da solo al campo del Circolo: Andrea decide quanto farsi male. // Da solo al campo del Circolo: Andrea decide quanto farsi male.
// Il flipper del River: Andrea da solo davanti alla tavola, sceglie il livello. [DA ANDREA: battute provvisorie]
flipper: {
nodes: {
start: {
options: [
{ text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Sessantamila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Centoventimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Duecentomila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
{ text: 'Un\'altra volta.', next: 'end' },
],
},
},
},
basket: { basket: {
nodes: { nodes: {
start: { start: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.65'; export const VERSION = '0.1.67';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+6 -4
View File
@@ -24,6 +24,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.pinball-hd { image-rendering: auto; }
#scene.torch { touch-action: none; } #scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; } #scene.bocce { touch-action: none; }
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } #scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
@@ -62,16 +63,17 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
/* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */ /* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
#bocce-throw.hold { min-height: 72px; font-size: 14px; } #bocce-throw.hold { min-height: 72px; font-size: 14px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-left, #bocce-right { touch-action: none; }
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary { cursor: pointer; padding: 6px 0; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p { max-width: 76em; line-height: 1.7; } #bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary, #flipper-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p, #flipper-help p { max-width: 76em; line-height: 1.7; }
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #bocce-throw { flex-basis: 100%; }
#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { font-size: 9px; } #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { font-size: 9px; }
} }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); } #effects-volume { width: 130px; accent-color: var(--accent); }
+167
View File
@@ -0,0 +1,167 @@
// Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper con due dita sulla scena,
// coi pulsanti e con le frecce, la pausa che lascia tutto, il risultato. npm run browser:flipper — schermate in
// /tmp/regolo-flipper.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-flipper';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
settle(game); game.go('via_provinciale'); settle(game);
game.act('use', 'river'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
const readyAt = await page.evaluate(() => game.closeup.visual.time);
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
await page.screenshot({ path: `${out}/${name}-tavola.png` });
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
const cdp = mobile ? await page.context().newCDPSession(page) : null;
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
// La molla: tenendo premuto sulla scena, col mouse e col dito.
if (mobile) {
await touch('touchStart', [P(440, 320)]);
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await touch('touchEnd', []);
} else {
await page.mouse.move(...P(440, 320)); await page.mouse.down();
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await page.mouse.up();
}
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 });
// Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su.
if (mobile) {
await touch('touchStart', [P(250, 200)]);
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
await touch('touchStart', [P(250, 200), P(400, 200)]);
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0 && game.closeup.flippers.left.held.length > 0, null, { timeout: 3000 });
await page.screenshot({ path: `${out}/${name}-flipper.png` });
await touch('touchEnd', [P(400, 200)]);
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
// I pulsanti tenuti premuti.
const b = await page.locator('#bocce-left').boundingBox();
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]);
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
} else {
await page.mouse.move(...P(400, 200)); await page.mouse.down();
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
await page.mouse.down({ button: 'right' });
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 1, null, { timeout: 3000 });
await page.screenshot({ path: `${out}/${name}-flipper.png` });
await page.mouse.up(); await page.mouse.up({ button: 'right' });
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
await page.keyboard.down('ArrowLeft');
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
// La pausa lascia tutto: tornando, il flipper è giù anche se il tasto era premuto.
await page.click('#pause-button');
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
await page.keyboard.up('ArrowLeft');
await page.click('#resume');
await page.waitForFunction(() => document.querySelector('#pause').hidden);
}
// Il traguardo aggiunge una pallina e lascia continuare la partita.
const reward = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0;
const b = c.bumpers[0]; b.touching = false;
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null });
m.update(c, 0.05);
return { balls: c.balls, step: c.step, winner: c.result?.winner };
});
assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' });
await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta'));
await page.screenshot({ path: `${out}/${name}-premio.png` });
// Una sequenza reale di rampe, con il motore: primo lock, secondo lock, rampa centrale e tre palline.
await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js');
Object.assign(game.closeup, m.newFlipper(73, { level: 'bona' }));
window.pinballShot = id => {
const c = game.closeup, r = c.ramps.find(r => r.id === id);
if (m.waiting(c)) m.launch(c, 0.8);
Object.assign(c.ball, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null });
let entered = false;
for (let i = 0; i < 1000; i++) { m.update(c, m.STEP); if (c.ball?.ramp) entered = true; else if (entered) return; }
throw new Error(`rampa ${id} non completata`);
};
for (let i = 0; i < 3; i++) window.pinballShot('sinistra');
});
assert.equal(await page.evaluate(() => game.closeup.locks.length), 1);
await page.screenshot({ path: `${out}/${name}-lock.png` });
await page.evaluate(async () => {
for (let i = 0; i < 3; i++) window.pinballShot('destra');
const m = await import('/src/engine/flipper.js');
for (let i = 0; i < 4; i++) m.update(game.closeup, 0.2);
});
assert.equal(await page.evaluate(() => game.closeup.special), 'super');
await page.screenshot({ path: `${out}/${name}-super.png` });
const multi = await page.evaluate(async () => {
window.pinballShot('super');
const m = await import('/src/engine/flipper.js'); m.update(game.closeup, 0.25); m.update(game.closeup, 0.25);
// Ferma solo la simulazione per fotografare le tre palline, prima che una possa scendere.
window.pinballResume = game.update.bind(game); game.update = dt => { game.closeup.visual.time += dt; };
return { balls: m.activeBalls(game.closeup).length, locks: game.closeup.locks.length, reserve: game.closeup.balls, time: game.closeup.visual.time };
});
assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 });
await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time);
await page.screenshot({ path: `${out}/${name}-multiball.png` });
// L'arretrato grafico (vedi la memoria «effetti leggeri»): 150 fotogrammi disegnati di fila con tre palline e gli
// effetti vivi, poi una lettura dei pixel che aspetta la grafica. Il flipper di Codex ne lasciava 4,2 s; deve
// restare sotto il secondo e mezzo anche col rendering software di queste prove.
const backlog = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup;
const ctx = document.querySelector('#scene').getContext('2d');
ctx.getImageData(0, 0, 4, 4); // si aspetta prima l'arretrato di tutta la prova fin qui
for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); }
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); return Math.round(performance.now() - t);
});
console.log(`${name}: arretrato grafico dopo 150 fotogrammi ${backlog} ms`);
assert.ok(backlog < 1500, `arretrato grafico ${backlog} ms`);
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('), null, { timeout: 2000 }); // il mobile sullo sfondo CSS
await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; });
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
const finalScore = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
for (let i = 0; i < 12 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.8);
c.saveUntil = 0;
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, releaseAt: 0 }); m.update(c, 0.1);
}
return c.score;
});
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`));
await page.screenshot({ path: `${out}/${name}-fine.png` });
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
await page.waitForFunction(() => document.querySelector('#scene').width === 640 && !document.querySelector('#scene').style.backgroundImage);
await page.close();
}
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+471
View File
@@ -0,0 +1,471 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as m from '../src/engine/flipper.js';
import { drawFlipper } from '../src/engine/flipper-render.js';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { existsSync } from 'node:fs';
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); };
const put = (c, ball) => Object.assign(c.ball, ball);
// Osserva il percorso fino all'uscita, senza includere il tiro successivo sulla tavola.
function ride(c, initial) {
put(c, initial);
let frames = 0, top = Infinity;
for (let i = 0; i < 4 / m.STEP; i++) {
m.update(c, m.STEP);
if (c.ball?.ramp) { frames++; top = Math.min(top, c.ball.y); }
else if (frames) return { frames, top, exit: { ...c.ball } };
}
assert.fail('la pallina non ha completato o abbandonato la rampa entro quattro secondi');
}
// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.75);
const b = c.ball;
for (const side of ['left', 'right']) {
const [px, py] = m.PIVOT[side], dir = side === 'left' ? 1 : -1, rel = (b ? b.x - px : 0) * dir;
const near = b && !b.waiting && b.vy > 0 && b.y > py - 34 && b.y < py + 14 && rel > -6 && rel < 44;
if (near !== c.flippers[side].held.includes('ai')) m.hold(c, side, 'ai', near);
}
m.update(c, 1 / 60);
}
}
test('la tavola: tutto sta dentro il campo, le corsie coprono tutta la larghezza sotto l\'arco, i funghi sui dischi del disegno', () => {
const c = m.newFlipper(1);
for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`);
// I tre dischi dipinti da Codex (misurati sull'immagine): i funghi ci stanno sopra, a meno di 3 pixel.
for (const [b, [x, y]] of c.bumpers.map((b, i) => [b, [[281, 120], [360, 120], [320, 153]][i]])) assert.ok(Math.hypot(b.x - x, b.y - y) < 3);
assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI);
assert.equal(c.lanes[0].x0, m.FIELD.x0); assert.equal(c.lanes[3].x1, m.LANE.x0);
for (let i = 1; i < 4; i++) assert.equal(c.lanes[i].x0, c.lanes[i - 1].x1, 'corsie contigue');
assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS);
assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla');
});
test('il lancio: anche la molla più fiacca arriva in campo, quella forte fa il giro dell\'arco; la ricaduta si rilancia', () => {
for (const level of ['bona', 'male']) for (const power of [0, 0.5, 1]) {
const c = m.newFlipper(2, { level }); assert.ok(m.launch(c, power)); assert.ok(!m.waiting(c));
let top = 999, out = false;
for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) out = true; }
assert.ok(top < 50, `${level} forza ${power}: in cima (${top.toFixed(0)})`);
assert.ok(out, `${level} forza ${power}: entrata in campo`);
}
// Una pallina ricaduta nella corsia si assesta e si rilancia, senza perderla.
const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0.5); put(d, { x: 440, y: 200, vx: 0, vy: 50 }); run(d, 3);
assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa');
});
test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola e non dipende dal fotogramma', () => {
const c = m.newFlipper(4, { level: 'seria' });
for (let n = 3; n >= 1; n--) {
assert.equal(c.balls, n);
m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto
put(c, { x: 320, y: 320, vx: 0, vy: 200 }); run(c, 1);
}
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c));
assert.match(m.status(c), /Ne servivano 120000/);
const d = m.newFlipper(5, { level: 'bona' });
m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla');
assert.match(d.line ?? '', /SALVATA/);
m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
assert.equal(d.balls, 2, 'la seconda volta è persa davvero');
// Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex).
for (const dts of [[1 / 240, 60], [1 / 60, 15], [0.25, 1]]) {
const e = m.newFlipper(6, { level: 'seria' }); m.launch(e, 0.8); e.sim = 2.9; e.saveUntil = 3; put(e, { x: 320, y: 344, vx: 0, vy: 100 });
for (let i = 0; i < dts[1]; i++) m.update(e, dts[0]);
assert.equal(e.balls, 3, `salvata con passi da ${dts[0]}`);
}
});
test('il flipper alzato rimanda su la pallina, anche a tutta velocità (niente attraversamenti); abbassato la lascia scendere', () => {
const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9);
put(up, { x: 300, y: 300, vx: 0, vy: 150 }); m.hold(up, 'left', 'test', true);
let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); }
assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`);
assert.ok(up.events.some(e => e.kind === 'flipper-flip'));
// Il caso riprodotto da Codex: pallina a 980 px/s sulla punta del flipper che si alza.
const fast = m.newFlipper(6, { level: 'seria' }); m.launch(fast, 0.9);
put(fast, { x: 313.438, y: 323.71, vx: -469.837, vy: 860.031 }); m.hold(fast, 'left', 'test', true);
m.update(fast, m.STEP);
assert.ok(fast.ball.vy < 0 && fast.ball.y < 324, `respinta, non passata sotto (y ${fast.ball.y.toFixed(1)}, vy ${fast.ball.vy.toFixed(0)})`);
const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9);
put(down, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; run(down, 2);
assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve');
});
test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le quattro corsie insieme il Palio', () => {
const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9);
const b = c.bumpers[0];
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta');
assert.ok(c.events.some(e => e.kind === 'flipper-fungo'));
// La pallina appoggiata sul Regolo (Codex: 240.000 punti in un secondo): non conta.
c.score = 0; put(c, { x: c.regolo.x, y: c.regolo.y - c.regolo.r - m.R, vx: 0, vy: 0 });
for (let i = 0; i < 240; i++) m.update(c, m.STEP);
assert.equal(c.score, 0, 'il contatto che dura non fa punti');
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.equal(c.score, m.SCORE.regolo);
c.score = 0;
for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; put(c, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); }
assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank);
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => t.down), 'la fila resta giù un attimo');
put(c, { x: 260, y: 230, vx: 0, vy: 0 }); run(c, 1);
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'poi si rialza');
assert.ok([c.line, ...c.lines].some(l => /PIEVE/.test(l ?? '')), 'la scritta della Pieve (in coda dietro al Regolo)');
// Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva).
c.score = 0;
for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) {
put(c, { x, y: c.lanes[i].y - 3, vx: 0, vy: 150 }); run(c, 0.1);
assert.ok(c.lanes[i].lit || c.lanes.every(k => !k.lit), c.lanes[i].rione);
}
assert.ok(c.score >= 4 * m.SCORE.lane + m.SCORE.palio, `corsie e Palio (${c.score}; in più, al massimo, un fungo sfiorato)`);
assert.ok([c.line, ...c.lines].some(l => /PALIO/.test(l ?? ''))); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro');
// In salita non conta.
put(c, { x: 287, y: c.lanes[1].y + 6, vx: 0, vy: -150 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.ok(!c.lanes[1].lit);
});
test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => {
const c = m.newFlipper(8, { level: 'bona' });
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
m.cancel(c); assert.ok(!c.flippers.right.held.length, 'annullare tutto (pausa) lascia i flipper');
// I pulsanti e le frecce; il pulsante da tastiera dà un colpo breve che la fisica vede.
assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); assert.ok(m.stopTurn(c, 'button')); assert.ok(!c.flippers.left.held.length);
m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click'));
const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length);
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length);
assert.ok(!m.key(c, 'x', true));
assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale');
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
});
test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => {
const c = m.newFlipper(11, { level: 'bona' }); m.launch(c, 0.9);
c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.ok(c.result && c.result.winner === 'player' && c.step === 'play', 'traguardo fatto, si continua');
assert.match(m.status(c), /record/);
const g = new Game(story); g.go('via_provinciale'); settle(g);
g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g);
g.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto');
// La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore.
const h = m.newFlipper(12, { level: 'bona' }); m.launch(h, 0.9); m.hold(h, 'left', 't', true); run(h, 0.5);
put(h, { x: 296, y: 300, vx: 0, vy: 0 }); run(h, 4);
assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`);
});
test('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) {
const c = m.newFlipper(85, { level }); m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - missing;
const currentBall = c.ball, b = c.bumpers[0];
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`);
assert.equal(c.ball, currentBall, 'il tiro in corso prosegue');
assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player');
assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1');
assert.match(m.status(c), /pallina premio vinta/);
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi');
c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1);
assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio');
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1);
assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
}
});
test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => {
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola');
assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`);
assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0);
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati).
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 273, y: 240, vx: 0, vy: -470 }); run(f, 0.3);
assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1);
assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`);
// Il secondo ponte subito dopo: i PONTI, col bonus.
ride(c, { x: 367, y: 240, vx: 0, vy: -470, ramp: null });
assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`);
assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? '')));
assert.equal(c.lastRamp, null, 'la combo è spesa');
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti.
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
put(d, { x: 273, y: 240, vx: 0, vy: -280 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 226, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
// Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no.
const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0;
put(e, { x: 273, y: 226.1, vx: 500, vy: -30 }); run(e, 0.5);
assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina.
const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 });
run(g, 13); assert.equal(g.lastRamp, null, 'scaduta');
g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 342, vx: 0, vy: 300, waiting: false }); run(g, 1);
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
});
test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) {
const c = m.newFlipper(81, { level }); m.launch(c, 0.8);
ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 });
assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`);
assert.ok(c.ramps[index].lit > 0);
for (const offset of [-12, 12]) {
const d = m.newFlipper(82, { level }); m.launch(d, 0.8);
const r = d.ramps[index];
ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 });
assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`);
}
const e = m.newFlipper(83, { level }); m.launch(e, 0.8);
const r = e.ramps[index];
put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05);
assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata');
}
});
test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => {
for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) {
const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y: 250, vx: 0, vy: 100 });
let lower = null;
for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) {
m.update(c, m.STEP);
if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x };
}
assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`);
if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`);
else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); }
}
});
test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => {
const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9);
const kinds = () => c.fx.map(f => f.kind);
const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('ring') && kinds().includes('pop') && kinds().includes('spark'), `fungo: ${kinds()}`);
assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000');
c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`);
c.fx.length = 0; ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`);
assert.ok(c.trail.length > 5, 'la cometa');
const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball;
assert.equal(c.fx.length, 0, 'spariti');
// Disegnarli non cambia niente.
const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
put(c, { x: 273, y: 240, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before);
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
function rampShot(c, id) {
if (m.waiting(c)) m.launch(c, 0.8);
const r = c.ramps.find(r => r.id === id);
return ride(c, { x: r.path[0][0], y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null, waiting: false });
}
function doubleLock(c) { for (const id of ['sinistra', 'destra']) for (let n = 0; n < 3; n++) rampShot(c, id); }
test('due LOCK aprono la SUPER SPECIAL: jackpot, pallina premio e multiball reale a tre, in ogni ordine', () => {
for (const level of Object.keys(m.LEVELS)) for (const ids of [['sinistra', 'destra'], ['destra', 'sinistra']]) {
const c = m.newFlipper(21, { level }); m.launch(c, 0.9);
const superRamp = c.ramps.find(r => r.id === 'super');
assert.ok(!m.rampOpen(c, superRamp));
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 1);
rampShot(c, ids[0]); assert.equal(c.rampRun.count, 2);
rampShot(c, ids[0]);
assert.equal(c.locks.length, 1); assert.equal(c.locks[0].rampId, ids[0]);
assert.equal(c.special, ids[1]); assert.ok(m.waiting(c)); assert.equal(c.balls, 3);
const lockedBall = c.locks[0].ball;
rampShot(c, ids[0]); assert.equal(c.locks.length, 1, 'una rampa occupata non produce altri lock');
const score = c.score; rampShot(c, ids[1]);
assert.equal(c.score - score, m.SCORE.ramp + m.SCORE.jackpot, 'SPECIAL paga una volta e avvia il secondo lock');
assert.equal(c.locks[0].ball, lockedBall); assert.equal(c.rampRun.count, 1);
const afterSpecial = c.score; rampShot(c, ids[1]);
assert.equal(c.score - afterSpecial, m.SCORE.ramp, 'niente jackpot ripetuti durante la carica');
rampShot(c, ids[1]);
assert.equal(c.locks.length, 2); assert.equal(c.special, 'super'); assert.ok(m.rampOpen(c, superRamp));
assert.ok(m.waiting(c)); assert.equal(c.extraBalls.length, 0);
const before = c.score, reserve = c.balls, hadGoal = !!c.result;
rampShot(c, 'super');
assert.equal(c.score - before, m.SCORE.super);
assert.equal(c.balls, reserve + 1 + (hadGoal ? 0 : 1), 'premio SUPER, più il traguardo se appena superato');
assert.equal(c.locks.length, 0); assert.equal(c.special, null);
assert.equal(m.activeBalls(c).length, 3); assert.ok(m.multiball(c));
assert.equal(new Set(m.activeBalls(c)).size, 3, 'tre palline distinte');
assert.ok(c.events.some(e => e.kind === 'flipper-super'));
const winScore = c.score, lives = c.balls;
rampShot(c, ids[0]);
assert.ok(c.score - winScore >= m.SCORE.ramp + m.SCORE.jackpot, 'in multiball le rampe pagano jackpot');
// Metti tutte le palline sotto il campo nello stesso passo: la vita si scala una volta soltanto.
for (const ball of m.activeBalls(c)) Object.assign(ball, { x: 320, y: 348, vx: 0, vy: 300, ramp: null, waiting: false, releaseAt: 0 });
c.saveUntil = 0; m.update(c, m.STEP);
assert.equal(c.balls, lives - 1); assert.equal(c.extraBalls.length, 0); assert.ok(m.waiting(c));
}
});
test('la finestra LOCK scade in simulazione, cambiare rampa interrompe la serie e la SUPER chiusa non cattura', () => {
const c = m.newFlipper(22); m.launch(c, 0.8);
put(c, { x: 320, y: 233, vx: 0, vy: -400 }); run(c, 0.02);
assert.equal(c.ball.ramp, null, 'la rampa centrale chiusa non esiste nella fisica');
rampShot(c, 'sinistra'); rampShot(c, 'sinistra');
c.ball.waiting = true; run(c, m.RAMP.lockWindow + 0.1);
assert.equal(c.rampRun.count, 0); rampShot(c, 'sinistra'); assert.equal(c.rampRun.count, 1);
rampShot(c, 'destra'); assert.equal(c.rampRun.id, 'destra'); assert.equal(c.rampRun.count, 1);
c.saveUntil = 0; put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.rampRun.count, 0);
});
test('la SUPER accompagna alla paletta sinistra e il multiball libera le palline sfalsate', () => {
for (const level of Object.keys(m.LEVELS)) {
const c = m.newFlipper(77, { level }); doubleLock(c); rampShot(c, 'super');
const main = c.ball, delayed = c.extraBalls.find(b => b.releaseAt > c.sim);
assert.equal(main.x, 229); assert.equal(main.y, 220); assert.equal(main.vx, 0); assert.equal(main.vy, 160);
for (const b of c.extraBalls) { assert.ok([229, 411].includes(b.x)); assert.equal(b.vx, 0, 'i lock non sparano verso le buche laterali'); }
assert.ok(delayed, 'una serratura aspetta prima di liberare la terza pallina');
const position = [delayed.x, delayed.y]; run(c, 0.2);
assert.deepEqual([delayed.x, delayed.y], position, 'durante il ritardo resta nel lock');
run(c, 0.3); assert.notDeepEqual([delayed.x, delayed.y], position, 'poi viene liberata');
// Isola la pallina della SUPER per verificare che un colpo normale possa recuperarla.
c.extraBalls = []; c.ball = main;
let recovered = false;
for (let i = 0; i < 480; i++) {
if (main.x > 284 && main.y > 300) m.hold(c, 'left', 'test', true);
m.update(c, m.STEP);
if (main.vy < -250) { recovered = true; break; }
}
assert.ok(recovered, `${level}: ripresa dalla paletta, senza cadere direttamente nella buca centrale`);
}
});
test('perdere una sostitutiva restituisce un lock alla volta senza consumare vite; la SUPER si richiude', () => {
const c = m.newFlipper(23, { level: 'male' }); doubleLock(c);
const lives = c.balls;
for (const remaining of [1, 0]) {
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 348, vx: 0, vy: 300, ramp: null }); m.update(c, m.STEP);
assert.equal(c.locks.length, remaining); assert.equal(c.balls, lives); assert.ok(m.waiting(c));
assert.notEqual(c.special, 'super'); assert.equal(c.superOpenedAt, null);
}
assert.equal(c.special, null); assert.equal(c.specialPaid, false);
});
test('il multiball ha contatti indipendenti, urti fra palline e palette che avanzano una sola volta', () => {
const c = m.newFlipper(24); doubleLock(c); rampShot(c, 'super');
const single = m.newFlipper(24); m.hold(single, 'left', 'test', true); m.hold(c, 'left', 'test', true);
m.update(single, m.STEP); m.update(c, m.STEP);
assert.ok(Math.abs(c.flippers.left.angle - single.flippers.left.angle) < 1e-10);
const [a, b, spare] = m.activeBalls(c), bumper = c.bumpers[0];
spare.waiting = true;
Object.assign(a, { x: bumper.x, y: bumper.y + bumper.r + 4, vx: 0, vy: -200, ramp: null, contacts: [] });
Object.assign(b, { x: bumper.x, y: bumper.y - bumper.r - 4, vx: 0, vy: 200, ramp: null, contacts: [] });
const score = c.score; m.update(c, m.STEP);
assert.equal(c.score - score, m.SCORE.bumper * 2, 'due urti sullo stesso fungo contano separatamente');
Object.assign(a, { x: 310, y: 275, vx: 100, vy: 0 });
Object.assign(b, { x: 318, y: 275, vx: -100, vy: 0 });
m.update(c, m.STEP); assert.ok(a.vx < 0 && b.vx > 0, 'le palline si respingono');
const lives = c.balls;
Object.assign(a, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
assert.equal(c.balls, lives); assert.equal(m.activeBalls(c).length, 2);
});
test('i bersagli alzano il jackpot fino a x3 e la valvola impedisce il rientro sulla molla', () => {
const c = m.newFlipper(25); m.launch(c, 0.8);
for (const bank of ['pieve', 'torre']) for (let i = 0; i < 3; i++) {
const t = c.targets.find(t => t.bank === bank && t.i === i), dir = bank === 'pieve' ? -1 : 1;
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, ramp: null }); run(c, 0.02);
}
assert.equal(c.jackpotX, 3); doubleLock(c);
const before = c.score; rampShot(c, 'super'); assert.equal(c.score - before, m.SCORE.super * 3);
for (const [x, y] of [[434, 52], [438, 52], [441, 56]]) {
const d = m.newFlipper(26); m.launch(d, 0.8);
put(d, { x, y, vx: 0, vy: 240, inPlay: true });
let enteredShooter = false;
for (let i = 0; i < 240; i++) { m.update(d, m.STEP); if (d.ball.x > m.LANE.x0 + m.R && d.ball.y > 76) enteredShooter = true; }
assert.ok(!enteredShooter, `valvola chiusa per il ritorno a x=${x}`);
}
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
let won = 0;
for (let seed = 1; seed <= 8; seed++) {
const c = m.newFlipper(seed * 7919, { level: 'bona' });
autoplay(c);
assert.equal(c.step, 'result', `seme ${seed}: finita`);
if (c.result.winner === 'player') won++;
}
assert.ok(won >= 5, `vinte ${won} su 8`);
});
test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giusto e le cifre corrono', async () => {
const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
const c = m.newFlipper(11, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); };
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
for (const r of c.ramps.filter(r => r.id !== 'super')) {
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === (r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO') && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
}
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'), 'quanto manca al traguardo, nella riga in basso');
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass ), 'il fondale sul fianco');
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'PUNTEGGIO'), 'il nuovo display del punteggio');
c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
// Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).
const l = m.newFlipper(12, { level: 'bona' }); l.step = 'result'; l.ball = null; l.result = { winner: 'cpu', score: 0, goal: 60000 }; calls.length = 0;
drawFlipper(ctx, l, story.closeups.flipper, bg);
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FINE PARTITA'));
// Le cifre che corrono: in mezzo secondo da dove erano a dove devono arrivare, a scatti di dieci.
const { shownScore } = await import('../src/engine/flipper-render.js');
const d = { from: 1000, to: 51000, t0: 10 };
assert.equal(shownScore(d, 10), 1000); assert.equal(shownScore(d, 10.25) % 10, 0); assert.ok(shownScore(d, 10.25) > 20000 && shownScore(d, 10.25) < 30000); assert.equal(shownScore(d, 10.5), 51000);
});
test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
const g = new Game(story); g.go('via_provinciale'); settle(g);
assert.equal(g.hotspot('river').default, 'open');
const [bx, by, bw, bh] = g.hotspot('river').box;
assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'river', 'il River si clicca');
g.act('use', 'river'); settle(g);
assert.ok(g.options().some(o => o.text === 'Una partita seria.'));
g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g);
assert.equal(g.closeup?.id, 'flipper'); assert.equal(g.closeup.level, 'seria');
g.closeupClose();
assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say);
settle(g);
g.act('open', 'river'); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.level, 'male');
});
test('dietro la Pieve non c\'è più il canale che portava dritto all\'uscita sinistra (Andrea: «a sinistra esce troppo facile»)', () => {
const c = m.newFlipper(31); m.launch(c, 0.8); c.saveUntil = 0;
// Una pallina che scivola lungo la parete sinistra, sopra la Pieve: viene rimandata in campo, non nel canale.
put(c, { x: 205, y: 120, vx: 0, vy: 150, ramp: null });
let inChannel = false;
for (let i = 0; i < 120; i++) { m.update(c, 1 / 60); if (c.ball.x < 210 && c.ball.y > 146 && c.ball.y < 198) inChannel = true; }
assert.ok(!inChannel, 'mai fra la parete e il dorso dei bersagli');
assert.ok(c.ball.x > 210 || c.ball.y < 146, `in campo (${Math.round(c.ball.x)}, ${Math.round(c.ball.y)})`);
});