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

154 lines
5.6 KiB
JavaScript

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