initial Game
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user