diff --git a/assets/audio/carte.mp3 b/assets/audio/carte.mp3 new file mode 100644 index 0000000..6abe1c7 Binary files /dev/null and b/assets/audio/carte.mp3 differ diff --git a/src/engine/briscola-render.js b/src/engine/briscola-render.js index 9cd69a2..f9ac677 100644 --- a/src/engine/briscola-render.js +++ b/src/engine/briscola-render.js @@ -1,6 +1,12 @@ -import { canPlay, cardName, counted, countRows } from './briscola.js'; +import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js'; import { text, panel } from './bocce-render.js'; +// Una carta in volo da un rettangolo [x, y, w, h] a un altro, con k da 0 a 1 (parte svelta, arriva piano). +// La usano anche tressette e scopa. +export function flight(from, to, k) { + const e = 1 - (1 - Math.min(1, Math.max(0, k))) ** 3; + return from.map((v, i) => v + (to[i] - v) * e); +} // Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono. export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 }; export function cardAt(c, [x, y]) { @@ -285,9 +291,13 @@ export function drawBriscola(ctx, c, def) { text(ctx, 'Prese', 550, y + 72, 6, paper, 'center'); } c.table.forEach((p, i) => { - // Il brevissimo scorrimento precede il fermo della presa. - const slide = i === c.table.length - 1 ? Math.max(0, 1 - c.t / 0.18) : 0; - drawCard(ctx, p.card, 242 + i * 86, 130 + slide * (p.who === 'player' ? 60 : -40), 64, 92); + // La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso. + const spot = [242 + i * 86, 130, 64, 92]; + const hand = p.who === 'player' ? [HAND.x + (p.from ?? 1) * HAND.gap, HAND.y, HAND.w, HAND.h] : [229 + (p.from ?? 1) * 62, 49, 52, 74]; + let r = spot; + if (i === c.table.length - 1 && c.step === 'play' || c.step === 'trick' && i === 1 && c.t < PLAY_TIME) r = flight(hand, spot, c.t / PLAY_TIME); + if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(spot, [526, c.last.winner === 'cpu' ? 74 : 257, 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05)); + drawCard(ctx, p.card, ...r); }); c.hands.player.forEach((card, i) => { const x = HAND.x + i * HAND.gap; diff --git a/src/engine/briscola.js b/src/engine/briscola.js index e75843f..0182e01 100644 --- a/src/engine/briscola.js +++ b/src/engine/briscola.js @@ -6,6 +6,9 @@ export const DECKS = { napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] }, }; export const LEVELS = ['bona', 'seria', 'male']; +// I tempi delle animazioni (briscola-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e +// poi vola sul mucchietto di chi ha preso (Andrea: «non si capisce quando vengono buttate le carte»). +export const PLAY_TIME = 0.3, TRICK_HOLD = 0.85, TRICK_TIME = 1.4; const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4]; const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7]; const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) => @@ -54,7 +57,7 @@ export function nudge(c, direction) { export function play(c, index = c.selected, who = 'player') { if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false; const [card] = c.hands[who].splice(index, 1); - c.table.push({ who, card }); c.t = 0; + c.table.push({ who, card, from: index }); c.t = 0; // `from`: da dove parte la carta nel disegno c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1)); c.events.push({ kind: 'card-slide' }); c.landing = true; if (c.table.length === 2) { @@ -171,8 +174,8 @@ export function chooseCard(v, level = 'seria', seed = 1) { export function update(c, dt) { if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return; c.t += dt; - if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; } - if (c.step === 'trick' && c.t >= 1.25) collect(c); + if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; } + if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c); else if (c.step === 'count') { // Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita. c.count = Math.min(40, Math.floor(c.t / 0.35)); diff --git a/src/engine/music.js b/src/engine/music.js index 2b3d366..221e83e 100644 --- a/src/engine/music.js +++ b/src/engine/music.js @@ -8,11 +8,13 @@ import { versioned } from '../version.js'; // d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js). // Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo); // altrimenti quello generale del paese. -export function trackFor(story, { screen, scene, phase }) { +// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto. +export function trackFor(story, { screen, scene, phase, closeup }) { const m = story.music ?? {}; if (screen === 'splash') return null; if (screen === 'title' || screen === 'intro') return m.title ?? null; if (screen === 'ending') return m.ending ?? m.game ?? null; + if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music; const sc = story.scenes[scene]; return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null; } diff --git a/src/engine/scopa-render.js b/src/engine/scopa-render.js index d6fd9b7..5a54748 100644 --- a/src/engine/scopa-render.js +++ b/src/engine/scopa-render.js @@ -1,5 +1,5 @@ -import { canPlay, TARGET } from './scopa.js'; -import { drawCard, drawTable } from './briscola-render.js'; +import { canPlay, TARGET, PLAY_TIME, TAKE_HOLD, TAKE_TIME } from './scopa.js'; +import { drawCard, drawTable, flight } from './briscola-render.js'; import { text, panel } from './bocce-render.js'; // Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola. @@ -35,17 +35,30 @@ function board(ctx, c, names) { if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center'); } } +const PILE = { cpu: 92, player: 196 }; function table(ctx, c) { - const take = c.step === 'take' && c.last.captured.length, shown = take ? [...c.table, ...c.last.captured] : c.table; + // Durante una presa la tavola resta com'era (le carte prese al loro posto, incorniciate) finché non volano via. + const take = c.step === 'take' && c.last.captured.length, shown = take ? c.last.before : c.table; const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id)); + const fly = take && c.t > TAKE_HOLD ? (c.t - TAKE_HOLD) / (TAKE_TIME - TAKE_HOLD - 0.05) : 0; + const pile = take ? [560, PILE[c.last.who], 42, 60] : null; + // Da dove parte la carta giocata: la mano di Andrea o quella coperta del giocatore. + const hand = c.step === 'take' && (c.last.who === 'player' + ? [handX(c.hands.player.length + 1, c.last.from), HAND.y, HAND.w, HAND.h] + : [320 - ((c.hands.cpu.length + 1) * 48 - 6) / 2 + c.last.from * 48, 38, 42, 60]); + const arrive = Math.min(1, c.t / PLAY_TIME); shown.forEach((card, i) => { const [x, y] = tableXY(shown.length, i); - if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h); - else if (c.step === 'take' && !take && card.id === c.last.card.id) frame(ctx, x, y, TABLE.w, TABLE.h, '#9fe3ff'); - drawCard(ctx, card, x, y, TABLE.w, TABLE.h); + let r = [x, y, TABLE.w, TABLE.h]; + if (c.step === 'take' && !take && card.id === c.last.card.id) r = flight(hand, r, arrive); + if (fly && marked.has(card.id)) r = flight(r, pile, fly); + else if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h); + drawCard(ctx, card, ...r); }); if (take) { - drawCard(ctx, c.last.card, 299, 200, TABLE.w, TABLE.h); + let r = flight(hand, [299, 200, TABLE.w, TABLE.h], arrive); + if (fly) r = flight(r, pile, fly); + drawCard(ctx, c.last.card, ...r); if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); } } } diff --git a/src/engine/scopa.js b/src/engine/scopa.js index 09bc550..468d2eb 100644 --- a/src/engine/scopa.js +++ b/src/engine/scopa.js @@ -5,6 +5,9 @@ import { DECKS, cardName } from './briscola.js'; export { cardName }; export const LEVELS = ['bona', 'seria', 'male']; export const TARGET = 11; +// I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi +// volano sul mucchietto di chi ha preso. +export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35; export const COIN = { toscane: 'mattoni', napoletane: 'denari' }; // Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14, // 3 13, 2 12, le figure 10. @@ -105,7 +108,7 @@ export function take(c, k = c.choice?.selected) { export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; } function execute(c, who, index, captured) { if (!captured && captures(c.table, c.hands[who][index]).length) return false; - const [card] = c.hands[who].splice(index, 1); + const before = [...c.table], [card] = c.hands[who].splice(index, 1); if (captured) { const ids = new Set(captured.map(a => a.id)); c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who; @@ -114,7 +117,8 @@ function execute(c, who, index, captured) { const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length; const scopa = !!captured && !c.table.length && !lastPlay; if (scopa) c.scope[who]++; - c.last = { who, card, captured: captured ?? [], scopa }; + // `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa. + c.last = { who, card, captured: captured ?? [], scopa, from: index, before }; c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : [])); c.step = 'take'; c.t = 0; if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1)); @@ -230,7 +234,7 @@ export function choose(v, level = 'seria', seed = 1) { export function update(c, dt) { if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return; c.t += dt; - if (c.step === 'take' && c.t >= (c.last.captured.length ? 1.3 : 0.6)) after(c); + if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c); else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) { c.aiSeed = lcg(c.aiSeed); const m = choose(observation(c, 'cpu'), c.level, c.aiSeed); diff --git a/src/engine/tressette-render.js b/src/engine/tressette-render.js index 338a52c..5a2d3b3 100644 --- a/src/engine/tressette-render.js +++ b/src/engine/tressette-render.js @@ -1,5 +1,5 @@ -import { canPlay, legal, accuseName, NAMES } from './tressette.js'; -import { drawCard, drawTable } from './briscola-render.js'; +import { canPlay, legal, accuseName, team, NAMES, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './tressette.js'; +import { drawCard, drawTable, flight } from './briscola-render.js'; import { text, panel } from './bocce-render.js'; // La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco. @@ -19,7 +19,9 @@ export function cardAt(c, [x, y]) { return -1; } // Dove cade la carta di ognuno al centro, e da dove arriva. -const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]], FROM = [[0, 70], [80, 0], [0, -60], [-80, 0]]; +const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]]; +// I mucchietti delle prese: noi in basso a destra, loro in basso a sinistra. +const PILE = [[560, 288], [34, 288]]; const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']]; const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3'; const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' }; @@ -37,6 +39,14 @@ function others(ctx, c, names) { text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align); } } +function piles(ctx, c) { + // Quante prese ha fatto ogni coppia, a mucchietto (una carta coperta ogni due prese). + for (const t of [0, 1]) { + const n = c.tricks.filter(x => team(x.winner) === t).length, [x, y] = PILE[t]; + for (let k = 0; k < Math.ceil(n / 2); k++) drawCard(ctx, null, x + k, y - k, 42, 60, true); + text(ctx, t ? 'Prese loro' : 'Prese noi', x + 21, y - 8, 5, t ? theirs : ours, 'center'); + } +} function scoreboard(ctx, c) { panel(ctx, 26, 34, 118, 38); text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours); @@ -51,10 +61,15 @@ function accuses(ctx, c, names) { c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 403, 41 + i * 11, 5, a.who % 2 ? theirs : ours)); } function trick(ctx, c, names) { + // Da dove parte la carta: la mano di Andrea, o le carte coperte degli altri. + const from = p => p.who === 0 ? [handX(c.hands[0].length + 1, p.from ?? 0), CARD.y, CARD.w, CARD.h] + : p.who === 2 ? [300, 46, 26, 37] : [p.who === 1 ? 584 : 30, 102 + (p.from ?? 0) * 12, 26, 37]; c.table.forEach((p, i) => { - const slide = i === c.table.length - 1 && c.step === 'play' ? Math.max(0, 1 - c.t / 0.18) : 0; - const [x, y] = SPOT[p.who], [dx, dy] = FROM[p.who]; - drawCard(ctx, p.card, x + dx * slide, y + dy * slide, CARD.w, CARD.h); + const [x, y] = SPOT[p.who]; + let r = [x, y, CARD.w, CARD.h]; + if (i === c.table.length - 1 && c.t < PLAY_TIME && (c.step === 'play' || c.step === 'trick')) r = flight(from(p), r, c.t / PLAY_TIME); + if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(r, [...PILE[team(c.last.winner)], 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05)); + drawCard(ctx, p.card, ...r); if (p.signal) { const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30]; panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center'); @@ -86,7 +101,7 @@ export function drawTressette(ctx, c, def) { const names = def?.seats ?? NAMES; ctx.save(); ctx.imageSmoothingEnabled = false; drawTable(ctx, 'TRESSETTE AI 21'); - scoreboard(ctx, c); others(ctx, c, names); accuses(ctx, c, names); trick(ctx, c, names); hand(ctx, c); + scoreboard(ctx, c); others(ctx, c, names); piles(ctx, c); accuses(ctx, c, names); hand(ctx, c); trick(ctx, c, names); if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`); if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro'); ctx.restore(); diff --git a/src/engine/tressette.js b/src/engine/tressette.js index 5665e37..8c97bae 100644 --- a/src/engine/tressette.js +++ b/src/engine/tressette.js @@ -6,6 +6,9 @@ export { cardName }; export const LEVELS = ['bona', 'seria', 'male']; export const SIGNALS = ['busso', 'striscio', 'volo']; export const TARGET = 21; +// I tempi delle animazioni (tressette-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e +// poi vola sul mucchietto della coppia che ha preso. +export const PLAY_TIME = 0.3, TRICK_HOLD = 0.8, TRICK_TIME = 1.35; // Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra. export const SEATS = [0, 1, 2, 3]; export const team = seat => seat % 2; @@ -115,7 +118,7 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign if (out(c)) return true; } const [card] = c.hands[who].splice(index, 1); - c.table.push({ who, card, signal: signal || null }); c.t = 0; c.landing = true; + c.table.push({ who, card, signal: signal || null, from: index }); c.t = 0; c.landing = true; // `from`: per il disegno // Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto. c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who }); if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); } @@ -254,8 +257,8 @@ export function choose(v, level = 'seria', seed = 1) { export function update(c, dt) { if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return; c.t += dt; - if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; } - if (c.step === 'trick' && c.t >= 1.2) collect(c); + if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; } + if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c); else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) { c.aiSeed = lcg(c.aiSeed); const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed); diff --git a/src/main.js b/src/main.js index e0acb53..b4e85f2 100644 --- a/src/main.js +++ b/src/main.js @@ -906,7 +906,7 @@ function frame(now) { // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. const searching = game?.closeup?.id === 'fessura'; music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; - music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt); + music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt); // Un minuto senza toccare niente, a gioco fermo: pausa da sola. if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 }); diff --git a/src/story/story.js b/src/story/story.js index f613e37..a63f10f 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -109,15 +109,16 @@ export const story = { closeups: { // [DA ANDREA: chi è] Partita facoltativa; battute provvisorie. briscola: { - type: 'briscola', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, + type: 'briscola', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, win: [{ set: 'briscola_vinta' }, { set: 'briscola_giocata' }, { say: 'Oh, bravo! Ora però rimescolo io.', who: 'giocatore_carte', pose: 'laugh' }], lose: [{ set: 'briscola_giocata' }, { say: 'I carichi portali qui, mica alla stazione! Rivincita quando vuoi.', who: 'giocatore_carte', pose: 'laugh' }], draw: [{ set: 'briscola_giocata' }, { say: 'Sessanta pari. Un si litiga nemmeno sul conto!', who: 'giocatore_carte', pose: 'talk' }], quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }], }, + // I tre tavoli da carte suonano il valzer della balera (tools/valzer_carte.py, Andrea: «una sua melodia»). // Il tressette a coppie ai 21 (idea di Andrea): busso, striscio, volo e accuse. [DA ANDREA: chi sono il socio e l'altro] tressette: { - type: 'tressette', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, + type: 'tressette', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, seats: ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'], win: [{ set: 'tressette_vinto' }, { say: 'Ventuno per primi. Bella coppia, voi due!', who: 'giocatore_carte', pose: 'laugh' }], lose: [{ say: 'Ventuno! Il tressette è un\'arte: si studia da piccini.', who: 'giocatore_carte', pose: 'laugh' }], @@ -126,7 +127,7 @@ export const story = { }, // La scopa ai 11 (idea di Andrea): carte, mattoni (o denari), settebello, primiera e scope. scopa: { - type: 'scopa', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, + type: 'scopa', music: 'assets/audio/carte.mp3', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' }, win: [{ set: 'scopa_vinta' }, { say: 'Settebello, primiera e pure le scope. Oggi l\'hai vista bene.', who: 'giocatore_carte', pose: 'talk' }], lose: [{ say: 'Undici! Il settebello lo sento arrivare da lontano.', who: 'giocatore_carte', pose: 'laugh' }], draw: [], diff --git a/tests/briscola.browser.mjs b/tests/briscola.browser.mjs index 2620b8a..a20e1dc 100644 --- a/tests/briscola.browser.mjs +++ b/tests/briscola.browser.mjs @@ -89,6 +89,8 @@ try { game.closeup = { id: 'briscola', ...newBriscola(91421, { level: 'male', deck }) }; }, deck); await page.waitForSelector('#briscola-controls:not([hidden])'); + // Il tavolo ha la sua musica (il valzer delle carte), non quella dell'oratorio. + await page.waitForFunction(() => music.want === 'assets/audio/carte.mp3'); assert.ok(await page.isHidden('#bocce-controls')); const scene = await page.locator('#scene').boundingBox(), exit = await page.locator('#closeup-close').boundingBox(); assert.ok(exit.y >= scene.y + scene.height, 'uscita fuori dal tavolo'); diff --git a/tests/briscola.test.mjs b/tests/briscola.test.mjs index 4c3385e..0bb09ec 100644 --- a/tests/briscola.test.mjs +++ b/tests/briscola.test.mjs @@ -39,7 +39,7 @@ test('nessun obbligo di seme; la presa resta sul tavolo, chi prende pesca prima assert.ok(play(c, 0, 'cpu')); assert.ok(play(c, 1)); assert.equal(c.step, 'trick'); assert.equal(c.last.winner, 'player'); assert.equal(c.table.length, 2); update(c, 1); assert.equal(c.table.length, 2); assert.equal(c.stock.length, stock.length); - update(c, 0.26); + update(c, 0.41); // TRICK_TIME: 1,4 s, la presa vola sulle prese assert.equal(c.hands.player.at(-1).id, stock[0].id); assert.equal(c.hands.cpu.at(-1).id, stock[1].id); assert.equal(c.current, 'player'); assert.equal(c.table.length, 0); diff --git a/tests/music.test.mjs b/tests/music.test.mjs index cbc2ef4..edd46d5 100644 --- a/tests/music.test.mjs +++ b/tests/music.test.mjs @@ -13,6 +13,10 @@ test('il brano giusto per titolo, fine e scena', () => { assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3'); assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3'); assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null); + // Un tavolo da carte aperto ha la sua musica; un primo piano senza musica lascia quella della scena. + const tavoli = { ...s, closeups: { carte: { music: 'c.mp3' }, libro: {} } }; + assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'carte' }), 'c.mp3'); + assert.equal(trackFor(tavoli, { screen: 'game', scene: 'b', closeup: 'libro' }), 'b.mp3'); // La sera ha la sua musica all'aperto; la scena col suo brano e gli interni non cambiano. const sera = { id: 'sera', music: 's.mp3' }; assert.equal(trackFor({ ...s, scenes: { ...s.scenes, i: { indoor: true } } }, { screen: 'game', scene: 'a', phase: sera }), 's.mp3'); diff --git a/tools/valzer_carte.py b/tools/valzer_carte.py new file mode 100644 index 0000000..26ab95a --- /dev/null +++ b/tools/valzer_carte.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""La musica dei tavoli da carte all'oratorio (assets/audio/carte.mp3), sintetizzata da zero come il trio del biliardo: +un valzer da balera in sol maggiore. Fisarmonica a due voci un po' scordate fra loro (il «musette») sul due e sul tre, +basso pizzicato sull'uno, e una melodia originale di mandolino, col tremolo sulle note lunghe. Trentadue battute che +chiudono sul re settima: il loop non si sente. (Andrea, 26 ottobre: «come per il biliardo, una sua melodia».) + python3 tools/valzer_carte.py # scrive assets/audio/carte.mp3 (serve ffmpeg) +""" +import math +import random +import subprocess +import tempfile +import wave +from array import array +from pathlib import Path + +SR = 22050 +BPM = 138 +BEAT = 60 / BPM +OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "carte.mp3" + +# Un accordo per battuta: fondamentale del basso e note della fisarmonica. +HARMONY = {"G": ["B3", "D4", "G4"], "D7": ["C4", "D4", "F#4"], "G7": ["B3", "F4", "G4"], "C": ["C4", "E4", "G4"], + "Em": ["B3", "E4", "G4"], "Am": ["C4", "E4", "A4"], "E7": ["B3", "D4", "G#4"]} +BASS = {"G": ("G2", "D2"), "D7": ("D2", "A1"), "G7": ("G2", "F2"), "C": ("C2", "G1"), "Em": ("E2", "B1"), "Am": ("A1", "E2"), + "E7": ("E2", "B1")} +CHORDS = ("G G D7 D7 D7 D7 G G G G7 C C G D7 G G " + "Em Em Am Am D7 D7 G G C C G E7 Am D7 G D7").split() +# La melodia del mandolino, battuta per battuta: (nota, durata in movimenti); None è una pausa. +MELODY = [ + [("B4", 2), ("D5", 1)], [("G5", 3)], [("F#5", 2), ("E5", 1)], [("D5", 2), ("C5", 1)], + [("A4", 2), ("C5", 1)], [("F#5", 3)], [("E5", 2), ("D5", 1)], [("B4", 3)], + [("B4", 1), ("D5", 1), ("G5", 1)], [("F5", 3)], [("E5", 2), ("G5", 1)], [("C6", 3)], + [("B5", 2), ("G5", 1)], [("A5", 1), ("F#5", 1), ("D5", 1)], [("G5", 3)], [(None, 2), ("D5", 1)], + [("G5", 2), ("F#5", 1)], [("E5", 3)], [("C5", 1), ("E5", 1), ("A5", 1)], [("A5", 2), ("G5", 1)], + [("F#5", 2), ("E5", 1)], [("D5", 1), ("F#5", 1), ("A5", 1)], [("B5", 3)], [("G5", 2), ("B4", 1)], + [("C5", 1), ("E5", 1), ("G5", 1)], [("E5", 3)], [("D5", 2), ("G5", 1)], [("G#5", 2), ("B5", 1)], + [("A5", 2), ("C6", 1)], [("A5", 1), ("F#5", 1), ("C5", 1)], [("B4", 3)], [("D5", 1), ("E5", 1), ("F#5", 1)], +] +assert len(MELODY) == len(CHORDS) and all(sum(d for _, d in bar) == 3 for bar in MELODY) +DUR = len(CHORDS) * 3 * BEAT +buf = [0.0] * int(SR * DUR) +rng = random.Random(7) + + +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): + n = int((length + release) * SR) + out = [] + 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) + out.append(vol * env * sum(a * math.exp(-t / d) * math.sin(2 * math.pi * freq * k * t) 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)) +# Mandolino: corda pizzicata, armonici alti che muoiono subito. +PLUCK = ((1, 1.0, LONG), (2, 0.5, 0.25), (3, 0.3, 0.12), (4, 0.15, 0.06)) + +for bar, name in enumerate(CHORDS): + b0 = bar * 3 + # Basso sull'uno: la fondamentale, una battuta sì e una no la quinta. + add(at(b0), tone(hz(BASS[name][bar % 2]), BEAT * 0.9, 0.3, ((1, 1.0, LONG), (2, 0.4, 0.3), (3, 0.15, 0.15)), decay=0.5)) + # Fisarmonica sul due e sul tre: due ance, una un filo crescente, che battono fra loro. + for beat in (1, 2): + for note in HARMONY[name]: + for detune, vol in ((1.0, 0.03), (1.0035, 0.022)): + add(at(b0 + beat), tone(hz(note) * detune, BEAT * 0.62, vol, REED, decay=LONG, attack=0.02, release=0.05)) + +beat = 0.0 +for bar in MELODY: + for note, length in bar: + if note: + if length >= 2: + # Le note lunghe col tremolo del plettro: tanti colpi fitti, sempre più piano. + picks = int(length * BEAT / 0.068) + for k in range(picks): + add(at(beat) + k * 0.068, tone(hz(note), 0.068, 0.07 * (1 - 0.35 * k / picks) * (1.15 if k == 0 else 1), + PLUCK, decay=0.12, attack=0.002, release=0.02)) + else: + add(at(beat), tone(hz(note), BEAT * length * 0.95, 0.1, PLUCK, decay=0.35, attack=0.002, release=0.05)) + beat += length + +peak = max(abs(x) for x in buf) or 1 +with tempfile.TemporaryDirectory() as tmp: + w = Path(tmp) / "carte.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()) + # Il cortile dell'oratorio: un po' d'eco dai muri, niente alti taglienti. + af = "aecho=0.8:0.55:60|130:0.2|0.12,lowpass=f=8000,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)") diff --git a/video/monta.py b/video/monta.py index 3dd9e46..3dd6b73 100644 --- a/video/monta.py +++ b/video/monta.py @@ -22,10 +22,16 @@ OUT = VIDEO / "out" # Area del gioco nello schermo della VM (Chromium a tutto schermo, zoom 125 %): misurata con «convert -trim». TAGLIO = "crop=1044:760:278:70" FASCIA = 110 # px sopra il gioco per le didascalie +# I video «Come si gioca» (--tutorial): ai tavoli la pagina usa tutta l'altezza, comandi sotto compresi; didascalie +# lunghe anche su tre righe. +TAGLIO_TUTORIAL = "crop=1046:900:268:0" +FASCIA_TUTORIAL = 132 FONDO = "0x1B1420" # il colore di fondo della pagina del gioco VOLUME = 0.5 # musica della demo-prototipo (senza voci) VOLUME_SOTTO = 0.22 # musica sotto le voci: bassa (Andrea, 25 ottobre) VOLUME_VOCI = 1.0 +VOLUME_NARRATORE = 1.15 +VOLUME_TUTORIAL = 0.07 # musica dei video «Come si gioca»: sottofondo # il narratore dei video «Come si gioca», sopra la musica bassa FADE = 1.2 @@ -42,6 +48,8 @@ def ts(s: float) -> str: def main() -> None: d = Path(sys.argv[1]).expanduser() + tutorial = "--tutorial" in sys.argv[2:] + taglio, fascia = (TAGLIO_TUTORIAL, FASCIA_TUTORIAL) if tutorial else (TAGLIO, FASCIA) meta = json.loads((d / "cues.json").read_text()) nome = meta["clip"] registro = OUT / f"{nome}-audio.json" @@ -59,6 +67,10 @@ def main() -> None: aperta["fine"] = e["t"] aperta = None volume = VOLUME_SOTTO + # Nei video «Come si gioca» parla il narratore quasi sempre: la musica resta un sottofondo (Andrea, 26 ottobre: + # «troppo alta la musica del gioco»). + if (OUT / f"{nome}-narrazione.json").exists(): + volume = VOLUME_TUTORIAL else: musica = json.loads((OUT / "musica.json").read_text()) volume = VOLUME @@ -105,9 +117,20 @@ def main() -> None: lungo = f"atrim=0:{v['fine'] - v['t'] + 0.1:.3f}," if "fine" in v else "" catene.append(f"[{n}:a]{lungo}asetpts=PTS-STARTPTS,volume={VOLUME_VOCI},adelay={int((v['t'] + scarto) * 1000)}:all=1[v{len(pezzi)}]") pezzi.append(f"[v{len(pezzi)}]") + # Il narratore dei video «Come si gioca» (video/narra.py): ogni frase all'inizio della sua battuta. + narrazione = OUT / f"{nome}-narrazione.json" + if narrazione.exists(): + frasi = {v["id"]: v["src"] for v in json.loads(narrazione.read_text())["voci"]} + for c in cues: + if c["id"] not in frasi: + continue + ingressi += ["-i", str(GIOCO / frasi[c["id"]])] + n = ingressi.count("-i") + catene.append(f"[{n}:a]asetpts=PTS-STARTPTS,volume={VOLUME_NARRATORE},adelay={int((c['start'] + scarto) * 1000)}:all=1[n{len(pezzi)}]") + pezzi.append(f"[n{len(pezzi)}]") mix = "".join(pezzi) + f"amix=inputs={len(pezzi)}:normalize=0:duration=longest[a]" stile = f"FontName=DejaVu Sans,FontSize=11,Bold=1,MarginV=4,Outline=1,Shadow=0,PrimaryColour=&H007AD2FF" - video = f"[0:v]{TAGLIO},pad=iw:ih+{FASCIA}:0:{FASCIA}:{FONDO},subtitles={srt}:force_style='{stile}'[v]" + video = f"[0:v]{taglio},pad=iw:ih+{fascia}:0:{fascia}:{FONDO},subtitles={srt}:force_style='{stile}'[v]" finale = OUT / f"{nome}.mp4" subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(raw), *ingressi, "-filter_complex", ";".join([video, *catene, mix]), "-map", "[v]", "-map", "[a]", diff --git a/video/narra.py b/video/narra.py index 34f744e..5d2fafc 100644 --- a/video/narra.py +++ b/video/narra.py @@ -36,7 +36,10 @@ def main() -> None: # XTTS dice «punto» se la frase finisce col punto: si toglie, come fa narrate.py del tour. lavori = [{"text": c["it"].rstrip(". "), "out": str(cartella / f"{c['id']}.wav"), "lang": "it", "speaker": NARRATORE} for c in cues] (cartella / "lavori.json").write_text(json.dumps(lavori, ensure_ascii=False, indent=1)) - pron = ["--pronounce", str(PRONUNCIA)] if PRONUNCIA.exists() else [] + # Le pronunce del gioco più quelle del narratore, in un file solo per `mbot parla`. + regole = {k: v for f in (PRONUNCIA, VIDEO / "pronuncia-narratore.json") if f.exists() for k, v in json.loads(f.read_text()).items() if not k.startswith("_")} + (cartella / "pronuncia.json").write_text(json.dumps(regole, ensure_ascii=False, indent=1)) + pron = ["--pronounce", str(cartella / "pronuncia.json")] subprocess.run(["mbot", "parla", "--jobs", str(cartella / "lavori.json"), *pron], check=True) ascolto = [{"file": l["out"], "expect": l["text"]} for l in lavori] (cartella / "ascolto.json").write_text(json.dumps(ascolto, ensure_ascii=False, indent=1)) diff --git a/video/pronuncia-narratore.json b/video/pronuncia-narratore.json new file mode 100644 index 0000000..fc317f5 --- /dev/null +++ b/video/pronuncia-narratore.json @@ -0,0 +1,7 @@ +{ + "_nota": "Pronunce in più per il narratore dei video «Come si gioca» (video/narra.py), oltre a tools/pronuncia.json. L'accento scritto sposta quello di XTTS (Andrea, 26 ottobre: «piccola» e «volta» con l'accento sbagliato).", + "piccola": "pìccola", + "piccole": "pìccole", + "volta": "vòlta", + "volte": "vòlte" +} diff --git a/video/tutorial-briscola.mjs b/video/tutorial-briscola.mjs index 9362edb..cb7a5c7 100644 --- a/video/tutorial-briscola.mjs +++ b/video/tutorial-briscola.mjs @@ -13,7 +13,7 @@ export const cues = [ c("pesca", "Chi prende pesca per primo ed esce alla mano dopo. E non c'è l'obbligo di rispondere al seme"), c("consiglio", "Un consiglio: su un liscio rispondi con un liscio, e i carichi dell'avversario prendili con una briscola piccola"), c("tastiera", "Si gioca toccando la carta, oppure coi pulsanti sotto il tavolo, o con le frecce e Invio"), - c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti, e sessanta a sessanta è patta"), + c("conto", "Finito il mazzo si contano le prese, una carta alla volta. Vince chi arriva a sessantuno punti; sessanta a sessanta è pari, e non vince nessuno"), c("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"), ]; @@ -21,6 +21,7 @@ export const setup = base; export async function run(d) { const p = d.page, r = regista(d, "tutorial-briscola"); + // Il registro dell'audio parte con la ripresa: monta.py rimette le voci del gioco al loro istante. await r.ascolta(); await r.vaiA("oratorio", [470, 300]); await r.cue("dove"); @@ -30,12 +31,14 @@ export async function run(d) { await r.scegli("Con le toscane."); await r.cue("livello"); await r.scegli("Seria."); - // Una mano fissa, la stessa a ogni ripresa. + // Una mano fissa, la stessa a ogni ripresa: giocando attento, Andrea vince 68 a 52. await p.evaluate(async () => { const { newBriscola } = await import("/src/engine/briscola.js"); - game.closeup = { id: "briscola", ...newBriscola(91421, { level: "seria", deck: "toscane" }) }; + game.closeup = { id: "briscola", ...newBriscola(8, { level: "seria", deck: "toscane" }) }; }); await sleep(800); + // Il tavolo ha la sua musica: se non parte, il gioco servito nella VM è vecchio. + await p.waitForFunction(() => window.music?.want === "assets/audio/carte.mp3", null, { timeout: 10000 }); await r.cue("tavolo"); await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0); @@ -69,6 +72,7 @@ export async function run(d) { await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 }); await sleep(2500); await r.cue("fine"); + await r.aspetta(); // la battuta del giocatore, uscendo, dopo quella del narratore await d.click(p.locator("#closeup-close"), 0.6); await r.fermi(800); await r.fine(); diff --git a/video/tutorial-comune.mjs b/video/tutorial-comune.mjs index 9611a24..cef0e60 100644 --- a/video/tutorial-comune.mjs +++ b/video/tutorial-comune.mjs @@ -15,6 +15,11 @@ export function narrazione(clip) { export async function setup(d) { const p = d.page; + // Niente cache: il Chromium della VM teneva i moduli JavaScript di una ripresa prima (il valzer non partiva). + const cdp = await p.context().newCDPSession(p); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await cdp.send("Network.clearBrowserCache"); await p.goto(URL_GIOCO); await p.evaluate(() => localStorage.clear()); await p.reload(); @@ -26,13 +31,16 @@ export function regista(d, clip) { const p = d.page, voce = narrazione(clip); let inizio = 0, dura = 0; const r = { - // Una battuta del narratore: prima si lascia finire quella di prima. + // Una battuta del narratore: prima si lascia finire quella di prima, e chi sta parlando nel gioco. async cue(id) { - const resto = inizio + dura + 350 - Date.now(); - if (resto > 0) await sleep(resto); + await r.aspetta(); + for (let i = 0; i < 300 && (await p.evaluate(() => !!game.speech || !!(window.voices?.audio && !window.voices.audio.paused && !window.voices.audio.ended))); i++) await sleep(100); + await sleep(250); await d.cue(id); inizio = Date.now(); dura = voce[id] ?? 0; }, + // Prima di far parlare un personaggio, il narratore deve aver finito: le due voci non si sovrappongono. + async aspetta() { const resto = inizio + dura + 350 - Date.now(); if (resto > 0) await sleep(resto); }, async fine() { const resto = inizio + dura + 600 - Date.now(); if (resto > 0) await sleep(resto); }, // Puntatore vero sulla scena (coordinate 640×360). async punto(x, y) { @@ -54,7 +62,7 @@ export function regista(d, clip) { } await sleep(pausa); }, - async scegli(testo) { await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); }, + async scegli(testo) { await r.aspetta(); await d.click(p.locator("#choices li", { hasText: testo }), 0.6); await r.fermi(); }, // Partita nuova per Campomigliaio, introduzione saltata, dritti nella scena del gioco. async vaiA(scena, at) { await d.click(p.locator("#new-game")); @@ -62,10 +70,12 @@ export function regista(d, clip) { await d.click(p.locator('#rione [data-rione="campomigliaio"]')); await sleep(800); if (await p.isVisible("#skip-intro")) await d.click(p.locator("#skip-intro"), 0.5); - await p.evaluate(([s, a]) => { game.go(s, a); }, [scena, at]); + // Le battute d'apertura di Andrea (il palio sparito) qui non servono: si saltano tutte. + await p.evaluate(([s, a]) => { for (let i = 0; i < 50 && (game.speech || game.queue.length); i++) { game.skip(); game.update(0.05); } game.go(s, a); }, [scena, at]); await r.fermi(1200); }, async parlaCon(id) { + await r.aspetta(); const [x, y] = await p.evaluate((id) => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id); await r.clicca([x, y]); await r.fermi();