diff --git a/assets/closeups/gp/boccino.png b/assets/closeups/gp/boccino.png new file mode 100644 index 0000000..00b12da Binary files /dev/null and b/assets/closeups/gp/boccino.png differ diff --git a/assets/closeups/gp/nrg.png b/assets/closeups/gp/nrg.png new file mode 100644 index 0000000..f6d2e9b Binary files /dev/null and b/assets/closeups/gp/nrg.png differ diff --git a/docs/STATO.md b/docs/STATO.md index d8527b5..aaf495f 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -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 diff --git a/index.html b/index.html index aad4682..d7b2aee 100644 --- a/index.html +++ b/index.html @@ -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.
I flipper: tieni premuto sulla metà sinistra o destra della scena (anche con due dita insieme, o col tasto diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 4e47293..3366aa4 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -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. diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 4c606a1..7a7507f 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -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) { diff --git a/src/story/piloti.js b/src/story/piloti.js index e6f9dc7..7625cb0 100644 --- a/src/story/piloti.js +++ b/src/story/piloti.js @@ -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] diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index 2249a11..55d5c08 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -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. diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 2ce65b0..26db920 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -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();