Compare commits

..
4 Commits
Author SHA1 Message Date
manzoloandClaude Fable 5.1 7d419be27a Test dei ponti: la pallina persa parte da sotto i flipper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:46:58 +01:00
manzoloandClaude Fable 5.1 07b6a381c1 Il flipper dopo la seconda revisione di Codex: flipper vivi anche sul ponte, imbocco solo in salita, ponti con la stessa energia su e giù e uscita nelle corsie di ritorno, combo che scade, scritte in coda col traguardo davanti, tabellone con l'esito giusto e le cifre che corrono in tempo fisso, tasti del mouse dalla maschera, glifi rifatti
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:43:57 +01:00
manzoloandClaude Fable 5.1 45b8c3d98e STATO.md: i ponti, i punti ×10, il tabellone luminoso e l'ingresso al flipper con un clic
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:21:31 +01:00
manzoloandClaude Fable 5.1 0a8b0310d3 Il flipper coi due ponti (le rampe che si incrociano sopra la tavola, i PONTI se fatti di fila), i punti ×10 come sui flipper degli anni 2000, il tabellone a punti luminosi con le cifre che corrono e le scritte che lampeggiano, e un clic sul River che entra e va al flipper (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
2026-10-31 21:20:53 +01:00
15 changed files with 319 additions and 82 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -9
View File
@@ -2983,17 +2983,17 @@
"file": "andrea-1b517ab1ef.mp3", "file": "andrea-1b517ab1ef.mp3",
"sec": 0.9 "sec": 0.9
}, },
"andrea|Seimila punti e sono contento.": { "andrea|Sessantamila punti e sono contento.": {
"file": "andrea-b6d9563c93.mp3", "file": "andrea-74e53fb53a.mp3",
"sec": 2.02 "sec": 2.65
}, },
"andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": { "andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
"file": "andrea-5811ae33d6.mp3", "file": "andrea-82ab821c37.mp3",
"sec": 3.91 "sec": 4.43
}, },
"andrea|Sedicimila. Il record del River, o quasi.": { "andrea|Duecentomila. Il record del River, o quasi.": {
"file": "andrea-d420a5e136.mp3", "file": "andrea-00b37d42f9.mp3",
"sec": 4.38 "sec": 4.32
}, },
"andrea|Due tiri alla bona, tanto per scaldarmi.": { "andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3", "file": "andrea-d564365fc2.mp3",
+8 -2
View File
@@ -63,8 +63,14 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
cancelletto-ripiano e molla che arriva sempre in campo, salvataggio col tempo della simulazione (e in vista sul 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 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. 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 [DA ANDREA] le battute; cosa si vince (`flipper_vinto` per ora non serve a niente). Poi (Andrea): i due ponti (rampe `ramps`: imbocco in salita, percorso sopra la tavola, uscita dall'altra
rampe («ponti»), più punti, il tabellone luminoso coi punti che corrono, un ingresso più intuitivo del River. parte; tutti e due di fila i PONTI), punti ×10 (traguardi 60.000/120.000/200.000), il tabellone a punti luminosi
(cifre che corrono, scritte che lampeggiano, lampadine che girano; carattere 5×7 in flipper-render.js) e un clic sul
River che entra e va al flipper. Seconda revisione di Codex (31 ottobre): i flipper rispondono anche con la pallina
sul ponte, l'imbocco conta solo la velocità lungo il ponte, salita e discesa con la stessa energia (meno l'attrito),
l'uscita dritta nella corsia di ritorno, la combo che scade e si azzera con la pallina persa, le scritte in coda col
traguardo davanti, il tabellone che dice l'esito giusto, le cifre che corrono in tempo fisso, i due tasti del mouse
dalla maschera `buttons` (il destro è sempre il flipper di destra), i glifi 3 e 5 rifatti.
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio - **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).
+8 -6
View File
@@ -306,15 +306,17 @@
<details id="flipper-help" hidden><summary>Come si gioca</summary> <details id="flipper-help" hidden><summary>Come si gioca</summary>
<p>Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto <p>Il flipper del River, con San Piero dipinto sulla tavola. La pallina parte dalla corsia di destra: tieni premuto
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e 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 (30.000 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 (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (1000).</p> destra, è il Regolo (10.000). I due ponti: imboccali dal basso con un colpo deciso, la pallina ci sale sopra e
scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un
bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.</p>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto <p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra. destro del mouse per quello di destra), o i pulsanti «← Flipper» e «Flipper →», o le frecce sinistra e destra.
Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 6000 punti, seria 10000, a fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000,
fassi male 16000; raggiunto il traguardo, si continua per il record.</p> a fassi male 200.000; 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
+127 -24
View File
@@ -1,9 +1,10 @@
// Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti // Il flipper del River in primo piano (flipper.js): la tavola dipinta da Codex con San Piero, e sopra le parti
// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono), // meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper, // corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due
// molla e pallina. A sinistra, sul fianco nero del mobile, il quadro dei punti. Niente filtri né passate a tutto // ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
// schermo (vedi risveglio.js). // come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
import { FIELD, LANE, R, PIVOT, flipperSeg, waiting, canThrow, saveLeft } from './flipper.js'; // girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, flipperSeg, alongRamp, rampHeight, 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' };
@@ -33,6 +34,28 @@ function regolo(ctx, o) {
for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h); 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); ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2);
} }
// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), un po'
// trasparente perché si veda la tavola sotto, più alta in cima (la quota), col bordo scuro e la luce; accesi, brillano.
const RAMP_LOOK = { sinistra: ['#c9b89a', '#8a7a5a'], destra: ['#b8c0c9', '#6a7a8a'] };
function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
function ramp(ctx, r, time, who) {
const path = () => { ctx.beginPath(); for (let s = 0; s <= r.len; s += 4) { const p = rampPoint(r, Math.min(s, r.len)); s ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y); } };
const [light, dark] = RAMP_LOOK[r.id], invite = who && who !== r.id && Math.sin(time * 6) > 0;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke();
ctx.globalAlpha = 0.8;
ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : invite ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke();
ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke();
ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke();
// Le pietre: trattini scuri a passo regolare lungo il ponte.
ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1;
for (let s = 6; s < r.len; s += 9) { const p = rampPoint(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); }
ctx.globalAlpha = 1;
// L'imbocco: la rampetta che sale, con la freccia.
const [[x0, y], [x1]] = r.mouth;
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6);
ctx.fillStyle = invite ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill();
}
function flipper(ctx, c, side) { function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
ctx.lineCap = 'round'; ctx.lineCap = 'round';
@@ -51,29 +74,106 @@ function plunger(ctx, c) {
} }
function ball(ctx, b) { function ball(ctx, b) {
if (!b) return; if (!b) return;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, R, 0, Math.PI * 2); ctx.fill(); // Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana.
const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, R); const z = b.ramp?.z ?? 0, r = R + 1.5 * z, y = b.y - 2 - 4 * z, sh = 3 + 4 * z;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(b.x - 1.5, y - 1.5, 0.5, b.x, y, r);
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, y, r, 0, Math.PI * 2); ctx.fill();
} }
// Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, il salvataggio, l'avviso.
function board(ctx, c) { // --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere.
panel(ctx, 16, 56, 172, 168); const GLYPHS = {
text(ctx, 'FLIPPER DEL RIVER', 102, 72, 7, '#efd49e', 'center'); '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
text(ctx, String(c.score).padStart(6, '0'), 102, 98, 16, '#fff4c8', 'center'); '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 28, 124, 7, '#d7ccb4'); '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '10000', '11110', '00001', '10001', '01110'],
text(ctx, c.result ? 'TRAGUARDO FATTO' : `TRAGUARDO ${c.skill.goal}`, 28, 140, 7, c.result ? '#9fd27a' : '#d7ccb4'); '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
text(ctx, 'RIONI', 28, 160, 7, '#d7ccb4'); '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
c.lanes.forEach((l, i) => disc(ctx, 86 + i * 22, 160, 7, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650')); A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
};
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore.
function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') {
const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch;
const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1);
chars.forEach((ch, i) => {
const g = GLYPHS[ch] ?? GLYPHS[' '];
for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) {
ctx.fillStyle = g[row][col] === '1' ? on : off;
ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size);
}
});
}
// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola
// presentazione, fuori dalla partita (così disegnare non cambia niente).
const shown = new WeakMap();
function board(ctx, c, time) {
let d = shown.get(c);
if (!d) { d = { from: c.score, to: c.score, t0: time, flash: 0, lineId: 0 }; shown.set(c, d); }
// Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita
// subito il totale.
if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; }
const score = c.step === 'result' ? c.score : shownScore(d, time), hot = score !== c.score;
if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; }
const x0 = 16, y0 = 50, w = 172, h = 180;
ctx.fillStyle = '#0c0806'; ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, y0 + 1, w - 2, h - 2);
// Le lampadine intorno: tranquille di solito, impazzite per due secondi a ogni scritta.
const party = c.line && time - d.flash < 2, beat = time * (party ? 14 : 2);
let n = 0;
const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); };
for (let x = x0 + 6; x < x0 + w - 4; x += 8) lamp(x, y0 + 4);
for (let y = y0 + 12; y < y0 + h - 4; y += 8) lamp(x0 + w - 5, y);
for (let x = x0 + w - 10; x > x0 + 4; x -= 8) lamp(x, y0 + h - 5);
for (let y = y0 + h - 12; y > y0 + 4; y -= 8) lamp(x0 + 4, y);
text(ctx, 'FLIPPER DEL RIVER', x0 + w / 2, y0 + 16, 7, '#efd49e', 'center');
// I punti, a cifre grosse color ambra col bagliore caldo dietro: sei cifre larghe, da un milione in su più fitte.
ctx.save(); ctx.beginPath(); ctx.rect(x0 + 8, y0 + 24, w - 16, h - 32); ctx.clip();
const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4;
ctx.fillStyle = hot ? 'rgba(255,120,30,0.22)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(x0 + 10, y0 + 26, w - 20, 34);
dots(ctx, digits, x0 + w / 2, y0 + 29 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center');
// La scritta grossa: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo.
const won = c.result?.winner === 'player';
const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`);
const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3;
if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
ctx.restore();
// Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono.
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a');
const save = saveLeft(c); const save = saveLeft(c);
if (save > 0) text(ctx, `SALVATA ${Math.ceil(save)} s`, 28, 180, 7, '#9fd27a'); if (save > 0) text(ctx, `SALVA ${Math.ceil(save)} s`, x0 + w - 10, y0 + 102, 7, '#9fd27a', 'right');
else if (c.ball && !c.ball.waiting) text(ctx, c.saved ? 'SALVATAGGIO USATO' : 'SENZA RETE', 28, 180, 6, '#a9a08a'); else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + w - 10, y0 + 102, 6, '#a08a60', 'right');
if (c.line) text(ctx, c.line, 102, 206, 8, '#ffe08c', 'center'); text(ctx, 'RIONI', x0 + 10, y0 + 124, 7, '#f0a24a');
c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 124, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040'));
text(ctx, 'PONTI', x0 + 10, y0 + 146, 7, '#f0a24a');
c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 146, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040'));
if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right');
else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a');
}
// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo.
export function shownScore(d, time) {
const k = Math.min(1, (time - d.t0) / 0.5);
return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10;
} }
function result(ctx, c) { function result(ctx, c) {
panel(ctx, 16, 236, 172, 56); panel(ctx, 16, 238, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 252, 8, '#efd49e', 'center'); text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center');
text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 274, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
} }
export function drawFlipper(ctx, c, def, bg) { export function drawFlipper(ctx, c, def, bg) {
@@ -100,10 +200,13 @@ export function drawFlipper(ctx, c, def, bg) {
regolo(ctx, c.regolo); regolo(ctx, c.regolo);
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); // La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte.
if (!c.ball?.ramp) ball(ctx, c.ball);
for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id);
if (c.ball?.ramp) 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); board(ctx, c, time);
if (c.step === 'result') result(ctx, c); if (c.step === 'result') result(ctx, c);
ctx.restore(); ctx.restore();
} }
+86 -14
View File
@@ -11,15 +11,19 @@
export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5; export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1: 448 }, R = 4.5;
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 }; export const 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 }; // I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 300, slope: 260, drag: 30, combo: 12, blend: 30 };
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta. // 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; 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 // 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. // gravità. La spinta dei funghi è uguale per tutti.
export const LEVELS = { export const LEVELS = {
bona: { g: 680, save: 8, kick: 340, goal: 6000 }, bona: { g: 680, save: 8, kick: 340, goal: 60000 },
seria: { g: 740, save: 4, kick: 340, goal: 10000 }, seria: { g: 740, save: 4, kick: 340, goal: 120000 },
male: { g: 800, save: 1, kick: 340, goal: 16000 }, male: { g: 800, save: 1, kick: 340, goal: 200000 },
}; };
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));
@@ -55,7 +59,27 @@ export function layout() {
// Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in // Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in
// discesa la riga LANE_LINE. // 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 })); const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false }));
return { walls, targets, bumpers, regolo, lanes }; // Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
// in cima, come due ponti.
const ramps = [
{ id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
{ id: 'destra', mouth: [[382, 236], [404, 236]], path: [[393, 236], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
];
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) {
r.len = 0; r.cum = [0];
for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); }
r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
}
return { walls, targets, bumpers, regolo, lanes, ramps };
}
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
export function alongRamp(r, s) {
s = clamp(s, 0, r.len);
let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++;
const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L;
return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L };
} }
export function newFlipper(seed = Date.now() >>> 0, def = {}) { export function newFlipper(seed = Date.now() >>> 0, def = {}) {
@@ -63,11 +87,11 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7, 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: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } }, flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, visual: { time: 0 }, ...layout() }; ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, 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; c.rest = 0; } function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; }
export const waiting = c => c.step === 'play' && !!c.ball?.waiting; export const 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;
@@ -78,11 +102,15 @@ 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). // 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; export const saveLeft = c => c.ball && !c.ball.waiting ? Math.max(0, c.saveUntil - c.sim) : 0;
function say(c, line) { c.line = line; c.lineT = 2; } // Le scritte del tabellone: una alla volta, due secondi l'una; il traguardo passa davanti a tutte.
function say(c, line, front = false) {
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
else c.lines.push(line);
}
// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record. // I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record.
function add(c, points) { function add(c, points) {
c.score += points; c.score += points;
if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); } if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.events.push({ kind: 'flipper-palio', power: 1 }); }
if (c.result) c.result.score = c.score; if (c.result) c.result.score = c.score;
} }
@@ -120,7 +148,7 @@ export function flipperSeg(c, side) {
export function update(c, dt) { export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return; if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt; dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null; if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt); for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
c.accumulator += dt; c.accumulator += dt;
@@ -139,8 +167,11 @@ function moveFlippers(c, h) {
function step(c) { function step(c) {
c.sim += STEP; c.sim += STEP;
const ball = c.ball; const ball = c.ball;
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; } if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; }
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; } for (const 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]; }
if (ball.ramp) { moveFlippers(c, STEP); rideRamp(c, ball); return; }
ball.vy += c.skill.g * STEP; ball.vy += c.skill.g * STEP;
limit(ball); limit(ball);
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. // Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
@@ -157,6 +188,7 @@ function step(c) {
limit(ball); limit(ball);
} }
crossLanes(c, from, ball); crossLanes(c, from, ball);
enterRamps(c, from, ball);
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è // La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è
// trattenuta su un flipper alzato: quella è una mossa del giocatore. // 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; }
@@ -176,11 +208,51 @@ function crossLanes(c, from, ball) {
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); 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; } if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
} }
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
// indietro e ricasca dall'imbocco.
function enterRamps(c, from, ball) {
for (const r of c.ramps) {
const [[x0, y], [x1]] = r.mouth;
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
if (x < x0 + R || x > x1 - R) continue;
// Conta solo la velocità lungo il ponte (sfiorarlo di traverso non fa salire).
const p = alongRamp(r, 0), forward = ball.vx * p.dx + ball.vy * p.dy;
if (forward < RAMP.speed) continue;
ball.ramp = { id: r.id, s: 0, v: forward * 0.95, off: x - p.x };
c.events.push({ kind: 'flipper-rampa', power: 0.7 });
return;
}
}
// Sul ponte: si sale fino alla cima perdendo velocità, si scende riprendendola (la stessa energia, meno un po'
// d'attrito); in cima la quota è massima. Da lì la velocità lungo il percorso, la posizione e la quota (`z`, 0..1).
export function rampHeight(r, s) { return s < r.top ? s / r.top : 1 - (s - r.top) / (r.len - r.top); }
function rideRamp(c, ball) {
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, uphill = on.s < r.top;
on.v += (uphill ? -RAMP.slope : RAMP.slope * r.top / (r.len - r.top)) * STEP - Math.sign(on.v) * RAMP.drag * STEP;
on.s += on.v * STEP;
const p = alongRamp(r, on.s), blend = Math.max(0, 1 - on.s / RAMP.blend);
ball.x = p.x + on.off * blend; ball.y = p.y; on.z = rampHeight(r, on.s);
if (on.s >= r.len) {
// In fondo al ponte: giù dall'altra parte, con la velocità che ha. Gli ostacoli lì sotto, se per caso la
// pallina esce sopra uno di loro, non contano come urto nuovo.
const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true;
add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 });
if (c.lastRamp && c.lastRamp.id !== r.id) { add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; }
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
} else if (on.s <= 0) {
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
}
function drain(c) { function drain(c) {
c.events.push({ kind: 'flipper-buco', power: 0.8 }); c.events.push({ kind: 'flipper-buco', power: 0.8 });
if (c.sim < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; } if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.balls--; c.saved = false; c.balls--; c.saved = false; c.lastRamp = null;
if (c.balls > 0) { say(c, `Persa: ne restano ${c.balls}.`); newBall(c); return; } if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
c.ball = null; c.step = 'result'; c.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 };
} }
@@ -262,7 +334,7 @@ export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFini
export function press(c, pointerId = 'ptr') { export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false; if (c.step !== 'play') return false;
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; } if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; }
return hold(c, (c.cursor?.[0] ?? 320) < 320 ? 'left' : 'right', pointerId, true); return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true);
} }
export function release(c, pointerId = 'ptr') { export function release(c, pointerId = 'ptr') {
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime)); if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
+1
View File
@@ -169,6 +169,7 @@ export class Soundscape {
else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); } else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); }
else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); } else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); }
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan); else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); } else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
+22 -13
View File
@@ -331,6 +331,7 @@ $('spots-button').addEventListener('click', showSpots);
// --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»). // --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»).
function openPause() { function openPause() {
dropPointers();
if (!running) return; if (!running) return;
running = false; running = false;
game.closeupCancel(); game.closeupCancel();
@@ -493,8 +494,18 @@ const TOUCH_REACH = 22;
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0; 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 // Le dita in più sul flipper (oltre a `press`): ognuna lascia il suo flipper quando si alza. Il tasto destro del
// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte. // mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte ('mouse-right', sempre il
// flipper di destra), e i due tasti si allineano alla maschera `e.buttons` a ogni evento (syncMouse).
const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId; const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId;
function syncMouse(e) {
const left = !!(e.buttons & 1), right = !!(e.buttons & 2);
if (right && !extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); }
if (!right && extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); }
if (left && !press) { game.closeupAim(scenePoint(e)); press = { closeup: true, pointerId: e.pointerId, state: game.closeup }; if (!game.skip()) game.closeupPress(e.pointerId); }
if (!left && press?.closeup && press.pointerId === e.pointerId) { const state = press.state; press = null; if (game.closeup === state) game.closeupRelease(e.pointerId); }
}
// Pausa, finestra che perde il fuoco: niente resta premuto, né nel motore né qui.
function dropPointers() { press = null; extra.clear(); }
canvas.addEventListener('pointerdown', e => { 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 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return; if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) return;
@@ -523,7 +534,8 @@ canvas.addEventListener('pointerdown', e => {
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); } 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. // Il flipper: un secondo dito comanda l'altro flipper, per conto suo; il tasto destro del mouse quello di destra.
if (type === 'flipper' && e.pointerType === 'mouse' && e.button === 2) { syncMouse(e); canvas.setPointerCapture(e.pointerId); return; }
if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) { if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) {
game.closeupAim(scenePoint(e)); game.closeupAim(scenePoint(e));
if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); } if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); }
@@ -546,8 +558,8 @@ canvas.addEventListener('pointerdown', e => {
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) }; 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 (e.pointerType === 'mouse' && game?.closeup && story.closeups[game.closeup.id].type === 'flipper' && (extra.has('mouse-right') || press?.closeup)) { syncMouse(e); return; }
if (extra.has(key)) { extra.delete(key); game?.closeupRelease(key); return; } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupRelease(e.pointerId); 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.
@@ -585,13 +597,14 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', e => { canvas.addEventListener('pointercancel', e => {
if (extra.has('mouse-right') && e.pointerType === 'mouse') { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); }
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (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 (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } }); canvas.addEventListener('lostpointercapture', e => { if (extra.has('mouse-right') && e.pointerType === 'mouse' && !(e.buttons & 2)) { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
canvas.addEventListener('contextmenu', e => { 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 });
@@ -599,13 +612,9 @@ 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»): // Un tasto del mouse premuto o lasciato mentre l'altro è giù arriva come pointermove (pulsanti «in accordo»):
// al flipper vale come un secondo dito, per il flipper di destra. // al flipper si legge la maschera dei pulsanti e si allineano i due comandi.
if (e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { if (e.pointerType === 'mouse' && e.button >= 0 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { syncMouse(e); return; }
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.)
@@ -727,7 +736,7 @@ for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza). // Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); }); $('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); }); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); addEventListener('blur', () => { dropPointers(); game?.closeupCancel(); });
// --- Verbi. // --- Verbi.
for (const verb of VERBS) { for (const verb of VERBS) {
+5 -4
View File
@@ -1675,7 +1675,8 @@ export const story = {
entry: [140, 340], entry: [140, 340],
hotspots: [ hotspots: [
{ {
id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up', // Un clic sul River entra e va al flipper (Andrea: «Usa» o «Apri» non si capiva).
id: 'river', name: 'il River: dentro c\'è il flipper', box: [60, 128, 190, 110], at: [160, 320], face: 'up', default: 'open',
actions: { actions: {
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }], look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
// Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo. // Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo.
@@ -4458,9 +4459,9 @@ export const story = {
nodes: { nodes: {
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: 'Sessantamila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Diecimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] }, { text: 'Una partita seria.', next: 'end', then: [{ say: 'Centoventimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Sedicimila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] }, { text: 'A fassi male.', next: 'end', then: [{ say: 'Duecentomila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
{ text: 'Un\'altra volta.', next: 'end' }, { text: 'Un\'altra volta.', next: 'end' },
], ],
}, },
+53 -10
View File
@@ -55,11 +55,11 @@ test('la pallina persa: tre palline, poi il risultato; il salvataggio vale una v
put(c, { x: 320, y: 320, vx: 0, vy: 200 }); run(c, 1); 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 10000/); assert.match(m.status(c), /Ne servivano 120000/);
const d = m.newFlipper(5, { level: 'bona' }); const d = m.newFlipper(5, { level: 'bona' });
m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1); 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); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, 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). // Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex).
@@ -104,7 +104,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
assert.ok(c.targets.filter(k => k.bank === 'pieve').every(t => t.down), 'la fila resta giù un attimo'); 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); 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.ok(c.targets.filter(k => k.bank === 'pieve').every(t => !t.down), 'poi si rialza');
assert.match(c.line ?? '', /PIEVE/); assert.ok([c.line, ...c.lines].some(l => /PIEVE/.test(l ?? '')), 'la scritta della Pieve (in coda dietro al Regolo)');
// Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva). // Le corsie: contano attraversando la riga, anche fuori centro (Codex: a x=287 non si accendeva).
c.score = 0; c.score = 0;
for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) { for (const [i, x] of [[0, 230], [1, 287], [2, 335], [3, 420]]) {
@@ -112,7 +112,7 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
assert.ok(c.lanes[i].lit || c.lanes.every(k => !k.lit), c.lanes[i].rione); assert.ok(c.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.ok(c.score >= 4 * m.SCORE.lane + m.SCORE.palio, `corsie e Palio (${c.score}; in più, al massimo, un fungo sfiorato)`);
assert.match(c.line, /PALIO/); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro'); assert.ok([c.line, ...c.lines].some(l => /PALIO/.test(l ?? ''))); assert.ok(c.lanes.every(l => !l.lit), 'si rispegne per un altro giro');
// In salita non conta. // 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); 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); assert.ok(!c.lanes[1].lit);
@@ -139,7 +139,7 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi. // 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)); e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
}); });
test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => { test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => {
@@ -149,7 +149,7 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
assert.match(m.status(c), /record/); assert.match(m.status(c), /record/);
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.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g); g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g);
g.closeup.score = 7000; g.closeup.result = { winner: 'player', score: 7000, goal: 6000 }; g.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 };
g.closeupClose(); settle(g); g.closeupClose(); settle(g);
assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto'); 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. // La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore.
@@ -158,6 +158,38 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`); assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`);
}); });
test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => {
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
put(c, { x: 247, y: 250, vx: 0, vy: -470 });
let onRamp = 0, top = 999, exit = null; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } else if (onRamp && !exit) exit = { x: c.ball.x, y: c.ball.y }; }
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola');
assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`);
assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0);
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati).
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 247, y: 250, vx: 0, vy: -470 }); run(f, 0.3);
assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1);
assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`);
// Il secondo ponte subito dopo: i PONTI, col bonus.
put(c, { x: 393, y: 250, vx: 0, vy: -470, ramp: null }); run(c, 2.5);
assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`);
assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? '')));
assert.equal(c.lastRamp, null, 'la combo è spesa');
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti.
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
put(d, { x: 247, y: 250, vx: 0, vy: -335 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
// Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no.
const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0;
put(e, { x: 247, y: 236.1, vx: 500, vy: -30 }); run(e, 0.5);
assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina.
const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 });
run(g, 13); assert.equal(g.lastRamp, null, 'scaduta');
g.lastRamp = { id: 'sinistra', at: g.sim }; g.saveUntil = 0; put(g, { x: 320, y: 342, vx: 0, vy: 300 }); run(g, 1);
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
let won = 0; let won = 0;
for (let seed = 1; seed <= 8; seed++) { for (let seed = 1; seed <= 8; seed++) {
@@ -169,7 +201,7 @@ test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita
assert.ok(won >= 5, `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; il tabellone dice l\'esito giusto e le cifre corrono', async () => {
const calls = [], gradient = { addColorStop() {} }; const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
const c = m.newFlipper(11, { level: 'bona' }), bg = {}; const c = m.newFlipper(11, { level: 'bona' }), bg = {};
@@ -177,13 +209,24 @@ test('il disegno del flipper non cambia lo stato', () => {
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); calls.length = 0; 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'); assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi');
c.step = 'result'; c.result = { winner: 'player', score: 7000, goal: 6000 }; calls.length = 0; draw(); c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!')); assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
// Perdendo, il tabellone dice FINITA (Codex: diceva «traguardo ok» anche a zero punti).
const l = m.newFlipper(12, { level: 'bona' }); l.step = 'result'; l.ball = null; l.result = { winner: 'cpu', score: 0, goal: 60000 }; calls.length = 0;
drawFlipper(ctx, l, story.closeups.flipper, bg);
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FINE PARTITA'));
// Le cifre che corrono: in mezzo secondo da dove erano a dove devono arrivare, a scatti di dieci.
const { shownScore } = await import('../src/engine/flipper-render.js');
const d = { from: 1000, to: 51000, t0: 10 };
assert.equal(shownScore(d, 10), 1000); assert.equal(shownScore(d, 10.25) % 10, 0); assert.ok(shownScore(d, 10.25) > 20000 && shownScore(d, 10.25) < 30000); assert.equal(shownScore(d, 10.5), 51000);
}); });
test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => { test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
const g = new Game(story); g.go('via_provinciale'); settle(g); const g = new Game(story); g.go('via_provinciale'); settle(g);
assert.equal(g.hotspot('river').default, 'open');
const [bx, by, bw, bh] = g.hotspot('river').box;
assert.equal(g.hotspotAt([bx + bw / 2, by + bh / 2])?.id, 'river', 'il River si clicca');
g.act('use', 'river'); settle(g); 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);