I cinque video «Come si gioca» dentro il gioco: pulsante «Guarda il video» nell'aiuto di briscola, tressette, scopa, bocce e biliardo, player a tutto schermo con l'audio (il gioco e la musica si fermano, Esc torna al tavolo). video/pubblica.py li taglia dall'arrivo nel posto, li comprime per il web (~4,5 MB l'uno) e fa la copertina. Il favicon e i video copiati anche nell'immagine Docker (il Regolino online dava 404); test che ogni cartella di assets usata dal gioco sia nel Dockerfile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 22:37:19 +01:00
co-authored by Claude Fable 5.1
parent add5a5fe77
commit 22be1c306e
17 changed files with 82 additions and 4 deletions
+2
View File
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects COPY assets/effects /usr/share/nginx/html/assets/effects
COPY assets/closeups /usr/share/nginx/html/assets/closeups COPY assets/closeups /usr/share/nginx/html/assets/closeups
COPY assets/favicon /usr/share/nginx/html/assets/favicon
COPY assets/video /usr/share/nginx/html/assets/video
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice. # Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \ RUN cd /usr/share/nginx/html \
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
+1 -1
View File
@@ -94,7 +94,7 @@
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title"> <section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
<h2 id="tutorial-title">Come si gioca</h2> <h2 id="tutorial-title">Come si gioca</h2>
<video id="tutorial-video" controls playsinline preload="metadata"></video> <video id="tutorial-video" controls playsinline preload="metadata"></video>
<button id="tutorial-close">Torna al gioco</button> <div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
</section> </section>
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti"> <section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
<h2>Ringraziamenti</h2> <h2>Ringraziamenti</h2>
+8
View File
@@ -203,6 +203,14 @@ export const story = {
paper: '#fbe6bc', paper: '#fbe6bc',
}, },
}, },
// I video «Come si gioca» (video/SCALETTA-CARTE.md): il pulsante «Guarda il video» nell'aiuto di ogni gioco.
tutorials: {
briscola: { title: 'Come si gioca: la briscola', src: 'assets/video/briscola.mp4', poster: 'assets/video/briscola.jpg' },
tressette: { title: 'Come si gioca: il tressette ai 21', src: 'assets/video/tressette.mp4', poster: 'assets/video/tressette.jpg' },
scopa: { title: 'Come si gioca: la scopa', src: 'assets/video/scopa.mp4', poster: 'assets/video/scopa.jpg' },
bocce: { title: 'Come si gioca: le bocce', src: 'assets/video/bocce.mp4', poster: 'assets/video/bocce.jpg' },
biliardo: { title: 'Come si gioca: il biliardo', src: 'assets/video/biliardo.mp4', poster: 'assets/video/biliardo.jpg' },
},
characters: { characters: {
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale. // [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' }, giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
+3 -3
View File
@@ -202,8 +202,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; } color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); } #pause { z-index: 3; background: rgba(15, 10, 20, .9); }
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */ /* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
#tutorial { z-index: 4; background: rgba(10, 8, 14, .96); padding: 8px; } #tutorial { position: fixed; inset: 0; z-index: 20; background: rgba(10, 8, 14, .97); padding: 8px; }
#tutorial video { max-width: 100%; max-height: calc(100% - 7em); background: #000; } #tutorial video { max-width: min(100%, 1100px); max-height: calc(100dvh - 9em); background: #000; }
.tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; } .tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; }
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); } .pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; } #pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
@@ -284,7 +284,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } } @media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti, /* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
la spiegazione e la versione non finiscono sopra i verbi. */ la spiegazione e la versione non finiscono sopra i verbi. */
@media (orientation: portrait) and (max-width: 700px) { #tutorial { position: fixed; inset: 0; z-index: 10; } @media (orientation: portrait) and (max-width: 700px) {
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } } #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */ /* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) { @media (max-width: 600px) {
+14
View File
@@ -91,6 +91,20 @@ try {
await page.waitForSelector('#briscola-controls:not([hidden])'); await page.waitForSelector('#briscola-controls:not([hidden])');
// Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio. // Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio.
await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3'); await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3');
if (name === 'desktop-mouse') {
// Il video «Come si gioca»: dall'aiuto del tavolo si apre il player, il gioco si ferma, Esc torna al tavolo.
await page.click('#briscola-controls summary');
await page.click('#briscola-controls .tutorial-open');
await page.waitForSelector('#tutorial:not([hidden])');
assert.match(await page.locator('#tutorial-video').getAttribute('src'), /assets\/video\/briscola\.mp4\?v=/);
assert.equal(await page.locator('#tutorial-title').textContent(), 'Come si gioca: la briscola');
assert.equal(await page.evaluate(() => music.hold), true, 'la musica del gioco tace');
await page.screenshot({ path: `${out}/briscola-video.png` });
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#tutorial'));
assert.equal(await page.evaluate(() => !music.hold && game.closeup?.id), 'briscola', 'si torna al tavolo');
await page.click('#briscola-controls summary');
}
assert.ok(await page.isHidden('#bocce-controls')); assert.ok(await page.isHidden('#bocce-controls'));
const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox(); const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox();
assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo'); assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo');
+13
View File
@@ -606,6 +606,19 @@ test('al Trebbio Andrea ci sale a piedi: la torre lo sa, e lui ha trovato la sua
assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.')); assert.ok(settle(g).some(s => s.who === 'castello' && s.text === 'Il castello.'));
}); });
// Il favicon c'era in pagina ma non nell'immagine Docker (404 online, 26 ottobre): ogni cartella di assets che il gioco
// usa deve essere fra quelle copiate, e ogni video «Come si gioca» deve esistere.
test('ogni cartella di assets usata dal gioco finisce nell\'immagine Docker', async () => {
const { readdirSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const copied = new Set(readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY assets\/(\S+)\s/gm).map(l => l.split(' ')[1].slice(7)));
const files = ['index.html', 'style.css', ...readdirSync(new URL('src/', root), { recursive: true }).filter(f => f.endsWith('.js')).map(f => 'src/' + f)];
const used = new Set(files.flatMap(f => [...readFileSync(new URL(f, root), 'utf8').matchAll(/assets\/([a-z0-9_-]+)\//g)].map(m => m[1])));
// raw e generated sono cartelle di lavoro, citate solo nei commenti.
assert.deepEqual([...used].filter(d => !copied.has(d) && !['raw', 'generated'].includes(d)).sort(), []);
for (const t of Object.values(story.tutorials)) for (const f of [t.src, t.poster]) assert.ok(existsSync(new URL(f, root)), f);
});
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => { test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs'); const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url); const root = new URL('../', import.meta.url);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Porta i video «Come si gioca» nel gioco (assets/video/, story.tutorials): da quando si arriva nel posto del gioco
(la prima battuta del narratore), più piccoli e compressi per il web, con una copertina.
python3 video/pubblica.py briscola tressette scopa bocce biliardo
Prima la ripresa e il montaggio (video/SCALETTA-CARTE.md): legge video/out/tutorial-<gioco>.mp4 e il cues.json della
ripresa. Il titolo del gioco all'inizio della ripresa non serve, e col ritaglio dei tavoli verrebbe tagliato ai lati.
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
RIPRESE = Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out"
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)
for gioco in sys.argv[1:]:
ripresa = RIPRESE / f"tutorial-{gioco}"
cues = json.loads((ripresa / "cues.json").read_text())["cues"]
# Lo stesso scarto di monta.py: la ripresa parte un po' prima del copione.
scarto = max(0.0, durata(ripresa / "raw.mkv") - (cues[-1]["end"] + 1.2))
inizio = max(0.0, cues[0]["start"] + scarto - 0.4)
dentro, fuori = VIDEO / "out" / f"tutorial-{gioco}.mp4", GIOCO / "assets" / "video" / f"{gioco}.mp4"
fuori.parent.mkdir(exist_ok=True)
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{inizio:.2f}", "-i", str(dentro),
"-vf", "scale=trunc(iw*0.75/2)*2:-2", "-c:v", "libx264", "-preset", "slow", "-crf", "27", "-pix_fmt", "yuv420p",
"-af", "afade=t=in:d=0.4", "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(fuori)], check=True)
# La copertina: un momento al tavolo, a un terzo del video.
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-ss", f"{durata(fuori) / 3:.1f}", "-i", str(fuori), "-frames:v", "1",
"-q:v", "4", str(fuori.with_suffix(".jpg"))], check=True)
for f in (fuori, fuori.with_suffix(".jpg")):
f.chmod(0o644)
print(f"-> {fuori} ({durata(fuori):.0f} s, {fuori.stat().st_size / 1e6:.1f} MB), da {inizio:.1f} s")