Il flipper col LOCK (idea di Andrea, fatto da Codex e completato): lo stesso ponte tre volte trattiene la pallina e ne dà una nuova, l'altro ponte SPECIAL vale il JACKPOT e il MULTIBALL a due palline; serratura, seconde palline e SPECIAL nel disegno, suoni, aiuto, test; imbocchi delle rampe al centro e uscite laterali più strette (Codex); pallina premio al traguardo; la barra del traguardo sotto la finestra invece del pannello sul Trebbio; il Boccino prudente in curva

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 23:17:36 +01:00
co-authored by Claude Fable 5.1
parent dfa70a9620
commit 0ec45c8745
8 changed files with 279 additions and 66 deletions
+10
View File
@@ -84,6 +84,16 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
imbocchi svasati allineati alla fisica, frecce animate e targhette RAMPA; funghi con ghiere metalliche, imbocchi svasati allineati alla fisica, frecce animate e targhette RAMPA; funghi con ghiere metalliche,
sponde smaltate, palette avorio con gomma rossa e Regolo verde con corona. Il traguardo e i punti mancanti sponde smaltate, palette avorio con gomma rossa e Regolo verde con corona. Il traguardo e i punti mancanti
restano visibili sotto il display, con una barra di avanzamento. Prove desktop e touch in `browser:flipper`. restano visibili sotto il display, con una barra di avanzamento. Prove desktop e touch in `browser:flipper`.
Accessibilità dei tiri (Andrea): imbocchi delle rampe spostati verso il centro e allargati da 22 a 36 pixel
(spazio utile alla pallina da 13 a 27; ridotti dai primi 42 perché troppo larghi), pendenza e attrito ridotti per salire con colpi medi. Uscite laterali
ristrette da 22/14 a 14/12 pixel, con più spazio nelle corsie di ritorno alle palette. Test sui tiri obliqui,
decentrati e fuori bocca a tutti i livelli, e sulle discese laterali recuperate e ancora perdenti.
Al traguardo si vince una pallina extra, una sola volta per partita: il tiro prosegue e il premio si aggiunge
alla riserva, con avviso sul tabellone. Test a tutti i livelli anche raggiungendo il traguardo con l'ultima pallina.
Il LOCK (idea di Andrea, fatto da Codex e completato, 31 ottobre): lo stesso ponte tre volte di fila trattiene la
pallina nella serratura in fondo al ponte e ne dà una nuova sulla molla; l'altro ponte si accende SPECIAL; fatto,
JACKPOT (100.000), la pallina si libera e si gioca in MULTIBALL (due palline, ognuna coi suoi contatti e la sua scia;
persa una, resta l'altra senza scalare). Persa la pallina nuova prima dello SPECIAL, torna quella trattenuta.
[DA ANDREA] un record per livello che resti salvato (suggerito da Codex). [DA ANDREA] un record per livello che resti salvato (suggerito da Codex).
- **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.»
+6 -3
View File
@@ -310,14 +310,17 @@
sono i bastioni della Fortezza (1.000 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 (5.000 l'uno, 25.000 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 (10.000). I due ponti: cerca le targhette RAMPA e le frecce illuminate, ambra a sinistra e destra, è il Regolo (10.000). I due ponti: cerca le targhette RAMPA e le frecce illuminate, ambra a sinistra e
azzurre a destra, appena sopra le sponde elastiche. Imboccali dal basso con un colpo deciso: la pallina ci sale sopra e azzurre a destra, verso il centro sopra le sponde elastiche. Gli ingressi larghi accettano anche tiri un po'
decentrati: imboccali dal basso con un colpo di media forza, 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 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> bonus di 50.000 in più. Lo stesso ponte tre volte di fila: LOCK, la pallina resta trattenuta in cima e te ne arriva
una nuova sulla molla, mentre l'altro ponte si accende SPECIAL; fallo e incassi il JACKPOT (100.000), la pallina
trattenuta si libera e giochi in MULTIBALL con due palline. 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 60.000 punti, seria 120.000, 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> a fassi male 200.000. Al traguardo vinci una pallina extra, una sola volta per partita, e continui 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
+27 -17
View File
@@ -4,7 +4,7 @@
// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti // 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 // 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). // girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js'; import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball } from './flipper.js';
import { ambientHash as hash } from './ambient.js'; import { ambientHash as hash } from './ambient.js';
import { text, panel } from './bocce-render.js'; import { text, panel } from './bocce-render.js';
@@ -112,7 +112,7 @@ function rampEntrance(ctx, r, time, who) {
} }
const rampCache = new Map(); const rampCache = new Map();
function ramp(ctx, r, time, who) { function ramp(ctx, r, time, who) {
const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : who && who !== r.id && Math.sin(time * 6) > 0 ? 'invite' : 'base'; const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : (who === r.id + '!' || (who && who !== r.id)) && Math.sin(time * 6) > 0 ? 'invite' : 'base';
if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; } if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; }
const key = `${r.id}:${state}`; const key = `${r.id}:${state}`;
let layer = rampCache.get(key); let layer = rampCache.get(key);
@@ -161,6 +161,14 @@ function plunger(ctx, c) {
for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1); for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1);
ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4); ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4);
} }
// La serratura: la pallina trattenuta in fondo al ponte, dietro due sbarre, con la luce che pulsa.
function lock(ctx, b, time) {
ball(ctx, b);
ctx.strokeStyle = Math.sin(time * 6) > 0 ? '#ffd45a' : '#8a7a5a'; ctx.lineWidth = 1.5;
for (const dx of [-3, 3]) { ctx.beginPath(); ctx.moveTo(b.x + dx, b.y - 9); ctx.lineTo(b.x + dx, b.y + 7); ctx.stroke(); }
ctx.beginPath(); ctx.moveTo(b.x - 7, b.y - 9); ctx.lineTo(b.x + 7, b.y - 9); ctx.stroke();
label(ctx, 'LOCK', b.x, b.y - 15, 5, '#ffe08c');
}
function ball(ctx, b) { function ball(ctx, b) {
if (!b) return; if (!b) return;
// Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana. // Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana.
@@ -261,13 +269,11 @@ function board(ctx, c, time, glass) {
if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh); if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh);
ctx.restore(); ctx.restore();
// Il traguardo resta leggibile anche mentre il display celebra un colpo. // Il traguardo resta leggibile anche mentre il display celebra un colpo: una barra sottile sotto la finestra e,
// nella riga in basso, quanto manca (il pannello che c'era prima copriva il Trebbio dipinto).
const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score); const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score);
panel(ctx, 20, 134, 160, 31); ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3);
label(ctx, `OBIETTIVO ${c.skill.goal.toLocaleString('it-IT')}`, 28, 142, 7, '#f1d7a1', 'left'); ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3);
label(ctx, remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO RAGGIUNTO', 28, 152, 6, remaining ? '#becbd0' : '#a9e7bf', 'left');
ctx.fillStyle = '#30434a'; ctx.fillRect(28, 159, 144, 2);
ctx.fillStyle = '#edc777'; ctx.fillRect(28, 159, 144 * progress, 2);
// I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia). // I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia).
c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a')); c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a'));
// I cinque dischi in basso: le palline che restano, e i quattro rioni accesi. // I cinque dischi in basso: le palline che restano, e i quattro rioni accesi.
@@ -276,7 +282,7 @@ function board(ctx, c, time, glass) {
rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); }); rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); });
// Sotto, in piccolo: il salvataggio o la combo. // Sotto, in piccolo: il salvataggio o la combo.
const save = saveLeft(c); const save = saveLeft(c);
const note = save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : ''; const note = multiball(c) ? 'MULTIBALL!' : c.locked ? 'LOCK: FAI IL PONTE SPECIAL' : c.rampRun?.count ? `PONTE ${c.rampRun.count}/3 PER IL LOCK` : save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO: +1 PALLINA';
if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center'); if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center');
} }
// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al // La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al
@@ -351,9 +357,9 @@ function shake(c, time) {
return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0]; return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0];
} }
// La cometa: le ultime posizioni della pallina, sempre più tenui. // La cometa: le ultime posizioni della pallina, sempre più tenui.
function trail(ctx, c) { function trail(ctx, b) {
const n = c.trail.length; const n = b.trail.length;
c.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); }); b.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); });
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
function result(ctx, c) { function result(ctx, c) {
@@ -393,12 +399,16 @@ export function drawFlipper(ctx, c, def, bg, glass) {
for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id); for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id);
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);
// La pallina (con la cometa) sotto i ponti, se è sulla tavola; sopra, se è su un ponte; poi gli effetti. // Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina
if (c.ball && !c.ball.waiting) trail(ctx, c); // trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti.
if (!c.ball?.ramp) ball(ctx, c.ball); const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id;
for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id); for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); }
if (c.ball?.ramp) ball(ctx, c.ball); for (const b of balls) if (b.waiting) ball(ctx, b);
for (const r of c.ramps) ramp(ctx, r, time, special);
if (c.locked) lock(ctx, c.locked.ball, time);
for (const b of balls) if (b.ramp) { trail(ctx, b); ball(ctx, b); }
drawFx(ctx, c, time); drawFx(ctx, c, time);
if (c.special) { const r = c.ramps.find(k => k.id === c.special); if (Math.sin(time * 8) > 0) label(ctx, 'SPECIAL', (r.mouth[0][0] + r.mouth[1][0]) / 2, r.mouth[0][1] - 38, 6, '#fff6d6'); }
// 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, time, glass); board(ctx, c, time, glass);
+116 -41
View File
@@ -12,7 +12,7 @@ export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1:
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] };
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»). // 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 }; export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si // 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. // sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30 }; export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30 };
@@ -64,9 +64,9 @@ export function layout() {
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola, // fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
// in cima, come due ponti. // in cima, come due ponti.
const ramps = [ const ramps = [
// Imbocchi verso il centro, davanti alle sponde elastiche: 33 pixel utili alla pallina anziché 13. // Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
{ id: 'sinistra', mouth: [[252, 226], [294, 226]], path: [[273, 226], [273, 196], [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: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [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: [[346, 226], [388, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false }, { id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [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. // Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) { for (const r of ramps) {
@@ -74,6 +74,8 @@ export function layout() {
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); } 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)]; r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
} }
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
return { walls, targets, bumpers, regolo, lanes, ramps }; return { walls, targets, bumpers, regolo, lanes, ramps };
} }
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì. // Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
@@ -89,11 +91,15 @@ 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, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; ball: null, extraBalls: [], locked: null, special: null, rampRun: { id: null, count: 0 },
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], 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, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; } function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
export const multiball = c => c.extraBalls.length > 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;
@@ -120,6 +126,8 @@ function add(c, points, at) {
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` }); if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
if (!c.result && c.score >= c.skill.goal) { if (!c.result && c.score >= c.skill.goal) {
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0); c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
} }
if (c.result) c.result.score = c.score; if (c.result) c.result.score = c.score;
@@ -166,7 +174,10 @@ export function update(c, dt) {
c.accumulator += dt; c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); } while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
// La scia della pallina: le ultime posizioni, per la cometa. // La scia della pallina: le ultime posizioni, per la cometa.
if (c.ball && !c.ball.waiting) { c.trail.push([c.ball.x, c.ball.y]); if (c.trail.length > 10) c.trail.shift(); } else c.trail.length = 0; for (const b of activeBalls(c)) {
if (!b.waiting) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
}
c.trail = c.ball?.trail ?? [];
} }
function moveFlippers(c, h) { function moveFlippers(c, h) {
for (const side of ['left', 'right']) { for (const side of ['left', 'right']) {
@@ -180,36 +191,56 @@ function moveFlippers(c, h) {
} }
function step(c) { function step(c) {
c.sim += STEP; c.sim += STEP;
const ball = c.ball;
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP); 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.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') { 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; } // Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
ball.vy += c.skill.g * STEP; const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp);
limit(ball); const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo. for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n; const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
const from = { x: ball.x, y: ball.y }; const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
moveFlippers(c, h); moveFlippers(c, h);
ball.x += ball.vx * h; ball.y += ball.vy * h; for (const ball of ground) {
for (const w of c.walls) collideSeg(c, ball, w); ball.x += ball.vx * h; ball.y += ball.vy * h;
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t); for (const w of c.walls) collideSeg(c, ball, w);
for (const b of c.bumpers) collideBumper(c, ball, b); for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
collideRegolo(c, ball); for (const b of c.bumpers) collideBumper(c, ball, b);
for (const side of ['left', 'right']) collideFlipper(c, ball, side); collideRegolo(c, ball);
limit(ball); for (const side of ['left', 'right']) collideFlipper(c, ball, side);
limit(ball);
}
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
} }
crossLanes(c, from, ball); for (const ball of balls) {
enterRamps(c, from, ball); if (ball.waiting) continue;
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è if (ball.ramp) { rideRamp(c, ball); continue; }
// trattenuta su un flipper alzato: quella è una mossa del giocatore. crossLanes(c, from.get(ball), ball);
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; } enterRamps(c, from.get(ball), ball);
else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } } // L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
// Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia. if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0; else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
if (ball.y > FIELD.y1 + R * 2) drain(c); if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
if ((ball.rest += STEP) > 0.3) {
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
else newBall(c);
}
} else ball.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
}
}
function collideBalls(a, b) {
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= R * 2) return;
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
limit(a); limit(b);
} }
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } } function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R); const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
@@ -256,8 +287,9 @@ function rideRamp(c, ball) {
// pallina esce sopra uno di loro, non contano come urto nuovo. // pallina esce sopra uno di loro, non contano come urto nuovo.
const v = Math.max(on.v, 150); const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null; 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; ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
add(c, SCORE.ramp, [p.x, p.y]); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id }); add(c, SCORE.ramp, [p.x, p.y]); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
if (rampFeature(c, r, ball)) return;
if (c.lastRamp && c.lastRamp.id !== r.id) { 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-ponti', power: 1 }); c.lastRamp = null; add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id }); fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
@@ -268,25 +300,68 @@ function rideRamp(c, ball) {
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; 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; } } else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
} }
function drain(c) { // La sequenza considera i passaggi completi: bersagli e rimbalzi non la interrompono, l'altra rampa sì.
function rampFeature(c, r, ball) {
if (c.locked) {
if (c.special !== r.id) { say(c, 'TENTA SPECIAL'); return true; }
const released = c.locked.ball;
released.vx = 0; released.vy = 170; released.waiting = false;
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
c.extraBalls.push(released); c.locked = null; c.special = null; c.lastRamp = null;
c.rampRun = { id: null, count: 0 }; c.saveUntil = 0; c.saved = true;
add(c, SCORE.jackpot, [324, 190]); say(c, 'JACKPOT!', c.line !== 'TRAGUARDO!'); say(c, 'MULTIBALL!');
c.events.push({ kind: 'flipper-jackpot', power: 1 }); fx(c, 'burst', 324, 130, { big: true }); fx(c, 'shake', 0, 0);
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
return true;
}
if (multiball(c)) return false;
c.rampRun = { id: r.id, count: c.rampRun.id === r.id ? c.rampRun.count + 1 : 1 };
if (c.rampRun.count < 3) return false;
// La pallina resta visibile nella serratura all'uscita della rampa. Il nuovo lancio è gratuito.
c.locked = { rampId: r.id, ball }; ball.vx = 0; ball.vy = 0; ball.trail = [];
c.special = c.ramps.find(other => other.id !== r.id).id; c.lastRamp = null;
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
newBall(c); say(c, 'LOCK!', c.line !== 'TRAGUARDO!'); say(c, 'SPECIAL!');
c.events.push({ kind: 'flipper-lock', power: 1 }); fx(c, 'ring', ball.x, ball.y);
return true;
}
function drain(c, ball) {
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' }); c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
if (multiball(c)) {
if (c.ball === ball) c.ball = c.extraBalls.shift();
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0 };
say(c, 'UNA IN GIOCO'); return;
}
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.rampRun = { id: null, count: 0 };
if (c.locked) {
// Perduta la sostitutiva: si recupera quella trattenuta, senza scalare una vita o lasciare lock sospesi.
c.locked = null; c.special = null; c.lastRamp = null; c.saved = false; c.saveUntil = 0;
say(c, 'LOCK LIBERO', true); newBall(c); return;
}
c.balls--; c.saved = false; c.lastRamp = null; c.balls--; c.saved = false; c.lastRamp = null;
if (c.balls > 0) { say(c, `PALLINE: ${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 };
} }
function contact(ball, object, hit) {
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
if (hit && fresh) ball.contacts.push(object.contactId);
else if (!hit && !fresh) ball.contacts.splice(index, 1);
object.touching = hit;
return fresh;
}
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la // Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi. // normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
function hitSeg(ball, w, e) { function hitSeg(ball, w, e) {
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1; const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u; const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny); let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
if (d >= R) { w.touching = false; return null; } if (d >= R) { contact(ball, w, false); return null; }
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; } if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
ball.x = px + nx * R; ball.y = py + ny * R; ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching; const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
w.touching = true;
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; } if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh }; return { vn: -vn, nx, ny, fresh };
} }
@@ -313,11 +388,10 @@ function collideTarget(c, ball, t) {
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca). // Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
function hitCircle(ball, o, e) { function hitCircle(ball, o, e) {
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r; const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
if (d >= min) { o.touching = false; return null; } if (d >= min) { contact(ball, o, false); return null; }
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1; const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
ball.x = o.x + nx * min; ball.y = o.y + ny * min; ball.x = o.x + nx * min; ball.y = o.y + ny * min;
const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching; const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
o.touching = true;
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; } if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
return { vn: -vn, nx, ny, fresh }; return { vn: -vn, nx, ny, fresh };
} }
@@ -390,8 +464,9 @@ export const shoot = c => canThrow(c) && launch(c, c.power);
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…'; export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
export function status(c) { export function status(c) {
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`; if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
const goal = c.result ? 'traguardo fatto, ora il record' : `traguardo ${c.skill.goal}`; const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`; const feature = c.locked ? `LOCK: pallina trattenuta. Rampa ${c.special === 'sinistra' ? 'sinistra' : 'destra'} SPECIAL: jackpot ${SCORE.jackpot} e multiball.` : multiball(c) ? 'MULTIBALL: due palline in gioco!' : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3 per il LOCK.` : '';
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
if (c.line) return `${c.line} ${tally}`; if (c.line) return `${c.line} ${tally}`;
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`; return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
} }
+2
View File
@@ -171,6 +171,8 @@ export class Soundscape {
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-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan); else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan);
else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); } else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); }
else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); }
else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, 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-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); }
+2 -2
View File
@@ -24,8 +24,8 @@ export const piloti = [
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea). // Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' }, { id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di // Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
// Andrea); il ritratto di Codex dalla sua foto. [DA ANDREA: motore e testa] // Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore]
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 3, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' }, { id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea). // Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' }, { id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa] // La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
+24 -3
View File
@@ -83,10 +83,31 @@ try {
await page.click('#resume'); await page.click('#resume');
await page.waitForFunction(() => document.querySelector('#pause').hidden); await page.waitForFunction(() => document.querySelector('#pause').hidden);
} }
// La fine: si fa finire la partita e si guarda il quadro. // Il traguardo aggiunge una pallina e lascia continuare la partita.
await page.evaluate(() => { const c = game.closeup; c.score = 7350; c.step = 'result'; c.ball = null; c.result = { winner: 'player', score: 7350, goal: c.skill.goal }; }); const reward = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
m.cancel(c); if (m.waiting(c)) m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - m.SCORE.bumper; c.saveUntil = 0;
const b = c.bumpers[0]; b.touching = false;
Object.assign(c.ball, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200, waiting: false, ramp: null });
m.update(c, 0.05);
return { balls: c.balls, step: c.step, winner: c.result?.winner };
});
assert.deepEqual(reward, { balls: 2, step: 'play', winner: 'player' });
await page.waitForFunction(() => document.querySelector('#bocce-status').textContent.includes('pallina premio vinta'));
await page.screenshot({ path: `${out}/${name}-premio.png` });
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
const finalScore = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
for (let i = 0; i < 4 && c.step === 'play'; i++) {
if (m.waiting(c)) m.launch(c, 0.8);
c.saveUntil = 0;
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); m.update(c, 0.1);
}
return c.score;
});
await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita'); await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Finita');
assert.match(await page.locator('#bocce-status').textContent(), /7350 punti/); assert.ok((await page.locator('#bocce-status').textContent()).includes(`${finalScore} punti`));
await page.screenshot({ path: `${out}/${name}-fine.png` }); await page.screenshot({ path: `${out}/${name}-fine.png` });
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup); await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
+92
View File
@@ -170,6 +170,27 @@ 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('il traguardo assegna una sola pallina premio, utilizzabile anche se si era all\'ultima, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const missing of [m.SCORE.bumper, 500]) {
const c = m.newFlipper(85, { level }); m.launch(c, 0.8);
c.balls = 1; c.score = c.skill.goal - missing;
const currentBall = c.ball, b = c.bumpers[0];
put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05);
assert.equal(c.balls, 2, `${level}: una pallina in più, raggiungendo o superando la soglia`);
assert.equal(c.ball, currentBall, 'il tiro in corso prosegue');
assert.equal(c.step, 'play'); assert.equal(c.result.winner, 'player');
assert.equal(c.line, 'TRAGUARDO!'); assert.equal(c.lines[0], 'PALLINA +1');
assert.match(m.status(c), /pallina premio vinta/);
put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(c.score > c.skill.goal); assert.equal(c.balls, 2, 'altri punti non assegnano altri premi');
c.saveUntil = 0; put(c, { x: 320, y: 342, vx: 0, vy: 300, ramp: null }); run(c, 0.1);
assert.equal(c.balls, 1); assert.ok(m.waiting(c), 'persa l\'ultima originale, si può lanciare la pallina premio');
m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x: 320, y: 342, vx: 0, vy: 300 }); run(c, 0.1);
assert.equal(c.balls, 0); assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player');
}
});
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', () => { 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; const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 }); const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
@@ -201,6 +222,40 @@ test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nel
assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa'); assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa');
}); });
test('le rampe centrali si prendono con tiri obliqui dalle palette e anche fuori centro, a tutti i livelli', () => {
for (const level of Object.keys(m.LEVELS)) for (const [index, side] of [[0, -1], [1, 1]]) {
const c = m.newFlipper(81, { level }); m.launch(c, 0.8);
ride(c, { x: 320 + side * 20, y: 290, vx: side * 90, vy: -460 });
assert.equal(c.score, m.SCORE.ramp, `${level}: tiro obliquo dalla zona delle palette, lato ${side}`);
assert.ok(c.ramps[index].lit > 0);
for (const offset of [-12, 12]) {
const d = m.newFlipper(82, { level }); m.launch(d, 0.8);
const r = d.ramps[index];
ride(d, { x: r.path[0][0] + offset, y: r.mouth[0][1] + 1, vx: 0, vy: -330 });
assert.equal(d.score, m.SCORE.ramp, `${level}: basta un colpo medio, decentrato di ${offset}`);
}
const e = m.newFlipper(83, { level }); m.launch(e, 0.8);
const r = e.ramps[index];
put(e, { x: r.mouth[0][0] + m.R - 1, y: r.mouth[0][1] + 1, vx: 0, vy: -330 }); run(e, 0.05);
assert.equal(e.ball.ramp, null, 'fuori dalla bocca disegnata la pallina non viene catturata');
}
});
test('le uscite laterali sono più strette: i tiri vicini rientrano verso le palette, quelli esterni si perdono ancora', () => {
for (const [x, safe] of [[218, true], [419, true], [207, false], [426, false]]) {
const c = m.newFlipper(84); m.launch(c, 0.8); c.saveUntil = 0;
put(c, { x, y: 250, vx: 0, vy: 100 });
let lower = null;
for (let i = 0; i < 2 / m.STEP && c.balls === 3; i++) {
m.update(c, m.STEP);
if (!lower && c.ball?.y >= 300) lower = { x: c.ball.x };
}
assert.ok(lower, `la pallina ${x} non resta incastrata nella corsia`);
if (safe) assert.ok(lower.x > 250 && lower.x < 390, `${x} torna verso le palette (${lower.x})`);
else { assert.equal(lower.x, x, 'la corsia esterna resta aperta'); assert.equal(c.balls, 2); }
}
});
test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => { test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => {
const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9); const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9);
const kinds = () => c.fx.map(f => f.kind); const kinds = () => c.fx.map(f => f.kind);
@@ -222,6 +277,43 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena'); assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
}); });
test('il LOCK: lo stesso ponte tre volte di fila trattiene la pallina e ne dà una nuova; l\'altro ponte SPECIAL vale il jackpot e il multiball', () => {
const c = m.newFlipper(21, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
const left = c.ramps[0], right = c.ramps[1];
// Un tiro nel ponte: si aspetta che la pallina esca in fondo, prima che vada altrove (così il conto non si azzera).
const shoot = r => { const [[x0], [x1]] = r.mouth; put(c, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(c, 1 / 60); if (c.ball?.ramp) was = true; else if (was) break; } };
shoot(left); assert.equal(c.rampRun.count, 1); assert.ok(!c.locked);
shoot(left); assert.equal(c.rampRun.count, 2);
shoot(left);
assert.ok(c.locked && c.locked.rampId === 'sinistra', 'la pallina è trattenuta');
assert.equal(c.special, 'destra', 'l\'altro ponte è SPECIAL');
assert.ok(m.waiting(c) && c.ball !== c.locked.ball, 'una pallina nuova sulla molla');
assert.ok([c.line, ...c.lines].some(l => /LOCK/.test(l ?? '')) && [c.line, ...c.lines].some(l => /SPECIAL/.test(l ?? '')));
assert.ok(c.events.some(e => e.kind === 'flipper-lock'));
assert.equal(c.balls, 3, 'il lock non costa una pallina');
// Il ponte sbagliato non libera niente.
m.launch(c, 0.9); c.events.length = 0; shoot(left);
assert.ok(c.locked, 'ancora trattenuta');
// Il ponte SPECIAL: jackpot, la pallina liberata, due in gioco.
const before = c.score; shoot(right);
assert.ok(c.score - before >= m.SCORE.jackpot + m.SCORE.ramp, `jackpot (${c.score - before})`);
assert.ok(!c.locked && !c.special, 'liberata');
assert.ok(m.multiball(c) && m.activeBalls(c).length === 2, 'multiball');
assert.ok(c.events.some(e => e.kind === 'flipper-jackpot'));
// In multiball perdere una pallina non costa: resta l'altra; persa anche quella, si scala.
const riserva = c.balls; // (il jackpot ha passato il traguardo: una pallina premio in più)
Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.ok(!m.multiball(c) && c.balls === riserva && m.activeBalls(c).length === 1, 'una in gioco, nessuna persa');
c.saveUntil = 0; Object.assign(c.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(c, 0.5);
assert.equal(c.balls, riserva - 1, 'ora sì');
// Persa la pallina nuova prima dello SPECIAL: quella trattenuta torna, senza scalare.
const d = m.newFlipper(22, { level: 'bona' }); m.launch(d, 0.9);
const go = r => { const [[x0], [x1]] = r.mouth; put(d, { x: (x0 + x1) / 2, y: r.mouth[0][1] + 14, vx: 0, vy: -470, ramp: null }); let was = false; for (let i = 0; i < 240; i++) { m.update(d, 1 / 60); if (d.ball?.ramp) was = true; else if (was) break; } };
go(d.ramps[0]); go(d.ramps[0]); go(d.ramps[0]); assert.ok(d.locked);
m.launch(d, 0.9); d.saveUntil = 0; Object.assign(d.ball, { x: 320, y: 342, vx: 0, vy: 300, waiting: false, ramp: null }); run(d, 0.5);
assert.ok(!d.locked && d.balls === 3 && m.waiting(d), 'lock libero, pallina di nuovo sulla molla');
});
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++) {