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
+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
// 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, 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 { text, panel } from './bocce-render.js';
@@ -112,7 +112,7 @@ function rampEntrance(ctx, r, time, who) {
}
const rampCache = new Map();
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; }
const key = `${r.id}:${state}`;
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);
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) {
if (!b) return;
// 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 (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh);
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);
panel(ctx, 20, 134, 160, 31);
label(ctx, `OBIETTIVO ${c.skill.goal.toLocaleString('it-IT')}`, 28, 142, 7, '#f1d7a1', 'left');
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);
ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3);
ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3);
// 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'));
// 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'); });
// Sotto, in piccolo: il salvataggio o la combo.
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');
}
// 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];
}
// La cometa: le ultime posizioni della pallina, sempre più tenui.
function trail(ctx, c) {
const n = c.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(); });
function trail(ctx, b) {
const n = b.trail.length;
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;
}
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 side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c);
// La pallina (con la cometa) sotto i ponti, se è sulla tavola; sopra, se è su un ponte; poi gli effetti.
if (c.ball && !c.ball.waiting) trail(ctx, c);
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);
// Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina
// trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti.
const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id;
for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); }
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);
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.
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);
+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 PIVOT = { left: [280, 314], right: [360, 314] };
// 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
// 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 };
@@ -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,
// in cima, come due ponti.
const ramps = [
// Imbocchi verso il centro, davanti alle sponde elastiche: 33 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: '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 },
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
{ 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: [[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.
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); }
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 };
}
// 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,
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, 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);
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 canThrow = c => waiting(c) && !c.holding;
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 (!c.result && c.score >= c.skill.goal) {
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);
}
if (c.result) c.result.score = c.score;
@@ -166,7 +174,10 @@ export function update(c, dt) {
c.accumulator += dt;
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
// 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) {
for (const side of ['left', 'right']) {
@@ -180,36 +191,56 @@ function moveFlippers(c, h) {
}
function step(c) {
c.sim += STEP;
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') { 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]; }
if (ball.ramp) { moveFlippers(c, STEP); 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.
const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n;
const from = { x: ball.x, y: ball.y };
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp);
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
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++) {
moveFlippers(c, h);
ball.x += ball.vx * h; ball.y += ball.vy * h;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
for (const b of c.bumpers) collideBumper(c, ball, b);
collideRegolo(c, ball);
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
limit(ball);
for (const ball of ground) {
ball.x += ball.vx * h; ball.y += ball.vy * h;
for (const w of c.walls) collideSeg(c, ball, w);
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
for (const b of c.bumpers) collideBumper(c, ball, b);
collideRegolo(c, 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);
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; }
else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
// Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia.
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0;
if (ball.y > FIELD.y1 + R * 2) drain(c);
for (const ball of balls) {
if (ball.waiting) continue;
if (ball.ramp) { rideRamp(c, ball); continue; }
crossLanes(c, from.get(ball), ball);
enterRamps(c, from.get(ball), ball);
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
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; }
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.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; } }
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.
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;
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 });
if (rampFeature(c, r, ball)) return;
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;
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;
} 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' });
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; }
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;
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 };
}
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
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
function hitSeg(ball, w, e) {
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
const 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);
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; }
ball.x = px + nx * R; ball.y = py + ny * R;
const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching;
w.touching = true;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
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).
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;
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;
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching;
o.touching = true;
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
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 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}.`}`;
const goal = c.result ? 'traguardo fatto, ora il record' : `traguardo ${c.skill.goal}`;
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`;
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
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}`;
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-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-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-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); }
+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).
{ 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
// Andrea); il ritratto di Codex dalla sua foto. [DA ANDREA: motore e testa]
{ 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' },
// 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: 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).
{ 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]