// 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; }