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