Download src/physics.js from DJLougen/moonshot: direct link, hf CLI and curl.
- Browser
- Download file 9.03 kB
-
https://huggingface.co/spaces/DJLougen/moonshot/resolve/main/src/physics.js
- Command line
-
hf download hf://spaces/DJLougen/moonshot/src/physics.js
-
curl -L -o physics.js https://huggingface.co/spaces/DJLougen/moonshot/resolve/main/src/physics.js
9.03 kB
| // 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 }; | |
| } | |