466 lines
15 KiB
JavaScript
466 lines
15 KiB
JavaScript
// Minimal 2D physics for a steel ball rolling on an inclined playfield.
|
|
//
|
|
// Units are millimetres and seconds. +x points right and +y points DOWN the
|
|
// table (towards the player), which matches canvas coordinates.
|
|
//
|
|
// Collision response follows Matthias Müller's "Ten Minute Physics" pinball
|
|
// tutorial: push the ball out of the obstacle along the contact normal, then
|
|
// correct the ball's normal velocity. This version adds speed-dependent
|
|
// restitution, moving surfaces (flippers) and a tapered flipper shape.
|
|
|
|
/** Approach speeds below this (mm/s) are treated as inelastic so the ball can roll and rest without jitter. */
|
|
const RESTING_SPEED = 30;
|
|
|
|
/**
|
|
* restitution: bounciness at low impact speed (0..1).
|
|
* falloff: how quickly restitution drops with impact speed: e = restitution / (1 + falloff * speed_in_m_per_s).
|
|
* friction: Coulomb-style friction coefficient applied to the sliding velocity on impact.
|
|
*/
|
|
export function makeMaterial(restitution, falloff = 0, friction = 0) {
|
|
return { restitution, falloff, friction };
|
|
}
|
|
|
|
/** Result of the most recent successful collision test (reused to avoid allocations in the hot loop). */
|
|
export const contact = { approach: 0, nx: 0, ny: 0 };
|
|
|
|
export class Ball {
|
|
constructor(radius) {
|
|
this.radius = radius;
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.prevX = 0;
|
|
this.prevY = 0;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
// Riding a ramp or wireform: the track, distance along it, speed along it and height above the playfield.
|
|
this.track = null;
|
|
this.s = 0;
|
|
this.v = 0;
|
|
this.z = 0;
|
|
this.prevZ = 0;
|
|
}
|
|
|
|
/** Teleport the ball (no interpolation smear) and stop it. */
|
|
place(x, y) {
|
|
this.x = this.prevX = x;
|
|
this.y = this.prevY = y;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
this.track = null;
|
|
this.z = this.prevZ = 0;
|
|
}
|
|
|
|
get speed() {
|
|
return Math.hypot(this.vx, this.vy);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advance the ball by one fixed step using semi-implicit Euler.
|
|
* `dampingFactor` is the per-step velocity multiplier (rolling resistance).
|
|
*/
|
|
export function integrate(ball, dt, gravity, dampingFactor, maxSpeed) {
|
|
ball.prevX = ball.x;
|
|
ball.prevY = ball.y;
|
|
ball.vy += gravity * dt;
|
|
ball.vx *= dampingFactor;
|
|
ball.vy *= dampingFactor;
|
|
const speed = Math.hypot(ball.vx, ball.vy);
|
|
if (speed > maxSpeed) {
|
|
const s = maxSpeed / speed;
|
|
ball.vx *= s;
|
|
ball.vy *= s;
|
|
}
|
|
ball.x += ball.vx * dt;
|
|
ball.y += ball.vy * dt;
|
|
}
|
|
|
|
/**
|
|
* Resolve a contact. (nx, ny) is the unit normal pointing from the surface to the ball,
|
|
* `depth` the penetration, and (svx, svy) the velocity of the surface at the contact point.
|
|
* Returns the approach speed if an impulse was applied, otherwise 0.
|
|
*/
|
|
export function resolveContact(ball, nx, ny, depth, svx, svy, material) {
|
|
ball.x += nx * depth;
|
|
ball.y += ny * depth;
|
|
|
|
const rvx = ball.vx - svx;
|
|
const rvy = ball.vy - svy;
|
|
const vn = rvx * nx + rvy * ny;
|
|
if (vn >= 0) return 0; // already separating
|
|
|
|
const approach = -vn;
|
|
const e = approach < RESTING_SPEED ? 0 : material.restitution / (1 + (material.falloff * approach) / 1000);
|
|
const jn = (1 + e) * approach;
|
|
let dvx = jn * nx;
|
|
let dvy = jn * ny;
|
|
|
|
if (material.friction > 0 && approach >= RESTING_SPEED) {
|
|
const tx = rvx - vn * nx;
|
|
const ty = rvy - vn * ny;
|
|
const ts = Math.hypot(tx, ty);
|
|
if (ts > 1e-6) {
|
|
const jt = Math.min(material.friction * jn, ts); // never reverse the sliding direction
|
|
dvx -= (tx / ts) * jt;
|
|
dvy -= (ty / ts) * jt;
|
|
}
|
|
}
|
|
|
|
ball.vx += dvx;
|
|
ball.vy += dvy;
|
|
return approach;
|
|
}
|
|
|
|
/** Make sure the ball leaves along (nx, ny) at no less than `speed` (pop bumpers, slingshots). */
|
|
export function kick(ball, nx, ny, speed) {
|
|
const vn = ball.vx * nx + ball.vy * ny;
|
|
if (vn < speed) {
|
|
ball.vx += (speed - vn) * nx;
|
|
ball.vy += (speed - vn) * ny;
|
|
}
|
|
}
|
|
|
|
/** A straight wall with rounded ends (a capsule of the given radius around the segment a→b). */
|
|
export class Segment {
|
|
constructor(ax, ay, bx, by, { radius = 0, material, oneWay = false, tag = null } = {}) {
|
|
this.ax = ax;
|
|
this.ay = ay;
|
|
this.bx = bx;
|
|
this.by = by;
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
this.len = Math.hypot(dx, dy);
|
|
this.ux = dx / this.len;
|
|
this.uy = dy / this.len;
|
|
// Left-hand normal when walking from a to b in screen space. One-way segments only block this side.
|
|
this.nx = this.uy;
|
|
this.ny = -this.ux;
|
|
this.radius = radius;
|
|
this.material = material;
|
|
this.oneWay = oneWay;
|
|
this.tag = tag;
|
|
this.enabled = true;
|
|
this.minX = Math.min(ax, bx) - radius;
|
|
this.maxX = Math.max(ax, bx) + radius;
|
|
this.minY = Math.min(ay, by) - radius;
|
|
this.maxY = Math.max(ay, by) + radius;
|
|
}
|
|
}
|
|
|
|
/** Returns true (and fills `contact`) if the ball touched the segment. */
|
|
export function collideSegment(ball, s) {
|
|
const r = ball.radius;
|
|
if (ball.x < s.minX - r || ball.x > s.maxX + r || ball.y < s.minY - r || ball.y > s.maxY + r) return false;
|
|
|
|
const px = ball.x - s.ax;
|
|
const py = ball.y - s.ay;
|
|
let t = px * s.ux + py * s.uy;
|
|
if (t < 0) t = 0;
|
|
else if (t > s.len) t = s.len;
|
|
const dx = ball.x - (s.ax + s.ux * t);
|
|
const dy = ball.y - (s.ay + s.uy * t);
|
|
const minDist = r + s.radius;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 >= minDist * minDist) return false;
|
|
if (s.oneWay && px * s.nx + py * s.ny < 0) return false;
|
|
|
|
const d = Math.sqrt(d2);
|
|
let nx = s.nx;
|
|
let ny = s.ny;
|
|
if (d > 1e-9) {
|
|
nx = dx / d;
|
|
ny = dy / d;
|
|
}
|
|
contact.nx = nx;
|
|
contact.ny = ny;
|
|
contact.approach = resolveContact(ball, nx, ny, minDist - d, 0, 0, s.material);
|
|
return true;
|
|
}
|
|
|
|
/** A round post, pop bumper body, etc. */
|
|
export class Circle {
|
|
constructor(x, y, radius, { material, tag = null } = {}) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.radius = radius;
|
|
this.material = material;
|
|
this.tag = tag;
|
|
}
|
|
}
|
|
|
|
export function collideCircle(ball, c) {
|
|
const dx = ball.x - c.x;
|
|
const dy = ball.y - c.y;
|
|
const minDist = ball.radius + c.radius;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 >= minDist * minDist) return false;
|
|
const d = Math.sqrt(d2);
|
|
const nx = d > 1e-9 ? dx / d : 0;
|
|
const ny = d > 1e-9 ? dy / d : -1;
|
|
contact.nx = nx;
|
|
contact.ny = ny;
|
|
contact.approach = resolveContact(ball, nx, ny, minDist - d, 0, 0, c.material);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* A flipper: a tapered capsule (large circle at the pivot, small circle at the tip) that
|
|
* rotates between a rest angle and an "up" angle. Angles are radians in screen space
|
|
* (0 = pointing right, positive = clockwise on screen because +y is down).
|
|
*/
|
|
export class Flipper {
|
|
constructor({ x, y, length, baseRadius, tipRadius, restAngle, upAngle, upSpeed, downSpeed, material }) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.length = length;
|
|
this.baseRadius = baseRadius;
|
|
this.tipRadius = tipRadius;
|
|
this.restAngle = restAngle;
|
|
this.upAngle = upAngle;
|
|
this.upSpeed = upSpeed;
|
|
this.downSpeed = downSpeed;
|
|
this.material = material;
|
|
this.angle = restAngle;
|
|
this.prevAngle = restAngle;
|
|
this.omega = 0; // angular velocity (rad/s) during the last step
|
|
this.pressed = false;
|
|
// Constants for Inigo Quilez's exact 2D uneven-capsule signed distance function.
|
|
this.b = (baseRadius - tipRadius) / length;
|
|
this.a = Math.sqrt(1 - this.b * this.b);
|
|
this._d = { dist: 0, nx: 0, ny: 0 };
|
|
}
|
|
|
|
update(dt) {
|
|
this.prevAngle = this.angle;
|
|
const target = this.pressed ? this.upAngle : this.restAngle;
|
|
const step = (this.pressed ? this.upSpeed : this.downSpeed) * dt;
|
|
const diff = target - this.angle;
|
|
this.angle = Math.abs(diff) <= step ? target : this.angle + Math.sign(diff) * step;
|
|
this.omega = (this.angle - this.prevAngle) / dt;
|
|
}
|
|
|
|
/** 0 at rest, 1 fully up. */
|
|
get lift() {
|
|
return (this.angle - this.restAngle) / (this.upAngle - this.restAngle);
|
|
}
|
|
|
|
tipPosition(angle = this.angle) {
|
|
return { x: this.x + Math.cos(angle) * this.length, y: this.y + Math.sin(angle) * this.length };
|
|
}
|
|
|
|
/**
|
|
* Signed distance from (px, py) to the flipper surface and the outward surface normal.
|
|
* Port of sdUnevenCapsule (iquilezles.org/articles/distfunctions2d): capsule along the local
|
|
* +y axis from (0,0) with radius r1 to (0,h) with radius r2.
|
|
*/
|
|
distance(px, py) {
|
|
const c = Math.cos(this.angle);
|
|
const s = Math.sin(this.angle);
|
|
const rx = px - this.x;
|
|
const ry = py - this.y;
|
|
const ly = rx * c + ry * s; // along the flipper, pivot → tip
|
|
const across = -rx * s + ry * c; // perpendicular to the flipper
|
|
const lx = Math.abs(across);
|
|
const side = across < 0 ? -1 : 1;
|
|
const { a, b, length: h } = this;
|
|
const k = -b * lx + a * ly;
|
|
let dist;
|
|
let nlx;
|
|
let nly;
|
|
if (k < 0) {
|
|
const L = Math.hypot(lx, ly);
|
|
dist = L - this.baseRadius;
|
|
nlx = L > 1e-9 ? lx / L : 1;
|
|
nly = L > 1e-9 ? ly / L : 0;
|
|
} else if (k > a * h) {
|
|
const qy = ly - h;
|
|
const L = Math.hypot(lx, qy);
|
|
dist = L - this.tipRadius;
|
|
nlx = L > 1e-9 ? lx / L : 1;
|
|
nly = L > 1e-9 ? qy / L : 0;
|
|
} else {
|
|
dist = a * lx + b * ly - this.baseRadius;
|
|
nlx = a;
|
|
nly = b;
|
|
}
|
|
nlx *= side;
|
|
const out = this._d;
|
|
out.dist = dist;
|
|
out.nx = nly * c - nlx * s;
|
|
out.ny = nly * s + nlx * c;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
export function collideFlipper(ball, f) {
|
|
const dx = ball.x - f.x;
|
|
const dy = ball.y - f.y;
|
|
const reach = f.length + f.baseRadius + ball.radius;
|
|
if (dx * dx + dy * dy > reach * reach) return false;
|
|
|
|
const { dist, nx, ny } = f.distance(ball.x, ball.y);
|
|
const pen = ball.radius - dist;
|
|
if (pen <= 0) return false;
|
|
|
|
// Velocity of the flipper surface at the contact point: omega x r.
|
|
const qx = ball.x - nx * dist;
|
|
const qy = ball.y - ny * dist;
|
|
const svx = -f.omega * (qy - f.y);
|
|
const svy = f.omega * (qx - f.x);
|
|
|
|
contact.nx = nx;
|
|
contact.ny = ny;
|
|
contact.approach = resolveContact(ball, nx, ny, pen, svx, svy, f.material);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Ball-to-ball collision between two equal-mass balls (multiball). This is the Ten Minute Physics
|
|
* handleBallBallCollision formula with m1 = m2, skipping the impulse when the balls already separate.
|
|
*/
|
|
export function collideBalls(a, b, restitution) {
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const minDist = a.radius + b.radius;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 === 0 || d2 >= minDist * minDist) return 0;
|
|
const d = Math.sqrt(d2);
|
|
const nx = dx / d;
|
|
const ny = dy / d;
|
|
const corr = (minDist - d) / 2;
|
|
a.x -= nx * corr;
|
|
a.y -= ny * corr;
|
|
b.x += nx * corr;
|
|
b.y += ny * corr;
|
|
const v1 = a.vx * nx + a.vy * ny;
|
|
const v2 = b.vx * nx + b.vy * ny;
|
|
if (v1 - v2 <= 0) return 0;
|
|
const newV1 = (v1 + v2 - (v1 - v2) * restitution) / 2;
|
|
const newV2 = (v1 + v2 - (v2 - v1) * restitution) / 2;
|
|
a.vx += nx * (newV1 - v1);
|
|
a.vy += ny * (newV1 - v1);
|
|
b.vx += nx * (newV2 - v2);
|
|
b.vy += ny * (newV2 - v2);
|
|
return v1 - v2;
|
|
}
|
|
|
|
/** Uniform Catmull-Rom spline through [x, y, z] control points, sampled `steps` times per span. */
|
|
function catmullRom(points, steps) {
|
|
const out = [];
|
|
const p = (i) => points[Math.max(0, Math.min(points.length - 1, i))];
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = p(i - 1);
|
|
const p1 = p(i);
|
|
const p2 = p(i + 1);
|
|
const p3 = p(i + 2);
|
|
for (let k = 0; k < steps; k++) {
|
|
const t = k / steps;
|
|
const t2 = t * t;
|
|
const t3 = t2 * t;
|
|
out.push(
|
|
[0, 1, 2].map(
|
|
(c) =>
|
|
0.5 *
|
|
(2 * p1[c] +
|
|
(-p0[c] + p2[c]) * t +
|
|
(2 * p0[c] - 5 * p1[c] + 4 * p2[c] - p3[c]) * t2 +
|
|
(-p0[c] + 3 * p1[c] - 3 * p2[c] + p3[c]) * t3),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
out.push([...points[points.length - 1]]);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* A ramp or wireform the ball rides above the playfield. The ball is treated as a bead on a wire:
|
|
* its state is a distance along a smooth path and a speed along it. The path climbs and falls through
|
|
* an elevation profile (z, mm above the playfield); the climb plus the table's own slope decide whether
|
|
* a shot makes it or rolls back out of the entrance, as a weak shot does on a real ramp.
|
|
*/
|
|
export class Track {
|
|
constructor(controlPoints, { name, kind = 'wire', width = 40, steps = 10 } = {}) {
|
|
this.name = name;
|
|
this.kind = kind; // 'ramp' (plastic) or 'wire' (habitrail)
|
|
this.width = width;
|
|
this.points = catmullRom(controlPoints, steps);
|
|
this.cum = [0];
|
|
for (let i = 1; i < this.points.length; i++) {
|
|
const [ax, ay] = this.points[i - 1];
|
|
const [bx, by] = this.points[i];
|
|
this.cum.push(this.cum[i - 1] + Math.hypot(bx - ax, by - ay));
|
|
}
|
|
this.length = this.cum[this.cum.length - 1];
|
|
this._s = { x: 0, y: 0, z: 0, tx: 0, ty: 0, slope: 0 };
|
|
}
|
|
|
|
/** Position, height, unit in-plane tangent and climb (dz per mm of plan distance) at distance s. */
|
|
sample(s) {
|
|
const cum = this.cum;
|
|
s = Math.max(0, Math.min(this.length, s));
|
|
let lo = 0;
|
|
let hi = cum.length - 1;
|
|
while (hi - lo > 1) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (cum[mid] <= s) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
const [ax, ay, az] = this.points[lo];
|
|
const [bx, by, bz] = this.points[hi];
|
|
const len = cum[hi] - cum[lo] || 1;
|
|
const t = (s - cum[lo]) / len;
|
|
const out = this._s;
|
|
out.x = ax + (bx - ax) * t;
|
|
out.y = ay + (by - ay) * t;
|
|
out.z = az + (bz - az) * t;
|
|
out.tx = (bx - ax) / len;
|
|
out.ty = (by - ay) / len;
|
|
out.slope = (bz - az) / len;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advance a ball riding a track by one step. `gTable` is gravity along the playfield (towards the
|
|
* player), `gUp` gravity perpendicular to it (what a ramp climbs against), `friction` a rolling
|
|
* deceleration. Returns 'end' or 'start' when the ball leaves the track, otherwise null.
|
|
*/
|
|
export function advanceOnTrack(ball, dt, gTable, gUp, friction) {
|
|
const track = ball.track;
|
|
let p = track.sample(ball.s);
|
|
const k = Math.sqrt(1 + p.slope * p.slope);
|
|
// Gravity along the 3D path direction (tx, ty, slope) / k.
|
|
let a = (gTable * p.ty - gUp * p.slope) / k;
|
|
if (ball.v > 0) a -= friction;
|
|
else if (ball.v < 0) a += friction;
|
|
ball.prevX = ball.x;
|
|
ball.prevY = ball.y;
|
|
ball.prevZ = ball.z;
|
|
ball.v += a * dt;
|
|
ball.s += (ball.v * dt) / k;
|
|
if (ball.s >= track.length) {
|
|
ball.s = track.length;
|
|
p = track.sample(ball.s);
|
|
return 'end';
|
|
}
|
|
if (ball.s <= 0) {
|
|
ball.s = 0;
|
|
return 'start';
|
|
}
|
|
p = track.sample(ball.s);
|
|
ball.x = p.x;
|
|
ball.y = p.y;
|
|
ball.z = p.z;
|
|
return null;
|
|
}
|
|
|
|
/** Does the segment p→q cross the segment a→b? (Used by tests to detect tunnelling.) */
|
|
export function segmentsCross(px, py, qx, qy, ax, ay, bx, by) {
|
|
const d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
const d2 = (bx - ax) * (qy - ay) - (by - ay) * (qx - ax);
|
|
const d3 = (qx - px) * (ay - py) - (qy - py) * (ax - px);
|
|
const d4 = (qx - px) * (by - py) - (qy - py) * (bx - px);
|
|
return d1 * d2 < 0 && d3 * d4 < 0;
|
|
}
|