initial Game

This commit is contained in:
christopher
2026-09-12 01:33:07 -04:00
commit a740d2f141
15 changed files with 4275 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
// 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;
}