Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea124933e | ||
|
|
51a46fe5a0 | ||
|
|
f6fb8922af | ||
|
|
bd7fbc3003 | ||
|
|
8b957c7cf7 | ||
|
|
f1de62f01e | ||
|
|
64dc8e18e7 | ||
|
|
b262977117 | ||
|
|
3762f2b465 | ||
|
|
9f7a9812ed | ||
|
|
151017d2e2 | ||
|
|
f193c6020f | ||
|
|
8a2e49036d | ||
|
|
1e5f66c2b6 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"image": "pescatori.png",
|
||||
"height": 110,
|
||||
"frames": {
|
||||
"work": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 170,
|
||||
"h": 110,
|
||||
"ax": 84.0,
|
||||
"ay": 110
|
||||
},
|
||||
"talk": {
|
||||
"x": 172,
|
||||
"y": 0,
|
||||
"w": 166,
|
||||
"h": 112,
|
||||
"ax": 79.2,
|
||||
"ay": 112
|
||||
},
|
||||
"laugh": {
|
||||
"x": 340,
|
||||
"y": 0,
|
||||
"w": 164,
|
||||
"h": 113,
|
||||
"ax": 79.7,
|
||||
"ay": 113
|
||||
},
|
||||
"proud": {
|
||||
"x": 506,
|
||||
"y": 0,
|
||||
"w": 166,
|
||||
"h": 121,
|
||||
"ax": 80.7,
|
||||
"ay": 121
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -203,6 +203,26 @@
|
||||
"file": "andrea-69337973d0.mp3",
|
||||
"sec": 5.94
|
||||
},
|
||||
"andrea|Quattro ragazzi che affilano canne di bambù col coltellino e ci legano le lenze.": {
|
||||
"file": "andrea-98ff3bf0a3.mp3",
|
||||
"sec": 6.41
|
||||
},
|
||||
"andrea|Oh, che fate?": {
|
||||
"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|Guarda questa: la lenza la regge anche una carpa di dieci chili.": {
|
||||
"file": "pescatori-afda722e0c.mp3",
|
||||
"sec": 3.2
|
||||
},
|
||||
"andrea|(Vedi, mia mamma aveva ragione: in Fortezza si viene a farsi le canne.)": {
|
||||
"file": "andrea-e0c6360b96.mp3",
|
||||
"sec": 4.35
|
||||
},
|
||||
"andrea|Nove bastioni, li volle Cosimo I. Per tenere fuori i nemici, dicono. Secondo me per tenere dentro il Regolo.": {
|
||||
"file": "andrea-d31f6e291b.mp3",
|
||||
"sec": 8.71
|
||||
@@ -507,6 +527,62 @@
|
||||
"file": "andrea-4d6871c326.mp3",
|
||||
"sec": 4.11
|
||||
},
|
||||
"andrea|Il Rattattù: la gara di canto del paese. Si sale sul palco e ci si sfida tutti, chi stona e chi no.": {
|
||||
"file": "andrea-a9903d3ae0.mp3",
|
||||
"sec": 8.33
|
||||
},
|
||||
"andrea|Quest'anno mi ci iscrivo. Forse.": {
|
||||
"file": "andrea-146000c293.mp3",
|
||||
"sec": 2.98
|
||||
},
|
||||
"andrea|Il Cinema Taiuti. Una sala, un proiettore e le poltroncine che cigolano a tempo.": {
|
||||
"file": "andrea-3b310a5164.mp3",
|
||||
"sec": 6.39
|
||||
},
|
||||
"andrea|A quest'ora è chiuso. Apre la sera, per il film o per il Rattattù.": {
|
||||
"file": "andrea-5e37f70197.mp3",
|
||||
"sec": 5.35
|
||||
},
|
||||
"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",
|
||||
"sec": 5.72
|
||||
},
|
||||
"andrea|Il ponticino delle Mozzète. Sotto ci passa un fossino che d'estate fa più zanzare che acqua.": {
|
||||
"file": "andrea-98dc291bd3.mp3",
|
||||
"sec": 7.21
|
||||
},
|
||||
"andrea|Terra appena arata. Profuma di campagna e di trattore.": {
|
||||
"file": "andrea-a07bf26ea7.mp3",
|
||||
"sec": 4.41
|
||||
},
|
||||
"andrea|La falegnameria. L'odore di segatura arriva fin qui.": {
|
||||
"file": "andrea-1ccd8e2127.mp3",
|
||||
"sec": 3.84
|
||||
},
|
||||
"andrea|Bussare a una falegnameria? Mi danno una scopa e mi fanno spazzare.": {
|
||||
"file": "andrea-09ec24ef60.mp3",
|
||||
"sec": 4.63
|
||||
},
|
||||
"andrea|Un fienile pieno di rotoballe. Fossi una mucca, sarebbe il paradiso.": {
|
||||
"file": "andrea-5dfec13174.mp3",
|
||||
"sec": 4.81
|
||||
},
|
||||
"andrea|La campagna verso Gabbiano. Da quassù si vede mezzo Mugello.": {
|
||||
"file": "andrea-06e1b17635.mp3",
|
||||
"sec": 5.31
|
||||
},
|
||||
"andrea|Il palio, però, no.": {
|
||||
"file": "andrea-0e9c7a48de.mp3",
|
||||
"sec": 1.6
|
||||
},
|
||||
"andrea|Un casolare vecchio, coi mattoni che spuntano dall'intonaco. Come le mie ginocchia.": {
|
||||
"file": "andrea-06c5f35df7.mp3",
|
||||
"sec": 5.66
|
||||
},
|
||||
"andrea|L'ingresso del campeggio. A giudicare dalle bandiere, ci dorme mezzo mondo.": {
|
||||
"file": "andrea-72dede01c0.mp3",
|
||||
"sec": 5.13
|
||||
@@ -627,6 +703,42 @@
|
||||
"file": "andrea-e24cb31cbf.mp3",
|
||||
"sec": 2.62
|
||||
},
|
||||
"andrea|Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.": {
|
||||
"file": "andrea-60f39f0dd7.mp3",
|
||||
"sec": 6.48
|
||||
},
|
||||
"andrea|Cistola. Il talento di via del Pignone.": {
|
||||
"file": "andrea-2b47355c2c.mp3",
|
||||
"sec": 3.81
|
||||
},
|
||||
"andrea|Col dieci, come i grandi. Se c'è un muro che se lo merita, è questo.": {
|
||||
"file": "andrea-1bd111f29d.mp3",
|
||||
"sec": 5.59
|
||||
},
|
||||
"andrea|Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.": {
|
||||
"file": "andrea-0e558e54fe.mp3",
|
||||
"sec": 4.18
|
||||
},
|
||||
"andrea|Il muro di Villa Schifanoia, la villa dei Medici, mezzo coperto dall'edera.": {
|
||||
"file": "andrea-6a873beff6.mp3",
|
||||
"sec": 5.07
|
||||
},
|
||||
"andrea|Lassù una volta c'era una torre, per tenere d'occhio chi passava sulla strada per Bologna. Ne ha viste più di me.": {
|
||||
"file": "andrea-d9ba9bd3ad.mp3",
|
||||
"sec": 7.34
|
||||
},
|
||||
"andrea|E la piazzetta si chiama come i Cambray-Digny, gli ultimi signori della villa.": {
|
||||
"file": "andrea-9d3ec66f60.mp3",
|
||||
"sec": 4.64
|
||||
},
|
||||
"andrea|Una casetta piccina, appoggiata al muro della villa come un gatto al sole.": {
|
||||
"file": "andrea-3cef067df6.mp3",
|
||||
"sec": 4.1
|
||||
},
|
||||
"andrea|Chiusa. E poi non è casa mia.": {
|
||||
"file": "andrea-7fd73fbb77.mp3",
|
||||
"sec": 2.66
|
||||
},
|
||||
"andrea|L'area feste. D'estate ci montano la pista da ballo, le tavolate e lo stand della pasta.": {
|
||||
"file": "andrea-a942b2723e.mp3",
|
||||
"sec": 6.77
|
||||
@@ -1923,6 +2035,30 @@
|
||||
"file": "arcu-406cd02197.mp3",
|
||||
"sec": 4.46
|
||||
},
|
||||
"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
|
||||
@@ -2167,6 +2303,18 @@
|
||||
"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
|
||||
},
|
||||
"ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
|
||||
"file": "ing_tozzi-51f855478a.mp3",
|
||||
"sec": 4.42
|
||||
|
||||
@@ -27,6 +27,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
|
||||
| `bosco-ai-frati.png` | Codex | `bosco-ai-frati.png` | [DA ANDREA] sembra una foto da Wikipedia/Wikimedia: verificare licenza | ? |
|
||||
| `campeggio.png` | Codex | `campeggio-ingresso.png` | [DA ANDREA] probabilmente dal sito del campeggio | ? |
|
||||
| `polisportiva-*.png`, `ponte-sieve.png`, `area-feste.png`, `via-provinciale.png`, `comune-asilo.png`, `giardini-poste.png`, `stazione.png` | Codex | foto omonime in `assets/raw/foto/` | Andrea (EXIF, 23 ottobre 2026) | sì |
|
||||
| `via-del-pignone.png` | Codex | `via-del-pignone.png` (murale: `riferimenti/cistola.png`, `murale-maradona.png`) | Andrea | sì (Cistola consenziente) |
|
||||
| `provinciale-pignone.png`, `piazzetta-cambray-digny.png`, `via-cipriani.png`, `via-massorondinaio.png`, `mozzete.png`, `ponticino-mozzete.png`, `coldaia.png`, `falegnameria-coldaia.png`, `gabbiano.png` | Codex | screenshot di Google Street View | Google | **no**: da rifare con foto di Andrea |
|
||||
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
|
||||
| `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti |
|
||||
| `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce |
|
||||
|
||||
@@ -199,6 +199,25 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
|
||||
sfondi da foto prese da internet (`docs/IMMAGINI.md`), Cardetole (dice dove abita Andrea).
|
||||
- Vecchietta e vecchietto sono in gioco sulla panchina della Piazza (`assets/characters/`, da
|
||||
`assets/raw/vecchiett?-sheet.png`): consenso delle persone ritratte confermato da Andrea il 2026-10-23; mancano i loro nomi.
|
||||
- Via Provinciale (dalla rotonda verso la Pieve) → a destra Via del Pignone → in cima Piazzetta Cambray Digny, che porta
|
||||
in Piazza (a sinistra, dove prima c'era l'uscita per il Comune) e in Via de' Medici. Battute provvisorie sul murale.
|
||||
Via del Pignone (sfondo di Codex da `assets/raw/foto/via-del-pignone.png`): sul muro di destra il
|
||||
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). 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<h2>Ringraziamenti</h2>
|
||||
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||
<dl id="credits-list"></dl>
|
||||
<div id="credits-reel" aria-hidden="true"><div class="track"></div></div>
|
||||
<div id="credits-reel"><button class="arrow prev" type="button" aria-label="Foto precedente">‹</button><div class="track" aria-label="Le immagini del gioco: toccane una per vederla grande"></div><button class="arrow next" type="button" aria-label="Foto successiva">›</button></div>
|
||||
<p class="legal">Monkey Island è un marchio di Lucasfilm Ltd., citato solo nella dedica: questo gioco non è affiliato né approvato da Lucasfilm.</p>
|
||||
<div class="buttons"><button id="close-credits">Chiudi</button></div>
|
||||
<figure id="face" hidden><img alt=""><figcaption></figcaption></figure>
|
||||
@@ -209,6 +209,16 @@
|
||||
<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="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,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.22",
|
||||
"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,7 @@
|
||||
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 briscola from './briscola.js';
|
||||
import * as tressette from './tressette.js';
|
||||
import * as scopa from './scopa.js';
|
||||
@@ -14,6 +15,7 @@ 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 },
|
||||
};
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
@@ -70,6 +72,22 @@ export function shuffledCredits(credits, random = Math.random) {
|
||||
});
|
||||
}
|
||||
|
||||
// Le immagini del carosello dei Ringraziamenti: tutti gli sfondi del paese (uno per scena, senza doppioni) col nome
|
||||
// del posto, e un ritratto dei crediti ogni quattro sfondi; i ritratti che non trovano posto chiudono la fila.
|
||||
export function reelPictures(story) {
|
||||
const seen = new Set(), scenes = [];
|
||||
for (const s of Object.values(story.scenes)) {
|
||||
if (!s.background || seen.has(s.background)) continue;
|
||||
seen.add(s.background);
|
||||
scenes.push({ src: s.background, name: s.name, kind: 'scene' });
|
||||
}
|
||||
const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(facePicture);
|
||||
let next = 0;
|
||||
const placed = scenes.flatMap((p, i) => i % 4 === 3 && next < faces.length ? [p, faces[next++]] : [p]);
|
||||
return [...placed, ...faces.slice(next)];
|
||||
}
|
||||
export const facePicture = n => ({ src: n.face, name: n.name, kind: n.face.endsWith('palio.png') ? 'palio' : 'face' });
|
||||
|
||||
// La ricerca della mappa: ogni parola scritta deve stare nel nome del posto, senza badare a maiuscole, accenti e
|
||||
// apostrofi («camp» trova Campomigliaio, «de medici» trova Via de' Medici).
|
||||
const plain = text => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { drawBriscola } from './briscola-render.js';
|
||||
import { drawTressette } from './tressette-render.js';
|
||||
import { drawScopa } from './scopa-render.js';
|
||||
@@ -682,6 +683,11 @@ function drawCloseup(ctx, game, time) {
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
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,13 @@ 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 }); }
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
|
||||
import { story } from './story/story.js';
|
||||
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace } from './engine/game.js';
|
||||
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
|
||||
import { Command } from './engine/command.js';
|
||||
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
|
||||
@@ -10,6 +10,7 @@ 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 briscola from './engine/briscola.js';
|
||||
import { cardAt } from './engine/briscola-render.js';
|
||||
import * as tressette from './engine/tressette.js';
|
||||
@@ -125,7 +126,7 @@ function openCredits() {
|
||||
if (typeof n === 'string') return Object.assign(document.createElement('span'), { textContent: n });
|
||||
// Chi ha il ritratto: il nome è un pulsante che lo mostra.
|
||||
const b = Object.assign(document.createElement('button'), { type: 'button', className: 'credit-face', textContent: n.name });
|
||||
b.addEventListener('click', () => showFace(n));
|
||||
b.addEventListener('click', () => showFace(facePicture(n)));
|
||||
return b;
|
||||
}));
|
||||
return [dt, dd];
|
||||
@@ -133,22 +134,64 @@ function openCredits() {
|
||||
fillReel();
|
||||
$('credits').hidden = false;
|
||||
rollCredits();
|
||||
rollReel();
|
||||
}
|
||||
// Sotto i nomi, un carosello che scorre da solo: gli sfondi del paese e i ritratti, due giri uguali in fila per non
|
||||
// vedere mai la cucitura.
|
||||
// Sotto i nomi, il carosello: tutti gli sfondi del paese e i ritratti (game.reelPictures), due giri uguali in fila per
|
||||
// non vedere mai la cucitura. Scorre da solo; si ferma sotto il mouse, si trascina, si spinge con la rotella, le
|
||||
// frecce ai lati o quelle della tastiera, e toccando una foto la si vede grande (Andrea, 27 ottobre).
|
||||
const reelTrack = $('credits-reel').querySelector('.track');
|
||||
let reeling = 0, reelWait = 0, reelDrag = null, reelDragged = false, reelTap = null;
|
||||
const reelPics = new WeakMap();
|
||||
function fillReel() {
|
||||
const track = $('credits-reel').querySelector('.track');
|
||||
if (track.children.length) return;
|
||||
const pictures = [...new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean))];
|
||||
const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(n => n.face);
|
||||
// I ritratti sparsi fra gli sfondi, uno ogni quattro.
|
||||
const all = pictures.flatMap((p, i) => i % 4 === 3 && faces.length ? [p, faces[(i >> 2) % faces.length]] : [p]);
|
||||
for (const src of [...all, ...all]) {
|
||||
const img = Object.assign(document.createElement('img'), { src, alt: '', loading: 'lazy', decoding: 'async' });
|
||||
if (src.includes('/credits/')) img.className = src.endsWith('palio.png') ? 'palio' : 'face';
|
||||
track.append(img);
|
||||
if (reelTrack.children.length) return;
|
||||
const all = reelPictures(story);
|
||||
for (const p of [...all, ...all]) {
|
||||
const img = Object.assign(document.createElement('img'), { src: versioned(p.src), alt: p.name, title: p.name, className: p.kind, loading: 'lazy', decoding: 'async', draggable: false });
|
||||
reelPics.set(img, p);
|
||||
reelTrack.append(img);
|
||||
}
|
||||
}
|
||||
function rollReel() {
|
||||
cancelAnimationFrame(reeling);
|
||||
reelTrack.scrollLeft = 0;
|
||||
reelWait = 0;
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
let last = performance.now(), pos = 0;
|
||||
const step = now => {
|
||||
if ($('credits').hidden) return;
|
||||
const dt = now - last; last = now;
|
||||
const half = reelTrack.scrollWidth / 2;
|
||||
if (reelTrack.matches(':hover') || reelDrag || !$('face').hidden || (reelWait -= dt) > 0) pos = reelTrack.scrollLeft;
|
||||
else { pos += dt * 0.05; if (pos >= half) pos -= half; reelTrack.scrollLeft = pos; }
|
||||
reeling = requestAnimationFrame(step);
|
||||
};
|
||||
reeling = requestAnimationFrame(step);
|
||||
}
|
||||
// Di una foto alla volta, con le frecce.
|
||||
function nudgeReel(dir) {
|
||||
reelWait = 4000;
|
||||
reelTrack.scrollBy({ left: dir * ((reelTrack.firstElementChild?.offsetWidth ?? 300) + 10), behavior: 'smooth' });
|
||||
}
|
||||
for (const b of $('credits-reel').querySelectorAll('.arrow')) b.addEventListener('click', () => nudgeReel(b.classList.contains('next') ? 1 : -1));
|
||||
// Col dito si trascina da sé (touch-action: pan-x); col mouse lo facciamo noi. Un tocco fermo su una foto la mostra
|
||||
// grande: si guarda al rilascio, perché con la cattura del puntatore il clic non arriva più all'immagine.
|
||||
reelTrack.addEventListener('pointerdown', e => {
|
||||
reelDragged = false;
|
||||
reelTap = reelPics.get(e.target) ?? null;
|
||||
if (e.pointerType !== 'mouse') { reelWait = 4000; return; }
|
||||
reelDrag = { x: e.clientX, left: reelTrack.scrollLeft, id: e.pointerId };
|
||||
reelTrack.setPointerCapture(e.pointerId);
|
||||
});
|
||||
reelTrack.addEventListener('pointermove', e => {
|
||||
if (!reelDrag) return;
|
||||
const dx = e.clientX - reelDrag.x;
|
||||
if (Math.abs(dx) > 6) reelDragged = true;
|
||||
reelTrack.scrollLeft = reelDrag.left - dx;
|
||||
});
|
||||
const endReelDrag = () => { if (reelDrag && reelDragged) reelWait = 4000; reelDrag = null; };
|
||||
reelTrack.addEventListener('pointerup', () => { if (!reelDragged && reelTap) showFace(reelTap); reelTap = null; endReelDrag(); });
|
||||
reelTrack.addEventListener('pointercancel', () => { reelTap = null; endReelDrag(); });
|
||||
reelTrack.addEventListener('wheel', e => { reelWait = 4000; if (e.deltaY && !e.deltaX) { reelTrack.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
|
||||
// I nomi salgono piano da soli; si fermano se ci si passa sopra o si tocca, e ricominciano dall'alto in fondo.
|
||||
let rolling = 0, rollWait = 0;
|
||||
$('credits-list').addEventListener('pointerdown', () => { rollWait = 4000; });
|
||||
@@ -170,9 +213,10 @@ function rollCredits() {
|
||||
};
|
||||
rolling = requestAnimationFrame(step);
|
||||
}
|
||||
function showFace({ name, face }) {
|
||||
$('face').querySelector('img').src = versioned(face);
|
||||
$('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png'));
|
||||
function showFace({ name, src, kind }) {
|
||||
const img = $('face').querySelector('img');
|
||||
img.src = versioned(src);
|
||||
img.className = kind;
|
||||
$('face').querySelector('figcaption').textContent = name;
|
||||
$('face').hidden = false;
|
||||
}
|
||||
@@ -453,11 +497,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'].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();
|
||||
@@ -507,7 +551,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'].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));
|
||||
});
|
||||
@@ -520,6 +564,7 @@ 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.' },
|
||||
};
|
||||
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
|
||||
const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||
@@ -631,7 +676,11 @@ window.addEventListener('keydown', e => {
|
||||
}
|
||||
if (debugKey(e)) return;
|
||||
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
|
||||
if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; }
|
||||
if (!$('credits').hidden) {
|
||||
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if ($('face').hidden) nudgeReel(e.key === 'ArrowRight' ? 1 : -1); }
|
||||
return;
|
||||
}
|
||||
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
||||
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
|
||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||
@@ -857,7 +906,7 @@ 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' }[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;
|
||||
@@ -867,6 +916,7 @@ function updateUi() {
|
||||
$('bocce-throw').textContent = 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';
|
||||
$('basket-help').hidden = def.type !== 'basket';
|
||||
if (!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 });
|
||||
|
||||
@@ -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]] },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -150,6 +150,22 @@ 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: [],
|
||||
},
|
||||
// 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 +231,10 @@ 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' },
|
||||
// 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' },
|
||||
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
|
||||
castello: { name: 'Lo spirito del Trebbio', color: '#c8f0ff' },
|
||||
@@ -477,13 +497,10 @@ export const story = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'verso_campeggio', name: 'strada verso il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
|
||||
id: 'verso_campeggio', name: 'Via Cipriani, verso il cinema e il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'via_cipriani', at: [300, 345], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'verso_villa_adami', name: 'Via de\' Medici, verso Villa Adami', box: [0, 196, 90, 50], at: [92, 250], exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'verso_comune', name: 'a sinistra, verso il Comune', box: [96, 196, 50, 54], at: [120, 252], exit: { scene: 'comune', at: [20, 300], face: 'right' },
|
||||
id: 'verso_piazzetta', name: 'Piazzetta Cambray Digny, verso Via de\' Medici', box: [0, 196, 146, 54], at: [92, 250], exit: { scene: 'piazzetta', at: [510, 274], face: 'down' },
|
||||
},
|
||||
{
|
||||
id: 'verso_calimara', name: 'Via Calimara, verso la farmacia', box: [180, 334, 380, 26], at: [360, 354], exit: { scene: 'via_calimara', at: [365, 212], face: 'down' },
|
||||
@@ -504,7 +521,22 @@ export const story = {
|
||||
walk: [[110, 284], [300, 280], [330, 272], [348, 274], [400, 292], [442, 294], [640, 352], [640, 360], [0, 360], [0, 315], [60, 310], [105, 295]],
|
||||
depth: [[272, 0.55], [360, 1.15]],
|
||||
entry: [175, 352],
|
||||
speakers: { pescatori: [150, 225] },
|
||||
// Quattro ragazzi sull'erba si fanno le canne da pesca col bambù.
|
||||
people: [{ who: 'pescatori', at: [150, 322], idle: 'work', talk: ['work', 'talk'], blink: 'laugh', every: 6, size: 1.25 }],
|
||||
hotspots: [
|
||||
{
|
||||
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: [
|
||||
{ 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: '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.)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mura', name: 'bastione', box: [420, 0, 220, 290],
|
||||
actions: {
|
||||
@@ -658,7 +690,7 @@ export const story = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' },
|
||||
id: 'verso_provinciale', name: 'Via Provinciale, verso il River', box: [0, 270, 46, 70], at: [14, 312], face: 'left', exit: { scene: 'provinciale_pignone', at: [268, 216], face: 'down' },
|
||||
},
|
||||
{
|
||||
id: 'verso_forno', name: 'a destra, verso il forno', box: [570, 176, 70, 76], at: [588, 250], exit: { scene: 'forno', at: [628, 300], face: 'left' },
|
||||
@@ -835,7 +867,7 @@ export const story = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Via de' Medici: dalla Piazza sale verso Villa Adami (la Piazza è alle spalle, in fondo si va su).
|
||||
// Via de' Medici: dalla Piazzetta Cambray Digny sale verso Villa Adami (la Piazzetta è alle spalle, in fondo si va su).
|
||||
via_de_medici: {
|
||||
name: 'Via de\' Medici',
|
||||
map: [22, 32],
|
||||
@@ -855,11 +887,192 @@ export const story = {
|
||||
id: 'su_villa_adami', name: 'su verso Villa Adami', box: [272, 186, 60, 34], at: [300, 216], exit: { scene: 'villa_adami', at: [50, 252], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'giu_piazza', name: 'giù verso la Piazza', box: [0, 334, 560, 26], at: [300, 354], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
|
||||
id: 'giu_piazzetta', name: 'giù verso Piazzetta Cambray Digny', box: [0, 334, 545, 26], at: [300, 354], exit: { scene: 'piazzetta', at: [20, 320], face: 'right' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// L'ingresso del campeggio: dalla Piazza a diritto; poco dopo, a destra, si sale al Bosco ai Frati.
|
||||
// Via Pietro Cipriani, fra Via Massorondinaio (alle spalle, verso il campeggio) e la Piazza (in fondo, col Circolo 4 Maggio arancione).
|
||||
// A destra il Cinema Taiuti, aperto come nel 2007, col cartellone del Rattattù; lassù il campo da basket del Circolo.
|
||||
via_cipriani: {
|
||||
name: 'Via Pietro Cipriani',
|
||||
map: [22, 64],
|
||||
background: 'assets/scenes/via-cipriani.png',
|
||||
walk: [[40, 360], [40, 320], [60, 280], [100, 272], [150, 276], [300, 300], [360, 318], [640, 345], [640, 360]],
|
||||
depth: [[272, 0.45], [360, 1.15]],
|
||||
entry: [300, 345],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'rattattu', name: 'cartellone del Rattattù', box: [460, 152, 58, 110], at: [480, 336], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Il Rattattù: la gara di canto del paese. Si sale sul palco e ci si sfida tutti, chi stona e chi no.' }, { say: 'Quest\'anno mi ci iscrivo. Forse.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cinema', name: 'Cinema Taiuti', box: [420, 40, 220, 220], at: [520, 340], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Il Cinema Taiuti. Una sala, un proiettore e le poltroncine che cigolano a tempo.' }],
|
||||
open: [{ say: 'A quest\'ora è chiuso. Apre la sera, per il film o per il Rattattù.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
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. Due tiri ce li faccio sempre volentieri.' }],
|
||||
use: [{ dialogue: 'basket' }],
|
||||
walk: [{ dialogue: 'basket' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_piazza', name: 'in fondo, la Piazza', box: [80, 230, 120, 50], at: [120, 280], exit: { scene: 'piazza', at: [345, 240], face: 'down' },
|
||||
},
|
||||
{
|
||||
id: 'al_campeggio', name: 'indietro, verso il campeggio', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'via_massorondinaio', at: [30, 300], face: 'right' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Via Massorondinaio: a sinistra si riprende Via Cipriani (verso la Piazza), a diritto si va al campeggio.
|
||||
via_massorondinaio: {
|
||||
name: 'Via Massorondinaio',
|
||||
map: [17, 74],
|
||||
background: 'assets/scenes/via-massorondinaio.png',
|
||||
walk: [[0, 360], [0, 266], [200, 258], [400, 240], [520, 226], [566, 226], [552, 250], [545, 290], [600, 360]],
|
||||
depth: [[226, 0.4], [360, 1.2]],
|
||||
entry: [300, 330],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'a_via_cipriani', name: 'a sinistra, Via Cipriani', box: [0, 262, 40, 80], at: [16, 300], face: 'left', exit: { scene: 'via_cipriani', at: [300, 345], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'al_campeggio', name: 'a diritto, verso il campeggio', box: [496, 196, 74, 34], at: [535, 232], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Le Mozzète (Via Donatello): in fondo il campo secondario, in basso a destra si scende al ponticino.
|
||||
mozzete: {
|
||||
name: 'Le Mozzète',
|
||||
map: [72, 92],
|
||||
background: 'assets/scenes/mozzete.png',
|
||||
walk: [[0, 360], [0, 280], [40, 232], [120, 212], [300, 200], [335, 198], [350, 210], [420, 270], [640, 330], [640, 360]],
|
||||
depth: [[198, 0.3], [360, 1.2]],
|
||||
entry: [320, 330],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'fermata', name: 'fermata della corriera', box: [450, 170, 110, 90], at: [470, 300], face: 'right',
|
||||
actions: {
|
||||
look: [{ say: 'La fermata della corriera. Da qui si va dappertutto. Se passa.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'al_secondario', name: 'in fondo, il campo secondario', box: [300, 176, 60, 30], at: [330, 206], exit: { scene: 'polisportiva', at: [628, 290], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'al_ponticino', name: 'giù a destra, il ponticino delle Mozzète', box: [580, 316, 60, 44], at: [612, 345], face: 'right', exit: { scene: 'ponticino_mozzete', at: [300, 196], face: 'down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Il ponticino delle Mozzète: lassù la congiunzione con le Mozzète, indietro si va verso Coldaia.
|
||||
ponticino_mozzete: {
|
||||
name: 'Il ponticino delle Mozzète',
|
||||
map: [78, 88],
|
||||
background: 'assets/scenes/ponticino-mozzete.png',
|
||||
walk: [[0, 360], [0, 300], [200, 270], [280, 190], [320, 186], [380, 230], [520, 290], [640, 310], [640, 360]],
|
||||
depth: [[186, 0.3], [360, 1.2]],
|
||||
entry: [300, 340],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'parapetto', name: 'parapetto del ponticino', box: [460, 230, 120, 50], at: [470, 300], face: 'right',
|
||||
actions: {
|
||||
look: [{ say: 'Il ponticino delle Mozzète. Sotto ci passa un fossino che d\'estate fa più zanzare che acqua.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alle_mozzete', name: 'lassù, le Mozzète', box: [270, 166, 60, 30], at: [300, 196], exit: { scene: 'mozzete', at: [612, 345], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'a_coldaia', name: 'indietro, verso Coldaia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'coldaia', at: [450, 345], face: 'up' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Coldaia: dietro il ponticino delle Mozzète; avanti la falegnameria e poi Gabbiano.
|
||||
coldaia: {
|
||||
name: 'Coldaia',
|
||||
map: [84, 89],
|
||||
background: 'assets/scenes/coldaia.png',
|
||||
walk: [[230, 360], [300, 250], [318, 200], [290, 166], [330, 162], [460, 205], [640, 268], [640, 360]],
|
||||
depth: [[162, 0.3], [360, 1.2]],
|
||||
entry: [450, 345],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'campo_arato', name: 'campo arato', box: [0, 160, 220, 130], at: [280, 330], face: 'left',
|
||||
actions: {
|
||||
look: [{ say: 'Terra appena arata. Profuma di campagna e di trattore.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_falegnameria', name: 'avanti, verso la falegnameria', box: [280, 140, 60, 34], at: [305, 168], exit: { scene: 'falegnameria_coldaia', at: [300, 340], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'al_ponticino', name: 'indietro, il ponticino delle Mozzète', box: [220, 334, 420, 26], at: [450, 354], exit: { scene: 'ponticino_mozzete', at: [300, 340], face: 'up' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// La falegnameria di Coldaia, col fienile delle rotoballe; a diritto si va a Gabbiano.
|
||||
falegnameria_coldaia: {
|
||||
name: 'La falegnameria',
|
||||
map: [90, 91],
|
||||
background: 'assets/scenes/falegnameria-coldaia.png',
|
||||
walk: [[0, 360], [0, 300], [100, 260], [160, 220], [185, 220], [230, 250], [640, 250], [640, 360]],
|
||||
depth: [[220, 0.35], [360, 1.2]],
|
||||
entry: [300, 340],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'falegnameria', name: 'falegnameria', box: [160, 176, 120, 44], at: [220, 260], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'La falegnameria. L\'odore di segatura arriva fin qui.' }],
|
||||
open: [{ say: 'Bussare a una falegnameria? Mi danno una scopa e mi fanno spazzare.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fienile', name: 'fienile', box: [290, 90, 320, 140], at: [440, 280], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Un fienile pieno di rotoballe. Fossi una mucca, sarebbe il paradiso.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'a_gabbiano', name: 'a diritto, verso Gabbiano', box: [140, 190, 64, 32], at: [172, 222], exit: { scene: 'gabbiano', at: [300, 340], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'a_coldaia', name: 'indietro, verso Coldaia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'coldaia', at: [305, 170], face: 'down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// La strada di Gabbiano, in mezzo alla campagna del Mugello.
|
||||
gabbiano: {
|
||||
name: 'Gabbiano',
|
||||
map: [95, 93],
|
||||
background: 'assets/scenes/gabbiano.png',
|
||||
walk: [[0, 360], [0, 300], [200, 260], [400, 215], [430, 186], [445, 186], [520, 240], [640, 280], [640, 360]],
|
||||
depth: [[186, 0.3], [360, 1.2]],
|
||||
entry: [300, 340],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'valle', name: 'campagna', box: [150, 140, 260, 60], at: [330, 300], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'La campagna verso Gabbiano. Da quassù si vede mezzo Mugello.' }, { say: 'Il palio, però, no.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'casolare', name: 'casolare', box: [0, 30, 140, 220], at: [150, 320], face: 'left',
|
||||
actions: {
|
||||
look: [{ say: 'Un casolare vecchio, coi mattoni che spuntano dall\'intonaco. Come le mie ginocchia.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_falegnameria', name: 'indietro, verso la falegnameria', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'falegnameria_coldaia', at: [172, 224], face: 'down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// L'ingresso del campeggio: dalla Piazza per Via Cipriani e Via Massorondinaio; poco dopo, a destra, si sale al Bosco ai Frati.
|
||||
campeggio: {
|
||||
name: 'Ingresso del campeggio',
|
||||
map: [12, 84],
|
||||
@@ -887,7 +1100,7 @@ export const story = {
|
||||
id: 'bosco_ai_frati', name: 'salita verso il Bosco ai Frati', box: [570, 250, 70, 110], at: [628, 320], exit: { scene: 'bosco_ai_frati', at: [320, 345], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'verso_piazza', name: 'torna in Piazza', box: [150, 290, 100, 70], at: [175, 345], exit: { scene: 'piazza', at: [345, 240], face: 'left' },
|
||||
id: 'verso_piazza', name: 'Via Massorondinaio, verso la Piazza', box: [150, 290, 100, 70], at: [175, 345], exit: { scene: 'via_massorondinaio', at: [530, 236], face: 'down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -939,8 +1152,8 @@ export const story = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Il Comune e l'asilo infantile Antonio Frilli: dalla Piazza (a sinistra, prima di Via de' Medici) o dalla
|
||||
// strada di fronte alla farmacia.
|
||||
// Il Comune e l'asilo infantile Antonio Frilli: dalla strada di fronte alla farmacia o da Via Calimara
|
||||
// (a sinistra in Piazza, dove una volta si veniva qui, c'è la Piazzetta Cambray Digny: Andrea, 27 ottobre).
|
||||
comune: {
|
||||
name: 'Il Comune',
|
||||
map: [11, 45],
|
||||
@@ -969,9 +1182,6 @@ export const story = {
|
||||
open: [{ say: 'All\'asilo? Ho già dato, grazie.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_piazza', name: 'su, verso la Piazza', box: [0, 250, 40, 110], at: [14, 300], exit: { scene: 'piazza', at: [120, 252], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'al_giardino', name: 'verso il giardino di Via Calimara', box: [596, 290, 44, 70], at: [600, 340], exit: { scene: 'via_calimara', at: [160, 290], face: 'right' },
|
||||
},
|
||||
@@ -1069,7 +1279,7 @@ export const story = {
|
||||
id: 'al_ponte', name: 'a sinistra, verso il campo sportivo', box: [0, 216, 60, 100], at: [12, 318], exit: { scene: 'ponte_sieve', at: [300, 350], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'pieve', at: [24, 312], face: 'right' },
|
||||
id: 'alla_pieve', name: 'dietro la farmacia, verso la Pieve', box: [470, 180, 120, 60], at: [560, 240], exit: { scene: 'provinciale_pignone', at: [300, 345], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'area_feste', name: 'qui sotto, verso l\'area feste', box: [0, 318, 300, 42], at: [120, 352], exit: { scene: 'area_feste', at: [44, 350], face: 'up' },
|
||||
@@ -1082,6 +1292,90 @@ export const story = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Via Provinciale fra la rotonda (alle spalle) e la Pieve (in fondo); a destra sale Via del Pignone.
|
||||
provinciale_pignone: {
|
||||
name: 'Via Provinciale',
|
||||
map: [50, 64],
|
||||
background: 'assets/scenes/provinciale-pignone.png',
|
||||
walk: [[30, 360], [30, 330], [130, 270], [245, 212], [290, 212], [340, 228], [540, 232], [540, 258], [640, 300], [640, 360]],
|
||||
depth: [[212, 0.32], [360, 1.2]],
|
||||
entry: [300, 345],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'cartelli', name: 'cartelli', box: [570, 162, 52, 28], at: [560, 290], face: 'right',
|
||||
actions: {
|
||||
look: [{ say: 'Cartelli per la Pieve, per la stazione, per Firenze. Per il palio, nessuno.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_pieve', name: 'in fondo, verso la Pieve', box: [236, 150, 64, 64], at: [268, 216], exit: { scene: 'pieve', at: [24, 312], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'via_pignone', name: 'a destra, Via del Pignone', box: [440, 196, 100, 56], at: [530, 245], face: 'right', exit: { scene: 'via_pignone', at: [300, 345], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'alla_rotonda', name: 'indietro, verso la rotonda della farmacia', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'via_provinciale', at: [560, 240], face: 'left' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Via del Pignone dal basso: sul muro a destra il murale di Cistola (Davide Poggi, consenso del 27 ottobre).
|
||||
// In cima si arriva in Piazzetta Cambray Digny.
|
||||
via_pignone: {
|
||||
name: 'Via del Pignone',
|
||||
map: [46, 54],
|
||||
background: 'assets/scenes/via-del-pignone.png',
|
||||
walk: [[0, 360], [0, 300], [150, 258], [290, 226], [335, 206], [352, 206], [350, 240], [345, 282], [640, 290], [640, 360]],
|
||||
depth: [[206, 0.3], [360, 1.2]],
|
||||
entry: [300, 345],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'murale', name: 'murale di Cistola', box: [430, 36, 170, 240], at: [470, 300], face: 'right',
|
||||
actions: {
|
||||
look: [{ say: 'Cistola. Il talento di via del Pignone.' }, { say: 'Col dieci, come i grandi. Se c\'è un muro che se lo merita, è questo.' }],
|
||||
talk: [{ say: 'Ciao Cistola! ...Almeno dipinto stai fermo ad ascoltarmi.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'su_piazzetta', name: 'su, verso Piazzetta Cambray Digny', box: [316, 176, 56, 40], at: [343, 210], exit: { scene: 'piazzetta', at: [610, 345], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'giu_provinciale', name: 'giù, in Via Provinciale', box: [0, 334, 640, 26], at: [300, 354], exit: { scene: 'provinciale_pignone', at: [530, 245], face: 'left' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Piazzetta Cambray Digny, in cima a Via del Pignone, sotto Villa Schifanoia: a sinistra Via de' Medici, a diritto la Piazza.
|
||||
piazzetta: {
|
||||
name: 'Piazzetta Cambray Digny',
|
||||
map: [40, 38],
|
||||
background: 'assets/scenes/piazzetta-cambray-digny.png',
|
||||
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
|
||||
depth: [[268, 0.55], [360, 1.15]],
|
||||
entry: [320, 330],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'villa_schifanoia', name: 'Villa Schifanoia', box: [60, 110, 230, 170], at: [180, 300], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Il muro di Villa Schifanoia, la villa dei Medici, mezzo coperto dall\'edera.' }, { say: 'Lassù una volta c\'era una torre, per tenere d\'occhio chi passava sulla strada per Bologna. Ne ha viste più di me.' }, { say: 'E la piazzetta si chiama come i Cambray-Digny, gli ultimi signori della villa.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'casetta_bianca', name: 'casetta bianca', box: [296, 196, 160, 90], at: [380, 292], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Una casetta piccina, appoggiata al muro della villa come un gatto al sole.' }],
|
||||
open: [{ say: 'Chiusa. E poi non è casa mia.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alla_piazza', name: 'a diritto, verso la Piazza', box: [470, 226, 70, 48], at: [510, 274], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'via_de_medici', name: 'a sinistra, Via de\' Medici', box: [0, 280, 40, 80], at: [20, 320], face: 'left', exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
|
||||
},
|
||||
{
|
||||
id: 'giu_pignone', name: 'giù per Via del Pignone', box: [570, 300, 70, 60], at: [610, 345], exit: { scene: 'via_pignone', at: [343, 212], face: 'down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// L'area feste (il parco Antonio Berti): dalla rotonda del River, a sinistra.
|
||||
area_feste: {
|
||||
name: 'Area feste',
|
||||
@@ -1275,6 +1569,9 @@ export const story = {
|
||||
{
|
||||
id: 'al_principale', name: 'indietro, verso il campo principale', box: [0, 330, 640, 30], at: [100, 354], exit: { scene: 'polisportiva_principale', at: [620, 330], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'alle_mozzete', name: 'a destra, verso le Mozzète', box: [610, 226, 30, 100], at: [628, 290], face: 'right', exit: { scene: 'mozzete', at: [330, 206], face: 'down' },
|
||||
},
|
||||
{
|
||||
id: 'barondeduke', name: 'il BaronDeDuke', box: [212, 245, 36, 55], at: [190, 316], face: 'right', default: 'talk',
|
||||
give: {
|
||||
@@ -2398,6 +2695,19 @@ export const story = {
|
||||
},
|
||||
},
|
||||
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
|
||||
// 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,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.16';
|
||||
export const VERSION = '0.1.22';
|
||||
|
||||
// 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 { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||
#bocce-help summary, #biliardo-help summary, #basket-help summary { cursor: pointer; padding: 6px 0; }
|
||||
#bocce-help p, #biliardo-help p, #basket-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 { 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); }
|
||||
@@ -166,7 +166,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#splash .splash-present { font-size: clamp(12px, 2.6vw, 26px); }
|
||||
/* Compare solo se il browser aspetta un gesto per il suono (#splash.wait, main.js). */
|
||||
#splash .splash-hint { display: none; margin-top: 2em; font-size: clamp(9px, 1.6vw, 14px); color: var(--dim); animation: blink 1.6s steps(1) infinite; }
|
||||
#splash.wait .splash-hint { display: block; }
|
||||
#splash.wait .splash-hint { display: block; opacity: 1; }
|
||||
/* Finché si aspetta il tocco le scritte restano ferme all'inizio: partono insieme alla musichetta, e la caduta di
|
||||
«(Poca)» cade sul suo rumorino (Andrea, 26 ottobre). */
|
||||
#splash.wait .splash-name, #splash.wait .splash-art, #splash.wait .splash-art span, #splash.wait .splash-present { animation-play-state: paused; }
|
||||
@keyframes blink { 50% { opacity: .25; } }
|
||||
#title .subtitle { font-size: max(10px, calc(var(--u) * 1.8)); }
|
||||
#title .dedication { font-size: max(9px, calc(var(--u) * 1.5)); }
|
||||
@@ -256,10 +259,18 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#credits dt { text-align: right; color: var(--dim); font-size: clamp(7.2px, 1.01vw, 10.08px); letter-spacing: .12em; text-transform: uppercase; }
|
||||
#credits dd span { display: block; }
|
||||
/* I nomi col ritratto: si toccano e compare il volto a pixel sopra i Ringraziamenti. */
|
||||
#credits-reel { flex: 0 0 auto; height: clamp(56px, 24vh, 200px); width: 100%; overflow: hidden; margin: .5em 0 .3em;
|
||||
#credits-reel { flex: 0 0 auto; position: relative; height: clamp(56px, 24vh, 200px); width: 100%; margin: .5em 0 .3em; }
|
||||
/* La fila scorre da sola (main.js) ed è anche un normale scorrimento orizzontale: dito, rotella, trascinamento col mouse. */
|
||||
#credits-reel .track { display: flex; gap: 10px; height: 100%; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; touch-action: pan-x; cursor: grab;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
|
||||
#credits-reel .track { display: flex; gap: 10px; height: 100%; width: max-content; animation: reel 240s linear infinite; }
|
||||
#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; }
|
||||
#credits-reel .track::-webkit-scrollbar { display: none; }
|
||||
#credits-reel .track:active { cursor: grabbing; }
|
||||
#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; cursor: pointer; user-select: none; -webkit-user-drag: none; }
|
||||
#credits-reel img:hover { border-color: #fff; }
|
||||
#credits-reel .arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; width: 1.8em; height: 2.4em; padding: 0; border: 1px solid var(--accent); border-radius: 6px;
|
||||
background: rgba(15, 10, 20, .8); color: var(--accent); font: inherit; font-size: clamp(12px, 1.8vw, 20px); line-height: 1; cursor: pointer; opacity: .85; }
|
||||
#credits-reel .arrow:hover, #credits-reel .arrow:focus-visible { opacity: 1; color: #fff; }
|
||||
#credits-reel .arrow.prev { left: .3em; } #credits-reel .arrow.next { right: .3em; }
|
||||
#credits-reel img.face { aspect-ratio: 1; border-color: var(--accent); }
|
||||
#credits-reel img.palio { aspect-ratio: auto; border: 0; }
|
||||
/* La fine: il quadro del palio accanto alle parole (sotto, sugli schermi stretti). */
|
||||
@@ -272,14 +283,14 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
|
||||
#ending figure.palio figcaption { font-size: clamp(6.5px, .85vw, 9px); color: var(--dim); max-width: 22em; }
|
||||
#ending .ending-row > div { max-width: 26em; text-align: center; }
|
||||
@media (max-aspect-ratio: 1/1) { #ending .ending-row { flex-direction: column; } #ending figure.palio img { height: 30vh; } }
|
||||
@keyframes reel { to { transform: translateX(-50%); } }
|
||||
@media (prefers-reduced-motion: reduce) { #credits-reel .track { animation: none; } }
|
||||
#credits .credit-face { all: unset; display: block; cursor: pointer; color: var(--accent); text-decoration: underline dotted; text-underline-offset: .25em; }
|
||||
#credits .credit-face:hover, #credits .credit-face:focus-visible { color: #fff; }
|
||||
#face { position: absolute; inset: 0; margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .8em;
|
||||
background: rgba(15, 10, 20, .92); cursor: pointer; z-index: 1; }
|
||||
#face[hidden] { display: none; }
|
||||
#face img { height: min(60vmin, 384px); width: auto; max-width: 90%; image-rendering: pixelated; border: 3px solid var(--accent); border-radius: 8px; }
|
||||
/* Uno sfondo ingrandito: largo quanto può restando 16:9 dentro lo schermo. */
|
||||
#face img.scene { height: auto; width: min(92%, 128vh, 1024px); }
|
||||
#face figcaption { color: var(--ink); font-size: clamp(9px, 1.4vw, 14px); text-align: center; padding: 0 1em; }
|
||||
#credits dd { margin: 0; text-align: left; color: var(--ink); font-size: clamp(8px, 1.15vw, 12px); line-height: 1.6; font-weight: bold; }
|
||||
#credits .legal { color: var(--dim); font-size: clamp(7.2px, 0.86vw, 9.36px); opacity: .8; }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { staticServer } from './helpers.mjs';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { solution, route } from '../src/story/solution.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { reelPictures } from '../src/engine/game.js';
|
||||
import { checkMultimedia } from './multimedia.browser.mjs';
|
||||
|
||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||||
@@ -120,6 +122,35 @@ try {
|
||||
await page.screenshot({ path: `${OUT}/01d-ritratto.png` });
|
||||
await page.click('#face');
|
||||
assert.ok(await page.isHidden('#face'));
|
||||
// Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una
|
||||
// foto, un tocco su una foto la mostra grande col nome del posto.
|
||||
{
|
||||
const track = page.locator('#credits-reel .track');
|
||||
assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri');
|
||||
await page.mouse.move(5, 5);
|
||||
const x0 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(600);
|
||||
const x1 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`);
|
||||
await track.hover({ position: { x: 20, y: 20 } });
|
||||
const x2 = await track.evaluate(t => t.scrollLeft);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma');
|
||||
await page.click('#credits-reel .arrow.next');
|
||||
await page.waitForTimeout(800);
|
||||
const x3 = await track.evaluate(t => t.scrollLeft);
|
||||
assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`);
|
||||
const third = track.locator('img').nth(2);
|
||||
const name = await third.getAttribute('alt');
|
||||
await third.click();
|
||||
assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande');
|
||||
assert.equal(await page.textContent('#face figcaption'), name);
|
||||
await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti');
|
||||
assert.ok(await page.isVisible('#credits'));
|
||||
}
|
||||
const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
|
||||
assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
|
||||
@@ -316,7 +347,7 @@ try {
|
||||
assert.ok(await page.isVisible('#map-none'), 'nessun posto: lo dice');
|
||||
await page.fill('#map-search', '');
|
||||
await page.click('#map-search');
|
||||
await page.keyboard.type('piaz');
|
||||
await page.keyboard.type('piazza col'); // «piaz» da solo trova anche la Piazzetta Cambray Digny
|
||||
assert.ok(await page.isVisible('#map'), 'scrivere «p» nella ricerca non chiude la mappa');
|
||||
assert.ok(await page.isHidden('#map-none'));
|
||||
const shown = await page.locator('#map-board button:visible').evaluateAll(bs => bs.map(b => b.dataset.scene));
|
||||
@@ -418,6 +449,40 @@ 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();
|
||||
}
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -15,7 +15,7 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
|
||||
const { g, c, at } = setup();
|
||||
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
|
||||
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
|
||||
assert.equal(c.sentence('verso_villa_adami'), 'Vai a Via de\' Medici, verso Villa Adami');
|
||||
assert.equal(c.sentence('verso_piazzetta'), 'Vai a Piazzetta Cambray Digny, verso Via de\' Medici');
|
||||
assert.equal(c.clickScene(at('vecchietti')), 'act');
|
||||
settle(g);
|
||||
assert.equal(g.dialogue?.id, 'vecchietti');
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { backgroundOrder } from '../src/engine/render.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { solution } from '../src/story/solution.js';
|
||||
import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace } from '../src/engine/game.js';
|
||||
import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace, reelPictures } from '../src/engine/game.js';
|
||||
import { inside } from '../src/engine/geometry.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { settle, exploreTorch } from './helpers.mjs';
|
||||
@@ -144,6 +144,20 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
|
||||
assert.deepEqual(seen, ids);
|
||||
});
|
||||
|
||||
test('il carosello dei Ringraziamenti: ogni sfondo una volta sola col nome del posto, tutti i ritratti, mai due ritratti vicini', () => {
|
||||
const reel = reelPictures(story);
|
||||
const backgrounds = new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean));
|
||||
assert.deepEqual(reel.filter(p => p.kind === 'scene').map(p => p.src).sort(), [...backgrounds].sort(), 'tutti gli sfondi, senza doppioni');
|
||||
for (const p of reel) { assert.ok(p.name, `${p.src} senza nome`); assert.ok(existsSync(new URL('../' + p.src, import.meta.url)), `manca ${p.src}`); }
|
||||
const faces = story.credits.flatMap(c => c.names).filter(n => n.face);
|
||||
assert.deepEqual(reel.filter(p => p.kind !== 'scene').map(p => p.src).sort(), faces.map(n => n.face).sort(), 'tutti i ritratti, una volta');
|
||||
assert.ok(reel.some(p => p.kind === 'palio'), 'il palio ha la sua cornice');
|
||||
reel.forEach((p, i) => { if (i && p.kind !== 'scene') assert.equal(reel[i - 1].kind, 'scene', `due ritratti di fila: ${reel[i - 1].name}, ${p.name}`); });
|
||||
// Poche scene: i ritratti che non trovano posto chiudono comunque la fila.
|
||||
const few = reelPictures({ scenes: { a: { name: 'A', background: 'x.png' } }, credits: story.credits });
|
||||
assert.equal(few.length, 1 + faces.length);
|
||||
});
|
||||
|
||||
test('la ricerca della mappa: pezzi di parola, senza maiuscole, accenti e apostrofi', () => {
|
||||
assert.ok(matchPlace('Campomigliaio', 'camp'));
|
||||
assert.ok(matchPlace('Via de\' Medici', 'de medici'));
|
||||
|
||||
@@ -49,5 +49,9 @@
|
||||
},
|
||||
"ing_tozzi": {
|
||||
"speaker": "Badr Odhiambo"
|
||||
},
|
||||
"pescatori": {
|
||||
"speaker": "Dionisio Schuyler",
|
||||
"filtro": "asetrate=24000*1.08,aresample=24000,atempo=1.04"
|
||||
}
|
||||
}
|
||||
|
||||