tow-game/js/physics.js
Erich Blume 41efdecd93 Initial prototype: TOW trailer-reversing trainer
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>
2026-06-06 18:39:03 -07:00

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 || {});