Compare commits

...
3 Commits
8 changed files with 659 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.51", "version": "0.1.52",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+113
View File
@@ -151,4 +151,117 @@ export const ambience = {
{ type: 'cloudshadow', phase: day, area: [60, 220, 520, 140], offset: 23000, { type: 'cloudshadow', phase: day, area: [60, 220, 520, 140], offset: 23000,
clip: [[0, 215], [400, 195], [640, 260], [640, 360], [0, 360]] }, clip: [[0, 215], [400, 195], [640, 260], [640, 360], [0, 360]] },
], ],
// I posti nuovi del 29 ottobre (foto di Andrea): nuvole, uccelli, la Carza che scorre e luccica, fronde, ombre.
via_compagnia: [
{ type: 'clouds', area: [130, 0, 340, 80], count: 2 },
{ type: 'lamps', phase: 'sera', points: [[115, 104]], glow: 10 },
bird([250, 300, 70, 16], 9000),
],
via_cafaggio: [
// Il panorama: Monte Cacioli e le colline, con le ombre delle nuvole che ci scorrono sopra.
{ type: 'clouds', area: [0, 0, 640, 80], count: 4, big: true },
{ type: 'cloudshadow', phase: day, area: [0, 80, 640, 90], offset: 6000, clip: rect([0, 80, 640, 90]) },
{ type: 'cloudshadow', phase: day, area: [0, 80, 640, 90], offset: 32000, clip: rect([0, 80, 640, 90]) },
{ type: 'birds', phase: day, area: [80, 20, 420, 70], count: 4, every: 21000, duration: 6000, offset: 3000 },
{ type: 'sway', rects: [[560, 0, 80, 90]], amount: 2 },
butterfly([40, 300, 160, 40], 15000),
],
via_cafaggio_fondo: [
{ type: 'clouds', area: [0, 10, 380, 80], count: 3, big: true },
{ type: 'birds', phase: day, area: [150, 20, 250, 60], count: 2, every: 26000, duration: 5000, offset: 8000 },
{ type: 'sway', rects: [[390, 0, 150, 160]], amount: 1.5 },
shade([300, 270, 220, 70], 12),
{ type: 'lamps', phase: 'sera', points: [[252, 83], [319, 121]], glow: 9 },
],
via_cafaggio_avanti: [
{ type: 'clouds', area: [150, 10, 400, 80], count: 3, big: true },
{ type: 'sway', rects: [[400, 60, 200, 90]], amount: 1.5 },
shade([180, 250, 260, 90], 12),
bird([120, 300, 80, 16], 14000),
],
rotonda_cafaggio: [
{ type: 'clouds', area: [200, 20, 420, 90], count: 4, big: true },
{ type: 'birds', phase: day, area: [250, 20, 300, 60], count: 3, every: 23000, duration: 5500, offset: 5000 },
{ type: 'sway', rects: [[0, 0, 140, 240], [350, 110, 200, 100], [560, 0, 80, 230]], amount: 2 },
shade([0, 250, 200, 60], 12),
],
provinciale_luna: [
{ type: 'clouds', area: [80, 20, 330, 80], count: 3, big: true },
{ type: 'birds', phase: day, area: [100, 20, 280, 60], count: 3, every: 24000, duration: 5000, offset: 12000 },
{ type: 'sway', rects: [[400, 0, 240, 170]], amount: 2 },
shade([380, 230, 170, 100], 14),
],
passeggiata_carza: [
// La cascatella che si vede dalla Luna: l'acqua che scende luccica, e la Carza scorre verso valle.
{ type: 'ripples', area: [150, 200, 196, 55], count: 14, flow: [-10, 4],
clip: [[150, 242], [210, 226], [290, 204], [346, 200], [346, 212], [300, 224], [225, 246], [160, 256]] },
{ type: 'sparkle', area: [296, 184, 46, 18], count: 5 },
{ type: 'sparkle', area: [170, 222, 120, 26], count: 3 },
{ type: 'clouds', area: [380, 40, 140, 50], count: 2 },
{ type: 'sway', rects: [[0, 0, 180, 250], [300, 0, 100, 200], [440, 60, 100, 120]], amount: 2 },
shade([200, 240, 300, 60], 14),
{ type: 'insect', kind: 'dragonfly', phase: day, area: [180, 214, 110, 30], every: 33000, duration: 9000, offset: 4000 },
butterfly([380, 228, 120, 40], 17000),
{ type: 'lamps', phase: 'sera', points: [[519, 77]], glow: 10 },
],
passeggiata_carza_avanti: [
{ type: 'ripples', area: [480, 236, 160, 70], count: 10, flow: [10, 4],
clip: [[480, 240], [560, 250], [640, 272], [640, 318], [560, 292], [500, 270]] },
{ type: 'sparkle', area: [520, 252, 100, 40], count: 3 },
{ type: 'clouds', area: [0, 0, 640, 90], count: 4, big: true },
{ type: 'birds', phase: day, area: [150, 20, 350, 60], count: 3, every: 22000, duration: 5500, offset: 7000 },
{ type: 'sway', rects: [[250, 10, 110, 170], [360, 40, 90, 120], [500, 60, 140, 120]], amount: 2 },
butterfly([40, 200, 160, 50], 9000),
],
passeggiata_poste: [
{ type: 'ripples', area: [470, 270, 170, 88], count: 12, flow: [10, 3],
clip: [[470, 278], [560, 272], [640, 300], [640, 358], [560, 358], [480, 302]] },
{ type: 'sparkle', area: [500, 290, 120, 50], count: 3 },
{ type: 'clouds', area: [380, 0, 260, 90], count: 3, big: true },
{ type: 'sway', rects: [[150, 50, 170, 150], [400, 120, 240, 90]], amount: 2 },
shade([180, 250, 130, 60], 10),
{ type: 'lamps', phase: 'sera', points: [[337, 144]], glow: 10 },
],
passeggiata_ponte: [
{ type: 'ripples', area: [480, 250, 160, 108], count: 12, flow: [10, 4],
clip: [[480, 256], [560, 250], [640, 272], [640, 358], [580, 358], [500, 300]] },
{ type: 'sparkle', area: [500, 270, 120, 70], count: 4 },
{ type: 'clouds', area: [240, 20, 300, 80], count: 3, big: true },
{ type: 'birds', phase: day, area: [250, 30, 250, 50], count: 2, every: 26000, duration: 5000, offset: 16000 },
{ type: 'sway', rects: [[0, 0, 250, 190], [270, 80, 370, 160]], amount: 2 },
butterfly([60, 220, 100, 40], 11000),
],
ponte_stazione: [
{ type: 'ripples', area: [300, 214, 340, 102], count: 18, flow: [12, 4],
clip: [[300, 218], [380, 214], [520, 240], [640, 280], [640, 316], [560, 300], [420, 252], [320, 228]] },
{ type: 'sparkle', area: [420, 240, 200, 60], count: 5 },
{ type: 'clouds', area: [180, 10, 280, 80], count: 3, big: true },
{ type: 'sway', rects: [[0, 0, 170, 190], [390, 0, 250, 210]], amount: 2 },
butterfly([150, 240, 150, 50], 6000),
{ type: 'insect', kind: 'dragonfly', phase: day, area: [440, 228, 110, 30], every: 31000, duration: 8500, offset: 15000 },
],
orti_zio_mario: [
{ type: 'clouds', area: [80, 20, 500, 90], count: 3, big: true },
{ type: 'birds', phase: day, area: [300, 30, 250, 60], count: 3, every: 24000, duration: 5000, offset: 2000 },
{ type: 'sway', rects: [[180, 10, 140, 180], [430, 10, 210, 240]], amount: 2 },
butterfly([10, 240, 140, 50], 8000),
bird([280, 290, 80, 18], 13000, 'blackbird'),
{ type: 'lamps', phase: 'sera', points: [[352, 110]], glow: 10 },
],
viale_stazione: [
{ type: 'clouds', area: [230, 0, 380, 110], count: 4, big: true },
{ type: 'birds', phase: day, area: [250, 20, 300, 60], count: 3, every: 22000, duration: 5500, offset: 9000 },
{ type: 'sway', rects: [[270, 110, 90, 110], [330, 160, 140, 70]], amount: 1.5 },
{ type: 'lamps', phase: 'sera', points: [[396, 117], [405, 155]], glow: 9 },
bird([520, 260, 60, 16], 18000),
],
terrazza: [
// Il panorama di casa: il paese e la Fortezza sotto un cielo che si muove, le ombre delle nuvole sulla collina.
{ type: 'clouds', area: [0, 0, 640, 80], count: 5, big: true },
{ type: 'cloudshadow', phase: day, area: [0, 40, 640, 200], offset: 4000, clip: rect([0, 40, 640, 200]) },
{ type: 'cloudshadow', phase: day, area: [0, 40, 640, 200], offset: 30000, clip: rect([0, 40, 640, 200]) },
{ type: 'birds', phase: day, area: [60, 20, 480, 80], count: 5, every: 19000, duration: 6500, offset: 1000 },
{ type: 'sway', rects: [[54, 176, 136, 100]], amount: 1.5 },
butterfly([500, 230, 100, 40], 10000),
],
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.51'; export const VERSION = '0.1.52';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+15
View File
@@ -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 - **Il ritaglio** (`TAGLIO` in `monta.py`) va rimisurato quando cambia l'impaginazione: `convert -trim` su una
schermata della prova a vuoto. schermata della prova a vuoto.
- **Per Telegram** (50 MB al massimo): due passate x264 `-tune animation` a 470 kbit/s → circa 45 MB. - **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/<clip>-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 <cartella> --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).
+9
View File
@@ -4,6 +4,7 @@ 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 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; - 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;
@@ -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_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre)
VOLUME_VOCI = 1.0 VOLUME_VOCI = 1.0
VOLUME_NARRATORE = 1.15 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 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 FADE = 1.2
@@ -49,6 +53,7 @@ def ts(s: float) -> str:
def main() -> None: def main() -> None:
d = Path(sys.argv[1]).expanduser() d = Path(sys.argv[1]).expanduser()
tutorial = "--tutorial" in sys.argv[2:] tutorial = "--tutorial" in sys.argv[2:]
trailer = "--trailer" in sys.argv[2:]
taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA) taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA)
meta = json.loads((d / "cues.json").read_text()) meta = json.loads((d / "cues.json").read_text())
nome = meta["clip"] nome = meta["clip"]
@@ -71,6 +76,8 @@ def main() -> None:
# «troppo alta la musica del gioco»). # «troppo alta la musica del gioco»).
if (OUT / f"{nome}-narrazione.json").exists(): if (OUT / f"{nome}-narrazione.json").exists():
volume = VOLUME_TUTORIAL volume = VOLUME_TUTORIAL
if trailer:
volume = VOLUME_TRAILER
else: else:
musica = json.loads((OUT / "musica.json").read_text()) musica = json.loads((OUT / "musica.json").read_text())
volume = VOLUME volume = VOLUME
@@ -131,6 +138,8 @@ def main() -> None:
mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]" 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]"
if trailer:
video = "[0:v]scale=1600:900:flags=lanczos,fade=t=in:d=0.5[v]"
finale = OUT / f"{nome}.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]",
+19
View File
@@ -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(); }
+263
View File
@@ -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 <cartella> --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));
}
+238
View File
@@ -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 <cartella> --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));
}