Browser game teaching how to reverse a car with a trailer. Vanilla JS, no build step, runs from file:// in Safari. - Bicycle-model car kinematics + trailer articulation (jackknife behavior) - WASD controls with steering input smoothing; rendered steering wheel and rig-angle HUD - Tron-style vector rendering, OBB/SAT collision - Four hand-built maps (dock, lot, roadside, driveway) plus a validated procedurally-generated 5th map Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7 KiB
JavaScript
200 lines
7 KiB
JavaScript
/* physics.js — kinematic simulation of a car towing a single-axle trailer.
|
|
*
|
|
* Model: bicycle (single-track) kinematics for the car, plus the standard
|
|
* trailer-articulation equation. The car's reference point is the REAR AXLE.
|
|
* Reversing makes the trailer unstable (jackknife) exactly as in real life,
|
|
* which is the whole point of the game.
|
|
*/
|
|
(function (TOW) {
|
|
'use strict';
|
|
|
|
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
|
|
|
|
// shortest signed difference a-b wrapped to [-PI, PI]
|
|
function angleDiff(a, b) {
|
|
let d = (a - b) % (Math.PI * 2);
|
|
if (d > Math.PI) d -= Math.PI * 2;
|
|
if (d < -Math.PI) d += Math.PI * 2;
|
|
return d;
|
|
}
|
|
|
|
// Fixed vehicle geometry (world pixels). Shared by physics, render, collision.
|
|
var GEO = {
|
|
L: 64, // wheelbase (rear axle -> front axle)
|
|
frontOverhang: 16, // body ahead of front axle
|
|
rearOverhang: 14, // body behind rear axle
|
|
carWidth: 34,
|
|
d: 14, // hitch behind rear axle
|
|
tongue: 26, // hitch -> front of trailer body
|
|
trailerLen: 78,
|
|
trailerWidth: 40,
|
|
M: 92 // hitch -> trailer axle (articulation length)
|
|
};
|
|
|
|
// Handling constants.
|
|
var CFG = {
|
|
maxSteer: 0.62, // rad, ~35deg at the wheels
|
|
steerRate: 1.7, // rad/s while holding A/D (the "input smoothing")
|
|
returnRate: 2.3, // rad/s self-centering when released
|
|
accel: 230, // px/s^2
|
|
friction: 170, // px/s^2 coast-down
|
|
maxForward: 135, // px/s
|
|
maxReverse: 95 // px/s
|
|
};
|
|
|
|
function Vehicle() {
|
|
this.geo = GEO;
|
|
this.cfg = CFG;
|
|
this.reset({ x: 0, y: 0, carHeading: 0, trailerHeading: 0 });
|
|
}
|
|
|
|
Vehicle.prototype.reset = function (start) {
|
|
this.x = start.x; // rear-axle position
|
|
this.y = start.y;
|
|
this.theta = start.carHeading; // car body heading (forward dir)
|
|
this.phi = start.trailerHeading; // trailer heading (axle -> hitch dir)
|
|
this.v = 0; // signed speed (+forward, -reverse)
|
|
this.steer = 0; // current (smoothed) steering angle
|
|
};
|
|
|
|
Vehicle.prototype.snapshot = function () {
|
|
return { x: this.x, y: this.y, theta: this.theta, phi: this.phi, v: this.v };
|
|
};
|
|
Vehicle.prototype.restore = function (s) {
|
|
this.x = s.x; this.y = s.y; this.theta = s.theta; this.phi = s.phi; this.v = s.v;
|
|
};
|
|
|
|
// input: {up,down,left,right}
|
|
Vehicle.prototype.step = function (dt, input) {
|
|
var c = this.cfg;
|
|
|
|
// --- steering: smoothed toward the held direction, self-centering when idle
|
|
if (input.left && !input.right) {
|
|
this.steer -= c.steerRate * dt;
|
|
} else if (input.right && !input.left) {
|
|
this.steer += c.steerRate * dt;
|
|
} else {
|
|
var r = c.returnRate * dt;
|
|
if (Math.abs(this.steer) <= r) this.steer = 0;
|
|
else this.steer -= Math.sign(this.steer) * r;
|
|
}
|
|
this.steer = clamp(this.steer, -c.maxSteer, c.maxSteer);
|
|
|
|
// --- throttle / brake
|
|
if (input.up && !input.down) {
|
|
this.v += c.accel * dt;
|
|
} else if (input.down && !input.up) {
|
|
this.v -= c.accel * dt;
|
|
} else {
|
|
var f = c.friction * dt;
|
|
if (Math.abs(this.v) <= f) this.v = 0;
|
|
else this.v -= Math.sign(this.v) * f;
|
|
}
|
|
this.v = clamp(this.v, -c.maxReverse, c.maxForward);
|
|
|
|
// --- integrate bicycle model (rate of heading change, per second)
|
|
var dtheta = (this.v / this.geo.L) * Math.tan(this.steer);
|
|
this.theta += dtheta * dt;
|
|
this.x += this.v * Math.cos(this.theta) * dt;
|
|
this.y += this.v * Math.sin(this.theta) * dt;
|
|
|
|
// --- trailer articulation (with hitch offset d behind the rear axle)
|
|
var rel = this.theta - this.phi;
|
|
var dphi = (this.v / this.geo.M) * Math.sin(rel)
|
|
- (this.geo.d / this.geo.M) * Math.cos(rel) * dtheta;
|
|
this.phi += dphi * dt;
|
|
};
|
|
|
|
// Resolve every body/anchor point from the current state. Single source of
|
|
// truth so rendering and collision never disagree.
|
|
Vehicle.prototype.bodies = function () {
|
|
var g = this.geo;
|
|
var ct = Math.cos(this.theta), st = Math.sin(this.theta);
|
|
var cf = Math.cos(this.phi), sf = Math.sin(this.phi);
|
|
|
|
var rax = { x: this.x, y: this.y };
|
|
var frontAxle = { x: rax.x + g.L * ct, y: rax.y + g.L * st };
|
|
var sc = (g.L + g.frontOverhang - g.rearOverhang) / 2; // body center ahead of rear axle
|
|
var carBox = {
|
|
cx: rax.x + sc * ct, cy: rax.y + sc * st,
|
|
hw: (g.L + g.frontOverhang + g.rearOverhang) / 2, hh: g.carWidth / 2,
|
|
a: this.theta
|
|
};
|
|
var hitch = { x: rax.x - g.d * ct, y: rax.y - g.d * st };
|
|
var bodyOff = g.tongue + g.trailerLen / 2; // body center behind hitch
|
|
var trailerBox = {
|
|
cx: hitch.x - bodyOff * cf, cy: hitch.y - bodyOff * sf,
|
|
hw: g.trailerLen / 2, hh: g.trailerWidth / 2,
|
|
a: this.phi
|
|
};
|
|
var trailerAxle = { x: hitch.x - g.M * cf, y: hitch.y - g.M * sf };
|
|
|
|
return {
|
|
rax: rax, frontAxle: frontAxle, hitch: hitch,
|
|
carBox: carBox, trailerBox: trailerBox, trailerAxle: trailerAxle
|
|
};
|
|
};
|
|
|
|
// Articulation (hitch) angle in radians; near +/-PI/2 means jackknifed.
|
|
Vehicle.prototype.hitchAngle = function () { return angleDiff(this.phi, this.theta); };
|
|
|
|
TOW.Vehicle = Vehicle;
|
|
TOW.GEO = GEO;
|
|
TOW.clamp = clamp;
|
|
TOW.angleDiff = angleDiff;
|
|
|
|
// ---- Oriented-box helpers + SAT collision -------------------------------
|
|
function corners(b) {
|
|
var c = Math.cos(b.a), s = Math.sin(b.a);
|
|
var ax = { x: c * b.hw, y: s * b.hw }; // half-extent along length
|
|
var ay = { x: -s * b.hh, y: c * b.hh }; // half-extent along width
|
|
return [
|
|
{ x: b.cx + ax.x + ay.x, y: b.cy + ax.y + ay.y },
|
|
{ x: b.cx + ax.x - ay.x, y: b.cy + ax.y - ay.y },
|
|
{ x: b.cx - ax.x - ay.x, y: b.cy - ax.y - ay.y },
|
|
{ x: b.cx - ax.x + ay.x, y: b.cy - ax.y + ay.y }
|
|
];
|
|
}
|
|
TOW.corners = corners;
|
|
|
|
function projOverlap(ca, cb, ax) {
|
|
var minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity, i, p;
|
|
for (i = 0; i < 4; i++) {
|
|
p = ca[i].x * ax.x + ca[i].y * ax.y;
|
|
if (p < minA) minA = p; if (p > maxA) maxA = p;
|
|
}
|
|
for (i = 0; i < 4; i++) {
|
|
p = cb[i].x * ax.x + cb[i].y * ax.y;
|
|
if (p < minB) minB = p; if (p > maxB) maxB = p;
|
|
}
|
|
return !(maxA < minB || maxB < minA);
|
|
}
|
|
|
|
// Separating Axis Theorem for two oriented boxes.
|
|
function obbOverlap(a, b) {
|
|
var ca = corners(a), cb = corners(b);
|
|
var axes = [
|
|
{ x: Math.cos(a.a), y: Math.sin(a.a) },
|
|
{ x: -Math.sin(a.a), y: Math.cos(a.a) },
|
|
{ x: Math.cos(b.a), y: Math.sin(b.a) },
|
|
{ x: -Math.sin(b.a), y: Math.cos(b.a) }
|
|
];
|
|
for (var i = 0; i < axes.length; i++) {
|
|
if (!projOverlap(ca, cb, axes[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
TOW.obbOverlap = obbOverlap;
|
|
|
|
// Any corner of box outside the play rect [0,W]x[0,H] (with margin)?
|
|
function outOfBounds(box, W, H, margin) {
|
|
var c = corners(box);
|
|
for (var i = 0; i < 4; i++) {
|
|
if (c[i].x < margin || c[i].x > W - margin ||
|
|
c[i].y < margin || c[i].y > H - margin) return true;
|
|
}
|
|
return false;
|
|
}
|
|
TOW.outOfBounds = outOfBounds;
|
|
|
|
})(window.TOW = window.TOW || {});
|