Biliardo: immagini e suoni insieme. Durante la steccata aspetta la fisica, non il disegno: la biglia parte quando la stecca la tocca, col suo suono, e si disegnano sempre le posizioni vere (segnalato da Codex: urti che sembravano senza contatto e suoni in anticipo di 180 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { LEN, WID, R, VIEW, canThrow, predict, status } from './biliardo.js';
|
import { LEN, WID, R, VIEW, WINDUP, canThrow, predict, status } from './biliardo.js';
|
||||||
import { text, panel } from './bocce-render.js';
|
import { text, panel } from './bocce-render.js';
|
||||||
|
|
||||||
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
|
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
|
||||||
@@ -6,25 +6,10 @@ const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
|||||||
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
|
// Biglia reale e fantasma hanno esattamente il raggio usato per gli urti.
|
||||||
const rr = R * VIEW.k;
|
const rr = R * VIEW.k;
|
||||||
|
|
||||||
// La grafica resta indietro di 180 ms: la stecca arriva alla biglia prima del moto
|
// Il disegno mostra sempre le posizioni vere: durante la steccata (WINDUP) è la fisica ad aspettare la stecca,
|
||||||
// visibile, mentre la simulazione e i comandi continuano senza alcun ritardo.
|
// non le immagini a restare indietro. Così gli urti si vedono dove avvengono e i suoni cadono insieme.
|
||||||
export const SHOT_DELAY = 0.18;
|
export const SHOT_DELAY = WINDUP;
|
||||||
export function visualFrame(c) {
|
export function visualFrame(c) { return { balls: c.balls, pins: c.pins, time: c.visual?.time ?? 0 }; }
|
||||||
const v = c.visual;
|
|
||||||
if (!v?.shot || !['rolling', 'settle'].includes(c.step)) return { balls: c.balls, pins: c.pins, time: v?.time ?? 0 };
|
|
||||||
const time = Math.max(v.shot.at, v.time - SHOT_DELAY), frames = v.frames;
|
|
||||||
let before = frames[0], after = before;
|
|
||||||
for (const frame of frames) {
|
|
||||||
if (frame.at <= time) before = frame;
|
|
||||||
after = frame;
|
|
||||||
if (frame.at >= time) break;
|
|
||||||
}
|
|
||||||
const mix = after.at > before.at ? (time - before.at) / (after.at - before.at) : 0;
|
|
||||||
const balls = Object.fromEntries(before.balls.map((b, i) => [b.role, {
|
|
||||||
...b, x: b.x + (after.balls[i].x - b.x) * mix, y: b.y + (after.balls[i].y - b.y) * mix,
|
|
||||||
}]));
|
|
||||||
return { balls, pins: before.pins, time };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
|
// Ritagli misurati sul master generato (1672×941). Il panno centrale è mappato
|
||||||
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
|
// esattamente su VIEW; le fasce esterne non possono cambiare l'area di gioco.
|
||||||
|
|||||||
+18
-9
@@ -7,6 +7,9 @@
|
|||||||
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
|
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 MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
|
||||||
export const SWING = 1.4, FIXED_STEP = 1 / 240;
|
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;
|
||||||
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
||||||
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
const CONTACT_EPS = 1e-7, BALL_RESTITUTION = 0.95;
|
||||||
export const OTHER = { player: 'cpu', cpu: 'player' };
|
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||||||
@@ -50,7 +53,7 @@ export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
|||||||
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.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();
|
c.pins = castle();
|
||||||
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
|
// Cronologia di sola presentazione: non entra in urti, guida, regole o scelte della CPU.
|
||||||
c.visual = { time: 0, shot: null, frames: [] };
|
c.visual = { time: 0, shot: null };
|
||||||
c.angle = angleTo(c.balls.player, c.balls.cpu);
|
c.angle = angleTo(c.balls.player, c.balls.cpu);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
@@ -169,11 +172,10 @@ function step(w, dt) {
|
|||||||
function strike(c, who, angle, power) {
|
function strike(c, who, angle, power) {
|
||||||
const b = c.balls[who], v = speedFor(power);
|
const b = c.balls[who], v = speedFor(power);
|
||||||
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
|
c.visual.shot = { at: c.visual.time, angle, power, x: b.x, y: b.y };
|
||||||
c.visual.frames = [{ at: c.visual.time, balls: list(c).map(b => ({ ...b })), pins: c.pins.map(p => ({ ...p })) }];
|
c.pending = { ball: b, vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, power };
|
||||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
c.windup = WINDUP;
|
||||||
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
|
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;
|
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
|
||||||
c.events.push({ kind: 'biliardo-cue', power });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||||
@@ -241,7 +243,17 @@ export function update(c, dt) {
|
|||||||
c.visual.time += dt;
|
c.visual.time += dt;
|
||||||
if (c.last) c.last.age += dt;
|
if (c.last) c.last.age += dt;
|
||||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||||
if (c.step === 'rolling') {
|
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;
|
c.accumulator += dt;
|
||||||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
||||||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||||
@@ -251,11 +263,8 @@ export function update(c, dt) {
|
|||||||
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
|
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.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
|
||||||
}
|
}
|
||||||
if (c.step === 'rolling' || c.step === 'settle') {
|
if (c.step === 'rolling' || c.step === 'settle')
|
||||||
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
|
for (const p of c.pins) if (p.down && p.visualDownAt === undefined) p.visualDownAt = c.visual.time;
|
||||||
c.visual.frames.push({ at: c.visual.time, balls: list(c).map(b => ({ ...b })), pins: c.pins.map(p => ({ ...p })) });
|
|
||||||
while (c.visual.frames.length > 2 && c.visual.frames[1].at < c.visual.time - 0.4) c.visual.frames.shift();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Comandi del giocatore: gli stessi delle bocce.
|
// --- Comandi del giocatore: gli stessi delle bocce.
|
||||||
|
|||||||
@@ -25,25 +25,21 @@ test('il panno generato coincide con VIEW, senza buchi fra i ritagli', () => {
|
|||||||
assert.ok(Math.abs(area - 640 * 360) < 1e-8);
|
assert.ok(Math.abs(area - 640 * 360) < 1e-8);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('la steccata trattiene solo il disegno: velocità immediata e traiettoria invariata', () => {
|
test('la steccata: la fisica parte quando la stecca tocca la biglia, e il disegno mostra sempre le posizioni vere', () => {
|
||||||
const c = newBiliardo(42), start = { ...c.balls.player };
|
const c = newBiliardo(42), start = { ...c.balls.player };
|
||||||
const expected = simulate(c, 'player', c.angle, c.power);
|
const expected = simulate(c, 'player', c.angle, c.power);
|
||||||
shoot(c);
|
shoot(c);
|
||||||
assert.ok(Math.hypot(c.balls.player.vx, c.balls.player.vy) > 0);
|
|
||||||
update(c, SHOT_DELAY / 2);
|
update(c, SHOT_DELAY / 2);
|
||||||
assert.notEqual(c.balls.player.x, start.x, 'la fisica è già partita');
|
assert.equal(c.balls.player.x, start.x, 'la stecca non è ancora arrivata');
|
||||||
assert.equal(visualFrame(c).balls.player.x, start.x, 'il disegno aspetta la punta della stecca');
|
assert.ok(!c.events.some(e => e.kind === 'biliardo-cue'), 'niente suono del colpo prima del contatto');
|
||||||
update(c, SHOT_DELAY);
|
update(c, SHOT_DELAY);
|
||||||
|
assert.ok(c.events.some(e => e.kind === 'biliardo-cue'), 'il suono del colpo arriva col contatto');
|
||||||
|
assert.ok(c.balls.player.x !== start.x || c.balls.player.y !== start.y, 'la biglia è partita');
|
||||||
const before = structuredClone(c);
|
const before = structuredClone(c);
|
||||||
const shown = visualFrame(c);
|
assert.equal(visualFrame(c).balls, c.balls, 'il disegno usa le posizioni vere, senza ritardo (Codex: urti e suoni sfasati)');
|
||||||
assert.ok(shown.balls.player.x > start.x);
|
|
||||||
assert.ok(shown.balls.player.x < c.balls.player.x);
|
|
||||||
assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato');
|
assert.deepEqual(c, before, 'leggere il fotogramma non scrive nello stato');
|
||||||
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
|
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
|
||||||
assert.deepEqual(judge(c.shot), expected);
|
assert.deepEqual(judge(c.shot), expected);
|
||||||
assert.ok(c.visual.frames.length < 110, 'la cronologia non cresce per tutta la partita');
|
|
||||||
while (c.step === 'settle') update(c, FIXED_STEP);
|
|
||||||
assert.equal(visualFrame(c).balls, c.balls, 'al nuovo turno torna la posizione corrente');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => {
|
test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata', () => {
|
||||||
@@ -62,7 +58,6 @@ test('caduta progressiva dei birilli, senza alterare guida, punteggio o rialzata
|
|||||||
assert.equal(pinFall(p, p.visualDownAt), 0);
|
assert.equal(pinFall(p, p.visualDownAt), 0);
|
||||||
assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1);
|
assert.ok(pinFall(p, p.visualDownAt + 0.12) > 0 && pinFall(p, p.visualDownAt + 0.12) < 1);
|
||||||
assert.equal(pinFall(p, p.visualDownAt + 0.3), 1);
|
assert.equal(pinFall(p, p.visualDownAt + 0.3), 1);
|
||||||
assert.equal(visualFrame(c).pins.some(p => p.down), false, 'anche la caduta segue il tempo delle biglie visibili');
|
|
||||||
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
|
for (let i = 0; i < 10000 && c.step === 'rolling'; i++) update(c, FIXED_STEP);
|
||||||
assert.deepEqual(judge(c.shot), expected);
|
assert.deepEqual(judge(c.shot), expected);
|
||||||
while (c.step === 'settle') update(c, FIXED_STEP);
|
while (c.step === 'settle') update(c, FIXED_STEP);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION } from '../src/engine/biliardo.js';
|
import { newBiliardo, update, press, release, cancel, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimAt, aimScreen, nudge, canThrow, collide, simulate, LEN, WID, R, VIEW, SWING, powerAt, FIXED_STEP, CUSHION, WINDUP } from '../src/engine/biliardo.js';
|
||||||
import { story } from '../src/story/story.js';
|
import { story } from '../src/story/story.js';
|
||||||
import { Game } from '../src/engine/game.js';
|
import { Game } from '../src/engine/game.js';
|
||||||
import { settle } from './helpers.mjs';
|
import { settle } from './helpers.mjs';
|
||||||
@@ -191,6 +191,7 @@ test('la mira è bloccata durante la carica, il tocco breve ripristina la forza
|
|||||||
press(c); update(c, 0.2); cancel(c);
|
press(c); update(c, 0.2); cancel(c);
|
||||||
assert.equal(c.power, power);
|
assert.equal(c.power, power);
|
||||||
press(c); update(c, 0.2); release(c);
|
press(c); update(c, 0.2); release(c);
|
||||||
|
update(c, WINDUP + 1 / 240); // la stecca arriva alla biglia
|
||||||
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
|
near(Math.atan2(c.balls.player.vy, c.balls.player.vx), fixed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user