diff --git a/docs/STATO.md b/docs/STATO.md index 295f1e8..c1b6e5c 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -71,6 +71,11 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, l'uscita dritta nella corsia di ritorno, la combo che scade e si azzera con la pallina persa, le scritte in coda col traguardo davanti, il tabellone che dice l'esito giusto, le cifre che corrono in tempo fisso, i due tasti del mouse dalla maschera `buttons` (il destro è sempre il flipper di destra), i glifi 3 e 5 rifatti. + Gli effetti (Andrea: «più spettacolare»): anelli e scintille sui funghi e sui bersagli, coriandoli nei colori dei + rioni ai colpi grossi, luci che corrono lungo i ponti, i punti che volano via dal punto colpito, il lampo colorato + sulla tavola e la scossa del mobile, la scia della pallina; sul tabellone la regia (la corona al traguardo, le + bandiere al Palio, i due archi ai ponti) e le fanfare. Ponti, sponde e griglia dei punti spenti in cache su tele a + parte (l'arretrato grafico con swiftshader da 8 s a meno di un secondo). - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» - **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere). diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 3c11bd1..be695e0 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -5,6 +5,7 @@ // 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, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft } from './flipper.js'; +import { ambientHash as hash } from './ambient.js'; import { text, panel } from './bocce-render.js'; const COLORS = { piazza: '#d0453a', pieve: '#f2eee2', cardetole: '#3fa34d', campomigliaio: '#2f6fd0' }; @@ -34,27 +35,48 @@ function regolo(ctx, o) { for (const [dx, h] of [[-4, 4], [-1, 6], [2, 4]]) ctx.fillRect(o.x + dx, o.y - r - 2 - h, 2, h); ctx.fillRect(o.x - 5, o.y - r - 2, 10, 2); } -// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), un po' -// trasparente perché si veda la tavola sotto, più alta in cima (la quota), col bordo scuro e la luce; accesi, brillano. +// I ponti: una striscia di pietra lungo il percorso (il sinistro caldo, il destro freddo, per seguirli), più alta +// in cima (la quota), con l'ombra, il bordo scuro e la luce; accesi, brillano; l'altro, in attesa della combo, invita. +// Si dipingono una volta per stato su una tela a parte e poi si copiano (i tratti larghi e trasparenti a ogni +// fotogramma lasciavano alla grafica un arretrato di secondi, vedi effetti-leggeri). const RAMP_LOOK = { sinistra: ['#c9b89a', '#8a7a5a'], destra: ['#b8c0c9', '#6a7a8a'] }; function rampPoint(r, s) { const p = alongRamp(r, s); return { ...p, y: p.y - 2 - 4 * rampHeight(r, s) }; } -function ramp(ctx, r, time, who) { +function paintRamp(ctx, r, state) { const path = () => { ctx.beginPath(); for (let s = 0; s <= r.len; s += 4) { const p = rampPoint(r, Math.min(s, r.len)); s ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y); } }; - const [light, dark] = RAMP_LOOK[r.id], invite = who && who !== r.id && Math.sin(time * 6) > 0; + const [light, dark] = RAMP_LOOK[r.id]; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - ctx.globalAlpha = 0.5; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke(); - ctx.globalAlpha = 0.8; - ctx.strokeStyle = r.lit > 0 && Math.sin(time * 14) > 0 ? '#ffd45a' : invite ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke(); + ctx.globalAlpha = 0.45; ctx.strokeStyle = '#000'; ctx.lineWidth = 12; ctx.beginPath(); r.path.forEach(([x, y], i) => i ? ctx.lineTo(x, y + 4) : ctx.moveTo(x, y + 4)); ctx.stroke(); + ctx.globalAlpha = 1; + ctx.strokeStyle = state === 'lit' ? '#ffd45a' : state === 'invite' ? '#ffe9a8' : '#2a2420'; ctx.lineWidth = 12; path(); ctx.stroke(); ctx.strokeStyle = dark; ctx.lineWidth = 9; path(); ctx.stroke(); ctx.strokeStyle = light; ctx.lineWidth = 6; path(); ctx.stroke(); // Le pietre: trattini scuri a passo regolare lungo il ponte. ctx.strokeStyle = 'rgba(60,50,40,0.45)'; ctx.lineWidth = 1; for (let s = 6; s < r.len; s += 9) { const p = rampPoint(r, s); ctx.beginPath(); ctx.moveTo(p.x - p.dy * 3, p.y + p.dx * 3); ctx.lineTo(p.x + p.dy * 3, p.y - p.dx * 3); ctx.stroke(); } - ctx.globalAlpha = 1; // L'imbocco: la rampetta che sale, con la freccia. const [[x0, y], [x1]] = r.mouth; ctx.fillStyle = '#6a5a4a'; ctx.fillRect(x0, y - 2, x1 - x0, 6); - ctx.fillStyle = invite ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill(); + ctx.fillStyle = state === 'invite' ? '#fff6c8' : '#ffe08c'; ctx.beginPath(); ctx.moveTo((x0 + x1) / 2, y - 1); ctx.lineTo((x0 + x1) / 2 - 3, y + 3); ctx.lineTo((x0 + x1) / 2 + 3, y + 3); ctx.closePath(); ctx.fill(); +} +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'; + if (typeof document === 'undefined') { paintRamp(ctx, r, state); return; } + const key = `${r.id}:${state}`; + let layer = rampCache.get(key); + if (!layer) { + layer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); + paintRamp(layer.getContext('2d'), r, state); + rampCache.set(key, layer); + } + ctx.drawImage(layer, 0, 0); +} +let railLayer = null; +function rails(ctx, c) { + const paint = g => { for (const w of c.walls) rail(g, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6'); }; + if (typeof document === 'undefined') { paint(ctx); return; } + if (!railLayer) { railLayer = Object.assign(document.createElement('canvas'), { width: 640, height: 360 }); paint(railLayer.getContext('2d')); } + ctx.drawImage(railLayer, 0, 0); } function flipper(ctx, c, side) { const f = c.flippers[side], { a: [px, py], b: [tx, ty] } = flipperSeg(c, side); @@ -107,16 +129,30 @@ const GLYPHS = { '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], '+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], }; -// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. +// Una scritta a punti: `pitch` pixel per punto, i punti spenti appena visibili, quelli accesi col colore. La griglia +// dei punti spenti è sempre uguale: si dipinge una volta per misura su una tela a parte e si copia (centinaia di +// rettangoli trasparenti a ogni fotogramma lasciavano arretrato alla grafica, vedi effetti-leggeri). +const gridCache = new Map(); +function offGrid(ctx, n, x0, y, pitch, off) { + if (typeof document === 'undefined') return; + const key = `${n}:${pitch}:${off}`, size = Math.max(1, pitch - 1); + let layer = gridCache.get(key); + if (!layer) { + layer = Object.assign(document.createElement('canvas'), { width: n * 6 * pitch, height: 7 * pitch }); + const g = layer.getContext('2d'); g.fillStyle = off; + for (let i = 0; i < n; i++) for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) g.fillRect((i * 6 + col) * pitch, row * pitch, size, size); + gridCache.set(key, layer); + } + ctx.drawImage(layer, x0, Math.round(y)); +} function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = 'left') { const chars = [...line.toUpperCase()], w = chars.length * 6 * pitch - pitch; const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x), size = Math.max(1, pitch - 1); + offGrid(ctx, chars.length, x0, y, pitch, off); + ctx.fillStyle = on; chars.forEach((ch, i) => { const g = GLYPHS[ch] ?? GLYPHS[' ']; - for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) { - ctx.fillStyle = g[row][col] === '1' ? on : off; - ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size); - } + for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) if (g[row][col] === '1') ctx.fillRect(x0 + (i * 6 + col) * pitch, Math.round(y) + row * pitch, size, size); }); } // Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola @@ -152,6 +188,7 @@ function board(ctx, c, time) { const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`); const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3; if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); + if (flashing) regia(ctx, c.line, time - d.flash, x0, y0, w); ctx.restore(); // Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono. text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a'); @@ -165,11 +202,83 @@ function board(ctx, c, time) { if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right'); else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a'); } +// La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al +// Palio le quattro bandiere dei rioni che sfilano, ai ponti due archi di punti che si uniscono. +const CROWN = ['10001000100010001', '11011101110111011', '11111111111111111', '01111111111111110', '01111111111111110']; +function regia(ctx, line, age, x0, y0, w) { + const cx = x0 + w / 2; + if (line === 'TRAGUARDO!') { + const cols = Math.min(17, Math.floor(age * 12)); + for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, y0 + 24 + row * 4, 3, 3); } + } else if (line === 'PALIO!') { + ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = x0 + 10 + ((age * 140 + i * 40) % (w - 20)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), y0 + 60, 8, 6); ctx.fillRect(Math.round(x), y0 + 88, 8, 6); }); + } else if (line === 'I PONTI!') { + const k = Math.min(1, age / 0.8); + for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); } + } +} // I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo. 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; } +// --- Gli effetti (c.fx): piccoli e tanti, niente filtri. Ognuno vive dal suo `t` per un tempo suo. +const CONFETTI = ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0', '#ffd45a']; +function drawFx(ctx, c, time) { + for (const f of c.fx) { + const age = time - f.t; + if (f.kind === 'ring' && age < 0.4) { + ctx.globalAlpha = 1 - age / 0.4; ctx.strokeStyle = '#fff4c8'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(f.x, f.y, 10 + age * 110, 0, Math.PI * 2); ctx.stroke(); + } else if (f.kind === 'spark' && age < 0.6) { + ctx.globalAlpha = 1 - age / 0.6; + for (let i = 0; i < 10; i++) { + const a = hash(i * 7 + f.t * 100) * Math.PI * 2, v = 80 + hash(i * 13 + 1) * 140; + ctx.fillStyle = i % 2 ? '#fff4c8' : '#ffb13a'; + ctx.fillRect(Math.round(f.x + Math.cos(a) * v * age), Math.round(f.y + Math.sin(a) * v * age + 220 * age * age), 2, 2); + } + } else if (f.kind === 'burst' && age < 1.6) { + const n = f.big ? 48 : 24; + ctx.globalAlpha = Math.min(1, (1.6 - age) / 0.5); + for (let i = 0; i < n; i++) { + const a = hash(i * 7 + 3) * Math.PI * 2, v = (f.big ? 120 : 80) + hash(i * 13 + 5) * 160, spin = hash(i * 3 + 9) * 10; + const x = f.x + Math.cos(a) * v * age * 0.8, y = f.y + Math.sin(a) * v * age * 0.6 + 160 * age * age; + if (x < FIELD.x0 || x > FIELD.x1 || y > FIELD.y1) continue; + ctx.fillStyle = CONFETTI[i % CONFETTI.length]; + const w = 3 + 2 * Math.abs(Math.sin(time * spin + i)), h = 2; + ctx.fillRect(Math.round(x - w / 2), Math.round(y), Math.round(w), h); + } + } else if (f.kind === 'chase' && age < 1.2) { + const r = c.ramps.find(k => k.id === f.ramp); + if (!r) continue; + ctx.globalAlpha = Math.min(1, (1.2 - age) / 0.3); + for (let k = 0; k < 8; k++) { + const s = (age * 520 + k * 28) % (r.len + 1), p = rampPoint(r, s); + ctx.fillStyle = k % 2 ? '#ffd45a' : '#fff6c8'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2); ctx.fill(); + } + } else if (f.kind === 'pop' && age < 1) { + ctx.globalAlpha = 1 - age; + text(ctx, f.text, f.x, f.y - age * 28, age < 0.15 ? 8 : 6, '#fff4c8', 'center'); + } else if (f.kind === 'wash' && age < 0.3) { + // Un lampo colorato sulla tavola sola, per un terzo di secondo: una passata piccola, non a tutto schermo. + ctx.globalAlpha = 0.35 * (1 - age / 0.3); ctx.fillStyle = `rgb(${f.color})`; + ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); + } + } + ctx.globalAlpha = 1; +} +// La scossa del mobile ai colpi grossi: tutta la scena trema per un terzo di secondo. +function shake(c, time) { + let k = 0; + for (const f of c.fx) if (f.kind === 'shake') k = Math.max(k, 1 - (time - f.t) / 0.35); + 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(); }); + ctx.globalAlpha = 1; +} function result(ctx, c) { panel(ctx, 16, 238, 172, 56); text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center'); @@ -177,8 +286,9 @@ function result(ctx, c) { } export function drawFlipper(ctx, c, def, bg) { - const time = c.visual.time; + const time = c.visual.time, [sx, sy] = shake(c, time); ctx.save(); + if (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); } if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#e9d9b4'; ctx.fillRect(FIELD.x0, FIELD.y0, FIELD.x1 - FIELD.x0, FIELD.y1 - FIELD.y0); } // Le luci delle corsie dei rioni, sulla riga che conta, sotto le bandiere. @@ -186,8 +296,9 @@ export function drawFlipper(ctx, c, def, bg) { ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(l.x - 7, l.y - 3, 14, 7); disc(ctx, l.x, l.y, 6, l.lit ? COLORS[l.rione] : '#2a2622', l.lit ? '#fff4c8' : '#6a6050'); } - // Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. - for (const w of c.walls) rail(ctx, w.a, w.b, w.sling ? '#5a1d16' : '#2a2622', w.sling ? '#e8584a' : '#c9c3b6'); + // Le sponde: tutte le pareti della fisica, cromate; le elastiche con la gomma rossa. Non cambiano mai: dipinte + // una volta su una tela a parte. + rails(ctx, c); // I bersagli a caduta: in piedi colorati col bordo scuro, caduti un segno a terra. for (const t of c.targets) { const [x, y0] = t.a, y1 = t.b[1]; @@ -200,10 +311,12 @@ export function drawFlipper(ctx, c, def, bg) { regolo(ctx, c.regolo); for (const side of ['left', 'right']) flipper(ctx, c, side); plunger(ctx, c); - // La pallina sotto i ponti, se è sulla tavola; sopra, se è su un ponte. + // 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); + drawFx(ctx, c, time); // 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); diff --git a/src/engine/flipper.js b/src/engine/flipper.js index b8d6067..4c606a1 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -87,7 +87,7 @@ 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, visual: { time: 0 }, ...layout() }; + ball: null, saveUntil: 0, still: 0, anchor: null, rest: 0, resets: {}, result: null, lastRamp: null, lines: [], lineId: 0, fx: [], trail: [], visual: { time: 0 }, ...layout() }; newBall(c); return c; } @@ -107,10 +107,19 @@ function say(c, line, front = false) { if (front || !c.line) { if (front && c.line) c.lines.unshift(c.line); c.line = line; c.lineT = 2; c.lineId++; } else c.lines.push(line); } -// I punti; al traguardo la vittoria è acquisita (anche uscendo prima), poi si continua per il record. -function add(c, points) { +// Gli effetti sulla tavola (Andrea: «più spettacolare»): anelli, scintille, coriandoli, luci che corrono, punti che +// volano, il lampo colorato e la scossa. Qui si segnano col loro tempo; li disegna flipper-render.js, e durano FX_LIFE. +export const FX_LIFE = 2; +function fx(c, kind, x, y, extra = {}) { c.fx.push({ kind, x, y, t: c.visual.time, ...extra }); } +// 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) { c.score += points; - if (!c.result && c.score >= c.skill.goal) { c.result = { winner: 'player', score: c.score, goal: c.skill.goal }; say(c, 'TRAGUARDO!', true); c.events.push({ kind: 'flipper-palio', power: 1 }); } + 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); + 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; } @@ -151,8 +160,11 @@ export function update(c, dt) { if (c.lineT > 0 && (c.lineT -= dt) <= 0) { c.line = c.lines.shift() ?? null; if (c.line) { c.lineT = 2; c.lineId++; } } if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } for (const b of c.bumpers) b.hit = Math.max(0, b.hit - dt); c.regolo.hit = Math.max(0, c.regolo.hit - dt); + c.fx = c.fx.filter(f => c.visual.time - f.t < FX_LIFE); 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; } function moveFlippers(c, h) { for (const side of ['left', 'right']) { @@ -205,8 +217,11 @@ function crossLanes(c, from, ball) { 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; - l.lit = true; add(c, SCORE.lane); c.events.push({ kind: 'flipper-corsia', power: 0.6 }); - if (c.lanes.every(k => k.lit)) { add(c, SCORE.palio); say(c, 'PALIO!'); c.events.push({ kind: 'flipper-palio', power: 1 }); for (const k of c.lanes) k.lit = false; } + 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)) { + 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); + } } // L'imbocco di una rampa: la pallina lo attraversa in salita, abbastanza forte, e sale sul ponte: da lì segue il // percorso (niente urti con la tavola sotto), rallentando in salita e riprendendo in discesa. Troppo piano, torna @@ -240,8 +255,11 @@ function rideRamp(c, ball) { 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; - add(c, SCORE.ramp); r.lit = 3; c.events.push({ kind: 'flipper-bersaglio', power: 0.9 }); - 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-palio', power: 1 }); c.lastRamp = null; } + 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 (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 }); + } else { say(c, r.id === 'sinistra' ? 'PONTE SX' : 'PONTE DX'); say(c, `+${SCORE.ramp}`); c.lastRamp = { id: r.id, at: c.sim }; } } else if (on.s <= 0) { // Non ce l'ha fatta: ricasca dall'imbocco, lungo la direzione del ponte. @@ -249,7 +267,7 @@ function rideRamp(c, ball) { } else { ball.vx = p.dx * on.v; ball.vy = p.dy * on.v; } } function drain(c) { - c.events.push({ kind: 'flipper-buco', power: 0.8 }); + c.events.push({ kind: 'flipper-buco', power: 0.8 }); fx(c, 'wash', 0, 0, { color: '40,60,160' }); if (c.sim < c.saveUntil) { say(c, 'SALVATA!'); c.saved = true; newBall(c); return; } c.balls--; c.saved = false; c.lastRamp = null; if (c.balls > 0) { say(c, `PALLINE: ${c.balls}`); newBall(c); return; } @@ -280,15 +298,15 @@ function collideSeg(c, ball, w) { 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; - add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); + add(c, SCORE.sling); c.events.push({ kind: 'flipper-sponda', power: 0.6 }); fx(c, 'spark', ball.x, ball.y); } else { ball.vx += 1.3 * hit.vn * hit.nx; ball.vy += 1.3 * hit.vn * hit.ny; } } function collideTarget(c, ball, t) { const hit = hitSeg(ball, t, 0.5); if (!hit || !hit.fresh || hit.vn < IMPACT) return; - t.down = true; add(c, SCORE.target); c.events.push({ kind: 'flipper-bersaglio', power: 0.7 }); + 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)) { add(c, SCORE.bank); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); c.resets[t.bank] = c.sim + BANK_RESET; } + if (bank.every(k => k.down)) { add(c, SCORE.bank, [t.a[0] + (t.bank === 'pieve' ? 30 : -30), 174]); say(c, t.bank === 'pieve' ? 'LA PIEVE!' : 'LA TORRE!'); 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) { @@ -306,12 +324,12 @@ function collideBumper(c, ball, b) { if (!hit || !hit.fresh || hit.vn < IMPACT) 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); c.events.push({ kind: 'flipper-fungo', power: 0.8 }); + 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; - c.regolo.hit = 0.3; add(c, SCORE.regolo); say(c, 'IL REGOLO!'); c.events.push({ kind: 'flipper-bersaglio', power: 1 }); + 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); } // Il flipper è una capsula che gira: alla pallina si aggiunge la velocità del punto toccato. function collideFlipper(c, ball, side) { diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index a3f0236..8f98e27 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -169,6 +169,8 @@ export class Soundscape { else if (kind === 'flipper-bersaglio') { this.tone(1400, 1200, 0.05, 0.05, 0, pan); this.tone(2100, 1800, 0.08, 0.04, 0.05, pan); } else if (kind === 'flipper-corsia') { this.tone(1046, 1046, 0.08, 0.04, 0, pan); this.tone(1318, 1318, 0.1, 0.04, 0.08, pan); } 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-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); } diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index ecc0974..33f3efa 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -190,6 +190,27 @@ test('i ponti: imboccati forte si sale, si passa sopra la tavola e si scende nel assert.equal(g.balls, 2); assert.equal(g.lastRamp, null, 'azzerata con la pallina persa'); }); +test('gli effetti: ogni colpo lascia il suo segno (anello, scintille, punti che volano, coriandoli, luci sui ponti, scossa), e dopo un po\' sparisce', () => { + const c = m.newFlipper(17, { level: 'bona' }); m.launch(c, 0.9); + const kinds = () => c.fx.map(f => f.kind); + const b = c.bumpers[0]; put(c, { x: b.x, y: b.y + b.r + 6, vx: 0, vy: -200 }); run(c, 0.05); + assert.ok(kinds().includes('ring') && kinds().includes('pop') && kinds().includes('spark'), `fungo: ${kinds()}`); + assert.equal(c.fx.find(f => f.kind === 'pop').text, '+1000'); + c.fx.length = 0; c.score = c.skill.goal - 100; put(c, { x: c.regolo.x, y: c.regolo.y + 12, vx: 0, vy: -200 }); run(c, 0.05); + assert.ok(kinds().includes('burst') && kinds().includes('wash') && kinds().includes('shake'), `traguardo: ${kinds()}`); + c.fx.length = 0; put(c, { x: 247, y: 250, vx: 0, vy: -470 }); run(c, 2.5); + assert.ok(kinds().includes('chase'), `ponte: ${kinds()}`); + assert.ok(c.trail.length > 5, 'la cometa'); + const ball = c.ball; c.ball = null; run(c, m.FX_LIFE + 0.1); c.ball = ball; + assert.equal(c.fx.length, 0, 'spariti'); + // Disegnarli non cambia niente. + const calls = [], gradient = { addColorStop() {} }; + const ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => { calls.push([k, ...args]); return gradient; }, set: (o, k, v) => { o[k] = v; return true; } }); + put(c, { x: 247, y: 250, vx: 0, vy: -470 }); run(c, 0.4); c.fx.push({ kind: 'burst', x: 320, y: 100, t: c.visual.time, big: true }, { kind: 'shake', x: 0, y: 0, t: c.visual.time }); + const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, {}); assert.deepEqual(c, before); + assert.ok(calls.some(([op]) => op === 'translate'), 'la scossa sposta la scena'); +}); + test('giocando con attenzione, alla bona si arriva al traguardo; nessuna partita si incastra', () => { let won = 0; for (let seed = 1; seed <= 8; seed++) {