Compare commits

...
2 Commits
Author SHA1 Message Date
manzolo a71dfcd7e6 Versione 0.1.74
test / test (push) Successful in 37s
test / browser (bruciate) (push) Successful in 53s
test / browser (ambient) (push) Successful in 56s
test / browser (cerbottana) (push) Successful in 58s
test / browser (biliardo) (push) Successful in 1m0s
test / browser (campana) (push) Successful in 1m8s
test / browser (briscola) (push) Successful in 1m14s
test / browser (condor) (push) Successful in 30s
test / browser (flipper) (push) Successful in 48s
test / browser (gara) (push) Successful in 59s
test / browser (ingorgo) (push) Successful in 59s
test / browser (micro) (push) Successful in 1m3s
test / browser (risveglio) (push) Successful in 38s
test / browser (scopa) (push) Successful in 36s
test / browser (smarronata) (push) Successful in 36s
test / browser (telefono) (push) Successful in 25s
test / browser (tressette) (push) Successful in 24s
test / browser (veicoli) (push) Successful in 16s
test / browser (minigiochi) (push) Successful in 2m58s
test / browser (telefoni) (push) Successful in 2m13s
test / browser (principale) (push) Successful in 4m16s
test / browser (peso) (push) Successful in 5m29s
test / release (push) Successful in 13s
2026-11-02 11:04:42 +01:00
manzoloandClaude Opus 5.5 4b5408597f Flipper più ricco (Codex): skill shot da 25.000 sulla corsia lampeggiante, spinner RIVER da 500 a giro, salvataggi laterali conquistabili, bonus a fine pallina fino a ×5; spintarella con N, freccia su o SCUOTI (terza scossa: TILT, palette spente e bonus perso); il Regolo del futuro concede 30 secondi per il colpo finale da 500.000; fuochi, fanfare, knocker, ruggito e conto alla rovescia
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdB3MK67QCXQMfaP2cV
2026-11-02 11:04:26 +01:00
10 changed files with 397 additions and 28 deletions
+9
View File
@@ -18,6 +18,15 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## 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
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).
+9 -1
View File
@@ -311,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.
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>
<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
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
@@ -322,7 +329,8 @@
<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
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
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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.73",
"version": "0.1.74",
"private": true,
"type": "module",
"scripts": {
+96 -12
View File
@@ -1,6 +1,6 @@
// 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.
import { R, SCORE, RAMP, FUTURE, PLUNGER_ZONE, 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 { SCENES, flipperScene, paintRegolo, paintZoneSymbol, paintFlipperScene, paintPlayfieldArt } from './flipper-art.js';
@@ -458,11 +458,25 @@ function mission(g, c, time) {
}
function effects(g, c, time) {
const latest = c.fx.findLast(f => f.kind === 'celebrate');
const firework = c.fx.findLast(f => f.kind === 'fireworks');
for (const f of c.fx) {
const age = time - f.t;
if (age < 0) continue;
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;
for (let i = 0; i < 8; i++) {
const a = hash(i * 7 + f.t) * TAU, length = (18 + hash(i * 9) * 45) * age;
@@ -493,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');
}
g.restore();
glow(g, 320, 277, 85, color, 0.45);
box(g, 257, 265, 126, 31, '#07111deb', color, 6);
text(g, f.text, 320, 276, 11, WHITE, 'center', 'sans-serif', 800);
text(g, f.points ? `+${number(f.points)}` : 'IL REGOLO SI RISVEGLIA', 320, 288, 6, color, 'center', 'monospace');
glow(g, 99, 277, 60, color, 0.45);
box(g, 24, 261, 150, 37, '#07111df5', color, 6);
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', 99, 288, 6, color, 'center', 'monospace');
}
g.restore();
}
@@ -547,10 +561,10 @@ function futureBoard(g, c) {
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, 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, 'FINALE O ULTIMA PALLINA PERSA:', 548, 318, 5.5, MUTED, 'center');
text(g, 'SI TORNA AL RIVER', 548, 332, 7, TINT.orbita, 'center');
text(g, ready ? 'MIRA AL REGOLO CORONATO!' : 'OGNI SIGILLO RISVEGLIA IL REGOLO', 548, 318, 5.5, 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) {
@@ -559,6 +573,7 @@ function futureField(g, c, time) {
target(g, c);
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);
showDevices(g, c, time);
const balls = activeBalls(c);
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) {
@@ -570,7 +585,8 @@ function futureField(g, c, time) {
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);
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) {
return l => {
@@ -638,6 +654,10 @@ export function drawFlipper(g, c, def, bg, glass) {
}
if (future) {
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
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); }
@@ -655,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');
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);
for (const [i, b] of balls.entries()) {
if (b.waiting) continue;
@@ -673,10 +694,11 @@ export function drawFlipper(g, c, def, bg, glass) {
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 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.
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); plungerHint(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();
}
// A pallina ferma, dove toccare per la molla (Andrea, 1 novembre: «sul telefono non è intuitivo premere al centro»): la
@@ -692,3 +714,65 @@ function plungerHint(g, c, time) {
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');
}
}
+134 -12
View File
@@ -25,6 +25,9 @@ export const LEVELS = {
seria: { g: 740, save: 4, kick: 340, goal: 120000 },
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'];
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; }
@@ -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 } },
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,
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() };
newBall(c);
return c;
@@ -150,7 +157,7 @@ export const canThrow = c => waiting(c) && !c.holding;
export const canPress = canThrow;
// Due permessi diversi: il cursore della molla solo prima del lancio, i flipper per tutta la partita.
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 powerAt = time => clamp(time / CHARGE, 0, 1);
// 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
// continua per il record.
function add(c, points, at) {
if (c.tilt.active) return;
c.score += points;
if (at) fx(c, 'pop', at[0], at[1], { text: `+${points}` });
if (!c.result && c.score >= c.skill.goal) {
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.
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);
}
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);
}
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.
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.
@@ -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.
// 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) {
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;
f.held = f.held.filter(h => h !== who); if (down) f.held.push(who);
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] };
}
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.
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
@@ -228,7 +290,7 @@ function moveFlippers(c, h) {
for (const side of ['left', 'right']) {
const f = c.flippers[side];
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;
f.angle += clamp(target - f.angle, -FLIP.speed * h, FLIP.speed * h);
f.omega = (f.angle - previous) / h;
@@ -241,6 +303,7 @@ function step(c) {
if (c.sim < c.transform.until) { moveFlippers(c, STEP); return; }
c.transform = null; announce(c, 'CARICA I TRE SIGILLI');
}
if (c.step === 'play') updateShow(c);
if (c.table === 'future') {
c.regolo.x = 320 + Math.sin(c.sim * 1.6) * 24;
}
@@ -273,6 +336,7 @@ function step(c) {
for (const ball of balls) {
if (ball.waiting || ball.releaseAt > c.sim) continue;
if (ball.ramp) { rideRamp(c, ball); continue; }
crossShow(c, from.get(ball), ball);
crossLanes(c, from.get(ball), ball);
enterRamps(c, from.get(ball), ball);
// 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) {
// 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; }
else if (c.tilt.active) { drain(c, ball); continue; }
else newBall(c);
}
} 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);
// Le corsie: contano attraversando la riga in discesa, dovunque nella corsia (la posizione orizzontale interpolata).
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 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);
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;
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
// indietro e ricasca dall'imbocco.
function enterRamps(c, from, ball) {
if (c.tilt.active) return;
for (const r of c.ramps) {
if (!rampOpen(c, r)) continue;
const [[x0, y], [x1]] = r.mouth;
@@ -345,9 +422,16 @@ function rideRamp(c, ball) {
const v = Math.max(on.v, 150);
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);
if (c.tilt.active) return;
c.bonus.ramps++;
if (c.table === 'future') {
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`);
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;
}
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]);
announce(c, title);
fx(c, 'celebrate', 320, 170, { text: title, points, color });
fireworks(c, color);
fx(c, 'burst', 320, 150, { big: true }); fx(c, 'shake', 0, 0);
}
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.events.push({ kind: 'flipper-esplosione', power: 1 });
say(c, 'PALLINA +1'); say(c, 'MULTIBALL!');
c.events.push({ kind: 'flipper-knocker', 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 }); }
return true;
@@ -441,7 +527,11 @@ function drain(c, ball) {
c.lastRamp = null; c.saved = false; c.saveUntil = 0;
announce(c, 'LOCK LIBERO'); newBall(c); return;
}
payBonus(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;
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
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.
const hit = hitSeg(ball, w, null);
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 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;
@@ -488,10 +578,15 @@ function collideSeg(c, ball, w) {
}
function collideTarget(c, ball, t) {
const hit = hitSeg(ball, t, 0.5);
if (c.tilt.active) 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);
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).
function hitCircle(ball, o, e) {
@@ -506,13 +601,14 @@ function hitCircle(ball, o, e) {
function collideBumper(c, ball, b) {
const hit = hitCircle(ball, b, 0.2);
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;
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);
}
function collideRegolo(c, ball) {
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; }
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);
}
@@ -541,6 +637,8 @@ export const PLUNGER_ZONE = 64; // mezza larghezza della fascia centrale, in pix
export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false;
const x = c.cursor?.[0] ?? 320;
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;
}
@@ -555,6 +653,7 @@ export function release(c, pointerId = 'ptr') {
// Annullare interrompe senza fare niente: la molla si scarica, i flipper ricadono (tutti, senza puntatore).
export function cancel(c, pointerId) {
const all = pointerId === undefined;
if (all) c.nudgeHeld = false;
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);
return true;
@@ -566,11 +665,33 @@ export function stopTurn(c, who = 'button') {
const l = hold(c, 'left', who, false), r = hold(c, 'right', who, false);
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 key(c, k, down) {
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 (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).
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; }
@@ -595,8 +716,9 @@ export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia
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}.`}`;
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 tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
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 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}`;
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}`;
}
+9
View File
@@ -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-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.
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-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); }
+1 -1
View File
@@ -701,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.' },
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).' },
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.' },
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).
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.73';
export const VERSION = '0.1.74';
// 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).
+30
View File
@@ -132,6 +132,36 @@ try {
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);
}
// 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.
const reward = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), c = game.closeup;
+107
View File
@@ -593,3 +593,110 @@ test('per provare, openSuper fa davvero i due LOCK e apre la SUPER SPECIAL (main
assert.ok(m.openSuper(c));
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);
});