From 07b6a381c1dfca139d0ccd75515c07669fdde374 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sat, 31 Oct 2026 21:43:57 +0100 Subject: [PATCH] =?UTF-8?q?Il=20flipper=20dopo=20la=20seconda=20revisione?= =?UTF-8?q?=20di=20Codex:=20flipper=20vivi=20anche=20sul=20ponte,=20imbocc?= =?UTF-8?q?o=20solo=20in=20salita,=20ponti=20con=20la=20stessa=20energia?= =?UTF-8?q?=20su=20e=20gi=C3=B9=20e=20uscita=20nelle=20corsie=20di=20ritor?= =?UTF-8?q?no,=20combo=20che=20scade,=20scritte=20in=20coda=20col=20tragua?= =?UTF-8?q?rdo=20davanti,=20tabellone=20con=20l'esito=20giusto=20e=20le=20?= =?UTF-8?q?cifre=20che=20corrono=20in=20tempo=20fisso,=20tasti=20del=20mou?= =?UTF-8?q?se=20dalla=20maschera,=20glifi=20rifatti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T --- docs/STATO.md | 6 +- index.html | 3 +- src/engine/flipper-render.js | 106 +++++++++++++++++++---------------- src/engine/flipper.js | 71 ++++++++++++++--------- src/main.js | 35 +++++++----- tests/flipper.test.mjs | 56 ++++++++++++------ 6 files changed, 172 insertions(+), 105 deletions(-) diff --git a/docs/STATO.md b/docs/STATO.md index 9ec7948..295f1e8 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -66,7 +66,11 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, [DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). Poi (Andrea): i due ponti (rampe `ramps`: imbocco in salita, percorso sopra la tavola, uscita dall'altra parte; tutti e due di fila i PONTI), punti ×10 (traguardi 60.000/120.000/200.000), il tabellone a punti luminosi (cifre che corrono, scritte che lampeggiano, lampadine che girano; carattere 5×7 in flipper-render.js) e un clic sul - River che entra e va al flipper. + River che entra e va al flipper. Seconda revisione di Codex (31 ottobre): i flipper rispondono anche con la pallina + sul ponte, l'imbocco conta solo la velocità lungo il ponte, salita e discesa con la stessa energia (meno l'attrito), + l'uscita dritta nella corsia di ritorno, la combo che scade e si azzera con la pallina persa, le scritte in coda col + traguardo davanti, il tabellone che dice l'esito giusto, le cifre che corrono in tempo fisso, i due tasti del mouse + dalla maschera `buttons` (il destro è sempre il flipper di destra), i glifi 3 e 5 rifatti. - **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 259442c..aad4682 100644 --- a/index.html +++ b/index.html @@ -310,7 +310,8 @@ sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a destra, è il Regolo (10.000). I due ponti: imboccali dal basso con un colpo deciso, la pallina ci sale sopra e - scende dall'altra parte (15.000); tutti e due di fila, i PONTI (50.000).

+ scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un + bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.

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 diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 5aee75b..3c11bd1 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -4,7 +4,7 @@ // ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti // come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che // girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js). -import { FIELD, LANE, R, flipperSeg, alongRamp, waiting, canThrow, saveLeft } from './flipper.js'; +import { FIELD, LANE, R, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js'; import { text, panel } from './bocce-render.js'; const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' }; @@ -34,23 +34,27 @@ function regolo(ctx, o) { 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); } -// I ponti: una striscia di pietra lungo il percorso, col bordo scuro e la luce in cima; accesi, brillano d'oro. -function ramp(ctx, r, time) { - const path = () => { ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); }; +// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), un po' +// trasparente perché si veda la tavola sotto, più alta in cima (la quota), col bordo scuro e la luce; accesi, brillano. +const RAMP_LOOK = { sinistra: ['#c9b89a', '#8a7a5a'], destra: ['#b8c0c9', '#6a7a8a'] }; +function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; } +function ramp(ctx, r, time, who) { + const path = () => { ctx.beginPath(); for (let s = 0; s <= r.len; s += 4) { const p = rampPoint(r, Math.min(s, r.len)); s ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y); } }; + const [light, dark] = RAMP_LOOK[r.id], invite = who && who !== r.id && Math.sin(time * 6) > 0; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 14; path(); ctx.stroke(); - ctx.translate(0, -3); - ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : '#3a3028'; ctx.lineWidth = 13; path(); ctx.stroke(); - ctx.strokeStyle = '#9a9088'; ctx.lineWidth = 9; path(); ctx.stroke(); - ctx.strokeStyle = '#c9c0b0'; ctx.lineWidth = 7; path(); ctx.stroke(); + ctx.globalAlpha = 0.5; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke(); + ctx.globalAlpha = 0.8; + ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : invite ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke(); + ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke(); + ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke(); // Le pietre: trattini scuri a passo regolare lungo il ponte. - ctx.strokeStyle = 'rgba(60,50,40,0.5)'; ctx.lineWidth = 1; - for (let s = 6; s < r.len; s += 9) { const p = alongRamp(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); } - ctx.translate(0, 3); + ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1; + for (let s = 6; s < r.len; s += 9) { const p = rampPoint(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); } + ctx.globalAlpha = 1; // L'imbocco: la rampetta che sale, con la freccia. const [[x0, y], [x1]] = r.mouth; ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6); - ctx.fillStyle = '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill(); + ctx.fillStyle = invite ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill(); } function flipper(ctx, c, side) { const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); @@ -70,19 +74,19 @@ function plunger(ctx, c) { } function ball(ctx, b) { if (!b) return; - // Sul ponte la pallina è più in alto: più grande, con l'ombra più lontana. - const r = b.ramp ? R + 1.5 : R, sh = b.ramp ? 6 : 3; + // Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana. + const z = b.ramp?.z ?? 0, r = R + 1.5 * z, y = b.y - 2 - 4 * z, sh = 3 + 4 * z; ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill(); - const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, r); + const g = ctx.createRadialGradient(b.x - 1.5, y - 1.5, 0.5, b.x, y, r); g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068'); - ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, y, r, 0, Math.PI * 2); ctx.fill(); } // --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere. const GLYPHS = { '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], - '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11111', '00010', '00100', '00010', '00001', '10001', '01110'], - '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'], + '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], + '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '10000', '11110', '00001', '10001', '01110'], '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'], A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], @@ -101,6 +105,7 @@ const GLYPHS = { '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'], ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], + '+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], }; // Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') { @@ -119,44 +124,51 @@ function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = const shown = new WeakMap(); function board(ctx, c, time) { let d = shown.get(c); - if (!d) { d = { score: c.score, t: time, flash: 0, line: null }; shown.set(c, d); } - const dt = Math.min(0.1, Math.max(0, time - d.t)); d.t = time; - // Le cifre corrono: in mezzo secondo raggiungono i punti veri, a scatti di almeno 10. - if (d.score < c.score) d.score = Math.min(c.score, d.score + Math.max(10, Math.ceil((c.score - d.score) * dt * 6))); - if (d.score > c.score) d.score = c.score; - if (c.line && c.line !== d.line) { d.line = c.line; d.flash = time; } - if (!c.line) d.line = null; + if (!d) { d = { from: c.score, to: c.score, t0: time, flash: 0, lineId: 0 }; shown.set(c, d); } + // Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita + // subito il totale. + if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; } + const score = c.step === 'result' ? c.score : shownScore(d, time), hot = score !== c.score; + if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; } const x0 = 16, y0 = 50, w = 172, h = 180; ctx.fillStyle = '#0c0806'; ctx.fillRect(x0, y0, w, h); ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, y0 + 1, w - 2, h - 2); - // Le lampadine intorno, che girano a tempo (e impazziscono alle scritte grosse). - const beat = time * (d.flash && time - d.flash < 2 ? 12 : 3); + // Le lampadine intorno: tranquille di solito, impazzite per due secondi a ogni scritta. + const party = c.line && time - d.flash < 2, beat = time * (party ? 14 : 2); let n = 0; - const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? '#f0a24a' : '#6a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); }; + const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); }; for (let x = x0 + 6; x < x0 + w - 4; x += 8) lamp(x, y0 + 4); for (let y = y0 + 12; y < y0 + h - 4; y += 8) lamp(x0 + w - 5, y); for (let x = x0 + w - 10; x > x0 + 4; x -= 8) lamp(x, y0 + h - 5); for (let y = y0 + h - 12; y > y0 + 4; y -= 8) lamp(x0 + 4, y); text(ctx, 'FLIPPER DEL RIVER', x0 + w / 2, y0 + 16, 7, '#efd49e', 'center'); - // I punti, a cifre grosse color ambra, col bagliore caldo dietro. - const digits = String(Math.round(d.score)).padStart(7, '0'), hot = d.score < c.score; + // I punti, a cifre grosse color ambra col bagliore caldo dietro: sei cifre larghe, da un milione in su più fitte. + ctx.save(); ctx.beginPath(); ctx.rect(x0 + 8, y0 + 24, w - 16, h - 32); ctx.clip(); + const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4; ctx.fillStyle = hot ? 'rgba(255,120,30,0.22)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(x0 + 10, y0 + 26, w - 20, 34); - dots(ctx, digits, x0 + w / 2, y0 + 30, 4, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center'); - // La scritta: lampeggia per due secondi, poi resta; altrimenti palline e traguardo. - const flashing = d.line && time - d.flash < 2, visible = !flashing || Math.sin(time * 16) > -0.2; - const line = d.line ?? (c.result ? 'TRAGUARDO OK' : `TRAG. ${c.skill.goal}`); - if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); - dots(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 88, 2, '#f0a24a'); - // Il salvataggio: quanti secondi restano, o se è già stato usato. + dots(ctx, digits, x0 + w / 2, y0 + 29 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center'); + // La scritta grossa: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo. + const won = c.result?.winner === 'player'; + const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`); + const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3; + if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); + ctx.restore(); + // Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono. + text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a'); const save = saveLeft(c); - if (save > 0) text(ctx, `PALLINA SALVATA PER ${Math.ceil(save)} s`, x0 + 10, y0 + 108, 6, '#9fd27a'); - else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + 10, y0 + 108, 6, '#a08a60'); - // I rioni accesi, e i ponti fatti. - dots(ctx, 'RIONI', x0 + 10, y0 + 120, 2, '#f0a24a'); - c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 127, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040')); - dots(ctx, 'PONTI', x0 + 10, y0 + 142, 2, '#f0a24a'); - c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 149, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040')); - text(ctx, 'ANDREA', x0 + 10, y0 + 168, 6, '#a9a08a'); + if (save > 0) text(ctx, `SALVA ${Math.ceil(save)} s`, x0 + w - 10, y0 + 102, 7, '#9fd27a', 'right'); + else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + w - 10, y0 + 102, 6, '#a08a60', 'right'); + text(ctx, 'RIONI', x0 + 10, y0 + 124, 7, '#f0a24a'); + c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 124, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040')); + text(ctx, 'PONTI', x0 + 10, y0 + 146, 7, '#f0a24a'); + c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 146, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040')); + if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right'); + else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a'); +} +// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo. +export function shownScore(d, time) { + const k = Math.min(1, (time - d.t0) / 0.5); + return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; } function result(ctx, c) { panel(ctx, 16, 238, 172, 56); @@ -190,7 +202,7 @@ export function drawFlipper(ctx, c, def, bg) { plunger(ctx, c); // La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte. if (!c.ball?.ramp) ball(ctx, c.ball); - for (const r of c.ramps) ramp(ctx, r, time); + for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id); if (c.ball?.ramp) 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'); diff --git a/src/engine/flipper.js b/src/engine/flipper.js index e5e611d..b8d6067 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -15,7 +15,7 @@ export const PIVOT = { left: [280, 314], right: [360, 314] }; export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 }; // Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si // sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI. -export const RAMP = { speed: 260, climb: 0.42, slope: 380, combo: 12 }; +export const RAMP = { speed: 300, slope: 260, drag: 30, combo: 12, blend: 30 }; // 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 @@ -60,12 +60,18 @@ export function layout() { // discesa la riga LANE_LINE. const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false })); // Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in - // fondo al percorso, dall'altra parte. Si incrociano in cima, come due ponti. + // fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola, + // in cima, come due ponti. const ramps = [ - { id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [247, 150], [252, 96], [272, 56], [312, 36], [352, 44], [382, 72], [398, 112], [400, 134]], lit: 0, touching: false }, - { id: 'destra', mouth: [[382, 236], [404, 236]], path: [[393, 236], [393, 150], [388, 96], [368, 56], [328, 36], [288, 44], [258, 72], [242, 112], [240, 134]], lit: 0, touching: false }, + { id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false }, + { id: 'destra', mouth: [[382, 236], [404, 236]], path: [[393, 236], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false }, ]; - for (const r of ramps) { r.len = 0; r.cum = [0]; for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); } } + // Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende. + for (const r of ramps) { + r.len = 0; r.cum = [0]; + for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); } + r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)]; + } return { walls, targets, bumpers, regolo, lanes, ramps }; } // Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì. @@ -81,7 +87,7 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) { const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7, holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false, flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } }, - ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, visual: { time: 0 }, ...layout() }; + ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, visual: { time: 0 }, ...layout() }; newBall(c); return c; } @@ -96,11 +102,15 @@ 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; } +// Le scritte del tabellone: una alla volta, due secondi l'una; il traguardo passa davanti a tutte. +function say(c, line, front = false) { + if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; } + else c.lines.push(line); +} // 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.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.events.push({ kind: 'flipper-palio', power: 1 }); } if (c.result) c.result.score = c.score; } @@ -138,7 +148,7 @@ export function flipperSeg(c, side) { 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.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } } if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt); c.accumulator += dt; @@ -157,10 +167,11 @@ function moveFlippers(c, h) { function step(c) { c.sim += STEP; const ball = c.ball; + for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); + if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null; if (c.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]; } - for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); - if (ball.ramp) { rideRamp(c, ball); return; } + if (ball.ramp) { moveFlippers(c, STEP); rideRamp(c, ball); return; } ball.vy += c.skill.g * STEP; limit(ball); // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. @@ -205,36 +216,42 @@ function enterRamps(c, from, ball) { const [[x0, y], [x1]] = r.mouth; if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue; const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k; - if (x < x0 || x > x1) continue; - const v = Math.hypot(ball.vx, ball.vy); - if (v < RAMP.speed) continue; - ball.ramp = { id: r.id, s: 0, v: v * 0.9 }; + if (x < x0 + R || x > x1 - R) continue; + // Conta solo la velocità lungo il ponte (sfiorarlo di traverso non fa salire). + const p = alongRamp(r, 0), forward = ball.vx * p.dx + ball.vy * p.dy; + if (forward < RAMP.speed) continue; + ball.ramp = { id: r.id, s: 0, v: forward * 0.95, off: x - p.x }; c.events.push({ kind: 'flipper-rampa', power: 0.7 }); return; } } +// Sul ponte: si sale fino alla cima perdendo velocità, si scende riprendendola (la stessa energia, meno un po' +// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1). +export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); } function rideRamp(c, ball) { - const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, top = r.len * RAMP.climb; - on.v += (on.s < top ? -RAMP.slope : RAMP.slope) * STEP; + const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top; + on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP; on.s += on.v * STEP; - const p = alongRamp(r, on.s); - ball.x = p.x; ball.y = p.y; + const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend); + ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s); if (on.s >= r.len) { - // In fondo al ponte: giù dall'altra parte, con la velocità che ha. + // In fondo al ponte: giù dall'altra parte, con la velocità che ha. Gli ostacoli lì sotto, se per caso la + // pallina esce sopra uno di loro, non contano come urto nuovo. const v = Math.max(on.v, 150); ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null; + for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true; add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); - if (c.lastRamp && c.lastRamp.id !== r.id && c.sim - c.lastRamp.at < RAMP.combo) { add(c, SCORE.ponti); say(c, 'I PONTI!'); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; } - else { say(c, r.id === 'sinistra' ? 'PONTE SINISTRO' : 'PONTE DESTRO'); c.lastRamp = { id: r.id, at: c.sim }; } + if (c.lastRamp && c.lastRamp.id !== r.id) { add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; } + else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; } } else if (on.s <= 0) { - // Non ce l'ha fatta: ricasca dall'imbocco. - ball.vx = 0; ball.vy = Math.max(60, -on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1; - } else ball.vx = p.dx * on.v, ball.vy = p.dy * on.v; + // Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte. + ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1; + } else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; } } function drain(c) { c.events.push({ kind: 'flipper-buco', power: 0.8 }); if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } - c.balls--; c.saved = false; + c.balls--; c.saved = false; c.lastRamp = null; if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; } c.ball = null; c.step = 'result'; c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal }; @@ -317,7 +334,7 @@ export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFini 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); + return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true); } export function release(c, pointerId = 'ptr') { if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime)); diff --git a/src/main.js b/src/main.js index 3481f8a..356d999 100644 --- a/src/main.js +++ b/src/main.js @@ -331,6 +331,7 @@ $('spots-button').addEventListener('click', showSpots); // --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»). function openPause() { + dropPointers(); if (!running) return; running = false; game.closeupCancel(); @@ -493,8 +494,18 @@ 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. +// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte ('mouse-right', sempre il +// flipper di destra), e i due tasti si allineano alla maschera `e.buttons` a ogni evento (syncMouse). const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId; +function syncMouse(e) { + const left = !!(e.buttons & 1), right = !!(e.buttons & 2); + if (right && !extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); } + if (!right && extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); } + if (left && !press) { game.closeupAim(scenePoint(e)); press = { closeup: true, pointerId: e.pointerId, state: game.closeup }; if (!game.skip()) game.closeupPress(e.pointerId); } + if (!left && press?.closeup && press.pointerId === e.pointerId) { const state = press.state; press = null; if (game.closeup === state) game.closeupRelease(e.pointerId); } +} +// Pausa, finestra che perde il fuoco: niente resta premuto, né nel motore né qui. +function dropPointers() { press = null; extra.clear(); } canvas.addEventListener('pointerdown', e => { if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return; @@ -523,7 +534,8 @@ 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. + // Il flipper: un secondo dito comanda l'altro flipper, per conto suo; il tasto destro del mouse quello di destra. + if (type === 'flipper' && e.pointerType === 'mouse' && e.button === 2) { syncMouse(e); canvas.setPointerCapture(e.pointerId); return; } if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) { game.closeupAim(scenePoint(e)); if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); } @@ -546,8 +558,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 (e.pointerType === 'mouse' && game?.closeup && story.closeups[game.closeup.id].type === 'flipper' && (extra.has('mouse-right') || press?.closeup)) { syncMouse(e); return; } + if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupRelease(e.pointerId); return; } if (!press) return; // 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. @@ -585,13 +597,14 @@ canvas.addEventListener('pointerup', e => { press = null; }); canvas.addEventListener('pointercancel', e => { + if (extra.has('mouse-right') && e.pointerType === 'mouse') { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press) clearTimeout(press.timer); if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId); press = null; }); -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('lostpointercapture', e => { if (extra.has('mouse-right') && e.pointerType === 'mouse' && !(e.buttons & 2)) { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); @@ -599,13 +612,9 @@ 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; - } + // Un tasto del mouse premuto o lasciato mentre l'altro è giù arriva come pointermove (pulsanti «in accordo»): + // al flipper si legge la maschera dei pulsanti e si allineano i due comandi. + if (e.pointerType === 'mouse' && e.button >= 0 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { syncMouse(e); return; } // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // 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.) @@ -727,7 +736,7 @@ for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) { // Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza). $('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); }); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); }); -addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); +addEventListener('blur', () => { dropPointers(); game?.closeupCancel(); }); // --- Verbi. for (const verb of VERBS) { diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index e5256bd..e31f19e 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -104,7 +104,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua 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/); + assert.ok([c.line, ...c.lines].some(l => /PIEVE/.test(l ?? '')), 'la scritta della Pieve (in coda dietro al Regolo)'); // Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva). c.score = 0; for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) { @@ -112,7 +112,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua 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'); + assert.ok([c.line, ...c.lines].some(l => /PALIO/.test(l ?? ''))); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro'); // In salita non conta. put(c, { x: 287, y: c.lanes[1].y + 6, vx: 0, vy: -150 }); m.update(c, 1 / 60); m.update(c, 1 / 60); assert.ok(!c.lanes[1].lit); @@ -158,22 +158,36 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; 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('i ponti: imboccati forte si sale e si scende dall\'altra parte (e la pallina non urta la tavola sotto); piano si ricasca; tutti e due di fila, i PONTI', () => { +test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => { const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; - put(c, { x: 247, y: 250, vx: 0, vy: -420 }); - let onRamp = 0, top = 999; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } } - assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 50, 'fin sopra la tavola'); - assert.ok(!c.ball.ramp && c.ball.x > 380, `scesa dall'altra parte (${c.ball.x.toFixed(0)}, ${c.ball.y.toFixed(0)})`); - assert.equal(c.score, m.SCORE.ramp); assert.match(c.line, /PONTE SINISTRO/); assert.ok(c.ramps[0].lit > 0); + put(c, { x: 247, y: 250, vx: 0, vy: -470 }); + let onRamp = 0, top = 999, exit = null; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } else if (onRamp && !exit) exit = { x: c.ball.x, y: c.ball.y }; } + assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola'); + assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`); + assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0); assert.ok(c.events.some(e => e.kind === 'flipper-rampa')); - // Il secondo ponte subito dopo: i PONTI. - put(c, { x: 393, y: 250, vx: 0, vy: -420, ramp: null }); run(c, 2.5); - assert.equal(c.score, 3 * m.SCORE.ramp - m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`); assert.match(c.line ?? '', /PONTI/); - // Troppo piano: non ci arriva, ricasca dall'imbocco senza punti. + // Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati). + const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 247, y: 250, vx: 0, vy: -470 }); run(f, 0.3); + assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1); + assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`); + // Il secondo ponte subito dopo: i PONTI, col bonus. + put(c, { x: 393, y: 250, vx: 0, vy: -470, ramp: null }); run(c, 2.5); + assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`); + assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? ''))); + assert.equal(c.lastRamp, null, 'la combo è spesa'); + // Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti. const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0; - put(d, { x: 247, y: 250, vx: 0, vy: -300 }); let climbed = false; for (let i = 0; i < 90; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; } - assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.equal(d.score % m.SCORE.ramp === 0 && d.score >= m.SCORE.ramp, false, 'niente punti del ponte'); - void climbed; + put(d, { x: 247, y: 250, vx: 0, vy: -335 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; } + assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte'); + // Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no. + const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0; + put(e, { x: 247, y: 236.1, vx: 500, vy: -30 }); run(e, 0.5); + assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita'); + // La combo scade dopo dodici secondi, e si azzera perdendo la pallina. + const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 }); + run(g, 13); assert.equal(g.lastRamp, null, 'scaduta'); + g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 330, vx: 0, vy: 300 }); run(g, 1); + assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa'); }); test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { @@ -187,7 +201,7 @@ test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita assert.ok(won >= 5, `vinte ${won} su 8`); }); -test('il disegno del flipper non cambia lo stato', () => { +test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giusto e le cifre corrono', async () => { const calls = [], gradient = { addColorStop() {} }; const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); const c = m.newFlipper(11, { level: 'bona' }), bg = {}; @@ -198,11 +212,21 @@ test('il disegno del flipper non cambia lo stato', () => { assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi'); c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw(); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!')); + // Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti). + const l = m.newFlipper(12, { level: 'bona' }); l.step = 'result'; l.ball = null; l.result = { winner: 'cpu', score: 0, goal: 60000 }; calls.length = 0; + drawFlipper(ctx, l, story.closeups.flipper, bg); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FINE PARTITA')); + // Le cifre che corrono: in mezzo secondo da dove erano a dove devono arrivare, a scatti di dieci. + const { shownScore } = await import('../src/engine/flipper-render.js'); + const d = { from: 1000, to: 51000, t0: 10 }; + assert.equal(shownScore(d, 10), 1000); assert.equal(shownScore(d, 10.25) % 10, 0); assert.ok(shownScore(d, 10.25) > 20000 && shownScore(d, 10.25) < 30000); assert.equal(shownScore(d, 10.5), 51000); }); test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => { const g = new Game(story); g.go('via_provinciale'); settle(g); assert.equal(g.hotspot('river').default, 'open'); + const [bx, by, bw, bh] = g.hotspot('river').box; + assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'river', 'il River si clicca'); g.act('use', 'river'); settle(g); assert.ok(g.options().some(o => o.text === 'Una partita seria.')); g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g);