Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) corre il GP di Fortezza col suo NRG bianco e rosso numero 1 (motorino e ritratto di Codex dalle sue foto) (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:52:53 +01:00
co-authored by Claude Fable 5.1
parent 3079c6ea8a
commit dfa70a9620
9 changed files with 163 additions and 53 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+5 -1
View File
@@ -80,6 +80,10 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
(`assets/closeups/river-flipper-tabellone.png`, 200×360: il Castello del Trebbio, la Fortezza sotto i fasci di luce,
il Regolo coi fulmini, i fuochi nei colori dei rioni), le cifre nella finestra nera, le lampadine sui bordi, i cinque
dischi in basso (palline e rioni), i ponti ai lati della finestra, il fondale che lampeggia ai colpi grossi.
Revisione estetica (Andrea, 31 ottobre): rampe a canalina con parapetti cromati e inserti ambra/azzurri,
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
restano visibili sotto il display, con una barra di avanzamento. Prove desktop e touch in `browser:flipper`.
[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
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
@@ -465,7 +469,7 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
truccatissimo, motore 6, viola e giallo dalla sua foto, ritratto di Codex, testa «non pervenuta»), Bini (Andrea
Bini, consenso del 28 ottobre: Italjet Formula giallo, motore 5, ritratto di Codex), Cistola (CR 50 Z), Andrea (Malaguti Fifty rosso e nero), Tozzino (Booster blu
scuro, di serie, casco alla Damon Hill di Codex: `prompts/personaggi/casco-tozzino.txt`), l'Arcu (Betino). Col telefono
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
si guida con un dito: premuto è gas, lo swipe indica dove andare sullo schermo (Codex, 28 ottobre). Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo NRG bianco e rosso numero 1 e il ritratto di Codex. Puntella (Marco Villani) ha dato il consenso il 28 ottobre; chi ha `consenso: null` in piloti.js non corre. Mancano: quanto erano
truccati gli altri, le battute di Andrea, il rumore del motore (c'è solo
quello dell'arrivo in griglia); i confini della paglia non si vedono sul quadro (revisione di Codex, punto 9).
- Strade attorno a casa (Andrea, 28 ottobre): la scena `area_camper` ora si chiama «Le villette del Giannini» (ci abita
+2 -1
View File
@@ -309,7 +309,8 @@
scende per una delle quattro corsie dei rioni: accendile tutte e quattro per il PALIO (30.000 punti). I tre funghi
sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (10.000). I due ponti: imboccali dal basso con un colpo deciso, la pallina ci sale sopra e
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
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>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
+116 -37
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, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js';
import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
import { text, panel } from './bocce-render.js';
@@ -16,47 +16,99 @@ const rail = (ctx, [ax, ay], [bx, by], dark = '#2a2622', light = '#c9c3b6') => {
};
function disc(ctx, x, y, r, fill, rim) {
ctx.fillStyle = rim; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - 2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r - Math.min(2, r * 0.45), 0, Math.PI * 2); ctx.fill();
}
function bumper(ctx, b) {
const r = b.r * (b.hit > 0 ? 1 + b.hit * 1.2 : 1);
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.arc(b.x + 2, b.y + 3, r, 0, Math.PI * 2); ctx.fill();
disc(ctx, b.x, b.y, r, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16');
disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e');
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.6), 3, 2);
const r = b.r, lit = b.hit > 0;
disc(ctx, b.x + 1, b.y + 3, r + 1, '#172127', '#131a1e');
const metal = ctx.createLinearGradient(b.x - r, b.y - r, b.x + r, b.y + r);
metal.addColorStop(0, '#fff3cc'); metal.addColorStop(0.35, '#bda16d');
metal.addColorStop(0.55, '#fff1cc'); metal.addColorStop(1, '#665238');
disc(ctx, b.x, b.y, r, metal, '#111d25');
disc(ctx, b.x, b.y, r - 2.5, lit ? '#fff3bd' : '#e3b658', '#654223');
const cap = ctx.createRadialGradient(b.x - 3, b.y - 4, 1, b.x, b.y, r - 4);
cap.addColorStop(0, lit ? '#fffef0' : '#e87c61'); cap.addColorStop(0.45, lit ? '#ffe39a' : '#ba4037'); cap.addColorStop(1, '#612b2b');
disc(ctx, b.x, b.y, r - 4, cap, '#602c27');
label(ctx, `${SCORE.bumper / 1000}K`, b.x, b.y + 0.5, 5, '#fff2d5');
ctx.strokeStyle = '#ffe6ae'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(b.x, b.y, r - 1, Math.PI * 1.08, Math.PI * 1.65); ctx.stroke();
}
// Incisioni con un carattere compatto, leggibile alla risoluzione della scena.
function label(ctx, line, x, y, size = 6, color = '#f8e6bc', align = 'center') {
ctx.font = `bold ${size}px monospace`; ctx.textAlign = align; ctx.textBaseline = 'middle';
ctx.fillStyle = color; ctx.fillText(line, x, y);
}
function polygon(ctx, points, fill, stroke) {
ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
function screw(ctx, x, y) {
disc(ctx, x, y, 1.7, '#d1d9d5', '#25333a');
ctx.strokeStyle = '#53636b'; ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(x - 0.7, y + 0.7); ctx.lineTo(x + 0.7, y - 0.7); ctx.stroke();
}
// Il Regolo: un bersaglio tondo d'oro con la corona e il bordo scuro, che non si confonde col drago dipinto.
function regolo(ctx, o) {
const r = o.r + (o.hit > 0 ? 2 : 0);
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.arc(o.x + 1, o.y + 2, r + 1, 0, Math.PI * 2); ctx.fill();
disc(ctx, o.x, o.y, r + 1, o.hit > 0 ? '#fff6c8' : '#f0c040', '#3a2a08');
ctx.fillStyle = '#3fa34d'; ctx.beginPath(); ctx.arc(o.x, o.y, r - 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffd45a';
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);
const gem = ctx.createRadialGradient(o.x - 2, o.y - 2, 0, o.x, o.y, r - 1);
gem.addColorStop(0, '#bceca0'); gem.addColorStop(0.35, '#3f9c77'); gem.addColorStop(1, '#153f42');
disc(ctx, o.x, o.y, r - 1.5, gem, '#65522c');
polygon(ctx, [[o.x - 5, o.y - r - 1], [o.x - 6, o.y - r - 6], [o.x - 2, o.y - r - 4], [o.x, o.y - r - 8], [o.x + 2, o.y - r - 4], [o.x + 6, o.y - r - 6], [o.x + 5, o.y - r - 1]], '#edc877', '#65522c');
label(ctx, `${SCORE.regolo / 1000}K`, o.x, o.y + 0.5, 5, '#fff4c8');
}
// 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.
// I ponti: canaline metalliche, ambra a sinistra e azzurre a destra, sollevate secondo la quota della pallina.
// 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'] };
const RAMP_LOOK = { sinistra: ['#ffd57a', '#95713d'], destra: ['#9ce9f2', '#497d92'] };
function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
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];
const [light, dark] = RAMP_LOOK[r.id], active = state !== 'base';
// Il fondo è una canalina scura; i due parapetti sono fili cromati separati.
// Si segue esattamente il percorso della fisica, anche ai cambi di direzione.
const points = r.cum.map(s => rampPoint(r, s));
const path = () => { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); };
const stroke = (width, color) => { ctx.lineWidth = width; ctx.strokeStyle = color; path(); ctx.stroke(); };
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
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(); }
// 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 = 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();
ctx.save(); ctx.translate(2, 4); stroke(17, 'rgba(3,12,19,0.48)'); ctx.restore();
stroke(16, '#12212a'); stroke(14, active ? light : dark);
stroke(12, '#e0e4d6'); stroke(10, '#32454e'); stroke(7, '#192e39');
stroke(2, r.id === 'sinistra' ? '#4e4940' : '#284957');
// Traverse distanziate e piccoli inserti luminosi sui parapetti.
for (let s = 18; s < r.len - 8; s += 26) {
const p = rampPoint(r, s);
ctx.strokeStyle = '#62727a'; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.moveTo(p.x - p.dy * 4, p.y + p.dx * 4); ctx.lineTo(p.x + p.dy * 4, p.y - p.dx * 4); ctx.stroke();
for (const side of [-1, 1]) {
ctx.fillStyle = active ? '#fff6d8' : light;
ctx.beginPath(); ctx.arc(p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side, 1, 0, Math.PI * 2); ctx.fill();
}
}
// L'apertura coincide con il segmento d'ingresso: una pedana svasata e una soglia a terra.
const [[x0, y], [x1]] = r.mouth, cx = (x0 + x1) / 2;
const deck = ctx.createLinearGradient(0, y - 30, 0, y);
deck.addColorStop(0, '#1b303b'); deck.addColorStop(0.75, '#455860'); deck.addColorStop(1, '#b8c3bd');
polygon(ctx, [[cx - 5, y - 30], [cx + 5, y - 30], [x1, y], [x0, y]], deck, '#18232b');
for (const side of [-1, 1]) {
rail(ctx, [cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y], '#18232b', light);
screw(ctx, cx + side * (x1 - x0) / 2, y);
}
ctx.fillStyle = '#edf0d9'; ctx.fillRect(x0 + 1, y - 1, x1 - x0 - 2, 1);
label(ctx, `${SCORE.ramp / 1000}K`, cx, y - 18, 5, light);
}
function rampEntrance(ctx, r, time, who) {
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
const color = RAMP_LOOK[r.id][0], invite = who && who !== r.id;
// Un inserto scuro isola le frecce dai dettagli del ponte dipinto sotto.
polygon(ctx, [[x - 6, y + 1], [x + 6, y + 1], [x + 7, y + 22], [x - 7, y + 22]], '#10222e', color);
// Una freccia piena, leggibile anche sul telefono; pulsa più rapida per la combo.
const on = Math.sin(time * (invite ? 9 : 4)) > 0;
polygon(ctx, [[x, y + 4], [x + 5, y + 11], [x + 2, y + 11], [x + 2, y + 20], [x - 2, y + 20], [x - 2, y + 11], [x - 5, y + 11]], on ? '#fff6d6' : color);
ctx.fillStyle = '#112530'; ctx.fillRect(x - 14, y + 22, 28, 10);
ctx.strokeStyle = color; ctx.lineWidth = 0.7; ctx.strokeRect(x - 14, y + 22, 28, 10);
label(ctx, 'RAMPA', x, y + 27, 6, color);
}
const rampCache = new Map();
function ramp(ctx, r, time, who) {
@@ -73,19 +125,34 @@ function ramp(ctx, r, time, who) {
}
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'); };
const paint = g => {
// Plastiche smaltate sopra gli elastici, con bordi e viti di fissaggio.
for (const side of [-1, 1]) {
const x = side < 0 ? 236 : 404, tip = x - side * 26;
polygon(g, [[x, 246], [x, 284], [tip, 290]], '#152f3c', '#c1a273');
polygon(g, [[x - side * 4, 260], [x - side * 4, 280], [tip + side * 7, 284]], '#294c5b', '#597c83');
screw(g, x, 250); screw(g, x, 281); screw(g, tip + side * 4, 286);
}
for (const w of c.walls) rail(g, w.a, w.b, w.sling ? '#582723' : '#18262d', w.sling ? '#f09a70' : '#d2d9cb');
};
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);
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px + 1, py + 3); ctx.lineTo(tx + 1, ty + 3); ctx.stroke();
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke();
ctx.strokeStyle = '#fff1b8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, py - 1.5); ctx.lineTo(tx, ty - 1.5); ctx.stroke();
disc(ctx, px, py, 4, '#8a7a6a', '#2a2622');
ctx.save(); ctx.translate(px, py); ctx.rotate(Math.atan2(ty - py, tx - px));
const len = Math.hypot(tx - px, ty - py);
const shape = () => {
ctx.beginPath(); ctx.moveTo(0, -3); ctx.lineTo(len, -2.5);
ctx.quadraticCurveTo(len + 3, 0, len, 2.5); ctx.lineTo(0, 3);
ctx.arc(0, 0, 3, Math.PI / 2, Math.PI * 1.5); ctx.closePath();
};
ctx.save(); ctx.translate(1, 3); shape(); ctx.fillStyle = '#142029'; ctx.fill(); ctx.restore();
shape(); ctx.fillStyle = f.held.length ? '#fff6da' : '#f0e4c6'; ctx.fill();
ctx.strokeStyle = '#bb503c'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.strokeStyle = '#fffdf0'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(3, -1.7); ctx.lineTo(len - 2, -1.2); ctx.stroke();
screw(ctx, 0, 0); ctx.restore();
}
function plunger(ctx, c) {
const x = (LANE.x0 + LANE.x1) / 2, top = 326, pull = c.holding ? c.power * 14 : canThrow(c) ? c.power * 6 : 0;
@@ -194,6 +261,13 @@ 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.
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);
// 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.
@@ -294,10 +368,12 @@ export function drawFlipper(ctx, c, def, bg, glass) {
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); }
// La stampa resta visibile, ma le parti giocabili emergono dal paesaggio.
ctx.fillStyle = 'rgba(8,25,34,0.22)'; ctx.fillRect(201, 73, 230, 261);
// Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
for (const l of c.lanes) {
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');
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#273a3e', l.lit ? '#fff4c8' : '#b5a278');
disc(ctx, l.x, l.y, 3.5, l.lit ? '#fff2c6' : COLORS[l.rione], '#182c32');
}
// 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.
@@ -309,9 +385,12 @@ export function drawFlipper(ctx, c, def, bg, glass) {
ctx.fillStyle = '#2a2622'; ctx.fillRect(x - 3, y0 - 1, 6, y1 - y0 + 2);
ctx.fillStyle = t.bank === 'pieve' ? '#f2eee2' : '#e8b640'; ctx.fillRect(x - 2, y0, 4, y1 - y0);
ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(x - 2, y1 - 3, 4, 3);
ctx.fillStyle = '#fff8d7'; ctx.fillRect(x - 2, y0, 4, 1);
ctx.fillStyle = '#3a5864'; ctx.fillRect(x - 1, y0 + 3, 2, 4);
}
for (const b of c.bumpers) bumper(ctx, b);
regolo(ctx, c.regolo);
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.
+7 -5
View File
@@ -15,7 +15,7 @@ export const PIVOT = { left: [280, 314], right: [360, 314] };
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 };
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 300, slope: 260, drag: 30, combo: 12, blend: 30 };
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30 };
// Quanto deve picchiare un urto perché conti (velocità lungo la normale, px/s): il tremolio da gravità non conta.
const IMPACT = 40, LANE_LINE = 104, BANK_RESET = 0.8, CLICK_HOLD = 0.16, NUDGE_AFTER = 2.5;
// I livelli: la stessa fisica, quasi; cambiano il traguardo, il salvataggio della pallina appena lanciata e un po' la
@@ -44,8 +44,9 @@ export function layout() {
for (const x of [277, 319, 361]) walls.push(seg([x, 70], [x, 102], { e: 0.4 }));
// I divisori delle uscite laterali e le guide che portano ai flipper (la guida passa sopra il perno e continua un
// po' lungo il flipper: finendo sul perno la pallina ci restava incastrata).
walls.push(seg([222, 240], [222, 290]), seg([222, 290], [292, 314], { e: 0.3 }));
walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 }));
// Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
walls.push(seg([214, 240], [214, 290]), seg([214, 290], [292, 314], { e: 0.3 }));
walls.push(seg([420, 240], [420, 290]), seg([420, 290], [348, 314], { e: 0.3 }));
// Le sponde elastiche sopra i flipper: il lato lungo spinge.
walls.push(seg([236, 246], [236, 284]), seg([236, 284], [262, 290]), seg([236, 246], [262, 290], { sling: true }));
walls.push(seg([404, 246], [404, 284]), seg([404, 284], [378, 290]), seg([404, 246], [378, 290], { sling: true }));
@@ -63,8 +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 = [
{ id: 'sinistra', mouth: [[236, 236], [258, 236]], path: [[247, 236], [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: [[382, 236], [404, 236]], path: [[393, 236], [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: 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 },
];
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) {
+3
View File
@@ -23,6 +23,9 @@ export const piloti = [
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' },
// 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' },
// 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]
+4
View File
@@ -26,12 +26,16 @@ try {
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
const readyAt = await page.evaluate(() => game.closeup.visual.time);
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
await page.screenshot({ path: `${out}/${name}-tavola.png` });
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
const cdp = mobile ? await page.context().newCDPSession(page) : null;
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
// La molla: tenendo premuto sulla scena, col mouse e col dito.
+26 -9
View File
@@ -9,6 +9,17 @@ import { existsSync } from 'node:fs';
const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); };
const put = (c, ball) => Object.assign(c.ball, ball);
// Osserva il percorso fino all'uscita, senza includere il tiro successivo sulla tavola.
function ride(c, initial) {
put(c, initial);
let frames = 0, top = Infinity;
for (let i = 0; i < 4 / m.STEP; i++) {
m.update(c, m.STEP);
if (c.ball?.ramp) { frames++; top = Math.min(top, c.ball.y); }
else if (frames) return { frames, top, exit: { ...c.ball } };
}
assert.fail('la pallina non ha completato o abbandonato la rampa entro quattro secondi');
}
// Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) {
@@ -161,28 +172,27 @@ test('il traguardo è acquisito appena raggiunto: anche uscendo prima, si vince;
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;
put(c, { x: 247, y: 250, vx: 0, vy: -470 });
let onRamp = 0, top = 999, exit = null; for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); if (c.ball.ramp) { onRamp++; top = Math.min(top, c.ball.y); } else if (onRamp && !exit) exit = { x: c.ball.x, y: c.ball.y }; }
const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola');
assert.ok(exit && exit.x > 400 && exit.x < 420, `scesa nella corsia di ritorno di destra (${exit?.x.toFixed(0)})`);
assert.equal(c.score, m.SCORE.ramp); assert.equal(c.line, 'PONTE SX'); assert.deepEqual(c.lines, ['+15000']); assert.ok(c.ramps[0].lit > 0);
assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati).
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 247, y: 250, vx: 0, vy: -470 }); run(f, 0.3);
const f = m.newFlipper(13, { level: 'bona' }); m.launch(f, 0.9); put(f, { x: 273, y: 240, vx: 0, vy: -470 }); run(f, 0.3);
assert.ok(f.ball.ramp, 'sul ponte'); m.turn(f, -1, 'click'); run(f, 0.1);
assert.ok(f.flippers.left.angle < 0.3, `il flipper si è alzato (${f.flippers.left.angle.toFixed(2)})`);
// Il secondo ponte subito dopo: i PONTI, col bonus.
put(c, { x: 393, y: 250, vx: 0, vy: -470, ramp: null }); run(c, 2.5);
ride(c, { x: 367, y: 240, vx: 0, vy: -470, ramp: null });
assert.equal(c.score, 2 * m.SCORE.ramp + m.SCORE.ponti, `i ponti (${c.score})`);
assert.ok([c.line, ...c.lines].some(l => l === 'I PONTI!') && [c.line, ...c.lines].some(l => /BONUS/.test(l ?? '')));
assert.equal(c.lastRamp, null, 'la combo è spesa');
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti.
const d = m.newFlipper(14, { level: 'bona' }); m.launch(d, 0.9); d.score = 0;
put(d, { x: 247, y: 250, vx: 0, vy: -335 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
put(d, { x: 273, y: 240, vx: 0, vy: -280 }); let climbed = false; for (let i = 0; i < 300; i++) { m.update(d, 1 / 60); if (d.ball.ramp) climbed = true; }
assert.ok(climbed, 'è salita'); assert.ok(!d.ball.ramp && d.ball.y > 226, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte');
// Di traverso (Codex: sfiorando l'imbocco quasi in orizzontale saliva lo stesso): no.
const e = m.newFlipper(15, { level: 'bona' }); m.launch(e, 0.9); e.score = 0;
put(e, { x: 247, y: 236.1, vx: 500, vy: -30 }); run(e, 0.5);
put(e, { x: 273, y: 226.1, vx: 500, vy: -30 }); run(e, 0.5);
assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina.
const g = m.newFlipper(16, { level: 'bona' }); m.launch(g, 0.9); g.lastRamp = { id: 'sinistra', at: g.sim }; put(g, { x: 320, y: 200, vx: 0, vy: 0 });
@@ -199,7 +209,7 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che
assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000');
c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05);
assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`);
c.fx.length = 0; put(c, { x: 247, y: 250, vx: 0, vy: -470 }); run(c, 2.5);
c.fx.length = 0; ride(c, { x: 273, y: 240, vx: 0, vy: -470 });
assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`);
assert.ok(c.trail.length > 5, 'la cometa');
const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball;
@@ -207,7 +217,7 @@ test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che
// Disegnarli non cambia niente.
const calls = [], gradient = { addColorStop() {} };
const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } });
put(c, { x: 247, y: 250, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
put(c, { x: 273, y: 240, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time });
const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before);
assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena');
});
@@ -229,6 +239,13 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu
const c = m.newFlipper(11, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); };
draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg));
assert.ok(calls.filter(([op]) => op === 'arc').every(([, , , r]) => r > 0), 'anche viti e inserti piccoli hanno raggi validi per Canvas');
for (const r of c.ramps) {
const x = (r.mouth[0][0] + r.mouth[1][0]) / 2;
assert.ok(calls.some(([op, line, tx, ty]) => op === 'fillText' && line === 'RAMPA' && tx === x && ty > r.mouth[0][1]), 'la targhetta indica l\'imbocco dal basso');
}
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'OBIETTIVO 60.000'));
assert.ok(calls.some(([op, line]) => op === 'fillText' && line === 'MANCANO 60.000'));
assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste');
const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass && w === 200 && h === 360), 'il fondale sul fianco');
m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();