Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
191 lines
9.8 KiB
JavaScript
191 lines
9.8 KiB
JavaScript
// 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 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;
|
|
}
|
|
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 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));
|
|
}
|
|
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.'}`;
|
|
}
|