«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
+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 });