Files

295 lines
18 KiB
JavaScript

// Copione del trailer lungo «La Leggenda del Regolo · extended» (scaletta approvata da Andrea il 29 ottobre): circa
// quattro 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, Condor, campana, pesca, basket, pokerino.
// Rifatto il 30 ottobre (Andrea): niente che dica che il Regolo è in Fortezza. La notte prima del Palio è la piazza
// con le finestre accese, e nel finale ci sono solo due occhi gialli nel buio, senza muro e senza fessura.
// 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("condor", "La Condor col Taglia"),
c("campana", "La campana"),
c("pesca", "La pesca al lago"),
c("basket", "Fai due tiri"),
c("poker", "Il pokerino del Barrettino"),
c("amici", "…e di vecchie conoscenze"),
c("buio", "due occhi gialli"),
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; }
#scene.notte { filter: brightness(.5) saturate(.85) hue-rotate(-8deg); }
/* Il titolo sopra il panorama: l'illustrazione del gioco ha il Regolo fra le mura della Fortezza. */
#title::before { background-image: url(assets/scenes/panorama-intro.png) !important; }
#title::after { background: linear-gradient(rgba(20, 12, 30, .55), rgba(20, 12, 30, .05) 45%, rgba(20, 12, 30, .7)),
url(assets/scenes/panorama-intro.png) center / contain no-repeat !important; }
#tr-occhi { position: fixed; left: 50%; top: 44%; z-index: 51; display: flex; gap: 46px; transform: translateX(-50%);
opacity: 0; transition: opacity 1.4s; pointer-events: none; }
#tr-occhi i { width: 22px; height: 22px; background: #ffe14a; box-shadow: 0 0 18px 6px rgba(255, 210, 40, .55); }
#tr-occhi.chiusi i { height: 3px; margin-top: 10px; }
` });
await p.evaluate(() => {
for (const id of ["tr-nero", "tr-scritta", "tr-occhi"]) { const e = document.createElement("div"); e.id = id; document.body.appendChild(e); }
document.getElementById("tr-occhi").innerHTML = "<i></i><i></i>";
});
// 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 piazza di sera, con le finestre accese (niente Fortezza e niente impronte: si capirebbe dov'è il Regolo).
await d.cue("notte");
await fuoco(`async () => { await (${vai("piazza", null, { casetta_aperta: true })})(); document.getElementById("scene").classList.add("notte"); }`);
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(`async () => { await (${vai("scuola", [300, 330], { casetta_aperta: false })})(); document.getElementById("scene").classList.remove("notte"); }`);
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(2800);
}
// 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 a ogni fotogramma chiama `mossa` (il giocatore che gioca da solo) finché passano i
// secondi. Dentro `mossa`, `pronto(ms)` dice se dall'ultima mossa è passato abbastanza: così non gioca a raffica.
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}
const mossa = new Function('M', 'c', 'pronto', ${JSON.stringify(mossa)});
let ultima = performance.now();
const pronto = (ms) => performance.now() - ultima > ms && (ultima = performance.now(), true);
const giro = () => { if (game.closeup !== c) return; mossa(M, c, pronto); requestAnimationFrame(giro); };
giro();
}`);
await scritta(nome, true);
await sleep(3000);
await scritta(null);
await sleep((secondi - 3) * 1000);
};
await gioca("bocce", "Le bocce al pallaio", "assets/audio/bocce.mp3", "bocce", "seria", "/src/engine/bocce.js", "",
"if (M.canThrow(c) && pronto(500)) { 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); }", 11);
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 && pronto(400)) 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);", 11);
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);", 13);
await gioca("carte", "La briscola all'oratorio", "assets/audio/carte.mp3", "briscola", "seria", "/src/engine/briscola.js", "",
"if (M.canPlay(c) && pronto(700)) M.play(c, 0);", 10);
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) && pronto(400)) { 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))); }", 10);
await gioca("cerbottana", "La cerbottana", "assets/audio/gara.mp3", "cerbottana", "seria", "/src/engine/cerbottana.js", "",
"if (M.canThrow(c) && pronto(400)) { 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); }", 9);
// La Condor: le lattine sulla panchina, le bottiglie, il palloncino; il pallino cala, si mira un po' sopra.
await gioca("condor", "La Condor col Taglia", "assets/audio/gara.mp3", "condor", "bona", "/src/engine/condor.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(800)) return; const su = x => M.DROP * M.far(x) ** 2; const k = c.cans.find(k => !k.down), b = c.bottles.find(b => !b.down);" +
" if (k) c.aim = { x: k.x, y: M.BENCH - M.CAN.h / 2 - su(k.x) }; else if (b) c.aim = { x: b.x, y: M.BAR - M.BOTTLE.h / 2 - su(b.x) };" +
" else if (!c.balloon.popped) { const p = M.balloon(c); c.aim = { x: p.x, y: p.y - su(p.x) }; } else c.aim = { x: M.CARTONE.x, y: M.CARTONE.y - su(M.CARTONE.x) }; M.shoot(c);", 10);
// La campana: il sassolino nella casella giusta, poi i salti quando la lancetta passa nel verde; poi tocca al Taglia.
await gioca("campana", "La campana", "assets/audio/macchinine.mp3", "campana", "bona", "/src/engine/campana.js", "",
"if (c.step === 'aim' && c.current === 'player' && pronto(700)) { const p = M.center(c.target.player); c.skill.scatter = 0; c.aim = { x: p.x, y: p.y }; c.power = Math.hypot(p.x - M.FROM.x, p.y - M.FROM.y) / M.REACH; M.shoot(c); }" +
" else if (c.step === 'hop' && (!c.hop || c.hop.t >= M.HOP) && c.meter?.t > 0 && Math.abs(M.needle(c)) < 0.08) M.press(c, 1);", 15);
// La pesca: il galleggiante abbocca presto (nel trailer non si aspetta), si ferra e si recupera senza strappare.
await gioca("pesca", "La pesca al lago", "assets/audio/paese.mp3", "pesca", "bona", "/src/engine/pesca.js", "",
"const f = c.float; if (c.step === 'aim' && pronto(800)) { c.power = 0.45; M.shoot(c); }" +
" else if (c.step === 'wait' && f) { if (!f.svelto && f.biteAt) { const prima = Math.max(0, f.biteAt - f.t - 1.6); f.biteAt -= prima; f.nibbles.forEach(n => n.at -= prima); f.svelto = true; }" +
" if (M.biting(c)) { M.strike(c); c.reeling = c.step === 'fight'; } } else if (c.step === 'fight') c.reeling = c.tension < 0.8;", 15);
// Fai due tiri: per ogni postazione si cerca prima, su una copia, un tiro che entra.
await gioca("basket", "Fai due tiri", "assets/audio/gara.mp3", "basket", "bona", "/src/engine/basket.js", "",
"if (!M.canThrow(c) || c.holding || !pronto(600)) return; let best = { angle: 55, power: 0.7 };" +
" cerca: for (let angle = 46; angle <= 66; angle += 4) for (let power = 0.3; power <= 1; power += 0.02) { const p = structuredClone(c); M.setAngle(p, angle); M.setPower(p, power); M.shoot(p);" +
" for (let i = 0; i < 2000 && p.step === 'flight'; i++) M.update(p, 1 / 120); if (p.ball.scored) { best = { angle, power }; break cerca; } }" +
" M.setAngle(c, best.angle); M.setPower(c, best.power); M.shoot(c);", 11);
await gioca("poker", "Il pokerino del Barrettino", "assets/audio/carte.mp3", "poker", "seria", "/src/engine/poker.js", "",
"if (M.canDraw(c) && pronto(900)) M.choose(c, 'cambia'); else if (M.myTurn(c) && pronto(900)) M.choose(c, 'vedo'); else if (c.step === 'show' && pronto(2500)) M.advance(c);", 11);
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: solo due occhi gialli che si aprono, guardano e sbattono (Andrea, 30 ottobre: niente Fortezza, niente
// fessura e niente palio, sennò si capisce dov'è il Regolo).
await d.cue("buio");
await musica("assets/audio/sera.mp3");
await p.evaluate(() => { document.getElementById("tr-nero").style.transition = "opacity .9s"; });
await nero(true);
await prepara(`async () => { game.closeup = null; game.queue.length = 0; game.speech = null; game.dialogue = null; game.state.x = -400; }`);
await sleep(1800);
await musica("assets/audio/cartello.mp3");
const occhi = (fn) => p.evaluate(fn);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 1; });
await sleep(2200);
for (const pausa of [140, 2200, 110, 140, 110]) {
await occhi(() => document.getElementById("tr-occhi").classList.toggle("chiusi"));
await sleep(pausa);
}
await dice(null, "E quelli... sono due occhi gialli!");
await finitoDiDire();
await sleep(900);
await occhi(() => { document.getElementById("tr-occhi").classList.add("chiusi"); });
await sleep(150);
await occhi(() => { document.getElementById("tr-occhi").style.opacity = 0; });
await sleep(1500);
// 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));
}