3
Stop vs Light vs Roundabout
drag Y to change arrival rate
idle
556 lines · vanilla
view source
// Stop vs Light vs Roundabout — three intersections, same arrival process.
//
// Cars Poisson-arrive from N/E/S/W at rate lambda (cars/sec total, split
// evenly across the four approaches). Each car has a random destination
// (straight across, mostly) and we measure throughput + average wait.
//
// Coordinate convention per panel: local (lx, ly) in [0..panelW] x [0..H].
// Lanes are at fixed offsets from the panel centerline.
const LANE_W = 14; // half-width of a lane (visual)
const CAR_LEN = 10;
const CAR_WID = 6;
const APPROACH_LEN = 110; // how long the visible approach is
const CRUISE_V = 60; // px/sec free-flow speed
const STOP_GAP = 16; // following distance at full stop
const ENTER_GAP = 26; // gap car wants to see before entering
const LIGHT_GREEN_S = 10; // green per phase
const LIGHT_YELLOW_S = 1.5;
// Roundabout geometry
const RB_OUTER = 56;
const RB_INNER = 30;
const RB_LANE = (RB_OUTER + RB_INNER) / 2;
const STATS_WIN = 90; // seconds for the trailing throughput window
let W, H;
let panelW;
let lastLambda = 0;
let lambda = 0.6; // cars/sec, total across 4 approaches
let lambdaSmooth = 0.6;
let time = 0;
let lastMouseY = -1;
let panels; // [stop, light, roundabout]
function rand() { return Math.random(); }
function expRand(rate) { return -Math.log(1 - Math.random()) / Math.max(1e-6, rate); }
// ---------- car factory ----------
let nextCarId = 1;
function spawnCar(panel, dir) {
// dir: 0=N (coming from top, going down), 1=E (from right, going left),
// 2=S (from bottom, going up), 3=W (from left, going right)
const c = {
id: nextCarId++,
dir,
s: 0, // progress along its own approach -> exit, in px
v: CRUISE_V,
state: 'approach', // approach | queued | crossing | exiting | done
color: laneColor(dir),
tSpawn: time,
tCleared: 0,
waitAccum: 0,
// roundabout-specific:
theta: 0, // current angle on the ring
targetExitDir: (dir + 2) % 4, // go straight across
};
panel.cars.push(c);
}
function laneColor(dir) {
const hues = [200, 30, 140, 320];
return `hsl(${hues[dir]},80%,65%)`;
}
// ---------- per-panel state ----------
function makePanel(kind, originX) {
return {
kind, // 'stop' | 'light' | 'rb'
originX,
cars: [],
arrivalTimers: [0, 0, 0, 0], // next arrival time per direction
// stop:
stopQueue: [], // FIFO of car ids waiting at stop line
occupant: null, // car currently crossing the intersection
// light:
phase: 0, // 0 = NS green, 1 = EW green
phaseT: 0,
yellow: false,
// roundabout: cars on ring are part of .cars with state 'crossing'
// stats:
throughputBuckets: [], // {t, count}
completedCount: 0,
waitSum: 0,
waitN: 0,
waitVarSum: 0, // running sum of (wait - mean)^2 via Welford
waitMean: 0,
};
}
// ---------- arrivals ----------
function maybeArrive(panel, dt) {
for (let d = 0; d < 4; d++) {
panel.arrivalTimers[d] -= dt;
while (panel.arrivalTimers[d] <= 0) {
// per-direction rate is lambda/4
const per = Math.max(0.01, lambdaSmooth / 4);
panel.arrivalTimers[d] += expRand(per);
// only spawn if there's room at the head of the approach
if (approachHeadFree(panel, d)) {
spawnCar(panel, d);
}
}
}
}
function approachHeadFree(panel, dir) {
// No car within CAR_LEN of s=0 in this direction
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
if (c.dir !== dir) continue;
if (c.state !== 'approach' && c.state !== 'queued') continue;
if (c.s < CAR_LEN * 1.5) return false;
}
return true;
}
// ---------- helpers: lane geometry ----------
// In each panel, the intersection center is at (panelW/2, H/2).
// Approach length is APPROACH_LEN. s=0 is the entry point of the approach
// (outside the panel); s=APPROACH_LEN puts the car at the stop line.
// Cars on the left half of their direction (right-hand drive).
function laneOffset(dir) {
// perpendicular offset of the inbound lane (cars drive on the right)
// returns dx, dy to shift the lane off the panel centerline
switch (dir) {
case 0: return { ox: LANE_W * 0.6, oy: 0 }; // N approach: car drives down on the east half
case 1: return { ox: 0, oy: LANE_W * 0.6 }; // E approach: car drives left on the south half
case 2: return { ox: -LANE_W * 0.6, oy: 0 }; // S approach: car drives up on the west half
case 3: return { ox: 0, oy: -LANE_W * 0.6 }; // W approach: car drives right on the north half
}
}
// Returns world XY for a car at progress s along approach->exit,
// given panel origin and direction. Path length total = 2*APPROACH_LEN.
function approachPos(panel, c) {
const cx = panel.originX + panelW / 2;
const cy = H / 2;
const off = laneOffset(c.dir);
// entry point is APPROACH_LEN outside center along the inbound dir
// direction vector (pointing toward center)
let dx = 0, dy = 0;
switch (c.dir) {
case 0: dx = 0; dy = 1; break;
case 1: dx = -1; dy = 0; break;
case 2: dx = 0; dy = -1; break;
case 3: dx = 1; dy = 0; break;
}
// entry = center - APPROACH_LEN * dir
const ex = cx - dx * APPROACH_LEN;
const ey = cy - dy * APPROACH_LEN;
const x = ex + dx * c.s + off.ox;
const y = ey + dy * c.s + off.oy;
return { x, y, dx, dy };
}
// distance from approach entry where the stop line sits
const STOP_LINE_S = APPROACH_LEN - 8;
const CROSS_END_S = APPROACH_LEN + 14;
const EXIT_END_S = APPROACH_LEN * 2;
// Find the car directly ahead (same dir, larger s) within the approach segment.
function leaderOnApproach(panel, c) {
let best = null;
for (let i = 0; i < panel.cars.length; i++) {
const o = panel.cars[i];
if (o === c) continue;
if (o.dir !== c.dir) continue;
if (o.state !== 'approach' && o.state !== 'queued') continue;
if (o.s <= c.s) continue;
if (!best || o.s < best.s) best = o;
}
return best;
}
// ---------- update: 4-way stop ----------
function updateStop(panel, dt) {
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
if (c.state === 'approach' || c.state === 'queued') {
const leader = leaderOnApproach(panel, c);
const target = leader ? leader.s - (CAR_LEN + STOP_GAP) : STOP_LINE_S;
// Are we at the head of our lane?
const isHead = !leader;
if (isHead && c.s >= STOP_LINE_S - 0.5) {
// queue up if not already
if (c.state !== 'queued') {
c.state = 'queued';
if (!panel.stopQueue.includes(c)) panel.stopQueue.push(c);
}
c.v = 0;
c.waitAccum += dt;
continue;
}
// crawl toward target
const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
c.v = desired;
const newS = c.s + c.v * dt;
if (newS >= STOP_LINE_S && isHead) {
c.s = STOP_LINE_S;
c.v = 0;
} else {
c.s = newS;
if (c.v < 1 && c.s < STOP_LINE_S - 1) c.waitAccum += dt;
}
} else if (c.state === 'crossing') {
c.v = CRUISE_V * 0.8;
c.s += c.v * dt;
if (c.s >= CROSS_END_S) {
c.state = 'exiting';
if (panel.occupant === c) panel.occupant = null;
recordCompletion(panel, c);
}
} else if (c.state === 'exiting') {
c.v = CRUISE_V;
c.s += c.v * dt;
if (c.s >= EXIT_END_S) c.state = 'done';
}
}
// dispatch: if no occupant, the next queued car goes
if (!panel.occupant && panel.stopQueue.length) {
// remove any dropped/dead entries from queue
while (panel.stopQueue.length && panel.stopQueue[0].state !== 'queued') {
panel.stopQueue.shift();
}
if (panel.stopQueue.length) {
const next = panel.stopQueue.shift();
next.state = 'crossing';
panel.occupant = next;
}
}
}
// ---------- update: signalized ----------
function updateLight(panel, dt) {
panel.phaseT += dt;
const totalGreen = LIGHT_GREEN_S;
const totalYellow = LIGHT_YELLOW_S;
const cycle = totalGreen + totalYellow;
panel.yellow = panel.phaseT > totalGreen;
if (panel.phaseT >= cycle) {
panel.phaseT -= cycle;
panel.phase ^= 1;
panel.yellow = false;
}
const greenForNS = (panel.phase === 0 && !panel.yellow);
const greenForEW = (panel.phase === 1 && !panel.yellow);
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
const isNS = (c.dir === 0 || c.dir === 2);
const greenForMe = isNS ? greenForNS : greenForEW;
if (c.state === 'approach' || c.state === 'queued') {
const leader = leaderOnApproach(panel, c);
let target;
if (leader) {
target = leader.s - (CAR_LEN + STOP_GAP);
} else if (greenForMe) {
target = EXIT_END_S; // pass right through
} else {
target = STOP_LINE_S;
}
const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
c.v = desired;
const newS = c.s + c.v * dt;
// crossing: cars that pass STOP_LINE_S while green become 'crossing'
if (newS >= STOP_LINE_S && c.state !== 'crossing' && greenForMe && !leader) {
c.state = 'crossing';
} else if (newS >= STOP_LINE_S - 0.5 && !greenForMe && !leader) {
c.s = STOP_LINE_S - 0.5;
c.v = 0;
c.state = 'queued';
c.waitAccum += dt;
continue;
}
c.s = newS;
if (c.v < 1 && c.s < STOP_LINE_S - 1) c.waitAccum += dt;
} else if (c.state === 'crossing') {
c.v = CRUISE_V * 0.9;
c.s += c.v * dt;
if (c.s >= CROSS_END_S) {
c.state = 'exiting';
recordCompletion(panel, c);
}
} else if (c.state === 'exiting') {
c.v = CRUISE_V;
c.s += c.v * dt;
if (c.s >= EXIT_END_S) c.state = 'done';
}
}
}
// ---------- update: roundabout ----------
// We model the ring as a 1D track parameterized by theta in [0, 2pi).
// Circulating cars have c.state='crossing' and c.theta set. They move
// CCW at a fixed angular speed. Entry checks for a gap on the ring
// in the conflict region near the car's entry angle.
const RB_ENTRY_ANGLE = [
Math.PI * 1.5, // N enters at top
0, // E enters at right
Math.PI * 0.5, // S enters at bottom
Math.PI, // W enters at left
];
const RB_OMEGA = CRUISE_V * 0.9 / RB_LANE; // rad/sec on ring
function angularDist(a, b) {
let d = a - b;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function ringGapClear(panel, entryAngle) {
// Cars on ring at angle just upstream (smaller angle, since we move CCW
// i.e. increasing theta). The threatening car is one whose theta is
// "behind" entryAngle by less than CONFLICT.
const CONFLICT = 0.55; // radians upstream gap required
for (let i = 0; i < panel.cars.length; i++) {
const o = panel.cars[i];
if (o.state !== 'crossing') continue;
// distance from o to entry going CCW: how far o still has to travel to
// reach entryAngle.
let d = entryAngle - o.theta;
while (d < 0) d += Math.PI * 2;
while (d > Math.PI * 2) d -= Math.PI * 2;
if (d < CONFLICT) return false;
}
return true;
}
function updateRoundabout(panel, dt) {
// 1) advance circulating cars
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
if (c.state !== 'crossing') continue;
// check leader on ring: another circulating car ahead within small gap
let minAhead = Infinity;
for (let j = 0; j < panel.cars.length; j++) {
const o = panel.cars[j];
if (o === c || o.state !== 'crossing') continue;
let d = o.theta - c.theta;
while (d < 0) d += Math.PI * 2;
if (d < minAhead) minAhead = d;
}
let omega = RB_OMEGA;
if (minAhead < 0.25) omega *= Math.max(0, minAhead / 0.25);
c.theta += omega * dt;
if (c.theta >= Math.PI * 2) c.theta -= Math.PI * 2;
// exit when we reach our exit angle
const exitAngle = RB_ENTRY_ANGLE[c.targetExitDir];
// We exit when theta has just crossed exitAngle going CCW
// and we have traveled at least some minimum arc from entry.
const entryAngle = RB_ENTRY_ANGLE[c.dir];
let traveled = c.theta - entryAngle;
while (traveled < 0) traveled += Math.PI * 2;
let toExit = exitAngle - entryAngle;
while (toExit <= 0) toExit += Math.PI * 2;
if (traveled >= toExit - 0.05) {
c.state = 'exiting';
c.s = APPROACH_LEN + 2; // we'll route it outward along its exit dir
c.dir = c.targetExitDir; // morph dir so exit lane uses the exit direction
recordCompletion(panel, c);
}
}
// 2) approaching cars
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
if (c.state === 'approach' || c.state === 'queued') {
const leader = leaderOnApproach(panel, c);
let target;
if (leader) {
target = leader.s - (CAR_LEN + STOP_GAP);
} else {
// wait at stop line until gap on ring
const entryAngle = RB_ENTRY_ANGLE[c.dir];
if (ringGapClear(panel, entryAngle)) {
// enter the ring
c.state = 'crossing';
c.theta = entryAngle;
continue;
} else {
target = STOP_LINE_S;
}
}
const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
c.v = desired;
c.s += c.v * dt;
if (c.s > STOP_LINE_S) c.s = STOP_LINE_S;
if (c.v < 1 && c.s >= STOP_LINE_S - 1) c.waitAccum += dt;
} else if (c.state === 'exiting') {
c.v = CRUISE_V;
c.s += c.v * dt;
if (c.s >= EXIT_END_S) c.state = 'done';
}
}
}
// ---------- stats ----------
function recordCompletion(panel, c) {
c.tCleared = time;
panel.completedCount++;
panel.throughputBuckets.push({ t: time, w: c.waitAccum });
// Welford
const x = c.waitAccum;
panel.waitN++;
const delta = x - panel.waitMean;
panel.waitMean += delta / panel.waitN;
panel.waitVarSum += delta * (x - panel.waitMean);
panel.waitSum += x;
}
function trimStats(panel) {
const cutoff = time - STATS_WIN;
while (panel.throughputBuckets.length && panel.throughputBuckets[0].t < cutoff) {
panel.throughputBuckets.shift();
}
}
function throughputCpm(panel) {
const n = panel.throughputBuckets.length;
if (n < 2) return 0;
const span = Math.max(1, Math.min(STATS_WIN, time - panel.throughputBuckets[0].t));
return n * 60 / span;
}
function avgWaitRecent(panel) {
const n = panel.throughputBuckets.length;
if (n === 0) return 0;
let s = 0;
for (let i = 0; i < n; i++) s += panel.throughputBuckets[i].w;
return s / n;
}
function waitStdRecent(panel) {
const n = panel.throughputBuckets.length;
if (n < 2) return 0;
const m = avgWaitRecent(panel);
let v = 0;
for (let i = 0; i < n; i++) { const d = panel.throughputBuckets[i].w - m; v += d * d; }
return Math.sqrt(v / (n - 1));
}
// ---------- drawing ----------
function drawPanelBg(panel) {
const x0 = panel.originX;
const cx = x0 + panelW / 2;
const cy = H / 2;
// panel bg
ctxBg(x0);
// road strips: two crossing roads
ctxSetFill('#222936');
// vertical road
fillRect(cx - LANE_W, 0, LANE_W * 2, H);
// horizontal road
fillRect(x0, cy - LANE_W, panelW, LANE_W * 2);
// intersection square
if (panel.kind === 'rb') {
// donut: outer disc dark gray, inner island green
ctxSetFill('#2a3140');
circle(cx, cy, RB_OUTER + 6);
ctxSetFill('#1a2230');
circle(cx, cy, RB_OUTER);
// grass island
ctxSetFill('#2e6a3a');
circle(cx, cy, RB_INNER);
} else {
ctxSetFill('#1a2230');
fillRect(cx - LANE_W, cy - LANE_W, LANE_W * 2, LANE_W * 2);
}
// lane dividers (dashed white) on approaches
_ctx.strokeStyle = 'rgba(220,220,200,0.4)';
_ctx.setLineDash([6, 6]);
_ctx.lineWidth = 1;
_ctx.beginPath();
// vertical centerline
_ctx.moveTo(cx, 0); _ctx.lineTo(cx, cy - LANE_W);
_ctx.moveTo(cx, cy + LANE_W); _ctx.lineTo(cx, H);
// horizontal centerline
_ctx.moveTo(x0, cy); _ctx.lineTo(cx - LANE_W, cy);
_ctx.moveTo(cx + LANE_W, cy); _ctx.lineTo(x0 + panelW, cy);
_ctx.stroke();
_ctx.setLineDash([]);
}
function drawStopSigns(panel) {
const x0 = panel.originX;
const cx = x0 + panelW / 2;
const cy = H / 2;
_ctx.fillStyle = '#d33';
const r = 5;
// 4 sign positions just outside the intersection corners
const positions = [
[cx + LANE_W + 6, cy - LANE_W - 6],
[cx - LANE_W - 6, cy + LANE_W + 6],
[cx - LANE_W - 6, cy - LANE_W - 6],
[cx + LANE_W + 6, cy + LANE_W + 6],
];
for (let i = 0; i < 4; i++) {
octagon(positions[i][0], positions[i][1], r);
}
}
function drawLights(panel) {
const x0 = panel.originX;
const cx = x0 + panelW / 2;
const cy = H / 2;
const greenNS = panel.phase === 0 && !panel.yellow;
const yellowNS = panel.phase === 0 && panel.yellow;
const greenEW = panel.phase === 1 && !panel.yellow;
const yellowEW = panel.phase === 1 && panel.yellow;
function lightAt(x, y, color) {
_ctx.fillStyle = '#111';
_ctx.beginPath();
_ctx.arc(x, y, 4.5, 0, Math.PI * 2);
_ctx.fill();
_ctx.fillStyle = color;
_ctx.beginPath();
_ctx.arc(x, y, 3, 0, Math.PI * 2);
_ctx.fill();
}
const cNS = greenNS ? '#3c3' : yellowNS ? '#ec3' : '#a22';
const cEW = greenEW ? '#3c3' : yellowEW ? '#ec3' : '#a22';
// NS lights at top-left and bottom-right of intersection
lightAt(cx - LANE_W - 7, cy - LANE_W - 7, cNS);
lightAt(cx + LANE_W + 7, cy + LANE_W + 7, cNS);
// EW lights at top-right and bottom-left
lightAt(cx + LANE_W + 7, cy - LANE_W - 7, cEW);
lightAt(cx - LANE_W - 7, cy + LANE_W + 7, cEW);
}
function drawCars(panel) {
const x0 = panel.originX;
const cx = x0 + panelW / 2;
const cy = H / 2;
for (let i = 0; i < panel.cars.length; i++) {
const c = panel.cars[i];
if (panel.kind === 'rb' && c.state === 'crossing') {
// on the ring
const x = cx + Math.cos(c.theta) * RB_LANE;
const y = cy + Math.sin(c.theta) * RB_LANE;
const ang = c.theta + Math.PI / 2; // tangent CCW
drawRect(x, y, CAR_LEN, CAR_WID, ang, c.color);
} else {
const p = approachPos(panel, c);
const ang = Math.atan2(p.dy, p.dx);
drawRect(p.x, p.y, CAR_LEN, CAR_WID, ang, c.color);
}
}
}
function drawRect(x, y, w, h, ang, color) {
_ctx.save();
_ctx.translate(x, y);
_ctx.rotate(ang);
_ctx.fillStyle = color;
_ctx.fillRect(-w / 2, -h / 2, w, h);
_ctx.restore();
}
function octagon(x, y, r) {
_ctx.beginPath();
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2 + Math.PI / 8;
const px = x + Math.cos(a) * r;
const py = y + Math.sin(a) * r;
if (i === 0) _ctx.moveTo(px, py); else _ctx.lineTo(px, py);
}
_ctx.closePath();
_ctx.fill();
}
function circle(x, y, r) {
_ctx.beginPath();
_ctx.arc(x, y, r, 0, Math.PI * 2);
_ctx.fill();
}
function fillRect(x, y, w, h) { _ctx.fillRect(x, y, w, h); }
function ctxBg(x0) {
_ctx.fillStyle = '#0e1320';
_ctx.fillRect(x0, 0, panelW, H);
}
function ctxSetFill(c) { _ctx.fillStyle = c; }
// We stash ctx so drawing helpers don't need it passed.
let _ctx;
function drawHud(panel, label) {
const x0 = panel.originX;
_ctx.fillStyle = 'rgba(0,0,0,0.55)';
_ctx.fillRect(x0 + 6, 6, panelW - 12, 56);
_ctx.fillStyle = '#fff';
_ctx.font = 'bold 12px sans-serif';
_ctx.textBaseline = 'top';
_ctx.fillText(label, x0 + 12, 10);
_ctx.font = '11px sans-serif';
_ctx.fillStyle = '#cfd8e6';
const tput = throughputCpm(panel);
const aw = avgWaitRecent(panel);
const sd = waitStdRecent(panel);
_ctx.fillText(`throughput: ${tput.toFixed(1)} cars/min`, x0 + 12, 26);
_ctx.fillText(`avg wait: ${aw.toFixed(1)}s (σ ${sd.toFixed(1)})`, x0 + 12, 40);
}
function drawGlobalHud() {
_ctx.fillStyle = 'rgba(0,0,0,0.55)';
_ctx.fillRect(8, H - 30, 260, 22);
_ctx.fillStyle = '#fff';
_ctx.font = '11px sans-serif';
_ctx.textBaseline = 'top';
_ctx.fillText(
`arrival rate λ = ${lambdaSmooth.toFixed(2)} cars/sec (drag Y ↑↓)`,
14, H - 26
);
}
// ---------- lifecycle ----------
function init({ ctx, width, height }) {
W = width;
H = height;
panelW = Math.floor(W / 3);
panels = [
makePanel('stop', 0),
makePanel('light', panelW),
makePanel('rb', panelW * 2),
];
_ctx = ctx;
ctx.fillStyle = '#0e1320';
ctx.fillRect(0, 0, W, H);
time = 0;
}
function tick({ ctx, dt, width, height, input }) {
_ctx = ctx;
if (width !== W || height !== H) {
W = width; H = height;
panelW = Math.floor(W / 3);
panels[0].originX = 0;
panels[1].originX = panelW;
panels[2].originX = panelW * 2;
}
if (dt > 0.08) dt = 0.08;
time += dt;
// lambda from mouseY: top of canvas = high rate, bottom = low rate
if (input && Number.isFinite(input.mouseY) && input.mouseY >= 0 && input.mouseY <= H) {
const u = 1 - input.mouseY / H; // 0..1
lambda = 0.1 + u * 2.0; // up to ~2 cars/sec across 4 approaches
}
// smooth so cars don't jolt
lambdaSmooth += (lambda - lambdaSmooth) * Math.min(1, dt * 2);
// simulate
for (let i = 0; i < panels.length; i++) {
const p = panels[i];
maybeArrive(p, dt);
if (p.kind === 'stop') updateStop(p, dt);
else if (p.kind === 'light') updateLight(p, dt);
else updateRoundabout(p, dt);
// sweep dead
p.cars = p.cars.filter(c => c.state !== 'done');
trimStats(p);
}
// draw
ctx.fillStyle = '#0e1320';
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < panels.length; i++) {
drawPanelBg(panels[i]);
if (panels[i].kind === 'stop') drawStopSigns(panels[i]);
if (panels[i].kind === 'light') drawLights(panels[i]);
drawCars(panels[i]);
}
// panel separator lines
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelW, 0); ctx.lineTo(panelW, H);
ctx.moveTo(panelW * 2, 0); ctx.lineTo(panelW * 2, H);
ctx.stroke();
drawHud(panels[0], '4-way stop');
drawHud(panels[1], 'Signalized');
drawHud(panels[2], 'Roundabout');
drawGlobalHud();
}
Comments (0)
Log in to comment.