Files
leggenda-del-regolo/video/collaudo.mjs
T

164 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Caccia ai difetti dell'interfaccia (scalette in video/COLLAUDO.md): tutta la soluzione in tre modalità
// (desktop pieno, metà schermo, smartphone col dito), una schermata a ogni passo e a ogni battuta, e un elenco
// di difetti trovati da soli: bersagli piccoli o coperti, fumetti fuori scena o sotto i pulsanti, testi tagliati,
// pagina che scorre di lato.
// node video/collaudo.mjs [desktop|meta|telefono ...] -> video/out/collaudo/<modo>/ e DIFETTI.md
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { solution } from '../src/story/solution.js';
export const MODI = {
desktop: { nome: 'Desktop pieno', viewport: { width: 1600, height: 900 } },
meta: { nome: 'Metà schermo', viewport: { width: 800, height: 900 } },
telefono: { nome: 'Smartphone in orizzontale', viewport: { width: 844, height: 390 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
};
// Sotto questa misura (pixel di schermo) un bersaglio è difficile da prendere: col dito di più. Il dito però ha
// la tolleranza del gioco (TOUCH_REACH in src/main.js) da ogni lato.
const MIN_MOUSE = 12, MIN_DITO = 28, TOUCH_REACH = 22;
const OUT = new URL('./out/collaudo/', import.meta.url).pathname;
const PORT = 8600 + Math.floor(Math.random() * 300);
const chosen = process.argv.slice(2).filter(a => MODI[a]);
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore', cwd: new URL('..', import.meta.url).pathname });
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
// I controlli sulla pagina così com'è: ognuno restituisce le frasi dei difetti visti adesso.
function controlli() {
const r = el => el.getBoundingClientRect(), $ = id => document.getElementById(id);
const over = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
const visible = el => el && !el.hidden && r(el).width > 0 && getComputedStyle(el).display !== 'none';
const out = [];
if (document.documentElement.scrollWidth > innerWidth + 1) out.push(`la pagina scorre di lato (${document.documentElement.scrollWidth} su ${innerWidth})`);
const stage = r($('stage')), speech = $('speech');
if (speech.textContent) {
const s = r(speech);
if (s.left < stage.left - 1 || s.right > stage.right + 1 || s.top < stage.top - 1) out.push(`fumetto fuori dalla scena: «${speech.textContent.slice(0, 40)}…»`);
for (const id of ['sound', 'corner']) if (visible($(id)) && over(s, r($(id)))) out.push(`fumetto sotto i pulsanti (${id}): «${speech.textContent.slice(0, 40)}…»`);
// Un fumetto stretto e altissimo (una parola per riga) si legge male: più alto che largo è un difetto.
if (s.height > s.width * 1.2) out.push(`fumetto a colonna (${Math.round(s.width)}×${Math.round(s.height)}): «${speech.textContent.slice(0, 40)}…»`);
}
for (const b of document.querySelectorAll('#verbs button, #inventory button, #choices li, #sentence'))
if (visible(b) && b.scrollWidth > b.clientWidth + 1) out.push(`testo tagliato: «${b.textContent.trim().slice(0, 40)}»`);
const ch = $('choices');
if (visible(ch)) for (const li of ch.children) if (r(li).bottom > innerHeight + 1) out.push(`scelta fuori dallo schermo: «${li.textContent.slice(0, 40)}»`);
return out;
}
// Dove cliccare un hotspot, quanto è grande sullo schermo e cosa c'è sopra il suo centro.
function bersaglio(id) {
const h = game.hotspot(id), c = document.getElementById('scene'), b = c.getBoundingClientRect(), k = b.width / 640;
if (!h) return null;
const [x, y, w, hh] = h.box, cx = b.left + (x + w / 2) * k, cy = b.top + (y + hh / 2) * k;
const top = document.elementFromPoint(cx, cy);
return { cx, cy, w: w * k, h: hh * k, sopra: top === c ? null : top?.id || top?.className || top?.tagName };
}
async function giro(modo) {
const conf = MODI[modo], dir = `${OUT}${modo}/`, difetti = [], visti = new Set();
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const page = await browser.newPage({ viewport: conf.viewport, deviceScaleFactor: conf.deviceScaleFactor ?? 1, isMobile: !!conf.isMobile, hasTouch: !!conf.hasTouch });
page.on('pageerror', e => difetti.push({ passo: '-', cosa: `errore JavaScript: ${e.message}` }));
await page.goto(`http://127.0.0.1:${PORT}/`);
await page.evaluate(() => localStorage.clear());
await page.reload();
const dito = !!conf.hasTouch;
const tocca = async (x, y) => (dito ? page.touchscreen.tap(x, y) : page.mouse.click(x, y));
const premi = async sel => (dito ? page.tap(sel) : page.click(sel));
let n = 0;
const foto = async (nome, passo) => {
const file = `${String(++n).padStart(3, '0')}-${nome.replace(/[^a-z0-9]+/gi, '-').slice(0, 40)}.png`;
await page.screenshot({ path: dir + file });
for (const cosa of await page.evaluate(controlli)) {
if (visti.has(cosa)) continue;
visti.add(cosa);
difetti.push({ passo, cosa, file });
}
return file;
};
// Aspetta che il gioco si fermi; ogni battuta nuova ha la sua schermata, poi si salta.
const fermo = async passo => {
let ultima = null;
for (let i = 0; i < 600; i++) {
const search = await page.evaluate(() => {
const c = game.closeup;
if (c?.id !== 'fessura' || game.speech) return null;
return c.step === 'done' ? { done: true } : c.step === 'search' ? { at: game.story.closeups.fessura.targets[c.found].at } : null;
});
if (search?.done) await premi('#closeup-close');
else if (search?.at) {
const r = await page.locator('#scene').boundingBox(), [x, y] = search.at;
await tocca(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
const s = await page.evaluate(() => ({ t: game.speech?.text ?? null, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.t && s.t !== ultima) { ultima = s.t; await page.waitForTimeout(60); await foto(`battuta-${s.t}`, passo); await page.evaluate(() => game.skip()); }
else if (!s.t && s.idle) return;
await page.waitForTimeout(40);
}
throw new Error(`${modo}: il gioco non si ferma (${passo})`);
};
await page.waitForSelector('#splash', { state: 'hidden', timeout: 15000 }).catch(() => {});
await foto('titolo', 'titolo');
await premi('#new-game');
await page.waitForSelector('#rione:not([hidden])');
await foto('rione', 'rione');
await premi('#rione [data-rione="campomigliaio"]');
await page.waitForSelector('#intro:not([hidden])');
await foto('introduzione', 'introduzione');
await premi('#skip-intro');
await fermo('inizio');
await foto('piazza', 'inizio');
for (const [verb, target, item] of solution) {
const passo = [verb, target, item].filter(Boolean).join(' ');
if (verb === 'choose') {
await (dito ? page.locator('#choices li', { hasText: target }).tap() : page.locator('#choices li', { hasText: target }).click());
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) { await premi(`#verbs [data-verb="${verb}"]`); await premi(`#inventory [data-item="${item}"]`); }
else if (!smart) await premi(`#verbs [data-verb="${verb}"]`);
if (await page.evaluate(t => !!game.story.items[t], target)) await premi(`#inventory [data-item="${target}"]`);
else {
const b = await page.evaluate(bersaglio, target);
if (!b) throw new Error(`${modo}: non c'è ${target}`);
const min = dito ? MIN_DITO : MIN_MOUSE, size = Math.min(b.w, b.h) + (dito ? 2 * TOUCH_REACH : 0);
if (size < min) difetti.push({ passo, cosa: `bersaglio piccolo: ${target} ${Math.round(b.w)}×${Math.round(b.h)} px (meno di ${min})` });
if (b.sopra) difetti.push({ passo, cosa: `il centro di ${target} è coperto da «${b.sopra}»: il clic non arriva` });
await tocca(b.cx, b.cy);
}
await page.waitForTimeout(60);
const preso = await page.evaluate(() => !!(game.task || game.queue.length || game.speech || game.walking));
if (!preso && verb !== 'choose') difetti.push({ passo, cosa: `il ${dito ? 'tocco' : 'clic'} non ha fatto niente` });
}
await fermo(passo);
await foto(`dopo-${passo}`, passo);
}
await page.waitForSelector('#ending:not([hidden])', { timeout: 10000 }).catch(() => difetti.push({ passo: 'fine', cosa: 'la schermata di fine non compare' }));
await foto('fine', 'fine');
await page.close();
return { modo, nome: conf.nome, difetti, foto: n };
}
try {
const risultati = [];
for (const modo of chosen.length ? chosen : Object.keys(MODI)) {
const r = await giro(modo);
risultati.push(r);
console.log(`${r.nome}: ${r.foto} schermate, ${r.difetti.length} difetti`);
}
const md = ['# Difetti trovati dal collaudo', '', `Generato da \`node video/collaudo.mjs\` il ${new Date().toLocaleString('it-IT')}.`, ''];
for (const r of risultati) {
md.push(`## ${r.nome} (\`${r.modo}/\`, ${r.foto} schermate)`, '');
if (!r.difetti.length) md.push('Nessun difetto trovato.', '');
for (const d of r.difetti) md.push(`- **${d.passo}**: ${d.cosa}${d.file ? ` — \`${r.modo}/${d.file}\`` : ''}`);
md.push('');
}
writeFileSync(`${OUT}DIFETTI.md`, md.join('\n'));
console.log(`-> ${OUT}DIFETTI.md`);
} finally {
await browser.close();
server.kill();
}