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
This commit is contained in:
2026-10-31 23:47:51 +01:00
co-authored by Claude Fable 5.1
parent bbce40b014
commit b660bbeb96
10 changed files with 694 additions and 529 deletions
+27 -45
View File
@@ -50,51 +50,33 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16);
vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`.
[DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?).
- **Il flipper del River (31 ottobre)**: «Usa» o «Apri» il River in Via Provinciale, si sceglie il livello e si gioca
(`src/engine/flipper.js`, disegno `flipper-render.js`, tavola di Codex `assets/closeups/river-flipper.png` con San
Piero dipinto: Fortezza, Pieve, torre, la Sieve che scende fra i flipper, il ponte, il Regolo con la corona, le
bandiere dei rioni). Fisica a passo fisso: molla, arco, quattro corsie dei rioni (tutte e quattro = PALIO), tre
funghi, bersagli a caduta della Pieve e della torre, il Regolo, sponde elastiche, due flipper, uscite laterali,
salvataggio della pallina una volta sola. Comandi: molla tenendo premuto (o cursore + Lancia, o Spazio), flipper
con le due metà della scena, i pulsanti «← Flipper»/«Flipper →» tenuti premuti o le frecce. Tre palline; traguardo
6000/12000/25000. Test `flipper.test.mjs` (col giocatore automatico), prova `npm run browser:flipper`.
Rivisto con la revisione critica di Codex (31 ottobre, chiesta da Andrea): punti solo a un urto nuovo (la pallina
appoggiata sul Regolo faceva 240.000 punti al secondo), sottopassi contro gli attraversamenti del flipper, niente
cancelletto-ripiano e molla che arriva sempre in campo, salvataggio col tempo della simulazione (e in vista sul
quadro), corsie che contano attraversando la riga, traguardo acquisito appena raggiunto (poi si continua per il
record), annullare non lancia, due dita sulla scena e tasto destro del mouse, pulsanti dei flipper sempre attivi.
[DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). Poi (Andrea): i due ponti (rampe `ramps`: imbocco in salita, percorso sopra la tavola, uscita dall'altra
parte; tutti e due di fila i PONTI), punti ×10 (traguardi 60.000/120.000/200.000), il tabellone a punti luminosi
(cifre che corrono, scritte che lampeggiano, lampadine che girano; carattere 5×7 in flipper-render.js) e un clic sul
River che entra e va al flipper. Seconda revisione di Codex (31 ottobre): i flipper rispondono anche con la pallina
sul ponte, l'imbocco conta solo la velocità lungo il ponte, salita e discesa con la stessa energia (meno l'attrito),
l'uscita dritta nella corsia di ritorno, la combo che scade e si azzera con la pallina persa, le scritte in coda col
traguardo davanti, il tabellone che dice l'esito giusto, le cifre che corrono in tempo fisso, i due tasti del mouse
dalla maschera `buttons` (il destro è sempre il flipper di destra), i glifi 3 e 5 rifatti.
Gli effetti (Andrea: «più spettacolare»): anelli e scintille sui funghi e sui bersagli, coriandoli nei colori dei
rioni ai colpi grossi, luci che corrono lungo i ponti, i punti che volano via dal punto colpito, il lampo colorato
sulla tavola e la scossa del mobile, la scia della pallina; sul tabellone la regia (la corona al traguardo, le
bandiere al Palio, i due archi ai ponti) e le fanfare. Ponti, sponde e griglia dei punti spenti in cache su tele a
parte (l'arretrato grafico con swiftshader da 8 s a meno di un secondo).
Il tabellone è un vero «backglass» (Andrea: «col Trebbio, la Fortezza e effetti speciali»): fondale di Codex
(`assets/closeups/river-flipper-tabellone.png`, 200×360: il Castello del Trebbio, la Fortezza sotto i fasci di luce,
il Regolo coi fulmini, i fuochi nei colori dei rioni), le cifre nella finestra nera, le lampadine sui bordi, i cinque
dischi in basso (palline e rioni), i ponti ai lati della finestra, il fondale che lampeggia ai colpi grossi.
Revisione estetica (Andrea, 31 ottobre): rampe a canalina con parapetti cromati e inserti ambra/azzurri,
imbocchi svasati allineati alla fisica, frecce animate e targhette RAMPA; funghi con ghiere metalliche,
sponde smaltate, palette avorio con gomma rossa e Regolo verde con corona. Il traguardo e i punti mancanti
restano visibili sotto il display, con una barra di avanzamento. Prove desktop e touch in `browser:flipper`.
Accessibilità dei tiri (Andrea): imbocchi delle rampe spostati verso il centro e allargati da 22 a 36 pixel
(spazio utile alla pallina da 13 a 27; ridotti dai primi 42 perché troppo larghi), pendenza e attrito ridotti per salire con colpi medi. Uscite laterali
ristrette da 22/14 a 14/12 pixel, con più spazio nelle corsie di ritorno alle palette. Test sui tiri obliqui,
decentrati e fuori bocca a tutti i livelli, e sulle discese laterali recuperate e ancora perdenti.
Al traguardo si vince una pallina extra, una sola volta per partita: il tiro prosegue e il premio si aggiunge
alla riserva, con avviso sul tabellone. Test a tutti i livelli anche raggiungendo il traguardo con l'ultima pallina.
Il LOCK (idea di Andrea, fatto da Codex e completato, 31 ottobre): lo stesso ponte tre volte di fila trattiene la
pallina nella serratura in fondo al ponte e ne dà una nuova sulla molla; l'altro ponte si accende SPECIAL; fatto,
JACKPOT (100.000), la pallina si libera e si gioca in MULTIBALL (due palline, ognuna coi suoi contatti e la sua scia;
persa una, resta l'altra senza scalare). Persa la pallina nuova prima dello SPECIAL, torna quella trattenuta.
[DA ANDREA] un record per livello che resti salvato (suggerito da Codex).
- **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.
- 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. 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`, intervallo fra frame.
[DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello.
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
- **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
+20 -17
View File
@@ -304,23 +304,26 @@
sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.</p>
</details>
<details id="flipper-help" hidden><summary>Come si gioca</summary>
<p>Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e
scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (30.000 punti). I tre funghi
sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (10.000). I due ponti: cerca le targhette RAMPA e le frecce illuminate, ambra a sinistra e
azzurre a destra, verso il centro sopra le sponde elastiche. Gli ingressi larghi accettano anche tiri un po'
decentrati: imboccali dal basso con un colpo di media forza, la pallina ci sale sopra e
scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un
bonus di 50.000 in più. Lo stesso ponte tre volte di fila: LOCK, la pallina resta trattenuta in cima e te ne arriva
una nuova sulla molla, mentre l'altro ponte si accende SPECIAL; fallo e incassi il JACKPOT (100.000), la pallina
trattenuta si libera e giochi in MULTIBALL con due palline. Il tasto destro del mouse è sempre il flipper di destra.</p>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra.
Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000,
a fassi male 200.000. Al traguardo vinci una pallina extra, una sola volta per partita, e continui per il record.</p>
<p>Il Regolo, il flipper del River. Tieni premuto per caricare la molla e lascia per lanciare, oppure usa
Spazio o il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
Le frecce sinistra e destra, i pulsanti Flipper o le due metà della scena comandano le palette;
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
passaggio paga un JACKPOT da 100.000, e tre passaggi ravvicinati fanno il secondo LOCK.</p>
<p>Con entrambi i LOCK si alza la rampa viola centrale, SUPER SPECIAL. Completala: 250.000 punti,
una pallina extra in riserva e MULTIBALL a tre palline! La SUPER restituisce la pallina nella corsia
laterale verso la paletta sinistra; quelle trattenute vengono liberate dai due lati, sfalsate.
In multiball le rampe pagano altri jackpot e perdi una vita solo quando perdi l'ultima pallina in gioco.
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
<p>Completa i tre bersagli di un lato per alzare il moltiplicatore dei jackpot, fino a ×3 (si azzera alla
perdita di una vita). Alterna le due rampe entro 12 secondi per il bonus PONTI da 50.000; accendi le
quattro corsie dei rioni per il PALIO da 30.000. I respingenti valgono 1.000, i bersagli laterali 5.000
l'uno e 25.000 la serie, il Regolo coronato 10.000.</p>
<p>Parti con tre palline. Appena lanciata, la pallina è protetta una volta: 8 secondi alla bona, 4 seria,
1 a fassi male. Il traguardo è 60.000, 120.000 o 200.000 punti: raggiungerlo assegna una pallina premio
una sola volta per partita. Poi puoi continuare per il record, i LOCK e la SUPER SPECIAL.</p>
</details>
<details id="cerbottana-help" hidden><summary>Come si gioca</summary>
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
+366 -384
View File
@@ -1,417 +1,399 @@
// Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti
// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due
// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball } from './flipper.js';
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
import { R, SCORE, RAMP, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
import { text, panel } from './bocce-render.js';
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => {
ctx.lineCap = 'round';
ctx.strokeStyle = dark; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.strokeStyle = light; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
};
function disc(ctx, x, y, r, fill, rim) {
ctx.fillStyle = rim; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - Math.min(2, r * 0.45), 0, Math.PI * 2); ctx.fill();
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 bumper(ctx, b) {
const r = b.r, lit = b.hit > 0;
disc(ctx, b.x + 1, b.y + 3, r + 1, '#172127', '#131a1e');
const metal = ctx.createLinearGradient(b.x - r, b.y - r, b.x + r, b.y + r);
metal.addColorStop(0, '#fff3cc'); metal.addColorStop(0.35, '#bda16d');
metal.addColorStop(0.55, '#fff1cc'); metal.addColorStop(1, '#665238');
disc(ctx, b.x, b.y, r, metal, '#111d25');
disc(ctx, b.x, b.y, r - 2.5, lit ? '#fff3bd' : '#e3b658', '#654223');
const cap = ctx.createRadialGradient(b.x - 3, b.y - 4, 1, b.x, b.y, r - 4);
cap.addColorStop(0, lit ? '#fffef0' : '#e87c61'); cap.addColorStop(0.45, lit ? '#ffe39a' : '#ba4037'); cap.addColorStop(1, '#612b2b');
disc(ctx, b.x, b.y, r - 4, cap, '#602c27');
label(ctx, `${SCORE.bumper / 1000}K`, b.x, b.y + 0.5, 5, '#fff2d5');
ctx.strokeStyle = '#ffe6ae'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(b.x, b.y, r - 1, Math.PI * 1.08, Math.PI * 1.65); ctx.stroke();
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();
}
// Incisioni con un carattere compatto, leggibile alla risoluzione della scena.
function label(ctx, line, x, y, size = 6, color = '#f8e6bc', align = 'center') {
ctx.font = `bold ${size}px monospace`; ctx.textAlign = align; ctx.textBaseline = 'middle';
ctx.fillStyle = color; ctx.fillText(line, x, y);
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 polygon(ctx, points, fill, stroke) {
ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.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 screw(ctx, x, y) {
disc(ctx, x, y, 1.7, '#d1d9d5', '#25333a');
ctx.strokeStyle = '#53636b'; ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(x - 0.7, y + 0.7); ctx.lineTo(x + 0.7, y - 0.7); ctx.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;
}
// Il Regolo: un bersaglio tondo d'oro con la corona e il bordo scuro, che non si confonde col drago dipinto.
function regolo(ctx, o) {
const r = o.r + (o.hit > 0 ? 2 : 0);
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.arc(o.x + 1, o.y + 2, r + 1, 0, Math.PI * 2); ctx.fill();
disc(ctx, o.x, o.y, r + 1, o.hit > 0 ? '#fff6c8' : '#f0c040', '#3a2a08');
const gem = ctx.createRadialGradient(o.x - 2, o.y - 2, 0, o.x, o.y, r - 1);
gem.addColorStop(0, '#bceca0'); gem.addColorStop(0.35, '#3f9c77'); gem.addColorStop(1, '#153f42');
disc(ctx, o.x, o.y, r - 1.5, gem, '#65522c');
polygon(ctx, [[o.x - 5, o.y - r - 1], [o.x - 6, o.y - r - 6], [o.x - 2, o.y - r - 4], [o.x, o.y - r - 8], [o.x + 2, o.y - r - 4], [o.x + 6, o.y - r - 6], [o.x + 5, o.y - r - 1]], '#edc877', '#65522c');
label(ctx, `${SCORE.regolo / 1000}K`, o.x, o.y + 0.5, 5, '#fff4c8');
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']]);
}
// I ponti: canaline metalliche, ambra a sinistra e azzurre a destra, sollevate secondo la quota della pallina.
// Accesi, brillano; l'altro, in attesa della combo, invita.
// Si dipingono una volta per stato su una tela a parte e poi si copiano (i tratti larghi e trasparenti a ogni
// fotogramma lasciavano alla grafica un arretrato di secondi, vedi effetti-leggeri).
const RAMP_LOOK = { sinistra: ['#ffd57a', '#95713d'], destra: ['#9ce9f2', '#497d92'] };
function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
function paintRamp(ctx, r, state) {
const [light, dark] = RAMP_LOOK[r.id], active = state !== 'base';
// Il fondo è una canalina scura; i due parapetti sono fili cromati separati.
// Si segue esattamente il percorso della fisica, anche ai cambi di direzione.
const points = r.cum.map(s => rampPoint(r, s));
const path = () => { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); };
const stroke = (width, color) => { ctx.lineWidth = width; ctx.strokeStyle = color; path(); ctx.stroke(); };
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.save(); ctx.translate(2, 4); stroke(17, 'rgba(3,12,19,0.48)'); ctx.restore();
stroke(16, '#12212a'); stroke(14, active ? light : dark);
stroke(12, '#e0e4d6'); stroke(10, '#32454e'); stroke(7, '#192e39');
stroke(2, r.id === 'sinistra' ? '#4e4940' : '#284957');
// Traverse distanziate e piccoli inserti luminosi sui parapetti.
for (let s = 18; s < r.len - 8; s += 26) {
const p = rampPoint(r, s);
ctx.strokeStyle = '#62727a'; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.moveTo(p.x - p.dy * 4, p.y + p.dx * 4); ctx.lineTo(p.x + p.dy * 4, p.y - p.dx * 4); ctx.stroke();
for (const side of [-1, 1]) {
ctx.fillStyle = active ? '#fff6d8' : light;
ctx.beginPath(); ctx.arc(p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side, 1, 0, Math.PI * 2); ctx.fill();
}
}
// L'apertura coincide con il segmento d'ingresso: una pedana svasata e una soglia a terra.
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
const deck = ctx.createLinearGradient(0, y - 30, 0, y);
deck.addColorStop(0, '#1b303b'); deck.addColorStop(0.75, '#455860'); deck.addColorStop(1, '#b8c3bd');
polygon(ctx, [[cx - 5, y - 30], [cx + 5, y - 30], [x1, y], [x0, y]], deck, '#18232b');
for (const side of [-1, 1]) {
rail(ctx, [cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y], '#18232b', light);
screw(ctx, cx + side * (x1 - x0) / 2, y);
}
ctx.fillStyle = '#edf0d9'; ctx.fillRect(x0 + 1, y - 1, x1 - x0 - 2, 1);
label(ctx, `${SCORE.ramp / 1000}K`, cx, y - 18, 5, light);
}
function rampEntrance(ctx, r, time, who) {
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
const color = RAMP_LOOK[r.id][0], invite = who && who !== r.id;
// Un inserto scuro isola le frecce dai dettagli del ponte dipinto sotto.
polygon(ctx, [[x - 6, y + 1], [x + 6, y + 1], [x + 7, y + 22], [x - 7, y + 22]], '#10222e', color);
// Una freccia piena, leggibile anche sul telefono; pulsa più rapida per la combo.
const on = Math.sin(time * (invite ? 9 : 4)) > 0;
polygon(ctx, [[x, y + 4], [x + 5, y + 11], [x + 2, y + 11], [x + 2, y + 20], [x - 2, y + 20], [x - 2, y + 11], [x - 5, y + 11]], on ? '#fff6d6' : color);
ctx.fillStyle = '#112530'; ctx.fillRect(x - 14, y + 22, 28, 10);
ctx.strokeStyle = color; ctx.lineWidth = 0.7; ctx.strokeRect(x - 14, y + 22, 28, 10);
label(ctx, 'RAMPA', x, y + 27, 6, color);
}
const rampCache = new Map();
function ramp(ctx, r, time, who) {
const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : (who === r.id + '!' || (who && who !== r.id)) && Math.sin(time * 6) > 0 ? 'invite' : 'base';
if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; }
const key = `${r.id}:${state}`;
let layer = rampCache.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 });
paintRamp(layer.getContext('2d'), r, state);
rampCache.set(key, layer);
}
ctx.drawImage(layer, 0, 0);
}
let railLayer = null;
function rails(ctx, c) {
const paint = g => {
// Plastiche smaltate sopra gli elastici, con bordi e viti di fissaggio.
for (const side of [-1, 1]) {
const x = side < 0 ? 236 : 404, tip = x - side * 26;
polygon(g, [[x, 246], [x, 284], [tip, 290]], '#152f3c', '#c1a273');
polygon(g, [[x - side * 4, 260], [x - side * 4, 280], [tip + side * 7, 284]], '#294c5b', '#597c83');
screw(g, x, 250); screw(g, x, 281); screw(g, tip + side * 4, 286);
}
for (const w of c.walls) rail(g, w.a, w.b, w.sling ? '#582723' : '#18262d', w.sling ? '#f09a70' : '#d2d9cb');
};
if (typeof document === 'undefined') { paint(ctx); return; }
if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); }
ctx.drawImage(railLayer, 0, 0);
}
function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
ctx.save(); ctx.translate(px, py); ctx.rotate(Math.atan2(ty - py, tx - px));
const len = Math.hypot(tx - px, ty - py);
const shape = () => {
ctx.beginPath(); ctx.moveTo(0, -3); ctx.lineTo(len, -2.5);
ctx.quadraticCurveTo(len + 3, 0, len, 2.5); ctx.lineTo(0, 3);
ctx.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); ctx.closePath();
};
ctx.save(); ctx.translate(1, 3); shape(); ctx.fillStyle = '#142029'; ctx.fill(); ctx.restore();
shape(); ctx.fillStyle = f.held.length ? '#fff6da' : '#f0e4c6'; ctx.fill();
ctx.strokeStyle = '#bb503c'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.strokeStyle = '#fffdf0'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(3, -1.7); ctx.lineTo(len - 2, -1.2); ctx.stroke();
screw(ctx, 0, 0); ctx.restore();
}
function plunger(ctx, c) {
const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0;
ctx.fillStyle = '#4a4440'; ctx.fillRect(Math.round(x) - 3, top + pull, 6, 336 - top - pull + 10);
ctx.fillStyle = '#9a9088';
for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1);
ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4);
}
// La serratura: la pallina trattenuta in fondo al ponte, dietro due sbarre, con la luce che pulsa.
function lock(ctx, b, time) {
ball(ctx, b);
ctx.strokeStyle = Math.sin(time * 6) > 0 ? '#ffd45a' : '#8a7a5a'; ctx.lineWidth = 1.5;
for (const dx of [-3, 3]) { ctx.beginPath(); ctx.moveTo(b.x + dx, b.y - 9); ctx.lineTo(b.x + dx, b.y + 7); ctx.stroke(); }
ctx.beginPath(); ctx.moveTo(b.x - 7, b.y - 9); ctx.lineTo(b.x + 7, b.y - 9); ctx.stroke();
label(ctx, 'LOCK', b.x, b.y - 15, 5, '#ffe08c');
}
function ball(ctx, b) {
if (!b) return;
// Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana.
const z = b.ramp?.z ?? 0, r = R + 1.5 * z, y = b.y - 2 - 4 * z, sh = 3 + 4 * z;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(b.x - 1.5, y - 1.5, 0.5, b.x, y, r);
g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, y, r, 0, Math.PI * 2); ctx.fill();
}
// --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere.
const GLYPHS = {
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '10000', '11110', '00001', '10001', '01110'],
'6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
};
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. La griglia
// dei punti spenti è sempre uguale: si dipinge una volta per misura su una tela a parte e si copia (centinaia di
// rettangoli trasparenti a ogni fotogramma lasciavano arretrato alla grafica, vedi effetti-leggeri).
const gridCache = new Map();
function offGrid(ctx, n, x0, y, pitch, off) {
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;
const key = `${n}:${pitch}:${off}`, size = Math.max(1, pitch - 1);
let layer = gridCache.get(key);
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 cached(g, key, paint, bounds = [0, 0, 640, 360]) {
if (typeof document === 'undefined') { paint(g); return; }
let layer = layers.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: n * 6 * pitch, height: 7 * pitch });
const g = layer.getContext('2d'); g.fillStyle = off;
for (let i = 0; i < n; i++) for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) g.fillRect((i * 6 + col) * pitch, row * pitch, size, size);
gridCache.set(key, 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);
}
ctx.drawImage(layer, x0, Math.round(y));
g.drawImage(layer, ...bounds);
}
function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') {
const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch;
const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1);
offGrid(ctx, chars.length, x0, y, pitch, off);
ctx.fillStyle = on;
chars.forEach((ch, i) => {
const g = GLYPHS[ch] ?? GLYPHS[' '];
for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) if (g[row][col] === '1') ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size);
});
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');
}
// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola
// presentazione, fuori dalla partita (così disegnare non cambia niente).
const shown = new WeakMap();
// Il tabellone è il fondale luminoso di Codex (il «backglass»: il Trebbio, la Fortezza, il Regolo, i fuochi) sul
// fianco sinistro, 200×360: le cifre nella finestra nera, le lampadine sui bordi che girano, i cinque dischi in basso
// per le palline e i rioni, i ponti ai lati della finestra. Senza il fondale (tests), un pannello scuro.
const GLASS = { window: [20, 78, 160, 46], sockets: [[23, 329], [67, 329], [100, 329], [133, 329], [169, 329]], lampX: [8, 192], lampY0: 16, lampStep: 20 };
function board(ctx, c, time, glass) {
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, flash: 0, lineId: 0 }; shown.set(c, d); }
// Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita
// subito il totale.
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), hot = score !== c.score;
if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; }
const party = c.line && time - d.flash < 2, [wx, wy, ww, wh] = GLASS.window;
if (glass) ctx.drawImage(glass, 0, 0, 200, 360);
else { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 200, 360); ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(wx - 3, wy - 3, ww + 6, wh + 6); }
// Alla festa il fondale lampeggia (un lampo breve, solo sul fianco: una passata piccola).
if (party && Math.sin(time * 12) > 0.6) { ctx.globalAlpha = 0.25; ctx.fillStyle = '#fff6c8'; ctx.fillRect(0, 0, 200, 360); ctx.globalAlpha = 1; }
// Le lampadine sui bordi: tranquille di solito, impazzite per due secondi a ogni scritta.
const beat = time * (party ? 14 : 2);
let n = 0;
const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 2, y - 2, 4, 4); };
for (let y = GLASS.lampY0; y < 300; y += GLASS.lampStep) lamp(GLASS.lampX[0], y);
for (let y = 300 - ((300 - GLASS.lampY0) % GLASS.lampStep); y >= GLASS.lampY0; y -= GLASS.lampStep) lamp(GLASS.lampX[1], y);
// La finestra: le cifre grosse color ambra col bagliore caldo dietro (sei cifre larghe, da un milione in su più
// fitte) e sotto la scritta: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo.
ctx.save(); ctx.beginPath(); ctx.rect(wx, wy, ww, wh); ctx.clip();
ctx.fillStyle = '#000'; ctx.fillRect(wx, wy, ww, wh);
const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4;
ctx.fillStyle = hot ? 'rgba(255,120,30,0.25)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(wx + 2, wy + 1, ww - 4, 29);
dots(ctx, digits, wx + ww / 2, wy + 1 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center');
const won = c.result?.winner === 'player';
const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`);
const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3;
if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh);
ctx.restore();
// Il traguardo resta leggibile anche mentre il display celebra un colpo: una barra sottile sotto la finestra e,
// nella riga in basso, quanto manca (il pannello che c'era prima copriva il Trebbio dipinto).
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);
ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3);
ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3);
// I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia).
c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a'));
// I cinque dischi in basso: le palline che restano, e i quattro rioni accesi.
const [[bx, by], ...rioni] = GLASS.sockets;
disc(ctx, bx, by, 11, '#2a2420', '#8a7a5a'); text(ctx, String(c.step === 'result' ? 0 : c.balls), bx, by + 1, 9, '#ffd45a', 'center');
rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); });
// Sotto, in piccolo: il salvataggio o la combo.
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);
const note = multiball(c) ? 'MULTIBALL!' : c.locked ? 'LOCK: FAI IL PONTE SPECIAL' : c.rampRun?.count ? `PONTE ${c.rampRun.count}/3 PER IL LOCK` : save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO: +1 PALLINA';
if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center');
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'); }
}
// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al
// Palio le quattro bandiere dei rioni che sfilano, ai ponti due archi di punti che si uniscono.
const CROWN = ['10001000100010001', '11011101110111011', '11111111111111111', '01111111111111110', '01111111111111110'];
function regia(ctx, line, age, wx, wy, ww, wh) {
const cx = wx + ww / 2;
if (line === 'TRAGUARDO!') {
const cols = Math.min(17, Math.floor(age * 12));
for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, wy + 2 + row * 3, 3, 2); }
} else if (line === 'PALIO!') {
['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = wx + 2 + ((age * 140 + i * 40) % (ww - 4)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), wy + 1, 8, 3); ctx.fillRect(Math.round(x), wy + wh - 4, 8, 3); });
} else if (line === 'I PONTI!') {
const k = Math.min(1, age / 0.8);
for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); }
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');
}
// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo.
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;
}
// --- Gli effetti (c.fx): piccoli e tanti, niente filtri. Ognuno vive dal suo `t` per un tempo suo.
const CONFETTI = ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0', '#ffd45a'];
function drawFx(ctx, c, time) {
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 (f.kind === 'ring' && age < 0.4) {
ctx.globalAlpha = 1 - age / 0.4; ctx.strokeStyle = '#fff4c8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(f.x, f.y, 10 + age * 110, 0, Math.PI * 2); ctx.stroke();
} else if (f.kind === 'spark' && age < 0.6) {
ctx.globalAlpha = 1 - age / 0.6;
for (let i = 0; i < 10; i++) {
const a = hash(i * 7 + f.t * 100) * Math.PI * 2, v = 80 + hash(i * 13 + 1) * 140;
ctx.fillStyle = i % 2 ? '#fff4c8' : '#ffb13a';
ctx.fillRect(Math.round(f.x + Math.cos(a) * v * age), Math.round(f.y + Math.sin(a) * v * age + 220 * age * age), 2, 2);
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 === 'burst' && age < 1.6) {
const n = f.big ? 48 : 24;
ctx.globalAlpha = Math.min(1, (1.6 - age) / 0.5);
for (let i = 0; i < n; i++) {
const a = hash(i * 7 + 3) * Math.PI * 2, v = (f.big ? 120 : 80) + hash(i * 13 + 5) * 160, spin = hash(i * 3 + 9) * 10;
const x = f.x + Math.cos(a) * v * age * 0.8, y = f.y + Math.sin(a) * v * age * 0.6 + 160 * age * age;
if (x < FIELD.x0 || x > FIELD.x1 || y > FIELD.y1) continue;
ctx.fillStyle = CONFETTI[i % CONFETTI.length];
const w = 3 + 2 * Math.abs(Math.sin(time * spin + i)), h = 2;
ctx.fillRect(Math.round(x - w / 2), Math.round(y), Math.round(w), h);
} 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 === 'chase' && age < 1.2) {
const r = c.ramps.find(k => k.id === f.ramp);
if (!r) continue;
ctx.globalAlpha = Math.min(1, (1.2 - age) / 0.3);
for (let k = 0; k < 8; k++) {
const s = (age * 520 + k * 28) % (r.len + 1), p = rampPoint(r, s);
ctx.fillStyle = k % 2 ? '#ffd45a' : '#fff6c8'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2); ctx.fill();
} 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');
}
} else if (f.kind === 'pop' && age < 1) {
ctx.globalAlpha = 1 - age;
text(ctx, f.text, f.x, f.y - age * 28, age < 0.15 ? 8 : 6, '#fff4c8', 'center');
} else if (f.kind === 'wash' && age < 0.3) {
// Un lampo colorato sulla tavola sola, per un terzo di secondo: una passata piccola, non a tutto schermo.
ctx.globalAlpha = 0.35 * (1 - age / 0.3); ctx.fillStyle = `rgb(${f.color})`;
ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0);
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();
}
}
ctx.globalAlpha = 1;
export function drawFlipper(g, c, def, bg, glass) {
const time = c.visual.time;
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'round'; g.lineJoin = 'round';
const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0);
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, `cabinet:${!!bg}:${!!glass}`, layer => cabinet(layer, c, bg, glass));
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');
}
// La scossa del mobile ai colpi grossi: tutta la scena trema per un terzo di secondo.
function shake(c, time) {
let k = 0;
for (const f of c.fx) if (f.kind === 'shake') k = Math.max(k, 1 - (time - f.t) / 0.35);
return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0];
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');
}
// La cometa: le ultime posizioni della pallina, sempre più tenui.
function trail(ctx, b) {
const n = b.trail.length;
b.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); });
ctx.globalAlpha = 1;
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(); });
}
function result(ctx, c) {
panel(ctx, 14, 236, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 100, 252, 8, '#efd49e', 'center');
text(ctx, `${c.result.score} su ${c.result.goal}`, 100, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
// Ogni rampa copre gli oggetti sotto; la sua pallina si disegna sul proprio piano.
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();
}
export function drawFlipper(ctx, c, def, bg, glass) {
const time = c.visual.time, [sx, sy] = shake(c, time);
ctx.save();
if (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); }
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); }
// La stampa resta visibile, ma le parti giocabili emergono dal paesaggio.
ctx.fillStyle = 'rgba(8,25,34,0.22)'; ctx.fillRect(201, 73, 230, 261);
// Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
for (const l of c.lanes) {
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#273a3e', l.lit ? '#fff4c8' : '#b5a278');
disc(ctx, l.x, l.y, 3.5, l.lit ? '#fff2c6' : COLORS[l.rione], '#182c32');
}
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. Non cambiano mai: dipinte
// una volta su una tela a parte.
rails(ctx, c);
// I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra.
for (const t of c.targets) {
const [x, y0] = t.a, y1 = t.b[1];
if (t.down) { ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(x - 2, y0, 4, y1 - y0); continue; }
ctx.fillStyle = '#2a2622'; ctx.fillRect(x - 3, y0 - 1, 6, y1 - y0 + 2);
ctx.fillStyle = t.bank === 'pieve' ? '#f2eee2' : '#e8b640'; ctx.fillRect(x - 2, y0, 4, y1 - y0);
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(x - 2, y1 - 3, 4, 3);
ctx.fillStyle = '#fff8d7'; ctx.fillRect(x - 2, y0, 4, 1);
ctx.fillStyle = '#3a5864'; ctx.fillRect(x - 1, y0 + 3, 2, 4);
}
for (const b of c.bumpers) bumper(ctx, b);
regolo(ctx, c.regolo);
for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id);
for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c);
// Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina
// trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti.
const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id;
for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); }
for (const b of balls) if (b.waiting) ball(ctx, b);
for (const r of c.ramps) ramp(ctx, r, time, special);
if (c.locked) lock(ctx, c.locked.ball, time);
for (const b of balls) if (b.ramp) { trail(ctx, b); ball(ctx, b); }
drawFx(ctx, c, time);
if (c.special) { const r = c.ramps.find(k => k.id === c.special); if (Math.sin(time * 8) > 0) label(ctx, 'SPECIAL', (r.mouth[0][0] + r.mouth[1][0]) / 2, r.mouth[0][1] - 38, 6, '#fff6d6'); }
// Da fermo, l'invito: la molla da caricare.
if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center');
board(ctx, c, time, glass);
if (c.step === 'result') result(ctx, c);
ctx.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();
}
+89 -38
View File
@@ -7,15 +7,15 @@
// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte.
// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita
// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che
// contano quando si attraversano, traguardo acquisito appena raggiunto.
// contano quando si attraversano, traguardo acquisito appena raggiunto. Valvola di lancio a senso unico.
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
export const PIVOT = { left: [280, 314], right: [360, 314] };
// 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 };
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 };
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
@@ -37,9 +37,11 @@ export function layout() {
const walls = [];
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 }));
walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336]));
// La corsia della molla, col fondo. Niente cancelletto: una pallina che ricasca nella corsia si rilancia e basta
// (il cancelletto a senso unico faceva da ripiano ai lanci a metà).
// La corsia della molla, col fondo. Un lancio che non è ancora entrato in campo si può ritentare;
// dopo l'ingresso la valvola inclinata impedisce il ritorno, senza creare un ripiano orizzontale.
walls.push(seg([432, 70], [432, 336]), seg([432, 326], [448, 326], { e: 0.2 }));
// Valvola inclinata: lascia uscire il lancio e devia a sinistra ogni rientro dal campo.
walls.push(seg([432, 70], [448, 60], { gate: true, e: 0.7 }));
// Le corsie dei rioni, in cima, fra le bandiere: tre guide.
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
// I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
@@ -67,6 +69,7 @@ export function layout() {
// 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) {
@@ -91,15 +94,17 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
ball: null, extraBalls: [], locked: null, special: null, rampRun: { id: null, count: 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, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, inPlay: false, releaseAt: 0, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
export const multiball = c => c.extraBalls.length > 0;
export const rampOpen = (c, r) => r.id !== 'super' || c.special === 'super';
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
@@ -117,7 +122,7 @@ function say(c, line, front = false) {
}
// 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 = 2;
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.
@@ -175,7 +180,7 @@ export function update(c, 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.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
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 ?? [];
}
@@ -193,10 +198,11 @@ 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);
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)));
@@ -206,6 +212,7 @@ function step(c) {
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);
@@ -216,7 +223,7 @@ function step(c) {
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
}
for (const ball of balls) {
if (ball.waiting) continue;
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);
@@ -261,6 +268,7 @@ function crossLanes(c, from, ball) {
// 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;
@@ -288,7 +296,8 @@ function rideRamp(c, ball) {
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);
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 (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;
@@ -300,29 +309,62 @@ function rideRamp(c, ball) {
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; }
}
// La sequenza considera i passaggi completi: bersagli e rimbalzi non la interrompono, l'altra rampa sì.
// 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 (c.locked) {
if (c.special !== r.id) { say(c, 'TENTA SPECIAL'); return true; }
const released = c.locked.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;
c.extraBalls.push(released); c.locked = null; c.special = null; c.lastRamp = null;
c.rampRun = { id: null, count: 0 }; c.saveUntil = 0; c.saved = true;
add(c, SCORE.jackpot, [324, 190]); say(c, 'JACKPOT!', c.line !== 'TRAGUARDO!'); say(c, 'MULTIBALL!');
c.events.push({ kind: 'flipper-jackpot', power: 1 }); fx(c, 'burst', 324, 130, { big: true }); fx(c, 'shake', 0, 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)) return false;
c.rampRun = { id: r.id, count: c.rampRun.id === r.id ? c.rampRun.count + 1 : 1 };
if (c.rampRun.count < 3) return false;
// La pallina resta visibile nella serratura all'uscita della rampa. Il nuovo lancio è gratuito.
c.locked = { rampId: r.id, ball }; ball.vx = 0; ball.vy = 0; ball.trail = [];
c.special = c.ramps.find(other => other.id !== r.id).id; c.lastRamp = null;
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); say(c, 'LOCK!', c.line !== 'TRAGUARDO!'); say(c, 'SPECIAL!');
c.events.push({ kind: 'flipper-lock', power: 1 }); fx(c, 'ring', ball.x, ball.y);
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) {
@@ -330,17 +372,20 @@ function drain(c, ball) {
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 };
say(c, 'UNA IN GIOCO'); return;
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 };
if (c.locked) {
// Perduta la sostitutiva: si recupera quella trattenuta, senza scalare una vita o lasciare lock sospesi.
c.locked = null; c.special = null; c.lastRamp = null; c.saved = false; c.saveUntil = 0;
say(c, 'LOCK LIBERO', 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.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 };
@@ -366,6 +411,12 @@ function hitSeg(ball, w, e) {
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.
@@ -383,7 +434,7 @@ function collideTarget(c, ball, t) {
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)) { add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
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) {
@@ -465,7 +516,7 @@ export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia
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.locked ? `LOCK: pallina trattenuta. Rampa ${c.special === 'sinistra' ? 'sinistra' : 'destra'} SPECIAL: jackpot ${SCORE.jackpot} e multiball.` : multiball(c) ? 'MULTIBALL: due palline in gioco!' : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3 per il LOCK.` : '';
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}`;
+6
View File
@@ -166,6 +166,12 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
}
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
// Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto.
if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') {
drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
return;
}
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false;
+2
View File
@@ -173,6 +173,8 @@ export class Soundscape {
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); }
+8
View File
@@ -1189,6 +1189,14 @@ function frame(now) {
const still = game && !running && `${game.state.scene}|${hasBackground(game.backdrop())}`;
if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt);
// Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art.
const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper';
const sceneScale = pinballHD ? 2 : 1;
if (canvas.width !== WIDTH * sceneScale) {
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
canvas.classList.toggle('pinball-hd', !!pinballHD);
}
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
// Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto.
if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; }
+1
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; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.pinball-hd { image-rendering: auto; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
+42 -2
View File
@@ -29,6 +29,7 @@ try {
// 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');
@@ -96,13 +97,51 @@ try {
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` });
const frameMs = await page.evaluate(() => new Promise(resolve => {
const stamps = []; const tick = t => { stamps.push(t); if (stamps.length === 20) resolve((t - stamps[0]) / 19); else requestAnimationFrame(tick); }; requestAnimationFrame(tick);
}));
console.log(`${name}: intervallo frame medio ${frameMs.toFixed(1)} ms`);
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 < 4 && c.step === 'play'; i++) {
for (let i = 0; i < 12 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.8);
c.saveUntil = 0;
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); m.update(c, 0.1);
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;
});
@@ -111,6 +150,7 @@ try {
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);
await page.close();
}
assert.deepEqual(errors, []);
+129 -39
View File
@@ -277,41 +277,131 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
test('il LOCK: lo stesso ponte tre volte di fila trattiene la pallina e ne dà una nuova; l\'altro ponte SPECIAL vale il jackpot e il multiball', () => {
const c = m.newFlipper(21, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
const left = c.ramps[0], right = c.ramps[1];
// Un tiro nel ponte: si aspetta che la pallina esca in fondo, prima che vada altrove (così il conto non si azzera).
const shoot = r => { const [[x0], [x1]] = r.mouth; put(c, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(c, 1 / 60); if (c.ball?.ramp) was = true; else if (was) break; } };
shoot(left); assert.equal(c.rampRun.count, 1); assert.ok(!c.locked);
shoot(left); assert.equal(c.rampRun.count, 2);
shoot(left);
assert.ok(c.locked && c.locked.rampId === 'sinistra', 'la pallina è trattenuta');
assert.equal(c.special, 'destra', 'l\'altro ponte è SPECIAL');
assert.ok(m.waiting(c) && c.ball !== c.locked.ball, 'una pallina nuova sulla molla');
assert.ok([c.line, ...c.lines].some(l => /LOCK/.test(l ?? '')) && [c.line, ...c.lines].some(l => /SPECIAL/.test(l ?? '')));
assert.ok(c.events.some(e => e.kind === 'flipper-lock'));
assert.equal(c.balls, 3, 'il lock non costa una pallina');
// Il ponte sbagliato non libera niente.
m.launch(c, 0.9); c.events.length = 0; shoot(left);
assert.ok(c.locked, 'ancora trattenuta');
// Il ponte SPECIAL: jackpot, la pallina liberata, due in gioco.
const before = c.score; shoot(right);
assert.ok(c.score - before >= m.SCORE.jackpot + m.SCORE.ramp, `jackpot (${c.score - before})`);
assert.ok(!c.locked && !c.special, 'liberata');
assert.ok(m.multiball(c) && m.activeBalls(c).length === 2, 'multiball');
assert.ok(c.events.some(e => e.kind === 'flipper-jackpot'));
// In multiball perdere una pallina non costa: resta l'altra; persa anche quella, si scala.
const riserva = c.balls; // (il jackpot ha passato il traguardo: una pallina premio in più)
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.ok(!m.multiball(c) && c.balls === riserva && m.activeBalls(c).length === 1, 'una in gioco, nessuna persa');
c.saveUntil = 0; Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.equal(c.balls, riserva - 1, 'ora sì');
// Persa la pallina nuova prima dello SPECIAL: quella trattenuta torna, senza scalare.
const d = m.newFlipper(22, { level: 'bona' }); m.launch(d, 0.9);
const go = r => { const [[x0], [x1]] = r.mouth; put(d, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(d, 1 / 60); if (d.ball?.ramp) was = true; else if (was) break; } };
go(d.ramps[0]); go(d.ramps[0]); go(d.ramps[0]); assert.ok(d.locked);
m.launch(d, 0.9); d.saveUntil = 0; Object.assign(d.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(d, 0.5);
assert.ok(!d.locked && d.balls === 3 && m.waiting(d), 'lock libero, pallina di nuovo sulla molla');
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', () => {
@@ -332,15 +422,15 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu
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) {
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 === 'RAMPA' && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
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 && w === 200 && h === 360), 'il fondale sul fianco');
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.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi');
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).