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

179 lines
5.6 KiB
JavaScript

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