Giochi di carte dopo la revisione di Codex sui tre insieme; player dei video «Come si gioca» e primo copione

Esito che vale e si salva appena deciso, per tutti i minigiochi (anche tornando al titolo dalla schermata del risultato); briscola: uscire durante il conteggio non perde più la vittoria; tressette: si esce ai 21 col primo evento che ci porta, accusa compresa (prima vinceva sempre Andrea se ci arrivavano tutte e due), il carico che fa vincere si dà anche se è il 2, accuse che non escono dal tavolo, convenzioni scritte nell'aiuto; scopa: primiera valutata per quel che migliora, finale esatto a mazzo finito, presa sulla tavola al rilascio con scelta e conferma, frecce e Invio sempre sulla presa scelta. Tastiera: Invio e Spazio giocano sempre la carta scelta. Tavola descritta a parole. Classi CSS comuni ai tre giochi.
Video: player nel gioco (pulsante «Guarda il video» nel «Come si gioca», compare quando il video esiste), video/narra.py per la voce del narratore, pezzi comuni dei copioni e il copione della briscola; scaletta in video/SCALETTA-CARTE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 16:39:22 +01:00
co-authored by Claude Opus 5.5
parent 30e324d7e2
commit 7e1e6f6144
24 changed files with 594 additions and 73 deletions
+58
View File
@@ -0,0 +1,58 @@
# Video «Come si gioca» — briscola, tressette ai 21, scopa, bocce, biliardo (scaletta)
Cinque clip brevi, una per gioco, con voce italiana (XTTS) e sottotitoli impressi. Si guardano dentro il gioco:
un pulsante «Guarda il video» nel «Come si gioca» di ogni gioco apre un player con l'audio.
Le mani sono preparate dal copione (semi fissi), così ogni regola si vede nel momento giusto.
## 1. Briscola (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Oratorio, Andrea parla al giocatore di carte, sceglie toscane e «Seria» | Dove si gioca, mazzo e livello |
| 2 | Il tavolo: la mano, la briscola sotto il mazzo, il mazzo | Tre carte a testa, la briscola comanda |
| 3 | Una presa col seme, una presa con la briscola | Chi prende: la più alta del seme, o la briscola |
| 4 | Liscio su un carico, poi un carico preso con una briscolina | Asso 11, 3 dieci, regio 4, donna 3, gobbo 2 |
| 5 | Fine mazzo e conteggio carta per carta | Si vince con 61, 60 a 60 è patta |
## 2. Tressette ai 21 (circa 3 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Un tressette ai 21?», i quattro al tavolo | In quattro a coppie, il socio di fronte |
| 2 | Una presa in cui si risponde al seme, una carta rifiutata | Obbligo di seme; ordine 3, 2, Asso, figure |
| 3 | Un'accusa scritta in alto | Napoletana, tre Assi/2/3, quattro |
| 4 | Andrea esce con «Striscio», poi «Busso» e il socio che ritorna il seme, poi un «Volo» | I segni, coi pulsanti o B, S, V |
| 5 | Riepilogo di smazzata e il «fuori» ai 21 | Terzi, ultima presa, si va fuori ai 21 |
## 3. Scopa (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Una scopa?», il tavolo con quattro carte | Valori: Asso 1 … regio 10 |
| 2 | Una presa della carta uguale, una presa per somma | La carta uguale viene prima delle somme |
| 3 | Due prese possibili: Andrea sceglie | Se le prese sono più d'una, scegli tu |
| 4 | Una scopa | Pulire la tavola fa un punto |
| 5 | Riepilogo: carte, mattoni, settebello, primiera, scope | Agli 11 |
## 4. Bocce al pallaio (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Brento Sanico, Andrea parla con Mariano, sceglie il livello | Dove si gioca e i livelli |
| 2 | Mira toccando il campo e il dettaglio del pallino, il cerchio azzurro | Come si mira |
| 3 | La forza col cursore e «Lancia»; poi tenere premuto e lasciare | Due modi di tirare, anche frecce e Spazio |
| 4 | Una boccia che si accosta, una bocciata di Mariano | Tira chi non ha il punto |
| 5 | Fine mano e punti | Tre bocce a testa, ogni boccia più vicina della migliore avversaria fa un punto |
## 5. Biliardo, cinque birilli (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Circolo 4 Maggio, l'Ing. Tozzi, il livello | Dove si gioca |
| 2 | Il tavolo: le due biglie, il pallino, il castello dei birilli | Si colpisce la propria biglia e la si manda sull'altra |
| 3 | La mira: la linea tratteggiata, le frecce dopo l'urto; clic che fissa la mira | Come si mira, Maiusc per la mira fine |
| 4 | Un colpo che abbatte un birillo bianco, poi il rosso | Bianco 2, rosso 4, rosso da solo 10, pallino 3 |
| 5 | Un fallo (mancare la biglia) | I falli danno i punti all'avversario |
| 6 | Il segnapunti e la vittoria a 20 | Si va a 20 |
Prima di girare: le correzioni della revisione di Codex sui tre giochi, così il video mostra il gioco definitivo.
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""La voce del narratore dei video «Come si gioca» (video/SCALETTA-CARTE.md), PRIMA di girare.
python3 video/narra.py video/tutorial-briscola.mjs
Legge le battute del copione (cues, «it»), le fa dire a XTTS con `mbot parla` (voce del narratore, diversa da
quelle dei personaggi), le controlla con `mbot ascolta` e scrive video/out/<clip>-narrazione.json con file e
durata di ognuna. Il copione aspetta che la frase sia finita prima di passare alla successiva; monta.py la mette
nel video all'inizio della sua battuta. Le frasi già fatte si saltano (per rifarne una: cancellare il file).
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
OUT = VIDEO / "out"
NARRATORE = "Claribel Dervla"
PRONUNCIA = GIOCO / "tools" / "pronuncia.json"
def durata(f: Path) -> float:
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
capture_output=True, text=True, check=True).stdout)
def main() -> None:
copione = Path(sys.argv[1]).resolve()
clip = copione.stem
cues = json.loads(subprocess.run(["node", "--input-type=module", "-e",
f"const m = await import({json.dumps(copione.as_uri())}); console.log(JSON.stringify(m.cues))"],
capture_output=True, text=True, check=True, cwd=GIOCO).stdout)
cartella = OUT / f"{clip}-narrazione"
cartella.mkdir(parents=True, exist_ok=True)
# XTTS dice «punto» se la frase finisce col punto: si toglie, come fa narrate.py del tour.
lavori = [{"text": c["it"].rstrip(". "), "out": str(cartella / f"{c['id']}.wav"), "lang": "it", "speaker": NARRATORE} for c in cues]
(cartella / "lavori.json").write_text(json.dumps(lavori, ensure_ascii=False, indent=1))
pron = ["--pronounce", str(PRONUNCIA)] if PRONUNCIA.exists() else []
subprocess.run(["mbot", "parla", "--jobs", str(cartella / "lavori.json"), *pron], check=True)
ascolto = [{"file": l["out"], "expect": l["text"]} for l in lavori]
(cartella / "ascolto.json").write_text(json.dumps(ascolto, ensure_ascii=False, indent=1))
subprocess.run(["mbot", "ascolta", "--jobs", str(cartella / "ascolto.json")])
voci = [{"id": c["id"], "src": str(Path(l["out"]).relative_to(GIOCO)), "sec": round(durata(Path(l["out"])), 2)} for c, l in zip(cues, lavori)]
(OUT / f"{clip}-narrazione.json").write_text(json.dumps({"voci": voci}, ensure_ascii=False, indent=1))
print(f"-> {OUT / f'{clip}-narrazione.json'}: {len(voci)} frasi, {sum(v['sec'] for v in voci):.0f} s di voce")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
// Copione del video «Come si gioca: la briscola» (video/SCALETTA-CARTE.md, clip 1).
// Giro: video/narra.py, poi rec.mjs come in video/SCALETTA.md, poi video/monta.py <cartella> --tutorial.
import { c, sleep, setup as base, regista } from "./tutorial-comune.mjs";
export const title = { it: "Come si gioca · la briscola", en: "Come si gioca · la briscola" };
export const cues = [
c("dove", "All'oratorio della Pieve, alle tavolate, c'è il giocatore di carte: per giocare basta parlarci"),
c("mazzo", "Prima si sceglie il mazzo: le carte toscane, con gobbo, donna e regio, oppure le napoletane"),
c("livello", "Poi quanto deve giocare forte: alla bona, seria, o a fassi male"),
c("tavolo", "In basso le tue tre carte, in alto quelle coperte dell'avversario. A sinistra il mazzo, con la briscola girata sotto: quel seme comanda"),
c("presa", "Si gioca una carta per uno. Prende la briscola più alta; se non ce ne sono, la carta più alta del seme di chi è uscito"),
c("punti", "Contano l'Asso, undici punti, il tre, dieci, il regio quattro, la donna tre e il gobbo due. Le altre sono lisci e non valgono niente"),
c("pesca", "Chi prende pesca per primo ed esce alla mano dopo. E non c'è l'obbligo di rispondere al seme"),
c("consiglio", "Un consiglio: su un liscio rispondi con un liscio, e i carichi dell'avversario prendili con una briscola piccola"),
c("tastiera", "Si gioca toccando la carta, oppure coi pulsanti sotto il tavolo, o con le frecce e Invio"),
c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti, e sessanta a sessanta è patta"),
c("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"),
];
export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-briscola");
await r.ascolta();
await r.vaiA("oratorio", [470, 300]);
await r.cue("dove");
await r.parlaCon("giocatore_carte");
await r.scegli("Una briscola?");
await r.cue("mazzo");
await r.scegli("Con le toscane.");
await r.cue("livello");
await r.scegli("Seria.");
// Una mano fissa, la stessa a ogni ripresa.
await p.evaluate(async () => {
const { newBriscola } = await import("/src/engine/briscola.js");
game.closeup = { id: "briscola", ...newBriscola(91421, { level: "seria", deck: "toscane" }) };
});
await sleep(800);
await r.cue("tavolo");
await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0);
// Una carta di Andrea: quella che sceglierebbe un giocatore attento, cliccata davvero sul tavolo.
const gioca = async () => {
for (let i = 0; i < 400; i++) {
const s = await p.evaluate(async () => {
const { canPlay } = await import("/src/engine/briscola.js");
return game.closeup?.step === "result" || game.closeup?.step === "count" ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
});
if (s === "fine") return false;
if (s === "tocca") break;
await sleep(150);
}
const i = await p.evaluate(async () => {
const { observation, attentive } = await import("/src/engine/briscola.js");
return attentive(observation(game.closeup, "player"));
});
await r.clicca([176 + i * 102 + 42, 290], 0.7);
return true;
};
const giocane = async (n) => { for (let k = 0; k < n; k++) if (!(await gioca())) return; };
await r.cue("presa"); await giocane(2);
await r.cue("punti"); await giocane(2);
await r.cue("pesca"); await giocane(2);
await r.cue("consiglio"); await giocane(2);
await r.cue("tastiera"); await giocane(20);
await p.waitForFunction(() => game.closeup?.step === "count", null, { timeout: 60000 });
await r.cue("conto");
await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 });
await sleep(2500);
await r.cue("fine");
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
await r.salvaAudio();
}
+101
View File
@@ -0,0 +1,101 @@
// Pezzi comuni dei copioni «Come si gioca» (video/SCALETTA-CARTE.md): briscola, tressette, scopa, bocce, biliardo.
// Si girano come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»); prima `python3 video/narra.py <copione>`
// per la voce del narratore, dopo `python3 video/monta.py <cartella> --tutorial`.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
export const URL_GIOCO = "http://127.0.0.1:8000/";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const c = (id, it) => ({ id, it, en: it, top: true });
// La durata di ogni frase del narratore (video/narra.py): la battuta resta finché la voce non ha finito.
export function narrazione(clip) {
const f = new URL(`./out/${clip}-narrazione.json`, import.meta.url);
return existsSync(f) ? Object.fromEntries(JSON.parse(readFileSync(f, "utf8")).voci.map((v) => [v.id, v.sec * 1000])) : {};
}
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await p.waitForSelector("#title:not([hidden])");
await sleep(1000);
}
export function regista(d, clip) {
const p = d.page, voce = narrazione(clip);
let inizio = 0, dura = 0;
const r = {
// Una battuta del narratore: prima si lascia finire quella di prima.
async cue(id) {
const resto = inizio + dura + 350 - Date.now();
if (resto > 0) await sleep(resto);
await d.cue(id);
inizio = Date.now(); dura = voce[id] ?? 0;
},
async fine() { const resto = inizio + dura + 600 - Date.now(); if (resto > 0) await sleep(resto); },
// Puntatore vero sulla scena (coordinate 640×360).
async punto(x, y) {
const b = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
return [(b.x + (x / 640) * b.width) * k, (b.y + (y / 360) * b.height) * k];
},
async clicca([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); },
async muovi([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur}`); await sleep(200); },
// Aspetta che l'omino abbia finito di camminare e di parlare; un dialogo o un primo piano aperti fermano l'attesa.
async fermi(pausa = 500) {
await sleep(150);
for (let i = 0; i < 1500; i++) {
const ok = await p.evaluate(() => {
if (game.speech || (game.queue.length && !game.closeup) || game.walking || game.reach > 0) return false;
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
});
if (ok) break;
await sleep(100);
}
await sleep(pausa);
},
async scegli(testo) { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); },
// Partita nuova per Campomigliaio, introduzione saltata, dritti nella scena del gioco.
async vaiA(scena, at) {
await d.click(p.locator("#new-game"));
await sleep(1200);
await d.click(p.locator('#rione [data-rione="campomigliaio"]'));
await sleep(800);
if (await p.isVisible("#skip-intro")) await d.click(p.locator("#skip-intro"), 0.5);
await p.evaluate(([s, a]) => { game.go(s, a); }, [scena, at]);
await r.fermi(1200);
},
async parlaCon(id) {
const [x, y] = await p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
await r.clicca([x, y]);
await r.fermi();
},
// L'audio annotato dentro la pagina (musica voluta e voci del gioco), per rimontarlo (monta.py).
async ascolta() {
await p.evaluate(() => {
window.__t0 = Date.now(); window.__audio = [];
let music = undefined, voice = null;
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
const look = () => {
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
},
async salvaAudio() {
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL(`${clip}-audio.json`, out), JSON.stringify({ eventi: audio }, null, 2));
},
};
return r;
}