Il flipper alleggerito senza cambiarne l'aspetto: il mobile statico sullo sfondo CSS della tela invece di una copia a tutto schermo per fotogramma, estremità e giunti arrotondati solo nei livelli in cache; arretrato grafico da 4,2 s a 0,03 s per 150 fotogrammi; la misura dell'arretrato nella prova nel browser al posto dell'intervallo fra frame, che non misurava nulla (Andrea)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-11-01 00:14:31 +01:00
co-authored by Claude Fable 5.1
parent b660bbeb96
commit 77a8777dcd
4 changed files with 47 additions and 13 deletions
+5 -1
View File
@@ -56,6 +56,9 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
coordinate logiche 640×360, gli altri ambienti restano in pixel art. Fondale di Codex conservato nel riquadro coordinate logiche 640×360, gli altri ambienti restano in pixel art. Fondale di Codex conservato nel riquadro
illustrato del backglass; niente pannelli sopra il Trebbio. Superfici e bagliori in cache, senza disegnare la illustrato del backglass; niente pannelli sopra il Trebbio. Superfici e bagliori in cache, senza disegnare la
strada nascosta sotto il flipper. Pannello a destra con progressione, timer, moltiplicatore e prossimo obiettivo. strada nascosta sotto il flipper. Pannello a destra con progressione, timer, moltiplicatore e prossimo obiettivo.
Alleggerito (1 novembre): il mobile statico sta sullo sfondo CSS della tela (`cabinetBehind`), la tela si pulisce
e basta; estremità e giunti arrotondati solo nei livelli in cache. Arretrato grafico da 4,2 s a 0,03 s per 150
fotogrammi (regole e misure nella memoria «effetti leggeri»).
- Rampe FORTEZZA e TREBBIO: imbocchi centrali da 36 pixel, 27 utili alla pallina, salite accessibili con colpi - Rampe FORTEZZA e TREBBIO: imbocchi centrali da 36 pixel, 27 utili alla pallina, salite accessibili con colpi
medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis. medi, 15.000 punti. Tre passaggi sulla stessa rampa entro 20 secondi dal primo: LOCK e nuova pallina gratis.
Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio Un'altra rampa completata o il tempo esaurito interrompono la serie. Dopo il primo LOCK, primo passaggio
@@ -75,7 +78,8 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca». Comandi: mouse, due dita, tasto destro, frecce, Spazio e pulsanti; istruzioni in «Come si gioca».
- Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni, - Test `flipper.test.mjs`: sequenze in entrambi gli ordini e a tutti i livelli, scadenze, premi, collisioni,
recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch, recupero della SUPER con la paletta, rilascio sfalsato e valvola. `npm run browser:flipper`: desktop e touch,
screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, intervallo fra frame. screenshot di tavolo, LOCK, SUPER, multiball e risultato in `/tmp/regolo-flipper`, e la misura dell'arretrato
grafico dopo 150 fotogrammi in multiball (deve restare sotto 1,5 s).
[DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello. [DA ANDREA] battute, premio nell'avventura (`flipper_vinto`), record persistente per livello.
- **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio - **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.» Mario, che mi ha fatto vivere tante avventure più belle della fantasia.»
+26 -7
View File
@@ -44,15 +44,31 @@ function glow(g, x, y, radius, color, alpha = 1) {
g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore(); g.save(); g.globalAlpha *= alpha; g.globalCompositeOperation = 'screen'; g.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2); g.restore();
} }
const layers = new Map(); const layers = new Map();
function cached(g, key, paint, bounds = [0, 0, 640, 360]) { function layerOf(key, paint, bounds) {
if (typeof document === 'undefined') { paint(g); return; }
let layer = layers.get(key); let layer = layers.get(key);
if (!layer) { if (!layer) {
layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 }); layer = Object.assign(document.createElement('canvas'), { width: bounds[2] * 2, height: bounds[3] * 2 });
const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer); const l = layer.getContext('2d'); l.scale(2, 2); l.translate(-bounds[0], -bounds[1]); paint(l); layers.set(key, layer);
} }
g.drawImage(layer, ...bounds); return layer;
} }
function cached(g, key, paint, bounds = [0, 0, 640, 360]) {
if (typeof document === 'undefined') { paint(g); return; }
g.drawImage(layerOf(key, paint, bounds), ...bounds);
}
// Il mobile, che non cambia mai, sta sullo sfondo CSS della tela in pagina invece di essere copiato a ogni fotogramma:
// una copia a tutto schermo fra gli altri tratti costava da sola mezzo secondo ogni 150 fotogrammi (swiftshader). La
// tela si pulisce e il mobile resta sotto; si ridisegna sulla tela solo durante la scossa, che lo sposta.
const sheets = new Map();
function cabinetBehind(g, key, paint) {
const canvas = g.canvas;
if (typeof document === 'undefined' || !(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return false;
let url = sheets.get(key);
if (!url) { url = `url(${layerOf(key, paint, [0, 0, 640, 360]).toDataURL()})`; sheets.set(key, url); }
if (canvas.dataset.cabinet !== key) { canvas.style.backgroundImage = url; canvas.style.backgroundSize = '100% 100%'; canvas.dataset.cabinet = key; }
return true;
}
export function cabinetAway(canvas) { canvas.style.backgroundImage = ''; canvas.style.backgroundSize = ''; delete canvas.dataset.cabinet; }
function crown(g, x, y, size, color) { function crown(g, x, y, size, color) {
poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color); poly(g, [[x - size, y + size / 2], [x - size * 1.1, y - size / 2], [x - size / 2, y], [x, y - size], [x + size / 2, y], [x + size * 1.1, y - size / 2], [x + size, y + size / 2]], color);
line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1); line(g, [[x - size, y + size * 0.8], [x + size, y + size * 0.8]], color, 1);
@@ -357,10 +373,14 @@ function effects(g, c, time) {
} }
export function drawFlipper(g, c, def, bg, glass) { export function drawFlipper(g, c, def, bg, glass) {
const time = c.visual.time; const time = c.visual.time;
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'round'; g.lineJoin = 'round'; // Niente estremità e giunti arrotondati nei tratti di ogni fotogramma: costano più di tutto il resto (vedi la
// memoria «effetti leggeri»); il mobile e i binari, in cache, li tengono.
g.save(); g.imageSmoothingEnabled = true; g.lineCap = 'butt'; g.lineJoin = 'miter';
const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0); const shake = c.fx.reduce((n, f) => f.kind === 'shake' ? Math.max(n, 1 - (time - f.t) / 0.3) : n, 0);
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); } const key = `cabinet:${!!bg}:${!!glass}`, paint = layer => cabinet(layer, c, bg, glass);
cached(g, `cabinet:${!!bg}:${!!glass}`, layer => cabinet(layer, c, bg, glass)); if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); }
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
else cached(g, key, paint);
for (const [i, l] of c.lanes.entries()) { for (const [i, l] of c.lanes.entries()) {
if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55); if (l.lit) glow(g, l.x, l.y, 17, RIONI[i], 0.55);
disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b'); disc(g, l.x, l.y, 5, '#06151d', l.lit ? RIONI[i] : '#47616b');
@@ -378,7 +398,6 @@ export function drawFlipper(g, c, def, bg, glass) {
const color = [TINT.sinistra, TINT.destra, TINT.super][i]; const color = [TINT.sinistra, TINT.destra, TINT.super][i];
b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); }); b.trail.forEach(([x, y], k) => { g.save(); g.globalAlpha = k / b.trail.length * 0.35; disc(g, x, y - 1, 1 + k / b.trail.length * 2, color); g.restore(); });
} }
// Ogni rampa copre gli oggetti sotto; la sua pallina si disegna sul proprio piano.
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b); for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) { for (const r of c.ramps) {
if (!rampOpen(c, r)) continue; if (!rampOpen(c, r)) continue;
+2
View File
@@ -3,6 +3,7 @@ import { story } from './story/story.js';
import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js'; import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js'; import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { cabinetAway } from './engine/flipper-render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js'; import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js'; import { Voices } from './engine/voices.js';
@@ -1195,6 +1196,7 @@ function frame(now) {
if (canvas.width !== WIDTH * sceneScale) { if (canvas.width !== WIDTH * sceneScale) {
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale; canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
canvas.classList.toggle('pinball-hd', !!pinballHD); canvas.classList.toggle('pinball-hd', !!pinballHD);
if (!pinballHD) cabinetAway(canvas);
} }
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0); ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
+14 -5
View File
@@ -130,10 +130,19 @@ try {
assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 }); assert.deepEqual({ balls: multi.balls, locks: multi.locks, reserve: multi.reserve }, { balls: 3, locks: 0, reserve: 5 });
await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time); await page.waitForFunction(t => game.closeup.visual.time > t + 0.7, multi.time);
await page.screenshot({ path: `${out}/${name}-multiball.png` }); await page.screenshot({ path: `${out}/${name}-multiball.png` });
const frameMs = await page.evaluate(() => new Promise(resolve => { // L'arretrato grafico (vedi la memoria «effetti leggeri»): 150 fotogrammi disegnati di fila con tre palline e gli
const stamps = []; const tick = t => { stamps.push(t); if (stamps.length === 20) resolve((t - stamps[0]) / 19); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); // effetti vivi, poi una lettura dei pixel che aspetta la grafica. Il flipper di Codex ne lasciava 4,2 s; deve
})); // restare sotto il secondo e mezzo anche col rendering software di queste prove.
console.log(`${name}: intervallo frame medio ${frameMs.toFixed(1)} ms`); const backlog = await page.evaluate(async () => {
const m = await import('/src/engine/flipper.js'), r = await import('/src/engine/render.js'), c = game.closeup;
const ctx = document.querySelector('#scene').getContext('2d');
ctx.getImageData(0, 0, 4, 4); // si aspetta prima l'arretrato di tutta la prova fin qui
for (let i = 0; i < 150; i++) { m.update(c, 1 / 60); r.render(ctx, game, { time: performance.now() }); }
const t = performance.now(); ctx.getImageData(0, 0, 4, 4); return Math.round(performance.now() - t);
});
console.log(`${name}: arretrato grafico dopo 150 fotogrammi ${backlog} ms`);
assert.ok(backlog < 1500, `arretrato grafico ${backlog} ms`);
await page.waitForFunction(() => document.querySelector('#scene').style.backgroundImage.startsWith('url('), null, { timeout: 2000 }); // il mobile sullo sfondo CSS
await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; }); await page.evaluate(() => { game.update = window.pinballResume; delete window.pinballResume; delete window.pinballShot; });
// Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale. // Si consumano le palline rimaste, compresa quella vinta, per arrivare al risultato reale.
const finalScore = await page.evaluate(async () => { const finalScore = await page.evaluate(async () => {
@@ -150,7 +159,7 @@ try {
await page.screenshot({ path: `${out}/${name}-fine.png` }); await page.screenshot({ path: `${out}/${name}-fine.png` });
if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato if (mobile) await page.locator('#closeup-close').click(); else await page.keyboard.press('Enter'); // Invio chiude il risultato
await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup); await page.waitForFunction(() => game.state.flags.flipper_vinto && !game.closeup);
await page.waitForFunction(() => document.querySelector('#scene').width === 640); await page.waitForFunction(() => document.querySelector('#scene').width === 640 && !document.querySelector('#scene').style.backgroundImage);
await page.close(); await page.close();
} }
assert.deepEqual(errors, []); assert.deepEqual(errors, []);