Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ec499590 | ||
|
|
75b532cda0 | ||
|
|
68686efe03 | ||
|
|
208e1d9535 | ||
|
|
f2a0f22f4a | ||
|
|
dea124933e | ||
|
|
51a46fe5a0 | ||
|
|
f6fb8922af | ||
|
|
bd7fbc3003 | ||
|
|
8b957c7cf7 | ||
|
|
f1de62f01e |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"image": "andrea-basket.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"hold": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 51,
|
||||
"h": 120,
|
||||
"ax": 19.1,
|
||||
"ay": 120
|
||||
},
|
||||
"load": {
|
||||
"x": 53,
|
||||
"y": 0,
|
||||
"w": 62,
|
||||
"h": 106,
|
||||
"ax": 32.9,
|
||||
"ay": 106
|
||||
},
|
||||
"shoot": {
|
||||
"x": 117,
|
||||
"y": 0,
|
||||
"w": 69,
|
||||
"h": 124,
|
||||
"ax": 30.0,
|
||||
"ay": 124
|
||||
},
|
||||
"cheer": {
|
||||
"x": 188,
|
||||
"y": 0,
|
||||
"w": 59,
|
||||
"h": 118,
|
||||
"ax": 26.5,
|
||||
"ay": 118
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"image": "andrea-pesca.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"hold": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 101,
|
||||
"h": 120,
|
||||
"ax": 31.2,
|
||||
"ay": 120
|
||||
},
|
||||
"cast": {
|
||||
"x": 103,
|
||||
"y": 0,
|
||||
"w": 145,
|
||||
"h": 89,
|
||||
"ax": 56.1,
|
||||
"ay": 89
|
||||
},
|
||||
"reel": {
|
||||
"x": 250,
|
||||
"y": 0,
|
||||
"w": 102,
|
||||
"h": 107,
|
||||
"ax": 32.5,
|
||||
"ay": 107
|
||||
},
|
||||
"show": {
|
||||
"x": 354,
|
||||
"y": 0,
|
||||
"w": 129,
|
||||
"h": 113,
|
||||
"ax": 87.9,
|
||||
"ay": 113
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+108
-8
@@ -211,9 +211,9 @@
|
||||
"file": "andrea-22b8efe94b.mp3",
|
||||
"sec": 1.75
|
||||
},
|
||||
"pescatori|Si preparano le canne: domattina si va a pescare al lago dell'Anatre, dietro Cardetole!": {
|
||||
"file": "pescatori-486103d189.mp3",
|
||||
"sec": 3.74
|
||||
"pescatori|Si preparano le canne: si va a pescare al lago dell'Anatre, dietro Cardetole!": {
|
||||
"file": "pescatori-e7edfae41f.mp3",
|
||||
"sec": 3.51
|
||||
},
|
||||
"pescatori|Guarda questa: la lenza la regge anche una carpa di dieci chili.": {
|
||||
"file": "pescatori-afda722e0c.mp3",
|
||||
@@ -543,9 +543,9 @@
|
||||
"file": "andrea-5e37f70197.mp3",
|
||||
"sec": 5.35
|
||||
},
|
||||
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Prima o poi due tiri ce li faccio.": {
|
||||
"file": "andrea-8cd2b5a36d.mp3",
|
||||
"sec": 6.49
|
||||
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.": {
|
||||
"file": "andrea-3c4ee692ad.mp3",
|
||||
"sec": 7.55
|
||||
},
|
||||
"andrea|La fermata della corriera. Da qui si va dappertutto. Se passa.": {
|
||||
"file": "andrea-ccd735c926.mp3",
|
||||
@@ -1984,8 +1984,8 @@
|
||||
"sec": 1.24
|
||||
},
|
||||
"andrea|S'ha andà a fallo!?!?": {
|
||||
"file": "andrea-b25aeefc97.mp3",
|
||||
"sec": 1.16
|
||||
"file": "andrea-bb61f7641e.mp3",
|
||||
"sec": 2.17
|
||||
},
|
||||
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
|
||||
"file": "andrea-fca6f59a70.mp3",
|
||||
@@ -2035,6 +2035,74 @@
|
||||
"file": "arcu-406cd02197.mp3",
|
||||
"sec": 4.46
|
||||
},
|
||||
"andrea|S'ha andà a fallo?!?": {
|
||||
"file": "andrea-c80714aae7.mp3",
|
||||
"sec": 1.08
|
||||
},
|
||||
"andrea|Quanti ne prendete, di solito?": {
|
||||
"file": "andrea-1d758ed60c.mp3",
|
||||
"sec": 1.84
|
||||
},
|
||||
"andrea|Buona pesca, ragazzi.": {
|
||||
"file": "andrea-cb3510ac67.mp3",
|
||||
"sec": 1.85
|
||||
},
|
||||
"pescatori|Gnamo! La canna ti si dà noi.": {
|
||||
"file": "pescatori-32054cfa54.mp3",
|
||||
"sec": 2.97
|
||||
},
|
||||
"pescatori|Come peschi?": {
|
||||
"file": "pescatori-520fe85721.mp3",
|
||||
"sec": 0.56
|
||||
},
|
||||
"pescatori|Dipende da chi racconta. Il Nanni dice dieci, il cestino dice due.": {
|
||||
"file": "pescatori-901d65f3bc.mp3",
|
||||
"sec": 4.09
|
||||
},
|
||||
"pescatori|Se vedi il palio, salutacelo!": {
|
||||
"file": "pescatori-52125dc78f.mp3",
|
||||
"sec": 1.74
|
||||
},
|
||||
"andrea|Alla bona, tanto per bagnare la lenza.": {
|
||||
"file": "andrea-b74e348e25.mp3",
|
||||
"sec": 2.55
|
||||
},
|
||||
"pescatori|Alla bona. Due pesci e si va a casa contenti.": {
|
||||
"file": "pescatori-8af3768e38.mp3",
|
||||
"sec": 3.26
|
||||
},
|
||||
"pescatori|Seria: tre pesci, e lo stivale non conta.": {
|
||||
"file": "pescatori-e417a89a3a.mp3",
|
||||
"sec": 2.82
|
||||
},
|
||||
"pescatori|A fassi male? Quattro pesci, e le carpe tirano come trattori.": {
|
||||
"file": "pescatori-befa87eee8.mp3",
|
||||
"sec": 4.17
|
||||
},
|
||||
"andrea|Due tiri alla bona, tanto per scaldarmi.": {
|
||||
"file": "andrea-d564365fc2.mp3",
|
||||
"sec": 2.53
|
||||
},
|
||||
"andrea|Due tiri seri.": {
|
||||
"file": "andrea-ec8f0db709.mp3",
|
||||
"sec": 1.32
|
||||
},
|
||||
"andrea|Lascia stare, ho il palio da trovare.": {
|
||||
"file": "andrea-f5519518f2.mp3",
|
||||
"sec": 1.99
|
||||
},
|
||||
"andrea|Tre su dieci e sono contento.": {
|
||||
"file": "andrea-5e3315a040.mp3",
|
||||
"sec": 2.33
|
||||
},
|
||||
"andrea|Cinque su dieci. Come ai tempi del Circolino.": {
|
||||
"file": "andrea-1bc731bcdf.mp3",
|
||||
"sec": 3.59
|
||||
},
|
||||
"andrea|Sette su dieci, senza guardare la parabola. Vediamo chi sono.": {
|
||||
"file": "andrea-fa062d00bd.mp3",
|
||||
"sec": 4.95
|
||||
},
|
||||
"andrea|Mariano! Facciamo una partita a bocce?": {
|
||||
"file": "andrea-83b1337b4d.mp3",
|
||||
"sec": 3.31
|
||||
@@ -2279,6 +2347,38 @@
|
||||
"file": "mariano-39e27158b7.mp3",
|
||||
"sec": 2.83
|
||||
},
|
||||
"andrea|Che mano! Jordan, fatti da parte.": {
|
||||
"file": "andrea-3a52789f04.mp3",
|
||||
"sec": 2.98
|
||||
},
|
||||
"andrea|Il ferro oggi era stretto. Riprovo dopo.": {
|
||||
"file": "andrea-2e9d8549c8.mp3",
|
||||
"sec": 3.36
|
||||
},
|
||||
"andrea|Lascio il pallone lì. Tanto lo sanno tutti che è mio.": {
|
||||
"file": "andrea-ee42022092.mp3",
|
||||
"sec": 3.98
|
||||
},
|
||||
"pescatori|Oh, hai visto? Mica male, per una canna di bambù.": {
|
||||
"file": "pescatori-2583bf891f.mp3",
|
||||
"sec": 3.79
|
||||
},
|
||||
"andrea|Stasera si frigge. Ma prima il palio.": {
|
||||
"file": "andrea-b00b469ef8.mp3",
|
||||
"sec": 3.48
|
||||
},
|
||||
"pescatori|I pesci c'erano. Eri te che non c'eri.": {
|
||||
"file": "pescatori-53d5588588.mp3",
|
||||
"sec": 2.41
|
||||
},
|
||||
"andrea|Si rifà. Dopo il palio.": {
|
||||
"file": "andrea-5ebf35bc74.mp3",
|
||||
"sec": 2.37
|
||||
},
|
||||
"andrea|Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.": {
|
||||
"file": "andrea-72bf22a3f9.mp3",
|
||||
"sec": 4.49
|
||||
},
|
||||
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
|
||||
"file": "ing_tozzi-51f855478a.mp3",
|
||||
"sec": 4.42
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
-13
@@ -105,18 +105,43 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
|
||||
- *Regia alla fessura*: col meccanismo del primo piano (`closeup`) il buio, la torcia, un istante di silenzio,
|
||||
poi gli occhi. Due immagini di Codex.
|
||||
- *Segreti facoltativi del paese* con pagine illustrate nel taccuino: funzione nuova, dopo la storia principale.
|
||||
- **Altri minigiochi come le bocce** (25 ottobre 2026): ognuno è un primo piano (`closeup` con il suo `type`, come
|
||||
`bocce`), con comandi per dito, mouse e tastiera, i suoi test e una battuta per chi vince e per chi perde.
|
||||
Il posto naturale sono le gare del Palio del secondo capitolo:
|
||||
- *Passamano dei mattoni* (da fare per primo): un gioco di ritmo, i mattoni arrivano lungo la fila e si passano al
|
||||
momento giusto; sbagliando il tempo ne cade uno. Facile col dito, e chiude il cerchio col finale (i mattoni
|
||||
portati in Fortezza).
|
||||
- **Altri minigiochi** (25 ottobre 2026, rivisti il 28 ottobre con le proposte di Claude che ad Andrea piacciono):
|
||||
ognuno è un primo piano (`closeup` con il suo `type`), con comandi per dito, mouse e tastiera, i suoi test, una
|
||||
battuta per chi vince e per chi perde, e a fine lavoro la revisione critica di Codex se Andrea la concede.
|
||||
Già fatti: briscola, tressette e scopa all'oratorio; le bocce al pallaio; il biliardo coi birilli al Circolo
|
||||
4 Maggio; «Fai due tiri» al campo da basket del Circolo (27 ottobre). I prossimi due, se Andrea è d'accordo:
|
||||
il calcio balilla (riusa la fisica del biliardo, fa subito bar) e il tiro alla fune (è il Palio, e vale come
|
||||
enigma del rione).
|
||||
Le gare del Palio (il secondo capitolo: passamano, tiro alla fune, carrette, lavandaie, caccia al tesoro):
|
||||
- *Passamano dei mattoni*: un gioco di ritmo, i mattoni arrivano lungo la fila e si passano al momento giusto,
|
||||
un tasto per ogni passaggio; sbagliando il tempo ne cade uno. Facile col dito, e chiude il cerchio col finale
|
||||
(i mattoni portati in Fortezza).
|
||||
- *Tiro alla fune a Cardetole*: si batte a ritmo (spazio o tocco) seguendo una barra che oscilla, lo sforzo
|
||||
della squadra; chi perde il tempo scivola. Semplice da fare, molto da palio; contro il bullo del rione, come in
|
||||
`docs/PROGETTO.md`.
|
||||
- *Le carrette*: corsa in discesa vista di lato, a destra e sinistra fra buche e balle di paglia. La più
|
||||
spettacolare, la più lunga da disegnare.
|
||||
- *Bandierine (lavanderine)*: correre a prendere la bandiera prima dell'avversario, di riflessi.
|
||||
spettacolare, la più lunga da disegnare: il «gioco grosso», con Mitrugno (consenso).
|
||||
- *Le lavandaie* (Andrea, 28 ottobre; prima era segnato male come «bandierine»): un vascone pieno d'acqua con
|
||||
dentro gli stracci, pezzi di stoffa dei quattro colori dei rioni mescolati; ogni squadra deve ripescare i suoi.
|
||||
Come minigioco: vista dall'alto sul vascone, gli stracci galleggiano e girano, si tocca il proprio colore per
|
||||
tirarlo fuori, un errore (il colore di un altro) fa perdere tempo; contro il tempo o contro le altre squadre.
|
||||
- *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo.
|
||||
Fuori dal Palio:
|
||||
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta.
|
||||
- *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere. Andrea ha ritrovato la foto di squadra
|
||||
sul campo in terra (porta, rete, la torre della Pieve dietro, anni '90): lo sfondo del minigioco lo fa Codex da quella,
|
||||
senza le persone (per i volti serve il consenso). Da salvare in `assets/raw/foto/campo-bettini-squadra.jpg`.
|
||||
Al bar e al circolo:
|
||||
- *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già
|
||||
la fisica delle sponde. Il gioco da bar più sampierino che c'è; il River «coi flipper e le figurine» lo cita già.
|
||||
- *Flipper del River*: più lavoro, ma la fisica delle biglie c'è già.
|
||||
- *Freccette al Circolo*: la mira vaga e si ferma al clic, tre freccette, si chiude il 301. Poco codice.
|
||||
- *Morra*: niente fisica, tutto dialogo e tempismo; molto toscana, le grida dei numeri in voce XTTS.
|
||||
Sport:
|
||||
- *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere che si butta. Andrea ha ritrovato la
|
||||
foto di squadra sul campo in terra (porta, rete, la torre della Pieve dietro, anni '90): lo sfondo lo fa Codex
|
||||
da quella, senza le persone (per i volti serve il consenso). Da salvare in `assets/raw/foto/campo-bettini-squadra.jpg`.
|
||||
- *Tennis o padel al campo secondario*: la macchina del tempo compare già quando Andrea dice di voler giocare a
|
||||
padel; un «muro» a tempismo basterebbe.
|
||||
- *Ping-pong al pallaio*: il tavolo verde c'è già nello sfondo.
|
||||
Giochi da bambini del 2007 (nostalgia):
|
||||
- *Le figurine al River*: un «batti-muro», o lo scambio «ce l'ho, ce l'ho, mi manca», più dialogo che gioco.
|
||||
- *La pesca al lago dell'Anatre*: coi ragazzi delle canne in Fortezza (27 ottobre) la scusa c'è già. Tempismo:
|
||||
la lenza vibra, si tira al momento giusto. Serve la scena del lago, dietro Cardetole.
|
||||
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta; la briscola
|
||||
c'è già all'oratorio, qui conta la gag.
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ tipi riconoscibili che persone identificabili, salvo accordi.
|
||||
|
||||
Obiettivo: entrare nella Fortezza e trovare il palio. Serve l'aiuto dei tre rioni oltre a quello di Andrea; ogni
|
||||
rione vuole una sfida del **Palietto** (i giochi dei bambini del Palio: passaggio dei mattoni, tiro alla fune, corsa
|
||||
coi carretti, gioco dell'elastico), qui trasformate in enigmi comici.
|
||||
coi carretti, gioco dell'elastico, le lavandaie: gli stracci dei quattro colori nel vascone, ognuno ripesca i suoi), qui trasformate in enigmi comici.
|
||||
|
||||
Le gare che Andrea ricorda davvero:
|
||||
|
||||
|
||||
+20
-2
@@ -205,12 +205,30 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
||||
murale di Cistola (Davide Poggi), «il talento di via del Pignone», alla maniera dei murali di Maradona a Napoli
|
||||
(volto da `assets/raw/riferimenti/cistola.png`). Consenso di Davide confermato da Andrea il 2026-10-27.
|
||||
- Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
|
||||
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile), campo da basket del Circolo: **da fare il
|
||||
gioco del basket** (idea di Andrea, 27 ottobre).
|
||||
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
|
||||
(`src/engine/basket.js`, vista di lato, sfondo e sprite di Andrea col pallone da Codex): da solo, dieci palloni, a ogni
|
||||
canestro si arretra fino alla linea da tre; tre livelli (quanti canestri per la gloria, quanta parabola mostra la
|
||||
guida). Battute provvisorie. Idea di Andrea del 27 ottobre. Revisione critica di Codex (27 ottobre) applicata per
|
||||
i punti gravi (angolo iniziale 64° con fascia di forza buona da ogni postazione, palo e tabellone da entrambi i lati,
|
||||
canestro che conta subito, pose senza doppio pallone, guida fino al primo urto, obiettivo sempre in vista). Restano
|
||||
le sue idee: «Riprova» dal risultato e record per livello; Andrea che arretra camminando; battute che dicono perché
|
||||
è uscita (corto, lungo, ferro); l'angolo anche nei comandi HTML; testi del canvas piccoli sul telefono;
|
||||
`basket_vinto` che lasci traccia nel paese; pannelli più compatti.
|
||||
- Fuori paese, dal campo secondario (a destra): le Mozzète → il ponticino → Coldaia → la falegnameria → Gabbiano;
|
||||
da Via Cipriani, indietro, Via Massorondinaio → campeggio. Sfondi da Street View, battute provvisorie.
|
||||
- In Fortezza quattro ragazzi inventati si fanno le canne da pesca col bambù per il lago dell'Anatre (sprite di Codex,
|
||||
`assets/raw/pescatori-sheet.png`, ritagliato 2x2 a mano: work, talk, laugh, proud); battuta finale di Andrea.
|
||||
Parlandoci: «S'ha andà a fallo?!?» (Andrea, 28 ottobre) e si va a pescare: minigioco `src/engine/pesca.js`
|
||||
(primo piano del lago, sfondo e Andrea con la canna di Codex; lancio tenendo premuto, tocchi finti e abboccata,
|
||||
ferrata a tempo, recupero con la lenza che si può spezzare; sei lanci; pesci più grossi al largo, lo stivale sotto
|
||||
riva; tre livelli). Battute provvisorie. Il lago come scena da visitare non c'è: serve una foto di Andrea.
|
||||
Revisione di Codex (28 ottobre) applicata nei punti gravi (galleggiante che saltava, sesto lancio bloccato dal
|
||||
pannello, invito a ferrare fuori tempo, lenza che non si spezzava mai, mira che si sbloccava) e in parecchi utili
|
||||
(un gesto solo per ferrare e recuperare, pulsante Recupera/Molla, recupero breve per i pesci piccoli, attese ripetute
|
||||
dimezzate, tocchi finti variabili, lenza dalla punta vera della canna, stivale con la sua battuta e senza peso,
|
||||
risultato deciso all'ultimo pesce). Restano le sue idee: «Porta a casa la pescata» a gloria raggiunta; premio per
|
||||
peso o varietà; i ragazzi che reagiscono (stivale, carpa, lenza rotta); paesaggio sonoro del lago (ora quello della
|
||||
Fortezza); abboccata e tensione anche nei comandi HTML e un richiamo grande sul telefono; Esc col fuoco sui pulsanti.
|
||||
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
|
||||
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
|
||||
|
||||
|
||||
+21
@@ -209,6 +209,27 @@
|
||||
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria.
|
||||
Vince chi fa più punti nella mano.</p>
|
||||
</details>
|
||||
<details id="basket-help" hidden><summary>Come si gioca</summary>
|
||||
<p>Muovi il mouse sul campo per scegliere l’angolo del tiro: la freccia parte dalle mani di Andrea. Un clic (o un
|
||||
tocco) fissa la mira, così puoi raggiungere i comandi senza cambiarla; un altro clic la sposta. Tieni premuto sul
|
||||
campo (o Spazio) e la forza riparte da zero e sale: lascia per tirare. Oppure regola la forza e premi Tira.
|
||||
Frecce ← →: angolo più teso o più alto; Maiusc + ← →: a mezzo grado; ↑ ↓: forza. Esc esce.</p>
|
||||
<p>Dieci palloni. Si comincia da sotto; a ogni canestro si arretra di una postazione: tiro libero, media distanza,
|
||||
da tre e da lontanissimo, dove un canestro vale tre punti invece di due. Il ferro e il tabellone rimandano la
|
||||
palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la
|
||||
guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.</p>
|
||||
</details>
|
||||
<details id="pesca-help" hidden><summary>Come si gioca</summary>
|
||||
<p>Un tocco sull’acqua sceglie dove lanciare (o regola la forza: più lontano, pesci più grossi, ma attese più
|
||||
lunghe). Tieni premuto sulla scena (o Spazio): la forza riparte da zero e sale, lascia per lanciare; oppure premi
|
||||
Lancia. Poi si aspetta: il galleggiante ha dei tocchi finti (si abbassa appena) e un’abboccata vera (va sotto del
|
||||
tutto): premi in quel momento per ferrare, e <b>resta premuto</b>: stai già recuperando. Premendo a vuoto si
|
||||
ricupera la lenza vuota e il lancio è perso; se non ferri in tempo il pesce lo sputa, e riprova fino a tre volte.</p>
|
||||
<p>Nel recupero guarda la lenza e la barra in basso: quando il pesce strattona (la lenza trema) la barra sale verso
|
||||
il rosso, lascia andare un attimo, poi riprendi. I pesci piccoli vengono a riva in fretta, le carpe no. Dal pannello
|
||||
il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale
|
||||
non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.</p>
|
||||
</details>
|
||||
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
|
||||
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
|
||||
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FLOOR, RIM, BALL, BALLS, SPOTS, canThrow, left, spot, releasePoint, guide as guidePoints, status } from './basket.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
const ANDREA = { scale: 0.66 };
|
||||
// Quale posa: carica mentre tiene premuto; finché la palla vola Andrea resta a braccia alzate (l'unica posa senza
|
||||
// pallone), esulta quando entra, e a palla ferma ce l'ha di nuovo in mano.
|
||||
export function andreaPose(c) {
|
||||
if (c.holding) return 'load';
|
||||
if (c.step === 'flight') return c.ball.scored ? 'cheer' : 'shoot';
|
||||
if (c.step === 'settle' && c.ball?.scored) return 'cheer';
|
||||
return 'hold';
|
||||
}
|
||||
function andrea(ctx, c, sheet) {
|
||||
const s = spot(c), x = s.x, y = FLOOR;
|
||||
ctx.fillStyle = 'rgba(8,10,9,0.5)'; ctx.beginPath(); ctx.ellipse(x + 4, y, 16, 3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
if (!sheet) return;
|
||||
const pose = andreaPose(c), frame = sheet.atlas.frames[pose] ?? sheet.atlas.frames.hold, k = ANDREA.scale;
|
||||
// Nel salto i piedi si staccano da terra per un attimo.
|
||||
const lift = pose === 'shoot' ? Math.round(Math.sin(Math.min(1, c.ball.time / 0.45) * Math.PI) * 10) : 0;
|
||||
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
|
||||
Math.round(x - frame.ax * k), Math.round(y - frame.ay * k) - lift, Math.round(frame.w * k), Math.round(frame.h * k));
|
||||
}
|
||||
function ball(ctx, b, r = BALL) {
|
||||
// L'ombra sul campo si stringe quando la palla è alta.
|
||||
const h = Math.max(0, FLOOR - b.y - r), k = Math.max(0.35, 1 - h / 260);
|
||||
ctx.fillStyle = `rgba(8,10,9,${0.45 * k})`; ctx.beginPath(); ctx.ellipse(b.x, FLOOR - 1, r * 1.2 * k, r * 0.4 * k, 0, 0, Math.PI * 2); ctx.fill();
|
||||
const shade = ctx.createRadialGradient(b.x - r * 0.35, b.y - r * 0.35, 0, b.x, b.y, r * 1.05);
|
||||
shade.addColorStop(0, '#ffb067'); shade.addColorStop(0.5, '#e8702a'); shade.addColorStop(1, '#8a3512');
|
||||
ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2); ctx.fill();
|
||||
// Le cuciture girano con la palla.
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, r - 0.3, 0, Math.PI * 2); ctx.clip();
|
||||
ctx.strokeStyle = 'rgba(60,20,8,0.75)'; ctx.lineWidth = 1;
|
||||
const a = b.spin ?? 0;
|
||||
ctx.beginPath(); ctx.ellipse(b.x, b.y, r, Math.abs(Math.cos(a)) * r, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.ellipse(b.x, b.y, Math.abs(Math.sin(a)) * r, r, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
ctx.fillStyle = '#ffd9b0'; ctx.fillRect(Math.round(b.x - r * 0.4), Math.round(b.y - r * 0.45), 1, 1);
|
||||
}
|
||||
function guide(ctx, c) {
|
||||
const r = releasePoint(c);
|
||||
// La freccia della mira c'è sempre, con la punta e un bordo scuro per staccarsi da rete e alberi; la parabola per
|
||||
// quanto concede il livello, fino al primo urto.
|
||||
const a = c.angle * Math.PI / 180, len = 22 + c.power * 18, tip = { x: r.x + Math.cos(a) * len, y: r.y - Math.sin(a) * len };
|
||||
const arrow = () => {
|
||||
ctx.beginPath(); ctx.moveTo(r.x, r.y); ctx.lineTo(tip.x, tip.y);
|
||||
for (const s of [-1, 1]) { ctx.moveTo(tip.x, tip.y); ctx.lineTo(tip.x - Math.cos(a - s * 0.5) * 6, tip.y + Math.sin(a - s * 0.5) * 6); }
|
||||
ctx.stroke();
|
||||
};
|
||||
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(20,16,10,0.8)'; arrow();
|
||||
ctx.lineWidth = 1; ctx.strokeStyle = '#fff6d2'; arrow();
|
||||
if (c.skill.preview > 0) {
|
||||
const pts = guidePoints(c, c.skill.preview);
|
||||
for (const [width, color] of [[3, 'rgba(20,16,10,0.6)'], [1, 'rgba(143,199,255,0.95)']]) {
|
||||
ctx.lineWidth = width; ctx.strokeStyle = color; ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
for (const [i, p] of pts.entries()) { if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = 'rgba(20,16,10,0.6)'; ctx.fillRect(r.x - 34, r.y - 16, 30, 11);
|
||||
text(ctx, `${c.angle.toFixed(c.angle % 1 ? 1 : 0)}°`, r.x - 6, r.y - 10, 6, '#fff4c9', 'right');
|
||||
}
|
||||
function swish(ctx, c) {
|
||||
// Un lampo di righe attorno al ferro quando la palla è appena entrata.
|
||||
const b = c.ball; if (c.step !== 'flight' || !b?.scored || b.time - b.scoredAt > 0.3) return;
|
||||
const cx = (RIM.x1 + RIM.x2) / 2;
|
||||
ctx.strokeStyle = 'rgba(255,240,170,0.8)';
|
||||
for (let i = 0; i < 6; i++) { const t = i / 6 * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx + Math.cos(t) * 20, RIM.y + Math.sin(t) * 8); ctx.lineTo(cx + Math.cos(t) * 30, RIM.y + Math.sin(t) * 13); ctx.stroke(); }
|
||||
}
|
||||
|
||||
export function drawBasket(ctx, c, def, bg, sheet) {
|
||||
ctx.save();
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||
else { ctx.fillStyle = '#6d7a5a'; ctx.fillRect(0, 0, 640, 360); }
|
||||
// Le postazioni sul campo: un segno chiaro dove si tira ora, uno spento dove si è già stati.
|
||||
for (const [i, s] of SPOTS.entries()) {
|
||||
ctx.fillStyle = i === c.spot ? 'rgba(255,244,201,0.9)' : i < c.spot ? 'rgba(255,244,201,0.35)' : 'rgba(255,244,201,0.15)';
|
||||
ctx.fillRect(s.x - 1, FLOOR + 2, 3, 3);
|
||||
}
|
||||
andrea(ctx, c, sheet);
|
||||
for (const [i, t] of c.trails.entries()) {
|
||||
ctx.fillStyle = `rgba(255,190,120,${0.3 * (1 - t.age / 0.5)})`;
|
||||
ctx.fillRect(Math.round(t.x) - 1, Math.round(t.y) - 1, i % 2 ? 2 : 3, i % 2 ? 2 : 3);
|
||||
}
|
||||
if (canThrow(c) || c.holding) guide(ctx, c);
|
||||
if (c.ball && c.step === 'flight') ball(ctx, c.ball); // ferma, la palla è già tornata in mano ad Andrea
|
||||
swish(ctx, c);
|
||||
panel(ctx, 182, 50, 440, 24);
|
||||
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
|
||||
// I palloni rimasti e il conto.
|
||||
panel(ctx, 18, 86, 196, 58);
|
||||
text(ctx, 'PALLONI', 26, 98, 6, '#eed5a0');
|
||||
for (let n = 0; n < BALLS; n++) {
|
||||
const x = 30 + n * 14, y = 113;
|
||||
if (n < left(c)) ball(ctx, { x, y, spin: 0 }, 4);
|
||||
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); }
|
||||
}
|
||||
text(ctx, `${c.baskets} su ${c.skill.goal} per la gloria · ${c.points} punti`, 26, 131, 6, c.baskets >= c.skill.goal ? '#b9e5b3' : '#d7ccb4');
|
||||
// La forza in basso a sinistra: a destra c'è il pulsante per uscire.
|
||||
if (canThrow(c) || c.holding) {
|
||||
panel(ctx, 18, 306, 176, 37);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 318, 7);
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5);
|
||||
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 331, Math.round(c.power * 150), 5);
|
||||
}
|
||||
if (c.step === 'result') {
|
||||
panel(ctx, 206, 91, 397, 70);
|
||||
text(ctx, c.result.winner === 'player' ? 'CHE MANO!' : 'DUE TIRI FATTI', 404, 108, 9, '#efd49e', 'center');
|
||||
text(ctx, `${c.result.baskets} canestri su ${BALLS}, ${c.result.points} punti`, 404, 132, 9, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||
text(ctx, c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto.` : `Per la gloria ne servivano ${c.result.goal}.`, 404, 150, 6, '#d6c7af', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// «Fai due tiri»: Andrea da solo al campo del Circolo 4 Maggio (idea di Andrea, 27 ottobre). Vista di lato, nelle
|
||||
// coordinate dello schermo (640x360): x verso il canestro, y verso il basso. Un pixel è circa 2,3 cm: il ferro, a
|
||||
// 3,05 m, sta 133 px sopra il campo. Dieci palloni; a ogni canestro si arretra di una postazione, fino a quella da
|
||||
// tre. Niente avversario: il risultato è quanti canestri su dieci, e i punti (due, tre da dietro la linea).
|
||||
export const FLOOR = 296, RIM = { x1: 482, x2: 518, y: 163, r: 2 }, BOARD = { x: 519, top: 118, bottom: 178, depth: 8 }, NET = 190, POLE = { x: 562, top: 112 };
|
||||
export const BALL = 8, G = 560, V_MIN = 140, V_MAX = 590, SWING = 1.4, BALLS = 10;
|
||||
export const FIXED_STEP = 1 / 240, REST = 0.55, FLIGHT_MAX = 7;
|
||||
export const RELEASE = { dx: 18, dy: -72 }; // da dove parte la palla rispetto ai piedi di Andrea: sopra la testa, nel salto
|
||||
export const ANGLE = { min: 25, max: 78 };
|
||||
export const SPOTS = [
|
||||
{ x: 400, name: 'da sotto', points: 2 },
|
||||
{ x: 320, name: 'dal tiro libero', points: 2 },
|
||||
{ x: 240, name: 'da media distanza', points: 2 },
|
||||
{ x: 150, name: 'da tre', points: 3 },
|
||||
{ x: 90, name: 'da lontanissimo', points: 3 },
|
||||
];
|
||||
// I livelli: quanti canestri su dieci valgono la gloria, e per quanto tempo di volo la guida mostra la parabola
|
||||
// (alla bona fino al primo urto, che è quasi tutta).
|
||||
export const LEVELS = {
|
||||
bona: { goal: 3, preview: 3 },
|
||||
seria: { goal: 5, preview: 0.4 },
|
||||
male: { goal: 7, preview: 0 },
|
||||
};
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const rad = deg => deg * Math.PI / 180;
|
||||
|
||||
export function newBasket(seed = Date.now() >>> 0, def = {}) {
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
return { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], current: 'player', spot: 0, thrown: 0, baskets: 0, points: 0,
|
||||
angle: 64, power: 0.5, holding: false, holdTime: 0, accumulator: 0, ball: null, events: [], trails: [], history: [] };
|
||||
}
|
||||
export const canThrow = c => c.step === 'aim' && c.thrown < BALLS;
|
||||
export const left = c => Math.max(0, BALLS - c.thrown);
|
||||
export const spot = c => SPOTS[c.spot];
|
||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||
export const speedFor = power => V_MIN + (V_MAX - V_MIN) * clamp(power, 0, 1);
|
||||
export function releasePoint(c) { const s = spot(c); return { x: s.x + RELEASE.dx, y: FLOOR + RELEASE.dy }; }
|
||||
export function velocity(c) { const v = speedFor(c.power), a = rad(c.angle); return { vx: v * Math.cos(a), vy: -v * Math.sin(a) }; }
|
||||
|
||||
export function setPower(c, value) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||
c.power = clamp(value, 0, 1); return true;
|
||||
}
|
||||
export function setAngle(c, deg) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(deg)) return false;
|
||||
c.angle = clamp(deg, ANGLE.min, ANGLE.max); return true;
|
||||
}
|
||||
export const nudge = (c, dir, fine = false) => setAngle(c, c.angle + dir * (fine ? 0.5 : 2));
|
||||
// La mira segue il punto toccato: l'angolo fra la mano di Andrea e il dito. Dietro di lui non si tira.
|
||||
export function aimScreen(c, [x, y]) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
const r = releasePoint(c);
|
||||
if (x <= r.x + 4) return false;
|
||||
return setAngle(c, Math.atan2(r.y - y, x - r.x) * 180 / Math.PI);
|
||||
}
|
||||
// La parabola libera (senza urti) per `seconds` di volo: la guida la mostra per il tempo che il livello concede.
|
||||
export function arc(c, seconds, every = 0.04) {
|
||||
const r = releasePoint(c), v = velocity(c), out = [];
|
||||
for (let t = 0; t <= seconds; t += every) out.push({ x: r.x + v.vx * t, y: r.y + v.vy * t + 0.5 * G * t * t });
|
||||
return out;
|
||||
}
|
||||
// La guida: la parabola libera finché non tocca qualcosa (ferro, tabellone, palo, campo), al massimo `seconds`.
|
||||
export function guide(c, seconds) {
|
||||
const out = [];
|
||||
for (const p of arc(c, seconds)) {
|
||||
const touches = p.y + BALL > FLOOR || [RIM.x1, RIM.x2].some(x => Math.hypot(p.x - x, p.y - RIM.y) < BALL + RIM.r)
|
||||
|| (p.x + BALL > BOARD.x && p.y + BALL > BOARD.top && p.y - BALL < BOARD.bottom) || (p.x + BALL > POLE.x && p.y + BALL > POLE.top);
|
||||
out.push(p);
|
||||
if (touches) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function shoot(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
const r = releasePoint(c), v = velocity(c);
|
||||
c.ball = { x: r.x, y: r.y, vx: v.vx, vy: v.vy, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, scoredAt: null, dead: false, spin: 0 };
|
||||
c.step = 'flight'; c.t = c.accumulator = 0; c.thrown++; c.trails = [];
|
||||
c.events.push({ kind: 'basket-throw', power: c.power });
|
||||
return true;
|
||||
}
|
||||
export function press(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||
}
|
||||
export function cancel(c) {
|
||||
if (!c.holding) return false;
|
||||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||
}
|
||||
export function release(c) {
|
||||
if (!c.holding) return false;
|
||||
if (c.holdTime < 0.14) return cancel(c); // un tocco breve serve a mirare, non spreca un pallone
|
||||
c.holding = false; return shoot(c);
|
||||
}
|
||||
|
||||
function hit(c, kind, strength) { if (c.events.length < 12) c.events.push({ kind, power: clamp(strength, 0, 1) }); }
|
||||
// Il ferro visto di lato sono due punti: il davanti e il dietro dell'anello. La palla ci rimbalza come su un tondino.
|
||||
function rimEnd(c, b, px, py) {
|
||||
const dx = b.x - px, dy = b.y - py, d = Math.hypot(dx, dy), r = BALL + RIM.r;
|
||||
if (d >= r || d < 1e-9) return;
|
||||
const nx = dx / d, ny = dy / d, closing = b.vx * nx + b.vy * ny;
|
||||
b.x = px + nx * r; b.y = py + ny * r;
|
||||
if (closing < 0) {
|
||||
const tx = -ny, ty = nx, vt = (b.vx * tx + b.vy * ty) * 0.92, vn = -closing * 0.58;
|
||||
b.vx = vn * nx + vt * tx; b.vy = vn * ny + vt * ty;
|
||||
b.rim = true;
|
||||
if (-closing > 25) hit(c, 'basket-rim', -closing / 400); // i microurti di una palla che si posa non tintinnano
|
||||
}
|
||||
}
|
||||
function physics(c, dt) {
|
||||
const b = c.ball, before = b.y;
|
||||
b.vy += G * dt; b.x += b.vx * dt; b.y += b.vy * dt; b.time += dt; b.spin += b.vx * dt / BALL;
|
||||
// Il tabellone: una lastra verticale che guarda Andrea, ma rimanda anche una palla che torna da dietro.
|
||||
// Il palo dietro ferma quel che passa oltre, dalla sua cima in giù: sopra, la palla vola via e ricade.
|
||||
if (b.y + BALL > BOARD.top && b.y - BALL < BOARD.bottom) {
|
||||
if (b.vx > 0 && b.x + BALL > BOARD.x && b.x - BALL < BOARD.x + BOARD.depth) { b.x = BOARD.x - BALL; b.vx = -b.vx * 0.6; b.board = true; hit(c, 'basket-board', Math.abs(b.vx) / 300); }
|
||||
else if (b.vx < 0 && b.x - BALL < BOARD.x + BOARD.depth && b.x + BALL > BOARD.x) { b.x = BOARD.x + BOARD.depth + BALL; b.vx = -b.vx * 0.6; b.board = true; hit(c, 'basket-board', Math.abs(b.vx) / 300); }
|
||||
}
|
||||
if (b.x + BALL > POLE.x && b.y + BALL > POLE.top) { b.x = POLE.x - BALL; if (b.vx > 0) { b.vx = -b.vx * 0.5; b.board = true; hit(c, 'basket-board', 0.3); } }
|
||||
rimEnd(c, b, RIM.x1, RIM.y); rimEnd(c, b, RIM.x2, RIM.y);
|
||||
// Il canestro: la palla attraversa l'anello scendendo, fra il davanti e il dietro, e non da sotto.
|
||||
const inside = b.x > RIM.x1 + BALL * 0.6 && b.x < RIM.x2 - BALL * 0.6;
|
||||
if (inside && before <= RIM.y && b.y > RIM.y && b.vy > 0 && !b.scored && !b.under) {
|
||||
b.scored = true; b.scoredAt = b.time; b.vx *= 0.35; b.vy *= 0.5;
|
||||
hit(c, b.rim || b.board ? 'basket-in' : 'basket-swish', 0.7);
|
||||
// Il canestro conta subito: il punteggio e, all'ultimo pallone, il risultato non aspettano la palla ferma.
|
||||
c.baskets++; c.points += spot(c).points;
|
||||
if (c.thrown >= BALLS) finish(c);
|
||||
} else if (inside && before >= RIM.y && b.y < RIM.y && b.vy < 0) b.under = true;
|
||||
if (b.scored && b.y < NET + BALL) { b.vx *= 1 - 2.5 * dt; b.vy *= 1 - 1.2 * dt; }
|
||||
if (b.y + BALL > FLOOR) {
|
||||
b.y = FLOOR - BALL;
|
||||
if (b.vy > 40) { b.vy = -b.vy * 0.55; b.vx *= 0.85; b.bounces++; hit(c, 'basket-bounce', b.vy / -400); }
|
||||
else { b.vy = 0; b.vx *= 1 - 3 * dt; }
|
||||
}
|
||||
const still = Math.hypot(b.vx, b.vy) < 10;
|
||||
// Entrata, la palla è finita: non si aspetta che rotoli. Una palla posata sul ferro, nemmeno.
|
||||
if (b.x < -20 || b.time > FLIGHT_MAX || b.bounces > 6 || (b.y + BALL >= FLOOR - 0.5 && Math.abs(b.vx) < 6 && b.vy === 0)
|
||||
|| (b.scored && b.y > NET + 30) || (still && b.time > 1.5)) b.dead = true;
|
||||
}
|
||||
function finish(c) {
|
||||
c.result = { winner: c.baskets >= c.skill.goal ? 'player' : 'cpu', baskets: c.baskets, points: c.points, goal: c.skill.goal };
|
||||
}
|
||||
function nextBall(c) {
|
||||
const b = c.ball;
|
||||
c.history.push({ spot: c.spot, scored: b.scored, rim: b.rim, board: b.board });
|
||||
if (b.scored) c.spot = Math.min(c.spot + 1, SPOTS.length - 1);
|
||||
if (c.thrown >= BALLS) { if (!c.result) finish(c); c.step = 'result'; } else c.step = 'aim';
|
||||
c.t = 0;
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
if (c.step === 'flight') {
|
||||
c.accumulator += dt;
|
||||
while (c.accumulator >= FIXED_STEP && !c.ball.dead) { physics(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||
const b = c.ball;
|
||||
if (!b.dead && Math.hypot(b.vx, b.vy) > 60) { c.trails.push({ x: b.x, y: b.y, age: 0 }); if (c.trails.length > 14) c.trails.shift(); }
|
||||
if (b.dead) { c.step = 'settle'; c.t = 0; }
|
||||
} else if (c.step === 'settle' && c.t >= REST) nextBall(c);
|
||||
for (const t of c.trails) t.age += dt;
|
||||
c.trails = c.trails.filter(t => t.age < 0.5);
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea' }) {
|
||||
const tally = `Canestri ${c.baskets} (ne servono ${c.skill.goal}), punti ${c.points}.`;
|
||||
if (c.step === 'result') return `${c.result.baskets} canestri su ${BALLS}, ${c.result.points} punti. ${c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
||||
if (c.step === 'flight') return c.ball.scored ? `Dentro! ${tally}` : 'Palla in aria…';
|
||||
if (c.step === 'settle') return c.ball.scored ? `Canestro! ${tally}` : `Fuori. ${tally}`;
|
||||
return `${names.player}, pallone ${c.thrown + 1} di ${BALLS}, ${spot(c).name}. ${tally}`;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
import * as bocce from './bocce.js';
|
||||
import * as biliardo from './biliardo.js';
|
||||
import * as basket from './basket.js';
|
||||
import * as pesca from './pesca.js';
|
||||
import * as briscola from './briscola.js';
|
||||
import * as tressette from './tressette.js';
|
||||
import * as scopa from './scopa.js';
|
||||
@@ -14,6 +16,8 @@ export const MINIGAMES = {
|
||||
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
|
||||
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
|
||||
};
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BANK, WATER, CASTS, left, fishCount, distanceFor, status } from './pesca.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const ANDREA = { x: 92, y: 258, scale: 0.68 }, BOYS = { x: 44, y: 262, scale: 0.5 };
|
||||
// La punta della canna in ogni posa, rispetto ai piedi di Andrea (misurata sul foglio di Codex): la lenza parte da lì.
|
||||
const TIPS = { hold: [60, -81], cast: [80, -57], reel: [43, -66], show: [60, -81] };
|
||||
const ROD_TIP = { x: ANDREA.x + TIPS.hold[0], y: ANDREA.y + TIPS.hold[1] };
|
||||
export function andreaPose(c) {
|
||||
if (c.step === 'cast') return 'cast';
|
||||
if (c.step === 'fight') return 'reel';
|
||||
if (c.step === 'settle' && c.how === 'landed') return c.fish?.gag ? 'hold' : 'show';
|
||||
return 'hold';
|
||||
}
|
||||
function sprite(ctx, sheet, pose, { x, y, scale }) {
|
||||
if (!sheet) return;
|
||||
const frame = sheet.atlas.frames[pose] ?? Object.values(sheet.atlas.frames)[0];
|
||||
ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h,
|
||||
Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale), Math.round(frame.w * scale), Math.round(frame.h * scale));
|
||||
}
|
||||
// Dove sta la punta della canna: segue la posa, così la lenza parte sempre dalla cima.
|
||||
function rodTip(c) {
|
||||
const [dx, dy] = TIPS[andreaPose(c)] ?? TIPS.hold;
|
||||
return { x: ANDREA.x + dx, y: ANDREA.y + dy };
|
||||
}
|
||||
function floatAt(c) {
|
||||
const f = c.float;
|
||||
if (!f) return null;
|
||||
if (c.step === 'cast') {
|
||||
// La parabola del lancio dalla punta della canna all'acqua.
|
||||
const tip = rodTip(c), k = f.fly, x = tip.x + (f.x - tip.x) * k, y = tip.y + (WATER - tip.y) * k - Math.sin(k * Math.PI) * 60;
|
||||
return { x, y };
|
||||
}
|
||||
return { x: f.x, y: WATER + f.dip + Math.sin(c.t * 2.2 + f.x) * 0.8 };
|
||||
}
|
||||
function fishShadow(ctx, c, p) {
|
||||
const fish = c.fish; if (!fish || c.step !== 'fight') return;
|
||||
const w = 10 + fish.weight * 9, h = 4 + fish.weight * 2.5, x = p.x + 8 + Math.sin(c.t * 3) * 6, y = WATER + 22 + Math.sin(c.t * 1.7) * 5;
|
||||
ctx.fillStyle = 'rgba(20,40,38,0.55)';
|
||||
ctx.beginPath(); ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2 + 5, y - 4); ctx.lineTo(x + w / 2 + 5, y + 4); ctx.fill();
|
||||
}
|
||||
function fishIcon(ctx, f, x, y) {
|
||||
if (f.gag) { ctx.fillStyle = '#4a3a2a'; ctx.fillRect(x - 3, y - 5, 4, 8); ctx.fillRect(x - 3, y + 1, 8, 3); return; }
|
||||
const w = 6 + f.weight * 3;
|
||||
ctx.fillStyle = f.kind === 'carpa' ? '#c9a24a' : f.kind === 'tinca' ? '#5a7a3a' : f.kind === 'persico' ? '#d9b14a' : '#9fb3b8';
|
||||
ctx.beginPath(); ctx.ellipse(x, y, w / 2, 2.5 + f.weight, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x - w / 2, y); ctx.lineTo(x - w / 2 - 3, y - 3); ctx.lineTo(x - w / 2 - 3, y + 3); ctx.fill();
|
||||
ctx.fillStyle = '#1a1a1a'; ctx.fillRect(Math.round(x + w / 2 - 3), Math.round(y - 1), 1, 1);
|
||||
}
|
||||
|
||||
export function drawPesca(ctx, c, def, bg, sheet, boys) {
|
||||
ctx.save();
|
||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||
else { ctx.fillStyle = '#5c7f7a'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#7a9a4a'; ctx.fillRect(0, 240, BANK, 120); }
|
||||
// I ragazzi sulla riva, dietro Andrea, intenti alle loro canne.
|
||||
sprite(ctx, boys, 'work', BOYS);
|
||||
sprite(ctx, sheet, andreaPose(c), ANDREA);
|
||||
// I cerchi nell'acqua: al tuffo del galleggiante e quando il pesce abbocca.
|
||||
for (const r of c.ripples) {
|
||||
ctx.strokeStyle = `rgba(220,240,235,${0.6 * (1 - r.age / 1.6)})`; ctx.lineWidth = 1;
|
||||
for (const k of [0, 0.4]) { const a = r.age - k; if (a < 0) continue; ctx.beginPath(); ctx.ellipse(r.x, WATER, 4 + a * 18, 1.5 + a * 5, 0, 0, Math.PI * 2); ctx.stroke(); }
|
||||
}
|
||||
// A lancio finito (pesce in mano, lenza rotta o vuota) niente galleggiante: la lenza è fuori dall'acqua.
|
||||
const p = ['cast', 'wait', 'fight'].includes(c.step) ? floatAt(c) : null;
|
||||
if (p) {
|
||||
// La lenza, con un po' di pancia; tesa e tremante quando il pesce tira.
|
||||
const tip = rodTip(c), sag = c.step === 'fight' ? 2 - c.tension * 2 : c.step === 'wait' ? 10 : 4;
|
||||
// Trema già allo strattone, prima che la lenza si tenda: il tempo di mollare.
|
||||
const shake = c.step === 'fight' && (c.tension > 0.75 || c.danger > 0.55) ? Math.sin(c.t * 60) * 1.2 : 0;
|
||||
ctx.strokeStyle = c.step === 'fight' && c.tension > 0.85 ? '#ffb0a0' : 'rgba(240,240,230,0.85)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(tip.x, tip.y); ctx.quadraticCurveTo((tip.x + p.x) / 2, Math.max(tip.y, p.y) + sag + shake, p.x, p.y); ctx.stroke();
|
||||
fishShadow(ctx, c, p);
|
||||
// Il galleggiante: rosso sopra, bianco sotto; sott'acqua si vede appena.
|
||||
const under = Math.max(0, p.y - WATER);
|
||||
ctx.fillStyle = '#e2e2d8'; ctx.fillRect(Math.round(p.x) - 1, Math.round(p.y) - 1, 3, 4);
|
||||
ctx.fillStyle = '#d8402a'; ctx.fillRect(Math.round(p.x) - 1, Math.round(p.y) - 6, 3, 5);
|
||||
if (under > 0) { ctx.fillStyle = 'rgba(40,70,80,0.55)'; ctx.fillRect(Math.round(p.x) - 2, WATER, 5, Math.round(under) + 1); }
|
||||
}
|
||||
// La mira: dove cadrà il galleggiante, e il filo tratteggiato della parabola.
|
||||
if (c.step === 'aim' && c.casts < CASTS) {
|
||||
const x = distanceFor(c.power);
|
||||
ctx.strokeStyle = 'rgba(255,246,210,0.9)'; ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(ROD_TIP.x, ROD_TIP.y); ctx.quadraticCurveTo((ROD_TIP.x + x) / 2, ROD_TIP.y - 70, x, WATER); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.beginPath(); ctx.ellipse(x, WATER, 7, 2.5, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(x, WATER - 8); ctx.lineTo(x, WATER + 8); ctx.stroke();
|
||||
}
|
||||
panel(ctx, 182, 50, 440, 24);
|
||||
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
|
||||
// I lanci rimasti, il cestino dei pesci.
|
||||
panel(ctx, 18, 86, 196, 58);
|
||||
text(ctx, 'LANCI', 26, 98, 6, '#eed5a0');
|
||||
for (let n = 0; n < CASTS; n++) {
|
||||
const x = 70 + n * 12, y = 98;
|
||||
if (n < left(c)) { ctx.fillStyle = '#d8402a'; ctx.fillRect(x - 1, y - 4, 3, 4); ctx.fillStyle = '#e2e2d8'; ctx.fillRect(x - 1, y, 3, 3); }
|
||||
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 2, y - 3, 5, 6); }
|
||||
}
|
||||
text(ctx, 'CESTINO', 26, 116, 6, '#eed5a0');
|
||||
c.caught.forEach((f, i) => fishIcon(ctx, f, 80 + i * 22, 116));
|
||||
const fish = fishCount(c);
|
||||
text(ctx, `${fish} su ${c.skill.goal} per la gloria · ${c.weight} kg`, 26, 133, 6, fish >= c.skill.goal ? '#b9e5b3' : '#d7ccb4');
|
||||
// In basso a sinistra: la forza del lancio, o la tensione della lenza.
|
||||
if (c.step === 'aim' || c.holding) {
|
||||
panel(ctx, 18, 306, 176, 37);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 318, 7);
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5);
|
||||
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 331, Math.round(c.power * 150), 5);
|
||||
} else if (c.step === 'fight') {
|
||||
panel(ctx, 18, 306, 176, 37);
|
||||
text(ctx, `LENZA ${Math.round(c.tension * 100)}%`, 30, 318, 7, c.tension > 0.85 ? '#ffb0a0' : '#f4e3c2');
|
||||
text(ctx, `riva ${Math.round((1 - Math.min(1, c.fish.dist)) * 100)}%`, 182, 318, 6, '#d7ccb4', 'right');
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5);
|
||||
ctx.fillStyle = 'rgba(224,87,74,0.35)'; ctx.fillRect(30 + 127, 331, 23, 5); // la zona rossa: qui si molla
|
||||
ctx.fillStyle = c.tension > 0.85 ? '#e0574a' : c.tension > 0.65 ? '#e0b24a' : '#b9d392'; ctx.fillRect(30, 331, Math.round(c.tension * 150), 5);
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(30 + 127, 329, 1, 9);
|
||||
}
|
||||
if (c.step === 'result') {
|
||||
panel(ctx, 206, 91, 397, 78);
|
||||
text(ctx, c.result.winner === 'player' ? 'BELLA PESCATA!' : 'ACQUA CHETA', 404, 108, 9, '#efd49e', 'center');
|
||||
text(ctx, `${c.result.fish} pesci su ${CASTS} lanci, ${c.result.weight} kg`, 404, 130, 9, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||
text(ctx, c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto.` : `Per la gloria ne servivano ${c.result.goal}.`, 404, 148, 6, '#d6c7af', 'center');
|
||||
c.result.caught.forEach((f, i) => fishIcon(ctx, f, 404 - (c.result.caught.length - 1) * 11 + i * 22, 160));
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// La pesca al lago dell'Anatre coi ragazzi delle canne (idea di Andrea, 28 ottobre). Vista di lato, coordinate dello
|
||||
// schermo (640x360): Andrea sulla riva a sinistra, l'acqua a destra. Si tiene premuto per lanciare (più forza, più
|
||||
// lontano: e al largo i pesci sono più grossi); il galleggiante balla, ai tocchi finti non si abbocca, quando va sotto
|
||||
// davvero si ferra premendo in tempo e, senza lasciare, si recupera: quando il pesce strattona la lenza si tende, e
|
||||
// bisogna mollare un attimo. Sei lanci; la gloria dipende da quanti pesci si tirano a riva (LEVELS).
|
||||
// Rivista con la revisione di Codex del 28 ottobre (galleggiante che saltava, sesto lancio bloccato dal pannello,
|
||||
// invito a ferrare fuori tempo, lenza che non si spezzava mai, mira che si sbloccava da sola).
|
||||
export const BANK = 150, WATER = 250, NEAR = 190, FAR = 600, SHORE = NEAR - 20;
|
||||
export const CASTS = 6, SWING = 1.4, FLY = 0.8, REST = 1.4, WAIT_MAX = 45;
|
||||
export const LEVELS = {
|
||||
bona: { goal: 2, window: 1.0, pull: 0.5 },
|
||||
seria: { goal: 3, window: 0.62, pull: 0.78 },
|
||||
male: { goal: 4, window: 0.38, pull: 1.0 },
|
||||
};
|
||||
// I pesci del lago, dal più piccolo al più grosso: al largo si pescano i grossi. Lo stivale è la beffa della riva.
|
||||
export const FISHES = [
|
||||
{ kind: 'stivale', name: 'uno stivale', weight: 0.9, strength: 0.25, stamina: 0.3, far: [0, 0.25], gag: true },
|
||||
{ kind: 'lasca', name: 'una lasca', weight: 0.15, strength: 0.3, stamina: 0.5, far: [0, 0.55] },
|
||||
{ kind: 'persico', name: 'un persico sole', weight: 0.3, strength: 0.45, stamina: 0.7, far: [0.1, 0.7] },
|
||||
{ kind: 'cavedano', name: 'un cavedano', weight: 0.8, strength: 0.6, stamina: 0.9, far: [0.3, 0.9] },
|
||||
{ kind: 'tinca', name: 'una tinca', weight: 1.4, strength: 0.75, stamina: 1.1, far: [0.45, 1] },
|
||||
{ kind: 'carpa', name: 'una carpa', weight: 3.2, strength: 1, stamina: 1.4, far: [0.7, 1] },
|
||||
];
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||
|
||||
export function newPesca(seed = Date.now() >>> 0, def = {}) {
|
||||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||||
return { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], current: 'player', casts: 0, caught: [], weight: 0,
|
||||
power: 0.5, holding: false, holdTime: 0, reeling: false, float: null, fish: null, tension: 0, pull: 0, events: [], ripples: [], last: null };
|
||||
}
|
||||
export const left = c => Math.max(0, CASTS - c.casts);
|
||||
export const fishCount = c => c.caught.filter(f => !f.gag).length;
|
||||
// Tre domande diverse: si può lanciare, si può agire (ferrare, recuperare), si può regolare la distanza.
|
||||
export const canCast = c => c.step === 'aim' && !c.holding && c.casts < CASTS;
|
||||
export const canThrow = c => canCast(c) || ((c.step === 'wait' || c.step === 'fight') && !c.holding);
|
||||
export const canAdjust = c => c.step === 'aim' && !c.holding && c.casts < CASTS;
|
||||
export const aimFree = c => c.step !== 'aim';
|
||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||
export const distanceFor = power => NEAR + (FAR - NEAR) * clamp(power, 0, 1);
|
||||
export function setPower(c, value) {
|
||||
if (!canAdjust(c) || !Number.isFinite(value)) return false;
|
||||
c.power = clamp(value, 0, 1); return true;
|
||||
}
|
||||
// Un tocco sull'acqua (non sul cielo, non sulla riva) sceglie dove lanciare: la forza che ci vuole per arrivarci.
|
||||
export function aimScreen(c, [x, y]) {
|
||||
if (!canAdjust(c) || !Number.isFinite(x) || !Number.isFinite(y) || x < BANK + 10 || y < WATER - 40) return false;
|
||||
return setPower(c, (x - NEAR) / (FAR - NEAR));
|
||||
}
|
||||
export const nudge = (c, dir, fine = false) => setPower(c, c.power + dir * (fine ? 0.01 : 0.05));
|
||||
|
||||
function event(c, kind, strength = 0.5) { if (c.events.length < 12) c.events.push({ kind, power: clamp(strength, 0, 1) }); }
|
||||
// Quando arriva il pesce: da uno a tre tocchi finti, a ritmo diverso, poi quello vero. Al largo si aspetta un po' di
|
||||
// più; dopo un'abboccata mancata l'attesa è la metà.
|
||||
function schedule(c) {
|
||||
const f = c.float, far = (f.x - NEAR) / (FAR - NEAR);
|
||||
f.biteAt = f.t + (2.5 + random(c) * 4 + far * 2) * (f.missed ? 0.5 : 1);
|
||||
const n = 1 + Math.floor(random(c) * 3);
|
||||
f.nibbles = Array.from({ length: n }, (_, i) => ({ at: f.biteAt - (n - i) * (0.6 + random(c) * 0.9), for: 0.18 + random(c) * 0.2 }));
|
||||
f.bitten = false;
|
||||
}
|
||||
function pickFish(c, far) {
|
||||
const pool = FISHES.filter(f => far >= f.far[0] && far <= f.far[1] && (!f.gag || random(c) < 0.35));
|
||||
const f = pool[Math.floor(random(c) * pool.length)] ?? FISHES[1];
|
||||
const size = 0.8 + random(c) * 0.5; // non tutti i cavedani sono uguali
|
||||
return { ...f, weight: Math.round(f.weight * size * 100) / 100, stamina: f.stamina * size, dist: 1, energy: 1, burst: random(c) * 6,
|
||||
reel: 0.22 / (0.7 + f.stamina * size) }; // i piccoli vengono a riva in fretta, le carpe no
|
||||
}
|
||||
export function cast(c) {
|
||||
if (!canCast(c)) return false;
|
||||
c.float = { x: distanceFor(c.power), y: WATER, t: 0, total: 0, fly: 0, dip: 0, missed: 0, from: null };
|
||||
c.step = 'cast'; c.t = 0; c.casts++; c.fish = null; c.tension = 0; c.pull = 0; c.reeling = false; c.last = null;
|
||||
event(c, 'pesca-lancio', c.power);
|
||||
return true;
|
||||
}
|
||||
// Il tasto dei comandi: in attesa ferra; nel recupero alterna recupera e molla (col dito si tiene premuto da sé).
|
||||
export function shoot(c) {
|
||||
if (c.step === 'aim') return cast(c);
|
||||
if (c.step === 'wait') return strike(c);
|
||||
if (c.step === 'fight') { c.reeling = !c.reeling; return true; }
|
||||
return false;
|
||||
}
|
||||
// Un gesto solo: premendo si ferra e, se il pesce c'è, si comincia subito a recuperare; lasciando si molla.
|
||||
export function press(c) {
|
||||
if (c.step === 'aim') { if (!canCast(c)) return false; c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true; }
|
||||
if (c.step === 'wait') { strike(c); if (c.step === 'fight') c.reeling = true; return true; }
|
||||
if (c.step === 'fight') { c.reeling = true; return true; }
|
||||
return false;
|
||||
}
|
||||
export function cancel(c) {
|
||||
if (c.holding) { c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true; }
|
||||
if (c.reeling) { c.reeling = false; return true; }
|
||||
return false;
|
||||
}
|
||||
export function release(c) {
|
||||
if (c.holding) {
|
||||
if (c.holdTime < 0.14) return cancel(c); // un tocco breve non spreca il lancio
|
||||
c.holding = false; return cast(c);
|
||||
}
|
||||
if (c.reeling) { c.reeling = false; return true; }
|
||||
return false;
|
||||
}
|
||||
export const biting = c => c.step === 'wait' && c.float?.bitten && c.float.t - c.float.biteAt <= c.skill.window;
|
||||
// Ferrare: dentro la finestra il pesce è preso; prima, o dopo, si recupera la lenza vuota (lancio sprecato).
|
||||
export function strike(c) {
|
||||
const f = c.float;
|
||||
if (c.step !== 'wait' || !f) return false;
|
||||
if (biting(c)) {
|
||||
c.fish = pickFish(c, (f.x - NEAR) / (FAR - NEAR));
|
||||
f.from = f.x;
|
||||
c.step = 'fight'; c.t = 0; c.tension = 0.3; c.reeling = false; c.danger = 0;
|
||||
event(c, 'pesca-ferrata', 0.8);
|
||||
} else {
|
||||
const late = f.bitten && f.t > f.biteAt;
|
||||
const touched = f.nibbles.some(n => f.t >= n.at);
|
||||
c.last = { kind: late ? 'late' : touched ? 'nibble' : 'early',
|
||||
text: late ? 'Troppo tardi: l\'ha già sputato.' : touched ? 'Era solo un tocco: a tirare così l\'ho spaventato.' : 'Ho tirato a vuoto: non aveva ancora abboccato.' };
|
||||
event(c, 'pesca-vuoto', 0.4);
|
||||
end(c, 'empty');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function end(c, how) {
|
||||
if (how === 'landed') {
|
||||
c.caught.push(c.fish);
|
||||
if (!c.fish.gag) c.weight = Math.round((c.weight + c.fish.weight) * 100) / 100;
|
||||
event(c, 'pesca-preso', 0.9);
|
||||
}
|
||||
if (how === 'broken') event(c, 'pesca-rottura', 1);
|
||||
if (how === 'free') event(c, 'pesca-vuoto', 0.6);
|
||||
c.step = 'settle'; c.t = 0; c.how = how; c.reeling = false; c.pull = 0;
|
||||
// L'ultimo lancio decide subito il risultato: uscire durante la posa non è abbandonare.
|
||||
if (c.casts >= CASTS) finish(c);
|
||||
}
|
||||
function finish(c) {
|
||||
c.result = { winner: fishCount(c) >= c.skill.goal ? 'player' : 'cpu', fish: fishCount(c), weight: c.weight, goal: c.skill.goal, caught: c.caught };
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
const f = c.float;
|
||||
if (c.step === 'cast') {
|
||||
f.fly = Math.min(1, c.t / FLY);
|
||||
if (c.t >= FLY) { c.step = 'wait'; f.t = 0; schedule(c); c.ripples.push({ x: f.x, age: 0 }); event(c, 'pesca-tuffo', 0.4); }
|
||||
} else if (c.step === 'wait') {
|
||||
f.t += dt; f.total += dt;
|
||||
const nibbling = f.nibbles.some(n => f.t >= n.at && f.t < n.at + n.for);
|
||||
if (!f.bitten && f.t >= f.biteAt) { f.bitten = true; event(c, 'pesca-abbocca', 0.6); c.ripples.push({ x: f.x, age: 0 }); }
|
||||
const since = f.bitten ? f.t - f.biteAt : -1;
|
||||
if (f.bitten && since > c.skill.window + 0.4) {
|
||||
// Non ferrato in tempo: il pesce se ne va, si riprova, ma non all'infinito.
|
||||
f.missed++; f.t = 0; schedule(c);
|
||||
if (f.missed >= 3) { c.last = { kind: 'gone', text: 'Tre abboccate e niente: qui non abbocca più nessuno.' }; end(c, 'empty'); }
|
||||
} else if (nibbling) { if (!f.nibbleSound) { event(c, 'pesca-tocco', 0.3); f.nibbleSound = true; } }
|
||||
else f.nibbleSound = false;
|
||||
// Sotto finché si può ferrare; poi riaffiora mentre il pesce lo sputa; i tocchi lo abbassano appena.
|
||||
f.dip = biting(c) ? 7 : f.bitten && since <= c.skill.window + 0.4 ? 3 : nibbling ? 2.5 : 0;
|
||||
if (f.total > WAIT_MAX) { c.last = { kind: 'gone', text: 'Niente. Qui i pesci dormono.' }; end(c, 'empty'); }
|
||||
} else if (c.step === 'fight') {
|
||||
const fish = c.fish, k = c.skill.pull;
|
||||
fish.burst += dt;
|
||||
// Gli strattoni: brevi, a picco; la lenza tesa li sente poco dopo, e c'è il tempo di mollare.
|
||||
const burst = 0.35 + 0.95 * Math.max(0, Math.sin(fish.burst * 2.1)) ** 3, peak = fish.strength * k * (0.3 + 0.7 * fish.energy);
|
||||
c.pull = peak * burst;
|
||||
c.danger = burst / 1.3; // quanto è forte lo strattone rispetto al massimo di questo pesce: la lenza trema prima
|
||||
const target = c.reeling ? 0.42 + c.pull * 1.1 : c.pull * 0.3;
|
||||
c.tension += (target - c.tension) * Math.min(1, dt * 6.5);
|
||||
if (c.reeling) {
|
||||
fish.dist -= dt * fish.reel * (1 - c.pull * 0.4); fish.energy = Math.max(0, fish.energy - dt * 0.09 / fish.stamina);
|
||||
// Il cigolio della lenza tesa, non a ogni fotogramma.
|
||||
c.reelSound = (c.reelSound ?? 0) - dt;
|
||||
if (c.tension > 0.6 && c.reelSound <= 0) { event(c, 'pesca-mulinello', c.tension); c.reelSound = 0.14; }
|
||||
} else { fish.dist += dt * (0.02 + 0.05 * c.pull); fish.energy = Math.max(0, fish.energy - dt * 0.03 / fish.stamina); }
|
||||
f.x = SHORE + fish.dist * (f.from - SHORE);
|
||||
f.dip = 5 + Math.sin(c.t * 7) * 2;
|
||||
if (c.tension >= 1) { c.last = { kind: 'broken', text: 'Crac! La lenza non ha retto.' }; end(c, 'broken'); }
|
||||
else if (fish.dist >= 1.35) { c.last = { kind: 'free', text: 'Ha preso troppo filo: si è slamato.' }; end(c, 'free'); }
|
||||
else if (fish.dist <= 0) {
|
||||
c.last = { kind: 'landed', text: fish.gag ? 'Uno stivale. Numero quarantatré. Di chi sarà?' : `${fish.name[0].toUpperCase()}${fish.name.slice(1)}, ${fish.weight} kg!` };
|
||||
end(c, 'landed');
|
||||
}
|
||||
} else if (c.step === 'settle' && c.t >= REST) {
|
||||
c.float = null; c.fish = null; c.tension = 0;
|
||||
if (c.casts >= CASTS) c.step = 'result'; else { c.step = 'aim'; c.t = 0; }
|
||||
}
|
||||
for (const r of c.ripples) r.age += dt;
|
||||
c.ripples = c.ripples.filter(r => r.age < 1.6);
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea' }) {
|
||||
const tally = `Pesci ${fishCount(c)} (ne servono ${c.skill.goal}), ${c.weight} kg.`;
|
||||
if (c.step === 'result') return `${c.result.fish} pesci su ${CASTS} lanci, ${c.result.weight} kg. ${c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`;
|
||||
if (c.step === 'cast') return 'Lancio…';
|
||||
if (c.step === 'wait') return biting(c) ? 'ABBOCCA! Ferra!' : c.float.bitten ? 'Lo sta sputando…' : c.float.dip > 0 ? 'Un tocco… aspetta.' : c.float.missed ? `Riprova: ${3 - c.float.missed} abboccate ancora.` : 'Il galleggiante è fermo. Si aspetta.';
|
||||
if (c.step === 'fight') return c.tension > 0.85 ? 'La lenza tira troppo: MOLLA!' : c.danger > 0.55 ? 'Strattona…' : c.reeling ? 'Recupera…' : 'Tieni premuto per recuperare.';
|
||||
if (c.step === 'settle') return c.last?.text ?? '';
|
||||
return `${names.player}, lancio ${c.casts + 1} di ${CASTS}. ${tally}`;
|
||||
}
|
||||
export const button = c => c.step === 'result' ? 'Finita' : c.step === 'wait' ? 'Ferra' : c.step === 'fight' ? (c.reeling ? 'Molla' : 'Recupera') : c.step === 'aim' ? 'Lancia' : '…';
|
||||
@@ -5,6 +5,8 @@ import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { drawBocce } from './bocce-render.js';
|
||||
import { drawBiliardo } from './biliardo-render.js';
|
||||
import { drawBasket } from './basket-render.js';
|
||||
import { drawPesca } from './pesca-render.js';
|
||||
import { drawBriscola } from './briscola-render.js';
|
||||
import { drawTressette } from './tressette-render.js';
|
||||
import { drawScopa } from './scopa-render.js';
|
||||
@@ -682,6 +684,16 @@ function drawCloseup(ctx, game, time) {
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
return;
|
||||
}
|
||||
if (def.type === 'pesca') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori'));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'basket') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
|
||||
return;
|
||||
}
|
||||
if (def.type === 'biliardo') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
|
||||
|
||||
@@ -118,6 +118,23 @@ export class Soundscape {
|
||||
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
|
||||
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
|
||||
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
|
||||
// Il basket: il tiro, la palla che batte per terra, il ferro, il tabellone, la retina.
|
||||
else if (kind === 'basket-throw') this.noiseSound(0.08, 1200, 0.05, { pan });
|
||||
else if (kind === 'basket-bounce') { this.tone(190, 95, 0.11, 0.05 + strength * 0.12, 0, pan); this.noiseSound(0.06, 500, 0.05, { pan }); }
|
||||
else if (kind === 'basket-rim') { this.tone(1500, 1100, 0.14, 0.04 + strength * 0.1, 0, pan); this.tone(2300, 1900, 0.08, 0.03, 0, pan); }
|
||||
else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); }
|
||||
else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan });
|
||||
else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); }
|
||||
// La pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva.
|
||||
else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan });
|
||||
else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
|
||||
else if (kind === 'pesca-tocco') this.tone(900, 700, 0.04, 0.03, 0, pan);
|
||||
else if (kind === 'pesca-abbocca') { this.tone(520, 260, 0.1, 0.08, 0, pan); this.noiseSound(0.1, 1100, 0.06, { pan }); }
|
||||
else if (kind === 'pesca-ferrata') this.noiseSound(0.1, 2600, 0.08, { pan });
|
||||
else if (kind === 'pesca-mulinello') this.tone(1800 + strength * 900, 1700 + strength * 900, 0.05, 0.015 + strength * 0.02, 0, pan);
|
||||
else if (kind === 'pesca-rottura') { this.tone(2400, 300, 0.12, 0.1, 0, pan); this.noiseSound(0.08, 3000, 0.06, { pan }); }
|
||||
else if (kind === 'pesca-vuoto') this.noiseSound(0.14, 1400, 0.05, { pan });
|
||||
else if (kind === 'pesca-preso') { this.noiseSound(0.25, 1000, 0.1, { pan }); this.tone(660, 990, 0.18, 0.05, 0.1, pan); }
|
||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
||||
else if (kind === 'tavolo-lontano') {
|
||||
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
|
||||
|
||||
+15
-8
@@ -10,6 +10,8 @@ import { Soundscape } from './engine/soundscape.js';
|
||||
import { soundscapeFor } from './story/soundscapes.js';
|
||||
import * as bocce from './engine/bocce.js';
|
||||
import * as biliardo from './engine/biliardo.js';
|
||||
import * as basket from './engine/basket.js';
|
||||
import * as pesca from './engine/pesca.js';
|
||||
import * as briscola from './engine/briscola.js';
|
||||
import { cardAt } from './engine/briscola-render.js';
|
||||
import * as tressette from './engine/tressette.js';
|
||||
@@ -496,11 +498,11 @@ canvas.addEventListener('pointerdown', e => {
|
||||
return;
|
||||
}
|
||||
const aimed = game.closeupAim(scenePoint(e));
|
||||
if (story.closeups[game.closeup.id].type === 'biliardo') {
|
||||
if (!aimed && !game.speech) return;
|
||||
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
|
||||
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
|
||||
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
|
||||
}
|
||||
if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true;
|
||||
if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true;
|
||||
press = { closeup: true };
|
||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||
if (!game.skip()) game.closeupPress();
|
||||
@@ -550,7 +552,7 @@ canvas.addEventListener('pointermove', e => {
|
||||
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
|
||||
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
|
||||
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
|
||||
const aiming = game.closeup && ['biliardo', 'bocce'].includes(story.closeups[game.closeup.id].type);
|
||||
const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
|
||||
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
|
||||
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
|
||||
});
|
||||
@@ -563,6 +565,8 @@ const MINI = {
|
||||
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
|
||||
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
|
||||
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
|
||||
};
|
||||
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
|
||||
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||
@@ -904,17 +908,20 @@ function updateUi() {
|
||||
}
|
||||
}
|
||||
if (mg && !cards) {
|
||||
$('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce');
|
||||
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca' }[def.type] ?? 'Comandi delle bocce');
|
||||
$('panel').hidden = $('choices').hidden = true;
|
||||
const line = mg.status(c, def.names);
|
||||
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||
$('bocce-power').value = Math.round(c.power * 100);
|
||||
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
|
||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !mg.canThrow(c) || c.holding || !!game.speech;
|
||||
$('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira';
|
||||
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || c.holding || !!game.speech;
|
||||
$('bocce-throw').disabled = !mg.canThrow(c) || c.holding || !!game.speech;
|
||||
$('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira');
|
||||
$('bocce-help').hidden = def.type !== 'bocce';
|
||||
$('biliardo-help').hidden = def.type !== 'biliardo';
|
||||
if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
|
||||
$('basket-help').hidden = def.type !== 'basket';
|
||||
$('pesca-help').hidden = def.type !== 'pesca';
|
||||
if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
|
||||
}
|
||||
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
|
||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||
|
||||
@@ -68,4 +68,62 @@ export const ambience = {
|
||||
viale_cipressi: [{ type: 'cloudshadow', phase: day, area: [95, 225, 435, 135], offset: 11000,
|
||||
clip: [[300, 225], [337, 225], [530, 360], [95, 360]] }],
|
||||
trebbio: [shade([278, 277, 112, 23], 6)],
|
||||
// Le scene del 27 ottobre (Andrea: «applicale tutte»).
|
||||
via_pignone: [
|
||||
{ type: 'clouds', area: [100, 0, 440, 110], count: 3, big: true },
|
||||
{ type: 'birds', phase: day, area: [200, 20, 300, 80], count: 3, every: 23000, duration: 5000, offset: 6000 },
|
||||
shade([330, 282, 180, 50], 10),
|
||||
{ type: 'lamps', phase: 'sera', points: [[250, 74]], glow: 10 },
|
||||
],
|
||||
piazzetta: [
|
||||
shade([30, 290, 300, 60], 14),
|
||||
butterfly([150, 170, 150, 60], 5000),
|
||||
{ type: 'lamps', phase: 'sera', points: [[616, 118]], glow: 12 },
|
||||
],
|
||||
provinciale_pignone: [
|
||||
shade([80, 270, 220, 70], 14), shade([470, 290, 170, 60], 10),
|
||||
{ type: 'cloudshadow', phase: day, area: [120, 230, 420, 130], offset: 8000,
|
||||
clip: [[290, 232], [370, 232], [640, 330], [640, 360], [0, 360], [0, 330]] },
|
||||
bird([410, 258, 70, 16], 9000),
|
||||
],
|
||||
gabbiano: [
|
||||
{ type: 'clouds', area: [140, 0, 500, 140], count: 3, big: true },
|
||||
{ type: 'birds', phase: day, area: [200, 40, 400, 100], count: 4, every: 21000, duration: 5500, offset: 3000 },
|
||||
{ type: 'cloudshadow', phase: day, area: [140, 160, 500, 200], offset: 7000, clip: rect([140, 160, 500, 200]) },
|
||||
{ type: 'sway', rects: [[430, 128, 70, 60]], amount: 2 },
|
||||
butterfly([120, 250, 200, 60], 3000),
|
||||
],
|
||||
ponticino_mozzete: [
|
||||
shade([0, 230, 640, 110], 22),
|
||||
{ type: 'insect', kind: 'dragonfly', phase: day, area: [80, 230, 140, 50], every: 37000, duration: 8500, offset: 7000 },
|
||||
],
|
||||
via_cipriani: [
|
||||
{ type: 'clouds', area: [0, 0, 420, 150], count: 3, big: true },
|
||||
shade([300, 300, 160, 50], 10),
|
||||
// Di sera le lampade del cinema e il cartellone del Rattattù illuminato.
|
||||
{ type: 'lamps', phase: 'sera', points: [[545, 214], [620, 168]], glow: 12 },
|
||||
{ type: 'lamps', phase: 'sera', points: [[490, 200]], glow: 34 },
|
||||
],
|
||||
via_massorondinaio: [
|
||||
{ type: 'clouds', area: [0, 0, 640, 100], count: 3, big: true },
|
||||
{ type: 'birds', phase: day, area: [100, 30, 400, 100], count: 3, every: 25000, duration: 5000, offset: 11000 },
|
||||
{ type: 'sway', rects: [[200, 128, 90, 72]], amount: 2 },
|
||||
],
|
||||
coldaia: [
|
||||
{ type: 'clouds', area: [200, 0, 440, 120], count: 2 },
|
||||
{ type: 'cloudshadow', phase: day, area: [0, 150, 640, 210], offset: 15000, clip: rect([0, 160, 640, 200]) },
|
||||
{ type: 'birds', phase: day, area: [250, 20, 350, 90], count: 3, every: 27000, duration: 5500, offset: 2000 },
|
||||
butterfly([60, 280, 200, 50], 12000),
|
||||
],
|
||||
falegnameria_coldaia: [
|
||||
{ type: 'clouds', area: [0, 0, 640, 110], count: 3, big: true },
|
||||
{ type: 'dust', phase: day, area: [330, 150, 260, 70], count: 10 },
|
||||
bird([250, 230, 90, 18], 8000, 'blackbird'),
|
||||
],
|
||||
mozzete: [
|
||||
{ type: 'clouds', area: [100, 0, 540, 110], count: 3, big: true },
|
||||
shade([0, 215, 230, 60], 12),
|
||||
{ type: 'cloudshadow', phase: day, area: [60, 220, 520, 140], offset: 23000,
|
||||
clip: [[0, 215], [400, 195], [640, 260], [640, 360], [0, 360]] },
|
||||
],
|
||||
};
|
||||
|
||||
+80
-5
@@ -150,6 +150,40 @@ export const story = {
|
||||
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
|
||||
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }],
|
||||
},
|
||||
// «Fai due tiri» al campo da basket del Circolo 4 Maggio (idea di Andrea, 27 ottobre): da solo, dieci palloni,
|
||||
// si arretra a ogni canestro. La gloria dipende dal livello (basket.LEVELS). [DA ANDREA: le sue battute]
|
||||
basket: {
|
||||
type: 'basket',
|
||||
picture: 'assets/closeups/basket-campo.png',
|
||||
names: { player: 'Andrea', cpu: 'il canestro' },
|
||||
win: [
|
||||
{ say: 'Che mano! Jordan, fatti da parte.' },
|
||||
{ set: 'basket_vinto' },
|
||||
],
|
||||
lose: [
|
||||
{ say: 'Il ferro oggi era stretto. Riprovo dopo.' },
|
||||
],
|
||||
quit: [{ say: 'Lascio il pallone lì. Tanto lo sanno tutti che è mio.' }],
|
||||
draw: [],
|
||||
},
|
||||
// La pesca al lago dell'Anatre coi ragazzi delle canne (idea di Andrea, 28 ottobre): sei lanci, da solo con la
|
||||
// canna di bambù; la gloria dipende dal livello (pesca.LEVELS). [DA ANDREA: le sue battute]
|
||||
pesca: {
|
||||
type: 'pesca',
|
||||
picture: 'assets/closeups/lago-anatre.png',
|
||||
names: { player: 'Andrea', cpu: 'il lago' },
|
||||
win: [
|
||||
{ say: 'Oh, hai visto? Mica male, per una canna di bambù.', who: 'pescatori', pose: 'laugh' },
|
||||
{ say: 'Stasera si frigge. Ma prima il palio.' },
|
||||
{ set: 'pesca_vinta' },
|
||||
],
|
||||
lose: [
|
||||
{ say: 'I pesci c\'erano. Eri te che non c\'eri.', who: 'pescatori', pose: 'talk' },
|
||||
{ say: 'Si rifà. Dopo il palio.' },
|
||||
],
|
||||
quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }],
|
||||
draw: [],
|
||||
},
|
||||
// Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute]
|
||||
biliardo: {
|
||||
type: 'biliardo',
|
||||
@@ -215,6 +249,9 @@ export const story = {
|
||||
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
|
||||
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' },
|
||||
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
|
||||
// Andrea col pallone, per «Fai due tiri» (foglio di Codex dal suo sprite): non parla, lo disegna basket-render.
|
||||
andrea_basket: { name: 'Andrea', sprite: 'assets/characters/andrea-basket.json' },
|
||||
andrea_pesca: { name: 'Andrea', sprite: 'assets/characters/andrea-pesca.json' },
|
||||
// Quattro ragazzi inventati (sprite di Codex, idea di Andrea del 27 ottobre): nessuna persona reale.
|
||||
pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' },
|
||||
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
|
||||
@@ -511,12 +548,13 @@ export const story = {
|
||||
id: 'pescatori', name: 'ragazzi con le canne', box: [92, 236, 120, 86], at: [240, 334], face: 'left', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Quattro ragazzi che affilano canne di bambù col coltellino e ci legano le lenze.' }],
|
||||
talk: [
|
||||
talk: [{ if: '!pescatori_parlato', then: [
|
||||
{ set: 'pescatori_parlato' },
|
||||
{ say: 'Oh, che fate?' },
|
||||
{ say: 'Si preparano le canne: domattina si va a pescare al lago dell\'Anatre, dietro Cardetole!', who: 'pescatori', pose: 'talk' },
|
||||
{ say: 'Si preparano le canne: si va a pescare al lago dell\'Anatre, dietro Cardetole!', who: 'pescatori', pose: 'talk' },
|
||||
{ say: 'Guarda questa: la lenza la regge anche una carpa di dieci chili.', who: 'pescatori', pose: 'proud' },
|
||||
{ say: '(Vedi, mia mamma aveva ragione: in Fortezza si viene a farsi le canne.)' },
|
||||
],
|
||||
] }, { dialogue: 'pescatori' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -897,9 +935,11 @@ export const story = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'campo_basket', name: 'campo da basket del Circolo', box: [250, 110, 170, 80], at: [340, 316], face: 'up',
|
||||
id: 'campo_basket', name: 'campo da basket del Circolo', box: [250, 110, 170, 80], at: [340, 316], face: 'up', default: 'use',
|
||||
actions: {
|
||||
look: [{ say: 'Il campo da basket del Circolo, lassù oltre la rete. Prima o poi due tiri ce li faccio.' }],
|
||||
look: [{ say: 'Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.' }],
|
||||
use: [{ dialogue: 'basket' }],
|
||||
walk: [{ dialogue: 'basket' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2675,6 +2715,41 @@ export const story = {
|
||||
},
|
||||
},
|
||||
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
|
||||
// I ragazzi delle canne: «S'ha andà a fallo?!?» (Andrea, 28 ottobre) e si va a pescare.
|
||||
pescatori: {
|
||||
nodes: {
|
||||
start: {
|
||||
options: [
|
||||
{ text: 'S\'ha andà a fallo?!?', next: 'livello', then: [
|
||||
{ say: 'Gnamo! La canna ti si dà noi.', who: 'pescatori', pose: 'laugh' },
|
||||
{ say: 'Come peschi?', who: 'pescatori', pose: 'talk' },
|
||||
] },
|
||||
{ text: 'Quanti ne prendete, di solito?', then: [{ say: 'Dipende da chi racconta. Il Nanni dice dieci, il cestino dice due.', who: 'pescatori', pose: 'laugh' }] },
|
||||
{ text: 'Buona pesca, ragazzi.', next: 'end', then: [{ say: 'Se vedi il palio, salutacelo!', who: 'pescatori', pose: 'talk' }] },
|
||||
],
|
||||
},
|
||||
livello: {
|
||||
options: [
|
||||
{ text: 'Alla bona, tanto per bagnare la lenza.', next: 'end', then: [{ say: 'Alla bona. Due pesci e si va a casa contenti.', who: 'pescatori' }, { closeup: 'pesca', level: 'bona' }] },
|
||||
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria: tre pesci, e lo stivale non conta.', who: 'pescatori', pose: 'proud' }, { closeup: 'pesca', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Quattro pesci, e le carpe tirano come trattori.', who: 'pescatori', pose: 'laugh' }, { closeup: 'pesca', level: 'male' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Da solo al campo del Circolo: Andrea decide quanto farsi male.
|
||||
basket: {
|
||||
nodes: {
|
||||
start: {
|
||||
options: [
|
||||
{ text: 'Due tiri alla bona, tanto per scaldarmi.', next: 'end', then: [{ say: 'Tre su dieci e sono contento.' }, { closeup: 'basket', level: 'bona' }] },
|
||||
{ text: 'Due tiri seri.', next: 'end', then: [{ say: 'Cinque su dieci. Come ai tempi del Circolino.' }, { closeup: 'basket', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Sette su dieci, senza guardare la parabola. Vediamo chi sono.' }, { closeup: 'basket', level: 'male' }] },
|
||||
{ text: 'Lascia stare, ho il palio da trovare.', next: 'end' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
mariano: {
|
||||
nodes: {
|
||||
start: {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.20';
|
||||
export const VERSION = '0.1.24';
|
||||
|
||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||
|
||||
@@ -53,16 +53,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
|
||||
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
||||
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
||||
#bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||
#bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; }
|
||||
#bocce-help p, #biliardo-help p { max-width: 76em; line-height: 1.7; }
|
||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||
#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary { cursor: pointer; padding: 6px 0; }
|
||||
#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p { max-width: 76em; line-height: 1.7; }
|
||||
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
||||
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||
#bocce-throw { flex-basis: 100%; }
|
||||
#bocce-help, #biliardo-help { font-size: 9px; }
|
||||
#bocce-help, #biliardo-help, #basket-help, #pesca-help { font-size: 9px; }
|
||||
}
|
||||
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
||||
#effects-volume { width: 130px; accent-color: var(--accent); }
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newBasket, press, release, cancel, update, shoot, setAngle, setPower, aimScreen, nudge, arc, guide, status, canThrow, releasePoint,
|
||||
powerAt, SWING, BALLS, SPOTS, LEVELS, RIM, BOARD, POLE, FLOOR, BALL, ANGLE } from '../src/engine/basket.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
const fly = c => { for (let i = 0; i < 2000 && c.step === 'flight'; i++) update(c, 1 / 120); return c.ball; };
|
||||
const rest = c => { for (let i = 0; i < 200 && c.step === 'settle'; i++) update(c, 1 / 60); };
|
||||
// Cerca un tiro che entra dalla postazione di adesso: la mano ferma dei test.
|
||||
function goodShot(c) {
|
||||
for (let angle = 40; angle <= 70; angle += 1) for (let power = 0.2; power <= 1; power += 0.02) {
|
||||
const probe = structuredClone(c);
|
||||
setAngle(probe, angle); setPower(probe, power); shoot(probe);
|
||||
if (fly(probe).scored) return { angle, power };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('la forza sale finché si tiene premuto; un tocco breve non spreca il pallone', () => {
|
||||
const c = newBasket(1);
|
||||
assert.equal(powerAt(SWING / 2), 0.5); assert.equal(powerAt(SWING * 2), 1);
|
||||
assert.ok(press(c)); update(c, 0.05); assert.ok(release(c)); // troppo breve: annullato
|
||||
assert.equal(c.thrown, 0); assert.equal(c.power, 0.5, 'la forza torna com\'era');
|
||||
assert.ok(press(c)); update(c, 0.7); assert.ok(release(c));
|
||||
assert.equal(c.thrown, 1); assert.equal(c.step, 'flight');
|
||||
assert.ok(Math.abs(c.ball.vx) > 0 && c.ball.vy < 0, 'la palla parte in alto verso il canestro');
|
||||
assert.ok(!press(c), 'in volo non si carica');
|
||||
assert.ok(!cancel(c));
|
||||
});
|
||||
|
||||
test('la mira: l\'angolo segue il punto toccato, resta fra i limiti, non si tira all\'indietro', () => {
|
||||
const c = newBasket(2), r = releasePoint(c);
|
||||
assert.ok(aimScreen(c, [r.x + 100, r.y - 100])); assert.equal(Math.round(c.angle), 45);
|
||||
assert.ok(aimScreen(c, [r.x + 10, r.y - 400])); assert.equal(c.angle, ANGLE.max, 'non oltre il massimo');
|
||||
assert.ok(aimScreen(c, [r.x + 300, r.y])); assert.equal(c.angle, ANGLE.min, 'non sotto il minimo');
|
||||
assert.ok(!aimScreen(c, [r.x - 50, r.y - 50]), 'dietro di lui niente');
|
||||
assert.ok(nudge(c, 1)); assert.equal(c.angle, ANGLE.min + 2);
|
||||
assert.ok(nudge(c, 1, true)); assert.equal(c.angle, ANGLE.min + 2.5);
|
||||
press(c); assert.ok(!aimScreen(c, [r.x + 100, r.y - 100]), 'mentre si carica la mira è ferma');
|
||||
assert.ok(!setPower(c, 0.3));
|
||||
});
|
||||
|
||||
test('la parabola della guida comincia dalle mani e scende per la gravità', () => {
|
||||
const c = newBasket(3), r = releasePoint(c), pts = arc(c, 1.5);
|
||||
assert.deepEqual(pts[0], r);
|
||||
assert.ok(pts[5].y < r.y, 'prima sale');
|
||||
assert.ok(pts.at(-1).y > pts[5].y, 'poi scende');
|
||||
});
|
||||
|
||||
test('da ogni postazione esiste un tiro che entra, e un tiro entrato conta canestro e punti', () => {
|
||||
for (const [i, s] of SPOTS.entries()) {
|
||||
const c = newBasket(10 + i); c.spot = i;
|
||||
const shot = goodShot(c);
|
||||
assert.ok(shot, `nessun tiro entra ${s.name}`);
|
||||
setAngle(c, shot.angle); setPower(c, shot.power); shoot(c);
|
||||
const b = fly(c);
|
||||
assert.ok(b.scored && b.dead);
|
||||
assert.ok(c.events.some(e => e.kind === 'basket-swish' || e.kind === 'basket-in'), 'si sente la retina');
|
||||
rest(c);
|
||||
assert.equal(c.baskets, 1); assert.equal(c.points, s.points);
|
||||
assert.equal(c.spot, Math.min(i + 1, SPOTS.length - 1), 'dopo un canestro si arretra');
|
||||
assert.equal(c.step, 'aim');
|
||||
}
|
||||
});
|
||||
|
||||
test('con l\'angolo iniziale, da ogni postazione entra in una fascia di forza contigua di almeno l\'8%', () => {
|
||||
for (const [i, s] of SPOTS.entries()) {
|
||||
const made = [];
|
||||
for (let p = 0; p <= 1.0001; p += 0.005) {
|
||||
const c = newBasket(1); c.spot = i; setPower(c, p); shoot(c);
|
||||
made.push(fly(c).scored);
|
||||
}
|
||||
let best = 0, run = 0;
|
||||
for (const v of made) { run = v ? run + 1 : 0; best = Math.max(best, run); }
|
||||
assert.ok(best >= 16, `${s.name}: la fascia migliore è ${(best - 1) * 0.5}%`);
|
||||
}
|
||||
});
|
||||
|
||||
test('il palo ferma solo dalla cima in giù; il tabellone rimanda anche da dietro; il canestro conta subito', () => {
|
||||
const c = newBasket(11); setAngle(c, 78); setPower(c, 1); shoot(c);
|
||||
let b = fly(c);
|
||||
assert.ok(!c.events.some(e => e.kind === 'basket-swish'), 'sopra il palo la palla vola via: non è un tiro pulito');
|
||||
const d = newBasket(12);
|
||||
d.ball = { x: POLE.x - 30, y: FLOOR - 30, vx: 300, vy: 0, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
|
||||
d.step = 'flight'; for (let i = 0; i < 30; i++) update(d, 1 / 120);
|
||||
assert.ok(d.ball.board && d.ball.vx < 0, 'il palo la rimanda indietro come un tabellone');
|
||||
const e = newBasket(13);
|
||||
e.ball = { x: BOARD.x + BOARD.depth + 30, y: (BOARD.top + BOARD.bottom) / 2, vx: -300, vy: 0, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
|
||||
e.step = 'flight'; for (let i = 0; i < 30; i++) update(e, 1 / 120);
|
||||
assert.ok(e.ball.board && e.ball.x > BOARD.x, 'da dietro non lo attraversa');
|
||||
const f = newBasket(14); f.thrown = BALLS - 1; f.skill = LEVELS.bona; f.baskets = 2;
|
||||
const shot = goodShot(f); setAngle(f, shot.angle); setPower(f, shot.power); shoot(f);
|
||||
for (let i = 0; i < 2000 && !f.ball.scored; i++) update(f, 1 / 120);
|
||||
assert.equal(f.baskets, 3, 'il canestro conta appena entra');
|
||||
assert.ok(typeof f.ball.scoredAt === 'number');
|
||||
assert.equal(f.result?.winner, 'player', 'all\'ultimo pallone il risultato c\'è già mentre la palla scende');
|
||||
fly(f); rest(f);
|
||||
assert.equal(f.step, 'result'); assert.equal(f.result.baskets, 3);
|
||||
});
|
||||
|
||||
test('la guida si ferma al primo urto', () => {
|
||||
const c = newBasket(15); c.spot = 1; setAngle(c, 30); setPower(c, 1);
|
||||
const g = guide(c, 3), last = g.at(-1);
|
||||
assert.ok(g.length < arc(c, 3).length, 'non arriva in fondo');
|
||||
assert.ok(last.x + BALL > BOARD.x - 2 || last.y + BALL > FLOOR - 2 || Math.hypot(last.x - RIM.x1, last.y - RIM.y) < BALL + 3, 'si ferma su tabellone, campo o ferro');
|
||||
});
|
||||
|
||||
test('un tiro corto batte sul campo e rimbalza; uno lungo prende il tabellone e torna indietro', () => {
|
||||
const c = newBasket(4); c.spot = 3; // da tre, piano: non arriva nemmeno al palo
|
||||
setAngle(c, 45); setPower(c, 0); shoot(c);
|
||||
let b = fly(c);
|
||||
assert.ok(!b.scored && b.bounces > 0, 'rimbalza per terra');
|
||||
assert.ok(c.events.some(e => e.kind === 'basket-bounce'));
|
||||
assert.ok(b.y + BALL <= FLOOR + 0.01, 'non sprofonda');
|
||||
const d = newBasket(5); d.spot = 1;
|
||||
setAngle(d, 34); setPower(d, 1); shoot(d);
|
||||
b = fly(d);
|
||||
assert.ok(b.board, 'ha preso il tabellone');
|
||||
assert.ok(c.events.every(e => e.kind !== 'basket-board') && d.events.some(e => e.kind === 'basket-board'));
|
||||
assert.ok(b.x < BOARD.x, 'non lo attraversa');
|
||||
});
|
||||
|
||||
test('la palla rimbalza sul ferro, e da sotto non è canestro', () => {
|
||||
const c = newBasket(6);
|
||||
c.ball = { x: RIM.x1 - 2, y: RIM.y - 60, vx: 0, vy: 150, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
|
||||
c.step = 'flight';
|
||||
for (let i = 0; i < 60; i++) update(c, 1 / 120);
|
||||
assert.ok(c.ball.rim, 'il davanti del ferro l\'ha toccata');
|
||||
assert.ok(c.events.some(e => e.kind === 'basket-rim'));
|
||||
const d = newBasket(7);
|
||||
d.ball = { x: (RIM.x1 + RIM.x2) / 2, y: RIM.y + 20, vx: 0, vy: -200, time: 0, bounces: 0, rim: false, board: false, under: false, scored: false, dead: false, spin: 0 };
|
||||
d.step = 'flight';
|
||||
fly(d);
|
||||
assert.ok(d.ball.under && !d.ball.scored, 'sale attraverso l\'anello e ricade: non vale');
|
||||
});
|
||||
|
||||
test('dieci palloni, poi il risultato: la gloria dipende dal livello', () => {
|
||||
for (const [level, { goal }] of Object.entries(LEVELS)) {
|
||||
const c = newBasket(8, { level });
|
||||
let made = 0;
|
||||
for (let i = 0; i < BALLS; i++) {
|
||||
assert.ok(canThrow(c));
|
||||
const shot = made < goal ? goodShot(c) : { angle: 45, power: 0 }; // quanto basta per la gloria, poi a vuoto
|
||||
setAngle(c, shot.angle); setPower(c, shot.power); shoot(c);
|
||||
if (fly(c).scored) made++;
|
||||
rest(c);
|
||||
}
|
||||
assert.equal(c.step, 'result'); assert.equal(c.thrown, BALLS); assert.ok(!canThrow(c));
|
||||
assert.equal(c.result.baskets, goal); assert.equal(c.result.winner, 'player', level);
|
||||
assert.match(status(c), /canestri su 10/);
|
||||
assert.ok(!shoot(c), 'finita');
|
||||
}
|
||||
const miss = newBasket(9, { level: 'bona' });
|
||||
for (let i = 0; i < BALLS; i++) { setAngle(miss, 45); setPower(miss, 0); shoot(miss); fly(miss); rest(miss); }
|
||||
assert.equal(miss.result.winner, 'cpu'); assert.equal(miss.spot, 0, 'senza canestri non ci si muove');
|
||||
});
|
||||
|
||||
test('nella storia: dal campo di Via Cipriani si sceglie il livello e parte il gioco; la gloria lascia il flag', () => {
|
||||
const g = new Game(story);
|
||||
g.go('via_cipriani', [340, 316]); settle(g);
|
||||
g.act('use', 'campo_basket'); settle(g);
|
||||
assert.ok(g.dialogue, 'si decide come tirare');
|
||||
g.choose(g.options().findIndex(o => o.text === 'Due tiri seri.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'basket'); assert.equal(g.closeup.level, 'seria');
|
||||
g.closeup.result = { winner: 'player', baskets: 6, points: 13, goal: 5 }; g.closeup.step = 'result';
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.basket_vinto, 'la gloria resta');
|
||||
assert.ok(!g.closeup);
|
||||
});
|
||||
@@ -449,6 +449,72 @@ try {
|
||||
await bocce.close();
|
||||
}
|
||||
|
||||
// «Fai due tiri» al campo del Circolo: il mouse mira, tenendo premuto la forza sale, lasciando la palla vola; Esc esce.
|
||||
{
|
||||
const k = await open({ width: 1280, height: 860 });
|
||||
await k.click('#splash');
|
||||
await k.click('#new-game');
|
||||
await k.click('#rione [data-rione="campomigliaio"]');
|
||||
await k.click('#skip-intro');
|
||||
await settle(k);
|
||||
await k.evaluate(() => game.go('via_cipriani', [340, 316]));
|
||||
await settle(k);
|
||||
await k.evaluate(() => game.act('use', 'campo_basket'));
|
||||
await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
|
||||
await k.locator('#choices li', { hasText: 'Due tiri seri.' }).click();
|
||||
await k.waitForFunction(() => game.closeup?.id === 'basket' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||||
const r = await k.locator('#scene').boundingBox();
|
||||
const at = (x, y) => [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
|
||||
await k.mouse.move(...at(560, 60));
|
||||
const high = await k.evaluate(() => game.closeup.angle);
|
||||
await k.mouse.move(...at(600, 200));
|
||||
assert.ok(await k.evaluate(() => game.closeup.angle) < high, 'la mira segue il mouse');
|
||||
await k.mouse.down();
|
||||
await k.waitForTimeout(500);
|
||||
assert.ok(await k.evaluate(() => game.closeup.power > 0.2 && game.closeup.holding), 'tenendo premuto la forza sale');
|
||||
await k.screenshot({ path: `${OUT}/03e-basket-carica.png` });
|
||||
await k.mouse.up();
|
||||
assert.equal(await k.evaluate(() => game.closeup.step), 'flight', 'lasciando, la palla parte');
|
||||
await k.waitForTimeout(350);
|
||||
await k.screenshot({ path: `${OUT}/03f-basket-volo.png` });
|
||||
await k.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.thrown === 1, null, { timeout: 20000 });
|
||||
await k.keyboard.press('Escape');
|
||||
await k.waitForFunction(() => game.speech?.text.startsWith('Lascio il pallone'), null, { timeout: 5000 });
|
||||
await k.close();
|
||||
}
|
||||
|
||||
// La pesca al lago dell'Anatre: coi ragazzi in Fortezza «S'ha andà a fallo?!?», poi il lancio tenendo premuto e l'attesa.
|
||||
{
|
||||
const k = await open({ width: 1280, height: 860 });
|
||||
await k.click('#splash');
|
||||
await k.click('#new-game');
|
||||
await k.click('#rione [data-rione="campomigliaio"]');
|
||||
await k.click('#skip-intro');
|
||||
await settle(k);
|
||||
await k.evaluate(() => game.go('fortezza', [240, 334]));
|
||||
await settle(k);
|
||||
await clickScene(k, await hotspotCenter(k, 'pescatori'));
|
||||
await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 40000 });
|
||||
await k.locator('#choices li', { hasText: 'S’ha andà a fallo?!?' }).or(k.locator('#choices li', { hasText: "S'ha andà a fallo?!?" })).first().click();
|
||||
await k.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 });
|
||||
await k.locator('#choices li', { hasText: 'Seria.' }).click();
|
||||
await k.waitForFunction(() => game.closeup?.id === 'pesca' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||||
const r = await k.locator('#scene').boundingBox();
|
||||
await k.mouse.move(r.x + r.width * 0.7, r.y + r.height * 0.7);
|
||||
assert.ok(await k.evaluate(() => game.closeup.power > 0.5), 'toccando l\'acqua al largo la forza sale');
|
||||
await k.mouse.down();
|
||||
await k.waitForTimeout(500);
|
||||
assert.ok(await k.evaluate(() => game.closeup.holding && game.closeup.power > 0.2), 'tenendo premuto si carica il lancio');
|
||||
await k.mouse.up();
|
||||
await k.waitForFunction(() => game.closeup.step === 'wait', null, { timeout: 5000 });
|
||||
await k.waitForTimeout(600);
|
||||
await k.screenshot({ path: `${OUT}/03g-pesca-attesa.png` });
|
||||
assert.equal(await k.textContent('#bocce-throw'), 'Ferra');
|
||||
await k.keyboard.press('Escape');
|
||||
await k.waitForFunction(() => game.speech?.text.startsWith('Lascio la canna'), null, { timeout: 5000 });
|
||||
await k.close();
|
||||
}
|
||||
|
||||
// Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale.
|
||||
{
|
||||
const b = await open({ width: 1280, height: 860 });
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newPesca, press, release, cancel, update, cast, strike, shoot, setPower, aimScreen, nudge, status, canThrow, canCast, canAdjust,
|
||||
distanceFor, biting, CASTS, LEVELS, FISHES, NEAR, FAR, BANK, SHORE, WATER, FLY, REST } from '../src/engine/pesca.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
// Come il gioco: a ogni fotogramma gli eventi sonori si consumano (e qui si ricordano).
|
||||
const heard = new Set();
|
||||
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) { update(c, dt); for (const e of c.events.splice(0)) heard.add(e.kind); } };
|
||||
// Lancia e aspetta l'abboccata vera (non i tocchi), poi ferra: la mano ferma dei test.
|
||||
function hook(c) {
|
||||
assert.ok(cast(c)); run(c, FLY + 0.05);
|
||||
assert.equal(c.step, 'wait');
|
||||
for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60);
|
||||
assert.ok(c.float.bitten, 'prima o poi abbocca');
|
||||
assert.ok(strike(c)); assert.equal(c.step, 'fight');
|
||||
}
|
||||
// Recupera con giudizio: tiene premuto finché la lenza non è quasi al limite, poi molla un attimo.
|
||||
function land(c) {
|
||||
for (let i = 0; i < 60 * 90 && c.step === 'fight'; i++) {
|
||||
if (c.tension > 0.8 && c.reeling) release(c);
|
||||
else if (c.tension < 0.55 && !c.reeling) press(c);
|
||||
update(c, 1 / 60); for (const e of c.events.splice(0)) heard.add(e.kind);
|
||||
}
|
||||
assert.notEqual(c.step, 'fight', 'il recupero finisce');
|
||||
}
|
||||
|
||||
test('la forza sale finché si tiene premuto; un tocco breve non spreca il lancio; dove si tocca l\'acqua si lancia', () => {
|
||||
const c = newPesca(1);
|
||||
assert.ok(press(c)); update(c, 0.05); assert.ok(release(c));
|
||||
assert.equal(c.casts, 0); assert.equal(c.power, 0.5);
|
||||
assert.ok(aimScreen(c, [FAR, 250])); assert.equal(c.power, 1);
|
||||
assert.ok(aimScreen(c, [NEAR, 250])); assert.equal(c.power, 0);
|
||||
assert.ok(!aimScreen(c, [BANK - 20, 250]), 'sulla riva non si lancia');
|
||||
assert.ok(!aimScreen(c, [400, 60]), 'il cielo non è acqua');
|
||||
assert.ok(nudge(c, 1)); assert.ok(Math.abs(c.power - 0.05) < 1e-9);
|
||||
setPower(c, 0.5);
|
||||
assert.ok(press(c)); update(c, 0.7); assert.ok(release(c));
|
||||
assert.equal(c.casts, 1); assert.equal(c.step, 'cast');
|
||||
assert.ok(Math.abs(c.float.x - distanceFor(c.power)) < 1e-9);
|
||||
assert.ok(c.events.some(e => e.kind === 'pesca-lancio'));
|
||||
assert.ok(!setPower(c, 0.2), 'a lenza in acqua la forza non si tocca');
|
||||
});
|
||||
|
||||
test('il galleggiante: i tocchi finti abbassano appena, l\'abboccata lo porta sotto; ferrare a vuoto perde il lancio', () => {
|
||||
const c = newPesca(2); cast(c); run(c, FLY + 0.05);
|
||||
const f = c.float;
|
||||
assert.ok(f.nibbles.length >= 1 && f.nibbles.every(n => n.at < f.biteAt), 'i tocchi vengono prima');
|
||||
assert.ok(strike(c)); assert.equal(c.step, 'settle'); assert.equal(c.how, 'empty');
|
||||
assert.match(c.last.text, /a vuoto|spaventato/);
|
||||
run(c, REST + 0.1); assert.equal(c.step, 'aim'); assert.equal(c.casts, 1);
|
||||
const d = newPesca(3); cast(d); run(d, FLY + 0.05);
|
||||
const g = d.float;
|
||||
for (let i = 0; i < 60 * 15 && !(g.t >= g.nibbles[0].at && g.t < g.nibbles[0].at + g.nibbles[0].for); i++) update(d, 1 / 60);
|
||||
assert.ok(g.dip > 0 && g.dip < 5, `un tocco finto: ${g.dip}`);
|
||||
for (let i = 0; i < 60 * 15 && !g.bitten; i++) update(d, 1 / 60);
|
||||
assert.equal(g.dip, 7, 'abboccata: sotto');
|
||||
assert.ok(d.events.some(e => e.kind === 'pesca-abbocca'));
|
||||
});
|
||||
|
||||
test('non ferrata in tempo, l\'abboccata passa; alla terza il pesce se ne va', () => {
|
||||
const c = newPesca(4, { level: 'male' }); cast(c); run(c, FLY + 0.05);
|
||||
for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60);
|
||||
// Il segnale e la finestra coincidono: finché dice «Ferra!» si ferra; dopo, lo sta sputando.
|
||||
assert.ok(biting(c) && c.float.dip === 7 && /Ferra!/.test(status(c)));
|
||||
run(c, c.skill.window + 0.05);
|
||||
assert.ok(!biting(c) && c.float.bitten && c.float.dip === 3 && /sputando/.test(status(c)));
|
||||
const late = structuredClone(c); strike(late);
|
||||
assert.equal(late.last.kind, 'late'); assert.match(late.last.text, /tardi/);
|
||||
run(c, 0.5);
|
||||
assert.ok(!c.float.bitten && c.float.missed === 1, 'persa la prima');
|
||||
assert.equal(c.step, 'wait');
|
||||
assert.match(status(c), /2 abboccate ancora/);
|
||||
run(c, 60);
|
||||
assert.equal(c.how, 'empty', 'dopo tre, o dopo quaranta secondi, si ricupera a vuoto');
|
||||
assert.equal(c.last.kind, 'gone'); assert.equal(c.step, 'aim');
|
||||
});
|
||||
|
||||
test('ferrato in tempo si combatte: tenendo premuto la lenza tira e il pesce si avvicina, mollando si allontana; troppo tesa si spezza', () => {
|
||||
const c = newPesca(5, { level: 'bona' }); setPower(c, 0.5); hook(c);
|
||||
assert.ok(c.fish && c.fish.dist === 1);
|
||||
const x0 = c.float.x;
|
||||
press(c); run(c, 1.5);
|
||||
assert.ok(c.float.x < x0 && c.float.x > SHORE, `il galleggiante viene verso riva senza salti: ${x0} → ${c.float.x}`);
|
||||
assert.ok(c.fish.dist < 1 && c.tension > 0.3, 'recuperando si avvicina');
|
||||
const d0 = c.fish.dist;
|
||||
release(c); run(c, 1.5);
|
||||
assert.ok(c.fish.dist > d0 - 0.001, 'mollando non si avvicina');
|
||||
land(c);
|
||||
assert.equal(c.how, 'landed', 'con giudizio si tira a riva');
|
||||
assert.equal(c.caught.length, 1); assert.ok(c.weight > 0);
|
||||
assert.ok(heard.has('pesca-preso'));
|
||||
// A fassi male, una carpa al largo tenuta sempre tesa spezza la lenza.
|
||||
const d = newPesca(6, { level: 'male' }); setPower(d, 1); hook(d);
|
||||
d.fish = { ...FISHES.at(-1), dist: 1, energy: 1, burst: 0, reel: 0.11 };
|
||||
press(d); run(d, 8);
|
||||
assert.equal(d.how, 'broken', `invece: ${d.step} ${d.how}`);
|
||||
assert.ok(heard.has('pesca-rottura'));
|
||||
});
|
||||
|
||||
test('un gesto solo: premendo all\'abboccata si ferra e si recupera; il pannello alterna recupera e molla; il sesto lancio si può finire', () => {
|
||||
const c = newPesca(21, { level: 'bona' }); setPower(c, 0.3); cast(c); run(c, FLY + 0.05);
|
||||
for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60);
|
||||
assert.ok(press(c)); assert.equal(c.step, 'fight'); assert.ok(c.reeling, 'ferrato e già in recupero');
|
||||
assert.ok(release(c)); assert.ok(!c.reeling);
|
||||
assert.ok(shoot(c)); assert.ok(c.reeling, 'dal pannello: recupera'); assert.ok(shoot(c)); assert.ok(!c.reeling, 'dal pannello: molla');
|
||||
const d = newPesca(22); d.casts = CASTS - 1;
|
||||
assert.ok(canCast(d)); cast(d); run(d, FLY + 0.05);
|
||||
assert.ok(!canCast(d) && canThrow(d), 'al sesto lancio si può ancora ferrare');
|
||||
assert.ok(!canAdjust(d), 'ma non regolare la distanza');
|
||||
for (let i = 0; i < 60 * 15 && !d.float.bitten; i++) update(d, 1 / 60);
|
||||
assert.ok(press(d)); assert.equal(d.step, 'fight'); assert.ok(canThrow(d), 'e recuperare');
|
||||
d.fish = { ...FISHES[1], dist: 1, energy: 1, burst: 0, reel: 0.2 }; land(d);
|
||||
assert.equal(d.how, 'landed');
|
||||
assert.ok(d.result, 'il risultato è deciso appena il pesce è a riva, prima della posa');
|
||||
run(d, REST + 0.1); assert.equal(d.step, 'result');
|
||||
});
|
||||
|
||||
test('la lenza si spezza davvero: tenendo sempre premuto, a seria un cavedano o più grosso la rompe, una lasca no', () => {
|
||||
const fight = (kind, level) => { const c = newPesca(23, { level }); setPower(c, 0.5); hook(c); c.fish = { ...FISHES.find(f => f.kind === kind), dist: 1, energy: 1, burst: 0, reel: 0.15 }; press(c); run(c, 20); return c.how; };
|
||||
assert.equal(fight('lasca', 'seria'), 'landed');
|
||||
assert.equal(fight('cavedano', 'seria'), 'broken');
|
||||
assert.equal(fight('carpa', 'bona'), 'broken');
|
||||
assert.equal(fight('cavedano', 'bona'), 'landed');
|
||||
// Mollata e ripresa, la stessa carpa viene a riva.
|
||||
const c = newPesca(24, { level: 'male' }); setPower(c, 1); hook(c); c.fish = { ...FISHES.at(-1), dist: 1, energy: 1, burst: 0, reel: 0.11 }; land(c);
|
||||
assert.equal(c.how, 'landed');
|
||||
});
|
||||
|
||||
test('al largo i pesci sono più grossi; lo stivale solo sotto riva e non conta per la gloria', () => {
|
||||
const near = new Set(), far = new Set();
|
||||
for (let seed = 1; seed < 40; seed++) {
|
||||
for (const [power, set] of [[0, near], [1, far]]) {
|
||||
const c = newPesca(seed); setPower(c, power); hook(c); set.add(c.fish.kind);
|
||||
}
|
||||
}
|
||||
assert.ok(far.has('carpa') || far.has('tinca'), `al largo: ${[...far]}`);
|
||||
assert.ok(!near.has('carpa'), 'sotto riva niente carpe');
|
||||
assert.ok(!far.has('stivale'), 'lo stivale sta sotto riva');
|
||||
const c = newPesca(7, { level: 'bona' }); c.caught = [{ ...FISHES[0] }, { ...FISHES[1] }]; c.weight = 1;
|
||||
assert.match(status(c), /Pesci 1 /, 'lo stivale non è un pesce');
|
||||
const d = newPesca(25); setPower(d, 0); hook(d); d.fish = { ...FISHES[0], dist: 1, energy: 1, burst: 0, reel: 0.3 }; land(d);
|
||||
assert.equal(d.weight, 0, 'e non pesa'); assert.match(d.last.text, /stivale/);
|
||||
});
|
||||
|
||||
test('sei lanci, poi il risultato: la gloria dipende dal livello', () => {
|
||||
for (const [level, { goal }] of Object.entries(LEVELS)) {
|
||||
const c = newPesca(8, { level }), fishes = [];
|
||||
for (let i = 0; i < CASTS; i++) {
|
||||
assert.ok(canThrow(c));
|
||||
// Finché serve si pesca per davvero (con una lasca docile sotto riva), poi si ricupera a vuoto.
|
||||
if (c.caught.length < goal) { setPower(c, 0); hook(c); c.fish = { ...FISHES[1], dist: 1, energy: 1, burst: 0, reel: 0.2 }; land(c); assert.equal(c.how, 'landed'); }
|
||||
else { cast(c); run(c, FLY + 0.05); strike(c); }
|
||||
run(c, REST + 0.1);
|
||||
}
|
||||
assert.equal(c.step, 'result'); assert.ok(!canThrow(c)); assert.ok(!cast(c));
|
||||
assert.equal(c.result.fish, goal); assert.equal(c.result.winner, 'player', level);
|
||||
assert.match(status(c), /pesci su 6 lanci/);
|
||||
}
|
||||
const miss = newPesca(9, { level: 'bona' });
|
||||
for (let i = 0; i < CASTS; i++) { cast(miss); run(miss, FLY + 0.05); strike(miss); run(miss, REST + 0.1); }
|
||||
assert.equal(miss.result.winner, 'cpu');
|
||||
});
|
||||
|
||||
test('nella storia: coi ragazzi in Fortezza «S\'ha andà a fallo?!?», loro accettano, si sceglie e si pesca; la gloria lascia il flag', () => {
|
||||
const g = new Game(story);
|
||||
g.go('fortezza', [240, 334]); settle(g);
|
||||
g.act('talk', 'pescatori'); const said = settle(g).map(s => s.text);
|
||||
assert.ok(said.some(t => t.includes('mia mamma aveva ragione')), 'la prima volta le canne');
|
||||
assert.ok(g.dialogue, 'poi si parla');
|
||||
g.choose(g.options().findIndex(o => o.text === 'S\'ha andà a fallo?!?'));
|
||||
const yes = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`);
|
||||
assert.ok(yes.includes('pescatori: Gnamo! La canna ti si dà noi.'));
|
||||
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'pesca'); assert.equal(g.closeup.level, 'seria');
|
||||
g.closeup.result = { winner: 'player', fish: 3, weight: 2.1, goal: 3, caught: [] }; g.closeup.step = 'result';
|
||||
g.closeupClose(); const after = settle(g).map(s => s.text);
|
||||
assert.ok(g.state.flags.pesca_vinta);
|
||||
assert.ok(after.some(t => t.includes('Stasera si frigge')));
|
||||
// La seconda volta si va dritti al dialogo.
|
||||
g.act('talk', 'pescatori'); settle(g);
|
||||
assert.ok(g.dialogue);
|
||||
});
|
||||
@@ -5,6 +5,6 @@
|
||||
"padel": "pàdel",
|
||||
"l'A. Bettini": "l'Abbettini",
|
||||
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino",
|
||||
"S'ha andà a fallo": "Si va a farlo",
|
||||
"S'ha andà a fallo": "saandà a fallo",
|
||||
"Ghiacciomobile": "Ghiacciomòbile"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user