moonshot / src /physics.js
DJLougen's picture
MOONSHOT: cosmic night-league batting (static Three.js game)
db520fb verified
Raw History Blame Contribute Delete
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 };
}