diff --git a/assets/voices/andrea-00b37d42f9.mp3 b/assets/voices/andrea-00b37d42f9.mp3
new file mode 100644
index 0000000..25e9afc
Binary files /dev/null and b/assets/voices/andrea-00b37d42f9.mp3 differ
diff --git a/assets/voices/andrea-5811ae33d6.mp3 b/assets/voices/andrea-5811ae33d6.mp3
deleted file mode 100644
index f2cce4c..0000000
Binary files a/assets/voices/andrea-5811ae33d6.mp3 and /dev/null differ
diff --git a/assets/voices/andrea-74e53fb53a.mp3 b/assets/voices/andrea-74e53fb53a.mp3
new file mode 100644
index 0000000..c4eb69b
Binary files /dev/null and b/assets/voices/andrea-74e53fb53a.mp3 differ
diff --git a/assets/voices/andrea-82ab821c37.mp3 b/assets/voices/andrea-82ab821c37.mp3
new file mode 100644
index 0000000..b5b96c2
Binary files /dev/null and b/assets/voices/andrea-82ab821c37.mp3 differ
diff --git a/assets/voices/andrea-b6d9563c93.mp3 b/assets/voices/andrea-b6d9563c93.mp3
deleted file mode 100644
index 38b6f8c..0000000
Binary files a/assets/voices/andrea-b6d9563c93.mp3 and /dev/null differ
diff --git a/assets/voices/andrea-d420a5e136.mp3 b/assets/voices/andrea-d420a5e136.mp3
deleted file mode 100644
index bc663fa..0000000
Binary files a/assets/voices/andrea-d420a5e136.mp3 and /dev/null differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index 6dc7f30..91f7398 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -2983,17 +2983,17 @@
"file": "andrea-1b517ab1ef.mp3",
"sec": 0.9
},
- "andrea|Seimila punti e sono contento.": {
- "file": "andrea-b6d9563c93.mp3",
- "sec": 2.02
+ "andrea|Sessantamila punti e sono contento.": {
+ "file": "andrea-74e53fb53a.mp3",
+ "sec": 2.65
},
- "andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": {
- "file": "andrea-5811ae33d6.mp3",
- "sec": 3.91
+ "andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
+ "file": "andrea-82ab821c37.mp3",
+ "sec": 4.43
},
- "andrea|Sedicimila. Il record del River, o quasi.": {
- "file": "andrea-d420a5e136.mp3",
- "sec": 4.38
+ "andrea|Duecentomila. Il record del River, o quasi.": {
+ "file": "andrea-00b37d42f9.mp3",
+ "sec": 4.32
},
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3",
diff --git a/index.html b/index.html
index 7169934..259442c 100644
--- a/index.html
+++ b/index.html
@@ -306,15 +306,16 @@
Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e
- scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (3000 punti). I tre funghi
- sono i bastioni della Fortezza (100 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
- destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a
- destra, è il Regolo (1000).Come si gioca
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.
+ fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000, + a fassi male 200.000; 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 72be1bb..5aee75b 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -1,9 +1,10 @@ // Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti // meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono), -// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, 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'; +// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due +// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti +// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che +// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js). +import { FIELD, LANE, R, flipperSeg, alongRamp, waiting, canThrow, saveLeft } from './flipper.js'; import { text, panel } from './bocce-render.js'; const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' }; @@ -33,6 +34,24 @@ 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)); }; + 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(); + // 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); + // 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(); +} function flipper(ctx, c, side) { const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); ctx.lineCap = 'round'; @@ -51,29 +70,98 @@ function plunger(ctx, c) { } function ball(ctx, b) { if (!b) return; - ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, R, 0, Math.PI * 2); ctx.fill(); - const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, R); + // 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; + 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); 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, b.y, r, 0, Math.PI * 2); ctx.fill(); } -// 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')); + +// --- 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'], + '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], + '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'], + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], + O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], + Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], + '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'], + ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], + '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], +}; +// 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') { + const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch; + const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1); + chars.forEach((ch, i) => { + const g = GLYPHS[ch] ?? GLYPHS[' ']; + for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) { + ctx.fillStyle = g[row][col] === '1' ? on : off; + ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size); + } + }); +} +// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola +// presentazione, fuori dalla partita (così disegnare non cambia niente). +const shown = new WeakMap(); +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; + 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); + 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); }; + 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; + 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. 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'); + 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'); } function result(ctx, c) { - 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'); + panel(ctx, 16, 238, 172, 56); + text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center'); + text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 274, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); } export function drawFlipper(ctx, c, def, bg) { @@ -100,10 +188,13 @@ export function drawFlipper(ctx, c, def, bg) { regolo(ctx, c.regolo); for (const side of ['left', 'right']) flipper(ctx, c, side); plunger(ctx, c); - ball(ctx, c.ball); + // 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); + 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'); - board(ctx, c); + board(ctx, c, time); if (c.step === 'result') result(ctx, c); ctx.restore(); } diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 0f37f5d..e5e611d 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -11,15 +11,19 @@ export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5; export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 }; export const PIVOT = { left: [280, 314], right: [360, 314] }; -export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 }; +// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»). +export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 }; +// 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 }; // Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta. const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5; // I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la // gravità. La spinta dei funghi è uguale per tutti. export const LEVELS = { - bona: { g: 680, save: 8, kick: 340, goal: 6000 }, - seria: { g: 740, save: 4, kick: 340, goal: 10000 }, - male: { g: 800, save: 1, kick: 340, goal: 16000 }, + bona: { g: 680, save: 8, kick: 340, goal: 60000 }, + seria: { g: 740, save: 4, kick: 340, goal: 120000 }, + male: { g: 800, save: 1, kick: 340, goal: 200000 }, }; export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio']; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); @@ -55,7 +59,21 @@ export function layout() { // 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 }; + // 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. + 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 }, + ]; + 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); } } + return { walls, targets, bumpers, regolo, lanes, ramps }; +} +// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì. +export function alongRamp(r, s) { + s = clamp(s, 0, r.len); + let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++; + const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L; + return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L }; } export function newFlipper(seed = Date.now() >>> 0, def = {}) { @@ -63,11 +81,11 @@ 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, visual: { time: 0 }, ...layout() }; + ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: 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; c.rest = 0; } +function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null }; 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; @@ -141,6 +159,8 @@ function step(c) { const ball = c.ball; 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; } ball.vy += c.skill.g * STEP; limit(ball); // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. @@ -157,6 +177,7 @@ function step(c) { limit(ball); } crossLanes(c, from, ball); + enterRamps(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; } @@ -176,11 +197,45 @@ function crossLanes(c, from, ball) { 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; } } +// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il +// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna +// indietro e ricasca dall'imbocco. +function enterRamps(c, from, ball) { + for (const r of c.ramps) { + 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 }; + c.events.push({ kind: 'flipper-rampa', power: 0.7 }); + return; + } +} +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; + on.s += on.v * STEP; + const p = alongRamp(r, on.s); + ball.x = p.x; ball.y = p.y; + if (on.s >= r.len) { + // In fondo al ponte: giù dall'altra parte, con la velocità che ha. + const v = Math.max(on.v, 150); + ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null; + 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 }; } + } 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; +} function drain(c) { c.events.push({ kind: 'flipper-buco', power: 0.8 }); - if (c.sim < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; } + if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } c.balls--; c.saved = false; - if (c.balls > 0) { say(c, `Persa: ne restano ${c.balls}.`); newBall(c); return; } + 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 }; } diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 2213f23..a3f0236 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -169,6 +169,7 @@ export class Soundscape { else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); } else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); } else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan); + else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); } else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. diff --git a/src/story/story.js b/src/story/story.js index e2ae9f9..75c8934 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -1675,7 +1675,8 @@ export const story = { entry: [140, 340], hotspots: [ { - id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up', + // Un clic sul River entra e va al flipper (Andrea: «Usa» o «Apri» non si capiva). + id: 'river', name: 'il River: dentro c\'è il flipper', box: [60, 128, 190, 110], at: [160, 320], face: 'up', default: 'open', actions: { look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }], // Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo. @@ -4458,9 +4459,9 @@ export const story = { nodes: { start: { options: [ - { text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Seimila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] }, - { text: 'Una partita seria.', next: 'end', then: [{ say: '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: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Sessantamila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] }, + { text: 'Una partita seria.', next: 'end', then: [{ say: 'Centoventimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'Duecentomila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] }, { text: 'Un\'altra volta.', next: 'end' }, ], }, diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 9b34e91..e5256bd 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -55,11 +55,11 @@ test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una v 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 10000/); + assert.match(m.status(c), /Ne servivano 120000/); const d = m.newFlipper(5, { level: 'bona' }); m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla'); - assert.match(d.line ?? '', /salvata/); + assert.match(d.line ?? '', /SALVATA/); m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); assert.equal(d.balls, 2, 'la seconda volta è persa davvero'); // Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex). @@ -139,7 +139,7 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d // 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)); + e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true)); }); test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => { @@ -149,7 +149,7 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; 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.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 }; g.closeupClose(); settle(g); assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto'); // La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore. @@ -158,6 +158,24 @@ 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', () => { + 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); + 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. + 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; +}); + test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { let won = 0; for (let seed = 1; seed <= 8; seed++) { @@ -177,13 +195,14 @@ test('il disegno del flipper non cambia lo stato', () => { 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); 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.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!')); }); -test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => { +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'); 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);