Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
401 lines
20 KiB
JavaScript
401 lines
20 KiB
JavaScript
// Biliardo all'italiana, cinque birilli, visto dall'alto. Regole da circolo, semplificate:
|
||
// si colpisce la propria biglia e la si manda sulla biglia avversaria; i birilli abbattuti dalla biglia avversaria
|
||
// o dal pallino fanno punti a chi tira (bianco 2, rosso 4, il rosso da solo 10), il pallino toccato dalla biglia
|
||
// avversaria ne vale 3. Fallo, con i punti all'avversario (almeno 2): mancare la biglia avversaria, toccare prima
|
||
// il pallino, abbattere birilli con la propria biglia. Un colpo a testa; vince chi arriva prima al traguardo.
|
||
// Unità: centimetri. x lungo il tavolo (0..LEN), y di traverso (0..WID).
|
||
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
|
||
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
|
||
export const SWING = 1.4, FIXED_STEP = 1 / 240;
|
||
// La steccata: la stecca va avanti e tocca la biglia; solo allora parte la fisica (e il suono del colpo).
|
||
// Così il disegno mostra sempre le posizioni vere e i suoni degli urti cadono insieme alle immagini.
|
||
export const WINDUP = 0.18, LONG_PRESS = 0.6;
|
||
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
||
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
||
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
|
||
export const VIEW = { x: 70, y: 88, k: 1.76 };
|
||
|
||
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 ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||
const list = c => Object.values(c.balls);
|
||
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
||
|
||
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
||
export function castle() {
|
||
const cx = LEN / 2, cy = WID / 2;
|
||
return [[0, 0, true], [-GAP, 0], [GAP, 0], [0, -GAP], [0, GAP]].map(([dx, dy, red]) => ({ x: cx + dx, y: cy + dy, red: !!red, down: false }));
|
||
}
|
||
|
||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
|
||
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
|
||
export function reach(v) {
|
||
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
|
||
return (v + k) / DRAG * (1 - Math.exp(-DRAG * t)) - k * t;
|
||
}
|
||
export const canThrow = c => c.step === 'aim' && c.current === 'player';
|
||
export const moving = c => list(c).some(b => b.vx || b.vy);
|
||
|
||
// Quanto è bravo l'Ingegnere: quanti colpi prova a vuoto (direzioni intorno al tavolo, ritocchi vicino alla biglia
|
||
// avversaria, forze) e quanto gli trema la mano. «A fassi male» è il campione vero: vince quasi sempre.
|
||
export const LEVELS = {
|
||
bona: { angles: 0, fine: 0.15, powers: [0.6], wobble: 0.16, powerNoise: 0.2 },
|
||
seria: { angles: 14, fine: 0.09, powers: [0.5, 0.8], wobble: 0.072, powerNoise: 0.1 },
|
||
male: { angles: 64, fine: 0.025, powers: [0.3, 0.5, 0.7, 0.9], wobble: 0.03, powerNoise: 0.05 },
|
||
};
|
||
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
||
const level = LEVELS[def.level] ? def.level : 'seria';
|
||
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
|
||
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
|
||
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
|
||
c.pins = castle();
|
||
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
|
||
c.visual = { time: 0, shot: null };
|
||
c.angle = angleTo(c.balls.player, c.balls.cpu);
|
||
return c;
|
||
}
|
||
|
||
// Valore dei birilli abbattuti in un colpo.
|
||
export function value(pins) {
|
||
if (pins.length === 1 && pins[0].red) return 10;
|
||
return pins.reduce((s, p) => s + (p.red ? 4 : 2), 0);
|
||
}
|
||
// Chi prende i punti di un colpo, e quanti.
|
||
export function judge(shot) {
|
||
const shooter = shot.shooter, other = OTHER[shooter];
|
||
const own = shot.pins.filter(p => p.by === shooter);
|
||
const foul = shot.first === null ? 'miss' : shot.first === 'pallino' ? 'pallino' : own.length ? 'own' : null;
|
||
if (foul) return { shooter, to: other, points: Math.max(2, value(shot.pins)), foul };
|
||
return { shooter, to: shooter, points: value(shot.pins) + (shot.pallino ? 3 : 0), foul: null };
|
||
}
|
||
|
||
function cushions(b) {
|
||
let impact = 0;
|
||
for (const [p, v, hi] of [['x', 'vx', LEN - R], ['y', 'vy', WID - R]]) {
|
||
if (b[p] < R || b[p] > hi) {
|
||
const out = b[p] < R ? b[v] < 0 : b[v] > 0;
|
||
b[p] = clamp(b[p], R, hi);
|
||
if (out) { impact = Math.max(impact, Math.abs(b[v])); b[v] *= -CUSHION; }
|
||
}
|
||
}
|
||
return impact;
|
||
}
|
||
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
|
||
export function collide(a, b) {
|
||
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||
if (d > 2 * R + CONTACT_EPS) return 0;
|
||
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = Math.max(0, (2 * R - d) / 2);
|
||
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
|
||
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||
if (closing <= CONTACT_EPS) return 0;
|
||
const j = closing * (1 + BALL_RESTITUTION) / 2;
|
||
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
|
||
return closing;
|
||
}
|
||
function touch(shot, a, b) {
|
||
if (!shot) return;
|
||
const roles = [a.role, b.role], other = OTHER[shot.shooter];
|
||
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
|
||
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
|
||
}
|
||
// Tempo al primo contatto: si risolve l'intersezione del moto relativo con il cerchio.
|
||
// Anche un colpo veloce o molto sottile viene risolto sul punto d'urto, non dopo la compenetrazione.
|
||
function contactTime(dx, dy, vx, vy, radius) {
|
||
const closing = dx * vx + dy * vy, speed2 = vx * vx + vy * vy;
|
||
if (closing >= -CONTACT_EPS * radius || speed2 === 0) return Infinity;
|
||
const gap = dx * dx + dy * dy - radius * radius;
|
||
if (gap <= CONTACT_EPS) return 0;
|
||
const discriminant = closing * closing - speed2 * gap;
|
||
if (discriminant <= 0) return Infinity;
|
||
return gap / (-closing + Math.sqrt(discriminant));
|
||
}
|
||
function slow(b, dt) {
|
||
const v = Math.hypot(b.vx, b.vy);
|
||
if (!v) return;
|
||
const nv = (v + FRICTION / DRAG) * Math.exp(-DRAG * dt) - FRICTION / DRAG;
|
||
const scale = nv < EPS ? 0 : nv / v;
|
||
b.vx *= scale; b.vy *= scale;
|
||
}
|
||
function advance(balls, dt) {
|
||
for (const b of balls) { b.x += b.vx * dt; b.y += b.vy * dt; }
|
||
}
|
||
function step(w, dt) {
|
||
// Mezzo passo d'attrito, moto con urti continui, mezzo passo d'attrito.
|
||
// Questa stessa integrazione serve al tiro, alla guida e alle prove dell'avversario.
|
||
for (const b of w.balls) slow(b, dt / 2);
|
||
let left = dt, hit = 0, rail = 0;
|
||
// Un tetto agli urti per passo: due biglie a contatto con un arrotondamento sfortunato non devono bloccare il gioco.
|
||
for (let events = 0; left > 1e-10 && events < 64; events++) {
|
||
let time = left, event = null;
|
||
const consider = (t, kind, a, b, axis, edge) => {
|
||
if (t >= -1e-10 && t <= time) { time = Math.max(0, t); event = { kind, a, b, axis, edge }; }
|
||
};
|
||
for (let i = 0; i < w.balls.length; i++) {
|
||
const a = w.balls[i];
|
||
if (a.vx) consider(((a.vx < 0 ? R : LEN - R) - a.x) / a.vx, 'rail', a, null, 'x', a.vx < 0 ? R : LEN - R);
|
||
if (a.vy) consider(((a.vy < 0 ? R : WID - R) - a.y) / a.vy, 'rail', a, null, 'y', a.vy < 0 ? R : WID - R);
|
||
for (let j = i + 1; j < w.balls.length; j++) {
|
||
const b = w.balls[j];
|
||
consider(contactTime(a.x - b.x, a.y - b.y, a.vx - b.vx, a.vy - b.vy, 2 * R), 'ball', a, b);
|
||
}
|
||
if (!a.vx && !a.vy) continue;
|
||
for (const p of w.pins) if (!p.down)
|
||
consider(contactTime(a.x - p.x, a.y - p.y, a.vx, a.vy, R + PIN), 'pin', a, p);
|
||
}
|
||
advance(w.balls, time); left -= time;
|
||
if (!event) break;
|
||
const { kind, a, b, axis, edge } = event;
|
||
if (kind === 'rail') {
|
||
const v = axis === 'x' ? 'vx' : 'vy';
|
||
a[axis] = edge;
|
||
rail = Math.max(rail, Math.abs(a[v])); a[v] *= -CUSHION;
|
||
} else if (kind === 'ball') {
|
||
const impact = collide(a, b);
|
||
if (impact > 0) { touch(w.shot, a, b); hit = Math.max(hit, impact); }
|
||
} else {
|
||
const v = Math.hypot(a.vx, a.vy);
|
||
Object.assign(b, { down: true, by: a.role, dir: Math.atan2(b.y - a.y, b.x - a.x) });
|
||
a.vx *= 0.88; a.vy *= 0.88;
|
||
w.shot?.pins.push(b);
|
||
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
|
||
}
|
||
w.onContact?.(event);
|
||
}
|
||
for (const b of w.balls) slow(b, dt / 2);
|
||
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
|
||
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
|
||
}
|
||
|
||
export function strike(c, who, angle, power) {
|
||
c.turn = null;
|
||
const b = c.balls[who], v = speedFor(power);
|
||
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
|
||
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
|
||
c.windup = WINDUP;
|
||
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
|
||
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
|
||
return true;
|
||
}
|
||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||
export function simulate(c, who, angle, power, { onContact, stop } = {}) {
|
||
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
||
const w = { balls, pins, onContact, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
||
const b = balls.find(x => x.role === who), v = speedFor(power);
|
||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||
for (let i = 0; i < 15 / FIXED_STEP && !stop?.() && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
||
return judge(w.shot);
|
||
}
|
||
|
||
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
|
||
function reset(c) {
|
||
for (const p of c.pins) delete p.visualDownAt;
|
||
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
|
||
for (let pass = 0; pass < 4; pass++) {
|
||
for (const b of list(c)) for (const p of c.pins) {
|
||
const d = Math.hypot(b.x - p.x, b.y - p.y), need = R + PIN + 0.3;
|
||
if (d >= need) continue;
|
||
const a = d > 1e-6 ? Math.atan2(b.y - p.y, b.x - p.x) : -Math.PI / 2;
|
||
b.x = p.x + Math.cos(a) * need; b.y = p.y + Math.sin(a) * need;
|
||
}
|
||
const all = list(c);
|
||
for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) collide(all[i], all[j]);
|
||
all.forEach(cushions);
|
||
}
|
||
}
|
||
function finish(c) {
|
||
const j = judge(c.shot);
|
||
c.points[j.to] += j.points;
|
||
c.last = { ...j, age: 0 };
|
||
c.shot = null;
|
||
reset(c);
|
||
c.t = 0;
|
||
if (c.points[j.to] >= c.target) { c.step = 'result'; c.result = { winner: j.to, points: { ...c.points } }; return; }
|
||
c.current = OTHER[j.shooter];
|
||
c.step = 'aim';
|
||
c.angle = angleTo(c.balls[c.current], c.balls[OTHER[c.current]]);
|
||
}
|
||
|
||
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
|
||
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
|
||
function cpuCandidates(c) {
|
||
const k = c.skill ?? LEVELS.male, base = angleTo(c.balls.cpu, c.balls.player), angles = [];
|
||
for (let i = 0; i < k.angles; i++) angles.push(i / k.angles * Math.PI * 2);
|
||
for (let d = -0.3; d <= 0.3; d += k.fine) angles.push(base + d);
|
||
return angles.flatMap(angle => k.powers.map(power => ({ angle, power })));
|
||
}
|
||
function cpuThink(c, budget = 12) {
|
||
const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
|
||
for (let i = 0; i < budget && t.todo.length; i++) {
|
||
const { angle, power } = t.todo.pop(), j = simulate(c, 'cpu', angle, power), gain = j.to === 'cpu' ? j.points : -j.points;
|
||
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
|
||
}
|
||
if (t.todo.length) return;
|
||
const k = c.skill ?? LEVELS.male;
|
||
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? k.wobble), power: clamp(t.best.power + deviation(c) * k.powerNoise, 0.05, 1) };
|
||
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||
}
|
||
|
||
export function update(c, dt, onContact) {
|
||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||
dt = Math.min(dt, 0.25); c.t += dt;
|
||
c.visual.time += dt;
|
||
if (c.last) c.last.age += dt;
|
||
if (c.turn && canThrow(c) && !c.holding) {
|
||
c.turn.time += dt;
|
||
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.03 : 0.6 + 1.8 * Math.min(1, c.turn.time / 1.2));
|
||
}
|
||
if (c.holding) {
|
||
c.holdTime += dt;
|
||
c.charging = c.holdTime >= c.holdDelay;
|
||
if (c.charging) c.power = c.fixedPower ?? (c.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime));
|
||
}
|
||
if (c.step === 'rolling' && c.windup > 0) {
|
||
// La stecca sta arrivando: la biglia parte quando la tocca; il resto del fotogramma è già moto.
|
||
c.windup -= dt;
|
||
if (c.windup <= 0) {
|
||
const { ball, vx, vy, power } = c.pending;
|
||
ball.vx = vx; ball.vy = vy; c.pending = null;
|
||
c.events.push({ kind: 'biliardo-cue', power });
|
||
dt = -c.windup; c.windup = 0;
|
||
} else dt = 0;
|
||
}
|
||
if (c.step === 'rolling' && !c.windup) {
|
||
c.accumulator += dt;
|
||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events, onContact };
|
||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
||
else if (c.step === 'aim' && c.current === 'cpu') {
|
||
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
|
||
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
|
||
}
|
||
if (c.step === 'rolling' || c.step === 'settle')
|
||
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
|
||
}
|
||
|
||
// --- Comandi del giocatore: gli stessi delle bocce.
|
||
export function aimAt(c, x, y) {
|
||
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||
const b = c.balls.player;
|
||
if (Math.hypot(x - b.x, y - b.y) < R) return false;
|
||
c.angle = Math.atan2(y - b.y, x - b.x); return true;
|
||
}
|
||
export function aimScreen(c, [sx, sy]) {
|
||
const x = (sx - VIEW.x) / VIEW.k, y = (sy - VIEW.y) / VIEW.k;
|
||
if (x < 0 || x > LEN || y < 0 || y > WID) return false;
|
||
return aimAt(c, x, y);
|
||
}
|
||
export function nudge(c, dir, fine = false) {
|
||
if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false;
|
||
c.angle += dir * (fine ? 0.0005 : 0.002); return true;
|
||
}
|
||
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 shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
|
||
// Una pressione continua ruota senza dipendere dalla ripetizione della tastiera.
|
||
export function turn(c, dir, owner, fine = false) {
|
||
if (!canThrow(c) || c.holding || c.turn) return false;
|
||
c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.0005 : 0.03);
|
||
c.aimLocked = true; return true;
|
||
}
|
||
export function stopTurn(c, owner) {
|
||
if (c.turn?.owner !== owner) return false;
|
||
c.turn = null; return true;
|
||
}
|
||
export function key(c, key, down, fine = false) {
|
||
if (!['ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||
if (down) { if (c.turn?.owner === key) c.turn.fine = fine; else turn(c, key === 'ArrowLeft' ? -1 : 1, key, fine); }
|
||
else stopTurn(c, key);
|
||
return true;
|
||
}
|
||
export function press(c, pointerId) {
|
||
if (!canThrow(c) || c.holding) return false;
|
||
c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS;
|
||
c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0;
|
||
if (!c.holdDelay && c.fixedPower === undefined) c.power = 0;
|
||
return true;
|
||
}
|
||
export function cancel(c, pointerId) {
|
||
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
|
||
c.turn = null; c.charging = false;
|
||
if (!c.holding) return false;
|
||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||
}
|
||
export function release(c, pointerId) {
|
||
if (c.pointerId !== pointerId) return false;
|
||
if (!c.holding) return false;
|
||
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
|
||
c.holding = c.charging = false; return strike(c, 'player', c.angle, c.power);
|
||
}
|
||
|
||
// La guida riproduce il tiro su una copia, con gli stessi urti e lo stesso attrito.
|
||
// Dopo il primo contatto mostra il tratto iniziale delle due biglie, fino al prossimo
|
||
// ostacolo, all'arresto o a 50 cm. La cache evita di riprovare il tiro a ogni disegno.
|
||
const predictions = new WeakMap();
|
||
export function predict(c) {
|
||
const key = [c.current, c.angle, c.power, ...list(c).flatMap(b => [b.x, b.y, b.vx, b.vy]),
|
||
...c.pins.flatMap(p => [p.x, p.y, p.down])].join('|');
|
||
const cached = predictions.get(c);
|
||
if (cached?.key === key) return cached.result;
|
||
const balls = list(c).map(b => ({ ...b })), me = balls.find(b => b.role === c.current);
|
||
const v = speedFor(c.power);
|
||
me.vx = Math.cos(c.angle) * v; me.vy = Math.sin(c.angle) * v;
|
||
const result = { points: [[me.x, me.y]], ghost: null, hit: null, dir: null, outgoing: [] };
|
||
const tracks = [];
|
||
const point = b => [b.x, b.y];
|
||
const endTrack = track => {
|
||
track.points.push(point(track.ball)); track.done = true;
|
||
};
|
||
const w = { balls, pins: c.pins.map(p => ({ ...p })), onContact(event) {
|
||
const { kind, a, b } = event;
|
||
for (const track of tracks) if (!track.done && (track.ball === a || track.ball === b)) endTrack(track);
|
||
if (result.hit || (a !== me && b !== me)) return;
|
||
result.points.push(point(me));
|
||
if (kind === 'rail') return;
|
||
result.ghost = { x: me.x, y: me.y };
|
||
result.hit = kind === 'pin' ? 'pin' : (a === me ? b : a).role;
|
||
if (kind === 'pin') return;
|
||
const other = a === me ? b : a, speed = Math.hypot(other.vx, other.vy);
|
||
result.dir = speed ? [other.vx / speed, other.vy / speed] : null;
|
||
for (const ball of [me, other]) {
|
||
const points = [point(ball)];
|
||
tracks.push({ ball, points, done: false });
|
||
result.outgoing.push({ role: ball.role, points });
|
||
}
|
||
} };
|
||
for (let i = 0; i < 15 / FIXED_STEP; i++) {
|
||
step(w, FIXED_STEP);
|
||
for (const track of tracks) {
|
||
if (track.done) continue;
|
||
const [x, y] = track.points[0], dx = track.ball.x - x, dy = track.ball.y - y, d = Math.hypot(dx, dy);
|
||
if (d >= 50) { track.points.push([x + dx * 50 / d, y + dy * 50 / d]); track.done = true; }
|
||
else if (!track.ball.vx && !track.ball.vy) endTrack(track);
|
||
}
|
||
if (result.hit ? tracks.every(t => t.done) : !me.vx && !me.vy) break;
|
||
}
|
||
if (!result.hit) result.points.push(point(me));
|
||
predictions.set(c, { key, result });
|
||
return result;
|
||
}
|
||
|
||
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
|
||
export function lastLine(c, names) {
|
||
const l = c.last;
|
||
if (!l) return '';
|
||
if (l.foul) return `Fallo di ${names[l.shooter]} (${FOULS[l.foul]}): ${l.points} a ${names[l.to]}.`;
|
||
return l.points ? `${names[l.to]} fa ${l.points} punti!` : `${names[l.shooter]}: nessun punto.`;
|
||
}
|
||
export function status(c, names) {
|
||
if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`;
|
||
if (c.step === 'rolling') return 'Biglie in movimento…';
|
||
if (c.step === 'settle') return 'Si contano i birilli…';
|
||
if (c.holding) return c.charging ? 'Carica iniziata: rilascia per tirare.' : 'Mira fissata. Tieni 0,6 s per iniziare la carica.';
|
||
const last = lastLine(c, names);
|
||
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
|
||
}
|