diff --git a/video/SCALETTA.md b/video/SCALETTA.md index 9cf9b98..b1d1ed0 100644 --- a/video/SCALETTA.md +++ b/video/SCALETTA.md @@ -67,3 +67,18 @@ python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/partita - **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. + +## Il trailer e la extended (29 ottobre 2026) + +Copioni `video/trailer.mjs` (circa 1 min 30 s) e `video/trailer-extended.mjs` (circa 3 min 25 s, col giro completo +del paese e i giochi giocati da soli). Niente clic: la scena è a tutto schermo, le scritte e gli stacchi al nero +sono elementi aggiunti nella pagina, e la musica la sceglie il copione (`out/-audio.json`). Nel finale si +cercano solo gli occhi gialli: il palio non si mostra (Andrea: si capirebbe dov'è). + +- Prova locale, senza VM: `OUT=/tmp/prova COPIONE=./trailer.mjs node video/prova-locale.mjs` (una schermata per + battuta, e i tempi). +- Ripresa: come la partita completa, poi `python3 video/monta.py --trailer`. +- Il gioco va in pausa da solo dopo un minuto senza tocchi: il copione manda un finto `pointermove` ogni 10 s. +- Una camminata lascia `game.task` in sospeso, e cambiando scena di colpo il gioco non si ferma più: si azzera. +- Le battute saltate lasciano spezzoni di voce nel registro: prima di montare si tolgono quelli sotto il mezzo secondo + (attenzione a «Let it be.», che dura un secondo ed è giusta). diff --git a/video/monta.py b/video/monta.py index 3dd6b73..a21a367 100644 --- a/video/monta.py +++ b/video/monta.py @@ -4,6 +4,7 @@ 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 + python3 video/monta.py ~/Workspaces/qemu/qemu-iso-lab/artifacts/tour/out/trailer --trailer - 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; @@ -31,6 +32,9 @@ 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 VOLUME_NARRATORE = 1.15 +# Il trailer (--trailer): tutto lo schermo della VM (la scena è a tutto schermo), niente fascia né didascalie (le +# scritte sono nella pagina), la musica in primo piano. +VOLUME_TRAILER = 0.6 VOLUME_TUTORIAL = 0.07 # musica dei video «Come si gioca»: sottofondo # il narratore dei video «Come si gioca», sopra la musica bassa FADE = 1.2 @@ -49,6 +53,7 @@ def ts(s: float) -> str: def main() -> None: d = Path(sys.argv[1]).expanduser() tutorial = "--tutorial" in sys.argv[2:] + trailer = "--trailer" in sys.argv[2:] taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA) meta = json.loads((d / "cues.json").read_text()) nome = meta["clip"] @@ -71,6 +76,8 @@ def main() -> None: # «troppo alta la musica del gioco»). if (OUT / f"{nome}-narrazione.json").exists(): volume = VOLUME_TUTORIAL + if trailer: + volume = VOLUME_TRAILER else: musica = json.loads((OUT / "musica.json").read_text()) volume = VOLUME @@ -131,6 +138,8 @@ def main() -> None: 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]" + if trailer: + video = "[0:v]scale=1600:900:flags=lanczos,fade=t=in:d=0.5[v]" 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]", diff --git a/video/prova-locale.mjs b/video/prova-locale.mjs new file mode 100644 index 0000000..431855f --- /dev/null +++ b/video/prova-locale.mjs @@ -0,0 +1,19 @@ +// Prova locale di un copione del trailer, senza la VM: un finto «d» col solo necessario, una schermata per battuta. +// OUT=/tmp/prova COPIONE=./trailer-extended.mjs node video/prova-locale.mjs +import { chromium } from 'playwright'; +import { staticServer } from '../tests/helpers.mjs'; +const server = await staticServer(); +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); +const out = process.env.OUT; +try { + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); + page.on('pageerror', e => console.log('ERR', e.message)); + const orig = page.goto.bind(page); + page.goto = url => orig(url.replace('http://127.0.0.1:8000/', `http://127.0.0.1:${server.port}/`)); + const mod = await import(process.env.COPIONE ?? './trailer.mjs'); + const t0 = Date.now(); let n = 0; + const d = { page, vm: () => {}, cue: async id => { console.log(((Date.now() - t0) / 1000).toFixed(1), id); setTimeout(() => page.screenshot({ path: `${out}/tr-${String(++n).padStart(2, '0')}-${id}.png` }), id === 'buio' ? 5000 : 3000); } }; + await mod.setup(d); + await mod.run(d); + console.log('fine', ((Date.now() - t0) / 1000).toFixed(1)); +} finally { await browser.close(); server.kill(); } diff --git a/video/trailer-extended.mjs b/video/trailer-extended.mjs new file mode 100644 index 0000000..01c222e --- /dev/null +++ b/video/trailer-extended.mjs @@ -0,0 +1,263 @@ +// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa tre +// minuti, come il trailer (video/trailer.mjs) ma col giro completo del paese e i giochi giocati per qualche secondo: +// bocce, biliardo, GP di Fortezza, briscola, macchinine, cerbottana. +// Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py --trailer . +import { mkdirSync, writeFileSync } from "node:fs"; + +const c = (id, it) => ({ id, it, en: it, top: true }); +export const title = { it: "La Leggenda del Regolo · extended", en: "La Leggenda del Regolo · extended" }; +export const cues = [ + c("apertura", "San Piero a Sieve, maggio 2007"), + c("notte", "La notte prima del Palio…"), + c("sparito", "…il palio è sparito."), + c("giro", "il giro del paese"), + c("sfide", "Un paese pieno di sfide"), + c("bocce", "Le bocce al pallaio"), + c("biliardo", "Il biliardo del Circolo"), + c("gara", "Il GP di Fortezza"), + c("carte", "La briscola all'oratorio"), + c("macchinine", "Le macchinine col gesso"), + c("cerbottana", "La cerbottana"), + c("amici", "…e di vecchie conoscenze"), + c("buio", "la fessura"), + c("titolo", "La Leggenda del Regolo"), +]; + +const URL_GIOCO = "http://127.0.0.1:8000/"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export async function setup(d) { + const p = d.page; + await p.goto(URL_GIOCO); + await p.evaluate(() => localStorage.clear()); + await p.reload(); + await sleep(500); + if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash"); + await p.waitForSelector("#title:not([hidden])"); + // Si entra in partita dietro le quinte: rione, introduzione saltata. + await p.click("#new-game"); + await p.click('#rione [data-rione="campomigliaio"]'); + await p.click("#skip-intro"); + // Il palcoscenico del trailer: solo la scena, a tutto schermo; sopra, il nero e le scritte. + await p.addStyleTag({ content: ` + #side, #corner, #sound, #closeup-close, #bocce-controls, #briscola-controls, #tressette-controls, #scopa-controls, + #map-button, #pause-button, #spots-button, #notebook-button, #sentence { display: none !important; } + body { background: #000 !important; overflow: hidden; } + #app { position: fixed !important; inset: 0 !important; width: 100vw !important; margin: 0 !important; } + #stage, #stage.biliardo, #stage.briscola { width: 100vw !important; height: 100vh !important; } + #scene { width: 100vw !important; height: 100vh !important; } + #title .buttons, #title .version, #title .dedication { display: none !important; } + #tr-nero { position: fixed; inset: 0; background: #000; opacity: 1; transition: opacity .7s; z-index: 50; pointer-events: none; } + #tr-scritta { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; z-index: 51; + font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0; + transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; } + #tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; } + ` }); + await p.evaluate(() => { + for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); } + }); + // Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio. + await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000)); + // Le battute d'ingresso in piazza si saltano e la voce si ferma: il trailer parte in silenzio. + await p.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); window.voices?.stop?.(); }); + // Il puntatore vero lontano, nell'angolo. + d.vm("DISPLAY=:0 ~/lab/video/mv.py 1599 899 0.2"); + await sleep(1200); +} + +export async function run(d) { + const p = d.page; + // L'orologio dell'audio, come nella partita completa: le voci che partono; la musica la decide il copione. + await p.evaluate(() => { + window.__t0 = Date.now(); window.__audio = []; + const t = () => (Date.now() - window.__t0) / 1000; + const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1)); + let voice = null; + window.__musica = (src) => window.__audio.push({ t: t(), music: src }); + const look = () => { + 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(); + }); + const musica = (src) => p.evaluate((s) => window.__musica(s), src); + const nero = (on) => p.evaluate((on) => { document.getElementById("tr-nero").style.opacity = on ? 1 : 0; }, on); + const scritta = (testo, basso = false) => p.evaluate(([t, b]) => { + const e = document.getElementById("tr-scritta"); + if (t) { e.textContent = t; e.classList.toggle("basso", b); } + e.style.opacity = t ? 1 : 0; + }, [testo, basso]); + // In ogni scena: niente primo piano, niente battute in coda, flag di partenza; Andrea dove serve (o fuori campo). + const vai = (scene, at, flags = {}) => `async () => { + const { settle } = await import('/tests/helpers.mjs'); + game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null; + Object.assign(game.state.flags, ${JSON.stringify(flags)}); + game.go('${scene}'${at ? `, ${JSON.stringify(at)}` : ""}); settle(game); + ${at ? "" : "game.state.x = -400;"} + }`; + const prepara = (codice) => p.evaluate(`(${codice})()`); + const fuoco = async (codice, veloce) => { + await p.evaluate((v) => { document.getElementById("tr-nero").style.transition = `opacity ${v ? .18 : .7}s`; }, veloce); + await nero(true); await sleep(veloce ? 200 : 750); + await prepara(codice); + await sleep(veloce ? 150 : 300); + await nero(false); await sleep(veloce ? 200 : 700); + }; + const dice = (who, testo, pose) => p.evaluate(([w, t, po]) => game.run(w ? { say: t, who: w, pose: po } : { say: t }), [who, testo, pose]); + const finitoDiDire = () => p.waitForFunction(() => !game.speech, null, { timeout: 20000 }); + + // 1. Il nero, poi il panorama di Monte Cacioli con le nuvole. + await musica("assets/audio/sera.mp3"); + await d.cue("apertura"); + await sleep(800); + await p.evaluate(() => { document.getElementById("tr-scritta").style.whiteSpace = "pre-line"; }); + await scritta("San Piero a Sieve\nmaggio 2007"); + await sleep(2400); + await scritta(null); + await fuoco(vai("via_cafaggio")); + await sleep(3200); + + // 2. La Fortezza al tramonto, le impronte. + await d.cue("notte"); + await fuoco(vai("fortezza", null, { indizio_regolo: true })); + await scritta("La notte prima del Palio…", true); + await sleep(3000); + await scritta(null); + + // 3. La scuola: il palio non c'è più. + await d.cue("sparito"); + await fuoco(vai("scuola", [300, 330], { indizio_regolo: false })); + await scritta("…il palio è sparito.", true); + await sleep(3200); + await scritta(null); + + // 4. Il giro completo del paese: Andrea cammina di scena in scena, con la musica del paese. + await d.cue("giro"); + await musica("assets/audio/paese.mp3"); + for (const [scene, da, a] of [["piazza", [120, 330], [300, 300]], ["via_de_medici", [300, 348], [300, 230]], ["via_compagnia", [300, 350], [430, 262]], + ["villa_adami", [300, 345], [560, 272]], ["fortezza", [175, 352], [400, 300]], ["pieve", [40, 330], [260, 300]], ["via_cafaggio", [150, 352], [420, 250]], + ["rotonda_cafaggio", [390, 352], [540, 230]], ["passeggiata_carza", [330, 352], [420, 250]], ["ponte_stazione", [300, 352], [200, 260]], + ["orti_zio_mario", [400, 352], [330, 260]], ["stazione", [320, 350], [450, 290]], ["cardetole", [300, 350], [470, 300]], ["terrazza", [300, 330], [420, 320]]]) { + await fuoco(vai(scene, da), true); + await p.evaluate((a) => game.walkTo(a), a); + await sleep(3300); + } + + // 5. Le sfide, giocate davvero per qualche secondo: ogni gioco col suo nome in basso e la sua musica. + await d.cue("sfide"); + await musica(null); + await fuoco(`async () => { game.state.x = -400; }`); + await scritta("Un paese pieno di sfide"); + await sleep(2000); + await scritta(null); + // Apre il primo piano, poi ogni 150 ms chiama `mossa` (il giocatore che gioca da solo) finché passano i secondi. + const gioca = async (cue, nome, brano, closeup, level, modulo, prima, mossa, secondi) => { + await d.cue(cue); + await musica(brano); + await fuoco(`async () => { + const { settle } = await import('/tests/helpers.mjs'); + game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null; + game.run({ closeup: '${closeup}', level: '${level}' }); settle(game); + window.__m = await import('${modulo}'); + const M = window.__m, c = game.closeup; ${prima} + }`); + await scritta(nome, true); + const fine = Date.now() + secondi * 1000; + while (Date.now() < fine) { + await p.evaluate(`(() => { const M = window.__m, c = game.closeup; if (!c) return; ${mossa} })()`); + if (Date.now() > fine - (secondi - 3) * 1000) await scritta(null); + await sleep(150); + } + }; + await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "", + "if (M.canThrow(c)) { c.aim = { u: c.jack.u, w: c.jack.w }; c.power = M.powerFor(Math.hypot(c.jack.u, c.jack.w) - 1.5); M.shoot(c); }", 14); + await gioca("biliardo", "Il biliardo del Circolo", "assets/audio/biliardo.mp3", "biliardo", "seria", "/src/engine/biliardo.js", "", + "if (c.step === 'aim' && c.current === 'player' && !c.holding) M.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x) + 0.04, 0.75);", 14); + await gioca("gara", "Il GP di Fortezza", "assets/audio/gara.mp3", "gara", "bona", "/src/engine/gara.js", + "while (c.step === 'intro') M.press(c); M.release(c);", "c.holding = c.step === 'race' && !M.mustLift(c);", 16); + await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "", + "if (M.canPlay(c)) M.play(c, 0);", 12); + await gioca("macchinine", "Le macchinine col gesso", "assets/audio/macchinine.mp3", "micro", "bona", "/src/engine/micro.js", "M.pick(c, 0);", + "if (M.canThrow(c)) { const car = c.cars.player, i = (car.idx + Math.round(110 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i]; M.strike(c, 'player', Math.atan2(y - car.y, x - car.x), M.powerFor(Math.hypot(x - car.x, y - car.y))); }", 12); + await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "", + "if (M.canThrow(c)) { const k = c.cans.find(k => !k.down); c.aim = k && c.left % 2 ? { x: k.x, y: M.LEDGE - 8 } : { x: 320 + (c.left % 3 - 1) * 6, y: 118 }; c.power = M.BEST; M.shoot(c); }", 10); + await musica("assets/audio/paese.mp3"); + + // 6. Le vecchie conoscenze, con le loro battute. + await d.cue("amici"); + await fuoco(vai("via_liguri_magelli", [330, 300])); + await scritta("…e di vecchie conoscenze", true); + await dice(null, "Di chi è quell'Audi lì?"); + await finitoDiDire(); + await scritta(null); + await dice("arcu", "Let it be.", "explain"); + await finitoDiDire(); + await sleep(300); + await dice(null, "Zio, hai problemi?"); + await finitoDiDire(); + await dice("zio_mario", "I problemi io gli avevo quando andavo a scuola!", "laugh"); + await finitoDiDire(); + await fuoco(vai("cardetole", [470, 330]), true); + await dice("chiaru", "Vorrai dire un giochino!", "laugh"); + await finitoDiDire(); + await sleep(400); + + // 7. Il buio: la sera, la torcia nella fessura, gli occhi gialli. + await d.cue("buio"); + // Sotto il buio una musica bassa di suspense (Andrea: «un po' di musichetta nel finale»). + await musica("assets/audio/sera.mp3"); + await fuoco(vai("fortezza", [462, 310], { indizio_regolo: true, casetta_aperta: true })); + await sleep(900); + await p.evaluate(() => { game.state.inventory.push("torcia"); game.act("use", "fessura", "torcia"); }); + // «Accendo la torcia e la punto dentro…» si salta: il primo piano si apre subito. + // Il palio non si vede (Andrea: si capirebbe che è in Fortezza): la luce parte da destra, nel buio, e i primi due + // ritrovamenti si danno per fatti; si cercano solo gli occhi. + for (let k = 0; k < 60 && await p.evaluate(() => game.closeup?.id !== "fessura"); k++) { await p.evaluate(() => game.skip()); await sleep(50); } + await p.evaluate(() => { const c = game.closeup; c.found = 2; c.beam = [600, 300]; }); + await sleep(1600); + const targets = await p.evaluate(() => game.story.closeups.fessura.targets.map((t) => t.at)); + for (let i = 2; i < targets.length; i++) { + if (i === 2) await musica("assets/audio/cartello.mp3"); + // La luce va piano verso il punto, come una mano che cerca: prima in basso, poi su verso gli occhi. + const from = await p.evaluate(() => game.closeup.beam); + for (let k = 1; k <= 30; k++) { + const x = from[0] + (targets[i][0] - from[0]) * k / 30 + Math.sin(k / 4) * 12, y = from[1] + (targets[i][1] - from[1]) * k / 30; + await p.evaluate(([x, y]) => game.closeupAim([x, y]), [x, y]); + await sleep(50); + } + // Le battute dei primi ritrovamenti si saltano (partono prima che il ritrovamento conti, e lo fermano); resta solo + // l'ultima: «…sono due occhi gialli!». + for (let k = 0; k < 300 && await p.evaluate((i) => !(game.closeup?.found > i), i); k++) { + if (i < 2) await p.evaluate(() => game.skip()); + await sleep(100); + } + await p.waitForFunction(() => !game.speech && !game.closeup?.lines?.length, null, { timeout: 30000 }); + await sleep(i === 2 ? 1400 : 100); + } + await nero(true); + await sleep(1400); + + // 8. Il titolo. + await d.cue("titolo"); + await musica("assets/audio/titolo.mp3"); + await p.evaluate(() => { + game.closeup = null; + document.getElementById("title").hidden = false; + document.getElementById("tr-nero").style.transition = "opacity 1.6s"; + }); + await sleep(300); + await nero(false); + await sleep(7500); + await nero(true); + await sleep(1600); + + const audio = await p.evaluate(() => window.__audio); + const out = new URL("./out/", import.meta.url); + mkdirSync(out, { recursive: true }); + writeFileSync(new URL("trailer-extended-audio.json", out), JSON.stringify({ eventi: audio }, null, 2)); +} diff --git a/video/trailer.mjs b/video/trailer.mjs new file mode 100644 index 0000000..0a4a9e3 --- /dev/null +++ b/video/trailer.mjs @@ -0,0 +1,238 @@ +// Copione del trailer «La Leggenda del Regolo» (scaletta approvata da Andrea il 29 ottobre): circa 75 secondi, la scena a +// tutto schermo, scritte grandi e stacchi al nero fatti dentro la pagina, niente puntatore e niente clic. +// Si gira come la partita completa (video/SCALETTA.md), poi: python3 video/monta.py --trailer . +// La musica non è quella che sceglierebbe il gioco: il copione annota i brani voluti, e le voci che partono, in +// out/trailer-audio.json; monta.py rimonta la colonna sonora. +import { mkdirSync, writeFileSync } from "node:fs"; + +const c = (id, it) => ({ id, it, en: it, top: true }); +export const title = { it: "La Leggenda del Regolo · trailer", en: "La Leggenda del Regolo · trailer" }; +export const cues = [ + c("apertura", "San Piero a Sieve, maggio 2007"), + c("notte", "La notte prima del Palio…"), + c("sparito", "…il palio è sparito."), + c("paese", "il paese"), + c("sfide", "Un paese pieno di sfide"), + c("amici", "…e di vecchie conoscenze"), + c("buio", "la fessura"), + c("titolo", "La Leggenda del Regolo"), +]; + +const URL_GIOCO = "http://127.0.0.1:8000/"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export async function setup(d) { + const p = d.page; + await p.goto(URL_GIOCO); + await p.evaluate(() => localStorage.clear()); + await p.reload(); + await sleep(500); + if (await p.evaluate(() => document.getElementById("splash").classList.contains("wait"))) await p.click("#splash"); + await p.waitForSelector("#title:not([hidden])"); + // Si entra in partita dietro le quinte: rione, introduzione saltata. + await p.click("#new-game"); + await p.click('#rione [data-rione="campomigliaio"]'); + await p.click("#skip-intro"); + // Il palcoscenico del trailer: solo la scena, a tutto schermo; sopra, il nero e le scritte. + await p.addStyleTag({ content: ` + #side, #corner, #sound, #closeup-close, #bocce-controls, #briscola-controls, #tressette-controls, #scopa-controls, + #map-button, #pause-button, #spots-button, #notebook-button, #sentence { display: none !important; } + body { background: #000 !important; overflow: hidden; } + #app { position: fixed !important; inset: 0 !important; width: 100vw !important; margin: 0 !important; } + #stage, #stage.biliardo, #stage.briscola { width: 100vw !important; height: 100vh !important; } + #scene { width: 100vw !important; height: 100vh !important; } + #title .buttons, #title .version, #title .dedication { display: none !important; } + #tr-nero { position: fixed; inset: 0; background: #000; opacity: 1; transition: opacity .7s; z-index: 50; pointer-events: none; } + #tr-scritta { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); text-align: center; z-index: 51; + font-family: "Press Start 2P", monospace; font-size: 42px; line-height: 1.6; color: #ffe9b0; opacity: 0; + transition: opacity .8s; text-shadow: 4px 4px 0 #2a1608, 0 0 24px rgba(0,0,0,.9); pointer-events: none; padding: 0 60px; } + #tr-scritta.basso { top: auto; bottom: 70px; transform: none; font-size: 32px; } + ` }); + await p.evaluate(() => { + for (const id of ["tr-nero", "tr-scritta"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); } + }); + // Senza nessun tocco il gioco va in pausa da solo dopo un minuto: un finto movimento ogni tanto lo tiene sveglio. + await p.evaluate(() => setInterval(() => dispatchEvent(new PointerEvent("pointermove")), 10000)); + // Le battute d'ingresso in piazza si saltano e la voce si ferma: il trailer parte in silenzio. + await p.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); settle(game); window.voices?.stop?.(); }); + // Il puntatore vero lontano, nell'angolo. + d.vm("DISPLAY=:0 ~/lab/video/mv.py 1599 899 0.2"); + await sleep(1200); +} + +export async function run(d) { + const p = d.page; + // L'orologio dell'audio, come nella partita completa: le voci che partono; la musica la decide il copione. + await p.evaluate(() => { + window.__t0 = Date.now(); window.__audio = []; + const t = () => (Date.now() - window.__t0) / 1000; + const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1)); + let voice = null; + window.__musica = (src) => window.__audio.push({ t: t(), music: src }); + const look = () => { + 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(); + }); + const musica = (src) => p.evaluate((s) => window.__musica(s), src); + const nero = (on) => p.evaluate((on) => { document.getElementById("tr-nero").style.opacity = on ? 1 : 0; }, on); + const scritta = (testo, basso = false) => p.evaluate(([t, b]) => { + const e = document.getElementById("tr-scritta"); + if (t) { e.textContent = t; e.classList.toggle("basso", b); } + e.style.opacity = t ? 1 : 0; + }, [testo, basso]); + // In ogni scena: niente primo piano, niente battute in coda, flag di partenza; Andrea dove serve (o fuori campo). + const vai = (scene, at, flags = {}) => `async () => { + const { settle } = await import('/tests/helpers.mjs'); + game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null; + Object.assign(game.state.flags, ${JSON.stringify(flags)}); + game.go('${scene}'${at ? `, ${JSON.stringify(at)}` : ""}); settle(game); + ${at ? "" : "game.state.x = -400;"} + }`; + const prepara = (codice) => p.evaluate(`(${codice})()`); + const fuoco = async (codice, veloce) => { + await p.evaluate((v) => { document.getElementById("tr-nero").style.transition = `opacity ${v ? .18 : .7}s`; }, veloce); + await nero(true); await sleep(veloce ? 200 : 750); + await prepara(codice); + await sleep(veloce ? 150 : 300); + await nero(false); await sleep(veloce ? 200 : 700); + }; + const dice = (who, testo, pose) => p.evaluate(([w, t, po]) => game.run(w ? { say: t, who: w, pose: po } : { say: t }), [who, testo, pose]); + const finitoDiDire = () => p.waitForFunction(() => !game.speech, null, { timeout: 20000 }); + + // 1. Il nero, poi il panorama di Monte Cacioli con le nuvole. + await musica("assets/audio/sera.mp3"); + await d.cue("apertura"); + await sleep(800); + await p.evaluate(() => { document.getElementById("tr-scritta").style.whiteSpace = "pre-line"; }); + await scritta("San Piero a Sieve\nmaggio 2007"); + await sleep(2400); + await scritta(null); + await fuoco(vai("via_cafaggio")); + await sleep(3200); + + // 2. La Fortezza al tramonto, le impronte. + await d.cue("notte"); + await fuoco(vai("fortezza", null, { indizio_regolo: true })); + await scritta("La notte prima del Palio…", true); + await sleep(3000); + await scritta(null); + + // 3. La scuola: il palio non c'è più. + await d.cue("sparito"); + await fuoco(vai("scuola", [300, 330], { indizio_regolo: false })); + await scritta("…il palio è sparito.", true); + await sleep(3200); + await scritta(null); + + // 4. Il paese, a stacchi: Andrea cammina. + await d.cue("paese"); + await musica("assets/audio/gara.mp3"); + for (const [scene, da, a] of [["piazza", [120, 330], [300, 300]], ["pieve", [40, 330], [260, 300]], ["passeggiata_carza", [330, 352], [420, 250]], + ["terrazza", [300, 330], [420, 320]], ["ponte_stazione", [300, 352], [200, 260]]]) { + await fuoco(vai(scene, da), true); + await p.evaluate((a) => game.walkTo(a), a); + await sleep(2100); + } + + // 5. Le sfide: un lampo per minigioco. + await d.cue("sfide"); + await scritta("Un paese pieno di sfide"); + await sleep(1600); + await scritta(null); + const sfida = async (closeup, level, dopo = "") => { + await fuoco(`async () => { + const { settle } = await import('/tests/helpers.mjs'); + game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.path = []; game.walking = false; game.task = null; + game.run({ closeup: '${closeup}', level: '${level}' }); settle(game); + ${dopo} + }`, true); + await sleep(1300); + }; + await sfida("gara", "seria", "const g = await import('/src/engine/gara.js'); while (game.closeup.step === 'intro') g.press(game.closeup);"); + await sfida("biliardo", "seria", "const b = await import('/src/engine/biliardo.js'); const c = game.closeup; b.strike(c, 'player', Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x), 0.8);"); + await sfida("rinquarto", "allenamento"); + await sfida("micro", "seria", "const m = await import('/src/engine/micro.js'); const c = game.closeup; m.pick(c, 0); m.strike(c, 'player', c.angle, 0.55);"); + await sfida("cerbottana", "seria", "const m = await import('/src/engine/cerbottana.js'); const c = game.closeup; c.aim = { x: 320, y: 118 }; c.power = m.BEST; m.shoot(c);"); + await sfida("briscola", "seria"); + await sfida("basket", "seria", "const b = await import('/src/engine/basket.js'); const c = game.closeup; c.power = 0.62; b.shoot(c);"); + + // 6. Le vecchie conoscenze, con le loro battute. + await d.cue("amici"); + await fuoco(vai("via_liguri_magelli", [330, 300])); + await scritta("…e di vecchie conoscenze", true); + await dice(null, "Di chi è quell'Audi lì?"); + await finitoDiDire(); + await scritta(null); + await dice("arcu", "Let it be.", "explain"); + await finitoDiDire(); + await sleep(300); + await dice(null, "Zio, hai problemi?"); + await finitoDiDire(); + await dice("zio_mario", "I problemi io gli avevo quando andavo a scuola!", "laugh"); + await finitoDiDire(); + await fuoco(vai("cardetole", [470, 330]), true); + await dice("chiaru", "Vorrai dire un giochino!", "laugh"); + await finitoDiDire(); + await sleep(400); + + // 7. Il buio: la sera, la torcia nella fessura, gli occhi gialli. + await d.cue("buio"); + // Sotto il buio una musica bassa di suspense (Andrea: «un po' di musichetta nel finale»). + await musica("assets/audio/sera.mp3"); + await fuoco(vai("fortezza", [462, 310], { indizio_regolo: true, casetta_aperta: true })); + await sleep(900); + await p.evaluate(() => { game.state.inventory.push("torcia"); game.act("use", "fessura", "torcia"); }); + // «Accendo la torcia e la punto dentro…» si salta: il primo piano si apre subito. + // Il palio non si vede (Andrea: si capirebbe che è in Fortezza): la luce parte da destra, nel buio, e i primi due + // ritrovamenti si danno per fatti; si cercano solo gli occhi. + for (let k = 0; k < 60 && await p.evaluate(() => game.closeup?.id !== "fessura"); k++) { await p.evaluate(() => game.skip()); await sleep(50); } + await p.evaluate(() => { const c = game.closeup; c.found = 2; c.beam = [600, 300]; }); + await sleep(1600); + const targets = await p.evaluate(() => game.story.closeups.fessura.targets.map((t) => t.at)); + for (let i = 2; i < targets.length; i++) { + if (i === 2) await musica("assets/audio/cartello.mp3"); + // La luce va piano verso il punto, come una mano che cerca: prima in basso, poi su verso gli occhi. + const from = await p.evaluate(() => game.closeup.beam); + for (let k = 1; k <= 30; k++) { + const x = from[0] + (targets[i][0] - from[0]) * k / 30 + Math.sin(k / 4) * 12, y = from[1] + (targets[i][1] - from[1]) * k / 30; + await p.evaluate(([x, y]) => game.closeupAim([x, y]), [x, y]); + await sleep(50); + } + // Le battute dei primi ritrovamenti si saltano (partono prima che il ritrovamento conti, e lo fermano); resta solo + // l'ultima: «…sono due occhi gialli!». + for (let k = 0; k < 300 && await p.evaluate((i) => !(game.closeup?.found > i), i); k++) { + if (i < 2) await p.evaluate(() => game.skip()); + await sleep(100); + } + await p.waitForFunction(() => !game.speech && !game.closeup?.lines?.length, null, { timeout: 30000 }); + await sleep(i === 2 ? 1400 : 100); + } + await nero(true); + await sleep(1400); + + // 8. Il titolo. + await d.cue("titolo"); + await musica("assets/audio/titolo.mp3"); + await p.evaluate(() => { + game.closeup = null; + document.getElementById("title").hidden = false; + document.getElementById("tr-nero").style.transition = "opacity 1.6s"; + }); + await sleep(300); + await nero(false); + await sleep(7500); + await nero(true); + await sleep(1600); + + const audio = await p.evaluate(() => window.__audio); + const out = new URL("./out/", import.meta.url); + mkdirSync(out, { recursive: true }); + writeFileSync(new URL("trailer-audio.json", out), JSON.stringify({ eventi: audio }, null, 2)); +}