La pista col gesso: le macchinine sul parcheggio di Cardetole contro il Chiaru (consenso del 29 ottobre, sprite di Codex dalla foto), un giro a spinte con le penalità alla francese (fuori, tagliè, cappottè, accompagnè: si torna dov'eri); Bertovo diventa Trappizzio Berti; Fabrizio Berti e il suo libro nei Ringraziamenti (Andrea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9KMfjthRUnujCGSzZ
This commit is contained in:
2026-10-29 08:47:03 +01:00
co-authored by Claude Opus 5.5
parent 1918c8d9c7
commit 08ab9ba069
57 changed files with 1082 additions and 54 deletions
+362
View File
@@ -0,0 +1,362 @@
// Le macchinine sulla pista col gesso (Andrea, 28 ottobre): sul parcheggio sotto casa, a Cardetole, si disegnava col
// gesso la pista e si davano spinte a turno alle Micro Machines; la macchinina doveva restare in pista: tutta fuori, si
// torna da dove si era partiti. Si gioca contro il Chiaru, un giro; vince chi taglia per primo il traguardo.
//
// Regole: una spinta a testa, prima Andrea e poi il Chiaru, così a fine giro hanno spinto lo stesso numero di volte.
// Se tagliano il traguardo tutti e due nello stesso giro di spinte vince chi è andato più in là. Le penalità, coi nomi
// alla francese che si usavano (Andrea, 29 ottobre), e per tutte si torna dove si era prima della spinta:
// - fuori: la macchinina esce tutta dal gesso (anche se ce la manda l'altra, urtandola);
// - tagliè: esce dalla parte di dentro di una curva, o salta l'erba di mezzo per tagliare la strada;
// - cappottè: un urto forte la ribalta;
// - accompagnè: il dito l'ha accompagnata invece di darle il colpetto (tenuto premuto oltre la carica piena).
// Unità: i pixel del primo piano 640×360; la pista si disegna sopra al parcheggio di Codex.
// La linea di mezzo, in senso orario a partire dal traguardo; la pista è larga 2·HALF, col gesso sui due bordi.
export const CONTROL = [
[150, 68], [300, 62], [450, 66], [540, 80], [586, 122], [580, 172], [536, 196], [470, 196], [414, 178],
[356, 168], [318, 196], [326, 240], [388, 262], [480, 260], [556, 262], [598, 290], [588, 324], [530, 334],
[400, 334], [250, 332], [130, 326], [74, 296], [58, 230], [62, 150], [88, 96],
];
export const HALF = 22, CAR = { len: 26, wid: 13, r: 6 }, LAPS = 1;
// La spinta: velocità di partenza da MIN_SPEED a MAX_SPEED, e l'asfalto che frena di DECEL px/s².
export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, LONG_PRESS = 0.6, FIXED_STEP = 1 / 120;
const REST = 0.7, THINK = 1, RESTITUTION = 0.6, OUT = HALF + CAR.r, WINDOW = 30;
// FLIP: la velocità d'urto (px/s) che ribalta la macchinina colpita; ACCOMPAGNA: quanti secondi si può restare alla
// carica piena prima che diventi un accompagnè.
export const FLIP = 260, ACCOMPAGNA = 0.45;
export const FAULTS = { fuori: 'Fuori', taglie: 'Tagliè', cappotte: 'Cappottè', accompagne: 'Accompagnè' };
export const OTHER = { player: 'cpu', cpu: 'player' };
// Quanto è bravo il Chiaru: quanto gli trema il dito (radianti e forza) e fin dove osa spingere (px). `guide`: cosa
// vede Andrea mentre mira (tutto il tiro col punto d'arrivo, solo la linea, solo la freccia).
export const LEVELS = {
bona: { wobble: 0.1, powerNoise: 0.16, ahead: 120, guide: 'full' },
seria: { wobble: 0.05, powerNoise: 0.08, ahead: 170, guide: 'line' },
male: { wobble: 0.022, powerNoise: 0.035, ahead: 200, guide: 'arrow' },
};
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;
export const powerAt = time => clamp(time / SWING, 0, 1);
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * clamp(power, 0, 1);
// Quanta strada fa una macchinina spinta a velocità v (l'asfalto frena sempre uguale).
export const reach = v => v * v / (2 * DECEL);
export const powerFor = distance => clamp((Math.sqrt(2 * DECEL * Math.max(0, distance)) - MIN_SPEED) / (MAX_SPEED - MIN_SPEED), 0, 1);
// --- La pista: una curva chiusa (Catmull-Rom sui punti di controllo), ricampionata a passo costante.
export function buildTrack(control = CONTROL, step = 2) {
const n = control.length, dense = [];
for (let i = 0; i < n; i++) {
const [p0, p1, p2, p3] = [control[(i - 1 + n) % n], control[i], control[(i + 1) % n], control[(i + 2) % n]];
for (let k = 0; k < 24; k++) {
const t = k / 24, t2 = t * t, t3 = t2 * t;
const f = j => 0.5 * (2 * p1[j] + (p2[j] - p0[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2 + (3 * p1[j] - p0[j] - 3 * p2[j] + p3[j]) * t3);
dense.push([f(0), f(1)]);
}
}
const cum = [0];
for (let i = 1; i <= dense.length; i++) cum.push(cum[i - 1] + Math.hypot(dense[i % dense.length][0] - dense[i - 1][0], dense[i % dense.length][1] - dense[i - 1][1]));
const total = cum.at(-1), count = Math.round(total / step), pts = [];
for (let j = 0, i = 0; j < count; j++) {
const at = j * total / count;
while (cum[i + 1] < at) i++;
const a = dense[i], b = dense[(i + 1) % dense.length], t = (at - cum[i]) / (cum[i + 1] - cum[i] || 1);
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
const tan = pts.map((p, i) => {
const a = pts[(i - 1 + count) % count], b = pts[(i + 1) % count], d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
return [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
});
return { pts, tan, n: count, step: total / count, length: total };
}
export const TRACK = buildTrack();
// Il punto della linea di mezzo più vicino: vicino all'ultimo noto (`hint`), così nei tornanti non si salta dall'altra
// parte; senza `hint`, su tutta la pista. `s` è la distanza lungo la pista dal traguardo, `d` quella dalla linea.
export function project(track, x, y, hint) {
const { pts, n } = track;
const from = hint === undefined ? 0 : hint - WINDOW, to = hint === undefined ? n : hint + WINDOW;
let best = null;
for (let k = from; k < to; k++) {
const i = ((k % n) + n) % n, a = pts[i], b = pts[(i + 1) % n];
const dx = b[0] - a[0], dy = b[1] - a[1], len2 = dx * dx + dy * dy || 1;
const t = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / len2, 0, 1);
const d = Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
if (!best || d < best.d) best = { i, t, d };
}
return { i: best.i, d: best.d, s: (best.i + best.t) * track.step };
}
// Tutta fuori: nessun pezzo della macchinina tocca più la pista.
export const isOut = d => d > OUT;
// Uscita dalla parte di dentro di una curva (verso dove gira la pista): è un tagliè.
function inside(track, i, x, y) {
const a = track.tan[(i - 6 + track.n) % track.n], b = track.tan[(i + 6) % track.n], turn = a[0] * b[1] - a[1] * b[0];
const [px, py] = track.pts[i], [tx, ty] = track.tan[i], side = tx * (y - py) - ty * (x - px);
return Math.abs(turn) > 0.05 && Math.sign(turn) === Math.sign(side);
}
// La macchinina tiene il conto della strada fatta (`progress`, anche oltre il giro) seguendo la pista da vicino.
function follow(track, car) {
const p = project(track, car.x, car.y, car.idx);
let delta = p.s - car.s;
if (delta > track.length / 2) delta -= track.length; else if (delta < -track.length / 2) delta += track.length;
car.progress += delta; car.s = p.s; car.idx = p.i;
if (isOut(p.d) && !car.fault) car.fault = inside(track, p.i, car.x, car.y) ? 'taglie' : 'fuori';
}
function newCar(track, role, back, side) {
const i = (track.n - Math.round(back / track.step)) % track.n, [x, y] = track.pts[i], [tx, ty] = track.tan[i];
const car = { role, x: x - ty * side, y: y + tx * side, vx: 0, vy: 0, heading: Math.atan2(ty, tx), idx: i, s: 0, progress: 0, fault: null };
const p = project(track, car.x, car.y);
car.idx = p.i; car.s = p.s; car.progress = p.s > track.length / 2 ? p.s - track.length : p.s;
return car;
}
const cars = c => [c.cars.player, c.cars.cpu];
export const moving = c => cars(c).some(car => car.vx || car.vy);
// L'urto fra le due macchinine: due dischi uguali, un po' di rimbalzo. Se l'urto è forte, quella colpita (la più
// lenta delle due) si ribalta.
function collide(a, b, events) {
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy), min = CAR.r * 2;
if (d >= min || d < 1e-6) return;
const nx = dx / d, ny = dy / d, push = (min - d) / 2;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const rel = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (rel <= 0) return;
const j = rel * (1 + RESTITUTION) / 2;
const hit = Math.hypot(a.vx, a.vy) < Math.hypot(b.vx, b.vy) ? a : b;
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
if (rel > FLIP) hit.fault = 'cappotte';
events?.push({ kind: 'micro-urto', power: Math.min(1, rel / 300) });
}
function step(track, list, dt, events) {
for (const car of list) {
const v = Math.hypot(car.vx, car.vy);
if (!v) continue;
const nv = Math.max(0, v - DECEL * dt);
if (nv < 2) { car.vx = car.vy = 0; continue; }
car.vx *= nv / v; car.vy *= nv / v;
car.x += car.vx * dt; car.y += car.vy * dt;
}
collide(list[0], list[1], events);
for (const car of list) if (car.vx || car.vy) follow(track, car);
}
export function newMicro(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK;
const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0,
holding: false, holdTime: 0, accumulator: 0, events: [], track, laps: LAPS, flicks: { player: 0, cpu: 0 },
line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } };
// In griglia: Andrea all'esterno, il Chiaru all'interno, poco prima del traguardo.
c.cars = { player: newCar(track, 'player', 18, -11), cpu: newCar(track, 'cpu', 18, 11) };
c.angle = c.cars.player.heading;
return c;
}
export const canThrow = c => c.step === 'aim' && c.current === 'player';
export const finished = (c, car) => car.progress >= c.laps * c.track.length;
const snapshot = car => ({ x: car.x, y: car.y, heading: car.heading, idx: car.idx, s: car.s, progress: car.progress });
export function strike(c, who, angle, power) {
c.turn = null;
const car = c.cars[who], v = speedFor(power);
c.before = { player: snapshot(c.cars.player), cpu: snapshot(c.cars.cpu) };
for (const x of cars(c)) x.fault = null;
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v; car.heading = angle;
c.returns = c.returns.filter(r => c.visual.time - r.at < 4);
if (who === 'player') c.line = null;
c.shooter = who; c.angle = angle; c.power = power; c.plan = null; c.trail = [[car.x, car.y]];
c.step = 'rolling'; c.t = c.accumulator = 0; c.flicks[who]++;
c.events.push({ kind: 'micro-spinta', power }, { kind: 'micro-strisciata', power });
return true;
}
// La spinta provata a vuoto su una copia: dove arriva ciascuna macchinina e se qualcuna esce.
export function simulate(c, who, angle, power) {
const list = cars(c).map(x => ({ ...x, fault: null })), car = list.find(x => x.role === who), v = speedFor(power);
car.vx = Math.cos(angle) * v; car.vy = Math.sin(angle) * v;
for (let i = 0; i < 6 / FIXED_STEP && list.some(x => x.vx || x.vy); i++) step(c.track, list, FIXED_STEP);
return Object.fromEntries(list.map(x => [x.role, x]));
}
// Il Chiaru guarda la pista davanti a sé: prova dei punti più o meno lontani, al centro e verso i bordi, e per ognuno
// anche le spinte storte che gli possono scappare; sceglie quello che in media lo porta più avanti senza uscire.
// Le prove si fanno a pezzi (BUDGET per fotogramma): tutte insieme fermerebbero il gioco per un decimo di secondo.
const BUDGET = 20;
function cpuCandidates(c) {
const me = c.cars.cpu, k = c.skill, list = [];
for (let ahead = 30; ahead <= k.ahead; ahead += 10) for (const side of [-10, 0, 10]) {
const i = (me.idx + Math.round(ahead / c.track.step)) % c.track.n, [px, py] = c.track.pts[i], [tx, ty] = c.track.tan[i];
const x = px - ty * side, y = py + tx * side, angle = Math.atan2(y - me.y, x - me.x), power = powerFor(Math.hypot(x - me.x, y - me.y));
for (const [da, dp] of [[0, 0], [k.wobble, 0], [-k.wobble, 0], [0, k.powerNoise], [0, -k.powerNoise]])
list.push({ angle, power, try: [angle + da, clamp(power + dp, 0, 1)] });
}
return list;
}
export function cpuThink(c, budget = BUDGET) {
const t = c.thinking ??= { todo: cpuCandidates(c), scores: new Map() }, me = c.cars.cpu;
for (let i = 0; i < budget && t.todo.length; i++) {
const x = t.todo.pop(), r = simulate(c, 'cpu', ...x.try).cpu, key = x.angle + '|' + x.power;
t.scores.set(key, { angle: x.angle, power: x.power, score: (t.scores.get(key)?.score ?? 0) + (r.fault ? -80 : r.progress - me.progress) });
}
if (t.todo.length) return false;
const best = [...t.scores.values()].reduce((a, b) => b.score > a.score ? b : a, { score: -Infinity, angle: me.heading, power: 0.3 });
const k = c.skill;
c.plan = { angle: best.angle + deviation(c) * k.wobble, power: clamp(best.power + deviation(c) * k.powerNoise, 0.02, 1) };
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
return true;
}
// Finita la spinta: chi ha fatto un fallo torna al suo posto, e alla fine di ogni giro di spinte si guarda il traguardo.
function settle(c) {
const names = { player: 'Andrea', cpu: 'il Chiaru' }, outs = cars(c).filter(car => car.fault);
// Uscita e ripresa la pista più in là: ha saltato l'erba di mezzo, è un tagliè.
for (const car of outs) if (car.fault === 'fuori' && !isOut(project(c.track, car.x, car.y).d)) car.fault = 'taglie';
// Le scritte di un giro di spinte restano insieme fino alla spinta dopo di Andrea: così vede anche le sue.
const said = outs.map(car => `${FAULTS[car.fault]}! ${names[car.role][0].toUpperCase() + names[car.role].slice(1)} torna dov'era.`);
// Dove è uscita resta una croce, e la macchinina si vede tornare indietro (solo disegno).
for (const car of outs) {
c.returns.push({ role: car.role, x: car.x, y: car.y, heading: car.heading, fault: car.fault, at: c.visual.time });
Object.assign(car, c.before[car.role], { vx: 0, vy: 0, fault: null });
c.events.push({ kind: 'micro-fuori', power: car.role === 'player' ? 1 : 0.5 });
}
// Rimessa al suo posto, non deve stare sopra all'altra.
const [a, b] = cars(c), d = Math.hypot(b.x - a.x, b.y - a.y);
if (outs.length === 1 && d < CAR.r * 2) {
const back = outs[0], other = back === a ? b : a, nx = d ? (back.x - other.x) / d : 1, ny = d ? (back.y - other.y) / d : 0;
back.x = other.x + nx * CAR.r * 2; back.y = other.y + ny * CAR.r * 2;
}
const mine = c.cars[c.shooter];
if (!outs.length && finished(c, mine)) said.push(`${c.shooter === 'player' ? 'Andrea' : 'Il Chiaru'} taglia il traguardo!`);
c.line = [c.line, ...said].filter(Boolean).join(' ') || null;
if (!outs.includes(mine) && finished(c, mine)) c.events.push({ kind: 'micro-arrivo', power: c.shooter === 'player' ? 1 : 0.5 });
// Il giro di spinte si chiude col Chiaru: solo allora si decide, così hanno spinto tutti e due lo stesso numero di volte.
const end = c.laps * c.track.length, over = { player: c.cars.player.progress - end, cpu: c.cars.cpu.progress - end };
if (c.shooter === 'cpu' && (over.player >= 0 || over.cpu >= 0)) {
const winner = over.player >= 0 && over.cpu >= 0 ? (Math.abs(over.player - over.cpu) < 1 ? 'draw' : over.player > over.cpu ? 'player' : 'cpu') : over.player >= 0 ? 'player' : 'cpu';
c.step = 'result'; c.result = { winner, flicks: { ...c.flicks } };
return;
}
// Chi ha già tagliato il traguardo aspetta; spinge l'altro.
c.current = OTHER[c.shooter];
c.step = 'aim'; c.t = 0;
if (c.current === 'player') c.angle = aimAhead(c);
}
// Quando tocca ad Andrea la mira parte già lungo la pista, un po' più avanti.
function aimAhead(c) {
const car = c.cars.player, i = (car.idx + Math.round(60 / c.track.step)) % c.track.n, [x, y] = c.track.pts[i];
return Math.atan2(y - car.y, x - car.x);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.turn && canThrow(c) && !c.holding) {
c.turn.time += dt;
c.angle += c.turn.dir * dt * (c.turn.fine ? 0.05 : 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.holdDelay ? Math.min(1, c.previousPower + powerAt(c.holdTime - c.holdDelay)) : powerAt(c.holdTime);
// Alla carica piena il dito non deve restare: si conta quanto ci sta, per l'accompagnè.
c.overHold = c.charging && c.power >= 1 ? (c.overHold ?? 0) + dt : 0;
}
if (c.step === 'rolling') {
c.accumulator += dt;
const list = cars(c), car = c.cars[c.shooter];
while (c.accumulator >= FIXED_STEP) { step(c.track, list, FIXED_STEP, c.events); c.accumulator -= FIXED_STEP; }
const last = c.trail.at(-1);
if (!last || Math.hypot(last[0] - car.x, last[1] - car.y) > 3) c.trail.push([car.x, car.y]);
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) settle(c);
else if (c.step === 'aim' && c.current === 'cpu') {
if (!c.plan && c.t >= THINK * 0.3) cpuThink(c);
if (c.plan && c.t >= THINK) strike(c, 'cpu', c.plan.angle, c.plan.power);
}
}
// --- Comandi del giocatore: gli stessi del biliardo (mira col mouse o col dito, si tiene premuto per la forza).
export function aimAt(c, x, y) {
if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const car = c.cars.player;
if (Math.hypot(x - car.x, y - car.y) < CAR.r) return false;
c.angle = Math.atan2(y - car.y, x - car.x); return true;
}
export const aimScreen = (c, [x, y]) => x >= 0 && x <= 640 && y >= 24 && y <= 360 && 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.004 : 0.03); 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); }
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.004 : 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.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; c.overHold = 0; return true;
}
export function release(c, pointerId) {
if (c.pointerId !== pointerId || !c.holding) return false;
if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto
const accompagne = (c.overHold ?? 0) > ACCOMPAGNA;
c.holding = c.charging = false; c.overHold = 0;
strike(c, 'player', c.angle, c.power);
if (accompagne) c.cars.player.fault = 'accompagne';
return true;
}
// Sta tenendo il dito oltre la carica piena: il disegno lo avvisa prima che diventi un accompagnè.
export const overHeld = c => c.holding && (c.overHold ?? 0) > 0;
// La guida: dove si fermerebbe la macchinina, se nessuno la tocca, e se ci arriva restando in pista.
export function guide(c) {
const car = c.cars.player, length = reach(speedFor(c.power)), ux = Math.cos(c.angle), uy = Math.sin(c.angle);
let idx = car.idx, out = false;
for (let d = 0; d <= length; d += 2) {
const p = project(c.track, car.x + ux * d, car.y + uy * d, idx);
idx = p.i; if (isOut(p.d)) { out = true; break; }
}
return { x: car.x + ux * length, y: car.y + uy * length, length, out };
}
export function button(c) {
if (c.step === 'result') return 'Finita';
if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…';
return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi';
}
// Quanta pista manca al traguardo, in percentuale, per il tabellone.
export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1);
export function status(c) {
const where = `Andrea ${Math.round(done(c, 'player') * 100)}%, Chiaru ${Math.round(done(c, 'cpu') * 100)}% del giro.`;
if (c.step === 'result') return `${where} ${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'}`;
if (c.step === 'rolling' || c.step === 'settle') return `${where} ${c.line ?? ''}`.trim();
if (c.current === 'cpu') return `${where} Spinge il Chiaru…`;
return `${where} ${c.line ? c.line + ' ' : ''}Tocca a te: mira e spingi.`;
}