From b0586e30612a13ffc3a053d1b5f6e8fe2f8a85a3 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sat, 24 Oct 2026 13:47:59 +0200 Subject: [PATCH] =?UTF-8?q?Collaudo=20in=20tre=20modalit=C3=A0=20(desktop,?= =?UTF-8?q?=20met=C3=A0=20schermo,=20smartphone)=20e=20fumetti=20che=20non?= =?UTF-8?q?=20finiscono=20pi=C3=B9=20a=20colonna?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - video/collaudo.mjs: tutta la soluzione in tre misure di schermo, una schermata a ogni passo e a ogni battuta, e i difetti trovati da soli (bersagli piccoli o coperti, fumetti fuori scena o sotto i pulsanti, testi tagliati) - video/COLLAUDO.md: le tre scalette e i difetti trovati - fumetto a larghezza naturale (vicino al bordo destro si stringeva a colonna), posizionato in pixel sotto i pulsanti in alto; sul telefono più largo e più piccolo - la fessura più grande, con Andrea alla sua sinistra Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh --- src/main.js | 12 +++- style.css | 5 +- video/COLLAUDO.md | 75 +++++++++++++++++++++++ video/collaudo.mjs | 150 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 video/COLLAUDO.md create mode 100644 video/collaudo.mjs diff --git a/src/main.js b/src/main.js index 0f1e1c6..6c23c31 100644 --- a/src/main.js +++ b/src/main.js @@ -342,10 +342,16 @@ function updateSpeech() { } if (!sp) { if (box.textContent) box.textContent = ''; return; } const [x, y] = game.speakerPoint(sp.who); - box.textContent = sp.text; + if (box.textContent !== sp.text) box.textContent = sp.text; box.style.color = story.characters[sp.who]?.color ?? '#fff'; - box.style.left = Math.min(77, Math.max(23, x / WIDTH * 100)) + '%'; - box.style.top = Math.max(18, y / HEIGHT * 100) + '%'; + // Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un + // fumetto lungo è alto quasi quanto la scena, e si misura in pixel. + const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight; + const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4; + const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width)); + const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height)); + box.style.left = px + 'px'; + box.style.top = py + 'px'; } function updateUi() { diff --git a/style.css b/style.css index ed4d28c..315b002 100644 --- a/style.css +++ b/style.css @@ -25,7 +25,8 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } -#speech { position: absolute; left: 0; top: 0; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none; +/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */ +#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none; font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center; text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; } #speech:empty { display: none; } @@ -181,6 +182,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins #stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } #sentence { font-size: 10.08px; } + /* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */ + #speech { max-width: 72%; font-size: 11px; } #panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; } #inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; } #verbs button, #inventory button, #choices li { font-size: 10.08px; } diff --git a/video/COLLAUDO.md b/video/COLLAUDO.md new file mode 100644 index 0000000..61d2e27 --- /dev/null +++ b/video/COLLAUDO.md @@ -0,0 +1,75 @@ +# Collaudo dell'interfaccia: tre scalette, tre modalità + +Lo scopo è trovare i difetti che si vedono solo giocando: clic che non arrivano, testi sovrapposti o tagliati, fumetti +fuori posto. Le tre scalette percorrono la stessa partita, cioè la soluzione completa del primo capitolo +(`src/story/solution.js`), ognuna con la sua misura di schermo e i suoi punti da guardare. + +Il giro automatico si lancia con `node video/collaudo.mjs` (oppure `node video/collaudo.mjs telefono` per una modalità +sola). Fa una schermata a ogni passo e a ogni battuta in `video/out/collaudo//` e scrive l'elenco dei difetti +trovati da solo in `video/out/collaudo/DIFETTI.md`. Cartella esclusa da git. Le stesse scalette servono per i video +sulla VM delle riprese (`video/SCALETTA.md`, «Come si rigira»). + +Controlli automatici, a ogni schermata: + +- la pagina scorre di lato; +- il fumetto esce dalla scena, finisce sotto i pulsanti in alto (Pausa, Mappa, «?», volume) o diventa una colonna + (più alto che largo); +- testo tagliato in verbi, inventario, scelte dei dialoghi e riga della frase; scelte fuori dallo schermo; +- prima di ogni clic sulla scena: il bersaglio è abbastanza grande (12 px col mouse; 28 px col dito, contando la + tolleranza di 22 px) e il suo centro non è coperto da un pulsante; +- dopo ogni clic: il gioco ha fatto qualcosa; +- errori JavaScript. + +## 1. Desktop pieno (1600×900, mouse) + +È lo schermo della VM delle riprese: il gioco come lo vede chi lo apre sul computer. + +| # | Momento | Cosa guardare | +| --- | --- | --- | +| 1 | Titolo e introduzione | Titolo, pulsanti (Nuova partita, Carica, Crediti) e volume senza sovrapposizioni; le righe dell'introduzione leggibili | +| 2 | Piazza: colonna, vecchietti, squama | Fumetti sopra chi parla; scelte del dialogo leggibili; la squama si prende al primo clic | +| 3 | Salita fino alla Fortezza | Uscite al primo clic; la riga della frase non resta vecchia | +| 4 | Impronte e fessura | Fumetti lunghi interi e dentro la scena; la fessura si vede e non la copre Andrea | +| 5 | Pieve: statua, indovinello, oratorio | Fumetto della statua sopra di lei; cinque risposte tutte visibili | +| 6 | Tennis: la Tosca, cedrata e ghiacciolo | Inventario con più oggetti; «usa… con» fra due oggetti dell'inventario | +| 7 | Ponticino, area feste, casetta | Uscite lunghe in fondo allo schermo; la torcia compare nell'inventario | +| 8 | Fortezza: la torcia nella fessura | Luce, angolo del palio e occhi visibili; i fumetti non coprono la fessura | +| 9 | Fine del capitolo | Testo, dedica e pulsanti interi; Crediti sopra a tutto | + +## 2. Metà schermo (800×900, mouse) + +È la finestra del browser affiancata a un'altra: la scena è stretta e in alto resta spazio vuoto. + +| # | Momento | Cosa guardare | +| --- | --- | --- | +| 1 | Titolo | Titolo su più righe senza sovrapporsi ai pulsanti | +| 2 | Piazza | Verbi e inventario sotto la scena: niente parole tagliate («Parla con») | +| 3 | Dialoghi (vecchietti, statua, Tosca) | Le scelte sostituiscono verbi e inventario: tutte visibili, numerate, senza scorrere | +| 4 | Fumetti lunghi (manifesto, fessura) | Su più righe ma larghi, dentro la scena, mai sotto il volume | +| 5 | Inventario con 3-4 oggetti | Nomi lunghi («chiave dell'oratorio», «stecco e gomma») su due righe senza uscire dal pulsante | +| 6 | Fine e crediti | Tutto dentro la finestra; i crediti scorrono se non ci stanno | + +## 3. Smartphone in orizzontale (844×390, dito) + +Un telefono vero girato in orizzontale: scena a sinistra, verbi e inventario a destra, si tocca invece di cliccare. + +| # | Momento | Cosa guardare | +| --- | --- | --- | +| 1 | Titolo e introduzione | Tutto a schermo intero; «Salta» raggiungibile; il volume non copre il titolo | +| 2 | Piazza: la squama | La squama si prende col dito anche toccando la panchina intorno | +| 3 | Fumetti lunghi | Mai sotto Pausa/Mappa/volume, mai fuori dalla scena, mai a colonna | +| 4 | Verbi in due colonne | Leggibili e grandi abbastanza per il dito | +| 5 | Dialoghi | Le scelte si toccano una per una; nessuna fuori dallo schermo | +| 6 | Uscite in fondo alla scena | Basse ma larghe: il tocco arriva anche un po' sopra | +| 7 | Inventario pieno | Si scorre; l'ultimo oggetto preso si vede | +| 8 | Pausa, Salva su file, mappa | Pulsanti dentro la scena; la mappa scorre e parte dal posto dove si è | +| 9 | Fine | Testo e pulsanti interi; Ricomincia e Crediti toccabili | + +## Difetti trovati e corretti (24 ottobre 2026) + +- Smartphone: le battute lunghe («Laggiù, appoggiato al muro…», «Ai piedi c'è una strana scia…») diventavano una + colonna alta quanto la scena, che saliva sotto i cursori del volume e usciva dalla scena. Ora il fumetto è più largo e + ha il carattere più piccolo; la posizione si calcola in pixel, sotto i pulsanti in alto e dentro i lati. +- In tutte le modalità: vicino al bordo destro il fumetto si stringeva a colonna (per esempio «La colonna, il centro + del paese…»). Corretto con `width: max-content`. +- La fessura nel bastione stava dietro Andrea: ora è più grande e lui si ferma alla sua sinistra. diff --git a/video/collaudo.mjs b/video/collaudo.mjs new file mode 100644 index 0000000..12f562a --- /dev/null +++ b/video/collaudo.mjs @@ -0,0 +1,150 @@ +// 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// 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 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('#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(); +}