Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
650d4e9cf8 | ||
|
|
7384726091 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.73",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.72';
|
||||
export const VERSION = '0.1.73';
|
||||
|
||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user