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