diff --git a/assets/characters/andrea-basket.json b/assets/characters/andrea-basket.json new file mode 100644 index 0000000..f9ddc84 --- /dev/null +++ b/assets/characters/andrea-basket.json @@ -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 + } + } +} diff --git a/assets/characters/andrea-basket.png b/assets/characters/andrea-basket.png new file mode 100644 index 0000000..c546589 Binary files /dev/null and b/assets/characters/andrea-basket.png differ diff --git a/assets/closeups/basket-campo.png b/assets/closeups/basket-campo.png new file mode 100644 index 0000000..358c1a4 Binary files /dev/null and b/assets/closeups/basket-campo.png differ diff --git a/assets/voices/andrea-1bc731bcdf.mp3 b/assets/voices/andrea-1bc731bcdf.mp3 new file mode 100644 index 0000000..549283f Binary files /dev/null and b/assets/voices/andrea-1bc731bcdf.mp3 differ diff --git a/assets/voices/andrea-2e9d8549c8.mp3 b/assets/voices/andrea-2e9d8549c8.mp3 new file mode 100644 index 0000000..dd1984e Binary files /dev/null and b/assets/voices/andrea-2e9d8549c8.mp3 differ diff --git a/assets/voices/andrea-3c4ee692ad.mp3 b/assets/voices/andrea-3c4ee692ad.mp3 new file mode 100644 index 0000000..9c862c1 Binary files /dev/null and b/assets/voices/andrea-3c4ee692ad.mp3 differ diff --git a/assets/voices/andrea-5e3315a040.mp3 b/assets/voices/andrea-5e3315a040.mp3 new file mode 100644 index 0000000..91f3225 Binary files /dev/null and b/assets/voices/andrea-5e3315a040.mp3 differ diff --git a/assets/voices/andrea-8cd2b5a36d.mp3 b/assets/voices/andrea-8cd2b5a36d.mp3 deleted file mode 100644 index 5042874..0000000 Binary files a/assets/voices/andrea-8cd2b5a36d.mp3 and /dev/null differ diff --git a/assets/voices/andrea-d564365fc2.mp3 b/assets/voices/andrea-d564365fc2.mp3 new file mode 100644 index 0000000..950789f Binary files /dev/null and b/assets/voices/andrea-d564365fc2.mp3 differ diff --git a/assets/voices/andrea-e4ec02fa6b.mp3 b/assets/voices/andrea-e4ec02fa6b.mp3 new file mode 100644 index 0000000..8a5cf91 Binary files /dev/null and b/assets/voices/andrea-e4ec02fa6b.mp3 differ diff --git a/assets/voices/andrea-ec8f0db709.mp3 b/assets/voices/andrea-ec8f0db709.mp3 new file mode 100644 index 0000000..1781e86 Binary files /dev/null and b/assets/voices/andrea-ec8f0db709.mp3 differ diff --git a/assets/voices/andrea-ee42022092.mp3 b/assets/voices/andrea-ee42022092.mp3 new file mode 100644 index 0000000..ac7a3bb Binary files /dev/null and b/assets/voices/andrea-ee42022092.mp3 differ diff --git a/assets/voices/andrea-f5519518f2.mp3 b/assets/voices/andrea-f5519518f2.mp3 new file mode 100644 index 0000000..e800ed5 Binary files /dev/null and b/assets/voices/andrea-f5519518f2.mp3 differ diff --git a/assets/voices/andrea-fa062d00bd.mp3 b/assets/voices/andrea-fa062d00bd.mp3 new file mode 100644 index 0000000..c2aee5a Binary files /dev/null and b/assets/voices/andrea-fa062d00bd.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 39e8729..6f223c6 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -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 diff --git a/docs/STATO.md b/docs/STATO.md index c5201b8..6888f55 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -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, diff --git a/index.html b/index.html index 91e5fed..6fc76d2 100644 --- a/index.html +++ b/index.html @@ -209,6 +209,14 @@
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.
+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.
+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.
+Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla. diff --git a/src/engine/basket-render.js b/src/engine/basket-render.js new file mode 100644 index 0000000..61cd704 --- /dev/null +++ b/src/engine/basket-render.js @@ -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(); +} diff --git a/src/engine/basket.js b/src/engine/basket.js new file mode 100644 index 0000000..d7ed22e --- /dev/null +++ b/src/engine/basket.js @@ -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}`; +} diff --git a/src/engine/game.js b/src/engine/game.js index a8812a8..dd90bb4 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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']; diff --git a/src/engine/render.js b/src/engine/render.js index 38082c4..f727eeb 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -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')); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 8130cd4..45e79f6 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -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. diff --git a/src/main.js b/src/main.js index 3231bec..f53bd91 100644 --- a/src/main.js +++ b/src/main.js @@ -10,6 +10,7 @@ import { Soundscape } from './engine/soundscape.js'; import { soundscapeFor } from './story/soundscapes.js'; import * as bocce from './engine/bocce.js'; import * as biliardo from './engine/biliardo.js'; +import * as basket from './engine/basket.js'; import * as briscola from './engine/briscola.js'; import { cardAt } from './engine/briscola-render.js'; import * as tressette from './engine/tressette.js'; @@ -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 }); diff --git a/src/story/story.js b/src/story/story.js index 568a70b..0370f36 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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: { diff --git a/style.css b/style.css index 71b93d8..7d90d49 100644 --- a/style.css +++ b/style.css @@ -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); } diff --git a/tests/basket.test.mjs b/tests/basket.test.mjs new file mode 100644 index 0000000..37bd749 --- /dev/null +++ b/tests/basket.test.mjs @@ -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); +}); diff --git a/tests/browser.mjs b/tests/browser.mjs index 8ba57e3..f11c1d8 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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 });