Effetti ambientali nelle altre 35 scene (lavoro di Codex): piccioni, merli, gatto, farfalle, libellula, falena, acqua che scorre, fontanella, tende, ombre delle fronde e delle nuvole

Controllato: test, partita completa e il giro di schermate degli effetti (npm run browser:effects).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
2026-10-25 00:55:39 +02:00
co-authored by Claude Opus 5.5
parent 55bb814cc0
commit aec43e6ee4
8 changed files with 411 additions and 14 deletions
+104
View File
@@ -0,0 +1,104 @@
// Controllo grafico degli ambienti, senza titolo/audio: tutte le scene nelle tre fasi,
// ritagli dei nuovi effetti, pause degli animali e nessuna modifica alla partita.
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const out = process.env.OUT ?? '/tmp/regolo-effects';
mkdirSync(out, { recursive: true });
const port = 9000 + Math.floor(Math.random() * 900);
const server = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1'], { stdio: 'ignore' });
let browser;
try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('response', r => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); });
// La pagina vive sullo stesso server del gioco, ma non avvia main.js e il suo RAF.
await page.route('**/__effects', route => route.fulfill({ contentType: 'text/html', body: '<style>body{margin:0}canvas{display:block}</style><canvas width="640" height="360"></canvas>' }));
await page.goto(`http://127.0.0.1:${port}/__effects`);
await page.evaluate(async () => {
const [{ story }, { Game }, renderer, { ambience }] = await Promise.all([
import('/src/story/story.js'), import('/src/engine/game.js'), import('/src/engine/render.js'), import('/src/story/ambience.js'),
]);
// Attende davvero il server prima delle immagini, senza dipendere da una pausa fissa.
const loaded = await renderer.loadAssets(story);
if (loaded.some(ok => !ok)) throw new Error('Uno sfondo/personaggio non è arrivato');
await Promise.all(Array.from({ length: 8 }, (_, i) => renderer.loadBackground(`assets/effects/nuvola-${i + 1}.png`)));
Object.assign(window, { story, Game, renderer, ambience, ctx: document.querySelector('canvas').getContext('2d') });
});
const scenes = await page.evaluate(() => Object.keys(story.scenes));
for (const phase of ['pomeriggio', 'tramonto', 'sera']) {
for (const id of scenes) {
const result = await page.evaluate(({ id, phase }) => {
const g = new Game(story), sc = story.scenes[id];
g.state.scene = id;
[g.state.x, g.state.y] = sc.entry;
g.state.flags = phase === 'sera' ? { casetta_aperta: true } : phase === 'tramonto' ? { indizio_regolo: true } : {};
g.enteredAt = 0;
const saved = JSON.stringify(g.snapshot()), times = [8, 15, 28, 48];
let first;
for (const time of times) {
g.time = time;
renderer.render(ctx, g, { time: time * 1000 });
if (time === 8) first = ctx.canvas.toDataURL();
if (ctx.globalAlpha !== 1 || !ctx.getTransform().isIdentity) throw new Error(`${id}: stato canvas non ripristinato`);
}
return { first, last: ctx.canvas.toDataURL(), unchanged: JSON.stringify(g.snapshot()) === saved };
}, { id, phase });
assert.ok(result.unchanged, `${id}: gli effetti modificano il salvataggio`);
writeFileSync(`${out}/${id}-${phase}.png`, Buffer.from(result.first.split(',')[1], 'base64'));
if (phase === 'pomeriggio') writeFileSync(`${out}/${id}-later.png`, Buffer.from(result.last.split(',')[1], 'base64'));
}
}
const checked = await page.evaluate(() => {
let clips = 0, phases = 0, pauses = 0;
const scene = { entry: [620, 355], effects: [], hotspots: [] };
const testStory = { ...story, start: { scene: 'test', at: scene.entry }, scenes: { test: scene } };
const g = new Game(testStory);
const pixels = effects => {
scene.effects = effects;
renderer.render(ctx, g, { time: g.time * 1000 });
return ctx.getImageData(0, 0, 640, 360).data;
};
const changed = (a, b) => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true; return false; };
const mask = document.createElement('canvas'); mask.width = 640; mask.height = 360;
const mc = mask.getContext('2d');
for (const [id, effects] of Object.entries(ambience)) for (const e of effects) {
g.time = ((e.every ?? 0) + (e.duration ?? 12000) * 0.5 - (e.offset ?? 0)) / 1000;
g.state.flags = e.phase === 'sera' ? { casetta_aperta: true } : {};
const before = pixels([]), after = pixels([e]);
if (e.clip) {
mc.clearRect(0, 0, 640, 360); mc.beginPath();
e.clip.forEach(([x, y], i) => i ? mc.lineTo(x, y) : mc.moveTo(x, y));
mc.closePath(); mc.fill();
const allowed = mc.getImageData(0, 0, 640, 360).data;
for (let i = 0; i < before.length; i += 4) {
if (allowed[i + 3]) continue;
for (let c = 0; c < 4; c++) if (before[i + c] !== after[i + c]) throw new Error(`${id}/${e.type}: disegna fuori dal ritaglio`);
}
clips++;
}
if (['cat', 'groundbird', 'insect'].includes(e.type)) {
if (!changed(before, after)) throw new Error(`${id}/${e.type}: animale invisibile`);
g.time = (e.every + e.duration + 100 - (e.offset ?? 0)) / 1000;
if (changed(pixels([]), pixels([e]))) throw new Error(`${id}/${e.type}: manca la pausa`);
pauses++;
}
if (e.phase) {
g.state.flags = e.phase === 'sera' ? {} : { casetta_aperta: true };
if (changed(pixels([]), pixels([e]))) throw new Error(`${id}/${e.type}: appare nella fase sbagliata`);
phases++;
}
}
return { clips, phases, pauses };
});
assert.deepEqual(errors, []);
console.log(`${scenes.length} scene × 3 fasi × 4 istanti; ${checked.clips} ritagli, ${checked.phases} condizioni giorno/sera, ${checked.pauses} pause animali. Immagini: ${out}`);
} finally {
await browser?.close();
server.kill();
}
+17
View File
@@ -85,5 +85,22 @@ test('gli effetti delle scene: tipi che il motore conosce, e dentro la scena', a
for (const ph of [e.phase ?? []].flat()) assert.ok(phases.has(ph), `${id}: fase sconosciuta ${ph}`);
const pts = [...(e.points ?? []), ...(e.at ? [e.at] : []), ...(e.area ? [e.area.slice(0, 2), [e.area[0] + e.area[2], e.area[1] + e.area[3]]] : []), ...(e.rects ?? []).map(r => r.slice(0, 2))];
for (const [x, y] of pts) assert.ok(x >= 0 && x <= 640 && y >= 0 && y <= 360, `${id}: ${e.type} fuori scena`);
if (e.clip) {
assert.ok(e.clip.length >= 3, `${id}: ritaglio senza area`);
for (const [x, y] of e.clip) assert.ok(Number.isFinite(x) && Number.isFinite(y) && x >= 0 && x <= 640 && y >= 0 && y <= 360, `${id}: ritaglio fuori scena`);
}
if (e.duration) assert.ok(e.duration > 0 && e.duration < e.every, `${id}: manca la pausa fra gli eventi`);
}
});
test('gli animali hanno vere pause e ingressi graduali, anche con offset diversi', async () => {
const { ambientMoment } = await import('../src/engine/ambient.js');
const timing = { every: 40000, duration: 10000, offset: 3000 };
assert.equal(ambientMoment(8000, timing), null);
assert.equal(ambientMoment(36000, timing), null);
assert.deepEqual(ambientMoment(37000, timing), { t: 0, alpha: 0 });
assert.equal(ambientMoment(42000, timing).alpha, 1);
assert.ok(ambientMoment(46900, timing).alpha < 0.2);
assert.equal(ambientMoment(47000, timing), null);
assert.deepEqual(ambientMoment(42000, timing), ambientMoment(82000, timing));
});