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

198 lines
6.5 KiB
JavaScript

/* render.js — Tron-style vector rendering of the play field, obstacles, goal
* and the rig (car + trailer). Glow is done with shadowBlur on neon strokes. */
(function (TOW) {
'use strict';
var COL = {
bg: '#04070e',
grid: 'rgba(40,120,160,0.16)',
border: '#19d3ff',
car: '#19d3ff',
trailer: '#7af9c0',
wheel: '#ffd54a',
hitch: '#ffffff',
trail: 'rgba(122,249,192,0.5)',
goal: '#39ff8b',
goalBad: '#ffe14d',
wall: '#ff4d6d',
parkedCar: '#b27bff',
hedge: '#5ee06a',
cone: '#ff9d3a'
};
function obColor(t) {
if (t === 'car') return COL.parkedCar;
if (t === 'hedge') return COL.hedge;
if (t === 'cone') return COL.cone;
return COL.wall;
}
function neonRect(ctx, b, stroke, lw, blur) {
var c = TOW.corners(b);
ctx.save();
ctx.lineWidth = lw;
ctx.strokeStyle = stroke;
ctx.shadowColor = stroke;
ctx.shadowBlur = blur;
ctx.beginPath();
ctx.moveTo(c[0].x, c[0].y);
for (var i = 1; i < 4; i++) ctx.lineTo(c[i].x, c[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function fillRectFaint(ctx, b, fill) {
var c = TOW.corners(b);
ctx.save();
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(c[0].x, c[0].y);
for (var i = 1; i < 4; i++) ctx.lineTo(c[i].x, c[i].y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawGrid(ctx, W, H) {
ctx.save();
ctx.strokeStyle = COL.grid;
ctx.lineWidth = 1;
var step = 40, x, y;
ctx.beginPath();
for (x = step; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (y = step; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
ctx.restore();
}
function drawGoal(ctx, goal, ok) {
var b = { cx: goal.x, cy: goal.y, hw: goal.w / 2, hh: goal.h / 2, a: goal.heading };
var col = ok ? COL.goal : COL.goalBad;
fillRectFaint(ctx, b, ok ? 'rgba(57,255,139,0.12)' : 'rgba(255,225,77,0.07)');
ctx.save();
ctx.setLineDash([12, 9]);
neonRect(ctx, b, col, 2.5, 16);
ctx.restore();
// arrow showing desired trailer direction (axle -> hitch)
ctx.save();
ctx.translate(goal.x, goal.y);
ctx.rotate(goal.heading);
ctx.strokeStyle = col; ctx.fillStyle = col;
ctx.shadowColor = col; ctx.shadowBlur = 12; ctx.lineWidth = 2.5;
var L = Math.min(goal.w, goal.h) * 0.3;
ctx.beginPath(); ctx.moveTo(-L, 0); ctx.lineTo(L, 0); ctx.stroke();
ctx.beginPath();
ctx.moveTo(L, 0); ctx.lineTo(L - 10, -7); ctx.lineTo(L - 10, 7);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawTrail(ctx, pts) {
if (pts.length < 2) return;
ctx.save();
ctx.strokeStyle = COL.trail;
ctx.lineWidth = 2;
ctx.shadowColor = COL.trail; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
}
// small filled wheel rectangle centered at p, oriented by ang
function drawWheel(ctx, p, ang, len, wid, col) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
ctx.fillStyle = col;
ctx.shadowColor = col; ctx.shadowBlur = 8;
ctx.fillRect(-len / 2, -wid / 2, len, wid);
ctx.restore();
}
function drawRig(ctx, veh) {
var g = veh.geo;
var bd = veh.bodies();
var ct = Math.cos(veh.theta), st = Math.sin(veh.theta);
var cf = Math.cos(veh.phi), sf = Math.sin(veh.phi);
// --- trailer (draw first so the car sits on top at the hitch)
// drawbar / tongue
ctx.save();
ctx.strokeStyle = COL.trailer; ctx.shadowColor = COL.trailer; ctx.shadowBlur = 10;
ctx.lineWidth = 4;
var tBodyFront = { x: bd.hitch.x - g.tongue * cf, y: bd.hitch.y - g.tongue * sf };
ctx.beginPath(); ctx.moveTo(bd.hitch.x, bd.hitch.y);
ctx.lineTo(tBodyFront.x, tBodyFront.y); ctx.stroke();
ctx.restore();
fillRectFaint(ctx, bd.trailerBox, 'rgba(122,249,192,0.10)');
neonRect(ctx, bd.trailerBox, COL.trailer, 3, 14);
// trailer wheels at the trailer axle
var twoff = (g.trailerWidth / 2) + 3;
drawWheel(ctx, { x: bd.trailerAxle.x - sf * twoff, y: bd.trailerAxle.y + cf * twoff }, veh.phi, 20, 7, COL.trailer);
drawWheel(ctx, { x: bd.trailerAxle.x + sf * twoff, y: bd.trailerAxle.y - cf * twoff }, veh.phi, 20, 7, COL.trailer);
// --- car
fillRectFaint(ctx, bd.carBox, 'rgba(25,211,255,0.10)');
neonRect(ctx, bd.carBox, COL.car, 3, 14);
// rear wheels (aligned with body)
var rwoff = (g.carWidth / 2) + 2;
drawWheel(ctx, { x: bd.rax.x - st * rwoff, y: bd.rax.y + ct * rwoff }, veh.theta, 20, 8, COL.car);
drawWheel(ctx, { x: bd.rax.x + st * rwoff, y: bd.rax.y - ct * rwoff }, veh.theta, 20, 8, COL.car);
// front wheels (steered)
var sa = veh.theta + veh.steer;
drawWheel(ctx, { x: bd.frontAxle.x - st * rwoff, y: bd.frontAxle.y + ct * rwoff }, sa, 22, 8, COL.wheel);
drawWheel(ctx, { x: bd.frontAxle.x + st * rwoff, y: bd.frontAxle.y - ct * rwoff }, sa, 22, 8, COL.wheel);
// a "nose" line so heading is obvious
ctx.save();
ctx.strokeStyle = COL.car; ctx.shadowColor = COL.car; ctx.shadowBlur = 10; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(bd.carBox.cx, bd.carBox.cy);
ctx.lineTo(bd.carBox.cx + ct * (g.L / 2 + g.frontOverhang),
bd.carBox.cy + st * (g.L / 2 + g.frontOverhang));
ctx.stroke();
ctx.restore();
// hitch joint
ctx.save();
ctx.fillStyle = COL.hitch; ctx.shadowColor = COL.hitch; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(bd.hitch.x, bd.hitch.y, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawScene(ctx, state) {
var W = TOW.WORLD.W, H = TOW.WORLD.H;
ctx.fillStyle = COL.bg;
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, W, H);
// border
neonRect(ctx, { cx: W / 2, cy: H / 2, hw: W / 2 - 3, hh: H / 2 - 3, a: 0 }, COL.border, 2, 10);
drawGoal(ctx, state.map.goal, state.won);
drawTrail(ctx, state.trail);
var obs = state.map.obstacles;
for (var i = 0; i < obs.length; i++) {
var o = obs[i];
var b = { cx: o.x, cy: o.y, hw: o.w / 2, hh: o.h / 2, a: o.a || 0 };
var col = obColor(o.type);
fillRectFaint(ctx, b, 'rgba(255,255,255,0.04)');
neonRect(ctx, b, col, 2.5, 12);
}
drawRig(ctx, state.veh);
if (state.bumpFlash > 0) {
ctx.save();
ctx.fillStyle = 'rgba(255,77,109,' + (0.28 * state.bumpFlash) + ')';
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
}
TOW.render = { drawScene: drawScene, COL: COL };
})(window.TOW = window.TOW || {});