// 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]; // Quanto è bravo Mariano: quanto si allarga il suo tiro e quanto spesso prova a bocciare la boccia migliore. // «A fassi male» è il campione vero del pallaio. export const LEVELS = { bona: { spread: 3, attack: 0.1 }, seria: { spread: 1.15, attack: 0.4 }, male: { spread: 0.22, attack: 0.85 }, }; export function newBocce(seed = Date.now() >>> 0, def = {}) { const level = LEVELS[def.level] ? def.level : 'seria'; const c = { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], 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; } 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 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; // Un tocco breve serve a mirare, non spreca una boccia. if (c.holdTime < 0.14) return cancel(c); c.holding = false; return shoot(c); } // 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 <= 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)); } 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) }); } } export function score(c) { 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 k = c.skill ?? LEVELS.male; const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < k.attack; const target = attack ? other : c.jack; const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2) * k.spread, 10, LANE - 2); const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8) * k.spread, -HALF + BALL, HALF - BALL); launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35)); } 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 === 'rolling') { 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); } // 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); return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s }; } 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 }; } 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 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.'}`; }