Giochi di carte dopo la revisione di Codex sui tre insieme; player dei video «Come si gioca» e primo copione

Esito che vale e si salva appena deciso, per tutti i minigiochi (anche tornando al titolo dalla schermata del risultato); briscola: uscire durante il conteggio non perde più la vittoria; tressette: si esce ai 21 col primo evento che ci porta, accusa compresa (prima vinceva sempre Andrea se ci arrivavano tutte e due), il carico che fa vincere si dà anche se è il 2, accuse che non escono dal tavolo, convenzioni scritte nell'aiuto; scopa: primiera valutata per quel che migliora, finale esatto a mazzo finito, presa sulla tavola al rilascio con scelta e conferma, frecce e Invio sempre sulla presa scelta. Tastiera: Invio e Spazio giocano sempre la carta scelta. Tavola descritta a parole. Classi CSS comuni ai tre giochi.
Video: player nel gioco (pulsante «Guarda il video» nel «Come si gioca», compare quando il video esiste), video/narra.py per la voce del narratore, pezzi comuni dei copioni e il copione della briscola; scaletta in video/SCALETTA-CARTE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
2026-10-26 16:39:22 +01:00
co-authored by Claude Opus 5.5
parent 30e324d7e2
commit 7e1e6f6144
24 changed files with 594 additions and 73 deletions
Binary file not shown.
+7 -3
View File
@@ -1527,9 +1527,9 @@
"file": "andrea-d2f285f610.mp3", "file": "andrea-d2f285f610.mp3",
"sec": 1.04 "sec": 1.04
}, },
"andrea|La rivincita?": { "andrea|Rivincita a briscola?": {
"file": "andrea-98e4366a56.mp3", "file": "andrea-6807209a9e.mp3",
"sec": 0.83 "sec": 1.37
}, },
"andrea|Una scopa?": { "andrea|Una scopa?": {
"file": "andrea-d8dfb86266.mp3", "file": "andrea-d8dfb86266.mp3",
@@ -1899,6 +1899,10 @@
"file": "andrea-83b1337b4d.mp3", "file": "andrea-83b1337b4d.mp3",
"sec": 3.31 "sec": 3.31
}, },
"andrea|La rivincita?": {
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
"andrea|Allora, Brento Sanico?": { "andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3", "file": "andrea-2add39ff10.mp3",
"sec": 1.29 "sec": 1.29
+8
View File
@@ -43,6 +43,14 @@ Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 vo
Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani. Queste percentuali misurano il confronto con quell’automatismo, non con giocatori umani.
## Revisione di Codex (26 ottobre, i tre giochi insieme)
Regole promosse. Corretti: uscendo durante il conteggio si perdeva una vittoria già fatta (ora il risultato è
deciso all'ultima presa e il conteggio lo mostra soltanto) e, per tutti e tre i giochi, l'esito che non finiva
nel salvataggio se dal risultato si tornava al titolo; Spazio sui pulsanti come negli altri giochi; la briscola e
la carta in tavola dette anche a parole; l'aiuto coi nomi di tutti e due i mazzi; «Rivincita a briscola?».
Proposta non fatta: i seguiti campionati di «A fassi male» col finale esatto, e l'obiettivo vittoria invece dei punti.
## Grafica e audio ## Grafica e audio
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
+15 -5
View File
@@ -20,13 +20,23 @@ le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Clau
## Avversari ## Avversari
- «Alla bona»: una mossa a caso fra quelle possibili. - «Alla bona»: una mossa a caso fra quelle possibili.
- «Seria»: sceglie la mossa che vale di più (settebello, sette, mattoni, primiera, scope) e teme a occhio di - «Seria»: sceglie la mossa che vale di più (settebello, mattoni, carte, scope, e la primiera per quanto migliora la
propria: un 6 di un seme dove si ha solo una figura vale più di uno dove si ha già il 7) e teme a occhio di
lasciare una scopa. lasciare una scopa.
- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, e non lascia 7 - «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, non lascia 7 in
in tavola. tavola e, a mazzo finito (le carte dell'altro sono proprio quelle non viste), calcola il finale esatto fino al
conteggio.
Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 92% contro Taratura (`node tools/calibrate_scopa.mjs 300`, Andrea che gioca come «A fassi male»): Andrea vince 96% contro
«Alla bona», 64% contro «Seria», 52% contro «A fassi male»; circa quattro smazzate a partita. «Alla bona», 76% contro «Seria», 50% contro «A fassi male»; circa quattro smazzate a partita.
## Revisione di Codex (26 ottobre, i tre giochi insieme)
Regole promosse (valori, presa obbligatoria, precedenza della carta uguale, scopa non all'ultima, residui
all'ultimo, maggioranze e pari, primiera, re in tavola, agli 11). Corretti: frecce e Invio che potevano prendere la
presa sbagliata (ora il fuoco segue la scelta); il tocco sulla tavola che agiva alla pressione e non bastava con
prese sovrapposte (ora al rilascio: il primo tocco sceglie, il secondo conferma); primiera valutata a occhio;
finali sbagliati a mazzo finito; la tavola descritta a parole; aiuto con i valori della primiera e i pari.
## Prove ## Prove
+7
View File
@@ -48,6 +48,13 @@ riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole
Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione
precedente dell'IA. precedente dell'IA.
Seconda revisione (i tre giochi insieme). Corretti: con le accuse tutte e due le coppie potevano arrivare ai 21 e
vinceva sempre Andrea (ora esce chi arriva per primo, anche con un'accusa, nel momento in cui la dice); il socio
che teneva il 2 anche quando caricarlo voleva dire vincere subito (l'IA vede ora punteggio e terzi del tabellone);
Invio su un pulsante che giocava la carta col fuoco invece di quella scelta; il pannello delle accuse che usciva
dal tavolo; le convenzioni del tavolo scritte anche nel «Come si gioca». Per tutti e tre i giochi l'esito vale e
si salva appena deciso (anche tornando al titolo dalla schermata del risultato).
## Prove ## Prove
`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata `npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
+23 -9
View File
@@ -86,6 +86,11 @@
<p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p> <p class="hint">La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».</p>
<p class="version"></p> <p class="version"></p>
</section> </section>
<section id="tutorial" class="overlay" hidden aria-labelledby="tutorial-title">
<h2 id="tutorial-title">Come si gioca</h2>
<video id="tutorial-video" controls playsinline preload="metadata"></video>
<button id="tutorial-close">Torna al gioco</button>
</section>
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti"> <section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
<h2>Ringraziamenti</h2> <h2>Ringraziamenti</h2>
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p> <p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
@@ -105,31 +110,34 @@
<ol id="choices" hidden></ol> <ol id="choices" hidden></ol>
<section id="scopa-controls" hidden aria-label="Comandi della scopa"> <section id="scopa-controls" hidden aria-label="Comandi della scopa">
<p id="scopa-status" role="status"></p> <p id="scopa-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea"> <div class="card-hand" aria-label="La mano di Andrea">
<button id="scopa-card-0" type="button"></button> <button id="scopa-card-0" type="button"></button>
<button id="scopa-card-1" type="button"></button> <button id="scopa-card-1" type="button"></button>
<button id="scopa-card-2" type="button"></button> <button id="scopa-card-2" type="button"></button>
</div> </div>
<div id="scopa-options" class="tressette-signals" hidden aria-label="Quale presa"></div> <div id="scopa-options" class="card-buttons" hidden aria-label="Quale presa"></div>
<button id="scopa-next" type="button" hidden>Prossima smazzata</button> <button id="scopa-next" type="button" hidden>Prossima smazzata</button>
<details><summary>Come si gioca</summary> <details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="scopa" hidden>Guarda il video</button>
<p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano <p>Si gioca una carta: se in tavola ce n’è una dello stesso valore la prendi; se no prendi le carte che sommano
il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10. il suo valore. Se la presa c’è, è obbligatoria; se sono più d’una, scegli tu. Asso 1, gobbo 8, donna 9, regio 10.
Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p> Chi pulisce la tavola fa scopa (non con l’ultima carta). Quel che resta alla fine va all’ultimo che ha preso.</p>
<p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di <p>A fine smazzata un punto per: più carte, più mattoni (con le napoletane i denari), il settebello (il 7 di
mattoni o di denari), la primiera (la carta migliore di ogni seme: 7, 6, Asso, 5, 4, 3, 2, figure), e uno per mattoni o di denari), la primiera, e uno per ogni scopa. A pari, il punto non va a nessuno.</p>
ogni scopa. Si va agli 11.</p> <p>Primiera: si somma la carta migliore di ogni seme (7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12,
figure 10); chi non ha almeno una carta per seme non la fa. Si va agli 11, contati a fine smazzata: vince chi ne
ha di più; pari, se ne fa un’altra.</p>
</details> </details>
</section> </section>
<section id="tressette-controls" hidden aria-label="Comandi del tressette"> <section id="tressette-controls" hidden aria-label="Comandi del tressette">
<p id="tressette-status" role="status"></p> <p id="tressette-status" role="status"></p>
<div class="tressette-signals" aria-label="Segni per il socio, solo quando esci"> <div class="card-buttons" aria-label="Segni per il socio, solo quando esci">
<button id="tressette-busso" type="button">Busso</button> <button id="tressette-busso" type="button">Busso</button>
<button id="tressette-striscio" type="button">Striscio</button> <button id="tressette-striscio" type="button">Striscio</button>
<button id="tressette-volo" type="button">Volo</button> <button id="tressette-volo" type="button">Volo</button>
<button id="tressette-next" type="button" hidden>Prossima smazzata</button> <button id="tressette-next" type="button" hidden>Prossima smazzata</button>
</div> </div>
<div class="tressette-hand" aria-label="La mano di Andrea"> <div class="card-hand-many" aria-label="La mano di Andrea">
<button id="tressette-card-0" type="button"></button> <button id="tressette-card-0" type="button"></button>
<button id="tressette-card-1" type="button"></button> <button id="tressette-card-1" type="button"></button>
<button id="tressette-card-2" type="button"></button> <button id="tressette-card-2" type="button"></button>
@@ -142,6 +150,7 @@
<button id="tressette-card-9" type="button"></button> <button id="tressette-card-9" type="button"></button>
</div> </div>
<details><summary>Come si gioca</summary> <details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="tressette" hidden>Guarda il video</button>
<p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra. <p>In quattro, a coppie: Andrea e il socio di fronte contro gli altri due. Si gira verso destra.
Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta Chi esce gioca quel che vuole, gli altri devono rispondere al seme se ce l’hanno. Prende la carta più alta
del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p> del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4. Chi prende esce.</p>
@@ -149,6 +158,8 @@
ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p> ci arriva per prima è fuori e ha vinto, anche a metà smazzata.</p>
<p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; <p>Accuse, dette da ognuno giocando la sua prima carta: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti;
tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p> tre Assi, tre 2 o tre 3 fanno 3 punti (si dice il seme che manca), quattro fanno 4.</p>
<p>Convenzioni di questo tavolo: il «fuori» ai 21 è automatico (un’accusa o una presa che ci porta fa vincere
subito); niente cappotto; i segni valgono anche alla prima presa.</p>
<p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme), <p>Segni, solo di chi esce: <b>busso</b> (prendi con la migliore e ritornami il seme),
<b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta. <b>striscio</b> (di questo seme ne ho altre), <b>volo</b> (era l’ultima). Prima scegli il segno, poi la carta.
Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p> Tastiera: B, S, V; frecce e 1–0 scelgono, Invio gioca, Esc lascia il tavolo.</p>
@@ -156,17 +167,18 @@
</section> </section>
<section id="briscola-controls" hidden aria-label="Comandi della briscola"> <section id="briscola-controls" hidden aria-label="Comandi della briscola">
<p id="briscola-status" role="status"></p> <p id="briscola-status" role="status"></p>
<div class="briscola-hand" aria-label="La mano di Andrea"> <div class="card-hand" aria-label="La mano di Andrea">
<button id="briscola-card-0" type="button"></button> <button id="briscola-card-0" type="button"></button>
<button id="briscola-card-1" type="button"></button> <button id="briscola-card-1" type="button"></button>
<button id="briscola-card-2" type="button"></button> <button id="briscola-card-2" type="button"></button>
</div> </div>
<details><summary>Come si gioca</summary> <details><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="briscola" hidden>Guarda il video</button>
<p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca. <p>Tocca una carta sul tavolo o il suo nome qui sotto per giocarla. Frecce o 1–2–3 scelgono; Invio gioca.
Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p> Esc lascia il tavolo. Vince chi fa almeno 61 punti; 60 a 60 è patta.</p>
<p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme <p>Non sei obbligato a rispondere al seme. Prende la briscola più alta, oppure la carta più alta del seme
di uscita. Ordine: Asso, 3, Re, Cavallo, Fante, 7, 6, 5, 4, 2. di uscita. Ordine: Asso, 3, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
Asso 11 punti, 3 dieci, Re 4, Cavallo 3, Fante 2; le altre zero.</p> Asso 11 punti, 3 dieci, regio 4, donna 3, gobbo 2; le altre zero. Fra parentesi i nomi delle napoletane.</p>
<p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo; <p>Chi prende pesca per primo e poi esce. La briscola scoperta è l’ultima carta del mazzo;
il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese. il contatore la comprende. Finito il mazzo, si giocano le carte rimaste e si contano le prese.
Durante la partita i punti non si dicono!</p> Durante la partita i punti non si dicono!</p>
@@ -183,6 +195,7 @@
<button id="bocce-throw" type="button">Lancia</button> <button id="bocce-throw" type="button">Lancia</button>
</div> </div>
<details id="bocce-help"><summary>Come si gioca</summary> <details id="bocce-help"><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="bocce" hidden>Guarda il video</button>
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia. <p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p> Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare. <p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
@@ -191,6 +204,7 @@
Vince chi fa più punti nella mano.</p> Vince chi fa più punti nella mano.</p>
</details> </details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary> <details id="biliardo-help" hidden><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla. <p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica La linea tratteggiata segue la tua biglia fino al primo urto, comprese le sponde; il cerchio vuoto indica
il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla il punto di contatto. Dopo l’urto, la freccia azzurra mostra la direzione dell’avversaria, quella gialla
+5 -1
View File
@@ -74,6 +74,8 @@ export function collect(c) {
} }
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' }); c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
c.step = c.hands.player.length ? 'play' : 'count'; c.step = c.hands.player.length ? 'play' : 'count';
// Il risultato è deciso all'ultima presa: il conteggio carta per carta lo mostra soltanto.
if (c.step === 'count') c.result = result(c.taken);
return true; return true;
} }
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano. // La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
@@ -195,5 +197,7 @@ export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…'; return last ? `${names[last.who]}: ${cardName(last.card)}, +${last.card.points}. Totale: ${counted(c)[last.who]}.` : 'Si contano le prese, una carta alla volta…';
} }
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`; if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} sceglie…`; // A parole anche quel che si vede sul tavolo: la briscola e la carta già giocata.
const table = `Briscola: ${c.trump}.${c.table.length ? ` In tavola: ${cardName(c.table[0].card)}.` : ''}`;
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} sceglie…`;
} }
+19 -2
View File
@@ -251,6 +251,20 @@ export class Game {
} }
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono. // Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
outcome(def, c) { return def[c.result.winner === 'player' ? 'win' : c.result.winner === 'cpu' ? 'lose' : 'draw'] ?? []; }
// Appena il risultato di un minigioco è deciso, i suoi flag (vittoria, partita giocata) valgono e si salvano:
// anche tornando al titolo dalla schermata del risultato, senza passare dall'uscita. Una volta sola.
settle(c) {
const def = this.story.closeups?.[c.id];
if (!c.result || c.settled || !def) return false;
c.settled = true;
for (const e of this.outcome(def, c)) {
if (e.set) { this.state.flags[e.set] = true; if (this.checkpoint) this.checkpoint.flags[e.set] = true; }
if (e.unset) { delete this.state.flags[e.unset]; if (this.checkpoint) delete this.checkpoint.flags[e.unset]; }
}
if (this.checkpoint) this.onSave(this.checkpoint);
return true;
}
closeupClose() { closeupClose() {
const c = this.closeup; const c = this.closeup;
if (!c) return false; if (!c) return false;
@@ -261,9 +275,11 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete); if (c.step === 'done') this.queue.unshift(...def.complete);
return true; return true;
} }
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. // I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
// di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) { if (MINIGAMES[def?.type]) {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? [])); this.settle(c);
this.queue.unshift(...(c.result ? this.outcome(def, c) : def.quit ?? []));
return true; return true;
} }
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`). // Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
@@ -409,6 +425,7 @@ export class Game {
const m = this.minigame(); const m = this.minigame();
if (m) { if (m) {
m.update(c, dt); m.update(c, dt);
this.settle(c);
// Il risultato resta leggibile finché il giocatore non esce. // Il risultato resta leggibile finché il giocatore non esce.
return; return;
} }
+45 -4
View File
@@ -146,7 +146,7 @@ export function advance(c) {
// La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte). // La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte).
export function observation(c, who = 'cpu') { export function observation(c, who = 'cpu') {
return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] }, return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] },
opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope } }; opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope }, lastTaker: c.lastTaker };
} }
// Le carte che non si sono viste: in mano all'altro o nel mazzo. // Le carte che non si sono viste: in mano all'altro o nel mazzo.
export function unseen(v) { export function unseen(v) {
@@ -159,7 +159,11 @@ export function unseen(v) {
export function score(v, card, captured, counting) { export function score(v, card, captured, counting) {
const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount; const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount;
const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card]; const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card];
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + (a.value === 7 ? 3 : a.value === 6 ? 1.2 : a.value === 1 ? 0.8 : 0); // La primiera vale per quanto migliora la propria: un 7 di un seme dove si ha già un 7 non aggiunge niente,
// la prima carta di un seme che manca aggiunge parecchio.
const mine = v.taken[v.who] ?? [], best = suit => Math.max(0, ...mine.filter(a => a.suit === suit).map(a => a.prime));
const prime = a => Math.max(0, a.prime - best(a.suit)) / 4 + (best(a.suit) ? 0 : 1);
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + prime(a);
let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6; let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6;
const sum = left.reduce((n, a) => n + a.value, 0); const sum = left.reduce((n, a) => n + a.value, 0);
if (left.length && sum <= 10 && v.opponentCount) { if (left.length && sum <= 10 && v.opponentCount) {
@@ -181,11 +185,46 @@ export function moves(v) {
}); });
} }
// «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più. // «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più.
// Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 92% contro «Alla bona», 64% contro «Seria», 52% contro «A fassi male». // Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 96% contro «Alla bona», 76% contro «Seria», 50% contro «A fassi male».
// A mazzo finito le carte dell'altro sono proprio quelle non viste: si calcola il finale esatto, fino al conteggio
// (carte, mattoni, settebello, primiera, scope, e la tavola all'ultimo che prende).
export function endgame(v) {
const me = v.who, them = other(me), memo = new Map();
const final = (taken, scope, table, last) => {
const all = { player: [...taken.player], cpu: [...taken.cpu] };
if (last) all[last].push(...table);
const t = tally(all, scope, v.deck);
return t.points[me] - t.points[them];
};
const solve = (hands, table, taken, scope, last, turn) => {
if (!hands.player.length && !hands.cpu.length) return final(taken, scope, table, last);
const key = `${turn}|${hands[me].map(a => a.id)}|${hands[them].map(a => a.id)}|${table.map(a => a.id).sort()}|${last}|${taken.player.length}|${scope.player}${scope.cpu}`;
if (memo.has(key)) return memo.get(key);
let best = turn === me ? -Infinity : Infinity;
for (const m of moves({ hand: hands[turn], table })) {
const r = step(hands, table, taken, scope, last, turn, m);
const value = solve(...r);
best = turn === me ? Math.max(best, value) : Math.min(best, value);
}
memo.set(key, best); return best;
};
const step = (hands, table, taken, scope, last, turn, m) => {
const card = hands[turn][m.index], rest = { ...hands, [turn]: hands[turn].filter((_, i) => i !== m.index) };
if (!m.captured) return [rest, [...table, card], taken, scope, last, other(turn)];
const ids = new Set(m.captured.map(a => a.id)), left = table.filter(a => !ids.has(a.id));
const end = !rest.player.length && !rest.cpu.length;
return [rest, left, { ...taken, [turn]: [...taken[turn], card, ...m.captured] },
!left.length && !end ? { ...scope, [turn]: scope[turn] + 1 } : scope, turn, other(turn)];
};
const hands = { [me]: v.hand, [them]: unseen(v) };
const start = [hands, v.table, v.taken, v.scope ?? { player: 0, cpu: 0 }, v.lastTaker ?? null, me];
return moves(v).map(m => ({ m, value: solve(...step(...start, m)) })).reduce((a, b) => b.value > a.value ? b : a).m;
}
export function choose(v, level = 'seria', seed = 1) { export function choose(v, level = 'seria', seed = 1) {
const all = moves(v); const all = moves(v);
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)]; if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
const counting = level === 'male'; const counting = level === 'male';
if (counting && !v.stock && unseen(v).length === v.opponentCount) return endgame(v);
return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a); return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a);
} }
export function update(c, dt) { export function update(c, dt) {
@@ -206,5 +245,7 @@ export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
const l = c.last; const l = c.last;
return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`; return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`;
} }
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} pensa…`; // A parole anche la tavola, per chi non vede il disegno.
const table = `In tavola: ${c.table.map(cardName).join(', ') || 'niente'}.`;
return c.current === 'player' ? `${table} Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.` : `${table} ${names.cpu} pensa…`;
} }
+3 -2
View File
@@ -46,8 +46,9 @@ function scoreboard(ctx, c) {
function accuses(ctx, c, names) { function accuses(ctx, c, names) {
// Le accuse dette restano in vista per tutta la smazzata. // Le accuse dette restano in vista per tutta la smazzata.
if (!c.accuse.length) return; if (!c.accuse.length) return;
panel(ctx, 456, 34, 158, 12 + c.accuse.length * 11); // Largo abbastanza per «Il giocatore: tre Assi, manca mattoni +3» (200 px nel carattere a 5).
c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 463, 41 + i * 11, 5, a.who % 2 ? theirs : ours)); panel(ctx, 396, 34, 218, 12 + c.accuse.length * 11);
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) { function trick(ctx, c, names) {
c.table.forEach((p, i) => { c.table.forEach((p, i) => {
+27 -13
View File
@@ -111,6 +111,8 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign
if (!c.tricks.length && c.pending[who].length) { if (!c.tricks.length && c.pending[who].length) {
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c); c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
c.events.push({ kind: 'accusa', who }); c.events.push({ kind: 'accusa', who });
// Un'accusa che porta ai 21 fa uscire subito: è il primo evento decisivo, prima ancora della carta.
if (out(c)) return true;
} }
const [card] = c.hands[who].splice(index, 1); 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 }); c.t = 0; c.landing = true;
@@ -132,19 +134,24 @@ export function collect(c) {
c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = []; c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' }); c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
recount(c); recount(c);
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata (una presa porta punti a una if (out(c, end ? team(winner) : null)) return true;
// coppia sola, quindi non ci arrivano mai insieme). if (end) { summarize(c, team(winner)); c.step = 'smazzata'; return true; }
const out = [0, 1].find(t => c.score[t] >= TARGET);
if (end || out !== undefined) {
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
lastTeam: end ? team(winner) : null, score: [...c.score], out: out ?? null };
if (out !== undefined) { c.step = 'result'; c.result = { winner: out === 0 ? 'player' : 'cpu', score: [...c.score] }; }
else c.step = 'smazzata';
return true;
}
c.step = 'play'; c.step = 'play';
return true; return true;
} }
function summarize(c, lastTeam = null) {
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
lastTeam, score: [...c.score] };
}
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata. Il controllo si fa a ogni evento che
// porta punti (un'accusa, una presa), e ognuno ne porta a una coppia sola: non ci si arriva mai insieme.
function out(c, lastTeam = null) {
const t = [0, 1].find(t => c.score[t] >= TARGET);
if (t === undefined) return false;
summarize(c, lastTeam); c.summary.out = t;
c.step = 'result'; c.result = { winner: t === 0 ? 'player' : 'cpu', score: [...c.score] };
return true;
}
export const finished = c => c.step === 'result'; export const finished = c => c.step === 'result';
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo. // Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) { export function advance(c) {
@@ -154,7 +161,8 @@ export function advance(c) {
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo. // La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
export function observation(c, seat) { export function observation(c, seat) {
return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })), // Anche il punteggio e i terzi presi: sono sul tabellone, li vede chiunque.
return { seat, deck: c.deck, hand: [...c.hands[seat]], table: c.table.map(p => ({ ...p })), score: [...c.score], thirds: [...c.thirds],
tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })), tricks: c.tricks.map(t => ({ ...t, cards: t.cards.map(p => ({ ...p })) })),
accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) }; accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
} }
@@ -218,8 +226,14 @@ function follow(v, k, moves) {
const over = cards.filter(a => a.power > top.card.power); const over = cards.filter(a => a.power > top.card.power);
if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a)); if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a));
} }
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). // Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). Se un
if (mate && !k.beatable(top.card, after)) return pick(rich(cards)); // carico basta per arrivare ai 21, si dà quello, anche il 2 o il 3: si esce e si vince.
if (mate && !k.beatable(top.card, after)) {
const t = team(v.seat), last = v.hand.length === 1, before = v.score?.[t] ?? 0, thirds = v.thirds?.[t] ?? 0;
const sum = v.table.reduce((n, p) => n + p.card.thirds, 0) + (last ? 3 : 0);
const wins = cards.filter(a => before - Math.floor(thirds / 3) + Math.floor((thirds + sum + a.thirds) / 3) >= TARGET);
return pick(wins.length ? byMin(wins, a => a.power) : rich(cards));
}
if (cards[0].suit === top.card.suit) { if (cards[0].suit === top.card.suit) {
const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after)); const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
if (safe.length) return pick(byMin(safe, a => a.power)); if (safe.length) return pick(byMin(safe, a => a.power));
+62 -13
View File
@@ -289,6 +289,38 @@ function closePause() {
running = true; running = true;
} }
$('pause-button').addEventListener('click', openPause); $('pause-button').addEventListener('click', openPause);
// I video «Come si gioca» (story.tutorials): il gioco si ferma come in pausa, la musica tace, il video ha il suo audio.
function openTutorial(kind) {
const t = story.tutorials?.[kind];
if (!t || !running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
$('tutorial-title').textContent = t.title;
const video = $('tutorial-video');
video.src = versioned(t.src);
if (t.poster) video.poster = versioned(t.poster);
video.muted = music.muted;
$('tutorial').hidden = false;
video.play().catch(() => {});
$('tutorial-close').focus();
}
function closeTutorial() {
const video = $('tutorial-video');
video.pause(); video.removeAttribute('src'); video.load();
$('tutorial').hidden = true;
music.hold = false;
voices.resume();
idleSince = performance.now();
running = true;
}
for (const button of document.querySelectorAll('.tutorial-open')) {
button.hidden = !story.tutorials?.[button.dataset.tutorial];
button.addEventListener('click', () => openTutorial(button.dataset.tutorial));
}
$('tutorial-close').addEventListener('click', closeTutorial);
// Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello; // Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello;
// la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi). // la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi).
function leave(hidden) { function leave(hidden) {
@@ -410,11 +442,10 @@ canvas.addEventListener('pointerdown', e => {
if (game.skip()) return; if (game.skip()) return;
// Tressette e scopa: fra una smazzata e l'altra un tocco va avanti. // Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
if (MINI[type].advance?.(game.closeup)) return; if (MINI[type].advance?.(game.closeup)) return;
// Scopa, più prese possibili: si tocca una carta in tavola; se sta in una presa sola, si prende quella. // Scopa, più prese possibili: si tocca una carta in tavola (si decide al rilascio, vedi pointerup).
if (type === 'scopa' && game.closeup.step === 'choose') { if (type === 'scopa' && game.closeup.step === 'choose') {
const c = game.closeup, card = c.table[scopaTableAt(c, scenePoint(e))]; press = { table: scopaTableAt(game.closeup, scenePoint(e)), state: game.closeup };
const options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []); canvas.setPointerCapture(e.pointerId);
if (options.length === 1) scopa.take(c, options[0]); else if (options.length) scopa.select(c, options[0]);
return; return;
} }
press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck }; press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
@@ -437,6 +468,16 @@ canvas.addEventListener('pointerdown', e => {
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (!press) return; if (!press) return;
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
if (press.table !== undefined) {
const p = press; press = null;
const c = game.closeup;
if (!running || c !== p.state || c.step !== 'choose' || p.table < 0 || scopaTableAt(c, scenePoint(e)) !== p.table) return;
const card = c.table[p.table], options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []);
if (options.includes(c.choice.selected)) scopa.take(c); else if (options.length) scopa.select(c, options[0]);
return;
}
if (press.card !== undefined) { if (press.card !== undefined) {
const p = press; press = null; const p = press; press = null;
if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card); if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
@@ -454,7 +495,7 @@ canvas.addEventListener('pointerup', e => {
press = null; press = null;
}); });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined) { press = null; game?.closeupCancel(); } }); canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -489,6 +530,7 @@ for (let i = 0; i < 3; i++) {
button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); }); button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); });
} }
$('scopa-options').addEventListener('focusin', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.step === 'choose') scopa.select(game.closeup, Number(k)); });
$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); }); $('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); });
$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); }); $('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); });
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -586,6 +628,7 @@ window.addEventListener('keydown', e => {
return; return;
} }
if (debugKey(e)) return; if (debugKey(e)) return;
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; } if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; }
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; } if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; } if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
@@ -595,7 +638,9 @@ window.addEventListener('keydown', e => {
if (story.closeups[game.closeup.id].type === 'scopa') { if (story.closeups[game.closeup.id].type === 'scopa') {
const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase(); const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase();
if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; } if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; }
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key)) return; // Sui pulsanti delle carte e delle prese Invio e Spazio valgono per quella scelta (una sola fonte: lo stato).
const choice = e.target.id?.startsWith('scopa-card-') || e.target.dataset?.option !== undefined;
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key) && !choice) return;
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) { if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) {
e.preventDefault(); e.preventDefault();
if (e.repeat || game.skip()) return; if (e.repeat || game.skip()) return;
@@ -603,9 +648,10 @@ window.addEventListener('keydown', e => {
else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1); else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose(); else if (c.step === 'result') game.closeupClose();
else if (c.step === 'choose') scopa.take(c); else if (c.step === 'choose') scopa.take(c);
else if (!scopa.advance(c) && key === 'enter') game.closeupPlay(); else if (!scopa.advance(c)) game.closeupPlay();
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta. // Il fuoco segue la scelta, così quel che si vede scelto è quel che parte.
if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus(); if (inControls && c.step === 'choose') $('scopa-options').querySelector(`[data-option="${c.choice.selected}"]`)?.focus();
else if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus();
return; return;
} }
// Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo. // Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo.
@@ -616,7 +662,10 @@ window.addEventListener('keydown', e => {
if (story.closeups[game.closeup.id].type === 'tressette') { if (story.closeups[game.closeup.id].type === 'tressette') {
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup; const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; } if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key)) return; // Sui pulsanti della mano Invio e Spazio giocano la carta scelta (anche se se ne è scelta una che non si
// può giocare: allora si legge perché), mai quella che aveva il fuoco prima.
const card = e.target.id?.startsWith('tressette-card-');
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key) && !card) return;
const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key]; const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key];
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) { if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) {
e.preventDefault(); e.preventDefault();
@@ -625,7 +674,7 @@ window.addEventListener('keydown', e => {
else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10); else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10);
else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1); else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose(); else if (c.step === 'result') game.closeupClose();
else if (!tressette.advance(c) && key === 'enter') game.closeupPlay(); else if (!tressette.advance(c)) game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta. // Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus(); if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus();
return; return;
@@ -638,7 +687,7 @@ window.addEventListener('keydown', e => {
const inControls = e.target.closest?.('#briscola-controls'); const inControls = e.target.closest?.('#briscola-controls');
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; } if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return; if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return;
if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && e.key === 'Enter' && briscola.canPlay(game.closeup)) return; if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && ['Enter', ' '].includes(e.key) && briscola.canPlay(game.closeup)) return;
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
e.preventDefault(); e.preventDefault();
if (e.repeat || game.skip()) return; if (e.repeat || game.skip()) return;
@@ -646,7 +695,7 @@ window.addEventListener('keydown', e => {
if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1); if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1); else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose(); else if (c.step === 'result') game.closeupClose();
else if (e.key === 'Enter') game.closeupPlay(); else game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus(); if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
return; return;
} }
+1 -1
View File
@@ -2161,7 +2161,7 @@ export const story = {
nodes: { nodes: {
start: { options: [ start: { options: [
{ text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] }, { text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] }, { text: 'Rivincita a briscola?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
{ text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] }, { text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] },
{ text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] }, { text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] },
{ text: 'Ora cerco il palio. A dopo.', next: 'end' }, { text: 'Ora cerco il palio. A dopo.', next: 'end' },
+18 -13
View File
@@ -28,21 +28,21 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#scene.bocce { touch-action: none; } #scene.bocce { touch-action: none; }
#scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } #scopa-controls, #tressette-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
#scopa-status, #tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; } #scopa-status, #tressette-status { color: #f3dcac; min-height: 2em; margin: 0 0 8px; }
.tressette-signals, .tressette-hand { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } .card-buttons, .card-hand-many { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tressette-signals button, .tressette-hand button { flex: 1 1 18%; overflow-wrap: anywhere; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; } .card-buttons button, .card-hand-many button { flex: 1 1 18%; overflow-wrap: anywhere; min-width: 0; min-height: 40px; padding: 6px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; line-height: 1.5; cursor: pointer; }
.tressette-signals button { flex-basis: 22%; } .card-buttons button { flex-basis: 22%; }
.tressette-signals button[aria-pressed="true"], .tressette-hand button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; } .card-buttons button[aria-pressed="true"], .card-hand-many button[aria-pressed="true"] { border-color: #ffdb76; color: #ffdb76; }
.tressette-signals button:disabled, .tressette-hand button:disabled { opacity: .5; cursor: default; } .card-buttons button:disabled, .card-hand-many button:disabled { opacity: .5; cursor: default; }
#scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; } #scopa-controls summary, #tressette-controls summary { cursor: pointer; padding: 8px 0; }
#scopa-controls p, #tressette-controls p { max-width: 76em; } #scopa-controls p, #tressette-controls p { max-width: 76em; }
#scopa-next { width: 100%; min-height: 40px; margin-bottom: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; cursor: pointer; } #scopa-next { width: 100%; min-height: 40px; margin-bottom: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 9px; cursor: pointer; }
.briscola-hand + .tressette-signals { margin-top: 8px; } .card-hand + .card-buttons { margin-top: 8px; }
#briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; } #briscola-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; line-height: 1.7; }
#briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; } #briscola-status { color: #f3dcac; min-height: 2em; margin: 0 0 10px; }
.briscola-hand { display: flex; gap: 8px; } .card-hand { display: flex; gap: 8px; }
.briscola-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; } .card-hand button { flex: 1; min-width: 0; min-height: 48px; padding: 8px; background: #38453c; color: #fff0cd; border: 2px solid #897b57; font-size: 10px; line-height: 1.7; cursor: pointer; }
.briscola-hand button[aria-pressed="true"] { border-color: #ffdb76; } .card-hand button[aria-pressed="true"] { border-color: #ffdb76; }
.briscola-hand button:disabled { opacity: .5; cursor: default; } .card-hand button:disabled { opacity: .5; cursor: default; }
#briscola-controls summary { cursor: pointer; padding: 10px 0; } #briscola-controls summary { cursor: pointer; padding: 10px 0; }
#briscola-controls p { max-width: 76em; } #briscola-controls p { max-width: 76em; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; } #bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
@@ -200,6 +200,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em; #map-button, #pause-button, #notebook-button, #spots-button { font: bold clamp(8.64px, 1.15vw, 11.52px) var(--font); padding: .35em .8em;
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; } color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); } #pause { z-index: 3; background: rgba(15, 10, 20, .9); }
/* Il player dei video «Come si gioca»: sopra a tutto, il video grande quanto può. */
#tutorial { z-index: 4; background: rgba(10, 8, 14, .96); padding: 8px; }
#tutorial video { max-width: 100%; max-height: calc(100% - 7em); background: #000; }
.tutorial-open { display: block; margin: 4px 0 8px; min-height: 36px; padding: 6px 12px; background: #4a3a1c; color: #ffe09b; border: 2px solid #c8a24a; font: inherit; cursor: pointer; }
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); } .pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
#pause .hint { color: var(--dim); max-width: 34em; text-align: center; } #pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); } .load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
@@ -279,7 +283,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } } @media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
/* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti, /* Telefono in verticale: la scena è bassa e la Pausa non ci sta. Prende tutto lo schermo, così il cursore, i pulsanti,
la spiegazione e la versione non finiscono sopra i verbi. */ la spiegazione e la versione non finiscono sopra i verbi. */
@media (orientation: portrait) and (max-width: 700px) { #pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } } @media (orientation: portrait) and (max-width: 700px) { #tutorial { position: fixed; inset: 0; z-index: 10; }
#pause { position: fixed; inset: 0; z-index: 10; overflow-y: auto; background: rgba(15, 10, 20, .97); } }
/* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */ /* Schermi stretti: il carattere a pixel è largo, i verbi vanno sopra l'inventario e su due righe da quattro. */
@media (max-width: 600px) { @media (max-width: 600px) {
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; } #panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
@@ -295,8 +300,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); } #stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; } #briscola-controls, #tressette-controls, #scopa-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */ /* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
.tressette-hand button, .tressette-signals button { flex-basis: 40%; overflow-wrap: anywhere; } .card-hand-many button, .card-buttons button { flex-basis: 40%; overflow-wrap: anywhere; }
.briscola-hand { flex-direction: column; gap: 4px; } .card-hand { flex-direction: column; gap: 4px; }
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } #side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 10.08px; } #sentence { font-size: 10.08px; }
/* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */ /* Il fumetto: più largo e più piccolo, sennò una battuta lunga diventa una colonna alta quanto la scena. */
+1 -1
View File
@@ -160,7 +160,7 @@ try {
await page.evaluate(async () => { await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game); const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'giocatore_carte'); settle(game);
}); });
await page.locator('#choices li', { hasText: 'La rivincita?' }).click(); await page.locator('#choices li', { hasText: 'Rivincita a briscola?' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
await page.locator('#choices li', { hasText: 'Con le toscane.' }).click(); await page.locator('#choices li', { hasText: 'Con le toscane.' }).click();
await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game)); await page.evaluate(async () => (await import('/tests/helpers.mjs')).settle(game));
+24 -2
View File
@@ -60,7 +60,7 @@ test('l’ultima pescata è la briscola, poi tre prese senza mazzo; totale e con
} }
assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20); assert.ok(lastDraw); assert.equal(c.step, 'count'); assert.equal(c.history.length, 20);
assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0); assert.equal(c.hands.player.length + c.hands.cpu.length, 0); assert.equal(c.stock.length, 0);
assert.equal(c.result, undefined, 'risultato solo dopo il conteggio'); assert.equal(c.result.player + c.result.cpu, 120, 'il risultato è già deciso: il conteggio lo mostra soltanto');
assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40); assert.equal(new Set([...c.taken.player, ...c.taken.cpu].map(a => a.id)).size, 40);
assert.deepEqual(counted(c), { player: 0, cpu: 0 }); assert.deepEqual(counted(c), { player: 0, cpu: 0 });
update(c, 0.36); assert.equal(c.count, 1); update(c, 0.36); assert.equal(c.count, 1);
@@ -165,7 +165,7 @@ test('oratorio: dialogo, livelli, abbandono, risultati, rivincita e salvataggio
g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win'); g.closeupClose(); settle(g); assert.equal(!!g.state.flags.briscola_vinta, outcome === 'win');
assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit'); assert.equal(!!g.state.flags.briscola_giocata, outcome !== 'quit');
g.act('talk', 'giocatore_carte'); settle(g); g.act('talk', 'giocatore_carte'); settle(g);
assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'La rivincita?'); assert.equal(g.options()[0].text, outcome === 'quit' ? 'Una briscola?' : 'Rivincita a briscola?');
assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press); assert.ok(MINIGAMES.bocce.press && MINIGAMES.biliardo.press);
} }
}); });
@@ -181,3 +181,25 @@ test('due mazzi, stesse regole: le napoletane cambiano solo i nomi', () => {
// L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane. // L'avversario ragiona sul mazzo giusto: le carte non viste sono napoletane.
assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit))); assert.ok(unseen(observation(n)).every(a => DECKS.napoletane.suits.includes(a.suit)));
}); });
// Codex (revisione dei tre giochi): l'esito vale appena è deciso. Uscire durante il conteggio della briscola non lo
// perde, e i flag finiscono nel salvataggio anche se dal risultato si torna al titolo senza uscire dal tavolo.
test('carte all\'oratorio: l\'esito si salva appena deciso, e uscire durante il conteggio non lo perde', () => {
const games = [['Una briscola?', 'briscola', 'briscola_vinta'], ['Un tressette ai 21?', 'tressette', 'tressette_vinto'], ['Una scopa?', 'scopa', 'scopa_vinta']];
for (const [ask, id, flag] of games) {
const saved = [], g = new Game(story, { onSave: s => saved.push(s) });
settle(g); g.go('oratorio'); settle(g);
g.act('talk', 'giocatore_carte'); settle(g);
for (const text of [ask, 'Con le toscane.', 'Seria.']) { g.choose(g.options().findIndex(o => o.text === text)); settle(g); }
assert.equal(g.closeup?.id, id);
// La briscola durante il conteggio: il risultato c'è già. Gli altri due, a risultato.
Object.assign(g.closeup, id === 'briscola' ? { step: 'count', result: { winner: 'player', player: 70, cpu: 50 } } : { step: 'result', result: { winner: 'player' } });
g.update(0.01);
assert.equal(saved.at(-1).flags[flag], true, `${id}: la vittoria è già nel salvataggio`);
assert.equal(g.state.flags[flag], true);
g.closeupClose(); const said = settle(g).map(s => s.text);
const win = story.closeups[id].win.find(e => e.say).say, quit = story.closeups[id].quit[0].say;
assert.ok(said.includes(win) && !said.includes(quit), `${id}: esce con la battuta della vittoria, non con quella di chi lascia`);
assert.equal(g.state.flags[flag], true);
}
});
+6 -2
View File
@@ -90,13 +90,17 @@ try {
assert.equal(await page.evaluate(() => game.closeup.step), 'choose'); assert.equal(await page.evaluate(() => game.closeup.step), 'choose');
await page.waitForSelector('#scopa-options:not([hidden])'); await page.waitForSelector('#scopa-options:not([hidden])');
await page.screenshot({ path: `${out}/scopa-${name}-scelta.png` }); await page.screenshot({ path: `${out}/scopa-${name}-scelta.png` });
if (mode === 'keyboard') { await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); } // Tastiera (Codex): fuoco sulla prima presa, freccia a destra, Invio: deve partire la seconda.
if (mode === 'keyboard') { await page.locator('#scopa-options button:first-child').focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); }
else if (mode === 'touch') { else if (mode === 'touch') {
// Il 5 in tavola sta solo nella presa 2+5: toccarlo la sceglie. // Il 5 in tavola sta solo nella presa 2+5.
const p = await page.evaluate(async () => { const p = await page.evaluate(async () => {
const { tableXY } = await import('/src/engine/scopa-render.js'), c = game.closeup; const { tableXY } = await import('/src/engine/scopa-render.js'), c = game.closeup;
const i = c.table.findIndex(a => a.value === 5), [x, y] = tableXY(c.table.length, i); return [x + 21, y + 30]; const i = c.table.findIndex(a => a.value === 5), [x, y] = tableXY(c.table.length, i); return [x + 21, y + 30];
}); });
// Il primo tocco sceglie la presa che lo contiene (evidenziata), il secondo la conferma.
await at(page, mode, p);
assert.equal(await page.evaluate(() => game.closeup.step === 'choose' && game.closeup.choice.selected), 1);
await at(page, mode, p); await at(page, mode, p);
} else await tap(page, mode, '#scopa-options button:nth-child(2)'); } else await tap(page, mode, '#scopa-options button:nth-child(2)');
assert.equal(await page.evaluate(() => game.closeup.step), 'take'); assert.equal(await page.evaluate(() => game.closeup.step), 'take');
+17
View File
@@ -101,6 +101,23 @@ test('l\'avversario prende il settebello e la scopa quando può, e non lascia sc
const d = choose(x, 'male'); assert.equal(x.hand[d.index].rank, 'Regio', 'col 5 lascerebbe 7 in tavola: meglio il regio (12)'); const d = choose(x, 'male'); assert.equal(x.hand[d.index].rank, 'Regio', 'col 5 lascerebbe 7 in tavola: meglio il regio (12)');
}); });
test('la primiera conta quel che migliora: col 6 si prende quello del seme dove si ha solo una figura (Codex)', () => {
const v = { who: 'cpu', deck: 'toscane', hand: [card('picche', '6')], table: [card('cuori', '6'), card('fiori', '6')],
taken: { player: [], cpu: [card('cuori', '7'), card('fiori', 'Regio')] }, opponentCount: 3, stock: 20, scope: { player: 0, cpu: 0 } };
for (const level of ['seria', 'male']) assert.equal(choose(v, level).captured[0].suit, 'fiori', level);
});
test('a mazzo finito «A fassi male» calcola il finale esatto: meglio perdere il settebello che regalare anche la scopa', () => {
const deck = DECK_CARDS.toscane, seen = [card('mattoni', '7'), card('cuori', '7'), card('fiori', '4'), card('picche', '3')];
// Andrea ha in mano l'ultimo 7. Posando il 4, Andrea prende 3+4 e fa scopa, e il settebello gli resta comunque;
// posando il settebello, Andrea lo prende col 7 ma niente scopa.
const v = { who: 'cpu', deck: 'toscane', hand: [card('fiori', '4'), card('mattoni', '7')], table: [card('picche', '3')], stock: 0,
taken: { player: deck.filter(a => !seen.includes(a)).slice(0, 18), cpu: deck.filter(a => !seen.includes(a)).slice(18) },
opponentCount: 1, scope: { player: 0, cpu: 0 }, lastTaker: 'player' };
assert.deepEqual(unseen(v).map(a => a.id), [card('cuori', '7').id], 'le carte dell\'altro sono quelle non viste');
assert.equal(v.hand[choose(v, 'male').index].rank, '7');
});
test('taratura: Alla bona < Seria < A fassi male', () => { test('taratura: Alla bona < Seria < A fassi male', () => {
const runs = calibrate(60); const runs = calibrate(60);
assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs)); assert.ok(runs[0].playerPercent > runs[1].playerPercent, JSON.stringify(runs));
+6
View File
@@ -94,6 +94,12 @@ try {
}); });
if (bad >= 0) { if (bad >= 0) {
const held = await page.evaluate(() => game.closeup.hands[0].length); const held = await page.evaluate(() => game.closeup.hands[0].length);
// Tastiera (Codex): fuoco su una carta buona, poi si sceglie col numero quella che non risponde e
// Invio. Non deve partire la carta che aveva il fuoco.
if (mode === 'keyboard') {
const good = await page.evaluate(async () => (await import('/src/engine/tressette.js')).legal(game.closeup.hands[0], game.closeup.table)[0]);
await page.locator(`#tressette-card-${good}`).focus();
}
await playCard(page, mode, bad); await playCard(page, mode, bad);
assert.match(await page.evaluate(() => game.closeup.refused), /Devi rispondere/); assert.match(await page.evaluate(() => game.closeup.refused), /Devi rispondere/);
await page.waitForFunction(() => /Devi rispondere/.test(document.getElementById('tressette-status').textContent)); await page.waitForFunction(() => /Devi rispondere/.test(document.getElementById('tressette-status').textContent));
+15 -2
View File
@@ -91,8 +91,12 @@ test('si va ai 21: chi ci arriva per primo è fuori, anche a metà smazzata, e l
d.pending = [[], [{ who: 1, kind: 'napoletana', suit: 'fiori', points: 3, cards: [] }], [], []]; d.pending = [[], [{ who: 1, kind: 'napoletana', suit: 'fiori', points: 3, cards: [] }], [], []];
d.hands[1] = [card('fiori', '7'), ...d.hands[1].slice(1)]; d.hands[1] = [card('fiori', '7'), ...d.hands[1].slice(1)];
assert.ok(play(d, 0, 1)); assert.equal(d.score[1], 22); assert.ok(play(d, 0, 1)); assert.equal(d.score[1], 22);
for (const seat of [2, 3, 0]) { d.current = seat; play(d, choose(observation(d, seat), 'seria').index, seat); } assert.equal(d.step, 'result', 'l\'accusa fa uscire subito'); assert.equal(d.result.winner, 'cpu');
assert.equal(d.step, 'trick'); collect(d); assert.equal(d.step, 'result'); assert.equal(d.result.winner, 'cpu'); // Tutte e due a un passo, tutte e due con un'accusa: esce chi accusa per primo, chiunque sia (Codex, seme 28).
const e = newTressette(28); e.base = [18, 18]; e.score = [18, 18]; e.current = 1; e.table = []; e.tricks = [];
e.pending = [[{ who: 0, kind: 'buongioco', rank: '3', points: 3, cards: [] }], [{ who: 1, kind: 'buongioco', rank: '2', points: 3, cards: [] }], [], []];
assert.ok(play(e, 0, 1)); assert.equal(e.result.winner, 'cpu'); assert.deepEqual(e.score, [18, 21]);
assert.equal(play(e, 0, 2), false, 'a partita finita non si gioca più');
}); });
test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => { test('partite intere deterministiche col seme; i due mazzi danno le stesse carte', () => {
@@ -146,6 +150,15 @@ test('chi riceve il busso prende con la migliore; il socio non spreca il 3 su un
assert.equal(d.hands[3][choose(observation(d, 3), 'male').index].rank, '5'); assert.equal(d.hands[3][choose(observation(d, 3), 'male').index].rank, '5');
}); });
test('se un carico al socio basta per i 21, si dà anche il 2', () => {
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 3;
c.base = [0, 20]; c.score = [0, 20]; c.thirds = [0, 1];
c.table = [{ who: 0, card: card('cuori', '4') }, { who: 1, card: card('cuori', '3') }, { who: 2, card: card('cuori', '5') }];
c.hands[3] = hand([['fiori', '2'], ['picche', '4']]);
assert.equal(c.hands[3][choose(observation(c, 3), 'seria').index].rank, '2');
assert.equal(c.hands[3][choose(observation(c, 3), 'male').index].rank, '2');
});
test('chi vola ha finito il seme: per «A fassi male» non può più avere il 3', () => { test('chi vola ha finito il seme: per «A fassi male» non può più avere il 3', () => {
const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 1; const c = newTressette(8); c.pending = [[], [], [], []]; c.current = 1;
c.tricks = [{ leader: 2, winner: 0, cards: [{ who: 2, card: card('cuori', '4'), signal: 'volo' }, { who: 3, card: card('cuori', '5') }, { who: 0, card: card('cuori', '6') }, { who: 1, card: card('cuori', '7') }] }]; c.tricks = [{ leader: 2, winner: 0, cards: [{ who: 2, card: card('cuori', '4'), signal: 'volo' }, { who: 3, card: card('cuori', '5') }, { who: 0, card: card('cuori', '6') }, { who: 1, card: card('cuori', '7') }] }];
+58
View File
@@ -0,0 +1,58 @@
# Video «Come si gioca» — briscola, tressette ai 21, scopa, bocce, biliardo (scaletta)
Cinque clip brevi, una per gioco, con voce italiana (XTTS) e sottotitoli impressi. Si guardano dentro il gioco:
un pulsante «Guarda il video» nel «Come si gioca» di ogni gioco apre un player con l'audio.
Le mani sono preparate dal copione (semi fissi), così ogni regola si vede nel momento giusto.
## 1. Briscola (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Oratorio, Andrea parla al giocatore di carte, sceglie toscane e «Seria» | Dove si gioca, mazzo e livello |
| 2 | Il tavolo: la mano, la briscola sotto il mazzo, il mazzo | Tre carte a testa, la briscola comanda |
| 3 | Una presa col seme, una presa con la briscola | Chi prende: la più alta del seme, o la briscola |
| 4 | Liscio su un carico, poi un carico preso con una briscolina | Asso 11, 3 dieci, regio 4, donna 3, gobbo 2 |
| 5 | Fine mazzo e conteggio carta per carta | Si vince con 61, 60 a 60 è patta |
## 2. Tressette ai 21 (circa 3 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Un tressette ai 21?», i quattro al tavolo | In quattro a coppie, il socio di fronte |
| 2 | Una presa in cui si risponde al seme, una carta rifiutata | Obbligo di seme; ordine 3, 2, Asso, figure |
| 3 | Un'accusa scritta in alto | Napoletana, tre Assi/2/3, quattro |
| 4 | Andrea esce con «Striscio», poi «Busso» e il socio che ritorna il seme, poi un «Volo» | I segni, coi pulsanti o B, S, V |
| 5 | Riepilogo di smazzata e il «fuori» ai 21 | Terzi, ultima presa, si va fuori ai 21 |
## 3. Scopa (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | «Una scopa?», il tavolo con quattro carte | Valori: Asso 1 … regio 10 |
| 2 | Una presa della carta uguale, una presa per somma | La carta uguale viene prima delle somme |
| 3 | Due prese possibili: Andrea sceglie | Se le prese sono più d'una, scegli tu |
| 4 | Una scopa | Pulire la tavola fa un punto |
| 5 | Riepilogo: carte, mattoni, settebello, primiera, scope | Agli 11 |
## 4. Bocce al pallaio (circa 2 minuti)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Brento Sanico, Andrea parla con Mariano, sceglie il livello | Dove si gioca e i livelli |
| 2 | Mira toccando il campo e il dettaglio del pallino, il cerchio azzurro | Come si mira |
| 3 | La forza col cursore e «Lancia»; poi tenere premuto e lasciare | Due modi di tirare, anche frecce e Spazio |
| 4 | Una boccia che si accosta, una bocciata di Mariano | Tira chi non ha il punto |
| 5 | Fine mano e punti | Tre bocce a testa, ogni boccia più vicina della migliore avversaria fa un punto |
## 5. Biliardo, cinque birilli (circa 2 minuti e mezzo)
| # | Cosa si vede | Voce |
|---|---|---|
| 1 | Circolo 4 Maggio, l'Ing. Tozzi, il livello | Dove si gioca |
| 2 | Il tavolo: le due biglie, il pallino, il castello dei birilli | Si colpisce la propria biglia e la si manda sull'altra |
| 3 | La mira: la linea tratteggiata, le frecce dopo l'urto; clic che fissa la mira | Come si mira, Maiusc per la mira fine |
| 4 | Un colpo che abbatte un birillo bianco, poi il rosso | Bianco 2, rosso 4, rosso da solo 10, pallino 3 |
| 5 | Un fallo (mancare la biglia) | I falli danno i punti all'avversario |
| 6 | Il segnapunti e la vittoria a 20 | Si va a 20 |
Prima di girare: le correzioni della revisione di Codex sui tre giochi, così il video mostra il gioco definitivo.
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""La voce del narratore dei video «Come si gioca» (video/SCALETTA-CARTE.md), PRIMA di girare.
python3 video/narra.py video/tutorial-briscola.mjs
Legge le battute del copione (cues, «it»), le fa dire a XTTS con `mbot parla` (voce del narratore, diversa da
quelle dei personaggi), le controlla con `mbot ascolta` e scrive video/out/<clip>-narrazione.json con file e
durata di ognuna. Il copione aspetta che la frase sia finita prima di passare alla successiva; monta.py la mette
nel video all'inizio della sua battuta. Le frasi già fatte si saltano (per rifarne una: cancellare il file).
"""
import json
import subprocess
import sys
from pathlib import Path
VIDEO = Path(__file__).resolve().parent
GIOCO = VIDEO.parent
OUT = VIDEO / "out"
NARRATORE = "Claribel Dervla"
PRONUNCIA = GIOCO / "tools" / "pronuncia.json"
def durata(f: Path) -> float:
return float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(f)],
capture_output=True, text=True, check=True).stdout)
def main() -> None:
copione = Path(sys.argv[1]).resolve()
clip = copione.stem
cues = json.loads(subprocess.run(["node", "--input-type=module", "-e",
f"const m = await import({json.dumps(copione.as_uri())}); console.log(JSON.stringify(m.cues))"],
capture_output=True, text=True, check=True, cwd=GIOCO).stdout)
cartella = OUT / f"{clip}-narrazione"
cartella.mkdir(parents=True, exist_ok=True)
# 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 []
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))
subprocess.run(["mbot", "ascolta", "--jobs", str(cartella / "ascolto.json")])
voci = [{"id": c["id"], "src": str(Path(l["out"]).relative_to(GIOCO)), "sec": round(durata(Path(l["out"])), 2)} for c, l in zip(cues, lavori)]
(OUT / f"{clip}-narrazione.json").write_text(json.dumps({"voci": voci}, ensure_ascii=False, indent=1))
print(f"-> {OUT / f'{clip}-narrazione.json'}: {len(voci)} frasi, {sum(v['sec'] for v in voci):.0f} s di voce")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
// Copione del video «Come si gioca: la briscola» (video/SCALETTA-CARTE.md, clip 1).
// 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 briscola", en: "Come si gioca · la briscola" };
export const cues = [
c("dove", "All'oratorio della Pieve, alle tavolate, c'è il giocatore di carte: per giocare basta parlarci"),
c("mazzo", "Prima si sceglie il mazzo: le carte toscane, con gobbo, donna e regio, oppure le napoletane"),
c("livello", "Poi quanto deve giocare forte: alla bona, seria, o a fassi male"),
c("tavolo", "In basso le tue tre carte, in alto quelle coperte dell'avversario. A sinistra il mazzo, con la briscola girata sotto: quel seme comanda"),
c("presa", "Si gioca una carta per uno. Prende la briscola più alta; se non ce ne sono, la carta più alta del seme di chi è uscito"),
c("punti", "Contano l'Asso, undici punti, il tre, dieci, il regio quattro, la donna tre e il gobbo due. Le altre sono lisci e non valgono niente"),
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("fine", "Poi si torna all'oratorio, e si può chiedere la rivincita. Buona briscola!"),
];
export const setup = base;
export async function run(d) {
const p = d.page, r = regista(d, "tutorial-briscola");
await r.ascolta();
await r.vaiA("oratorio", [470, 300]);
await r.cue("dove");
await r.parlaCon("giocatore_carte");
await r.scegli("Una briscola?");
await r.cue("mazzo");
await r.scegli("Con le toscane.");
await r.cue("livello");
await r.scegli("Seria.");
// Una mano fissa, la stessa a ogni ripresa.
await p.evaluate(async () => {
const { newBriscola } = await import("/src/engine/briscola.js");
game.closeup = { id: "briscola", ...newBriscola(91421, { level: "seria", deck: "toscane" }) };
});
await sleep(800);
await r.cue("tavolo");
await r.muovi([150, 160], 1.0); await sleep(600); await r.muovi([320, 280], 1.0);
// Una carta di Andrea: quella che sceglierebbe un giocatore attento, cliccata davvero sul tavolo.
const gioca = async () => {
for (let i = 0; i < 400; i++) {
const s = await p.evaluate(async () => {
const { canPlay } = await import("/src/engine/briscola.js");
return game.closeup?.step === "result" || game.closeup?.step === "count" ? "fine" : canPlay(game.closeup) && !game.speech ? "tocca" : "aspetta";
});
if (s === "fine") return false;
if (s === "tocca") break;
await sleep(150);
}
const i = await p.evaluate(async () => {
const { observation, attentive } = await import("/src/engine/briscola.js");
return attentive(observation(game.closeup, "player"));
});
await r.clicca([176 + i * 102 + 42, 290], 0.7);
return true;
};
const giocane = async (n) => { for (let k = 0; k < n; k++) if (!(await gioca())) return; };
await r.cue("presa"); await giocane(2);
await r.cue("punti"); await giocane(2);
await r.cue("pesca"); await giocane(2);
await r.cue("consiglio"); await giocane(2);
await r.cue("tastiera"); await giocane(20);
await p.waitForFunction(() => game.closeup?.step === "count", null, { timeout: 60000 });
await r.cue("conto");
await p.waitForFunction(() => game.closeup?.step === "result", null, { timeout: 60000 });
await sleep(2500);
await r.cue("fine");
await d.click(p.locator("#closeup-close"), 0.6);
await r.fermi(800);
await r.fine();
await r.salvaAudio();
}
+101
View File
@@ -0,0 +1,101 @@
// Pezzi comuni dei copioni «Come si gioca» (video/SCALETTA-CARTE.md): briscola, tressette, scopa, bocce, biliardo.
// Si girano come demo-prototipo.mjs (video/SCALETTA.md, «Come si rigira»); prima `python3 video/narra.py <copione>`
// per la voce del narratore, dopo `python3 video/monta.py <cartella> --tutorial`.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
export const URL_GIOCO = "http://127.0.0.1:8000/";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const c = (id, it) => ({ id, it, en: it, top: true });
// La durata di ogni frase del narratore (video/narra.py): la battuta resta finché la voce non ha finito.
export function narrazione(clip) {
const f = new URL(`./out/${clip}-narrazione.json`, import.meta.url);
return existsSync(f) ? Object.fromEntries(JSON.parse(readFileSync(f, "utf8")).voci.map((v) => [v.id, v.sec * 1000])) : {};
}
export async function setup(d) {
const p = d.page;
await p.goto(URL_GIOCO);
await p.evaluate(() => localStorage.clear());
await p.reload();
await p.waitForSelector("#title:not([hidden])");
await sleep(1000);
}
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.
async cue(id) {
const resto = inizio + dura + 350 - Date.now();
if (resto > 0) await sleep(resto);
await d.cue(id);
inizio = Date.now(); dura = voce[id] ?? 0;
},
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) {
const b = await p.locator("#scene").boundingBox(), k = await p.evaluate(() => devicePixelRatio);
return [(b.x + (x / 640) * b.width) * k, (b.y + (y / 360) * b.height) * k];
},
async clicca([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur} click`); await sleep(300); },
async muovi([x, y], dur = 0.8) { const [a, b] = await r.punto(x, y); d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${a} ${b} ${dur}`); await sleep(200); },
// Aspetta che l'omino abbia finito di camminare e di parlare; un dialogo o un primo piano aperti fermano l'attesa.
async fermi(pausa = 500) {
await sleep(150);
for (let i = 0; i < 1500; i++) {
const ok = await p.evaluate(() => {
if (game.speech || (game.queue.length && !game.closeup) || game.walking || game.reach > 0) return false;
return !!game.dialogue || !!game.closeup || (!game.busy && !game.task);
});
if (ok) break;
await sleep(100);
}
await sleep(pausa);
},
async scegli(testo) { 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"));
await sleep(1200);
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]);
await r.fermi(1200);
},
async parlaCon(id) {
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();
},
// L'audio annotato dentro la pagina (musica voluta e voci del gioco), per rimontarlo (monta.py).
async ascolta() {
await p.evaluate(() => {
window.__t0 = Date.now(); window.__audio = [];
let music = undefined, voice = null;
const t = () => (Date.now() - window.__t0) / 1000;
const path = (src) => decodeURI(new URL(src, location.href).pathname.slice(1));
const look = () => {
if (window.music && window.music.want !== music) { music = window.music.want; window.__audio.push({ t: t(), music: music ?? null }); }
const a = window.voices?.audio, now = a && !a.paused && !a.ended ? a : null;
if (now !== voice) {
if (voice) window.__audio.push({ t: t(), voiceEnd: true });
if (now) window.__audio.push({ t: t(), voice: path(now.src) });
voice = now;
}
requestAnimationFrame(look);
};
look();
});
},
async salvaAudio() {
const audio = await p.evaluate(() => window.__audio);
const out = new URL("./out/", import.meta.url);
mkdirSync(out, { recursive: true });
writeFileSync(new URL(`${clip}-audio.json`, out), JSON.stringify({ eventi: audio }, null, 2));
},
};
return r;
}