Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
531 lines
35 KiB
JavaScript
531 lines
35 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 { staticServer } from './helpers.mjs';
|
||
import { mkdirSync, readFileSync } from 'node:fs';
|
||
import assert from 'node:assert/strict';
|
||
import { solution, route } from '../src/story/solution.js';
|
||
import { checkMultimedia } from './multimedia.browser.mjs';
|
||
|
||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||
mkdirSync(OUT, { recursive: true });
|
||
const server = await staticServer(), PORT = server.port;
|
||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||
const errors = [];
|
||
|
||
async function open(viewport, query = '') {
|
||
const page = await browser.newPage({ viewport, acceptDownloads: true });
|
||
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}/${query}`); 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 = async (page, id) => {
|
||
const c = await page.evaluate(id => { const h = game.hotspot(id); return h && [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
|
||
if (!c) throw new Error(`non c'è ${id} in ${await page.evaluate(() => game.state.scene)}`);
|
||
return c;
|
||
};
|
||
|
||
try {
|
||
await checkMultimedia(browser, PORT, OUT, errors);
|
||
if (!process.argv.includes('--media-only')) {
|
||
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\?v=\d+\.\d+\.\d+$/, '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 });
|
||
// Senza un gesto il browser può bloccare la musichetta (succede online su un sito mai visto, non in locale): allora
|
||
// il cartello aspetta un tocco, con l'invito a toccare, e solo dopo parte coi suoi tempi.
|
||
{
|
||
const muto = await chromium.launch(), p = await muto.newPage();
|
||
await p.goto(`http://127.0.0.1:${PORT}/`);
|
||
await p.waitForTimeout(800);
|
||
if (await p.evaluate(() => document.getElementById('splash').classList.contains('wait'))) {
|
||
assert.ok(await p.isVisible('#splash .splash-hint'), 'l\'invito a toccare');
|
||
await p.waitForTimeout(5500);
|
||
assert.ok(await p.isHidden('#title'), 'il cartello aspetta il tocco');
|
||
await p.click('#splash');
|
||
assert.ok(await p.isHidden('#splash .splash-hint'));
|
||
await p.waitForSelector('#title:not([hidden])', { timeout: 8000 });
|
||
console.log('Cartello senza suono permesso: aspetta il tocco, poi parte.');
|
||
} else console.log('(questo Chromium lascia suonare senza gesto: il caso bloccato non si prova qui)');
|
||
await muto.close();
|
||
}
|
||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||
assert.match(await page.textContent('#title .version'), /^v\d+\.\d+\.\d+$/, 'la versione nell\'angolo del titolo');
|
||
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
|
||
{
|
||
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
|
||
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
|
||
await slow.addInitScript(() => {
|
||
const observer = new MutationObserver(() => {
|
||
const title = document.getElementById('title'), splash = document.getElementById('splash');
|
||
if (title && !title.hidden && splash) {
|
||
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
|
||
});
|
||
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
|
||
await slow.goto(`http://127.0.0.1:${PORT}/`);
|
||
await slow.waitForSelector('#splash');
|
||
await slow.waitForTimeout(300);
|
||
if (await slow.evaluate(() => document.getElementById('splash').classList.contains('wait'))) await slow.click('#splash');
|
||
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
|
||
const opacity = await slow.evaluate(() => window.titleRevealOpacity);
|
||
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
|
||
await slow.close();
|
||
}
|
||
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
|
||
await page.click('#title .open-credits');
|
||
assert.match(await page.textContent('#credits'), /Ringraziamenti\s*Al Lele/, 'la dedica al Lele viene per prima');
|
||
assert.match(await page.textContent('#credits'), /Baldoria \(Yuri Baldi\)/);
|
||
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
|
||
// I nomi col ritratto: si tocca il nome, compare il volto; un altro tocco lo chiude.
|
||
await page.click('#credits .credit-face >> text=Mariano');
|
||
assert.ok(await page.isVisible('#face img'));
|
||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
||
await page.click('#face');
|
||
assert.ok(await page.isHidden('#face'));
|
||
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
||
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
||
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');
|
||
// Il rione: quattro scelte; la Pieve cambia la riga dell'introduzione e resta nella partita.
|
||
await page.waitForSelector('#rione:not([hidden])');
|
||
assert.equal(await page.locator('#rione-buttons button').count(), 4);
|
||
await page.screenshot({ path: `${OUT}/01a-rione.png` });
|
||
assert.equal(await page.locator('#rione-buttons button:disabled').count(), 3, 'per ora gli altri tre rioni sono chiusi');
|
||
await page.click('#rione [data-rione="campomigliaio"]');
|
||
// L'introduzione: una riga per clic, poi comincia la partita.
|
||
await page.waitForSelector('#intro:not([hidden])');
|
||
for (let i = 0; i < 2; i++) await page.click('#intro', { position: { x: 40, y: 40 } });
|
||
assert.match(await page.textContent('#intro-text'), /Campomigliaio, i blu/, 'la riga del rione scelto');
|
||
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\?v=\d+\.\d+\.\d+$/);
|
||
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
|
||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||
assert.ok(await page.evaluate(() => game.state.flags['rione:campomigliaio']), 'il rione resta nella partita');
|
||
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);
|
||
// «?» mostra per qualche secondo cosa si può toccare.
|
||
await page.click('#spots-button');
|
||
await page.screenshot({ path: `${OUT}/02c-punti.png` });
|
||
// Inventario pieno (16 oggetti di prova): resta alto quanto i verbi, dentro lo schermo, si scorre e mostra l'ultimo preso.
|
||
await page.evaluate(() => {
|
||
const items = game.story.items, kinds = ['chiave_oratorio', 'libro', 'squama'];
|
||
for (let i = 1; i <= 16; i++) { items['prova' + i] = { name: 'prova ' + i, icon: items[kinds[i % 3]].icon }; game.state.inventory.push('prova' + i); }
|
||
});
|
||
await page.waitForTimeout(150);
|
||
const inv = await page.evaluate(() => { const e = document.getElementById('inventory'), r = e.getBoundingClientRect(), last = e.lastElementChild.getBoundingClientRect();
|
||
return { inside: r.bottom <= innerHeight, scrolls: e.scrollHeight > e.clientHeight, lastVisible: last.bottom <= r.bottom + 1 && last.top >= r.top - 1 }; });
|
||
assert.deepEqual(inv, { inside: true, scrolls: true, lastVisible: true }, 'inventario pieno');
|
||
await page.screenshot({ path: `${OUT}/02d-inventario-pieno.png` });
|
||
await page.evaluate(() => { game.state.inventory = game.state.inventory.filter(i => !i.startsWith('prova')); for (let i = 1; i <= 16; i++) delete game.story.items['prova' + i]; });
|
||
// 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` });
|
||
// Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
|
||
const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
|
||
assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
|
||
const savedFile = `${OUT}/partita.json`;
|
||
await download.saveAs(savedFile);
|
||
const before = await page.evaluate(() => game.snapshot());
|
||
await page.click('#to-title');
|
||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||
await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
|
||
await page.waitForSelector('#load-error:not([hidden])');
|
||
assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
|
||
// Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
|
||
await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
|
||
const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
|
||
lost.state.scene = 'luna';
|
||
await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
|
||
await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
|
||
assert.ok(await page.isVisible('#title'));
|
||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
|
||
await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
|
||
await page.setInputFiles('#load-file', savedFile);
|
||
await page.waitForSelector('#title', { state: 'hidden' });
|
||
assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
|
||
assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForSelector('#pause:not([hidden])');
|
||
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');
|
||
await page.locator('#voice-volume').fill('70');
|
||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
|
||
assert.equal(await page.evaluate(() => voices.volume), 0.7);
|
||
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
|
||
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
|
||
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
|
||
await hide(true);
|
||
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
|
||
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
|
||
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
|
||
await hide(false);
|
||
await page.click('#resume');
|
||
await page.waitForFunction(() => !music.current.audio.paused, null, { timeout: 5000 });
|
||
|
||
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}"]`);
|
||
// Un oggetto dell'inventario su un altro (cedrata col ghiacciolo) si clicca nell'inventario, non nella scena.
|
||
if (await page.evaluate(t => !!game.story.items[t], target)) await page.click(`#inventory [data-item="${target}"]`);
|
||
else await clickScene(page, await hotspotCenter(page, target));
|
||
}
|
||
await page.waitForTimeout(80);
|
||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
|
||
if (target === 'fessura' && item) {
|
||
await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
|
||
for (let i = 0; i < 3; i++) {
|
||
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
|
||
const box = await page.locator('#scene').boundingBox();
|
||
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
|
||
await page.waitForFunction(() => game.closeup?.step === 'reveal');
|
||
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
|
||
for (let n = 0; n < 200; n++) {
|
||
if (await page.evaluate(i => game.closeup.found > i, i)) break;
|
||
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
|
||
await page.waitForTimeout(30);
|
||
}
|
||
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
|
||
}
|
||
await page.click('#closeup-close');
|
||
}
|
||
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');
|
||
assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la 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 route('piazza', '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');
|
||
await settle(page);
|
||
// Il taccuino (N): la tappa di adesso in evidenza, Esc lo chiude.
|
||
await page.keyboard.press('n');
|
||
assert.ok(await page.isVisible('#notebook'));
|
||
assert.ok((await page.textContent('#notebook-list li.now')).length > 10, 'c\'è l\'obiettivo di adesso');
|
||
await page.screenshot({ path: `${OUT}/07a-taccuino.png` });
|
||
await page.keyboard.press('Escape');
|
||
assert.ok(await page.isHidden('#notebook'));
|
||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||
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` });
|
||
// La ricerca: scrivendo restano solo i posti giusti; una «p» nella casella non chiude la mappa; Invio ci va.
|
||
await page.fill('#map-search', 'xyz');
|
||
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
||
await page.fill('#map-search', '');
|
||
await page.click('#map-search');
|
||
await page.keyboard.type('piaz');
|
||
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
||
assert.ok(await page.isHidden('#map-none'));
|
||
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
||
assert.deepEqual(shown, ['piazza']);
|
||
await page.screenshot({ path: `${OUT}/07c-mappa-ricerca.png` });
|
||
await page.keyboard.press('Enter');
|
||
assert.ok(await page.isHidden('#map'));
|
||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
||
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
||
await page.setViewportSize({ width: 560, height: 340 });
|
||
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
||
await page.click('#map-button');
|
||
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
||
await page.fill('#map-search', 'campo');
|
||
const found = await page.evaluate(() => {
|
||
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
||
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
||
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
||
});
|
||
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
||
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
||
await page.click('#close-map');
|
||
await page.setViewportSize({ width: 1280, height: 860 });
|
||
|
||
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
|
||
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
|
||
{
|
||
const book = await open({ width: 1280, height: 860 });
|
||
await book.click('#splash');
|
||
await book.click('#new-game');
|
||
await book.click('#rione [data-rione="campomigliaio"]');
|
||
await book.click('#skip-intro');
|
||
await settle(book);
|
||
await book.evaluate(() => game.go('oratorio', [540, 300]));
|
||
await settle(book);
|
||
await book.click('#verbs [data-verb="take"]');
|
||
await clickScene(book, await hotspotCenter(book, 'libro_dimenticato'));
|
||
await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 });
|
||
await book.waitForTimeout(600);
|
||
await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` });
|
||
const where = await book.evaluate(() => [game.state.x, game.state.y]);
|
||
await clickScene(book, [320, 200]);
|
||
assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro');
|
||
await clickScene(book, [320, 200]);
|
||
assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina');
|
||
await book.waitForFunction(() => game.closeup?.step === 'end');
|
||
assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno');
|
||
await book.screenshot({ path: `${OUT}/03b-libro-fine.png` });
|
||
await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 });
|
||
assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea');
|
||
await book.keyboard.press('Space');
|
||
await book.keyboard.press('Escape');
|
||
assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro');
|
||
await book.waitForSelector('#closeup-close', { state: 'hidden' });
|
||
await settle(book);
|
||
await book.click('#verbs [data-verb="open"]');
|
||
await book.click('#inventory button');
|
||
await book.waitForFunction(() => game.closeup?.step === 'cover');
|
||
assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi');
|
||
await book.click('#closeup-close');
|
||
assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro');
|
||
await book.close();
|
||
}
|
||
|
||
// Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma.
|
||
{
|
||
const bocce = await open({ width: 1280, height: 860 });
|
||
await bocce.click('#splash');
|
||
await bocce.click('#new-game');
|
||
await bocce.click('#rione [data-rione="campomigliaio"]');
|
||
await bocce.click('#skip-intro');
|
||
await settle(bocce);
|
||
await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310]));
|
||
await settle(bocce);
|
||
await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
|
||
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
|
||
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
|
||
await bocce.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
|
||
await bocce.locator('#choices li', { hasText: 'Seria.' }).click();
|
||
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||
const r = await bocce.locator('#scene').boundingBox();
|
||
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
|
||
await bocce.mouse.down();
|
||
await bocce.waitForTimeout(500);
|
||
assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
|
||
await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` });
|
||
await bocce.mouse.up();
|
||
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
||
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
||
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
|
||
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
|
||
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
|
||
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
|
||
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
|
||
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
|
||
await bocce.keyboard.press('Escape');
|
||
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
||
await bocce.close();
|
||
}
|
||
|
||
// Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale.
|
||
{
|
||
const b = await open({ width: 1280, height: 860 });
|
||
await b.click('#splash');
|
||
await b.click('#new-game');
|
||
await b.click('#rione [data-rione="campomigliaio"]');
|
||
await b.click('#skip-intro');
|
||
await settle(b);
|
||
await b.evaluate(() => game.go('circolo_4_maggio'));
|
||
await settle(b);
|
||
await b.waitForTimeout(1500); // la dissolvenza del cambio scena
|
||
await b.screenshot({ path: `${OUT}/03d-circolo.png` });
|
||
await clickScene(b, await hotspotCenter(b, 'ing_tozzi'));
|
||
await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
|
||
await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click();
|
||
await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
|
||
await b.locator('#choices li', { hasText: 'Seria.' }).click();
|
||
await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||
const r = await b.locator('#scene').boundingBox();
|
||
const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; });
|
||
await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height);
|
||
await b.mouse.down();
|
||
await b.waitForTimeout(500);
|
||
assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
|
||
await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` });
|
||
await b.mouse.up();
|
||
assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte');
|
||
await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 });
|
||
await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` });
|
||
await b.keyboard.press('Escape');
|
||
await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 });
|
||
await b.close();
|
||
}
|
||
|
||
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('#rione [data-rione="campomigliaio"]');
|
||
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` });
|
||
// Telefono stretto in orizzontale: niente deve allargare la pagina oltre lo schermo (il browser la rimpicciolirebbe
|
||
// e taglierebbe verbi e inventario a destra), e la pausa sta tutta dentro la scena.
|
||
const small = await browser.newPage({ viewport: { width: 740, height: 330 }, deviceScaleFactor: 2.4, hasTouch: true, isMobile: true });
|
||
small.on('pageerror', e => errors.push(e.message));
|
||
await small.goto(`http://127.0.0.1:${PORT}/`);
|
||
await small.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
|
||
await small.click('#new-game');
|
||
await small.click('#rione [data-rione="campomigliaio"]');
|
||
await small.click('#skip-intro');
|
||
await settle(small);
|
||
assert.equal(await small.evaluate(() => innerWidth), 740, 'la pagina è più larga dello schermo del telefono');
|
||
await small.click('#pause-button');
|
||
assert.ok(await small.evaluate(() => document.getElementById('to-title').getBoundingClientRect().bottom <= document.getElementById('stage').getBoundingClientRect().bottom), 'la pausa esce dalla scena');
|
||
await small.screenshot({ path: `${OUT}/08b-telefono-stretto-pausa.png` });
|
||
// Telefono in verticale: la Pausa prende tutto lo schermo, e versione e spiegazione non finiscono sopra i verbi.
|
||
{
|
||
const tall = await browser.newPage({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true });
|
||
await tall.goto(`http://127.0.0.1:${PORT}/`);
|
||
await tall.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
|
||
await tall.click('#new-game');
|
||
await tall.click('#rione [data-rione="campomigliaio"]');
|
||
await tall.click('#skip-intro');
|
||
await settle(tall);
|
||
await tall.click('#pause-button');
|
||
const fit = await tall.evaluate(() => {
|
||
const p = document.getElementById('pause'), v = p.querySelector('.version').getBoundingClientRect();
|
||
return { inside: p.scrollHeight <= p.clientHeight + 1, top: document.elementFromPoint(v.x + v.width / 2, v.y + v.height / 2)?.closest('#pause') !== null };
|
||
});
|
||
assert.deepEqual(fit, { inside: true, top: true }, 'in verticale la pausa esce dallo schermo o finisce sotto i verbi');
|
||
await tall.close();
|
||
}
|
||
const landscape = await open({ width: 844, height: 390 });
|
||
await landscape.click('#new-game');
|
||
await landscape.click('#rione [data-rione="campomigliaio"]');
|
||
await landscape.click('#skip-intro');
|
||
await settle(landscape);
|
||
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||
assert.ok(await landscape.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().height >= 26)), 'in orizzontale i verbi si leggono interi e il dito li prende');
|
||
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
|
||
|
||
// Debug (?debug): area calpestabile e hotspot disegnati; Maiusc+clic segna punti senza muovere Andrea, X li azzera.
|
||
const dbg = await open({ width: 1280, height: 860 }, '?debug');
|
||
await dbg.click('#new-game');
|
||
await dbg.click('#rione [data-rione="campomigliaio"]');
|
||
await dbg.click('#skip-intro');
|
||
await settle(dbg);
|
||
const start = await dbg.evaluate(() => [game.state.x, game.state.y]);
|
||
await dbg.keyboard.down('Shift');
|
||
await clickScene(dbg, [100, 300]);
|
||
await clickScene(dbg, [200, 320]);
|
||
await dbg.keyboard.up('Shift');
|
||
await dbg.mouse.move(700, 400);
|
||
await dbg.waitForTimeout(200);
|
||
assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
|
||
await dbg.screenshot({ path: `${OUT}/10-debug.png` });
|
||
|
||
assert.deepEqual(errors, [], 'errori nella console');
|
||
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
|
||
}
|
||
assert.deepEqual(errors, [], 'errori nella console');
|
||
} finally {
|
||
await browser.close();
|
||
server.kill();
|
||
}
|