// physics.js — PURE math: only imports dims.js constants, no three.js, no DOM. // Node-testable. Units: meters + seconds. // Contact plane z=0, release z=-18.44 (mound, from dims.js). See CONTRACT.md. import { RELEASE } from './dims.js'; export { RELEASE }; export const PLANE_Z = 0; export const ZONE = { x0: -0.45, x1: 0.45, y0: 0.45, y1: 1.55 }; export const SWING = { min: 0.02, max: 0.32, ideal: 0.14 }; // seconds before crossing (R16: longer window, later sweet spot) const TAU = Math.PI * 2; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const lerp = (a, b, s) => a + (b - a) * s; // --------------------------------------------------------------------------- // Deterministic RNG shared by game + capture mode. // --------------------------------------------------------------------------- export function mulberry32(seed) { let a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // --------------------------------------------------------------------------- // createPitch — draw one pitch from a levelDef + rng. // levelDef fields used: duration, speedVar, curve:[lo,hi]?, jitter:[lo,hi]? // Consumes rng in a FIXED order (deterministic for a given seed + levelDef): // duration, target.x, target.y, curve?, curveDir, drop, jitter?, jf1, jf2, p1, p2 // --------------------------------------------------------------------------- export function createPitch(levelDef, rng) { // Duration jittered by speedVar (± speedVar * duration). const duration = levelDef.duration * (1 + (rng() * 2 - 1) * (levelDef.speedVar ?? 0)); // Nominal aim point at the contact plane (inside-ish of the strike zone). const target = { x: -0.32 + rng() * 0.64, // x ∈ [-0.32, 0.32] y: 0.75 + rng() * 0.70, // y ∈ [0.75, 1.45] }; const cr = levelDef.curve; // [lo,hi] or absent const curve = cr ? lerp(cr[0], cr[1], rng()) : 0; const curveDir = rng() < 0.5 ? -1 : 1; const drop = 0.08 + rng() * 0.22; // [0.08, 0.30] const jr = levelDef.jitter; // [lo,hi] or absent const jitterAmp = jr ? lerp(jr[0], jr[1], rng()) : 0; const jf1 = 2 + rng() * 2; // jitter freq 1 ∈ [2,4] Hz (R15: knuckleball wobble, not buzz) const jf2 = 2 + rng() * 2; // jitter freq 2 ∈ [2,4] Hz const p1 = rng() * TAU; // phases ∈ [0, 2π) const p2 = rng() * TAU; return { duration, release: { x: RELEASE.x, y: RELEASE.y, z: RELEASE.z }, target, curve, curveDir, drop, jitterAmp, jf1, jf2, p1, p2, }; } // --------------------------------------------------------------------------- // Internal path evaluation, t clamped into [0, duration] by callers. // s = t/duration // z = lerp(-18.44 → 0, s) // x = lerp(release.x → target.x, s) + curveDir*curve*breakShape(s) + jitterX // y = lerp(release.y → target.y, s) - drop·s² + jitterY // jitterX = jitterAmp·min(1,2s)·sin(2π·jf1·t + p1) — env 0 at s=0 ⇒ release exact, // jitterY = jitterAmp·min(1,2s)·sin(2π·jf2·t + p2) full-strength from s≥0.5 (R15) // breakShape(s) = sin(π·s^1.7)·(1−s)/BREAK_PEAK — R6 curve-snap fix. // The bare sin(π·s^1.7) bump is 0 at s=1 but lands with a nonzero slope // (≈5.34·curve/duration lateral m/s): the ball visibly zips sideways in // the last instant and the plane velocity carries that kink. Multiplying // by (1−s) makes BOTH the value and the derivative vanish at s=1 — the // break settles before the plate like a real breaking ball, and the // linear extrapolation past the plane is C1-continuous through it. // BREAK_PEAK normalizes so `curve` still means "peak lateral break in // meters" (peak at s≈0.516, vs 0.665 before — the pitch commits to its // break a touch earlier, then runs true). // --------------------------------------------------------------------------- // Numerically measured max of sin(π·s^1.7)·(1−s) on s∈[0,1] (at s≈0.51586). const BREAK_PEAK = 0.41245259522492866; function breakShape(s) { return (Math.sin(Math.PI * Math.pow(s, 1.7)) * (1 - s)) / BREAK_PEAK; } function evalPath(pitch, t) { const s = t / pitch.duration; const wob = pitch.jitterAmp * Math.min(1, s * 2); // R15: ramp in early, wobble reads mid-flight return { x: lerp(pitch.release.x, pitch.target.x, s) + pitch.curveDir * pitch.curve * breakShape(s) + wob * Math.sin(TAU * pitch.jf1 * t + pitch.p1), y: lerp(pitch.release.y, pitch.target.y, s) - pitch.drop * s * s + wob * Math.sin(TAU * pitch.jf2 * t + pitch.p2), z: lerp(pitch.release.z, PLANE_Z, s), }; } // --------------------------------------------------------------------------- // pitchPosition(pitch, t) → {x,y,z} // t<0 clamps to the release point; 0..duration follows the path; // t>duration extrapolates linearly with the plane velocity so a missed // ball keeps sailing toward the camera. // --------------------------------------------------------------------------- export function pitchPosition(pitch, t) { if (t <= 0) return evalPath(pitch, 0); if (t <= pitch.duration) return evalPath(pitch, t); const end = evalPath(pitch, pitch.duration); const v = pitchVelocityAtPlane(pitch); const dt = t - pitch.duration; return { x: end.x + v.x * dt, y: end.y + v.y * dt, z: end.z + v.z * dt }; } // --------------------------------------------------------------------------- // pitchVelocityAtPlane — CENTRAL finite difference straddling s=1 (evalPath // stays valid slightly past the plane). Central ≈ 2nd-order: with // breakShape'(1)=0 this is exactly the slope a missed ball keeps when the // game extrapolates it toward the mitt — no kink, no snap. // --------------------------------------------------------------------------- export function pitchVelocityAtPlane(pitch) { const eps = Math.min(1e-3, pitch.duration * 0.5); const a = evalPath(pitch, pitch.duration - eps); const b = evalPath(pitch, pitch.duration + eps); return { x: (b.x - a.x) / (2 * eps), y: (b.y - a.y) / (2 * eps), z: (b.z - a.z) / (2 * eps), }; } // --------------------------------------------------------------------------- // inZone — strike-zone test on a plane position {x,y}. // --------------------------------------------------------------------------- export function inZone({ x, y }) { return x >= ZONE.x0 && x <= ZONE.x1 && y >= ZONE.y0 && y <= ZONE.y1; } // --------------------------------------------------------------------------- // zoneOverlap(pos) — fraction of the ball disc inside the strike zone (R23). // The ball is sampled as a 16-point ring of radius BALL_ZONE_R around pos // (deterministic angles i·2π/16); returns (#samples inside ZONE.x/y)/16 ∈[0,1]. // Pure, no rng. Used at the CLICK instant so "how much of the ball was in the // zone" feeds contact quality. // --------------------------------------------------------------------------- export const BALL_ZONE_R = 0.10; export function zoneOverlap(pos) { let inside = 0; for (let i = 0; i < 16; i++) { const a = (i * TAU) / 16; const x = pos.x + BALL_ZONE_R * Math.cos(a); const y = pos.y + BALL_ZONE_R * Math.sin(a); if (x >= ZONE.x0 && x <= ZONE.x1 && y >= ZONE.y0 && y <= ZONE.y1) inside += 1; } return inside / 16; } // --------------------------------------------------------------------------- // judgeContact({timingErr, clickOff, zoneFrac}, hrThreshold=0.60) (R23 v2) // timingErr: seconds the swing lands BEFORE crossing (positive = early). // clickOff: reticle-aim distance from the ball's LIVE position at click. // zoneFrac: zoneOverlap(clickPos) — ball-disc fraction inside the zone. // tq = clamp(1 - |timingErr-ideal|/ideal, 0, 1) — swing timing // cq = clamp(1 - clickOff/0.5, 0, 1) — where on the ball (1=dead-on) // quality = clamp(0.55*tq + 0.30*cq + 0.15*zoneFrac, 0, 1) // contact iff SWING.min<=timingErr<=SWING.max && tq>0.1 && cq>0.15 // R16: SWING widened to [0.02, 0.32] around ideal 0.14 — effective contact // reach extends to ~0.266 s early (tq>0.1 still gates the tail). // R17: homerun iff quality >= hrThreshold (level 1 passes 0.50); // else 'popup' (weak contact) iff quality < 0.45; else 'inplay'. // --------------------------------------------------------------------------- export function judgeContact({ timingErr, clickOff, zoneFrac }, hrThreshold = 0.6) { const tq = clamp(1 - Math.abs(timingErr - SWING.ideal) / SWING.ideal, 0, 1); const cq = clamp(1 - clickOff / 0.5, 0, 1); const quality = clamp(0.55 * tq + 0.3 * cq + 0.15 * zoneFrac, 0, 1); const contact = timingErr >= SWING.min && timingErr <= SWING.max && tq > 0.1 && cq > 0.15; if (!contact) return { kind: 'none', quality }; if (quality >= hrThreshold) return { kind: 'homerun', quality }; return { kind: quality < 0.45 ? 'popup' : 'inplay', quality }; }