- Il carattere è dentro il gioco (assets/fonts, con la licenza); testi ridotti e senza grassetti finti. - Mappa con etichette più piccole, crediti con un nome per riga, verbi sopra l'inventario sul telefono (la prova nel browser controlla che nessun verbo sia tagliato). - Crediti: Tozzino (Francesco Tozzi) fra le idee, il carattere fra i ringraziamenti. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017egeN2amL84ErjfgQFtEDn
192 lines
11 KiB
JavaScript
192 lines
11 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');
|
||
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3$/, 'la musichetta del cartello');
|
||
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
|
||
await page.waitForTimeout(2800);
|
||
await page.screenshot({ path: `${OUT}/00-cartello.png` });
|
||
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
|
||
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
|
||
await page.waitForTimeout(350);
|
||
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
|
||
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
|
||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
||
await page.click('#title .open-credits');
|
||
assert.match(await page.textContent('#credits'), /Crediti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
||
assert.match(await page.textContent('#credits'), /Yuri Baldi/);
|
||
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
||
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
|
||
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
|
||
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
|
||
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
|
||
await page.keyboard.press('Escape');
|
||
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
|
||
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);
|
||
|
||
// Doppio clic su un punto della scena: Andrea ci va di corsa.
|
||
const r = await page.locator('#scene').boundingBox();
|
||
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
|
||
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
|
||
await settle(page);
|
||
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForSelector('#pause:not([hidden])');
|
||
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
|
||
await page.click('#resume');
|
||
assert.ok(await page.isHidden('#pause'));
|
||
await page.click('#pause-button');
|
||
await page.click('#to-title');
|
||
await page.waitForSelector('#continue:not([hidden])');
|
||
await page.click('#continue');
|
||
await settle(page);
|
||
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
|
||
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
|
||
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
|
||
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
|
||
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` });
|
||
await page.click('#ending .open-credits');
|
||
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
|
||
await page.click('#close-credits');
|
||
assert.ok(await page.isHidden('#credits'));
|
||
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');
|
||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||
await settle(page);
|
||
await page.click('#map-button');
|
||
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
||
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
||
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
||
await page.click('#map-board button[data-scene="piazza"]');
|
||
assert.ok(await page.isHidden('#map'));
|
||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||
|
||
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);
|
||
// Ogni verbo si vede tutto: niente pulsanti tagliati dall'inventario.
|
||
assert.ok(await phone.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().right <= innerWidth)), 'verbi tagliati sul telefono');
|
||
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();
|
||
}
|