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:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user