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>
This commit is contained in:
Erich Blume 2026-06-06 18:39:03 -07:00
commit 41efdecd93
9 changed files with 1238 additions and 0 deletions

62
README.md Normal file
View file

@ -0,0 +1,62 @@
# TOW — trailer reversing trainer
A small browser game that teaches you the counter-intuitive art of **reversing a
car with a trailer**. Vector / "Tron" graphics, no build step, no dependencies.
## Play
Just open the file in Safari:
```sh
open index.html
```
(or double-click `index.html`). Everything runs client-side from `file://` — the
scripts are plain classic `<script>` tags specifically so this works without a
server.
If you'd rather serve it over HTTP:
```sh
python3 -m http.server 8000 # then visit http://localhost:8000
```
## How to play
- **W / S** — drive forward / reverse (gear shows **D** / **R** / **N**).
- **A / D** — steer. You must *hold* the key to wind the wheel over; it
self-centers when you let go. Watch the steering-wheel instrument.
- **R** — reset the current map · **N** — next map · **15** — jump to a map.
Goal: back the **trailer** into the dashed green zone, roughly aligned with the
arrow, and come to a stop. The **RIG ANGLE** panel (bottom-left) shows the live
angle between car and trailer — keep an eye on it: reversing amplifies the angle,
and if it folds past the limit you'll **jackknife** (ease off the steering and
pull forward to straighten out).
### Maps
1. **Loading Dock** — straight reverse, the warm-up.
2. **Parking Lot** — 90° back-in between two cars.
3. **Roadside** — parallel park into a gap at the curb.
4. **Driveway** — back up an angled drive between the house and a hedge.
5. **Random** — a procedurally generated lot or roadside scenario, rebuilt every
time you arrive at it (each candidate is validated for a collision-free,
reachable start before being accepted). `R` retries the same layout.
## Code layout
Plain ES5-ish modules attached to a global `TOW` namespace, loaded in order:
| File | Responsibility |
|-------------------|-----------------------------------------------------------------|
| `js/physics.js` | Vehicle kinematics (bicycle model + trailer articulation), geometry, OBB/SAT collision. |
| `js/maps.js` | Scenario data: start pose, goal zone, obstacles. |
| `js/input.js` | Keyboard state and one-shot actions. |
| `js/render.js` | Tron-style scene drawing. |
| `js/hud.js` | Steering wheel + rig-angle schematic + gear/speed instruments. |
| `js/game.js` | Main loop, collision handling, win detection, map switching. |
The physics reference point is the car's **rear axle**. The trailer follows the
standard articulation equation, which is what makes reversing behave like the
real thing.

167
css/style.css Normal file
View file

