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, (`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 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. 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). [DA ANDREA] un record per livello che resti salvato (suggerito da Codex).
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio
Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
@@ -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 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 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 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 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). 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 - 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 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 sono i bastioni della Fortezza (1.000 a botta); i bersagli bianchi a sinistra sono la Pieve e quelli gialli a
destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a destra la torre dell'orologio (5.000 l'uno, 25.000 la fila intera); il bersaglio verde con la corona, in alto a
destra, è il Regolo (10.000). I due ponti: 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 scende dall'altra parte, nella corsia di ritorno (15.000); l'altro ponte entro dodici secondi: i PONTI, con un
bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.</p> bonus di 50.000 in più. Il tasto destro del mouse è sempre il flipper di destra.</p>
<p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto <p>I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto
+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 // ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che // come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js). // girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
import { FIELD, LANE, R, 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 { ambientHash as hash } from './ambient.js';
import { text, panel } from './bocce-render.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) { 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 = 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) { function bumper(ctx, b) {
const r = b.r * (b.hit > 0 ? 1 + b.hit * 1.2 : 1); const r = b.r, lit = b.hit > 0;
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 + 1, b.y + 3, r + 1, '#172127', '#131a1e');
disc(ctx, b.x, b.y, r, b.hit > 0 ? '#ffe9a8' : '#d9463a', '#5a1d16'); const metal = ctx.createLinearGradient(b.x - r, b.y - r, b.x + r, b.y + r);
disc(ctx, b.x, b.y, r * 0.55, b.hit > 0 ? '#fff6d8' : '#f0a24a', '#8a3a1e'); metal.addColorStop(0, '#fff3cc'); metal.addColorStop(0.35, '#bda16d');
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); 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. // 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) { function regolo(ctx, o) {
const r = o.r + (o.hit > 0 ? 2 : 0); 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(); 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'); 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(); const gem = ctx.createRadialGradient(o.x - 2, o.y - 2, 0, o.x, o.y, r - 1);
ctx.fillStyle = '#ffd45a'; gem.addColorStop(0, '#bceca0'); gem.addColorStop(0.35, '#3f9c77'); gem.addColorStop(1, '#153f42');
for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h); disc(ctx, o.x, o.y, r - 1.5, gem, '#65522c');
ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2); 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 // I ponti: canaline metalliche, ambra a sinistra e azzurre a destra, sollevate secondo la quota della pallina.
// in cima (la quota), con l'ombra, il bordo scuro e la luce; accesi, brillano; l'altro, in attesa della combo, invita. // 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 // 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). // 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 rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; }
function paintRamp(ctx, r, state) { 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], active = state !== 'base';
const [light, dark] = RAMP_LOOK[r.id]; // 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.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.save(); ctx.translate(2, 4); stroke(17, 'rgba(3,12,19,0.48)'); ctx.restore();
ctx.globalAlpha = 1; stroke(16, '#12212a'); stroke(14, active ? light : dark);
ctx.strokeStyle = state === 'lit' ? '#ffd45a' : state === 'invite' ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke(); stroke(12, '#e0e4d6'); stroke(10, '#32454e'); stroke(7, '#192e39');
ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke(); stroke(2, r.id === 'sinistra' ? '#4e4940' : '#284957');
ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke(); // Traverse distanziate e piccoli inserti luminosi sui parapetti.
// Le pietre: trattini scuri a passo regolare lungo il ponte. for (let s = 18; s < r.len - 8; s += 26) {
ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1; const p = rampPoint(r, s);
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.strokeStyle = '#62727a'; ctx.lineWidth = 0.7;
// L'imbocco: la rampetta che sale, con la freccia. 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();
const [[x0, y], [x1]] = r.mouth; for (const side of [-1, 1]) {
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6); ctx.fillStyle = active ? '#fff6d8' : light;
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.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(); const rampCache = new Map();
function ramp(ctx, r, time, who) { function ramp(ctx, r, time, who) {
@@ -73,19 +125,34 @@ function ramp(ctx, r, time, who) {
} }
let railLayer = null; let railLayer = null;
function rails(ctx, c) { 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 (typeof document === 'undefined') { paint(ctx); return; }
if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); } if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); }
ctx.drawImage(railLayer, 0, 0); ctx.drawImage(railLayer, 0, 0);
} }
function flipper(ctx, c, side) { function flipper(ctx, c, side) {
const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side);
ctx.lineCap = 'round'; ctx.save(); ctx.translate(px, py); ctx.rotate(Math.atan2(ty - py, tx - px));
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(); const len = Math.hypot(tx - px, ty - py);
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke(); const shape = () => {
ctx.strokeStyle = f.held.length ? '#ffd45a' : '#e8b640'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(tx, ty); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -3); ctx.lineTo(len, -2.5);
ctx.strokeStyle = '#fff1b8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px, py - 1.5); ctx.lineTo(tx, ty - 1.5); ctx.stroke(); ctx.quadraticCurveTo(len + 3, 0, len, 2.5); ctx.lineTo(0, 3);
disc(ctx, px, py, 4, '#8a7a6a', '#2a2622'); 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) { 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; 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 (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh); if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh);
ctx.restore(); ctx.restore();
// Il traguardo resta leggibile anche mentre il display celebra un colpo.
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). // I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia).
c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a')); c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a'));
// I cinque dischi in basso: le palline che restano, e i quattro rioni accesi. // I cinque dischi in basso: le palline che restano, e i quattro rioni accesi.
@@ -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 (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); }
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); 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); } 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. // Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere.
for (const l of c.lanes) { 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] : '#273a3e', l.lit ? '#fff4c8' : '#b5a278');
disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050'); 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 // 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. // 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 = '#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 = 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 = '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); for (const b of c.bumpers) bumper(ctx, b);
regolo(ctx, c.regolo); 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); for (const side of ['left', 'right']) flipper(ctx, c, side);
plunger(ctx, c); plunger(ctx, c);
// La pallina (con la cometa) sotto i ponti, se è sulla tavola; sopra, se è su un ponte; poi gli effetti. // 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 }; 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 // Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI. // sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
export const RAMP = { speed: 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. // 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; 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 // 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 })); 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 // 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). // 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 })); // Le uscite lasciano solo 14/12 pixel; lo spazio recuperato allarga le corsie di ritorno.
walls.push(seg([418, 240], [418, 290]), seg([418, 290], [348, 314], { e: 0.3 })); 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. // 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([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 })); 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, // fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
// in cima, come due ponti. // in cima, come due ponti.
const ramps = [ const ramps = [
{ 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 }, // Imbocchi verso il centro, davanti alle sponde elastiche: 33 pixel utili alla pallina anziché 13.
{ 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 }, { 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. // Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
for (const r of ramps) { for (const r of ramps) {
+3
View File
@@ -23,6 +23,9 @@ export const piloti = [
casco: 'assets/closeups/gp/casco-tozzino.png', helmet: '#131c3c', stripe: '#f4f4f4' }, 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). // Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' }, { id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
// 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). // Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' }, { id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa] // La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
+4
View File
@@ -26,12 +26,16 @@ try {
}); });
await page.waitForSelector('#bocce-controls:not([hidden])'); await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png')); 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.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible()); assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper'); 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'); 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` }); 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]; 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 cdp = mobile ? await page.context().newCDPSession(page) : null;
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) }); 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. // 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 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); 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. // Il giocatore automatico: alza il flipper quando la pallina gli scende addosso, come un umano attento.
function autoplay(c, secs = 600) { function autoplay(c, secs = 600) {
for (let i = 0; i < secs * 60 && c.step === 'play'; i++) { 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', () => { test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nella corsia di ritorno; piano si ricasca; di traverso no; tutti e due di fila, i PONTI', () => {
const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0; const c = m.newFlipper(13, { level: 'bona' }); m.launch(c, 0.9); c.score = 0;
put(c, { x: 247, y: 250, vx: 0, vy: -470 }); const { frames: onRamp, top, exit } = ride(c, { x: 273, y: 240, 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 }; }
assert.ok(onRamp > 20, 'è salita sul ponte'); assert.ok(top < 40, 'fin sopra la tavola'); 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.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.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')); assert.ok(c.events.some(e => e.kind === 'flipper-rampa'));
// Mentre è sul ponte i flipper rispondono lo stesso (Codex: restavano congelati). // 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.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)})`); 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. // 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.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.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'); assert.equal(c.lastRamp, null, 'la combo è spesa');
// Troppo piano: sale un po', non ce la fa, ricasca dall'imbocco senza punti. // 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; 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; } 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 > 236, 'ricaduta'); assert.ok(d.score < m.SCORE.ramp, 'niente punti del ponte'); 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. // 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; 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'); assert.ok(!e.ball.ramp && e.score < m.SCORE.ramp, 'non è salita');
// La combo scade dopo dodici secondi, e si azzera perdendo la pallina. // 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 }); 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'); 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); 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()}`); 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(kinds().includes('chase'), `ponte: ${kinds()}`);
assert.ok(c.trail.length > 5, 'la cometa'); 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; 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. // Disegnarli non cambia niente.
const calls = [], gradient = { addColorStop() {} }; 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; } }); 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); 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'); 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 c = m.newFlipper(11, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); }; 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)); 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'); 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'); 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(); m.launch(c, 0.8); run(c, 1); calls.length = 0; draw();