commit 41efdecd9328c6b48fa2788329e439e5d3f34d8e Author: Erich Blume Date: Sat Jun 6 18:39:03 2026 -0700 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d71511 --- /dev/null +++ b/README.md @@ -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 ` + + + + + + + diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..a27290b --- /dev/null +++ b/js/game.js @@ -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 || {}); diff --git a/js/hud.js b/js/hud.js new file mode 100644 index 0000000..485de40 --- /dev/null +++ b/js/hud.js @@ -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 || {}); diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..50fdb14 --- /dev/null +++ b/js/input.js @@ -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 || {}); diff --git a/js/maps.js b/js/maps.js new file mode 100644 index 0000000..0dd810f --- /dev/null +++ b/js/maps.js @@ -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 || {}); diff --git a/js/physics.js b/js/physics.js new file mode 100644 index 0000000..c59b1af --- /dev/null +++ b/js/physics.js @@ -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 || {}); diff --git a/js/render.js b/js/render.js new file mode 100644 index 0000000..de437ca --- /dev/null +++ b/js/render.js @@ -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 || {});