From 1fabb6e54384a928192b6d1d15bbf14fba581a5c Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 25 Oct 2026 17:36:06 +0100 Subject: [PATCH] =?UTF-8?q?Video=20della=20partita=20completa=20(pomeriggi?= =?UTF-8?q?o=20=E2=86=92=20sera)=20con=20voci=20e=20musiche=20rimontate=20?= =?UTF-8?q?dal=20registro=20audio=20del=20copione;=20inventario:=20nomi=20?= =?UTF-8?q?lunghi=20su=20due=20righe=20dentro=20il=20pulsante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R --- src/main.js | 7 +- style.css | 4 +- video/SCALETTA.md | 18 ++++ video/monta.py | 75 ++++++++++++----- video/partita-completa.mjs | 167 +++++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 video/partita-completa.mjs diff --git a/src/main.js b/src/main.js index 957f1c6..a9b621f 100644 --- a/src/main.js +++ b/src/main.js @@ -447,7 +447,12 @@ function updateInventory() { icon = document.createElement('i'); icon.style.background = item.icon?.fill ?? '#ccc'; } - b.append(icon, item.name); + // Il nome in uno span: i nomi lunghi («stecco e gomma») vanno a capo su due righe strette, non su tre. + const name = document.createElement('span'); + // Dopo l'apostrofo si può andare a capo («chiave dell'» / «oratorio»): le parole restano intere. + name.textContent = item.name.replace(/'/g, "'\u200b"); + if (item.name.length > 10) name.className = 'long'; + b.append(icon, name); let timer = null, long = false; b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); }); b.addEventListener('pointerup', () => clearTimeout(timer)); diff --git a/style.css b/style.css index 5699297..c49336a 100644 --- a/style.css +++ b/style.css @@ -51,13 +51,15 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h padding: .35em .2em; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; cursor: pointer; } #verbs button:hover, #inventory button:hover { color: var(--ink); } #verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); } -#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; +#inventory { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px; /* L'inventario non allunga mai il pannello: è alto quanto i verbi, e se gli oggetti sono tanti si scorre. */ contain: size; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #6a557a transparent; } #inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45; font-size: clamp(9.36px, 1.22vw, 13.68px); font-weight: bold; letter-spacing: .08em; } #inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); } +#inventory button { min-width: 0; overflow: hidden; } +#inventory button span.long { min-width: 0; font-size: .72em; line-height: 1.15; text-align: left; } #inventory button[aria-pressed="true"] { border-color: var(--accent); } #inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; } #inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; } diff --git a/video/SCALETTA.md b/video/SCALETTA.md index 47e218e..9cf9b98 100644 --- a/video/SCALETTA.md +++ b/video/SCALETTA.md @@ -49,3 +49,21 @@ e scrive `video/out/demo-prototipo.mp4` (escluso da git). - Le battute del gioco durano `sayTime` (circa 18 caratteri al secondo): il copione non le salta, aspetta che finiscano. - Una pressione più lunga di 450 ms sulla scena vale «guarda»: i clic di `mv.py` sono brevi, non usare trascinamenti. - Il salvataggio sta nel `localStorage` del profilo della VM: il `setup` del copione lo svuota, sennò compare «Continua». + +## La partita completa (25 ottobre 2026) + +Copione `video/partita-completa.mjs`: il primo capitolo intero, dal pomeriggio alla sera (circa 10 minuti e mezzo), +seguendo `src/story/solution.js` capitolo per capitolo, più il libro dell'oratorio in primo piano e i vecchietti +che a sera sparlano di Andrea. Si gira con gli stessi comandi della demo (`mbot studio up`, `mbot studio session`, +`prepara-ripresa.sh`, `rec.mjs … --dry`, di nuovo `prepara-ripresa.sh`, `rec.mjs …`), poi: + +```zsh +python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa +``` + +- **L'audio**: la VM registra solo lo schermo. Il copione annota dentro la pagina ogni cambio di musica e ogni voce + che parte (con l'orologio della VM) in `video/out/partita-completa-audio.json`; `monta.py` rimonta la colonna + sonora dai file del gioco: musica bassa (`VOLUME_SOTTO`), voci piene. Il fruscio della pagina non c'è. +- **Il ritaglio** (`TAGLIO` in `monta.py`) va rimisurato quando cambia l'impaginazione: `convert -trim` su una + schermata della prova a vuoto. +- **Per Telegram** (50 MB al massimo): due passate x264 `-tune animation` a 470 kbit/s → circa 45 MB. diff --git a/video/monta.py b/video/monta.py index 608ceb4..3dd9e46 100644 --- a/video/monta.py +++ b/video/monta.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -"""Montaggio della demo (video/SCALETTA.md): raw.mkv + cues.json della ripresa e video/out/musica.json --> video/out/demo-prototipo.mp4. +"""Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal +copione -> video/out/.mp4. python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/demo-prototipo + python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita-completa - ritaglia lo schermo 1600x900 della VM sull'area del gioco e lascia sopra una fascia per le didascalie; - le didascalie (cues.json, solo «it») restano a schermo il tempo di leggerle, non fino alla successiva; -- la colonna sonora si rimonta dai cambi di brano registrati dal copione, con le dissolvenze del gioco (1,2 s). +- la colonna sonora si rimonta dai cambi di brano registrati dal copione, con le dissolvenze del gioco (1,2 s); +- le voci (solo in video/out/-audio.json) tornano al loro istante, sopra la musica tenuta bassa. La ripresa parte qualche secondo prima del copione (ffmpeg si avvia prima): lo scarto si misura dalla durata. """ import json @@ -18,10 +20,12 @@ VIDEO = Path(__file__).resolve().parent GIOCO = VIDEO.parent OUT = VIDEO / "out" # Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim». -TAGLIO = "crop=1076:780:262:60" +TAGLIO = "crop=1044:760:278:70" FASCIA = 110 # px sopra il gioco per le didascalie FONDO = "0x1B1420" # il colore di fondo della pagina del gioco -VOLUME = 0.5 +VOLUME = 0.5 # musica della demo-prototipo (senza voci) +VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre) +VOLUME_VOCI = 1.0 FADE = 1.2 @@ -39,7 +43,25 @@ def ts(s: float) -> str: def main() -> None: d = Path(sys.argv[1]).expanduser() meta = json.loads((d / "cues.json").read_text()) - musica = json.loads((OUT / "musica.json").read_text()) + nome = meta["clip"] + registro = OUT / f"{nome}-audio.json" + voci = [] + if registro.exists(): + # Il registro del copione: {t, music} a ogni cambio di brano, {t, voice} e {t, voiceEnd} per le voci. + eventi = json.loads(registro.read_text())["eventi"] + musica = {"cambi": [{"t": e["t"], "src": e["music"]} for e in eventi if "music" in e]} + aperta = None + for e in eventi: + if "voice" in e: + aperta = {"t": e["t"], "src": e["voice"].split("?")[0]} + voci.append(aperta) + elif "voiceEnd" in e and aperta: + aperta["fine"] = e["t"] + aperta = None + volume = VOLUME_SOTTO + else: + musica = json.loads((OUT / "musica.json").read_text()) + volume = VOLUME raw = d / "raw.mkv" totale = durata(raw) cues = meta["cues"] @@ -54,26 +76,39 @@ def main() -> None: fine = min(c["end"] + scarto if c["end"] is not None else inizio + lettura, inizio + lettura) righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""] OUT.mkdir(exist_ok=True) - srt = OUT / "demo-prototipo.it.srt" + srt = OUT / f"{nome}.it.srt" srt.write_text("\n".join(righe), encoding="utf-8") - # Un segmento per brano: dall'istante del cambio al successivo, più la dissolvenza in uscita. + # Un segmento per brano: dall'istante del cambio al cambio successivo (anche al silenzio), più la dissolvenza. # Il primo brano parte da zero (nel browser aspetta il primo clic, nel video no). - cambi = [c for c in musica["cambi"] if c["src"]] - cambi[0]["t"] = -scarto - ingressi, catene = [], [] - for i, c in enumerate(cambi): - a = c["t"] + scarto - b = (cambi[i + 1]["t"] + scarto) if i + 1 < len(cambi) else totale - lungo = b - a + (FADE if i + 1 < len(cambi) else 0) + tutti = musica["cambi"] + primo = next(i for i, c in enumerate(tutti) if c["src"]) + tutti[primo]["t"] = -scarto + ingressi, catene, pezzi = [], [], [] + for i, c in enumerate(tutti): + if not c["src"]: + continue + a = max(0.0, c["t"] + scarto) + ultimo = i + 1 >= len(tutti) + b = totale if ultimo else tutti[i + 1]["t"] + scarto + lungo = max(0.5, b - a + (0 if ultimo else FADE)) ingressi += ["-stream_loop", "-1", "-i", str(GIOCO / c["src"])] - uscita = f",afade=t=out:st={lungo - FADE:.3f}:d={FADE}" if i + 1 < len(cambi) else f",afade=t=out:st={lungo - 3:.3f}:d=3" - catene.append(f"[{i + 1}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={VOLUME}," - f"afade=t=in:d={FADE if i else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{i}]") - mix = "".join(f"[m{i}]" for i in range(len(cambi))) + f"amix=inputs={len(cambi)}:normalize=0:duration=longest[a]" + n = ingressi.count("-i") # l'ingresso 0 è il video + uscita = f",afade=t=out:st={max(0.0, lungo - 3):.3f}:d=3" if ultimo else f",afade=t=out:st={max(0.0, lungo - FADE):.3f}:d={FADE}" + catene.append(f"[{n}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={volume}," + f"afade=t=in:d={FADE if i > primo else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{len(pezzi)}]") + pezzi.append(f"[m{len(pezzi)}]") + # Le voci: ognuna dal suo istante, tagliata dove il gioco l'ha fermata (battuta saltata o finita). + for v in voci: + ingressi += ["-i", str(GIOCO / v["src"])] + n = ingressi.count("-i") + lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else "" + catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]") + pezzi.append(f"[v{len(pezzi)}]") + mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]" stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF" video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]" - finale = OUT / "demo-prototipo.mp4" + finale = OUT / f"{nome}.mp4" subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi, "-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]", "-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", diff --git a/video/partita-completa.mjs b/video/partita-completa.mjs new file mode 100644 index 0000000..82ba867 --- /dev/null +++ b/video/partita-completa.mjs @@ -0,0 +1,167 @@ +// Copione del video «La Leggenda del Regolo · la partita completa» (primo capitolo, dal pomeriggio alla sera). +// Si gira come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»), poi: python3 video/monta.py . +// Segue la soluzione vera (src/story/solution.js), capitolo per capitolo, con due aggiunte: il libro dell'oratorio +// in primo piano e, a sera, i vecchietti che sparlano di Andrea. +// L'audio non si registra (la VM prende solo lo schermo): la pagina annota ogni cambio di musica e ogni voce che +// parte, col suo tempo, in out/partita-completa-audio.json; monta.py rimonta la colonna sonora dai file del gioco. +import { mkdirSync, writeFileSync } from "node:fs"; +import { solution, route } from "../src/story/solution.js"; + +const c = (id, it) => ({ id, it, en: it, top: true }); + +export const title = { it: "La Leggenda del Regolo · la partita completa", en: "La Leggenda del Regolo · la partita completa" }; + +export const cues = [ + c("titolo", "La Leggenda del Regolo — il primo capitolo, dall'inizio alla fine"), + c("intro", "Si corre per Campomigliaio, i blu. L'introduzione, sopra il panorama del Mugello"), + c("piazza", "Pomeriggio, Piazza Colonna. I vecchietti della panchina sanno sempre tutto"), + c("fortezza", "Sotto la Fortezza, impronte strane. E il sole comincia a calare"), + c("pieve", "La Pieve: l'indovinello di San Pietro apre la porta dell'oratorio"), + c("libro", "Nell'oratorio, sulle tavolate, un libro dimenticato. Bello spesso"), + c("tosca", "Al tennis della chiesa c'è la Tosca: ghiacciolo al limone, inzuppato nella cedrata"), + c("chiavetta", "Stecco e gomma ripescano una chiavetta. Nella casetta dell'area feste, una torcia"), + c("sera", "È sera. In piazza, intanto, si chiacchiera"), + c("finale", "Di corsa alla Fortezza: la torcia nella fessura del bastione"), + c("fine", "Fine del primo capitolo"), +]; + +const URL_GIOCO = "http://127.0.0.1:8000/"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const at = (verb, target) => solution.findIndex(([v, t]) => v === verb && t === target); + +export async function setup(d) { + const p = d.page; + await p.goto(URL_GIOCO); + await p.evaluate(() => localStorage.clear()); + await p.reload(); + await p.waitForSelector("#title:not([hidden])"); + await sleep(1500); +} + +export async function run(d) { + const p = d.page; + + // --- L'audio, annotato dentro la pagina (orologio della VM, a ogni fotogramma): musica voluta e voce in corso. + await p.evaluate(() => { + window.__t0 = Date.now(); + window.__audio = []; + let music = undefined, voice = null; + const t = () => (Date.now() - window.__t0) / 1000; + const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1)); + const look = () => { + if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); } + const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null; + if (now !== voice) { + if (voice) window.__audio.push({ t: t(), voiceEnd: true }); + if (now) window.__audio.push({ t: t(), voice: path(now.src) }); + voice = now; + } + requestAnimationFrame(look); + }; + look(); + }); + + // --- Puntatore vero sulla scena (coordinate 640×360) e sui pulsanti. + const punto = async (x, y) => { + const r = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio); + return [(r.x + (x / 640) * r.width) * k, (r.y + (y / 360) * r.height) * k]; + }; + const clicca = async ([x, y], dur = 0.8) => { const [a, b] = await punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); }; + const centro = (id) => p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id); + // Aspetta che l'omino abbia finito di camminare e di parlare (le battute non si saltano: si ascoltano). + // Un dialogo aperto o un primo piano fermo aspettano il giocatore: lì ci si ferma. + const fermi = async (pausa = 600) => { + await sleep(150); + for (let i = 0; i < 1500; i++) { + const ok = await p.evaluate(() => { + if (game.speech || game.queue.length && !game.closeup || game.walking || game.reach > 0) return false; + return !!game.dialogue || !!game.closeup || (!game.busy && !game.task); + }); + if (ok) break; + await sleep(100); + } + await sleep(pausa); + }; + const verbo = (v) => d.click(p.locator(`#verbs [data-verb="${v}"]`), 0.6); + const oggetto = (id) => d.click(p.locator(`#inventory [data-item="${id}"]`), 0.6); + const scegli = async (testo) => { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await fermi(); }; + // Un passo della soluzione, come lo farebbe un giocatore: il verbo (se il clic da solo non basta), l'oggetto, la cosa. + const passo = async ([verb, target, item]) => { + if (verb === "choose") return scegli(target); + const smart = await p.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]); + if (item) { await verbo(verb); await oggetto(item); } + else if (!smart) await verbo(verb); + if (await p.evaluate((t) => !!game.story.items[t], target)) await oggetto(target); + else await clicca(await centro(target)); + await fermi(verb === "walk" ? 900 : 700); + }; + const passi = async (lista) => { for (const s of lista) await passo(s); }; + + // 1. Titolo, rione, introduzione (una riga per clic). + await d.cue("titolo"); + await d.hover(p.locator("#title .dedication"), 1.2); + await sleep(4000); + await d.click(p.locator("#new-game")); + await sleep(2500); + await d.click(p.locator('#rione [data-rione="campomigliaio"]')); + await d.cue("intro"); + for (let i = 0; i < 8 && (await p.isVisible("#intro")); i++) { + await sleep(4200); + if (await p.isVisible("#intro")) await d.click(p.locator("#intro-text"), 0.5); + } + await fermi(800); + + // 2-7. La soluzione, a capitoli. + const capitoli = [ + ["piazza", at("take", "cosa_luccicante")], + ["fortezza", at("look", "fessura")], + ["pieve", at("take", "gomma_terra")], + ]; + let da = 0; + for (const [cue, fino] of capitoli) { + await d.cue(cue); + await passi(solution.slice(da, fino + 1)); + da = fino + 1; + } + + // Il libro dell'oratorio: preso in mano, si apre, si gira la pagina, «FINE»; poi si chiude. + await d.cue("libro"); + await verbo("take"); + await clicca(await centro("libro_dimenticato")); + await p.waitForFunction(() => game.closeup?.step === "cover", null, { timeout: 30000 }); + await sleep(2800); + await clicca([320, 200]); + await sleep(2600); + await clicca([320, 200]); + await p.waitForFunction(() => game.closeup?.lines && !game.closeup.lines.length && !game.speech, null, { timeout: 30000 }); + await sleep(1500); + await d.click(p.locator("#closeup-close"), 0.6); + await fermi(); + + for (const [cue, fino] of [["tosca", at("use", "gomma", "stecco")], ["chiavetta", at("open", "casetta")]]) { + await d.cue(cue); + await passi(solution.slice(da, fino + 1)); + da = fino + 1; + } + + // 8. Sera: una deviazione in piazza, dove i vecchietti parlano di Andrea prima di accorgersi che è lì. + await d.cue("sera"); + await passi(route("area_feste", "piazza")); + await passo(["talk", "vecchietti"]); + await scegli("Ho una torcia"); + await scegli("Ci vediamo dopo"); + + // 9. Il finale: la torcia nella fessura. + await d.cue("finale"); + await passi(route("piazza", "fortezza")); + await passi(solution.slice(-1)); + await p.waitForSelector("#ending:not([hidden])", { timeout: 60000 }); + await d.cue("fine"); + await d.hover(p.locator("#ending .dedication"), 1.2); + await sleep(7000); + + const audio = await p.evaluate(() => window.__audio); + const out = new URL("./out/", import.meta.url); + mkdirSync(out, { recursive: true }); + writeFileSync(new URL("partita-completa-audio.json", out), JSON.stringify({ eventi: audio }, null, 2)); +}