diff --git a/albertopecchini.it/pzkko/404.html b/albertopecchini.it/pzkko/404.html index ee82b6c1..279cc53b 100644 --- a/albertopecchini.it/pzkko/404.html +++ b/albertopecchini.it/pzkko/404.html @@ -156,7 +156,7 @@ .muro::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;mix-blend-mode:screen;background:url(/art/muro.webp) 0 0/2200px auto repeat;filter:grayscale(1) contrast(3.2) brightness(1.25);opacity:calc(.75 * var(--auto-l)); -webkit-mask-image:radial-gradient(34% 100% at calc(var(--auto-x) * 100%) 50%,#000,transparent 70%); mask-image:radial-gradient(34% 100% at calc(var(--auto-x) * 100%) 50%,#000,transparent 70%)} -.parete{position:relative;height:calc(3252 * var(--u))} +.parete{position:relative;height:calc(3320 * var(--u))} .gruppo{display:contents} /* ogni cosa sul muro è posizionata in 1280esimi (e in 390esimi sul telefono) */ @@ -295,7 +295,7 @@ /* ── il telefono: il muro visto da vicino, due volantini per riga ─────────── */ @media (max-width:899px){ :root{--u:calc(min(100cqw, 600px) / 390)} - .parete{height:calc(5654 * var(--u));max-width:600px;margin:0 auto} + .parete{height:calc(5684 * var(--u));max-width:600px;margin:0 auto} .it{--px:var(--mx,var(--x));--py:var(--my,var(--y));--pw:var(--mw,var(--w));--pr:var(--mr,var(--r,0deg))} .spray{font-size:calc(40 * var(--u))} .vol h3{font-size:max(calc(22px * var(--ts,1)),calc(16cqw * var(--ts,1)))} @@ -831,15 +831,16 @@

questa pagina non c'è.

setTimeout(tic, veloce > 1 ? 250 : 1000) document.addEventListener('visibilitychange', () => { if (!document.hidden) aggiorna() }) - // I giochi: la roulette, il blackjack, testa o croce, le mine e il dado, ognuno in una finestra sopra il muro - // (games//). Il codice e il 3D si caricano solo quando si apre (o quando - // il puntatore passa sul volantino); #roulette, #blackjack, #moneta, #mine o #dado nell'indirizzo li apre subito. + // I giochi: la roulette, il blackjack, testa o croce, le mine, il dado e virtual, ognuno in una finestra sopra il muro + // (games//). Il codice e il 3D si caricano solo quando si apre (o quando il puntatore passa sul volantino); + // #roulette, #blackjack, #moneta, #mine, #dado o #virtual nell'indirizzo li apre subito. const GIOCHI = { roulette: ['roulette.js', 'ruota.js', 'regole.js', 'lib/three.js'].map((f) => `/games/roulette/${f}`), blackjack: ['blackjack.js', 'regole.js', 'scena.js', 'carte.js', '../pzk.js', '../roulette/lib/three.js'].map((f) => new URL(f, `${location.origin}/games/blackjack/`).pathname), moneta: ['moneta.js', 'lancio.js', 'scena.js', '../pzk.js', '../roulette/lib/three.js'].map((f) => new URL(f, `${location.origin}/games/moneta/`).pathname), mine: ['mine.js', 'regole.js', 'scena.js', 'tessere.js', '../pzk.js', '../roulette/lib/three.js'].map((f) => new URL(f, `${location.origin}/games/mine/`).pathname), dado: ['dado.js', 'regole.js', 'campana.js', '../pzk.js', '../roulette/lib/three.js', '../roulette/lib/rapier.mjs'].map((f) => new URL(f, `${location.origin}/games/dado/`).pathname), + virtual: ['virtual.js', 'partita.js', 'squadre.js', 'schermo.js', '../pzk.js'].map((f) => new URL(f, `${location.origin}/games/virtual/`).pathname), } const apriGioco = (nome) => import(`/games/${nome}/${nome}.js`).then((m) => m.apri()) for (const gioco of document.querySelectorAll('a[data-gioco]')) { diff --git a/albertopecchini.it/pzkko/README.md b/albertopecchini.it/pzkko/README.md index c930ea4f..a990a5c0 100644 --- a/albertopecchini.it/pzkko/README.md +++ b/albertopecchini.it/pzkko/README.md @@ -92,6 +92,17 @@ serio ma è un progetto Vercel a parte. `../roulette/lib/rapier.mjs`), `scena.js` (il piatto e la moneta in 3D, la telecamera e la lente, con `../roulette/lib/three.js`). Le facce della moneta sono in `art/moneta/` (gpt-image-2 il 22.10.2026, `tools/pzkko/moneta/facce.mjs`). +- `games/virtual/` — «virtual», le scommesse sul campionato di calcio inventato (23.10), nella + stessa finestra (vedi sotto): `virtual.js` e `virtual.css` (la finestra, le quote stampate, la + schedina, le fiches, i pagamenti), `partita.js` (il calendario a cicli di cinque minuti, le + quote dalla forza vera, il risultato a caso, il meteo, la cronaca minuto per minuto, tutto dal + seme dell'orario), `squadre.js` (le quaranta squadre di A e B coi nomi storpiati, i colori, gli + stemmi disegnati in SVG, i cognomi inventati), `schermo.js` (lo schermino a pixel: il campo, gli + omini, i tifosi, la telecamera, il meteo, le scene degli eventi), `schedina.js` (la schedina in + comune fra la finestra e l'app del cercapersone: le puntate nel browser, il tavolo dei $pzk, il + pagamento, l'unione fra schede). L'illustrazione del volantino è `art/virtual/pallone.svg`, + disegnata a mano. Le prove: `../tools/pzkko/virtual/prova.mjs`; `../tools/pzkko/virtual/icona.mjs` + scrive la piastrella dell'app (`art/chat-app2/app-virtual.webp`). - `games/pzk.js` — i $pzk, gli stessi per tutti i giochi e per il wallet: la sessione (1.000, non si salvano) o il conto (vedi «Il wallet e la borsa», sotto). - `wallet/` — il wallet dei $pzk (22.10 notte), il portafoglio di pelle alla Fallout in alto a sinistra: @@ -1462,6 +1473,94 @@ volantino si precarica). cilindro arrotondato di Rapier va meglio di un poliedro a 48 lati, che entrava di 8 mm; passi più corti (1/1200 s) non servono. +## Virtual (games/virtual/) + +Il volantino «virtual» (23.10.2026) apre **le scommesse sul calcio inventato** nella stessa finestra +degli altri giochi: `#virtual` nell'indirizzo lo apre subito. Un campionato infinito, una partita +sola alla volta, uguale per chiunque sia sul sito in quel momento; si gioca coi $pzk di +`games/pzk.js` (niente soldi veri, mai). + +- **Il calendario** (`partita.js`): il tempo è a **cicli di cinque minuti** allineati all'orologio. + Nei primi quattro minuti le scommesse sono aperte (12:55:00 → 12:58:59), l'ultimo minuto la + partita è in diretta (90' più il recupero compressi in 60 secondi), allo scoccare del ciclo dopo è + finita, si paga e si apre la successiva. L'indice della partita è `Math.floor(Date.now() / + 300000)`; da lì, con un generatore seminato (mulberry32), escono squadre, quote, meteo, + formazioni e tutta la cronaca. Così due persone vedono la **stessa partita** nello stesso + momento (provato con due schede: stesso punteggio allo stesso secondo), chi arriva a metà la + vede dal punto giusto e ricaricare non cambia niente. Il conto alla rovescia sta sul tabellone + («si chiude tra 2:41», «IN DIRETTA · finisce tra 34s») e sullo schermino («FISCHIO TRA 3:26»). +- **Le squadre** (`squadre.js`): le venti di Serie A e le venti di Serie B 2026/27 coi nomi + storpiati ma riconoscibili (Juvenclub, Inder, Milano Rossonera, Napule, Atalanto, Fiorentoina, + Mantua, Verona Scaligera, Stabia Vespe…), la sigla, la città, i colori di casa e di trasferta + (gli ospiti giocano in seconda maglia se la prima somiglia a quella di casa, e il portiere è di + un colore che non c'è), uno **stemma originale in SVG** (la forma, il colore sociale e lo + «spirito»: una zebra, un biscione, un giglio, un lupo, un ciuccio, una vespa; mai il logo vero) e + una **forza da 1 a 100** presa dalla realtà. I cognomi dei giocatori sono sillabe italiane a + caso. Gli accoppiamenti: A contro A (52%), B contro B (34%), un incrocio A–B il 14% delle volte. +- **Le quote seguono la realtà, i risultati no.** Le quote si calcolano come farebbe un + bookmaker: gol attesi dalla differenza di forza (più 4 punti di fattore campo e un filo di rumore + dal seme), probabilità di 1 X 2 e dei totali da due Poisson, margine del 6,5%, centesimi, mai + sotto 1,05 (1 X 2 sommano al 106,5% in probabilità implicita, over e under della stessa soglia + pure). Inder–Vicentini dà 1,07 · 11,05 · 26,85; Juvenclub–Milano 2,29 · 3,66 · 2,82. Il + **risultato** invece esce da un altro seme con una **Poisson di media fissa 1,35 a squadra**, + uguale per tutte: non legge niente delle squadre. La sfavorita vince quanto la favorita; è la + battuta del gioco, scritta nelle regole («le quote copiano la Serie A vera, i risultati no»). + Una partita su 120 finisce **a tavolino** (3–0, per nebbia, blackout dei riflettori, invasione di + nutrie, gli ospiti che sbagliano stadio…): paga come tale. +- **Le puntate**: 1 X 2 e over/under con soglie 1,5 · 2,5 · 3,5 · 4,5, anche insieme, con le fiches + degli altri giochi; vincita = puntata × quota. La schedina dice cosa si è giocato, la quota, la + possibile vincita e l'esito; le scommesse restano nel browser (`localStorage`, `pzkko-virtual`) e + si pagano al fischio finale anche a finestra chiusa (riaprendo si trova l'esito), i $pzk puntati + stanno sul tavolo di `pzk.js` fino al pagamento e tornano sul tavolo dopo una ricarica. A partita + iniziata le quote si spengono. «togli» e «rifai» come negli altri giochi. +- **Lo schermino** (`schermo.js`): un solo canvas di 192 × 120 pixel grossi per un fattore intero + (`image-rendering: pixelated`: netti a ogni misura, anche sul telefono), dentro una cornice di + plastica da tamagotchi. Il campo da calcio a cinque in erba con le righe e le porte, cinque omini + per parte con le maglie della loro squadra (strisce, metà, fascia, croce), gli spalti coi tifosi + pixel nei colori delle due squadre (che entrano durante le scommesse, saltano al gol, fanno + l'onda), le bandiere al vento, le panchine con le riserve. Tutto è funzione del tempo: niente + stato che si accumula. **La telecamera** segue la palla con inerzia e zooma sulle azioni (campo + largo, mezzo campo, primo piano sui cartellini), per i rigori l'inquadratura da dietro la porta, + il gol ha il replay al rallentatore. **Gli eventi**: fischio d'inizio, gol (il tiro, la rete che + si gonfia, GOL!, l'esultanza, il replay), tiro fuori, palo e parata, rimessa, fallo, giallo e + rosso (l'arbitro alza il cartellino, l'espulso esce e la squadra resta in quattro), rigore (gol, + parato o fuori), infortunio (a terra, la barella gialla, entra la riserva), intervallo, fischio + finale, la sospensione. **Il meteo** della città di casa è deciso dal seme ma coerente col clima + e la stagione (cinque climi, pesi mese per mese: a ottobre a Bergamo nebbia o pioggia, a Palermo + sereno, d'inverno al nord può nevicare): sereno, nuvole (le ombre che passano), pioggia (gocce, + pozzanghere, scivolate), nebbia (si vede poco), neve (il prato bianco, la palla arancione), + vento (le bandiere e le foglie). **La luce** segue l'ora vera: di sera la luce bassa, di notte i + riflettori accesi e il buio intorno. Il meteo non tocca il risultato. Un fotogramma costa 0,2–2 ms + anche a 6× (il telefono a 3×). Il ciclo si ferma con la finestra chiusa o la scheda nascosta e + riprende al secondo giusto; con `prefers-reduced-motion` la telecamera sta ferma e larga e si + disegna due volte al secondo. +- **Durante le scommesse** lo schermino non è vuoto: le squadre si scaldano nelle due metà, i + tifosi entrano, il meteo c'è già, e a rotazione i cartelli della presentazione (stemmi, forza, + le ultime cinque «uscite», inventate; la città e il meteo; «le quote copiano la Serie A vera, i + risultati no»). +- **Il tabellone** sotto lo schermino: gli stemmi SVG, i nomi, città · forza, il punteggio che + salta al gol, il minuto, i cartellini, il conto alla rovescia; sotto, una riga col meteo e le + ultime cinque. «come si gioca» apre le regole in un foglietto. +- **La schedina in comune** (`schedina.js`, 23.10 sera): le puntate, il tavolo dei $pzk, il pagamento + e l'unione fra schede stanno in un modulo solo, usato dalla finestra grande e dall'app del + cercapersone (`telefono/app/virtual.js`, vedi «Il cercapersone»): una copia per pagina, le due + interfacce si avvisano a vicenda (`ascolta`) e con le altre schede via `storage`. Chi apre per + primo (`avvia`) rimette sul tavolo le puntate aperte e paga le partite finite nel frattempo. +- **I suoni** (`suono/voci.js`, `suono/suono.js`): il fischietto, il boato del gol, l'«uuuh» del + palo, la fiche posata, la vincita a 8 bit; passano dal motore della strada, quindi spenti se chi + guarda ha spento il suono. Nella regia, scheda «suono», si provano. +- **Le prove**: `node tools/pzkko/virtual/prova.mjs [partite, 2000]` (dalla cartella + `albertopecchini.it`) costruisce N partite di fila e controlla il determinismo, le quote (la + favorita quotata più bassa, 1 X 2 al 106,5%, over/under complementari, mai sotto 1,05), che i + risultati **non** dipendano dalla forza, la cronaca (i gol della cronaca sono quelli del + risultato, gli eventi in ordine e a distanza, mai a cavallo dell'intervallo, il rosso lascia in + quattro, il tavolino 3–0) e le puntate contro un conto fatto al contrario. Il 23.10.2026, 5.000 + partite: nessun errore; correlazione fra differenza di forza e quota dell'1 −0,77 (le quote + seguono la forza), fra differenza di forza e differenza di gol +0,01 e fra quota dell'1 e «la + casa vince» −0,00 (i risultati no); con una favorita netta (≥ 15 punti) vince la favorita il + 38,8%, pareggio 25,5%, la sfavorita 35,7%, nelle partite equilibrate 38,1 / 24,6 / 37,2 (chi + quadro p = 0,54); gol a squadra media 1,364 e varianza 1,374 (Poisson); tavolini 0,5%. + ## La regia (regia/) Il pannello per provare e regolare il sito senza aspettare la sera o un'auto. Si apre @@ -1507,7 +1606,7 @@ passare dall'una all'altra; l'ultima aperta resta nel browser): bestie (ogni specie sì o no, le blatte anche di giorno, la velocità, «spaventale», una zanzara davanti alla telecamera); le gocce più fitte o più rade, una adesso; - **suono**: i suoni della strada (`suono/`, sotto): acceso, il volume, e ognuno da - provare; + provare (dal 23.10 anche il fischietto, il boato e l'«uuuh» di virtual); - **prove**: il muro del DOM in trasparenza (`?allinea`), rifare l'arrivo, il glTF (questi due ricaricano la pagina; il muro senza 3D, dal 23.10, è l'interruttore in cima a «grafica»), e i numeri della scheda (il @@ -1838,7 +1937,17 @@ Sul cellulare lo schermo prende tutta la finestra e i tasti diventano una barra col conto alla rovescia a mezzanotte), reparti, ricerca, ordine, preferiti, la scheda del prodotto, il carrello, la cassa con lo scontrino. **Sfide**: le tre sfide di oggi. **Quiz**: i quiz retributivi. **Quest**: le side quest. **Giocatori**: il database (classifiche col podio, notizie, cerca, il tuo profilo coi rivali, i numeri della banca). **Zaino**: quello - che hai, coi bonus in corso. **Foto**: le foto vere dei quiz. **Banca**: apre il wallet. + che hai, coi bonus in corso. **Foto**: le foto vere dei quiz. **Virtual** (23.10 sera, Alberto: «il gioco dovrebbe essere + anche dentro al cercapersone»; `telefono/app/virtual.js`): le scommesse sul calcio inventato, la **stessa partita** del + gioco grande sullo stesso schermino a pixel (`games/virtual/partita.js` e `schermo.js`), col tabellone, il conto alla + rovescia, le quote 1 X 2 e over/under, le fiches e la schedina; scommesse e pagamenti passano dalla **stessa schedina** + della finestra grande (`games/virtual/schedina.js`, `pzkko-virtual`, lo stesso tavolo dei $pzk), quindi quello che si + punta qui si vede là e viceversa; «gioco grande» apre la finestra sopra il muro; sullo schermo largo lo schermino sta a + sinistra e il banco a destra, sul cellulare tutto in colonna. Il pallino: le vincite non ancora viste, o le scommesse + aperte sulla partita che chiude entro un minuto. In chat `virtual` (anche `scommesse`, `calcio`) la apre. La piastrella + (`art/chat-app2/app-virtual.webp`) per ora è disegnata a mano in SVG da `tools/pzkko/virtual/icona.mjs` (il pallone + davanti alla porta col fischietto, sul verde): da rigenerare con gpt-image-2 come le altre, `node tools/pzkko/immagini.mjs + genera chat-app2` (il prompt è nel foglio `chat-app2` di `tools/pzkko/fogli-chat.js`). **Banca**: apre il wallet. **La miniera** (23.10; app `telefono/app/miniera.js`, regole `chat/miniera.js`, catalogo `chat/miniera-dati.mjs`, disegni `chat/miniera-pixel.js`): si minano $pzk col PC, ma prima bisogna comprare i pezzi e farsi la postazione. Dodici posti @@ -1890,7 +1999,7 @@ scorrerle. Niente emoji di sistema: solo le nostre icone (`chat/icone.js`, `art/ - *negozio*: `negozio [reparto]` (offerte, cibo, attrezzi, fortuna, roba, pzk: ogni pezzo in una riga col tasto «compra»), `compra` (da solo apre la vetrina), `vendi`, `usa`, `info`, `zaino`, `apri`, `gratta`. Un nome di oggetto scritto da solo propone compra/usa/info. -- *app*: `telefono`, `shop`, `sfide [riscuoti]`, `missioni`, `quest [scelta]`. +- *app*: `telefono`, `shop`, `virtual`, `sfide [riscuoti]`, `missioni`, `quest [scelta]`. - *giocatori* (il database): `classifica [metrica]`, `cerca `, `giocatore [nome|pzk123456]`, `notizie`, `rivale`, `banca`. diff --git a/albertopecchini.it/pzkko/art/chat-app2/app-virtual.webp b/albertopecchini.it/pzkko/art/chat-app2/app-virtual.webp new file mode 100644 index 00000000..73a16048 Binary files /dev/null and b/albertopecchini.it/pzkko/art/chat-app2/app-virtual.webp differ diff --git a/albertopecchini.it/pzkko/art/scena/vol-lo/virtual.webp b/albertopecchini.it/pzkko/art/scena/vol-lo/virtual.webp new file mode 100644 index 00000000..4fbf6d0f Binary files /dev/null and b/albertopecchini.it/pzkko/art/scena/vol-lo/virtual.webp differ diff --git a/albertopecchini.it/pzkko/art/scena/vol-m-lo/virtual.webp b/albertopecchini.it/pzkko/art/scena/vol-m-lo/virtual.webp new file mode 100644 index 00000000..e0659714 Binary files /dev/null and b/albertopecchini.it/pzkko/art/scena/vol-m-lo/virtual.webp differ diff --git a/albertopecchini.it/pzkko/art/scena/vol-m.json b/albertopecchini.it/pzkko/art/scena/vol-m.json index a3d222b2..64615857 100644 --- a/albertopecchini.it/pzkko/art/scena/vol-m.json +++ b/albertopecchini.it/pzkko/art/scena/vol-m.json @@ -666,5 +666,13 @@ "cw": 176.72, "scala": 2, "tagliandi": null + }, + "virtual": { + "w": 392, + "h": 566, + "m": 27, + "cw": 169.2, + "scala": 2, + "tagliandi": null } -} \ No newline at end of file +} diff --git a/albertopecchini.it/pzkko/art/scena/vol-m/virtual.webp b/albertopecchini.it/pzkko/art/scena/vol-m/virtual.webp new file mode 100644 index 00000000..684397a4 Binary files /dev/null and b/albertopecchini.it/pzkko/art/scena/vol-m/virtual.webp differ diff --git a/albertopecchini.it/pzkko/art/scena/vol.json b/albertopecchini.it/pzkko/art/scena/vol.json index 60f5eb04..e5e3b0d5 100644 --- a/albertopecchini.it/pzkko/art/scena/vol.json +++ b/albertopecchini.it/pzkko/art/scena/vol.json @@ -666,5 +666,13 @@ "cw": 255, "scala": 2, "tagliandi": null + }, + "virtual": { + "w": 532, + "h": 638, + "m": 37, + "cw": 230, + "scala": 2, + "tagliandi": null } -} \ No newline at end of file +} diff --git a/albertopecchini.it/pzkko/art/scena/vol/virtual.webp b/albertopecchini.it/pzkko/art/scena/vol/virtual.webp new file mode 100644 index 00000000..7bdc7079 Binary files /dev/null and b/albertopecchini.it/pzkko/art/scena/vol/virtual.webp differ diff --git a/albertopecchini.it/pzkko/art/virtual/pallone.svg b/albertopecchini.it/pzkko/art/virtual/pallone.svg new file mode 100644 index 00000000..d5d24a66 --- /dev/null +++ b/albertopecchini.it/pzkko/art/virtual/pallone.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/albertopecchini.it/pzkko/chat/comandi/app.js b/albertopecchini.it/pzkko/chat/comandi/app.js index 2094878c..3ba0bd1e 100644 --- a/albertopecchini.it/pzkko/chat/comandi/app.js +++ b/albertopecchini.it/pzkko/chat/comandi/app.js @@ -1,4 +1,4 @@ -// I comandi delle app del telefono, giocabili anche dalla chat: telefono, shop, sfide, missioni, quest, quiz. +// I comandi delle app del telefono, giocabili anche dalla chat: telefono, shop, virtual, sfide, missioni, quest, quiz. // (La logica sta in sfide.js, missioni.js, quiz.js, mercato.js; l'interfaccia grande è nelle app di telefono/app/.) import * as zaino from '../zaino.js' import * as sfide from '../sfide.js' @@ -77,6 +77,10 @@ export const APP = [ nomi: ['shop', 'ecommerce'], cat: 'app', icona: 'sacco', breve: 'lo shop del telefono: offerte, carrello, tutto il catalogo', uso: 'shop', senzaEventi: true, async fai(ctx) { ctx.apriApp('negozio') }, }, + { + nomi: ['virtual', 'scommesse', 'calcio'], cat: 'app', icona: 'app-virtual', breve: 'le scommesse sul campionato inventato: una partita ogni 5 minuti', uso: 'virtual', senzaEventi: true, + async fai(ctx) { ctx.apriApp('virtual') }, + }, { nomi: ['sfide', 'sfida', 'daily', 'giornaliere'], cat: 'app', icona: 'fulmine', breve: 'le tre sfide di oggi; «sfide riscuoti» ritira i premi', uso: 'sfide [riscuoti]', senzaEventi: true, async fai(ctx) { diff --git a/albertopecchini.it/pzkko/chat/icone.js b/albertopecchini.it/pzkko/chat/icone.js index b477e550..1fa6b596 100644 --- a/albertopecchini.it/pzkko/chat/icone.js +++ b/albertopecchini.it/pzkko/chat/icone.js @@ -28,6 +28,9 @@ export const FOGLI_CHAT = { 'chat-pzk': ['luckypzkko', 'gomma', 'tombino', 'lucchettone', 'citofono', 'pzkkonet', 'portachiavi', 'petizione', 'neon', 'portafoglio', 'pager', 'peluche'], // le app del cercapersone grande (telefono/): piastrelle adesivo intere, e i tre tasti della barra del cellulare 'chat-app': ['app-chat', 'app-shop', 'app-sfide', 'app-quiz', 'app-quest', 'app-giocatori', 'app-zaino', 'app-foto', 'app-banca', 'app-home', 'app-indietro', 'app-chiudi'], + // la piastrella di virtual (23.10 sera): per ora disegnata a mano in SVG → webp (art/chat-app2/app-virtual.webp), da rifare + // con gpt-image-2 come le altre (il prompt è nel foglio chat-app2 di tools/pzkko/fogli-chat.js) + 'chat-app2': ['app-virtual'], // l'app Miniera (23.10): questa NON è di Replicate, è disegnata in codice a pixel come tutta la miniera // (tools/pzkko/miniera-icona.mjs la scrive in art/chat-miniera/; fogli-chat.js non ha un prompt per lei e non la rifà) 'chat-miniera': ['app-miniera'], diff --git a/albertopecchini.it/pzkko/games/PROMPT-virtual.md b/albertopecchini.it/pzkko/games/PROMPT-virtual.md new file mode 100644 index 00000000..e02b73f8 --- /dev/null +++ b/albertopecchini.it/pzkko/games/PROMPT-virtual.md @@ -0,0 +1,168 @@ +# Prompt — «Virtual», le scommesse sul calcio inventato di pzkko.it + +## Il gioco in una frase + +Un nuovo gioco di pzkko.it, `games/virtual/`, nella stessa finestra degli altri +(roulette, blackjack, mine, dado, moneta): un campionato di calcio **inventato e +infinito**, una partita sola alla volta, quattro minuti per scommettere e un minuto +di partita animata in uno schermino in stile tamagotchi. Si gioca coi **$pzk** di +`games/pzk.js`: niente soldi veri, mai. + +## Prima di scrivere una riga + +1. Leggi `pzkko/README.md` per intero, poi apri e studia **almeno due giochi già + fatti** (consiglio `games/moneta/` e `games/mine/`) e `games/pzk.js`, + `games/crt.js`/`crt.css`, `wallet/`. Il nuovo gioco deve sembrare nato insieme a + loro: stessa finestra, stessa apertura dal muro, stesse fiches, stesso modo di + prendere e restituire i $pzk (sessione da 1.000 o conto del wallet), stesso + rispetto di `prefers-reduced-motion`, stesso italiano nei commenti e nei testi. +2. Guarda come la home aggancia i giochi (volantino sul muro, apertura della + finestra, regia) e aggancia `virtual` allo stesso modo. +3. Niente build, niente framework: file statici come il resto di `pzkko/`. + +## Il calendario: la partita di ogni minuto, uguale per tutti + +- Il tempo è diviso in **cicli di 5 minuti** allineati all'orologio: + - minuti 0–4 del ciclo: **scommesse aperte** (es. 12:55:00 → 12:58:59); + - ultimo minuto: **partita in diretta** (12:59:00 → 12:59:59); + - a 13:00:00 la partita è finita, si paga e si apre la successiva + (13:00 → 13:04 scommesse, 13:04 → 13:05 partita, e così via all'infinito). + - Nota per te: «ogni minuto c'è una partita sola» significa che sullo schermo + c'è sempre **una sola** partita, mai due insieme; il ritmo è 4+1. +- **Tutto è deterministico dall'orario**: l'indice della partita è + `Math.floor(Date.now() / 300000)`; da quell'indice, con un PRNG seminato + (stile `regole.js` delle mine, che già decide le mine dal seme), escono + squadre, quote, meteo e l'intera cronaca minuto per minuto. Così due persone + sullo stesso sito vedono la **stessa partita** nello stesso momento, chi apre + il gioco a metà partita la vede dal punto giusto, e un ricaricamento non cambia + niente. Mostra sempre un conto alla rovescia grande e chiaro («si chiude tra + 2:41», «IN DIRETTA 34'», «FINALE»). +- Le scommesse si accettano solo nella finestra dei 4 minuti; a partita iniziata + il pulsante si spegne. Le scommesse fatte restano visibili nella schedina e si + pagano al fischio finale anche se la finestra è stata chiusa (salvale come fanno + gli altri giochi; se l'utente torna dopo, trova l'esito). + +## Le squadre: Serie A e Serie B storpiate + +- Tutte le squadre di Serie A e Serie B 2026/27 con **nomi storpiati ma + riconoscibili** (stile videogioco senza licenza: «Juvenclub», «Inder», + «Milano Rossonera», «Napule», «Atalanto», «Fiorentoina», «Mantua»…, inventane + di divertenti ma non offensivi) e **stemmi originali disegnati da te** in SVG o + canvas: stessi colori sociali e lo stesso «spirito» (una zebra stilizzata, un + biscione, un giglio), ma **mai** il logo vero, mai i marchi registrati, mai i + nomi veri dei giocatori. Cognomi dei giocatori inventati anche quelli. +- Ogni squadra ha: nome storpiato, sigla di 3 lettere, città, colori maglia + casa/trasferta (le maglie si vedono addosso agli omini in campo), stemma, e una + **forza** da 1 a 100 presa dalla realtà (le big alte, le neopromosse e le + squadre di B basse). +- Gli accoppiamenti: casuali dal seme, di solito A contro A o B contro B, ogni + tanto un incrocio A–B (che dà quote più sbilanciate e più divertenti). + +## Le quote: seguono la realtà, i risultati no + +Questo è il cuore, rispettalo alla lettera: + +- **Le quote seguono la forza reale**: la squadra forte è quotata bassa, quella + scarsa alta; fattore campo leggero per chi gioca in casa; un pizzico di rumore + dal seme così due partite uguali non hanno quote identiche. +- **I risultati sono completamente casuali** e **non** dipendono dalla forza: il + risultato esce da una distribuzione indipendente dalle squadre (gol per + squadra, per esempio, con una Poisson di media fissa ~1,35 uguale per tutte). + Quindi la sfavorita vince spesso quanto la favorita: è voluto, è la battuta del + gioco. Scrivilo in una riga nella schermata delle regole («le quote copiano la + Serie A vera, i risultati no»). +- Le quote devono comunque essere **proporzionate e credibili** come quelle di un + bookmaker: calcola le probabilità implicite dalla forza, applica un margine + (~6–8%), arrotonda a due decimali, mai sotto 1,05, coerenti tra loro (1 X 2 che + sommano a ~106%, Over e Under della stessa soglia complementari). + +### Le scommesse possibili + +Solo queste, niente altro: + +- **1 X 2** (esito finale). +- **Over / Under** sul totale dei gol con soglie **1,5 · 2,5 · 3,5 · 4,5** + (le «maggiore/minore di 2, 3, 4…» della richiesta originale: le soglie a mezzo + gol evitano il pareggio della scommessa). + +Si possono mettere più puntate sulla stessa partita (anche 1 e Over 2,5 insieme), +con le fiches dei $pzk come negli altri giochi; vincita = puntata × quota. +Schedina chiara: cosa hai giocato, quota, possibile vincita, esito. + +## La partita: un minuto di schermino tamagotchi + +Uno **schermino LCD/pixel** (canvas, rendering `image-rendering: pixelated`, +palette ridotta, animazioni a pochi fotogrammi, «clean da tamagotchi»: carino, +leggibile, mai confuso) dentro la finestra del gioco, con sotto o accanto il +tabellone (stemmi, nomi, punteggio, minuto, cartellini). + +- **Campo da calcio a 5 in erba**, con le righe, le due porte, e i giocatori a + **cinque per parte** con le **maglie della propria squadra** (colori casa e, + se si somigliano, trasferta per gli ospiti) e un portiere di colore diverso. +- Il minuto vero di partita corre come **90' + recupero compressi in 60 + secondi**. La cronaca è una lista di eventi decisi dal seme prima che la + partita inizi; l'animazione li mette in scena all'istante giusto. +- **Eventi animati**, ognuno con una piccola sequenza riconoscibile: + gol (esultanza, rete che si gonfia, scritta GOL), tiro fuori / palo / parata + (quasi gol), rimessa laterale, fallo, **cartellino giallo**, **cartellino + rosso** (il giocatore esce e la squadra resta in quattro), **rigore** + (dischetto, rincorsa, gol o parata), **infortunio** (giocatore a terra, barella + pixel, cambio), fischio d'inizio, intervallo, fischio finale. Rara ma possibile: + la **vittoria a tavolino** (partita sospesa, 3–0 a tavolino scritto sul + tabellone; decidi tu una causa buffa e innocua, tipo «nebbia», «blackout dei + riflettori», «invasione di nutrie»). Le scommesse si pagano sul risultato + ufficiale, tavolino compreso. +- **Telecamera che si muove**: segue la palla con un po' di inerzia, zooma sulle + azioni pericolose; **inquadrature diverse** che cambiano (campo largo, mezzo + campo, dietro la porta per i rigori, primo piano sul cartellino, replay rapido + del gol a velocità ridotta). +- **Contorno**: spalti con **tifosi pixel** nei colori delle due squadre che si + alzano ed esultano al gol, sbandieratori, cori come onde di pixel; panchine. +- **Meteo della città di chi gioca in casa**, deciso dal seme ma **coerente col + clima e la stagione reale** di quella città nel giorno di oggi (a ottobre a + Bergamo può piovere o esserci nebbia, a Palermo è sereno e caldo, al nord + d'inverno può nevicare; se sei in dubbio usa la media climatica della zona). + Effetti: sereno, nuvole, pioggia (pozzanghere e scivolate), nebbia (si vede + poco, bellissimo nello schermino), neve (palla arancione), vento. **Luce in + base all'ora vera**: partite serali coi riflettori accesi. + Ma il meteo **non cambia mai il risultato**: è solo scena. +- Suoni opzionali coerenti con `suono/` (fischietto, boato, «uuuh» sul palo), + sintetizzati come il resto del sito, spenti se l'utente ha spento il suono. + +## Durante le scommesse (i 4 minuti) + +Lo schermino non è vuoto: squadre che si scaldano, tifosi che entrano, meteo +già visibile, presentazione della partita (stemmi, forza, ultimi «risultati» +inventati, che ovviamente non contano). Tutto deve far venire voglia di giocare +ma senza schermate rumorose. + +## Qualità e dettagli che contano + +- Funziona bene da **telefono** (molti aprono pzkko.it da lì): schermino che si + ridimensiona tenendo i pixel netti, pulsanti grandi, niente scroll orizzontale. +- Prestazioni: un solo canvas, `requestAnimationFrame`, si ferma quando la + finestra è chiusa o la scheda nascosta (riprende al punto giusto grazie al + determinismo). +- `prefers-reduced-motion`: niente telecamera che si muove, solo il tabellone e + le scritte degli eventi. +- Le regole del gioco in una schermata breve e simpatica nello stesso tono degli + altri giochi, con la frase su quote vere e risultati a caso, e che si gioca coi + $pzk, che non valgono niente fuori da pzkko.it. +- Nessuna immagine generata da un modello è necessaria: disegna tutto in canvas/SVG. + Se proprio ti serve un'immagine, segui il metodo di `tools/pzkko/fogli.js`, ma + preferisci di no. + +## Consegna + +1. Lavora su un ramo `pzkko/virtual`, a partire da `main` aggiornato. +2. Aggiorna `pzkko/README.md` con una voce `games/virtual/` nello stesso stile + delle altre (cosa fa ogni file), e la `regia` se gli altri giochi ci compaiono. +3. Provalo davvero: apri la pagina in locale (`npm run dev:pzkko` dalla radice), + apri il gioco, guarda almeno un ciclo completo scommesse → partita → pagamento, + verifica che due schede aperte vedano la stessa partita e che le quote siano + sensate (favorita bassa, sfavorita alta) mentre i risultati su ~1.000 partite + simulate siano indipendenti dalla forza. Controlla anche il telefono + (viewport stretto). +4. Commit chiari in italiano, come quelli già nel repo; push del ramo e apri una + pull request verso `main` con una descrizione breve in italiano di cosa c'è e + come provarlo. Non fare merge su `main` da solo. diff --git a/albertopecchini.it/pzkko/games/pzk.js b/albertopecchini.it/pzkko/games/pzk.js index 4f49d2c9..85616d36 100644 --- a/albertopecchini.it/pzkko/games/pzk.js +++ b/albertopecchini.it/pzkko/games/pzk.js @@ -1,4 +1,4 @@ -// I $pzk di pzkko.it: soldi finti, gli stessi per tutti i giochi (roulette, blackjack, mine, dado, moneta e quelli che +// I $pzk di pzkko.it: soldi finti, gli stessi per tutti i giochi (roulette, blackjack, mine, dado, moneta, virtual e quelli che // verranno) e per il wallet in alto a sinistra (wallet/wallet.js). Non valgono niente e non si comprano. // // Due modi di stare al mondo (dal 22.10 notte): diff --git a/albertopecchini.it/pzkko/games/virtual/partita.js b/albertopecchini.it/pzkko/games/virtual/partita.js new file mode 100644 index 00000000..98fb7a91 --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/partita.js @@ -0,0 +1,410 @@ +// Il calendario e le partite di «Virtual», il campionato inventato di pzkko.it. Niente disegno qui: la finestra +// (virtual.js) e lo schermino (schermo.js) chiedono a questo modulo cosa succede, e la prova +// (tools/pzkko/virtual/prova.mjs) lo fa girare su migliaia di partite senza browser. +// +// IL TEMPO. Il tempo è diviso in cicli di cinque minuti allineati all'orologio: i primi quattro minuti del ciclo le +// scommesse sono aperte, l'ultimo minuto la partita è in diretta (90' più il recupero compressi in 60 secondi), allo +// scoccare del ciclo dopo è finita, si paga e si apre la successiva. L'indice della partita è Math.floor(Date.now() / +// 300000): da quell'indice, con un generatore seminato, escono le squadre, le quote, il meteo e tutta la cronaca. Così +// chiunque apra pzkko.it vede LA STESSA partita nello stesso momento, chi arriva a metà la vede dal punto giusto e +// ricaricare non cambia niente. +// +// LE QUOTE SEGUONO LA REALTÀ, I RISULTATI NO. Le quote si calcolano dalla forza vera delle squadre (squadre.js: le big +// alte, le neopromosse e la Serie B basse) con un fattore campo leggero e un pizzico di rumore dal seme, come farebbe un +// bookmaker: gol attesi dalla differenza di forza, probabilità di 1 X 2 e dei totali da una Poisson, un margine del +// 6,5%, arrotondate ai centesimi e mai sotto 1,05. Il risultato invece esce da una Poisson di media fissa (GOL_MEDI, +// 1,35 a squadra) uguale per tutte, che della forza non sa niente: la sfavorita vince quanto la favorita. È voluto, è la +// battuta del gioco. Anche il meteo è solo scena: non tocca il risultato. + +import { SQUADRE, SERIE_A, SERIE_B, cognome } from './squadre.js' + +export const CICLO = 5 * 60 * 1000 // ms +export const SCOMMESSE = 4 * 60 * 1000 // i primi quattro minuti del ciclo +export const DURATA = CICLO - SCOMMESSE // il minuto della partita +export const GOL_MEDI = 1.35 +export const MARGINE = 0.065 +export const QUOTA_MINIMA = 1.05 +export const CAMPO = 4 // il fattore campo, in punti di forza +export const SOGLIE = [1.5, 2.5, 3.5, 4.5] +/** Le puntate possibili: 1 X 2 e over/under sulle quattro soglie. */ +export const PUNTATE = [ + { id: '1', nome: '1', dice: 'vince chi gioca in casa' }, + { id: 'X', nome: 'X', dice: 'pareggio' }, + { id: '2', nome: '2', dice: 'vince chi gioca fuori' }, + ...SOGLIE.flatMap((s) => [{ id: `O${s}`, nome: `over ${s.toLocaleString('it-IT')}`, dice: `più di ${s.toLocaleString('it-IT')} gol in tutto` }, { id: `U${s}`, nome: `under ${s.toLocaleString('it-IT')}`, dice: `meno di ${s.toLocaleString('it-IT')} gol in tutto` }]), +] +export const PER_PUNTATA = new Map(PUNTATE.map((p) => [p.id, p])) + +// ── l'orologio del campionato ──────────────────────────────────────────────── +export const indiceAdesso = (now = Date.now()) => Math.floor(now / CICLO) +/** Dove siamo nel ciclo: { indice, fase: 'scommesse' | 'partita', manca (ms alla chiusura o alla fine), t (secondi di partita, 0–60) }. */ +export function fase(now = Date.now()) { + const indice = Math.floor(now / CICLO), dentro = now - indice * CICLO + if (dentro < SCOMMESSE) return { indice, fase: 'scommesse', manca: SCOMMESSE - dentro, t: 0 } + return { indice, fase: 'partita', manca: CICLO - dentro, t: (dentro - SCOMMESSE) / 1000 } +} + +// ── il generatore seminato (mulberry32) ────────────────────────────────────── +/** Un generatore riproducibile: rand() dà un numero fra 0 e 1, sempre gli stessi per lo stesso seme. */ +export function generatore(seme) { + let a = (seme ^ 0x9e3779b9) >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} +// due semi diversi per la stessa partita: uno per squadre, quote e scena (che DEVONO dipendere dall'indice), uno a parte +// per il risultato. Così è anche evidente nel codice che il risultato non legge niente delle squadre +const semeScena = (indice) => (Math.imul(indice, 2654435761) ^ 0x5bd1e995) >>> 0 +const semeRisultato = (indice) => (Math.imul(indice ^ 0x7f4a7c15, 1597334677) + 0x3c6ef372) >>> 0 + +/** Una Poisson di media `media`, con rand. */ +export function poisson(media, rand) { + const L = Math.exp(-media) + let k = 0, p = rand() + while (p > L) { k++; p *= rand() } + return k +} + +// ── le probabilità da cui nascono le quote ─────────────────────────────────── +const fattoriale = (n) => { let f = 1; for (let i = 2; i <= n; i++) f *= i; return f } +const pPoisson = (l, k) => (Math.exp(-l) * l ** k) / fattoriale(k) +/** + * Dai gol attesi delle due squadre, le probabilità di 1, X, 2 e di over/under per ogni soglia (Poisson indipendenti, + * fino a 10 gol a testa). È il modello del bookmaker: con le forze vere dà quote credibili. + */ +export function probabilita(lc, lo) { + let p1 = 0, pX = 0, p2 = 0 + const tot = new Array(21).fill(0) + for (let a = 0; a <= 10; a++) for (let b = 0; b <= 10; b++) { + const p = pPoisson(lc, a) * pPoisson(lo, b) + if (a > b) p1 += p; else if (a === b) pX += p; else p2 += p + tot[a + b] += p + } + const s = p1 + pX + p2 + const over = {}, under = {} + for (const soglia of SOGLIE) { + let o = 0 + for (let n = Math.ceil(soglia); n <= 20; n++) o += tot[n] + over[soglia] = o / s; under[soglia] = 1 - o / s + } + return { 1: p1 / s, X: pX / s, 2: p2 / s, over, under } +} +/** I gol attesi che il bookmaker dà a casa e ospite dalle loro forze (già col rumore). */ +export function golAttesi(forzaCasa, forzaOspite) { + const d = (forzaCasa + CAMPO - forzaOspite) / 100 + return { lc: GOL_MEDI * Math.exp(1.25 * d), lo: GOL_MEDI * Math.exp(-1.25 * d) } +} +const quota = (p) => Math.max(QUOTA_MINIMA, Math.round(100 / (p * (1 + MARGINE))) / 100) +/** Le quote dalle probabilità: margine, centesimi, mai sotto 1,05. */ +export function quoteDa(P) { + const q = { 1: quota(P[1]), X: quota(P.X), 2: quota(P[2]) } + for (const s of SOGLIE) { q[`O${s}`] = quota(P.over[s]); q[`U${s}`] = quota(P.under[s]) } + return q +} + +// ── il tempo di gioco: 90' + recupero in 60 secondi ────────────────────────── +// primo tempo 0–27 s (0'–45'), intervallo 27–29,5 s, secondo tempo 29,5–57 s (45'–90'), recupero 57–60 s (90+1'…90+3') +export const T = { fine1: 27, via2: 29.5, fine2: 57, fine: 60 } +/** Il minuto da scrivere sul tabellone a t secondi: { min, extra, scritta } (scritta: «34'», «90+2'», «INT», «FINE»). */ +export function minuto(t) { + if (t < 0) return { min: 0, extra: 0, scritta: "0'" } + if (t < T.fine1) { const m = Math.floor((t / T.fine1) * 45) + 1; return { min: m, extra: 0, scritta: `${m}'` } } + if (t < T.via2) return { min: 45, extra: 0, scritta: 'INT', intervallo: true } + if (t < T.fine2) { const m = 45 + Math.floor(((t - T.via2) / (T.fine2 - T.via2)) * 45) + 1; return { min: m, extra: 0, scritta: `${m}'` } } + if (t < T.fine) { const e = Math.floor(((t - T.fine2) / (T.fine - T.fine2)) * 3) + 1; return { min: 90, extra: e, scritta: `90+${e}'` } } + return { min: 90, extra: 3, scritta: 'FINE', finita: true } +} +/** Il secondo di partita in cui cade il minuto m (1–93), con una frazione a caso dentro il minuto. */ +function secondoDelMinuto(m, frazione = 0.5) { + if (m <= 45) return ((m - 1 + frazione) / 45) * T.fine1 + if (m <= 90) return T.via2 + ((m - 46 + frazione) / 45) * (T.fine2 - T.via2) + return T.fine2 + ((m - 91 + frazione) / 3) * (T.fine - T.fine2) +} + +// ── il meteo della città di casa, coerente col clima e con la stagione ────── +// Per ogni clima, mese per mese, quanto spesso c'è sereno, nuvole, pioggia, nebbia, neve, vento (pesi). Medie climatiche a +// occhio, non previsioni: a ottobre a Bergamo può piovere o esserci nebbia, a Palermo è sereno e caldo, al nord d'inverno +// può nevicare. È solo scena: il risultato non lo guarda. +const METEO = ['sereno', 'nuvole', 'pioggia', 'nebbia', 'neve', 'vento'] +// ser nuv pio neb nev ven +const CLIMA = { + nord: { + inverno: [20, 25, 15, 28, 8, 4], primavera: [35, 30, 25, 4, 0, 6], estate: [60, 20, 14, 0, 0, 6], autunno: [25, 25, 22, 22, 1, 5], + temp: { inverno: [0, 7], primavera: [10, 20], estate: [22, 33], autunno: [8, 18] }, + }, + alpi: { + inverno: [35, 25, 5, 5, 28, 2], primavera: [35, 30, 20, 2, 8, 5], estate: [50, 25, 20, 0, 0, 5], autunno: [35, 25, 20, 5, 10, 5], + temp: { inverno: [-4, 5], primavera: [6, 17], estate: [16, 28], autunno: [4, 15] }, + }, + liguria: { + inverno: [35, 30, 25, 0, 2, 8], primavera: [40, 30, 22, 0, 0, 8], estate: [65, 20, 8, 0, 0, 7], autunno: [30, 25, 35, 0, 0, 10], + temp: { inverno: [6, 12], primavera: [12, 20], estate: [22, 30], autunno: [11, 19] }, + }, + centro: { + inverno: [35, 30, 22, 6, 2, 5], primavera: [45, 28, 20, 1, 0, 6], estate: [70, 18, 7, 0, 0, 5], autunno: [38, 25, 28, 4, 0, 5], + temp: { inverno: [4, 12], primavera: [11, 21], estate: [22, 33], autunno: [10, 20] }, + }, + sud: { + inverno: [45, 28, 18, 1, 1, 7], primavera: [58, 24, 11, 0, 0, 7], estate: [85, 10, 2, 0, 0, 3], autunno: [52, 24, 17, 0, 0, 7], + temp: { inverno: [8, 15], primavera: [13, 23], estate: [24, 34], autunno: [14, 23] }, + }, +} +const stagione = (mese) => (mese === 11 || mese <= 1 ? 'inverno' : mese <= 4 ? 'primavera' : mese <= 7 ? 'estate' : 'autunno') +// l'ora e il mese di Mantova (Europe/Rome) dell'istante +function oraRoma(ms) { + const p = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Rome', hour: 'numeric', minute: 'numeric', month: 'numeric', hour12: false }).formatToParts(new Date(ms)) + const v = (t) => Number(p.find((x) => x.type === t)?.value) + return { ora: v('hour') % 24 + v('minute') / 60, mese: v('month') - 1 } +} +// tramonto e alba a occhio per l'Italia del nord, mese per mese (ora locale): basta per decidere se i riflettori sono accesi +const TRAMONTO = [17.0, 17.7, 18.3, 20.0, 20.6, 21.1, 21.1, 20.6, 19.7, 18.7, 17.0, 16.8] +const ALBA = [7.9, 7.4, 6.7, 6.8, 6.0, 5.6, 5.8, 6.3, 6.9, 7.4, 7.1, 7.8] +function meteoDi(squadra, inizio, rand) { + const { ora, mese } = oraRoma(inizio) + const c = CLIMA[squadra.clima], st = stagione(mese), pesi = c[st] + let r = rand() * pesi.reduce((a, b) => a + b, 0), tipo = 'sereno' + for (let i = 0; i < pesi.length; i++) { if (r < pesi[i]) { tipo = METEO[i]; break } r -= pesi[i] } + const [tmin, tmax] = c.temp[st] + // la temperatura: fra minima e massima della stagione, più fresca di notte e col maltempo + let temp = tmin + (tmax - tmin) * (0.25 + 0.5 * rand()) + const notte = ora < ALBA[mese] || ora >= TRAMONTO[mese] + const sera = !notte && ora >= TRAMONTO[mese] - 0.75 // la luce bassa prima del tramonto + if (notte) temp -= 3 + if (tipo === 'pioggia' || tipo === 'nebbia') temp -= 2 + if (tipo === 'neve') temp = Math.min(temp, 1) + return { tipo, temp: Math.round(temp), notte, sera, forza: 0.5 + rand() * 0.5, mese, ora } +} + +// ── le cause buffe della vittoria a tavolino ───────────────────────────────── +export const CAUSE = [ + 'blackout dei riflettori', 'invasione di nutrie', 'il pallone è finito nel canale e non ce n\'era un altro', 'gli ospiti hanno sbagliato stadio', + 'l\'arbitro ha perso il fischietto', 'il bar dello stadio ha finito le brioche', 'un drone del fantacalcio sul campo', 'la squadra di casa è rimasta chiusa negli spogliatoi', +] +const CAUSA_NEBBIA = 'nebbia fitta: non si vede la porta' + +// ── la partita ─────────────────────────────────────────────────────────────── +const cache = new Map() +/** + * La partita con quell'indice: squadre, quote, meteo, formazioni, le ultime cinque «uscite» inventate, il risultato + * ufficiale e la cronaca (eventi con il secondo in cui cadono). Tutto dal seme: uguale per chiunque la chieda. + */ +export function partita(indice) { + let p = cache.get(indice) + if (p) return p + p = costruisci(indice) + if (cache.size > 40) cache.delete(cache.keys().next().value) + cache.set(indice, p) + return p +} + +function costruisci(indice) { + const rand = generatore(semeScena(indice)) + const inizio = indice * CICLO + SCOMMESSE // il fischio d'inizio, in ms + // le squadre: di solito A contro A o B contro B, una su sette un incrocio + const A = SQUADRE.filter((s) => s.serie === SERIE_A), B = SQUADRE.filter((s) => s.serie === SERIE_B) + const r = rand() + let lista1, lista2 + if (r < 0.52) lista1 = lista2 = A + else if (r < 0.86) lista1 = lista2 = B + else if (rand() < 0.5) { lista1 = A; lista2 = B } else { lista1 = B; lista2 = A } + const casa = lista1[Math.floor(rand() * lista1.length)] + let ospite = lista2[Math.floor(rand() * lista2.length)] + while (ospite === casa) ospite = lista2[Math.floor(rand() * lista2.length)] + + // le quote: dalla forza vera, col fattore campo e un filo di rumore (così due partite uguali non hanno quote identiche) + const fc = casa.forza + (rand() - 0.5) * 6, fo = ospite.forza + (rand() - 0.5) * 6 + const { lc, lo } = golAttesi(fc, fo) + const prob = probabilita(lc, lo) + const quote = quoteDa(prob) + + const meteo = meteoDi(casa, inizio, rand) + const formazioni = { casa: formazione(rand), ospite: formazione(rand) } + const ultime = { casa: Array.from({ length: 5 }, () => 'VNP'[Math.floor(rand() * 3)]), ospite: Array.from({ length: 5 }, () => 'VNP'[Math.floor(rand() * 3)]) } + // le maglie: gli ospiti giocano in trasferta se la loro prima maglia somiglia a quella di casa + const maglie = { casa: { colori: casa.casa, disegno: casa.disegno }, ospite: simili(casa.casa, ospite.casa) ? { colori: ospite.fuori, disegno: 'pieno' } : { colori: ospite.casa, disegno: ospite.disegno } } + if (simili(maglie.casa.colori, maglie.ospite.colori)) maglie.ospite = { colori: ospite.fuori, disegno: 'pieno' } // (fuori è sempre diversa da casa) + const portieri = { casa: portiere(maglie.casa.colori, maglie.ospite.colori), ospite: portiere(maglie.ospite.colori, maglie.casa.colori) } + + // ── IL RISULTATO: dal suo seme, con la Poisson fissa. Non legge niente di quanto sopra ── + const rr = generatore(semeRisultato(indice)) + const golCasa = poisson(GOL_MEDI, rr), golOspite = poisson(GOL_MEDI, rr) + const tavolino = rr() < 1 / 120 ? (rr() < 0.5 ? 'casa' : 'ospite') : null + + // ── la cronaca: i gol veri messi al loro minuto, e intorno la scena (dal seme della scena) ── + const eventi = cronaca({ golCasa, golOspite, tavolino, rand, formazioni, meteo }) + const risultato = tavolino ? { casa: tavolino === 'casa' ? 3 : 0, ospite: tavolino === 'ospite' ? 3 : 0, tavolino, causa: eventi.find((e) => e.tipo === 'sospesa').causa } : { casa: golCasa, ospite: golOspite, tavolino: null } + // il punteggio sul campo, prima della sospensione (per il tabellone durante la partita) + return { indice, inizio, fine: inizio + DURATA, casa, ospite, forze: { casa: casa.forza, ospite: ospite.forza }, quote, prob, attesi: { casa: lc, ospite: lo }, meteo, formazioni, maglie, portieri, ultime, risultato, eventi } +} + +// due maglie si somigliano se i colori principali sono vicini +const esa = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)) +const vicini = (a, b) => { const [r1, g1, b1] = esa(a), [r2, g2, b2] = esa(b); return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 150 } +const simili = (a, b) => vicini(a[0], b[0]) || (a[1] && b[0] && vicini(a[1], b[0]) && vicini(a[0], b[1] ?? b[0])) +// il portiere di un colore che non c'è nelle due squadre +const PORTIERI = ['#1a8a3a', '#e8742a', '#f2c230', '#8e44ad', '#7fc4ea', '#e889b4'] +function portiere(nostri, loro) { return PORTIERI.find((c) => !nostri.some((n) => vicini(c, n)) && !loro.some((n) => vicini(c, n))) ?? '#8a8a8a' } + +/** Cinque in campo e due in panchina, con numeri e cognomi inventati; [0] è il portiere. */ +function formazione(rand) { + const usati = new Set(), numeri = [1] + while (numeri.length < 7) { const n = 2 + Math.floor(rand() * 22); if (!usati.has(n)) { usati.add(n); numeri.push(n) } } + return numeri.map((n, i) => ({ n, nome: cognome(rand), ruolo: i === 0 ? 'P' : i <= 2 ? 'D' : i === 3 ? 'C' : i === 4 ? 'A' : 'R' })) +} + +// Gli eventi della cronaca, ognuno con il secondo t in cui cade e quanto dura la sua messa in scena (dur). Tipi: +// inizio, intervallo, ripresa, fine, gol (squadra, chi, rigore?, punteggio), tiro (esito: fuori | palo | parata), +// rimessa, fallo, giallo, rosso, rigore (esito: parato | fuori: quelli segnati sono «gol» con rigore: true), +// infortunio (chi, entra), sospesa (causa). I grandi eventi stanno ad almeno 3,2 s l'uno dall'altro. +function cronaca({ golCasa, golOspite, tavolino, rand, formazioni, meteo }) { + const ev = [] + const minutoLibero = (occupati, lontano = 2) => { + for (let k = 0; k < 40; k++) { + const m = 2 + Math.floor(rand() * 90) // 2..91 + if (occupati.every((o) => Math.abs(o - m) >= lontano)) return m + } + return 2 + Math.floor(rand() * 90) + } + const chi = (squadra, { noPortiere = true } = {}) => { const f = formazioni[squadra]; const i = noPortiere ? 1 + Math.floor(rand() * 4) : Math.floor(rand() * 5); return { i, n: f[i].n, nome: f[i].nome } } + const grandi = [] // i minuti già presi dai grandi eventi + // i gol + const gol = [] + for (let i = 0; i < golCasa; i++) gol.push({ squadra: 'casa', m: minutoLibero(grandi, 3) }); + for (let i = 0; i < golOspite; i++) gol.push({ squadra: 'ospite', m: minutoLibero(grandi, 3) }) + for (const g of gol) grandi.push(g.m) + gol.sort((a, b) => a.m - b.m) + // il rigore: una partita su sette ne ha uno; se quella squadra ha segnato, uno dei suoi gol è su rigore, se no è sbagliato + let rigoreSbagliato = null + if (rand() < 0.14) { + const sq = rand() < 0.5 ? 'casa' : 'ospite' + const suoi = gol.filter((g) => g.squadra === sq) + if (suoi.length && rand() < 0.75) suoi[Math.floor(rand() * suoi.length)].rigore = true + else { const m = minutoLibero(grandi, 3); grandi.push(m); rigoreSbagliato = { squadra: sq, m, esito: rand() < 0.6 ? 'parato' : 'fuori' } } + } + // cartellini: gialli (in media 2,5, al più 4), un rosso una partita su quattordici + const gialli = Math.min(4, poisson(2.5, rand)) + const cartellini = [] + const ammoniti = { casa: new Set(), ospite: new Set() } + for (let i = 0; i < gialli; i++) { + const sq = rand() < 0.5 ? 'casa' : 'ospite', c = chi(sq), m = minutoLibero(grandi, 3) + grandi.push(m) + cartellini.push({ tipo: 'giallo', squadra: sq, m, chi: c }) + ammoniti[sq].add(c.i) + } + let rosso = null + if (rand() < 1 / 14) { + const sq = rand() < 0.5 ? 'casa' : 'ospite', c = chi(sq), m = 15 + Math.floor(rand() * 70) + grandi.push(m) + rosso = { tipo: 'rosso', squadra: sq, m, chi: c, doppio: ammoniti[sq].has(c.i) && cartellini.some((k) => k.squadra === sq && k.chi.i === c.i && k.m < m) } + } + // l'infortunio: una su otto; esce in barella ed entra la riserva + let infortunio = null + if (rand() < 0.125) { + const sq = rand() < 0.5 ? 'casa' : 'ospite', c = chi(sq), m = minutoLibero(grandi, 3) + grandi.push(m) + infortunio = { tipo: 'infortunio', squadra: sq, m, chi: c, entra: formazioni[sq][5 + Math.floor(rand() * 2)] } + } + // i quasi gol, le rimesse e i falli: la scena fra un grande evento e l'altro + const piccoli = [] + const nTiri = 4 + poisson(3, rand), nRimesse = 2 + Math.floor(rand() * 3), nFalli = 2 + Math.floor(rand() * 3) + const occupati = [...grandi] + for (let i = 0; i < nTiri; i++) { const m = minutoLibero(occupati, 2); occupati.push(m); const r = rand(); piccoli.push({ tipo: 'tiro', squadra: rand() < 0.5 ? 'casa' : 'ospite', m, esito: r < 0.45 ? 'fuori' : r < 0.6 ? 'palo' : 'parata' }) } + for (let i = 0; i < nRimesse; i++) { const m = minutoLibero(occupati, 2); occupati.push(m); piccoli.push({ tipo: 'rimessa', squadra: rand() < 0.5 ? 'casa' : 'ospite', m, lato: rand() < 0.5 ? 'alto' : 'basso' }) } + for (let i = 0; i < nFalli; i++) { const m = minutoLibero(occupati, 2); occupati.push(m); piccoli.push({ tipo: 'fallo', squadra: rand() < 0.5 ? 'casa' : 'ospite', m }) } + // con la pioggia qualcuno scivola (solo scena) + if (meteo.tipo === 'pioggia') for (let i = 0; i < 1 + Math.floor(rand() * 2); i++) { const m = minutoLibero(occupati, 2); occupati.push(m); piccoli.push({ tipo: 'scivola', squadra: rand() < 0.5 ? 'casa' : 'ospite', m, chi: chi(rand() < 0.5 ? 'casa' : 'ospite') }) } + + // tutti insieme, in secondi, poi la sospensione se c'è + let punteggio = { casa: 0, ospite: 0 } + const tutti = [] + for (const g of gol) tutti.push({ tipo: 'gol', squadra: g.squadra, m: g.m, rigore: !!g.rigore, chi: chi(g.squadra) }) + if (rigoreSbagliato) tutti.push({ tipo: 'rigore', ...rigoreSbagliato, chi: chi(rigoreSbagliato.squadra) }) + tutti.push(...cartellini) + if (rosso) tutti.push(rosso) + if (infortunio) tutti.push(infortunio) + tutti.push(...piccoli) + for (const e of tutti) e.t = secondoDelMinuto(e.m, 0.15 + rand() * 0.7) + tutti.sort((a, b) => a.t - b.t) + // i grandi eventi ad almeno 3,2 s l'uno dall'altro, i piccoli ad almeno 1,2 s da tutto (spostati in avanti); un piccolo + // evento che non ci sta più (la fine, l'intervallo) si lascia cadere, un grande si schiaccia all'indietro quanto basta + const grande = (e) => ['gol', 'rigore', 'giallo', 'rosso', 'infortunio'].includes(e.tipo) + const sistemati = [] + let ultimoGrande = -10, ultimo = 0.6 // (il primo evento dopo il fischio d'inizio) + for (const e of tutti) { + const minimo = grande(e) ? Math.max(ultimoGrande + 3.2, ultimo + 1.4) : ultimo + 1.2 + if (e.t < minimo) e.t = minimo + // mai a cavallo dell'intervallo + if (e.t > T.fine1 - 1.6 && e.t < T.via2 + 0.6) e.t = T.via2 + 0.6 + (e.t - (T.fine1 - 1.6)) * 0.2 + if (e.t > T.fine - 1.4) { + if (!grande(e)) continue + e.t = T.fine - 1.4 + // i grandi di prima arretrano, a catena, finché stanno a 3,2 s + for (let j = sistemati.length - 1, lim = e.t; j >= 0; j--) { const g = sistemati[j]; if (!grande(g)) continue; if (g.t > lim - 3.2) g.t = lim - 3.2; if (g.t > T.fine1 - 1.6 && g.t < T.via2 + 0.6) g.t = T.fine1 - 1.6; lim = g.t } + } + sistemati.push(e) + ultimo = e.t + if (grande(e)) ultimoGrande = e.t + } + tutti.length = 0 + tutti.push(...sistemati) + // (i piccoli che l'arretramento ha lasciato addosso a un grande si tolgono) + for (let j = tutti.length - 1; j >= 0; j--) { const e = tutti[j]; if (grande(e)) continue; if (tutti.some((g) => g !== e && grande(g) && Math.abs(g.t - e.t) < 1.1)) tutti.splice(j, 1) } + tutti.sort((a, b) => a.t - b.t) + for (const e of tutti) { e.m = minuto(e.t).min; e.minuto = minuto(e.t).scritta } + // il punteggio dopo ogni gol, i secondi gialli diventano l'espulsione già scritta + for (const e of tutti) if (e.tipo === 'gol') { punteggio = { ...punteggio, [e.squadra]: punteggio[e.squadra] + 1 }; e.punteggio = punteggio } + ev.push({ tipo: 'inizio', t: 0, dur: 1.2, minuto: "1'" }) + ev.push(...tutti) + ev.push({ tipo: 'intervallo', t: T.fine1, dur: T.via2 - T.fine1, minuto: '45\'' }, { tipo: 'ripresa', t: T.via2, dur: 1, minuto: '46\'' }) + if (tavolino) { + const ts = secondoDelMinuto(12 + Math.floor(rand() * 70), 0.5) + const causa = meteo.tipo === 'nebbia' ? CAUSA_NEBBIA : CAUSE[Math.floor(rand() * CAUSE.length)] + const prima = ev.filter((e) => e.t < ts - 1.5 || e.tipo === 'inizio') + prima.push({ tipo: 'sospesa', t: Math.max(ts, 4), dur: T.fine - Math.max(ts, 4), minuto: minuto(ts).scritta, causa, aChi: tavolino }) + prima.sort((a, b) => a.t - b.t) + for (const e of prima) e.dur ??= durata(e) + return prima + } + ev.push({ tipo: 'fine', t: T.fine, dur: 10, minuto: 'FINE' }) + ev.sort((a, b) => a.t - b.t) + for (const e of ev) e.dur ??= durata(e) + return ev +} +/** Quanto dura la messa in scena di un evento, in secondi. */ +export function durata(e) { + switch (e.tipo) { + case 'gol': return e.rigore ? 5.2 : 4.4 + case 'rigore': return 4.2 + case 'giallo': return 2.6 + case 'rosso': return 3.4 + case 'infortunio': return 3.6 + case 'tiro': return 1.1 + case 'rimessa': return 0.9 + case 'fallo': return 0.9 + case 'scivola': return 1 + default: return 1 + } +} + +/** Il punteggio sul campo a t secondi (prima dell'eventuale sospensione). */ +export function punteggioA(p, t) { + let c = 0, o = 0 + for (const e of p.eventi) if (e.tipo === 'gol' && e.t <= t) { if (e.squadra === 'casa') c++; else o++ } + return { casa: c, ospite: o } +} + +/** Una puntata vince? (`id` di PUNTATE, sul risultato ufficiale, tavolino compreso). */ +export function vince(id, risultato) { + const { casa, ospite } = risultato, tot = casa + ospite + if (id === '1') return casa > ospite + if (id === 'X') return casa === ospite + if (id === '2') return casa < ospite + const s = Number(id.slice(1)) + return id[0] === 'O' ? tot > s : tot < s +} +/** Il risultato come si scrive: «2–1», «3–0 a tavolino». */ +export const scriviRisultato = (r) => `${r.casa}–${r.ospite}${r.tavolino ? ' a tavolino' : ''}` diff --git a/albertopecchini.it/pzkko/games/virtual/schedina.js b/albertopecchini.it/pzkko/games/virtual/schedina.js new file mode 100644 index 00000000..438b3ccb --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/schedina.js @@ -0,0 +1,217 @@ +// La schedina di «virtual», in comune fra la finestra grande (virtual.js) e l'app del cercapersone (telefono/app/virtual.js): +// le scommesse di questo browser (localStorage, pzkko-virtual), i $pzk sul tavolo di ../pzk.js, il pagamento al fischio +// finale. Una sola copia per pagina: le due interfacce leggono e scrivono qui e si avvisano a vicenda (ascolta). Niente +// disegno: chi chiama decide cosa dire e come animare. +// +// Una puntata: { indice, id, quota, puntata, fiches: [[valore, timbro]…], esito: null | 'vinta' | 'persa', pagata, casa, +// ospite, risultato? }. Due schede possono scrivere nello stesso istante: invece di sovrascriversi si uniscono (unisci): +// ogni fiche ha un timbro suo, una puntata è l'unione delle fiches viste dalle due parti meno quelle tolte («tolte» tiene i +// timbri delle fiches ritirate con «togli», così non ricompaiono dalla copia vecchia dell'altra scheda); «pagata» vince su +// «da pagare». + +import * as pzk from '../pzk.js' +import { PER_PUNTATA, fase, partita, vince, scriviRisultato } from './partita.js' + +export const GIOCO = 'virtual' +export const CHIAVE = 'pzkko-virtual' +export const fmtQuota = (q) => q.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +/** La vincita di una puntata (a mezzi $pzk). */ +export const vincitaDi = (b) => Math.round(b.puntata * b.quota * 2) / 2 + +// ── lo stato ───────────────────────────────────────────────────────────────── +export const S = { + schedina: [], // le ultime (quelle della partita di adesso e le passate, al più 40) + fiche: 5, // la fiche scelta (la stessa nelle due interfacce) + ultime: null, // le puntate dell'ultima partita (per «rifai») + tolte: [], // i timbri delle fiches ritirate con «togli» (vedi unisci) +} +const ascolti = new Set() +/** fn() a ogni cambio della schedina (anche da un'altra scheda). Restituisce la funzione per smettere. */ +export function ascolta(fn) { ascolti.add(fn); return () => ascolti.delete(fn) } +const avvisa = () => { for (const f of ascolti) f() } + +// le fiches di una puntata nell'ordine in cui sono state posate (le puntate salvate prima non lo sapevano: si ricostruiscono, +// con timbri uguali in tutte le schede così l'unione non le raddoppia) +export function fichesDi(b) { + if (Array.isArray(b.fiches) && b.fiches.length && b.fiches.every((x) => Array.isArray(x))) return b.fiches + return pzk.inFiches(b.puntata).map((v, i) => [v, b.indice * 1e-3 + i * 1e-6]) +} +function leggi() { + try { + const s = JSON.parse(localStorage.getItem(CHIAVE) || 'null') + return { + schedina: Array.isArray(s?.schedina) ? s.schedina.filter((b) => Number.isInteger(b.indice) && PER_PUNTATA.has(b.id) && b.puntata > 0 && b.quota >= 1) : [], + ultime: Array.isArray(s?.ultime) ? s.ultime : null, + tolte: Array.isArray(s?.tolte) ? s.tolte : [], + } + } catch { return { schedina: [], ultime: null, tolte: [] } } +} +export function unisci(a, b) { + const tolte = [...new Set([...a.tolte, ...b.tolte])].slice(-200) + const via = new Set(tolte) + const per = new Map() + for (const x of [...a.schedina, ...b.schedina]) { + const k = `${x.indice}|${x.id}|${x.pagata ? 'p' : 'a'}` + const g = per.get(k) + if (!g) { per.set(k, { ...x, fiches: fichesDi(x) }); continue } + if (x.vista) g.vista = true // (vista da una parte: vista) + const timbri = new Set(g.fiches.map((f) => f[1])) + g.fiches = [...g.fiches, ...fichesDi(x).filter((f) => !timbri.has(f[1]))].sort((m, n) => m[1] - n[1]) + } + // una puntata pagata in una scheda e ancora aperta nell'altra: conta quella pagata + for (const [k, x] of per) if (!x.pagata && per.has(k.slice(0, -1) + 'p')) per.delete(k) + const schedina = [...per.values()] + .map((x) => { const f = x.fiches.filter((c) => !via.has(c[1])); return { ...x, fiches: f, puntata: f.reduce((t, c) => t + c[0], 0) } }) + .filter((x) => x.puntata > 0) + .sort((m, n) => m.indice - n.indice || (m.fiches[0][1] - n.fiches[0][1])) + .slice(-40) + return { schedina, ultime: b.ultime ?? a.ultime, tolte } +} +/** Rilegge la schedina del browser e la unisce a quella di qui (un'altra scheda può aver scritto). */ +export function carica() { + const u = unisci(leggi(), { schedina: S.schedina, ultime: S.ultime, tolte: S.tolte ?? [] }) + S.schedina = u.schedina; S.ultime = u.ultime; S.tolte = u.tolte + return u +} +function salva() { + const u = carica() + try { localStorage.setItem(CHIAVE, JSON.stringify(u)) } catch {} + avvisa() +} +/** Le puntate della partita `indice`. */ +export const diPartita = (indice) => S.schedina.filter((b) => b.indice === indice) +/** I $pzk puntati sulla partita `indice`. */ +export const sulTavolo = (indice) => diPartita(indice).reduce((a, b) => a + b.puntata, 0) +/** Le puntate ancora da pagare, in tutto. */ +export const aperte = () => S.schedina.filter((b) => !b.pagata).reduce((a, b) => a + b.puntata, 0) + +/** Fa in modo che sul tavolo di pzk.js ci siano almeno `quanto` $pzk (quelli delle puntate aperte), prendendoli dal saldo. */ +export function rimettiSulTavolo(quanto) { + const manca = Math.round((quanto - pzk.sulTavolo(GIOCO)) * 2) / 2 + if (manca <= 0) return + if (!pzk.punta(GIOCO, manca)) pzk.punta(GIOCO, Math.min(manca, pzk.saldo())) +} +/** Il tavolo deve valere quanto le puntate ancora aperte: se un'altra scheda le ha pagate (o tolte), il di più torna nel saldo. */ +export function riconcilia() { + const ap = aperte() + const troppo = Math.round((pzk.sulTavolo(GIOCO) - ap) * 2) / 2 + if (troppo > 0) pzk.ritira(GIOCO, troppo) + else if (troppo < 0) rimettiSulTavolo(ap) +} + +// ── le mosse ───────────────────────────────────────────────────────────────── +/** + * Punta la fiche `v` sulla quota `id` della partita `p` (che dev'essere in fase di scommesse: lo controlla chi chiama). + * → { ok: true, ricaricato } oppure { ok: false, motivo: 'secco' (a secco e niente ricarica: pzk.perche() dice perché) | + * 'nonbastano' | 'finiti' }. + */ +export function punta(p, id, v) { + let ricaricato = false + if (pzk.aSecco()) { + if (!pzk.ricarica()) return { ok: false, motivo: 'secco' } + ricaricato = true + } + if (!pzk.punta(GIOCO, v)) return { ok: false, motivo: pzk.saldo() >= 1 ? 'nonbastano' : 'finiti', ricaricato } + const q = p.quote[id] + // (un'altra scheda può aver appena puntato: si rilegge la schedina del browser prima di scriverla, così non si perde) + carica() + // la stessa puntata sulla stessa quota si somma + const gia = S.schedina.find((b) => b.indice === p.indice && b.id === id && !b.pagata) + // ogni fiche come [valore, quando è stata posata]: «togli» toglie l'ultima posata, su qualunque quota sia + const timbro = Date.now() + Math.random() / 2 // (unico anche fra due schede nello stesso millisecondo) + if (gia) { gia.fiches = [...fichesDi(gia), [v, timbro]]; gia.puntata += v } + else S.schedina.push({ indice: p.indice, id, quota: q, puntata: v, fiches: [[v, timbro]], esito: null, pagata: false, casa: p.casa.sigla, ospite: p.ospite.sigla }) + S.schedina = S.schedina.slice(-40) + salva() + return { ok: true, ricaricato, quota: q } +} +/** «togli»: l'ultima fiche posata sulla partita `indice` torna nel saldo. → { v, id } o null se non c'è niente. */ +export function togli(indice) { + carica() + const mie = diPartita(indice).filter((b) => !b.pagata) + // la quota su cui è stata posata l'ultima fiche, e proprio quella fiche + const quando = (x) => fichesDi(x).at(-1)?.[1] ?? 0 + const b = mie.reduce((a, x) => (a && quando(a) > quando(x) ? a : x), null) + if (!b) return null + const f = fichesDi(b) + const [v, timbro] = f.at(-1) + b.fiches = f.slice(0, -1) + b.puntata -= v + S.tolte = [...(S.tolte ?? []), timbro].slice(-200) + if (b.puntata <= 0) S.schedina.splice(S.schedina.indexOf(b), 1) + pzk.ritira(GIOCO, v) + salva() + return { v, id: b.id } +} + +// ── il pagamento ───────────────────────────────────────────────────────────── +/** + * Paga le puntate della partita `indice` sul suo risultato ufficiale (tavolino compreso). + * → { giocato, torna, risultato, p } oppure null se non c'era niente da pagare. + */ +export function paga(indice) { + // (un'altra scheda può aver già pagato: si rilegge quello che c'è scritto nel browser) + carica() + const mie = S.schedina.filter((b) => b.indice === indice && !b.pagata) + if (!mie.length) { riconcilia(); return null } + const p = partita(indice), r = p.risultato + let giocato = 0, torna = 0 + for (const b of mie) { + b.esito = vince(b.id, r) ? 'vinta' : 'persa' + b.pagata = true + b.risultato = scriviRisultato(r) + giocato += b.puntata + if (b.esito === 'vinta') torna += vincitaDi(b) + } + // il tavolo di pzk.js potrebbe non avere queste puntate: ricaricando la pagina pzk.js rimette nel saldo quello che era + // sui tavoli (vedi ../pzk.js), e all'avvio rimettiamo sul tavolo le puntate ancora aperte (avvia); se non bastavano i $pzk + // (spesi altrove) la puntata si regola per quanto c'è davvero + rimettiSulTavolo(giocato) + const g = Math.min(giocato, pzk.sulTavolo(GIOCO)) + pzk.regola(GIOCO, g, giocato ? (torna * g) / giocato : 0) + // le puntate di questa partita restano come «ultime», per «rifai» + S.ultime = mie.map((b) => ({ id: b.id, puntata: b.puntata })) + salva() + return { giocato, torna, risultato: r, p } +} +/** Le scommesse delle partite già finite che non sono state pagate (la finestra era chiusa): si pagano ora. → quante partite */ +export function pagaScadute() { + const adesso = fase().indice + const indici = new Set(S.schedina.filter((b) => !b.pagata && b.indice < adesso).map((b) => b.indice)) + for (const i of indici) paga(i) + return indici.size +} +/** Il badge per il cercapersone: le puntate pagate che non si sono ancora viste (vinte), più quelle aperte. */ +export const daVedere = () => S.schedina.filter((b) => b.pagata && b.esito === 'vinta' && !b.vista) +/** Per il badge: 1 se ci sono scommesse aperte sulla partita di adesso e le scommesse chiudono entro un minuto, se no 0. */ +export function aperteInChiusura() { + const f = fase() + return f.fase === 'scommesse' && f.manca < 60_000 && diPartita(f.indice).some((b) => !b.pagata) ? 1 : 0 +} +/** Segna viste le puntate pagate (il badge si spegne). */ +export function viste() { + let cambiato = false + for (const b of S.schedina) if (b.pagata && !b.vista) { b.vista = true; cambiato = true } + if (cambiato) salva() +} + +// ── l'avvio: una volta per pagina ──────────────────────────────────────────── +let avviato = false +/** Legge la schedina, rimette sul tavolo le puntate aperte (una ricarica le aveva rimesse nel saldo), paga le partite finite + * mentre non c'era nessuno, e ascolta le altre schede. Da chiamare da ogni interfaccia prima di usarla. */ +export function avvia() { + if (avviato) return + avviato = true + carica() + rimettiSulTavolo(aperte()) + pagaScadute() + // un'altra scheda ha puntato o pagato: la schedina si rilegge + addEventListener('storage', (e) => { + if (e.key !== CHIAVE) return + const prima = e.newValue + const u = carica() + if (JSON.stringify(u) !== prima) try { localStorage.setItem(CHIAVE, JSON.stringify(u)) } catch {} + riconcilia() + avvisa() + }) +} diff --git a/albertopecchini.it/pzkko/games/virtual/schermo.js b/albertopecchini.it/pzkko/games/virtual/schermo.js new file mode 100644 index 00000000..e10f635a --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/schermo.js @@ -0,0 +1,689 @@ +// Lo schermino di «Virtual»: un piccolo schermo a pixel, pulito come un tamagotchi, su cui si vede la partita del +// campionato inventato (partita.js). È un solo canvas: la tela interna è 192 × 120 «pixel grossi» moltiplicati per un +// fattore intero (misura()), così i pixel restano netti a qualunque misura e su qualunque schermo. Tutto quello che si +// vede è una funzione del tempo (il secondo di partita, o quanto manca al fischio d'inizio): niente stato che si accumula, +// quindi due schede mostrano la stessa cosa, chi arriva a metà vede il punto giusto e una scheda nascosta riprende da dove +// il tempo è arrivato. +// +// Dentro: il campo da calcio a cinque in erba con le righe e le due porte, i dieci omini con le maglie delle loro squadre +// (il portiere di un altro colore), gli spalti coi tifosi pixel nei colori delle due squadre, le bandiere, le panchine; +// la telecamera segue la palla con un po' di inerzia e si avvicina sulle azioni (campo largo, mezzo campo, primo piano); +// per i rigori l'inquadratura da dietro la porta; il replay del gol al rallentatore. Il meteo (sereno, nuvole, pioggia con +// le pozzanghere, nebbia, neve con la palla arancione, vento) e la luce (di giorno, al tramonto, di notte coi riflettori) +// vengono dalla partita e sono solo scena. Le scritte sono in un carattere a pixel 3 × 5 disegnato qui. +// +// const s = new Schermo(canvas) +// s.misura(larghezzaCss, altezzaCss) // il fattore intero e la misura della tela +// s.disegna(partita, { fase, t, manca, ridotto }) // un fotogramma: fase 'scommesse' | 'partita' | 'finita', t secondi +// s.evento // dopo disegna(): l'evento in scena (per i suoni e la voce), o null + +import { T, minuto, punteggioA } from './partita.js' + +export const LARG = 192, ALT = 120 +// il campo nel mondo: 160 × 80 unità (un'unità = un pixel con la telecamera larga), centrato in (80, 40); fuori gli spalti +const CAMPO = { x0: 0, y0: 0, x1: 160, y1: 80, cx: 80, cy: 40, porta: 14 } // porta: mezza larghezza della porta +const TRIB = 20 // la profondità degli spalti + +// ── il carattere a pixel 3 × 5 ────────────────────────────────────────────── +const GLIFI = { + A: '010101111101101', B: '110101110101110', C: '011100100100011', D: '110101101101110', E: '111100110100111', F: '111100110100100', G: '011100101101011', + H: '101101111101101', I: '111010010010111', J: '001001001101010', K: '101101110101101', L: '100100100100111', M: '101111111101101', N: '110101101101101', + O: '010101101101010', P: '110101110100100', Q: '010101101111011', R: '110101110101101', S: '011100010001110', T: '111010010010010', U: '101101101101011', + V: '101101101101010', W: '101101111111101', X: '101101010101101', Y: '101101010010010', Z: '111001010100111', + 0: '111101101101111', 1: '010110010010111', 2: '110001010100111', 3: '110001010001110', 4: '101101111001001', 5: '111100110001110', 6: '011100110101010', 7: '111001010010010', 8: '010101010101010', 9: '010101011001110', + ' ': '000000000000000', '!': '010010010000010', "'": '010010000000000', '-': '000000111000000', '–': '000000111000000', ':': '000010000010000', '.': '000000000000010', ',': '000000000010100', '+': '000010111010000', '?': '110001010000010', '/': '001001010100100', '(': '010100100100010', ')': '010001001001010', '°': '010101010000000', '%': '101001010100101', 'È': '111100110100111', 'À': '010101111101101', 'Ù': '101101101101011', 'É': '111100110100111', 'Ì': '111010010010111', 'Ò': '010101101101010', +} +/** Quanto è larga una scritta, in pixel grossi (3 per lettera più uno di spazio). */ +export const largTesto = (s) => s.length * 4 - 1 + +// ── piccoli rumori riproducibili (dal numero della partita e da un indice) ── +const h32 = (a, b) => { let h = (Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x165667b1, 0xc2b2ae35)) >>> 0; h ^= h >>> 15; h = Math.imul(h, 0x27d4eb2f) >>> 0; h ^= h >>> 13; return (h >>> 0) / 4294967296 } +const lerp = (a, b, u) => a + (b - a) * u +const liscia = (u) => (u <= 0 ? 0 : u >= 1 ? 1 : u * u * (3 - 2 * u)) +const limita = (v, a, b) => Math.max(a, Math.min(b, v)) + +export class Schermo { + constructor(canvas) { + this.c = canvas + this.ctx = canvas.getContext('2d', { alpha: false }) + this.k = 2 + this.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } // la telecamera di adesso (si muove con inerzia verso la meta) + this.ultimo = 0 + this.evento = null + this.scena = null + } + + /** Il fattore intero della tela dalla misura CSS del contenitore: la tela è LARG × ALT pixel grossi per k, e il CSS la + * allarga a tutto il contenitore (image-rendering: pixelated): con k scelto vicino ai pixel veri dello schermo i + * pixel grossi restano netti e tutti uguali a meno di un pixel del display. */ + misura(w, h) { + const dpr = Math.max(1, Math.min(3, devicePixelRatio || 1)) + const k = Math.max(1, Math.round(Math.min((w * dpr) / LARG, (h * dpr) / ALT))) + if (k !== this.k || this.c.width !== LARG * k) { + this.k = k + this.c.width = LARG * k; this.c.height = ALT * k + } + } + + // ── le primitive: tutto in pixel grossi, interi ── + px(x, y, col) { this.ctx.fillStyle = col; this.ctx.fillRect(Math.round(x) * this.k, Math.round(y) * this.k, this.k, this.k) } + rett(x, y, w, h, col) { const k = this.k, x0 = Math.round(x), y0 = Math.round(y), x1 = Math.round(x + w), y1 = Math.round(y + h); if (x1 <= x0 || y1 <= y0) return; this.ctx.fillStyle = col; this.ctx.fillRect(x0 * k, y0 * k, (x1 - x0) * k, (y1 - y0) * k) } + testo(s, x, y, col, { centro = false, ombra = null } = {}) { + s = String(s).toUpperCase() + if (centro) x -= Math.floor(largTesto(s) / 2) + if (ombra) this.testo(s, x + 1, y + 1, ombra) + for (const ch of s) { + const g = GLIFI[ch] ?? GLIFI['?'] + for (let i = 0; i < 15; i++) if (g[i] === '1') this.px(x + (i % 3), y + Math.floor(i / 3), col) + x += 4 + } + } + /** Una targhetta col testo: fondo scuro, testo chiaro, in basso o dove si dice. */ + targa(s, y, { fondo = '#161616', col = '#ffffff', x = LARG / 2 } = {}) { + const w = largTesto(s) + 6 + this.rett(x - Math.floor(w / 2), y - 2, w, 9, fondo) + this.testo(s, x, y, col, { centro: true }) + } + + // il mondo → lo schermo + sx(wx) { return LARG / 2 + (wx - this.cam.x) * this.cam.z } + sy(wy) { return ALT / 2 + (wy - this.cam.y) * this.cam.z } + rw(x, y, w, h, col) { const z = this.cam.z; this.rett(this.sx(x), this.sy(y), w * z, h * z, col) } + + // ── il fotogramma ── + /** + * Disegna la partita `p` com'è adesso. stato: { fase: 'scommesse' | 'partita' | 'finita', t: secondi di partita (anche + * negativi prima del fischio: −240…0), ridotto: movimento ridotto (telecamera ferma, solo scritte), dt: secondi dal + * fotogramma prima }. + */ + disegna(p, { fase, t, ridotto = false, dt = 1 / 60 }) { + this.p = p + this.t = t + this.rid = ridotto + const scena = fase === 'partita' ? this.scenaPartita(p, t) : fase === 'finita' ? this.scenaFinale(p) : this.scenaAttesa(p, t) + this.scena = scena + this.evento = scena.evento ?? null + // la telecamera: con inerzia verso la meta (ferma e larga col movimento ridotto) + const meta = ridotto ? { x: CAMPO.cx, y: CAMPO.cy, z: 1 } : scena.cam + const a = ridotto || scena.subito ? 1 : 1 - Math.exp(-dt * (scena.lesto ? 9 : 4.5)) + this.cam.x = lerp(this.cam.x, meta.x, a); this.cam.y = lerp(this.cam.y, meta.y, a); this.cam.z = lerp(this.cam.z, meta.z, a) + if (scena.scossa && !ridotto) { this.cam.x += (h32(p.indice, Math.floor(t * 30)) - 0.5) * scena.scossa; this.cam.y += (h32(p.indice + 7, Math.floor(t * 30)) - 0.5) * scena.scossa } + this.ctx.imageSmoothingEnabled = false + if (scena.dietroPorta && !ridotto) this.disegnaDietroPorta(p, scena) + else this.disegnaMondo(p, scena) + this.disegnaMeteoDavanti(p, scena) + for (const s of scena.scritte ?? []) this.targa(s.t, s.y, s) + if (scena.pannello) this.pannello(p, scena.pannello) + } + + // ── il mondo visto dall'alto ── + disegnaMondo(p, scena) { + const m = p.meteo, notte = m.notte, sera = m.sera + const L = this.luce(p) + // il fondo: il cemento degli spalti; di notte quasi buio + this.rett(0, 0, LARG, ALT, L.cemento) + this.spalti(p, scena, L) + this.panchine(p, scena, L) + // il prato a strisce + const z = this.cam.z + for (let i = 0; i < 8; i++) this.rw(CAMPO.x0 + i * 20, CAMPO.y0, 20, 80, i % 2 ? L.erba1 : L.erba2) + if (m.tipo === 'neve') for (let i = 0; i < 40; i++) { const r = h32(p.indice, 300 + i); this.rw(CAMPO.x0 + r * 156, CAMPO.y0 + h32(p.indice, 400 + i) * 77, 2 + r * 5, 1 + r * 2, L.neve) } + if (m.tipo === 'nuvole' || m.tipo === 'pioggia') this.ombreNuvole(p, L) + // le righe + const R = L.righe + this.rw(CAMPO.x0, CAMPO.y0, 160, 1, R); this.rw(CAMPO.x0, CAMPO.y1 - 1, 160, 1, R); this.rw(CAMPO.x0, CAMPO.y0, 1, 80, R); this.rw(CAMPO.x1 - 1, CAMPO.y0, 1, 80, R) + this.rw(CAMPO.cx, CAMPO.y0, 1, 80, R) + this.cerchio(CAMPO.cx, CAMPO.cy, 12, R) + for (const lato of [-1, 1]) { + const x = lato < 0 ? CAMPO.x0 : CAMPO.x1 + // l'area: un arco di 20 davanti alla porta + this.arco(x, CAMPO.cy, 20, lato, R) + this.rw(lato < 0 ? CAMPO.x0 + 24 : CAMPO.x1 - 25, CAMPO.cy, 1, 1, R) // il dischetto + } + if (m.tipo === 'pioggia') this.pozzanghere(p, L) + // le porte (viste dall'alto: la rete dietro la linea) + for (const lato of [-1, 1]) { + const x = lato < 0 ? CAMPO.x0 - 4 : CAMPO.x1 + const gonfia = scena.rete === lato ? 2 : 0 + this.rw(x - (lato > 0 ? 0 : gonfia), CAMPO.cy - CAMPO.porta, 4 + gonfia, CAMPO.porta * 2, L.rete) + this.rw(lato < 0 ? CAMPO.x0 - 1 : CAMPO.x1, CAMPO.cy - CAMPO.porta, 1, 1, '#ffffff'); this.rw(lato < 0 ? CAMPO.x0 - 1 : CAMPO.x1, CAMPO.cy + CAMPO.porta - 1, 1, 1, '#ffffff') + } + // gli omini, dal più in alto al più in basso (chi sta più giù copre) + const omini = [...scena.omini].sort((a, b) => a.y - b.y) + for (const o of omini) this.omino(o, L) + if (scena.barella) this.barella(scena.barella, L) + if (scena.arbitro) this.omino(scena.arbitro, L) + if (scena.cartellino) this.cartellino(scena.cartellino) + if (scena.palla) this.palla(scena.palla, L, p) + if (scena.palla2) this.palla(scena.palla2, L, p) + if (scena.nutrie) for (const n of scena.nutrie) this.nutria(n, L) + // i riflettori, di notte: quattro torri agli angoli e la luce che cade sul campo + if (notte || sera) this.riflettori(p, L, notte) + if (scena.buio) this.rett(0, 0, LARG, ALT, `rgba(4,6,14,${scena.buio})`) + } + + luce(p) { + const m = p.meteo + const notte = m.notte, sera = m.sera, grigio = m.tipo === 'nuvole' || m.tipo === 'pioggia' || m.tipo === 'nebbia' + if (notte) return { cemento: '#141724', erba1: '#2c7a3c', erba2: '#266c34', righe: '#e2eedb', rete: '#5b6473', neve: '#d9dfe8', tifosi: 0.55, pelle: '#c48f68', ombra: 'rgba(0,0,0,.35)', cielo: '#0c1020', gradoni: '#232738', neutro: '#3a3d48', pelle2: '#8a6a4a' } + if (sera) return { cemento: '#6a5850', erba1: '#4f9a3f', erba2: '#459038', righe: '#f4ecd8', rete: '#9a8e86', neve: '#f0e8e0', tifosi: 0.85, pelle: '#d9a37a', ombra: 'rgba(0,0,0,.25)', cielo: '#d87a3c', gradoni: '#5c4c46', neutro: '#6a6358', pelle2: '#a87a56' } + if (grigio) return { cemento: '#7c7f84', erba1: '#4e9a44', erba2: '#46903d', righe: '#eef2ec', rete: '#a9adb2', neve: '#eef1f4', tifosi: 0.9, pelle: '#d9a37a', ombra: 'rgba(0,0,0,.18)', cielo: '#9aa3ad', gradoni: '#6a6d72', neutro: '#8e9196', pelle2: '#a87a56' } + if (m.tipo === 'neve') return { cemento: '#9aa0a8', erba1: '#8fb58c', erba2: '#84ad81', righe: '#ffffff', rete: '#c2c7cf', neve: '#f7f9fb', tifosi: 0.9, pelle: '#d9a37a', ombra: 'rgba(0,0,0,.15)', cielo: '#c9d0d8', gradoni: '#8a9098', neutro: '#aab0b8', pelle2: '#a87a56' } + return { cemento: '#8b8f93', erba1: '#5bb24a', erba2: '#4fa842', righe: '#f6fbf3', rete: '#b9bfc5', neve: '#ffffff', tifosi: 1, pelle: '#d9a37a', ombra: 'rgba(0,0,0,.22)', cielo: '#7fc4ea', gradoni: '#767a7f', neutro: '#9a9ea3', pelle2: '#a87a56' } + } + + cerchio(cx, cy, r, col) { + const z = this.cam.z, n = Math.max(16, Math.floor(r * z * 1.2)) + for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; this.rett(this.sx(cx + Math.cos(a) * r), this.sy(cy + Math.sin(a) * r), 1, 1, col) } + } + arco(x, cy, r, lato, col) { + const z = this.cam.z, n = Math.max(10, Math.floor(r * z)) + for (let i = 0; i <= n; i++) { const a = -Math.PI / 2 + (i / n) * Math.PI; this.rett(this.sx(x + Math.cos(a) * r * (lato < 0 ? 1 : -1)), this.sy(cy + Math.sin(a) * r), 1, 1, col) } + } + + // ── gli spalti: le file di tifosi coi colori delle due squadre, che si alzano al gol ── + spalti(p, scena, L) { + const { indice } = p, piena = scena.folla ?? 1, salto = scena.salto // salto: { squadra, u } i tifosi di quella squadra saltano + const colori = (sq) => p.maglie[sq].colori + // chi sta dove: la curva di casa in alto e a sinistra, gli ospiti in basso a destra (un quarto); in basso a sinistra + // misti, e in alto a destra ancora casa + const zone = [ + { x: CAMPO.x0 - 6, y: CAMPO.y0 - TRIB - 2, w: 172, h: TRIB, sq: 'casa', file: 5 }, // tribuna alta + { x: CAMPO.x0 - 6, y: CAMPO.y1 + 2, w: 92, h: TRIB, sq: 'misti', file: 5 }, // bassa, sinistra + { x: CAMPO.x0 + 88, y: CAMPO.y1 + 2, w: 78, h: TRIB, sq: 'ospite', file: 5 }, // bassa, destra: gli ospiti + { x: CAMPO.x0 - TRIB - 8, y: CAMPO.y0 - 2, w: TRIB, h: 84, sq: 'casa', file: 5, vert: true }, + { x: CAMPO.x1 + 8, y: CAMPO.y0 - 2, w: TRIB, h: 84, sq: 'casa', file: 5, vert: true }, + ] + for (const zo of zone) { + // i gradoni + const sx = this.sx(zo.x), sy = this.sy(zo.y), z = this.cam.z + if (sx > LARG || sy > ALT || sx + zo.w * z < 0 || sy + zo.h * z < 0) continue + this.rw(zo.x, zo.y, zo.w, zo.h, L.gradoni) + // le file: i tifosi a 2 × 2 con la testa sopra, ogni fila sfalsata di un pixel, qualche posto vuoto e qualche + // tifoso «neutro» (il cappotto grigio) così non è un tappeto a righe + const passo = 3 + const n1 = Math.floor((zo.vert ? zo.h : zo.w) / passo), n2 = Math.floor((zo.vert ? zo.w : zo.h) / 4) + for (let f = 0; f < n2; f++) for (let i = 0; i < n1; i++) { + const r = h32(indice + f * 977, i + 31 * zone.indexOf(zo)) + if (r > piena * 0.92) continue // il posto è vuoto (o non ancora preso) + const sq = zo.sq === 'misti' ? (r < 0.5 ? 'casa' : 'ospite') : zo.sq + const cc = colori(sq) + const r2 = (r * 13) % 1 + const col = r2 < 0.14 ? L.neutro : r2 < 0.65 ? cc[0] : cc[1] ?? cc[0] + const sf = f % 2 // la fila sfalsata + let x = zo.vert ? zo.x + f * 4 : zo.x + i * passo + sf, y = zo.vert ? zo.y + i * passo + sf : zo.y + f * 4 + 1 + // chi salta + if (salto && salto.squadra === sq && Math.sin(salto.u * 40 + r * 9) > 0.2) y -= 1 + // l'onda del coro: ogni tanto una fila si alza a onda + if (scena.onda != null && Math.abs((zo.vert ? y : x) - scena.onda) < 6) y -= 1 + this.rw(x, y, 2, 2, this.scuro(col, L.tifosi)) + this.rw(x, y - 1, 2, 1, (r * 29) % 1 < 0.5 ? L.pelle : L.pelle2) + } + } + // le bandiere degli sbandieratori: tre di casa e una ospite, che ondeggiano col vento + const vento = p.meteo.tipo === 'vento' ? 2.2 : 0.8 + const bandiere = [{ x: 20, y: CAMPO.y0 - TRIB + 2, sq: 'casa' }, { x: 120, y: CAMPO.y0 - TRIB + 2, sq: 'casa' }, { x: 130, y: CAMPO.y1 + TRIB - 2, sq: 'ospite' }, { x: 60, y: CAMPO.y1 + TRIB - 2, sq: 'casa' }] + for (const [i, b] of bandiere.entries()) { + if (h32(indice, 900 + i) > piena + 0.2) continue + const cc = colori(b.sq), ph = this.t * (3 + vento) + i + const dx = Math.round(Math.sin(ph) * vento) + this.rw(b.x, b.y - 5, 1, 6, '#e8e4dc') + this.rw(b.x + 1 + dx * 0.3, b.y - 5, 4, 2, cc[0]); this.rw(b.x + 1 + dx * 0.6, b.y - 3, 4, 2, cc[1] ?? cc[0]) + } + } + scuro(col, f) { + if (f >= 1) return col + const [r, g, b] = [1, 3, 5].map((i) => Math.round(parseInt(col.slice(i, i + 2), 16) * f)) + return `rgb(${r},${g},${b})` + } + + panchine(p, scena, L) { + // due panchine sotto la tribuna alta, ai lati del centro: la tettoia e le riserve sedute + for (const [i, sq] of ['casa', 'ospite'].entries()) { + const x = i === 0 ? CAMPO.cx - 34 : CAMPO.cx + 14 + this.rw(x, CAMPO.y0 - 8, 20, 5, '#3a3d44') + const seduti = scena.inPanchina?.[sq] ?? 2 + for (let j = 0; j < seduti; j++) this.omino({ x: x + 4 + j * 6, y: CAMPO.y0 - 5, sq, seduto: true, maglia: p.maglie[sq], fermo: true }, L) + } + } + + // ── un omino: 3 × 6 pixel grossi al mondo (testa, maglia a due righe, pantaloncini, gambe) ── + omino(o, L) { + const z = this.cam.z, X = this.sx(o.x), Y = this.sy(o.y) + const m = o.maglia, c1 = o.portiere ? o.portiere : m.colori[0], c2 = o.portiere ? o.portiere : m.colori[1] ?? m.colori[0] + const w = Math.max(1, Math.round(z * 3)), u = Math.max(1, Math.round(z)) // un pixel grosso del mondo + const x0 = Math.round(X - w / 2) + if (o.aTerra) { + // steso: lungo, la testa da un lato + const y0 = Math.round(Y - u) + this.rett(x0 - u, y0, u, u, L.pelle) + this.rett(x0, y0, w, u, c1); this.rett(x0 + w, y0, u, u, c2) + this.rett(x0 + w + u, y0, u * 2, u, '#2b2b2b') + return + } + const alto = o.seduto ? 4 : 6 + const y0 = Math.round(Y - alto * u) + this.rett(Math.round(X - u / 2), y0, u, u, L.pelle) // la testa + // la maglia: due righe, col disegno + const maglia = (yy) => { + if (o.portiere) { this.rett(x0, yy, w, u, c1); return } + const d = m.disegno + if (d === 'strisce') { for (let i = 0; i < w; i += u) this.rett(x0 + i, yy, u, u, Math.floor(i / u) % 2 ? c2 : c1) } + else if (d === 'meta') { this.rett(x0, yy, Math.ceil(w / 2), u, c1); this.rett(x0 + Math.ceil(w / 2), yy, Math.floor(w / 2), u, c2) } + else if (d === 'fascia') { this.rett(x0, yy, w, u, yy === y0 + u ? c1 : c2) } + else if (d === 'croce') { this.rett(x0, yy, w, u, c1); this.rett(x0 + Math.floor(w / 2 / u) * u, yy, u, u, c2); if (yy === y0 + 2 * u) this.rett(x0, yy, w, u, c2) } + else { this.rett(x0, yy, w, u, c1); if (yy === y0 + u && w >= 3 * u) this.rett(x0, yy, u, u, c2) } + } + maglia(y0 + u); maglia(y0 + 2 * u) + if (o.seduto) { this.rett(x0, y0 + 3 * u, w, u, '#2b2b2b'); return } + this.rett(x0, y0 + 3 * u, w, u, o.portiere ? '#2b2b2b' : m.colori[1] === '#ffffff' && m.colori[0] !== '#ffffff' ? '#ffffff' : '#2b2b2b') // i pantaloncini + // le gambe: due pixel che si alternano correndo + const passo = o.fermo ? 0 : Math.floor(this.t * 10 + (o.i ?? 0)) % 2 + this.rett(x0 + (passo ? u : 0), y0 + 4 * u, u, u, L.pelle); this.rett(x0 + w - u - (passo ? u : 0), y0 + 4 * u, u, u, L.pelle) + this.rett(x0 + (passo ? 0 : u) + 0, y0 + 5 * u, u, u, '#161616'); this.rett(x0 + w - u - (passo ? 0 : u), y0 + 5 * u, u, u, '#161616') + if (o.numero != null && z >= 2.4) this.testo(String(o.numero), Math.round(X) - (o.numero >= 10 ? 3 : 1), y0 - 7, '#ffffff', { ombra: '#161616' }) + } + palla(b, L, p) { + const z = this.cam.z, s = Math.max(1, Math.round(z * 0.9)) + const col = p.meteo.tipo === 'neve' ? '#ff7a1a' : '#ffffff' + const alto = b.h ?? 0 // in aria: l'ombra resta sotto + if (alto > 0) this.rett(this.sx(b.x) - s / 2, this.sy(b.y) - s / 2, s, s, L.ombra) + this.rett(this.sx(b.x) - s / 2, this.sy(b.y - alto) - s / 2, s, s, col) + if (s >= 3) this.rett(this.sx(b.x) - s / 2 + 1, this.sy(b.y - alto) - s / 2 + 1, 1, 1, '#161616') + } + barella(b, L) { + // due barellieri in giallo che portano via l'infortunato steso + const z = this.cam.z, u = Math.max(1, Math.round(z)) + const X = this.sx(b.x), Y = this.sy(b.y) + this.rett(X - 4 * u, Y - u, 8 * u, u, '#d0cfc8') + this.rett(X - 3 * u, Y - 2 * u, 6 * u, u, b.maglia.colori[0]) + this.rett(X - 4 * u, Y - 2 * u, u, u, L.pelle) + for (const dx of [-5, 5]) { this.rett(X + dx * u - u / 2, Y - 5 * u, u, u, L.pelle); this.rett(X + dx * u - u, Y - 4 * u, 2 * u, 2 * u, '#f2c230'); this.rett(X + dx * u - u, Y - 2 * u, u, 2 * u, '#161616'); this.rett(X + dx * u, Y - 2 * u, u, 2 * u, '#161616') } + } + cartellino(c) { + // il primo piano dell'arbitro che alza il cartellino: la mano e il rettangolo + this.rett(c.x - 1, c.y - 8, 2, 4, '#161616') + this.rett(c.x - 3, c.y - 16, 6, 9, c.rosso ? '#e0202a' : '#ffd400') + this.rett(c.x - 2, c.y - 15, 4, 7, c.rosso ? '#f04a52' : '#ffe566') + } + nutria(n, L) { + // la nutria: una salsiccia marrone con la coda, che corre sul prato + const z = this.cam.z, u = Math.max(1, Math.round(z)) + const X = this.sx(n.x), Y = this.sy(n.y) + this.rett(X - 2 * u, Y - u, 4 * u, u, '#6b4a2b'); this.rett(X + 2 * u * n.dir, Y - u, u, u, '#4a3220'); this.rett(X - 2 * u * n.dir, Y - u, 2 * u, u * 0.5, '#4a3220') + } + + pozzanghere(p, L) { + for (let i = 0; i < 6; i++) { const r = h32(p.indice, 500 + i); this.rw(CAMPO.x0 + 8 + r * 140, CAMPO.y0 + 6 + h32(p.indice, 600 + i) * 66, 5 + r * 6, 2 + r * 2, L.cielo === '#0c1020' ? '#3a4a6a' : '#9db6c8') } + } + ombreNuvole(p, L) { + // tre ombre di nuvola che scorrono piano sul prato + const t = this.t + 300 + for (let i = 0; i < 3; i++) { const x = ((t * (2 + i) + i * 70) % 240) - 40, y = 10 + i * 25; this.rw(x, y, 36, 14, 'rgba(0,30,0,.14)') } + } + riflettori(p, L, notte) { + // le torri e il bagliore sul campo + const torri = [[CAMPO.x0 - 10, CAMPO.y0 - 12], [CAMPO.x1 + 10, CAMPO.y0 - 12], [CAMPO.x0 - 10, CAMPO.y1 + 12], [CAMPO.x1 + 10, CAMPO.y1 + 12]] + for (const [x, y] of torri) { this.rw(x - 1, y - 1, 3, 3, '#fff6c8'); this.rw(x, y, 1, 1, '#ffffff') } + if (notte) { + // la luce: più chiara al centro del campo, buia fuori (la vignetta della notte) + this.ctx.save() + const k = this.k, g = this.ctx.createRadialGradient(this.sx(CAMPO.cx) * k, this.sy(CAMPO.cy) * k, 30 * k * this.cam.z, this.sx(CAMPO.cx) * k, this.sy(CAMPO.cy) * k, 100 * k * this.cam.z) + g.addColorStop(0, 'rgba(255,245,200,.14)'); g.addColorStop(0.45, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,16,.7)') + this.ctx.fillStyle = g; this.ctx.fillRect(0, 0, LARG * k, ALT * k) + this.ctx.restore() + } + } + + // ── il meteo davanti a tutto: pioggia, neve, nebbia, vento ── + disegnaMeteoDavanti(p, scena) { + const m = p.meteo, t = this.t + 1000, vento = m.tipo === 'vento' ? 1.6 : 0.3 + if (m.tipo === 'pioggia') { + for (let i = 0; i < 46; i++) { const r = h32(p.indice, 700 + i), x = (r * LARG + t * 60 * vento * 0.4) % LARG, y = (h32(p.indice, 800 + i) * ALT + t * 140 * (0.7 + r * 0.6)) % ALT; this.rett(x, y, 1, 3, 'rgba(210,225,240,.55)') } + } else if (m.tipo === 'neve') { + for (let i = 0; i < 40; i++) { const r = h32(p.indice, 700 + i), x = (r * LARG + Math.sin(t * 1.3 + i) * 6 + t * 10 * vento) % LARG, y = (h32(p.indice, 800 + i) * ALT + t * (12 + r * 14)) % ALT; this.rett((x + LARG) % LARG, y, 1, 1, '#ffffff') } + } else if (m.tipo === 'vento') { + // foglie e carte che volano + for (let i = 0; i < 7; i++) { const r = h32(p.indice, 700 + i), x = (r * LARG + t * (40 + r * 40)) % LARG, y = (h32(p.indice, 800 + i) * ALT + Math.sin(t * 3 + i) * 5 + ALT) % ALT; this.rett(x, y, 2, 1, i % 2 ? '#c9a94f' : '#e8e4dc') } + } + if (m.tipo === 'nebbia' || scena.nebbia) { + // la nebbia a blocchi: più fitta lontano dal centro dello schermo, e che respira piano + const forza = scena.nebbia ?? (0.45 + 0.35 * m.forza) + const B = 8 + for (let y = 0; y < ALT; y += B) for (let x = 0; x < LARG; x += B) { + const d = Math.hypot(x + B / 2 - LARG / 2, (y + B / 2 - ALT / 2) * 1.4) / 100 + const a = limita(forza * (0.45 + d) + 0.05 * Math.sin(t * 0.7 + x * 0.05 + y * 0.09), 0, 0.92) + this.rett(x, y, B, B, `rgba(214,218,222,${a.toFixed(2)})`) + } + } + } + + // ── la vista da dietro la porta, per i rigori ── + disegnaDietroPorta(p, s) { + const L = this.luce(p), u = s.u // 0..1 dentro il rigore + this.rett(0, 0, LARG, ALT, L.cielo) + if (p.meteo.notte) for (let i = 0; i < 12; i++) this.px(h32(p.indice, 1000 + i) * LARG, h32(p.indice, 1100 + i) * 30, '#aab') + // gli spalti di fronte + this.rett(0, 28, LARG, 22, L.cemento) + const cc = p.maglie[s.squadra === 'casa' ? 'ospite' : 'casa'].colori + for (let f = 0; f < 5; f++) for (let i = 0; i < 48; i++) { const r = h32(p.indice + f, i); this.rett(i * 4 + 1, 30 + f * 4 + (r > 0.5 ? 1 : 0), 1, 2, this.scuro(r < 0.6 ? cc[0] : cc[1] ?? cc[0], L.tifosi)); this.rett(i * 4 + 1, 29 + f * 4 + (r > 0.5 ? 1 : 0), 1, 1, L.pelle) } + // il prato in prospettiva + this.rett(0, 50, LARG, ALT - 50, L.erba1) + for (let y = 50; y < ALT; y += 10) this.rett(0, y, LARG, 5, L.erba2) + this.rett(0, 50, LARG, 1, L.righe) + // la porta: i pali e la rete (visti da dietro) + const gx = 36, gy = 22, gw = 120, gh = 44 + for (let y = gy; y < gy + gh; y += 3) this.rett(gx, y, gw, 1, 'rgba(255,255,255,.35)') + for (let x = gx; x < gx + gw; x += 3) this.rett(x, gy, 1, gh, 'rgba(255,255,255,.35)') + this.rett(gx - 2, gy - 2, gw + 4, 2, '#ffffff'); this.rett(gx - 2, gy, 2, gh + 2, '#ffffff'); this.rett(gx + gw, gy, 2, gh + 2, '#ffffff') + // il portiere: grande, al centro, che si tuffa da un lato dopo il tiro + const sq = s.squadra === 'casa' ? 'ospite' : 'casa' + const tuffo = u > 0.62 ? limita((u - 0.62) / 0.12, 0, 1) : 0 + const gkX = LARG / 2 + s.tuffo * tuffo * 34, gkY = 66 + tuffo * 6 + const pc = p.portieri[sq] + this.rett(gkX - 3, gkY - 22, 6, 6, L.pelle); this.rett(gkX - 7 - tuffo * 6, gkY - 16, 14 + tuffo * 12, 9, pc); this.rett(gkX - 6, gkY - 7, 12, 4, '#2b2b2b'); this.rett(gkX - 5, gkY - 3, 4, 3, L.pelle); this.rett(gkX + 1, gkY - 3, 4, 3, L.pelle) + if (!tuffo) { this.rett(gkX - 13, gkY - 15, 6, 3, pc); this.rett(gkX + 7, gkY - 15, 6, 3, pc) } + // il tiratore di spalle, in fondo, e la palla sul dischetto che poi vola verso di noi (cresce) o in porta + const corsa = limita((u - 0.3) / 0.3, 0, 1) + const tx = LARG / 2 - 20 + corsa * 18, ty = ALT - 8 - corsa * 4 + if (u < 0.6) { + // il tiratore di spalle: testa, maglia coi colori, pantaloncini, gambe (grande: è vicino a noi) + const mg = p.maglie[s.squadra].colori + this.rett(tx - 3, ty - 24, 6, 6, L.pelle); this.rett(tx - 7, ty - 18, 14, 10, mg[0]); this.rett(tx - 7, ty - 14, 14, 2, mg[1] ?? mg[0]); this.rett(tx - 6, ty - 8, 12, 4, '#2b2b2b') + const passo = corsa > 0 ? Math.floor(this.t * 12) % 2 : 0 + this.rett(tx - 5, ty - 4 - passo * 2, 4, 4 + passo * 2, L.pelle); this.rett(tx + 1, ty - 4, 4, 4, L.pelle) + } + let bx = LARG / 2, by = ALT - 14, bs = 4 + if (u >= 0.6) { + const v = limita((u - 0.6) / 0.14, 0, 1) + const destX = s.esito === 'fuori' ? LARG / 2 + s.tuffo * -1 * 76 : LARG / 2 + s.tuffo * -1 * 30 // il portiere va dal lato sbagliato, tranne se para + const paraX = LARG / 2 + s.tuffo * 32 + bx = lerp(LARG / 2, s.esito === 'parato' ? paraX : destX, v); by = lerp(ALT - 14, s.esito === 'fuori' ? gy - 8 : gy + 14, v); bs = lerp(4, 2, v) + if (s.esito === 'gol' && v >= 1) { this.rett(gx + (s.tuffo < 0 ? gw - 30 : 6), gy + 6, 24, 20, 'rgba(255,255,255,.25)') } + } + this.rett(bx - bs / 2, by - bs / 2, bs, bs, p.meteo.tipo === 'neve' ? '#ff7a1a' : '#ffffff') + if (bs >= 3) this.rett(bx - bs / 2 + 1, by - bs / 2 + 1, 1, 1, '#161616') + } + + // ── il pannello della presentazione (prima della partita) ── + pannello(p, q) { + // un cartello scuro con i due stemmi a pixel (le forme semplificate), i nomi, la forza e le ultime cinque + const y0 = 14 + this.rett(14, y0, LARG - 28, 66, 'rgba(22,22,22,.86)') + this.rett(14, y0, LARG - 28, 1, '#ffd400') + const col = (sq, i) => p.maglie[sq].colori[i] ?? p.maglie[sq].colori[0] + for (const [i, sq] of ['casa', 'ospite'].entries()) { + const x = i === 0 ? 24 : LARG - 24 - 18 + // lo stemmino: la forma dello stemma col fondo e la sigla + this.stemmino(p[sq], x, y0 + 8) + this.testo(p[sq].sigla, x + 9, y0 + 30, '#ffffff', { centro: true }) + // la forza come barra + this.rett(x - 2, y0 + 38, 22, 3, '#3a3a3a'); this.rett(x - 2, y0 + 38, Math.round((22 * p.forze[sq]) / 100), 3, i === 0 ? col('casa', 0) : col('ospite', 0)) + this.testo(`${p.forze[sq]}`, x + 9, y0 + 43, '#b8b8b8', { centro: true }) + // le ultime cinque: V N P (inventate: non contano) + for (let j = 0; j < 5; j++) { const u = p.ultime[sq][j]; this.rett(x - 2 + j * 5, y0 + 52, 4, 4, u === 'V' ? '#1a8a3a' : u === 'N' ? '#8a8a8a' : '#b3121f'); this.testo(u, x - 2 + j * 5 + 1, y0 + 52, '#ffffff') } + } + this.testo('VS', LARG / 2, y0 + 18, '#ffd400', { centro: true }) + if (q.riga) this.testo(q.riga, LARG / 2, y0 + 30, '#ffffff', { centro: true }) + if (q.riga2) this.testo(q.riga2, LARG / 2, y0 + 40, '#b8b8b8', { centro: true }) + if (q.riga3) this.testo(q.riga3, LARG / 2, y0 + 52, '#b8b8b8', { centro: true }) + } + stemmino(sq, x, y) { + const { fondo, tinta, forma } = sq.stemma + const bordo = fondo === '#ffffff' ? tinta : '#161616' + if (forma === 'tondo' || forma === 'ovale') { this.rett(x + 3, y, 12, 18, fondo); this.rett(x, y + 3, 18, 12, fondo); this.rett(x + 3, y, 12, 1, bordo); this.rett(x + 3, y + 17, 12, 1, bordo); this.rett(x, y + 3, 1, 12, bordo); this.rett(x + 17, y + 3, 1, 12, bordo) } + else if (forma === 'rombo') { for (let i = 0; i < 9; i++) { this.rett(x + 9 - i, y + i, 2 * i, 1, fondo); this.rett(x + 9 - i, y + 17 - i, 2 * i, 1, fondo) } } + else { this.rett(x, y, 18, 11, fondo); for (let i = 0; i < 7; i++) this.rett(x + i, y + 11 + i, 18 - 2 * i, 1, fondo); this.rett(x, y, 18, 1, bordo) } + this.rett(x + 7, y + 5, 4, 6, tinta); this.rett(x + 5, y + 7, 8, 2, tinta) + } + + // ════════════════════════════════════════════════════════════════════════════ + // LE SCENE: da (partita, tempo) a cosa c'è in campo. Tutto deterministico. + // ════════════════════════════════════════════════════════════════════════════ + + /** Da che parte attacca una squadra a t secondi (si cambia campo nella ripresa): +1 verso destra. */ + verso(squadra, t) { const destra = squadra === 'casa'; return (destra !== t >= T.via2) ? 1 : -1 } + portaDi(squadra, t) { return this.verso(squadra, t) > 0 ? CAMPO.x1 : CAMPO.x0 } // la porta che la squadra attacca + + /** La palla del gioco «normale»: tappe ogni 1,3 s pescate dal seme, raccordate con morbidezza. */ + pallaBase(p, t) { + const passo = 1.3, k = Math.floor(t / passo), u = liscia((t - k * passo) / passo) + const tappa = (j) => ({ x: 14 + h32(p.indice * 3 + 1, j) * 132, y: 8 + h32(p.indice * 3 + 2, j) * 64 }) + const a = tappa(k), b = tappa(k + 1) + return { x: lerp(a.x, b.x, u), y: lerp(a.y, b.y, u) } + } + /** I dieci omini attorno alla palla: il portiere sulla sua linea, i quattro in formazione che seguono la palla. */ + omini(p, t, palla, { fuori = {}, sostituiti = {}, corrono = true } = {}) { + const out = [] + for (const sq of ['casa', 'ospite']) { + const v = this.verso(sq, t), f = p.formazioni[sq], maglia = p.maglie[sq] + const px = v > 0 ? CAMPO.x0 + 5 : CAMPO.x1 - 6 // la propria porta + out.push({ x: px, y: limita(CAMPO.cy + (palla.y - CAMPO.cy) * 0.4, CAMPO.cy - 10, CAMPO.cy + 10), sq, maglia, portiere: p.portieri[sq], numero: f[0].n, i: 0, fermo: !corrono }) + const base = [[-22, -18], [-22, 18], [-6, -6], [8, 8]] // rispetto al centro della squadra, verso la porta avversaria + for (let j = 1; j <= 4; j++) { + if (fuori[sq]?.has?.(j)) continue + const [bx, by] = base[j - 1] + const cx = lerp(CAMPO.cx - v * 14, palla.x, 0.55), cy = lerp(CAMPO.cy, palla.y, 0.5) + const jitter = Math.sin(t * 2.1 + j * 1.7 + (sq === 'casa' ? 0 : 2)) * 2 + const chi = sostituiti[sq]?.[j] ?? f[j] + out.push({ x: limita(cx + v * bx + jitter, CAMPO.x0 + 3, CAMPO.x1 - 4), y: limita(cy + by + jitter * 0.5, CAMPO.y0 + 4, CAMPO.y1 - 2), sq, maglia, numero: chi.n, i: j + (sq === 'casa' ? 0 : 5), fermo: !corrono }) + } + } + return out + } + // chi è fuori a t secondi (espulso) e chi è entrato al posto di chi + statoSquadre(p, t) { + const fuori = { casa: new Set(), ospite: new Set() }, sostituiti = { casa: {}, ospite: {} }, inPanchina = { casa: 2, ospite: 2 } + for (const e of p.eventi) { + if (e.t + (e.dur ?? 1) > t) continue + if (e.tipo === 'rosso') fuori[e.squadra].add(e.chi.i) + if (e.tipo === 'infortunio') { sostituiti[e.squadra][e.chi.i] = e.entra; inPanchina[e.squadra] = 1 } + } + return { fuori, sostituiti, inPanchina } + } + cartelliniA(p, t) { + const g = { casa: 0, ospite: 0 }, r = { casa: 0, ospite: 0 } + for (const e of p.eventi) if (e.t <= t) { if (e.tipo === 'giallo') g[e.squadra]++; if (e.tipo === 'rosso') r[e.squadra]++ } + return { gialli: g, rossi: r } + } + + /** La scena a t secondi di partita. */ + scenaPartita(p, t) { + const S = this.statoSquadre(p, t) + // l'evento in corso: quello il cui [t, t + dur] contiene t + const e = p.eventi.find((x) => t >= x.t && t < x.t + (x.dur ?? 1) && x.tipo !== 'inizio') ?? (t < 1.2 ? p.eventi[0] : null) + const base = this.pallaBase(p, t) + const scena = { cam: { x: lerp(CAMPO.cx, base.x, 0.6), y: lerp(CAMPO.cy, base.y, 0.5), z: 1.35 }, palla: base, omini: [], scritte: [], evento: e ?? null, inPanchina: S.inPanchina, folla: 1 } + const minu = minuto(t) + // l'onda del coro ogni tanto + if (Math.floor(t / 7) % 3 === 0) scena.onda = ((t % 7) / 7) * 220 - 20 + if (!e) { + // fra un evento e l'altro: si gioca. Vicino al prossimo grande evento la palla si porta dove servirà + const prossimo = p.eventi.find((x) => x.t > t && x.t - t < 1.1 && ['gol', 'tiro', 'rigore'].includes(x.tipo)) + if (prossimo) { const dest = this.puntoDiAttacco(p, prossimo); const u = liscia(1 - (prossimo.t - t) / 1.1); scena.palla = { x: lerp(base.x, dest.x, u), y: lerp(base.y, dest.y, u) }; scena.cam = { x: lerp(CAMPO.cx, dest.x, 0.7), y: lerp(CAMPO.cy, dest.y, 0.5), z: 1.6 } } + scena.omini = this.omini(p, t, scena.palla, S) + return scena + } + const u = limita((t - e.t) / (e.dur ?? 1), 0, 1) + const v = e.squadra ? this.verso(e.squadra, t) : 1 + const porta = e.squadra ? this.portaDi(e.squadra, t) : CAMPO.x1 + const att = e.squadra ? this.puntoDiAttacco(p, e) : base + switch (e.tipo) { + case 'inizio': { + scena.palla = { x: CAMPO.cx, y: CAMPO.cy } + scena.omini = this.omini(p, t, scena.palla, { ...S, corrono: u > 0.7 }) + scena.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } + scena.scritte.push({ t: 'FISCHIO D\'INIZIO', y: 104 }) + break + } + case 'gol': { + // 0–0,3 il tiro, 0,3–0,55 la rete che si gonfia e GOL, 0,55–1 l'esultanza e il replay al rallentatore + const inPorta = { x: porta + v * 2, y: CAMPO.cy + (h32(p.indice, e.t * 100) - 0.5) * 20 } + const replay = u > 0.58 + const uu = replay ? limita((u - 0.58) / 0.42, 0, 1) * 0.55 : u // il replay rifà il tiro a metà velocità + const tiro = limita(uu / 0.3, 0, 1) + scena.palla = { x: lerp(att.x, inPorta.x, tiro), y: lerp(att.y, inPorta.y, tiro), h: Math.sin(tiro * Math.PI) * 3 } + scena.omini = this.omini(p, t, replay ? att : { x: att.x, y: att.y }, S) + // chi ha segnato corre verso la palla e poi esulta (salta) + const chi = scena.omini.find((o) => o.sq === e.squadra && o.i % 5 === e.chi.i) + if (chi) { chi.x = lerp(att.x - v * 6, att.x - v * 3, tiro); chi.y = att.y + 1; if (!replay && u > 0.4) { chi.y -= Math.abs(Math.sin(u * 30)) * 2; chi.fermo = false } } + if (!replay && u > 0.3 && u < 0.5) scena.rete = v + if (replay && uu > 0.3 && uu < 0.5) scena.rete = v + scena.cam = replay ? { x: lerp(att.x, inPorta.x, 0.6), y: CAMPO.cy, z: 2.6 } : u < 0.3 ? { x: lerp(att.x, porta, 0.5), y: att.y, z: 2.1 } : { x: att.x, y: att.y, z: 2.4 } + if (u > 0.28 && u < 0.58) { scena.salto = { squadra: e.squadra, u }; if (Math.floor(u * 12) % 2) scena.scritte.push({ t: 'GOL!', y: 30, fondo: '#b3121f' }); scena.scossa = u < 0.4 ? 1.5 : 0 } + if (u >= 0.5 && u < 0.58) scena.scritte.push({ t: `${e.chi.nome} ${e.minuto}`, y: 104 }) + if (replay) { scena.scritte.push({ t: 'REPLAY', y: 8, fondo: '#ffd400', col: '#161616' }); scena.lesto = true } + if (e.rigore && u < 0.3) scena.scritte.push({ t: 'RIGORE', y: 104 }) + break + } + case 'rigore': { + // da dietro la porta: 0–0,3 la palla sul dischetto, 0,3–0,6 la rincorsa, 0,6–0,75 il tiro, poi l'esito + scena.dietroPorta = true; scena.subito = true; scena.u = u; scena.squadra = e.squadra; scena.esito = e.esito; scena.tuffo = h32(p.indice, e.t * 10) < 0.5 ? -1 : 1 + scena.scritte.push({ t: u < 0.75 ? 'RIGORE' : e.esito === 'parato' ? 'PARATO!' : 'FUORI!', y: 8, fondo: u < 0.75 ? '#161616' : '#1446a0' }) + if (u > 0.75) scena.salto = { squadra: e.squadra === 'casa' ? 'ospite' : 'casa', u } + break + } + case 'tiro': { + const fin = e.esito === 'fuori' ? { x: porta + v * 7, y: CAMPO.cy + (h32(p.indice, e.t * 100) < 0.5 ? -CAMPO.porta - 7 : CAMPO.porta + 7) } : e.esito === 'palo' ? { x: porta, y: CAMPO.cy + (h32(p.indice, e.t * 100) < 0.5 ? -CAMPO.porta : CAMPO.porta - 1) } : { x: porta - v * 3, y: CAMPO.cy + (h32(p.indice, e.t * 100) - 0.5) * 12 } + const tiro = limita(u / 0.55, 0, 1) + scena.palla = { x: lerp(att.x, fin.x, tiro), y: lerp(att.y, fin.y, tiro), h: Math.sin(tiro * Math.PI) * 2 } + if (e.esito === 'palo' && u > 0.55) { const r = limita((u - 0.55) / 0.45, 0, 1); scena.palla = { x: fin.x - v * r * 14, y: fin.y + (fin.y > CAMPO.cy ? -1 : 1) * r * 6 } } + scena.omini = this.omini(p, t, att, S) + if (e.esito === 'parata' && u > 0.4) { const gk = scena.omini.find((o) => o.sq !== e.squadra && o.i === 0); if (gk) { gk.y = fin.y; gk.aTerra = u > 0.6 } } + scena.cam = { x: lerp(att.x, porta, 0.5), y: lerp(att.y, fin.y, 0.5), z: 2 } + if (u > 0.5) scena.scritte.push({ t: e.esito === 'fuori' ? 'FUORI' : e.esito === 'palo' ? 'PALO!' : 'PARATA', y: 104, fondo: e.esito === 'palo' ? '#1446a0' : '#161616' }) + break + } + case 'rimessa': { + const y = e.lato === 'alto' ? CAMPO.y0 - 1 : CAMPO.y1 + const x = limita(base.x, 20, 140) + scena.palla = u < 0.5 ? { x, y } : { x: x + v * (u - 0.5) * 30, y: lerp(y, CAMPO.cy + (e.lato === 'alto' ? -10 : 10), (u - 0.5) * 2), h: Math.sin((u - 0.5) * 2 * Math.PI) * 4 } + scena.omini = this.omini(p, t, { x, y: CAMPO.cy + (e.lato === 'alto' ? -14 : 14) }, S) + const chi = scena.omini.find((o) => o.sq === e.squadra && o.i % 5 === 1); if (chi) { chi.x = x - v * 1; chi.y = y + (e.lato === 'alto' ? 1 : 2); chi.fermo = u < 0.5 } + scena.cam = { x, y: lerp(CAMPO.cy, y, 0.6), z: 1.7 } + if (u < 0.5) scena.scritte.push({ t: 'RIMESSA', y: 104 }) + break + } + case 'fallo': + case 'scivola': { + scena.palla = base + scena.omini = this.omini(p, t, base, S) + const a = scena.omini.find((o) => o.sq === e.squadra && o.i % 5 === 2), b = scena.omini.find((o) => o.sq !== e.squadra && o.i % 5 === 3) + if (a) { a.x = base.x - 2; a.y = base.y + 1; a.aTerra = e.tipo === 'scivola' || u > 0.2 } + if (b && e.tipo === 'fallo') { b.x = base.x + 2; b.y = base.y + 1; b.fermo = true } + scena.cam = { x: base.x, y: base.y, z: 2 } + scena.scritte.push({ t: e.tipo === 'fallo' ? 'FALLO' : 'SCIVOLA!', y: 104 }) + break + } + case 'giallo': + case 'rosso': { + const rosso = e.tipo === 'rosso' + scena.palla = base + scena.omini = this.omini(p, t, base, S) + const chi = scena.omini.find((o) => o.sq === e.squadra && o.i % 5 === e.chi.i) + if (chi) { chi.fermo = true; chi.x = base.x + 3; chi.y = base.y + 2; if (rosso && u > 0.5) { const w = (u - 0.5) * 2; chi.x = lerp(base.x + 3, CAMPO.cx, w); chi.y = lerp(base.y + 2, CAMPO.y0 - 3, w); chi.fermo = false } } + scena.arbitro = { x: base.x - 3, y: base.y + 2, maglia: { colori: ['#161616', '#161616'], disegno: 'pieno' }, fermo: true } + scena.cam = { x: base.x, y: base.y, z: 3 } + if (u > 0.15 && u < 0.7) scena.cartellino = { x: this.sx(base.x - 3) + 4, y: this.sy(base.y + 2) - 10, rosso } + scena.scritte.push({ t: rosso ? (e.doppio ? 'DOPPIO GIALLO: ROSSO' : 'ROSSO!') : 'GIALLO', y: 8, fondo: rosso ? '#b3121f' : '#ffd400', col: rosso ? '#ffffff' : '#161616' }) + if (u > 0.3) scena.scritte.push({ t: `${e.chi.n} ${e.chi.nome}`, y: 104 }) + if (rosso && u > 0.6) scena.scritte.push({ t: `${p[e.squadra].sigla} IN QUATTRO`, y: 94 }) + break + } + case 'infortunio': { + scena.palla = base + scena.omini = this.omini(p, t, base, S) + const chi = scena.omini.find((o) => o.sq === e.squadra && o.i % 5 === e.chi.i) + if (chi) { chi.aTerra = true; chi.x = base.x + 2; chi.y = base.y + 2; if (u > 0.45) { const w = liscia((u - 0.45) / 0.5); scena.barella = { x: lerp(base.x + 2, CAMPO.cx + (e.squadra === 'casa' ? -20 : 28), w), y: lerp(base.y + 2, CAMPO.y0 - 2, w), maglia: p.maglie[e.squadra] }; chi.x = -100 } } + if (u > 0.7) { const w = (u - 0.7) / 0.3; scena.omini.push({ x: lerp(CAMPO.cx + (e.squadra === 'casa' ? -20 : 28), base.x, w), y: lerp(CAMPO.y0 - 2, base.y, w), sq: e.squadra, maglia: p.maglie[e.squadra], numero: e.entra.n, i: 9 }) } + scena.inPanchina = { ...S.inPanchina, [e.squadra]: u > 0.7 ? 1 : S.inPanchina[e.squadra] } + scena.cam = { x: base.x, y: base.y, z: u < 0.45 ? 2.6 : 1.6 } + scena.scritte.push({ t: u < 0.45 ? 'INFORTUNIO' : u < 0.7 ? 'BARELLA' : `ENTRA ${e.entra.n} ${e.entra.nome}`, y: 104 }) + break + } + case 'intervallo': { + const pt = punteggioA(p, t) + scena.palla = { x: CAMPO.cx, y: CAMPO.cy } + scena.omini = this.omini(p, t, scena.palla, S).map((o) => ({ ...o, y: lerp(o.y, CAMPO.y0 - 4, liscia(u * 1.4)), x: lerp(o.x, CAMPO.cx + (o.sq === 'casa' ? -24 : 24), liscia(u * 1.4)) })) + scena.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } + scena.scritte.push({ t: 'INTERVALLO', y: 8 }, { t: `${p.casa.sigla} ${pt.casa} - ${pt.ospite} ${p.ospite.sigla}`, y: 104 }) + break + } + case 'ripresa': { + scena.palla = { x: CAMPO.cx, y: CAMPO.cy } + scena.omini = this.omini(p, t, scena.palla, { ...S, corrono: u > 0.6 }) + scena.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } + scena.scritte.push({ t: 'SI RIPRENDE', y: 104 }) + break + } + case 'sospesa': { + scena.palla = base + scena.omini = this.omini(p, t, base, { ...S, corrono: false }) + scena.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } + const causa = e.causa + if (causa.startsWith('nebbia')) scena.nebbia = limita(0.5 + u * 1.5, 0, 0.95) + else if (causa.startsWith('blackout')) scena.buio = u < 0.1 ? u * 8 : 0.82 + else if (causa.startsWith('invasione')) scena.nutrie = Array.from({ length: 7 }, (_, i) => ({ x: ((h32(p.indice, 1300 + i) * 200 + t * (14 + i * 3)) % 200) - 20, y: 6 + h32(p.indice, 1400 + i) * 68, dir: 1 })) + const r = p.risultato + scena.scritte.push({ t: 'PARTITA SOSPESA', y: 8, fondo: '#b3121f' }) + if (u > 0.08) scena.scritte.push({ t: causa.toUpperCase().slice(0, 44), y: 94, col: '#ffd400' }) + if (u > 0.2) scena.scritte.push({ t: `${r.casa}-${r.ospite} A TAVOLINO`, y: 104 }) + break + } + case 'fine': { + const r = p.risultato + scena.palla = base + scena.omini = this.omini(p, t, base, { ...S, corrono: false }) + scena.cam = { x: CAMPO.cx, y: CAMPO.cy, z: 1 } + if (r.casa !== r.ospite) scena.salto = { squadra: r.casa > r.ospite ? 'casa' : 'ospite', u } + scena.scritte.push({ t: 'FINALE', y: 8 }, { t: `${p.casa.sigla} ${r.casa} - ${r.ospite} ${p.ospite.sigla}`, y: 104 }) + break + } + default: { + scena.omini = this.omini(p, t, base, S) + } + } + return scena + } + puntoDiAttacco(p, e) { + const v = this.verso(e.squadra, e.t), porta = this.portaDi(e.squadra, e.t) + return { x: porta - v * (22 + h32(p.indice, e.t * 100 + 1) * 10), y: CAMPO.cy + (h32(p.indice, e.t * 100 + 2) - 0.5) * 40 } + } + + /** Prima della partita (t negativo: secondi al fischio): il riscaldamento, i tifosi che entrano, la presentazione. */ + scenaAttesa(p, t) { + const manca = -t // secondi al fischio, 0..240 + const folla = limita(1 - (manca - 20) / 200, 0.15, 1) + const scena = { cam: { x: CAMPO.cx, y: CAMPO.cy, z: 1 }, omini: [], scritte: [], evento: null, folla, inPanchina: { casa: 2, ospite: 2 } } + // le due squadre si scaldano nella loro metà: corsette e un pallone a testa che gira + const palle = [] + for (const [i, sq] of ['casa', 'ospite'].entries()) { + const v = i === 0 ? 1 : -1, f = p.formazioni[sq], maglia = p.maglie[sq] + const cx = CAMPO.cx - v * 40 + palle.push({ x: cx + Math.cos(this.t * 1.1 + i) * 14, y: CAMPO.cy + Math.sin(this.t * 1.7 + i) * 10 }) + for (let j = 0; j < 5; j++) { + const a = this.t * 0.7 + j * 1.26 + i + scena.omini.push({ x: cx + Math.cos(a) * 16, y: CAMPO.cy + Math.sin(a) * 12, sq, maglia, portiere: j === 0 ? p.portieri[sq] : null, numero: f[j].n, i: j + i * 5 }) + } + } + scena.palla = palle[0]; scena.palla2 = palle[1] + // la presentazione: a rotazione, ogni 12 secondi un cartello diverso (le squadre, il meteo, «quote vere, risultati a caso») + const tt = manca, giro = Math.floor(tt / 14) % 3, dentro = tt % 14 + if (dentro < 7) { + const m = p.meteo + const meteoTxt = { sereno: 'SERENO', nuvole: 'NUVOLOSO', pioggia: 'PIOGGIA', nebbia: 'NEBBIA', neve: 'NEVE', vento: 'VENTO FORTE' }[m.tipo] + if (giro === 0) scena.pannello = { riga: `${p.casa.citta.toUpperCase().slice(0, 12)}`, riga2: `${meteoTxt} ${m.temp}°`, riga3: m.notte ? 'IN NOTTURNA' : m.sera ? 'AL TRAMONTO' : 'DI GIORNO' } + else if (giro === 1) scena.pannello = { riga: 'LE QUOTE COPIANO', riga2: 'LA SERIE A VERA', riga3: 'I RISULTATI NO' } + else scena.pannello = { riga: `SERIE ${p.casa.serie}${p.casa.serie !== p.ospite.serie ? ' CONTRO ' + p.ospite.serie : ''}`, riga2: 'FORZA E ULTIME 5', riga3: '(INVENTATE)' } + } + const mm = Math.floor(manca / 60), ss = Math.floor(manca % 60) + scena.scritte.push({ t: manca > 0 ? `FISCHIO TRA ${mm}:${String(ss).padStart(2, '0')}` : 'SI COMINCIA', y: 104 }) + return scena + } + scenaFinale(p) { + const r = p.risultato + const scena = { cam: { x: CAMPO.cx, y: CAMPO.cy, z: 1 }, omini: [], scritte: [{ t: 'FINALE', y: 8 }, { t: `${p.casa.sigla} ${r.casa} - ${r.ospite} ${p.ospite.sigla}${r.tavolino ? ' TAV.' : ''}`, y: 104 }], evento: null, folla: 0.6, palla: { x: CAMPO.cx, y: CAMPO.cy }, inPanchina: { casa: 2, ospite: 2 } } + scena.omini = this.omini(p, T.fine, scena.palla, { corrono: false }) + return scena + } +} diff --git a/albertopecchini.it/pzkko/games/virtual/squadre.js b/albertopecchini.it/pzkko/games/virtual/squadre.js new file mode 100644 index 00000000..48a3b01d --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/squadre.js @@ -0,0 +1,128 @@ +// Le squadre di «Virtual», il campionato inventato di pzkko.it: tutte quelle di Serie A e Serie B 2026/27 coi nomi +// storpiati ma riconoscibili (stile videogioco senza licenza), la sigla, la città, i colori della maglia di casa e di +// trasferta (che si vedono addosso agli omini del campo), uno stemma originale disegnato qui in SVG (gli stessi colori +// sociali e lo stesso «spirito» del vero: una zebra, un biscione, un giglio; mai il logo vero) e una forza da 1 a 100 +// presa dalla realtà. La forza decide SOLO le quote (partita.js): i risultati no. +// +// Niente nomi veri di giocatori: i cognomi li inventa cognome(rand), con sillabe italiane a caso. +// +// Il clima della città (per il meteo della partita, partita.js): 'nord' la pianura padana e il Veneto (nebbia e nevicate +// d'inverno), 'alpi' Bolzano, 'liguria' la costa ligure e toscana del nord (piove tanto, mai nebbia), 'centro' Toscana, +// Marche e Lazio, 'sud' il meridione e le isole (sereno e caldo). + +export const SERIE_A = 'A', SERIE_B = 'B' + +// colori: maglia casa [primo, secondo], disegno della maglia ('strisce' verticali, 'meta' due metà, 'pieno' tinta unita +// con dettagli del secondo colore, 'croce', 'fascia' orizzontale, 'quarti'); la trasferta [primo, secondo] sempre 'pieno' +const S = (id, nome, sigla, citta, serie, forza, clima, casa, disegno, fuori, stemma) => ({ id, nome, sigla, citta, serie, forza, clima, casa, disegno, fuori, stemma }) + +export const SQUADRE = [ + // ── Serie A 2026/27 ── + S('atalanta', 'Atalanto', 'ATL', 'Bergamo', SERIE_A, 84, 'nord', ['#1b2a6b', '#161616'], 'strisce', ['#ffffff', '#1b2a6b'], { forma: 'scudo', simbolo: 'dea', fondo: '#1b2a6b', tinta: '#ffffff' }), + S('bologna', 'Bolognese', 'BOL', 'Bologna', SERIE_A, 77, 'nord', ['#b3121f', '#1b2a6b'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'croce', fondo: '#1b2a6b', tinta: '#b3121f' }), + S('cagliari', 'Cagliaritas', 'CAG', 'Cagliari', SERIE_A, 56, 'sud', ['#b3121f', '#1b2a6b'], 'meta', ['#ffffff', '#b3121f'], { forma: 'tondo', simbolo: 'mori', fondo: '#ffffff', tinta: '#161616' }), + S('como', 'Comodo', 'COM', 'Como', SERIE_A, 72, 'nord', ['#1e4fb5', '#ffffff'], 'pieno', ['#ffffff', '#1e4fb5'], { forma: 'ovale', simbolo: 'onde', fondo: '#1e4fb5', tinta: '#ffffff' }), + S('fiorentina', 'Fiorentoina', 'FIO', 'Firenze', SERIE_A, 76, 'centro', ['#5a2d8e', '#ffffff'], 'pieno', ['#ffffff', '#5a2d8e'], { forma: 'rombo', simbolo: 'giglio', fondo: '#ffffff', tinta: '#b3121f' }), + S('frosinone', 'Frosinoni', 'FRO', 'Frosinone', SERIE_A, 45, 'centro', ['#f2c230', '#1e4fb5'], 'pieno', ['#1e4fb5', '#f2c230'], { forma: 'scudo', simbolo: 'leone', fondo: '#f2c230', tinta: '#1e4fb5' }), + S('genoa', 'Genova Grifo', 'GEN', 'Genova', SERIE_A, 62, 'liguria', ['#b3121f', '#1b2a6b'], 'meta', ['#ffffff', '#1b2a6b'], { forma: 'scudo', simbolo: 'grifone', fondo: '#b3121f', tinta: '#ffffff' }), + S('inter', 'Inder', 'IND', 'Milano', SERIE_A, 92, 'nord', ['#161616', '#1e4fb5'], 'strisce', ['#ffffff', '#1e4fb5'], { forma: 'tondo', simbolo: 'biscione', fondo: '#161616', tinta: '#1e4fb5' }), + S('juventus', 'Juvenclub', 'JUV', 'Torino', SERIE_A, 85, 'nord', ['#ffffff', '#161616'], 'strisce', ['#161616', '#ffffff'], { forma: 'scudo', simbolo: 'zebra', fondo: '#ffffff', tinta: '#161616' }), + S('lazio', 'Lazzio', 'LAZ', 'Roma', SERIE_A, 78, 'centro', ['#7fc4ea', '#ffffff'], 'pieno', ['#ffffff', '#7fc4ea'], { forma: 'tondo', simbolo: 'aquila', fondo: '#7fc4ea', tinta: '#ffffff' }), + S('lecce', 'Leccese', 'LEC', 'Lecce', SERIE_A, 52, 'sud', ['#f2c230', '#b3121f'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'lupo', fondo: '#f2c230', tinta: '#b3121f' }), + S('milan', 'Milano Rossonera', 'MIL', 'Milano', SERIE_A, 86, 'nord', ['#b3121f', '#161616'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'ovale', simbolo: 'tridente', fondo: '#161616', tinta: '#b3121f' }), + S('monza', 'Monzese', 'MON', 'Monza', SERIE_A, 50, 'nord', ['#b3121f', '#ffffff'], 'pieno', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'corona', fondo: '#b3121f', tinta: '#f2c230' }), + S('napoli', 'Napule', 'NAP', 'Napoli', SERIE_A, 90, 'sud', ['#2e86d9', '#ffffff'], 'pieno', ['#ffffff', '#2e86d9'], { forma: 'tondo', simbolo: 'ciuccio', fondo: '#2e86d9', tinta: '#ffffff' }), + S('parma', 'Parmigiano', 'PAR', 'Parma', SERIE_A, 58, 'nord', ['#ffffff', '#f2c230'], 'croce', ['#1b2a6b', '#f2c230'], { forma: 'scudo', simbolo: 'croce', fondo: '#f2c230', tinta: '#1b2a6b' }), + S('roma', 'Romma', 'ROM', 'Roma', SERIE_A, 82, 'centro', ['#8e1a2b', '#f2c230'], 'pieno', ['#ffffff', '#8e1a2b'], { forma: 'scudo', simbolo: 'lupo', fondo: '#8e1a2b', tinta: '#f2c230' }), + S('sassuolo', 'Sassolino', 'SAS', 'Sassuolo', SERIE_A, 55, 'nord', ['#161616', '#1a8a3a'], 'strisce', ['#ffffff', '#1a8a3a'], { forma: 'ovale', simbolo: 'foglia', fondo: '#1a8a3a', tinta: '#161616' }), + S('torino', 'Toro Granata', 'TOR', 'Torino', SERIE_A, 66, 'nord', ['#8e1a2b', '#ffffff'], 'pieno', ['#ffffff', '#8e1a2b'], { forma: 'scudo', simbolo: 'toro', fondo: '#8e1a2b', tinta: '#ffffff' }), + S('udinese', 'Udinesi', 'UDI', 'Udine', SERIE_A, 64, 'nord', ['#ffffff', '#161616'], 'strisce', ['#161616', '#ffffff'], { forma: 'scudo', simbolo: 'castello', fondo: '#161616', tinta: '#ffffff' }), + S('venezia', 'Venexia', 'VEN', 'Venezia', SERIE_A, 48, 'nord', ['#161616', '#e8742a'], 'pieno', ['#e8742a', '#1a8a3a'], { forma: 'scudo', simbolo: 'leone', fondo: '#161616', tinta: '#e8742a' }), + // ── Serie B 2026/27 ── + S('arezzo', 'Aretini', 'ARE', 'Arezzo', SERIE_B, 24, 'centro', ['#8e1a2b', '#ffffff'], 'pieno', ['#ffffff', '#8e1a2b'], { forma: 'scudo', simbolo: 'cavallo', fondo: '#8e1a2b', tinta: '#f2c230' }), + S('avellino', 'Avellinesi', 'AVE', 'Avellino', SERIE_B, 30, 'sud', ['#1a8a3a', '#ffffff'], 'pieno', ['#ffffff', '#1a8a3a'], { forma: 'scudo', simbolo: 'lupo', fondo: '#1a8a3a', tinta: '#ffffff' }), + S('benevento', 'Beneventani', 'BEN', 'Benevento', SERIE_B, 31, 'sud', ['#f2c230', '#b3121f'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'tondo', simbolo: 'strega', fondo: '#f2c230', tinta: '#161616' }), + S('carrarese', 'Carraresi', 'CAR', 'Carrara', SERIE_B, 27, 'liguria', ['#2e86d9', '#f2c230'], 'pieno', ['#f2c230', '#2e86d9'], { forma: 'scudo', simbolo: 'montagna', fondo: '#2e86d9', tinta: '#ffffff' }), + S('catanzaro', 'Catanzarese', 'CTZ', 'Catanzaro', SERIE_B, 34, 'sud', ['#f2c230', '#b3121f'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'aquila', fondo: '#b3121f', tinta: '#f2c230' }), + S('cesena', 'Cesenati', 'CES', 'Cesena', SERIE_B, 35, 'nord', ['#ffffff', '#161616'], 'strisce', ['#161616', '#ffffff'], { forma: 'tondo', simbolo: 'cavalluccio', fondo: '#ffffff', tinta: '#161616' }), + S('cremonese', 'Cremonata', 'CRE', 'Cremona', SERIE_B, 42, 'nord', ['#8a8a8a', '#b3121f'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'violino', fondo: '#8a8a8a', tinta: '#b3121f' }), + S('empoli', 'Empolesi', 'EMP', 'Empoli', SERIE_B, 43, 'centro', ['#2e86d9', '#ffffff'], 'pieno', ['#ffffff', '#2e86d9'], { forma: 'scudo', simbolo: 'castello', fondo: '#2e86d9', tinta: '#ffffff' }), + S('verona', 'Verona Scaligera', 'VER', 'Verona', SERIE_B, 44, 'nord', ['#f2c230', '#1b2a6b'], 'strisce', ['#ffffff', '#1b2a6b'], { forma: 'scudo', simbolo: 'scala', fondo: '#1b2a6b', tinta: '#f2c230' }), + S('juvestabia', 'Stabia Vespe', 'STB', 'Castellammare', SERIE_B, 30, 'sud', ['#f2c230', '#1b2a6b'], 'fascia', ['#ffffff', '#1b2a6b'], { forma: 'tondo', simbolo: 'vespa', fondo: '#f2c230', tinta: '#161616' }), + S('mantova', 'Mantua', 'MAN', 'Mantova', SERIE_B, 29, 'nord', ['#ffffff', '#b3121f'], 'meta', ['#b3121f', '#ffffff'], { forma: 'scudo', simbolo: 'alloro', fondo: '#ffffff', tinta: '#b3121f' }), + S('modena', 'Modenesi', 'MOD', 'Modena', SERIE_B, 36, 'nord', ['#f2c230', '#1b2a6b'], 'pieno', ['#1b2a6b', '#f2c230'], { forma: 'scudo', simbolo: 'torre', fondo: '#f2c230', tinta: '#1b2a6b' }), + S('padova', 'Padovana', 'PAD', 'Padova', SERIE_B, 32, 'nord', ['#ffffff', '#b3121f'], 'croce', ['#b3121f', '#ffffff'], { forma: 'scudo', simbolo: 'croce', fondo: '#ffffff', tinta: '#b3121f' }), + S('palermo', 'Palermitano', 'PAL', 'Palermo', SERIE_B, 40, 'sud', ['#e889b4', '#161616'], 'meta', ['#161616', '#e889b4'], { forma: 'scudo', simbolo: 'aquila', fondo: '#e889b4', tinta: '#161616' }), + S('pisa', 'Pisa Torre', 'PIS', 'Pisa', SERIE_B, 39, 'liguria', ['#161616', '#2e86d9'], 'strisce', ['#ffffff', '#2e86d9'], { forma: 'scudo', simbolo: 'torrependente', fondo: '#2e86d9', tinta: '#ffffff' }), + S('sampdoria', 'Sampdoriana', 'SAM', 'Genova', SERIE_B, 40, 'liguria', ['#1e4fb5', '#ffffff'], 'fascia', ['#ffffff', '#1e4fb5'], { forma: 'scudo', simbolo: 'ancora', fondo: '#1e4fb5', tinta: '#ffffff' }), + S('sudtirol', 'Sudtirolo', 'SUD', 'Bolzano', SERIE_B, 28, 'alpi', ['#ffffff', '#b3121f'], 'pieno', ['#b3121f', '#ffffff'], { forma: 'scudo', simbolo: 'montagna', fondo: '#ffffff', tinta: '#b3121f' }), + S('vicenza', 'Vicentini', 'VIC', 'Vicenza', SERIE_B, 27, 'nord', ['#b3121f', '#ffffff'], 'strisce', ['#ffffff', '#b3121f'], { forma: 'scudo', simbolo: 'leone', fondo: '#b3121f', tinta: '#ffffff' }), + S('entella', 'Entella Virtuosa', 'ENT', 'Chiavari', SERIE_B, 25, 'liguria', ['#ffffff', '#7fc4ea'], 'strisce', ['#7fc4ea', '#ffffff'], { forma: 'ovale', simbolo: 'onde', fondo: '#7fc4ea', tinta: '#ffffff' }), + S('ascoli', 'Ascolano', 'ASC', 'Ascoli Piceno', SERIE_B, 22, 'centro', ['#ffffff', '#161616'], 'strisce', ['#161616', '#ffffff'], { forma: 'scudo', simbolo: 'picchio', fondo: '#161616', tinta: '#ffffff' }), +] +export const PER_ID = new Map(SQUADRE.map((s) => [s.id, s])) + +// ── i cognomi inventati ────────────────────────────────────────────────────── +const INIZI = ['Ber', 'Gra', 'Mon', 'Pel', 'Ros', 'Fer', 'Cas', 'Bal', 'Lom', 'Ven', 'Tor', 'Mar', 'Pic', 'Bru', 'Spa', 'Zan', 'Gal', 'Fab', 'Riz', 'Col', 'Ser', 'Pas', 'Tre', 'Buf', 'Can', 'Del', 'Mor', 'Pal', 'Sal', 'Vit'] +const MEZZI = ['to', 'na', 'ri', 'lo', 'ga', 'mi', 'ne', 'ca', 'de', 'ti', 'bo', 'sa', 'vi', 'gu', 'ra', ''] +const FINI = ['lazzi', 'nchetti', 'tolini', 'rdello', 'sconi', 'ttini', 'gnoli', 'relli', 'zzoni', 'vanti', 'ssini', 'ldi', 'nari', 'retti', 'cchi', 'sotti', 'melli', 'ndri', 'ttoni', 'gli'] +/** Un cognome inventato (rand = una funzione che dà un numero fra 0 e 1). */ +export function cognome(rand) { + const i = INIZI[Math.floor(rand() * INIZI.length)], m = MEZZI[Math.floor(rand() * MEZZI.length)], f = FINI[Math.floor(rand() * FINI.length)] + return i + m + f +} + +// ── gli stemmi ─────────────────────────────────────────────────────────────── +// Ogni stemma è una forma (scudo, tondo, ovale, rombo) col fondo del colore sociale, un bordo, il simbolo disegnato con +// forme semplici (in un quadrato 40 × 40) e sotto la sigla. Disegnati qui, non copiati: una zebra stilizzata, un biscione +// che è una esse, un giglio di tre petali… Lo stesso SVG si stampa sul tabellone in HTML e, piccolo, sullo schermino. +const FORME = { + scudo: 'M50 6 L90 16 L88 56 Q86 84 50 96 Q14 84 12 56 L10 16 Z', + tondo: 'M50 6 A44 44 0 1 1 49.9 6 Z', + ovale: 'M50 4 Q92 4 92 50 Q92 96 50 96 Q8 96 8 50 Q8 4 50 4 Z', + rombo: 'M50 4 L94 50 L50 96 L6 50 Z', +} +// i simboli, in un quadrato 40 × 40, come pezzi SVG (riempiti con currentColor = la tinta; «a» = il colore di accento) +const SIMBOLI = { + // la zebra: la testa di profilo con le strisce + zebra: '', + // il biscione: una esse col capo e la lingua + biscione: '', + giglio: '', + lupo: '', + aquila: '', + tridente: '', + toro: '', + grifone: '', + ciuccio: '', + dea: '', + mori: '', + onde: '', + leone: '', + corona: '', + croce: '', + foglia: '', + castello: '', + torre: '', + torrependente: '', + cavallo: '', + strega: '', + montagna: '', + cavalluccio: '', + violino: '', + scala: '', + vespa: '', + alloro: '', + ancora: '', + picchio: '', +} + +/** Lo stemma di una squadra come SVG (stringa). `misura` in px; senza, scala col contenitore. */ +export function stemmaSVG(sq, misura = null) { + const { forma, simbolo, fondo, tinta } = sq.stemma + const chiaro = fondo === '#ffffff' + const bordo = chiaro ? tinta : '#161616' + const accento = tinta === '#ffffff' ? fondo : fondo === '#ffffff' ? '#ffffff' : sq.casa.includes(tinta) ? (sq.casa.find((c) => c !== tinta) ?? '#ffffff') : fondo + const sz = misura ? ` width="${misura}" height="${misura}"` : '' + return `` +} diff --git a/albertopecchini.it/pzkko/games/virtual/virtual.css b/albertopecchini.it/pzkko/games/virtual/virtual.css new file mode 100644 index 00000000..711f8c17 --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/virtual.css @@ -0,0 +1,164 @@ +/* La finestra di «Virtual» di pzkko.it: lo stesso foglio col nastro della roulette e degli altri giochi + (art/roulette/carta.webp), 800 × 600 sul computer, a tutto schermo sul telefono. Dentro: lo schermino a pixel + (canvas, pixel netti) con sotto il tabellone, le quote stampate sul foglio come un tavolo, la schedina, le fiches + fotografate. Caratteri e colori quelli del muro. */ + +.vi{position:fixed;inset:0;margin:auto;width:800px;height:600px;max-width:none;max-height:none;padding:0;border:0;background:none;overflow:visible;color:#161616; + font:400 14px/1.3 "Special Elite","Courier New",monospace;--rosso:#b3121f;--nero:#161616;--evid:#ffd400;--marker:"Permanent Marker","Comic Sans MS",cursive;--fa:"Anton",Impact,sans-serif;--verde:#0b5d2b;--blu:#1446a0} +.vi::backdrop{background:rgba(12,10,8,.62);backdrop-filter:blur(2px)} +.vi[open]{animation:vi-attacca .32s cubic-bezier(.22,1,.36,1)} +.vi.chiude{animation:vi-stacca .22s ease-in forwards} +@keyframes vi-attacca{from{opacity:0;transform:translateY(18px) rotate(-1deg) scale(.97)}} +@keyframes vi-stacca{to{opacity:0;transform:translateY(10px) rotate(.8deg) scale(.98)}} +.vi::before{content:"";position:absolute;left:-8.66%;right:-9.04%;top:-12.49%;bottom:-12.6%;z-index:-1;background:url(/art/roulette/carta.webp) 0 0/100% 100% no-repeat;filter:drop-shadow(0 4px 6px rgba(0,0,0,.35)) drop-shadow(0 24px 30px rgba(0,0,0,.4));pointer-events:none} +.vi *{box-sizing:border-box} +.vi button{font:inherit;color:inherit} +.vi :focus-visible{outline:3px solid var(--evid);outline-offset:2px} +.vi [hidden]{display:none !important} + +.vi-dentro{position:absolute;inset:0;display:grid;grid-template-columns:400px 1fr;grid-template-rows:50px 1fr 78px;column-gap:14px;padding:10px 20px 8px 14px} + +/* la testata: il titolo come quello dei volantini, la voce a pennarello (la telecronaca), la chiusura */ +.vi-testa{grid-column:1/3;display:flex;align-items:center;gap:14px;min-width:0;padding-left:8px} +.vi-testa h2{margin:0;font:400 44px/1 var(--fa);letter-spacing:.005em;flex:none} +.vi-voce{margin:0;flex:1;min-width:0;font:400 18px/1.1 var(--marker);color:var(--rosso);transform:rotate(-1.5deg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.vi-chiudi{flex:none;width:44px;height:44px;padding:6px;border:0;border-radius:50%;background:none;cursor:pointer;color:#161616;transform:rotate(-6deg);transition:transform .2s cubic-bezier(.22,1,.36,1)} +.vi-chiudi svg{width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:4.2;stroke-linecap:round} +@media (hover:hover){.vi-chiudi:hover{transform:rotate(-6deg) scale(1.12)}} +.vi-regole-tel{display:none} +.vi-piccolo{flex:none;padding:2px 4px;border:0;border-bottom:2px solid currentColor;background:none;cursor:pointer;font:400 15px/1.1 var(--marker) !important;color:var(--blu) !important} +.vi-piccolo:disabled{opacity:.35;cursor:not-allowed} + +/* la diretta: lo schermino e il tabellone */ +.vi-diretta{grid-row:2;display:flex;flex-direction:column;gap:6px;min-height:0;justify-content:center} +/* lo schermino: la cornice di plastica grigia di un tamagotchi, il vetro leggermente scuro, la tela coi pixel netti + centrata (la misura la decide schermo.js: un multiplo intero di 192 × 120) */ +.vi-schermo{position:relative;flex:none;width:100%;aspect-ratio:192/120;display:grid;place-items:center;padding:8px;border-radius:14px;background:#d8d4cc;box-shadow:inset 0 2px 0 rgba(255,255,255,.7),inset 0 -2px 0 rgba(0,0,0,.18),0 2px 3px rgba(0,0,0,.25);overflow:hidden} +.vi-schermo::before{content:"";position:absolute;inset:8px;border-radius:6px;background:#1c2a1e;box-shadow:inset 0 0 0 2px #3a3d3a,inset 0 0 18px rgba(0,0,0,.5)} +.vi-schermo canvas{position:relative;display:block;width:100%;height:100%;image-rendering:pixelated;image-rendering:crisp-edges;border-radius:3px;box-shadow:0 0 0 1px rgba(0,0,0,.4)} +/* il vetro: un riflesso in diagonale, sopra tutto */ +.vi-schermo::after{content:"";position:absolute;inset:8px;border-radius:6px;pointer-events:none;background:linear-gradient(118deg,rgba(255,255,255,.1),rgba(255,255,255,.02) 40%,transparent 42%)} +.vi-tabellone{flex:none;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;padding:6px 8px;border:1.8px solid var(--nero);border-radius:6px;background:rgba(255,255,255,.15)} +.vi-sq{display:grid;grid-template-columns:40px 1fr;grid-template-rows:auto auto;column-gap:7px;align-items:center;min-width:0} +.vi-sq.ospite{grid-template-columns:1fr 40px;text-align:right} +.vi-sq.ospite .vi-st{grid-column:2;grid-row:1/3} +.vi-sq.ospite .vi-nome,.vi-sq.ospite .vi-citta,.vi-sq.ospite .vi-cart{grid-column:1} +.vi-st{grid-row:1/3;width:40px;height:40px} +.vi-st svg{width:100%;height:100%;display:block;filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +.vi-nome{font:400 15px/1 var(--fa);letter-spacing:.01em;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-wrap:balance} +.vi-citta{font-size:10px;line-height:1.1;color:#5a544c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +.vi-cart{display:flex;gap:2px;min-height:8px;margin-top:2px} +.vi-sq.ospite .vi-cart{justify-content:flex-end} +.vi-cart i{display:block;width:6px;height:8px;border-radius:1px;background:var(--evid);box-shadow:0 0 0 1px rgba(0,0,0,.4)} +.vi-cart i.r{background:#e0202a} +.vi-centro{display:flex;flex-direction:column;align-items:center;gap:1px;min-width:92px;flex:none} +.vi-punti{font:400 30px/1 var(--fa);white-space:nowrap;padding:1px 8px 2px;background:var(--nero);color:#fff;border-radius:3px} +.vi-punti.salta{animation:vi-salta .5s cubic-bezier(.2,1.6,.4,1)} +@keyframes vi-salta{30%{transform:scale(1.25) rotate(-3deg)}} +.vi-minuto{font:400 13px/1 var(--marker);color:var(--rosso);white-space:nowrap} +.vi-conto{font-size:10.5px;line-height:1.1;color:#5a544c;white-space:nowrap} +.vi.diretta .vi-conto{color:var(--rosso);font-weight:700} +.vi-meteo{flex:none;margin:0;font-size:10.5px;line-height:1.2;color:#5a544c;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* il tavolo delle quote, stampato sul foglio: una griglia di caselle col nome e la quota; sopra ci volano le fiches */ +.vi-tavolo{grid-column:2;grid-row:2;display:flex;flex-direction:column;gap:8px;min-height:0;min-width:0} +.vi-quote{display:flex;flex-direction:column;gap:5px;flex:none} +.vi-esiti{display:grid;grid-template-columns:repeat(3,1fr);gap:5px} +.vi-ou{display:grid;grid-template-columns:repeat(4,1fr);gap:4px} +.vi-ou-titolo{margin:2px 0 -2px;font:400 12px/1 var(--marker);color:#3a3632;transform:rotate(-1deg)} +.vi-quota{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-height:44px;padding:4px 2px 5px;border:1.8px solid var(--nero);border-radius:5px;background:none;cursor:pointer;transition:transform .15s cubic-bezier(.22,1,.36,1),background .15s} +.vi-quota.esito{min-height:64px} +.vi-quota.esito .vi-q-nome{font:400 24px/1 var(--fa)} +.vi-quota.esito .vi-q-val{font-size:20px} +.vi-quota .vi-q-nome{font:400 10.5px/1 "Special Elite","Courier New",monospace;text-transform:lowercase;color:#3a3632;white-space:nowrap} +.vi-quota .vi-q-val{font:400 17px/1 var(--fa);letter-spacing:.02em} +.vi-quota.fav::after{content:"";position:absolute;inset:3px;border-radius:4px;background:rgba(255,212,0,.35);z-index:-1} +@media (hover:hover){.vi-quota:not(:disabled):hover{background:rgba(255,212,0,.25);transform:translateY(-2px)}} +.vi-quota:disabled{cursor:not-allowed;opacity:.65} +.vi.chiuso .vi-quota .vi-q-val{color:#5a544c} +.vi-q-pila{position:absolute;right:3px;bottom:2px;display:flex;align-items:flex-end;pointer-events:none} +.vi-q-pila img{position:absolute;right:0;bottom:0;width:22px;height:22px;max-width:none;filter:drop-shadow(0 1px 1px rgba(0,0,0,.35))} +.vi-q-pila b{position:relative;z-index:1;margin:0 0 -3px 20px;padding:0 3px;font:400 10px/1.3 var(--fa);background:var(--nero);color:#fff;border-radius:2px} +.vi-volo{position:fixed;left:0;top:0;width:26px;height:26px;margin:-13px 0 0 -13px;z-index:5;pointer-events:none;will-change:transform;filter:drop-shadow(0 2px 2px rgba(0,0,0,.3))} + +/* la schedina: le righe scritte sul foglio */ +.vi-schedina{flex:1;min-height:0;margin:0;padding:0 2px;list-style:none;overflow-y:auto;overscroll-behavior:contain;font-size:11.5px;line-height:1.25} +.vi-schedina li{display:grid;grid-template-columns:1fr auto auto;column-gap:6px;padding:3px 0;border-bottom:1px dashed rgba(22,22,22,.35)} +.vi-schedina li.vuota{display:block;color:#5a544c;font:400 14px/1.2 var(--marker);transform:rotate(-1deg);border:0;padding:6px 0} +.vi-schedina .vi-s-partita{grid-column:1/4;font-size:10px;color:#5a544c} +.vi-schedina .vi-s-partita i{font-style:normal;color:var(--nero)} +.vi-schedina .vi-s-cosa small{color:#5a544c} +.vi-schedina .vi-s-punt{color:#3a3632} +.vi-schedina .vi-s-punt::before{content:"$pzk "} +.vi-schedina b{font:400 13px/1 var(--marker)} +.vi-schedina b.vinta{color:var(--verde)} +.vi-schedina b.persa{color:var(--rosso);text-decoration:line-through} +.vi-schedina b.attesa{color:var(--blu)} +.vi-schedina b.corso{color:var(--rosso)} +.vi-schedina li.vinta{background:rgba(11,93,43,.08)} +.vi-schedina li.aperta .vi-s-cosa{font-weight:700} + +/* il banco: il saldo, le fiches da scegliere, i tasti */ +.vi-banco{grid-column:1/3;grid-row:3;display:flex;align-items:center;gap:16px;padding:0 4px 0 12px;min-width:0} +.vi-saldo{flex:none;margin:0;line-height:1} +.vi-saldo small{display:block;font:400 12px/1 var(--marker);color:#3a3632} +.vi-saldo b{display:block;font:400 26px/1.05 var(--fa);font-weight:400} +.vi-saldo .puntato{display:block;margin-top:3px;font-size:11px;color:#5a544c} +.vi-saldo .pzk-ig{display:inline-block;margin-top:4px;padding-bottom:1px;border-bottom:2px solid currentColor;font:400 12px/1.15 var(--marker);color:var(--blu);text-decoration:none;white-space:nowrap} +.vi-saldo .pzk-ig[hidden]{display:none} +.vi-saldo .pzk-ig b{display:inline;font:inherit;color:var(--rosso)} +.vi-saldo .pzk-eur{display:block;margin-top:3px;font:400 12px/1.15 var(--marker);color:var(--verde);text-decoration:none;white-space:nowrap} +.vi-saldo .pzk-eur[hidden]{display:none} +.vi-saldo .pzk-eur b{font:inherit;color:var(--verde);border-bottom:2px solid currentColor} +.vi-scegli{display:flex;gap:2px;flex:none} +.vi-scegli button{position:relative;width:42px;height:46px;padding:0;border:0;background:none;cursor:pointer;transition:transform .18s cubic-bezier(.22,1,.36,1)} +.vi-scegli img{width:38px;height:38px;max-width:none;filter:drop-shadow(0 1px 1px rgba(0,0,0,.35)) drop-shadow(0 3px 4px rgba(0,0,0,.18))} +.vi-scegli button[aria-pressed="true"]{transform:translateY(-7px)} +.vi-scegli button[aria-pressed="true"] img{filter:drop-shadow(0 2px 1px rgba(0,0,0,.3)) drop-shadow(0 9px 8px rgba(0,0,0,.28))} +.vi-scegli button:disabled{opacity:.35;cursor:not-allowed} +.vi-tasti{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;justify-content:flex-end;flex:1;min-width:0} +.vi-stato{font:400 15px/1.1 var(--marker);color:var(--verde);transform:rotate(-1deg)} +.vi.chiuso .vi-stato{color:var(--rosso)} + +/* le regole: un foglietto sopra il tavolo */ +.vi-regole{position:absolute;inset:60px 20px 20px;z-index:3;padding:16px 22px;border:1.8px solid var(--nero);border-radius:6px;background:#f3eee2;box-shadow:0 8px 24px rgba(0,0,0,.35);overflow-y:auto;font-size:14px;line-height:1.4} +.vi-regole h3{margin:0 0 8px;font:400 30px/1 var(--fa)} +.vi-regole p{margin:0 0 8px} +.vi-regole .vi-battuta{padding:6px 10px;background:rgba(255,212,0,.35);border-radius:4px} +.vi-regole .vi-regole-chiudi{margin-top:4px;font-size:18px !important} + +@media (prefers-reduced-motion:reduce){ + .vi[open],.vi.chiude{animation:none} + .vi-punti.salta{animation:none} + .vi-quota{transition:none} +} + +/* il telefono e le finestre basse: il foglio a tutto schermo, tutto in colonna, lo schermino largo */ +@media (max-width:820px),(max-height:640px){ + .vi{width:100vw;height:100svh;max-height:100svh} + .vi::before{inset:0;background:url(/art/roulette/carta.webp) 50% 50%/240% 220% no-repeat;filter:none} + .vi-dentro{overflow-y:auto;overscroll-behavior:contain;grid-template-columns:1fr;grid-template-rows:44px auto auto auto;padding:6px 12px calc(10px + env(safe-area-inset-bottom));row-gap:8px} + .vi-testa{grid-column:1;padding-left:2px;gap:8px} + .vi-testa h2{font-size:32px} + .vi-voce{font-size:15px} + .vi-testa .vi-regole-tasto{display:none} + .vi-diretta{grid-row:2} + .vi-tabellone{gap:6px;padding:5px 6px} + .vi-st{width:34px;height:34px} + .vi-sq{grid-template-columns:34px 1fr} + .vi-sq.ospite{grid-template-columns:1fr 34px} + .vi-nome{font-size:14px} + .vi-punti{font-size:26px} + .vi-meteo{white-space:normal} + .vi-tavolo{grid-column:1;grid-row:3} + .vi-quota.esito{min-height:60px} + .vi-quota{min-height:50px} + .vi-schedina{flex:none;max-height:240px} + .vi-banco{grid-column:1;grid-row:4;padding:0;flex-wrap:wrap;gap:6px 10px} + .vi-scegli button{width:40px} + .vi-scegli img{width:36px;height:36px} + .vi-tasti{flex:1 1 100%;justify-content:space-between;gap:6px 12px} + .vi-regole-tel{display:inline-block} + .vi-stato{flex:1 1 100%;text-align:center;order:3} + .vi-regole{inset:50px 10px 10px;padding:12px 14px} +} diff --git a/albertopecchini.it/pzkko/games/virtual/virtual.js b/albertopecchini.it/pzkko/games/virtual/virtual.js new file mode 100644 index 00000000..a3be3acb --- /dev/null +++ b/albertopecchini.it/pzkko/games/virtual/virtual.js @@ -0,0 +1,379 @@ +// «Virtual», le scommesse sul calcio inventato di pzkko.it, in una finestra sopra il muro come gli altri giochi: il foglio +// grande col nastro, lo schermino a pixel in stile tamagotchi (schermo.js) con la partita, il tabellone (stemmi, nomi, +// punteggio, minuto, cartellini), le quote stampate sul foglio come un tavolo, le fiches fotografate (le stesse degli +// altri giochi) e la schedina. Il campionato è infinito e uguale per tutti: ogni cinque minuti una partita (quattro di +// scommesse, uno di diretta), tutto deciso dall'orario (partita.js). Le quote copiano la forza vera delle squadre, i +// risultati sono a caso: è la battuta del gioco. Soldi finti: i $pzk di ../pzk.js. +// +// Le scommesse fatte restano nella schedina (localStorage, pzkko-virtual) e si pagano al fischio finale anche se la +// finestra era chiusa: riaprendo si trova l'esito. I $pzk puntati stanno sul «tavolo» di pzk.js finché la partita non è +// pagata, come negli altri giochi (così se la pagina si chiude a metà tornano nel saldo). +// +// index.html la apre con import('/games/virtual/virtual.js').then((m) => m.apri()). + +import * as pzk from '../pzk.js' +import { PUNTATE, PER_PUNTATA, fase, partita, minuto, punteggioA, scriviRisultato, GOL_MEDI } from './partita.js' +import { stemmaSVG } from './squadre.js' +import { Schermo } from './schermo.js' +import * as SC from './schedina.js' + +const QUI = new URL('.', import.meta.url).href +const ART = '/art/' +const GIOCO = SC.GIOCO +const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)') +const fmt = pzk.fmt +const fmtQuota = SC.fmtQuota +const suona = (nome, d = {}) => window.pzkkoSuono?.evento(nome, d) +const esc = (s) => String(s).replace(/&/g, '&').replace(/ SC.diPartita(corrente.indice) +const sulTavolo = () => SC.sulTavolo(corrente.indice) + +// ── la finestra ────────────────────────────────────────────────────────────── +let dlg, el = {}, schermo = null, raf = 0, ultimoT = 0 +let corrente = fase() // dove siamo nel ciclo +let detto = false // la voce ha appena detto l'esito: aggiorna() non la copre +let P = null // la partita in scena + +export async function apri() { + if (!dlg) await costruisci() + if (!dlg.open) { + dlg.classList.remove('chiude') + dlg.showModal() + el.regole.hidden = true + } + misura() + aggiorna(true) + sveglia() +} + +async function costruisci() { + SC.avvia() + await new Promise((ok) => { + const l = document.createElement('link') + l.rel = 'stylesheet'; l.href = QUI + 'virtual.css' + l.onload = ok; l.onerror = ok + document.head.appendChild(l) + }) + dlg = document.createElement('dialog') + dlg.className = 'vi' + dlg.setAttribute('aria-labelledby', 'vi-titolo') + dlg.innerHTML = ` +
+
+

virtual

+

+ + +
+
+
+
+
+
0 – 0
+
+
+

+
+
+

gol in tutto

+
    +
    +
    +

    hai

    +
    +
    + + + + +
    +
    + +
    ` + document.body.appendChild(dlg) + const q = (s) => dlg.querySelector(s) + el = { + voce: q('.vi-voce'), chiudi: q('.vi-chiudi'), schermoBox: q('.vi-schermo'), canvas: q('canvas'), tab: q('.vi-tabellone'), punti: q('.vi-punti'), minuto: q('.vi-minuto'), conto: q('.vi-conto'), meteo: q('.vi-meteo'), + casa: q('.vi-sq.casa'), ospite: q('.vi-sq.ospite'), quote: q('.vi-quote'), schedina: q('.vi-schedina'), saldo: q('.vi-saldo b'), puntato: q('.vi-saldo .puntato'), scegli: q('.vi-scegli'), + togli: q('.vi-togli'), rifai: q('.vi-rifai'), statoT: q('.vi-stato'), regole: q('.vi-regole'), regoleTasto: q('.vi-regole-tasto'), + } + // chi mi segue su instagram riceve 1.000 $pzk (una volta, ../pzk.js) + q('.vi-saldo').appendChild(pzk.linkInstagram({ fatto: () => voce(`grazie del follow: +${fmt(pzk.PREMIO_IG)} $pzk`) })) + // col conto, gli EUR€ parcheggiati nel wallet (../pzk.js, wallet/) + q('.vi-saldo').appendChild(pzk.rigaEuro()) + for (const v of pzk.FICHES) { + const b = document.createElement('button') + b.type = 'button' + b.dataset.v = v + b.setAttribute('aria-label', `fiche da ${v}`) + b.innerHTML = `` + b.addEventListener('click', () => { stato.fiche = v; aggiornaBanco() }) + el.scegli.appendChild(b) + } + // le quote: 1 X 2 su una riga; sotto gli over in fila e gli under in fila (per soglia) + const ordine = [...PUNTATE.filter((p) => p.id.length === 1), ...PUNTATE.filter((p) => p.id[0] === 'O'), ...PUNTATE.filter((p) => p.id[0] === 'U')] + for (const p of ordine) { + const b = document.createElement('button') + b.type = 'button' + b.className = `vi-quota ${p.id[0] === 'O' ? 'over' : p.id[0] === 'U' ? 'under' : 'esito'}` + b.dataset.id = p.id + b.title = p.dice + b.innerHTML = `${esc(p.nome)}–` + b.addEventListener('click', () => punta(p.id)) + el.quote.querySelector(p.id.length === 1 ? '.vi-esiti' : '.vi-ou').appendChild(b) + } + el.chiudi.addEventListener('click', chiudi) + dlg.addEventListener('cancel', (e) => { e.preventDefault(); chiudi() }) + dlg.addEventListener('click', (e) => { if (e.target === dlg) chiudi() }) + el.togli.addEventListener('click', togli) + el.rifai.addEventListener('click', rifai) + const regole = (si) => { el.regole.hidden = !si; for (const b of dlg.querySelectorAll('.vi-regole-tasto')) b.setAttribute('aria-expanded', String(si)); if (si) el.regole.scrollTop = 0 } + for (const b of dlg.querySelectorAll('.vi-regole-tasto')) b.addEventListener('click', () => regole(el.regole.hidden)) + q('.vi-regole-chiudi').addEventListener('click', () => regole(false)) + new ResizeObserver(() => { if (dlg.open) { misura(); disegna(true) } }).observe(el.schermoBox) + pzk.ascolta(() => aggiornaBanco()) // il saldo cambia anche giocando agli altri giochi + document.addEventListener('visibilitychange', () => { if (!document.hidden && dlg.open) { aggiorna(true); sveglia() } }) + // un'altra scheda (o l'app del cercapersone) ha puntato o pagato: si ridisegna + SC.ascolta(() => { if (dlg.open) { quote(); schedina(); aggiornaBanco() } }) + schermo = new Schermo(el.canvas) +} + +function chiudi() { + if (!dlg?.open || dlg.classList.contains('chiude')) return + const fine = () => { dlg.close(); dlg.classList.remove('chiude'); if (location.hash === '#virtual') history.replaceState(null, '', location.pathname + location.search) } + if (RIDOTTO.matches) return fine() + dlg.classList.add('chiude') + setTimeout(fine, 220) +} +function voce(t) { if (el.voce) el.voce.textContent = t } +function misura() { if (schermo && el.schermoBox.clientWidth) schermo.misura(el.schermoBox.clientWidth, el.schermoBox.clientHeight) } + +// ── il ciclo: ogni fotogramma si guarda dove siamo nel ciclo e si disegna di conseguenza ── +function sveglia() { if (!raf && dlg?.open) { ultimoT = performance.now(); raf = requestAnimationFrame(ciclo) } } +function ciclo(now) { + raf = 0 + if (!dlg.open || document.hidden) return + const f = fase() + // è cambiata la fase o la partita: si rifanno il tabellone, le quote, i pagamenti + if (f.indice !== corrente.indice || f.fase !== corrente.fase) aggiorna() + else corrente = f + disegna(false, (now - ultimoT) / 1000) + ultimoT = now + // sui tempi lunghi (le scommesse, senza movimento ridotto) si va comunque a pieno ritmo: il riscaldamento si muove; + // col movimento ridotto bastano due fotogrammi al secondo + if (RIDOTTO.matches) setTimeout(sveglia, 500) + else raf = requestAnimationFrame(ciclo) +} + +/** La fase è cambiata (o la finestra si è aperta): la partita di adesso, le quote, i pagamenti, il tabellone. */ +function aggiorna(apertura = false) { + const prima = corrente + corrente = fase() + P = partita(corrente.indice) + if (!apertura && prima.indice !== corrente.indice) { + // la partita prima è finita: si paga (le scommesse di quella partita) + paga(prima.indice) + } else pagaScadute() + if (corrente.fase === 'partita' && (apertura || prima.fase !== 'partita')) { if (!apertura) suona('virtual-fischio'); voce(`in diretta: ${P.casa.nome} – ${P.ospite.nome}`) } + else if (corrente.fase === 'scommesse' && !detto) voce(sulTavolo() ? `hai ${fmt(sulTavolo())} $pzk sulla partita: puoi ancora puntare` : `${P.casa.nome} – ${P.ospite.nome}: scegli una fiche e tocca una quota`) + detto = false + tabellone() + quote() + schedina() + aggiornaBanco() +} + +const pagaScadute = () => SC.pagaScadute() +function paga(indice) { + const r = SC.paga(indice) + if (!r) return + const { p, torna, risultato } = r + voce(torna > 0 ? `${p.casa.sigla}–${p.ospite.sigla} ${scriviRisultato(risultato)}: hai vinto ${fmt(torna)} $pzk` : `${p.casa.sigla}–${p.ospite.sigla} ${scriviRisultato(risultato)}: niente, stavolta`) + detto = true + if (torna > 0) suona('virtual-vinta') + SC.viste() // (il badge del cercapersone: l'esito l'ha visto chi ha la finestra aperta) +} + +// ── il tabellone ───────────────────────────────────────────────────────────── +function tabellone() { + for (const sq of ['casa', 'ospite']) { + const e = el[sq], s = P[sq] + e.querySelector('.vi-st').innerHTML = stemmaSVG(s) + e.querySelector('.vi-nome').textContent = s.nome + e.querySelector('.vi-citta').textContent = `${s.citta} · ${P.forze[sq]}` + } + const m = P.meteo + const meteoTxt = { sereno: 'sereno', nuvole: 'nuvoloso', pioggia: 'pioggia', nebbia: 'nebbia', neve: 'neve', vento: 'vento forte' }[m.tipo] + el.meteo.textContent = `a ${P.casa.citta}: ${meteoTxt}, ${m.temp}°${m.notte ? ', in notturna coi riflettori' : m.sera ? ', al tramonto' : ''} · ultime 5 (inventate): ${P.casa.sigla} ${P.ultime.casa.join('')} · ${P.ospite.sigla} ${P.ultime.ospite.join('')}` + dlg.classList.toggle('diretta', corrente.fase === 'partita') +} +let ultimoMin = '', ultimiPunti = '' +function tabelloneVivo(t) { + if (corrente.fase === 'partita') { + const pt = punteggioA(P, Math.max(0, t)) + const sospesa = P.eventi.find((e) => e.tipo === 'sospesa' && e.t <= t) + const punti = sospesa ? `${P.risultato.casa} – ${P.risultato.ospite}` : `${pt.casa} – ${pt.ospite}` + const mn = sospesa ? 'SOSP.' : minuto(t).scritta + if (punti !== ultimiPunti) { el.punti.textContent = punti; ultimiPunti = punti; if (!RIDOTTO.matches) { el.punti.classList.remove('salta'); void el.punti.offsetWidth; el.punti.classList.add('salta') } } + if (mn !== ultimoMin) { el.minuto.textContent = mn === 'INT' ? 'intervallo' : mn === 'FINE' ? 'finale' : mn; ultimoMin = mn } + const manca = Math.ceil(corrente.manca / 1000) + el.conto.textContent = `IN DIRETTA · finisce tra ${manca}s` + // i cartellini + const c = schermo.cartelliniA(P, t) + for (const sq of ['casa', 'ospite']) el[sq].querySelector('.vi-cart').innerHTML = ''.repeat(c.gialli[sq]) + ''.repeat(c.rossi[sq]) + } else { + const s = Math.ceil(corrente.manca / 1000), mm = Math.floor(s / 60), ss = s % 60 + const txt = `si chiude tra ${mm}:${String(ss).padStart(2, '0')}` + if (el.conto.textContent !== txt) el.conto.textContent = txt + if (ultimiPunti !== '–') { el.punti.textContent = '–'; ultimiPunti = '–'; el.minuto.textContent = 'scommesse aperte'; ultimoMin = ''; for (const sq of ['casa', 'ospite']) el[sq].querySelector('.vi-cart').innerHTML = '' } + } +} + +// ── le quote ───────────────────────────────────────────────────────────────── +function quote() { + const aperte = corrente.fase === 'scommesse' + for (const b of el.quote.querySelectorAll('.vi-quota')) { + const id = b.dataset.id + b.querySelector('.vi-q-val').textContent = fmtQuota(P.quote[id]) + b.disabled = !aperte + const mie = diAdesso().filter((x) => x.id === id).reduce((a, x) => a + x.puntata, 0) + pila(b.querySelector('.vi-q-pila'), mie) + } + // la favorita evidenziata a pennarello + const fav = P.quote[1] < P.quote[2] ? '1' : '2' + for (const b of el.quote.querySelectorAll('.vi-quota')) b.classList.toggle('fav', b.dataset.id === fav) +} +function pila(cont, importo) { + cont.innerHTML = '' + if (!importo) return + const f = pzk.inFiches(importo).slice(0, 8) + f.forEach((v, i) => { const img = document.createElement('img'); img.src = `${ART}fiches/f${v}.webp`; img.alt = ''; img.style.transform = `translateY(${-i * 2.4}px) rotate(${((v * 37 + i * 101) % 23) - 11}deg)`; cont.appendChild(img) }) + const n = document.createElement('b'); n.textContent = fmt(importo); cont.appendChild(n) +} + +// prima di puntare o togliere si guarda l'orologio vero (col movimento ridotto il ciclo passa solo ogni mezzo secondo) +function allOra() { const f = fase(); if (f.indice !== corrente.indice || f.fase !== corrente.fase) aggiorna() } +function punta(id) { + allOra() + if (corrente.fase !== 'scommesse') { voce('la partita è in corso: si punta sulla prossima'); return } + const v = stato.fiche + const r = SC.punta(P, id, v) + if (r.ricaricato) voce(`altri ${fmt(pzk.INIZIALE)} $pzk finti: punta`) + if (!r.ok) { + aggiornaBanco() + if (r.motivo === 'secco') voce(pzk.perche()) + else { voce(r.motivo === 'nonbastano' ? `non ti bastano: hai ${fmt(pzk.saldo())} $pzk` : 'hai finito i $pzk'); scuoti(el.saldo) } + return + } + const b = el.quote.querySelector(`[data-id="${id}"]`) + vola(v, centro(el.scegli.querySelector(`[data-v="${v}"] img`) ?? el.saldo), centro(b.querySelector('.vi-q-pila')), { alto: 30 }).then(() => { pila(b.querySelector('.vi-q-pila'), diAdesso().filter((x) => x.id === id).reduce((a, x) => a + x.puntata, 0)) }) + suona('virtual-fiche') + schedina() + aggiornaBanco() + const pu = PER_PUNTATA.get(id) + voce(`${pu.nome} a ${fmtQuota(r.quota)}: se va, ${fmt(Math.round(v * r.quota * 2) / 2)} $pzk`) +} +// «togli»: l'ultima fiche posata su questa partita torna nel saldo +function togli() { + allOra() + if (corrente.fase !== 'scommesse') return + const t = SC.togli(corrente.indice) + if (!t) return + const q = el.quote.querySelector(`[data-id="${t.id}"] .vi-q-pila`) + vola(t.v, centro(q), centro(el.scegli.querySelector(`[data-v="${t.v}"] img`) ?? el.saldo), { durata: 300, alto: 24 }) + quote(); schedina(); aggiornaBanco() +} +// «rifai»: le puntate dell'ultima partita sulla partita di adesso (alle quote di adesso) +async function rifai() { + if (corrente.fase !== 'scommesse' || !stato.ultime?.length || sulTavolo()) return + const tot = stato.ultime.reduce((a, b) => a + b.puntata, 0) + if (tot > pzk.saldo()) { voce(`per rifare servono ${fmt(tot)} $pzk`); scuoti(el.saldo); return } + const prima = stato.fiche + for (const u of stato.ultime) for (const v of pzk.inFiches(u.puntata)) { stato.fiche = v; punta(u.id); await new Promise((r) => setTimeout(r, RIDOTTO.matches ? 0 : 70)) } + stato.fiche = prima + aggiornaBanco() +} + +// ── la schedina ────────────────────────────────────────────────────────────── +function schedina() { + const mie = [...stato.schedina].reverse().slice(0, 12) + if (!mie.length) { el.schedina.innerHTML = '
  1. la schedina è vuota: tocca una quota
  2. '; return } + el.schedina.innerHTML = mie.map((b) => { + const pu = PER_PUNTATA.get(b.id), diQuesta = b.indice === corrente.indice + const vinc = SC.vincitaDi(b) + const esito = b.pagata ? (b.esito === 'vinta' ? `+${fmt(vinc)}` : `persa`) : diQuesta && corrente.fase === 'partita' ? 'in corso' : `→ ${fmt(vinc)}` + return `
  3. ${esc(b.casa)}–${esc(b.ospite)}${b.risultato ? ` ${esc(b.risultato)}` : ''}${esc(pu.nome)} @ ${fmtQuota(b.quota)}${fmt(b.puntata)}${esito}
  4. ` + }).join('') +} + +// ── il banco ───────────────────────────────────────────────────────────────── +function aggiornaBanco() { + if (!el.saldo) return + el.saldo.textContent = `$pzk ${fmt(pzk.saldo())}` + const p = pzk.sulTavolo(GIOCO) + el.puntato.textContent = p ? `sulla partita: ${fmt(p)}` : '' + const aperte = corrente.fase === 'scommesse' + for (const b of el.scegli.children) { b.setAttribute('aria-pressed', String(Number(b.dataset.v) === stato.fiche)); b.disabled = !aperte } + el.togli.disabled = !aperte || !diAdesso().length + el.rifai.disabled = !aperte || !stato.ultime?.length || !!sulTavolo() + el.statoT.textContent = aperte ? 'scommesse aperte' : 'scommesse chiuse: si gioca' + dlg.classList.toggle('chiuso', !aperte) +} +function scuoti(e) { if (!RIDOTTO.matches) e.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-5px)' }, { transform: 'translateX(5px)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(0)' }], { duration: 300 }) } +const centro = (e) => { const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 } } +/** Una fiche che vola da a a b (coordinate della finestra), con l'arco e l'ombra, come negli altri giochi. */ +function vola(v, a, b, { durata = 380, alto = 40, svanisce = false } = {}) { + if (RIDOTTO.matches) return Promise.resolve() + const img = document.createElement('img') + img.className = 'vi-volo' + img.src = `${ART}fiches/f${v}.webp` + img.alt = '' + dlg.appendChild(img) + const mx = (a.x + b.x) / 2, my = Math.min(a.y, b.y) - alto + const anim = img.animate([ + { transform: `translate(${a.x}px, ${a.y}px) scale(1)`, opacity: 1 }, + { offset: 0.5, transform: `translate(${mx}px, ${my}px) scale(1.3)`, opacity: 1 }, + { transform: `translate(${b.x}px, ${b.y}px) scale(1)`, opacity: svanisce ? 0 : 1 }, + ], { duration: durata, easing: 'cubic-bezier(.3,.7,.4,1)', fill: 'forwards' }) + return anim.finished.then(() => img.remove(), () => img.remove()) +} + +// ── il disegno dello schermino e del tabellone, a ogni fotogramma ─────────── +let ultimoEvento = null +function disegna(forza = false, dt = 1 / 60) { + if (!schermo || !P) return + const f = corrente + const now = Date.now() + let t, fs + if (f.fase === 'partita') { t = (now - P.inizio) / 1000; fs = 'partita' } + else { t = -(f.manca / 1000); fs = 'scommesse' } + schermo.disegna(P, { fase: fs, t, ridotto: RIDOTTO.matches, dt }) + tabelloneVivo(t) + // i suoni degli eventi (una volta per evento), e la voce + const e = schermo.evento + if (e !== ultimoEvento) { + ultimoEvento = e + if (e && f.fase === 'partita') { + if (e.tipo === 'gol') { suona('virtual-gol'); voce(`GOL! ${e.chi.nome} (${P[e.squadra].nome}) al ${e.minuto}`) } + else if (e.tipo === 'tiro' && e.esito === 'palo') { suona('virtual-palo'); voce(`palo di ${P[e.squadra].sigla} al ${e.minuto}!`) } + else if (e.tipo === 'rigore') voce(`rigore per ${P[e.squadra].nome}: ${e.esito}`) + else if (e.tipo === 'rosso') voce(`espulso ${e.chi.nome}: ${P[e.squadra].nome} in quattro`) + else if (e.tipo === 'giallo') voce(`ammonito ${e.chi.nome} (${P[e.squadra].sigla})`) + else if (e.tipo === 'infortunio') voce(`${e.chi.nome} esce in barella, entra ${e.entra.nome}`) + else if (e.tipo === 'sospesa') voce(`partita sospesa: ${e.causa}. ${scriviRisultato(P.risultato)}`) + else if (e.tipo === 'intervallo') { suona('virtual-fischio'); voce('intervallo') } + else if (e.tipo === 'fine') { suona('virtual-fischio'); voce(`finale: ${scriviRisultato(P.risultato)}`) } + } + } +} diff --git a/albertopecchini.it/pzkko/index.html b/albertopecchini.it/pzkko/index.html index a62dcfd5..51e04bad 100644 --- a/albertopecchini.it/pzkko/index.html +++ b/albertopecchini.it/pzkko/index.html @@ -167,7 +167,7 @@ .muro::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;mix-blend-mode:screen;background:url(/art/muro.webp) 0 0/2200px auto repeat;filter:grayscale(1) contrast(3.2) brightness(1.25);opacity:calc(.75 * var(--auto-l)); -webkit-mask-image:radial-gradient(34% 100% at calc(var(--auto-x) * 100%) 50%,#000,transparent 70%); mask-image:radial-gradient(34% 100% at calc(var(--auto-x) * 100%) 50%,#000,transparent 70%)} -.parete{position:relative;height:calc(3252 * var(--u))} +.parete{position:relative;height:calc(3320 * var(--u))} .gruppo{display:contents} /* ogni cosa sul muro è posizionata in 1280esimi (e in 390esimi sul telefono) */ @@ -306,7 +306,7 @@ /* ── il telefono: il muro visto da vicino, due volantini per riga ─────────── */ @media (max-width:899px){ :root{--u:calc(min(100cqw, 600px) / 390)} - .parete{height:calc(5654 * var(--u));max-width:600px;margin:0 auto} + .parete{height:calc(5684 * var(--u));max-width:600px;margin:0 auto} .it{--px:var(--mx,var(--x));--py:var(--my,var(--y));--pw:var(--mw,var(--w));--pr:var(--mr,var(--r,0deg))} .spray{font-size:calc(40 * var(--u))} .vol h3{font-size:max(calc(22px * var(--ts,1)),calc(16cqw * var(--ts,1)))} @@ -419,36 +419,36 @@

    - - - + + + - + - - + + - - - - - + + + + + - + - + - - + + - + - - + + @@ -463,24 +463,24 @@

    - - - + + + - - - - - + + + + + - - - - + + + + - - + +

    siti

    @@ -558,7 +558,7 @@

    da dove viene questo muro?

    -
    +
    la piaggio ciao grigia sull'erba, al tramonto @@ -575,7 +575,7 @@

    +

    petizione popolare

    @@ -602,10 +602,10 @@

    pzkko.it è un bel sito?

    - + - - + +
    @@ -647,7 +647,7 @@

    + -
    +
    @@ -686,7 +686,7 @@

    +
    @@ -701,10 +701,10 @@

    -

    games

    +

    games

    -
    + -
    +
    +18
    @@ -739,7 +739,7 @@

    mine

    -
    +
    +18
    @@ -751,7 +751,7 @@

    dado

    -
    +
    +18
    @@ -761,10 +761,23 @@

    moneta

    gioca

    + + +
    + + +18 +
    + +

    virtual

    +

    il campionato inventato: quote vere, risultati a caso. ogni 5 minuti una partita sullo schermino. soldi finti.

    +

    gioca

    +
    +
    -

    tools

    +

    tools

    + + + + + + + + + + + + + + + + + + + + +` +await sharp(Buffer.from(svg)).webp({ quality: 92, alphaQuality: 90 }).toFile(OUT) +console.log('scritto', OUT) diff --git a/albertopecchini.it/tools/pzkko/virtual/prova.mjs b/albertopecchini.it/tools/pzkko/virtual/prova.mjs new file mode 100644 index 00000000..515f855d --- /dev/null +++ b/albertopecchini.it/tools/pzkko/virtual/prova.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Le prove di «Virtual» (pzkko/games/virtual/partita.js): costruisce N partite di fila dal calendario (indici +// consecutivi, come quelle che il sito farà vedere) e controlla, senza browser: +// - che la stessa partita chiesta due volte sia identica (il determinismo: due schede vedono la stessa cosa); +// - che le quote seguano la forza: la favorita (forza più alta) ha la quota più bassa, sempre; la correlazione fra la +// differenza di forza e la quota dell'1 è forte e negativa; 1 X 2 sommano a ~106% in probabilità implicita e +// over/under della stessa soglia pure; nessuna quota sotto 1,05; +// - che i risultati NON dipendano dalla forza: la favorita vince quanto la sfavorita (percentuali e un chi quadro +// fra «chi è più forte» e l'esito 1/X/2), la correlazione fra differenza di forza e differenza di gol è ~0, i gol a +// squadra fanno in media GOL_MEDI con la varianza di una Poisson, la quota non predice niente; +// - che la cronaca sia sensata: i gol della cronaca sono quelli del risultato, gli eventi stanno in ordine e dentro i 60 +// secondi, i grandi eventi ad almeno 3 s l'uno dall'altro, mai a cavallo dell'intervallo; il rosso lascia la squadra in +// quattro; il tavolino dà 3–0 e paga come tale; +// - che le puntate paghino giuste: per ogni partita, ogni puntata vincente è coerente col risultato (un conto fatto in un +// altro modo), e il ritorno al giocatore di chi punta sempre la stessa cosa sta attorno a 100% − margine. +// +// node tools/pzkko/virtual/prova.mjs [partite, 2000] [--da=indice] + +import { partita, vince, PUNTATE, GOL_MEDI, MARGINE, QUOTA_MINIMA, SOGLIE, T, indiceAdesso } from '../../../pzkko/games/virtual/partita.js' + +const arg = (n, d) => { const a = process.argv.find((x) => x.startsWith(`--${n}=`)); return a ? a.slice(n.length + 3) : d } +const N = Number(process.argv.slice(2).find((a) => /^\d+$/.test(a)) ?? 2000) +const DA = Number(arg('da', indiceAdesso())) +const errori = [] +const err = (s) => { if (errori.length < 30) errori.push(s) } +const pct = (x) => `${(100 * x).toFixed(1)}%` +const corr = (xs, ys) => { + const n = xs.length, mx = xs.reduce((a, b) => a + b, 0) / n, my = ys.reduce((a, b) => a + b, 0) / n + let sxy = 0, sxx = 0, syy = 0 + for (let i = 0; i < n; i++) { sxy += (xs[i] - mx) * (ys[i] - my); sxx += (xs[i] - mx) ** 2; syy += (ys[i] - my) ** 2 } + return sxy / Math.sqrt(sxx * syy) +} +// p di un chi quadro con gdl gradi (serie, basta per gdl piccoli) +function pChi(x, gdl) { + const gamma = (s, z) => { let sum = 1 / s, term = 1 / s; for (let k = 1; k < 200; k++) { term *= z / (s + k); sum += term } return sum * Math.exp(-z + s * Math.log(z)) } + const G = (s) => (s === 0.5 ? Math.sqrt(Math.PI) : s === 1 ? 1 : (s - 1) * G(s - 1)) + return 1 - gamma(gdl / 2, x / 2) / G(gdl / 2) +} + +const esiti = { fav: { 1: 0, X: 0, 2: 0 }, sfav: { 1: 0, X: 0, 2: 0 } } // 1 = vince la favorita, X, 2 = vince la sfavorita +const perForza = [] // [diff forza, diff gol] +const perQuota = [] // [quota 1, casa vince] +let golTot = 0, golQ = 0, nGol = 0, tavolini = 0, rossi = 0, rigori = 0, infortuni = 0, incroci = 0 +const ritorno = Object.fromEntries(PUNTATE.map((p) => [p.id, { puntato: 0, tornato: 0 }])) +const casaVince = { si: 0, tot: 0 } +const meteo = {} +let sommaMedia = 0 + +for (let k = 0; k < N; k++) { + const i = DA + k + const p = partita(i), p2 = partita(i) + if (JSON.stringify(p) !== JSON.stringify(p2)) err(`${i}: due costruzioni diverse`) + if (p.casa === p.ospite) err(`${i}: la squadra gioca contro se stessa`) + if (p.casa.serie !== p.ospite.serie) incroci++ + meteo[p.meteo.tipo] = (meteo[p.meteo.tipo] ?? 0) + 1 + // le quote + const q = p.quote + for (const [id, v] of Object.entries(q)) if (!(v >= QUOTA_MINIMA) || Math.abs(Math.round(v * 100) - v * 100) > 1e-6) err(`${i}: quota ${id} = ${v}`) + const s1x2 = 1 / q[1] + 1 / q.X + 1 / q[2] + sommaMedia += s1x2 + if (Math.abs(s1x2 - (1 + MARGINE)) > 0.02 && q[1] > QUOTA_MINIMA && q[2] > QUOTA_MINIMA) err(`${i}: 1 X 2 sommano a ${pct(s1x2)}`) + for (const s of SOGLIE) { const so = 1 / q[`O${s}`] + 1 / q[`U${s}`]; if (Math.abs(so - (1 + MARGINE)) > 0.03 && q[`O${s}`] > QUOTA_MINIMA && q[`U${s}`] > QUOTA_MINIMA) err(`${i}: over/under ${s} sommano a ${pct(so)}`) } + const favCasa = p.forze.casa + 4 >= p.forze.ospite + if (p.forze.casa - p.forze.ospite > 8 && q[1] >= q[2]) err(`${i}: ${p.casa.nome} (${p.forze.casa}) in casa quotata ${q[1]} contro ${p.ospite.nome} (${p.forze.ospite}) a ${q[2]}`) + if (p.forze.ospite - p.forze.casa > 12 && q[2] >= q[1]) err(`${i}: ${p.ospite.nome} (${p.forze.ospite}) fuori quotata ${q[2]} contro ${p.casa.nome} (${p.forze.casa}) a ${q[1]}`) + // il risultato + const r = p.risultato + const diffForza = p.forze.casa - p.forze.ospite + if (r.tavolino) { tavolini++; if (!((r.casa === 3 && r.ospite === 0) || (r.casa === 0 && r.ospite === 3))) err(`${i}: tavolino ${r.casa}-${r.ospite}`) } + else { + perForza.push([diffForza, r.casa - r.ospite]) + perQuota.push([q[1], r.casa > r.ospite ? 1 : 0]) + golTot += r.casa + r.ospite; golQ += r.casa ** 2 + r.ospite ** 2; nGol += 2 + const chiVince = r.casa > r.ospite ? 'casa' : r.casa < r.ospite ? 'ospite' : 'X' + const e = chiVince === 'X' ? 'X' : (chiVince === 'casa') === favCasa ? '1' : '2' + esiti[Math.abs(diffForza) >= 15 ? 'fav' : 'sfav'][e]++ + casaVince.tot++; if (chiVince === 'casa') casaVince.si++ + } + // la cronaca + const ev = p.eventi + let c = 0, o = 0, prima = -1, ultimoGrande = -10, inCampo = { casa: 5, ospite: 5 } + for (const e of ev) { + if (!(e.t >= 0 && e.t <= T.fine)) err(`${i}: evento ${e.tipo} a ${e.t}`) + if (e.t < prima) err(`${i}: eventi in disordine (${e.tipo} a ${e.t} dopo ${prima})`) + prima = e.t + if (e.tipo === 'gol') { if (e.squadra === 'casa') c++; else o++; if (e.rigore) rigori++ } + if (e.tipo === 'rigore') rigori++ + if (e.tipo === 'rosso') { rossi++; inCampo[e.squadra]-- } + if (e.tipo === 'infortunio') infortuni++ + if (['gol', 'rigore', 'giallo', 'rosso', 'infortunio'].includes(e.tipo)) { + if (e.t - ultimoGrande < 3.1) err(`${i}: ${e.tipo} a ${e.t.toFixed(1)} troppo vicino al grande evento prima (${ultimoGrande.toFixed(1)})`) + if (e.t > T.fine1 - 1.5 && e.t < T.via2 + 0.5) err(`${i}: ${e.tipo} a cavallo dell'intervallo (${e.t.toFixed(1)})`) + ultimoGrande = e.t + } + } + if (!r.tavolino && (c !== r.casa || o !== r.ospite)) err(`${i}: la cronaca dice ${c}-${o}, il risultato ${r.casa}-${r.ospite}`) + if (r.tavolino && !ev.some((e) => e.tipo === 'sospesa')) err(`${i}: tavolino senza sospensione`) + if (!r.tavolino && ev[ev.length - 1].tipo !== 'fine') err(`${i}: non finisce col fischio finale`) + if (inCampo.casa < 4 || inCampo.ospite < 4) err(`${i}: una squadra sotto i quattro`) + // le puntate: ogni id contro un conto fatto al contrario, e il ritorno di chi punta sempre 1 $pzk su ognuna + const tot = r.casa + r.ospite + const giuste = new Set([r.casa > r.ospite ? '1' : r.casa < r.ospite ? '2' : 'X', ...SOGLIE.map((s) => (tot > s ? `O${s}` : `U${s}`))]) + for (const pu of PUNTATE) { + const v = vince(pu.id, r) + if (v !== giuste.has(pu.id)) err(`${i}: la puntata ${pu.id} ${v ? 'vince' : 'perde'} su ${r.casa}-${r.ospite}`) + ritorno[pu.id].puntato += 1 + if (v) ritorno[pu.id].tornato += q[pu.id] + } +} + +console.log(`\nVirtual: ${N} partite dall'indice ${DA} (${new Date(DA * 300000).toISOString()})`) +console.log(`incroci A–B ${pct(incroci / N)} · tavolini ${tavolini} (${pct(tavolini / N)}) · rossi ${rossi} · rigori ${rigori} · infortuni ${infortuni}`) +console.log(`meteo: ${Object.entries(meteo).map(([k, v]) => `${k} ${pct(v / N)}`).join(', ')}`) +console.log(`\nLE QUOTE SEGUONO LA FORZA`) +console.log(` 1 X 2 in probabilità implicita: ${pct(sommaMedia / N)} in media (atteso ${pct(1 + MARGINE)})`) +const df = perForza.map((x) => x[0]), q1 = perQuota.map((x) => x[0]) +console.log(` correlazione fra (forza casa − forza ospite) e quota dell'1: ${corr(df, q1).toFixed(3)} (forte e negativa: più forte la casa, più bassa la quota)`) +console.log(`\nI RISULTATI NON DIPENDONO DALLA FORZA`) +const dg = perForza.map((x) => x[1]) +console.log(` correlazione fra (forza casa − forza ospite) e (gol casa − gol ospite): ${corr(df, dg).toFixed(3)} (atteso ~0)`) +console.log(` correlazione fra la quota dell'1 e «la casa vince»: ${corr(q1, perQuota.map((x) => x[1])).toFixed(3)} (atteso ~0: la quota non predice niente)`) +const tf = esiti.fav[1] + esiti.fav.X + esiti.fav[2], ts = esiti.sfav[1] + esiti.sfav.X + esiti.sfav[2] +console.log(` partite con una favorita netta (≥ 15 punti di forza), ${tf}: vince la favorita ${pct(esiti.fav[1] / tf)}, pareggio ${pct(esiti.fav.X / tf)}, vince la sfavorita ${pct(esiti.fav[2] / tf)}`) +console.log(` partite equilibrate, ${ts}: vince la «favorita» ${pct(esiti.sfav[1] / ts)}, pareggio ${pct(esiti.sfav.X / ts)}, vince l'altra ${pct(esiti.sfav[2] / ts)}`) +// chi quadro: favorita netta vs equilibrate, sugli esiti +{ + const righe = [esiti.fav, esiti.sfav], tot = tf + ts + let chi = 0 + for (const e of ['1', 'X', '2']) { const col = esiti.fav[e] + esiti.sfav[e]; for (const r of righe) { const n = r[1] + r.X + r[2], att = (n * col) / tot; if (att > 0) chi += (r[e] - att) ** 2 / att } } + console.log(` chi quadro «favorita netta o no» × esito: ${chi.toFixed(2)} su 2 gradi, p = ${pChi(chi, 2).toFixed(2)} (se dipendesse dalla forza sarebbe ~0)`) +} +console.log(` la casa vince ${pct(casaVince.si / casaVince.tot)} (nessun fattore campo nei risultati: quanto gli ospiti, 36,1% per due Poisson di media 1,35)`) +const media = golTot / nGol, varianza = golQ / nGol - media ** 2 +console.log(` gol a squadra: media ${media.toFixed(3)} (atteso ${GOL_MEDI}), varianza ${varianza.toFixed(3)} (Poisson: uguale alla media)`) +console.log(`\nLE PUNTATE PAGANO GIUSTE`) +for (const pu of PUNTATE) { const r = ritorno[pu.id]; console.log(` ${pu.nome.padEnd(10)} ritorno ${pct(r.tornato / r.puntato)}`) } +console.log(` (chi punta a caso sulle quote «vere» con risultati a caso: la favorita paga poco e vince come l'altra, quindi sull'1 e sul 2 il ritorno dipende da chi si sceglie; over/under ~${pct(1 / (1 + MARGINE))})`) +console.log(errori.length ? `\n${errori.length} ERRORI:\n ${errori.join('\n ')}` : '\nnessun errore') +process.exit(errori.length ? 1 : 0)