@ -0,0 +1,167 @@
:root {
--bg: #04070e;
--panel: rgba(8, 16, 28, 0.78);
--cyan: #19d3ff;
--green: #7af9c0;
--line: rgba(25, 211, 255, 0.30);
--text: #cfe9f5;
--dim: #6f93a6;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background:
radial-gradient(1200px 700px at 50% -10%, rgba(25,211,255,0.10), transparent 60%),
var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.shell {
width: min(960px, 100%);
display: flex;
flex-direction: column;
gap: 12px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title { display: flex; align-items: baseline; gap: 12px; }
.logo {
font-weight: 800;
font-size: 26px;
letter-spacing: 6px;
color: var(--cyan);
text-shadow: 0 0 14px rgba(25, 211, 255, 0.7);
}
.sub { color: var(--dim); font-size: 13px; letter-spacing: 1px; }
.tabs { display: flex; gap: 8px; flex-wrap: wrap; }
.tab {
background: var(--panel);
border: 1px solid var(--line);
color: var(--text);
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.tab:hover { border-color: var(--cyan); }
.tab.active {
border-color: var(--cyan);
color: #fff;
box-shadow: 0 0 14px rgba(25, 211, 255, 0.4);
background: rgba(25, 211, 255, 0.10);
}
.stage {
position: relative;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 0 40px rgba(25, 211, 255, 0.12), inset 0 0 60px rgba(0,0,0,0.6);
line-height: 0;
}
canvas {
display: block;
width: 100%;
height: auto;
background: var(--bg);
}
.overlay {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(2, 6, 12, 0.55);
backdrop-filter: blur(3px);
line-height: normal;
}
.overlay.show { display: flex; animation: fade 0.25s ease; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.card {
text-align: center;
padding: 32px 40px;
border: 1px solid var(--green);
border-radius: 14px;
background: rgba(6, 18, 16, 0.9);
box-shadow: 0 0 40px rgba(122, 249, 192, 0.35);
}
.card h1 {
margin: 0 0 6px;
font-size: 40px;
letter-spacing: 4px;
color: var(--green);
text-shadow: 0 0 18px rgba(122, 249, 192, 0.7);
}
.card p { margin: 0 0 20px; color: var(--dim); }
.btns { display: flex; gap: 12px; justify-content: center; }
button.ghost, button.primary {
border-radius: 9px;
padding: 10px 18px;
font-size: 14px;
cursor: pointer;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
transition: all 0.15s ease;
}
button.primary {
border-color: var(--green);
color: #052;
background: var(--green);
font-weight: 700;
box-shadow: 0 0 18px rgba(122, 249, 192, 0.5);
}
button.ghost:hover { border-color: var(--cyan); }
button.primary:hover { filter: brightness(1.1); }
.bottombar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
}
.mapinfo { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.mname { color: var(--cyan); font-weight: 700; letter-spacing: 1px; }
.mhint { color: var(--dim); }
.keys { color: var(--dim); }
kbd {
display: inline-block;
min-width: 18px;
text-align: center;
padding: 2px 6px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--panel);
color: var(--text);
font-size: 11px;
font-family: inherit;
}

54
index.html Normal file
View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TOW — trailer reversing trainer</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="title">
<span class="logo">TOW</span>
<span class="sub">trailer reversing trainer</span>
</div>
<div id="mapTabs" class="tabs"></div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="winOverlay" class="overlay">
<div class="card">
<h1>PARKED.</h1>
<p>Nicely backed in.</p>
<div class="btns">
<button id="btnRetry" class="ghost">Retry (R)</button>
<button id="btnNext" class="primary">Next map (N)</button>
</div>
</div>
</div>
</div>
<footer class="bottombar">
<div class="mapinfo">
<span id="mapName" class="mname"></span>
<span id="mapHint" class="mhint"></span>
</div>
<div class="keys">
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> drive ·
hold <kbd>A</kbd>/<kbd>D</kbd> to turn the wheel ·
<kbd>R</kbd> reset · <kbd>N</kbd> next · <kbd>1</kbd><kbd>5</kbd> maps (5 = random)
</div>
</footer>
</div>
<script src="js/physics.js"></script>
<script src="js/maps.js"></script>
<script src="js/input.js"></script>
<script src="js/render.js"></script>
<script src="js/hud.js"></script>
<script src="js/game.js"></script>
</body>
</html>

179
js/game.js Normal file
View file

@ -0,0 +1,179 @@
/* game.js wires everything together: fixed-timestep-ish loop, collision
* (revert-on-overlap), win detection, map switching, and DOM overlay updates. */
(function (TOW) {
'use strict';
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
canvas.width = TOW.WORLD.W;
canvas.height = TOW.WORLD.H;
var input = new TOW.Input();
var veh = new TOW.Vehicle();
var state = {
veh: veh,
mapIndex: 0,
map: TOW.maps[0],
trail: [],
won: false,
bumpFlash: 0,
winHoldT: 0 // time spent satisfying the win condition
};
// DOM
var elName = document.getElementById('mapName');
var elHint = document.getElementById('mapHint');
var elWin = document.getElementById('winOverlay');
var elTabs = document.getElementById('mapTabs');
// build map tabs
TOW.maps.forEach(function (m, i) {
var b = document.createElement('button');
b.className = 'tab';
b.textContent = (i + 1) + '. ' + m.name;
b.addEventListener('click', function () { loadMap(i); });
elTabs.appendChild(b);
});
var tabEls = elTabs.querySelectorAll('.tab');
// (re)initialize the rig/HUD for the currently-loaded state.map
function applyState() {
veh.reset(state.map.start);
var ax = veh.bodies().trailerAxle;
lastTrail = { x: ax.x, y: ax.y };
state.trail = [];
state.won = false;
state.bumpFlash = 0;
state.winHoldT = 0;
elWin.classList.remove('show');
}
function loadMap(i) {
i = (i + TOW.maps.length) % TOW.maps.length;
state.mapIndex = i;
var m = TOW.maps[i];
if (m.generate) m = m.generate(); // procedural slot: fresh layout on arrival
state.map = m;
applyState();
elName.textContent = m.name;
elHint.textContent = m.hint;
for (var t = 0; t < tabEls.length; t++) {
tabEls[t].classList.toggle('active', t === i);
}
}
// R retries the SAME layout (so a random map stays put on reset)
function resetMap() { applyState(); }
document.getElementById('btnRetry').addEventListener('click', resetMap);
document.getElementById('btnNext').addEventListener('click', function () { loadMap(state.mapIndex + 1); });
// --- win test: trailer centered in goal, aligned, nearly stopped
function trailerParked() {
var g = state.map.goal;
var b = veh.bodies().trailerBox;
var dx = b.cx - g.x, dy = b.cy - g.y;
// distance along goal axes
var c = Math.cos(g.heading), s = Math.sin(g.heading);
var along = Math.abs(dx * c + dy * s);
var across = Math.abs(-dx * s + dy * c);
if (along > g.posTol || across > g.posTol) return false;
// heading: accept either orientation of the trailer in the slot
var hd = Math.min(
Math.abs(TOW.angleDiff(veh.phi, g.heading)),
Math.abs(TOW.angleDiff(veh.phi, g.heading + Math.PI))
);
if (hd > g.headTol) return false;
if (Math.abs(veh.v) > 10) return false;
return true;
}
function collides() {
var b = veh.bodies();
var W = TOW.WORLD.W, H = TOW.WORLD.H;
if (TOW.outOfBounds(b.carBox, W, H, 2)) return true;
if (TOW.outOfBounds(b.trailerBox, W, H, 2)) return true;
var obs = state.map.obstacles;
for (var i = 0; i < obs.length; i++) {
var o = obs[i];
var ob = { cx: o.x, cy: o.y, hw: o.w / 2, hh: o.h / 2, a: o.a || 0 };
if (TOW.obbOverlap(b.carBox, ob)) return true;
if (TOW.obbOverlap(b.trailerBox, ob)) return true;
}
return false;
}
var lastTrail = { x: 0, y: 0 };
function pushTrail() {
var p = veh.bodies().trailerAxle;
var dx = p.x - lastTrail.x, dy = p.y - lastTrail.y;
if (dx * dx + dy * dy > 36) {
state.trail.push({ x: p.x, y: p.y });
lastTrail = { x: p.x, y: p.y };
if (state.trail.length > 140) state.trail.shift();
}
}
// --- main loop
var prev = null;
function frame(ts) {
if (prev === null) prev = ts;
var dt = (ts - prev) / 1000;
prev = ts;
if (dt > 0.05) dt = 0.05; // clamp after tab-switch / hiccup
// one-shot actions
var acts = input.drainActions();
for (var i = 0; i < acts.length; i++) {
var a = acts[i];
if (a === 'reset') resetMap();
else if (a === 'next') loadMap(state.mapIndex + 1);
else if (a === 'prev') loadMap(state.mapIndex - 1);
else if (a.indexOf('map:') === 0) loadMap(parseInt(a.slice(4), 10));
}
if (!state.won) {
var before = veh.snapshot();
veh.step(dt, input);
if (collides()) {
veh.restore(before);
veh.v = 0;
state.bumpFlash = 1;
}
pushTrail();
// win must be held briefly so you can't "drive through" the goal
if (trailerParked()) {
state.winHoldT += dt;
if (state.winHoldT > 0.4) {
state.won = true;
elWin.classList.add('show');
}
} else {
state.winHoldT = 0;
}
} else {
// let it coast to rest visually
veh.step(dt, { up: false, down: false, left: false, right: false });
}
if (state.bumpFlash > 0) state.bumpFlash = Math.max(0, state.bumpFlash - dt * 2.5);
TOW.render.drawScene(ctx, state);
TOW.hud.drawCluster(ctx, state);
requestAnimationFrame(frame);
}
loadMap(0);
requestAnimationFrame(frame);
});
})(window.TOW = window.TOW || {});

154
js/hud.js Normal file
View file

@ -0,0 +1,154 @@
/* hud.js on-canvas instrument cluster:
* - a rotating steering wheel (bottom center)
* - a top-down orientation schematic of car + front wheels + trailer (left)
* - gear + speed readout (right)
* Drawn in screen space over the scene each frame. */
(function (TOW) {
'use strict';
var C = TOW.render.COL;
function panel(ctx, x, y, w, h, label) {
ctx.save();
ctx.fillStyle = 'rgba(4,10,18,0.62)';
ctx.strokeStyle = 'rgba(25,211,255,0.35)';
ctx.lineWidth = 1.5;
ctx.beginPath();
var r = 10;
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill(); ctx.stroke();
if (label) {
ctx.fillStyle = 'rgba(120,200,230,0.8)';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.fillText(label, x + 10, y + 16);
}
ctx.restore();
}
function drawSteeringWheel(ctx, cx, cy, R, steer, maxSteer) {
// visual rotation amplified so small steer is readable
var ang = (steer / maxSteer) * 1.55;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ang);
ctx.strokeStyle = C.wheel;
ctx.shadowColor = C.wheel; ctx.shadowBlur = 14;
ctx.lineWidth = 5;
// rim
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.stroke();
// spokes (3-spoke)
ctx.lineWidth = 4;
var i, a;
for (i = 0; i < 3; i++) {
a = -Math.PI / 2 + i * (Math.PI * 2 / 3);
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R); ctx.stroke();
}
// hub
ctx.fillStyle = C.wheel;
ctx.beginPath(); ctx.arc(0, 0, R * 0.18, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
// mini top-down: car points up, front wheels show steer, trailer shows the
// real articulation (hitch) angle so the player can read jackknife risk.
function drawOrientation(ctx, cx, cy, veh) {
var rel = TOW.angleDiff(veh.phi, veh.theta); // trailer relative to car
var jack = Math.abs(rel) > 1.4;
ctx.save();
ctx.translate(cx, cy);
// car body (fixed, pointing up)
var cw = 22, chl = 34;
ctx.strokeStyle = C.car; ctx.shadowColor = C.car; ctx.shadowBlur = 8; ctx.lineWidth = 2.5;
ctx.strokeRect(-cw / 2, -chl / 2, cw, chl);
// nose marker
ctx.beginPath(); ctx.moveTo(0, -chl / 2); ctx.lineTo(0, -chl / 2 - 9); ctx.stroke();
// front wheels steered (front is "up" = -y; steer>0 means right turn)
ctx.strokeStyle = C.wheel; ctx.shadowColor = C.wheel; ctx.lineWidth = 3;
var fwY = -chl / 2 + 4;
[-cw / 2, cw / 2].forEach(function (wx) {
ctx.save();
ctx.translate(wx, fwY);
ctx.rotate(veh.steer);
ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(0, 6); ctx.stroke();
ctx.restore();
});
// trailer drawn from the rear of the car at the articulation angle.
// straight-behind is +y (down); rel rotates it.
var hx = 0, hy = chl / 2; // hitch at rear
var tl = 40;
var dir = rel; // rotation away from straight-back
var ex = hx + Math.sin(dir) * tl;
var ey = hy + Math.cos(dir) * tl;
ctx.strokeStyle = jack ? C.wall : C.trailer;
ctx.shadowColor = jack ? C.wall : C.trailer; ctx.shadowBlur = 8; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ex, ey); ctx.stroke();
// trailer box hint at the end
ctx.save();
ctx.translate(ex, ey);
ctx.rotate(-dir);
ctx.strokeRect(-8, -2, 16, 18);
ctx.restore();
ctx.restore();
}
function drawCluster(ctx, state) {
var W = TOW.WORLD.W, H = TOW.WORLD.H;
var veh = state.veh;
// --- steering wheel, bottom center
var wpW = 150, wpH = 150, wpx = W / 2 - wpW / 2, wpy = H - wpH - 14;
panel(ctx, wpx, wpy, wpW, wpH, 'STEERING');
drawSteeringWheel(ctx, wpx + wpW / 2, wpy + wpH / 2 + 8, 48, veh.steer, veh.cfg.maxSteer);
// --- orientation schematic, bottom left
var opW = 130, opH = 150, opx = 14, opy = H - opH - 14;
panel(ctx, opx, opy, opW, opH, 'RIG ANGLE');
drawOrientation(ctx, opx + opW / 2, opy + opH / 2 + 10, veh);
// --- gear + speed, bottom right
var gpW = 130, gpH = 150, gpx = W - gpW - 14, gpy = H - gpH - 14;
panel(ctx, gpx, gpy, gpW, gpH, 'DRIVE');
var gear = veh.v > 3 ? 'D' : veh.v < -3 ? 'R' : 'N';
var gcol = gear === 'R' ? C.wall : gear === 'D' ? C.trailer : 'rgba(160,200,220,0.8)';
ctx.save();
ctx.fillStyle = gcol; ctx.shadowColor = gcol; ctx.shadowBlur = 12;
ctx.font = 'bold 44px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(gear, gpx + gpW / 2, gpy + 70);
// speed bar
var sp = Math.abs(veh.v) / veh.cfg.maxForward;
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(gpx + 18, gpy + 96, gpW - 36, 10);
ctx.fillStyle = gcol; ctx.shadowColor = gcol; ctx.shadowBlur = 8;
ctx.fillRect(gpx + 18, gpy + 96, (gpW - 36) * Math.min(1, sp), 10);
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(160,200,220,0.85)';
ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText(Math.round(Math.abs(veh.v)) + ' u/s', gpx + gpW / 2, gpy + 126);
ctx.restore();
// jackknife warning banner
if (Math.abs(veh.hitchAngle()) > 1.4 && !state.won) {
ctx.save();
ctx.fillStyle = C.wall; ctx.shadowColor = C.wall; ctx.shadowBlur = 14;
ctx.font = 'bold 20px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('⚠ JACKKNIFE — ease off & pull forward', W / 2, 40);
ctx.restore();
}
}
TOW.hud = { drawCluster: drawCluster };
})(window.TOW = window.TOW || {});

