diff --git a/assets/voices/andrea-01a2eb256d.mp3 b/assets/voices/andrea-01a2eb256d.mp3 deleted file mode 100644 index a5b50e7..0000000 Binary files a/assets/voices/andrea-01a2eb256d.mp3 and /dev/null differ diff --git a/assets/voices/andrea-5811ae33d6.mp3 b/assets/voices/andrea-5811ae33d6.mp3 new file mode 100644 index 0000000..f2cce4c Binary files /dev/null and b/assets/voices/andrea-5811ae33d6.mp3 differ diff --git a/assets/voices/andrea-d420a5e136.mp3 b/assets/voices/andrea-d420a5e136.mp3 new file mode 100644 index 0000000..bc663fa Binary files /dev/null and b/assets/voices/andrea-d420a5e136.mp3 differ diff --git a/assets/voices/andrea-e455fd8483.mp3 b/assets/voices/andrea-e455fd8483.mp3 deleted file mode 100644 index 959d1a8..0000000 Binary files a/assets/voices/andrea-e455fd8483.mp3 and /dev/null differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 75854e6..6dc7f30 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -2987,13 +2987,13 @@ "file": "andrea-b6d9563c93.mp3", "sec": 2.02 }, - "andrea|Dodicimila. Come quando ci lasciavo le cinquecento lire.": { - "file": "andrea-01a2eb256d.mp3", - "sec": 4.12 + "andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": { + "file": "andrea-5811ae33d6.mp3", + "sec": 3.91 }, - "andrea|Venticinquemila. Il record del River, o quasi.": { - "file": "andrea-e455fd8483.mp3", - "sec": 4.2 + "andrea|Sedicimila. Il record del River, o quasi.": { + "file": "andrea-d420a5e136.mp3", + "sec": 4.38 }, "andrea|Due tiri alla bona, tanto per scaldarmi.": { "file": "andrea-d564365fc2.mp3", diff --git a/docs/STATO.md b/docs/STATO.md index 755a2a2..e1284a5 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -58,7 +58,13 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, 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). + 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). Da fare (Andrea): un paio di + rampe («ponti»), più punti, il tabellone luminoso coi punti che corrono, un ingresso più intuitivo del River. - **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 8dfe6b1..7169934 100644 --- a/index.html +++ b/index.html @@ -308,12 +308,13 @@ 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.
+ destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a + destra, è il Regolo (1000). +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 6000 punti, seria 10000, a + fassi male 16000; raggiunto il traguardo, si continua per il record.
La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 377bbd9..72be1bb 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -1,34 +1,44 @@ // 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'; +// 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, 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, PIVOT, flipperSeg, waiting, canThrow, saveLeft } 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(); + 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 - 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; +function bumper(ctx, b) { + const r = b.r * (b.hit > 0 ? 1 + b.hit * 1.2 : 1); 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; +} +// 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'); + ctx.fillStyle = '#3fa34d'; ctx.beginPath(); ctx.arc(o.x, o.y, r - 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#ffd45a'; + for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h); + ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2); } 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; + const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); 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 = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(tx + 1, ty + 3); ctx.stroke(); + ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke(); + ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = 5; 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'); } @@ -46,23 +56,24 @@ function ball(ctx, b) { 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'); +// Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, il salvataggio, l'avviso. +function board(ctx, c) { + panel(ctx, 16, 56, 172, 168); + text(ctx, 'FLIPPER DEL RIVER', 102, 72, 7, '#efd49e', 'center'); + text(ctx, String(c.score).padStart(6, '0'), 102, 98, 16, '#fff4c8', 'center'); + text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 28, 124, 7, '#d7ccb4'); + text(ctx, c.result ? 'TRAGUARDO FATTO' : `TRAGUARDO ${c.skill.goal}`, 28, 140, 7, c.result ? '#9fd27a' : '#d7ccb4'); + text(ctx, 'RIONI', 28, 160, 7, '#d7ccb4'); + c.lanes.forEach((l, i) => disc(ctx, 86 + i * 22, 160, 7, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650')); + const save = saveLeft(c); + if (save > 0) text(ctx, `SALVATA ${Math.ceil(save)} s`, 28, 180, 7, '#9fd27a'); + else if (c.ball && !c.ball.waiting) text(ctx, c.saved ? 'SALVATAGGIO USATO' : 'SENZA RETE', 28, 180, 6, '#a9a08a'); + if (c.line) text(ctx, c.line, 102, 206, 8, '#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'); + panel(ctx, 16, 236, 172, 56); + text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 252, 8, '#efd49e', 'center'); + text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); } export function drawFlipper(ctx, c, def, bg) { @@ -70,32 +81,29 @@ export function drawFlipper(ctx, c, def, bg) { 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); + // Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere. + for (const l of c.lanes) { + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7); + disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050'); } - // I bersagli a caduta: in piedi colorati, caduti un segno a terra. + // Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. + for (const w of c.walls) rail(ctx, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6'); + // 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); } - 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 b of c.bumpers) bumper(ctx, b); + regolo(ctx, c.regolo); 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); + board(ctx, c); if (c.step === 'result') result(ctx, c); ctx.restore(); } -export { SCORE }; diff --git a/src/engine/flipper.js b/src/engine/flipper.js index f75915d..0f37f5d 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -1,23 +1,30 @@ // 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). +// abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo, e giù dalle sponde elastiche ai +// due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al traguardo di +// punti del livello (e poi si può continuare per il record). Unità: i pixel del primo piano. La fisica va a passo +// fisso (come il biliardo), con sottopassi quando pallina e flipper vanno forte. +// Rivista con la revisione di Codex del 31 ottobre: punti solo a un urto nuovo (non al contatto che dura), due dita +// sulla scena, annullare non lancia, niente cancelletto-ripiano, salvataggio col tempo della simulazione, corsie che +// contano quando si attraversano, traguardo acquisito appena raggiunto. 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 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] }; 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. +// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta. +const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5; +// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la +// gravità. La spinta dei funghi è uguale per tutti. export const LEVELS = { - bona: { g: 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 }, + bona: { g: 680, save: 8, kick: 340, goal: 6000 }, + seria: { g: 740, save: 4, kick: 340, goal: 10000 }, + male: { g: 800, save: 1, kick: 340, goal: 16000 }, }; 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 }); +const seg = (a, b, extra = {}) => ({ a, b, e: 0.55, touching: false, ...extra }); // La tavola: sponde, corsie, sponde elastiche, funghi, bersagli. Le coordinate seguono il disegno di Codex // (assets/closeups/river-flipper.png): i tre dischi dei funghi, le quattro bandiere dei rioni, la corsia della molla. @@ -26,12 +33,13 @@ 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, 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 })); + // 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à). + walls.push(seg([432, 70], [432, 336]), 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.) + // 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. @@ -40,50 +48,73 @@ export function layout() { // 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 })); + for (const [bank, x] of [['pieve', 216], ['torre', 423]]) for (let i = 0; i < 3; i++) targets.push({ bank, i, a: [x, 150 + i * 16], b: [x, 162 + i * 16], down: false, touching: false }); + const bumpers = [[281, 120], [360, 120], [320, 153]].map(([x, y]) => ({ x, y, r: 12, hit: 0, touching: false })); // Il Regolo, in alto a destra sotto l'arco, dove la pallina scende dal giro. - const regolo = { x: 404, y: 62, r: 6, hit: 0 }; - const lanes = [257, 297, 340, 381].map((x, i) => ({ x, y: 88, rione: RIONI[i], lit: false })); + const regolo = { x: 404, y: 62, r: 7, hit: 0, touching: false }; + // Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in + // discesa la riga LANE_LINE. + const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false })); 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, + 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: [] }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [] } }, - ball: null, saveUntil: 0, still: 0, visual: { time: 0 }, ...layout() }; + flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } }, + ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, 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; } +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; c.rest = 0; } 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'; +// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita. +export const canAdjust = canThrow; +export const canFlip = c => c.step === 'play'; export const aimFree = () => true; export const powerAt = time => clamp(time / CHARGE, 0, 1); +// Quanto resta del salvataggio della pallina, in secondi (0: non protetta). +export const saveLeft = c => c.ball && !c.ball.waiting ? Math.max(0, c.saveUntil - c.sim) : 0; function say(c, line) { c.line = line; c.lineT = 2; } -function add(c, points) { c.score += points; } +// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record. +function add(c, points) { + c.score += points; + if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); } + if (c.result) c.result.score = c.score; +} -// Il lancio: la molla spinge la pallina su per la corsia. +// Il lancio: la molla spinge la pallina su per la corsia. Anche la molla più fiacca arriva all'arco (la velocità +// minima dipende dalla gravità del livello); da lì in su, la forza decide quanto gira. +export function launchSpeed(c, power) { + const min = Math.sqrt(2 * c.skill.g * 340), max = Math.max(min + 150, 900); + return min + (max - min) * clamp(power, 0, 1); +} export function launch(c, power) { if (!waiting(c)) return false; - c.ball.waiting = false; c.ball.vy = -(LAUNCH[0] + (LAUNCH[1] - LAUNCH[0]) * clamp(power, 0, 1)); c.ball.vx = 0; + // La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale. + c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0; // Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa. - c.saveUntil = c.saved ? 0 : c.t + c.skill.save; c.holding = false; c.holdTime = 0; - c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * power }); + c.saveUntil = c.saved ? 0 : c.sim + c.skill.save; c.holding = false; c.holdTime = 0; c.pointerId = null; + c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * clamp(power, 0, 1) }); return true; } // Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno. +// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda. export function hold(c, side, who, down) { const f = c.flippers[side]; if (!f) return false; const was = f.held.length > 0; f.held = f.held.filter(h => h !== who); if (down) f.held.push(who); + if (who === 'click' && down) f.click = CLICK_HOLD; if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 }); return true; } +export function flipperSeg(c, side) { + const f = c.flippers[side], [px, py] = PIVOT[side]; + return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] }; +} // La fisica, a passi fissi. export function update(c, dt) { @@ -95,89 +126,120 @@ export function update(c, 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. +function moveFlippers(c, h) { for (const side of ['left', 'right']) { const f = c.flippers[side]; + if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click'); const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest); - const 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 previous = f.angle; + f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h); + f.omega = (f.angle - previous) / h; } +} +function step(c) { + c.sim += STEP; const ball = c.ball; - if (c.step !== 'play' || !ball || ball.waiting) return; + if (c.step !== 'play' || !ball || ball.waiting) { 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]; } 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; + limit(ball); + // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. + const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n; + const from = { x: ball.x, y: ball.y }; + for (let i = 0; i < n; i++) { + moveFlippers(c, h); + ball.x += ball.vx * h; ball.y += ball.vy * h; + for (const w of c.walls) collideSeg(c, ball, w); + for (const t of c.targets) if (!t.down) collideTarget(c, ball, t); + for (const b of c.bumpers) collideBumper(c, ball, b); + collideRegolo(c, ball); + for (const side of ['left', 'right']) collideFlipper(c, ball, side); + limit(ball); } - // La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. + crossLanes(c, from, ball); + // La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è + // trattenuta su un flipper alzato: quella è una mossa del giocatore. 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); + else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } } + // Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia. + if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0; 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 limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } } +const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R); +// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata). +function crossLanes(c, from, ball) { + if (!(from.y < LANE_LINE && ball.y >= LANE_LINE)) return; + const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k; + const l = c.lanes.find(l => x >= l.x0 && x < l.x1); + if (!l || l.lit) return; + l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); + if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = 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; } + if (c.sim < 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; } + if (c.balls > 0) { say(c, `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) { +// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la +// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi. +function hitSeg(ball, w, e) { const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1; const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u; let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny); - if (d >= R) return false; + if (d >= R) { w.touching = false; return null; } if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; } - // 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; + const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching; + w.touching = true; + if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; } + return { vn: -vn, nx, ny, fresh }; } -function collideCircle(ball, o, e) { +function collideSeg(c, ball, w) { + if (!w.sling) { hitSeg(ball, w, w.e); return; } + // La sponda elastica: spinge via lungo la normale, tenendo la componente di strisciamento, e un po' a caso come + // la gomma vera; solo a un urto nuovo, e solo se picchia. + const hit = hitSeg(ball, w, null); + if (!hit || hit.vn <= 0) return; + if (hit.fresh && hit.vn > IMPACT) { + const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30; + const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda + ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6; + add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); + } else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; } +} +function collideTarget(c, ball, t) { + const hit = hitSeg(ball, t, 0.5); + if (!hit || !hit.fresh || hit.vn < IMPACT) return; + t.down = true; add(c, SCORE.target); 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!'); c.resets[t.bank] = c.sim + BANK_RESET; } +} +// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca). +function hitCircle(ball, o, e) { const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r; - if (d >= min) return false; + if (d >= min) { o.touching = false; return null; } const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1; ball.x = o.x + nx * min; ball.y = o.y + ny * min; - const vn = ball.vx * nx + ball.vy * ny; + const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching; + o.touching = true; if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; } - return true; + return { vn: -vn, nx, ny, fresh }; } -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; +function collideBumper(c, ball, b) { + const hit = hitCircle(ball, b, 0.2); + if (!hit || !hit.fresh || hit.vn < IMPACT) return; + const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2; + ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick; + b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); +} +function collideRegolo(c, ball) { + const hit = hitCircle(ball, c.regolo, 0.7); + if (!hit || !hit.fresh || hit.vn < IMPACT) return; + c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); } // Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato. function collideFlipper(c, ball, side) { @@ -195,7 +257,7 @@ function collideFlipper(c, ball, side) { } // --- 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. +// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita). export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; } export function press(c, pointerId = 'ptr') { if (c.step !== 'play') return false; @@ -208,13 +270,24 @@ export function release(c, pointerId = 'ptr') { 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. +// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore). +export function cancel(c, pointerId) { + const all = pointerId === undefined; + if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; } + for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId); + return true; +} +// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti; da tastiera un colpo breve), e le frecce. export const turn = (c, dir, who = 'button') => c.step === 'play' && hold(c, dir < 0 ? 'left' : 'right', who, true); -export const stopTurn = (c, who = 'button') => hold(c, 'left', who, false) | hold(c, 'right', who, false); +export function stopTurn(c, who = 'button') { + if (who === 'click') return false; // il colpo breve finisce da sé + const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false); + return l || r; +} export const nudge = () => false; export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; } export function key(c, k, down) { + if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js) if (k === 'ArrowLeft' || k === 'ArrowRight') { hold(c, k === 'ArrowLeft' ? 'left' : 'right', 'key-' + k, down); return true; } if (k === ' ') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; } if (k === 'Enter') { if (down) shoot(c); return true; } @@ -225,7 +298,8 @@ 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}.`; + const goal = c.result ? 'traguardo fatto, ora il record' : `traguardo ${c.skill.goal}`; + const tally = `Punti ${c.score} (${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/main.js b/src/main.js index 0f5d22c..3481f8a 100644 --- a/src/main.js +++ b/src/main.js @@ -492,9 +492,12 @@ const debugView = () => debug && debugShown && game && { mouse, points: debugPoi const TOUCH_REACH = 22; const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0; let press = null; +// Le dita in più sul flipper (oltre a `press`): ognuna lascia il suo flipper quando si alza. Il tasto destro del +// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte. +const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId; canvas.addEventListener('pointerdown', e => { if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } - if (!running || e.button > 0) return; + if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return; // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. if (game.closeup) { const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type]; @@ -520,6 +523,12 @@ canvas.addEventListener('pointerdown', e => { if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); } return; } + // Il flipper: un secondo dito (o il secondo pulsante del mouse) comanda l'altro flipper, per conto suo. + if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) { + game.closeupAim(scenePoint(e)); + if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); } + return; + } if (press?.closeup) return; const aimed = game.closeupAim(scenePoint(e)); if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) { @@ -537,6 +546,8 @@ canvas.addEventListener('pointerdown', e => { press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) }; }); canvas.addEventListener('pointerup', e => { + const key = e.button === 2 ? 'mouse-right' : e.pointerId; + if (extra.has(key)) { extra.delete(key); game?.closeupRelease(key); return; } if (!press) return; // Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no // si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente. @@ -574,12 +585,13 @@ canvas.addEventListener('pointerup', e => { press = null; }); canvas.addEventListener('pointercancel', e => { + if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press) clearTimeout(press.timer); if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId); press = null; }); -canvas.addEventListener('lostpointercapture', e => { if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } }); +canvas.addEventListener('lostpointercapture', e => { if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); @@ -587,6 +599,13 @@ canvas.addEventListener('contextmenu', e => { canvas.addEventListener('pointermove', e => { if (!running) return; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; + // Il tasto destro premuto o lasciato mentre il sinistro è giù arriva come pointermove (pulsanti «in accordo»): + // al flipper vale come un secondo dito, per il flipper di destra. + if (e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { + if (e.buttons & 2) { if (!extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); } } + else if (extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); } + return; + } // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) @@ -620,7 +639,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.' }, + flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper o le frecce sinistra e destra.' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, 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). @@ -1057,6 +1076,8 @@ function updateUi() { setValue($('bocce-power'), Math.round(c.power * 100)); setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`); for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech); + // Al flipper i due pulsanti valgono per tutta la partita, il cursore della molla solo prima del lancio. + if (mg.canFlip) for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'disabled', !mg.canFlip(c) || !!game.speech); setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech); // Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti). // Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata). diff --git a/src/story/story.js b/src/story/story.js index 353a235..e2ae9f9 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -4459,8 +4459,8 @@ export const story = { 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: 'Una partita seria.', next: 'end', then: [{ say: 'Diecimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'Sedicimila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] }, { text: 'Un\'altra volta.', next: 'end' }, ], }, diff --git a/style.css b/style.css index ccc68d0..2421c21 100644 --- a/style.css +++ b/style.css @@ -62,6 +62,7 @@ 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-left, #bocce-right { touch-action: none; } #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; } diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index d63e8fc..2249a11 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -1,5 +1,6 @@ -// 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. +// Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper con due dita sulla scena, +// coi pulsanti e con le frecce, la pausa che lascia tutto, il risultato. npm run browser:flipper — schermate in +// /tmp/regolo-flipper. import { chromium } from 'playwright'; import { mkdirSync } from 'node:fs'; import assert from 'node:assert/strict'; @@ -28,43 +29,63 @@ try { assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia'); assert.ok(await page.locator('#flipper-help').isVisible()); assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper'); + assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio'); await page.screenshot({ path: `${out}/${name}-tavola.png` }); const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; - // La molla: col mouse tenendo premuto sulla scena, col dito il pulsante Lancia. - if (mobile) await page.locator('#bocce-throw').tap(); - else { + const cdp = mobile ? await page.context().newCDPSession(page) : null; + const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) }); + // La molla: tenendo premuto sulla scena, col mouse e col dito. + if (mobile) { + await touch('touchStart', [P(440, 320)]); + await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); + await touch('touchEnd', []); + } else { await page.mouse.move(...P(440, 320)); await page.mouse.down(); await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' }); await page.mouse.up(); } await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 }); - // I flipper: col dito i pulsanti tenuti premuti, col mouse la metà della scena e poi le frecce. + // Due dita sulla scena: sinistra, poi anche destra, poi si alza la sinistra e la destra resta su. 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 touch('touchStart', [P(250, 200)]); await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); + await touch('touchStart', [P(250, 200), P(400, 200)]); + await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0 && game.closeup.flippers.left.held.length > 0, null, { timeout: 3000 }); await page.screenshot({ path: `${out}/${name}-flipper.png` }); - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await touch('touchEnd', [P(400, 200)]); + await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 }); + await touch('touchEnd', []); + await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0); + // I pulsanti tenuti premuti. + const b = await page.locator('#bocce-left').boundingBox(); + await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]); + await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); + await touch('touchEnd', []); await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0); } else { await page.mouse.move(...P(400, 200)); await page.mouse.down(); await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0); + await page.mouse.down({ button: 'right' }); + await page.waitForFunction(() => game.closeup.flippers.right.held.length > 1, null, { timeout: 3000 }); await page.screenshot({ path: `${out}/${name}-flipper.png` }); - await page.mouse.up(); + await page.mouse.up(); await page.mouse.up({ button: 'right' }); await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0); await page.keyboard.down('ArrowLeft'); await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); - await page.keyboard.up('ArrowLeft'); + // La pausa lascia tutto: tornando, il flipper è giù anche se il tasto era premuto. + await page.click('#pause-button'); await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0); + await page.keyboard.up('ArrowLeft'); + await page.click('#resume'); + await page.waitForFunction(() => document.querySelector('#pause').hidden); } // 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.evaluate(() => { const c = game.closeup; c.score = 7350; 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); + 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.close(); } assert.deepEqual(errors, []); diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 423e6a9..9b34e91 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -7,6 +7,7 @@ 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); }; +const put = (c, ball) => Object.assign(c.ball, ball); // 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++) { @@ -21,94 +22,140 @@ function autoplay(c, secs = 600) { } } -test('la tavola: tutto sta dentro il campo, le corsie sotto l\'arco, i funghi sui dischi del disegno', () => { +test('la tavola: tutto sta dentro il campo, le corsie coprono tutta la larghezza sotto l\'arco, i funghi sui dischi del disegno', () => { const c = m.newFlipper(1); for (const w of c.walls) for (const [x, y] of [w.a, w.b]) assert.ok(x >= m.FIELD.x0 && x <= m.FIELD.x1 && y >= m.FIELD.y0 && y <= m.FIELD.y1, `${w.a}-${w.b}`); - for (const b of c.bumpers) assert.ok(b.x > m.FIELD.x0 + 20 && b.x < m.LANE.x0 - 20); + // I tre dischi dipinti da Codex (misurati sull'immagine): i funghi ci stanno sopra, a meno di 3 pixel. + for (const [b, [x, y]] of c.bumpers.map((b, i) => [b, [[281, 120], [360, 120], [320, 153]][i]])) assert.ok(Math.hypot(b.x - x, b.y - y) < 3); assert.equal(c.lanes.length, 4); assert.deepEqual(c.lanes.map(l => l.rione), m.RIONI); + assert.equal(c.lanes[0].x0, m.FIELD.x0); assert.equal(c.lanes[3].x1, m.LANE.x0); + for (let i = 1; i < 4; i++) assert.equal(c.lanes[i].x0, c.lanes[i - 1].x1, 'corsie contigue'); assert.equal(c.targets.length, 6); assert.equal(c.balls, m.BALLS); assert.ok(m.waiting(c) && m.canThrow(c) && c.ball.x > m.LANE.x0, 'la pallina aspetta sulla molla'); }); -test('il lancio: 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); +test('il lancio: anche la molla più fiacca arriva in campo, quella forte fa il giro dell\'arco; la ricaduta si rilancia', () => { + for (const level of ['bona', 'male']) for (const power of [0, 0.5, 1]) { + const c = m.newFlipper(2, { level }); assert.ok(m.launch(c, power)); assert.ok(!m.waiting(c)); + let top = 999, out = false; + for (let i = 0; i < 180; i++) { m.update(c, 1 / 60); top = Math.min(top, c.ball?.y ?? 999); if (c.ball && c.ball.x < m.LANE.x0 - 30) out = true; } + assert.ok(top < 50, `${level} forza ${power}: in cima (${top.toFixed(0)})`); + assert.ok(out, `${level} forza ${power}: entrata in campo`); + } + // Una pallina ricaduta nella corsia si assesta e si rilancia, senza perderla. + const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0.5); put(d, { x: 440, y: 200, vx: 0, vy: 50 }); run(d, 3); assert.ok(m.waiting(d) && d.balls === m.BALLS, 'tornata sulla molla, nessuna pallina persa'); }); -test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola', () => { +test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una volta sola e non dipende dal fotogramma', () => { const c = m.newFlipper(4, { level: 'seria' }); for (let n = 3; n >= 1; n--) { assert.equal(c.balls, n); m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto - c.ball.x = 320; c.ball.y = 320; c.ball.vx = 0; c.ball.vy = 200; run(c, 1); + put(c, { x: 320, y: 320, vx: 0, vy: 200 }); run(c, 1); } assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c)); - assert.match(m.status(c), /Ne servivano 12000/); + assert.match(m.status(c), /Ne servivano 10000/); 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); + m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla'); assert.match(d.line ?? '', /salvata/); - m.launch(d, 0.9); d.ball.x = 320; d.ball.y = 320; d.ball.vx = 0; d.ball.vy = 200; run(d, 1); + m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.equal(d.balls, 2, 'la seconda volta è persa davvero'); + // Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex). + for (const dts of [[1 / 240, 60], [1 / 60, 15], [0.25, 1]]) { + const e = m.newFlipper(6, { level: 'seria' }); m.launch(e, 0.8); e.sim = 2.9; e.saveUntil = 3; put(e, { x: 320, y: 344, vx: 0, vy: 100 }); + for (let i = 0; i < dts[1]; i++) m.update(e, dts[0]); + assert.equal(e.balls, 3, `salvata con passi da ${dts[0]}`); + } }); -test('il flipper alzato rimanda su la pallina; abbassato la lascia scendere', () => { +test('il flipper alzato rimanda su la pallina, anche a tutta velocità (niente attraversamenti); abbassato la lascia scendere', () => { const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9); - Object.assign(up.ball, { x: 300, y: 300, vx: 0, vy: 150 }); - m.hold(up, 'left', 'test', true); + put(up, { x: 300, y: 300, vx: 0, vy: 150 }); m.hold(up, 'left', 'test', true); let best = 0; for (let i = 0; i < 60; i++) { m.update(up, 1 / 60); best = Math.min(best, up.ball?.vy ?? 0); } assert.ok(best < -250, `tirata su (vy ${best.toFixed(0)})`); assert.ok(up.events.some(e => e.kind === 'flipper-flip')); + // Il caso riprodotto da Codex: pallina a 980 px/s sulla punta del flipper che si alza. + const fast = m.newFlipper(6, { level: 'seria' }); m.launch(fast, 0.9); + put(fast, { x: 313.438, y: 323.71, vx: -469.837, vy: 860.031 }); m.hold(fast, 'left', 'test', true); + m.update(fast, m.STEP); + assert.ok(fast.ball.vy < 0 && fast.ball.y < 324, `respinta, non passata sotto (y ${fast.ball.y.toFixed(1)}, vy ${fast.ball.vy.toFixed(0)})`); const down = m.newFlipper(6, { level: 'seria' }); m.launch(down, 0.9); - Object.assign(down.ball, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; - run(down, 2); + put(down, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; run(down, 2); assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve'); }); -test('funghi, bersagli, Regolo e corsie fanno punti; le quattro corsie insieme il Palio', () => { +test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le quattro corsie insieme il Palio', () => { const c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9); const b = c.bumpers[0]; - 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); + put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.equal(c.score, m.SCORE.bumper); assert.ok(c.ball.vy > 0 && b.hit > 0, 'respinta'); assert.ok(c.events.some(e => e.kind === 'flipper-fungo')); - 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); + // La pallina appoggiata sul Regolo (Codex: 240.000 punti in un secondo): non conta. + c.score = 0; put(c, { x: c.regolo.x, y: c.regolo.y - c.regolo.r - m.R, vx: 0, vy: 0 }); + for (let i = 0; i < 240; i++) m.update(c, m.STEP); + assert.equal(c.score, 0, 'il contatto che dura non fa punti'); + put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.equal(c.score, m.SCORE.regolo); c.score = 0; - for (const 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); + for (const t of c.targets.filter(k => k.bank === 'pieve')) { const before = c.score; put(c, { x: t.a[0] + 10, y: (t.a[1] + t.b[1]) / 2, vx: -300, vy: 0 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(c.score > before, `caduto il ${t.i}`); } + assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank); + assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => t.down), 'la fila resta giù un attimo'); + put(c, { x: 260, y: 230, vx: 0, vy: 0 }); run(c, 1); + assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'poi si rialza'); + assert.match(c.line ?? '', /PIEVE/); + // Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva). + c.score = 0; + for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) { + put(c, { x, y: c.lanes[i].y - 3, vx: 0, vy: 150 }); run(c, 0.1); + assert.ok(c.lanes[i].lit || c.lanes.every(k => !k.lit), c.lanes[i].rione); + } + assert.ok(c.score >= 4 * m.SCORE.lane + m.SCORE.palio, `corsie e Palio (${c.score}; in più, al massimo, un fungo sfiorato)`); assert.match(c.line, /PALIO/); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro'); + // In salita non conta. + put(c, { x: 287, y: c.lanes[1].y + 6, vx: 0, vy: -150 }); m.update(c, 1 / 60); m.update(c, 1 / 60); + assert.ok(!c.lanes[1].lit); }); -test('comandi: tenere premuto carica la molla e lasciare lancia; in gioco la metà della scena è il flipper; tasti', () => { +test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => { const c = m.newFlipper(8, { level: 'bona' }); m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6); assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`); assert.ok(!m.release(c, 2), 'un altro dito non lancia'); - assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c)); + m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio'); + m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c)); m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3)); m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4)); assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4)); - 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); + m.cancel(c); assert.ok(!c.flippers.right.held.length, 'annullare tutto (pausa) lascia i flipper'); + // I pulsanti e le frecce; il pulsante da tastiera dà un colpo breve che la fisica vede. + assert.ok(m.turn(c, -1, 'button')); assert.ok(c.flippers.left.held.length); assert.ok(m.stopTurn(c, 'button')); assert.ok(!c.flippers.left.held.length); + m.turn(c, 1, 'click'); m.stopTurn(c, 'click'); assert.ok(c.flippers.right.held.includes('click')); + const a0 = c.flippers.right.angle; run(c, 0.1); assert.notEqual(c.flippers.right.angle, a0, 'il flipper si è mosso'); run(c, 0.3); assert.ok(!c.flippers.right.held.length); assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(c.flippers.right.held.length); m.key(c, 'ArrowRight', false); assert.ok(!c.flippers.right.held.length); assert.ok(!m.key(c, 'x', true)); - // Spazio: carica e lancia; Invio: lancia con la forza del cursore. + assert.ok(!m.setPower(c, 0.3), 'in gioco il cursore della molla non vale'); + // Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi. const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d)); const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…'); + e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 10000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true)); +}); + +test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => { + const c = m.newFlipper(11, { level: 'bona' }); m.launch(c, 0.9); + c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60); + assert.ok(c.result && c.result.winner === 'player' && c.step === 'play', 'traguardo fatto, si continua'); + assert.match(m.status(c), /record/); + const g = new Game(story); g.go('via_provinciale'); settle(g); + g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g); + g.closeup.score = 7000; g.closeup.result = { winner: 'player', score: 7000, goal: 6000 }; + g.closeupClose(); settle(g); + assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto'); + // La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore. + const h = m.newFlipper(12, { level: 'bona' }); m.launch(h, 0.9); m.hold(h, 'left', 't', true); run(h, 0.5); + put(h, { x: 296, y: 300, vx: 0, vy: 0 }); run(h, 4); + assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`); }); test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { @@ -119,7 +166,7 @@ test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita assert.equal(c.step, 'result', `seme ${seed}: finita`); if (c.result.winner === 'player') won++; } - assert.ok(won >= 4, `vinte ${won} su 8`); + assert.ok(won >= 5, `vinte ${won} su 8`); }); test('il disegno del flipper non cambia lo stato', () => { @@ -129,23 +176,22 @@ test('il disegno del flipper non cambia lo stato', () => { 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(); + m.launch(c, 0.8); run(c, 1); calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && /^SALVATA \d s$/.test(t)), 'il salvataggio si vede'); 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', () => { +test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => { 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.closeupClose(); + assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say); + settle(g); g.act('open', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); assert.equal(g.closeup?.level, 'male'); - g.closeupClose(); - assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say); });