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>
179 lines
5.6 KiB
JavaScript
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 || {});
|