Il flipper col LOCK (idea di Andrea, fatto da Codex e completato): lo stesso ponte tre volte trattiene la pallina e ne dà una nuova, l'altro ponte SPECIAL vale il JACKPOT e il MULTIBALL a due palline; serratura, seconde palline e SPECIAL nel disegno, suoni, aiuto, test; imbocchi delle rampe al centro e uscite laterali più strette (Codex); pallina premio al traguardo; la barra del traguardo sotto la finestra invece del pannello sul Trebbio; il Boccino prudente in curva
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
// ponti (le rampe), flipper, molla e pallina. A sinistra, sul fianco nero del mobile, il tabellone luminoso a punti
|
||||
// come sui flipper degli anni 2000 (Andrea): le cifre che corrono, le scritte che lampeggiano, le lampadine che
|
||||
// girano intorno. Niente filtri né passate a tutto schermo (vedi risveglio.js).
|
||||
import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js';
|
||||
import { FIELD, LANE, R, SCORE, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball } from './flipper.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
@@ -112,7 +112,7 @@ function rampEntrance(ctx, r, time, who) {
|
||||
}
|
||||
const rampCache = new Map();
|
||||
function ramp(ctx, r, time, who) {
|
||||
const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : who && who !== r.id && Math.sin(time * 6) > 0 ? 'invite' : 'base';
|
||||
const state = r.lit > 0 && Math.sin(time * 14) > 0 ? 'lit' : (who === r.id + '!' || (who && who !== r.id)) && Math.sin(time * 6) > 0 ? 'invite' : 'base';
|
||||
if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; }
|
||||
const key = `${r.id}:${state}`;
|
||||
let layer = rampCache.get(key);
|
||||
@@ -161,6 +161,14 @@ function plunger(ctx, c) {
|
||||
for (let y = top + pull + 2; y < 346; y += 3) ctx.fillRect(Math.round(x) - 4, Math.round(y), 8, 1);
|
||||
ctx.fillStyle = '#d9463a'; ctx.fillRect(Math.round(x) - 5, Math.round(top + pull) - 3, 10, 4);
|
||||
}
|
||||
// La serratura: la pallina trattenuta in fondo al ponte, dietro due sbarre, con la luce che pulsa.
|
||||
function lock(ctx, b, time) {
|
||||
ball(ctx, b);
|
||||
ctx.strokeStyle = Math.sin(time * 6) > 0 ? '#ffd45a' : '#8a7a5a'; ctx.lineWidth = 1.5;
|
||||
for (const dx of [-3, 3]) { ctx.beginPath(); ctx.moveTo(b.x + dx, b.y - 9); ctx.lineTo(b.x + dx, b.y + 7); ctx.stroke(); }
|
||||
ctx.beginPath(); ctx.moveTo(b.x - 7, b.y - 9); ctx.lineTo(b.x + 7, b.y - 9); ctx.stroke();
|
||||
label(ctx, 'LOCK', b.x, b.y - 15, 5, '#ffe08c');
|
||||
}
|
||||
function ball(ctx, b) {
|
||||
if (!b) return;
|
||||
// Sul ponte la pallina sta più in alto, a seconda della quota: più su, più grande, con l'ombra più lontana.
|
||||
@@ -261,13 +269,11 @@ function board(ctx, c, time, glass) {
|
||||
if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center');
|
||||
if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh);
|
||||
ctx.restore();
|
||||
// Il traguardo resta leggibile anche mentre il display celebra un colpo.
|
||||
// Il traguardo resta leggibile anche mentre il display celebra un colpo: una barra sottile sotto la finestra e,
|
||||
// nella riga in basso, quanto manca (il pannello che c'era prima copriva il Trebbio dipinto).
|
||||
const progress = Math.min(1, c.score / c.skill.goal), remaining = Math.max(0, c.skill.goal - c.score);
|
||||
panel(ctx, 20, 134, 160, 31);
|
||||
label(ctx, `OBIETTIVO ${c.skill.goal.toLocaleString('it-IT')}`, 28, 142, 7, '#f1d7a1', 'left');
|
||||
label(ctx, remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO RAGGIUNTO', 28, 152, 6, remaining ? '#becbd0' : '#a9e7bf', 'left');
|
||||
ctx.fillStyle = '#30434a'; ctx.fillRect(28, 159, 144, 2);
|
||||
ctx.fillStyle = '#edc777'; ctx.fillRect(28, 159, 144 * progress, 2);
|
||||
ctx.fillStyle = '#1a1410'; ctx.fillRect(wx, wy + wh + 3, ww, 3);
|
||||
ctx.fillStyle = remaining ? '#edc777' : '#a9e7bf'; ctx.fillRect(wx, wy + wh + 3, Math.round(ww * progress), 3);
|
||||
// I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia).
|
||||
c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a'));
|
||||
// I cinque dischi in basso: le palline che restano, e i quattro rioni accesi.
|
||||
@@ -276,7 +282,7 @@ function board(ctx, c, time, glass) {
|
||||
rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); });
|
||||
// Sotto, in piccolo: il salvataggio o la combo.
|
||||
const save = saveLeft(c);
|
||||
const note = save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : '';
|
||||
const note = multiball(c) ? 'MULTIBALL!' : c.locked ? 'LOCK: FAI IL PONTE SPECIAL' : c.rampRun?.count ? `PONTE ${c.rampRun.count}/3 PER IL LOCK` : save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : remaining ? `MANCANO ${remaining.toLocaleString('it-IT')}` : 'TRAGUARDO: +1 PALLINA';
|
||||
if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center');
|
||||
}
|
||||
// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al
|
||||
@@ -351,9 +357,9 @@ function shake(c, time) {
|
||||
return k > 0 ? [Math.round(Math.sin(time * 90) * 3 * k), Math.round(Math.cos(time * 70) * 2 * k)] : [0, 0];
|
||||
}
|
||||
// La cometa: le ultime posizioni della pallina, sempre più tenui.
|
||||
function trail(ctx, c) {
|
||||
const n = c.trail.length;
|
||||
c.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); });
|
||||
function trail(ctx, b) {
|
||||
const n = b.trail.length;
|
||||
b.trail.forEach(([x, y], i) => { ctx.globalAlpha = 0.05 + 0.3 * i / n; ctx.fillStyle = '#e8ecf2'; ctx.beginPath(); ctx.arc(x, y, R * (0.3 + 0.6 * i / n), 0, Math.PI * 2); ctx.fill(); });
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
function result(ctx, c) {
|
||||
@@ -393,12 +399,16 @@ export function drawFlipper(ctx, c, def, bg, glass) {
|
||||
for (const r of c.ramps) rampEntrance(ctx, r, time, c.lastRamp?.id);
|
||||
for (const side of ['left', 'right']) flipper(ctx, c, side);
|
||||
plunger(ctx, c);
|
||||
// La pallina (con la cometa) sotto i ponti, se è sulla tavola; sopra, se è su un ponte; poi gli effetti.
|
||||
if (c.ball && !c.ball.waiting) trail(ctx, c);
|
||||
if (!c.ball?.ramp) ball(ctx, c.ball);
|
||||
for (const r of c.ramps) ramp(ctx, r, time, c.lastRamp?.id);
|
||||
if (c.ball?.ramp) ball(ctx, c.ball);
|
||||
// Le palline (con la cometa) sotto i ponti, se sono sulla tavola; sopra, se sono su un ponte; la pallina
|
||||
// trattenuta (LOCK) nella serratura in fondo al suo ponte; poi gli effetti.
|
||||
const balls = activeBalls(c), special = c.special ? c.special + '!' : c.lastRamp?.id;
|
||||
for (const b of balls) if (!b.waiting && !b.ramp) { trail(ctx, b); ball(ctx, b); }
|
||||
for (const b of balls) if (b.waiting) ball(ctx, b);
|
||||
for (const r of c.ramps) ramp(ctx, r, time, special);
|
||||
if (c.locked) lock(ctx, c.locked.ball, time);
|
||||
for (const b of balls) if (b.ramp) { trail(ctx, b); ball(ctx, b); }
|
||||
drawFx(ctx, c, time);
|
||||
if (c.special) { const r = c.ramps.find(k => k.id === c.special); if (Math.sin(time * 8) > 0) label(ctx, 'SPECIAL', (r.mouth[0][0] + r.mouth[1][0]) / 2, r.mouth[0][1] - 38, 6, '#fff6d6'); }
|
||||
// Da fermo, l'invito: la molla da caricare.
|
||||
if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center');
|
||||
board(ctx, c, time, glass);
|
||||
|
||||
+116
-41
@@ -12,7 +12,7 @@ export const FIELD = { x0: 200, x1: 448, y0: 5, y1: 336 }, LANE = { x0: 432, x1:
|
||||
export const BALLS = 3, STEP = 1 / 240, MAX_SPEED = 980, CHARGE = 1.2, FLIP = { len: 34, rest: 0.5, up: -0.46, speed: 26, r: 3 };
|
||||
export const PIVOT = { left: [280, 314], right: [360, 314] };
|
||||
// I punti, generosi come sui flipper degli anni 2000 (Andrea: «aumenta i punti»).
|
||||
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000 };
|
||||
export const SCORE = { bumper: 1000, sling: 500, lane: 2500, palio: 30000, target: 5000, bank: 25000, regolo: 10000, ramp: 15000, ponti: 50000, jackpot: 100000 };
|
||||
// Le rampe (Andrea: «un paio di ponti sui quali sale la pallina»): si entra dal basso con abbastanza velocità, si
|
||||
// sale, si passa sopra la tavola e si scende dall'altra parte. Fatte tutte e due di fila: i PONTI.
|
||||
export const RAMP = { speed: 240, slope: 155, drag: 20, combo: 12, blend: 30 };
|
||||
@@ -64,9 +64,9 @@ export function layout() {
|
||||
// fondo al percorso, dall'altra parte, dritta nella corsia di ritorno verso il flipper. Si incrociano una volta sola,
|
||||
// in cima, come due ponti.
|
||||
const ramps = [
|
||||
// Imbocchi verso il centro, davanti alle sponde elastiche: 33 pixel utili alla pallina anziché 13.
|
||||
{ id: 'sinistra', mouth: [[252, 226], [294, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
|
||||
{ id: 'destra', mouth: [[346, 226], [388, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
|
||||
// Imbocchi verso il centro, davanti alle sponde elastiche: 27 pixel utili alla pallina anziché 13.
|
||||
{ id: 'sinistra', mouth: [[255, 226], [291, 226]], path: [[273, 226], [273, 196], [247, 150], [250, 96], [262, 52], [290, 30], [322, 34], [352, 52], [378, 84], [394, 116], [411, 128], [411, 142]], lit: 0, touching: false },
|
||||
{ id: 'destra', mouth: [[349, 226], [385, 226]], path: [[367, 226], [367, 196], [393, 150], [390, 96], [378, 52], [350, 30], [318, 34], [288, 52], [262, 84], [246, 116], [229, 128], [229, 142]], lit: 0, touching: false },
|
||||
];
|
||||
// Lunghezze, e la cima (il punto più alto del disegno): fin lì si sale, poi si scende.
|
||||
for (const r of ramps) {
|
||||
@@ -74,6 +74,8 @@ export function layout() {
|
||||
for (let i = 1; i < r.path.length; i++) { r.len += Math.hypot(r.path[i][0] - r.path[i - 1][0], r.path[i][1] - r.path[i - 1][1]); r.cum.push(r.len); }
|
||||
r.top = r.cum[r.path.reduce((best, p, i) => p[1] < r.path[best][1] ? i : best, 0)];
|
||||
}
|
||||
// Ogni pallina ricorda i propri contatti: in multiball un urto non cancella quello dell'altra.
|
||||
[...walls, ...targets, ...bumpers, regolo].forEach((o, i) => { o.contactId = i; });
|
||||
return { walls, targets, bumpers, regolo, lanes, ramps };
|
||||
}
|
||||
// Il punto a `s` pixel lungo il percorso di una rampa, e la direzione lì.
|
||||
@@ -89,11 +91,15 @@ export function newFlipper(seed = Date.now() >>> 0, def = {}) {
|
||||
const c = { step: 'play', t: 0, sim: 0, seed, level, skill: LEVELS[level], current: 'player', balls: BALLS, score: 0, power: 0.7,
|
||||
holding: false, holdTime: 0, pointerId: null, cursor: null, accumulator: 0, events: [], line: null, lineT: 0, saved: false,
|
||||
flippers: { left: { angle: FLIP.rest, omega: 0, held: [], click: 0 }, right: { angle: Math.PI - FLIP.rest, omega: 0, held: [], click: 0 } },
|
||||
ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
||||
ball: null, extraBalls: [], locked: null, special: null, rampRun: { id: null, count: 0 },
|
||||
saveUntil: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() };
|
||||
newBall(c);
|
||||
return c;
|
||||
}
|
||||
function newBall(c) { c.ball = { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null }; c.still = 0; c.anchor = null; c.rest = 0; }
|
||||
function makeBall() { return { x: (LANE.x0 + LANE.x1) / 2, y: 318, vx: 0, vy: 0, waiting: true, ramp: null, contacts: [], trail: [], still: 0, anchor: null, rest: 0 }; }
|
||||
function newBall(c) { c.ball = makeBall(); c.trail = c.ball.trail; }
|
||||
export const activeBalls = c => [c.ball, ...c.extraBalls].filter(Boolean);
|
||||
export const multiball = c => c.extraBalls.length > 0;
|
||||
export const waiting = c => c.step === 'play' && !!c.ball?.waiting;
|
||||
export const canThrow = c => waiting(c) && !c.holding;
|
||||
export const canPress = canThrow;
|
||||
@@ -120,6 +126,8 @@ function add(c, points, at) {
|
||||
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-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;
|
||||
@@ -166,7 +174,10 @@ export function update(c, dt) {
|
||||
c.accumulator += dt;
|
||||
while (c.accumulator >= STEP) { c.accumulator -= STEP; step(c); }
|
||||
// La scia della pallina: le ultime posizioni, per la cometa.
|
||||
if (c.ball && !c.ball.waiting) { c.trail.push([c.ball.x, c.ball.y]); if (c.trail.length > 10) c.trail.shift(); } else c.trail.length = 0;
|
||||
for (const b of activeBalls(c)) {
|
||||
if (!b.waiting) { b.trail.push([b.x, b.y]); if (b.trail.length > 10) b.trail.shift(); } else b.trail.length = 0;
|
||||
}
|
||||
c.trail = c.ball?.trail ?? [];
|
||||
}
|
||||
function moveFlippers(c, h) {
|
||||
for (const side of ['left', 'right']) {
|
||||
@@ -180,36 +191,56 @@ function moveFlippers(c, h) {
|
||||
}
|
||||
function step(c) {
|
||||
c.sim += STEP;
|
||||
const ball = c.ball;
|
||||
for (const r of c.ramps) r.lit = Math.max(0, r.lit - STEP);
|
||||
if (c.lastRamp && c.sim - c.lastRamp.at > RAMP.combo) c.lastRamp = null;
|
||||
if (c.step !== 'play' || !ball || ball.waiting) { moveFlippers(c, STEP); return; }
|
||||
if (c.step !== 'play') { moveFlippers(c, STEP); return; }
|
||||
for (const bank of Object.keys(c.resets)) if (c.sim >= c.resets[bank]) { for (const t of c.targets) if (t.bank === bank) t.down = false; delete c.resets[bank]; }
|
||||
if (ball.ramp) { moveFlippers(c, STEP); rideRamp(c, ball); return; }
|
||||
ball.vy += c.skill.g * STEP;
|
||||
limit(ball);
|
||||
// Sottopassi: pallina e punta del flipper insieme non devono fare più di un raggio per passo.
|
||||
const tip = FLIP.speed * FLIP.len, n = clamp(Math.ceil((Math.hypot(ball.vx, ball.vy) + tip) * STEP / R), 1, 6), h = STEP / n;
|
||||
const from = { x: ball.x, y: ball.y };
|
||||
// Una fotografia delle palline all'inizio del passo: quelle appena liberate si muovono dal prossimo.
|
||||
const balls = activeBalls(c), ground = balls.filter(b => !b.waiting && !b.ramp);
|
||||
const from = new Map(ground.map(b => [b, { x: b.x, y: b.y }]));
|
||||
for (const b of ground) { b.vy += c.skill.g * STEP; limit(b); }
|
||||
const speed = Math.max(0, ...ground.map(b => Math.hypot(b.vx, b.vy)));
|
||||
const n = clamp(Math.ceil((speed + FLIP.speed * FLIP.len) * STEP / R), 1, 6), h = STEP / n;
|
||||
// Le palette avanzano una sola volta per sottopasso, indipendentemente dal numero di palline.
|
||||
for (let i = 0; i < n; i++) {
|
||||
moveFlippers(c, h);
|
||||
ball.x += ball.vx * h; ball.y += ball.vy * h;
|
||||
for (const w of c.walls) collideSeg(c, ball, w);
|
||||
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
|
||||
for (const b of c.bumpers) collideBumper(c, ball, b);
|
||||
collideRegolo(c, ball);
|
||||
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
|
||||
limit(ball);
|
||||
for (const ball of ground) {
|
||||
ball.x += ball.vx * h; ball.y += ball.vy * h;
|
||||
for (const w of c.walls) collideSeg(c, ball, w);
|
||||
for (const t of c.targets) if (!t.down) collideTarget(c, ball, t);
|
||||
for (const b of c.bumpers) collideBumper(c, ball, b);
|
||||
collideRegolo(c, ball);
|
||||
for (const side of ['left', 'right']) collideFlipper(c, ball, side);
|
||||
limit(ball);
|
||||
}
|
||||
for (let a = 0; a < ground.length; a++) for (let b = a + 1; b < ground.length; b++) collideBalls(ground[a], ground[b]);
|
||||
}
|
||||
crossLanes(c, from, ball);
|
||||
enterRamps(c, from, ball);
|
||||
// La pallina che resta nello stesso punto (ferma, o che trema in un incastro): dopo un po' una scossa. Non se è
|
||||
// trattenuta su un flipper alzato: quella è una mossa del giocatore.
|
||||
if (!c.anchor || Math.hypot(ball.x - c.anchor.x, ball.y - c.anchor.y) > 3) { c.anchor = { x: ball.x, y: ball.y }; c.still = 0; }
|
||||
else if ((c.still += STEP) > NUDGE_AFTER) { c.still = 0; c.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
|
||||
// Un lancio fiacco (o una pallina ricaduta nella corsia) si assesta sul fondo e si rilancia.
|
||||
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) { if ((c.rest += STEP) > 0.3) newBall(c); } else c.rest = 0;
|
||||
if (ball.y > FIELD.y1 + R * 2) drain(c);
|
||||
for (const ball of balls) {
|
||||
if (ball.waiting) continue;
|
||||
if (ball.ramp) { rideRamp(c, ball); continue; }
|
||||
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.
|
||||
if (!ball.anchor || Math.hypot(ball.x - ball.anchor.x, ball.y - ball.anchor.y) > 3) { ball.anchor = { x: ball.x, y: ball.y }; ball.still = 0; }
|
||||
else if ((ball.still += STEP) > NUDGE_AFTER) { ball.still = 0; ball.anchor = null; if (!cradled(c, ball)) { ball.vx += (random(c) - 0.5) * 160; ball.vy -= 90; } }
|
||||
if (ball.x > LANE.x0 && ball.y > 318 && Math.hypot(ball.vx, ball.vy) < 12) {
|
||||
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 newBall(c);
|
||||
}
|
||||
} else ball.rest = 0;
|
||||
if (ball.y > FIELD.y1 + R * 2) drain(c, ball);
|
||||
}
|
||||
}
|
||||
function collideBalls(a, b) {
|
||||
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||
if (d >= R * 2) return;
|
||||
const nx = d > 1e-6 ? dx / d : 1, ny = d > 1e-6 ? dy / d : 0, push = (R * 2 - d) / 2;
|
||||
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
|
||||
const speed = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (speed > 0) { const impulse = speed * 0.95; a.vx -= nx * impulse; a.vy -= ny * impulse; b.vx += nx * impulse; b.vy += ny * impulse; }
|
||||
limit(a); limit(b);
|
||||
}
|
||||
function limit(ball) { const v = Math.hypot(ball.vx, ball.vy); if (v > MAX_SPEED) { ball.vx *= MAX_SPEED / v; ball.vy *= MAX_SPEED / v; } }
|
||||
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);
|
||||
@@ -256,8 +287,9 @@ function rideRamp(c, ball) {
|
||||
// pallina esce sopra uno di loro, non contano come urto nuovo.
|
||||
const v = Math.max(on.v, 150);
|
||||
ball.vx = p.dx * v; ball.vy = p.dy * v; ball.ramp = null;
|
||||
for (const o of [...c.bumpers, c.regolo, ...c.targets, ...c.walls]) o.touching = true;
|
||||
ball.contacts = [...c.bumpers, c.regolo, ...c.targets, ...c.walls].map(o => o.contactId);
|
||||
add(c, SCORE.ramp, [p.x, p.y]); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); fx(c, 'chase', 0, 0, { ramp: r.id });
|
||||
if (rampFeature(c, r, ball)) return;
|
||||
if (c.lastRamp && c.lastRamp.id !== r.id) {
|
||||
add(c, SCORE.ponti); say(c, 'I PONTI!'); say(c, `BONUS +${SCORE.ponti}`); c.events.push({ kind: 'flipper-ponti', power: 1 }); c.lastRamp = null;
|
||||
fx(c, 'burst', 324, 60, { big: true }); fx(c, 'wash', 0, 0, { color: '120,200,255' }); fx(c, 'shake', 0, 0); fx(c, 'chase', 0, 0, { ramp: c.ramps.find(k => k.id !== r.id).id });
|
||||
@@ -268,25 +300,68 @@ function rideRamp(c, ball) {
|
||||
ball.vx = -p.dx * on.v; ball.vy = Math.max(60, -p.dy * on.v); ball.ramp = null; ball.y = r.mouth[0][1] + 1;
|
||||
} else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; }
|
||||
}
|
||||
function drain(c) {
|
||||
// La sequenza considera i passaggi completi: bersagli e rimbalzi non la interrompono, l'altra rampa sì.
|
||||
function rampFeature(c, r, ball) {
|
||||
if (c.locked) {
|
||||
if (c.special !== r.id) { say(c, 'TENTA SPECIAL'); return true; }
|
||||
const released = c.locked.ball;
|
||||
released.vx = 0; released.vy = 170; released.waiting = false;
|
||||
released.trail = []; released.still = 0; released.anchor = null; released.rest = 0;
|
||||
c.extraBalls.push(released); c.locked = null; c.special = null; c.lastRamp = null;
|
||||
c.rampRun = { id: null, count: 0 }; c.saveUntil = 0; c.saved = true;
|
||||
add(c, SCORE.jackpot, [324, 190]); say(c, 'JACKPOT!', c.line !== 'TRAGUARDO!'); say(c, 'MULTIBALL!');
|
||||
c.events.push({ kind: 'flipper-jackpot', power: 1 }); fx(c, 'burst', 324, 130, { big: true }); fx(c, 'shake', 0, 0);
|
||||
for (const ramp of c.ramps) { ramp.lit = 5; fx(c, 'chase', 0, 0, { ramp: ramp.id }); }
|
||||
return true;
|
||||
}
|
||||
if (multiball(c)) return false;
|
||||
c.rampRun = { id: r.id, count: c.rampRun.id === r.id ? c.rampRun.count + 1 : 1 };
|
||||
if (c.rampRun.count < 3) return false;
|
||||
// La pallina resta visibile nella serratura all'uscita della rampa. Il nuovo lancio è gratuito.
|
||||
c.locked = { rampId: r.id, ball }; ball.vx = 0; ball.vy = 0; ball.trail = [];
|
||||
c.special = c.ramps.find(other => other.id !== r.id).id; c.lastRamp = null;
|
||||
c.saveUntil = 0; c.saved = false; c.holding = false; c.holdTime = 0; c.pointerId = null;
|
||||
newBall(c); say(c, 'LOCK!', c.line !== 'TRAGUARDO!'); say(c, 'SPECIAL!');
|
||||
c.events.push({ kind: 'flipper-lock', power: 1 }); fx(c, 'ring', ball.x, ball.y);
|
||||
return true;
|
||||
}
|
||||
function drain(c, ball) {
|
||||
c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' });
|
||||
if (multiball(c)) {
|
||||
if (c.ball === ball) c.ball = c.extraBalls.shift();
|
||||
else c.extraBalls = c.extraBalls.filter(b => b !== ball);
|
||||
c.saveUntil = 0; c.saved = true; c.rampRun = { id: null, count: 0 };
|
||||
say(c, 'UNA IN GIOCO'); return;
|
||||
}
|
||||
if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; }
|
||||
c.rampRun = { id: null, count: 0 };
|
||||
if (c.locked) {
|
||||
// Perduta la sostitutiva: si recupera quella trattenuta, senza scalare una vita o lasciare lock sospesi.
|
||||
c.locked = null; c.special = null; c.lastRamp = null; c.saved = false; c.saveUntil = 0;
|
||||
say(c, 'LOCK LIBERO', true); newBall(c); return;
|
||||
}
|
||||
c.balls--; c.saved = false; c.lastRamp = null;
|
||||
if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; }
|
||||
c.ball = null; c.step = 'result';
|
||||
c.result = { winner: c.score >= c.skill.goal ? 'player' : 'cpu', score: c.score, goal: c.skill.goal };
|
||||
}
|
||||
function contact(ball, object, hit) {
|
||||
const index = ball.contacts.indexOf(object.contactId), fresh = index < 0;
|
||||
if (hit && fresh) ball.contacts.push(object.contactId);
|
||||
else if (!hit && !fresh) ball.contacts.splice(index, 1);
|
||||
object.touching = hit;
|
||||
return fresh;
|
||||
}
|
||||
// Pallina contro un segmento: la si spinge fuori e si riflette la velocità. Torna la velocità d'urto lungo la
|
||||
// normale (positiva se picchiava), o null se non tocca; `touching` ricorda il contatto, per contare solo gli urti nuovi.
|
||||
function hitSeg(ball, w, e) {
|
||||
const [ax, ay] = w.a, [bx, by] = w.b, dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1;
|
||||
const u = clamp(((ball.x - ax) * dx + (ball.y - ay) * dy) / len2, 0, 1), px = ax + dx * u, py = ay + dy * u;
|
||||
let nx = ball.x - px, ny = ball.y - py; const d = Math.hypot(nx, ny);
|
||||
if (d >= R) { w.touching = false; return null; }
|
||||
if (d >= R) { contact(ball, w, false); return null; }
|
||||
if (d < 1e-6) { nx = -dy; ny = dx; const n = Math.hypot(nx, ny); nx /= n; ny /= n; } else { nx /= d; ny /= d; }
|
||||
ball.x = px + nx * R; ball.y = py + ny * R;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = !w.touching;
|
||||
w.touching = true;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, w, true);
|
||||
if (vn < 0 && e !== null) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||||
return { vn: -vn, nx, ny, fresh };
|
||||
}
|
||||
@@ -313,11 +388,10 @@ function collideTarget(c, ball, t) {
|
||||
// Pallina contro un cerchio: spinta fuori; torna la velocità d'urto lungo la normale (null se non tocca).
|
||||
function hitCircle(ball, o, e) {
|
||||
const dx = ball.x - o.x, dy = ball.y - o.y, d = Math.hypot(dx, dy), min = R + o.r;
|
||||
if (d >= min) { o.touching = false; return null; }
|
||||
if (d >= min) { contact(ball, o, false); return null; }
|
||||
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : -1;
|
||||
ball.x = o.x + nx * min; ball.y = o.y + ny * min;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = !o.touching;
|
||||
o.touching = true;
|
||||
const vn = ball.vx * nx + ball.vy * ny, fresh = contact(ball, o, true);
|
||||
if (vn < 0) { ball.vx -= (1 + e) * vn * nx; ball.vy -= (1 + e) * vn * ny; }
|
||||
return { vn: -vn, nx, ny, fresh };
|
||||
}
|
||||
@@ -390,8 +464,9 @@ export const shoot = c => canThrow(c) && launch(c, c.power);
|
||||
export const button = c => c.step === 'result' ? 'Finita' : waiting(c) ? 'Lancia' : 'In gioco…';
|
||||
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, ora il record' : `traguardo ${c.skill.goal}`;
|
||||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.`;
|
||||
const goal = c.result ? 'traguardo fatto, pallina premio vinta, ora il record' : `traguardo ${c.skill.goal}`;
|
||||
const feature = c.locked ? `LOCK: pallina trattenuta. Rampa ${c.special === 'sinistra' ? 'sinistra' : 'destra'} SPECIAL: jackpot ${SCORE.jackpot} e multiball.` : multiball(c) ? 'MULTIBALL: due palline in gioco!' : c.rampRun.count ? `Rampa ${c.rampRun.id}: ${c.rampRun.count}/3 per il LOCK.` : '';
|
||||
const tally = `Punti ${c.score} (${goal}), palline ${c.balls}.${feature ? ' ' + feature : ''}`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ export class Soundscape {
|
||||
else if (kind === 'flipper-palio') for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.14, 0.06, i * 0.1, pan);
|
||||
else if (kind === 'flipper-ponti') for (const [i, f] of [392, 523, 659, 784, 1046, 1318].entries()) this.tone(f, f, 0.16, 0.06, i * 0.08, pan);
|
||||
else if (kind === 'flipper-traguardo') { for (const [i, f] of [523, 659, 784, 1046].entries()) this.tone(f, f, 0.12, 0.06, i * 0.09, pan); for (const [i, f] of [784, 1046, 1318].entries()) this.tone(f, f * 1.01, 0.5, 0.05, 0.4 + i * 0.03, pan); }
|
||||
else if (kind === 'flipper-lock') { this.tone(330, 330, 0.25, 0.06, 0, pan); this.tone(220, 220, 0.4, 0.06, 0.2, pan); this.noiseSound(0.08, 1200, 0.05, { pan }); }
|
||||
else if (kind === 'flipper-jackpot') { for (const [i, f] of [523, 659, 784, 1046, 1318, 1568].entries()) this.tone(f, f, 0.14, 0.06, i * 0.07, pan); for (const [i, f] of [1046, 1318, 1568].entries()) this.tone(f, f * 1.01, 0.7, 0.05, 0.5 + i * 0.04, pan); }
|
||||
else if (kind === 'flipper-rampa') { this.noiseSound(0.25, 2400, 0.04, { pan }); this.tone(300, 900, 0.3, 0.04, 0, pan); }
|
||||
else if (kind === 'flipper-buco') { this.tone(220, 80, 0.3, 0.06, 0, pan); this.noiseSound(0.2, 600, 0.04, { pan }); }
|
||||
else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); }
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ export const piloti = [
|
||||
// Yuri de' Motori (Juri Lascialfari, consenso del 28 ottobre) col suo Yamaha Spy bianco e rosso, il «Mazinga Spy» (foto di Andrea).
|
||||
{ id: 'yuri', name: 'Yuri de\' Motori', bike: 'il Mazinga Spy', motore: 3, testa: 5, color: '#f0f0ee', trim: '#d8302a', moto: 'assets/closeups/gp/spy.png', face: 'assets/closeups/gp/yuri.png', consenso: '2026-10-28' },
|
||||
// Il Boccino (Gabriele Chiesi, consenso del 31 ottobre) col suo Piaggio NRG bianco e rosso col numero 1 (foto di
|
||||
// Andrea); il ritratto di Codex dalla sua foto. [DA ANDREA: motore e testa]
|
||||
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 3, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
|
||||
// Andrea); il ritratto di Codex dalla sua foto. In curva prudente (Andrea, 31 ottobre). [DA ANDREA: il motore]
|
||||
{ id: 'boccino', name: 'Il Boccino', bike: 'l\'NRG numero 1', motore: 3, testa: 5, color: '#f4f4f2', trim: '#d8302a', helmet: '#d8302a', stripe: '#f4f4f2', moto: 'assets/closeups/gp/nrg.png', face: 'assets/closeups/gp/boccino.png', consenso: '2026-10-31' },
|
||||
// Il Gianno (il Giannini, consenso del 28 ottobre) col suo Piaggio Typhoon rosso e nero (foto di Andrea).
|
||||
{ id: 'gianno', name: 'Il Gianno', bike: 'il Typhoon', motore: 3, testa: 3, color: '#9e1b22', trim: '#151515', moto: 'assets/closeups/gp/typhoon.png', face: 'assets/closeups/gp/gianno.png', consenso: '2026-10-28' },
|
||||
// La Brauda (Giody Brunetti, consenso del 28 ottobre) col suo Aprilia Rally nero (foto di Andrea). [DA ANDREA: motore e testa]
|
||||
|
||||
Reference in New Issue
Block a user