47
js/input.js Normal file
View file

@ -0,0 +1,47 @@
/* input.js — keyboard state. WASD / arrow keys drive; held A/D feeds the
* steering smoothing in physics.js. R / N / [1-4] are edge-triggered actions. */
(function (TOW) {
'use strict';
function Input() {
this.up = false; this.down = false; this.left = false; this.right = false;
this.actions = []; // queue of one-shot actions: 'reset','next','prev','map:N'
var self = this;
function set(code, val, e) {
switch (code) {
case 'KeyW': case 'ArrowUp': self.up = val; e.preventDefault(); break;
case 'KeyS': case 'ArrowDown': self.down = val; e.preventDefault(); break;
case 'KeyA': case 'ArrowLeft': self.left = val; e.preventDefault(); break;
case 'KeyD': case 'ArrowRight': self.right = val; e.preventDefault(); break;
}
}
window.addEventListener('keydown', function (e) {
if (e.repeat) { set(e.code, true, e); return; }
set(e.code, true, e);
switch (e.code) {
case 'KeyR': self.actions.push('reset'); break;
case 'KeyN': self.actions.push('next'); break;
case 'KeyP': self.actions.push('prev'); break;
case 'Digit1': self.actions.push('map:0'); break;
case 'Digit2': self.actions.push('map:1'); break;
case 'Digit3': self.actions.push('map:2'); break;
case 'Digit4': self.actions.push('map:3'); break;
case 'Digit5': self.actions.push('map:4'); break;
}
});
window.addEventListener('keyup', function (e) { set(e.code, false, e); });
// releasing focus shouldn't leave a key "stuck" down
window.addEventListener('blur', function () {
self.up = self.down = self.left = self.right = false;
});
}
Input.prototype.drainActions = function () {
var a = this.actions; this.actions = []; return a;
};
TOW.Input = Input;
})(window.TOW = window.TOW || {});

