Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add5a5fe77 | ||
|
|
3019a6e32f | ||
|
|
111b892916 | ||
|
|
f1781afea8 | ||
|
|
b44eeffd2a |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -307,26 +307,6 @@
|
|||||||
"file": "andrea-68a845881b.mp3",
|
"file": "andrea-68a845881b.mp3",
|
||||||
"sec": 1.94
|
"sec": 1.94
|
||||||
},
|
},
|
||||||
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
|
|
||||||
"file": "andrea-97d75e2a9d.mp3",
|
|
||||||
"sec": 5.38
|
|
||||||
},
|
|
||||||
"andrea|Buongiorno, Fra Peppino!": {
|
|
||||||
"file": "andrea-94988dc8c6.mp3",
|
|
||||||
"sec": 1.74
|
|
||||||
},
|
|
||||||
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
|
||||||
"file": "fra_peppino-651c4d95ab.mp3",
|
|
||||||
"sec": 3.72
|
|
||||||
},
|
|
||||||
"andrea|Il palio. È sparito.": {
|
|
||||||
"file": "andrea-c9c068f507.mp3",
|
|
||||||
"sec": 2.39
|
|
||||||
},
|
|
||||||
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
|
||||||
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
|
||||||
"sec": 4.47
|
|
||||||
},
|
|
||||||
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
|
||||||
"file": "andrea-43fd5c2b27.mp3",
|
"file": "andrea-43fd5c2b27.mp3",
|
||||||
"sec": 4.46
|
"sec": 4.46
|
||||||
@@ -543,6 +523,26 @@
|
|||||||
"file": "andrea-43d3435d61.mp3",
|
"file": "andrea-43d3435d61.mp3",
|
||||||
"sec": 2.99
|
"sec": 2.99
|
||||||
},
|
},
|
||||||
|
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
|
||||||
|
"file": "andrea-6797d70153.mp3",
|
||||||
|
"sec": 8.45
|
||||||
|
},
|
||||||
|
"andrea|Buongiorno, Fra Peppino!": {
|
||||||
|
"file": "andrea-94988dc8c6.mp3",
|
||||||
|
"sec": 1.74
|
||||||
|
},
|
||||||
|
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
|
||||||
|
"file": "fra_peppino-651c4d95ab.mp3",
|
||||||
|
"sec": 3.72
|
||||||
|
},
|
||||||
|
"andrea|Il palio. È sparito.": {
|
||||||
|
"file": "andrea-c9c068f507.mp3",
|
||||||
|
"sec": 2.39
|
||||||
|
},
|
||||||
|
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
|
||||||
|
"file": "fra_peppino-8b3d0a5b8f.mp3",
|
||||||
|
"sec": 4.47
|
||||||
|
},
|
||||||
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
|
||||||
"file": "andrea-486c980f52.mp3",
|
"file": "andrea-486c980f52.mp3",
|
||||||
"sec": 7.48
|
"sec": 7.48
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
||||||
<title>La Leggenda del Regolo</title>
|
<title>La Leggenda del Regolo</title>
|
||||||
|
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
|
||||||
|
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.13",
|
"version": "0.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
if (!aimed && !game.speech) return;
|
if (!aimed && !game.speech) return;
|
||||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||||
}
|
}
|
||||||
|
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
|
||||||
press = { closeup: true };
|
press = { closeup: true };
|
||||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||||
if (!game.skip()) game.closeupPress();
|
if (!game.skip()) game.closeupPress();
|
||||||
@@ -503,10 +504,11 @@ canvas.addEventListener('contextmenu', e => {
|
|||||||
canvas.addEventListener('pointermove', e => {
|
canvas.addEventListener('pointermove', e => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||||
// Al biliardo la stecca segue il mouse finché un clic (o una regolazione fine) non fissa la mira: dopo,
|
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
|
||||||
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
|
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
|
||||||
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
|
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||||
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
const aiming = game.closeup && ['biliardo', 'bocce'].includes(story.closeups[game.closeup.id].type);
|
||||||
|
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const story = {
|
|||||||
},
|
},
|
||||||
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
|
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
|
||||||
bocce: {
|
bocce: {
|
||||||
type: 'bocce',
|
type: 'bocce', music: 'assets/audio/bocce.mp3', // la polka del pallaio (tools/polka_bocce.py, Andrea: «un sound suo»)
|
||||||
picture: 'assets/closeups/pallaio.png',
|
picture: 'assets/closeups/pallaio.png',
|
||||||
names: { player: 'Andrea', cpu: 'Mariano' },
|
names: { player: 'Andrea', cpu: 'Mariano' },
|
||||||
win: [
|
win: [
|
||||||
@@ -576,23 +576,13 @@ export const story = {
|
|||||||
map: [56, 45],
|
map: [56, 45],
|
||||||
background: 'assets/scenes/pieve-dalla-salita.png',
|
background: 'assets/scenes/pieve-dalla-salita.png',
|
||||||
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
|
// Quando parla, dalla statua esce lo spirito di San Pietro con le sue chiavi (disegnato da Codex, idea di Andrea).
|
||||||
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] },
|
speakers: { san_pietro: [300, 100] },
|
||||||
// Fra Peppino da Gricignano sul sagrato (sprite di Codex dalla foto di Andrea, 25 ottobre).
|
|
||||||
people: [{ who: 'fra_peppino', at: [412, 236], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
|
||||||
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
|
apparitions: [{ who: 'san_pietro', src: 'assets/characters/spirito-san-pietro.png', at: [300, 232], height: 100 }],
|
||||||
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
||||||
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
||||||
depth: [[215, 0.42], [360, 1.15]],
|
depth: [[215, 0.42], [360, 1.15]],
|
||||||
entry: [30, 320],
|
entry: [30, 320],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
{
|
|
||||||
// Battute provvisorie: le vere le scrive Andrea.
|
|
||||||
id: 'fra_peppino', name: 'Fra Peppino', box: [402, 202, 20, 34], at: [385, 240], face: 'right', default: 'talk',
|
|
||||||
actions: {
|
|
||||||
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.' }],
|
|
||||||
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
|
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
@@ -900,7 +890,19 @@ export const story = {
|
|||||||
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
||||||
depth: [[292, 0.78], [360, 1.1]],
|
depth: [[292, 0.78], [360, 1.1]],
|
||||||
entry: [320, 345],
|
entry: [320, 345],
|
||||||
|
// Fra Peppino da Gricignano (sprite di Codex dalla foto di Andrea, 25 ottobre): stava sul sagrato della Pieve,
|
||||||
|
// ma è un francescano, e il Bosco ai Frati è il loro convento (Andrea, 26 ottobre).
|
||||||
|
speakers: { fra_peppino: [470, 238] },
|
||||||
|
people: [{ who: 'fra_peppino', at: [470, 304], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.8 }],
|
||||||
hotspots: [
|
hotspots: [
|
||||||
|
{
|
||||||
|
// Battute provvisorie: le vere le scrive Andrea.
|
||||||
|
id: 'fra_peppino', name: 'Fra Peppino', box: [458, 248, 26, 58], at: [430, 306], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.' }],
|
||||||
|
talk: [{ say: 'Buongiorno, Fra Peppino!' }, { say: 'Pace e bene, figliolo. Cerchi qualcosa?', who: 'fra_peppino', pose: 'bless' }, { say: 'Il palio. È sparito.' }, { say: 'Le cose sparite, prima o poi, tornano. Come le pecorelle.', who: 'fra_peppino', pose: 'explain' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
|
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.13';
|
export const VERSION = '0.1.14';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -113,8 +113,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
||||||
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
#inventory img { width: 1.9em; height: 1.9em; flex: none; image-rendering: pixelated; }
|
||||||
|
|
||||||
#choices { margin: 0; padding: 10px 14px 12px 2.4em; background: var(--panel); min-height: 9.5em; }
|
/* Il carattere sta sull'elenco, non sulle righe: il rientro in em segue il carattere. «1. » nel carattere pixel è largo tre caratteri e mezzo. */
|
||||||
#choices li { padding: .25em 0; font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; color: var(--dim); cursor: pointer; }
|
#choices { margin: 0; padding: 10px 14px 12px 3.6em; background: var(--panel); min-height: 6.5em; font-size: clamp(10.8px, 1.44vw, 15.84px); }
|
||||||
|
#choices li { padding: .25em 0; font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||||
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||||
|
|
||||||
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||||
@@ -308,7 +309,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#speech { max-width: 72%; font-size: 11px; }
|
#speech { max-width: 72%; font-size: 11px; }
|
||||||
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
|
||||||
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
|
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
|
||||||
#verbs button, #inventory button, #choices li { font-size: 10.08px; }
|
#verbs button, #inventory button, #choices { font-size: 10.08px; }
|
||||||
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
|
/* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
|
||||||
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
|
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
|
||||||
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
#verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -324,7 +325,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
|||||||
#map-board button { width: 68px; }
|
#map-board button { width: 68px; }
|
||||||
#map-board button img { width: 60px; }
|
#map-board button img { width: 60px; }
|
||||||
#pause .hint { font-size: 8px; }
|
#pause .hint { font-size: 8px; }
|
||||||
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
#choices { min-height: 0; padding: 6px 8px 6px 3.6em; }
|
||||||
}
|
}
|
||||||
|
|
||||||
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
||||||
|
|||||||
@@ -387,6 +387,12 @@ try {
|
|||||||
await bocce.mouse.up();
|
await bocce.mouse.up();
|
||||||
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
||||||
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
||||||
|
// Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta.
|
||||||
|
await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 });
|
||||||
|
await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6);
|
||||||
|
const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim));
|
||||||
|
await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 });
|
||||||
|
assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato');
|
||||||
await bocce.keyboard.press('Escape');
|
await bocce.keyboard.press('Escape');
|
||||||
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
||||||
await bocce.close();
|
await bocce.close();
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disegna il Regolino, pixel per pixel, senza immagini sorgenti.
|
||||||
|
|
||||||
|
python3 tools/favicon.py
|
||||||
|
|
||||||
|
Scrive assets/favicon/ e /tmp/regolo-shots/favicon-anteprima.png.
|
||||||
|
La matrice 16×16 è il disegno originale: ogni simbolo è un pixel.
|
||||||
|
Tutti gli ingrandimenti sono interi e usano NEAREST, anche nell'ICO.
|
||||||
|
Colori e carattere riprendono regolo() in src/engine/briscola-render.js.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUT = ROOT / "assets" / "favicon"
|
||||||
|
PREVIEW = Path("/tmp/regolo-shots/favicon-anteprima.png")
|
||||||
|
BACKGROUND = "#1b1420"
|
||||||
|
PALETTE = {
|
||||||
|
".": (0, 0, 0, 0),
|
||||||
|
"#": "#38271f", # contorno del dorso
|
||||||
|
"G": "#dba32b", # oro
|
||||||
|
"H": "#ffe396", # luce sulla corona
|
||||||
|
"R": "#aa4034", # rubino
|
||||||
|
"L": "#94b365", # testa verde salvia
|
||||||
|
"S": "#487348", # ombra / spira
|
||||||
|
"V": "#a3bc72", # luce sulla spira
|
||||||
|
"B": "#d0cd83", # ventre
|
||||||
|
"W": "#fff0cd", # occhi crema
|
||||||
|
}
|
||||||
|
|
||||||
|
# Corona a tre punte, grande testa sorridente, collo e coda arrotolata.
|
||||||
|
# Nessun tratto viene interpolato: anche gli occhi sono singoli pixel.
|
||||||
|
PIXELS = (
|
||||||
|
"....#..##..#....",
|
||||||
|
"...#H##HH##H#...",
|
||||||
|
"...#HGGGGGGH#...",
|
||||||
|
"....#GGRGGG#....",
|
||||||
|
"...##########...",
|
||||||
|
"..#LLLLLLLLLL#..",
|
||||||
|
".#LLWWLLWWLLLL#.",
|
||||||
|
".#LLW#LL#WLLLS#.",
|
||||||
|
".#LLLLLLLLLLLS#.",
|
||||||
|
"..#L#LLLLL#LS#..",
|
||||||
|
"...#L#####LS#...",
|
||||||
|
"....###BS###....",
|
||||||
|
"..###SSBSLSS##..",
|
||||||
|
".#SVV##BS##SSS#.",
|
||||||
|
".#SVVVVVVVVSS#..",
|
||||||
|
"..###########...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sprite():
|
||||||
|
"""Interpreta la matrice letterale, mantenendo solo alpha 0 o 255."""
|
||||||
|
if len(PIXELS) != 16 or any(len(row) != 16 for row in PIXELS):
|
||||||
|
raise ValueError("Il disegno deve contenere esattamente 16×16 pixel")
|
||||||
|
result = Image.new("RGBA", (16, 16))
|
||||||
|
for y, row in enumerate(PIXELS):
|
||||||
|
for x, symbol in enumerate(row):
|
||||||
|
color = PALETTE[symbol]
|
||||||
|
if isinstance(color, str):
|
||||||
|
color = tuple(bytes.fromhex(color[1:])) + (255,)
|
||||||
|
result.putpixel((x, y), color)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def enlarge(source, factor):
|
||||||
|
return source.resize(
|
||||||
|
(source.width * factor, source.height * factor), Image.Resampling.NEAREST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview(icons):
|
||||||
|
"""Mostra ogni PNG a 1× e 8×, sia su chiaro sia su scuro.
|
||||||
|
|
||||||
|
Le sei righe includono anche le icone grandi: il foglio è volutamente
|
||||||
|
ampio, così la scala dichiarata resta esatta per tutti i file.
|
||||||
|
"""
|
||||||
|
margin, gap, label_height = 24, 24, 32
|
||||||
|
largest = max(icon.width for _, icon in icons)
|
||||||
|
panel_width = margin * 2 + largest + gap + largest * 8
|
||||||
|
height = margin + sum(label_height + icon.height * 8 + gap for _, icon in icons)
|
||||||
|
sheet = Image.new("RGB", (panel_width * 2, height))
|
||||||
|
draw = ImageDraw.Draw(sheet)
|
||||||
|
font = ImageFont.load_default(size=16)
|
||||||
|
for panel, (background, foreground) in enumerate(
|
||||||
|
(("#f5f1e9", "#38271f"), (BACKGROUND, "#fff0cd"))
|
||||||
|
):
|
||||||
|
left = panel * panel_width
|
||||||
|
draw.rectangle((left, 0, left + panel_width - 1, height - 1), fill=background)
|
||||||
|
y = margin
|
||||||
|
for name, icon in icons:
|
||||||
|
draw.text(
|
||||||
|
(left + margin, y),
|
||||||
|
f"{name} | {icon.width} x {icon.height} | 1x e 8x",
|
||||||
|
fill=foreground,
|
||||||
|
font=font,
|
||||||
|
)
|
||||||
|
y += label_height
|
||||||
|
sheet.paste(icon, (left + margin, y), icon)
|
||||||
|
magnified = enlarge(icon, 8)
|
||||||
|
sheet.paste(magnified, (left + margin + icon.width + gap, y), magnified)
|
||||||
|
y += icon.height * 8 + gap
|
||||||
|
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sheet.save(PREVIEW, optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
base = sprite()
|
||||||
|
icons = []
|
||||||
|
for size in (16, 32, 48):
|
||||||
|
icon = enlarge(base, size // 16)
|
||||||
|
name = f"favicon-{size}.png"
|
||||||
|
icon.save(OUTPUT / name, optimize=True)
|
||||||
|
icons.append((name, icon))
|
||||||
|
|
||||||
|
# 16×9 = 144: margini di 18 px, centratura esatta sul fondo opaco.
|
||||||
|
apple = Image.new("RGBA", (180, 180), BACKGROUND)
|
||||||
|
apple.alpha_composite(enlarge(base, 9), (18, 18))
|
||||||
|
apple = apple.convert("RGB")
|
||||||
|
apple.save(OUTPUT / "apple-touch-icon.png", optimize=True)
|
||||||
|
icons.append(("apple-touch-icon.png", apple.convert("RGBA")))
|
||||||
|
|
||||||
|
for size in (192, 512):
|
||||||
|
icon = enlarge(base, size // 16)
|
||||||
|
name = f"icon-{size}.png"
|
||||||
|
icon.save(OUTPUT / name, optimize=True)
|
||||||
|
icons.append((name, icon))
|
||||||
|
|
||||||
|
# Fornire tutti i frame evita il ridimensionamento implicito di Pillow.
|
||||||
|
icons[2][1].save(
|
||||||
|
OUTPUT / "favicon.ico",
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||||
|
append_images=[icons[0][1], icons[1][1]],
|
||||||
|
)
|
||||||
|
preview(icons)
|
||||||
|
for name, _ in icons:
|
||||||
|
print(OUTPUT / name)
|
||||||
|
print(OUTPUT / "favicon.ico")
|
||||||
|
print(PREVIEW)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La musica delle bocce al pallaio (assets/audio/bocce.mp3), sintetizzata da zero come il valzer delle carte: una
|
||||||
|
polka da circolo in do maggiore, in due quarti. Basso «um-pa» sui due movimenti, fisarmonica a contrattempo e una
|
||||||
|
melodia originale di clarinetto, con un filo di vibrato. Trentadue battute che chiudono sul sol settima: il loop non
|
||||||
|
si sente. (Andrea, 26 ottobre: «anche le bocce avrebbero bisogno di un sound suo».)
|
||||||
|
python3 tools/polka_bocce.py # scrive assets/audio/bocce.mp3 (serve ffmpeg)
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SR = 22050
|
||||||
|
BPM = 116
|
||||||
|
BEAT = 60 / BPM
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "bocce.mp3"
|
||||||
|
|
||||||
|
HARMONY = {"C": ["E4", "G4", "C5"], "G7": ["F4", "G4", "B4"], "F": ["F4", "A4", "C5"], "A7": ["E4", "G4", "C#5"],
|
||||||
|
"Dm": ["F4", "A4", "D5"]}
|
||||||
|
BASS = {"C": ("C2", "G2"), "G7": ("G1", "D2"), "F": ("F1", "C2"), "A7": ("A1", "E2"), "Dm": ("D2", "A1")}
|
||||||
|
CHORDS = ("C C G7 G7 G7 G7 C C C C F F C G7 C C "
|
||||||
|
"F F C C G7 G7 C C F F C A7 Dm G7 C G7").split()
|
||||||
|
h = 0.5
|
||||||
|
MELODY = [
|
||||||
|
[("E5", h), ("G5", h), ("C6", 1)], [("B5", h), ("A5", h), ("G5", 1)], [("F5", h), ("G5", h), ("A5", h), ("B5", h)], [("D6", 1), ("B5", 1)],
|
||||||
|
[("A5", h), ("B5", h), ("A5", h), ("G5", h)], [("F5", 1), ("D5", 1)], [("E5", h), ("F5", h), ("G5", 1)], [("C5", 2)],
|
||||||
|
[("E5", h), ("G5", h), ("C6", 1)], [("E6", h), ("D6", h), ("C6", 1)], [("A5", h), ("C6", h), ("A5", h), ("F5", h)], [("A5", 1.5), ("G5", h)],
|
||||||
|
[("G5", h), ("E5", h), ("C5", h), ("E5", h)], [("D5", h), ("F5", h), ("B5", 1)], [("C6", 1), ("G5", 1)], [("C6", 1), (None, 1)],
|
||||||
|
[("C6", h), ("A5", h), ("F5", 1)], [("A5", h), ("G5", h), ("F5", 1)], [("G5", h), ("E5", h), ("C5", 1)], [("E5", h), ("G5", h), ("C6", 1)],
|
||||||
|
[("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("D5", 1), ("G5", 1)], [("E5", h), ("G5", h), ("E5", h), ("C5", h)], [("G4", 2)],
|
||||||
|
[("A4", h), ("C5", h), ("F5", 1)], [("A5", 1), ("F5", 1)], [("E5", h), ("G5", h), ("C6", 1)], [("C#6", 1), ("A5", 1)],
|
||||||
|
[("D6", h), ("C6", h), ("A5", 1)], [("B5", h), ("A5", h), ("G5", h), ("F5", h)], [("E5", 1), ("C5", 1)], [("D5", h), ("E5", h), ("F5", h), ("G5", h)],
|
||||||
|
]
|
||||||
|
assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 2 for bar in MELODY)
|
||||||
|
DUR = len(CHORDS) * 2 * BEAT
|
||||||
|
buf = [0.0] * int(SR * DUR)
|
||||||
|
rng = random.Random(11)
|
||||||
|
|
||||||
|
|
||||||
|
def midi(note: str) -> int:
|
||||||
|
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||||
|
semis = names[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
|
||||||
|
return semis + 12 * (int(note[-1]) + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def hz(note: str) -> float:
|
||||||
|
return 440 * 2 ** ((midi(note) - 69) / 12)
|
||||||
|
|
||||||
|
|
||||||
|
def add(start: float, samples) -> None:
|
||||||
|
"""Somma un suono alla traccia; la coda che esce dalla fine rientra all'inizio (loop continuo)."""
|
||||||
|
n0 = int(start * SR)
|
||||||
|
for i, v in enumerate(samples):
|
||||||
|
buf[(n0 + i) % len(buf)] += v
|
||||||
|
|
||||||
|
|
||||||
|
def tone(freq: float, length: float, vol: float, partials, decay: float, attack: float = 0.006, release: float = 0.06,
|
||||||
|
vibrato: float = 0.0):
|
||||||
|
n = int((length + release) * SR)
|
||||||
|
out, phase = [], 0.0
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
env = min(1.0, t / attack) * math.exp(-t / decay)
|
||||||
|
if t > length:
|
||||||
|
env *= max(0.0, 1 - (t - length) / release)
|
||||||
|
# Il vibrato arriva dopo l'attacco, come quello di un fiato.
|
||||||
|
phase += 2 * math.pi * freq * (1 + vibrato * min(1.0, t / 0.25) * math.sin(2 * math.pi * 5.2 * t)) / SR
|
||||||
|
out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(k * phase) for k, a, d in partials))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
at = lambda beat: beat * BEAT # noqa: E731
|
||||||
|
LONG = 99
|
||||||
|
# Fisarmonica: ance ricche di armonici, che non si spengono finché il mantice spinge.
|
||||||
|
REED = ((1, 1.0, LONG), (2, 0.55, LONG), (3, 0.38, LONG), (4, 0.22, LONG), (5, 0.12, LONG), (6, 0.06, LONG))
|
||||||
|
# Clarinetto: armonici dispari, il suono di legno.
|
||||||
|
REEDWOOD = ((1, 1.0, LONG), (3, 0.42, LONG), (5, 0.2, LONG), (7, 0.09, LONG), (2, 0.06, LONG))
|
||||||
|
|
||||||
|
for bar, name in enumerate(CHORDS):
|
||||||
|
b0 = bar * 2
|
||||||
|
# Basso «um-pa»: fondamentale sull'uno, quinta sul due.
|
||||||
|
for k, note in enumerate(BASS[name]):
|
||||||
|
add(at(b0 + k), tone(hz(note), BEAT * 0.55, 0.32, ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.2, 0.12)), decay=0.35))
|
||||||
|
# Fisarmonica a contrattempo, corta e frizzante.
|
||||||
|
for k in (0.5, 1.5):
|
||||||
|
for note in HARMONY[name]:
|
||||||
|
for detune, vol in ((1.0, 0.026), (1.004, 0.019)):
|
||||||
|
add(at(b0 + k), tone(hz(note) * detune, BEAT * 0.3, vol, REED, decay=LONG, attack=0.012, release=0.04))
|
||||||
|
# Un piatto leggero sul due, quello della banda.
|
||||||
|
add(at(b0 + 1), [0.03 * math.exp(-i / SR / 0.08) * rng.uniform(-1, 1) for i in range(int(0.2 * SR))])
|
||||||
|
|
||||||
|
beat = 0.0
|
||||||
|
for bar in MELODY:
|
||||||
|
for note, length in bar:
|
||||||
|
if note:
|
||||||
|
add(at(beat), tone(hz(note), BEAT * length * 0.9, 0.09, REEDWOOD, decay=LONG, attack=0.03, release=0.06, vibrato=0.006))
|
||||||
|
beat += length
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in buf) or 1
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
w = Path(tmp) / "bocce.wav"
|
||||||
|
with wave.open(str(w), "wb") as f:
|
||||||
|
f.setnchannels(1)
|
||||||
|
f.setsampwidth(2)
|
||||||
|
f.setframerate(SR)
|
||||||
|
f.writeframes(array("h", (int(x / peak * 0.85 * 32767) for x in buf)).tobytes())
|
||||||
|
# All'aperto, accanto al campo: poca eco, alti morbidi.
|
||||||
|
af = "aecho=0.8:0.5:70|150:0.12|0.07,lowpass=f=8500,volume=1.25"
|
||||||
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
|
||||||
|
print(f"-> {OUT} ({DUR:.0f} s)")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Controlla che nel video «Come si gioca» il narratore non parli sopra i personaggi del gioco.
|
||||||
|
python3 video/sovrapposte.py tutorial-briscola (dopo la ripresa: cues.json, registro audio, narrazione)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VIDEO = Path(__file__).resolve().parent
|
||||||
|
clip = sys.argv[1]
|
||||||
|
cues = json.loads((Path.home() / "Workspaces/qemu/qemu-iso-lab/artifacts/tour/out" / clip / "cues.json").read_text())["cues"]
|
||||||
|
narr = {v["id"]: v["sec"] for v in json.loads((VIDEO / "out" / f"{clip}-narrazione.json").read_text())["voci"]}
|
||||||
|
voci, aperta = [], None
|
||||||
|
for e in json.loads((VIDEO / "out" / f"{clip}-audio.json").read_text())["eventi"]:
|
||||||
|
if "voice" in e:
|
||||||
|
aperta = [e["t"], None, e["voice"]]
|
||||||
|
voci.append(aperta)
|
||||||
|
elif "voiceEnd" in e and aperta:
|
||||||
|
aperta[1], aperta = e["t"], None
|
||||||
|
male = [(c["id"], v[2]) for c in cues for v in voci if v[1] and v[0] < c["start"] + narr[c["id"]] and v[1] > c["start"]]
|
||||||
|
for id_, voce in male:
|
||||||
|
print(f"✗ «{id_}» sopra {voce}")
|
||||||
|
print(f"{clip}: {len(voci)} voci del gioco, {len(cues)} frasi del narratore, {len(male)} sovrapposte")
|
||||||
|
sys.exit(1 if male else 0)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Copione del video «Come si gioca: il biliardo» (video/SCALETTA-CARTE.md, clip 5).
|
||||||
|
// 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 · il biliardo", en: "Come si gioca · il biliardo" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Il biliardo è al Circolo 4 Maggio, in piazza. L'Ingegner Tozzi gioca ai cinque birilli, e si va a venti"),
|
||||||
|
c("livello", "Anche qui si sceglie quanto deve giocare forte"),
|
||||||
|
c("tavolo", "Il tavolo dall'alto: la tua biglia, quella dell'avversario, il pallino, e in mezzo il castello dei birilli"),
|
||||||
|
c("scopo", "Si colpisce la propria biglia e la si manda su quella dell'avversario: i birilli che lei abbatte fanno punti a chi tira"),
|
||||||
|
c("mira", "La stecca segue il mouse, e un clic fissa la mira. La linea tratteggiata è la strada della biglia; le frecce, dove vanno le due biglie dopo l'urto"),
|
||||||
|
c("tira", "Con le frecce si gira la stecca, con Maiuscolo ancora più piano. Poi si regola la forza, e si preme Tira"),
|
||||||
|
c("valori", "Il birillo bianco vale due, il rosso quattro, il rosso da solo dieci. Il pallino toccato dalla biglia avversaria, tre"),
|
||||||
|
c("fallo", "Attenzione ai falli: mancare la biglia avversaria, toccare prima il pallino, o buttare giù birilli con la propria. I punti vanno all'avversario"),
|
||||||
|
c("fine", "Un colpo a testa, finché qualcuno arriva a venti. Buona partita!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-biliardo");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("circolo_4_maggio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("ing_tozzi");
|
||||||
|
await r.scegli("una partita ai birilli");
|
||||||
|
await r.cue("livello");
|
||||||
|
await r.scegli("Alla bona.");
|
||||||
|
// Una partita fissa: un colpo da otto, un fallo voluto per farlo vedere, poi quattro e tre; vince Andrea 21 a 2.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newBiliardo } = await import("/src/engine/biliardo.js");
|
||||||
|
game.closeup = { id: "biliardo", ...newBiliardo(2, { level: "bona" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await r.cue("tavolo");
|
||||||
|
await r.muovi([200, 200], 0.9); await sleep(300); await r.muovi([400, 180], 0.9);
|
||||||
|
await r.cue("scopo");
|
||||||
|
|
||||||
|
// Il colpo: il migliore fra quelli provati a vuoto (o, la seconda volta, un fallo apposta). Si mira col clic sul
|
||||||
|
// tavolo lungo la direzione scelta, poi forza col cursore e Tira.
|
||||||
|
const colpo = (sbaglia) => p.evaluate(async (sbaglia) => {
|
||||||
|
const L = await import("/src/engine/biliardo.js"), c = game.closeup, b = c.balls.player, o = c.balls.cpu;
|
||||||
|
let top = null;
|
||||||
|
if (sbaglia) for (let a = 0; a < Math.PI * 2 && !top; a += 0.05) { if (L.simulate(c, "player", a, 0.12).foul === "miss") top = { angle: a, power: 0.12 }; }
|
||||||
|
else {
|
||||||
|
const base = Math.atan2(o.y - b.y, o.x - b.x);
|
||||||
|
for (let d = -0.35; d <= 0.35; d += 0.01) for (const power of [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]) {
|
||||||
|
const j = L.simulate(c, "player", base + d, power), gain = j.to === "player" ? j.points : -j.points;
|
||||||
|
if (!top || gain > top.gain) top = { angle: base + d, power, gain };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const x = b.x + Math.cos(top.angle) * 45, y = b.y + Math.sin(top.angle) * 45;
|
||||||
|
return { ...top, at: [L.VIEW.x + x * L.VIEW.k, L.VIEW.y + y * L.VIEW.k] };
|
||||||
|
}, sbaglia);
|
||||||
|
const tocca = async () => {
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canThrow } = await import("/src/engine/biliardo.js");
|
||||||
|
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s !== "aspetta") return s === "tocca";
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let n = 0;
|
||||||
|
while (await tocca()) {
|
||||||
|
n++;
|
||||||
|
if (n === 1) await r.cue("mira");
|
||||||
|
if (n === 2) await r.cue("fallo");
|
||||||
|
const m = await colpo(n === 2);
|
||||||
|
await r.muovi(m.at, 0.9); await sleep(400);
|
||||||
|
await r.clicca(m.at, 0.3);
|
||||||
|
await p.evaluate(({ angle }) => { game.closeup.angle = angle; game.closeup.aimLocked = true; }, m);
|
||||||
|
if (n === 1) await r.cue("tira");
|
||||||
|
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||||
|
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||||
|
await p.evaluate((v) => { game.closeup.power = v; }, m.power);
|
||||||
|
await sleep(500);
|
||||||
|
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
if (n === 1) await r.cue("valori");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 120000 });
|
||||||
|
await sleep(1500);
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copione del video «Come si gioca: le bocce» (video/SCALETTA-CARTE.md, clip 4).
|
||||||
|
// 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 · le bocce", en: "Come si gioca · le bocce" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Le bocce si giocano al pallaio, accanto al tennis della chiesa. Mariano, il campione, sfida chiunque"),
|
||||||
|
c("livello", "Si sceglie quanto deve impegnarsi: alla bona, seria, o a fassi male"),
|
||||||
|
c("campo", "Il campo visto da dietro: in fondo c'è il pallino, e a sinistra il dettaglio, per vedere da vicino chi è più accosto"),
|
||||||
|
c("mira", "Per mirare si tocca il campo, o il dettaglio vicino al pallino. Il cerchio azzurro dice dove si fermerebbe la boccia"),
|
||||||
|
c("forza", "Poi si regola la forza col cursore, e si preme Lancia"),
|
||||||
|
c("turno", "Tira sempre chi non ha il punto. Chi è dietro può accostare, oppure bocciare: colpire la boccia dell'altro per toglierla di lì"),
|
||||||
|
c("tieni", "C'è anche un altro modo: si tiene premuto sul campo, la forza sale, e lasciando parte la boccia. Vanno bene anche le frecce e la barra spaziatrice"),
|
||||||
|
c("punti", "Tre bocce a testa. Alla fine fa un punto ogni boccia più vicina al pallino della migliore dell'avversario"),
|
||||||
|
c("fine", "E se batti Mariano, ti porta a Brento Sanico. Buona partita!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-bocce");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("tennis_chiesa", [430, 310]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("mariano");
|
||||||
|
await r.scegli("Facciamo una partita");
|
||||||
|
await r.cue("livello");
|
||||||
|
await r.scegli("Alla bona.");
|
||||||
|
// Una mano fissa: accostando bene, Andrea fa tre punti (anche se il tiro a pressione viene un po' corto o lungo).
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newBocce } = await import("/src/engine/bocce.js");
|
||||||
|
game.closeup = { id: "bocce", ...newBocce(9, { level: "bona" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/bocce.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("campo");
|
||||||
|
await r.muovi([300, 200], 1.0); await sleep(400); await r.muovi([90, 170], 1.0);
|
||||||
|
|
||||||
|
// Dove mirare: poco prima del pallino, così la boccia si ferma accosto (il cerchio azzurro ci finisce sopra).
|
||||||
|
const mira = () => p.evaluate(async () => {
|
||||||
|
const B = await import("/src/engine/bocce.js"), c = game.closeup, u = c.jack.u - 0.55, w = c.jack.w;
|
||||||
|
const s = B.project(u, w);
|
||||||
|
return { at: [s.x, s.y], u, w, power: B.powerFor(Math.hypot(u, w) - 0.29) };
|
||||||
|
});
|
||||||
|
const tocca = async () => {
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canThrow } = await import("/src/engine/bocce.js");
|
||||||
|
return game.closeup.step === "result" ? "fine" : canThrow(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s !== "aspetta") return s === "tocca";
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let tiro = 0;
|
||||||
|
while (await tocca()) {
|
||||||
|
tiro++;
|
||||||
|
const m = await mira();
|
||||||
|
if (tiro === 1) await r.cue("mira");
|
||||||
|
if (tiro < 3) {
|
||||||
|
// Mira col clic, forza col cursore, poi Lancia.
|
||||||
|
await r.clicca(m.at, 0.8);
|
||||||
|
await p.evaluate(async ({ u, w }) => (await import("/src/engine/bocce.js")).aim(game.closeup, u, w), m);
|
||||||
|
if (tiro === 1) await r.cue("forza");
|
||||||
|
await d.hover(p.locator("#bocce-power"), 0.6);
|
||||||
|
await p.locator("#bocce-power").fill(String(Math.round(m.power * 100)));
|
||||||
|
await p.evaluate(async (v) => (await import("/src/engine/bocce.js")).setPower(game.closeup, v), m.power);
|
||||||
|
await sleep(600);
|
||||||
|
await d.click(p.locator("#bocce-throw"), 0.6);
|
||||||
|
if (tiro === 1) await r.cue("turno");
|
||||||
|
} else {
|
||||||
|
// L'ultima tenendo premuto: la forza sale finché non si lascia.
|
||||||
|
await r.cue("tieni");
|
||||||
|
await r.muovi(m.at, 0.8);
|
||||||
|
const [a, b] = await r.punto(...m.at);
|
||||||
|
d.vm(`DISPLAY=:0 xdotool mousemove ${Math.round(a)} ${Math.round(b)} mousedown 1 sleep ${(m.power * 1.6).toFixed(2)} mouseup 1`);
|
||||||
|
}
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "result", null, { timeout: 90000 });
|
||||||
|
await sleep(1000);
|
||||||
|
await r.cue("punti");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copione del video «Come si gioca: la scopa» (video/SCALETTA-CARTE.md, clip 3).
|
||||||
|
// 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 scopa", en: "Come si gioca · la scopa" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Allo stesso tavolo dell'oratorio si gioca anche a scopa, in due: basta chiedere «Una scopa?»"),
|
||||||
|
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||||
|
c("valori", "Quattro carte in tavola e tre in mano. L'Asso vale uno, le altre il loro numero, il gobbo otto, la donna nove e il regio dieci"),
|
||||||
|
c("presa", "Con una carta prendi quella dello stesso valore; se non c'è, un gruppo di carte che fa la stessa somma. Se puoi prendere, devi"),
|
||||||
|
c("scelta", "Quando le prese possibili sono più d'una, scegli tu: tocca una carta in tavola, oppure usa i pulsanti qui sotto"),
|
||||||
|
c("scopa", "Se con la tua carta pulisci tutta la tavola, è scopa: un punto in più"),
|
||||||
|
c("resto", "Finite le tre carte se ne danno altre tre, finché c'è mazzo. Quel che resta in tavola alla fine va all'ultimo che ha preso"),
|
||||||
|
c("conto", "A fine smazzata un punto a chi ha più carte, uno a chi ha più mattoni, uno per il settebello, il sette di mattoni, e uno per la primiera"),
|
||||||
|
c("primiera", "La primiera somma la carta migliore di ogni seme: il sette vale ventuno, il sei diciotto, l'Asso sedici"),
|
||||||
|
c("fine", "Si va agli undici punti, una smazzata dopo l'altra. Buona scopa!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-scopa");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("oratorio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("giocatore_carte");
|
||||||
|
await r.scegli("Una scopa?");
|
||||||
|
await r.cue("mazzo");
|
||||||
|
await r.scegli("Con le toscane.");
|
||||||
|
await r.scegli("Seria.");
|
||||||
|
// Una smazzata fissa: Andrea sceglie fra due prese, fa due scope e la vince 5 a 1.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newScopa } = await import("/src/engine/scopa.js");
|
||||||
|
game.closeup = { id: "scopa", ...newScopa(73, { level: "seria", deck: "toscane" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("valori");
|
||||||
|
await r.muovi([320, 160], 1.0); await sleep(500); await r.muovi([320, 300], 1.0);
|
||||||
|
await r.cue("presa");
|
||||||
|
|
||||||
|
// Una mossa di Andrea, come la sceglierebbe un giocatore attento; la scelta fra più prese si fa col pulsante.
|
||||||
|
let k = 1, scelta = false, scopa = false;
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canPlay } = await import("/src/engine/scopa.js");
|
||||||
|
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s === "fine") break;
|
||||||
|
if (s === "aspetta") { await sleep(150); continue; }
|
||||||
|
const m = await p.evaluate(async (k) => {
|
||||||
|
const S = await import("/src/engine/scopa.js"), c = game.closeup, m = S.choose(S.observation(c, "player"), "male", k);
|
||||||
|
const card = c.hands.player[m.index], many = S.captures(c.table, card).length > 1;
|
||||||
|
const clean = !!m.captured && m.captured.length === c.table.length;
|
||||||
|
const { handX, HAND } = await import("/src/engine/scopa-render.js");
|
||||||
|
return { ...m, many, clean, at: [handX(c.hands.player.length, m.index) + 32, HAND.y + 46] };
|
||||||
|
}, k++);
|
||||||
|
if (m.many && !scelta) { scelta = true; await r.cue("scelta"); }
|
||||||
|
if (m.clean && !scopa) { scopa = true; await r.cue("scopa"); }
|
||||||
|
await r.clicca(m.at, 0.7);
|
||||||
|
if (m.many) {
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "choose", null, { timeout: 5000 });
|
||||||
|
await sleep(900);
|
||||||
|
await d.click(p.locator(`#scopa-options [data-option="${m.option}"]`), 0.7);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
if (k === 9) await r.cue("resto");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => game.closeup.step === "smazzata", null, { timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
await r.cue("conto");
|
||||||
|
await r.cue("primiera");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copione del video «Come si gioca: il tressette ai 21» (video/SCALETTA-CARTE.md, clip 2).
|
||||||
|
// 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 · il tressette ai 21", en: "Come si gioca · il tressette ai 21" };
|
||||||
|
export const cues = [
|
||||||
|
c("dove", "Per il tressette ai ventuno servono quattro giocatori: il giocatore di carte chiama due amici, e uno fa da socio ad Andrea"),
|
||||||
|
c("mazzo", "Mazzo e livello si scelgono come per la briscola"),
|
||||||
|
c("tavolo", "Il socio è di fronte, gli avversari ai lati. Dieci carte a testa, e si gira verso destra"),
|
||||||
|
c("accuse", "In alto le accuse: la napoletana, cioè Asso, due e tre dello stesso seme, oppure tre Assi, tre due o tre tre. Valgono punti subito"),
|
||||||
|
c("seme", "Chi esce gioca quel che vuole; gli altri devono rispondere al seme, se ce l'hanno. Le carte che non si possono giocare diventano scure"),
|
||||||
|
c("ordine", "Prende la più alta del seme di uscita: il tre, poi il due, l'Asso, il regio, la donna, il gobbo, e giù fino al quattro"),
|
||||||
|
c("segni", "Chi esce può fare un segno al socio. Si sceglie coi pulsanti qui sotto, o coi tasti B, S e V, e poi si gioca la carta"),
|
||||||
|
c("striscio", "Striscio vuol dire: di questo seme ne ho ancora, non è l'ultima"),
|
||||||
|
c("busso", "Busso: prendi con la migliore che hai, e poi ritornami il seme"),
|
||||||
|
c("volo", "Volo: era l'ultima carta di questo seme"),
|
||||||
|
c("punti", "L'Asso vale un punto; il tre, il due e le figure un terzo di punto. L'ultima presa vale un punto in più"),
|
||||||
|
c("smazzata", "A fine smazzata si contano i punti interi, e le accuse si sommano"),
|
||||||
|
c("fine", "Si gioca smazzata dopo smazzata: la coppia che arriva per prima a ventuno è fuori, e ha vinto. Buon tressette!"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const setup = base;
|
||||||
|
|
||||||
|
export async function run(d) {
|
||||||
|
const p = d.page, r = regista(d, "tutorial-tressette");
|
||||||
|
await r.ascolta();
|
||||||
|
await r.vaiA("oratorio", [470, 300]);
|
||||||
|
await r.cue("dove");
|
||||||
|
await r.parlaCon("giocatore_carte");
|
||||||
|
await r.scegli("Un tressette ai 21?");
|
||||||
|
await r.cue("mazzo");
|
||||||
|
await r.scegli("Con le toscane.");
|
||||||
|
await r.scegli("Seria.");
|
||||||
|
// Una smazzata fissa: accuse, e Andrea che usa striscio, busso e volo.
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { newTressette } = await import("/src/engine/tressette.js");
|
||||||
|
game.closeup = { id: "tressette", ...newTressette(11, { level: "seria", deck: "toscane" }) };
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 });
|
||||||
|
await r.cue("tavolo");
|
||||||
|
await r.muovi([320, 60], 0.9); await sleep(300); await r.muovi([600, 150], 0.9); await sleep(300); await r.muovi([40, 150], 0.9);
|
||||||
|
|
||||||
|
let k = 1, detto = new Set(), seme = false, accuse = false;
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const s = await p.evaluate(async () => {
|
||||||
|
const { canPlay } = await import("/src/engine/tressette.js");
|
||||||
|
return ["smazzata", "result"].includes(game.closeup.step) ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
|
||||||
|
});
|
||||||
|
if (s === "fine") break;
|
||||||
|
if (s === "aspetta") { await sleep(150); continue; }
|
||||||
|
// Le accuse si dicono giocando la prima carta: se ne parla quando sono scritte in alto.
|
||||||
|
if (!accuse && await p.evaluate(() => game.closeup.accuse.length > 0)) { accuse = true; await r.cue("accuse"); }
|
||||||
|
const m = await p.evaluate(async (k) => {
|
||||||
|
const T = await import("/src/engine/tressette.js"), c = game.closeup, m = T.choose(T.observation(c, 0), "male", k);
|
||||||
|
const { handX, CARD } = await import("/src/engine/tressette-render.js");
|
||||||
|
return { ...m, follow: c.table.length > 0, at: [handX(c.hands[0].length, m.index) + 21, CARD.y + 30] };
|
||||||
|
}, k++);
|
||||||
|
if (m.follow && !seme) { seme = true; await r.cue("seme"); await r.cue("ordine"); }
|
||||||
|
if (m.signal && !detto.has(m.signal)) {
|
||||||
|
if (!detto.size) await r.cue("segni");
|
||||||
|
detto.add(m.signal);
|
||||||
|
await r.cue(m.signal);
|
||||||
|
}
|
||||||
|
if (m.signal) await d.click(p.locator(`#tressette-${m.signal}`), 0.6);
|
||||||
|
await p.locator("#scene").scrollIntoViewIfNeeded();
|
||||||
|
await r.clicca(m.at, 0.7);
|
||||||
|
if (k === 7) await r.cue("punti");
|
||||||
|
}
|
||||||
|
await p.waitForFunction(() => ["smazzata", "result"].includes(game.closeup.step), null, { timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
await r.cue("smazzata");
|
||||||
|
await r.cue("fine");
|
||||||
|
await r.aspetta();
|
||||||
|
await d.click(p.locator("#closeup-close"), 0.6);
|
||||||
|
await r.fermi(800);
|
||||||
|
await r.fine();
|
||||||
|
await r.salvaAudio();
|
||||||
|
}
|
||||||