«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
This commit is contained in:
2026-10-27 23:16:35 +01:00
co-authored by Claude Fable 5.1
parent 64dc8e18e7
commit f1de62f01e
27 changed files with 563 additions and 14 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",
"sec": 5.35
},
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Prima o poi due tiri ce li faccio.": {
"file": "andrea-8cd2b5a36d.mp3",
"sec": 6.49
"andrea|Il campo da basket del Circolo, lassù oltre la rete. Due tiri ce li faccio sempre volentieri.": {
"file": "andrea-3c4ee692ad.mp3",
"sec": 7.55
},
"andrea|La fermata della corriera. Da qui si va dappertutto. Se passa.": {
"file": "andrea-ccd735c926.mp3",
@@ -2035,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
@@ -2279,6 +2303,18 @@
"file": "mariano-39e27158b7.mp3",
"sec": 2.83
},
"andrea|Che mano! Cistola, fatti da parte.": {
"file": "andrea-e4ec02fa6b.mp3",
"sec": 3.61
},
"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
+4 -2
View File
@@ -205,8 +205,10 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
murale di Cistola (Davide Poggi), «il talento di via del Pignone», alla maniera dei murali di Maradona a Napoli
(volto da `assets/raw/riferimenti/cistola.png`). Consenso di Davide confermato da Andrea il 2026-10-27.
- Via Pietro Cipriani (fra Piazza e campeggio): Cinema Taiuti aperto con la locandina del Rattattù 2007 (stile da
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile), campo da basket del Circolo: **da fare il
gioco del basket** (idea di Andrea, 27 ottobre).
`assets/raw/riferimenti/rattattu-locandina.png`, scaletta illeggibile). Al campo da basket del Circolo, «Fai due tiri»
(`src/engine/basket.js`, vista di lato, sfondo e sprite di Andrea col pallone da Codex): da solo, dieci palloni, a ogni
canestro si arretra fino alla linea da tre; tre livelli (quanti canestri per la gloria, quanta parabola mostra la
guida). Battute provvisorie. Idea di Andrea del 27 ottobre.
- 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,
+8
View File
@@ -209,6 +209,14 @@
<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 o tocca il campo per scegliere l’angolo del tiro: la freccia parte dalle mani di Andrea.
Tieni premuto sul campo (o Spazio) per caricare la forza e lascia per tirare; oppure regola la forza e premi Tira.
Frecce ← →: angolo; Maiusc + ← →: a mezzo grado; ↑ ↓: forza. Esc esce.</p>
<p>Dieci palloni. Si comincia da sotto; a ogni canestro si arretra di una postazione, fino a dietro la linea da tre,
dove un canestro vale tre punti invece di due. Il ferro e il tabellone rimandano la palla: entra anche sporca.
Alla bona la guida mostra quasi tutta la parabola, 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.
+102
View File
@@ -0,0 +1,102 @@
import { FLOOR, RIM, BALL, BALLS, SPOTS, canThrow, left, spot, releasePoint, arc, status } from './basket.js';
import { text, panel } from './bocce-render.js';
const ANDREA = { scale: 0.66 };
// Quale posa: carica mentre tiene premuto, il salto appena lasciata la palla, l'esultanza quando entra.
export function andreaPose(c) {
if (c.holding) return 'load';
if (c.step === 'flight' && c.ball.time < 0.45) return 'shoot';
if ((c.step === 'settle' || (c.step === 'flight' && c.ball.scored)) && 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; la parabola per quanto concede il livello.
const a = c.angle * Math.PI / 180, len = 22 + c.power * 18;
ctx.strokeStyle = 'rgba(255,246,210,0.9)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(r.x, r.y); ctx.lineTo(r.x + Math.cos(a) * len, r.y - Math.sin(a) * len); ctx.stroke();
if (c.skill.preview > 0) {
ctx.setLineDash([2, 4]); ctx.strokeStyle = 'rgba(143,199,255,0.8)';
ctx.beginPath();
for (const [i, p] of arc(c, c.skill.preview).entries()) { if (p.y > FLOOR) break; if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
ctx.stroke(); ctx.setLineDash([]);
}
text(ctx, `${Math.round(c.angle)}°`, 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 (!b?.scored || b.time - (b.scoredAt ?? b.time) > 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, 156, 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} canestri · ${c.points} punti`, 26, 131, 6, '#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();
}
+149
View File
@@ -0,0 +1,149 @@
// «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 }, NET = 190, POLE = 562;
export const BALL = 8, G = 560, V_MIN = 230, V_MAX = 560, SWING = 1.4, BALLS = 10;
export const FIXED_STEP = 1 / 240, REST = 0.9, FLIGHT_MAX = 7;
export const RELEASE = { dx: 16, dy: -86 }; // da dove parte la palla rispetto ai piedi di Andrea
export const ANGLE = { min: 25, max: 78 };
export const SPOTS = [
{ x: 420, 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.
export const LEVELS = {
bona: { goal: 3, preview: 1.1 },
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: 52, 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;
}
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, 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; hit(c, 'basket-rim', -closing / 400);
}
}
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. Il palo dietro ferma quel che passa oltre.
if (b.vx > 0 && b.x + BALL > BOARD.x && b.x - BALL < BOARD.x + 8 && b.y + BALL > BOARD.top && b.y - BALL < BOARD.bottom) {
b.x = BOARD.x - BALL; b.vx = -b.vx * 0.6; b.board = true; hit(c, 'basket-board', Math.abs(b.vx) / 300);
}
if (b.x + BALL > POLE) { b.x = POLE - BALL; if (b.vx > 0) { b.vx = -b.vx * 0.5; 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.vx *= 0.35; b.vy *= 0.5;
hit(c, b.rim || b.board ? 'basket-in' : 'basket-swish', 0.7);
} 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; }
}
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.dead = true;
}
function nextBall(c) {
const b = c.ball, s = spot(c);
c.history.push({ spot: c.spot, scored: b.scored, rim: b.rim, board: b.board });
if (b.scored) { c.baskets++; c.points += s.points; c.spot = Math.min(c.spot + 1, SPOTS.length - 1); }
if (c.thrown >= BALLS) {
c.step = 'result';
c.result = { winner: c.baskets >= c.skill.goal ? 'player' : 'cpu', baskets: c.baskets, points: c.points, goal: c.skill.goal };
} 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}, 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 * 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'];
+6
View File
@@ -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'));
+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-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.
+6 -3
View File
@@ -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';
@@ -500,7 +501,7 @@ canvas.addEventListener('pointerdown', e => {
if (!aimed && !game.speech) return;
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();
@@ -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,
// 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));
});
@@ -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.' },
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];
@@ -904,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;
@@ -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-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 });
+35 -2
View File
@@ -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! Cistola, 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,8 @@ 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' },
@@ -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: {
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.
// 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: {
+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-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); }
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBasket, press, release, cancel, update, shoot, setAngle, setPower, aimScreen, nudge, arc, status, canThrow, releasePoint,
powerAt, SWING, BALLS, SPOTS, LEVELS, RIM, BOARD, 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);
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('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, 30); 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();
}
// «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 });