Bocce più vere (lavoro di Codex): mira libera sul campo o nel dettaglio del pallino, forza regolabile senza tempismo, urti fra masse diverse, tira chi non ha il punto, distanze in centimetri, suoni di rotolamento e urto, comandi anche a pulsanti e tastiera; test aggiornati alle nuove regole

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 19:26:23 +01:00
co-authored by Claude Opus 5.5
parent a0201fea18
commit 44485cff52
10 changed files with 386 additions and 154 deletions
+19
View File
@@ -103,6 +103,25 @@
<div id="inventory" aria-label="Inventario"></div>
</div>
<ol id="choices" hidden></ol>
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
<p id="bocce-status" role="status"></p>
<div class="bocce-adjust">
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
<input id="bocce-power" type="range" min="0" max="100" step="1" value="50" aria-label="Forza del lancio">
</label>
<button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button>
<button id="bocce-throw" type="button">Lancia</button>
</div>
<details id="bocce-help"><summary>Come si gioca</summary>
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
Frecce ← →: mira; ↑ ↓: forza. Esc esce dalla mano.</p>
<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>
</section>
</div>
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
</div>
+130
View File
@@ -0,0 +1,130 @@
import { project, detailPoint, DETAIL, HALF, canThrow, landing, left, closest, gap, score, status } from './bocce.js';
import { ambientHash as hash } from './ambient.js';
const FONT = '"Press Start 2P", monospace';
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
}
function panel(ctx, x, y, w, h) {
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
}
function sphere(ctx, b, x, y, r, top = false) {
const [dark, mid, light] = COLORS[b.jack ? 'jack' : b.who];
ctx.fillStyle = 'rgba(29,22,13,0.4)'; ctx.beginPath();
ctx.ellipse(x + r * 0.4, y + r * 0.12, r * 1.25, r * 0.45, 0.3, 0, Math.PI * 2); ctx.fill();
const cy = top ? y : y - r;
const shade = ctx.createRadialGradient(x - r * 0.35, cy - r * 0.4, 0, x, cy, r * 1.1);
shade.addColorStop(0, light); shade.addColorStop(0.28, mid); shade.addColorStop(1, dark);
ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(x, cy, r, 0, Math.PI * 2); ctx.fill();
if (!b.jack) {
ctx.save(); ctx.beginPath(); ctx.arc(x, cy, r - 0.2, 0, Math.PI * 2); ctx.clip();
ctx.strokeStyle = 'rgba(240,230,210,0.65)'; ctx.lineWidth = Math.max(0.6, r * 0.1);
// Le incisioni si spostano con la distanza percorsa: si vede il rotolamento.
const offset = Math.sin(b.spin ?? 0) * r * 0.8;
ctx.beginPath(); ctx.ellipse(x + offset, cy, Math.max(0.5, r * 0.35), r * 1.1, -0.3, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.35), Math.round(cy - r * 0.4), 1, 1);
}
function cross(ctx, p, radius, color) {
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.45, 0, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(p.x - radius - 3, p.y); ctx.lineTo(p.x + radius + 3, p.y);
ctx.moveTo(p.x, p.y - radius * 0.45 - 3); ctx.lineTo(p.x, p.y + radius * 0.45 + 3); ctx.stroke();
}
const cm = (c, b) => b ? `${(gap(b, c.jack) * 20).toFixed(1)} cm` : '—';
function detail(ctx, c, def) {
const d = DETAIL;
panel(ctx, d.x, d.y, d.w, d.h);
text(ctx, 'VICINO AL PALLINO', d.x + d.w / 2, d.y + 13, 6, '#eed5a0', 'center');
ctx.save(); ctx.beginPath(); ctx.rect(d.x + 5, d.y + 25, d.w - 10, d.h - 49); ctx.clip();
ctx.fillStyle = '#b79a6c'; ctx.fillRect(d.x, d.y + 25, d.w, d.h);
for (let i = 0; i < 160; i++) {
ctx.fillStyle = i % 2 ? '#c9b17d' : '#9c8059';
ctx.fillRect(Math.round(d.x + hash(i + 5) * d.w), Math.round(d.y + 25 + hash(i + 190) * (d.h - 49)), 1, 1);
}
// Le sponde compaiono anche nel dettaglio quando il pallino si avvicina al bordo.
for (const w of [-HALF, HALF]) {
const p = detailPoint(c, { u: c.jack.u, w });
ctx.fillStyle = '#73513b'; ctx.fillRect(p.x - 2, d.y + 25, 4, d.h);
}
const jack = detailPoint(c, c.jack), leader = score(c).winner;
for (const who of ['player', 'cpu']) {
const b = closest(c, who);
if (!b) continue;
const p = detailPoint(c, b);
ctx.strokeStyle = who === 'player' ? '#315d92' : '#923827'; ctx.setLineDash([2, 2]);
ctx.beginPath(); ctx.moveTo(jack.x, jack.y); ctx.lineTo(p.x, p.y); ctx.stroke(); ctx.setLineDash([]);
if (leader === who) { ctx.strokeStyle = '#fff2a3'; ctx.beginPath(); ctx.arc(p.x, p.y, b.r * d.scale + 3, 0, Math.PI * 2); ctx.stroke(); }
}
if (canThrow(c)) {
const target = detailPoint(c, c.aim), end = detailPoint(c, landing(c));
cross(ctx, target, 6, '#fff4c9');
ctx.setLineDash([2, 2]); cross(ctx, end, 5, '#315d92'); ctx.setLineDash([]);
}
for (const b of [...c.balls, c.jack]) {
const p = detailPoint(c, b);
sphere(ctx, b, p.x, p.y, Math.max(b.jack ? 2 : 3, b.r * d.scale), true);
}
ctx.restore();
text(ctx, 'Tocca qui per mirare', d.x + d.w / 2, d.y + d.h - 12, 6, '#d7ccb4', 'center');
for (const [i, who] of ['player', 'cpu'].entries()) {
text(ctx, `${def.names[who]}: ${cm(c, closest(c, who))}`, d.x + 2, d.y + d.h + 13 + i * 14, 6, COLORS[who][2]);
}
}
export function drawBocce(ctx, c, def, bg) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else { ctx.fillStyle = '#bca16d'; ctx.fillRect(0, 0, 640, 360); }
// Polvere breve dietro l'ultima boccia, agganciata al terreno e subito dissolta.
for (const [i, t] of c.trails.entries()) {
const p = project(t.u, t.w);
ctx.fillStyle = `rgba(218,195,145,${0.22 * (1 - t.age / 0.55)})`;
ctx.fillRect(Math.round(p.x + (hash(i) - 0.5) * 5), Math.round(p.y), Math.max(1, p.s * 8), 1);
}
if (canThrow(c)) {
const from = project(1, 0), target = project(c.aim.u, c.aim.w), end = landing(c);
const to = project(Math.min(99, end.u), Math.max(-HALF, Math.min(HALF, end.w)));
ctx.strokeStyle = 'rgba(255,246,210,0.8)'; ctx.lineWidth = 1; ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.setLineDash([]);
cross(ctx, target, 5, '#fff4c9'); cross(ctx, to, 7, '#8fc7ff');
}
for (const b of [c.jack, ...c.balls].sort((a, b) => b.u - a.u)) {
const p = project(b.u, b.w);
const r = Math.max(b.jack ? 1.7 : 2.5, b.r * 33.9 * p.s * (1 + 0.65 * (1 - p.s)));
sphere(ctx, b, p.x, p.y, r);
}
// Pallino identificabile anche senza distinguere i colori delle squadre.
const j = project(c.jack.u, c.jack.w);
text(ctx, 'P', j.x, j.y - 13, 6, '#fff7c7', 'center');
detail(ctx, c, def);
panel(ctx, 18, 306, 180, 43);
for (const [i, who] of ['player', 'cpu'].entries()) {
const y = 319 + i * 17;
text(ctx, def.names[who], 28, y, 7, COLORS[who][2]);
for (let n = 0; n < 3; n++) {
const x = 139 + n * 18;
if (n < left(c, who)) sphere(ctx, { who, spin: 0 }, x, y, 4, true);
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); }
}
}
panel(ctx, 182, 50, 440, 24);
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
if (canThrow(c)) {
panel(ctx, 446, 274, 176, 37);
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 458, 286, 7);
ctx.fillStyle = '#3e3e36'; ctx.fillRect(458, 299, 150, 5);
ctx.fillStyle = '#b9d392'; ctx.fillRect(458, 299, Math.round(c.power * 150), 5);
}
if (c.step === 'result') {
panel(ctx, 206, 91, 397, 70);
text(ctx, 'MANO CONCLUSA', 404, 108, 9, '#efd49e', 'center');
text(ctx, status(c, def.names), 404, 132, 10, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
text(ctx, 'Le distanze restano visibili fino a quando esci.', 404, 150, 6, '#d6c7af', 'center');
}
ctx.restore();
}
+157 -139
View File
@@ -1,172 +1,190 @@
// Le bocce al pallaio (primo piano `type: 'bocce'`): una mano da tre bocce a testa contro il campione.
// Tieni premuto e la forza va su e giù; lasci e la boccia parte. Vince la mano chi ha la boccia più vicina al pallino.
// La pista è un piano: `u` lungo la pista (0 dove si tira, 100 la sponda di fondo), `w` di traverso (-10…10).
// Niente fisica vera: attrito costante, urti semplici (chi colpisce cede quasi tutta la spinta), sponde che respingono.
// Mano amichevole da tre bocce: mira libera, forza ripetibile, gioca chi non ha il punto.
// Il campo usa coordinate u (lunghezza) e w (larghezza). Un'unità rappresenta 20 cm.
// È una versione da circolo: sponde elastiche, senza falli o dichiarazioni arbitrali.
export const LANE = 100, HALF = 10, FRICTION = 8, BALL = 0.27, JACK = 0.1, BALLS = 3;
export const SWING = 1.6;
export const FIXED_STEP = 1 / 240;
const REST = 0.75, EPS = 0.005;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const deviation = c => random(c) + random(c) + random(c) - 1.5;
const distance = (a, b) => Math.hypot(a.u - b.u, a.w - b.w);
export const gap = (a, b) => Math.max(0, distance(a, b) - a.r - b.r);
export const left = (c, who) => Math.max(0, BALLS - c.balls.filter(b => b.who === who).length);
export const thrower = c => c.current;
export const canThrow = c => c.step === 'aim' && c.current === 'player' && left(c, 'player') > 0;
export const powerAt = time => clamp(time / SWING, 0, 1);
export const rangeFor = power => 8 + 96 * power;
export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
export const LANE = 100, HALF = 10, FRICTION = 40, BALL = 1.25, JACK = 0.6, BALLS = 3;
export const SWING = 1.6; // secondi per un giro della forza (0 → 1 → 0)
const REST = 0.6; // pausa a bocce ferme prima del tiro seguente
// Numeri «a caso» ripetibili: lo stato del generatore sta nel primo piano, così le prove sono sempre uguali.
function random(c) {
c.seed = (c.seed * 1664525 + 1013904223) % 4294967296;
return c.seed / 4294967296;
}
const gauss = c => (random(c) + random(c) + random(c) - 1.5) * 1.15;
export function newBocce(seed = Date.now() % 4294967296) {
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, power: 0, holding: false, holdTime: 0 };
c.jack = { u: 38 + random(c) * 22, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, jack: true };
export function newBocce(seed = Date.now() >>> 0) {
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
c.aim = { u: c.jack.u, w: c.jack.w };
return c;
}
// Di chi è il tiro: Andrea ai tiri pari, il campione ai dispari.
export const thrower = c => (c.turn % 2 ? 'cpu' : 'player');
export const left = (c, who) => BALLS - c.balls.filter(b => b.who === who).length;
const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > 0.01);
const dist = (a, b) => Math.hypot(a.u - b.u, a.w - b.w);
// Una boccia lanciata verso un punto: parte da dove si tira con la velocità che basta ad arrivarci.
function launch(c, who, u, w) {
const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * d);
c.balls.push({ who, u: 0, w: 0, vu: v * u / d, vw: v * w / d, r: BALL });
c.step = 'rolling';
c.t = 0;
export function setPower(c, value) {
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
c.power = clamp(value, 0, 1); return true;
}
// Andrea: la forza decide quanto lontano; la direzione punta al pallino, con un po' di mano che trema.
export function press(c) {
if (c.step !== 'aim' || thrower(c) !== 'player' || c.holding) return false;
c.holding = true;
c.holdTime = 0;
c.power = 0;
export function aim(c, u, w) {
if (!canThrow(c) || c.holding || !Number.isFinite(u) || !Number.isFinite(w)) return false;
c.aim = { u: clamp(u, 10, LANE - 2), w: clamp(w, -HALF + BALL, HALF - BALL) }; return true;
}
export function landing(c) {
const d = Math.hypot(c.aim.u, c.aim.w), range = rangeFor(c.power);
return { u: BALL + 0.02 + range * c.aim.u / d, w: range * c.aim.w / d };
}
function launch(c, who, u, w, range) {
if (left(c, who) <= 0) return false;
const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * range);
c.balls.push({ who, number: BALLS - left(c, who) + 1, u: BALL + 0.02, w: 0,
vu: v * u / d, vw: v * w / d, r: BALL, mass: 1, spin: 0 });
c.step = 'rolling'; c.t = c.accumulator = c.travel = 0; c.lastThrower = who;
c.events.push({ kind: 'bocce-roll', power: c.power });
return true;
}
export function shoot(c) {
if (!canThrow(c) || c.holding) return false;
return launch(c, 'player', c.aim.u, c.aim.w, rangeFor(c.power));
}
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;
c.holding = false;
launch(c, 'player', 8 + c.power * (LANE - 4), c.jack.w + gauss(c) * 0.9);
return true;
// Un tocco breve serve a mirare, non spreca una boccia.
if (c.holdTime < 0.14) return cancel(c);
c.holding = false; return shoot(c);
}
export const powerAt = holdTime => (1 - Math.cos((2 * Math.PI * holdTime) / SWING)) / 2;
// Impulso normale fra masse diverse: niente accelerazioni create dagli urti, e
// separazione geometrica anche quando due pezzi sono già fermi o sovrapposti.
export function collide(a, b) {
const du = b.u - a.u, dw = b.w - a.w, d = Math.hypot(du, dw), r = a.r + b.r;
if (d >= r) return 0;
const nu = d > 1e-9 ? du / d : 1, nw = d > 1e-9 ? dw / d : 0;
const ia = 1 / (a.mass ?? (a.jack ? 0.1 : 1)), ib = 1 / (b.mass ?? (b.jack ? 0.1 : 1));
const overlap = r - d + 0.00001;
a.u -= nu * overlap * ia / (ia + ib); a.w -= nw * overlap * ia / (ia + ib);
b.u += nu * overlap * ib / (ia + ib); b.w += nw * overlap * ib / (ia + ib);
const closing = (a.vu - b.vu) * nu + (a.vw - b.vw) * nw;
if (closing <= 0) return 0;
const impulse = 1.68 * closing / (ia + ib);
a.vu -= impulse * ia * nu; a.vw -= impulse * ia * nw;
b.vu += impulse * ib * nu; b.vw += impulse * ib * nw;
return closing;
}
function bounds(b) {
let impact = 0;
for (const [pos, vel, low, high] of [['u', 'vu', b.r, LANE - b.r], ['w', 'vw', -HALF + b.r, HALF - b.r]]) {
if (b[pos] < low || b[pos] > high) {
const outward = b[pos] < low ? b[vel] < 0 : b[vel] > 0;
b[pos] = clamp(b[pos], low, high);
if (outward) { impact = Math.max(impact, Math.abs(b[vel])); b[vel] *= -0.32; }
}
}
return impact;
}
function step(c, dt) {
const all = [c.jack, ...c.balls];
let hit = 0, rail = 0;
for (const b of all) {
const v = Math.hypot(b.vu, b.vw);
if (v <= 0.01) { b.vu = b.vw = 0; continue; }
const slow = Math.max(0, v - FRICTION * dt) / v;
b.vu *= slow; b.vw *= slow;
b.u += b.vu * dt; b.w += b.vw * dt;
// Le sponde: di lato e in fondo respingono, piano.
if (Math.abs(b.w) > HALF - b.r) { b.w = Math.sign(b.w) * (HALF - b.r); b.vw *= -0.4; }
if (b.u > LANE - b.r) { b.u = LANE - b.r; b.vu *= -0.3; }
if (v <= EPS) { b.vu = b.vw = 0; continue; }
const stop = Math.min(dt, v / FRICTION), travel = v * stop - 0.5 * FRICTION * stop * stop;
b.u += b.vu / v * travel; b.w += b.vw / v * travel;
b.spin = (b.spin ?? 0) + travel / b.r;
const next = Math.max(0, v - FRICTION * dt) / v;
b.vu *= next; b.vw *= next;
rail = Math.max(rail, bounds(b));
}
// Gli urti: chi va più forte spinge l'altro lungo la congiungente e si tiene poco.
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
const a = all[i], b = all[j], d = dist(a, b), min = a.r + b.r;
if (d >= min || d === 0) continue;
const [hit, other] = Math.hypot(a.vu, a.vw) >= Math.hypot(b.vu, b.vw) ? [a, b] : [b, a];
const nu = (other.u - hit.u) / d, nw = (other.w - hit.w) / d, push = hit.vu * nu + hit.vw * nw;
if (push <= 0) continue;
const k = other.jack ? 1.1 : 0.85;
other.vu += nu * push * k; other.vw += nw * push * k;
hit.vu -= nu * push * 0.85; hit.vw -= nw * push * 0.85;
// Si staccano subito, così non restano incollate.
const back = (min - d) / 2;
hit.u -= nu * back; hit.w -= nw * back; other.u += nu * back; other.w += nw * back;
for (let pass = 0; pass < 3; pass++) {
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) hit = Math.max(hit, collide(all[i], all[j]));
all.forEach(bounds);
}
if (hit > 0.8 || rail > 1) {
// Un solo evento per urto, limitato anche se il chiamante non consuma l'audio.
if (c.events.length < 12) c.events.push({ kind: hit > rail ? 'bocce-hit' : 'bocce-rail', power: Math.min(1, Math.max(hit, rail) / 30) });
}
}
// Il punto: chi ha la boccia più vicina al pallino vince, e segna una boccia per ognuna più vicina della migliore
// dell'altro.
export function score(c) {
const by = who => c.balls.filter(b => b.who === who).map(b => dist(b, c.jack)).sort((a, b) => a - b);
const p = by('player'), q = by('cpu');
if (!p.length || !q.length) return { winner: null, points: 0 };
const winner = p[0] <= q[0] ? 'player' : 'cpu', [mine, theirs] = winner === 'player' ? [p, q] : [q, p];
return { winner, points: mine.filter(d => d < theirs[0]).length };
const a = closest(c, 'player'), b = closest(c, 'cpu');
if (!a && !b) return { winner: null, points: 0 };
const p = a ? gap(a, c.jack) : Infinity, q = b ? gap(b, c.jack) : Infinity;
if (Math.abs(p - q) < EPS) return { winner: null, points: 0 };
const winner = p < q ? 'player' : 'cpu', limit = Math.min(p, q) === p ? q : p;
return { winner, points: c.balls.filter(ball => ball.who === winner && gap(ball, c.jack) < limit - EPS).length };
}
function nextTurn(c) {
if (!left(c, 'player') && !left(c, 'cpu')) { c.step = 'result'; c.result = score(c); return; }
const winner = score(c).winner;
const behind = winner ? (winner === 'player' ? 'cpu' : 'player') : c.lastThrower;
c.current = left(c, behind) ? behind : behind === 'player' ? 'cpu' : 'player';
c.step = 'aim';
}
function cpuThrow(c) {
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45;
const target = attack ? other : c.jack;
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2);
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL);
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
}
// L'età del primo piano (`age`) la conta il motore; qui solo il tempo del passo in corso.
export function update(c, dt) {
c.t += 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 === 'rolling') {
for (let s = 0; s < 4; s++) step(c, dt / 4);
if (!moving(c) && c.t > 0.3) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) {
c.turn++;
c.t = 0;
c.step = c.turn >= BALLS * 2 ? 'result' : 'aim';
if (c.step === 'result') c.result = score(c);
} else if (c.step === 'aim' && thrower(c) === 'cpu' && c.t >= 1.1) {
// Il campione mira al pallino e sbaglia di poco, a volte di più.
launch(c, 'cpu', Math.min(LANE - 2, Math.max(10, c.jack.u + gauss(c) * 7)), c.jack.w + gauss(c) * 1.4);
c.accumulator += dt;
while (c.accumulator >= FIXED_STEP) { step(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
const b = c.balls.at(-1);
if (b && Math.hypot(b.vu, b.vw) > 1) {
c.trails.push({ u: b.u, w: b.w, age: 0 });
if (c.trails.length > 18) c.trails.shift();
}
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) {
c.turn++; c.t = 0; nextTurn(c);
} else if (c.step === 'aim' && c.current === 'cpu' && c.t >= 1.25) cpuThrow(c);
for (const t of c.trails) t.age += dt;
c.trails = c.trails.filter(t => t.age < 0.55);
}
// --- Il disegno: lo sfondo della pista e, sopra, bocce e pallino in prospettiva (misurata sullo sfondo di Codex).
// Prospettiva calibrata sulle sponde dello sfondo esistente.
const NEAR = { y: 360, x: 355, half: 339 }, FAR = { y: 187, x: 298, half: 37.5 };
const S_FAR = FAR.half / NEAR.half, Q = (1 / S_FAR - 1) / LANE, HORIZON = (FAR.y - NEAR.y * S_FAR) / (1 - S_FAR);
export function project(u, w) {
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
const cx = FAR.x + (NEAR.x - FAR.x) * k;
return { x: cx + (w / HALF) * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
}
const COLORS = { player: ['#1d3f8f', '#3f6fd1', '#a9c4ff'], cpu: ['#7a1f1a', '#c2392d', '#ffb0a3'], jack: ['#a8862a', '#f2d75c', '#fff6c4'] };
// Il disegno ha la sua misura (le bocce vere su una pista di 4 metri), più piccola di quella degli urti; lontano si
// ingrandisce un po' (fino a 2,6 volte in fondo), sennò in prospettiva esatta resterebbero puntini.
const LOOK = { player: 0.42, cpu: 0.42, jack: 0.2 };
function ball(ctx, b, kind) {
const { x, y, s } = project(b.u, b.w), r = Math.max(2, LOOK[kind] * (NEAR.half / HALF) * s * (1 + 1.6 * (1 - s)));
const [dark, mid, light] = COLORS[kind];
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(x + r * 0.25, y + r * 0.15, r, r * 0.45, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = dark; ctx.beginPath(); ctx.arc(x, y - r, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = mid; ctx.beginPath(); ctx.arc(x - r * 0.15, y - r * 1.12, r * 0.72, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.45), Math.round(y - r * 1.55), Math.max(1, Math.round(r * 0.35)), Math.max(1, Math.round(r * 0.3)));
export function unproject(x, y) {
const s = clamp((y - HORIZON) / (NEAR.y - HORIZON), S_FAR, 1);
const u = (1 / s - 1) / Q, k = (s - S_FAR) / (1 - S_FAR);
return { u, w: (x - FAR.x - (NEAR.x - FAR.x) * k) / (NEAR.half * s) * HALF };
}
const FONT = '"Press Start 2P", monospace';
function label(ctx, text, x, y, size = 7, color = '#ffd27a', align = 'center') {
ctx.font = `${size}px ${FONT}`;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
const w = ctx.measureText(text).width, x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
ctx.fillStyle = 'rgba(0,0,0,0.62)';
ctx.fillRect(Math.round(x0 - 6), Math.round(y - size), Math.round(w + 12), size * 2);
ctx.fillStyle = color;
ctx.fillText(text, Math.round(x), Math.round(y));
export const DETAIL = { x: 18, y: 86, w: 150, h: 170, scale: 11 };
export function detailPoint(c, b) { return { x: DETAIL.x + DETAIL.w / 2 + (b.w - c.jack.w) * DETAIL.scale, y: DETAIL.y + 93 - (b.u - c.jack.u) * DETAIL.scale }; }
export function aimScreen(c, [x, y]) {
const d = DETAIL;
if (x >= d.x && x <= d.x + d.w && y >= d.y + 24 && y <= d.y + d.h - 18)
return aim(c, c.jack.u + (d.y + 93 - y) / d.scale, c.jack.w + (x - d.x - d.w / 2) / d.scale);
if (y < 185 || y > 360) return false;
const p = unproject(x, y); return aim(c, p.u, p.w);
}
export function drawBocce(ctx, c, def, bg, time) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#c9b48a'; ctx.fillRect(0, 0, 640, 360); }
const pieces = [{ b: c.jack, kind: 'jack' }, ...c.balls.map(b => ({ b, kind: b.who }))].sort((a, b) => b.b.u - a.b.u);
for (const p of pieces) ball(ctx, p.b, p.kind);
// Il punteggio in basso a sinistra (in alto ci sono Pausa e il volume): le bocce che restano a ciascuno.
const dots = (who, y) => label(ctx, `${def.names[who]} ${'o'.repeat(left(c, who))}`, 16, y, 7, who === 'player' ? '#a9c4ff' : '#ffb0a3', 'left');
dots('player', 326);
dots('cpu', 344);
if (c.step === 'aim' && thrower(c) === 'player') {
// La forza: una barra che sale e scende finché si tiene premuto.
const x = 586, y = 320, h = 120;
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(x - 3, y - h - 3, 20, h + 6);
ctx.fillStyle = '#3a2d42'; ctx.fillRect(x, y - h, 14, h);
const p = c.holding ? c.power : 0, grad = ['#7fe08a', '#ffd27a', '#ff7a5a'];
ctx.fillStyle = grad[Math.min(2, Math.floor(p * 3))]; ctx.fillRect(x, Math.round(y - h * p), 14, Math.round(h * p));
label(ctx, 'FORZA', x + 7, y + 14, 6);
if (!c.holding && c.age > 0.4) {
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
label(ctx, 'Tieni premuto per caricare, lascia per tirare', 320, 62);
ctx.globalAlpha = 1;
}
} else if (c.step === 'aim') label(ctx, `Tira ${def.names.cpu}...`, 320, 62);
if (c.step === 'result' && c.result) {
const win = c.result.winner === 'player';
label(ctx, win ? `Punto per ${def.names.player}!` : `Punto per ${def.names.cpu}!`, 320, 150, 12, win ? '#7fe08a' : '#ffb0a3');
}
ctx.restore();
export function status(c, names) {
if (c.step === 'result') return c.result.winner ? `${names[c.result.winner]}: ${c.result.points} ${c.result.points === 1 ? 'punto' : 'punti'}.` : 'Parità: nessun punto.';
if (c.step === 'rolling') return 'Boccia in movimento…';
if (c.step === 'settle') return 'Misuriamo il punto…';
const leader = score(c).winner;
return `${names[c.current]} al tiro. ${leader ? `Il punto è di ${names[leader]}.` : 'Il punto è da prendere.'}`;
}
+5 -4
View File
@@ -1,7 +1,7 @@
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease } from './bocce.js';
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js';
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
@@ -250,7 +250,7 @@ export class Game {
}
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
if (def?.type === 'bocce') {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : 'lose'] : def.quit ?? []));
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
return true;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
@@ -261,11 +261,13 @@ export class Game {
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
const c = this.closeup;
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
@@ -390,8 +392,7 @@ export class Game {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'bocce') {
bocceUpdate(c, dt);
// Il risultato resta a schermo un momento, poi si torna al pallaio per i commenti.
if (c.step === 'result' && c.t >= 2.4) this.closeupClose();
// Il risultato resta leggibile finché il giocatore torna al pallaio.
return;
}
c.t += dt;
+1 -1
View File
@@ -3,7 +3,7 @@
import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce.js';
import { drawBocce } from './bocce-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
+4 -1
View File
@@ -98,9 +98,12 @@ export class Soundscape {
source.start(at); source.stop(at + duration + 0.03);
}
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
+41 -4
View File
@@ -8,6 +8,7 @@ import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import { aim as bocceAim, shoot as bocceShoot, setPower as boccePower, canThrow as bocceReady, status as bocceStatus } from './engine/bocce.js';
import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id);
@@ -265,6 +266,7 @@ $('spots-button').addEventListener('click', showSpots);
function openPause() {
if (!running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
@@ -397,9 +399,9 @@ canvas.addEventListener('pointerdown', e => {
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
press = { closeup: true };
game.closeupAim(scenePoint(e));
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
game.closeupAim(scenePoint(e));
canvas.setPointerCapture(e.pointerId);
return;
}
@@ -419,7 +421,8 @@ canvas.addEventListener('pointerup', e => {
}
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -431,6 +434,15 @@ canvas.addEventListener('pointermove', e => {
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio
// con un solo clic, anche per chi non vuole usare la pressione temporizzata.
const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null;
$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); });
$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); });
for (const [id, move] of [['bocce-left', -0.3], ['bocce-right', 0.3]])
$(id).addEventListener('click', () => { const c = bocceState(); if (c) bocceAim(c, c.aim.u, c.aim.w + move); });
addEventListener('blur', () => game?.closeupCancel());
// --- Verbi.
for (const verb of VERBS) {
const b = document.createElement('button');
@@ -516,6 +528,17 @@ window.addEventListener('keydown', e => {
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
if (game.closeup.id === 'bocce') {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
const c = game.closeup;
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3));
else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return;
}
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
}
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
e.preventDefault();
@@ -590,16 +613,30 @@ function updateUi() {
updateInventory();
updateChoices();
updateSpeech();
const bocce = c?.id === 'bocce';
$('bocce-controls').hidden = !bocce;
$('bocce-controls').inert = !running;
if (bocce) {
$('panel').hidden = $('choices').hidden = true;
const line = bocceStatus(c, story.closeups.bocce.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
$('bocce-power').value = Math.round(c.power * 100);
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !bocceReady(c) || c.holding || !!game.speech;
$('bocce-throw').textContent = c.step === 'result' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia';
for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
}
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : c && story.closeups[c.id].type === 'bocce' ? 'Lascia stare' : 'Chiudi';
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : 'Chiudi';
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
canvas.classList.toggle('bocce', bocce);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page');
+1
View File
@@ -122,6 +122,7 @@ export const story = {
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
],
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }],
},
fessura: {
type: 'torch',
+20
View File
@@ -25,6 +25,26 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
#scene.bocce { touch-action: none; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
.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 { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
#bocce-help summary { cursor: pointer; padding: 6px 0; }
#bocce-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 { 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); }
+8 -5
View File
@@ -14,10 +14,11 @@ function throwTo(c, u) {
assert.ok(release(c));
}
test('la forza sale e scende: a metà giro è piena, a giro finito torna a zero', () => {
test('la forza sale finché si tiene premuto, fino al massimo: niente tempismo da azzeccare', () => {
assert.equal(powerAt(0), 0);
assert.ok(Math.abs(powerAt(SWING / 2) - 1) < 1e-9);
assert.ok(powerAt(SWING) < 1e-9);
assert.ok(Math.abs(powerAt(SWING / 2) - 0.5) < 1e-9);
assert.equal(powerAt(SWING), 1);
assert.equal(powerAt(SWING * 3), 1, 'oltre il massimo resta piena, non ricomincia');
});
test('una boccia va lontano quanto dice la forza, verso il pallino', () => {
@@ -64,6 +65,7 @@ test('una mano intera: tre bocce a testa, a turno, poi il risultato', () => {
assert.equal(c.balls.length, BALLS * 2);
assert.ok(['player', 'cpu'].includes(c.result.winner));
assert.equal(press(c), false, 'a mano finita non si tira più');
assert.ok(['player', 'cpu'].includes(c.result.winner) || c.result.winner === null);
});
test('la prospettiva: lontano è più in alto e più piccolo, al centro della pista', () => {
@@ -95,8 +97,9 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale
}
assert.equal(c.step, 'result');
c.result = { winner: 'player', points: 1 };
for (let i = 0; i < 20 && g.closeup; i++) g.update(0.25);
assert.equal(g.closeup, null, 'il risultato si chiude da solo');
for (let i = 0; i < 20; i++) g.update(0.25);
assert.equal(g.closeup, c, 'il risultato resta a schermo finché non si torna al pallaio');
assert.ok(g.closeupClose());
const after = settle(g).map(s => `${s.who}: ${s.text}`);
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
assert.ok(g.state.flags.bocce_vinte);