Il flipper rivisto con la revisione di Codex: punti solo a un urto nuovo, sottopassi contro gli attraversamenti, molla che arriva sempre in campo (senza cancelletto), salvataggio col tempo della simulazione e in vista, corsie che contano attraversando la riga, traguardo acquisito appena raggiunto, annullare non lancia, due dita e tasto destro, pulsanti dei flipper sempre attivi

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-10-31 20:24:01 +01:00
co-authored by Claude Fable 5.1
parent 7076123fcf
commit 1fc446d02a
14 changed files with 386 additions and 208 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2987,13 +2987,13 @@
"file": "andrea-b6d9563c93.mp3", "file": "andrea-b6d9563c93.mp3",
"sec": 2.02 "sec": 2.02
}, },
"andrea|Dodicimila. Come quando ci lasciavo le cinquecento lire.": { "andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": {
"file": "andrea-01a2eb256d.mp3", "file": "andrea-5811ae33d6.mp3",
"sec": 4.12 "sec": 3.91
}, },
"andrea|Venticinquemila. Il record del River, o quasi.": { "andrea|Sedicimila. Il record del River, o quasi.": {
"file": "andrea-e455fd8483.mp3", "file": "andrea-d420a5e136.mp3",
"sec": 4.2 "sec": 4.38
}, },
"andrea|Due tiri alla bona, tanto per scaldarmi.": { "andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3", "file": "andrea-d564365fc2.mp3",
+7 -1
View File
@@ -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 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 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`. 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 - **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.» 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). - **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere).
+7 -6
View File
@@ -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 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 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 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 destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a
(1000).</p> destra, è il Regolo (1000).</p>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena, o i pulsanti «← Flipper» e «Flipper →», o le <p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
frecce sinistra e destra. Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra.
8 secondi, seria per 3, a fassi male mai). Si vince arrivando al traguardo: alla bona 6000 punti, seria 12000, Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
a fassi male 25000.</p> 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.</p>
</details> </details>
<details id="cerbottana-help" hidden><summary>Come si gioca</summary> <details id="cerbottana-help" hidden><summary>Come si gioca</summary>
<p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio <p>La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio
+50 -42
View File
@@ -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 // 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, // meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
// funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper, molla e pallina. A sinistra, sul fianco nero del // corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper,
// mobile, il quadro dei punti. Niente filtri né passate a tutto schermo (vedi risveglio.js). // molla e pallina. A sinistra, sul fianco nero del mobile, il quadro dei punti. Niente filtri né passate a tutto
import { FIELD, LANE, R, FLIP, PIVOT, SCORE, waiting, canThrow } from './flipper.js'; // schermo (vedi risveglio.js).
import { FIELD, LANE, R, PIVOT, flipperSeg, waiting, canThrow, saveLeft } from './flipper.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' }; const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => { const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => {
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.strokeStyle = dark; ctx.lineWidth = 4; 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.5; 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) { 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 = 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(); ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - 2, 0, Math.PI * 2); ctx.fill();
} }
function bumper(ctx, b, time) { function bumper(ctx, b) {
const k = b.hit > 0 ? 1 + b.hit * 1.2 : 1, r = b.r * k; 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(); 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, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16');
disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e'); 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); 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) { 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.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 = '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 = FLIP.r * 2 + 2; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); 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 = FLIP.r * 2 - 1; 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(); 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'); 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'); 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, l'ultimo avviso. // Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, il salvataggio, l'avviso.
function board(ctx, c, def) { function board(ctx, c) {
panel(ctx, 22, 60, 160, 150); panel(ctx, 16, 56, 172, 168);
text(ctx, 'FLIPPER DEL RIVER', 102, 76, 7, '#efd49e', 'center'); text(ctx, 'FLIPPER DEL RIVER', 102, 72, 7, '#efd49e', 'center');
text(ctx, String(c.step === 'result' ? c.result.score : c.score).padStart(6, '0'), 102, 100, 14, '#fff4c8', 'center'); text(ctx, String(c.score).padStart(6, '0'), 102, 98, 16, '#fff4c8', 'center');
text(ctx, 'PUNTI', 102, 116, 5, '#a9a08a', 'center'); text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 28, 124, 7, '#d7ccb4');
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 34, 136, 6, '#d7ccb4'); text(ctx, c.result ? 'TRAGUARDO FATTO' : `TRAGUARDO ${c.skill.goal}`, 28, 140, 7, c.result ? '#9fd27a' : '#d7ccb4');
text(ctx, `TRAGUARDO ${c.skill.goal}`, 34, 150, 6, '#d7ccb4'); text(ctx, 'RIONI', 28, 160, 7, '#d7ccb4');
text(ctx, 'RIONI', 34, 168, 6, '#d7ccb4'); c.lanes.forEach((l, i) => disc(ctx, 86 + i * 22, 160, 7, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650'));
c.lanes.forEach((l, i) => disc(ctx, 80 + i * 20, 168, 6, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650')); const save = saveLeft(c);
text(ctx, (def.names?.player ?? 'Andrea').toUpperCase(), 34, 190, 6, '#efd49e'); if (save > 0) text(ctx, `SALVATA ${Math.ceil(save)} s`, 28, 180, 7, '#9fd27a');
if (c.line) text(ctx, c.line.toUpperCase(), 102, 203, 5, '#ffe08c', 'center'); 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) { function result(ctx, c) {
panel(ctx, 20, 230, 164, 56); panel(ctx, 16, 236, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 246, 7, '#efd49e', 'center'); 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, 266, 7, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', '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) { export function drawFlipper(ctx, c, def, bg) {
@@ -70,32 +81,29 @@ export function drawFlipper(ctx, c, def, bg) {
ctx.save(); ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); 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); } 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. // Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
for (const l of c.lanes) disc(ctx, l.x, l.y + 18, 4, l.lit ? COLORS[l.rione] : 'rgba(40,36,30,0.6)', l.lit ? '#fff4c8' : 'rgba(0,0,0,0.4)'); for (const l of c.lanes) {
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7);
for (const w of c.walls) { disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050');
if (w.gate) { ctx.strokeStyle = '#8a8078'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(...w.a); ctx.lineTo(...w.b); ctx.stroke(); ctx.setLineDash([]); continue; }
if (w.sling) { rail(ctx, w.a, w.b, '#5a1d16', '#e8584a'); continue; }
rail(ctx, w.a, w.b);
} }
// I bersagli a caduta: in piedi colorati, caduti un segno a terra. // 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) { for (const t of c.targets) {
const [x, y0] = t.a, y1 = t.b[1]; 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; } 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 = '#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 = 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); for (const b of c.bumpers) bumper(ctx, b);
// Il Regolo: il bersaglio verde con la corona, che brilla quando è colpito. regolo(ctx, c.regolo);
disc(ctx, c.regolo.x, c.regolo.y, c.regolo.r + (c.regolo.hit > 0 ? 2 : 0), c.regolo.hit > 0 ? '#c9ff9a' : '#3fa34d', '#1e4a24');
ctx.fillStyle = '#ffd45a'; ctx.fillRect(c.regolo.x - 3, c.regolo.y - c.regolo.r - 4, 6, 3);
for (const side of ['left', 'right']) flipper(ctx, c, side); for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c); plunger(ctx, c);
ball(ctx, c.ball); ball(ctx, c.ball);
// Da fermo, l'invito: la molla da caricare. // 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'); 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); if (c.step === 'result') result(ctx, c);
ctx.restore(); ctx.restore();
} }
export { SCORE };
+159 -85
View File
@@ -1,23 +1,30 @@
// Il flipper del River (Andrea, 31 ottobre: «una tavola bellissima, con San Piero dentro»). Visto dall'alto, la tavola // 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 // 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), // 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 // abbatte i bersagli della Pieve a sinistra e della torre a destra, tocca il Regolo, e giù dalle sponde elastiche ai
// elastiche ai due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al // due flipper, con la Sieve dipinta che scorre nel buco in mezzo. Tre palline; si vince arrivando al traguardo di
// traguardo di punti del livello. Unità: i pixel del primo piano. La fisica va a passo fisso (come il biliardo). // 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 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 PIVOT = { left: [280, 314], right: [360, 314] };
export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 }; 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 = { export const LEVELS = {
bona: { g: 620, save: 8, kick: 300, goal: 6000 }, bona: { g: 680, save: 8, kick: 340, goal: 6000 },
seria: { g: 780, save: 3, kick: 340, goal: 12000 }, seria: { g: 740, save: 4, kick: 340, goal: 10000 },
male: { g: 880, save: 0, kick: 380, goal: 25000 }, male: { g: 800, save: 1, kick: 340, goal: 16000 },
}; };
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio']; export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); 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; } 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 // 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. // (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 = []; const walls = [];
for (let i = 1; i < arc.length; i++) walls.push(seg(arc[i - 1], arc[i], { e: 0.6 })); 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])); 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. // La corsia della molla, col fondo. Niente cancelletto: una pallina che ricasca nella corsia si rilancia e basta
walls.push(seg([432, 70], [432, 336]), seg([432, 70], [448, 70], { gate: true }), seg([432, 326], [448, 326], { e: 0.2 })); // (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. // 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 })); 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. // I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
// (La guida passa sopra il perno e continua un po' lungo il flipper: finendo sul perno la pallina ci restava incastrata.) // 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([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 })); 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. // 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. // 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])); walls.push(seg([210, 146], [210, 198]), seg([429, 146], [429, 198]));
const targets = []; 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 }); 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 })); 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. // 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 regolo = { x: 404, y: 62, r: 7, hit: 0, touching: false };
const lanes = [257, 297, 340, 381].map((x, i) => ({ x, y: 88, rione: RIONI[i], lit: 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 }; return { walls, targets, bumpers, regolo, lanes };
} }
export function newFlipper(seed = Date.now() >>> 0, def = {}) { export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria'; 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, 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: [] } }, 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, visual: { time: 0 }, ...layout() }; ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, visual: { time: 0 }, ...layout() };
newBall(c); newBall(c);
return 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 waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding; export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow; 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 aimFree = () => true;
export const powerAt = time => clamp(time / CHARGE, 0, 1); 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 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) { export function launch(c, power) {
if (!waiting(c)) return false; 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. // 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.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 * power }); c.events.push({ kind: 'flipper-lancio', power: 0.5 + 0.5 * clamp(power, 0, 1) });
return true; return true;
} }
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno. // 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) { export function hold(c, side, who, down) {
const f = c.flippers[side]; if (!f) return false; const f = c.flippers[side]; if (!f) return false;
const was = f.held.length > 0; const was = f.held.length > 0;
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who); 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 }); if (!was && f.held.length && c.step === 'play') c.events.push({ kind: 'flipper-flip', power: 0.6 });
return true; 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. // La fisica, a passi fissi.
export function update(c, dt) { export function update(c, dt) {
@@ -95,89 +126,120 @@ export function update(c, dt) {
c.accumulator += dt; c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); } while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
} }
function flipperSeg(c, side) { function moveFlippers(c, h) {
const f = c.flippers[side], [px, py] = PIVOT[side];
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
}
function step(c) {
// I flipper: su se tenuti, giù se no; velocità angolare costante.
for (const side of ['left', 'right']) { for (const side of ['left', 'right']) {
const f = c.flippers[side]; 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 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; const previous = f.angle;
f.omega = Math.abs(d) <= move ? 0 : Math.sign(d) * FLIP.speed; f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
f.angle = Math.abs(d) <= move ? target : f.angle + Math.sign(d) * move; f.omega = (f.angle - previous) / h;
} }
}
function step(c) {
c.sim += STEP;
const ball = c.ball; 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; ball.vy += c.skill.g * STEP;
const v = Math.hypot(ball.vx, ball.vy); limit(ball);
if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
ball.x += ball.vx * STEP; ball.y += ball.vy * STEP; 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 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 t of c.targets) if (!t.down) collideTarget(c, ball, 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 }); } for (const b of c.bumpers) collideBumper(c, ball, b);
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 }); } collideRegolo(c, ball);
for (const side of ['left', 'right']) collideFlipper(c, ball, side); for (const side of ['left', 'right']) collideFlipper(c, ball, side);
// Le corsie si accendono entrando (non restandoci sopra: dopo il Palio ripartono spente). limit(ball);
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; 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 è
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. // 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; } 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; } 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 ricasca sulla molla: si rilancia. // Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia.
if (ball.x > LANE.x0 && ball.y > 315 && Math.abs(ball.vy) < 8 && !ball.waiting) newBall(c); 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); if (ball.y > FIELD.y1 + R * 2) drain(c);
} }
function dropTarget(c, t) { 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; } }
t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); 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);
const bank = c.targets.filter(k => k.bank === t.bank); // Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
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 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) { function drain(c) {
c.events.push({ kind: 'flipper-buco', power: 0.8 }); 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; 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.ball = null; c.step = 'result';
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal }; 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. // Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
function collideSeg(c, ball, w) { // 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 [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; 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); 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; } 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; ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny; const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching;
if (vn < 0) { w.touching = true;
// La sponda elastica spinge via, un po' a caso come la gomma vera (così la pallina non fa il ping-pong in eterno). if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
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 }); } return { vn: -vn, nx, ny, fresh };
else { ball.vx -= (1 + w.e) * vn * nx; ball.vy -= (1 + w.e) * vn * ny; }
}
return true;
} }
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; 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; 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; 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; } 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) { function collideBumper(c, ball, b) {
const a = Math.atan2(ball.y - o.y, ball.x - o.x) + (random(c) - 0.5) * 0.3; const hit = hitCircle(ball, b, 0.2);
ball.vx = Math.cos(a) * speed; ball.vy = Math.sin(a) * speed; 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. // Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
function collideFlipper(c, ball, side) { 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 // --- 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 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') { export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false; 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; for (const side of ['left', 'right']) if (c.flippers[side].held.includes(pointerId)) any = hold(c, side, pointerId, false) || any;
return any; return any;
} }
export const cancel = (c, pointerId) => pointerId === undefined ? release(c, c.pointerId ?? 'ptr') : release(c, pointerId); // Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
// I pulsanti «← Flipper» e «Flipper →» (tenuti premuti), e le frecce. 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 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 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 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) { 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 === '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 === ' ') { 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; } 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 const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
export function status(c) { 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}.`}`; 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}`; 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}`; 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}`;
} }
+24 -3
View File
@@ -492,9 +492,12 @@ const debugView = () => debug && debugShown && game && { mouse, points: debugPoi
const TOUCH_REACH = 22; const TOUCH_REACH = 22;
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0; const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
let press = null; 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 => { canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } 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. // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { if (game.closeup) {
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type]; 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); } if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return; 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; if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type)) { 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) }; press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
}); });
canvas.addEventListener('pointerup', e => { 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; if (!press) return;
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no // 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. // 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; press = null;
}); });
canvas.addEventListener('pointercancel', e => { 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?.closeup && e.pointerId !== press.pointerId) return;
if (press) clearTimeout(press.timer); if (press) clearTimeout(press.timer);
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId); if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
press = null; 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 => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -587,6 +599,13 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; 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, // 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. // 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.) // (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.' }, 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.' }, 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).' }, 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.' }, 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.' }, 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). // 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'), Math.round(c.power * 100));
setValue($('bocce-power-value'), `${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); 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); 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). // 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). // Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
+2 -2
View File
@@ -4459,8 +4459,8 @@ export const story = {
start: { start: {
options: [ 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 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: '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: 'Venticinquemila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] }, { 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' }, { text: 'Un\'altra volta.', next: 'end' },
], ],
}, },
+1
View File
@@ -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. */ /* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */
#bocce-throw.hold { min-height: 72px; font-size: 14px; } #bocce-throw.hold { min-height: 72px; font-size: 14px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .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, #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 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; } #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; }
+36 -15
View File
@@ -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 // Il flipper del River nel browser vero: dal bar, la molla col mouse e col dito, i flipper con due dita sulla scena,
// frecce, il risultato. npm run browser:flipper — schermate in /tmp/regolo-flipper. // 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 { chromium } from 'playwright';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -28,43 +29,63 @@ try {
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia'); assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible()); assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper'); 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` }); 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]; 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. const cdp = mobile ? await page.context().newCDPSession(page) : null;
if (mobile) await page.locator('#bocce-throw').tap(); const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
else { // 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.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.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await page.mouse.up(); await page.mouse.up();
} }
await page.waitForFunction(() => game.closeup && !game.closeup.ball?.waiting, null, { timeout: 5000 }); 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) { if (mobile) {
const b = await page.locator('#bocce-left').boundingBox(); await touch('touchStart', [P(250, 200)]);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: b.x + b.width / 2, y: b.y + b.height / 2 }] });
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); await page.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 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); await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
} else { } else {
await page.mouse.move(...P(400, 200)); await page.mouse.down(); await page.mouse.move(...P(400, 200)); await page.mouse.down();
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0); 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.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.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
await page.keyboard.down('ArrowLeft'); await page.keyboard.down('ArrowLeft');
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0); 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.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. // 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'); await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/); assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/);
await page.screenshot({ path: `${out}/${name}-fine.png` }); await page.screenshot({ path: `${out}/${name}-fine.png` });
await page.locator('#closeup-close').click(); 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); await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
await page.close(); await page.close();
} }
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
+92 -46
View File
@@ -7,6 +7,7 @@ import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); }; 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. // Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) { function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) { 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); 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 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.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.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'); 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', () => { test('il lancio: anche la molla più fiacca arriva in campo, quella forte fa il giro dell\'arco; la ricaduta si rilancia', () => {
const c = m.newFlipper(2, { level: 'bona' }); for (const level of ['bona', 'male']) for (const power of [0, 0.5, 1]) {
assert.ok(m.launch(c, 0.8)); assert.ok(!m.waiting(c)); const c = m.newFlipper(2, { level }); assert.ok(m.launch(c, power)); assert.ok(!m.waiting(c));
let top = 999, left = false; 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) left = true; } 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 < 40, `è arrivata in cima (${top.toFixed(0)})`); assert.ok(top < 50, `${level} forza ${power}: in cima (${top.toFixed(0)})`);
assert.ok(left, 'è uscita dalla corsia'); assert.ok(out, `${level} forza ${power}: entrata in campo`);
// Un lancio fiacco ricasca sulla molla e si rilancia. }
const d = m.newFlipper(3, { level: 'bona' }); m.launch(d, 0); run(d, 3); // 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'); 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' }); const c = m.newFlipper(4, { level: 'seria' });
for (let n = 3; n >= 1; n--) { for (let n = 3; n >= 1; n--) {
assert.equal(c.balls, n); assert.equal(c.balls, n);
m.launch(c, 0.9); c.saveUntil = 0; // niente salvataggio: si prova sotto 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.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' }); 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.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); 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'); 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); const up = m.newFlipper(6, { level: 'seria' }); m.launch(up, 0.9);
Object.assign(up.ball, { x: 300, y: 300, vx: 0, vy: 150 }); put(up, { x: 300, y: 300, vx: 0, vy: 150 }); m.hold(up, 'left', 'test', true);
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); } 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(best < -250, `tirata su (vy ${best.toFixed(0)})`);
assert.ok(up.events.some(e => e.kind === 'flipper-flip')); 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); 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; put(down, { x: 300, y: 300, vx: 0, vy: 150 }); down.saveUntil = 0; run(down, 2);
run(down, 2);
assert.equal(down.balls, 2, 'senza flipper, giù nella Sieve'); 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 c = m.newFlipper(7, { level: 'bona' }); m.launch(c, 0.9);
const b = c.bumpers[0]; 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.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')); assert.ok(c.events.some(e => e.kind === 'flipper-fungo'));
c.score = 0; // La pallina appoggiata sul Regolo (Codex: 240.000 punti in un secondo): non conta.
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}`); } c.score = 0; put(c, { x: c.regolo.x, y: c.regolo.y - c.regolo.r - m.R, vx: 0, vy: 0 });
assert.equal(c.score, 3 * m.SCORE.target + m.SCORE.bank); for (let i = 0; i < 240; i++) m.update(c, m.STEP);
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'la fila si rialza'); assert.equal(c.score, 0, 'il contatto che dura non fa punti');
assert.match(c.line, /Pieve/); 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);
c.score = 0;
Object.assign(c.ball, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); m.update(c, 1 / 60); m.update(c, 1 / 60);
assert.equal(c.score, m.SCORE.regolo); assert.equal(c.score, m.SCORE.regolo);
c.score = 0; 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); } 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, 4 * m.SCORE.lane + m.SCORE.palio); 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'); 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' }); const c = m.newFlipper(8, { level: 'bona' });
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6); 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(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, 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, [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)); 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.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); m.cancel(c); assert.ok(!c.flippers.right.held.length, 'annullare tutto (pausa) lascia i flipper');
// I pulsanti e le frecce. // 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); m.stopTurn(c, 'button'); assert.ok(!c.flippers.left.held.length); 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, '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)); 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 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…'); 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', () => { 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`); assert.equal(c.step, 'result', `seme ${seed}: finita`);
if (c.result.winner === 'player') won++; 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', () => { 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); }; 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)); draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER')); 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(); 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!')); 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); const g = new Game(story); g.go('via_provinciale'); settle(g);
g.act('use', 'river'); settle(g); g.act('use', 'river'); settle(g);
assert.ok(g.options().some(o => o.text === 'Una partita seria.')); assert.ok(g.options().some(o => o.text === 'Una partita seria.'));
g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g); 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'); 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();
g.closeupClose(); settle(g); assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say);
assert.ok(g.state.flags.flipper_vinto); settle(g);
g.act('open', 'river'); settle(g); g.act('open', 'river'); settle(g);
g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g);
assert.equal(g.closeup?.level, 'male'); assert.equal(g.closeup?.level, 'male');
g.closeupClose();
assert.equal(g.queue[0]?.say, story.closeups.flipper.quit[0].say);
}); });