Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
295 lines
18 KiB
JavaScript
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));
|
|
}
|