Files
leggenda-del-regolo/src/engine/biliardo.js
T

401 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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})`;
}