Video della partita completa (pomeriggio → sera) con voci e musiche rimontate dal registro audio del copione; inventario: nomi lunghi su due righe dentro il pulsante
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
+6
-1
@@ -447,7 +447,12 @@ function updateInventory() {
|
|||||||
icon = document.createElement('i');
|
icon = document.createElement('i');
|
||||||
icon.style.background = item.icon?.fill ?? '#ccc';
|
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;
|
let timer = null, long = false;
|
||||||
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
|
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
|
||||||
b.addEventListener('pointerup', () => clearTimeout(timer));
|
b.addEventListener('pointerup', () => clearTimeout(timer));
|
||||||
|
|||||||
@@ -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; }
|
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:hover, #inventory button:hover { color: var(--ink); }
|
||||||
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
#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;
|
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. */
|
/* 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; }
|
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;
|
#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; }
|
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 { 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 button[aria-pressed="true"] { border-color: var(--accent); }
|
||||||
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
#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; }
|
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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».
|
- 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.
|
||||||
|
|||||||
+55
-20
@@ -1,12 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Montaggio della demo (video/SCALETTA.md): raw.mkv + cues.json della ripresa e video/out/musica.json
|
"""Montaggio di una ripresa del gioco (video/SCALETTA.md): raw.mkv + cues.json della ripresa e l'audio annotato dal
|
||||||
-> video/out/demo-prototipo.mp4.
|
copione -> video/out/<clip>.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/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;
|
- 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;
|
- 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/<clip>-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.
|
La ripresa parte qualche secondo prima del copione (ffmpeg si avvia prima): lo scarto si misura dalla durata.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -18,10 +20,12 @@ VIDEO = Path(__file__).resolve().parent
|
|||||||
GIOCO = VIDEO.parent
|
GIOCO = VIDEO.parent
|
||||||
OUT = VIDEO / "out"
|
OUT = VIDEO / "out"
|
||||||
# Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim».
|
# 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
|
FASCIA = 110 # px sopra il gioco per le didascalie
|
||||||
FONDO = "0x1B1420" # il colore di fondo della pagina del gioco
|
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
|
FADE = 1.2
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +43,25 @@ def ts(s: float) -> str:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
d = Path(sys.argv[1]).expanduser()
|
d = Path(sys.argv[1]).expanduser()
|
||||||
meta = json.loads((d / "cues.json").read_text())
|
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"
|
raw = d / "raw.mkv"
|
||||||
totale = durata(raw)
|
totale = durata(raw)
|
||||||
cues = meta["cues"]
|
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)
|
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"], ""]
|
righe += [str(i + 1), f"{ts(inizio)} --> {ts(fine)}", "{\\an8}" + c["it"], ""]
|
||||||
OUT.mkdir(exist_ok=True)
|
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")
|
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).
|
# 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"]]
|
tutti = musica["cambi"]
|
||||||
cambi[0]["t"] = -scarto
|
primo = next(i for i, c in enumerate(tutti) if c["src"])
|
||||||
ingressi, catene = [], []
|
tutti[primo]["t"] = -scarto
|
||||||
for i, c in enumerate(cambi):
|
ingressi, catene, pezzi = [], [], []
|
||||||
a = c["t"] + scarto
|
for i, c in enumerate(tutti):
|
||||||
b = (cambi[i + 1]["t"] + scarto) if i + 1 < len(cambi) else totale
|
if not c["src"]:
|
||||||
lungo = b - a + (FADE if i + 1 < len(cambi) else 0)
|
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"])]
|
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"
|
n = ingressi.count("-i") # l'ingresso 0 è il video
|
||||||
catene.append(f"[{i + 1}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={VOLUME},"
|
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}"
|
||||||
f"afade=t=in:d={FADE if i else 0.01}{uscita},adelay={int(a * 1000)}:all=1[m{i}]")
|
catene.append(f"[{n}:a]atrim=0:{lungo:.3f},asetpts=PTS-STARTPTS,volume={volume},"
|
||||||
mix = "".join(f"[m{i}]" for i in range(len(cambi))) + f"amix=inputs={len(cambi)}:normalize=0:duration=longest[a]"
|
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"
|
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]"
|
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,
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi,
|
||||||
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
|
"-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]",
|
||||||
"-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
"-t", f"{totale:.3f}", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
|||||||
@@ -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 <cartella> .
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user