diff --git a/src/engine/flipper-art.js b/src/engine/flipper-art.js new file mode 100644 index 0000000..7257153 --- /dev/null +++ b/src/engine/flipper-art.js @@ -0,0 +1,185 @@ +// Illustrazioni vettoriali del backglass, riusate come inserti in lacca sul campo. +import { activeBalls } from './flipper.js'; + +export const SCENES = { + regolo: { color: '#91f5b1', title: 'IL REGOLO SI RISVEGLIA', note: 'IL CUSTODE DELLA SIEVE', at: [320, 193] }, + fortezza: { color: '#65ddec', title: 'LA FORTEZZA SI ACCENDE', note: 'SOPRA I BASTIONI', at: [320, 119] }, + trebbio: { color: '#ffc16c', title: 'LA TORRE DEL TREBBIO', note: 'UN PASSAGGIO NELLA STORIA', at: [365, 164] }, + pieve: { color: '#ffaccc', title: 'LE LUCI DELLA PIEVE', note: 'NEL CUORE DEL PAESE', at: [240, 174] }, + rioni: { color: '#ffe6a0', title: 'I COLORI DEI RIONI', note: 'QUATTRO COLORI, UN PAESE', at: [320, 88] }, + sieve: { color: '#6fcfff', title: 'SULLE ONDE DELLA SIEVE', note: 'IL VIAGGIO CONTINUA', at: [320, 280] }, + cosmo: { color: '#dba5ff', title: 'IL REGOLO DEL FUTURO', note: 'OLTRE IL TEMPO', at: [320, 193] }, + spira: { color: '#ff729e', title: 'NELLA SPIRA AL NEON', note: 'ENERGIA IN MOVIMENTO', at: [376, 170] }, +}; +const RAMP_SCENE = { sinistra: 'fortezza', destra: 'trebbio', super: 'cosmo', orbita: 'cosmo', ponte: 'fortezza', spira: 'spira' }; + +// Solo lettura: nessun evento, punteggio o numero casuale della simulazione viene consumato dal disegno. +export function flipperScene(c) { + const balls = activeBalls(c).filter(b => !b.waiting && b.releaseAt <= c.sim); + if (c.regolo.hit > 0 || balls.some(b => !b.ramp && Math.hypot(b.x - c.regolo.x, b.y - c.regolo.y) < 33)) return 'regolo'; + const ramp = Object.keys(RAMP_SCENE).find(id => balls.some(b => b.ramp?.id === id)); + if (ramp) return RAMP_SCENE[ramp]; + if (!balls.length) return c.table === 'future' ? 'cosmo' : 'regolo'; + if (c.bumpers.some(b => b.hit > 0)) return 'fortezza'; + // Priorità stabile anche in multiball, indipendente dall'ordine delle palline. + if (balls.some(b => b.y < 106)) return c.table === 'future' ? 'cosmo' : 'rioni'; + if (balls.some(b => b.x < 242 && b.y < 218)) return 'pieve'; + if (balls.some(b => b.x > 398 && b.x < 432 && b.y < 218)) return 'trebbio'; + if (balls.some(b => b.y < 180)) return 'fortezza'; + if (balls.some(b => b.y < 244)) return c.table === 'future' ? 'cosmo' : 'regolo'; + return 'sieve'; +} +const TAU = Math.PI * 2; +function stroke(g, pts, color, width = 1) { + g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); + g.strokeStyle = color; g.lineWidth = width; g.stroke(); +} +function fill(g, pts, color) { + g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); + g.closePath(); g.fillStyle = color; g.fill(); +} +function circle(g, x, y, r, color) { g.beginPath(); g.arc(x, y, r, 0, TAU); g.fillStyle = color; g.fill(); } + +export function paintRegolo(g, x, y, size, color = '#91f5b1') { + g.save(); g.translate(x, y); g.scale(size / 30, size / 30); g.lineCap = 'round'; g.lineJoin = 'round'; + // Un serpente a spire, con ventre dorato, cresta, occhio e corona: leggibile anche come piccolo emblema. + const body = () => { + g.beginPath(); g.moveTo(-29, 22); + g.bezierCurveTo(-8, 28, 29, 23, 23, 7); + g.bezierCurveTo(17, -9, -24, 0, -18, 13); + g.bezierCurveTo(-13, 24, 13, 12, 9, 0); + g.bezierCurveTo(5, -11, -9, -9, -4, -23); + }; + body(); g.strokeStyle = '#061d26'; g.lineWidth = 12; g.stroke(); + body(); g.strokeStyle = color; g.lineWidth = 8; g.stroke(); + body(); g.strokeStyle = '#153f43'; g.lineWidth = 4.6; g.stroke(); + g.save(); g.translate(0, 2); body(); g.strokeStyle = '#ffe5a6'; g.lineWidth = 1.1; g.stroke(); g.restore(); + for (let i = 0; i < 7; i++) { + const a = i * 0.36; + stroke(g, [[-16 + i * 5, 20 - Math.sin(a) * 3], [-15 + i * 5, 17 - Math.sin(a) * 3]], color, 1); + } + fill(g, [[-9, -26], [-4, -31], [5, -29], [12, -23], [9, -18], [0, -18], [-5, -14]], color); + stroke(g, [[3, -18], [11, -19], [15, -17], [18, -19]], '#ff92a7', 1); + circle(g, 4, -25, 2.1, '#071821'); circle(g, 4.5, -25.6, 0.8, '#ffffff'); + fill(g, [[-8, -33], [-11, -43], [-4, -38], [0, -47], [5, -38], [12, -43], [8, -33]], '#ffdc88'); + stroke(g, [[-8, -32], [8, -32]], '#fff5d5', 1.6); circle(g, 0, -38, 1.4, '#ff729e'); + g.restore(); +} + +export function paintZoneSymbol(g, id) { + const { color, at: [x, y] } = SCENES[id]; + g.save(); g.translate(x, y); g.lineJoin = 'round'; + if (id === 'fortezza' || id === 'trebbio' || id === 'pieve') { + const roof = id === 'pieve' ? [[-12, -2], [0, -13], [12, -2]] + : id === 'trebbio' ? [[-7, 8], [-7, -14], [-3, -14], [-3, -10], [3, -10], [3, -14], [7, -14], [7, 8]] + : [[-14, 7], [-14, -7], [-9, -7], [-9, -3], [-4, -3], [-4, -10], [4, -10], [4, -3], [9, -3], [9, -7], [14, -7], [14, 7]]; + stroke(g, roof, color, 1.3); + stroke(g, [[-10, 0], [-10, 9], [10, 9], [10, 0]], color, 0.8); + stroke(g, [[-3, 8], [-3, 2], [0, -1], [3, 2], [3, 8]], '#fff1c1', 0.8); + } else if (id === 'rioni') { + for (let i = 0; i < 4; i++) { + const px = -15 + i * 10; + fill(g, [[px, -10], [px + 7, -10], [px + 7, 3], [px + 3.5, 7], [px, 3]], ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'][i]); + } + } else if (id === 'sieve') { + for (let i = 0; i < 3; i++) { + g.beginPath(); g.moveTo(-19, -6 + i * 6); g.bezierCurveTo(-8, -14 + i * 6, 8, 2 + i * 6, 19, -6 + i * 6); + g.strokeStyle = color; g.lineWidth = 1.2; g.stroke(); + } + } else if (id === 'spira') { + for (let i = 0; i < 3; i++) { + g.beginPath(); g.ellipse(0, 0, 6 + i * 5, 4 + i * 4, -0.5, 0, TAU); + g.strokeStyle = color; g.lineWidth = 0.9; g.stroke(); + } + } + g.restore(); +} + +function fortress(g, tower, color) { + fill(g, [[18, 69], [18, 43], [28, 39], [28, 32], [35, 32], [35, 41], [51, 40], [51, 29], [61, 29], [61, 39], [87, 37], [87, 26], [97, 26], [97, 36], [119, 34], [119, 29], [129, 29], [129, 65]], '#163648'); + stroke(g, [[18, 43], [35, 41], [51, 40], [61, 39], [87, 37], [97, 36], [129, 34]], color, 1.5); + if (tower) { + fill(g, [[61, 63], [61, 14], [66, 14], [66, 19], [73, 19], [73, 12], [79, 12], [79, 19], [86, 19], [86, 14], [91, 14], [91, 63]], '#365062'); + stroke(g, [[61, 63], [61, 14], [66, 14]], color, 1.3); + for (const y of [27, 40, 53]) { g.fillStyle = '#ffd590'; g.fillRect(73, y, 5, 7); } + } + for (let y = 46; y < 67; y += 7) stroke(g, [[22, y], [126, y - 6]], '#426076', 0.5); + for (const x of [33, 49, 101, 117]) { g.fillStyle = color; g.fillRect(x, 47, 2, 6); } + fill(g, [[67, 70], [67, 55], [73, 50], [80, 55], [80, 70]], '#071927'); + stroke(g, [[14, 72], [74, 65], [135, 70]], color, 1); +} + +// Una scena di 150 × 88, interamente in cache nel renderer. +export function paintFlipperScene(g, id) { + const color = SCENES[id].color; + const sky = g.createLinearGradient(0, 0, 150, 88); + sky.addColorStop(0, '#071125'); sky.addColorStop(0.6, id === 'trebbio' ? '#38273b' : '#15364a'); sky.addColorStop(1, '#071925'); + g.fillStyle = sky; g.fillRect(0, 0, 150, 88); + circle(g, 120, 19, 13, color + '18'); circle(g, 120, 19, 8, '#fff0be'); circle(g, 117, 16, 7, '#153044'); + for (let i = 0; i < 20; i++) circle(g, (i * 47 + 13) % 150, (i * 23 + 9) % 57, i % 3 ? 0.5 : 0.9, color + 'aa'); + fill(g, [[0, 59], [21, 37], [44, 53], [77, 27], [108, 51], [139, 33], [150, 40], [150, 88], [0, 88]], '#142b3c'); + fill(g, [[0, 74], [39, 53], [67, 64], [99, 44], [150, 62], [150, 88], [0, 88]], '#1f4350'); + for (let i = 0; i < 5; i++) { + g.beginPath(); g.moveTo(0, 74 + i * 3); g.bezierCurveTo(52, 58 + i * 3, 78, 91 + i * 2, 150, 67 + i * 4); + g.strokeStyle = i % 2 ? '#69b6ce66' : '#102837'; g.lineWidth = 1; g.stroke(); + } + if (id === 'fortezza' || id === 'trebbio') fortress(g, id === 'trebbio', color); + else if (id === 'pieve') { + fill(g, [[30, 69], [30, 43], [59, 25], [87, 43], [87, 69]], '#406077'); + fill(g, [[87, 68], [87, 23], [94, 13], [101, 23], [101, 68]], '#29495e'); + stroke(g, [[26, 44], [59, 22], [90, 44]], color, 2); + circle(g, 59, 43, 7, '#142a3e'); circle(g, 59, 43, 4, '#ffc8de'); + fill(g, [[52, 69], [52, 58], [59, 53], [66, 58], [66, 69]], '#102334'); + g.fillStyle = '#ffdfa2'; g.fillRect(91, 28, 6, 9); + } else if (id === 'rioni') { + ['#f97980', '#f7eac9', '#78e2ad', '#7bb6ff'].forEach((c, i) => { + const x = 24 + i * 29, y = 23 + (i % 2) * 5; + stroke(g, [[x, y - 4], [x, 69]], '#d5c7a1', 1.2); + fill(g, [[x + 2, y], [x + 23, y + 3], [x + 23, y + 30], [x + 12, y + 38], [x + 2, y + 30]], c); + stroke(g, [[x + 12, y + 7], [x + 12, y + 27]], '#213347', 2); + stroke(g, [[x + 6, y + 17], [x + 18, y + 17]], '#213347', 2); + }); + } else if (id === 'sieve') { + g.beginPath(); g.moveTo(10, 65); g.quadraticCurveTo(74, 9, 140, 65); + g.strokeStyle = '#0a1b2c'; g.lineWidth = 10; g.stroke(); g.strokeStyle = color; g.lineWidth = 2; g.stroke(); + for (const x of [28, 49, 74, 99, 122]) stroke(g, [[x, 49 - Math.sin(x / 150 * Math.PI) * 10], [x, 73]], '#5f8b9f', 3); + paintRegolo(g, 77, 70, 12, '#91f5b1'); + } else { + for (let i = 0; i < 3; i++) { + g.beginPath(); g.ellipse(75, 44, 28 + i * 12, 23 + i * 8, -0.4 + i * 0.3, 0, TAU); + g.strokeStyle = color + (i ? '55' : 'aa'); g.lineWidth = 0.8; g.stroke(); + } + paintRegolo(g, 75, 51, id === 'spira' ? 26 : 30, color); + } + // Riflessi e retino del vetro inciso. + for (let y = 2; y < 88; y += 3) stroke(g, [[0, y], [150, y]], '#050f1c22', 0.5); +} + +export function paintPlayfieldArt(g, future = false) { + const color = future ? '#ad8cff' : '#74c5bd'; + g.save(); + // Rosone centrale, smalti e serpente coronato sotto il piano di gioco. + for (const r of [30, 33, 43]) { + g.beginPath(); g.ellipse(320, 194, r, r * 0.83, 0, 0, TAU); + g.strokeStyle = color + '55'; g.lineWidth = r === 33 ? 1.4 : 0.6; g.stroke(); + } + for (let i = 0; i < 16; i++) { + const a = i / 16 * TAU; + stroke(g, [[320 + Math.cos(a) * 37, 194 + Math.sin(a) * 31], [320 + Math.cos(a) * 41, 194 + Math.sin(a) * 34]], '#d6be7855', 0.8); + } + g.globalAlpha = 0.46; paintRegolo(g, 320, 202, 25, color); g.globalAlpha = 1; + for (const [x, side, tint] of [[239, -1, '#65ddec'], [402, 1, '#ffc16c']]) { + stroke(g, [[x, 217], [x - side * 10, 230], [x - side * 10, 242]], tint + '66', 1); + for (let i = 0; i < 3; i++) fill(g, [[x, 209 + i * 8], [x + 2, 212 + i * 8], [x, 215 + i * 8], [x - 2, 212 + i * 8]], tint + '88'); + } + // Filetti dorati delle sponde e frecce smaltate verso le palette. + for (const side of [-1, 1]) { + const x = 320 + side * 67; + stroke(g, [[x, 263], [x - side * 14, 279], [x - side * 22, 300]], '#e7c88955', 0.8); + for (let i = 0; i < 3; i++) { + const cx = x - side * i * 7, y = 274 + i * 10; + stroke(g, [[cx - 3, y - 2], [cx, y + 1], [cx + 3, y - 2]], color + '77', 1); + } + } + g.restore(); +} diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 46b4bda..bf0b882 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -2,6 +2,7 @@ // Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia. import { R, SCORE, RAMP, FUTURE, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js'; import { ambientHash as hash } from './ambient.js'; +import { SCENES, flipperScene, paintRegolo, paintZoneSymbol, paintFlipperScene, paintPlayfieldArt } from './flipper-art.js'; const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa'; const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff', orbita: '#58f4e2', ponte: '#c1a0ff', spira: '#ff729e' }; @@ -56,6 +57,74 @@ function cached(g, key, paint, bounds = [0, 0, 640, 360]) { if (typeof document === 'undefined') { paint(g); return; } g.drawImage(layerOf(key, paint, bounds), ...bounds); } +// Il display cambia con il percorso. Una breve permanenza e la dissolvenza evitano il tremolio sui confini. +const apparitions = new WeakMap(); +function apparition(c, time) { + const next = flipperScene(c); + let d = apparitions.get(c); + if (!d || time < d.at || d.table !== c.table) { + d = { id: next, previous: next, at: time, pending: next, since: time, table: c.table }; apparitions.set(c, d); + } + if (next !== d.pending) { d.pending = next; d.since = time; } + if (next !== d.id && ((c.regolo.hit > 0) || (time - d.at > 0.65 && time - d.since > 0.12))) { + d.previous = d.id; d.id = next; d.at = time; + } + return d; +} +function livingBackglass(g, c, time) { + const d = apparition(c, time), scene = SCENES[d.id], fade = clamp((time - d.at) / 0.32, 0, 1); + box(g, 22, 236, 154, 91, '#061422', scene.color, 5); + g.save(); g.beginPath(); g.roundRect(24, 238, 150, 87, 4); g.clip(); + const paint = id => cached(g, `vision:${id}`, l => { l.save(); l.translate(24, 238); paintFlipperScene(l, id); l.restore(); }, [24, 238, 150, 88]); + paint(d.previous); + g.save(); g.globalAlpha = fade; paint(d.id); g.restore(); + // Poche luci nel cielo, animate separatamente dall'illustrazione in cache. + for (let i = 0; i < 5; i++) { + const x = 36 + i * 29, y = 254 + Math.sin(time * 0.8 + i * 2) * 6; + glow(g, x, y, 5, scene.color, 0.35 + Math.sin(time * 2 + i) * 0.2); + } + g.fillStyle = '#030b18c9'; g.fillRect(24, 238, 150, 12); g.fillRect(24, 309, 150, 16); + text(g, scene.title, 99, 244, 5.7, scene.color, 'center'); + text(g, scene.note, 99, 317, 5, '#e5e9d8', 'center'); + g.restore(); + for (const x of [19, 179]) { + glow(g, x, 280, 15, scene.color, 0.4); + line(g, [[x, 256], [x, 304]], scene.color, 1.2); + } +} +function livingPlayfield(g, c, time) { + const d = apparition(c, time), scene = SCENES[d.id], balls = activeBalls(c).filter(b => !b.waiting && b.releaseAt <= c.sim); + const pulse = 0.5 + Math.sin(time * 2.5) * 0.12; + // Il rosone si risveglia sotto gli oggetti: palline, palette e bersagli si disegnano dopo. + glow(g, ...scene.at, 49, scene.color, balls.length ? 0.48 : 0.2); + g.save(); g.globalAlpha = (d.id === 'regolo' || d.id === 'cosmo' ? 0.65 : 0.16) * pulse; + cached(g, `regolo-insert:${scene.color}`, l => paintRegolo(l, 320, 202, 25, scene.color), [290, 160, 60, 70]); + g.restore(); + if (balls.length && d.id !== 'regolo' && d.id !== 'cosmo') { + g.save(); g.globalAlpha = clamp((time - d.at) / 0.3, 0, 0.7); + cached(g, `zone-insert:${d.id}`, l => paintZoneSymbol(l, d.id), [scene.at[0] - 22, scene.at[1] - 18, 44, 36]); + g.restore(); + } + for (const side of [-1, 1]) for (let i = 0; i < 18; i++) { + const x = side < 0 ? 196 : 452, y = 43 + i * 16; + const near = balls.some(b => Math.abs(b.y - y) < 25 && (side < 0 ? b.x < 325 : b.x > 315)); + const chase = (i + Math.floor(time * (balls.length ? 9 : 3))) % 7 === 0; + const color = near ? scene.color : i % 3 === 0 ? '#c9a5ff' : side < 0 ? TINT.sinistra : TINT.destra; + box(g, x - 1, y - 2, 2, 4, near || chase ? color : '#2b4351', null, 0.6); + if (near || chase) glow(g, x, y, near ? 10 : 6, color, near ? 0.6 : 0.3); + } + for (const b of balls) { + if (b.ramp) continue; + glow(g, b.x, b.y, 21, scene.color, 0.25); + // Piccoli inserti a rombo, accesi dalla vicinanza della pallina. + for (const [x, y] of [[272, 175], [367, 175], [292, 274], [348, 274], [320, 297]]) { + const near = Math.hypot(b.x - x, b.y - y) < 48; + if (!near) continue; + glow(g, x, y, 12, scene.color, 0.6); + poly(g, [[x, y - 3], [x + 2, y], [x, y + 3], [x - 2, y]], scene.color); + } + } +} // Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma: // una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La // tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta. @@ -111,6 +180,11 @@ function cabinet(g, c, bg, glass) { // I bastioni stilizzati al fondo del piano. poly(g, [[278, 83], [278, 60], [286, 60], [286, 50], [296, 50], [296, 59], [307, 59], [307, 45], [318, 45], [318, 59], [329, 59], [329, 50], [340, 50], [340, 60], [352, 60], [352, 83]], '#1e3e43', '#365358'); text(g, 'LA FORTEZZA', 315, 77, 5.5, '#5c7d7d', 'center'); + paintPlayfieldArt(g); + for (const [i, x] of [257, 297, 340, 381].entries()) { + poly(g, [[x - 5, 83], [x + 5, 83], [x + 5, 92], [x, 96], [x - 5, 92]], RIONI[i] + '88', '#d8d7ab66'); + line(g, [[x, 85], [x, 91]], '#0b263b', 1.2); + } // La corsia della molla è una scanalatura separata, con il fondo metallico. g.fillStyle = gradient(g, 432, 0, 16, 0, [[0, '#01070b'], [0.3, '#213442'], [0.65, '#0c1922'], [1, '#304754']]); g.fillRect(432, 72, 16, 264); line(g, [[440, 86], [440, 306]], '#45606c', 0.5); @@ -253,7 +327,7 @@ function rampLights(g, c, r, time, balls) { text(g, locked ? 'LOCKED' : r.id === 'super' ? 'SUPER SPECIAL' : invited ? 'JACKPOT' : combo ? 'COMBO +50K' : r.id === 'sinistra' ? 'FORTEZZA' : 'TREBBIO', x, y + 29, r.id === 'super' ? 5.1 : 5.8, invited ? WHITE : color, 'center'); } function bumper(g, b, time) { - const hot = b.hit > 0, color = hot ? '#ffce84' : '#76e4ec'; + const hot = b.hit > 0, color = hot ? '#ffce84' : b.x < 310 ? '#76e4ec' : b.x > 330 ? '#ffbf81' : '#91f5b1'; glow(g, b.x, b.y, hot ? 29 : 18, color, hot ? 1 : 0.24); g.beginPath(); g.ellipse(b.x + 1, b.y + 5, b.r + 2, b.r, 0, 0, TAU); g.fillStyle = '#01080d'; g.fill(); disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b'); @@ -262,6 +336,10 @@ function bumper(g, b, time) { v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e'); disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7'); crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9'); + for (let i = 0; i < 8; i++) { + const a = i * TAU / 8; + disc(g, b.x + Math.cos(a) * (b.r + 2.5), b.y + Math.sin(a) * (b.r + 2.5), 0.65, hot || (i + Math.floor(time * 5)) % 8 < 2 ? color : '#315260'); + } for (const dx of [-8, 8]) screw(g, b.x + dx, b.y); if (hot) { g.strokeStyle = color; g.lineWidth = 1; g.beginPath(); g.arc(b.x, b.y, b.r + 4 + Math.sin(time * 24) * 2, 0, TAU); g.stroke(); } } @@ -319,6 +397,7 @@ function plunger(g, c) { const shown = new WeakMap(); export function shownScore(d, time) { const k = Math.min(1, (time - d.t0) / 0.5); return k >= 1 ? d.to : d.from + Math.floor((d.to - d.from) * k / 10) * 10; } function board(g, c, time) { + glow(g, 99, 43, 25, SCENES[apparition(c, time).id].color, 0.4); crown(g, 99, 43, 10, '#e7c889'); text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500); text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center'); @@ -340,6 +419,7 @@ function board(g, c, time) { text(g, 'PALLINE', 28, 225, 6, MUTED); for (let i = 0; i < Math.min(8, c.balls); i++) disc(g, 88 + i * 9, 225, 2.4, i === 0 ? '#d4f6fc' : '#4f7786', '#8aacb5'); if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right'); + livingBackglass(g, c, time); if (c.step === 'result') { box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5); text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center'); @@ -438,6 +518,7 @@ function futureCabinet(g, c, bg, glass) { text(g, 'FORTEZZA / NUCLEO', 320, 91, 5, '#d1c7ff', 'center'); const serpent = [[304, 277], [288, 265], [295, 252], [326, 252], [343, 270], [332, 284], [313, 284], [313, 271], [323, 267]]; line(g, serpent, '#143d50', 8); line(g, serpent, '#398c9c', 1); crown(g, 323, 264, 5, '#58f4e2'); + paintPlayfieldArt(g, true); for (const w of c.walls) if (!w.gate) { line(g, [w.a, w.b], '#2e526a', 3); line(g, [w.a, w.b], '#69b7c7', 0.7); } g.restore(); // Il mobile stesso cambia: pannelli a circuito, la finestra sul River resta il legame col 2007. @@ -470,8 +551,10 @@ function futureBoard(g, c) { text(g, '+500.000', 548, 294, 22, WHITE, 'center', 'monospace'); text(g, 'FINALE O ULTIMA PALLINA PERSA:', 548, 318, 5.5, MUTED, 'center'); text(g, 'SI TORNA AL RIVER', 548, 332, 7, TINT.orbita, 'center'); + livingBackglass(g, c, c.visual.time); } function futureField(g, c, time) { + livingPlayfield(g, c, time); for (const b of c.bumpers) bumper(g, b, time); target(g, c); const ready = Object.values(c.future.hits).every(n => n === 2); @@ -560,6 +643,7 @@ export function drawFlipper(g, c, def, bg, glass) { if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); } else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint); + livingPlayfield(g, c, time); for (const [i, l] of c.lanes.entries()) { if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55); disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b'); @@ -597,8 +681,9 @@ export function drawFlipper(g, c, def, bg, glass) { } // A pallina ferma, dove toccare per la molla (Andrea, 1 novembre: «sul telefono non è intuitivo premere al centro»): la // fascia centrale di flipper.js (press), tratteggiata, e la scritta che pulsa; sparisce appena si comincia a caricare. +// Solo col dito (`c.touch`, da main.js): sul computer no, Andrea non la vuole. function plungerHint(g, c, time) { - if (!waiting(c) || c.holding) return; + if (!c.touch || !waiting(c) || c.holding) return; const x0 = 320 - PLUNGER_ZONE + 4, x1 = 320 + PLUNGER_ZONE - 4, y0 = 196, y1 = 300, pulse = 0.8 + Math.sin(time * 4) * 0.2; g.save(); g.globalAlpha = pulse; g.setLineDash([4, 3]); g.strokeStyle = '#ffe08a'; g.lineWidth = 1.2; g.strokeRect(x0, y0, x1 - x0, y1 - y0); g.setLineDash([]); diff --git a/src/main.js b/src/main.js index 3bcb010..ed1953c 100644 --- a/src/main.js +++ b/src/main.js @@ -59,6 +59,7 @@ function showSound() { $('effects-volume').value = Math.round(ambience.volume * 100); } for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true }); +addEventListener('pointerdown', e => { if (game?.closeup && 'touch' in game.closeup) game.closeup.touch = e.pointerType !== 'mouse'; }, { capture: true }); // Il muto zittisce subito anche la voce che sta parlando. const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); }; $('mute').addEventListener('click', toggleMute); @@ -1209,6 +1210,8 @@ function updateUi() { $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('briscola', !!cards); const pinball = story.closeups[c?.id]?.type === 'flipper'; + // Si gioca col dito? Dallo schermo all'apertura, poi da ogni tocco (pointerdown): la scritta della molla è per il dito. + if (pinball && c.touch === undefined) c.touch = matchMedia('(hover: none)').matches; $('app').classList.toggle('flipper', pinball); $('app').classList.toggle('table-only', pinball && tableOnly); setProp($('table-only'), 'hidden', !pinball); diff --git a/tests/flipper-art.test.mjs b/tests/flipper-art.test.mjs new file mode 100644 index 0000000..b235292 --- /dev/null +++ b/tests/flipper-art.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newFlipper, futureLayout } from '../src/engine/flipper.js'; +import { flipperScene } from '../src/engine/flipper-art.js'; + +test('il backglass segue i luoghi attraversati, senza modificare la partita', () => { + const c = newFlipper(91); + assert.equal(flipperScene(c), 'regolo'); + for (const [x, y, scene] of [[403, 70, 'regolo'], [297, 90, 'rioni'], [220, 168, 'pieve'], [416, 180, 'trebbio'], [290, 135, 'fortezza'], [320, 202, 'regolo'], [320, 290, 'sieve']]) { + Object.assign(c.ball, { waiting: false, x, y }); + const before = structuredClone(c); + assert.equal(flipperScene(c), scene, `zona ${x}, ${y}`); + assert.deepEqual(c, before); + } + for (const [id, scene] of [['sinistra', 'fortezza'], ['destra', 'trebbio'], ['super', 'cosmo'], ['orbita', 'cosmo'], ['ponte', 'fortezza'], ['spira', 'spira']]) { + c.ball.ramp = { id }; + assert.equal(flipperScene(c), scene); + } + c.regolo.hit = 0.2; + assert.equal(flipperScene(c), 'regolo', 'il colpo al Regolo ha precedenza sulle rampe'); +}); + +test('palline in attesa o trattenute non attivano immagini, il multiball ha una priorità stabile', () => { + const c = newFlipper(92); + c.extraBalls = [{ ...structuredClone(c.ball), x: 210, y: 170, waiting: false, releaseAt: c.sim + 2 }]; + assert.equal(flipperScene(c), 'regolo'); + Object.assign(c.ball, { waiting: false, ramp: { id: 'destra' } }); + Object.assign(c.extraBalls[0], { releaseAt: 0, ramp: { id: 'sinistra' } }); + const first = flipperScene(c); + [c.ball, c.extraBalls[0]] = [c.extraBalls[0], c.ball]; + assert.equal(flipperScene(c), first); + c.ball = null; c.extraBalls = []; c.step = 'result'; + assert.equal(flipperScene(c), 'regolo'); + Object.assign(c, futureLayout(), { table: 'future' }); + assert.equal(flipperScene(c), 'cosmo'); +}); diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index f78e077..c462d5e 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -51,8 +51,38 @@ try { assert.ok(w > viewport.width * 0.6, `sul telefono la scena del flipper è larga ${w} px`); } await page.screenshot({ path: `${out}/${name}-tavola.png` }); + // «TIENI PREMUTO QUI» per la molla solo col dito (Andrea): sul computer la tavola non la dice. + assert.equal(await page.evaluate(() => !!game.closeup.touch), mobile, 'la scritta della molla solo sul telefono'); 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 } }); + // Le apparizioni seguono zone vere del campo e cambiano effettivamente i pixel del backglass. + if (!mobile) { + await page.evaluate(() => { + window.artState = structuredClone(game.closeup); window.artUpdate = game.update; + game.update = () => {}; + }); + const pictures = new Set(); + for (const [x, y, scene] of [[403, 70, 'regolo'], [290, 135, 'fortezza'], [416, 180, 'trebbio'], [220, 168, 'pieve'], [297, 90, 'rioni'], [320, 290, 'sieve']]) { + const result = await page.evaluate(async ({ x, y }) => { + const { render } = await import('/src/engine/render.js'); + const { flipperScene } = await import('/src/engine/flipper-art.js'); + const c = game.closeup, ctx = document.querySelector('#scene').getContext('2d'); + Object.assign(c.ball, { x, y, waiting: false, releaseAt: 0, ramp: null }); + for (let i = 0; i < 4; i++) { c.visual.time += 0.4; render(ctx, game, { time: performance.now() }); } + const pixels = ctx.getImageData(48, 476, 300, 176).data; + let signature = 0; + for (let i = 0; i < pixels.length; i++) signature = (Math.imul(signature, 31) + pixels[i]) | 0; + return { scene: flipperScene(c), signature }; + }, { x, y }); + assert.equal(result.scene, scene); pictures.add(result.signature); + await page.screenshot({ path: `${out}/desktop-visione-${scene}.png` }); + } + assert.equal(pictures.size, 6, 'sei illustrazioni diverse nel display'); + await page.evaluate(() => { + Object.assign(game.closeup, window.artState); game.update = window.artUpdate; + delete window.artState; delete window.artUpdate; + }); + } 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.