Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71dfcd7e6 | ||
|
|
4b5408597f | ||
|
|
650d4e9cf8 | ||
|
|
7384726091 | ||
|
|
c68b365df2 | ||
|
|
531aee18bf |
@@ -18,6 +18,15 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
|
|||||||
|
|
||||||
## Dove siamo arrivati
|
## Dove siamo arrivati
|
||||||
|
|
||||||
|
- **Flipper, il risveglio del Regolo (2 novembre)**: skill shot da 25.000 sulla prima corsia lampeggiante del lancio,
|
||||||
|
spinner RIVER da 500 a giro, kickback monouso sul lato del banco completato. Bonus per vita: 1.000 a corsia,
|
||||||
|
1.500 a bersaglio, 3.000 a rampa, moltiplicatore PALIO fino a ×5; lock e multiball lo conservano. N, freccia su
|
||||||
|
o SCUOTI sul bordo del canvas danno una spintarella: due avvertimenti per vita, terza scossa TILT (palette spente,
|
||||||
|
punti e salvataggi sospesi, bonus perso). Nel futuro i sigilli illuminano il Regolo: completati tutti, 30 secondi
|
||||||
|
per il colpo finale da 500.000; alla scadenza resta un giro per ciascun sigillo da rifare. Fuochi sui fianchi,
|
||||||
|
fanfare, spinner meccanico, knocker, ruggito e conto alla rovescia; annunci sul display laterale per lasciare
|
||||||
|
libere le palette. Regole nella guida del flipper, prove in `tests/flipper.test.mjs` e `tests/flipper.browser.mjs`.
|
||||||
|
|
||||||
- **La folla dell'Ingorgo che salta (31 ottobre)**: nelle cinque scene della festa la gente dipinta nello sfondo va su
|
- **La folla dell'Ingorgo che salta (31 ottobre)**: nelle cinque scene della festa la gente dipinta nello sfondo va su
|
||||||
e giù a tempo (effetto `crowd` in render.js: la fascia della folla tagliata in strisce verticali, ognuna sobbalza sul
|
e giù a tempo (effetto `crowd` in render.js: la fascia della folla tagliata in strisce verticali, ognuna sobbalza sul
|
||||||
battito con una fase sua; due fasce per scena, dietro piano e davanti forte, al `bpm` delle luci della scena).
|
battito con una fase sua; due fasce per scena, dietro piano e davanti forte, al `bpm` delle luci della scena).
|
||||||
@@ -349,6 +358,14 @@ versione nuova.
|
|||||||
`REGOLO_GPU=0/1` forza. Con 6 o 8 prove insieme il giro dura uguale: il limite è la partita intera (243 s, il tempo
|
`REGOLO_GPU=0/1` forza. Con 6 o 8 prove insieme il giro dura uguale: il limite è la partita intera (243 s, il tempo
|
||||||
del gioco, non della grafica). La pipeline dà la GPU ai job (`--gpus all`, runtime NVIDIA di Docker); runner a 6 job.
|
del gioco, non della grafica). La pipeline dà la GPU ai job (`--gpus all`, runtime NVIDIA di Docker); runner a 6 job.
|
||||||
|
|
||||||
|
- **Schermo intero e flipper (1 novembre sera, Andrea):** pulsante ⛶ accanto ai volumi e tasto F; il gioco non si ferma
|
||||||
|
più a 1280 px (fino a 2560: a 1920×1080 la scena è 1553 px e prende tutta l'altezza). Nei minigiochi la scena lascia
|
||||||
|
sotto lo spazio vero dei comandi (`fitControls` in main.js, `--below`; cresce subito, cala solo oltre 24 px, «Come si
|
||||||
|
gioca» aperto non conta: una frase che va a capo faceva saltare il piano). Al flipper «Solo tavola» nasconde la
|
||||||
|
pulsantiera (ricordato nel browser); sul telefono in orizzontale è nascosta sempre e «Lascia stare» sta in alto. La
|
||||||
|
molla si carica toccando al centro (fascia 256-384), ai lati le palette anche prima del lancio; a pallina ferma la
|
||||||
|
tavola dice «TIENI PREMUTO QUI». Ponti rifatti da Codex (parapetti, portali, luci che seguono la pallina).
|
||||||
|
|
||||||
- **La prova del peso (1 novembre, Andrea: «senza GPU scoprivamo anche gli effetti troppo pesanti»):** con la scheda
|
- **La prova del peso (1 novembre, Andrea: «senza GPU scoprivamo anche gli effetti troppo pesanti»):** con la scheda
|
||||||
video le prove non vedono più un effetto pesante. `tests/peso.browser.mjs` usa sempre swiftshader e, nel gioco vero,
|
video le prove non vedono più un effetto pesante. `tests/peso.browser.mjs` usa sempre swiftshader e, nel gioco vero,
|
||||||
in ogni scena nelle tre fasi e in ogni primo piano (il flipper anche in gioco e nel Regolo del futuro) misura
|
in ogni scena nelle tre fasi e in ogni primo piano (il flipper anche in gioco e nel Regolo del futuro) misura
|
||||||
|
|||||||
+11
-1
@@ -23,12 +23,14 @@
|
|||||||
<button id="notebook-button" hidden title="Taccuino (N)">Taccuino</button>
|
<button id="notebook-button" hidden title="Taccuino (N)">Taccuino</button>
|
||||||
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
|
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
|
||||||
<button id="back-button" hidden></button>
|
<button id="back-button" hidden></button>
|
||||||
|
<button id="table-only" hidden title="Nasconde la pulsantiera: il flipper a tutta altezza (si gioca con mouse e tastiera)">Solo tavola</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sound">
|
<div id="sound">
|
||||||
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
|
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
|
||||||
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
|
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
|
||||||
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
|
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
|
||||||
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
|
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
|
||||||
|
<button id="fullscreen-hud" aria-label="Schermo intero" title="Schermo intero (F)">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
<section id="splash" class="overlay" aria-label="Manzolo (Poca) Art Presents">
|
<section id="splash" class="overlay" aria-label="Manzolo (Poca) Art Presents">
|
||||||
<p class="splash-name">Manzolo</p>
|
<p class="splash-name">Manzolo</p>
|
||||||
@@ -309,6 +311,13 @@
|
|||||||
premuto Spazio o la freccia giù, o usa il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
|
premuto Spazio o la freccia giù, o usa il pulsante Lancia. Una valvola impedisce alla pallina entrata in campo di tornare sulla molla.
|
||||||
Le frecce sinistra e destra, i due Shift, i pulsanti Flipper o le due metà della scena comandano le palette;
|
Le frecce sinistra e destra, i due Shift, i pulsanti Flipper o le due metà della scena comandano le palette;
|
||||||
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
|
puoi usare due dita insieme e il tasto destro del mouse comanda sempre la paletta destra.</p>
|
||||||
|
<p><strong>Tiro perfetto:</strong> al lancio centra la corsia che lampeggia SKILL per 25.000 punti.
|
||||||
|
Lo spinner RIVER paga 500 punti a ogni giro. Completa i tre bersagli di un lato per accendere il
|
||||||
|
<strong>Salvataggio della Sieve</strong> su quel lato: recupera una sola uscita laterale.</p>
|
||||||
|
<p><strong>Spintarella:</strong> premi N o freccia su, oppure tocca SCUOTI sul bordo inferiore della tavola.
|
||||||
|
Due avvertimenti per pallina; alla terza scossa è TILT: palette spente, niente punti né salvataggi e bonus perso.
|
||||||
|
A fine pallina incassi 1.000 per corsia, 1.500 per bersaglio e 3.000 per rampa; ogni PALIO alza
|
||||||
|
il moltiplicatore bonus fino a ×5. Il bonus si conserva durante lock e multiball e si paga alla perdita di una vita.</p>
|
||||||
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
|
<p>Le rampe FORTEZZA (ciano) e TREBBIO (ambra) valgono 15.000 punti. Tre passaggi completi sulla stessa
|
||||||
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
|
rampa entro 20 secondi dal primo fanno LOCK: la pallina viene trattenuta e ne arriva una nuova gratis.
|
||||||
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
|
Completare l'altra rampa interrompe la serie. Dopo il primo LOCK l'altra rampa diventa SPECIAL: il primo
|
||||||
@@ -320,7 +329,8 @@
|
|||||||
<p>Nel futuro completa due volte ciascun percorso: il loop ORBITA DEL SERPENTE vale 35.000 a giro,
|
<p>Nel futuro completa due volte ciascun percorso: il loop ORBITA DEL SERPENTE vale 35.000 a giro,
|
||||||
la rampa sopraelevata PONTE DELLA FORTEZZA 50.000 e la SPIRA AL NEON 65.000. Contano solo i giri
|
la rampa sopraelevata PONTE DELLA FORTEZZA 50.000 e la SPIRA AL NEON 65.000. Contano solo i giri
|
||||||
completi: un tiro troppo debole ricade senza premio. Ogni percorso carica un sigillo, senza limite di tempo.
|
completi: un tiro troppo debole ricade senza premio. Ogni percorso carica un sigillo, senza limite di tempo.
|
||||||
Accesi tutti e tre, colpisci il Regolo coronato che si muove al centro: 500.000 punti e ritorno al tavolo
|
Accesi tutti e tre, il Regolo si risveglia: hai 30 secondi per colpire il bersaglio coronato che si muove al centro.
|
||||||
|
Se scade il tempo, basta un altro giro su ciascuna rampa per riprovare. Il colpo finale vale 500.000 punti e il ritorno al tavolo
|
||||||
classico, con una pallina pronta sulla molla senza consumare la riserva. Torni al classico anche perdendo
|
classico, con una pallina pronta sulla molla senza consumare la riserva. Torni al classico anche perdendo
|
||||||
l'ultima pallina in gioco: in quel caso consumi una sola vita. I punti restano e puoi riaprire la SUPER.
|
l'ultima pallina in gioco: in quel caso consumi una sola vita. I punti restano e puoi riaprire la SUPER.
|
||||||
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
|
Se perdi la sostitutiva prima della SUPER, recuperi l'ultimo LOCK senza consumare una vita.</p>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.71",
|
"version": "0.1.74",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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();
|
||||||
|
}
|
||||||
+258
-27
@@ -1,7 +1,8 @@
|
|||||||
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
|
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
|
||||||
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
|
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
|
||||||
import { R, SCORE, RAMP, FUTURE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
|
import { R, SCORE, RAMP, FUTURE, SHOW, SPINNER, NUDGE_ZONE, bonusValue, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
|
||||||
import { ambientHash as hash } from './ambient.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 INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
|
||||||
const TINT = { sinistra: '#65ddec', destra: '#ffc16c', super: '#dba5ff', orbita: '#58f4e2', ponte: '#c1a0ff', spira: '#ff729e' };
|
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; }
|
if (typeof document === 'undefined') { paint(g); return; }
|
||||||
g.drawImage(layerOf(key, paint, bounds), ...bounds);
|
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:
|
// 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
|
// 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.
|
// 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.
|
// 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');
|
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');
|
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.
|
// 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);
|
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);
|
line(g, [[440, 86], [440, 306]], '#45606c', 0.5);
|
||||||
@@ -151,7 +225,8 @@ function paintTrack(g, r) {
|
|||||||
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
|
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
|
||||||
screw(g, p.x + 7, p.y + 10);
|
screw(g, p.x + 7, p.y + 10);
|
||||||
}
|
}
|
||||||
const track = (color, width) => {
|
const track = (color, width, points = pts) => {
|
||||||
|
const pts = points;
|
||||||
g.beginPath(); g.moveTo(...pts[0]);
|
g.beginPath(); g.moveTo(...pts[0]);
|
||||||
for (let i = 1; i < pts.length - 1; i++) {
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
|
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
|
||||||
@@ -165,7 +240,24 @@ function paintTrack(g, r) {
|
|||||||
track(color + '18', 20); track('#02080e', 16); track(color, 14);
|
track(color + '18', 20); track('#02080e', 16); track(color, 14);
|
||||||
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
|
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
|
||||||
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
|
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
|
||||||
track(color + '24', 4);
|
track(color + '38', 4);
|
||||||
|
// Parapetti rialzati: montanti e tiranti triangolari fanno leggere il ponte anche da fermo.
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
let previous;
|
||||||
|
for (let s = 34; s < r.len - 10; s += 18) {
|
||||||
|
const p = rampPoint(r, s), foot = [p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side];
|
||||||
|
const top = [foot[0], foot[1] - 5];
|
||||||
|
line(g, [foot, top], '#a6c6d2', 0.8);
|
||||||
|
if (previous) line(g, [previous, foot], color + '88', 0.65);
|
||||||
|
disc(g, ...top, 0.9, color); previous = top;
|
||||||
|
}
|
||||||
|
const rail = pts.map(([x, y], i) => {
|
||||||
|
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
|
||||||
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
|
||||||
|
return [x + (b[1] - a[1]) / len * 6.5 * side, y - (b[0] - a[0]) / len * 6.5 * side - 5];
|
||||||
|
});
|
||||||
|
track('#06121d', 2.6, rail); track(color, 1.3, rail); track('#ffffff99', 0.45, rail);
|
||||||
|
}
|
||||||
for (let s = 12; s < r.len - 4; s += 22) {
|
for (let s = 12; s < r.len - 4; s += 22) {
|
||||||
const p = rampPoint(r, s);
|
const p = rampPoint(r, s);
|
||||||
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
|
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
|
||||||
@@ -178,18 +270,50 @@ function paintTrack(g, r) {
|
|||||||
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
|
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
|
||||||
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
|
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
|
||||||
}
|
}
|
||||||
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
|
// Portale d'ingresso in metallo: l'arco e le torrette incorniciano la bocca fisica della rampa.
|
||||||
|
const arch = [[x0 + 2, y - 2], [x0 + 2, y - 17], [cx - 8, y - 27], [cx + 8, y - 27], [x1 - 2, y - 17], [x1 - 2, y - 2]];
|
||||||
|
line(g, arch, '#020910', 5); line(g, arch, metal(g, x0, y - 27, x1 - x0, 27), 3.2); line(g, arch, color, 1);
|
||||||
|
for (const x of [x0 + 2, x1 - 2]) {
|
||||||
|
box(g, x - 2.5, y - 16, 5, 16, '#112735', '#8aaab7', 1);
|
||||||
|
box(g, x - 0.8, y - 13, 1.6, 9, color, null, 0.5);
|
||||||
|
}
|
||||||
|
box(g, cx - 10, y - 31, 20, 8, '#0a1b28', color, 2);
|
||||||
|
crown(g, cx, y - 27, 2.3, color);
|
||||||
|
for (let i = 0; i < 2; i++) line(g, [[cx - 3, y - 5 - i * 5], [cx, y - 8 - i * 5], [cx + 3, y - 5 - i * 5]], color + 'aa', 0.8);
|
||||||
|
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 18, 4.5, WHITE, 'center');
|
||||||
}
|
}
|
||||||
function rampLights(g, c, r, time) {
|
function trackEnergy(g, c, r, time, hot, balls) {
|
||||||
|
const color = TINT[r.id], riders = balls.filter(b => b.ramp?.id === r.id);
|
||||||
|
const charged = hot || riders.length > 0;
|
||||||
|
// Luci sui bordi: il centro rimane libero per seguire la pallina.
|
||||||
|
for (let k = 0; k < (charged ? 7 : 3); k++) {
|
||||||
|
const p = rampPoint(r, (time * (charged ? 155 : 48) + k * r.len / (charged ? 7 : 3)) % r.len);
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const x = p.x + p.dy * 6.5 * side, y = p.y - p.dx * 6.5 * side - 5;
|
||||||
|
if (charged) glow(g, x, y, 7, color, 0.5);
|
||||||
|
disc(g, x, y, charged ? 1.1 : 0.8, charged ? WHITE : color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of riders) {
|
||||||
|
// La scia segue la distanza percorsa, anche quando un tiro debole ricade indietro.
|
||||||
|
for (let i = 7; i > 0; i--) {
|
||||||
|
const s = b.ramp.s - Math.sign(b.ramp.v) * i * 4;
|
||||||
|
if (s < 0 || s > r.len) continue;
|
||||||
|
const p = rampPoint(r, s);
|
||||||
|
g.save(); g.globalAlpha *= (1 - i / 8) * 0.65;
|
||||||
|
line(g, [[p.x - p.dy * 3, p.y + p.dx * 3], [p.x + p.dy * 3, p.y - p.dx * 3]], color, 2);
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
|
const p = rampPoint(r, b.ramp.s); glow(g, b.x, p.y, 14, color, 0.8);
|
||||||
|
}
|
||||||
|
const [[x0, y], [x1]] = r.mouth, pulse = 0.45 + Math.sin(time * (charged ? 7 : 2)) * 0.15;
|
||||||
|
for (const x of [x0 + 2, x1 - 2]) glow(g, x, y - 10, 11, color, charged ? 0.85 : pulse);
|
||||||
|
}
|
||||||
|
function rampLights(g, c, r, time, balls) {
|
||||||
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
|
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
|
||||||
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
|
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
|
||||||
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
|
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
|
||||||
if (hot) {
|
trackEnergy(g, c, r, time, hot, balls);
|
||||||
for (let k = 0; k < 7; k++) {
|
|
||||||
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
|
|
||||||
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
|
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
|
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
|
||||||
@@ -203,7 +327,7 @@ function rampLights(g, c, r, time) {
|
|||||||
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');
|
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) {
|
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);
|
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();
|
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');
|
disc(g, b.x, b.y + 1, b.r, metal(g, b.x - 10, b.y - 10, 20, 20), '#07111b');
|
||||||
@@ -212,6 +336,10 @@ function bumper(g, b, time) {
|
|||||||
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
|
v.addColorStop(0, hot ? '#fff8d4' : '#427382'); v.addColorStop(1, hot ? '#d9813d' : '#10232e');
|
||||||
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
|
disc(g, b.x, b.y - 1, b.r - 4, v, '#759ca7');
|
||||||
crown(g, b.x, b.y - 1, 3, hot ? '#fffaf0' : '#d6e9d9');
|
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);
|
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(); }
|
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(); }
|
||||||
}
|
}
|
||||||
@@ -269,6 +397,7 @@ function plunger(g, c) {
|
|||||||
const shown = new WeakMap();
|
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; }
|
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) {
|
function board(g, c, time) {
|
||||||
|
glow(g, 99, 43, 25, SCENES[apparition(c, time).id].color, 0.4);
|
||||||
crown(g, 99, 43, 10, '#e7c889');
|
crown(g, 99, 43, 10, '#e7c889');
|
||||||
text(g, 'IL REGOLO', 99, 77, 24, '#eee8d7', 'center', 'Georgia, serif', 500);
|
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');
|
text(g, 'R I V E R / P I N B A L L', 99, 96, 5.8, '#7f9d9e', 'center');
|
||||||
@@ -290,6 +419,7 @@ function board(g, c, time) {
|
|||||||
text(g, 'PALLINE', 28, 225, 6, MUTED);
|
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');
|
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');
|
if (c.balls > 8) text(g, `+${c.balls - 8}`, 162, 225, 5, WHITE, 'right');
|
||||||
|
livingBackglass(g, c, time);
|
||||||
if (c.step === 'result') {
|
if (c.step === 'result') {
|
||||||
box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5);
|
box(g, 24, 246, 150, 62, '#06131deb', '#c8b579', 5);
|
||||||
text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center');
|
text(g, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 99, 264, 9, '#efdfa8', 'center');
|
||||||
@@ -328,11 +458,25 @@ function mission(g, c, time) {
|
|||||||
}
|
}
|
||||||
function effects(g, c, time) {
|
function effects(g, c, time) {
|
||||||
const latest = c.fx.findLast(f => f.kind === 'celebrate');
|
const latest = c.fx.findLast(f => f.kind === 'celebrate');
|
||||||
|
const firework = c.fx.findLast(f => f.kind === 'fireworks');
|
||||||
for (const f of c.fx) {
|
for (const f of c.fx) {
|
||||||
const age = time - f.t;
|
const age = time - f.t;
|
||||||
if (age < 0) continue;
|
if (age < 0) continue;
|
||||||
g.save();
|
g.save();
|
||||||
if (f.kind === 'spark' && age < 0.5) {
|
if (f.kind === 'fireworks' && f === firework && age < 2.8) {
|
||||||
|
// Quattro esplosioni sui fianchi: mai sopra la zona delle palette.
|
||||||
|
for (let k = 0; k < 4; k++) {
|
||||||
|
const a = age - k * 0.28;
|
||||||
|
if (a < 0 || a > 1.7) continue;
|
||||||
|
const x = k % 2 ? 455 : 185, y = 55 + Math.floor(k / 2) * 95;
|
||||||
|
g.globalAlpha = Math.min(1, (1.7 - a) * 2);
|
||||||
|
for (let j = 0; j < 18; j++) {
|
||||||
|
const angle = j / 18 * TAU + k, speed = 15 + hash(j + k * 31) * 27;
|
||||||
|
const dx = Math.cos(angle) * speed, dy = Math.sin(angle) * speed;
|
||||||
|
line(g, [[x + dx * a, y + dy * a + 14 * a * a], [x + dx * (a + 0.09), y + dy * (a + 0.09) + 14 * a * a]], j % 3 ? f.color : WHITE, 1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (f.kind === 'spark' && age < 0.5) {
|
||||||
g.globalAlpha = 1 - age / 0.5;
|
g.globalAlpha = 1 - age / 0.5;
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
|
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
|
||||||
@@ -363,10 +507,10 @@ function effects(g, c, time) {
|
|||||||
poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18');
|
poly(g, [[320, 305], [320 + Math.cos(a - 0.025) * 330, 305 + Math.sin(a - 0.025) * 330], [320 + Math.cos(a + 0.025) * 330, 305 + Math.sin(a + 0.025) * 330]], color + '18');
|
||||||
}
|
}
|
||||||
g.restore();
|
g.restore();
|
||||||
glow(g, 320, 277, 85, color, 0.45);
|
glow(g, 99, 277, 60, color, 0.45);
|
||||||
box(g, 257, 265, 126, 31, '#07111deb', color, 6);
|
box(g, 24, 261, 150, 37, '#07111df5', color, 6);
|
||||||
text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800);
|
text(g, f.text, 99, 274, Math.min(10, 140 / (f.text.length * 0.65)), WHITE, 'center', 'sans-serif', 800);
|
||||||
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace');
|
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 99, 288, 6, color, 'center', 'monospace');
|
||||||
}
|
}
|
||||||
g.restore();
|
g.restore();
|
||||||
}
|
}
|
||||||
@@ -388,6 +532,7 @@ function futureCabinet(g, c, bg, glass) {
|
|||||||
text(g, 'FORTEZZA / NUCLEO', 320, 91, 5, '#d1c7ff', 'center');
|
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]];
|
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');
|
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); }
|
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();
|
g.restore();
|
||||||
// Il mobile stesso cambia: pannelli a circuito, la finestra sul River resta il legame col 2007.
|
// Il mobile stesso cambia: pannelli a circuito, la finestra sul River resta il legame col 2007.
|
||||||
@@ -416,28 +561,32 @@ function futureBoard(g, c) {
|
|||||||
text(g, `${n}/2`, 603, y + 27, 9, WHITE, 'right', 'monospace');
|
text(g, `${n}/2`, 603, y + 27, 9, WHITE, 'right', 'monospace');
|
||||||
text(g, `OGNI GIRO +${number(r.points)}`, 488, y + 40, 5.5, MUTED);
|
text(g, `OGNI GIRO +${number(r.points)}`, 488, y + 40, 5.5, MUTED);
|
||||||
}
|
}
|
||||||
text(g, ready ? 'IL REGOLO È VULNERABILE' : 'POI COLPISCI IL REGOLO MOBILE', 548, 274, 6, ready ? TINT.orbita : '#d6c1ff', 'center');
|
text(g, ready ? `RISVEGLIO: ${Math.min(SHOW.awake, Math.max(0, Math.ceil((c.future.awakeUntil ?? c.sim) - c.sim)))}s` : 'POI COLPISCI IL REGOLO MOBILE', 548, 274, 6, ready ? TINT.orbita : '#d6c1ff', 'center');
|
||||||
text(g, '+500.000', 548, 294, 22, WHITE, 'center', 'monospace');
|
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, ready ? 'MIRA AL REGOLO CORONATO!' : 'OGNI SIGILLO RISVEGLIA IL REGOLO', 548, 318, 5.5, TINT.orbita, 'center');
|
||||||
text(g, 'SI TORNA AL RIVER', 548, 332, 7, TINT.orbita, 'center');
|
text(g, 'FINALE: RITORNO AL RIVER', 548, 332, 6, MUTED, 'center');
|
||||||
|
livingBackglass(g, c, c.visual.time);
|
||||||
}
|
}
|
||||||
function futureField(g, c, time) {
|
function futureField(g, c, time) {
|
||||||
|
livingPlayfield(g, c, time);
|
||||||
for (const b of c.bumpers) bumper(g, b, time);
|
for (const b of c.bumpers) bumper(g, b, time);
|
||||||
target(g, c);
|
target(g, c);
|
||||||
const ready = Object.values(c.future.hits).every(n => n === 2);
|
const ready = Object.values(c.future.hits).every(n => n === 2);
|
||||||
if (ready) glow(g, c.regolo.x, c.regolo.y, 30, TINT.orbita, 0.6);
|
if (ready) glow(g, c.regolo.x, c.regolo.y, 30, TINT.orbita, 0.6);
|
||||||
|
showDevices(g, c, time);
|
||||||
const balls = activeBalls(c);
|
const balls = activeBalls(c);
|
||||||
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
|
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
|
||||||
for (const r of c.ramps) {
|
for (const r of c.ramps) {
|
||||||
cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [207, 5, 221, 250]);
|
cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [202, 0, 232, 258]);
|
||||||
const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1];
|
const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1];
|
||||||
for (let k = 0; k < 4; k++) { const p = rampPoint(r, (time * 120 + k * r.len / 4) % r.len); disc(g, p.x, p.y, 1.3, color); }
|
trackEnergy(g, c, r, time, r.lit > 0 || c.future.hits[r.id] === 2, balls);
|
||||||
poly(g, [[x, y + 7], [x - 4, y + 14], [x + 4, y + 14]], color);
|
poly(g, [[x, y + 7], [x - 4, y + 14], [x + 4, y + 14]], color);
|
||||||
text(g, r.id.toUpperCase(), x, y + 21, 5.5, color, 'center');
|
text(g, r.id.toUpperCase(), x, y + 21, 5.5, color, 'center');
|
||||||
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, color);
|
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, color);
|
||||||
}
|
}
|
||||||
for (const side of ['left', 'right']) paddle(g, c, side);
|
for (const side of ['left', 'right']) paddle(g, c, side);
|
||||||
plunger(g, c); effects(g, c, time); futureBoard(g, c);
|
plunger(g, c); futureBoard(g, c); showFeatures(g, c, time); effects(g, c, time);
|
||||||
|
for (const b of balls) if (!b.ramp && !b.waiting && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
|
||||||
}
|
}
|
||||||
function oldPlayfield(c, bg, glass) {
|
function oldPlayfield(c, bg, glass) {
|
||||||
return l => {
|
return l => {
|
||||||
@@ -505,11 +654,16 @@ export function drawFlipper(g, c, def, bg, glass) {
|
|||||||
}
|
}
|
||||||
if (future) {
|
if (future) {
|
||||||
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
|
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
|
||||||
futureField(g, c, time); g.restore(); return;
|
if (shake > 0) {
|
||||||
|
const dx = Math.sin(time * 75) * shake * 1.3, dy = Math.cos(time * 63) * shake;
|
||||||
|
g.translate(dx, dy); if (g.canvas?.style) g.canvas.style.backgroundPosition = `${dx}px ${dy}px`;
|
||||||
|
}
|
||||||
|
futureField(g, c, time); plungerHint(g, c, time); g.restore(); return;
|
||||||
}
|
}
|
||||||
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); }
|
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 if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
|
||||||
else cached(g, key, paint);
|
else cached(g, key, paint);
|
||||||
|
livingPlayfield(g, c, time);
|
||||||
for (const [i, l] of c.lanes.entries()) {
|
for (const [i, l] of c.lanes.entries()) {
|
||||||
if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55);
|
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');
|
disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b');
|
||||||
@@ -521,6 +675,7 @@ export function drawFlipper(g, c, def, bg, glass) {
|
|||||||
box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center');
|
box(g, 307, 223, 26, 11, '#142431', null, 2); text(g, 'SUPER', 320, 228, 5, '#8b91ac', 'center');
|
||||||
for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748');
|
for (let i = 0; i < 2; i++) disc(g, 315 + i * 10, 239, 1.6, c.locks.length > i ? TINT.super : '#263748');
|
||||||
}
|
}
|
||||||
|
showDevices(g, c, time);
|
||||||
const balls = activeBalls(c);
|
const balls = activeBalls(c);
|
||||||
for (const [i, b] of balls.entries()) {
|
for (const [i, b] of balls.entries()) {
|
||||||
if (b.waiting) continue;
|
if (b.waiting) continue;
|
||||||
@@ -532,16 +687,92 @@ export function drawFlipper(g, c, def, bg, glass) {
|
|||||||
if (!rampOpen(c, r)) continue;
|
if (!rampOpen(c, r)) continue;
|
||||||
g.save();
|
g.save();
|
||||||
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
|
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
|
||||||
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time);
|
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [208, 45, 148, 207] : [208, 0, 225, 238]); rampLights(g, c, r, time, balls);
|
||||||
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
|
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
|
||||||
g.restore();
|
g.restore();
|
||||||
}
|
}
|
||||||
c.locks.forEach(held => lock(g, held, time));
|
c.locks.forEach(held => lock(g, held, time));
|
||||||
for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time);
|
for (const b of balls) if (b.releaseAt > c.sim) lock(g, { ball: b, rampId: b.releasedFrom }, time);
|
||||||
for (const side of ['left', 'right']) paddle(g, c, side);
|
for (const side of ['left', 'right']) paddle(g, c, side);
|
||||||
plunger(g, c); effects(g, c, time);
|
plunger(g, c);
|
||||||
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
|
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
|
||||||
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
|
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
|
||||||
board(g, c, time); mission(g, c, time);
|
board(g, c, time); mission(g, c, time); showFeatures(g, c, time); effects(g, c, time); plungerHint(g, c, time);
|
||||||
|
for (const b of balls) if (!b.ramp && !b.waiting && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
|
||||||
g.restore();
|
g.restore();
|
||||||
}
|
}
|
||||||
|
// 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 (!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([]);
|
||||||
|
box(g, 320 - 58, 262, 116, 32, '#07121c', '#ffe08a', 2);
|
||||||
|
text(g, 'TIENI PREMUTO QUI', 320, 272, 8.5, '#ffe08a', 'center', 'sans-serif', 800);
|
||||||
|
text(g, 'e lascia: MOLLA', 320, 285, 7, WHITE, 'center', 'sans-serif', 700);
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserti e contatori dinamici, senza filtri o grandi superfici trasparenti.
|
||||||
|
function showDevices(g, c, time) {
|
||||||
|
if (c.table === 'classic') {
|
||||||
|
const { x, y, half } = SPINNER, height = 2 + Math.abs(Math.cos(c.spinner.angle)) * 9;
|
||||||
|
line(g, [[x - half - 3, y - 10], [x - half - 3, y + 10]], '#b6cad4', 2);
|
||||||
|
line(g, [[x + half + 3, y - 10], [x + half + 3, y + 10]], '#b6cad4', 2);
|
||||||
|
line(g, [[x - half - 3, y], [x + half + 3, y]], '#e8f6ff', 1);
|
||||||
|
box(g, x - half, y - height / 2, half * 2, height, c.spinner.speed > 2 ? '#ffc16c' : '#48717c', '#fff1c7', 1);
|
||||||
|
if (height > 6) text(g, 'RIVER', x, y, 5, INK, 'center');
|
||||||
|
text(g, 'SPINNER 500 / GIRO', x, y + 15, 4, '#f2d5a0', 'center');
|
||||||
|
if (!c.tilt.active && (c.skillShot.available || c.skillShot.active)) {
|
||||||
|
const lane = c.lanes[c.skillShot.lane], pulse = 0.6 + Math.sin(time * 6) * 0.3;
|
||||||
|
glow(g, lane.x, 91, 20, '#ffcf75', pulse);
|
||||||
|
poly(g, [[lane.x, 98], [lane.x - 4, 91], [lane.x + 4, 91]], '#fff0a8');
|
||||||
|
text(g, 'SKILL', lane.x, 82, 5, '#ffdf83', 'center');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [side, x] of [['left', 207], ['right', 426]]) {
|
||||||
|
const lit = c.kickback[side] && !c.tilt.active;
|
||||||
|
box(g, x - 3, 271, 6, 13, lit ? '#74ffe0' : '#203947', lit ? '#d5fff2' : '#406071', 1);
|
||||||
|
if (lit) { glow(g, x, 277, 15, '#74ffe0', 0.65); poly(g, [[x, 258], [x - 3, 264], [x + 3, 264]], '#74ffe0'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showFeatures(g, c, time) {
|
||||||
|
const off = c.tilt.active || waiting(c) || c.step !== 'play';
|
||||||
|
const z = NUDGE_ZONE, color = off ? '#54717e' : c.tilt.warnings ? '#ffbe79' : '#9ae4e3';
|
||||||
|
box(g, z.x, z.y + 1, z.w, z.h - 3, '#0a202c', color, 4);
|
||||||
|
text(g, c.tilt.active ? 'TILT' : `SCUOTI ${c.tilt.warnings}/2`, 320, z.y + 7, 6.5, color, 'center');
|
||||||
|
text(g, 'TOCCA / N / ↑', 320, z.y + 14, 4.5, color, 'center');
|
||||||
|
box(g, 24, 329, 150, 26, '#071018', null, 2);
|
||||||
|
text(g, `BONUS ×${c.bonus.x} ${number(bonusValue(c))}`, 99, 336, 6, '#f2d5a0', 'center');
|
||||||
|
if (c.table === 'classic' && (c.skillShot.available || c.skillShot.active) && !c.tilt.active) {
|
||||||
|
text(g, `SKILL: ${c.lanes[c.skillShot.lane].rione.toUpperCase()}`, 99, 348, 5.5, '#ffdf83', 'center');
|
||||||
|
}
|
||||||
|
const b = c.bonusPaid;
|
||||||
|
if (b && time - b.at < 3 && c.step !== 'result') {
|
||||||
|
box(g, 24, 244, 150, 65, '#07111df5', b.lost ? '#ff8079' : '#ffcf75', 4);
|
||||||
|
text(g, b.lost ? 'TILT · BONUS PERSO' : 'BONUS DI FINE PALLINA', 99, 255, 6.5, '#ffcf75', 'center');
|
||||||
|
text(g, `CORSIE ${b.lanes} · BERSAGLI ${b.targets}`, 99, 270, 5.5, WHITE, 'center');
|
||||||
|
text(g, `RAMPE ${b.ramps} · MOLTIPLICATORE ×${b.x}`, 99, 281, 5.5, WHITE, 'center');
|
||||||
|
text(g, `+${number(Math.floor(b.total * Math.min(1, (time - b.at) / 1.2)))}`, 99, 297, 12, '#a4f7d1', 'center');
|
||||||
|
}
|
||||||
|
if (c.table === 'future') {
|
||||||
|
const parts = Object.values(c.future.hits).filter(n => n === 2).length;
|
||||||
|
// Occhi, squame e coda prendono vita con i tre sigilli.
|
||||||
|
g.save(); g.beginPath(); g.rect(28, 251, 142, 57); g.clip();
|
||||||
|
box(g, 28, 251, 142, 57, '#07121f', null, 2);
|
||||||
|
paintRegolo(g, 99, 286, 20, '#274751');
|
||||||
|
for (let i = 0; i < parts; i++) {
|
||||||
|
g.save(); g.beginPath(); g.rect(28, 251 + i * 19, 142, 19); g.clip();
|
||||||
|
paintRegolo(g, 99, 286, 20, ['#58f4e2', '#c1a0ff', '#ffcf75'][i]); g.restore();
|
||||||
|
}
|
||||||
|
if (parts === 3) glow(g, 99, 280, 35, '#74ffe0', 0.6);
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
|
if (c.tilt.active) {
|
||||||
|
box(g, 260, 116, 120, 35, '#1d0c18', '#ff8079', 5);
|
||||||
|
text(g, 'T I L T', 320, 130, 18, '#ff8079', 'center');
|
||||||
|
text(g, 'PALETTE SPENTE · BONUS PERSO', 320, 144, 5, WHITE, 'center');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+143
-14
@@ -25,6 +25,9 @@ export const LEVELS = {
|
|||||||
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
|
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
|
||||||
male: { g: 800, save: 1, kick: 340, goal: 200000 },
|
male: { g: 800, save: 1, kick: 340, goal: 200000 },
|
||||||
};
|
};
|
||||||
|
export const SHOW = { skill: 25000, spinner: 500, awake: 30, nudgeCooldown: 0.7 };
|
||||||
|
export const SPINNER = { x: 320, y: 193, half: 15 };
|
||||||
|
export const NUDGE_ZONE = { x: 270, y: 337, w: 100, h: 22 };
|
||||||
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
|
export const RIONI = ['piazza', 'pieve', 'cardetole', 'campomigliaio'];
|
||||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
@@ -136,6 +139,10 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
|
|||||||
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
|
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
|
||||||
table: 'classic', transform: null, future: null, ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
|
table: 'classic', transform: null, future: null, ball: null, extraBalls: [], locks: [], special: null, specialPaid: false, jackpotX: 1,
|
||||||
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
|
rampRun: { id: null, count: 0, until: 0 }, superOpenedAt: null,
|
||||||
|
skillShot: { lane: seed % 3, available: true, active: false, until: 0 },
|
||||||
|
spinner: { angle: 0, speed: 0 }, kickback: { left: false, right: false },
|
||||||
|
bonus: { lanes: 0, targets: 0, ramps: 0, x: 1 }, bonusPaid: null,
|
||||||
|
tilt: { warnings: 0, active: false, last: -10 }, nudgeHeld: false,
|
||||||
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
||||||
newBall(c);
|
newBall(c);
|
||||||
return c;
|
return c;
|
||||||
@@ -150,7 +157,7 @@ export const canThrow = c => waiting(c) && !c.holding;
|
|||||||
export const canPress = canThrow;
|
export const canPress = canThrow;
|
||||||
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
|
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
|
||||||
export const canAdjust = canThrow;
|
export const canAdjust = canThrow;
|
||||||
export const canFlip = c => c.step === 'play';
|
export const canFlip = c => c.step === 'play' && !c.tilt.active;
|
||||||
export const aimFree = () => true;
|
export const aimFree = () => true;
|
||||||
export const powerAt = time => clamp(time / CHARGE, 0, 1);
|
export const powerAt = time => clamp(time / CHARGE, 0, 1);
|
||||||
// Quanto resta del salvataggio della pallina, in secondi (0: non protetta).
|
// Quanto resta del salvataggio della pallina, in secondi (0: non protetta).
|
||||||
@@ -167,12 +174,15 @@ function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.tim
|
|||||||
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
|
// I punti, che volano via dal punto colpito; al traguardo la vittoria è acquisita (anche uscendo prima), poi si
|
||||||
// continua per il record.
|
// continua per il record.
|
||||||
function add(c, points, at) {
|
function add(c, points, at) {
|
||||||
|
if (c.tilt.active) return;
|
||||||
c.score += points;
|
c.score += points;
|
||||||
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
|
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
|
||||||
if (!c.result && c.score >= c.skill.goal) {
|
if (!c.result && c.score >= c.skill.goal) {
|
||||||
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
|
c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true);
|
||||||
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
|
// La vittoria viene acquisita una volta sola: la pallina premio va nella riserva, senza fermare il tiro.
|
||||||
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
|
c.balls++; c.lines.unshift('PALLINA +1'); fx(c, 'pop', 324, 235, { text: '+1 PALLINA' });
|
||||||
|
c.events.push({ kind: 'flipper-knocker', power: 1 });
|
||||||
|
fireworks(c, '#ffcf75');
|
||||||
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
|
c.events.push({ kind: 'flipper-traguardo', power: 1 }); fx(c, 'burst', 324, 170, { big: true }); fx(c, 'wash', 0, 0, { color: '255,210,80' }); fx(c, 'shake', 0, 0);
|
||||||
}
|
}
|
||||||
if (c.result) c.result.score = c.score;
|
if (c.result) c.result.score = c.score;
|
||||||
@@ -185,7 +195,9 @@ export function launchSpeed(c, power) {
|
|||||||
return min + (max - min) * clamp(power, 0, 1);
|
return min + (max - min) * clamp(power, 0, 1);
|
||||||
}
|
}
|
||||||
export function launch(c, power) {
|
export function launch(c, power) {
|
||||||
if (!waiting(c)) return false;
|
if (!waiting(c) || c.tilt.active) return false;
|
||||||
|
c.skillShot.active = c.table === 'classic' && c.skillShot.available;
|
||||||
|
c.skillShot.until = c.sim + 8;
|
||||||
// La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale.
|
// La molla vera non è mai identica due volte: un pelo di caso, così la partita non si ripete uguale.
|
||||||
c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0;
|
c.ball.waiting = false; c.ball.vy = -launchSpeed(c, power) * (0.985 + random(c) * 0.03); c.ball.vx = 0;
|
||||||
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
|
// Il salvataggio vale una volta per pallina: persa subito, torna; persa di nuovo, è persa.
|
||||||
@@ -196,7 +208,7 @@ export function launch(c, power) {
|
|||||||
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
|
// Il flipper: il tasto tenuto lo alza, lasciato ricade. Più dita o tasti insieme: resta su finché ne resta uno.
|
||||||
// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda.
|
// Un «clic» (il pulsante attivato da tastiera) lo tiene su per un attimo, abbastanza perché la fisica lo veda.
|
||||||
export function hold(c, side, who, down) {
|
export function hold(c, side, who, down) {
|
||||||
const f = c.flippers[side]; if (!f) return false;
|
const f = c.flippers[side]; if (!f || (down && !canFlip(c))) return false;
|
||||||
const was = f.held.length > 0;
|
const was = f.held.length > 0;
|
||||||
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
|
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
|
||||||
if (who === 'click' && down) f.click = CLICK_HOLD;
|
if (who === 'click' && down) f.click = CLICK_HOLD;
|
||||||
@@ -208,6 +220,56 @@ export function flipperSeg(c, side) {
|
|||||||
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
|
return { a: [px, py], b: [px + Math.cos(f.angle) * FLIP.len, py + Math.sin(f.angle) * FLIP.len] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fireworks(c, color) {
|
||||||
|
fx(c, 'fireworks', 0, 0, { color });
|
||||||
|
}
|
||||||
|
export const bonusValue = c => (c.bonus.lanes * 1000 + c.bonus.targets * 1500 + c.bonus.ramps * 3000) * c.bonus.x;
|
||||||
|
function payBonus(c) {
|
||||||
|
const total = c.tilt.active ? 0 : bonusValue(c);
|
||||||
|
c.bonusPaid = { ...c.bonus, total, lost: c.tilt.active, at: c.visual.time };
|
||||||
|
if (total) { add(c, total); announce(c, `BONUS +${total}`); c.events.push({ kind: 'flipper-bonus', power: 1 }); }
|
||||||
|
c.bonus = { lanes: 0, targets: 0, ramps: 0, x: 1 };
|
||||||
|
}
|
||||||
|
function updateShow(c) {
|
||||||
|
if (c.skillShot.active && c.sim > c.skillShot.until) { c.skillShot.active = false; c.skillShot.available = false; }
|
||||||
|
const s = c.spinner;
|
||||||
|
if (c.table === 'classic' && !c.tilt.active && s.speed > 0) {
|
||||||
|
s.angle += s.speed * STEP; s.speed = Math.max(0, s.speed - 9 * STEP);
|
||||||
|
if (s.angle >= Math.PI * 2) {
|
||||||
|
s.angle %= Math.PI * 2; add(c, SHOW.spinner, [SPINNER.x, SPINNER.y - 10]);
|
||||||
|
c.events.push({ kind: 'flipper-spinner', power: 0.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!c.future?.awakeUntil || c.tilt.active) return;
|
||||||
|
const left = Math.min(SHOW.awake, Math.ceil(c.future.awakeUntil - c.sim));
|
||||||
|
if (left <= 0) {
|
||||||
|
c.future.awakeUntil = null;
|
||||||
|
c.future.hits = { orbita: 1, ponte: 1, spira: 1 };
|
||||||
|
announce(c, 'RICARICA I TRE SIGILLI');
|
||||||
|
} else if (left <= 10 && left !== c.future.beat) {
|
||||||
|
c.future.beat = left; c.events.push({ kind: 'flipper-countdown', power: (11 - left) / 10 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function crossShow(c, from, ball) {
|
||||||
|
if (c.tilt.active || !from) return;
|
||||||
|
if (c.skillShot.active && ball.y > 185 && ball.inPlay) { c.skillShot.active = false; c.skillShot.available = false; }
|
||||||
|
if (c.table === 'classic' && ((from.y < SPINNER.y && ball.y >= SPINNER.y) || (from.y > SPINNER.y && ball.y <= SPINNER.y)) && Math.abs(ball.y - from.y) > 0.01 && Math.abs(ball.vy) > 30) {
|
||||||
|
const x = from.x + (ball.x - from.x) * (SPINNER.y - from.y) / (ball.y - from.y);
|
||||||
|
if (Math.abs(x - SPINNER.x) <= SPINNER.half) {
|
||||||
|
c.spinner.speed = Math.min(48, c.spinner.speed + Math.abs(ball.vy) * 0.055);
|
||||||
|
fx(c, 'spark', SPINNER.x, SPINNER.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (from.y < 275 && ball.y >= 275 && ball.vy > 0) {
|
||||||
|
const side = ball.x < 214 ? 'left' : ball.x > 420 && ball.x < 432 ? 'right' : null;
|
||||||
|
if (side && c.kickback[side]) {
|
||||||
|
c.kickback[side] = false; ball.vy = -560; ball.vx = side === 'left' ? 35 : -35;
|
||||||
|
announce(c, 'SALVATA DALLA SIEVE!'); fx(c, 'ring', ball.x, ball.y);
|
||||||
|
c.events.push({ kind: 'flipper-kickback', power: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// La fisica, a passi fissi.
|
// La fisica, a passi fissi.
|
||||||
export function update(c, dt) {
|
export function update(c, dt) {
|
||||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
@@ -228,7 +290,7 @@ function moveFlippers(c, h) {
|
|||||||
for (const side of ['left', 'right']) {
|
for (const side of ['left', 'right']) {
|
||||||
const f = c.flippers[side];
|
const f = c.flippers[side];
|
||||||
if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click');
|
if (f.click > 0 && (f.click -= h) <= 0) f.held = f.held.filter(k => k !== 'click');
|
||||||
const target = f.held.length ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
|
const target = f.held.length && !c.tilt.active ? (side === 'left' ? FLIP.up : Math.PI - FLIP.up) : (side === 'left' ? FLIP.rest : Math.PI - FLIP.rest);
|
||||||
const previous = f.angle;
|
const previous = f.angle;
|
||||||
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
|
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
|
||||||
f.omega = (f.angle - previous) / h;
|
f.omega = (f.angle - previous) / h;
|
||||||
@@ -241,6 +303,7 @@ function step(c) {
|
|||||||
if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; }
|
if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; }
|
||||||
c.transform = null; announce(c, 'CARICA I TRE SIGILLI');
|
c.transform = null; announce(c, 'CARICA I TRE SIGILLI');
|
||||||
}
|
}
|
||||||
|
if (c.step === 'play') updateShow(c);
|
||||||
if (c.table === 'future') {
|
if (c.table === 'future') {
|
||||||
c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24;
|
c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24;
|
||||||
}
|
}
|
||||||
@@ -273,6 +336,7 @@ function step(c) {
|
|||||||
for (const ball of balls) {
|
for (const ball of balls) {
|
||||||
if (ball.waiting || ball.releaseAt > c.sim) continue;
|
if (ball.waiting || ball.releaseAt > c.sim) continue;
|
||||||
if (ball.ramp) { rideRamp(c, ball); continue; }
|
if (ball.ramp) { rideRamp(c, ball); continue; }
|
||||||
|
crossShow(c, from.get(ball), ball);
|
||||||
crossLanes(c, from.get(ball), ball);
|
crossLanes(c, from.get(ball), ball);
|
||||||
enterRamps(c, from.get(ball), ball);
|
enterRamps(c, from.get(ball), ball);
|
||||||
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
|
// L'anti-incastro è indipendente per ogni pallina; non scuote quelle trattenute sulla paletta.
|
||||||
@@ -282,6 +346,7 @@ function step(c) {
|
|||||||
if ((ball.rest += STEP) > 0.3) {
|
if ((ball.rest += STEP) > 0.3) {
|
||||||
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
|
// In multiball una ricaduta nella molla si rilancia da sola: i comandi restano alle palette.
|
||||||
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
|
if (multiball(c)) { ball.vx = 0; ball.vy = -launchSpeed(c, 0.7); ball.rest = 0; }
|
||||||
|
else if (c.tilt.active) { drain(c, ball); continue; }
|
||||||
else newBall(c);
|
else newBall(c);
|
||||||
}
|
}
|
||||||
} else ball.rest = 0;
|
} else ball.rest = 0;
|
||||||
@@ -302,12 +367,23 @@ function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED
|
|||||||
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
|
const cradled = (c, ball) => ['left', 'right'].some(side => c.flippers[side].held.length && Math.hypot(ball.x - PIVOT[side][0], ball.y - PIVOT[side][1]) < FLIP.len + 2 * R);
|
||||||
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
|
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
|
||||||
function crossLanes(c, from, ball) {
|
function crossLanes(c, from, ball) {
|
||||||
if (!(from.y < LANE_LINE && ball.y >= LANE_LINE)) return;
|
if (c.tilt.active || !(from.y < LANE_LINE && ball.y >= LANE_LINE)) return;
|
||||||
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
const k = (LANE_LINE - from.y) / (ball.y - from.y), x = from.x + (ball.x - from.x) * k;
|
||||||
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
|
const l = c.lanes.find(l => x >= l.x0 && x < l.x1);
|
||||||
if (!l || l.lit) return;
|
if (!l) return;
|
||||||
|
if (c.skillShot.active) {
|
||||||
|
c.skillShot.active = false; c.skillShot.available = false;
|
||||||
|
if (c.lanes.indexOf(l) === c.skillShot.lane) {
|
||||||
|
celebrate(c, 'TIRO PERFETTO!', SHOW.skill, '#ffcf75');
|
||||||
|
c.events.push({ kind: 'flipper-skill', power: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (l.lit) return;
|
||||||
|
c.bonus.lanes++;
|
||||||
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
|
l.lit = true; add(c, SCORE.lane, [l.x, l.y]); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); fx(c, 'spark', l.x, l.y);
|
||||||
if (c.lanes.every(k => k.lit)) {
|
if (c.lanes.every(k => k.lit)) {
|
||||||
|
c.bonus.x = Math.min(5, c.bonus.x + 1);
|
||||||
|
fireworks(c, '#ffcf75');
|
||||||
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
|
add(c, SCORE.palio, [324, 150]); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false;
|
||||||
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
|
fx(c, 'burst', 324, 110, { big: true }); fx(c, 'wash', 0, 0, { color: '255,80,80' }); fx(c, 'shake', 0, 0);
|
||||||
}
|
}
|
||||||
@@ -316,6 +392,7 @@ function crossLanes(c, from, ball) {
|
|||||||
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
|
// percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna
|
||||||
// indietro e ricasca dall'imbocco.
|
// indietro e ricasca dall'imbocco.
|
||||||
function enterRamps(c, from, ball) {
|
function enterRamps(c, from, ball) {
|
||||||
|
if (c.tilt.active) return;
|
||||||
for (const r of c.ramps) {
|
for (const r of c.ramps) {
|
||||||
if (!rampOpen(c, r)) continue;
|
if (!rampOpen(c, r)) continue;
|
||||||
const [[x0, y], [x1]] = r.mouth;
|
const [[x0, y], [x1]] = r.mouth;
|
||||||
@@ -345,9 +422,16 @@ function rideRamp(c, ball) {
|
|||||||
const v = Math.max(on.v, 150);
|
const v = Math.max(on.v, 150);
|
||||||
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
|
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
|
||||||
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
|
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
|
||||||
|
if (c.tilt.active) return;
|
||||||
|
c.bonus.ramps++;
|
||||||
if (c.table === 'future') {
|
if (c.table === 'future') {
|
||||||
c.future.hits[r.id] = Math.min(2, c.future.hits[r.id] + 1); r.lit = 3;
|
c.future.hits[r.id] = Math.min(2, c.future.hits[r.id] + 1); r.lit = 3;
|
||||||
add(c, r.points, [p.x, p.y]); announce(c, `${r.id.toUpperCase()} ${c.future.hits[r.id]}/2`);
|
add(c, r.points, [p.x, p.y]); announce(c, `${r.id.toUpperCase()} ${c.future.hits[r.id]}/2`);
|
||||||
|
if (!c.future.awakeUntil && Object.values(c.future.hits).every(n => n === 2)) {
|
||||||
|
c.future.awakeUntil = c.sim + SHOW.awake; c.future.beat = 31;
|
||||||
|
celebrate(c, 'REGOLO RISVEGLIATO!', 0, '#74ffe0');
|
||||||
|
c.events.push({ kind: 'flipper-risveglio', power: 1 });
|
||||||
|
}
|
||||||
fx(c, 'ring', p.x, p.y); c.events.push({ kind: 'flipper-futuro-giro', power: 1 }); return;
|
fx(c, 'ring', p.x, p.y); c.events.push({ kind: 'flipper-futuro-giro', power: 1 }); return;
|
||||||
}
|
}
|
||||||
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
|
if (r.id !== 'super') add(c, SCORE.ramp, [p.x, p.y]);
|
||||||
@@ -374,6 +458,7 @@ function celebrate(c, title, points, color) {
|
|||||||
if (points) add(c, points, [320, 184]);
|
if (points) add(c, points, [320, 184]);
|
||||||
announce(c, title);
|
announce(c, title);
|
||||||
fx(c, 'celebrate', 320, 170, { text: title, points, color });
|
fx(c, 'celebrate', 320, 170, { text: title, points, color });
|
||||||
|
fireworks(c, color);
|
||||||
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
|
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
|
||||||
}
|
}
|
||||||
function rampFeature(c, r, ball) {
|
function rampFeature(c, r, ball) {
|
||||||
@@ -396,6 +481,7 @@ function rampFeature(c, r, ball) {
|
|||||||
c.transform = { at: c.sim, until: c.sim + FUTURE.duration };
|
c.transform = { at: c.sim, until: c.sim + FUTURE.duration };
|
||||||
c.events.push({ kind: 'flipper-esplosione', power: 1 });
|
c.events.push({ kind: 'flipper-esplosione', power: 1 });
|
||||||
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
|
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
|
||||||
|
c.events.push({ kind: 'flipper-knocker', power: 1 });
|
||||||
c.events.push({ kind: 'flipper-super', power: 1 });
|
c.events.push({ kind: 'flipper-super', power: 1 });
|
||||||
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
|
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
|
||||||
return true;
|
return true;
|
||||||
@@ -441,7 +527,11 @@ function drain(c, ball) {
|
|||||||
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
|
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
|
||||||
announce(c, 'LOCK LIBERO'); newBall(c); return;
|
announce(c, 'LOCK LIBERO'); newBall(c); return;
|
||||||
}
|
}
|
||||||
|
payBonus(c);
|
||||||
if (c.table === 'future') returnClassic(c);
|
if (c.table === 'future') returnClassic(c);
|
||||||
|
c.tilt = { warnings: 0, active: false, last: -10 };
|
||||||
|
c.kickback = { left: false, right: false }; c.spinner.speed = 0;
|
||||||
|
c.skillShot = { lane: (c.skillShot.lane + 1) % 3, available: true, active: false, until: 0 };
|
||||||
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
|
c.balls--; c.saved = false; c.lastRamp = null; c.jackpotX = 1;
|
||||||
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
|
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
|
||||||
c.ball = null; c.step = 'result';
|
c.ball = null; c.step = 'result';
|
||||||
@@ -479,7 +569,7 @@ function collideSeg(c, ball, w) {
|
|||||||
// la gomma vera; solo a un urto nuovo, e solo se picchia.
|
// la gomma vera; solo a un urto nuovo, e solo se picchia.
|
||||||
const hit = hitSeg(ball, w, null);
|
const hit = hitSeg(ball, w, null);
|
||||||
if (!hit || hit.vn <= 0) return;
|
if (!hit || hit.vn <= 0) return;
|
||||||
if (hit.fresh && hit.vn > IMPACT) {
|
if (hit.fresh && hit.vn > IMPACT && !c.tilt.active) {
|
||||||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
|
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.24, s = 250 + random(c) * 30;
|
||||||
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
|
const tx = ball.vx + hit.vn * hit.nx, ty = ball.vy + hit.vn * hit.ny; // la parte lungo la sponda
|
||||||
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
|
ball.vx = Math.cos(a) * s + tx * 0.6; ball.vy = Math.sin(a) * s + ty * 0.6;
|
||||||
@@ -488,10 +578,15 @@ function collideSeg(c, ball, w) {
|
|||||||
}
|
}
|
||||||
function collideTarget(c, ball, t) {
|
function collideTarget(c, ball, t) {
|
||||||
const hit = hitSeg(ball, t, 0.5);
|
const hit = hitSeg(ball, t, 0.5);
|
||||||
|
if (c.tilt.active) return;
|
||||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||||||
|
c.bonus.targets++;
|
||||||
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
|
t.down = true; add(c, SCORE.target, [t.a[0] + (t.bank === 'pieve' ? 14 : -14), (t.a[1] + t.b[1]) / 2]); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); fx(c, 'spark', t.a[0], (t.a[1] + t.b[1]) / 2);
|
||||||
const bank = c.targets.filter(k => k.bank === t.bank);
|
const bank = c.targets.filter(k => k.bank === t.bank);
|
||||||
if (bank.every(k => k.down)) { c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
|
if (bank.every(k => k.down)) {
|
||||||
|
c.kickback[t.bank === 'pieve' ? 'left' : 'right'] = true;
|
||||||
|
say(c, t.bank === 'pieve' ? 'SALVA SIEVE SX' : 'SALVA SIEVE DX');
|
||||||
|
c.jackpotX = Math.min(3, c.jackpotX + 1); fx(c, 'pop', 320, 205, { text: `JACKPOT X${c.jackpotX}` }); add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'IL TREBBIO!'); c.resets[t.bank] = c.sim + BANK_RESET; fx(c, 'burst', t.a[0] + (t.bank === 'pieve' ? 20 : -20), 174); }
|
||||||
}
|
}
|
||||||
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
|
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
|
||||||
function hitCircle(ball, o, e) {
|
function hitCircle(ball, o, e) {
|
||||||
@@ -506,13 +601,14 @@ function hitCircle(ball, o, e) {
|
|||||||
function collideBumper(c, ball, b) {
|
function collideBumper(c, ball, b) {
|
||||||
const hit = hitCircle(ball, b, 0.2);
|
const hit = hitCircle(ball, b, 0.2);
|
||||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
||||||
|
if (c.tilt.active) return;
|
||||||
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
|
const a = Math.atan2(hit.ny, hit.nx) + (random(c) - 0.5) * 0.2;
|
||||||
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
|
ball.vx = Math.cos(a) * c.skill.kick; ball.vy = Math.sin(a) * c.skill.kick;
|
||||||
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
|
b.hit = 0.15; add(c, SCORE.bumper, [b.x, b.y - 16]); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); fx(c, 'ring', b.x, b.y); fx(c, 'spark', ball.x, ball.y);
|
||||||
}
|
}
|
||||||
function collideRegolo(c, ball) {
|
function collideRegolo(c, ball) {
|
||||||
const hit = hitCircle(ball, c.regolo, 0.7);
|
const hit = hitCircle(ball, c.regolo, 0.7);
|
||||||
if (!hit || !hit.fresh || hit.vn < IMPACT) return;
|
if (!hit || !hit.fresh || hit.vn < IMPACT || c.tilt.active) return;
|
||||||
if (c.table === 'future' && Object.values(c.future.hits).every(n => n === 2)) { c.future.won = true; return; }
|
if (c.table === 'future' && Object.values(c.future.hits).every(n => n === 2)) { c.future.won = true; return; }
|
||||||
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
|
c.regolo.hit = 0.3; add(c, SCORE.regolo, [c.regolo.x, c.regolo.y - 14]); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); fx(c, 'burst', c.regolo.x, c.regolo.y); fx(c, 'ring', c.regolo.x, c.regolo.y);
|
||||||
}
|
}
|
||||||
@@ -534,10 +630,19 @@ function collideFlipper(c, ball, side) {
|
|||||||
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
|
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
|
||||||
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
|
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
|
||||||
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
|
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
|
||||||
|
// Un tocco (Andrea, 1 novembre: «il pollice destro sulla metà destra, il sinistro sulla sinistra, al centro per lanciare»):
|
||||||
|
// al centro della scena, a pallina ferma, carica la molla; ai lati alza la paletta di quel lato, anche prima del lancio.
|
||||||
|
// La tastiera ('key': Spazio, freccia giù) carica sempre.
|
||||||
|
export const PLUNGER_ZONE = 64; // mezza larghezza della fascia centrale, in pixel di scena (256-384 su 640)
|
||||||
export function press(c, pointerId = 'ptr') {
|
export function press(c, pointerId = 'ptr') {
|
||||||
if (c.step !== 'play') return false;
|
if (c.step !== 'play') return false;
|
||||||
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; }
|
const x = c.cursor?.[0] ?? 320;
|
||||||
return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true);
|
const y = c.cursor?.[1] ?? 0;
|
||||||
|
if (pointerId !== 'key' && pointerId !== 'mouse-right' && x >= NUDGE_ZONE.x && x <= NUDGE_ZONE.x + NUDGE_ZONE.w && y >= NUDGE_ZONE.y && y <= NUDGE_ZONE.y + NUDGE_ZONE.h) return nudge(c);
|
||||||
|
if (waiting(c) && pointerId !== 'mouse-right' && (pointerId === 'key' || Math.abs(x - 320) < PLUNGER_ZONE)) {
|
||||||
|
if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true;
|
||||||
|
}
|
||||||
|
return hold(c, pointerId === 'mouse-right' || x >= 320 ? 'right' : 'left', pointerId, true);
|
||||||
}
|
}
|
||||||
export function release(c, pointerId = 'ptr') {
|
export function release(c, pointerId = 'ptr') {
|
||||||
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
|
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
|
||||||
@@ -548,6 +653,7 @@ export function release(c, pointerId = 'ptr') {
|
|||||||
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
|
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
|
||||||
export function cancel(c, pointerId) {
|
export function cancel(c, pointerId) {
|
||||||
const all = pointerId === undefined;
|
const all = pointerId === undefined;
|
||||||
|
if (all) c.nudgeHeld = false;
|
||||||
if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; }
|
if (all || c.pointerId === pointerId) { c.holding = false; c.holdTime = 0; c.pointerId = null; }
|
||||||
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
for (const f of Object.values(c.flippers)) f.held = all ? [] : f.held.filter(id => id !== pointerId);
|
||||||
return true;
|
return true;
|
||||||
@@ -559,11 +665,33 @@ export function stopTurn(c, who = 'button') {
|
|||||||
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
|
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
|
||||||
return l || r;
|
return l || r;
|
||||||
}
|
}
|
||||||
export const nudge = () => false;
|
export function nudge(c, dir = 0) {
|
||||||
|
if (c.step !== 'play' || waiting(c) || c.transform || c.tilt.active || c.sim - c.tilt.last < SHOW.nudgeCooldown) return false;
|
||||||
|
c.tilt.last = c.sim; c.tilt.warnings++;
|
||||||
|
fx(c, 'shake', 0, 0);
|
||||||
|
if (c.tilt.warnings >= 3) {
|
||||||
|
c.tilt.active = true; c.saveUntil = 0; c.skillShot.active = false;
|
||||||
|
c.locks = []; c.special = null; c.superOpenedAt = null; c.spinner.speed = 0;
|
||||||
|
cancel(c); announce(c, 'TILT! BONUS PERSO');
|
||||||
|
c.events.push({ kind: 'flipper-tilt', power: 1 });
|
||||||
|
} else {
|
||||||
|
for (const b of activeBalls(c)) if (!b.waiting && !b.ramp && b.releaseAt <= c.sim) {
|
||||||
|
b.vx += clamp(dir, -1, 1) * 85; b.vy -= 105; limit(b);
|
||||||
|
}
|
||||||
|
announce(c, `ATTENTO! ${c.tilt.warnings}/2`);
|
||||||
|
c.events.push({ kind: 'flipper-nudge', power: 0.7 });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
|
export function setPower(c, v) { if (!canThrow(c) || !Number.isFinite(v)) return false; c.power = clamp(v, 0, 1); return true; }
|
||||||
export function key(c, k, down) {
|
export function key(c, k, down) {
|
||||||
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
if (c.step !== 'play') return false; // a partita finita Spazio e Invio chiudono il risultato (main.js)
|
||||||
if (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); return true; }
|
if (['ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight'].includes(k)) { hold(c, k.endsWith('Left') ? 'left' : 'right', 'key-' + k, down); return true; }
|
||||||
|
if (k === 'ArrowUp' || k.toLowerCase() === 'n') {
|
||||||
|
if (down && !c.nudgeHeld) { nudge(c); c.nudgeHeld = true; }
|
||||||
|
if (!down) c.nudgeHeld = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// La molla: Spazio, o la freccia giù (si tira indietro come la molla vera).
|
// La molla: Spazio, o la freccia giù (si tira indietro come la molla vera).
|
||||||
if (k === ' ' || k === 'ArrowDown') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
|
if (k === ' ' || k === 'ArrowDown') { if (down) { if (!c.holding && waiting(c)) press(c, 'key'); } else if (c.holding && c.pointerId === 'key') release(c, 'key'); return true; }
|
||||||
if (k === 'Enter') { if (down) shoot(c); return true; }
|
if (k === 'Enter') { if (down) shoot(c); return true; }
|
||||||
@@ -588,8 +716,9 @@ export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia
|
|||||||
export function status(c) {
|
export function status(c) {
|
||||||
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
if (c.step === 'result') return `${c.result.score} punti. ${c.result.winner === 'player' ? `Traguardo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
||||||
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
|
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
|
||||||
const feature = c.transform ? 'SUPER SPECIAL! Il piano si apre: arriva il Regolo del futuro.' : c.table === 'future' ? `FUTURO: Orbita ${c.future.hits.orbita}/2 · Ponte ${c.future.hits.ponte}/2 · Spira ${c.future.hits.spira}/2. Poi Regolo mobile: +500000 e ritorno. Ultima pallina persa: ritorno, −1 vita.` : c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
|
const feature = c.transform ? 'SUPER SPECIAL! Il piano si apre: arriva il Regolo del futuro.' : c.table === 'future' ? `FUTURO: Orbita ${c.future.hits.orbita}/2 · Ponte ${c.future.hits.ponte}/2 · Spira ${c.future.hits.spira}/2. Poi 30 secondi per il Regolo mobile: +500000 e ritorno. Ultima pallina persa: ritorno, −1 vita.` : c.special === 'super' ? 'SUPER SPECIAL aperta al centro: pallina premio e multiball!' : multiball(c) ? `MULTIBALL: ${activeBalls(c).length} palline. Le rampe valgono JACKPOT!` : c.locks.length ? `LOCK ${c.locks.length}/2. Completa tre passaggi sulla rampa ${c.special} per aprire la SUPER SPECIAL.` : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3, restano ${Math.ceil(c.rampRun.until - c.sim)} secondi.` : 'Tre passaggi sulla stessa rampa in 20 secondi: LOCK.';
|
||||||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
|
const extras = c.tilt.active ? ' TILT: palette spente, bonus perso.' : ` Bonus ×${c.bonus.x}. Spintarella: N, ↑ o SCUOTI; terza scossa: tilt.`;
|
||||||
|
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}${extras}`;
|
||||||
if (c.line) return `${c.line} ${tally}`;
|
if (c.line) return `${c.line} ${tally}`;
|
||||||
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
|
return waiting(c) ? `Tieni premuto per caricare la molla e lascia, o premi Lancia. ${tally}` : `Flipper: metà sinistra e destra della scena, o i pulsanti. ${tally}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ export class Soundscape {
|
|||||||
else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); }
|
else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); }
|
||||||
else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan });
|
else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan });
|
||||||
// Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco.
|
// Il flipper: la molla, il colpo del flipper, il fungo, la sponda elastica, il bersaglio, la corsia, il Palio, il buco.
|
||||||
|
else if (kind === 'flipper-spinner') { this.noiseSound(0.018, 3200, 0.025, { pan }); this.tone(1900, 950, 0.025, 0.035, 0, pan); }
|
||||||
|
else if (kind === 'flipper-skill') { for (const [i, f] of [659, 880, 1109, 1760].entries()) this.tone(f, f, 0.2, 0.06, i * 0.09, pan); }
|
||||||
|
else if (kind === 'flipper-knocker') { this.noiseSound(0.045, 650, 0.12, { pan }); this.tone(95, 42, 0.08, 0.11, 0, pan); }
|
||||||
|
else if (kind === 'flipper-nudge') { this.noiseSound(0.07, 400, 0.06, { pan }); this.tone(180, 120, 0.12, 0.06, 0, pan); }
|
||||||
|
else if (kind === 'flipper-tilt') { this.tone(165, 55, 0.8, 0.08, 0, pan); this.tone(173, 58, 0.8, 0.05, 0, pan); }
|
||||||
|
else if (kind === 'flipper-kickback') { this.noiseSound(0.08, 1400, 0.09, { pan }); this.tone(220, 1320, 0.3, 0.07, 0, pan); }
|
||||||
|
else if (kind === 'flipper-bonus') { for (let i = 0; i < 8; i++) this.tone(660 + i * 110, 660 + i * 110, 0.075, 0.04, i * 0.12, pan); }
|
||||||
|
else if (kind === 'flipper-risveglio') { this.noiseSound(1, 220, 0.09, { pan }); this.tone(55, 170, 1.4, 0.09, 0, pan); for (const [i, f] of [220, 330, 440, 660].entries()) this.tone(f, f, 0.5, 0.045, 0.3 + i * 0.15, pan); }
|
||||||
|
else if (kind === 'flipper-countdown') { this.tone(440 + strength * 660, 440 + strength * 660, 0.12, 0.055, 0, pan); this.tone(110, 55, 0.15, 0.055, 0.15, pan); }
|
||||||
else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); }
|
else if (kind === 'flipper-lancio') { this.noiseSound(0.12, 1800, 0.05 * strength, { pan }); this.tone(180, 600, 0.12, 0.05, 0, pan); }
|
||||||
else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); }
|
else if (kind === 'flipper-flip') { this.tone(240, 120, 0.05, 0.07, 0, pan); this.noiseSound(0.03, 2200, 0.04, { pan }); }
|
||||||
else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); }
|
else if (kind === 'flipper-fungo') { this.tone(900, 500, 0.06, 0.06 * strength + 0.02, 0, pan); this.tone(1800, 900, 0.04, 0.03, 0, pan); }
|
||||||
|
|||||||
+55
-8
@@ -59,6 +59,7 @@ function showSound() {
|
|||||||
$('effects-volume').value = Math.round(ambience.volume * 100);
|
$('effects-volume').value = Math.round(ambience.volume * 100);
|
||||||
}
|
}
|
||||||
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true });
|
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.
|
// Il muto zittisce subito anche la voce che sta parlando.
|
||||||
const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
|
const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
|
||||||
$('mute').addEventListener('click', toggleMute);
|
$('mute').addEventListener('click', toggleMute);
|
||||||
@@ -399,16 +400,52 @@ addEventListener('pagehide', () => leave(true));
|
|||||||
addEventListener('pageshow', () => leave(document.hidden));
|
addEventListener('pageshow', () => leave(document.hidden));
|
||||||
$('resume').addEventListener('click', closePause);
|
$('resume').addEventListener('click', closePause);
|
||||||
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
||||||
// Schermo intero dalla Pausa, dove il browser lo permette (su iPhone no: il pulsante sparisce); Esc o il pulsante per uscire.
|
// I comandi di un minigioco (molla, pulsanti, carte, «Come si gioca») sono più alti del pannello dei verbi: con quelli
|
||||||
|
// aperti la scena lascia intorno lo spazio che occupano davvero, fasce comprese (--below, misurato), così su uno schermo grande o a schermo
|
||||||
|
// intero non finiscono sotto il bordo. Senza comandi vale la riserva del pannello in unità (style.css, #app).
|
||||||
|
// La misura cambia la scena solo per differenze grandi: una frase di stato che va a capo (PONTE SX al flipper) non deve
|
||||||
|
// ridimensionarla a ogni colpo, che a schermo sembra un piano che salta di qualche pixel (Andrea, 1 novembre). E «Come si
|
||||||
|
// gioca» aperto non conta: se lo si apre, la pagina scorre invece di stringere la scena (e poi non torna larga).
|
||||||
|
function fitControls() {
|
||||||
|
const app = $('app'), open = !!document.querySelector('#side > section[id$="-controls"]:not([hidden])');
|
||||||
|
if (!app.classList.toggle('controls', open)) { app.style.removeProperty('--below'); return; }
|
||||||
|
// Tutto quello che non è la scena 16:9: le fasce di audio, pausa e uscita e la pulsantiera (dalla larghezza, non
|
||||||
|
// dalla tela, che mentre cambia risoluzione può avere per un attimo un'altezza qualunque).
|
||||||
|
const box = app.getBoundingClientRect(), help = document.querySelector('#side details[id$="-help"][open]');
|
||||||
|
const extra = help ? help.getBoundingClientRect().height - help.querySelector('summary').getBoundingClientRect().height : 0;
|
||||||
|
const below = Math.ceil(box.height - box.width * 9 / 16 - extra), was = parseFloat(app.style.getPropertyValue('--below'));
|
||||||
|
// Cresce subito (niente deve finire sotto il bordo), cala solo per differenze grandi (niente salti a ogni frase).
|
||||||
|
if (!(below <= was + 1 && below >= was - 24)) app.style.setProperty('--below', `${below}px`);
|
||||||
|
}
|
||||||
|
// «Solo tavola» al flipper (Andrea, 1 novembre): nasconde la pulsantiera, la tavola prende tutta l'altezza; il browser
|
||||||
|
// se lo ricorda (sul telefono in orizzontale la pulsantiera del flipper è già nascosta, style.css).
|
||||||
|
let tableOnly = (() => { try { return storage?.getItem('regolo-solo-tavola') === '1'; } catch { return false; } })();
|
||||||
|
$('table-only').addEventListener('click', e => {
|
||||||
|
tableOnly = !tableOnly;
|
||||||
|
try { storage?.setItem('regolo-solo-tavola', tableOnly ? '1' : '0'); } catch { /* niente memoria: vale per questa volta */ }
|
||||||
|
e.currentTarget.blur();
|
||||||
|
});
|
||||||
|
// Schermo intero, dove il browser lo permette (su iPhone no: i pulsanti spariscono): dalla Pausa, dal pulsante accanto ai
|
||||||
|
// volumi (anche sul titolo) o col tasto F; Esc, il pulsante o F per uscire.
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenEnabled) return false;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const full = $('fullscreen');
|
const full = $('fullscreen'), hud = $('fullscreen-hud');
|
||||||
if (!document.fullscreenEnabled) full.hidden = true;
|
if (!document.fullscreenEnabled) full.hidden = hud.hidden = true;
|
||||||
else {
|
else {
|
||||||
full.addEventListener('click', () => {
|
full.addEventListener('click', toggleFullscreen);
|
||||||
if (document.fullscreenElement) document.exitFullscreen();
|
hud.addEventListener('click', e => { toggleFullscreen(); e.currentTarget.blur(); });
|
||||||
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const on = !!document.fullscreenElement;
|
||||||
|
full.textContent = on ? 'Esci da schermo intero' : 'Schermo intero';
|
||||||
|
hud.setAttribute('aria-label', on ? 'Esci da schermo intero' : 'Schermo intero');
|
||||||
|
hud.title = on ? 'Esci da schermo intero (F o Esc)' : 'Schermo intero (F)';
|
||||||
|
hud.setAttribute('aria-pressed', on);
|
||||||
});
|
});
|
||||||
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement ? 'Esci da schermo intero' : 'Schermo intero'; });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$('to-title').addEventListener('click', () => {
|
$('to-title').addEventListener('click', () => {
|
||||||
@@ -664,7 +701,7 @@ const MINI = {
|
|||||||
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
|
||||||
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
|
||||||
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
|
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
|
||||||
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi colpisci il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro; comandi invariati.' },
|
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi hai 30 secondi per colpire il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro. Skill shot sulla corsia lampeggiante: 25.000. Spinner: 500 a giro. Completa un banco per il salvataggio laterale. Bonus a fine pallina fino a ×5. N, freccia su o SCUOTI: spintarella; terza scossa nella stessa vita: TILT, palette spente e bonus perso.' },
|
||||||
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
|
||||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||||
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
|
||||||
@@ -838,6 +875,8 @@ window.addEventListener('keydown', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (debugKey(e)) return;
|
if (debugKey(e)) return;
|
||||||
|
// F: schermo intero ovunque (nessun gioco usa la F; nella ricerca della mappa si scrive, sopra).
|
||||||
|
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat && toggleFullscreen()) { e.preventDefault(); return; }
|
||||||
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
|
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
|
||||||
if (!$('credits').hidden) {
|
if (!$('credits').hidden) {
|
||||||
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
|
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
|
||||||
@@ -1170,6 +1209,14 @@ function updateUi() {
|
|||||||
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
|
||||||
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
|
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
|
||||||
$('stage').classList.toggle('briscola', !!cards);
|
$('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);
|
||||||
|
setText($('table-only'), tableOnly ? 'Pulsantiera' : 'Solo tavola');
|
||||||
|
fitControls();
|
||||||
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
|
||||||
// La pagina che comincia a girare fa il suo fruscio.
|
// La pagina che comincia a girare fa il suo fruscio.
|
||||||
const step = game.closeup?.step ?? null;
|
const step = game.closeup?.step ?? null;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.71';
|
export const VERSION = '0.1.74';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// 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).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -20,15 +20,25 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|||||||
button, input { font-family: var(--font); }
|
button, input { font-family: var(--font); }
|
||||||
body { line-height: 1.5; }
|
body { line-height: 1.5; }
|
||||||
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
||||||
/* Il gioco è largo quanto può: tutta la finestra fino a 1280px, oppure quanto ci sta in altezza con il pannello sotto la
|
/* Il gioco è largo quanto può: tutta la finestra (fino a 2560px: a schermo intero su un monitor grande), oppure quanto ci sta in altezza con il pannello sotto la
|
||||||
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
|
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
|
||||||
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 12,8px: tutto il pannello cresce e cala col gioco, non con
|
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 25,6px: tutto il pannello cresce e cala col gioco, non con
|
||||||
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
|
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
|
||||||
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
|
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
|
||||||
#app { --u: clamp(8.75px, 1cqw, 12.8px); --side-h: 13.3; container-type: inline-size;
|
#app { --u: clamp(8.75px, 1cqw, 25.6px); --side-h: 13.3; container-type: inline-size;
|
||||||
width: min(100vw, 1280px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
|
width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
|
||||||
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
||||||
|
|
||||||
|
/* Coi comandi di un minigioco aperti (main.js, fitControls) la scena lascia intorno il loro spazio vero, --below; ma non
|
||||||
|
scende sotto i 700px (nelle finestre basse si scorre un poco per arrivare ai comandi, come prima). */
|
||||||
|
@media (min-width: 601px) and (min-height: 521px) {
|
||||||
|
#app.controls { width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9),
|
||||||
|
max(calc((100dvh - var(--below, 0px)) * 16 / 9), 700px)); }
|
||||||
|
/* «Solo tavola» (Andrea): la pulsantiera del flipper nascosta, la tavola a tutta altezza. */
|
||||||
|
#app.controls.table-only { width: min(100vw, 2560px, calc((100dvh - var(--below, 88px)) * 16 / 9)); }
|
||||||
|
#app.table-only #side { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||||
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
#scene.pinball-hd { image-rendering: auto; }
|
#scene.pinball-hd { image-rendering: auto; }
|
||||||
@@ -54,7 +64,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#poker-controls summary, #briscola-controls summary { cursor: pointer; padding: 10px 0; }
|
#poker-controls summary, #briscola-controls summary { cursor: pointer; padding: 10px 0; }
|
||||||
#poker-controls p, #briscola-controls p { max-width: 76em; }
|
#poker-controls p, #briscola-controls p { max-width: 76em; }
|
||||||
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
|
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
|
||||||
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
|
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 3em; } /* due righe: la scena non salta quando va a capo */
|
||||||
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||||
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
||||||
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
||||||
@@ -113,6 +123,8 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
|
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
|
||||||
#volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
|
#volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
|
||||||
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
|
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
|
||||||
|
#fullscreen-hud { width: 30px; height: 30px; padding: 0; font: bold 17px system-ui, sans-serif; line-height: 1; color: var(--accent); background: none; border: 0; cursor: pointer; }
|
||||||
|
#fullscreen-hud[aria-pressed="true"] { color: var(--blue); }
|
||||||
|
|
||||||
/* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
|
/* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
|
||||||
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
|
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
|
||||||
@@ -239,7 +251,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
||||||
/* In alto a sinistra: Pausa e Mappa. */
|
/* In alto a sinistra: Pausa e Mappa. */
|
||||||
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
|
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
|
||||||
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
|
#map-button, #pause-button, #notebook-button, #spots-button, #back-button, #table-only { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
|
||||||
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
||||||
#back-button { color: #1b1420; background: #9fd0ff; }
|
#back-button { color: #1b1420; background: #9fd0ff; }
|
||||||
/* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
|
/* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
|
||||||
@@ -355,6 +367,12 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
#app.presenta { justify-content: center; }
|
#app.presenta { justify-content: center; }
|
||||||
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
|
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
|
||||||
|
/* Il flipper sul telefono (Andrea, 1 novembre): pollice sinistro sulla metà sinistra della scena, destro sulla destra,
|
||||||
|
al centro la molla. La scena prende tutta la larghezza e la pulsantiera sparisce: punti e palline sono sulla tavola. */
|
||||||
|
#app.flipper { justify-content: center; }
|
||||||
|
#app.flipper #side, #app.flipper #table-only { display: none; }
|
||||||
|
#app.flipper #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
|
||||||
|
#app.flipper #stage.biliardo #closeup-close { position: absolute; top: 6px; bottom: auto; left: 86px; right: auto; margin: 0; min-height: 0; height: 30px; padding: 0 .9em; font-size: 10px; }
|
||||||
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
|
||||||
#briscola-controls, #tressette-controls, #scopa-controls, #poker-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
#briscola-controls, #tressette-controls, #scopa-controls, #poker-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
|
||||||
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
|
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
|
||||||
|
|||||||
@@ -508,6 +508,22 @@ try {
|
|||||||
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
|
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
|
||||||
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
|
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
|
||||||
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
|
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
|
||||||
|
// Su uno schermo grande (1920×1080, a schermo intero) il gioco non si ferma più a 1280 px: prende tutta l'altezza.
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
|
await page.waitForFunction(() => document.getElementById('stage').getBoundingClientRect().width > 1500);
|
||||||
|
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight && document.getElementById('app').getBoundingClientRect().height >= innerHeight - 2), 'a 1920×1080 il gioco prende tutta l\'altezza, senza scorrere');
|
||||||
|
await page.setViewportSize({ width: 1280, height: 860 });
|
||||||
|
// Schermo intero: il pulsante accanto ai volumi e il tasto F, per entrare e per uscire.
|
||||||
|
if (await page.evaluate(() => document.fullscreenEnabled)) {
|
||||||
|
await page.click('#fullscreen-hud');
|
||||||
|
await page.waitForFunction(() => !!document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'true');
|
||||||
|
await page.keyboard.press('f');
|
||||||
|
await page.waitForFunction(() => !document.fullscreenElement);
|
||||||
|
await page.keyboard.press('F');
|
||||||
|
await page.waitForFunction(() => !!document.fullscreenElement);
|
||||||
|
await page.click('#fullscreen-hud');
|
||||||
|
await page.waitForFunction(() => !document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'false');
|
||||||
|
} else assert.ok(await page.evaluate(() => document.getElementById('fullscreen-hud').hidden), 'senza schermo intero il pulsante sparisce');
|
||||||
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await page.waitForSelector('#pause:not([hidden])');
|
await page.waitForSelector('#pause:not([hidden])');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
+80
-11
@@ -25,24 +25,69 @@ try {
|
|||||||
game.act('use', 'river'); settle(game);
|
game.act('use', 'river'); settle(game);
|
||||||
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
|
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
|
||||||
});
|
});
|
||||||
await page.waitForSelector('#bocce-controls:not([hidden])');
|
await page.waitForSelector('#bocce-controls:not([hidden])', { state: 'attached' });
|
||||||
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.
|
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
|
||||||
const readyAt = await page.evaluate(() => game.closeup.visual.time);
|
const readyAt = await page.evaluate(() => game.closeup.visual.time);
|
||||||
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
|
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
|
||||||
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
|
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
|
||||||
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());
|
if (!mobile) {
|
||||||
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
|
// «Solo tavola»: la pulsantiera sparisce e la tavola si allarga; ripremuto, torna tutto com'era.
|
||||||
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
|
const before = (await page.locator('#scene').boundingBox()).width;
|
||||||
|
await page.click('#table-only');
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display === 'none');
|
||||||
|
await page.waitForFunction(w => document.getElementById('scene').getBoundingClientRect().width > w + 50, before);
|
||||||
|
assert.equal(await page.locator('#table-only').textContent(), 'Pulsantiera');
|
||||||
|
await page.click('#table-only');
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display !== 'none');
|
||||||
|
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');
|
||||||
|
} else {
|
||||||
|
// Sul telefono in orizzontale (Andrea): niente pulsantiera, la scena larga sotto i due pollici.
|
||||||
|
assert.ok(!(await page.locator('#side').isVisible()), 'sul telefono la pulsantiera del flipper sparisce');
|
||||||
|
const w = (await page.locator('#scene').boundingBox()).width;
|
||||||
|
assert.ok(w > viewport.width * 0.6, `sul telefono la scena del flipper è larga ${w} px`);
|
||||||
|
}
|
||||||
await page.screenshot({ path: `${out}/${name}-tavola.png` });
|
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];
|
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 } });
|
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 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.
|
||||||
if (mobile) {
|
if (mobile) {
|
||||||
await touch('touchStart', [P(440, 320)]);
|
await touch('touchStart', [P(320, 320)]); // al centro: la molla
|
||||||
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
|
||||||
await touch('touchEnd', []);
|
await touch('touchEnd', []);
|
||||||
} else {
|
} else {
|
||||||
@@ -63,12 +108,6 @@ try {
|
|||||||
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
|
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
|
||||||
await touch('touchEnd', []);
|
await touch('touchEnd', []);
|
||||||
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
|
||||||
// I pulsanti tenuti premuti.
|
|
||||||
const b = await page.locator('#bocce-left').boundingBox();
|
|
||||||
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]);
|
|
||||||
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
|
|
||||||
await touch('touchEnd', []);
|
|
||||||
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
|
|
||||||
} else {
|
} else {
|
||||||
await page.mouse.move(...P(400, 200)); await page.mouse.down();
|
await page.mouse.move(...P(400, 200)); await page.mouse.down();
|
||||||
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
|
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
|
||||||
@@ -93,6 +132,36 @@ try {
|
|||||||
await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft');
|
await page.keyboard.up('ShiftRight'); await page.keyboard.up('ShiftLeft');
|
||||||
await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length);
|
await page.waitForFunction(() => !game.closeup.flippers.left.held.length && !game.closeup.flippers.right.held.length);
|
||||||
}
|
}
|
||||||
|
// Nuove regole sul canvas reale: skill shot, fuochi e SCUOTI anche senza pulsantiera.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const m = await import('/src/engine/flipper.js');
|
||||||
|
window.showState = structuredClone(game.closeup); window.showUpdate = game.update; game.update = () => {};
|
||||||
|
const c = game.closeup; Object.assign(c, m.newFlipper(1, { level: c.level })); m.launch(c, 0.3);
|
||||||
|
Object.assign(c.ball, { x: c.lanes[c.skillShot.lane].x, y: 103, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||||
|
if (!c.events.some(e => e.kind === 'flipper-skill')) throw new Error('skill shot non premiato');
|
||||||
|
c.ball.waiting = true; c.kickback.left = true; c.spinner.speed = 30;
|
||||||
|
for (let i = 0; i < 3; i++) m.update(c, 0.25);
|
||||||
|
Object.assign(c.ball, { waiting: false, x: 320, y: 250, vx: 0, vy: 0 });
|
||||||
|
// Anche a simulazione ferma, lascia finire il conteggio animato del display.
|
||||||
|
(await import('/src/engine/render.js')).render(document.querySelector('#scene').getContext('2d'), game, { time: performance.now() });
|
||||||
|
c.visual.time += 0.6;
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `${out}/${name}-fuochi.png` });
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await page.evaluate(() => { game.closeup.sim += 1; game.closeup.ball.releaseAt = 0; });
|
||||||
|
if (mobile) {
|
||||||
|
const b = await page.locator('#scene').boundingBox();
|
||||||
|
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height * 347 / 360]]); await touch('touchEnd', []);
|
||||||
|
} else await page.keyboard.press('n');
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.tilt.warnings), i);
|
||||||
|
}
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.tilt.active), true);
|
||||||
|
await page.keyboard.down('ArrowLeft');
|
||||||
|
assert.equal(await page.evaluate(() => game.closeup.flippers.left.held.length), 0);
|
||||||
|
await page.keyboard.up('ArrowLeft');
|
||||||
|
await page.screenshot({ path: `${out}/${name}-tilt.png` });
|
||||||
|
await page.evaluate(() => { Object.assign(game.closeup, window.showState); game.update = window.showUpdate; delete window.showState; delete window.showUpdate; });
|
||||||
|
assert.deepEqual(errors, [], 'nessun errore durante skill shot e tilt');
|
||||||
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
// Il traguardo aggiunge una pallina e lascia continuare la partita.
|
||||||
const reward = await page.evaluate(async () => {
|
const reward = await page.evaluate(async () => {
|
||||||
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
const m = await import('/src/engine/flipper.js'), c = game.closeup;
|
||||||
|
|||||||
+114
-3
@@ -130,13 +130,17 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
|
|||||||
assert.ok(!c.lanes[1].lit);
|
assert.ok(!c.lanes[1].lit);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => {
|
test('comandi: la molla si carica al centro e si lancia lasciando, ai lati le palette anche prima del lancio; annullare non lancia; due dita, pulsanti e tasti', () => {
|
||||||
const c = m.newFlipper(8, { level: 'bona' });
|
const c = m.newFlipper(8, { level: 'bona' });
|
||||||
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
|
// A pallina ferma i lati alzano le palette, il centro carica la molla.
|
||||||
|
m.aimScreen(c, [120, 300]); assert.ok(m.press(c, 5)); assert.ok(c.flippers.left.held.includes(5) && !c.holding, 'a sinistra, la paletta');
|
||||||
|
m.aimScreen(c, [520, 300]); assert.ok(m.press(c, 6)); assert.ok(c.flippers.right.held.includes(6) && !c.holding, 'a destra, la paletta');
|
||||||
|
m.release(c, 5); m.release(c, 6); assert.ok(m.waiting(c));
|
||||||
|
m.aimScreen(c, [320, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
|
||||||
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
|
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
|
||||||
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
|
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
|
||||||
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
|
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
|
||||||
m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
|
m.aimScreen(c, [300, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
|
||||||
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
|
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
|
||||||
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
|
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
|
||||||
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
|
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
|
||||||
@@ -589,3 +593,110 @@ test('per provare, openSuper fa davvero i due LOCK e apre la SUPER SPECIAL (main
|
|||||||
assert.ok(m.openSuper(c));
|
assert.ok(m.openSuper(c));
|
||||||
assert.equal(c.special, 'super'); assert.equal(c.locks.length, 2); assert.equal(c.table, 'classic');
|
assert.equal(c.special, 'super'); assert.equal(c.locks.length, 2); assert.equal(c.table, 'classic');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('skill shot: il primo passaggio paga una volta, anche su corsia già accesa; il ball save non lo rinnova', () => {
|
||||||
|
const c = m.newFlipper(1); m.launch(c, 0.2);
|
||||||
|
const lane = c.lanes[c.skillShot.lane]; lane.lit = true;
|
||||||
|
put(c, { x: lane.x, y: 103, vx: 0, vy: 300 });
|
||||||
|
const score = c.score; m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score - score, m.SHOW.skill);
|
||||||
|
assert.equal(c.skillShot.available, false);
|
||||||
|
c.saveUntil = c.sim + 2;
|
||||||
|
put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||||
|
m.launch(c, 0.2); assert.equal(c.skillShot.active, false);
|
||||||
|
put(c, { x: lane.x, y: 103, vx: 0, vy: 300 });
|
||||||
|
const again = c.score; m.update(c, m.STEP); assert.equal(c.score, again);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le corsie scelte per lo skill shot si raggiungono con la molla a ogni difficoltà', () => {
|
||||||
|
for (const level of Object.keys(m.LEVELS)) {
|
||||||
|
const reached = new Set();
|
||||||
|
for (let power = 0; power <= 100; power++) {
|
||||||
|
const c = m.newFlipper(42, { level }); m.launch(c, power / 100);
|
||||||
|
for (let i = 0; i < 900 && c.skillShot.available; i++) m.update(c, m.STEP);
|
||||||
|
c.lanes.forEach((l, i) => { if (l.lit) reached.add(i); });
|
||||||
|
}
|
||||||
|
for (let seed = 0; seed < 6; seed++) assert.ok(reached.has(m.newFlipper(seed).skillShot.lane), `${level}: corsia raggiungibile`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spinner: un attraversamento reale lo accelera, ogni giro paga e poi si ferma', () => {
|
||||||
|
const c = m.newFlipper(3); m.launch(c, 0.5); c.skillShot.active = false;
|
||||||
|
put(c, { x: m.SPINNER.x, y: m.SPINNER.y + 1, vx: 0, vy: -400 }); m.update(c, m.STEP);
|
||||||
|
assert.ok(c.spinner.speed > 15);
|
||||||
|
c.ball.waiting = true; const before = c.score; run(c, 4);
|
||||||
|
assert.ok(c.score > before); assert.equal((c.score - before) % m.SHOW.spinner, 0);
|
||||||
|
assert.equal(c.spinner.speed, 0);
|
||||||
|
const stopped = c.score; run(c, 1); assert.equal(c.score, stopped);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('i banchi accendono i kickback, ogni respingente salva una sola uscita laterale', () => {
|
||||||
|
for (const [bank, side, x] of [['pieve', 'left', 207], ['torre', 'right', 426]]) {
|
||||||
|
const c = m.newFlipper(25); m.launch(c, 0.8);
|
||||||
|
for (const t of c.targets.filter(t => t.bank === bank)) {
|
||||||
|
const dir = bank === 'pieve' ? -1 : 1;
|
||||||
|
put(c, { x: t.a[0] - dir * 5, y: (t.a[1] + t.b[1]) / 2, vx: dir * 200, vy: 0, contacts: [] }); run(c, 0.02);
|
||||||
|
}
|
||||||
|
assert.equal(c.kickback[side], true);
|
||||||
|
const lives = c.balls;
|
||||||
|
put(c, { x, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP);
|
||||||
|
assert.ok(c.ball.vy < -500); assert.equal(c.kickback[side], false); assert.equal(c.balls, lives);
|
||||||
|
put(c, { x, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP); assert.ok(c.ball.vy > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scosse: due avvertimenti, autorepeat ignorato, tilt disattiva palette punti e bonus fino alla prossima vita', () => {
|
||||||
|
const c = m.newFlipper(17); assert.equal(m.nudge(c), false); m.launch(c, 0.7);
|
||||||
|
put(c, { x: 320, y: 260, vx: 0, vy: 0 });
|
||||||
|
m.key(c, 'n', true); m.key(c, 'n', true); assert.equal(c.tilt.warnings, 1); assert.ok(c.ball.vy < 0);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
m.key(c, 'n', false); c.sim += 1; m.key(c, 'n', true);
|
||||||
|
}
|
||||||
|
assert.equal(c.tilt.active, true); assert.equal(m.hold(c, 'left', 'test', true), false);
|
||||||
|
c.bonus.lanes = 3; c.bonus.x = 2; c.kickback.left = true;
|
||||||
|
const score = c.score, lives = c.balls;
|
||||||
|
const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 4, vx: 0, vy: -200, contacts: [] }); m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score, score);
|
||||||
|
put(c, { x: 207, y: 274, vx: 0, vy: 400 }); m.update(c, m.STEP); assert.ok(c.ball.vy > 0);
|
||||||
|
put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score, score); assert.equal(c.bonusPaid.lost, true); assert.equal(c.bonusPaid.total, 0);
|
||||||
|
assert.equal(c.balls, lives - 1); assert.equal(c.tilt.active, false); assert.equal(c.tilt.warnings, 0);
|
||||||
|
assert.ok(m.launch(c, 0.7)); assert.ok(m.hold(c, 'left', 'test', true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il bonus si paga una sola volta alla perdita della vita, senza consumarlo sui ball save', () => {
|
||||||
|
const c = m.newFlipper(1); m.launch(c, 0.5);
|
||||||
|
c.bonus = { lanes: 2, targets: 3, ramps: 1, x: 2 };
|
||||||
|
const expected = 19000, score = c.score;
|
||||||
|
assert.equal(m.bonusValue(c), expected);
|
||||||
|
c.saveUntil = c.sim + 2; put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score, score); assert.equal(m.bonusValue(c), expected);
|
||||||
|
m.launch(c, 0.5); put(c, { x: 320, y: 348, vx: 0, vy: 300 }); m.update(c, m.STEP);
|
||||||
|
assert.equal(c.score - score, expected); assert.equal(c.bonusPaid.total, expected); assert.equal(m.bonusValue(c), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il risveglio dura trenta secondi di simulazione e alla scadenza lascia un giro per sigillo da rifare', () => {
|
||||||
|
const c = inFuture();
|
||||||
|
for (const id of ['orbita', 'ponte', 'spira']) { rampShot(c, id); rampShot(c, id); }
|
||||||
|
assert.ok(c.future.awakeUntil > c.sim);
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'flipper-risveglio'));
|
||||||
|
for (const b of m.activeBalls(c)) b.releaseAt = c.sim + 100;
|
||||||
|
const score = c.score, lives = c.balls;
|
||||||
|
run(c, 30.1);
|
||||||
|
assert.equal(c.future.awakeUntil, null);
|
||||||
|
assert.deepEqual(c.future.hits, { orbita: 1, ponte: 1, spira: 1 });
|
||||||
|
assert.equal(c.balls, lives); assert.equal(c.score, score);
|
||||||
|
c.ball.releaseAt = 0;
|
||||||
|
for (const id of ['orbita', 'ponte', 'spira']) rampShot(c, id);
|
||||||
|
assert.ok(c.future.awakeUntil > c.sim);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SCUOTI non intercetta la molla da tastiera o il tasto destro del mouse', () => {
|
||||||
|
const c = m.newFlipper(10); m.aimScreen(c, [320, 347]);
|
||||||
|
m.key(c, ' ', true); assert.ok(c.holding); run(c, 0.2); m.key(c, ' ', false);
|
||||||
|
assert.ok(!m.waiting(c));
|
||||||
|
m.press(c, 'mouse-right'); assert.ok(c.flippers.right.held.includes('mouse-right'));
|
||||||
|
assert.equal(c.tilt.warnings, 0); m.release(c, 'mouse-right');
|
||||||
|
m.press(c, 'touch'); assert.equal(c.tilt.warnings, 1);
|
||||||
|
assert.equal(c.flippers.left.held.length + c.flippers.right.held.length, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,18 +13,32 @@ async function tap(page, mode, selector) {
|
|||||||
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
|
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
|
||||||
else await page.click(selector);
|
else await page.click(selector);
|
||||||
}
|
}
|
||||||
|
// Una battuta che compare proprio in quell'istante (le battute del giocatore partono dal ciclo del gioco) si prende il
|
||||||
|
// tasto e la salta, come nel gioco: allora, come un giocatore, si ripreme. Al più tre volte.
|
||||||
|
async function pressUntil(page, key, done, arg) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
if (await page.evaluate(done, arg)) return;
|
||||||
|
await page.waitForFunction(() => !game.speech);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function playCard(page, mode, index, signal) {
|
async function playCard(page, mode, index, signal) {
|
||||||
// Un tasto premuto mentre qualcuno parla salta la battuta e basta (come nel gioco): si aspetta che abbia finito.
|
// Un tasto premuto mentre qualcuno parla salta la battuta e basta, e il segnale si sceglie solo quando tocca a noi
|
||||||
if (mode === 'keyboard') await page.waitForFunction(() => !game.speech);
|
// (come nel gioco): si aspetta che la battuta sia finita e che si possa giocare.
|
||||||
|
if (mode === 'keyboard') {
|
||||||
|
await page.evaluate(async () => { window.tressetteRules ??= await import('/src/engine/tressette.js'); });
|
||||||
|
await page.waitForFunction(() => !game.speech && tressetteRules.canPlay(game.closeup));
|
||||||
|
}
|
||||||
if (signal) {
|
if (signal) {
|
||||||
if (mode === 'keyboard') await page.keyboard.press(signal[0]);
|
if (mode === 'keyboard') await pressUntil(page, signal[0], s => game.closeup.signal === s, signal);
|
||||||
else await tap(page, mode, `#tressette-${signal}`);
|
else await tap(page, mode, `#tressette-${signal}`);
|
||||||
assert.equal(await page.evaluate(() => game.closeup.signal), signal);
|
assert.equal(await page.evaluate(() => game.closeup.signal), signal);
|
||||||
}
|
}
|
||||||
if (mode === 'keyboard') {
|
if (mode === 'keyboard') {
|
||||||
await page.keyboard.press(String((index + 1) % 10));
|
await pressUntil(page, String((index + 1) % 10), i => game.closeup.selected === i, index);
|
||||||
assert.equal(await page.evaluate(() => game.closeup.selected), index);
|
assert.equal(await page.evaluate(() => game.closeup.selected), index);
|
||||||
await page.keyboard.press('Enter');
|
const held = await page.evaluate(() => game.closeup.hands[0].length);
|
||||||
|
await pressUntil(page, 'Enter', n => game.closeup.hands[0].length < n || !!game.closeup.refused, held);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const at = await page.evaluate(async i => {
|
const at = await page.evaluate(async i => {
|
||||||
|
|||||||
Reference in New Issue
Block a user