177
js/maps.js Normal file
View file

@ -0,0 +1,177 @@
/* maps.js scenario definitions.
*
* All coordinates are world == canvas pixels (W x H). Heading 0 points +x
* (right); +y is down (canvas convention); angle increases clockwise on screen.
*
* obstacle: {x,y,w,h,a?,type?} -> oriented box centered at (x,y); type colors it
* goal: {x,y,heading,w,h,posTol,headTol} -> where the TRAILER must end up
* start: {x,y,carHeading,trailerHeading} -> car rear-axle pose
*/
(function (TOW) {
'use strict';
var W = 960, H = 680;
var PI = Math.PI;
// a couple of headings for readability
var UP = -PI / 2, DOWN = PI / 2, LEFT = PI, RIGHT = 0;
var maps = [
{
name: 'Loading Dock',
hint: 'Warm-up. Reverse straight back to slot the trailer into the bay.',
start: { x: 480, y: 470, carHeading: DOWN, trailerHeading: DOWN },
// NB: goal w == extent ALONG heading (depth), h == perpendicular (width)
goal: { x: 480, y: 120, heading: DOWN, w: 150, h: 120, posTol: 34, headTol: 0.30 },
obstacles: [
{ x: 250, y: 95, w: 300, h: 130, type: 'wall' },
{ x: 710, y: 95, w: 300, h: 130, type: 'wall' }
]
},
{
name: 'Parking Lot',
hint: 'Back into the empty stall between the two parked cars.',
start: { x: 700, y: 470, carHeading: LEFT, trailerHeading: LEFT },
goal: { x: 360, y: 130, heading: DOWN, w: 140, h: 110, posTol: 30, headTol: 0.26 },
obstacles: [
{ x: 250, y: 110, w: 70, h: 150, type: 'car' },
{ x: 470, y: 110, w: 70, h: 150, type: 'car' },
{ x: 690, y: 110, w: 70, h: 150, type: 'car' },
{ x: 480, y: 40, w: 960, h: 24, type: 'wall' } // back curb
]
},
{
name: 'Roadside',
hint: 'Parallel park: tuck the trailer into the gap along the curb.',
start: { x: 250, y: 430, carHeading: RIGHT, trailerHeading: RIGHT },
goal: { x: 480, y: 175, heading: RIGHT, w: 210, h: 80, posTol: 36, headTol: 0.24 },
obstacles: [
{ x: 480, y: 70, w: 960, h: 80, type: 'wall' }, // building / far curb
{ x: 215, y: 175, w: 230, h: 70, type: 'car' }, // car in front of the gap
{ x: 760, y: 175, w: 230, h: 70, type: 'car' } // car behind the gap
]
},
{
name: 'Driveway',
hint: 'Back up the angled driveway between the house and the hedge.',
start: { x: 615, y: 500, carHeading: 0.82, trailerHeading: 0.82 },
// driveway channel runs along a = -2.32 rad (up-left); goal sits on the
// channel midline (midpoint of the two walls) up toward the house
goal: { x: 223, y: 184, heading: -2.32, w: 120, h: 74, posTol: 34, headTol: 0.32 },
obstacles: [
{ x: 407, y: 286, w: 340, h: 40, a: -2.32, type: 'wall' }, // house wall
{ x: 313, y: 374, w: 340, h: 40, a: -2.32, type: 'hedge' }, // hedge
{ x: 480, y: 600, w: 960, h: 40, type: 'wall' } // street curb
]
}
];
// ---- procedural map #5 ---------------------------------------------------
// A fresh layout is built every time the player arrives at this slot. Each
// candidate is validated (start & goal collision-free, goal in bounds and
// reachable) before being accepted; we retry until one passes.
function rnd(a, b) { return a + Math.random() * (b - a); }
function chance(p) { return Math.random() < p; }
function dist(ax, ay, bx, by) { return Math.hypot(ax - bx, ay - by); }
function box(o) { return { cx: o.x, cy: o.y, hw: o.w / 2, hh: o.h / 2, a: o.a || 0 }; }
function validLayout(m) {
var v = new TOW.Vehicle(); v.reset(m.start);
var b = v.bodies();
if (TOW.outOfBounds(b.carBox, W, H, 4)) return false;
if (TOW.outOfBounds(b.trailerBox, W, H, 4)) return false;
var g = m.goal;
var gb = { cx: g.x, cy: g.y, hw: g.w / 2, hh: g.h / 2, a: g.heading };
if (TOW.outOfBounds(gb, W, H, 2)) return false;
for (var i = 0; i < m.obstacles.length; i++) {
var ob = box(m.obstacles[i]);
if (TOW.obbOverlap(b.carBox, ob)) return false;
if (TOW.obbOverlap(b.trailerBox, ob)) return false;
if (TOW.obbOverlap(gb, ob)) return false;
}
return true;
}
// back-into-a-stall, with the stall at a random x and a random gap width
function buildVertical() {
var gx = rnd(250, W - 250);
var gap = rnd(104, 132);
var carW = 70, off = gap / 2 + carW / 2;
var dir = chance(0.5) ? LEFT : RIGHT;
return {
name: 'Random · Parking Lot',
hint: 'Freshly generated. Back the trailer into the open stall.',
start: {
x: TOW.clamp(gx + rnd(-150, 150), 190, W - 190),
y: rnd(450, 500), carHeading: dir, trailerHeading: dir
},
goal: { x: gx, y: 135, heading: DOWN, w: rnd(135, 150), h: gap - 18, posTol: 32, headTol: 0.28 },
obstacles: [
{ x: gx - off, y: 110, w: carW, h: 150, type: 'car' },
{ x: gx + off, y: 110, w: carW, h: 150, type: 'car' },
{ x: W / 2, y: 38, w: W, h: 24, type: 'wall' }
]
};
}
// parallel park into a random gap along the curb
function buildParallel() {
var gx = rnd(345, W - 345);
var len = rnd(195, 225);
var y = 178, carLen = 200, off = len / 2 + carLen / 2 + 10; // +clearance to gap
return {
name: 'Random · Roadside',
hint: 'Freshly generated. Parallel-park the trailer into the gap.',
start: { x: rnd(190, 320), y: rnd(420, 460), carHeading: RIGHT, trailerHeading: RIGHT },
goal: { x: gx, y: y, heading: RIGHT, w: len, h: 78, posTol: 36, headTol: 0.24 },
obstacles: [
{ x: W / 2, y: 66, w: W, h: 80, type: 'wall' },
{ x: gx - off, y: y, w: carLen, h: 66, type: 'car' },
{ x: gx + off, y: y, w: carLen, h: 66, type: 'car' }
]
};
}
// sprinkle a few cones as extra hazards, kept clear of the goal & start
function addCones(m) {
var want = (Math.random() * 3) | 0; // 0..2
var added = 0, tries = 0;
while (added < want && tries < 60) {
tries++;
var c = { x: rnd(120, W - 120), y: rnd(250, 430), w: rnd(26, 38), h: rnd(26, 38), type: 'cone' };
if (dist(c.x, c.y, m.goal.x, m.goal.y) < 145) continue;
if (dist(c.x, c.y, m.start.x, m.start.y) < 120) continue;
var probe = { start: m.start, goal: m.goal, obstacles: m.obstacles.concat([c]) };
if (!validLayout(probe)) continue;
m.obstacles.push(c); added++;
}
}
function generateRandomMap() {
for (var attempt = 0; attempt < 150; attempt++) {
var m = chance(0.5) ? buildVertical() : buildParallel();
if (!validLayout(m)) continue;
var v = new TOW.Vehicle(); v.reset(m.start);
var tb = v.bodies().trailerBox;
if (dist(tb.cx, tb.cy, m.goal.x, m.goal.y) < 200) continue; // not already parked
addCones(m);
return m;
}
return buildVertical(); // extremely unlikely fallback
}
maps.push({
name: 'Random',
hint: 'A fresh layout every time you arrive (R retries the same one).',
generate: generateRandomMap
});
TOW.WORLD = { W: W, H: H };
TOW.maps = maps;
TOW.generateRandomMap = generateRandomMap;
})(window.TOW = window.TOW || {});

