tools/voci.mjs (make voci) genera un mp3 per ogni battuta con la voce del personaggio scelta in tools/voci.json, solo per i testi nuovi o cambiati; tools/voci_xtts.py divide le frasi e toglie punti finali e caporali, che XTTS in italiano leggeva («Buongiorno, punto»). Nel gioco il fumetto dura quanto la voce, la musica si abbassa mentre si parla, il muto spegne anche le voci. Il leone ora parla e i personaggi parlano dalla posizione che la scena dichiara (speakers), che prima era ignorata. Tutte le voci verificate con Whisper; test: ogni battuta ha la sua voce, il fumetto non finisce prima della voce, la musica si abbassa (provati con errori iniettati). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
142 lines
7.5 KiB
JavaScript
142 lines
7.5 KiB
JavaScript
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
|
||
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
||
// npm run browser
|
||
import { chromium } from 'playwright';
|
||
import { spawn } from 'node:child_process';
|
||
import { mkdirSync } from 'node:fs';
|
||
import assert from 'node:assert/strict';
|
||
import { solution } from '../src/story/solution.js';
|
||
|
||
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900);
|
||
mkdirSync(OUT, { recursive: true });
|
||
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' });
|
||
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||
const errors = [];
|
||
|
||
async function open(viewport) {
|
||
const page = await browser.newPage({ viewport });
|
||
page.on('pageerror', e => errors.push(e.message));
|
||
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
|
||
for (let i = 0; i < 50; i++) {
|
||
try { await page.goto(`http://127.0.0.1:${PORT}/`); break; } catch { await page.waitForTimeout(100); }
|
||
}
|
||
return page;
|
||
}
|
||
|
||
// Clic su un punto della scena (coordinate 640×360).
|
||
async function clickScene(page, [x, y]) {
|
||
const r = await page.locator('#scene').boundingBox();
|
||
await page.mouse.click(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
|
||
}
|
||
|
||
// Aspetta che il gioco sia fermo, saltando le battute con la barra spaziatrice.
|
||
async function settle(page) {
|
||
for (let i = 0; i < 400; i++) {
|
||
const s = await page.evaluate(() => ({ speech: !!game.speech, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
|
||
if (s.speech) await page.keyboard.press('Space');
|
||
else if (s.idle) return;
|
||
await page.waitForTimeout(50);
|
||
}
|
||
throw new Error('il gioco non si ferma');
|
||
}
|
||
|
||
const hotspotCenter = (page, id) => page.evaluate(id => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
|
||
|
||
try {
|
||
const page = await open({ width: 1280, height: 860 });
|
||
// Il cartello d'apertura, poi da solo il titolo.
|
||
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
|
||
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
|
||
await page.waitForTimeout(2800);
|
||
await page.screenshot({ path: `${OUT}/00-cartello.png` });
|
||
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||
assert.ok(await page.isHidden('#splash'), 'il cartello sparisce');
|
||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||
await page.click('#new-game');
|
||
// L'introduzione: una riga per clic, poi comincia la partita.
|
||
await page.waitForSelector('#intro:not([hidden])');
|
||
await page.screenshot({ path: `${OUT}/01b-intro.png` });
|
||
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
|
||
await page.waitForFunction(() => game.speech);
|
||
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
|
||
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
|
||
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3$/);
|
||
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
|
||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||
await settle(page);
|
||
|
||
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
||
assert.ok((await page.evaluate(() => music.current?.src)).endsWith('paese.mp3'), 'musica del paese');
|
||
await page.click('#mute');
|
||
await page.locator('#volume').fill('30');
|
||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false });
|
||
|
||
for (const [verb, target, item] of solution) {
|
||
if (verb === 'choose') {
|
||
await page.locator('#choices li', { hasText: target }).click();
|
||
} else {
|
||
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||
if (item) {
|
||
await page.click(`#verbs [data-verb="${verb}"]`);
|
||
await page.click(`#inventory [data-item="${item}"]`);
|
||
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
||
await clickScene(page, await hotspotCenter(page, target));
|
||
}
|
||
await page.waitForTimeout(80);
|
||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||
await settle(page);
|
||
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
||
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
|
||
}
|
||
await page.waitForSelector('#ending:not([hidden])');
|
||
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
||
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
||
|
||
// Una partita a metà riprende da dove era rimasta.
|
||
await page.click('#restart');
|
||
await settle(page);
|
||
for (const exit of ['verso_villa_adami', 'su_villa_adami', 'salita', 'su_fortezza', 'sentiero_trebbio', 'al_trebbio']) {
|
||
await clickScene(page, await hotspotCenter(page, exit));
|
||
await settle(page);
|
||
}
|
||
await page.click('#verbs [data-verb="talk"]');
|
||
await clickScene(page, await hotspotCenter(page, 'torre'));
|
||
await page.locator('#choices li', { hasText: 'bloccato' }).click();
|
||
await page.waitForFunction(() => game.speech?.who === 'castello');
|
||
await page.waitForTimeout(100);
|
||
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
|
||
await settle(page);
|
||
await page.locator('#choices li', { hasText: 'pace' }).click();
|
||
await settle(page);
|
||
await page.reload();
|
||
await page.waitForSelector('#continue:not([hidden])');
|
||
await page.click('#continue');
|
||
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
||
|
||
const phone = await open({ width: 390, height: 844 });
|
||
await phone.click('#splash'); // un clic salta il cartello
|
||
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
|
||
await phone.click('#new-game');
|
||
await phone.click('#skip-intro');
|
||
await settle(phone);
|
||
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
|
||
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
|
||
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
|
||
const landscape = await open({ width: 844, height: 390 });
|
||
await landscape.click('#new-game');
|
||
await landscape.click('#skip-intro');
|
||
await settle(landscape);
|
||
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
|
||
|
||
assert.deepEqual(errors, [], 'errori nella console');
|
||
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
|
||
} finally {
|
||
await browser.close();
|
||
server.kill();
|
||
}
|