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);