// I rinquarti di Trappizzio Berti (Andrea, 28 ottobre; revisione di Codex): Trappizzio Berti mette le biglie in modo che la diretta sia // coperta, e si deve arrivare al bersaglio (la gialla) dopo almeno tante sponde senza toccare prima i disturbi. // Sulla stessa posizione tira prima Andrea, poi il tavolo torna com'era e tira Trappizzio Berti: un punto per ogni rinquarto // riuscito, sei posizioni, vince chi ne fa di più. Nell'allenamento tira prima Trappizzio Berti, per far vedere, e senza punti. // // La forza è sempre piena: conta solo il primo contatto, quindi tirare piano non serve mai (revisione di Codex, // rilievo 5). Il gioco è un rompicapo di angoli. import * as billiard from './biliardo.js'; const { LEN, WID, R, PIN, simulate, predict } = billiard; export { aimAt, aimScreen, nudge, release, cancel, turn, stopTurn, key } from './biliardo.js'; // `cushions`: sponde da fare prima del bersaglio. `width`: la finestra di angoli buoni (in gradi, a forza piena) che // una posizione deve avere, dalla prima all'ultima: le posizioni si fanno più strette man mano. `bertovo`: quanto è // preciso lui (la probabilità di stare dentro la finestra), dal più distratto al più spietato. export const LEVELS = { allenamento: { positions: 3, cushions: 1, width: [5, 4], bertovo: 1, guideBounces: 2, guideLength: Infinity, training: true }, bona: { positions: 6, cushions: 1, width: [5, 2.5], bertovo: 0.45, guideBounces: 2, guideLength: Infinity }, seria: { positions: 6, cushions: 2, width: [3, 1.5], bertovo: 0.65, guideBounces: 1, guideLength: 65 }, male: { positions: 6, cushions: 3, width: [2, 1], bertovo: 0.88, guideBounces: 0, guideLength: 0 }, }; // BUDGET: prove a vuoto per fotogramma mentre si prepara la posizione dopo (una costa circa 0,2 ms su un PC). const SCAN = 720, STEP_ANGLE = Math.PI * 2 / SCAN, BUDGET = 15, THINK = 1.1; const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 }); function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } const gauss = c => Math.sqrt(-2 * Math.log(1 - random(c))) * Math.cos(2 * Math.PI * random(c)); // z tale che una normale stia entro ±z con probabilità p: quanto deve essere stretto l'errore di Trappizzio Berti. const zFor = p => p >= 1 ? Infinity : Math.sqrt(2) * erfinv(p); function erfinv(x) { const a = 0.147, l = Math.log(1 - x * x), t = 2 / (Math.PI * a) + l / 2; return Math.sign(x) * Math.sqrt(Math.sqrt(t * t - l / a) - t); } const attempt = () => ({ cushions: 0, decided: false, point: false, reason: null, marks: [], hit: null }); export const shooter = c => c.order[c.round ?? 0]; // Si può mirare e tirare solo quando tocca ad Andrea (la biglia, per il biliardo, è sempre quella «player»). export const canThrow = c => c.step === 'aim' && shooter(c) === 'andrea'; export const canAdjust = c => canThrow(c) && !c.holding; // Il pulsante del pannello: Tira quando tocca ad Andrea, Prossima mentre si guarda l'esito. export const canPress = c => canThrow(c) || c.step === 'review'; export const aimFree = c => !canThrow(c); export const setPower = () => false; // la forza è sempre piena export const button = c => c.step === 'review' ? (c.last ? 'Finita' : 'Prossima') : c.step === 'result' ? 'Finita' : c.step === 'think' ? 'Tira Trappizzio Berti…' : c.step === 'aim' ? 'Tira' : 'In movimento…'; // Conta solo le sponde della bianca prima del primo ostacolo; il primo contatto decide. function contact(result, required, { kind, a, b }) { if (result.decided || (a.role !== 'player' && b?.role !== 'player')) return; if (kind === 'rail') { result.cushions++; result.marks.push({ x: a.x, y: a.y }); return; } result.decided = true; const white = a.role === 'player' ? a : b, other = white === a ? b : a; result.hit = { x: white.x, y: white.y }; result.point = kind === 'ball' && other.role === 'cpu' && result.cushions >= required; result.reason = result.point ? null : kind === 'ball' && other.role === 'cpu' ? 'poche sponde' : 'disturbo toccato'; } // Il tiro provato a vuoto, su una disposizione qualunque (quella in gioco se non se ne passa un'altra). export function tryShot(c, angle, power = 1, layout = null) { const w = layout ? { ...c, balls: layout.balls, pins: layout.pins } : c, result = attempt(); simulate(w, 'player', angle, power, { onContact: event => contact(result, c.skill.cushions, event), stop: () => result.decided }); if (!result.decided) result.reason = 'bersaglio mancato'; return result; } // Coperta: lungo la linea dei centri il primo urto della bianca non è il bersaglio (si confrontano le distanze del // primo impatto, non solo la vicinanza alla linea: revisione di Codex, rilievo 8). export function directCovered(c, layout = c) { const a = layout.balls.player, b = layout.balls.cpu, dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); if (!d) return false; const ux = dx / d, uy = dy / d; const impact = (p, radius) => { const t = (p.x - a.x) * ux + (p.y - a.y) * uy, off = Math.hypot(p.x - a.x - t * ux, p.y - a.y - t * uy), reach = R + radius; return t > 0 && off < reach ? t - Math.sqrt(reach * reach - off * off) : Infinity; }; const target = impact(b, R); const obstacles = [...Object.values(layout.balls).filter(x => !['player', 'cpu'].includes(x.role)).map(x => impact(x, R)), ...layout.pins.filter(p => !p.down).map(p => impact(p, PIN))]; return obstacles.some(t => t < target); } // Una disposizione a caso: bianca e gialla lontane, un disturbo in mezzo, un altro e due birilli sparsi. function candidate(c) { const place = role => ball(role, 20 + random(c) * (LEN - 40), 18 + random(c) * (WID - 36)); const player = place('player'), cpu = place('cpu'); if (Math.hypot(cpu.x - player.x, cpu.y - player.y) < 70) return null; const t = 0.3 + random(c) * 0.4; const blocker = ball('blocker', player.x + (cpu.x - player.x) * t, player.y + (cpu.y - player.y) * t); const layout = { balls: { player, cpu, blocker, extra: place('extra') }, pins: Array.from({ length: 2 }, () => ({ x: 20 + random(c) * (LEN - 40), y: 18 + random(c) * (WID - 36), red: false, down: false })) }; const objects = [...Object.values(layout.balls).map(b => ({ ...b, radius: R })), ...layout.pins.map(p => ({ ...p, radius: PIN }))]; if (objects.some((a, i) => objects.slice(i + 1).some(b => Math.hypot(a.x - b.x, a.y - b.y) < a.radius + b.radius + 2))) return null; return directCovered(c, layout) ? layout : null; } // Le finestre di angoli buoni (a forza piena), dalle prove fatte: gruppi di angoli vicini che fanno punto. function windows(hits) { const out = []; for (let i = 0; i < SCAN; i++) { if (!hits[i] || hits[(i - 1 + SCAN) % SCAN]) continue; let n = 0; const cushions = []; while (hits[(i + n) % SCAN] && n < SCAN) { cushions.push(hits[(i + n) % SCAN]); n++; } const middle = cushions[Math.floor(n / 2)]; out.push({ angle: (i + (n - 1) / 2) * STEP_ANGLE, width: n * 360 / SCAN, cushions: middle }); } return out; } // La finestra che deve avere la posizione numero k (da 0): dalla più larga alla più stretta. const widthFor = (skill, k) => skill.width[0] + (skill.width[1] - skill.width[0]) * (skill.positions > 1 ? k / (skill.positions - 1) : 0); // Il generatore lavora a pezzi (BUDGET prove alla volta), così il gioco non si ferma mentre prepara la posizione dopo. function generate(c, budget) { let g = c.gen; while (budget > 0) { if (!g) { const layout = candidate(c); if (!layout) { budget--; continue; } g = c.gen = { layout, i: 0, hits: new Array(SCAN).fill(0) }; } for (; g.i < SCAN && budget > 0; g.i++, budget--) { const r = tryShot(c, g.i * STEP_ANGLE, 1, g.layout); if (r.point) g.hits[g.i] = r.cushions; } if (g.i < SCAN) return null; c.gen = null; const k = c.ready.length + c.position, need = widthFor(c.skill, k); // Si preferisce una finestra col numero giusto di sponde; alla bona deve esserci per forza. const all = windows(g.hits), exact = all.filter(w => w.cushions === c.skill.cushions); const pool = exact.length || c.skill.cushions === 1 ? exact : all; const best = pool.sort((a, b) => b.width - a.width)[0]; if (best && best.width >= need) return { layout: g.layout, solution: { angle: best.angle, power: 1, width: best.width, cushions: best.cushions } }; g = null; } return null; } const copy = layout => ({ balls: Object.fromEntries(Object.entries(layout.balls).map(([k, b]) => [k, { ...b }])), pins: layout.pins.map(p => ({ ...p })) }); // Prepara il tavolo per chi tira adesso, sempre dalla disposizione di partenza della posizione. function setTable(c) { const fresh = copy(c.start); c.balls = fresh.balls; c.pins = fresh.pins; c.step = shooter(c) === 'andrea' ? 'aim' : 'think'; c.t = c.accumulator = 0; c.power = 1; c.holding = false; c.charging = false; c.aimLocked = false; c.plan = null; c.shot = c.pending = c.visual.shot = null; c.windup = 0; c.trail = []; c.angle = Math.atan2(c.balls.cpu.y - c.balls.player.y, c.balls.cpu.x - c.balls.player.x); c.attempt = attempt(); c.line = shooter(c) === 'andrea' ? 'Trappizzio Berti: «Oh tirami questo rinquarto!?»' : 'Tira Trappizzio Berti…'; } function nextPosition(c) { while (!c.ready.length) { const p = generate(c, 4000); if (p) c.ready.push(p); } const p = c.ready.shift(); c.position++; c.start = copy(p.layout); c.solution = p.solution; c.round = 0; setTable(c); } export function newRinquarto(seed = Date.now() >>> 0, def = {}) { const level = LEVELS[def.level] ? def.level : 'seria'; const c = { ...billiard.newBiliardo(seed, { maxSpeed: billiard.MAX_SPEED }), level, skill: LEVELS[level], position: 0, score: { andrea: 0, bertovo: 0 }, history: [], order: LEVELS[level].training ? ['bertovo', 'andrea'] : ['andrea', 'bertovo'], ready: [], gen: null, fixedPower: 1, trail: [], last: false }; nextPosition(c); return c; } // Trappizzio Berti mira al centro della finestra buona, con un errore che dipende dal livello: dentro la finestra con la // probabilità `bertovo`. Il tiro poi si fa davvero: se sbaglia, sbaglia sul panno. export function bertovoPlan(c) { const half = c.solution.width / 2 * Math.PI / 180, z = zFor(c.skill.bertovo); return { angle: c.solution.angle + (Number.isFinite(z) ? gauss(c) * half / z : 0), power: 1 }; } // Dopo l'esito, un tocco (o Prossima) va avanti: tocca all'altro sulla stessa posizione, o alla posizione dopo. export function advance(c) { if (c.step !== 'review') return false; if (c.round < c.order.length - 1) { c.round++; setTable(c); return true; } if (c.position >= c.skill.positions) { finish(c); return true; } nextPosition(c); return true; } function finish(c) { const { andrea, bertovo } = c.score; c.step = 'result'; c.result = { winner: c.skill.training ? 'draw' : andrea > bertovo ? 'player' : andrea < bertovo ? 'cpu' : 'draw', points: { ...c.score }, training: !!c.skill.training }; } // Mentre si guarda l'esito, Spazio, Invio, un tocco sul tavolo o il pulsante vanno avanti. export function press(c, pointerId) { return c.step === 'review' ? advance(c) : billiard.press(c, pointerId); } export function shoot(c) { return c.step === 'review' ? advance(c) : canThrow(c) && !c.holding && billiard.strike(c, 'player', c.angle, 1); } export function update(c, dt) { if (!Number.isFinite(dt) || dt <= 0) return; dt = Math.min(dt, 0.25); // Intanto si prepara la posizione dopo, un po' per volta. if (c.position + c.ready.length < c.skill.positions) { const p = generate(c, BUDGET); if (p) c.ready.push(p); } // L'ultimo birillo può essere ancora in caduta: l'animazione va avanti anche fermi sull'esito. if (c.step === 'result' || c.step === 'review') { c.visual.time += dt; return; } if (c.step === 'think') { c.t += dt; c.visual.time += dt; if (c.t >= THINK) { const plan = bertovoPlan(c); billiard.strike(c, 'player', plan.angle, plan.power); } return; } const before = c.step; billiard.update(c, dt, event => contact(c.attempt, c.skill.cushions, event)); if (c.step === 'rolling' && !c.windup) { const b = c.balls.player, last = c.trail.at(-1); if (!last || Math.hypot(last[0] - b.x, last[1] - b.y) > 1.5) c.trail.push([b.x, b.y]); } if (before !== 'rolling' || c.step !== 'settle') return; // Il tiro è finito: l'esito resta sul tavolo, con la traiettoria, finché non si va avanti. if (!c.attempt.decided) c.attempt.reason = 'bersaglio mancato'; const who = shooter(c); if (!c.skill.training) c.score[who] += Number(c.attempt.point); c.history.push({ position: c.position, who, ...c.attempt }); c.line = `${who === 'andrea' ? 'Andrea' : 'Trappizzio Berti'}: ${c.attempt.point ? 'punto!' : `niente, ${c.attempt.reason}.`}`; c.step = 'review'; c.last = c.round === c.order.length - 1 && c.position >= c.skill.positions; } // La guida si ferma al contatto, al limite di lunghezza o alla sponda consentita. export function guide(c) { if (!c.skill.guideLength) return []; const source = predict(c).points, points = [source[0]]; let remaining = c.skill.guideLength; for (const end of source.slice(1, c.skill.guideBounces + 1)) { const start = points.at(-1), dx = end[0] - start[0], dy = end[1] - start[1], distance = Math.hypot(dx, dy); if (distance > remaining) { points.push([start[0] + dx * remaining / distance, start[1] + dy * remaining / distance]); break; } points.push(end); remaining -= distance; } return points; } const need = c => c.skill.cushions === 1 ? 'Serve almeno 1 sponda.' : `Servono almeno ${c.skill.cushions} sponde.`; export function status(c) { const score = c.skill.training ? 'Allenamento, senza punti.' : `Andrea ${c.score.andrea}, Trappizzio Berti ${c.score.bertovo}.`; const prefix = `Posizione ${c.position} di ${c.skill.positions}. ${score}`; if (c.step === 'result') { if (c.result.training) return 'Allenamento finito. Quando vuoi, la sfida vera.'; return `${prefix} ${c.result.winner === 'player' ? 'Hai battuto Trappizzio Berti!' : c.result.winner === 'cpu' ? 'Ha vinto Trappizzio Berti.' : 'Pari.'}`; } if (c.step === 'review') return `${prefix} ${c.line} ${c.last ? 'Tocca per il risultato.' : 'Tocca o premi Prossima.'}`; if (c.step === 'think') return `${prefix} Tira Trappizzio Berti…`; if (c.step === 'rolling') return `${prefix} Sponde: ${c.attempt.cushions}/${c.skill.cushions}.`; return `${prefix} ${c.line} ${need(c)}`; }