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>
This commit is contained in:
commit
41efdecd93
9 changed files with 1238 additions and 0 deletions
62
README.md
Normal file
62
README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# TOW — trailer reversing trainer
|
||||
|
||||
A small browser game that teaches you the counter-intuitive art of **reversing a
|
||||
car with a trailer**. Vector / "Tron" graphics, no build step, no dependencies.
|
||||
|
||||
## Play
|
||||
|
||||
Just open the file in Safari:
|
||||
|
||||
```sh
|
||||
open index.html
|
||||
```
|
||||
|
||||
(or double-click `index.html`). Everything runs client-side from `file://` — the
|
||||
scripts are plain classic `<script>` tags specifically so this works without a
|
||||
server.
|
||||
|
||||
If you'd rather serve it over HTTP:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 8000 # then visit http://localhost:8000
|
||||
```
|
||||
|
||||
## How to play
|
||||
|
||||
- **W / S** — drive forward / reverse (gear shows **D** / **R** / **N**).
|
||||
- **A / D** — steer. You must *hold* the key to wind the wheel over; it
|
||||
self-centers when you let go. Watch the steering-wheel instrument.
|
||||
- **R** — reset the current map · **N** — next map · **1–5** — jump to a map.
|
||||
|
||||
Goal: back the **trailer** into the dashed green zone, roughly aligned with the
|
||||
arrow, and come to a stop. The **RIG ANGLE** panel (bottom-left) shows the live
|
||||
angle between car and trailer — keep an eye on it: reversing amplifies the angle,
|
||||
and if it folds past the limit you'll **jackknife** (ease off the steering and
|
||||
pull forward to straighten out).
|
||||
|
||||
### Maps
|
||||
|
||||
1. **Loading Dock** — straight reverse, the warm-up.
|
||||
2. **Parking Lot** — 90° back-in between two cars.
|
||||
3. **Roadside** — parallel park into a gap at the curb.
|
||||
4. **Driveway** — back up an angled drive between the house and a hedge.
|
||||
5. **Random** — a procedurally generated lot or roadside scenario, rebuilt every
|
||||
time you arrive at it (each candidate is validated for a collision-free,
|
||||
reachable start before being accepted). `R` retries the same layout.
|
||||
|
||||
## Code layout
|
||||
|
||||
Plain ES5-ish modules attached to a global `TOW` namespace, loaded in order:
|
||||
|
||||
| File | Responsibility |
|
||||
|-------------------|-----------------------------------------------------------------|
|
||||
| `js/physics.js` | Vehicle kinematics (bicycle model + trailer articulation), geometry, OBB/SAT collision. |
|
||||
| `js/maps.js` | Scenario data: start pose, goal zone, obstacles. |
|
||||
| `js/input.js` | Keyboard state and one-shot actions. |
|
||||
| `js/render.js` | Tron-style scene drawing. |
|
||||
| `js/hud.js` | Steering wheel + rig-angle schematic + gear/speed instruments. |
|
||||
| `js/game.js` | Main loop, collision handling, win detection, map switching. |
|
||||
|
||||
The physics reference point is the car's **rear axle**. The trailer follows the
|
||||
standard articulation equation, which is what makes reversing behave like the
|
||||
real thing.
|
||||
167
css/style.css
Normal file
167
css/style.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
:root {
|
||||
--bg: #04070e;
|
||||
--panel: rgba(8, 16, 28, 0.78);
|
||||
--cyan: #19d3ff;
|
||||
--green: #7af9c0;
|
||||
--line: rgba(25, 211, 255, 0.30);
|
||||
--text: #cfe9f5;
|
||||
--dim: #6f93a6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 50% -10%, rgba(25,211,255,0.10), transparent 60%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(960px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title { display: flex; align-items: baseline; gap: 12px; }
|
||||
.logo {
|
||||
font-weight: 800;
|
||||
font-size: 26px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 14px rgba(25, 211, 255, 0.7);
|
||||
}
|
||||
.sub { color: var(--dim); font-size: 13px; letter-spacing: 1px; }
|
||||
|
||||
.tabs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tab {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.tab:hover { border-color: var(--cyan); }
|
||||
.tab.active {
|
||||
border-color: var(--cyan);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 14px rgba(25, 211, 255, 0.4);
|
||||
background: rgba(25, 211, 255, 0.10);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 40px rgba(25, 211, 255, 0.12), inset 0 0 60px rgba(0,0,0,0.6);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 12, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
line-height: normal;
|
||||
}
|
||||
.overlay.show { display: flex; animation: fade 0.25s ease; }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 32px 40px;
|
||||
border: 1px solid var(--green);
|
||||
border-radius: 14px;
|
||||
background: rgba(6, 18, 16, 0.9);
|
||||
box-shadow: 0 0 40px rgba(122, 249, 192, 0.35);
|
||||
}
|
||||
.card h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 40px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 18px rgba(122, 249, 192, 0.7);
|
||||
}
|
||||
.card p { margin: 0 0 20px; color: var(--dim); }
|
||||
.btns { display: flex; gap: 12px; justify-content: center; }
|
||||
|
||||
button.ghost, button.primary {
|
||||
border-radius: 9px;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
button.primary {
|
||||
border-color: var(--green);
|
||||
color: #052;
|
||||
background: var(--green);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 18px rgba(122, 249, 192, 0.5);
|
||||
}
|
||||
button.ghost:hover { border-color: var(--cyan); }
|
||||
button.primary:hover { filter: brightness(1.1); }
|
||||
|
||||
.bottombar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mapinfo { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.mname { color: var(--cyan); font-weight: 700; letter-spacing: 1px; }
|
||||
.mhint { color: var(--dim); }
|
||||
.keys { color: var(--dim); }
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 5px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
54
index.html
Normal file
54
index.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TOW — trailer reversing trainer</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<span class="logo">TOW</span>
|
||||
<span class="sub">trailer reversing trainer</span>
|
||||
</div>
|
||||
<div id="mapTabs" class="tabs"></div>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<canvas id="game"></canvas>
|
||||
|
||||
<div id="winOverlay" class="overlay">
|
||||
<div class="card">
|
||||
<h1>PARKED.</h1>
|
||||
<p>Nicely backed in.</p>
|
||||
<div class="btns">
|
||||
<button id="btnRetry" class="ghost">Retry (R)</button>
|
||||
<button id="btnNext" class="primary">Next map (N)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="bottombar">
|
||||
<div class="mapinfo">
|
||||
<span id="mapName" class="mname"></span>
|
||||
<span id="mapHint" class="mhint"></span>
|
||||
</div>
|
||||
<div class="keys">
|
||||
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> drive ·
|
||||
hold <kbd>A</kbd>/<kbd>D</kbd> to turn the wheel ·
|
||||
<kbd>R</kbd> reset · <kbd>N</kbd> next · <kbd>1</kbd>–<kbd>5</kbd> maps (5 = random)
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="js/physics.js"></script>
|
||||
<script src="js/maps.js"></script>
|
||||
<script src="js/input.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/hud.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
179
js/game.js
Normal file
179
js/game.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/* 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 || {});
|
||||
154
js/hud.js
Normal file
154
js/hud.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/* 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 || {});
|
||||
47
js/input.js
Normal file
47
js/input.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* input.js — keyboard state. WASD / arrow keys drive; held A/D feeds the
|
||||
* steering smoothing in physics.js. R / N / [1-4] are edge-triggered actions. */
|
||||
(function (TOW) {
|
||||
'use strict';
|
||||
|
||||
function Input() {
|
||||
this.up = false; this.down = false; this.left = false; this.right = false;
|
||||
this.actions = []; // queue of one-shot actions: 'reset','next','prev','map:N'
|
||||
var self = this;
|
||||
|
||||
function set(code, val, e) {
|
||||
switch (code) {
|
||||
case 'KeyW': case 'ArrowUp': self.up = val; e.preventDefault(); break;
|
||||
case 'KeyS': case 'ArrowDown': self.down = val; e.preventDefault(); break;
|
||||
case 'KeyA': case 'ArrowLeft': self.left = val; e.preventDefault(); break;
|
||||
case 'KeyD': case 'ArrowRight': self.right = val; e.preventDefault(); break;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.repeat) { set(e.code, true, e); return; }
|
||||
set(e.code, true, e);
|
||||
switch (e.code) {
|
||||
case 'KeyR': self.actions.push('reset'); break;
|
||||
case 'KeyN': self.actions.push('next'); break;
|
||||
case 'KeyP': self.actions.push('prev'); break;
|
||||
case 'Digit1': self.actions.push('map:0'); break;
|
||||
case 'Digit2': self.actions.push('map:1'); break;
|
||||
case 'Digit3': self.actions.push('map:2'); break;
|
||||
case 'Digit4': self.actions.push('map:3'); break;
|
||||
case 'Digit5': self.actions.push('map:4'); break;
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', function (e) { set(e.code, false, e); });
|
||||
// releasing focus shouldn't leave a key "stuck" down
|
||||
window.addEventListener('blur', function () {
|
||||
self.up = self.down = self.left = self.right = false;
|
||||
});
|
||||
}
|
||||
|
||||
Input.prototype.drainActions = function () {
|
||||
var a = this.actions; this.actions = []; return a;
|
||||
};
|
||||
|
||||
TOW.Input = Input;
|
||||
|
||||
})(window.TOW = window.TOW || {});
|
||||
177
js/maps.js
Normal file
177
js/maps.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/* maps.js — scenario definitions.
|
||||
*
|
||||
* All coordinates are world == canvas pixels (W x H). Heading 0 points +x
|
||||
* (right); +y is down (canvas convention); angle increases clockwise on screen.
|
||||
*
|
||||
* obstacle: {x,y,w,h,a?,type?} -> oriented box centered at (x,y); type colors it
|
||||
* goal: {x,y,heading,w,h,posTol,headTol} -> where the TRAILER must end up
|
||||
* start: {x,y,carHeading,trailerHeading} -> car rear-axle pose
|
||||
*/
|
||||
(function (TOW) {
|
||||
'use strict';
|
||||
|
||||
var W = 960, H = 680;
|
||||
var PI = Math.PI;
|
||||
|
||||
// a couple of headings for readability
|
||||
var UP = -PI / 2, DOWN = PI / 2, LEFT = PI, RIGHT = 0;
|
||||
|
||||
var maps = [
|
||||
{
|
||||
name: 'Loading Dock',
|
||||
hint: 'Warm-up. Reverse straight back to slot the trailer into the bay.',
|
||||
start: { x: 480, y: 470, carHeading: DOWN, trailerHeading: DOWN },
|
||||
// NB: goal w == extent ALONG heading (depth), h == perpendicular (width)
|
||||
goal: { x: 480, y: 120, heading: DOWN, w: 150, h: 120, posTol: 34, headTol: 0.30 },
|
||||
obstacles: [
|
||||
{ x: 250, y: 95, w: 300, h: 130, type: 'wall' },
|
||||
{ x: 710, y: 95, w: 300, h: 130, type: 'wall' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Parking Lot',
|
||||
hint: 'Back into the empty stall between the two parked cars.',
|
||||
start: { x: 700, y: 470, carHeading: LEFT, trailerHeading: LEFT },
|
||||
goal: { x: 360, y: 130, heading: DOWN, w: 140, h: 110, posTol: 30, headTol: 0.26 },
|
||||
obstacles: [
|
||||
{ x: 250, y: 110, w: 70, h: 150, type: 'car' },
|
||||
{ x: 470, y: 110, w: 70, h: 150, type: 'car' },
|
||||
{ x: 690, y: 110, w: 70, h: 150, type: 'car' },
|
||||
{ x: 480, y: 40, w: 960, h: 24, type: 'wall' } // back curb
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Roadside',
|
||||
hint: 'Parallel park: tuck the trailer into the gap along the curb.',
|
||||
start: { x: 250, y: 430, carHeading: RIGHT, trailerHeading: RIGHT },
|
||||
goal: { x: 480, y: 175, heading: RIGHT, w: 210, h: 80, posTol: 36, headTol: 0.24 },
|
||||
obstacles: [
|
||||
{ x: 480, y: 70, w: 960, h: 80, type: 'wall' }, // building / far curb
|
||||
{ x: 215, y: 175, w: 230, h: 70, type: 'car' }, // car in front of the gap
|
||||
{ x: 760, y: 175, w: 230, h: 70, type: 'car' } // car behind the gap
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Driveway',
|
||||
hint: 'Back up the angled driveway between the house and the hedge.',
|
||||
start: { x: 615, y: 500, carHeading: 0.82, trailerHeading: 0.82 },
|
||||
// driveway channel runs along a = -2.32 rad (up-left); goal sits on the
|
||||
// channel midline (midpoint of the two walls) up toward the house
|
||||
goal: { x: 223, y: 184, heading: -2.32, w: 120, h: 74, posTol: 34, headTol: 0.32 },
|
||||
obstacles: [
|
||||
{ x: 407, y: 286, w: 340, h: 40, a: -2.32, type: 'wall' }, // house wall
|
||||
{ x: 313, y: 374, w: 340, h: 40, a: -2.32, type: 'hedge' }, // hedge
|
||||
{ x: 480, y: 600, w: 960, h: 40, type: 'wall' } // street curb
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ---- procedural map #5 ---------------------------------------------------
|
||||
// A fresh layout is built every time the player arrives at this slot. Each
|
||||
// candidate is validated (start & goal collision-free, goal in bounds and
|
||||
// reachable) before being accepted; we retry until one passes.
|
||||
|
||||
function rnd(a, b) { return a + Math.random() * (b - a); }
|
||||
function chance(p) { return Math.random() < p; }
|
||||
function dist(ax, ay, bx, by) { return Math.hypot(ax - bx, ay - by); }
|
||||
function box(o) { return { cx: o.x, cy: o.y, hw: o.w / 2, hh: o.h / 2, a: o.a || 0 }; }
|
||||
|
||||
function validLayout(m) {
|
||||
var v = new TOW.Vehicle(); v.reset(m.start);
|
||||
var b = v.bodies();
|
||||
if (TOW.outOfBounds(b.carBox, W, H, 4)) return false;
|
||||
if (TOW.outOfBounds(b.trailerBox, W, H, 4)) return false;
|
||||
var g = m.goal;
|
||||
var gb = { cx: g.x, cy: g.y, hw: g.w / 2, hh: g.h / 2, a: g.heading };
|
||||
if (TOW.outOfBounds(gb, W, H, 2)) return false;
|
||||
for (var i = 0; i < m.obstacles.length; i++) {
|
||||
var ob = box(m.obstacles[i]);
|
||||
if (TOW.obbOverlap(b.carBox, ob)) return false;
|
||||
if (TOW.obbOverlap(b.trailerBox, ob)) return false;
|
||||
if (TOW.obbOverlap(gb, ob)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// back-into-a-stall, with the stall at a random x and a random gap width
|
||||
function buildVertical() {
|
||||
var gx = rnd(250, W - 250);
|
||||
var gap = rnd(104, 132);
|
||||
var carW = 70, off = gap / 2 + carW / 2;
|
||||
var dir = chance(0.5) ? LEFT : RIGHT;
|
||||
return {
|
||||
name: 'Random · Parking Lot',
|
||||
hint: 'Freshly generated. Back the trailer into the open stall.',
|
||||
start: {
|
||||
x: TOW.clamp(gx + rnd(-150, 150), 190, W - 190),
|
||||
y: rnd(450, 500), carHeading: dir, trailerHeading: dir
|
||||
},
|
||||
goal: { x: gx, y: 135, heading: DOWN, w: rnd(135, 150), h: gap - 18, posTol: 32, headTol: 0.28 },
|
||||
obstacles: [
|
||||
{ x: gx - off, y: 110, w: carW, h: 150, type: 'car' },
|
||||
{ x: gx + off, y: 110, w: carW, h: 150, type: 'car' },
|
||||
{ x: W / 2, y: 38, w: W, h: 24, type: 'wall' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// parallel park into a random gap along the curb
|
||||
function buildParallel() {
|
||||
var gx = rnd(345, W - 345);
|
||||
var len = rnd(195, 225);
|
||||
var y = 178, carLen = 200, off = len / 2 + carLen / 2 + 10; // +clearance to gap
|
||||
return {
|
||||
name: 'Random · Roadside',
|
||||
hint: 'Freshly generated. Parallel-park the trailer into the gap.',
|
||||
start: { x: rnd(190, 320), y: rnd(420, 460), carHeading: RIGHT, trailerHeading: RIGHT },
|
||||
goal: { x: gx, y: y, heading: RIGHT, w: len, h: 78, posTol: 36, headTol: 0.24 },
|
||||
obstacles: [
|
||||
{ x: W / 2, y: 66, w: W, h: 80, type: 'wall' },
|
||||
{ x: gx - off, y: y, w: carLen, h: 66, type: 'car' },
|
||||
{ x: gx + off, y: y, w: carLen, h: 66, type: 'car' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// sprinkle a few cones as extra hazards, kept clear of the goal & start
|
||||
function addCones(m) {
|
||||
var want = (Math.random() * 3) | 0; // 0..2
|
||||
var added = 0, tries = 0;
|
||||
while (added < want && tries < 60) {
|
||||
tries++;
|
||||
var c = { x: rnd(120, W - 120), y: rnd(250, 430), w: rnd(26, 38), h: rnd(26, 38), type: 'cone' };
|
||||
if (dist(c.x, c.y, m.goal.x, m.goal.y) < 145) continue;
|
||||
if (dist(c.x, c.y, m.start.x, m.start.y) < 120) continue;
|
||||
var probe = { start: m.start, goal: m.goal, obstacles: m.obstacles.concat([c]) };
|
||||
if (!validLayout(probe)) continue;
|
||||
m.obstacles.push(c); added++;
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomMap() {
|
||||
for (var attempt = 0; attempt < 150; attempt++) {
|
||||
var m = chance(0.5) ? buildVertical() : buildParallel();
|
||||
if (!validLayout(m)) continue;
|
||||
var v = new TOW.Vehicle(); v.reset(m.start);
|
||||
var tb = v.bodies().trailerBox;
|
||||
if (dist(tb.cx, tb.cy, m.goal.x, m.goal.y) < 200) continue; // not already parked
|
||||
addCones(m);
|
||||
return m;
|
||||
}
|
||||
return buildVertical(); // extremely unlikely fallback
|
||||
}
|
||||
|
||||
maps.push({
|
||||
name: 'Random',
|
||||
hint: 'A fresh layout every time you arrive (R retries the same one).',
|
||||
generate: generateRandomMap
|
||||
});
|
||||
|
||||
TOW.WORLD = { W: W, H: H };
|
||||
TOW.maps = maps;
|
||||
TOW.generateRandomMap = generateRandomMap;
|
||||
|
||||
})(window.TOW = window.TOW || {});
|
||||
200
js/physics.js
Normal file
200
js/physics.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/* physics.js — kinematic simulation of a car towing a single-axle trailer.
|
||||
*
|
||||
* Model: bicycle (single-track) kinematics for the car, plus the standard
|
||||
* trailer-articulation equation. The car's reference point is the REAR AXLE.
|
||||
* Reversing makes the trailer unstable (jackknife) exactly as in real life,
|
||||
* which is the whole point of the game.
|
||||
*/
|
||||
(function (TOW) {
|
||||
'use strict';
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
|
||||
|
||||
// shortest signed difference a-b wrapped to [-PI, PI]
|
||||
function angleDiff(a, b) {
|
||||
let d = (a - b) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return d;
|
||||
}
|
||||
|
||||
// Fixed vehicle geometry (world pixels). Shared by physics, render, collision.
|
||||
var GEO = {
|
||||
L: 64, // wheelbase (rear axle -> front axle)
|
||||
frontOverhang: 16, // body ahead of front axle
|
||||
rearOverhang: 14, // body behind rear axle
|
||||
carWidth: 34,
|
||||
d: 14, // hitch behind rear axle
|
||||
tongue: 26, // hitch -> front of trailer body
|
||||
trailerLen: 78,
|
||||
trailerWidth: 40,
|
||||
M: 92 // hitch -> trailer axle (articulation length)
|
||||
};
|
||||
|
||||
// Handling constants.
|
||||
var CFG = {
|
||||
maxSteer: 0.62, // rad, ~35deg at the wheels
|
||||
steerRate: 1.7, // rad/s while holding A/D (the "input smoothing")
|
||||
returnRate: 2.3, // rad/s self-centering when released
|
||||
accel: 230, // px/s^2
|
||||
friction: 170, // px/s^2 coast-down
|
||||
maxForward: 135, // px/s
|
||||
maxReverse: 95 // px/s
|
||||
};
|
||||
|
||||
function Vehicle() {
|
||||
this.geo = GEO;
|
||||
this.cfg = CFG;
|
||||
this.reset({ x: 0, y: 0, carHeading: 0, trailerHeading: 0 });
|
||||
}
|
||||
|
||||
Vehicle.prototype.reset = function (start) {
|
||||
this.x = start.x; // rear-axle position
|
||||
this.y = start.y;
|
||||
this.theta = start.carHeading; // car body heading (forward dir)
|
||||
this.phi = start.trailerHeading; // trailer heading (axle -> hitch dir)
|
||||
this.v = 0; // signed speed (+forward, -reverse)
|
||||
this.steer = 0; // current (smoothed) steering angle
|
||||
};
|
||||
|
||||
Vehicle.prototype.snapshot = function () {
|
||||
return { x: this.x, y: this.y, theta: this.theta, phi: this.phi, v: this.v };
|
||||
};
|
||||
Vehicle.prototype.restore = function (s) {
|
||||
this.x = s.x; this.y = s.y; this.theta = s.theta; this.phi = s.phi; this.v = s.v;
|
||||
};
|
||||
|
||||
// input: {up,down,left,right}
|
||||
Vehicle.prototype.step = function (dt, input) {
|
||||
var c = this.cfg;
|
||||
|
||||
// --- steering: smoothed toward the held direction, self-centering when idle
|
||||
if (input.left && !input.right) {
|
||||
this.steer -= c.steerRate * dt;
|
||||
} else if (input.right && !input.left) {
|
||||
this.steer += c.steerRate * dt;
|
||||
} else {
|
||||
var r = c.returnRate * dt;
|
||||
if (Math.abs(this.steer) <= r) this.steer = 0;
|
||||
else this.steer -= Math.sign(this.steer) * r;
|
||||
}
|
||||
this.steer = clamp(this.steer, -c.maxSteer, c.maxSteer);
|
||||
|
||||
// --- throttle / brake
|
||||
if (input.up && !input.down) {
|
||||
this.v += c.accel * dt;
|
||||
} else if (input.down && !input.up) {
|
||||
this.v -= c.accel * dt;
|
||||
} else {
|
||||
var f = c.friction * dt;
|
||||
if (Math.abs(this.v) <= f) this.v = 0;
|
||||
else this.v -= Math.sign(this.v) * f;
|
||||
}
|
||||
this.v = clamp(this.v, -c.maxReverse, c.maxForward);
|
||||
|
||||
// --- integrate bicycle model (rate of heading change, per second)
|
||||
var dtheta = (this.v / this.geo.L) * Math.tan(this.steer);
|
||||
this.theta += dtheta * dt;
|
||||
this.x += this.v * Math.cos(this.theta) * dt;
|
||||
this.y += this.v * Math.sin(this.theta) * dt;
|
||||
|
||||
// --- trailer articulation (with hitch offset d behind the rear axle)
|
||||
var rel = this.theta - this.phi;
|
||||
var dphi = (this.v / this.geo.M) * Math.sin(rel)
|
||||
- (this.geo.d / this.geo.M) * Math.cos(rel) * dtheta;
|
||||
this.phi += dphi * dt;
|
||||
};
|
||||
|
||||
// Resolve every body/anchor point from the current state. Single source of
|
||||
// truth so rendering and collision never disagree.
|
||||
Vehicle.prototype.bodies = function () {
|
||||
var g = this.geo;
|
||||
var ct = Math.cos(this.theta), st = Math.sin(this.theta);
|
||||
var cf = Math.cos(this.phi), sf = Math.sin(this.phi);
|
||||
|
||||
var rax = { x: this.x, y: this.y };
|
||||
var frontAxle = { x: rax.x + g.L * ct, y: rax.y + g.L * st };
|
||||
var sc = (g.L + g.frontOverhang - g.rearOverhang) / 2; // body center ahead of rear axle
|
||||
var carBox = {
|
||||
cx: rax.x + sc * ct, cy: rax.y + sc * st,
|
||||
hw: (g.L + g.frontOverhang + g.rearOverhang) / 2, hh: g.carWidth / 2,
|
||||
a: this.theta
|
||||
};
|
||||
var hitch = { x: rax.x - g.d * ct, y: rax.y - g.d * st };
|
||||
var bodyOff = g.tongue + g.trailerLen / 2; // body center behind hitch
|
||||
var trailerBox = {
|
||||
cx: hitch.x - bodyOff * cf, cy: hitch.y - bodyOff * sf,
|
||||
hw: g.trailerLen / 2, hh: g.trailerWidth / 2,
|
||||
a: this.phi
|
||||
};
|
||||
var trailerAxle = { x: hitch.x - g.M * cf, y: hitch.y - g.M * sf };
|
||||
|
||||
return {
|
||||
rax: rax, frontAxle: frontAxle, hitch: hitch,
|
||||
carBox: carBox, trailerBox: trailerBox, trailerAxle: trailerAxle
|
||||
};
|
||||
};
|
||||
|
||||
// Articulation (hitch) angle in radians; near +/-PI/2 means jackknifed.
|
||||
Vehicle.prototype.hitchAngle = function () { return angleDiff(this.phi, this.theta); };
|
||||
|
||||
TOW.Vehicle = Vehicle;
|
||||
TOW.GEO = GEO;
|
||||
TOW.clamp = clamp;
|
||||
TOW.angleDiff = angleDiff;
|
||||
|
||||
// ---- Oriented-box helpers + SAT collision -------------------------------
|
||||
function corners(b) {
|
||||
var c = Math.cos(b.a), s = Math.sin(b.a);
|
||||
var ax = { x: c * b.hw, y: s * b.hw }; // half-extent along length
|
||||
var ay = { x: -s * b.hh, y: c * b.hh }; // half-extent along width
|
||||
return [
|
||||
{ x: b.cx + ax.x + ay.x, y: b.cy + ax.y + ay.y },
|
||||
{ x: b.cx + ax.x - ay.x, y: b.cy + ax.y - ay.y },
|
||||
{ x: b.cx - ax.x - ay.x, y: b.cy - ax.y - ay.y },
|
||||
{ x: b.cx - ax.x + ay.x, y: b.cy - ax.y + ay.y }
|
||||
];
|
||||
}
|
||||
TOW.corners = corners;
|
||||
|
||||
function projOverlap(ca, cb, ax) {
|
||||
var minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity, i, p;
|
||||
for (i = 0; i < 4; i++) {
|
||||
p = ca[i].x * ax.x + ca[i].y * ax.y;
|
||||
if (p < minA) minA = p; if (p > maxA) maxA = p;
|
||||
}
|
||||
for (i = 0; i < 4; i++) {
|
||||
p = cb[i].x * ax.x + cb[i].y * ax.y;
|
||||
if (p < minB) minB = p; if (p > maxB) maxB = p;
|
||||
}
|
||||
return !(maxA < minB || maxB < minA);
|
||||
}
|
||||
|
||||
// Separating Axis Theorem for two oriented boxes.
|
||||
function obbOverlap(a, b) {
|
||||
var ca = corners(a), cb = corners(b);
|
||||
var axes = [
|
||||
{ x: Math.cos(a.a), y: Math.sin(a.a) },
|
||||
{ x: -Math.sin(a.a), y: Math.cos(a.a) },
|
||||
{ x: Math.cos(b.a), y: Math.sin(b.a) },
|
||||
{ x: -Math.sin(b.a), y: Math.cos(b.a) }
|
||||
];
|
||||
for (var i = 0; i < axes.length; i++) {
|
||||
if (!projOverlap(ca, cb, axes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
TOW.obbOverlap = obbOverlap;
|
||||
|
||||
// Any corner of box outside the play rect [0,W]x[0,H] (with margin)?
|
||||
function outOfBounds(box, W, H, margin) {
|
||||
var c = corners(box);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
if (c[i].x < margin || c[i].x > W - margin ||
|
||||
c[i].y < margin || c[i].y > H - margin) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
TOW.outOfBounds = outOfBounds;
|
||||
|
||||
})(window.TOW = window.TOW || {});
|
||||
198
js/render.js
Normal file
198
js/render.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/* 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 || {});
|
||||
Loading…
Add table
Add a link
Reference in a new issue