200
js/physics.js Normal file
View file

@ -0,0 +1,200 @@
/* 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 || {});

198
js/render.js Normal file
View file

@ -0,0 +1,198 @@
/* render.js Tron-style vector rendering of the play field, obstacles, goal
* and the rig (car + trailer). Glow is done with shadowBlur on neon strokes. */
(function (TOW) {
'use strict';
var COL = {
bg: '#04070e',
grid: 'rgba(40,120,160,0.16)',
border: '#19d3ff',
car: '#19d3ff',
trailer: '#7af9c0',
wheel: '#ffd54a',
hitch: '#ffffff',
trail: 'rgba(122,249,192,0.5)',
goal: '#39ff8b',
goalBad: '#ffe14d',
wall: '#ff4d6d',
parkedCar: '#b27bff',
hedge: '#5ee06a',
cone: '#ff9d3a'
};
function obColor(t) {
if (t === 'car') return COL.parkedCar;
if (t === 'hedge') return COL.hedge;
if (t === 'cone') return COL.cone;
return COL.wall;
}
function neonRect(ctx, b, stroke, lw, blur) {
var c = TOW.corners(b);
ctx.save();
ctx.lineWidth = lw;
ctx.strokeStyle = stroke;
ctx.shadowColor = stroke;
ctx.shadowBlur = blur;
ctx.beginPath();
ctx.moveTo(c[0].x, c[0].y);
for (var i = 1; i < 4; i++) ctx.lineTo(c[i].x, c[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function fillRectFaint(ctx, b, fill) {
var c = TOW.corners(b);
ctx.save();
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(c[0].x, c[0].y);
for (var i = 1; i < 4; i++) ctx.lineTo(c[i].x, c[i].y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawGrid(ctx, W, H) {
ctx.save();
ctx.strokeStyle = COL.grid;
ctx.lineWidth = 1;
var step = 40, x, y;
ctx.beginPath();
for (x = step; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (y = step; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
ctx.restore();
}
function drawGoal(ctx, goal, ok) {
var b = { cx: goal.x, cy: goal.y, hw: goal.w / 2, hh: goal.h / 2, a: goal.heading };
var col = ok ? COL.goal : COL.goalBad;
fillRectFaint(ctx, b, ok ? 'rgba(57,255,139,0.12)' : 'rgba(255,225,77,0.07)');
ctx.save();
ctx.setLineDash([12, 9]);
neonRect(ctx, b, col, 2.5, 16);
ctx.restore();
// arrow showing desired trailer direction (axle -> hitch)
ctx.save();
ctx.translate(goal.x, goal.y);
ctx.rotate(goal.heading);
ctx.strokeStyle = col; ctx.fillStyle = col;
ctx.shadowColor = col; ctx.shadowBlur = 12; ctx.lineWidth = 2.5;
var L = Math.min(goal.w, goal.h) * 0.3;
ctx.beginPath(); ctx.moveTo(-L, 0); ctx.lineTo(L, 0); ctx.stroke();
ctx.beginPath();
ctx.moveTo(L, 0); ctx.lineTo(L - 10, -7); ctx.lineTo(L - 10, 7);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawTrail(ctx, pts) {
if (pts.length < 2) return;
ctx.save();
ctx.strokeStyle = COL.trail;
ctx.lineWidth = 2;
ctx.shadowColor = COL.trail; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
}
// small filled wheel rectangle centered at p, oriented by ang
function drawWheel(ctx, p, ang, len, wid, col) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
ctx.fillStyle = col;
ctx.shadowColor = col; ctx.shadowBlur = 8;
ctx.fillRect(-len / 2, -wid / 2, len, wid);
ctx.restore();
}
function drawRig(ctx, veh) {
var g = veh.geo;
var bd = veh.bodies();
var ct = Math.cos(veh.theta), st = Math.sin(veh.theta);
var cf = Math.cos(veh.phi), sf = Math.sin(veh.phi);
// --- trailer (draw first so the car sits on top at the hitch)
// drawbar / tongue
ctx.save();
ctx.strokeStyle = COL.trailer; ctx.shadowColor = COL.trailer; ctx.shadowBlur = 10;
ctx.lineWidth = 4;
var tBodyFront = { x: bd.hitch.x - g.tongue * cf, y: bd.hitch.y - g.tongue * sf };
ctx.beginPath(); ctx.moveTo(bd.hitch.x, bd.hitch.y);
ctx.lineTo(tBodyFront.x, tBodyFront.y); ctx.stroke();
ctx.restore();
fillRectFaint(ctx, bd.trailerBox, 'rgba(122,249,192,0.10)');
neonRect(ctx, bd.trailerBox, COL.trailer, 3, 14);
// trailer wheels at the trailer axle
var twoff = (g.trailerWidth / 2) + 3;
drawWheel(ctx, { x: bd.trailerAxle.x - sf * twoff, y: bd.trailerAxle.y + cf * twoff }, veh.phi, 20, 7, COL.trailer);
drawWheel(ctx, { x: bd.trailerAxle.x + sf * twoff, y: bd.trailerAxle.y - cf * twoff }, veh.phi, 20, 7, COL.trailer);
// --- car
fillRectFaint(ctx, bd.carBox, 'rgba(25,211,255,0.10)');
neonRect(ctx, bd.carBox, COL.car, 3, 14);
// rear wheels (aligned with body)
var rwoff = (g.carWidth / 2) + 2;
drawWheel(ctx, { x: bd.rax.x - st * rwoff, y: bd.rax.y + ct * rwoff }, veh.theta, 20, 8, COL.car);
drawWheel(ctx, { x: bd.rax.x + st * rwoff, y: bd.rax.y - ct * rwoff }, veh.theta, 20, 8, COL.car);
// front wheels (steered)
var sa = veh.theta + veh.steer;
drawWheel(ctx, { x: bd.frontAxle.x - st * rwoff, y: bd.frontAxle.y + ct * rwoff }, sa, 22, 8, COL.wheel);
drawWheel(ctx, { x: bd.frontAxle.x + st * rwoff, y: bd.frontAxle.y - ct * rwoff }, sa, 22, 8, COL.wheel);
// a "nose" line so heading is obvious
ctx.save();
ctx.strokeStyle = COL.car; ctx.shadowColor = COL.car; ctx.shadowBlur = 10; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(bd.carBox.cx, bd.carBox.cy);
ctx.lineTo(bd.carBox.cx + ct * (g.L / 2 + g.frontOverhang),
bd.carBox.cy + st * (g.L / 2 + g.frontOverhang));
ctx.stroke();
ctx.restore();
// hitch joint
ctx.save();
ctx.fillStyle = COL.hitch; ctx.shadowColor = COL.hitch; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(bd.hitch.x, bd.hitch.y, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawScene(ctx, state) {
var W = TOW.WORLD.W, H = TOW.WORLD.H;
ctx.fillStyle = COL.bg;
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, W, H);
// border
neonRect(ctx, { cx: W / 2, cy: H / 2, hw: W / 2 - 3, hh: H / 2 - 3, a: 0 }, COL.border, 2, 10);
drawGoal(ctx, state.map.goal, state.won);
drawTrail(ctx, state.trail);
var obs = state.map.obstacles;
for (var i = 0; i < obs.length; i++) {
var o = obs[i];
var b = { cx: o.x, cy: o.y, hw: o.w / 2, hh: o.h / 2, a: o.a || 0 };
var col = obColor(o.type);
fillRectFaint(ctx, b, 'rgba(255,255,255,0.04)');
neonRect(ctx, b, col, 2.5, 12);
}
drawRig(ctx, state.veh);
if (state.bumpFlash > 0) {
ctx.save();
ctx.fillStyle = 'rgba(255,77,109,' + (0.28 * state.bumpFlash) + ')';
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
}
TOW.render = { drawScene: drawScene, COL: COL };
})(window.TOW = window.TOW || {});