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
This commit is contained in:
2026-10-31 21:20:53 +01:00
co-authored by Claude Fable 5.1
parent a50847da2b
commit 0a8b0310d3
13 changed files with 227 additions and 59 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",
"sec": 0.9
},
"andrea|Seimila punti e sono contento.": {
"file": "andrea-b6d9563c93.mp3",
"sec": 2.02
"andrea|Sessantamila punti e sono contento.": {
"file": "andrea-74e53fb53a.mp3",
"sec": 2.65
},
"andrea|Diecimila. Come quando ci lasciavo le cinquecento lire.": {
"file": "andrea-5811ae33d6.mp3",
"sec": 3.91
"andrea|Centoventimila. Come quando ci lasciavo le cinquecento lire.": {
"file": "andrea-82ab821c37.mp3",
"sec": 4.43
},
"andrea|Sedicimila. Il record del River, o quasi.": {
"file": "andrea-d420a5e136.mp3",
"sec": 4.38
"andrea|Duecentomila. Il record del River, o quasi.": {
"file": "andrea-00b37d42f9.mp3",
"sec": 4.32
},
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
"file": "andrea-d564365fc2.mp3",
+7 -6
View File
@@ -306,15 +306,16 @@
<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
per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia, o Spazio). Fa il giro dell'arco e
scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (3000 punti). I tre funghi
sono i bastioni della Fortezza (100 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (500 l'uno, 2000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (1000).</p>
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 (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (10.000). I due ponti: imboccali dal basso con un colpo deciso, la pallina ci sale sopra e
scende dall'altra parte (15.000); tutti e due di fila, i PONTI (50.000).</p>
<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.
Tre palline; la prima volta, appena lanciata, se la perdi subito torna (alla bona per 8 secondi, seria per 4, a
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 6000 punti, seria 10000, a
fassi male 16000; raggiunto il traguardo, si continua per il record.</p>
fassi male per 1). Si vince arrivando al traguardo, anche se poi si esce: alla bona 60.000 punti, seria 120.000,
a fassi male 200.000; raggiunto il traguardo, si continua per il record.</p>
</details>
<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
+115 -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
// meccaniche disegnate dal gioco, nelle stesse coordinate della fisica: sponde cromate (larghe quanto collidono),
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, flipper,
// molla e pallina. A sinistra, sul fianco nero del mobile, il quadro dei punti. Niente filtri né passate a tutto
// schermo (vedi risveglio.js).
import { FIELD, LANE, R, PIVOT, flipperSeg, waiting, canThrow, saveLeft } from './flipper.js';
// corsie dei rioni con le luci sulla riga che conta, funghi, bersagli a caduta, il Regolo, sponde elastiche, i due
// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, flipperSeg, alongRamp, waiting, canThrow, saveLeft } from './flipper.js';
import { text, panel } from './bocce-render.js';
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
@@ -33,6 +34,24 @@ function regolo(ctx, o) {
for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h);
ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2);
}
// I ponti: una striscia di pietra lungo il percorso, col bordo scuro e la luce in cima; accesi, brillano d'oro.
function ramp(ctx, r, time) {
const path = () => { ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); };
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 14; path(); ctx.stroke();
ctx.translate(0, -3);
ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : '#3a3028'; ctx.lineWidth = 13; path(); ctx.stroke();
ctx.strokeStyle = '#9a9088'; ctx.lineWidth = 9; path(); ctx.stroke();
ctx.strokeStyle = '#c9c0b0'; ctx.lineWidth = 7; path(); ctx.stroke();
// Le pietre: trattini scuri a passo regolare lungo il ponte.
ctx.strokeStyle = 'rgba(60,50,40,0.5)'; ctx.lineWidth = 1;
for (let s = 6; s < r.len; s += 9) { const p = alongRamp(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); }
ctx.translate(0, 3);
// L'imbocco: la rampetta che sale, con la freccia.
const [[x0, y], [x1]] = r.mouth;
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6);
ctx.fillStyle = '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill();
}
function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
ctx.lineCap = 'round';
@@ -51,29 +70,98 @@ function plunger(ctx, c) {
}
function ball(ctx, b) {
if (!b) return;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, R, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, R);
// Sul ponte la pallina è più in alto: più grande, con l'ombra più lontana.
const r = b.ramp ? R + 1.5 : R, sh = b.ramp ? 6 : 3;
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(b.x + sh * 0.7, b.y + sh, r, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(b.x - 1.5, b.y - 1.5, 0.5, b.x, b.y, r);
g.addColorStop(0, '#ffffff'); g.addColorStop(0.4, '#cfd3da'); g.addColorStop(1, '#5a6068');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, R, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2); ctx.fill();
}
// Il quadro dei punti sul fianco del mobile: punti, palline, i rioni accesi, il traguardo, il salvataggio, l'avviso.
function board(ctx, c) {
panel(ctx, 16, 56, 172, 168);
text(ctx, 'FLIPPER DEL RIVER', 102, 72, 7, '#efd49e', 'center');
text(ctx, String(c.score).padStart(6, '0'), 102, 98, 16, '#fff4c8', 'center');
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, 28, 124, 7, '#d7ccb4');
text(ctx, c.result ? 'TRAGUARDO FATTO' : `TRAGUARDO ${c.skill.goal}`, 28, 140, 7, c.result ? '#9fd27a' : '#d7ccb4');
text(ctx, 'RIONI', 28, 160, 7, '#d7ccb4');
c.lanes.forEach((l, i) => disc(ctx, 86 + i * 22, 160, 7, l.lit ? COLORS[l.rione] : '#3a3630', l.lit ? '#fff4c8' : '#5a5650'));
// --- Il tabellone a punti luminosi: un carattere 5×7, cifre e lettere.
const GLYPHS = {
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11111', '00010', '00100', '00010', '00001', '10001', '01110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
'6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], D: ['11100', '10010', '10001', '10001', '10001', '10010', '11100'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'], J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '10001', '01010', '00100', '00100', '00100'], Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], ':': ['00000', '00100', '00000', '00000', '00000', '00100', '00000'],
',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
};
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore.
function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') {
const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch;
const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1);
chars.forEach((ch, i) => {
const g = GLYPHS[ch] ?? GLYPHS[' '];
for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) {
ctx.fillStyle = g[row][col] === '1' ? on : off;
ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size);
}
});
}
// Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola
// presentazione, fuori dalla partita (così disegnare non cambia niente).
const shown = new WeakMap();
function board(ctx, c, time) {
let d = shown.get(c);
if (!d) { d = { score: c.score, t: time, flash: 0, line: null }; shown.set(c, d); }
const dt = Math.min(0.1, Math.max(0, time - d.t)); d.t = time;
// Le cifre corrono: in mezzo secondo raggiungono i punti veri, a scatti di almeno 10.
if (d.score < c.score) d.score = Math.min(c.score, d.score + Math.max(10, Math.ceil((c.score - d.score) * dt * 6)));
if (d.score > c.score) d.score = c.score;
if (c.line && c.line !== d.line) { d.line = c.line; d.flash = time; }
if (!c.line) d.line = null;
const x0 = 16, y0 = 50, w = 172, h = 180;
ctx.fillStyle = '#0c0806'; ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, y0 + 1, w - 2, h - 2);
// Le lampadine intorno, che girano a tempo (e impazziscono alle scritte grosse).
const beat = time * (d.flash && time - d.flash < 2 ? 12 : 3);
let n = 0;
const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? '#f0a24a' : '#6a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); };
for (let x = x0 + 6; x < x0 + w - 4; x += 8) lamp(x, y0 + 4);
for (let y = y0 + 12; y < y0 + h - 4; y += 8) lamp(x0 + w - 5, y);
for (let x = x0 + w - 10; x > x0 + 4; x -= 8) lamp(x, y0 + h - 5);
for (let y = y0 + h - 12; y > y0 + 4; y -= 8) lamp(x0 + 4, y);
text(ctx, 'FLIPPER DEL RIVER', x0 + w / 2, y0 + 16, 7, '#efd49e', 'center');
// I punti, a cifre grosse color ambra, col bagliore caldo dietro.
const digits = String(Math.round(d.score)).padStart(7, '0'), hot = d.score < c.score;
ctx.fillStyle = hot ? 'rgba(255,120,30,0.22)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(x0 + 10, y0 + 26, w - 20, 34);
dots(ctx, digits, x0 + w / 2, y0 + 30, 4, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center');
// La scritta: lampeggia per due secondi, poi resta; altrimenti palline e traguardo.
const flashing = d.line && time - d.flash < 2, visible = !flashing || Math.sin(time * 16) > -0.2;
const line = d.line ?? (c.result ? 'TRAGUARDO OK' : `TRAG. ${c.skill.goal}`);
if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
dots(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 88, 2, '#f0a24a');
// Il salvataggio: quanti secondi restano, o se è già stato usato.
const save = saveLeft(c);
if (save > 0) text(ctx, `SALVATA ${Math.ceil(save)} s`, 28, 180, 7, '#9fd27a');
else if (c.ball && !c.ball.waiting) text(ctx, c.saved ? 'SALVATAGGIO USATO' : 'SENZA RETE', 28, 180, 6, '#a9a08a');
if (c.line) text(ctx, c.line, 102, 206, 8, '#ffe08c', 'center');
if (save > 0) text(ctx, `PALLINA SALVATA PER ${Math.ceil(save)} s`, x0 + 10, y0 + 108, 6, '#9fd27a');
else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + 10, y0 + 108, 6, '#a08a60');
// I rioni accesi, e i ponti fatti.
dots(ctx, 'RIONI', x0 + 10, y0 + 120, 2, '#f0a24a');
c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 127, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040'));
dots(ctx, 'PONTI', x0 + 10, y0 + 142, 2, '#f0a24a');
c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 149, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040'));
text(ctx, 'ANDREA', x0 + 10, y0 + 168, 6, '#a9a08a');
}
function result(ctx, c) {
panel(ctx, 16, 236, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 252, 8, '#efd49e', 'center');
text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
panel(ctx, 16, 238, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center');
text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 274, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center');
}
export function drawFlipper(ctx, c, def, bg) {
@@ -100,10 +188,13 @@ export function drawFlipper(ctx, c, def, bg) {
regolo(ctx, c.regolo);
for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c);
ball(ctx, c.ball);
// La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte.
if (!c.ball?.ramp) ball(ctx, c.ball);
for (const r of c.ramps) ramp(ctx, r, time);
if (c.ball?.ramp) ball(ctx, c.ball);
// Da fermo, l'invito: la molla da caricare.
if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center');
board(ctx, c);
board(ctx, c, time);
if (c.step === 'result') result(ctx, c);
ctx.restore();
}
+64 -9
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 BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
export const PIVOT = { left: [280, 314], right: [360, 314] };
export const SCORE = { bumper: 100, sling: 50, lane: 200, palio: 3000, target: 500, bank: 2000, regolo: 1000 };
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 260, climb: 0.42, slope: 380, combo: 12 };
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
// gravità. La spinta dei funghi è uguale per tutti.
export const LEVELS = {
bona: { g: 680, save: 8, kick: 340, goal: 6000 },
seria: { g: 740, save: 4, kick: 340, goal: 10000 },
male: { g: 800, save: 1, kick: 340, goal: 16000 },
bona: { g: 680, save: 8, kick: 340, goal: 60000 },
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
male: { g: 800, save: 1, kick: 340, goal: 200000 },
};
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
@@ -55,7 +59,21 @@ export function layout() {
// Le corsie: ognuna fra due guide (o la sponda), con la bandiera dipinta in `x`; si accende attraversando in
// discesa la riga LANE_LINE.
const lanes = [[200, 277, 257], [277, 319, 297], [319, 361, 340], [361, 432, 381]].map(([x0, x1, x], i) => ({ x0, x1, x, y: LANE_LINE, rione: RIONI[i], lit: false }));
return { walls, targets, bumpers, regolo, lanes };
// Le due rampe: l'imbocco (un segmento che si attraversa in salita), il percorso sopra la tavola, l'uscita in
// fondo al percorso, dall'altra parte. Si incrociano in cima, come due ponti.
const ramps = [
{ id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [247, 150], [252, 96], [272, 56], [312, 36], [352, 44], [382, 72], [398, 112], [400, 134]], lit: 0, touching: false },
{ id: 'destra', mouth: [[382, 236], [404, 236]], path: [[393, 236], [393, 150], [388, 96], [368, 56], [328, 36], [288, 44], [258, 72], [242, 112], [240, 134]], lit: 0, touching: false },
];
for (const r of ramps) { r.len = 0; r.cum = [0]; for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); } }
return { walls, targets, bumpers, regolo, lanes, ramps };
}
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
export function alongRamp(r, s) {
s = clamp(s, 0, r.len);
let i = 1; while (i < r.cum.length - 1 && r.cum[i] < s) i++;
const [ax, ay] = r.path[i - 1], [bx, by] = r.path[i], L = r.cum[i] - r.cum[i - 1] || 1, k = (s - r.cum[i - 1]) / L;
return { x: ax + (bx - ax) * k, y: ay + (by - ay) * k, dx: (bx - ax) / L, dy: (by - ay) / L };
}
export function newFlipper(seed = Date.now() >>> 0, def = {}) {
@@ -63,11 +81,11 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, visual: { time: 0 }, ...layout() };
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, visual: { time: 0 }, ...layout() };
newBall(c);
return c;
}
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true }; c.still = 0; c.anchor = null; c.rest = 0; }
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; }
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
@@ -141,6 +159,8 @@ function step(c) {
const ball = c.ball;
if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; }
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
if (ball.ramp) { rideRamp(c, ball); return; }
ball.vy += c.skill.g * STEP;
limit(ball);
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
@@ -157,6 +177,7 @@ function step(c) {
limit(ball);
}
crossLanes(c, from, ball);
enterRamps(c, from, ball);
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è
// trattenuta su un flipper alzato: quella è una mossa del giocatore.
if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; }
@@ -176,11 +197,45 @@ function crossLanes(c, from, ball) {
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 });
if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
}
// L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
// indietro e ricasca dall'imbocco.
function enterRamps(c, from, ball) {
for (const r of c.ramps) {
const [[x0, y], [x1]] = r.mouth;
if (!(from.y > y && ball.y <= y && ball.vy < 0)) continue;
const k = (y - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
if (x < x0 || x > x1) continue;
const v = Math.hypot(ball.vx, ball.vy);
if (v < RAMP.speed) continue;
ball.ramp = { id: r.id, s: 0, v: v * 0.9 };
c.events.push({ kind: 'flipper-rampa', power: 0.7 });
return;
}
}
function rideRamp(c, ball) {
const r = c.ramps.find(k => k.id === ball.ramp.id), on = ball.ramp, top = r.len * RAMP.climb;
on.v += (on.s < top ? -RAMP.slope : RAMP.slope) * STEP;
on.s += on.v * STEP;
const p = alongRamp(r, on.s);
ball.x = p.x; ball.y = p.y;
if (on.s >= r.len) {
// In fondo al ponte: giù dall'altra parte, con la velocità che ha.
const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 });
if (c.lastRamp && c.lastRamp.id !== r.id && c.sim - c.lastRamp.at < RAMP.combo) { add(c, SCORE.ponti); say(c, 'I PONTI!'); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; }
else { say(c, r.id === 'sinistra' ? 'PONTE SINISTRO' : 'PONTE DESTRO'); c.lastRamp = { id: r.id, at: c.sim }; }
} else if (on.s <= 0) {
// Non ce l'ha fatta: ricasca dall'imbocco.
ball.vx = 0; ball.vy = Math.max(60, -on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
} else ball.vx = p.dx * on.v, ball.vy = p.dy * on.v;
}
function drain(c) {
c.events.push({ kind: 'flipper-buco', power: 0.8 });
if (c.sim < c.saveUntil) { say(c, 'Pallina salvata!'); c.saved = true; newBall(c); return; }
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.balls--; c.saved = false;
if (c.balls > 0) { say(c, `Persa: ne restano ${c.balls}.`); newBall(c); return; }
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
c.ball = null; c.step = 'result';
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
}
+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-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); }
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
// Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo.
+5 -4
View File
@@ -1675,7 +1675,8 @@ export const story = {
entry: [140, 340],
hotspots: [
{
id: 'river', name: 'il River', box: [60, 128, 190, 110], at: [160, 320], face: 'up',
// Un clic sul River entra e va al flipper (Andrea: «Usa» o «Apri» non si capiva).
id: 'river', name: 'il River: dentro c\'è il flipper', box: [60, 128, 190, 110], at: [160, 320], face: 'up', default: 'open',
actions: {
look: [{ say: 'Il River, il bar della rotonda. Fuori gli ombrelloni, dentro i flipper e le figurine.' }],
// Dentro, il flipper (Andrea, 31 ottobre): si entra e si sceglie come giocarlo.
@@ -4458,9 +4459,9 @@ export const story = {
nodes: {
start: {
options: [
{ text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Seimila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Diecimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Sedicimila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
{ text: 'Una partita alla bona, tanto per sentire la molla.', next: 'end', then: [{ say: 'Sessantamila punti e sono contento.' }, { closeup: 'flipper', level: 'bona' }] },
{ text: 'Una partita seria.', next: 'end', then: [{ say: 'Centoventimila. Come quando ci lasciavo le cinquecento lire.' }, { closeup: 'flipper', level: 'seria' }] },
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Duecentomila. Il record del River, o quasi.' }, { closeup: 'flipper', level: 'male' }] },
{ text: 'Un\'altra volta.', next: 'end' },
],
},
+26 -7
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);
}
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.ok(!m.canThrow(c));
assert.match(m.status(c), /Ne servivano 10000/);
assert.match(m.status(c), /Ne servivano 120000/);
const d = m.newFlipper(5, { level: 'bona' });
m.launch(d, 0.9); assert.ok(m.saveLeft(d) > 7); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
assert.ok(m.waiting(d) && d.balls === 3, 'salvata: la pallina torna sulla molla');
assert.match(d.line ?? '', /salvata/);
assert.match(d.line ?? '', /SALVATA/);
m.launch(d, 0.9); assert.equal(m.saveLeft(d), 0); put(d, { x: 320, y: 320, vx: 0, vy: 200 }); run(d, 1);
assert.equal(d.balls, 2, 'la seconda volta è persa davvero');
// Lo stesso quarto di secondo, a passi piccoli o in un fotogramma solo: stesso esito (Codex).
@@ -139,7 +139,7 @@ test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; d
// Spazio: carica e lancia; Invio: lancia con la forza del cursore; a partita finita i tasti non sono suoi.
const d = m.newFlipper(9); assert.ok(m.key(d, ' ', true)); run(d, 0.3); assert.ok(d.holding); m.key(d, ' ', false); assert.ok(!m.waiting(d));
const e = m.newFlipper(10); assert.ok(m.setPower(e, 0.5)); assert.equal(m.button(e), 'Lancia'); m.key(e, 'Enter', true); assert.ok(!m.waiting(e)); assert.equal(m.button(e), 'In gioco…');
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 10000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
e.step = 'result'; e.result = { winner: 'cpu', score: 0, goal: 120000 }; assert.ok(!m.key(e, ' ', true) && !m.key(e, 'Enter', true));
});
test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince; la trattenuta sul flipper non viene scossa', () => {
@@ -149,7 +149,7 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
assert.match(m.status(c), /record/);
const g = new Game(story); g.go('via_provinciale'); settle(g);
g.act('use', 'river'); settle(g); g.choose(g.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(g);
g.closeup.score = 7000; g.closeup.result = { winner: 'player', score: 7000, goal: 6000 };
g.closeup.score = 70000; g.closeup.result = { winner: 'player', score: 70000, goal: 60000 };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.flipper_vinto, 'uscito dopo il traguardo: vinto');
// La pallina trattenuta sul flipper alzato resta lì (niente scossa): è una mossa del giocatore.
@@ -158,6 +158,24 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
assert.ok(h.ball && !h.ball.waiting && h.ball.y > 280 && h.ball.x < 320, `ancora sul flipper (${h.ball?.x.toFixed(0)}, ${h.ball?.y.toFixed(0)})`);
});
test('i ponti: imboccati forte si sale e si scende dall\'altra parte (e la pallina non urta la tavola sotto); piano si ricasca; tutti e due di fila, i PONTI', () => {
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
put(c, { x: 247, y: 250, vx: 0, vy: -420 });
let onRamp = 0, top = 999; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } }
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 50, 'fin sopra la tavola');
assert.ok(!c.ball.ramp && c.ball.x > 380, `scesa dall'altra parte (${c.ball.x.toFixed(0)}, ${c.ball.y.toFixed(0)})`);
assert.equal(c.score, m.SCORE.ramp); assert.match(c.line, /PONTE SINISTRO/); assert.ok(c.ramps[0].lit > 0);
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Il secondo ponte subito dopo: i PONTI.
put(c, { x: 393, y: 250, vx: 0, vy: -420, ramp: null }); run(c, 2.5);
assert.equal(c.score, 3 * m.SCORE.ramp - m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`); assert.match(c.line ?? '', /PONTI/);
// Troppo piano: non ci arriva, ricasca dall'imbocco senza punti.
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
put(d, { x: 247, y: 250, vx: 0, vy: -300 }); let climbed = false; for (let i = 0; i < 90; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.equal(d.score % m.SCORE.ramp === 0 && d.score >= m.SCORE.ramp, false, 'niente punti del ponte');
void climbed;
});
test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => {
let won = 0;
for (let seed = 1; seed <= 8; seed++) {
@@ -177,13 +195,14 @@ test('il disegno del flipper non cambia lo stato', () => {
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER'));
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && /^SALVATA \d s$/.test(t)), 'il salvataggio si vede');
c.step = 'result'; c.result = { winner: 'player', score: 7000, goal: 6000 }; calls.length = 0; draw();
assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi');
c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'TRAGUARDO FATTO!'));
});
test('al River: «Usa» o «Apri» il bar, il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
test('al River: un clic sul bar (o «Usa», «Apri»), il livello, la partita; uscendo senza traguardo, la battuta di chi lascia', () => {
const g = new Game(story); g.go('via_provinciale'); settle(g);
assert.equal(g.hotspot('river').default, 'open');
g.act('use', 'river'); settle(g);
assert.ok(g.options().some(o => o.text === 'Una partita seria.'));
g.choose(g.options().findIndex(o => o.text === 'Una partita seria.')); settle(g);