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:
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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…`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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') }] }];
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user