// 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})`; }