tow-game/js/maps.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

177 lines
7 KiB
JavaScript

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