Compare commits

...
6 Commits
Author SHA1 Message Date
manzolo dea124933e Versione 0.1.22 2026-10-28 00:31:08 +01:00
manzoloandClaude Fable 5.1 51a46fe5a0 Effetti ambientali nelle dieci scene del 27 ottobre (Andrea: «applicale tutte»)
Nuvole, rondini e ombre di foglie dove ci sono alberi e cielo; ombra di nuvola sui campi di Gabbiano, Coldaia, le
Mozzète e la Provinciale; alberi al vento a Gabbiano e Via Massorondinaio; farfalle alla Piazzetta, a Gabbiano e
Coldaia, la libellula al ponticino delle Mozzète; piccioni e merli sulla Provinciale e alla falegnameria, col pulviscolo
nel fienile; di sera la lanterna della Piazzetta, il lampione di Via del Pignone, le lampade del Cinema Taiuti e il
cartellone del Rattattù illuminato.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-28 00:25:01 +01:00
manzolo f6fb8922af Versione 0.1.21 2026-10-27 23:56:31 +01:00
manzoloandClaude Fable 5.1 bd7fbc3003 Basket: alla gloria Andrea sfida Jordan, non Cistola (Andrea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:50:10 +01:00
manzoloandClaude Fable 5.1 8b957c7cf7 Basket: la revisione di Codex, i punti gravi
Dalla mira iniziale (52°) da sotto non entrava con nessuna forza: ora 64°, forza minima più bassa e prima postazione
un passo indietro, così da ogni postazione entra in una fascia di forza contigua di almeno l'8% (test). Il palo
fermava anche sopra la sua cima e contava come tiro pulito; il tabellone lasciava passare da dietro. Il canestro
conta appena entra (anche il risultato all'ultimo pallone), la pausa fra un pallone e l'altro è più corta, la palla
entrata non rotola fino in fondo. Andrea resta a braccia alzate finché la palla vola (niente doppio pallone), il
rilascio parte sopra la sua testa. La guida si ferma al primo urto e ha un bordo scuro con la punta; il lampo del
canestro si spegne; l'obiettivo del livello è sempre in vista; i microurti sul ferro non tintinnano; dietro Andrea
non si carica; l'aiuto spiega il clic che fissa la mira e le soglie.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:39:30 +01:00
manzoloandClaude Fable 5.1 f1de62f01e «Fai due tiri» al campo da basket del Circolo 4 Maggio
Minigioco nuovo (idea di Andrea, 27 ottobre): vista di lato, Andrea da solo con dieci palloni; il mouse o il dito
danno l'angolo, tenendo premuto sale la forza, lasciando la palla vola in parabola, rimbalza sul ferro (davanti e
dietro dell'anello), sul tabellone e per terra, entra anche sporca; da sotto non vale. A ogni canestro si arretra
di una postazione fino a dietro la linea da tre (tre punti). Tre livelli: quanti canestri su dieci per la gloria
(3, 5, 7) e quanta parabola mostra la guida (quasi tutta, l'inizio, niente). Sfondo del campo e foglio di Andrea col
pallone di Codex; suoni di palla, ferro, tabellone e retina; stessi comandi delle bocce (pannello, tastiera, pressione);
dialogo di scelta dal campo di Via Cipriani, battute provvisorie e voci; test del motore (da ogni postazione esiste
un tiro che entra) e prova nel browser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
2026-10-27 23:16:35 +01:00
30 changed files with 706 additions and 18 deletions
+38
View File
@@ -0,0 +1,38 @@
{
"image": "andrea-basket.png",
"height": 120,
"frames": {
"hold": {
"x": 0,
"y": 0,
"w": 51,
"h": 120,
"ax": 19.1,
"ay": 120
},
"load": {
"x": 53,
"y": 0,
"w": 62,
"h": 106,
"ax": 32.9,
"ay": 106
},
"shoot": {
"x": 117,
"y": 0,
"w": 69,
"h": 124,
"ax": 30.0,
"ay": 124
},
"cheer": {
"x": 188,
"y": 0,
"w": 59,
"h": 118,
"ax": 26.5,
"ay": 118
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39 -3
View File
@@ -543,9 +543,9 @@
"file": "andrea-5e37f70197.mp3", "file": "andrea-5e37f70197.mp3",
"sec": 5.35 "sec": 5.35
}, },
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Prima o poi due tiri ce li faccio.": { "andrea|Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.": {
"file": "andrea-8cd2b5a36d.mp3", "file": "andrea-3c4ee692ad.mp3",
"sec": 6.49 "sec": 7.55
}, },
"andrea|La fermata della corriera. Da qui si va dappertutto. Se passa.": { "andrea|La fermata della corriera. Da qui si va dappertutto. Se passa.": {
"file": "andrea-ccd735c926.mp3", "file": "andrea-ccd735c926.mp3",
@@ -2035,6 +2035,30 @@
"file": "arcu-406cd02197.mp3", "file": "arcu-406cd02197.mp3",
"sec": 4.46 "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?": { "andrea|Mariano! Facciamo una partita a bocce?": {
"file": "andrea-83b1337b4d.mp3", "file": "andrea-83b1337b4d.mp3",
"sec": 3.31 "sec": 3.31
@@ -2279,6 +2303,18 @@
"file": "mariano-39e27158b7.mp3", "file": "mariano-39e27158b7.mp3",
"sec": 2.83 "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.": { "ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": {
"file": "ing_tozzi-51f855478a.mp3", "file": "ing_tozzi-51f855478a.mp3",
"sec": 4.42 "sec": 4.42
+9 -2
View File
@@ -205,8 +205,15 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
murale di Cistola (Davide Poggi), «il talento di via del Pignone», alla maniera dei murali di Maradona a Napoli 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. (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 - Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile), campo da basket del Circolo: **da fare il `assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
gioco del basket** (idea di Andrea, 27 ottobre). (`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; - 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. 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, - In Fortezza quattro ragazzi inventati si fanno le canne da pesca col bambù per il lago dell'Anatre (sprite di Codex,
+10
View File
@@ -209,6 +209,16 @@
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria. <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> Vince chi fa più punti nella mano.</p>
</details> </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> <details id="biliardo-help" hidden><summary>Come si gioca</summary>
<button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button> <button class="tutorial-open" type="button" data-tutorial="biliardo" hidden>Guarda il video</button>
<p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla. <p>Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.20", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+113
View File
@@ -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();
}
+170
View File
@@ -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}`;
}
+2
View File
@@ -3,6 +3,7 @@
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js'; import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import * as bocce from './bocce.js'; import * as bocce from './bocce.js';
import * as biliardo from './biliardo.js'; import * as biliardo from './biliardo.js';
import * as basket from './basket.js';
import * as briscola from './briscola.js'; import * as briscola from './briscola.js';
import * as tressette from './tressette.js'; import * as tressette from './tressette.js';
import * as scopa from './scopa.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 }, 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 }, 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 }, 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']; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
+6
View File
@@ -5,6 +5,7 @@ import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js'; import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js'; import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js'; import { drawBiliardo } from './biliardo-render.js';
import { drawBasket } from './basket-render.js';
import { drawBriscola } from './briscola-render.js'; import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js'; import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-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); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; 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 (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture); if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi')); drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
+7
View File
@@ -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-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-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); } else if (kind === '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 === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'tavolo-lontano') { else if (kind === 'tavolo-lontano') {
// Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano. // Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano.
+8 -5
View File
@@ -10,6 +10,7 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js'; import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js'; import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js'; import * as biliardo from './engine/biliardo.js';
import * as basket from './engine/basket.js';
import * as briscola from './engine/briscola.js'; import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js'; import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js'; import * as tressette from './engine/tressette.js';
@@ -496,11 +497,11 @@ canvas.addEventListener('pointerdown', e => {
return; return;
} }
const aimed = game.closeupAim(scenePoint(e)); const aimed = game.closeupAim(scenePoint(e));
if (story.closeups[game.closeup.id].type === 'biliardo') { if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; 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 (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 }; press = { closeup: true };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(); if (!game.skip()) game.closeupPress();
@@ -550,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, // 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. // 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.) // (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)); } 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)); else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
@@ -563,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.' }, 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.' }, 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.' }, 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 miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type]; const mini = () => MINI[story.closeups[game.closeup.id].type];
@@ -904,7 +906,7 @@ function updateUi() {
} }
} }
if (mg && !cards) { 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; $('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.names); const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
@@ -914,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-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'; $('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo'; $('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.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 }); if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
+58
View File
@@ -68,4 +68,62 @@ export const ambience = {
viale_cipressi: [{ type: 'cloudshadow', phase: day, area: [95, 225, 435, 135], offset: 11000, viale_cipressi: [{ type: 'cloudshadow', phase: day, area: [95, 225, 435, 135], offset: 11000,
clip: [[300, 225], [337, 225], [530, 360], [95, 360]] }], clip: [[300, 225], [337, 225], [530, 360], [95, 360]] }],
trebbio: [shade([278, 277, 112, 23], 6)], 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]] },
],
}; };
+35 -2
View File
@@ -150,6 +150,22 @@ export const story = {
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }], quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }], 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] // 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: { biliardo: {
type: 'biliardo', type: 'biliardo',
@@ -215,6 +231,8 @@ export const story = {
// [DA ANDREA: chi è] Figura inventata, nessuna persona reale. // [DA ANDREA: chi è] Figura inventata, nessuna persona reale.
giocatore_carte: { name: 'Il giocatore di carte', color: '#edc476', sprite: 'assets/characters/giocatore-carte.json' }, 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: { 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. // 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' }, pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
@@ -897,9 +915,11 @@ export const story = {
}, },
}, },
{ {
id: 'campo_basket', name: 'campo da basket del Circolo', box: [250, 110, 170, 80], at: [340, 316], face: 'up', id: 'campo_basket', name: 'campo da basket del Circolo', box: [250, 110, 170, 80], at: [340, 316], face: 'up', default: 'use',
actions: { actions: {
look: [{ say: 'Il campo da basket del Circolo, lassù oltre la rete. Prima o poi due tiri ce li faccio.' }], look: [{ say: 'Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.' }],
use: [{ dialogue: 'basket' }],
walk: [{ dialogue: 'basket' }],
}, },
}, },
{ {
@@ -2675,6 +2695,19 @@ export const story = {
}, },
}, },
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico. // 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: { mariano: {
nodes: { nodes: {
start: { start: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.20'; export const VERSION = '0.1.22';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // 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). // mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+4 -4
View File
@@ -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-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-throw { background: #ead09a; color: #202b25; min-width: 100px; }
.bocce-adjust button:disabled { opacity: .4; cursor: default; } .bocce-adjust button:disabled { opacity: .4; cursor: default; }
#bocce-help, #biliardo-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } #bocce-help, #biliardo-help, #basket-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary, #biliardo-help summary { cursor: pointer; padding: 6px 0; } #bocce-help summary, #biliardo-help summary, #basket-help summary { cursor: pointer; padding: 6px 0; }
#bocce-help p, #biliardo-help p { max-width: 76em; line-height: 1.7; } #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) { @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
.bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust { flex-wrap: wrap; gap: 6px; }
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
#bocce-throw { flex-basis: 100%; } #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-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); } #effects-volume { width: 130px; accent-color: var(--accent); }
+171
View File
@@ -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);
});
+34
View File
@@ -449,6 +449,40 @@ try {
await bocce.close(); 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. // 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 }); const b = await open({ width: 1280, height: 860 });