Il flipper più spettacolare: anelli e scintille, coriandoli dei rioni, luci che corrono sui ponti, punti che volano, lampo e scossa, scia della pallina, regia e fanfare sul tabellone; ponti, sponde e griglia dei punti in cache (niente arretrato grafico) (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 22:06:26 +01:00
co-authored by Claude Fable 5.1
parent 721b6de930
commit 4d4aa8aeea
5 changed files with 191 additions and 32 deletions
+131 -18
View File
@@ -5,6 +5,7 @@
// 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, rampHeight, waiting, canThrow, saveLeft } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
import { text, panel } from './bocce-render.js';
const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' };
@@ -34,27 +35,48 @@ 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 (il sinistro caldo, il destro freddo, per seguirli), un po'
// trasparente perché si veda la tavola sotto, più alta in cima (la quota), col bordo scuro e la luce; accesi, brillano.
// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), più alta
// in cima (la quota), con l'ombra, il bordo scuro e la luce; accesi, brillano; l'altro, in attesa della combo, invita.
// Si dipingono una volta per stato su una tela a parte e poi si copiano (i tratti larghi e trasparenti a ogni
// fotogramma lasciavano alla grafica un arretrato di secondi, vedi effetti-leggeri).
const RAMP_LOOK = { sinistra: ['#c9b89a', '#8a7a5a'], destra: ['#b8c0c9', '#6a7a8a'] };
function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
function ramp(ctx, r, time, who) {
function paintRamp(ctx, r, state) {
const path = () => { ctx.beginPath(); for (let s = 0; s <= r.len; s += 4) { const p = rampPoint(r, Math.min(s, r.len)); s ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y); } };
const [light, dark] = RAMP_LOOK[r.id], invite = who && who !== r.id && Math.sin(time * 6) > 0;
const [light, dark] = RAMP_LOOK[r.id];
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke();
ctx.globalAlpha = 0.8;
ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : invite ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke();
ctx.globalAlpha = 0.45; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke();
ctx.globalAlpha = 1;
ctx.strokeStyle = state === 'lit' ? '#ffd45a' : state === 'invite' ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke();
ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke();
ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke();
// Le pietre: trattini scuri a passo regolare lungo il ponte.
ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1;
for (let s = 6; s < r.len; s += 9) { const p = rampPoint(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); }
ctx.globalAlpha = 1;
// L'imbocco: la rampetta che sale, con la freccia.
const [[x0, y], [x1]] = r.mouth;
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6);
ctx.fillStyle = invite ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill();
ctx.fillStyle = state === 'invite' ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill();
}
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';
if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; }
const key = `${r.id}:${state}`;
let layer = rampCache.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 });
paintRamp(layer.getContext('2d'), r, state);
rampCache.set(key, layer);
}
ctx.drawImage(layer, 0, 0);
}
let railLayer = null;
function rails(ctx, c) {
const paint = g => { for (const w of c.walls) rail(g, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6'); };
if (typeof document === 'undefined') { paint(ctx); return; }
if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); }
ctx.drawImage(railLayer, 0, 0);
}
function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
@@ -107,16 +129,30 @@ const GLYPHS = {
'.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
};
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore.
// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. La griglia
// dei punti spenti è sempre uguale: si dipinge una volta per misura su una tela a parte e si copia (centinaia di
// rettangoli trasparenti a ogni fotogramma lasciavano arretrato alla grafica, vedi effetti-leggeri).
const gridCache = new Map();
function offGrid(ctx, n, x0, y, pitch, off) {
if (typeof document === 'undefined') return;
const key = `${n}:${pitch}:${off}`, size = Math.max(1, pitch - 1);
let layer = gridCache.get(key);
if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: n * 6 * pitch, height: 7 * pitch });
const g = layer.getContext('2d'); g.fillStyle = off;
for (let i = 0; i < n; i++) for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) g.fillRect((i * 6 + col) * pitch, row * pitch, size, size);
gridCache.set(key, layer);
}
ctx.drawImage(layer, x0, Math.round(y));
}
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);
offGrid(ctx, chars.length, x0, y, pitch, off);
ctx.fillStyle = on;
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);
}
for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) if (g[row][col] === '1') 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
@@ -152,6 +188,7 @@ function board(ctx, c, time) {
const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`);
const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3;
if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
if (flashing) regia(ctx, c.line, time - d.flash, x0, y0, w);
ctx.restore();
// Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono.
text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a');
@@ -165,11 +202,83 @@ function board(ctx, c, time) {
if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right');
else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a');
}
// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al
// Palio le quattro bandiere dei rioni che sfilano, ai ponti due archi di punti che si uniscono.
const CROWN = ['10001000100010001', '11011101110111011', '11111111111111111', '01111111111111110', '01111111111111110'];
function regia(ctx, line, age, x0, y0, w) {
const cx = x0 + w / 2;
if (line === 'TRAGUARDO!') {
const cols = Math.min(17, Math.floor(age * 12));
for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, y0 + 24 + row * 4, 3, 3); }
} else if (line === 'PALIO!') {
['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = x0 + 10 + ((age * 140 + i * 40) % (w - 20)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), y0 + 60, 8, 6); ctx.fillRect(Math.round(x), y0 + 88, 8, 6); });
} else if (line === 'I PONTI!') {
const k = Math.min(1, age / 0.8);
for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); }
}
}
// I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo.
export function shownScore(d, time) {
const k = Math.min(1, (time - d.t0) / 0.5);
return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10;
}
// --- Gli effetti (c.fx): piccoli e tanti, niente filtri. Ognuno vive dal suo `t` per un tempo suo.
const CONFETTI = ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0', '#ffd45a'];
function drawFx(ctx, c, time) {
for (const f of c.fx) {
const age = time - f.t;
if (f.kind === 'ring' && age < 0.4) {
ctx.globalAlpha = 1 - age / 0.4; ctx.strokeStyle = '#fff4c8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(f.x, f.y, 10 + age * 110, 0, Math.PI * 2); ctx.stroke();
} else if (f.kind === 'spark' && age < 0.6) {
ctx.globalAlpha = 1 - age / 0.6;
for (let i = 0; i < 10; i++) {
const a = hash(i * 7 + f.t * 100) * Math.PI * 2, v = 80 + hash(i * 13 + 1) * 140;
ctx.fillStyle = i % 2 ? '#fff4c8' : '#ffb13a';
ctx.fillRect(Math.round(f.x + Math.cos(a) * v * age), Math.round(f.y + Math.sin(a) * v * age + 220 * age * age), 2, 2);
}
} else if (f.kind === 'burst' && age < 1.6) {
const n = f.big ? 48 : 24;
ctx.globalAlpha = Math.min(1, (1.6 - age) / 0.5);
for (let i = 0; i < n; i++) {
const a = hash(i * 7 + 3) * Math.PI * 2, v = (f.big ? 120 : 80) + hash(i * 13 + 5) * 160, spin = hash(i * 3 + 9) * 10;
const x = f.x + Math.cos(a) * v * age * 0.8, y = f.y + Math.sin(a) * v * age * 0.6 + 160 * age * age;
if (x < FIELD.x0 || x > FIELD.x1 || y > FIELD.y1) continue;
ctx.fillStyle = CONFETTI[i % CONFETTI.length];
const w = 3 + 2 * Math.abs(Math.sin(time * spin + i)), h = 2;
ctx.fillRect(Math.round(x - w / 2), Math.round(y), Math.round(w), h);
}
} else if (f.kind === 'chase' && age < 1.2) {
const r = c.ramps.find(k => k.id === f.ramp);
if (!r) continue;
ctx.globalAlpha = Math.min(1, (1.2 - age) / 0.3);
for (let k = 0; k < 8; k++) {
const s = (age * 520 + k * 28) % (r.len + 1), p = rampPoint(r, s);
ctx.fillStyle = k % 2 ? '#ffd45a' : '#fff6c8'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2); ctx.fill();
}
} else if (f.kind === 'pop' && age < 1) {
ctx.globalAlpha = 1 - age;
text(ctx, f.text, f.x, f.y - age * 28, age < 0.15 ? 8 : 6, '#fff4c8', 'center');
} else if (f.kind === 'wash' && age < 0.3) {
// Un lampo colorato sulla tavola sola, per un terzo di secondo: una passata piccola, non a tutto schermo.
ctx.globalAlpha = 0.35 * (1 - age / 0.3); ctx.fillStyle = `rgb(${f.color})`;
ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0);
}
}
ctx.globalAlpha = 1;
}
// La scossa del mobile ai colpi grossi: tutta la scena trema per un terzo di secondo.
function shake(c, time) {
let k = 0;
for (const f of c.fx) if (f.kind === 'shake') k = Math.max(k, 1 - (time - f.t) / 0.35);
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(); });
ctx.globalAlpha = 1;
}
function result(ctx, c) {
panel(ctx, 16, 238, 172, 56);
text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center');
@@ -177,8 +286,9 @@ function result(ctx, c) {
}
export function drawFlipper(ctx, c, def, bg) {
const time = c.visual.time;
const time = c.visual.time, [sx, sy] = shake(c, time);
ctx.save();
if (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); }
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); }
// Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
@@ -186,8 +296,9 @@ export function drawFlipper(ctx, c, def, bg) {
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7);
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050');
}
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa.
for (const w of c.walls) rail(ctx, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6');
// Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. Non cambiano mai: dipinte
// una volta su una tela a parte.
rails(ctx, c);
// I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra.
for (const t of c.targets) {
const [x, y0] = t.a, y1 = t.b[1];
@@ -200,10 +311,12 @@ export function drawFlipper(ctx, c, def, bg) {
regolo(ctx, c.regolo);
for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c);
// La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte.
// 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);
drawFx(ctx, c, time);
// 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);
+32 -14
View File
@@ -87,7 +87,7 @@ 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, visual: { time: 0 }, ...layout() };
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
newBall(c);
return c;
}
@@ -107,10 +107,19 @@ function say(c, line, front = false) {
if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; }
else c.lines.push(line);
}
// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record.
function add(c, points) {
// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che
// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE.
export const FX_LIFE = 2;
function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); }
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
// continua per il record.
function add(c, points, at) {
c.score += points;
if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.events.push({ kind: 'flipper-palio', power: 1 }); }
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);
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;
}
@@ -151,8 +160,11 @@ export function update(c, dt) {
if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } }
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt);
c.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE);
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;
}
function moveFlippers(c, h) {
for (const side of ['left', 'right']) {
@@ -205,8 +217,11 @@ function crossLanes(c, from, ball) {
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
if (!l || l.lit) return;
l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 });
if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; }
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
if (c.lanes.every(k => k.lit)) {
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
}
}
// 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
@@ -240,8 +255,11 @@ function rideRamp(c, ball) {
const v = Math.max(on.v, 150);
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true;
add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 });
if (c.lastRamp && c.lastRamp.id !== r.id) { add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-palio', power: 1 }); c.lastRamp = null; }
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 (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 });
}
else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; }
} else if (on.s <= 0) {
// Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte.
@@ -249,7 +267,7 @@ function rideRamp(c, ball) {
} 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 });
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
c.balls--; c.saved = false; c.lastRamp = null;
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
@@ -280,15 +298,15 @@ function collideSeg(c, ball, w) {
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 });
add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y);
} else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; }
}
function collideTarget(c, ball, t) {
const hit = hitSeg(ball, t, 0.5);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 });
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
const bank = c.targets.filter(k => k.bank === t.bank);
if (bank.every(k => k.down)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; }
if (bank.every(k => k.down)) { add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
}
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
function hitCircle(ball, o, e) {
@@ -306,12 +324,12 @@ function collideBumper(c, ball, b) {
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
b.hit = 0.15; add(c, SCORE.bumper); c.events.push({ kind: 'flipper-fungo', power: 0.8 });
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
}
function collideRegolo(c, ball) {
const hit = hitCircle(ball, c.regolo, 0.7);
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 });
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
}
// Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato.
function collideFlipper(c, ball, side) {
+2
View File
@@ -169,6 +169,8 @@ 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-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-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); }