Compare commits

..
25 Commits
Author SHA1 Message Date
manzolo ca3035e33f Versione 0.1.15 2026-10-26 22:42:15 +01:00
manzoloandClaude Fable 5.1 22be1c306e I cinque video «Come si gioca» dentro il gioco: pulsante «Guarda il video» nell'aiuto di briscola, tressette, scopa, bocce e biliardo, player a tutto schermo con l'audio (il gioco e la musica si fermano, Esc torna al tavolo). video/pubblica.py li taglia dall'arrivo nel posto, li comprime per il web (~4,5 MB l'uno) e fa la copertina. Il favicon e i video copiati anche nell'immagine Docker (il Regolino online dava 404); test che ogni cartella di assets usata dal gioco sia nel Dockerfile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 22:37:19 +01:00
manzolo add5a5fe77 Versione 0.1.14 2026-10-26 22:03:12 +01:00
manzoloandClaude Opus 5.5 3019a6e32f Bocce: il clic fissa la mira come al biliardo, così andando verso la forza o «Lancia» il puntatore non la sposta più (scoperto girando il video: la boccia partiva a due passi). Copioni dei video «Come si gioca» di scopa, tressette, bocce e biliardo, e video/sovrapposte.py per controllare che il narratore non parli sopra i personaggi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 21:23:23 +01:00
manzoloandClaude Opus 5.5 111b892916 Il favicon: un Regolino in pixel art con la corona (Codex, tools/favicon.py), da 16 a 512 pixel e per l'iPhone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:51:11 +01:00
manzoloandClaude Opus 5.5 f1781afea8 Scelte dei dialoghi: i numeri non escono più a sinistra del pannello (il rientro segue il carattere pixel, largo tre caratteri e mezzo per «1. »; Andrea, schermata)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:50:48 +01:00
manzoloandClaude Opus 5.5 b44eeffd2a Fra Peppino da Gricignano passa dal sagrato della Pieve al Bosco ai Frati, davanti al convento dei francescani (Andrea); la polka del pallaio per le bocce (tools/polka_bocce.py: clarinetto, fisarmonica a contrattempo, basso um-pa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:43:07 +01:00
manzolo 89ebdd40bb Versione 0.1.13 2026-10-26 18:34:08 +01:00
manzoloandClaude Opus 5.5 538bf6cedb Carte che volano: dalla mano al tavolo e, finita la presa, sul mucchietto di chi ha preso (briscola, tressette, scopa; Andrea: «non si capisce quando vengono buttate le carte»). Il valzer della balera ai tavoli da carte (tools/valzer_carte.py: fisarmonica musette, basso, melodia di mandolino col tremolo), musica per primo piano; video «Come si gioca»: cache del Chromium della VM spenta, narratore e personaggi a turno, musica di sottofondo, pronunce del narratore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 18:17:23 +01:00
manzoloandClaude Opus 5.5 7e1e6f6144 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
2026-10-26 16:39:22 +01:00
manzoloandClaude Opus 5.5 30e324d7e2 Scopa agli 11 all'oratorio, e il tressette rivisto dopo la revisione critica di Codex
Scopa: toscane o napoletane (mattoni o denari), presa obbligatoria, la carta uguale prima delle somme, scelta fra più prese toccando la tavola, coi pulsanti o con frecce e Invio; scope (non all'ultima carta), carte, mattoni, settebello, primiera; tre livelli tarati (92, 64, 52%); test e prova nel browser.
Tressette: fuori ai 21 presa per presa, vittoria subito definitiva (Esc non la cancella più), accuse dette al proprio turno e col seme che manca del tris, striscio e busso meno rigidi, il socio risponde al busso, tiene il 3 e il 2, non esce d'Asso scoperto, legge il volo; tastiera col fuoco sulla carta scelta, tavola descritta a parole, riepilogo che aspetta Andrea. Stesso Esc corretto anche nella scopa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 15:59:10 +01:00
manzoloandClaude Opus 5.5 e8152563d8 Tressette ai 21 all'oratorio: in quattro a coppie, Andrea e il socio contro il giocatore di carte e l'altro. Obbligo di seme, punti in terzi e ultima presa, accuse (napoletana, tre o quattro Assi, 2 o 3), segni onesti di chi esce (busso, striscio, volo) coi loro suoni, si va ai 21 smazzata dopo smazzata. Avversari «Alla bona», «Seria» (gioca a regola ma non conta) e «A fassi male» (conta, legge semi finiti, accuse e busso del socio). Tavolo a quattro, pulsanti dei segni e della mano, tastiera B/S/V; test, taratura e prova nel browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 15:14:46 +01:00
manzolo fef236dc54 Versione 0.1.12 2026-10-26 14:34:08 +01:00
manzoloandClaude Opus 5.5 c82bef70b2 Oratorio: il libro della Storia infinita passa sulla prima tavolata, a sinistra del palo, lontano dal giocatore di carte (Andrea: «troppo vicini, non riesco a interagire»); test che li distingue
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 14:28:21 +01:00
manzolo e9907e3e66 Versione 0.1.11 2026-10-26 14:01:26 +01:00
manzoloandClaude Opus 5.5 92e7a289be Briscola all'oratorio: alle tavolate il giocatore di carte (segnaposto, sprite di Codex) sfida Andrea a briscola in due. Si sceglie il mazzo, toscane (cuori, mattoni, fiori, picche; gobbo, donna e regio) o napoletane, e il livello: «Alla bona», «Seria», «A fassi male» (conta le carte uscite, finale perfetto a mazzo finito, mai la mano altrui). Regole fedeli, conteggio delle prese a fine partita, rivincita, suoni di carte, comandi con mouse, tocco e tastiera. Carte e dorso col Regolo serpentello disegnati da Codex; motore e prove nel browser rivisti da Claude
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
2026-10-26 13:56:39 +01:00
manzolo 2362fc7741 Versione 0.1.10 2026-10-26 00:19:14 +01:00
manzoloandClaude Opus 5.5 2dd4257fd4 Piazza: la Torre dello Scoronconcolo, con l'aneddoto di Michele del Tavolaccino (Lorenzino de' Medici, il duca Alessandro, le galere contro i Turchi), trovato da Andrea su Wikipedia
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-26 00:14:20 +01:00
manzolo d51f5e56ea Versione 0.1.9 2026-10-26 00:03:14 +01:00
manzoloandClaude Opus 5.5 7b7e0031d1 Biliardo: immagini e suoni insieme. Durante la steccata aspetta la fisica, non il disegno: la biglia parte quando la stecca la tocca, col suo suono, e si disegnano sempre le posizioni vere (segnalato da Codex: urti che sembravano senza contatto e suoni in anticipo di 180 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:55:31 +01:00
manzoloandClaude Opus 5.5 94e9da4009 Difficoltà alle bocce e al biliardo, scelta nel dialogo con l'avversario: «Alla bona», «Seria», «A fassi male». Tarata con partite simulate contro un giocatore attento: al biliardo vince 9 su 12, circa 4 su 10 e quasi mai; alle bocce 72%, 50% e 35% delle mani. Battute nuove per Mariano e l'Ingegnere, test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:46:46 +01:00
manzoloandClaude Opus 5.5 bd66ea45ad Biliardo più bello (Codex): tavolo in pixel art visto dall'alto con cornice a fasce e diamanti, sala in penombra e luce delle lampade sul panno, segnapunti a cifre rosse, steccata e birilli che cadono animati, biglie in pixel, l'Ing. Tozzi a bordo tavolo che dà il gesso, esulta o allarga le braccia. Più: scritte a 8 punti (a 6 la B sembrava una D) e la stecca che torna a seguire il mouse finché un clic non fissa la mira (Andrea: «non riesco più a girare la stecca»)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:36:23 +01:00
manzoloandClaude Opus 5.5 9eabd5c6e1 Biliardo più preciso (revisione di Codex): urti calcolati nel punto esatto di contatto, anche nei colpi di taglio e veloci; la guida simula il tiro vero e mostra le due biglie dopo l'urto e i falli; la mira resta ferma andando sui comandi; frecce e Maiusc per la mira fine; comandi che non coprono il tavolo; test nel browser con mouse, touch e telefono. Più un tetto agli urti per passo, così due biglie a contatto non possono bloccare il gioco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 23:12:46 +01:00
manzoloandClaude Opus 5.5 ddc877a2d1 Sala dei biliardi: trio jazz da circolo sintetizzato da zero (tools/jazz_biliardo.py: contrabbasso, piano elettrico, spazzole, vibrafono, swing in si bemolle) e un sottofondo con gli altri tavoli e il bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:59:43 +01:00
manzoloandClaude Opus 5.5 14a1e6e5b8 L'Ing. Tozzi nella sala dei biliardi (sprite di Codex dalla foto di Andrea): la sfida parte parlandogli, come con Mariano, con la rivincita dopo una vittoria; la sala in scala più alta, Andrea non sta più sotto il tavolo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 22:56:55 +01:00
142 changed files with 5770 additions and 245 deletions
+2
View File
@@ -13,6 +13,8 @@ COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects
COPY assets/closeups /usr/share/nginx/html/assets/closeups
COPY assets/favicon /usr/share/nginx/html/assets/favicon
COPY assets/video /usr/share/nginx/html/assets/video
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \
+6
View File
@@ -47,6 +47,11 @@ Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
Al biliardo del Circolo 4 Maggio, clic o tocco fissa la mira; regola la forza e premi **Tira**.
Le frecce ← → affinano la direzione, con Maiusc per passi ancora più piccoli. La guida usa la stessa fisica
del tiro: mostra sponde, primo contatto e direzioni iniziali dopo l'urto (azzurro per l'avversaria, giallo
per la propria biglia). Tenendo premuto sul tavolo o Spazio si carica il colpo, con la mira bloccata.
## In Docker
```zsh
@@ -71,5 +76,6 @@ npm test # logica, storia coerente, soluzione percorsa dall'inizio alla
npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
npm run browser:biliardo # mira e tiro con mouse/touch, desktop e telefono; schermate in /tmp/regolo-biliardo
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
{
"image": "giocatore-carte.png",
"height": 100,
"frames": {
"sit": {
"x": 0,
"y": 0,
"w": 67,
"h": 100,
"ax": 25.7,
"ay": 100
},
"talk": {
"x": 69,
"y": 0,
"w": 68,
"h": 100,
"ax": 27.6,
"ay": 100
},
"laugh": {
"x": 139,
"y": 0,
"w": 72,
"h": 99,
"ax": 19.3,
"ay": 99
},
"shuffle": {
"x": 213,
"y": 0,
"w": 67,
"h": 97,
"ax": 25.2,
"ay": 97
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+70
View File
@@ -0,0 +1,70 @@
{
"image": "ing-tozzi.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 53,
"h": 120,
"ax": 27.9,
"ay": 120
},
"blink": {
"x": 55,
"y": 0,
"w": 54,
"h": 120,
"ax": 27.8,
"ay": 120
},
"talk": {
"x": 111,
"y": 0,
"w": 77,
"h": 119,
"ax": 29.9,
"ay": 119
},
"explain": {
"x": 190,
"y": 0,
"w": 58,
"h": 119,
"ax": 30.8,
"ay": 119
},
"laugh": {
"x": 250,
"y": 0,
"w": 53,
"h": 119,
"ax": 28.2,
"ay": 119
},
"chalk": {
"x": 305,
"y": 0,
"w": 52,
"h": 119,
"ax": 24.1,
"ay": 119
},
"shrug": {
"x": 359,
"y": 0,
"w": 87,
"h": 120,
"ax": 43.9,
"ay": 120
},
"cheer": {
"x": 448,
"y": 0,
"w": 56,
"h": 123,
"ax": 30.9,
"ay": 123
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+214 -30
View File
@@ -47,6 +47,26 @@
"file": "andrea-dacb32e5c6.mp3",
"sec": 4.01
},
"andrea|La Torre dello Scoronconcolo. Era una vecchia torre di guardia, già in disuso nel Cinquecento.": {
"file": "andrea-536c525f5c.mp3",
"sec": 6.64
},
"andrea|Ci si rifugiò Michele del Tavolaccino, detto Scoronconcolo, sanpierino: nel 1537 aiutò Lorenzino de' Medici ad ammazzare il duca Alessandro.": {
"file": "andrea-37eaf3e803.mp3",
"sec": 11.82
},
"andrea|Nel 1539 finì sulle galere dell'ordine gerosolimitano, in guerra contro i Turchi. Altro che Palio.": {
"file": "andrea-5f4c4fca29.mp3",
"sec": 8.04
},
"andrea|Da quassù non ci arrivo. E poi, di nascondigli in quella torre, ce n'è già stato uno di troppo.": {
"file": "andrea-f60ba3ee31.mp3",
"sec": 7.38
},
"andrea|Una torre in tasca? Nemmeno lo Scoronconcolo ci è riuscito.": {
"file": "andrea-938fbd1326.mp3",
"sec": 4.59
},
"andrea|Il bar della piazza. Chi c'era dietro il bancone nel 2007? Questo lo sa solo Andrea.": {
"file": "andrea-4b733def4f.mp3",
"sec": 7.27
@@ -287,26 +307,6 @@
"file": "andrea-68a845881b.mp3",
"sec": 1.94
},
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Sul sagrato sembra nato lì.": {
"file": "andrea-97d75e2a9d.mp3",
"sec": 5.38
},
"andrea|Buongiorno, Fra Peppino!": {
"file": "andrea-94988dc8c6.mp3",
"sec": 1.74
},
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
"file": "fra_peppino-651c4d95ab.mp3",
"sec": 3.72
},
"andrea|Il palio. È sparito.": {
"file": "andrea-c9c068f507.mp3",
"sec": 2.39
},
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
"file": "fra_peppino-8b3d0a5b8f.mp3",
"sec": 4.47
},
"andrea|La Pieve di San Pietro. Il mio rione: noi bianchi.": {
"file": "andrea-43fd5c2b27.mp3",
"sec": 4.46
@@ -467,6 +467,10 @@
"file": "andrea-f798fa755d.mp3",
"sec": 4.9
},
"andrea|Ha già mescolato. Mi sa che aspettava proprio un pollo di passaggio.": {
"file": "andrea-71f3bff8a0.mp3",
"sec": 5.09
},
"andrea|Una gomma da masticare ancora incartata, persa davanti al palco. Qualcuno ha perso l'appetito.": {
"file": "andrea-f2ea95922d.mp3",
"sec": 6.54
@@ -519,6 +523,26 @@
"file": "andrea-43d3435d61.mp3",
"sec": 2.99
},
"andrea|Fra Peppino da Gricignano, col saio e i sandali. Qui al Bosco ai Frati è a casa sua: è un convento di francescani.": {
"file": "andrea-6797d70153.mp3",
"sec": 8.45
},
"andrea|Buongiorno, Fra Peppino!": {
"file": "andrea-94988dc8c6.mp3",
"sec": 1.74
},
"fra_peppino|Pace e bene, figliolo. Cerchi qualcosa?": {
"file": "fra_peppino-651c4d95ab.mp3",
"sec": 3.72
},
"andrea|Il palio. È sparito.": {
"file": "andrea-c9c068f507.mp3",
"sec": 2.39
},
"fra_peppino|Le cose sparite, prima o poi, tornano. Come le pecorelle.": {
"file": "fra_peppino-8b3d0a5b8f.mp3",
"sec": 4.47
},
"andrea|Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.": {
"file": "andrea-486c980f52.mp3",
"sec": 7.48
@@ -655,13 +679,9 @@
"file": "andrea-2d04be65fb.mp3",
"sec": 5.97
},
"ing_tozzi|Una partita ai birilli? Si va a venti.": {
"file": "ing_tozzi-7aef4a5420.mp3",
"sec": 3.3
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
"andrea|Il tavolo è dell'Ingegnere: prima gli chiedo una partita.": {
"file": "andrea-c43b158842.mp3",
"sec": 3.27
},
"ing_tozzi|Si parla col panno, qui. Prendi la stecca.": {
"file": "ing_tozzi-4814e97759.mp3",
@@ -671,6 +691,10 @@
"file": "andrea-98b3e4a969.mp3",
"sec": 3.49
},
"andrea|L'Ing. Tozzi, il campione del circolo. Occhiali scuri anche al chiuso: dice che così non vede le biglie degli altri.": {
"file": "andrea-127a90af53.mp3",
"sec": 9.37
},
"andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": {
"file": "andrea-1eae1a8577.mp3",
"sec": 6.1
@@ -1499,6 +1523,86 @@
"file": "andrea-60cdae2e35.mp3",
"sec": 5.1
},
"andrea|Una briscola?": {
"file": "andrea-d2f285f610.mp3",
"sec": 1.04
},
"andrea|Rivincita a briscola?": {
"file": "andrea-6807209a9e.mp3",
"sec": 1.37
},
"andrea|Una scopa?": {
"file": "andrea-d8dfb86266.mp3",
"sec": 0.65
},
"andrea|Un tressette ai 21?": {
"file": "andrea-4fbe84fada.mp3",
"sec": 1.66
},
"andrea|Ora cerco il palio. A dopo.": {
"file": "andrea-6e63dafda1.mp3",
"sec": 2.61
},
"giocatore_carte|Gnamo. In due, senza soci e senza scuse.": {
"file": "giocatore_carte-1523994a6c.mp3",
"sec": 3.78
},
"giocatore_carte|Il tavolo è qui. Il coraggio ce lo metti te.": {
"file": "giocatore_carte-8dfadb4acc.mp3",
"sec": 3.4
},
"giocatore_carte|Ai undici. E occhio al settebello: quello è sempre mio.": {
"file": "giocatore_carte-af54c11857.mp3",
"sec": 4.27
},
"giocatore_carte|Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.": {
"file": "giocatore_carte-3e2671e35e.mp3",
"sec": 4.88
},
"andrea|Con le toscane.": {
"file": "andrea-e8599cc8b6.mp3",
"sec": 1.03
},
"andrea|Con le napoletane.": {
"file": "andrea-be276497e3.mp3",
"sec": 1.09
},
"andrea|Ci ripenso.": {
"file": "andrea-b851b9ed26.mp3",
"sec": 0.8
},
"giocatore_carte|Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?": {
"file": "giocatore_carte-89686f55a0.mp3",
"sec": 6.14
},
"giocatore_carte|Le napoletane? Le tengo per i villeggianti. Come la vuoi?": {
"file": "giocatore_carte-4f39bc172d.mp3",
"sec": 5.13
},
"andrea|Alla bona.": {
"file": "andrea-67afa54e05.mp3",
"sec": 0.69
},
"andrea|Seria.": {
"file": "andrea-f6ef96032e.mp3",
"sec": 0.58
},
"andrea|A fassi male.": {
"file": "andrea-f52137b691.mp3",
"sec": 0.78
},
"giocatore_carte|Alla bona. I carichi li regalo, oggi.": {
"file": "giocatore_carte-43e76cf11b.mp3",
"sec": 3.43
},
"giocatore_carte|Allora posa il gomito e guarda le carte.": {
"file": "giocatore_carte-f1f219bd1c.mp3",
"sec": 2.51
},
"giocatore_carte|Io le conto tutte. Anche quelle che butti via per sbaglio.": {
"file": "giocatore_carte-12f97003ec.mp3",
"sec": 4.19
},
"andrea|Una Fanta.": {
"file": "andrea-8927b87738.mp3",
"sec": 0.65
@@ -1815,14 +1919,62 @@
"file": "mariano-0eeb942ca2.mp3",
"sec": 3.52
},
"mariano|Il campione non scappa. Tira!": {
"file": "mariano-bdf58ba1be.mp3",
"sec": 3.26
"mariano|Come la vuoi?": {
"file": "mariano-0288ec460b.mp3",
"sec": 0.7
},
"mariano|Il campione non scappa. Come la vuoi?": {
"file": "mariano-1d8388d631.mp3",
"sec": 3.55
},
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
"file": "mariano-1c9cd0c325.mp3",
"sec": 5.96
},
"mariano|Alla bona: tiro con gli occhi chiusi.": {
"file": "mariano-b1b1ca273b.mp3",
"sec": 2.2
},
"mariano|Seria. Così mi piace.": {
"file": "mariano-accff65835.mp3",
"sec": 2.27
},
"mariano|A fassi male? Poi non piangere, eh.": {
"file": "mariano-d65fb6e871.mp3",
"sec": 3.42
},
"andrea|Ingegnere, una partita ai birilli?": {
"file": "andrea-dbcf4d1083.mp3",
"sec": 2.49
},
"andrea|Arrivederci, Ingegnere.": {
"file": "andrea-8f63435d69.mp3",
"sec": 1.38
},
"ing_tozzi|Una partita ai birilli? Si va a venti. Come la vuoi?": {
"file": "ing_tozzi-82e7cc88aa.mp3",
"sec": 4.58
},
"ing_tozzi|Ci mancherebbe. Stavolta guardo bene. Come la vuoi?": {
"file": "ing_tozzi-344ac1021f.mp3",
"sec": 4.67
},
"ing_tozzi|Alla bona: tiro senza occhiali.": {
"file": "ing_tozzi-17e489814e.mp3",
"sec": 2.19
},
"andrea|Gnamo.": {
"file": "andrea-a496b0aca9.mp3",
"sec": 0.58
},
"ing_tozzi|Seria. Dammi il gesso.": {
"file": "ing_tozzi-4b3adfa04a.mp3",
"sec": 2.28
},
"ing_tozzi|A fassi male? Allora mi tolgo la giacca. Ah no, non ce l'ho.": {
"file": "ing_tozzi-c365e9f760.mp3",
"sec": 6.05
},
"andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3",
"sec": 3.9
@@ -1935,6 +2087,38 @@
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
"giocatore_carte|Oh, bravo! Ora però rimescolo io.": {
"file": "giocatore_carte-a8846bfa7b.mp3",
"sec": 2.9
},
"giocatore_carte|I carichi portali qui, mica alla stazione! Rivincita quando vuoi.": {
"file": "giocatore_carte-6ce98bce81.mp3",
"sec": 4.72
},
"giocatore_carte|Sessanta pari. Un si litiga nemmeno sul conto!": {
"file": "giocatore_carte-13af2e2331.mp3",
"sec": 3.86
},
"giocatore_carte|Vai, ti tengo il posto. Le carte mica scappano.": {
"file": "giocatore_carte-729b3344da.mp3",
"sec": 3.3
},
"giocatore_carte|Ventuno per primi. Bella coppia, voi due!": {
"file": "giocatore_carte-3215efd9dd.mp3",
"sec": 3.51
},
"giocatore_carte|Ventuno! Il tressette è un'arte: si studia da piccini.": {
"file": "giocatore_carte-5dbd39a25f.mp3",
"sec": 4.44
},
"giocatore_carte|Settebello, primiera e pure le scope. Oggi l'hai vista bene.": {
"file": "giocatore_carte-9a43eb8b2c.mp3",
"sec": 5.54
},
"giocatore_carte|Undici! Il settebello lo sento arrivare da lontano.": {
"file": "giocatore_carte-445e962a1b.mp3",
"sec": 4.1
},
"mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
# Primo piano del biliardo
Il tavolo in `assets/closeups/biliardo-tavolo.png` è generato con lo strumento immagini integrato
di Codex, senza API. Prompt completo in `prompts/closeups/biliardo-tavolo.integrated.txt`
(cartella esclusa da git). Specifica: vista ortogonale dall'alto, pixel art da avventura anni '90,
mogano scuro a fasce chiare e scure, diamanti d'ottone, panno verde, sala in penombra;
senza buche, biglie, birilli, persone, stecche o testi. Riferimento di stile: `circolo-4-maggio.png`.
L'Ingegnere usa il foglio esistente `ing-tozzi.json`, coerente con la fotografia di riferimento.
Il master misura 1672×941. `tablePatches()` in `biliardo-render.js` mappa il panno sorgente
(190, 214)–(1484, 722) su `(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k)`; i ritagli
esterni mantengono una cornice larga 16 pixel. La geometria della simulazione resta invariata.
Il caricamento passa da `story.closeups.biliardo.picture`, con la stessa cache delle bocce.
La luce calda è disegnata sul panno con una piccola tela memorizzata, ingrandita senza smoothing.
Biglie e birilli usano blocchi di pixel; le ombre delle biglie si allontanano dalla lampada centrale.
I segnapunti sono cifre rosse a sette segmenti. Le quattro pose di Tozzi rimangono nel margine sinistro.
`biliardo.js` aggiunge soltanto stato visivo: orologio, posizione iniziale della steccata,
breve cronologia delle posizioni e istante della caduta dei birilli. La presentazione del moto
è ritardata di 180 ms, per mostrare arretramento e scatto della stecca; la fisica parte subito.
La caduta dei birilli dura 240 ms sul medesimo orologio visivo. Previsione e CPU non scrivono
questa cronologia. Un confronto con la versione precedente ha verificato 6626 aggiornamenti
identici, escludendo i soli campi visivi.
Controlli: `tests/biliardo-render.test.mjs` verifica allineamento, presentazione, cadute,
pose e immutabilità del disegno. `npm run browser:biliardo` conserva i controlli dei comandi
su desktop, touch e telefono verticale e aggiunge controlli dei pixel e catture delle animazioni
in `/tmp/regolo-biliardo`.
Verifica di questa modifica: test JavaScript e Python superati; anteprima locale del renderer
ispezionata. Restano da completare `make -s verify` e `npm run browser:biliardo` in un ambiente
che permetta server locale e Chromium: nella sandbox della sessione entrambi sono bloccati
da `Operation not permitted`. Le vecchie schermate browser non validano questa modifica.
+137
View File
@@ -0,0 +1,137 @@
# Briscola all’oratorio
Implementazione del 26 ottobre 2026. ES modules statici, nessun build step.
## Partita e comandi
Il giocatore di carte è un personaggio inventato e provvisorio, seduto a destra alle tavolate
dell’oratorio: **[DA ANDREA: chi è]**. Il dialogo offre «Alla bona», «Seria», «A fassi male»;
dopo una partita conclusa offre la rivincita. `briscola_vinta` ricorda una vittoria e
`briscola_giocata` abilita la rivincita anche dopo sconfitta o patta. Nessuno dei due flag
serve alla soluzione dell’avventura.
Un clic o tocco su una carta la gioca al rilascio sullo stesso bersaglio. Nessuna carica.
Le frecce e 1–2–3 scelgono, Invio gioca, Esc esce. I tre pulsanti con nome e seme
offrono bersagli aggiuntivi e accesso tramite Tab. I comandi di tiro degli altri minigiochi
rimangono separati; in `MINIGAMES` pressione, rilascio, annullamento e mira sono opzionali.
Mazzo da 40 carte, 120 punti, distribuzione alternata e mazziere deterministico dal seed.
La briscola è in fondo al mazzo e compresa nel contatore. Chi prende pesca prima e riapre;
non esiste obbligo di seme. La presa resta visibile 1,25 secondi. Solo dopo la ventesima
presa si contano tutte le carte, una ogni 0,35 secondi, mostrando carta ingrandita,
incremento e totale. A 60–60 il risultato è patta. Il risultato resta fino all’uscita.
## Avversari e taratura
`observation` espone soltanto la propria mano, le carte pubbliche, i conteggi e l’eventuale
briscola pescata dall’altro. Non passa la mano avversaria, l’ordine del mazzo o il seed della
distribuzione. «Alla bona» sceglie casualmente; «Seria» scarta lisci, conserva briscole e
prende carichi con quelle piccole. «A fassi male» ricostruisce le carte non viste, confronta
le mosse su 40 distribuzioni possibili e simula seguiti giocati con buon senso. Sono ipotesi,
non la distribuzione effettiva. A mazzo finito il complemento delle carte pubbliche e della
propria mano identifica l’altra mano: da quel momento usa minimax esatto fino alla fine.
Comando riproducibile: `node tools/calibrate_briscola.mjs 1000`.
Semi `Math.imul(i, 2654435761) >>> 0`, i da 1 a 1000, stessi mazzi per ogni livello.
Andrea usa sempre `attentive`, l’euristica di riferimento; dà le carte 496 volte su 1000.
| Livello | Vittorie Andrea | Vittorie avversario | Patte | Punti medi Andrea |
| --- | ---: | ---: | ---: | ---: |
| Alla bona | 847 (84,7%) | 144 (14,4%) | 9 (0,9%) | 79,159 |
| Seria | 481 (48,1%) | 494 (49,4%) | 25 (2,5%) | 59,952 |
| A fassi male | 359 (35,9%) | 621 (62,1%) | 20 (2,0%) | 55,628 |
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
Due mazzi, scelti nel dialogo («Con le toscane» / «Con le napoletane»): stesse regole e stessi id, cambiano
nomi e disegni (`DECKS` e `DECK_CARDS` in `briscola.js`; la scelta è il flag `carte_napoletane`).
- **Toscane** (predefinite): semi francesi cuori, mattoni, fiori, picche; figure gobbo, donna e regio (il regio
sempre con la corona). Numerali senza numeri, semi disposti come nel mazzo vero e capovolti nella metà bassa;
figure intere col seme e la lettera (G, D, R) in alto a sinistra. Riferimento: `assets/raw/carte/toscane-riferimento.png`.
- **Napoletane**: monete, coppe, lame ricurve incrociate, bastoni nodosi; fante, cavallo e re. Valore nell’angolo
e seme scritto per non affidarsi solo al colore.
Tutto disegnato via canvas, in pixel art originale.
I bersagli della mano sono 84×120 pixel a 640×360. Dorso con serpentello coronato e cornice
simmetrica; tavolino di legno e panno, mano avversaria coperta, mazzo e mucchietti delle prese.
Sprite generato con lo **strumento immagini integrato**, poi preparato con
`slice_sprites.py --alpha --fixed-grid --cols 2 --rows 2` e `prepare_character.py`.
Pose: `sit`, `talk`, `laugh`, `shuffle`. Foglio originale in
`assets/raw/giocatore-carte-sheet.png`; prompt completo in
`prompts/personaggi/giocatore-carte-sheet.txt` (entrambi esclusi da git).
Effetti Web Audio `card-slide`, `card-tap`, `card-shuffle`, collegati allo stesso volume,
muto e pausa degli altri effetti. Il colpo accompagna l’arrivo della carta dopo lo scorrimento.
13 nuove battute XTTS (4 Andrea, 9 giocatore), con indice e durate aggiornati.
Per generarle senza GPU/rete, `tools/voci_xtts.py` accetta `XTTS_DEVICE=cpu` e
`XTTS_MODEL_DIR` con la cartella del modello già scaricato; le impostazioni predefinite
rimangono quelle del progetto. Non è stata verificata all’ascolto la pronuncia delle nuove voci.
## Verifica e limiti della sessione
**`npm test`: passato.** Regole, determinismo, conservazione delle 40 carte e dei 120 punti,
pescata, ultima briscola, fine mazzo, patta, livelli, accesso negato ai dati nascosti,
minimax confrontato con un oracolo esaustivo, renderer puro, input geometrico,
dialoghi/flag/rivincita e audio. Passano anche i test precedenti e la soluzione completa.
`npm run browser` e `npm run browser:briscola` sono stati lanciati ma **bloccati dall’ambiente**:
`PermissionError: [Errno 1] Operation not permitted` alla creazione del socket del server.
Anche l’avvio isolato di Chromium fallisce con `sandbox_host_linux.cc:41`,
`shutdown: Operation not permitted`. Non è stato possibile verificare l’interfaccia reale
con Playwright né produrre schermate desktop/telefono.
Il test `tests/briscola.browser.mjs` è pronto: partite complete con mouse, touch e tastiera
su desktop e telefono verticale, più touch orizzontale; pausa, muto, gesto annullato,
conteggio, risultato e rivincita. Accelera solo le attese tramite `game.update`, senza
giocare carte di Andrea direttamente nel motore. Salva in `/tmp/regolo-shots/briscola-*.png`.
Le misure Web Audio dei tre nuovi suoni sono comprese nel collaudo browser multimedia.
Anteprime **offline, non schermate browser**, prodotte e ispezionate nella sessione:
- `/tmp/regolo-shots/briscola-offline-carte.png`
- `/tmp/regolo-shots/briscola-offline-tavolo.png`
- `/tmp/regolo-shots/briscola-offline-risultato.png`
## File
Nuovi: `src/engine/briscola.js`, `src/engine/briscola-render.js`,
`assets/characters/giocatore-carte.png`, `assets/characters/giocatore-carte.json`,
`tests/briscola.test.mjs`, `tests/briscola-render.test.mjs`, `tests/briscola.browser.mjs`,
`tools/calibrate_briscola.mjs`, questo documento e 13 MP3 elencati qui sotto.
Modificati: `src/engine/game.js`, `src/engine/render.js`, `src/engine/soundscape.js`,
`src/main.js`, `src/story/story.js`, `index.html`, `style.css`, `package.json`,
`tests/story.test.mjs`, `tests/multimedia.test.mjs`, `tests/multimedia.browser.mjs`,
`tools/voci.json`, `tools/voci_xtts.py`, `assets/voices/index.json`.
Nuovi file in `assets/voices/`:
```text
andrea-6e63dafda1.mp3
andrea-71f3bff8a0.mp3
andrea-b851b9ed26.mp3
andrea-d2f285f610.mp3
giocatore_carte-12f97003ec.mp3
giocatore_carte-13af2e2331.mp3
giocatore_carte-43e76cf11b.mp3
giocatore_carte-6ce98bce81.mp3
giocatore_carte-729b3344da.mp3
giocatore_carte-8dfadb4acc.mp3
giocatore_carte-9c07caaab4.mp3
giocatore_carte-a8846bfa7b.mp3
giocatore_carte-f1f219bd1c.mp3
```
`docs/STATO.md` invariato. Nessun commit, tag o push.
+45
View File
@@ -0,0 +1,45 @@
# Scopa agli 11
All'oratorio, dal giocatore di carte: «Una scopa?». In due, Andrea contro il giocatore di carte, con le toscane o
le napoletane (stessa scelta della briscola). Motore `src/engine/scopa.js` (Claude), tavolo `src/engine/scopa-render.js`.
## Regole
- Valori: Asso 1, poi i numeri, gobbo (fante) 8, donna (cavallo) 9, regio (re) 10.
- Quattro carte in tavola (con tre o quattro re si rimescola), tre a testa; finite le tre, se ne danno altre tre.
Gioca per primo chi non fa le carte; il mazziere cambia a ogni smazzata.
- Una carta prende quella dello stesso valore (se ce n'è più d'una si sceglie quale); se non c'è, un gruppo di carte
che somma il suo valore (più gruppi: si sceglie). Se la presa c'è, è obbligatoria. Se no, la carta resta in tavola.
- **Scopa**: chi pulisce la tavola fa un punto; non vale con l'ultima carta della smazzata.
- A fine smazzata le carte in tavola vanno all'ultimo che ha preso. Un punto per: **carte** (più di 20), **mattoni**
(con le napoletane i denari, più di 5), **settebello** (il 7 di mattoni o di denari), **primiera** (la carta
migliore di ogni seme: 7 = 21, 6 = 18, Asso = 16, 5 = 15, 4 = 14, 3 = 13, 2 = 12, figure 10; senza un seme non
si fa), e uno per scopa. A pari, il punto non va a nessuno.
- Si va **agli 11**, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), un'altra smazzata.
## Avversari
- «Alla bona»: una mossa a caso fra quelle possibili.
- «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.
- «A fassi male»: come «Seria», ma conta le carte non viste per stimare se l'altro può fare scopa, non lascia 7 in
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 96% contro
«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
`npm test` (`tests/scopa.test.mjs`) e `npm run browser:scopa`: partita intera col mouse, una smazzata col tocco
(telefono e orizzontale) e con la tastiera; in ogni caso una presa da scegliere fra due (toccando la tavola, con
un pulsante o con frecce e Invio). Schermate in `/tmp/regolo-shots/scopa-*.png`.
+11
View File
@@ -18,6 +18,17 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
- **Briscola all'oratorio (26 ottobre)**: alle tavolate siede «Il giocatore di carte» (segnaposto, [DA ANDREA: chi è],
sprite e battute provvisorie di Codex). Parlandoci si sceglie il mazzo (toscane o napoletane) e il livello
(«Alla bona», «Seria», «A fassi male»), poi briscola a due fedele alle regole, conteggio delle prese a fine
partita e rivincita. Facoltativa: nessun flag serve alla soluzione. Motore `src/engine/briscola.js` (Claude),
disegno `briscola-render.js` (Codex), dettagli e taratura in `docs/BRISCOLA.md`. Prova: `npm run browser:briscola`.
**Tressette ai 21** (stesso giocatore, «Un tressette ai 21?»): in quattro a coppie, busso/striscio/volo e accuse,
motore e tavolo di Claude, regole e taratura in `docs/TRESSETTE.md`, prova `npm run browser:tressette`.
**Scopa agli 11** («Una scopa?»): carte, mattoni (o denari), settebello, primiera e scope, presa a scelta fra
più possibili; `docs/SCOPA.md`, prova `npm run browser:scopa`. Con la scopa i giochi di carte sono finiti
(decisione di Andrea, 26 ottobre). Tressette rivisto dopo la revisione critica di Codex (in `docs/TRESSETTE.md`).
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
+62
View File
@@ -0,0 +1,62 @@
# Tressette ai 21
All'oratorio, dal giocatore di carte: «Un tressette ai 21?». Quattro al tavolo, a coppie: Andrea e il socio
(di fronte) contro il giocatore di carte e l'altro. [DA ANDREA: chi sono il socio e l'altro.]
Motore `src/engine/tressette.js` (Claude), disegno `src/engine/tressette-render.js` con le carte della briscola.
## Regole
- Mazzo da 40, toscane o napoletane (stessa scelta e stesso flag `carte_napoletane` della briscola). Dieci carte a testa.
- Si gira verso destra (antiorario); esce chi sta a destra del mazziere, e il mazzo passa a destra a ogni smazzata.
- Obbligo di rispondere al seme. Prende la più alta del seme di uscita: 3, 2, Asso, regio, donna, gobbo, 7, 6, 5, 4.
- Punti in terzi: Asso 3 terzi, 3, 2 e figure 1 terzo; l'ultima presa 1 punto. 35 terzi per smazzata, i terzi
avanzati si perdono: le due coppie fanno sempre 11 punti in tutto.
- **Accuse**, dette da ognuno al proprio turno giocando la prima carta della smazzata: napoletana (Asso, 2 e 3
dello stesso seme) 3 punti; tre Assi, tre 2 o tre 3 fanno 3 punti (dicendo il seme che manca), quattro fanno 4.
Restano scritte in alto per tutta la smazzata: Andrea sa quel che sanno gli avversari.
- **Segni**, solo di chi esce: busso (prendi con la migliore e ritornamelo; si può sempre), striscio (ne ho
altre), volo (era l'ultima). Volo e striscio sono onesti: il gioco rifiuta quelli falsi. Si sentono: due nocche
sul tavolo, la carta strusciata, la carta lasciata cadere.
- Si va **ai 21**, presa per presa: il punteggio in alto conta accuse e punti interi già presi, e la coppia che
arriva a 21 per prima è fuori e ha vinto, anche a metà smazzata (le accuse contano alla fine della presa in cui
si dicono). Il risultato è subito definitivo: uscire dal riepilogo non lo cancella. Il riepilogo di fine
smazzata resta finché Andrea non va avanti.
Scelte da confermare con Andrea: «fuori» automatico ai 21 (al tavolo vero bisogna chiamarsi fuori, e se si
sbaglia si perde); nessun «cappotto»; nessuna napoletana lunga; segni ammessi anche alla prima presa.
## Avversari
- «Alla bona»: una carta a caso fra quelle buone, nessun segno.
- «Seria»: gioca a regola (carico al socio quando prende di sicuro, presa sicura con la carta più bassa, scarta
quel che costa meno, segni onesti) ma non conta: un 3 uscito per lui può essere ancora in giro.
- «A fassi male»: conta le carte uscite, capisce chi ha finito un seme (anche da chi vola), ricorda le accuse,
risponde al busso del socio prendendo con la migliore e poi gli ritorna il seme. Il socio di Andrea gioca sempre così.
- Tutti: il carico al socio che prende di sicuro, ma senza buttarci il 3 o il 2; a parità di lunghezza si esce dal
seme con meno punti da esporre (niente Asso scoperto).
Taratura (`node tools/calibrate_tressette.mjs 300`, Andrea che gioca come il socio): Andrea e il socio vincono
87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male»; circa tre smazzate a partita.
## Revisione di Codex (26 ottobre)
Revisione critica sul primo giro. Corretti: vittoria persa uscendo dal riepilogo; accuse tutte pubbliche prima
della prima carta; il seme mancante del tris noto all'IA ma non ad Andrea; striscio troppo stretto e busso con
un requisito inventato; il socio che non rispondeva al busso, sprecava il 3 su una presa già sua o usciva
d'Asso scoperto; il volo non letto da «A fassi male»; tastiera che poteva giocare la carta sbagliata; il
riepilogo che ripartiva da solo dopo sette secondi; la tavola descritta a parole per chi non vede il disegno.
Restano proposte (non fatte): una tabella di tavolo accessibile completa e la taratura contro una versione
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
`npm test` (`tests/tressette.test.mjs`) e `npm run browser:tressette`: partita intera ai 21 col mouse, una smazzata
col tocco (telefono e orizzontale) e con la tastiera, segni con pulsanti e tasti B, S, V, carta che non risponde
al seme rifiutata. Schermate in `/tmp/regolo-shots/tressette-*.png`.
+96 -3
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
<title>La Leggenda del Regolo</title>
<!-- Il Regolino (tools/favicon.py, disegnato da Codex pixel per pixel) -->
<link rel="icon" href="assets/favicon/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon/icon-192.png">
<link rel="apple-touch-icon" href="assets/favicon/apple-touch-icon.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -86,6 +91,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="version"></p>
</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>
<div class="buttons"><button id="tutorial-close">Torna al gioco</button></div>
</section>
<section id="credits" class="overlay" hidden aria-label="Ringraziamenti">
<h2>Ringraziamenti</h2>
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
@@ -103,6 +113,82 @@
<div id="inventory" aria-label="Inventario"></div>
</div>
<ol id="choices" hidden></ol>
<section id="scopa-controls" hidden aria-label="Comandi della scopa">
<p id="scopa-status" role="status"></p>
<div class="card-hand" aria-label="La mano di Andrea">
<button id="scopa-card-0" type="button"></button>
<button id="scopa-card-1" type="button"></button>
<button id="scopa-card-2" type="button"></button>
</div>
<div id="scopa-options" class="card-buttons" hidden aria-label="Quale presa"></div>
<button id="scopa-next" type="button" hidden>Prossima smazzata</button>
<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
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>
<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, e uno per ogni scopa. A pari, il punto non va a nessuno.</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>
</section>
<section id="tressette-controls" hidden aria-label="Comandi del tressette">
<p id="tressette-status" role="status"></p>
<div class="card-buttons" aria-label="Segni per il socio, solo quando esci">
<button id="tressette-busso" type="button">Busso</button>
<button id="tressette-striscio" type="button">Striscio</button>
<button id="tressette-volo" type="button">Volo</button>
<button id="tressette-next" type="button" hidden>Prossima smazzata</button>
</div>
<div class="card-hand-many" aria-label="La mano di Andrea">
<button id="tressette-card-0" type="button"></button>
<button id="tressette-card-1" type="button"></button>
<button id="tressette-card-2" type="button"></button>
<button id="tressette-card-3" type="button"></button>
<button id="tressette-card-4" type="button"></button>
<button id="tressette-card-5" type="button"></button>
<button id="tressette-card-6" type="button"></button>
<button id="tressette-card-7" type="button"></button>
<button id="tressette-card-8" type="button"></button>
<button id="tressette-card-9" type="button"></button>
</div>
<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.
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>
<p>L’Asso vale un punto; 3, 2 e figure un terzo di punto; l’ultima presa un punto. Si va ai 21: la coppia che
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;
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),
<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>
</details>
</section>
<section id="briscola-controls" hidden aria-label="Comandi della briscola">
<p id="briscola-status" role="status"></p>
<div class="card-hand" aria-label="La mano di Andrea">
<button id="briscola-card-0" type="button"></button>
<button id="briscola-card-1" type="button"></button>
<button id="briscola-card-2" type="button"></button>
</div>
<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.
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
di uscita. Ordine: Asso, 3, regio (re), donna (cavallo), gobbo (fante), 7, 6, 5, 4, 2.
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;
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>
</details>
</section>
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
<p id="bocce-status" role="status"></p>
<div class="bocce-adjust">
@@ -114,6 +200,7 @@
<button id="bocce-throw" type="button">Lancia</button>
</div>
<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.
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.
@@ -122,9 +209,15 @@
Vince chi fa più punti nella mano.</p>
</details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
<p>Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita.
Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare.
Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.</p>
<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.
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
la tua. Le frecce mostrano solo il primo tratto, fino al prossimo ostacolo o al massimo 50 cm.
Senza urti, il cerchietto indica dove ti fermi. La guida arancione segnala un primo contatto falloso.</p>
<p>Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare:
durante la carica la mira resta ferma. Frecce ← →: mira fine; Maiusc + ← →: regolazione ancora più precisa;
↑ ↓: forza. Esc esce dalla partita. I colpi sono centrali, senza effetti di rotazione.</p>
<p>Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti,
rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima
il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.</p>
+5 -1
View File
@@ -1,11 +1,15 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.8",
"version": "0.1.15",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
"browser:biliardo": "node tests/biliardo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
+191 -83
View File
@@ -1,115 +1,223 @@
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js';
import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono.
const rr = R * VIEW.k * 1.3;
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
const rr = R * VIEW.k;
function sphere(ctx, role, x, y, r) {
// Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
export const SHOT_DELAY = WINDUP;
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
export function tablePatches() {
const xs = [0, 108, 190, 1484, 1564, 1672], ys = [0, 134, 214, 722, 813, 941];
const xd = [0, VIEW.x - 16, VIEW.x, sx(LEN), sx(LEN) + 16, 640];
const yd = [0, VIEW.y - 16, VIEW.y, sy(WID), sy(WID) + 16, 360];
return ys.slice(1).flatMap((_, j) => xs.slice(1).map((_, i) => [
xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j],
xd[i], yd[j], xd[i + 1] - xd[i], yd[j + 1] - yd[j],
]));
}
// Ombra rivolta verso l'esterno rispetto alla lampada centrale.
function sphere(ctx, role, x, y, r, shadow = true) {
const [dark, mid, light] = BALL[role];
ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill();
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05);
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1);
}
function pin(ctx, p) {
const x = sx(p.x), y = sy(p.y), h = 7, w = 3;
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4'];
if (p.down) {
// Birillo a terra, dalla parte in cui è caduto.
ctx.strokeStyle = top; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke();
ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2);
return;
const lx = shadow ? (sx(LEN / 2) - x) / (LEN * VIEW.k / 2) : -0.6;
const ly = shadow ? (sy(WID / 2) - y) / (WID * VIEW.k / 2) - 0.25 : -0.6;
if (shadow) {
const dx = (x - sx(LEN / 2)) / (LEN * VIEW.k / 2) * 3;
const dy = (y - sy(WID / 2)) / (WID * VIEW.k / 2) * 3 + 1;
ctx.fillStyle = '#103b2bcc';
ctx.fillRect(Math.round(x - r + dx), Math.round(y - r / 2 + dy), Math.round(r * 2), Math.round(r));
}
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2);
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1);
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3);
// Sagoma a scalini, tre tinte e riflesso quadrato; r resta quello fisico.
const left = Math.floor(x - r), top = Math.floor(y - r);
for (let py = top; py < y + r; py++) for (let px = left; px < x + r; px++) {
const dx = px + 0.5 - x, dy = py + 0.5 - y, d = Math.hypot(dx, dy);
if (d > r) continue;
ctx.fillStyle = d > r - 1 ? '#302a1e' : dx * lx + dy * ly < -r * 0.2 ? dark : mid;
ctx.fillRect(px, py, 1, 1);
}
ctx.fillStyle = light; ctx.fillRect(Math.round(x + lx * r * 0.4) - 1, Math.round(y + ly * r * 0.4) - 1, 2, 2);
}
function table(ctx) {
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k;
// Cornice di legno con i diamanti, sponda, panno.
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32);
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26);
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3);
ctx.fillStyle = '#e7d7a8';
for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); }
for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); }
ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10);
ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h);
for (let i = 0; i < 420; i++) {
ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f';
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1);
export function pinFall(p, time) {
if (!p.down) return 0;
if (p.visualDownAt === undefined) return 1;
return Math.min(1, Math.max(0, (time - p.visualDownAt) / 0.24));
}
function pin(ctx, p, time) {
const x = sx(p.x), y = sy(p.y), fall = pinFall(p, time);
const tilt = Math.sin(fall * Math.PI / 2), lift = Math.cos(fall * Math.PI / 2);
const tx = x + Math.cos(p.dir ?? 0) * 7 * tilt;
const ty = y + Math.sin(p.dir ?? 0) * 7 * tilt - 7 * lift;
const [base, top] = p.red ? ['#8d1f1a', '#ff7060'] : ['#b3a57f', '#fff8e4'];
ctx.fillStyle = '#123c2bb0'; ctx.fillRect(Math.round(x - 1), Math.round(y + 1), 5 + Math.round(tilt * 3), 2);
// Piccoli blocchi seguono la rotazione senza linee levigate.
for (let i = 0; i <= 7; i++) {
ctx.fillStyle = i > 4 ? top : base;
ctx.fillRect(Math.round(x + (tx - x) * i / 7) - 1, Math.round(y + (ty - y) * i / 7) - 1, i > 4 ? 3 : 2, 2);
}
// La linea di metà tavolo e il punto del castello.
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h);
}
let lighting;
function table(ctx, bg) {
if (bg) for (const patch of tablePatches()) ctx.drawImage(bg, ...patch);
else {
ctx.fillStyle = '#21170f'; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#51321c'; ctx.fillRect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#14674d'; ctx.fillRect(VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
}
// La sala scompare in penombra, ma si riconoscono pavimento e boiserie.
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 640, 360);
ctx.rect(VIEW.x - 16, VIEW.y - 16, LEN * VIEW.k + 32, WID * VIEW.k + 32);
ctx.fillStyle = '#100f10bb'; ctx.fill('evenodd'); ctx.restore();
if (!lighting) {
lighting = document.createElement('canvas'); lighting.width = 125; lighting.height = 63;
const light = lighting.getContext('2d');
for (let y = 0; y < 63; y++) for (let x = 0; x < 125; x++) {
const d = Math.hypot((x - 62) / 69, (y - 31) / 38);
const warm = Math.max(0, 1 - d) * 0.22;
const shade = Math.max(0, d - 0.35) * 0.28;
light.fillStyle = `rgba(255,215,128,${Math.round(warm * 32) / 32})`; light.fillRect(x, y, 1, 1);
light.fillStyle = `rgba(4,25,20,${Math.round(shade * 32) / 32})`; light.fillRect(x, y, 1, 1);
}
}
ctx.drawImage(lighting, VIEW.x, VIEW.y, LEN * VIEW.k, WID * VIEW.k);
}
function aimGuide(ctx, c) {
const p = predict(c), me = c.balls[c.current];
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
const p = predict(c);
const foul = p.hit === 'pin' || p.hit === 'pallino';
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,248,220,0.85)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
ctx.stroke(); ctx.setLineDash([]);
if (p.ghost) {
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
if (p.dir) {
const o = c.balls[p.hit], L = 22;
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)';
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke();
ctx.strokeStyle = foul ? '#ffae8b' : 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
for (const track of p.outgoing) {
const [start, end] = track.points;
if (!end || Math.hypot(end[0] - start[0], end[1] - start[1]) < 1) continue;
const x = sx(end[0]), y = sy(end[1]), a = Math.atan2(end[1] - start[1], end[0] - start[0]);
ctx.strokeStyle = track.role === c.current ? '#f4df8d' : track.role === 'pallino' ? '#ffae8b' : '#96dfff';
ctx.setLineDash(track.role === c.current ? [2, 3] : []);
ctx.beginPath(); ctx.moveTo(sx(start[0]), sy(start[1])); ctx.lineTo(x, y); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(x - Math.cos(a - 0.5) * 5, y - Math.sin(a - 0.5) * 5);
ctx.lineTo(x, y); ctx.lineTo(x - Math.cos(a + 0.5) * 5, y - Math.sin(a + 0.5) * 5); ctx.stroke();
}
} else {
// Il cerchietto finale è l'arresto effettivo con la forza scelta.
const [x, y] = p.points.at(-1);
ctx.strokeStyle = '#fff8dc'; ctx.beginPath(); ctx.arc(sx(x), sy(y), 2, 0, Math.PI * 2); ctx.stroke();
}
// La stecca dietro la biglia, tirata indietro quanto la forza.
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back;
ctx.lineCap = 'round';
ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke();
ctx.lineCap = 'butt';
}
export function drawBiliardo(ctx, c, def) {
ctx.save();
// La sala, in penombra, e il lume sopra il tavolo.
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360);
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420);
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)');
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360);
table(ctx);
for (const p of c.pins) pin(ctx, p);
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c);
for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
// Punteggio: i due nomi, i punti, chi deve tirare.
panel(ctx, 18, 8, 604, 62);
function cue(ctx, x, y, angle, back, alpha = 1) {
ctx.save(); ctx.globalAlpha = alpha;
ctx.translate(sx(x), sy(y)); ctx.rotate(angle);
ctx.fillStyle = '#25170e'; ctx.fillRect(-back - 150, -2, 150, 5);
ctx.fillStyle = '#c79a59'; ctx.fillRect(-back - 148, -1, 145, 2);
ctx.fillStyle = '#81502a'; ctx.fillRect(-back - 148, -1, 62, 3);
ctx.fillStyle = '#f2d59a'; ctx.fillRect(-back - 75, -1, 70, 1);
ctx.fillStyle = '#b8d3cf'; ctx.fillRect(-back - 5, -1, 3, 3);
ctx.fillStyle = '#568f9c'; ctx.fillRect(-back - 2, -1, 2, 3);
ctx.restore();
}
export function tozziPose(c) {
const last = c.last;
if (last && last.age < 2.4) {
if (last.shooter === 'cpu' && (last.foul || !last.points)) return 'shrug';
if (last.to === 'cpu' && last.points > 0) return 'cheer';
}
return c.step === 'aim' && c.current === 'cpu' ? 'chalk' : 'stand';
}
export const TOZZI = { x: 26, y: 258, scale: 0.46 };
function tozzi(ctx, c, sheet) {
if (!sheet) return;
const pose = tozziPose(c), frame = sheet.atlas.frames[pose];
const { x, y, scale } = TOZZI;
const bob = pose === 'chalk' ? Math.floor(Math.sin(c.visual.time * 9) * 0.8) : 0;
ctx.fillStyle = '#080a09aa'; ctx.fillRect(10, y - 1, 33, 4);
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale) + bob,
Math.round(frame.w * scale), Math.round(frame.h * scale));
}
const SEGMENTS = [0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f];
function digits(ctx, value, x, y) {
const segments = [[2, 0, 7, 2], [9, 2, 2, 6], [9, 10, 2, 6], [2, 16, 7, 2],
[0, 10, 2, 6], [0, 2, 2, 6], [2, 8, 7, 2]];
for (const [i, digit] of String(value).padStart(2, '0').split('').entries()) {
segments.forEach(([dx, dy, w, h], bit) => {
ctx.fillStyle = SEGMENTS[Number(digit)] & (1 << bit) ? '#ff5a3d' : '#381b17';
ctx.fillRect(x + i * 15 + dx, y + dy, w, h);
});
}
}
function scoreboard(ctx, c, def) {
for (const [i, who] of ['player', 'cpu'].entries()) {
const x = 30 + i * 300, y = 24;
sphere(ctx, who, x + 4, y, 4);
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]);
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2');
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3');
const x = 70 + i * 352, active = c.step === 'aim' && c.current === who;
ctx.fillStyle = '#0e0e0c'; ctx.fillRect(x - 2, 7, 152, 43);
ctx.fillStyle = '#705039'; ctx.fillRect(x, 7, 148, 1);
ctx.fillStyle = '#36281d'; ctx.fillRect(x, 49, 148, 2);
ctx.fillStyle = '#b09359'; ctx.fillRect(x + 3, 10, 1, 1); ctx.fillRect(x + 144, 46, 1, 1);
text(ctx, def.names[who], x + 10, 20, 8, '#ddcfad');
sphere(ctx, who, x + 13, 36, 3, false);
text(ctx, active ? 'AL TIRO' : 'PUNTI', x + 24, 36, 8, active ? '#eac27d' : '#8e8064');
digits(ctx, c.points[who], x + 105, 22);
}
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : '';
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
text(ctx, 'CINQUE BIRILLI', 320, 15, 8, '#cab58c', 'center');
if (canThrow(c)) {
panel(ctx, 18, 318, 176, 34);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 320, 29, 8, '#eee0ba', 'center');
ctx.fillStyle = '#151c16'; ctx.fillRect(245, 39, 150, 5);
for (let i = 0; i < 30; i++) {
ctx.fillStyle = i / 30 < c.power ? i > 23 ? '#c19554' : '#9fbc82' : '#303b2d';
ctx.fillRect(246 + i * 5, 40, 3, 3);
}
} else text(ctx, `A ${c.target} PUNTI`, 320, 33, 8, '#b0a080', 'center');
}
export function drawBiliardo(ctx, c, def, bg, sheet) {
ctx.save(); ctx.imageSmoothingEnabled = false;
table(ctx, bg);
tozzi(ctx, c, sheet);
const view = visualFrame(c);
ctx.save(); ctx.beginPath(); ctx.rect(VIEW.x - 15, VIEW.y - 15, LEN * VIEW.k + 30, WID * VIEW.k + 30); ctx.clip();
if (c.step === 'aim' && (c.current === 'player' || c.plan)) {
aimGuide(ctx, c);
const me = c.balls[c.current];
cue(ctx, me.x, me.y, c.angle, rr + 6 + c.power * 26);
} else if (c.visual.shot) {
const shot = c.visual.shot, age = c.visual.time - shot.at;
if (age < SHOT_DELAY + 0.12) {
const pull = 6 + shot.power * 26;
const back = age < 0.12 ? pull + age / 0.12 * 10 : Math.max(0, (pull + 10) * (1 - (age - 0.12) / 0.06));
cue(ctx, shot.x, shot.y, shot.angle, rr + back, age < SHOT_DELAY ? 1 : 1 - (age - SHOT_DELAY) / 0.12);
}
}
for (const p of view.pins) pin(ctx, p, view.time);
for (const role of ['pallino', 'cpu', 'player']) { const b = view.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
ctx.restore();
scoreboard(ctx, c, def);
const hint = canThrow(c) ? predict(c).hit : null;
const instruction = hint === 'pin' ? 'Attenzione: la tua biglia va sui birilli (fallo).'
: hint === 'pallino' ? 'Attenzione: tocchi prima il pallino (fallo).'
: c.aimLocked ? 'Mira fissata: un altro clic la sposta.' : 'La stecca segue il mouse: clic per fissare la mira.';
ctx.fillStyle = '#17160fd9'; ctx.fillRect(54, 53, 532, 15);
// Il carattere pixel è disegnato per gli 8 punti: a 6 la B si confonde con la D. Le righe lunghe restano a 6.
const say = c.step === 'aim' ? c.current === 'player' ? instruction : `${def.names.cpu} studia il colpo…` : status(c, def.names);
text(ctx, say, 320, 60, say.length * 8 <= 600 ? 8 : 6, hint === 'pin' || hint === 'pallino' ? '#ffae8b' : '#d7ccb4', 'center');
if (c.step === 'result') {
panel(ctx, 170, 140, 300, 80);
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center');
text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center');
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 8, '#d7ccb4', 'center');
}
ctx.restore();
}
+159 -50
View File
@@ -7,10 +7,14 @@
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
export const SWING = 1.4, FIXED_STEP = 1 / 240;
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
export const WINDUP = 0.18;
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
export const OTHER = { player: 'cpu', cpu: 'player' };
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
export const VIEW = { x: 57, y: 78, k: 1.85 };
export const VIEW = { x: 70, y: 88, k: 1.76 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
@@ -35,11 +39,21 @@ export function reach(v) {
export const canThrow = c => c.step === 'aim' && c.current === 'player';
export const moving = c => list(c).some(b => b.vx || b.vy);
// Quanto è bravo l'Ingegnere: quanti colpi prova a vuoto (direzioni intorno al tavolo, ritocchi vicino alla biglia
// avversaria, forze) e quanto gli trema la mano. «A fassi male» è il campione vero: vince quasi sempre.
export const LEVELS = {
bona: { angles: 0, fine: 0.15, powers: [0.6], wobble: 0.16, powerNoise: 0.2 },
seria: { angles: 14, fine: 0.09, powers: [0.5, 0.8], wobble: 0.072, powerNoise: 0.1 },
male: { angles: 64, fine: 0.025, powers: [0.3, 0.5, 0.7, 0.9], wobble: 0.03, powerNoise: 0.05 },
};
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
c.pins = castle();
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
c.visual = { time: 0, shot: null };
c.angle = angleTo(c.balls.player, c.balls.cpu);
return c;
}
@@ -72,12 +86,12 @@ function cushions(b) {
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
export function collide(a, b) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 2 * R) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2;
if (d > 2 * R + CONTACT_EPS) return 0;
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (2 * R - d) / 2);
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (closing <= 0) return 0;
const j = closing * 0.975;
if (closing <= CONTACT_EPS) return 0;
const j = closing * (1 + BALL_RESTITUTION) / 2;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
return closing;
}
@@ -87,38 +101,81 @@ function touch(shot, a, b) {
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
}
// Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
// Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
function contactTime(dx, dy, vx, vy, radius) {
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
const gap = dx * dx + dy * dy - radius * radius;
if (gap <= CONTACT_EPS) return 0;
const discriminant = closing * closing - speed2 * gap;
if (discriminant <= 0) return Infinity;
return gap / (-closing + Math.sqrt(discriminant));
}
function slow(b, dt) {
const v = Math.hypot(b.vx, b.vy);
if (!v) return;
const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
const scale = nv < EPS ? 0 : nv / v;
b.vx *= scale; b.vy *= scale;
}
function advance(balls, dt) {
for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
}
function step(w, dt) {
let hit = 0, rail = 0;
for (const b of w.balls) {
const v = Math.hypot(b.vx, b.vy);
if (v < EPS) { b.vx = b.vy = 0; continue; }
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt;
b.x += b.vx / v * travel; b.y += b.vy / v * travel;
b.vx *= nv / v; b.vy *= nv / v;
rail = Math.max(rail, cushions(b));
for (const p of w.pins) {
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue;
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio.
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) });
b.vx *= 0.88; b.vy *= 0.88;
w.shot?.pins.push(p);
// Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
// Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
for (const b of w.balls) slow(b, dt / 2);
let left = dt, hit = 0, rail = 0;
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
for (let events = 0; left > 1e-10 && events < 64; events++) {
let time = left, event = null;
const consider = (t, kind, a, b, axis, edge) => {
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
};
for (let i = 0; i < w.balls.length; i++) {
const a = w.balls[i];
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
for (let j = i + 1; j < w.balls.length; j++) {
const b = w.balls[j];
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
}
if (!a.vx && !a.vy) continue;
for (const p of w.pins) if (!p.down)
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
}
advance(w.balls, time); left -= time;
if (!event) break;
const { kind, a, b, axis, edge } = event;
if (kind === 'rail') {
const v = axis === 'x' ? 'vx' : 'vy';
a[axis] = edge;
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
} else if (kind === 'ball') {
const impact = collide(a, b);
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
} else {
const v = Math.hypot(a.vx, a.vy);
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
a.vx *= 0.88; a.vy *= 0.88;
w.shot?.pins.push(b);
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
}
w.onContact?.(event);
}
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) {
const s = collide(w.balls[i], w.balls[j]);
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
}
for (const b of w.balls) slow(b, dt / 2);
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
}
function strike(c, who, angle, power) {
const b = c.balls[who], v = speedFor(power);
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
c.windup = WINDUP;
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
c.events.push({ kind: 'biliardo-cue', power });
return true;
}
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
@@ -133,6 +190,7 @@ export function simulate(c, who, angle, power) {
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
function reset(c) {
for (const p of c.pins) delete p.visualDownAt;
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
for (let pass = 0; pass < 4; pass++) {
for (const b of list(c)) for (const p of c.pins) {
@@ -162,10 +220,10 @@ function finish(c) {
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
function cpuCandidates(c) {
const base = angleTo(c.balls.cpu, c.balls.player), angles = [];
for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2);
for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d);
return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power })));
const k = c.skill ?? LEVELS.male, base = angleTo(c.balls.cpu, c.balls.player), angles = [];
for (let i = 0; i < k.angles; i++) angles.push(i / k.angles * Math.PI * 2);
for (let d = -0.3; d <= 0.3; d += k.fine) angles.push(base + d);
return angles.flatMap(angle => k.powers.map(power => ({ angle, power })));
}
function cpuThink(c, budget = 12) {
const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
@@ -174,16 +232,28 @@ function cpuThink(c, budget = 12) {
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
}
if (t.todo.length) return;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) };
const k = c.skill ?? LEVELS.male;
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? k.wobble), power: clamp(t.best.power + deviation(c) * k.powerNoise, 0.05, 1) };
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt;
c.visual.time += dt;
if (c.last) c.last.age += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling') {
if (c.step === 'rolling' && c.windup > 0) {
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
c.windup -= dt;
if (c.windup <= 0) {
const { ball, vx, vy, power } = c.pending;
ball.vx = vx; ball.vy = vy; c.pending = null;
c.events.push({ kind: 'biliardo-cue', power });
dt = -c.windup; c.windup = 0;
} else dt = 0;
}
if (c.step === 'rolling' && !c.windup) {
c.accumulator += dt;
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
@@ -193,17 +263,26 @@ export function update(c, dt) {
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
}
if (c.step === 'rolling' || c.step === 'settle')
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
}
// --- Comandi del giocatore: gli stessi delle bocce.
export function aimAt(c, x, y) {
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false;
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const b = c.balls.player;
if (Math.hypot(x - b.x, y - b.y) < R) return false;
c.angle = Math.atan2(y - b.y, x - b.x); return true;
}
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); }
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; }
export function aimScreen(c, [sx, sy]) {
const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
return aimAt(c, x, y);
}
export function nudge(c, dir, fine = false) {
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
c.angle += dir * (fine ? 0.0005 : 0.002); return true;
}
export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true;
@@ -223,24 +302,54 @@ export function release(c) {
c.holding = false; return strike(c, 'player', c.angle, c.power);
}
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza),
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita.
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
// Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
const predictions = new WeakMap();
export function predict(c) {
const me = c.balls[c.current], others = list(c).filter(b => b !== me);
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power));
const points = [[x, y]], STEP = 0.5;
for (let n = 0; left > 0 && n < 6000; n++) {
x += dx * STEP; y += dy * STEP; left -= STEP;
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); }
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); }
for (const o of others) {
const d = Math.hypot(o.x - x, o.y - y);
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; }
const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
const cached = predictions.get(c);
if (cached?.key === key) return cached.result;
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
const v = speedFor(c.power);
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
const tracks = [];
const point = b => [b.x, b.y];
const endTrack = track => {
track.points.push(point(track.ball)); track.done = true;
};
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
const { kind, a, b } = event;
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
if (result.hit || (a !== me && b !== me)) return;
result.points.push(point(me));
if (kind === 'rail') return;
result.ghost = { x: me.x, y: me.y };
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
if (kind === 'pin') return;
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
for (const ball of [me, other]) {
const points = [point(ball)];
tracks.push({ ball, points, done: false });
result.outgoing.push({ role: ball.role, points });
}
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; }
} };
for (let i = 0; i < 15 / FIXED_STEP; i++) {
step(w, FIXED_STEP);
for (const track of tracks) {
if (track.done) continue;
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
}
if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
}
points.push([x, y]);
return { points, ghost: null, hit: null, dir: null };
if (!result.hit) result.points.push(point(me));
predictions.set(c, { key, result });
return result;
}
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
+14 -5
View File
@@ -19,8 +19,16 @@ export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
export function newBocce(seed = Date.now() >>> 0) {
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
// Quanto è bravo Mariano: quanto si allarga il suo tiro e quanto spesso prova a bocciare la boccia migliore.
// «A fassi male» è il campione vero del pallaio.
export const LEVELS = {
bona: { spread: 3, attack: 0.1 },
seria: { spread: 1.15, attack: 0.4 },
male: { spread: 0.22, attack: 0.85 },
};
export function newBocce(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], balls: [], turn: 0, current: 'player', power: 0.5,
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
c.aim = { u: c.jack.u, w: c.jack.w };
@@ -134,10 +142,11 @@ function nextTurn(c) {
function cpuThrow(c) {
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45;
const k = c.skill ?? LEVELS.male;
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < k.attack;
const target = attack ? other : c.jack;
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2);
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL);
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2) * k.spread, 10, LANE - 2);
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8) * k.spread, -HALF + BALL, HALF - BALL);
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
}
export function update(c, dt) {
+309
View File
@@ -0,0 +1,309 @@
import { canPlay, cardName, counted, countRows, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './briscola.js';
import { text, panel } from './bocce-render.js';
// Una carta in volo da un rettangolo [x, y, w, h] a un altro, con k da 0 a 1 (parte svelta, arriva piano).
// La usano anche tressette e scopa.
export function flight(from, to, k) {
const e = 1 - (1 - Math.min(1, Math.max(0, k))) ** 3;
return from.map((v, i) => v + (to[i] - v) * e);
}
// Coordinate condivise da disegno e hit test: bersagli larghi anche sul telefono.
export const HAND = { x: 176, y: 230, gap: 102, w: 84, h: 120 };
export function cardAt(c, [x, y]) {
if (!canPlay(c)) return -1;
return c.hands.player.findIndex((_, i) => x >= HAND.x + i * HAND.gap && x < HAND.x + i * HAND.gap + HAND.w && y >= HAND.y && y < HAND.y + HAND.h);
}
const ink = '#38271f', paper = '#fff0cd', gold = '#dba32b', red = '#aa4034', blue = '#386a89', green = '#487348';
function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
function poly(ctx, points, color) {
ctx.fillStyle = color; ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); ctx.fill();
}
// Simboli originali: moneta incisa, calice, lama ricurva e bastone coi nodi.
function suit(ctx, name, x, y) {
ctx.save(); ctx.translate(x, y);
if (name === 'denari') {
rect(ctx, -4, -3, 8, 6, ink); rect(ctx, -3, -4, 6, 8, ink);
rect(ctx, -3, -2, 6, 4, gold); rect(ctx, -2, -3, 4, 6, gold);
rect(ctx, -2, -2, 1, 4, '#ffe58c'); rect(ctx, 0, -1, 2, 2, '#9e6b25');
} else if (name === 'coppe') {
rect(ctx, -4, -4, 8, 2, ink); rect(ctx, -3, -2, 6, 3, gold);
rect(ctx, -2, 0, 4, 2, red); rect(ctx, -1, 2, 2, 2, ink);
rect(ctx, -3, 4, 6, 1, gold); rect(ctx, -3, -3, 6, 1, '#ffdd70');
} else if (name === 'spade') {
poly(ctx, [[-4,5],[-3,1],[0,-1],[2,-5],[4,-6],[3,-1],[-1,3],[-2,6]], ink);
rect(ctx, 1, -3, 2, 3, '#9eb3ba'); rect(ctx, -1, 0, 2, 2, '#c9d8d6');
rect(ctx, -5, 3, 5, 1, gold); rect(ctx, -4, 5, 2, 2, red);
} else {
poly(ctx, [[-3,6],[-4,4],[-1,-1],[0,-6],[3,-5],[3,-2],[0,3],[-1,6]], ink);
rect(ctx, 0, -4, 2, 5, '#94632e'); rect(ctx, -2, 1, 2, 4, '#ae7a39');
rect(ctx, 2, -3, 2, 2, gold); rect(ctx, -3, 2, 2, 2, gold); rect(ctx, 3, -5, 2, 1, green);
}
ctx.restore();
}
function regolo(ctx) {
// Cornice simmetrica e un piccolo re sorridente, avvolto nelle proprie spire.
rect(ctx, 3, 3, 36, 54, blue); rect(ctx, 5, 5, 32, 50, gold); rect(ctx, 6, 6, 30, 48, '#203e44');
for (let y = 9; y < 54; y += 6) for (const x of [8, 32]) rect(ctx, x, y, 2, 2, '#63867c');
poly(ctx, [[15,32],[28,32],[32,36],[32,43],[28,47],[14,47],[10,43],[10,37]], ink);
poly(ctx, [[15,34],[27,34],[30,37],[30,42],[27,45],[15,45],[12,42],[12,38]], green);
poly(ctx, [[17,37],[26,37],[28,39],[27,41],[16,41],[15,39]], '#203e44');
rect(ctx, 14, 42, 13, 2, '#a3bc72'); rect(ctx, 16, 36, 11, 1, '#a3bc72');
poly(ctx, [[19,25],[25,25],[25,33],[23,36],[18,36],[17,33],[20,32]], green);
rect(ctx, 20, 28, 2, 6, '#d0cd83');
poly(ctx, [[15,16],[27,16],[30,19],[30,25],[27,29],[15,29],[12,25],[12,20]], ink);
poly(ctx, [[16,17],[26,17],[29,20],[29,24],[26,27],[16,27],[13,24],[13,21]], '#94b365');
rect(ctx, 15, 19, 4, 4, paper); rect(ctx, 23, 19, 4, 4, paper);
rect(ctx, 17, 20, 2, 3, ink); rect(ctx, 23, 20, 2, 3, ink);
rect(ctx, 18, 25, 6, 1, ink); rect(ctx, 16, 24, 2, 1, ink); rect(ctx, 24, 24, 2, 1, ink);
rect(ctx, 17, 12, 9, 4, gold); for (const x of [17, 21, 25]) rect(ctx, x, 9, 1, 4, '#ffe396');
rect(ctx, 21, 13, 1, 2, red);
for (const x of [15, 25]) { rect(ctx, x, 50, 2, 2, gold); rect(ctx, x + 1, 49, 1, 4, gold); }
}
function figure(ctx, rank, name) {
// Figure intere: fante in tunica, cavaliere a cavallo, re con corona e manto.
const king = rank === 'Re', horse = rank === 'Cavallo', y = horse ? 15 : 17;
if (horse) {
poly(ctx, [[8,36],[12,29],[27,30],[30,23],[35,23],[37,29],[32,33],[31,41],[12,41]], ink);
rect(ctx, 11, 33, 20, 7, '#b78044'); rect(ctx, 30, 26, 4, 8, '#c79c60');
rect(ctx, 12, 40, 3, 10, ink); rect(ctx, 27, 39, 3, 11, ink);
rect(ctx, 15, 40, 2, 8, '#a36b3c'); rect(ctx, 32, 27, 1, 1, paper);
}
if (king) poly(ctx, [[15,24],[29,24],[33,48],[10,48]], red);
poly(ctx, [[19,y],[25,y],[27,y+2],[28,y+4],[26,y+5],[26,y+8],[20,y+9],[17,y+6],[17,y+2]], ink);
poly(ctx, [[20,y+1],[25,y+1],[25,y+3],[27,y+4],[25,y+5],[25,y+7],[21,y+8],[19,y+5]], '#eab578');
rect(ctx, 18, y + 1, 2, 4, '#86502d'); rect(ctx, 20, y + 1, 4, 1, '#ffe0a2');
rect(ctx, 24, y + 2, 1, 2, ink); rect(ctx, 24, y + 6, 3, 1, ink);
rect(ctx, 17, y - 2, 10, 3, king ? gold : blue);
if (king) for (const x of [17, 21, 25]) rect(ctx, x, y - 5, 2, 4, gold);
else rect(ctx, 18, y - 5, 2, 4, red);
poly(ctx, [[18,y+9],[26,y+9],[29,y+12],[27,y+19],[29,y+24],[15,y+24],[17,y+19],[15,y+12]], ink);
poly(ctx, [[19,y+10],[25,y+10],[27,y+12],[25,y+19],[27,y+22],[17,y+22],[19,y+18],[17,y+12]], horse ? red : blue);
rect(ctx, 18, y + 10, 3, 10, king ? gold : '#6291a5'); rect(ctx, 17, y + 19, 11, 2, gold);
rect(ctx, 23, y + 11, 1, 6, paper); rect(ctx, 23, y + 19, 2, 2, ink);
rect(ctx, 12, y + 11, 4, 3, '#eab578'); rect(ctx, 28, y + 10, 4, 4, '#eab578');
if (!horse) {
poly(ctx, [[17,y+24],[21,y+24],[20,y+32],[16,y+32]], ink);
poly(ctx, [[23,y+24],[27,y+24],[29,y+32],[25,y+32]], ink);
rect(ctx, 18, y + 24, 2, 5, '#b9884f'); rect(ctx, 24, y + 24, 2, 4, '#b9884f');
rect(ctx, 15, y + 32, 6, 2, red); rect(ctx, 24, y + 32, 6, 2, red);
}
suit(ctx, name, 10, 24);
if (king) { rect(ctx, 32, 22, 1, 24, gold); rect(ctx, 31, 20, 3, 3, gold); }
}
const PIPS = {
Asso: [[21,32]], 2: [[21,22],[21,42]], 3: [[21,20],[21,32],[21,44]],
4: [[13,23],[29,23],[13,41],[29,41]],
5: [[13,21],[29,21],[21,32],[13,43],[29,43]],
6: [[13,20],[29,20],[13,32],[29,32],[13,44],[29,44]],
7: [[13,19],[29,19],[13,32],[29,32],[13,45],[29,45],[21,32]],
};
// Le toscane hanno semi francesi, nessun indice sulle numerali e figure intere.
const TUSCAN_COLORS = {
cuori: { cloth: '#326447', light: '#65936a', cloak: '#ab393b', ground: '#bc7760' },
mattoni: { cloth: '#a93832', light: '#d56a46', cloak: '#315f7e', ground: '#b3a05b' },
fiori: { cloth: '#315e85', light: '#6995ac', cloak: '#bc842e', ground: '#829968' },
picche: { cloth: '#755079', light: '#ac7d9a', cloak: '#376b66', ground: '#9e8271' },
};
const TUSCAN_PIPS = {
Asso: [[21,30]], 2: [[21,17],[21,43]], 3: [[21,15],[21,30],[21,45]],
4: [[12,16],[30,16],[12,44],[30,44]],
5: [[12,16],[30,16],[21,30],[12,44],[30,44]],
6: [[12,15],[30,15],[12,30],[30,30],[12,45],[30,45]],
7: [[10,14],[32,14],[21,22],[10,30],[32,30],[10,46],[32,46]],
};
function tuscanSuit(ctx, name, x, y, inverted = false) {
ctx.save(); ctx.translate(x, y);
if (inverted) ctx.scale(1, -1);
const color = name === 'cuori' || name === 'mattoni' ? '#c12f35' : '#202326';
// Sagome a gradini sulla griglia: nessuna sfumatura nei semi.
if (name === 'cuori') {
poly(ctx, [[-4,-4],[-1,-4],[0,-3],[1,-4],[4,-4],[5,-3],[5,0],[4,1],[4,2],[3,2],[3,3],[2,3],[2,4],[1,4],[1,5],[0,6],[-1,5],[-1,4],[-2,4],[-2,3],[-3,3],[-3,2],[-4,2],[-4,1],[-5,0],[-5,-3]], color);
} else if (name === 'mattoni') {
poly(ctx, [[0,-6],[1,-4],[2,-3],[3,-2],[5,0],[3,2],[2,3],[1,4],[0,6],[-1,4],[-2,3],[-3,2],[-5,0],[-3,-2],[-2,-3],[-1,-4]], color);
} else if (name === 'fiori') {
poly(ctx, [[-2,-5],[2,-5],[3,-4],[3,-2],[2,-1],[4,-2],[6,0],[6,2],[4,4],[2,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-2,4],[-4,4],[-6,2],[-6,0],[-4,-2],[-2,-1],[-3,-2],[-3,-4]], color);
} else {
poly(ctx, [[0,-6],[1,-5],[1,-4],[3,-2],[5,0],[5,3],[3,4],[1,3],[1,4],[3,6],[-3,6],[-1,4],[-1,3],[-3,4],[-5,3],[-5,0],[-3,-2],[-1,-4],[-1,-5]], color);
}
ctx.restore();
}
function tuscanFigure(ctx, rank, name) {
const p = TUSCAN_COLORS[name], skin = '#e9b785', shade = '#bf805d', trim = '#e0b957';
const queen = rank === 'Donna', king = rank === 'Regio';
// Cornice sottile, terreno e silhouette originali, disegnati su una griglia 42×60.
rect(ctx, 3, 3, 36, 54, '#b6a48a'); rect(ctx, 3.5, 3.5, 35, 53, paper);
poly(ctx, [[4,51],[14,49],[26,50],[38,48],[38,56],[4,56]], p.ground);
if (queen) {
// Dama di profilo: diadema, manica a sbuffo, gonna a ventaglio e fazzoletto.
poly(ctx, [[21,11],[27,11],[29,14],[28,22],[31,26],[27,29],[19,24],[18,18]], ink);
poly(ctx, [[22,12],[26,12],[27,15],[25,19],[22,21],[20,18],[19,17],[21,16]], skin);
rect(ctx, 23, 12, 4, 2, '#70452c'); rect(ctx, 26, 14, 2, 7, '#70452c');
rect(ctx, 21, 15, 1, 1, ink); rect(ctx, 20, 18, 2, 1, shade);
poly(ctx, [[21,12],[21,10],[23,11],[24,9],[25,11],[27,10],[27,13]], trim);
rect(ctx, 24, 10, 1, 1, '#c12f35');
poly(ctx, [[21,21],[26,20],[30,24],[28,31],[31,39],[37,54],[12,54],[15,43],[18,33],[17,26]], ink);
poly(ctx, [[22,22],[26,21],[28,24],[26,31],[29,39],[35,53],[14,53],[17,42],[20,32],[19,26]], p.cloth);
poly(ctx, [[21,32],[25,32],[25,40],[31,52],[23,51],[19,46]], p.light);
poly(ctx, [[26,34],[29,41],[33,51],[30,48],[27,43]], trim);
poly(ctx, [[19,35],[18,45],[16,52],[20,52],[21,49],[19,47]], p.cloak);
poly(ctx, [[18,23],[22,25],[20,29],[16,31],[12,29],[13,26]], '#d8dfd7');
rect(ctx, 17, 25, 4, 2, '#94a7ad');
poly(ctx, [[14,28],[16,30],[12,33],[9,32],[10,30]], skin);
poly(ctx, [[9,31],[12,32],[10,35],[8,35],[6,40],[6,35]], '#fcf7df');
rect(ctx, 8, 34, 1, 3, '#94a7ad');
poly(ctx, [[20,29],[26,29],[27,31],[20,33],[18,32]], trim);
rect(ctx, 22, 23, 1, 3, trim); rect(ctx, 16, 54, 7, 1, ink);
} else if (king) {
// Regio anziano: corona sempre visibile, barba bianca e bordi d'ermellino.
poly(ctx, [[15,23],[27,22],[32,27],[36,54],[9,54],[11,31]], ink);
poly(ctx, [[15,24],[27,23],[30,28],[34,53],[11,53],[13,31]], p.cloak);
poly(ctx, [[18,27],[25,27],[27,52],[16,52]], p.cloth);
rect(ctx, 20, 33, 3, 18, p.light); rect(ctx, 16, 48, 11, 2, trim);
poly(ctx, [[15,24],[18,25],[16,34],[15,45],[12,52],[11,51],[13,33]], '#f6edcf');
poly(ctx, [[26,24],[28,25],[29,35],[32,51],[29,52],[26,35],[24,27]], '#f6edcf');
for (const [x,y] of [[14,29],[13,37],[12,46],[27,29],[28,38],[30,47]]) rect(ctx, x, y, 1, 2, ink);
poly(ctx, [[17,13],[24,12],[27,16],[26,23],[23,28],[18,26],[15,21],[15,16]], '#b9bfbb');
poly(ctx, [[18,14],[24,14],[25,17],[27,19],[24,20],[24,22],[19,22],[17,18]], skin);
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 18, 16, 1, 2, shade);
poly(ctx, [[16,18],[19,21],[22,20],[25,21],[24,25],[21,28],[18,25],[16,23]], '#f7f0d7');
rect(ctx, 21, 22, 3, 1, '#9ba8a3'); rect(ctx, 18, 22, 1, 3, '#c6cdc0');
poly(ctx, [[16,14],[15,8],[17,8],[18,11],[20,10],[20,7],[22,7],[22,10],[24,11],[25,8],[27,8],[26,14]], ink);
poly(ctx, [[17,13],[16,9],[17,9],[18,12],[21,11],[21,8],[22,12],[25,12],[26,9],[25,13]], trim);
rect(ctx, 17, 13, 9, 2, trim); rect(ctx, 21, 13, 2, 1, '#c12f35');
rect(ctx, 33, 23, 1, 30, '#9b6a29'); rect(ctx, 33, 24, 1, 27, trim);
poly(ctx, [[33,19],[35,21],[35,23],[33,25],[31,23],[31,21]], trim);
rect(ctx, 33, 21, 1, 2, '#fff1ae'); rect(ctx, 31, 32, 4, 3, skin);
rect(ctx, 10, 33, 4, 3, skin); rect(ctx, 15, 53, 6, 2, ink); rect(ctx, 24, 53, 6, 2, ink);
} else {
// Gobbo giovane, senza corona: berretto morbido, mantello corto e mazza.
poly(ctx, [[15,23],[26,22],[31,27],[33,39],[27,43],[11,40],[10,28]], ink);
poly(ctx, [[15,24],[25,23],[29,27],[31,38],[27,41],[12,39],[12,28]], p.cloak);
poly(ctx, [[18,37],[23,38],[21,48],[20,53],[15,53],[17,46]], ink);
poly(ctx, [[24,37],[28,37],[28,46],[31,53],[26,53],[23,46]], ink);
rect(ctx, 18, 41, 2, 9, '#d3ad60'); rect(ctx, 25, 41, 2, 6, '#d3ad60');
rect(ctx, 16, 50, 4, 4, '#744531'); rect(ctx, 27, 50, 3, 4, '#744531');
rect(ctx, 13, 54, 7, 1, ink); rect(ctx, 26, 54, 7, 1, ink);
poly(ctx, [[17,12],[24,12],[27,16],[26,23],[22,25],[17,22],[15,18]], '#70452c');
poly(ctx, [[19,14],[24,14],[24,16],[26,18],[24,19],[24,21],[21,23],[18,20]], skin);
rect(ctx, 23, 16, 1, 2, ink); rect(ctx, 23, 20, 2, 1, shade);
rect(ctx, 17, 15, 2, 7, '#70452c'); rect(ctx, 16, 19, 2, 5, '#70452c');
poly(ctx, [[16,14],[17,11],[21,10],[25,11],[26,14],[28,15],[16,15]], p.cloth);
rect(ctx, 18, 13, 8, 1, p.light);
poly(ctx, [[18,24],[24,23],[27,27],[25,33],[28,40],[15,40],[17,33],[15,27]], ink);
poly(ctx, [[19,25],[23,24],[25,27],[23,33],[26,39],[17,39],[19,33],[17,27]], p.cloth);
rect(ctx, 20, 26, 2, 7, p.light); rect(ctx, 16, 34, 10, 2, trim);
poly(ctx, [[18,23],[21,25],[20,29],[17,25]], '#f6edcf');
rect(ctx, 21, 34, 2, 2, ink);
poly(ctx, [[10,27],[13,29],[12,33],[9,34],[8,32]], skin);
poly(ctx, [[27,28],[30,30],[28,34],[25,35],[24,33],[27,32]], skin);
poly(ctx, [[7,30],[9,30],[9,46],[11,50],[11,53],[6,53],[6,49],[7,46]], ink);
rect(ctx, 8, 33, 1, 17, '#ae783a'); rect(ctx, 7, 49, 3, 3, trim);
rect(ctx, 7, 31, 3, 2, skin);
}
ctx.save(); ctx.translate(9, 8); ctx.scale(0.65, 0.65); tuscanSuit(ctx, name, 0, 0); ctx.restore();
text(ctx, queen ? 'D' : king ? 'R' : 'G', 7, 16, 4, ink);
}
function tuscanCard(ctx, card) {
// Angoli arrotondati a gradini e un solo bordo sottile; nessuna scritta al piede.
const outline = [[2,0],[40,0],[42,2],[42,58],[40,60],[2,60],[0,58],[0,2]];
ctx.save(); ctx.translate(1, 2); poly(ctx, outline, '#15231d'); ctx.restore();
poly(ctx, outline, '#89765f');
poly(ctx, [[2,1],[40,1],[41,2],[41,58],[40,59],[2,59],[1,58],[1,2]], paper);
if (TUSCAN_PIPS[card.rank]) {
for (const [x, y] of TUSCAN_PIPS[card.rank]) tuscanSuit(ctx, card.suit, x, y, y > 30);
} else tuscanFigure(ctx, card.rank, card.suit);
}
export function drawCard(ctx, card, x, y, w = 84, h = 120, back = false) {
ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(w / 42, h / 60);
if (!back && TUSCAN_COLORS[card.suit]) { tuscanCard(ctx, card); ctx.restore(); return; }
rect(ctx, 1, 2, 42, 60, '#15231d'); rect(ctx, 0, 0, 42, 60, ink);
rect(ctx, 1, 1, 40, 58, '#c5a576'); rect(ctx, 2, 2, 38, 56, paper);
if (back) regolo(ctx);
else {
const rank = { Asso: 'A', Fante: 'F', Cavallo: 'C', Re: 'R', Gobbo: 'G', Donna: 'D', Regio: 'R' }[card.rank] ?? card.rank;
text(ctx, rank, 4, 7, 6, ink); suit(ctx, card.suit, 34, 8);
if (PIPS[card.rank]) {
for (const [px, py] of PIPS[card.rank]) {
// Una lama per simbolo: le coppie si allungano e s'incrociano al centro.
if (card.suit === 'spade' && (px === 13 || px === 29)) {
ctx.save(); ctx.translate(px === 13 ? 18 : 24, py); ctx.scale(px === 13 ? 1.8 : -1.8, 1);
suit(ctx, card.suit, 0, 0); ctx.restore();
} else suit(ctx, card.suit, px, py);
}
} else figure(ctx, card.rank, card.suit);
text(ctx, card.suit.toUpperCase(), 21, 55, 3.7, ink, 'center');
}
ctx.restore();
}
function countTable(ctx, c, names) {
panel(ctx, 12, 32, 616, 318);
const rows = countRows(c), shown = rows.slice(0, c.count), totals = counted(c);
for (const [side, who] of ['player', 'cpu'].entries()) {
const x = 25 + side * 310;
text(ctx, names[who], x, 48, 7);
const cards = shown.filter(p => p.who === who);
cards.forEach(({ card }, i) => {
const cx = x + (i % 8) * 36, cy = 64 + Math.floor(i / 8) * 49;
drawCard(ctx, card, cx, cy, 30, 40);
text(ctx, '+' + card.points, cx + 15, cy + 44, 5, '#ffe09b', 'center');
});
text(ctx, `${totals[who]} punti`, x, 321, 10, '#ffe09b');
}
const last = shown.at(-1);
if (c.step === 'count' && last) {
panel(ctx, 270, 115, 100, 140); drawCard(ctx, last.card, 278, 123);
}
text(ctx, c.step === 'result' ? c.result.winner ? `Vince ${names[c.result.winner]}!` : '60 a 60. Patta!' : last ? `${cardName(last.card)}: ${last.card.points} punti` : 'Si contano tutte le carte…', 320, 339, 6, paper, 'center');
}
// Il tavolino dell'oratorio: legno, panno verde e il titolo. Lo usa anche il tressette.
export function drawTable(ctx, title) {
rect(ctx, 0, 0, 640, 360, '#573720');
for (let y = 0; y < 360; y += 24) {
rect(ctx, 0, y, 640, 2, '#38271e');
for (let x = 0; x < 640; x += 53) rect(ctx, x + y % 17, y + 6, 29, 1, '#72462a');
}
rect(ctx, 17, 27, 606, 329, '#b28949'); rect(ctx, 20, 30, 600, 323, '#234c40');
for (let i = 0; i < 600; i++) rect(ctx, 23 + (i * 73) % 590, 33 + (i * 41) % 315, 1, 1, '#2a5143');
text(ctx, title, 320, 15, 9, '#ffe0a0', 'center');
}
export function drawBriscola(ctx, c, def) {
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'BRISCOLA ALL’ORATORIO');
if (c.step === 'count' || c.step === 'result') { countTable(ctx, c, def.names); ctx.restore(); return; }
text(ctx, def.names.cpu, 320, 38, 6, paper, 'center');
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 229 + i * 62, 49, 52, 74, true));
// La briscola sporge sotto il mazzo, girata di traverso: più di metà resta scoperta, anche l'asso che ha
// un seme solo nel mezzo (le toscane non hanno numeri negli angoli).
if (c.stock.length) {
ctx.save(); ctx.translate(154, 150); ctx.rotate(Math.PI / 2);
drawCard(ctx, c.trumpCard, 0, 0, 64, 92); ctx.restore();
if (c.stock.length > 1) {
for (let i = Math.min(5, Math.ceil(c.stock.length / 7)); i >= 0; i--)
drawCard(ctx, null, 38 + i, 116 - i, 58, 83, true);
}
}
text(ctx, `Mazzo: ${c.stock.length}`, 34, 243, 7);
text(ctx, 'Briscola:', 34, 263, 7); // a 6 punti la B sembra una D
text(ctx, c.trump.toUpperCase(), 34, 278, 7, '#ffe09b');
for (const [i, who] of ['cpu', 'player'].entries()) {
const y = 74 + i * 183;
if (c.taken[who].length) for (let n = 0; n < Math.min(4, c.taken[who].length / 2); n++) drawCard(ctx, null, 526 + n * 2, y - n * 2, 42, 60, true);
text(ctx, 'Prese', 550, y + 72, 6, paper, 'center');
}
c.table.forEach((p, i) => {
// La carta vola dalla mano di chi la gioca; finita la presa, tutte e due volano sulle prese di chi ha preso.
const spot = [242 + i * 86, 130, 64, 92];
const hand = p.who === 'player' ? [HAND.x + (p.from ?? 1) * HAND.gap, HAND.y, HAND.w, HAND.h] : [229 + (p.from ?? 1) * 62, 49, 52, 74];
let r = spot;
if (i === c.table.length - 1 && c.step === 'play' || c.step === 'trick' && i === 1 && c.t < PLAY_TIME) r = flight(hand, spot, c.t / PLAY_TIME);
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(spot, [526, c.last.winner === 'cpu' ? 74 : 257, 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
drawCard(ctx, p.card, ...r);
});
c.hands.player.forEach((card, i) => {
const x = HAND.x + i * HAND.gap;
if (canPlay(c) && c.selected === i) rect(ctx, x - 3, HAND.y - 3, HAND.w + 6, HAND.h + 6, '#ffdb76');
drawCard(ctx, card, x, HAND.y, HAND.w, HAND.h);
text(ctx, String(i + 1), x + HAND.w / 2, 221, 7, '#fff0cd', 'center');
});
ctx.restore();
}
+206
View File
@@ -0,0 +1,206 @@
// Briscola in due: regole e avversari senza DOM. Le carte sono immutabili, lo stato è serializzabile.
// Due mazzi, stesse regole, stessi id: cambiano solo nomi e disegni (scelti nel dialogo, idea di Andrea).
// Toscane: semi francesi (i quadri in Toscana sono «mattoni»), figure gobbo, donna e regio.
export const DECKS = {
toscane: { suits: ['cuori', 'mattoni', 'fiori', 'picche'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Gobbo', 'Donna', 'Regio'] },
napoletane: { suits: ['denari', 'coppe', 'spade', 'bastoni'], ranks: ['Asso', '2', '3', '4', '5', '6', '7', 'Fante', 'Cavallo', 'Re'] },
};
export const LEVELS = ['bona', 'seria', 'male'];
// I tempi delle animazioni (briscola-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
// poi vola sul mucchietto di chi ha preso (Andrea: «non si capisce quando vengono buttate le carte»).
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.85, TRICK_TIME = 1.4;
const VALUES = [11, 0, 10, 0, 0, 0, 0, 2, 3, 4];
const ORDER = [9, 0, 8, 1, 2, 3, 4, 5, 6, 7];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
Object.freeze({ id: s * 10 + r, suit, rank, points: VALUES[r], strength: ORDER[r] }))));
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
export const CARDS = DECK_CARDS.toscane, SUITS = DECKS.toscane.suits;
export const other = who => who === 'player' ? 'cpu' : 'player';
export const cardName = card => `${card.rank} di ${card.suit}`;
export const points = cards => cards.reduce((n, card) => n + card.points, 0);
function rng(seed) { let n = seed >>> 0; return () => ((n = (Math.imul(n, 1664525) + 1013904223) >>> 0) / 4294967296); }
function shuffle(cards, random) {
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
return cards;
}
// Il secondo vince solo con una briscola o con una carta più alta dello stesso seme.
export function beats(second, first, trump) {
return second.suit === first.suit ? second.strength > first.strength : second.suit === trump;
}
export function trickWinner(table, trump) {
return beats(table[1].card, table[0].card, trump) ? table[1].who : table[0].who;
}
export function result(taken) {
const player = points(taken.player), cpu = points(taken.cpu);
return { player, cpu, winner: player === cpu ? null : player > cpu ? 'player' : 'cpu' };
}
export function newBriscola(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
if (!DECKS[deck]) deck = 'toscane';
const random = rng(seed), dealer = random() < 0.5 ? 'player' : 'cpu';
const stock = shuffle([...DECK_CARDS[deck]], random), hands = { player: [], cpu: [] };
// Distribuzione alternata, a partire da chi è di mano.
for (let i = 0; i < 3; i++) for (const who of [other(dealer), dealer]) hands[who].push(stock.shift());
const trumpCard = stock.shift(); stock.push(trumpCard);
return { step: 'play', t: 0, age: 0, level: LEVELS.includes(level) ? level : 'seria', deck, dealer,
hands, stock, trumpCard, trump: trumpCard.suit, current: other(dealer), table: [],
taken: { player: [], cpu: [] }, history: [], selected: 0, last: null, drawnTrump: null,
aiSeed: (seed ^ 0x9e3779b9) >>> 0, events: [{ kind: 'card-shuffle' }], count: 0 };
}
export const canPlay = c => c.step === 'play' && c.current === 'player';
export function select(c, index) {
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
c.selected = index; return true;
}
export function nudge(c, direction) {
return select(c, (c.selected + direction + c.hands.player.length) % c.hands.player.length);
}
export function play(c, index = c.selected, who = 'player') {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
const [card] = c.hands[who].splice(index, 1);
c.table.push({ who, card, from: index }); c.t = 0; // `from`: da dove parte la carta nel disegno
c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
c.events.push({ kind: 'card-slide' }); c.landing = true;
if (c.table.length === 2) {
c.step = 'trick'; c.last = { winner: trickWinner(c.table, c.trump), cards: c.table.map(p => p.card) };
} else c.current = other(who);
return true;
}
export function collect(c) {
if (c.step !== 'trick') return false;
const winner = c.last.winner;
c.taken[winner].push(...c.last.cards);
c.history.push({ winner, cards: [...c.last.cards] }); c.table = [];
// Chi prende pesca per primo. La briscola scoperta è davvero l'ultima carta.
for (const who of [winner, other(winner)]) if (c.stock.length) {
const card = c.stock.shift(); c.hands[who].push(card);
if (card.id === c.trumpCard.id) c.drawnTrump = who;
}
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
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;
}
// La sola porta d'ingresso dell'IA: copie di informazioni pubbliche e della PROPRIA mano.
// Mai il mazzo, la mano avversaria o il seed della distribuzione.
export function observation(c, who = 'cpu') {
return { who, deck: c.deck, hand: [...c.hands[who]], opponentCount: c.hands[other(who)].length,
table: c.table.map(p => ({ ...p })), played: c.history.flatMap(t => t.cards),
ownPoints: points(c.taken[who]), otherPoints: points(c.taken[other(who)]),
remaining: c.stock.length, trump: c.trump, trumpCard: c.trumpCard,
knownOpponent: c.drawnTrump === other(who) && !c.history.some(t => t.cards.some(a => a.id === c.trumpCard.id))
&& !c.table.some(p => p.card.id === c.trumpCard.id) ? [c.trumpCard] : [] };
}
export function unseen(v) {
const seen = new Set([...v.hand, ...v.played, ...v.table.map(p => p.card)].map(a => a.id));
if (v.remaining) seen.add(v.trumpCard.id);
return DECK_CARDS[v.deck ?? 'toscane'].filter(a => !seen.has(a.id));
}
// Giocatore attento di riferimento: scarta i lisci, conserva le briscole e prende i carichi.
export function attentive(v) {
const lead = v.table[0]?.card;
const cost = a => a.points * 2 + (a.suit === v.trump ? 10 + a.strength : a.strength * 0.15);
const sorted = v.hand.map((a, i) => ({ a, i })).sort((a, b) => cost(a.a) - cost(b.a));
if (lead) {
const wins = sorted.filter(({ a }) => beats(a, lead, v.trump));
const take = wins.find(({ a }) => a.suit !== v.trump || lead.points + a.points >= 8 || !v.remaining);
if (take) return take.i;
}
return sorted[0].i;
}
// Finale a informazione completa: minimax esatto, nessuna euristica né campionamento.
// Massimizza i punti finali (quindi anche vittoria/patta), con memoizzazione per i sottogiochi.
export function perfect(hand, enemy, table, trump, who, memo = new Map()) {
const solve = (own, opp, lead, turn) => {
if (!own.length && !opp.length && !lead.length) return 0;
const key = `${own.map(a => a.id)}|${opp.map(a => a.id)}|${lead.map(p => p.who + p.card.id)}|${turn}`;
if (memo.has(key)) return memo.get(key);
const mine = turn === who, choices = mine ? own : opp;
let best = mine ? -Infinity : Infinity;
choices.forEach((card, i) => {
const next = choices.filter((_, j) => i !== j), a = mine ? next : own, b = mine ? opp : next;
const pair = [...lead, { who: turn, card }];
let value;
if (pair.length === 2) {
const winner = trickWinner(pair, trump);
value = (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(a, b, [], winner);
} else value = solve(a, b, pair, other(turn));
best = mine ? Math.max(best, value) : Math.min(best, value);
});
memo.set(key, best); return best;
};
return hand.map((card, i) => {
const own = hand.filter((_, j) => i !== j), pair = [...table, { who, card }];
if (pair.length === 1) return solve(own, enemy, pair, other(who));
const winner = trickWinner(pair, trump);
return (winner === who ? 1 : -1) * points(pair.map(p => p.card)) + solve(own, enemy, [], winner);
});
}
const bestIndex = scores => scores.indexOf(Math.max(...scores));
// «A fassi male»: mondi possibili compatibili con le carte uscite, mai la distribuzione vera.
// Stesse distribuzioni campionate per tutte le mosse, entrambi giocano attenti nei seguiti.
// Taratura: 1000 partite/livello, seed imul(1..1000, 2654435761), tools/calibrate_briscola.mjs.
// Vittorie di Andrea attento: bona 84,7%, seria 48,1%, male 35,9%; patte 0,9%, 2,5%, 2,0%.
// Mazziere Andrea in 496/1000 partite. È una misura contro questa euristica, non contro persone.
export function chooseCard(v, level = 'seria', seed = 1) {
const random = rng(seed);
if (level === 'bona') return Math.floor(random() * v.hand.length);
if (level !== 'male') return attentive(v);
const unknown = unseen(v);
if (!v.remaining) return bestIndex(perfect(v.hand, unknown, v.table, v.trump, v.who));
const totals = v.hand.map(() => 0), known = new Set(v.knownOpponent.map(a => a.id));
for (let sample = 0; sample < 40; sample++) {
const pool = shuffle(unknown.filter(a => !known.has(a.id)), random);
const enemy = [...v.knownOpponent, ...pool.splice(0, v.opponentCount - known.size)];
const stock = [...pool, v.trumpCard];
for (let move = 0; move < v.hand.length; move++) {
const hands = { [v.who]: [...v.hand], [other(v.who)]: [...enemy] };
let table = [...v.table], turn = v.who, deck = [...stock], delta = 0, first = true;
while (hands.player.length || hands.cpu.length || table.length) {
const idx = first ? move : attentive({ hand: hands[turn], table, trump: v.trump, remaining: deck.length });
first = false;
const [card] = hands[turn].splice(idx, 1); table.push({ who: turn, card });
if (table.length === 1) { turn = other(turn); continue; }
turn = trickWinner(table, v.trump);
delta += (turn === v.who ? 1 : -1) * points(table.map(p => p.card)); table = [];
for (const who of [turn, other(turn)]) if (deck.length) hands[who].push(deck.shift());
}
totals[move] += delta;
}
}
return bestIndex(totals);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt;
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
else if (c.step === 'count') {
// Una carta ogni 0,35 s; i totali crescono solo ora, alla fine della partita.
c.count = Math.min(40, Math.floor(c.t / 0.35));
if (c.count === 40) { c.result = result(c.taken); c.step = 'result'; }
} else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.7) {
c.aiSeed = (Math.imul(c.aiSeed, 1664525) + 1013904223) >>> 0;
play(c, chooseCard(observation(c), c.level, c.aiSeed), 'cpu');
}
}
export function countRows(c) {
return ['player', 'cpu'].flatMap(who => c.taken[who].map(card => ({ who, card })));
}
export function counted(c) {
const totals = { player: 0, cpu: 0 };
for (const p of countRows(c).slice(0, c.count)) totals[p.who] += p.card.points;
return totals;
}
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
if (c.step === 'result') return `${c.result.winner ? 'Vince ' + names[c.result.winner] : 'Patta'}! ${c.result.player} a ${c.result.cpu}.`;
if (c.step === 'count') {
const last = countRows(c)[c.count - 1];
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]}.`;
// 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…`;
}
+37 -10
View File
@@ -3,11 +3,17 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js';
import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.js';
// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi.
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = {
bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
};
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
@@ -245,6 +251,20 @@ export class Game {
}
// 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() {
const c = this.closeup;
if (!c) return false;
@@ -255,9 +275,11 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete);
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]) {
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;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
@@ -267,16 +289,17 @@ export class Game {
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); }
closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); }
closeupPress() { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup); }
closeupRelease() { return !!this.minigame()?.release?.(this.closeup); }
closeupCancel() { return !!this.minigame()?.cancel?.(this.closeup); }
closeupPlay(index) { return !this.speech && !!this.minigame()?.play?.(this.closeup, index); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
const c = this.closeup;
const m = this.minigame();
if (m) return m.aim(c, [x, y]);
if (m) return !!m.aim?.(c, [x, y]);
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
@@ -402,6 +425,7 @@ export class Game {
const m = this.minigame();
if (m) {
m.update(c, dt);
this.settle(c);
// Il risultato resta leggibile finché il giocatore non esce.
return;
}
@@ -439,7 +463,10 @@ export class Game {
else if (e.closeup) {
const type = this.story.closeups[e.closeup].type;
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
}
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
+3 -1
View File
@@ -8,11 +8,13 @@ import { versioned } from '../version.js';
// d'apertura nessuno: lì suona la sua musichetta, una volta sola (main.js).
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
// altrimenti quello generale del paese.
export function trackFor(story, { screen, scene, phase }) {
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
export function trackFor(story, { screen, scene, phase, closeup }) {
const m = story.music ?? {};
if (screen === 'splash') return null;
if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null;
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
const sc = story.scenes[scene];
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
}
+11 -1
View File
@@ -5,6 +5,9 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -666,6 +669,9 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
if (def.type === 'scopa') { drawScopa(ctx, c, def); return; }
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
@@ -676,7 +682,11 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; }
if (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
return;
}
ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
+97
View File
@@ -0,0 +1,97 @@
import { canPlay, TARGET, PLAY_TIME, TAKE_HOLD, TAKE_TIME } from './scopa.js';
import { drawCard, drawTable, flight } from './briscola-render.js';
import { text, panel } from './bocce-render.js';
// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola.
export const HAND = { y: 262, w: 64, h: 92, gap: 72 };
export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap);
const TABLE = { w: 42, h: 60, gap: 47, row: 8 };
export function tableXY(n, i) {
const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row);
return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132];
}
export function cardAt(c, [x, y]) {
if (!canPlay(c)) return -1;
return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h);
}
export function tableAt(c, [x, y]) {
return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; });
}
const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3';
const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); };
function board(ctx, c, names) {
// Un giocatore per riga: i nomi lunghi non si accavallano.
panel(ctx, 26, 34, 150, 50);
for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) {
text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right');
}
text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center');
// Il mazzo a sinistra, le prese a destra con le scope.
if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true);
text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center');
for (const [who, y] of [['cpu', 92], ['player', 196]]) {
if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true);
if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center');
}
}
const PILE = { cpu: 92, player: 196 };
function table(ctx, c) {
// Durante una presa la tavola resta com'era (le carte prese al loro posto, incorniciate) finché non volano via.
const take = c.step === 'take' && c.last.captured.length, shown = take ? c.last.before : c.table;
const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id));
const fly = take && c.t > TAKE_HOLD ? (c.t - TAKE_HOLD) / (TAKE_TIME - TAKE_HOLD - 0.05) : 0;
const pile = take ? [560, PILE[c.last.who], 42, 60] : null;
// Da dove parte la carta giocata: la mano di Andrea o quella coperta del giocatore.
const hand = c.step === 'take' && (c.last.who === 'player'
? [handX(c.hands.player.length + 1, c.last.from), HAND.y, HAND.w, HAND.h]
: [320 - ((c.hands.cpu.length + 1) * 48 - 6) / 2 + c.last.from * 48, 38, 42, 60]);
const arrive = Math.min(1, c.t / PLAY_TIME);
shown.forEach((card, i) => {
const [x, y] = tableXY(shown.length, i);
let r = [x, y, TABLE.w, TABLE.h];
if (c.step === 'take' && !take && card.id === c.last.card.id) r = flight(hand, r, arrive);
if (fly && marked.has(card.id)) r = flight(r, pile, fly);
else if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h);
drawCard(ctx, card, ...r);
});
if (take) {
let r = flight(hand, [299, 200, TABLE.w, TABLE.h], arrive);
if (fly) r = flight(r, pile, fly);
drawCard(ctx, c.last.card, ...r);
if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); }
}
}
function hands(ctx, c) {
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true));
const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose';
c.hands.player.forEach((card, i) => {
const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i;
const up = chosen ? 8 : 0;
if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h);
drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h);
});
}
function summary(ctx, c, names, title) {
const s = c.summary;
panel(ctx, 140, 80, 360, 200);
text(ctx, title, 320, 96, 8, gold, 'center');
text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center');
s.rows.forEach((r, i) => {
const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v);
text(ctx, r.label, 160, y, 7, paper);
text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center');
text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center');
});
text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center');
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center');
}
export function drawScopa(ctx, c, def) {
const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' };
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'SCOPA ALL’ORATORIO');
board(ctx, c, names); table(ctx, c); hands(ctx, c);
if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`);
if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`);
ctx.restore();
}
+255
View File
@@ -0,0 +1,255 @@
// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello,
// primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari.
import { DECKS, cardName } from './briscola.js';
export { cardName };
export const LEVELS = ['bona', 'seria', 'male'];
export const TARGET = 11;
// I tempi delle animazioni (scopa-render.js): la carta vola dalla mano; le carte prese restano ferme un attimo e poi
// volano sul mucchietto di chi ha preso.
export const PLAY_TIME = 0.3, TAKE_HOLD = 0.75, TAKE_TIME = 1.35;
export const COIN = { toscane: 'mattoni', napoletane: 'denari' };
// Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14,
// 3 13, 2 12, le figure 10.
const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] }))));
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
export const other = who => who === 'player' ? 'cpu' : 'player';
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
function shuffle(cards, random) {
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
return cards;
}
// Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a
// scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria.
export function captures(table, card) {
const same = table.filter(a => a.value === card.value);
if (same.length) return same.map(a => [a]);
const out = [];
for (let mask = 1; mask < 1 << table.length; mask++) {
const pick = table.filter((_, i) => mask & 1 << i);
if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick);
}
return out;
}
const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7;
// Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa.
export function primiera(cards, deck) {
const suits = DECKS[deck].suits;
if (!suits.every(s => cards.some(a => a.suit === s))) return 0;
return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0);
}
// I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa.
export function tally(taken, scope, deck) {
const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null;
const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck);
rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) });
rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) });
const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu';
rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven });
rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) });
const points = { player: 0, cpu: 0 };
for (const r of rows) if (r.winner) points[r.winner]++;
rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null });
points.player += scope.player; points.cpu += scope.cpu;
return { rows, points };
}
function deal(c) {
// Con tre o quattro re in tavola si rimescola.
let stock;
do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); }
while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3);
c.table = stock.splice(0, 4); c.stock = stock;
c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 };
c.current = other(c.dealer); c.lastTaker = null; c.round++;
refill(c); c.events.push({ kind: 'card-shuffle' });
}
// Tre carte a testa, prima a chi gioca per primo.
function refill(c) {
for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift());
c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null;
}
export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 },
round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null };
c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu';
deal(c); return c;
}
export const canPlay = c => c.step === 'play' && c.current === 'player';
export function select(c, index) {
if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; }
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
c.selected = index; return true;
}
export function nudge(c, direction) {
if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); }
const n = c.hands.player.length;
return select(c, (c.selected + direction + n) % n);
}
// Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»).
export function play(c, index = c.selected, who = 'player', option = null) {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
const options = captures(c.table, c.hands[who][index]);
if (options.length > 1 && option === null) {
if (who !== 'player') return false;
c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true;
}
return execute(c, who, index, options.length ? options[option ?? 0] : null);
}
export function take(c, k = c.choice?.selected) {
if (c.step !== 'choose' || !c.choice.options[k]) return false;
const { index, options } = c.choice; c.step = 'play'; c.choice = null;
return execute(c, 'player', index, options[k]);
}
export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; }
function execute(c, who, index, captured) {
if (!captured && captures(c.table, c.hands[who][index]).length) return false;
const before = [...c.table], [card] = c.hands[who].splice(index, 1);
if (captured) {
const ids = new Set(captured.map(a => a.id));
c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who;
} else c.table.push(card);
// L'ultima carta della smazzata non fa scopa.
const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length;
const scopa = !!captured && !c.table.length && !lastPlay;
if (scopa) c.scope[who]++;
// `from` e `before` servono al disegno: da dove parte la carta e com'era la tavola prima della presa.
c.last = { who, card, captured: captured ?? [], scopa, from: index, before };
c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : []));
c.step = 'take'; c.t = 0;
if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
return true;
}
function after(c) {
c.current = other(c.last.who); c.t = 0; c.step = 'play';
if (c.hands.player.length || c.hands.cpu.length) return;
if (c.stock.length) { refill(c); return; }
// Fine smazzata: quel che resta in tavola va all'ultimo che ha preso.
if (c.lastTaker) c.taken[c.lastTaker].push(...c.table);
c.table = [];
const t = tally(c.taken, c.scope, c.deck);
c.score.player += t.points.player; c.score.cpu += t.points.cpu;
c.summary = { ...t, score: { ...c.score } }; c.t = 0;
// Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria.
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; }
else c.step = 'smazzata';
}
// Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra.
export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu;
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) {
if (c.step !== 'smazzata') return false;
c.dealer = other(c.dealer); deal(c);
return true;
}
// 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') {
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 }, lastTaker: c.lastTaker };
}
// Le carte che non si sono viste: in mano all'altro o nel mazzo.
export function unseen(v) {
const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id));
return DECK_CARDS[v.deck].filter(a => !seen.has(a.id));
}
// Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare
// una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore
// possono essere ancora in mano all'altro.
export function score(v, card, captured, counting) {
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];
// 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;
const sum = left.reduce((n, a) => n + a.value, 0);
if (left.length && sum <= 10 && v.opponentCount) {
let p = 0.5;
if (counting) {
const pool = unseen(v), same = pool.filter(a => a.value === sum).length;
p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0;
}
value -= p * 20;
}
// Lasciare in tavola un 7 o il settebello vuol dire regalarli.
value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0);
return value;
}
export function moves(v) {
return v.hand.flatMap((card, index) => {
const opts = captures(v.table, card);
return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }];
});
}
// «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 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) {
const all = moves(v);
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
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);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt;
if (c.step === 'take' && c.t >= (c.last.captured.length ? TAKE_TIME : PLAY_TIME * 2)) after(c);
else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) {
c.aiSeed = lcg(c.aiSeed);
const m = choose(observation(c, 'cpu'), c.level, c.aiSeed);
play(c, m.index, 'cpu', m.option);
}
}
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`;
if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`;
if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.';
if (c.step === 'take') {
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)}.`;
}
// 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…`;
}
+15
View File
@@ -101,6 +101,16 @@ export class Soundscape {
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'card-slide') this.noiseSound(0.18, 2100, 0.13, { paper: true, pan });
else if (kind === 'card-tap') { this.noiseSound(0.065, 1400, 0.17, { pan }); this.tone(220, 100, 0.06, 0.06, 0, pan); }
// I segni del tressette si sentono: due nocche sul tavolo, la carta strusciata, la carta lasciata cadere.
else if (kind === 'card-busso') for (const d of [0, 0.16]) { this.tone(150, 90, 0.07, 0.16, d, pan); this.tone(320, 180, 0.03, 0.06, d, pan); }
else if (kind === 'card-striscio') this.noiseSound(0.6, 1500, 0.14, { paper: true, pan });
else if (kind === 'card-volo') { this.noiseSound(0.12, 4200, 0.08, { paper: true, pan }); this.tone(260, 140, 0.05, 0.09, 0.12, pan); }
else if (kind === 'card-shuffle') {
this.noiseSound(0.55, 3100, 0.16, { paper: true, pan });
for (let i = 0; i < 6; i++) this.tone(900, 300, 0.035, 0.035, i * 0.07, pan);
}
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
@@ -109,6 +119,11 @@ export class Soundscape {
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
this.tone(1700, 900, 0.04, 0.025, 0, pan);
this.tone(2500, 1600, 0.04, 0.03, 0.35 + this.random() * 0.5, pan);
}
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
+108
View File
@@ -0,0 +1,108 @@
import { canPlay, legal, accuseName, team, NAMES, PLAY_TIME, TRICK_HOLD, TRICK_TIME } from './tressette.js';
import { drawCard, drawTable, flight } from './briscola-render.js';
import { text, panel } from './bocce-render.js';
// La mano di Andrea a ventaglio, carte a grandezza naturale (42×60): coordinate condivise da disegno e tocco.
export const CARD = { w: 42, h: 60, y: 288, lift: 8 };
export function handX(n, i) {
const gap = n > 1 ? Math.min(44, (440 - CARD.w) / (n - 1)) : 0;
return Math.round(320 - (gap * (n - 1) + CARD.w) / 2 + i * gap);
}
// Le carte si coprono: vince quella sopra, cioè la più a destra.
export function cardAt(c, [x, y]) {
if (!canPlay(c)) return -1;
const n = c.hands[0].length;
for (let i = n - 1; i >= 0; i--) {
const top = CARD.y - (c.selected === i ? CARD.lift : 0), left = handX(n, i);
if (x >= left && x < left + CARD.w && y >= top && y < top + CARD.h + CARD.lift) return i;
}
return -1;
}
// Dove cade la carta di ognuno al centro, e da dove arriva.
const SPOT = [[299, 190], [354, 146], [299, 100], [244, 146]];
// I mucchietti delle prese: noi in basso a destra, loro in basso a sinistra.
const PILE = [[560, 288], [34, 288]];
const LABEL = [[320, 280, 'center'], [612, 92, 'right'], [320, 39, 'center'], [28, 92, 'left']];
const gold = '#ffe09b', paper = '#fff0cd', ours = '#9fe3ff', theirs = '#ffb0a3';
const SAY = { busso: 'BUSSO!', striscio: 'STRISCIO!', volo: 'VOLO!' };
function others(ctx, c, names) {
// Il socio di fronte, gli avversari ai lati: carte coperte, quante ne hanno.
const n2 = c.hands[2].length, w2 = n2 ? (n2 - 1) * 12 + 26 : 0;
for (let i = 0; i < n2; i++) drawCard(ctx, null, 320 - w2 / 2 + i * 12, 46, 26, 37, true);
for (const [seat, x] of [[3, 30], [1, 584]]) {
for (let i = 0; i < c.hands[seat].length; i++) drawCard(ctx, null, x, 102 + i * 12, 26, 37, true);
}
for (const seat of [1, 2, 3]) {
// Chi deve giocare ha il nome in giallo.
const [x, y, align] = LABEL[seat], turn = c.step === 'play' && c.current === seat;
text(ctx, names[seat], x, y, 6, turn ? gold : seat % 2 ? theirs : ours, align);
}
}
function piles(ctx, c) {
// Quante prese ha fatto ogni coppia, a mucchietto (una carta coperta ogni due prese).
for (const t of [0, 1]) {
const n = c.tricks.filter(x => team(x.winner) === t).length, [x, y] = PILE[t];
for (let k = 0; k < Math.ceil(n / 2); k++) drawCard(ctx, null, x + k, y - k, 42, 60, true);
text(ctx, t ? 'Prese loro' : 'Prese noi', x + 21, y - 8, 5, t ? theirs : ours, 'center');
}
}
function scoreboard(ctx, c) {
panel(ctx, 26, 34, 118, 38);
text(ctx, `Noi ${c.score[0]}`, 34, 46, 7, ours);
text(ctx, `Loro ${c.score[1]}`, 136, 46, 7, theirs, 'right');
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
}
function accuses(ctx, c, names) {
// Le accuse dette restano in vista per tutta la smazzata.
if (!c.accuse.length) return;
// Largo abbastanza per «Il giocatore: tre Assi, manca mattoni +3» (200 px nel carattere a 5).
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) {
// Da dove parte la carta: la mano di Andrea, o le carte coperte degli altri.
const from = p => p.who === 0 ? [handX(c.hands[0].length + 1, p.from ?? 0), CARD.y, CARD.w, CARD.h]
: p.who === 2 ? [300, 46, 26, 37] : [p.who === 1 ? 584 : 30, 102 + (p.from ?? 0) * 12, 26, 37];
c.table.forEach((p, i) => {
const [x, y] = SPOT[p.who];
let r = [x, y, CARD.w, CARD.h];
if (i === c.table.length - 1 && c.t < PLAY_TIME && (c.step === 'play' || c.step === 'trick')) r = flight(from(p), r, c.t / PLAY_TIME);
if (c.step === 'trick' && c.t > TRICK_HOLD) r = flight(r, [...PILE[team(c.last.winner)], 42, 60], (c.t - TRICK_HOLD) / (TRICK_TIME - TRICK_HOLD - 0.05));
drawCard(ctx, p.card, ...r);
if (p.signal) {
const [lx, ly] = p.who === 0 ? [x + 21, y + 70] : p.who === 2 ? [x + 21, y - 8] : p.who === 1 ? [x + 66, y + 30] : [x - 24, y + 30];
panel(ctx, lx - 30, ly - 7, 60, 14); text(ctx, SAY[p.signal], lx, ly, 6, gold, 'center');
}
});
if (c.step === 'trick') text(ctx, `Prende ${names[c.last.winner]}`, 320, 262, 6, gold, 'center');
}
function hand(ctx, c) {
const cards = c.hands[0], n = cards.length, mine = canPlay(c), ok = mine ? legal(cards, c.table) : [];
cards.forEach((card, i) => {
const x = handX(n, i), up = mine && c.selected === i ? CARD.lift : 0;
if (up) { ctx.fillStyle = '#ffdb76'; ctx.fillRect(x - 2, CARD.y - up - 2, CARD.w + 4, CARD.h + 4); }
drawCard(ctx, card, x, CARD.y - up, CARD.w, CARD.h);
// Le carte che non rispondono al seme si spengono.
if (mine && !ok.includes(i)) { ctx.fillStyle = 'rgba(20,28,24,0.55)'; ctx.fillRect(x, CARD.y, CARD.w, CARD.h); }
});
if (mine && !c.table.length && c.signal) text(ctx, `Segno: ${SAY[c.signal]}`, 320, 278, 6, gold, 'center');
}
function summary(ctx, c, title) {
const s = c.summary, row = (y, who, t, color) =>
text(ctx, `${who}: ${s.thirds[t]} terzi = ${s.points[t]} punti${s.accused[t] ? `, accuse +${s.accused[t]}` : ''}${s.lastTeam === t ? ' (con l’ultima)' : ''}`, 172, y, 6, color);
panel(ctx, 160, 96, 320, 132);
text(ctx, title, 320, 112, 8, gold, 'center');
row(140, 'Noi', 0, ours); row(158, 'Loro', 1, theirs);
text(ctx, `Siamo ${c.score[0]} a ${c.score[1]}`, 320, 186, 8, paper, 'center');
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 210, 5, paper, 'center');
}
export function drawTressette(ctx, c, def) {
const names = def?.seats ?? NAMES;
ctx.save(); ctx.imageSmoothingEnabled = false;
drawTable(ctx, 'TRESSETTE AI 21');
scoreboard(ctx, c); others(ctx, c, names); piles(ctx, c); accuses(ctx, c, names); hand(ctx, c); trick(ctx, c, names);
if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro');
ctx.restore();
}
+279
View File
@@ -0,0 +1,279 @@
// Tressette a coppie «ai 21», come si gioca in Mugello: quattro al tavolo, Andrea e il socio contro gli altri due.
// Regole e avversari senza DOM, come la briscola. Segni di chi esce (busso, striscio, volo) e accuse a inizio smazzata.
import { DECKS, cardName } from './briscola.js';
export { cardName };
export const LEVELS = ['bona', 'seria', 'male'];
export const SIGNALS = ['busso', 'striscio', 'volo'];
export const TARGET = 21;
// I tempi delle animazioni (tressette-render.js): la carta vola dalla mano al tavolo; la presa resta ferma un attimo e
// poi vola sul mucchietto della coppia che ha preso.
export const PLAY_TIME = 0.3, TRICK_HOLD = 0.8, TRICK_TIME = 1.35;
// Posti in senso antiorario, come si gira da noi: 0 Andrea (sotto), 1 a destra, 2 il socio (di fronte), 3 a sinistra.
export const SEATS = [0, 1, 2, 3];
export const team = seat => seat % 2;
export const next = seat => (seat + 1) % 4;
export const partner = seat => (seat + 2) % 4;
// Ordine di presa 3 > 2 > Asso > Re > Cavallo > Fante > 7 > 6 > 5 > 4 (indici delle carte: Asso, 2, …, 7, figure).
const POWER = [7, 8, 9, 0, 1, 2, 3, 4, 5, 6];
// Punti in terzi: l'Asso vale un punto, il 2, il 3 e le figure un terzo. 32 terzi più 3 dell'ultima presa: 11 punti.
const THIRDS = [3, 1, 1, 0, 0, 0, 0, 1, 1, 1];
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
Object.freeze({ id: s * 10 + r, suit, rank, thirds: THIRDS[r], power: POWER[r] }))));
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
function shuffle(cards, random) {
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
return cards;
}
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre.
export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) {
const out = [], tops = ['Asso', '2', '3'];
for (const suit of new Set(hand.map(a => a.suit))) {
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
if (cards.length === 3) out.push({ kind: 'napoletana', suit, points: 3, cards });
}
for (const rank of tops) {
const cards = hand.filter(a => a.rank === rank);
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null });
}
return out;
}
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}`
: `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`;
// Si risponde al seme quando si può; chi esce gioca quel che vuole.
export function legal(hand, table) {
const led = table[0]?.card.suit, same = hand.flatMap((a, i) => a.suit === led ? [i] : []);
return same.length ? same : hand.map((_, i) => i);
}
export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a);
// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e
// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi.
export function signalOk(hand, index, table, signal) {
if (!signal) return true;
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true;
}
// Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene.
export function reason(c, index, signal = c.signal) {
const hand = c.hands[0], card = hand[index];
if (!card) return 'Scegli una carta.';
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
if (!signal || signalOk(hand, index, c.table, signal)) return '';
if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.';
if (c.table.length) return 'I segni li fa solo chi esce.';
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
}
const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
function deal(c) {
c.seed = lcg(c.seed);
const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
// Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere.
c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
// Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a })));
c.accuse = [];
c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
c.events.push({ kind: 'card-shuffle' });
}
export function newTressette(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: [0, 0], round: 0,
seed: seed >>> 0, aiSeed: (seed ^ 0x85ebca6b) >>> 0, age: 0, events: [], last: null, summary: null };
c.dealer = Math.floor(rng(seed)() * 4);
deal(c); return c;
}
export const canPlay = c => c.step === 'play' && c.current === 0;
export function select(c, index) {
if (!canPlay(c) || !Number.isInteger(index) || !c.hands[0][index]) return false;
c.selected = index; c.refused = ''; return true;
}
export function nudge(c, direction) {
const n = c.hands[0].length;
return select(c, (c.selected + direction + n) % n);
}
// Il segno di Andrea vale per la prossima carta con cui esce; ripremuto, si toglie.
export function setSignal(c, signal) {
if (!canPlay(c) || c.table.length || (signal && !SIGNALS.includes(signal))) return false;
c.signal = c.signal === signal ? null : signal; c.refused = ''; return true;
}
export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.signal : null) {
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
if (!legal(c.hands[who], c.table).includes(index) || !signalOk(c.hands[who], index, c.table, signal)) {
if (who === 0) c.refused = reason(c, index, signal);
return false;
}
if (!c.tricks.length && c.pending[who].length) {
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
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);
c.table.push({ who, card, signal: signal || null, from: index }); c.t = 0; c.landing = true; // `from`: per il disegno
// Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
c.events.push({ kind: signal ? 'card-' + signal : 'card-slide', who });
if (who === 0) { c.signal = null; c.refused = ''; c.selected = Math.min(c.selected, Math.max(0, c.hands[0].length - 1)); }
if (c.table.length === 4) { c.step = 'trick'; c.last = { winner: best(c.table).who, cards: c.table.map(p => p.card) }; }
else c.current = next(who);
return true;
}
// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi.
const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0);
function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); }
export function collect(c) {
if (c.step !== 'trick') return false;
const winner = c.last.winner, end = !c.hands[0].length;
// L'ultima presa vale un punto; i terzi avanzati non contano.
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0);
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' });
recount(c);
if (out(c, end ? team(winner) : null)) return true;
if (end) { summarize(c, team(winner)); c.step = 'smazzata'; return true; }
c.step = 'play';
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';
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
export function advance(c) {
if (c.step !== 'smazzata') return false;
deal(c); return true;
}
// 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) {
// 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 })) })),
accuse: c.accuse.map(a => ({ ...a, cards: [...a.cards] })), counts: c.hands.map(h => h.length) };
}
// Quel che un giocatore sa. Chi conta davvero («A fassi male») ricorda le carte uscite, i semi finiti di ognuno e le
// carte accusate dagli altri.
export function knowledge(v, full) {
// Chi non conta («Seria») vede solo le carte in tavola e le sue: un 3 già uscito per lui può essere ancora in giro.
const played = [...(full ? v.tricks.flatMap(t => t.cards) : []), ...v.table];
const gone = new Set([...v.hand, ...played.map(p => p.card)].map(a => a.id));
const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
const voids = SEATS.map(() => new Set()), holder = new Map();
if (full) {
// Chi non risponde al seme l'ha finito; chi vola anche.
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => {
if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit);
if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit);
});
for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who);
}
const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat);
// Qualcuno fra `seats` può ancora battere `card`?
const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
return { out, voids, could, beatable, full };
}
// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo.
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0));
const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
function lead(v, k) {
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
// Il socio ha bussato e la presa l'ho fatta io: gli ritorno il seme con la più bassa.
const prev = v.tricks.at(-1), knock = prev?.cards[0];
if (k.full && prev?.winner === v.seat && knock?.who === partner(v.seat) && knock.signal === 'busso') {
const same = hand.filter(a => a.suit === knock.card.suit);
if (same.length) return { index: hand.indexOf(cheap(same)), signal: null };
}
// Una carta che gli avversari non possono più battere: si fa la presa, e coi punti dentro.
const sure = hand.filter(a => !k.beatable(a, foes));
if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
// Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
const count = suit => hand.filter(a => a.suit === suit).length;
// A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri).
const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit;
const card = cheap(hand.filter(a => a.suit === longest));
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
}
function signalFor(hand, card, k) {
const rest = hand.filter(a => a !== card && a.suit === card.suit);
if (!rest.length) return 'volo';
// Busso: la mia più alta è la seconda buona del seme; il socio prenda con la prima e me lo ritorni.
const top = rest.reduce((a, b) => b.power > a.power ? b : a);
if (top.power > card.power && k.out.filter(a => a.suit === card.suit && a.power > top.power).length === 1) return 'busso';
return rest.length >= 2 ? 'striscio' : null;
}
function follow(v, k, moves) {
const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
const pick = card => v.hand.indexOf(card);
// Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme).
const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full;
if (knock && cards[0].suit === top.card.suit) {
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));
}
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito). Se un
// 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) {
const safe = cards.filter(a => a.power > top.card.power && !k.beatable(a, after));
if (safe.length) return pick(byMin(safe, a => a.power));
}
return pick(cheap(cards));
}
// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md).
export function choose(v, level = 'seria', seed = 1) {
const moves = legal(v.hand, v.table);
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
const k = knowledge(v, level === 'male');
return v.table.length ? { index: follow(v, k, moves), signal: null } : lead(v, k);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
c.t += dt;
if (c.landing && c.t >= PLAY_TIME) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
if (c.step === 'trick' && c.t >= TRICK_TIME) collect(c);
else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
c.aiSeed = lcg(c.aiSeed);
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
play(c, index, seat, signal);
}
}
export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
export function status(c, names = NAMES) {
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`;
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
// Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
if (c.refused) return c.refused;
if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
}

Some files were not shown because too many files have changed in this diff Show More