diff --git a/assets/closeups/river-flipper.png b/assets/closeups/river-flipper.png new file mode 100644 index 0000000..480498f Binary files /dev/null and b/assets/closeups/river-flipper.png differ diff --git a/assets/voices/andrea-01a2eb256d.mp3 b/assets/voices/andrea-01a2eb256d.mp3 new file mode 100644 index 0000000..a5b50e7 Binary files /dev/null and b/assets/voices/andrea-01a2eb256d.mp3 differ diff --git a/assets/voices/andrea-1b517ab1ef.mp3 b/assets/voices/andrea-1b517ab1ef.mp3 new file mode 100644 index 0000000..bb1e4cd Binary files /dev/null and b/assets/voices/andrea-1b517ab1ef.mp3 differ diff --git a/assets/voices/andrea-2669d0fea1.mp3 b/assets/voices/andrea-2669d0fea1.mp3 deleted file mode 100644 index ea4ea06..0000000 Binary files a/assets/voices/andrea-2669d0fea1.mp3 and /dev/null differ diff --git a/assets/voices/andrea-3479aca550.mp3 b/assets/voices/andrea-3479aca550.mp3 new file mode 100644 index 0000000..c7dcab3 Binary files /dev/null and b/assets/voices/andrea-3479aca550.mp3 differ diff --git a/assets/voices/andrea-5c306f1589.mp3 b/assets/voices/andrea-5c306f1589.mp3 new file mode 100644 index 0000000..3d629e6 Binary files /dev/null and b/assets/voices/andrea-5c306f1589.mp3 differ diff --git a/assets/voices/andrea-61a0d27bbb.mp3 b/assets/voices/andrea-61a0d27bbb.mp3 new file mode 100644 index 0000000..cf5e5fb Binary files /dev/null and b/assets/voices/andrea-61a0d27bbb.mp3 differ diff --git a/assets/voices/andrea-6fd4fcd345.mp3 b/assets/voices/andrea-6fd4fcd345.mp3 new file mode 100644 index 0000000..9a6120c Binary files /dev/null and b/assets/voices/andrea-6fd4fcd345.mp3 differ diff --git a/assets/voices/andrea-737d8c7f64.mp3 b/assets/voices/andrea-737d8c7f64.mp3 new file mode 100644 index 0000000..376cf27 Binary files /dev/null and b/assets/voices/andrea-737d8c7f64.mp3 differ diff --git a/assets/voices/andrea-ad00ae1145.mp3 b/assets/voices/andrea-ad00ae1145.mp3 new file mode 100644 index 0000000..d292a71 Binary files /dev/null and b/assets/voices/andrea-ad00ae1145.mp3 differ diff --git a/assets/voices/andrea-b6d9563c93.mp3 b/assets/voices/andrea-b6d9563c93.mp3 new file mode 100644 index 0000000..38b6f8c Binary files /dev/null and b/assets/voices/andrea-b6d9563c93.mp3 differ diff --git a/assets/voices/andrea-dcbb849b12.mp3 b/assets/voices/andrea-dcbb849b12.mp3 new file mode 100644 index 0000000..ee671de Binary files /dev/null and b/assets/voices/andrea-dcbb849b12.mp3 differ diff --git a/assets/voices/andrea-e455fd8483.mp3 b/assets/voices/andrea-e455fd8483.mp3 new file mode 100644 index 0000000..959d1a8 Binary files /dev/null and b/assets/voices/andrea-e455fd8483.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 51efef1..75854e6 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -691,9 +691,13 @@ "file": "andrea-b830f3f8ca.mp3", "sec": 6.87 }, - "andrea|Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.": { - "file": "andrea-2669d0fea1.mp3", - "sec": 5.23 + "andrea|Un ghiacciolo ci starebbe. Ma prima... il flipper.": { + "file": "andrea-737d8c7f64.mp3", + "sec": 4.56 + }, + "andrea|Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.": { + "file": "andrea-61a0d27bbb.mp3", + "sec": 6.67 }, "andrea|La farmacia, con la croce verde. Mia nonna ci veniva per le pasticche e per le chiacchiere. Più per le chiacchiere.": { "file": "andrea-1447e3515d.mp3", @@ -2967,6 +2971,30 @@ "file": "arcu-26a5fb0d0c.mp3", "sec": 3.17 }, + "andrea|Una partita alla bona, tanto per sentire la molla.": { + "file": "andrea-ad00ae1145.mp3", + "sec": 3.38 + }, + "andrea|Una partita seria.": { + "file": "andrea-3479aca550.mp3", + "sec": 1.16 + }, + "andrea|Un'altra volta.": { + "file": "andrea-1b517ab1ef.mp3", + "sec": 0.9 + }, + "andrea|Seimila punti e sono contento.": { + "file": "andrea-b6d9563c93.mp3", + "sec": 2.02 + }, + "andrea|Dodicimila. Come quando ci lasciavo le cinquecento lire.": { + "file": "andrea-01a2eb256d.mp3", + "sec": 4.12 + }, + "andrea|Venticinquemila. Il record del River, o quasi.": { + "file": "andrea-e455fd8483.mp3", + "sec": 4.2 + }, "andrea|Due tiri alla bona, tanto per scaldarmi.": { "file": "andrea-d564365fc2.mp3", "sec": 2.53 @@ -3683,6 +3711,18 @@ "file": "andrea-356a1afe8d.mp3", "sec": 5.91 }, + "andrea|Traguardo! Se c'era la classifica, oggi ci mettevo le mie iniziali.": { + "file": "andrea-6fd4fcd345.mp3", + "sec": 4.64 + }, + "andrea|Tre palline, e tutte nella Sieve. Il flipper del River non perdona.": { + "file": "andrea-5c306f1589.mp3", + "sec": 5.57 + }, + "andrea|Lascio la pallina lì. Tanto il River non chiude.": { + "file": "andrea-dcbb849b12.mp3", + "sec": 4.02 + }, "andrea|Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.": { "file": "andrea-145f20bff9.mp3", "sec": 5.11 diff --git a/docs/IDEE.md b/docs/IDEE.md index c58c91e..e955fea 100644 --- a/docs/IDEE.md +++ b/docs/IDEE.md @@ -151,7 +151,7 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone Al bar e al circolo: - *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già. - - *Flipper del River*: più lavoro, ma la fisica delle biglie c'è già. + - *Flipper del River*: fatto il 31 ottobre (vedi STATO.md). - *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice. - *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS. Sport: diff --git a/docs/STATO.md b/docs/STATO.md index 5344db4..755a2a2 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -50,6 +50,15 @@ 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`. + [DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). - **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). diff --git a/index.html b/index.html index af6bfaa..8dfe6b1 100644 --- a/index.html +++ b/index.html @@ -303,6 +303,18 @@ quelle di sopra restano crude; troppo forte volano fuori, nel fuoco. COTTURA: quando è nel verde, Rovescia nel sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.
+Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto + per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e + scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (3000 punti). I tre funghi + sono i bastioni della Fortezza (100 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a + destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona è il Regolo + (1000).
+I flipper: tieni premuto sulla metà sinistra o destra della scena, 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 3, a fassi male mai). Si vince arrivando al traguardo: alla bona 6000 punti, seria 12000, + a fassi male 25000.
+La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria diff --git a/package.json b/package.json index 4ae2933..f312371 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "browser:condor": "node tests/condor.browser.mjs", "browser:campana": "node tests/campana.browser.mjs", "browser:bruciate": "node tests/bruciate.browser.mjs", + "browser:flipper": "node tests/flipper.browser.mjs", "browser:ingorgo": "node tests/ingorgo.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs", diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js new file mode 100644 index 0000000..377bbd9 --- /dev/null +++ b/src/engine/flipper-render.js @@ -0,0 +1,101 @@ +// 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, corsie dei rioni con le luci, +// funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper, molla e pallina. A sinistra, sul fianco nero del +// mobile, il quadro dei punti. Niente filtri né passate a tutto schermo (vedi risveglio.js). +import { FIELD, LANE, R, FLIP, PIVOT, SCORE, waiting, canThrow } from './flipper.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 = 4; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); + ctx.strokeStyle = light; ctx.lineWidth = 1.5; 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 - 2, 0, Math.PI * 2); ctx.fill(); +} +function bumper(ctx, b, time) { + const k = b.hit > 0 ? 1 + b.hit * 1.2 : 1, r = b.r * k; + ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, r, 0, Math.PI * 2); ctx.fill(); + disc(ctx, b.x, b.y, r, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16'); + disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e'); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.6), 3, 2); + void time; +} +function flipper(ctx, c, side) { + const f = c.flippers[side], [px, py] = PIVOT[side], tx = px + Math.cos(f.angle) * FLIP.len, ty = py + Math.sin(f.angle) * FLIP.len; + ctx.lineCap = 'round'; + ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = FLIP.r * 2 + 2; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(tx + 1, ty + 3); ctx.stroke(); + ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = FLIP.r * 2 + 2; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke(); + ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = FLIP.r * 2 - 1; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke(); + ctx.strokeStyle = '#fff1b8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, py - 1.5); ctx.lineTo(tx, ty - 1.5); ctx.stroke(); + disc(ctx, px, py, 4, '#8a7a6a', '#2a2622'); +} +function plunger(ctx, c) { + const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0; + ctx.fillStyle = '#4a4440'; ctx.fillRect(Math.round(x) - 3, top + pull, 6, 336 - top - pull + 10); + ctx.fillStyle = '#9a9088'; + for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1); + ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4); +} +function ball(ctx, b) { + if (!b) return; + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, R, 0, Math.PI * 2); ctx.fill(); + const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, R); + g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068'); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, R, 0, Math.PI * 2); ctx.fill(); +} +// Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, l'ultimo avviso. +function board(ctx, c, def) { + panel(ctx, 22, 60, 160, 150); + text(ctx, 'FLIPPER DEL RIVER', 102, 76, 7, '#efd49e', 'center'); + text(ctx, String(c.step === 'result' ? c.result.score : c.score).padStart(6, '0'), 102, 100, 14, '#fff4c8', 'center'); + text(ctx, 'PUNTI', 102, 116, 5, '#a9a08a', 'center'); + text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 34, 136, 6, '#d7ccb4'); + text(ctx, `TRAGUARDO ${c.skill.goal}`, 34, 150, 6, '#d7ccb4'); + text(ctx, 'RIONI', 34, 168, 6, '#d7ccb4'); + c.lanes.forEach((l, i) => disc(ctx, 80 + i * 20, 168, 6, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650')); + text(ctx, (def.names?.player ?? 'Andrea').toUpperCase(), 34, 190, 6, '#efd49e'); + if (c.line) text(ctx, c.line.toUpperCase(), 102, 203, 5, '#ffe08c', 'center'); +} +function result(ctx, c) { + panel(ctx, 20, 230, 164, 56); + text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 246, 7, '#efd49e', 'center'); + text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 266, 7, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); +} + +export function drawFlipper(ctx, c, def, bg) { + const time = c.visual.time; + ctx.save(); + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); + else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); } + // Le luci delle corsie dei rioni, sotto le bandiere. + for (const l of c.lanes) disc(ctx, l.x, l.y + 18, 4, l.lit ? COLORS[l.rione] : 'rgba(40,36,30,0.6)', l.lit ? '#fff4c8' : 'rgba(0,0,0,0.4)'); + // Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. + for (const w of c.walls) { + if (w.gate) { ctx.strokeStyle = '#8a8078'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(...w.a); ctx.lineTo(...w.b); ctx.stroke(); ctx.setLineDash([]); continue; } + if (w.sling) { rail(ctx, w.a, w.b, '#5a1d16', '#e8584a'); continue; } + rail(ctx, w.a, w.b); + } + // I bersagli a caduta: in piedi colorati, 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); + } + for (const b of c.bumpers) bumper(ctx, b, time); + // Il Regolo: il bersaglio verde con la corona, che brilla quando è colpito. + disc(ctx, c.regolo.x, c.regolo.y, c.regolo.r + (c.regolo.hit > 0 ? 2 : 0), c.regolo.hit > 0 ? '#c9ff9a' : '#3fa34d', '#1e4a24'); + ctx.fillStyle = '#ffd45a'; ctx.fillRect(c.regolo.x - 3, c.regolo.y - c.regolo.r - 4, 6, 3); + for (const side of ['left', 'right']) flipper(ctx, c, side); + plunger(ctx, c); + ball(ctx, c.ball); + // Da fermo, l'invito: la molla da caricare. + if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center'); + board(ctx, c, def); + if (c.step === 'result') result(ctx, c); + ctx.restore(); +} +export { SCORE }; diff --git a/src/engine/flipper.js b/src/engine/flipper.js new file mode 100644 index 0000000..f75915d --- /dev/null +++ b/src/engine/flipper.js @@ -0,0 +1,231 @@ +// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»). Visto dall'alto, la tavola +// in verticale al centro del primo piano 640×360; la pallina parte dalla corsia di destra con la molla, fa il giro +// dell'arco in cima, passa in una delle quattro corsie dei rioni, rimbalza sui tre funghi (i bastioni della Fortezza), +// abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo in mezzo, e giù dalle sponde +// elastiche ai due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al +// traguardo di punti del livello. Unità: i pixel del primo piano. La fisica va a passo fisso (come il biliardo). +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, LAUNCH = [420, 900], CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 }; +export const PIVOT = { left: [280, 314], right: [360, 314] }; +export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 }; +// I livelli: la gravità, quanti secondi dura il salvataggio della pallina appena lanciata, la spinta dei funghi, il traguardo. +export const LEVELS = { + bona: { g: 620, save: 8, kick: 300, goal: 6000 }, + seria: { g: 780, save: 3, kick: 340, goal: 12000 }, + male: { g: 880, save: 0, kick: 380, goal: 25000 }, +}; +export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio']; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } +const seg = (a, b, extra = {}) => ({ a, b, e: 0.55, ...extra }); + +// La tavola: sponde, corsie, sponde elastiche, funghi, bersagli. Le coordinate seguono il disegno di Codex +// (assets/closeups/river-flipper.png): i tre dischi dei funghi, le quattro bandiere dei rioni, la corsia della molla. +export function layout() { + const arc = [[200, 72], [203, 50], [212, 32], [228, 18], [252, 10], [288, 6], [324, 5], [360, 6], [396, 10], [420, 18], [436, 32], [445, 50], [448, 72]]; + const walls = []; + for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 })); + walls.push(seg([200, 72], [200, 336]), seg([448, 72], [448, 336])); + // La corsia della molla, e in cima il cancelletto che non fa tornare indietro la pallina. + walls.push(seg([432, 70], [432, 336]), seg([432, 70], [448, 70], { gate: true }), seg([432, 326], [448, 326], { e: 0.2 })); + // Le corsie dei rioni, in cima, fra le bandiere: tre guide. + for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 })); + // I divisori delle uscite laterali e le guide che portano ai flipper. + // (La guida passa sopra il perno e continua un po' lungo il flipper: finendo sul perno la pallina ci restava incastrata.) + walls.push(seg([222, 240], [222, 290]), seg([222, 290], [292, 314], { e: 0.3 })); + walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 })); + // Le sponde elastiche sopra i flipper: il lato lungo spinge. + walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true })); + walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true })); + // I bersagli a caduta: la Pieve a sinistra, la torre a destra, con la sponda dietro. + walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198])); + const targets = []; + for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false }); + const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0 })); + // Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro. + const regolo = { x: 404, y: 62, r: 6, hit: 0 }; + const lanes = [257, 297, 340, 381].map((x, i) => ({ x, y: 88, rione: RIONI[i], lit: false })); + return { walls, targets, bumpers, regolo, lanes }; +} + +export function newFlipper(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + const c = { step: 'play', t: 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: [] }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [] } }, + ball: null, saveUntil: 0, still: 0, visual: { time: 0 }, ...layout() }; + newBall(c); + return c; +} +function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true }; c.still = 0; c.anchor = null; } +export const waiting = c => c.step === 'play' && !!c.ball?.waiting; +export const canThrow = c => waiting(c) && !c.holding; +export const canPress = canThrow; +export const canAdjust = c => c.step !== 'result'; +export const aimFree = () => true; +export const powerAt = time => clamp(time / CHARGE, 0, 1); +function say(c, line) { c.line = line; c.lineT = 2; } +function add(c, points) { c.score += points; } + +// Il lancio: la molla spinge la pallina su per la corsia. +export function launch(c, power) { + if (!waiting(c)) return false; + c.ball.waiting = false; c.ball.vy = -(LAUNCH[0] + (LAUNCH[1] - LAUNCH[0]) * clamp(power, 0, 1)); c.ball.vx = 0; + // Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa. + c.saveUntil = c.saved ? 0 : c.t + c.skill.save; c.holding = false; c.holdTime = 0; + c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * power }); + return true; +} +// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno. +export function hold(c, side, who, down) { + const f = c.flippers[side]; if (!f) return false; + const was = f.held.length > 0; + f.held = f.held.filter(h => h !== who); if (down) f.held.push(who); + if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 }); + return true; +} + +// La fisica, a passi fissi. +export function update(c, dt) { + if (!Number.isFinite(dt) || dt <= 0) return; + dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt; + if (c.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null; + if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } + for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt); + c.accumulator += dt; + while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); } +} +function flipperSeg(c, side) { + const f = c.flippers[side], [px, py] = PIVOT[side]; + return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] }; +} +function step(c) { + // I flipper: su se tenuti, giù se no; velocità angolare costante. + for (const side of ['left', 'right']) { + const f = c.flippers[side]; + const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest); + const d = target - f.angle, move = FLIP.speed * STEP; + f.omega = Math.abs(d) <= move ? 0 : Math.sign(d) * FLIP.speed; + f.angle = Math.abs(d) <= move ? target : f.angle + Math.sign(d) * move; + } + const ball = c.ball; + if (c.step !== 'play' || !ball || ball.waiting) return; + ball.vy += c.skill.g * STEP; + const v = Math.hypot(ball.vx, ball.vy); + if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } + ball.x += ball.vx * STEP; ball.y += ball.vy * STEP; + for (const w of c.walls) collideSeg(c, ball, w); + for (const t of c.targets) if (!t.down && collideSeg(c, ball, { a: t.a, b: t.b, e: 0.5 })) dropTarget(c, t); + for (const b of c.bumpers) if (collideCircle(ball, b, 0)) { kick(c, ball, b, c.skill.kick); b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); } + if (collideCircle(ball, c.regolo, 0.7)) { c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'Il Regolo!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); } + for (const side of ['left', 'right']) collideFlipper(c, ball, side); + // Le corsie si accendono entrando (non restandoci sopra: dopo il Palio ripartono spente). + for (const l of c.lanes) { + const inside = Math.hypot(ball.x - l.x, ball.y - l.y) < 7; + if (inside && !l.inside && !l.lit) { + l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); + if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO! Tutti e quattro i rioni!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; } + } + l.inside = inside; + } + // La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. + if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; } + else if ((c.still += STEP) > 2.5) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; c.still = 0; c.anchor = null; } + // Un lancio fiacco ricasca sulla molla: si rilancia. + if (ball.x > LANE.x0 && ball.y > 315 && Math.abs(ball.vy) < 8 && !ball.waiting) newBall(c); + if (ball.y > FIELD.y1 + R * 2) drain(c); +} +function dropTarget(c, t) { + t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); + const bank = c.targets.filter(k => k.bank === t.bank); + if (bank.every(k => k.down)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'La Pieve!' : 'La torre!'); for (const k of bank) k.down = false; } +} +function drain(c) { + c.events.push({ kind: 'flipper-buco', power: 0.8 }); + if (c.t < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; } + c.balls--; c.saved = false; + if (c.balls > 0) { say(c, `Pallina persa. Ne restano ${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 }; +} +// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna true se ha toccato. +function collideSeg(c, ball, w) { + const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1; + const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u; + let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny); + if (d >= R) return false; + if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; } + // Il cancelletto ferma solo chi scende dall'alto. + if (w.gate && !(ny < 0 && ball.vy > 0)) return false; + ball.x = px + nx * R; ball.y = py + ny * R; + const vn = ball.vx * nx + ball.vy * ny; + if (vn < 0) { + // La sponda elastica spinge via, un po' a caso come la gomma vera (così la pallina non fa il ping-pong in eterno). + if (w.sling) { const a = Math.atan2(ny, nx) + (random(c) - 0.5) * 0.5, s = 230 + random(c) * 40; ball.vx = Math.cos(a) * s + (ball.vx - vn * nx) * 0.3; ball.vy = Math.sin(a) * s + (ball.vy - vn * ny) * 0.3; add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); } + else { ball.vx -= (1 + w.e) * vn * nx; ball.vy -= (1 + w.e) * vn * ny; } + } + return true; +} +function collideCircle(ball, o, e) { + const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r; + if (d >= min) return false; + const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1; + ball.x = o.x + nx * min; ball.y = o.y + ny * min; + const vn = ball.vx * nx + ball.vy * ny; + if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; } + return true; +} +function kick(c, ball, o, speed) { + const a = Math.atan2(ball.y - o.y, ball.x - o.x) + (random(c) - 0.5) * 0.3; + ball.vx = Math.cos(a) * speed; ball.vy = Math.sin(a) * speed; +} +// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato. +function collideFlipper(c, ball, side) { + const f = c.flippers[side], { a, b } = flipperSeg(c, side), [ax, ay] = a, dx = b[0] - ax, dy = b[1] - ay, len2 = dx * dx + dy * dy; + const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u; + let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny), min = R + FLIP.r; + if (d >= min) return false; + if (d < 1e-6) { nx = 0; ny = -1; } else { nx /= d; ny /= d; } + ball.x = px + nx * min; ball.y = py + ny * min; + // La velocità della superficie: ω × r, col raggio dal perno al punto toccato. + const rx = px - ax, ry = py - ay, sx = -f.omega * ry, sy = f.omega * rx; + const vn = (ball.vx - sx) * nx + (ball.vy - sy) * ny; + if (vn < 0) { ball.vx -= (1 + 0.45) * vn * nx; ball.vy -= (1 + 0.45) * vn * ny; } + return true; +} + +// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco +// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto. +export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; } +export function press(c, pointerId = 'ptr') { + if (c.step !== 'play') return false; + if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; } + return hold(c, (c.cursor?.[0] ?? 320) < 320 ? 'left' : 'right', pointerId, true); +} +export function release(c, pointerId = 'ptr') { + if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime)); + let any = false; + for (const side of ['left', 'right']) if (c.flippers[side].held.includes(pointerId)) any = hold(c, side, pointerId, false) || any; + return any; +} +export const cancel = (c, pointerId) => pointerId === undefined ? release(c, c.pointerId ?? 'ptr') : release(c, pointerId); +// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti), e le frecce. +export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true); +export const stopTurn = (c, who = 'button') => hold(c, 'left', who, false) | hold(c, 'right', who, false); +export const nudge = () => false; +export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; } +export function key(c, k, down) { + if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; } + if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; } + if (k === 'Enter') { if (down) shoot(c); return true; } + return false; +} +// Il pulsante Lancia: con la forza del cursore. +export const shoot = c => canThrow(c) && launch(c, c.power); +export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…'; +export function status(c) { + if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`; + const tally = `Punti ${c.score} (traguardo ${c.skill.goal}), palline ${c.balls}.`; + 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}`; +} diff --git a/src/engine/game.js b/src/engine/game.js index 277989b..8cbdc98 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -19,6 +19,7 @@ import { newTelefono, updateTelefono, tapTelefono } from './telefono.js'; import * as condor from './condor.js'; import * as campana from './campana.js'; import * as bruciate from './bruciate.js'; +import * as flipper from './flipper.js'; import * as poker from './poker.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. @@ -36,6 +37,7 @@ export const MINIGAMES = { condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen }, campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen }, bruciate: { create: (seed, level) => bruciate.newBruciate(seed, { level }), update: bruciate.update, press: bruciate.press, release: bruciate.release, cancel: bruciate.cancel, aim: bruciate.aimScreen }, + flipper: { create: (seed, level) => flipper.newFlipper(seed, { level }), update: flipper.update, press: flipper.press, release: flipper.release, cancel: flipper.cancel, aim: flipper.aimScreen }, micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen }, gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen }, }; diff --git a/src/engine/render.js b/src/engine/render.js index 0d9a1c6..d5cd23f 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -15,6 +15,7 @@ import { drawCorsa } from './corsa.js'; import { drawCondor } from './condor-render.js'; import { drawCampana } from './campana-render.js'; import { drawBruciate } from './bruciate-render.js'; +import { drawFlipper } from './flipper-render.js'; import { drawPoker } from './poker-render.js'; import { drawViaggio } from './viaggio.js'; import { drawIncanto } from './incanto.js'; @@ -1055,6 +1056,11 @@ function drawCloseup(ctx, game, time) { drawCampana(ctx, c, def, backgrounds.get(def.picture)); return; } + if (def.type === 'flipper') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawFlipper(ctx, c, def, backgrounds.get(def.picture)); + return; + } if (def.type === 'bruciate') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawBruciate(ctx, c, def, backgrounds.get(def.picture)); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 200fb85..2213f23 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -161,6 +161,15 @@ export class Soundscape { else if (kind === 'bruciate-scoppio') { this.noiseSound(0.04, 2600, 0.05 * strength, { pan }); this.tone(900, 500, 0.03, 0.03, 0, pan); } else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); } else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan }); + // Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco. + else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); } + else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); } + else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); } + else if (kind === 'flipper-sponda') { this.tone(520, 260, 0.05, 0.05, 0, pan); this.noiseSound(0.03, 1600, 0.03, { pan }); } + else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); } + else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); } + else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan); + else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); } diff --git a/src/main.js b/src/main.js index 0dc512c..0f5d22c 100644 --- a/src/main.js +++ b/src/main.js @@ -16,6 +16,7 @@ import * as cerbottana from './engine/cerbottana.js'; import * as condor from './engine/condor.js'; import * as campana from './engine/campana.js'; import * as bruciate from './engine/bruciate.js'; +import * as flipper from './engine/flipper.js'; import * as poker from './engine/poker.js'; import { cardAt as pokerAt } from './engine/poker-render.js'; import * as basket from './engine/basket.js'; @@ -619,6 +620,7 @@ const MINI = { condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' }, bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' }, + flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, oppure i pulsanti Flipper o le frecce sinistra e destra.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). @@ -1048,7 +1050,7 @@ function updateUi() { } } if (mg && !cards) { - setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate' }[def.type] ?? 'Comandi delle bocce'); + setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate', flipper: 'Comandi del flipper' }[def.type] ?? 'Comandi delle bocce'); setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true); const line = mg.status(c, def.names); if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; @@ -1064,7 +1066,7 @@ function updateUi() { const easy = def.type === 'gara' && gara.simple(c); setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy); for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate'); - const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza '; + const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : def.type === 'flipper' ? 'Molla ' : 'Forza '; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio'); setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy); @@ -1072,8 +1074,8 @@ function updateUi() { c.sensLoaded = true; try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ } } - setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira'); - setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →'); + setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : def.type === 'flipper' ? '← Flipper' : '← Mira'); + setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : def.type === 'flipper' ? 'Flipper →' : 'Mira →'); setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy); $('bocce-throw').classList.toggle('hold', easy); setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech); @@ -1086,6 +1088,7 @@ function updateUi() { setProp($('condor-help'), 'hidden', def.type !== 'condor'); setProp($('campana-help'), 'hidden', def.type !== 'campana'); setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate'); + setProp($('flipper-help'), 'hidden', def.type !== 'flipper'); setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('gara-help'), 'hidden', def.type !== 'gara'); @@ -1117,7 +1120,7 @@ function updateUi() { canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('bocce', !!mg); // Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli). - $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].includes(story.closeups[c.id].type)); + $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('briscola', !!cards); setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); // La pagina che comincia a girare fa il suo fruscio. diff --git a/src/story/story.js b/src/story/story.js index 5cda1aa..353a235 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -360,6 +360,17 @@ export const story = { draw: [], quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }], }, + // Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»): la tavola di Codex, + // fisica e parti meccaniche in flipper.js. [DA ANDREA: le battute sono provvisorie] + flipper: { + type: 'flipper', + picture: 'assets/closeups/river-flipper.png', + names: { player: 'Andrea', cpu: 'il flipper' }, + win: [{ say: 'Traguardo! Se c\'era la classifica, oggi ci mettevo le mie iniziali.' }, { set: 'flipper_vinto' }], + lose: [{ say: 'Tre palline, e tutte nella Sieve. Il flipper del River non perdona.' }], + draw: [], + quit: [{ say: 'Lascio la pallina lì. Tanto il River non chiude.' }], + }, // Le bruciate della Smarronata, 1993: la manovella del cilindro sul fuoco, coi volontari della Misericordia // (Andrea, 31 ottobre: «a manovella, a mano»). [DA ANDREA: le battute sono provvisorie] bruciate: { @@ -1667,7 +1678,9 @@ export const story = { id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up', actions: { look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }], - open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima il palio, poi i ghiaccioli.' }], + // Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo. + open: [{ say: 'Un ghiacciolo ci starebbe. Ma prima... il flipper.' }, { dialogue: 'flipper' }], + use: [{ say: 'Dentro, in fondo, fra le figurine: il flipper. Con San Piero dipinto sulla tavola.' }, { dialogue: 'flipper' }], }, }, { @@ -4440,6 +4453,19 @@ export const story = { }, }, // Da solo al campo del Circolo: Andrea decide quanto farsi male. + // Il flipper del River: Andrea da solo davanti alla tavola, sceglie il livello. [DA ANDREA: battute provvisorie] + flipper: { + nodes: { + start: { + options: [ + { text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Seimila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] }, + { text: 'Una partita seria.', next: 'end', then: [{ say: 'Dodicimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'Venticinquemila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] }, + { text: 'Un\'altra volta.', next: 'end' }, + ], + }, + }, + }, basket: { nodes: { start: { diff --git a/style.css b/style.css index 53e14bc..ccc68d0 100644 --- a/style.css +++ b/style.css @@ -62,16 +62,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h /* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */ #bocce-throw.hold { min-height: 72px; font-size: 14px; } .bocce-adjust button:disabled { opacity: .4; cursor: default; } -#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } -#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary { cursor: pointer; padding: 6px 0; } -#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p { max-width: 76em; line-height: 1.7; } +#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } +#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary, #flipper-help summary { cursor: pointer; padding: 6px 0; } +#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p, #flipper-help p { max-width: 76em; line-height: 1.7; } @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } .bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } #bocce-throw { flex-basis: 100%; } - #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help, #flipper-help { font-size: 9px; } } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } #effects-volume { width: 130px; accent-color: var(--accent); } diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs new file mode 100644 index 0000000..d63e8fc --- /dev/null +++ b/tests/flipper.browser.mjs @@ -0,0 +1,72 @@ +// Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper coi pulsanti e con le +// frecce, il risultato. npm run browser:flipper — schermate in /tmp/regolo-flipper. +import { chromium } from 'playwright'; +import { mkdirSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { staticServer } from './helpers.mjs'; + +const out = process.env.OUT ?? '/tmp/regolo-flipper'; +mkdirSync(out, { recursive: true }); +const server = await staticServer(); +let browser; +const errors = []; +try { + browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); + for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { + const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); + page.on('pageerror', e => errors.push(e.message)); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro'); + await page.evaluate(async () => { + const { settle } = await import('/tests/helpers.mjs'); + settle(game); game.go('via_provinciale'); settle(game); + game.act('use', 'river'); settle(game); + game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game); + }); + await page.waitForSelector('#bocce-controls:not([hidden])'); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png')); + 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'); + await page.screenshot({ path: `${out}/${name}-tavola.png` }); + const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; + // La molla: col mouse tenendo premuto sulla scena, col dito il pulsante Lancia. + if (mobile) await page.locator('#bocce-throw').tap(); + else { + await page.mouse.move(...P(440, 320)); await page.mouse.down(); + await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); + await page.mouse.up(); + } + await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 }); + // I flipper: col dito i pulsanti tenuti premuti, col mouse la metà della scena e poi le frecce. + if (mobile) { + const b = await page.locator('#bocce-left').boundingBox(); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: b.x + b.width / 2, y: b.y + b.height / 2 }] }); + await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); + await page.screenshot({ path: `${out}/${name}-flipper.png` }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0); + } else { + await page.mouse.move(...P(400, 200)); await page.mouse.down(); + await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0); + await page.screenshot({ path: `${out}/${name}-flipper.png` }); + await page.mouse.up(); + await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0); + await page.keyboard.down('ArrowLeft'); + await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); + await page.keyboard.up('ArrowLeft'); + await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0); + } + // La fine: si fa finire la partita e si guarda il quadro. + await page.evaluate(() => { const c = game.closeup; c.step = 'result'; c.ball = null; c.result = { winner: 'player', score: 7350, goal: c.skill.goal }; }); + await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita'); + assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/); + await page.screenshot({ path: `${out}/${name}-fine.png` }); + await page.locator('#closeup-close').click(); + await page.waitForFunction(() => game.state.flags.flipper_vinto); + await page.close(); + } + assert.deepEqual(errors, []); + console.log(`ok: schermate in ${out}`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs new file mode 100644 index 0000000..423e6a9 --- /dev/null +++ b/tests/flipper.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m from '../src/engine/flipper.js'; +import { drawFlipper } from '../src/engine/flipper-render.js'; +import { Game } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); }; +// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento. +function autoplay(c, secs = 600) { + for (let i = 0; i < secs * 60 && c.step === 'play'; i++) { + if (m.waiting(c)) m.launch(c, 0.75); + const b = c.ball; + for (const side of ['left', 'right']) { + const [px, py] = m.PIVOT[side], dir = side === 'left' ? 1 : -1, rel = (b ? b.x - px : 0) * dir; + const near = b && !b.waiting && b.vy > 0 && b.y > py - 34 && b.y < py + 14 && rel > -6 && rel < 44; + if (near !== c.flippers[side].held.includes('ai')) m.hold(c, side, 'ai', near); + } + m.update(c, 1 / 60); + } +} + +test('la tavola: tutto sta dentro il campo, le corsie sotto l\'arco, i funghi sui dischi del disegno', () => { + const c = m.newFlipper(1); + for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`); + for (const b of c.bumpers) assert.ok(b.x > m.FIELD.x0 + 20 && b.x < m.LANE.x0 - 20); + assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI); + assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS); + assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla'); +}); + +test('il lancio: la molla manda la pallina su per la corsia, oltre il cancelletto, e non torna più nella corsia', () => { + const c = m.newFlipper(2, { level: 'bona' }); + assert.ok(m.launch(c, 0.8)); assert.ok(!m.waiting(c)); + let top = 999, left = false; + for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) left = true; } + assert.ok(top < 40, `è arrivata in cima (${top.toFixed(0)})`); + assert.ok(left, 'è uscita dalla corsia'); + // Un lancio fiacco ricasca sulla molla e si rilancia. + const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0); run(d, 3); + assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa'); +}); + +test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola', () => { + const c = m.newFlipper(4, { level: 'seria' }); + for (let n = 3; n >= 1; n--) { + assert.equal(c.balls, n); + m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto + c.ball.x = 320; c.ball.y = 320; c.ball.vx = 0; c.ball.vy = 200; run(c, 1); + } + assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c)); + assert.match(m.status(c), /Ne servivano 12000/); + const d = m.newFlipper(5, { level: 'bona' }); + m.launch(d, 0.9); d.ball.x = 320; d.ball.y = 320; d.ball.vx = 0; d.ball.vy = 200; run(d, 1); + assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla'); + assert.match(d.line ?? '', /salvata/); + m.launch(d, 0.9); d.ball.x = 320; d.ball.y = 320; d.ball.vx = 0; d.ball.vy = 200; run(d, 1); + assert.equal(d.balls, 2, 'la seconda volta è persa davvero'); +}); + +test('il flipper alzato rimanda su la pallina; abbassato la lascia scendere', () => { + const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9); + Object.assign(up.ball, { x: 300, y: 300, vx: 0, vy: 150 }); + m.hold(up, 'left', 'test', true); + let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); } + assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`); + assert.ok(up.events.some(e => e.kind === 'flipper-flip')); + const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9); + Object.assign(down.ball, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; + run(down, 2); + assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve'); +}); + +test('funghi, bersagli, Regolo e corsie fanno punti; le quattro corsie insieme il Palio', () => { + const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9); + const b = c.bumpers[0]; + Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); + assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta'); + assert.ok(c.events.some(e => e.kind === 'flipper-fungo')); + c.score = 0; + for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; Object.assign(c.ball, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); } + assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank); + assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'la fila si rialza'); + assert.match(c.line, /Pieve/); + c.score = 0; + Object.assign(c.ball, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); + assert.equal(c.score, m.SCORE.regolo); + c.score = 0; + for (const l of c.lanes) { Object.assign(c.ball, { x: l.x, y: l.y - 10, vx: 0, vy: 100 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(l.lit || c.lanes.every(k => !k.lit), l.rione); } + assert.equal(c.score, 4 * m.SCORE.lane + m.SCORE.palio); + assert.match(c.line, /PALIO/); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro'); +}); + +test('comandi: tenere premuto carica la molla e lasciare lancia; in gioco la metà della scena è il flipper; tasti', () => { + const c = m.newFlipper(8, { level: 'bona' }); + m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6); + assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`); + assert.ok(!m.release(c, 2), 'un altro dito non lancia'); + assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c)); + m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3)); + m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4)); + assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4)); + assert.ok(m.cancel(c, 4)); assert.ok(!c.flippers.right.held.length); + // I pulsanti e le frecce. + assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); m.stopTurn(c, 'button'); assert.ok(!c.flippers.left.held.length); + assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length); + assert.ok(!m.key(c, 'x', true)); + // Spazio: carica e lancia; Invio: lancia con la forza del cursore. + const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d)); + const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…'); +}); + +test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { + let won = 0; + for (let seed = 1; seed <= 8; seed++) { + const c = m.newFlipper(seed * 7919, { level: 'bona' }); + autoplay(c); + assert.equal(c.step, 'result', `seme ${seed}: finita`); + if (c.result.winner === 'player') won++; + } + assert.ok(won >= 4, `vinte ${won} su 8`); +}); + +test('il disegno del flipper non cambia lo stato', () => { + const calls = [], gradient = { addColorStop() {} }; + const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); + const c = m.newFlipper(11, { level: 'bona' }), bg = {}; + const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); }; + draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER')); + m.launch(c, 0.8); run(c, 1); draw(); + c.step = 'result'; c.result = { winner: 'player', score: 7000, goal: 6000 }; calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!')); +}); + +test('al River: «Usa» o «Apri» il bar, il livello, la partita; vincendo il flag', () => { + const g = new Game(story); g.go('via_provinciale'); settle(g); + g.act('use', 'river'); settle(g); + assert.ok(g.options().some(o => o.text === 'Una partita seria.')); + g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g); + assert.equal(g.closeup?.id, 'flipper'); assert.equal(g.closeup.level, 'seria'); + g.closeup.step = 'result'; g.closeup.result = { winner: 'player', score: 13000, goal: 12000 }; + g.closeupClose(); settle(g); + assert.ok(g.state.flags.flipper_vinto); + g.act('open', 'river'); settle(g); + g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); + assert.equal(g.closeup?.level, 'male'); + g.closeupClose(); + assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say); +});