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