Compare commits

...
5 Commits
Author SHA1 Message Date
manzolo add5a5fe77 Versione 0.1.14 2026-10-26 22:03:12 +01:00
manzoloandClaude Opus 5.5 3019a6e32f Bocce: il clic fissa la mira come al biliardo, così andando verso la forza o «Lancia» il puntatore non la sposta più (scoperto girando il video: la boccia partiva a due passi). Copioni dei video «Come si gioca» di scopa, tressette, bocce e biliardo, e video/sovrapposte.py per controllare che il narratore non parli sopra i personaggi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 21:23:23 +01:00
manzoloandClaude Opus 5.5 111b892916 Il favicon: un Regolino in pixel art con la corona (Codex, tools/favicon.py), da 16 a 512 pixel e per l'iPhone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:51:11 +01:00
manzoloandClaude Opus 5.5 f1781afea8 Scelte dei dialoghi: i numeri non escono più a sinistra del pannello (il rientro segue il carattere pixel, largo tre caratteri e mezzo per «1. »; Andrea, schermata)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:50:48 +01:00
manzoloandClaude Opus 5.5 b44eeffd2a Fra Peppino da Gricignano passa dal sagrato della Pieve al Bosco ai Frati, davanti al convento dei francescani (Andrea); la polka del pallaio per le bocce (tools/polka_bocce.py: clarinetto, fisarmonica a contrattempo, basso um-pa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:43:07 +01:00
25 changed files with 684 additions and 42 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.
Binary file not shown.
+20 -20
View File
@@ -307,26 +307,6 @@
"file": "andrea-68a845881b.mp3",
"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.": {
"file": "andrea-43fd5c2b27.mp3",
"sec": 4.46
@@ -543,6 +523,26 @@
"file": "andrea-43d3435d61.mp3",
"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.": {
"file": "andrea-486c980f52.mp3",
"sec": 7.48
+5
View File
@@ -4,6 +4,11 @@
<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.">
<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">
</head>
<body>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.13",
"version": "0.1.14",
"private": true,
"type": "module",
"scripts": {
+6 -4
View File
@@ -457,6 +457,7 @@ canvas.addEventListener('pointerdown', e => {
if (!aimed && !game.speech) return;
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 };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
@@ -503,10 +504,11 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => {
if (!running) return;
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,
// attraversare il tavolo per raggiungere «Tira» non cambia il colpo. Al tiro successivo torna libera.
const billiards = game.closeup && story.closeups[game.closeup.id].type === 'biliardo';
if (billiards) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
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));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
+14 -12
View File
@@ -135,7 +135,7 @@ export const story = {
},
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
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',
names: { player: 'Andrea', cpu: 'Mariano' },
win: [
@@ -576,23 +576,13 @@ export const story = {
map: [56, 45],
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).
speakers: { san_pietro: [300, 100], fra_peppino: [412, 196] },
// 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 }],
speakers: { san_pietro: [300, 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).
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]],
entry: [30, 320],
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',
actions: {
@@ -900,7 +890,19 @@ export const story = {
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
depth: [[292, 0.78], [360, 1.1]],
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: [
{
// 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',
actions: {
+1 -1
View File
@@ -1,5 +1,5 @@
// 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,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+5 -4
View File
@@ -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 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; }
#choices li { padding: .25em 0; font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; color: var(--dim); cursor: pointer; }
/* 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 { 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; }
.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; }
#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; }
#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
colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
#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 img { width: 60px; }
#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; }
+6
View File
@@ -387,6 +387,12 @@ try {
await bocce.mouse.up();
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 });
// 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.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
await bocce.close();
+149
View File
@@ -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()
+113
View File
@@ -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)")
+24
View File
@@ -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)
+92
View File
@@ -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();
}
+90
View File
@@ -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();
}
+79
View File
@@ -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();
}
+79
View File
@@ -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();
}