diff --git a/web/app.js b/web/app.js
index ea9bf1d..44b9093 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,5 +1,5 @@
import { LandscapeRenderer, KIND } from './renderer.js';
-import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, ROUTE_KIND, enterBuilding, assignDoors, routeThroughDoors, streetFurniture, furnitureBoxes, DOOR, MOVE } from './city.js';
+import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, ROUTE_KIND, enterBuilding, assignDoors, routeThroughDoors, streetFurniture, furnitureBoxes, DOOR, MOVE, trafficLoops, spawnCars, carBoxes, stepCars } from './city.js';
import { LabelAtlas, PosterAtlas } from './labels.js';
import { personFor, residentSeed, drawPortrait } from './people.js';
import { apiFetch, progressEvents } from './api.mjs';
@@ -47,7 +47,7 @@ let searchHits=[];
let expandedCoverageLayer=null;
let coverageActiveIndex=-1;
let cameraAnimation=null;
-let cityFurniture=[],propState={key:''};
+let cityFurniture=[],propState={key:''},cars=[];
let cityModel=null,cityEntries=[],cityIndex=null,collisionIndex=null,cityWallBoxes=[],cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true;
let currentSnapshot=null;
let currentName='';
@@ -151,6 +151,7 @@ function computeCity(){
cityFurniture=[...doorBoxes.map(box=>({x:box.x+box.w/2,y:box.y+box.h/2,boxes:[box]})),...furniture.map(item=>({x:item.x,y:item.y,boxes:furnitureBoxes(item).map(part=>({...part,kind:part.part==='pool'?KIND.pool:part.part==='lamp'?KIND.lamp:KIND.prop,lit:part.z}))}))];
for(const item of cityFurniture)for(const part of item.boxes)if(part.kind===KIND.prop&&part.z<1)obstacles.push({x:part.x,y:part.y,w:part.w,h:part.h,height:part.height});
propState.key='';
+ cars=spawnCars(trafficLoops(cityModel.blocks),seededRandom(furnitureSeed^0x5bd1e995));
collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes,...obstacles]);
updateFires();
spawnResidents();
@@ -827,10 +828,12 @@ function drawResidentTag(ctx){
}
// Street furniture is only uploaded near where you're looking: from high up it is sub-pixel, and
// thousands of small boxes are costly to draw everywhere at once.
-function updateProps(){
+function propRange(){
const c=renderer.city,heli=c.view==='heli';
- const radius=heli?(c.distance>600?0:Math.min(400,Math.max(150,c.distance*.9))):320;
- const focus=heli?[c.x,c.y]:[c.ex,c.ey],key=radius?[Math.round(focus[0]/40),Math.round(focus[1]/40),Math.round(radius/100)].join():'none';
+ return {radius:heli?(c.distance>600?0:Math.min(400,Math.max(150,c.distance*.9))):320,focus:heli?[c.x,c.y]:[c.ex,c.ey]};
+}
+function updateProps(){
+ const {radius,focus}=propRange(),key=radius?[Math.round(focus[0]/40),Math.round(focus[1]/40),Math.round(radius/100)].join():'none';
if(key===propState.key)return;
propState.key=key;
const boxes=[];
@@ -990,6 +993,19 @@ function updateResidents(seconds){
renderer.setWanderers(residentBuffer);
}
+// Traffic always moves, but only cars near the camera are built and uploaded (like furniture).
+function updateCars(seconds){
+ const {radius,focus}=propRange(),boxes=[],c=renderer.city;
+ // Only someone at street level can stand in a car's way.
+ stepCars(cars,seconds,c.view!=='heli'&&c.ez<3?[c.ex,c.ey]:null);
+ if(radius)for(const car of cars){
+ const [min,,max]=car.loop.corners;
+ if(Math.max(min[0]-focus[0],focus[0]-max[0],min[1]-focus[1],focus[1]-max[1])>radius)continue;
+ for(const part of carBoxes(car))boxes.push({...part,kind:part.part==='pool'?KIND.pool:part.part==='lamp'?KIND.lamp:KIND.prop,lit:part.z});
+ }
+ if(boxes.length||renderer.carLayer.count)renderer.setCars(boxes);
+}
+
// ---- Security alerts from GitHub: burning buildings, hazard tape and inspector details ----
let fileAlerts=new Map(),alertState={loading:false,reason:'',statuses:null};
async function loadAlerts(){
@@ -1253,12 +1269,14 @@ function animate(now){
if(renderer.mode==='city'&&renderer.city.view!=='heli'&&!cameraAnimation&&(heldKeys.size||touchMove.forward||touchMove.right))stepCity(frameSeconds);
if(tour)advanceTour(now,frameSeconds);
if(renderer.mode==='city'&&wanderers.length)updateResidents(frameSeconds);
+ if(renderer.mode==='city')updateCars(frameSeconds);
if(cameraAnimation){
const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4;
for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
if(progress>=1)cameraAnimation=null;dirty=true;
}
if(renderer.animating)dirty=true;
+ hideStaleTooltip();
if(dirty){
// The overlay fills the code overview, so draw it before the GPU pass that samples it.
renderer.resize();drawOverlay();renderer.codeOn=showCode;renderer.render();dirty=codeTexturesPending;
@@ -1313,8 +1331,16 @@ viewport.addEventListener('pointermove',event=>{
else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));}
dirty=true;$('#tooltip').hidden=true;return;
}
- const item=pickAt(x,y),tip=$('#tooltip');if(item){tip.hidden=false;tip.style.left=`${Math.min(viewport.clientWidth-320,x+13)}px`;tip.style.top=`${Math.min(viewport.clientHeight-70,y+13)}px`;tip.innerHTML=`${escapeHtml(item.name)}${escapeHtml(item.path)} · ${format(item.lines)} lines`;}else tip.hidden=true;
+ const item=pickAt(x,y),tip=$('#tooltip');if(item){tip.hidden=false;tipView=viewKey();tip.style.left=`${Math.min(viewport.clientWidth-320,x+13)}px`;tip.style.top=`${Math.min(viewport.clientHeight-70,y+13)}px`;tip.innerHTML=`${escapeHtml(item.name)}${escapeHtml(item.path)} · ${format(item.lines)} lines`;}else tip.hidden=true;
});
+// The hover tooltip belongs to the view it was shown in: any camera move, view or mode change, or
+// mouse-look hides it, since none of those send a pointermove to update it.
+let tipView='';
+const viewKey=()=>renderer.mode+JSON.stringify(renderer.mode==='city'?renderer.city:renderer.camera);
+function hideStaleTooltip(){
+ const tip=$('#tooltip');
+ if(!tip.hidden&&(document.pointerLockElement||viewKey()!==tipView))tip.hidden=true;
+}
function finishPointer(event){
if(event.pointerType==='touch')endTouch(event);
if(!pointer.down||event.pointerId!==pointer.id)return;
diff --git a/web/city.js b/web/city.js
index c5a2f20..cb4be2f 100644
--- a/web/city.js
+++ b/web/city.js
@@ -73,12 +73,13 @@ export function layoutCity(files, name = '') {
const width = Math.sqrt(area * 1000 / 680), height = area / width;
const buildings = new Map(), blocks = [];
const visit = (node, x, y, w, h, depth) => {
+ let half = 0;
if (depth > 0) {
// Half a road on every side, so neighbouring blocks are a full road apart.
- const half = Math.min(CITY.roadWidths[Math.min(depth - 1, CITY.roadWidths.length - 1)] / 2, w * .2, h * .2);
+ half = Math.min(CITY.roadWidths[Math.min(depth - 1, CITY.roadWidths.length - 1)] / 2, w * .2, h * .2);
x += half; y += half; w -= half * 2; h -= half * 2;
}
- blocks.push({ node, depth, x, y, w, h });
+ blocks.push({ node, depth, x, y, w, h, road: half });
const kerb = Math.min(CITY.sidewalk, w * .15, h * .15);
const values = [...node.children.values(), ...node.files.map(file => ({ file, weight: Math.max(CITY.minLines, file.lines || 0) }))];
const total = values.reduce((sum, value) => sum + value.weight, 0), floor = total / Math.max(1, values.length * 8);
@@ -234,8 +235,8 @@ function wallQuad(wall, z, width, height) {
return { c: [wall.c[0] + wall.n[0] * offset, wall.c[1] + wall.n[1] * offset, z], u: [right[0] * width / 2, right[1] * width / 2, 0], v: [0, 0, height / 2] };
}
-// Name signs at first-floor height on the walls that face the viewer.
-export function wallSigns(b, eye, aspect, height = .9, z = 3.2) {
+// Name signs on the walls that face the viewer, a clear metre and more above the top of the door.
+export function wallSigns(b, eye, aspect, height = .9, z = DOOR.height + 1.6) {
const out = [];
for (const wall of walls(b)) {
if ((eye[0] - wall.c[0]) * wall.n[0] + (eye[1] - wall.c[1]) * wall.n[1] <= 0) continue;
@@ -244,7 +245,7 @@ export function wallSigns(b, eye, aspect, height = .9, z = 3.2) {
const width = Math.min(segment * .85, aspect * height);
for (let i = 0; i < count; i++) {
const offset = (i + .5) * segment - wall.width / 2;
- out.push(wallQuad({ ...wall, c: [wall.c[0] + along[0] * offset, wall.c[1] + along[1] * offset] }, Math.min(z, b.height * .5), width, width / aspect));
+ out.push(wallQuad({ ...wall, c: [wall.c[0] + along[0] * offset, wall.c[1] + along[1] * offset] }, Math.min(z, b.height - height), width, width / aspect));
}
}
return out;
@@ -889,14 +890,18 @@ export function streetFurniture(blocks, buildings, random = Math.random) {
// Boxes that draw a piece of furniture: { x, y, w, h, z, height, color, part } where part is
// 'solid', 'lamp' (emissive) or 'pool' (a pool of light on the pavement).
-export function furnitureBoxes(item) {
- const boxes = [], { x, y, out, along } = item;
- const box = (ax, ay, sx, sy, z, height, color, part = 'solid') => {
- // ax/ay are offsets along the edge and out towards the road; sx/sy sizes in those directions.
+// Box builder in a local frame at (x, y): ax/ay are offsets along `along` and `out` (axis-aligned
+// unit vectors), sx/sy sizes in those directions.
+function localBoxes(boxes, x, y, along, out) {
+ return (ax, ay, sx, sy, z, height, color, part = 'solid') => {
const cx = x + along[0] * ax + out[0] * ay, cy = y + along[1] * ax + out[1] * ay;
const w = Math.abs(along[0]) * sx + Math.abs(out[0]) * sy, h = Math.abs(along[1]) * sx + Math.abs(out[1]) * sy;
boxes.push({ x: cx - w / 2, y: cy - h / 2, w, h, z, height, color, part });
};
+}
+
+export function furnitureBoxes(item) {
+ const boxes = [], box = localBoxes(boxes, item.x, item.y, item.along, item.out);
switch (item.type) {
case 'light':
box(0, 0, .18, .18, 0, 5.6, [.18, .18, .2]);
@@ -930,3 +935,71 @@ export function furnitureBoxes(item) {
}
return boxes;
}
+
+// ---- Traffic ----
+
+// Cars circle the blocks inside a folder, on its grey streets: one lane per block, half-way into the
+// road around it, driving with the kerb on their right. Top-level districts border the black ground
+// and get no traffic. Neighbouring blocks are a full road apart, so no two loops ever meet.
+export const TRAFFIC = { minRoad: 2.5, spacing: 55, length: 4.2, width: 1.8 };
+export function trafficLoops(blocks) {
+ return blocks.filter(b => b.depth > 1 && b.road >= TRAFFIC.minRoad).map(b => {
+ const o = b.road / 2, x0 = b.x - o, y0 = b.y - o, x1 = b.x + b.w + o, y1 = b.y + b.h + o;
+ // Clockwise, matching the furniture edges: right of travel (-dy, dx) points into the block.
+ const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
+ return { corners, length: 2 * (x1 - x0 + y1 - y0) };
+ });
+}
+
+// Cars on one loop share a speed and start evenly spaced, so they never catch each other up.
+export function spawnCars(loops, random = Math.random) {
+ const cars = [];
+ for (const loop of loops) {
+ const count = Math.floor(loop.length / TRAFFIC.spacing + random());
+ if (!count) continue;
+ const speed = 6 + random() * 6, start = random() * loop.length;
+ for (let i = 0; i < count; i++) cars.push({ loop, speed, s: start + i * loop.length / count, color: CAR_COLOURS[Math.floor(random() * CAR_COLOURS.length)] });
+ }
+ return cars;
+}
+const CAR_COLOURS = [[.75, .1, .1], [.1, .3, .7], [.85, .85, .8], [.12, .12, .14], [.9, .6, .1], [.2, .5, .35], [.5, .52, .55]];
+
+// Where a car is along its loop: position and axis-aligned travel direction.
+export function carPose(car) {
+ const { corners, length } = car.loop;
+ let s = ((car.s % length) + length) % length;
+ for (let i = 0; i < 4; i++) {
+ const a = corners[i], b = corners[(i + 1) % 4], edge = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
+ if (s <= edge || i === 3) {
+ const dir = [Math.sign(b[0] - a[0]), Math.sign(b[1] - a[1])], t = Math.min(s, edge);
+ return { x: a[0] + dir[0] * t, y: a[1] + dir[1] * t, dir };
+ }
+ s -= edge;
+ }
+}
+
+// Moves the traffic on. Cars stop for someone standing in the lane just ahead of them, and the rest
+// of that loop waits too, so nobody drives into the car in front.
+export function stepCars(cars, dt, eye = null) {
+ const waiting = new Set();
+ if (eye) for (const car of cars) {
+ const { x, y, dir } = carPose(car), ahead = (eye[0] - x) * dir[0] + (eye[1] - y) * dir[1];
+ if (ahead > 0 && ahead < TRAFFIC.length / 2 + 4 && Math.abs((eye[0] - x) * dir[1] - (eye[1] - y) * dir[0]) < TRAFFIC.width / 2 + .8) waiting.add(car.loop);
+ }
+ for (const car of cars) if (!waiting.has(car.loop)) car.s += car.speed * dt;
+}
+
+// Body, cabin, wheels, head and tail lights, and a pool of headlight on the road ahead.
+export function carBoxes(car) {
+ const { x, y, dir } = carPose(car), boxes = [], box = localBoxes(boxes, x, y, dir, [-dir[1], dir[0]]);
+ const { length: l, width: w } = TRAFFIC, dark = [.05, .05, .06];
+ for (const ax of [-l * .3, l * .3]) box(ax, 0, .7, w + .1, 0, .6, dark);
+ box(0, 0, l, w, .3, .75, car.color);
+ box(-l * .08, 0, l * .5, w * .88, 1.05, .6, car.color.map(c => c * .7));
+ for (const ay of [-w * .32, w * .32]) {
+ box(l / 2, ay, .08, .38, .65, .22, [1, .95, .8], 'lamp');
+ box(-l / 2, ay, .08, .38, .65, .2, [.9, .05, .04], 'lamp');
+ }
+ box(l / 2 + 4, 0, 7, 7, .42, .02, [.9, .88, .75], 'pool');
+ return boxes;
+}
diff --git a/web/renderer.js b/web/renderer.js
index 784d4ee..752b86e 100644
--- a/web/renderer.js
+++ b/web/renderer.js
@@ -481,7 +481,7 @@ export class LandscapeRenderer {
this.codeTexture = gl.createTexture(); this.codeOn = false;
this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
this.cube = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
- this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer(); this.cityFloor = this.boxLayer(); this.glowLayer = this.boxLayer(); this.propLayer = this.boxLayer();
+ this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer(); this.cityFloor = this.boxLayer(); this.glowLayer = this.boxLayer(); this.propLayer = this.boxLayer(); this.carLayer = this.boxLayer(); this.carGlow = this.boxLayer();
this.signProgram = program(gl, SIGN_VS, SIGN_FS); this.signUniforms = uniformsOf(gl, this.signProgram);
this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.posterLayer = this.quadLayer(); this.posterTexture = this.texture();
@@ -646,7 +646,7 @@ export class LandscapeRenderer {
return c.view === 'heli' ? Math.min(6, Math.max(1, c.distance / 250)) : c.view === 'fly' ? Math.min(4, Math.max(1, c.ez / 120)) : 1;
}
- get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0 || this.smokeLayer.count > 0); }
+ get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0 || this.carLayer.count > 0 || this.smokeLayer.count > 0); }
syncAtlas() {
const gl = this.gl, atlas = this.atlas; if (!atlas) return;
@@ -703,6 +703,13 @@ export class LandscapeRenderer {
this.upload(this.glowLayer, pack(boxes.filter(b => b.kind === KIND.pool)));
}
+ // Cars near the camera, uploaded every frame they move.
+ setCars(boxes) {
+ const pack = LandscapeRenderer.packBoxes;
+ this.upload(this.carLayer, pack(boxes.filter(b => b.kind !== KIND.pool)));
+ this.upload(this.carGlow, pack(boxes.filter(b => b.kind === KIND.pool)));
+ }
+
setCity(instances, bounds) {
const pack = LandscapeRenderer.packBoxes;
const floor = instances.filter(b => b.kind === KIND.ground || b.kind === KIND.block);
@@ -820,11 +827,17 @@ export class LandscapeRenderer {
gl.useProgram(this.propProgram); this.setCameraUniforms(p); gl.uniform1f(p.uAlpha, alpha);
gl.uniform1f(p.uFogDensity, fog); gl.uniform3f(p.uFogColor, ...fogColor);
};
- if (city && this.propLayer.count) { useProps(); gl.bindVertexArray(this.propLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.propLayer.count); gl.useProgram(this.cityProgram); }
+ const drawProps = (...layers) => {
+ if (!layers.some(l => l.count)) return;
+ useProps();
+ for (const l of layers) if (l.count) { gl.bindVertexArray(l.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, l.count); }
+ gl.useProgram(this.cityProgram);
+ };
+ if (city) drawProps(this.propLayer, this.carLayer);
if (this.mode === 'city') {
// Light effects add colour and never occlude: additive blending without depth writes.
gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE);
- if (this.glowLayer.count) { useProps(); gl.bindVertexArray(this.glowLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.glowLayer.count); gl.useProgram(this.cityProgram); }
+ drawProps(this.glowLayer, this.carGlow);
if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); }
if (this.smokeLayer.count) {
// Camera-facing particles: the view matrix rows are the camera's right and up axes.
diff --git a/webtest/infrastructure.test.mjs b/webtest/infrastructure.test.mjs
index 48d51eb..e580081 100644
--- a/webtest/infrastructure.test.mjs
+++ b/webtest/infrastructure.test.mjs
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { layoutCity, buildNavGrid, routesFrom, assignDoors, routeThroughDoors, ribbonVertices, streetFurniture, furnitureBoxes, walkable, DOOR, seededRandom } from '../web/city.js';
+import { layoutCity, buildNavGrid, routesFrom, assignDoors, routeThroughDoors, ribbonVertices, streetFurniture, furnitureBoxes, walkable, DOOR, seededRandom, trafficLoops, spawnCars, carBoxes, carPose, stepCars, wallSigns } from '../web/city.js';
const ROUTE_MARGIN = 2;
function city() {
@@ -87,3 +87,38 @@ test('street furniture stays on pavements along folder edges, clear of buildings
assert.ok(Math.abs(edge - .5) < 1e-6, `light on the folder boundary line (${edge})`);
}
});
+
+test('cars stay on the grey streets, clear of buildings, furniture and each other', () => {
+ const { model, buildings } = city();
+ const cars = spawnCars(trafficLoops(model.blocks), seededRandom(3));
+ assert.ok(cars.length > 20, `some traffic (${cars.length})`);
+ const solids = streetFurniture(model.blocks, buildings, seededRandom(1)).flatMap(furnitureBoxes).filter(p => p.part === 'solid' && p.z < 1);
+ const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
+ const districts = model.blocks.filter(b => b.depth === 1);
+ for (let step = 0; step < 40; step++) {
+ const bodies = cars.map(car => { car.s += 3.7; return carBoxes(car).find(p => p.part === 'solid' && p.z === .3); });
+ bodies.forEach((body, i) => {
+ assert.ok(!buildings.some(b => overlaps(body, b)), 'not in a building');
+ assert.ok(!solids.some(p => overlaps(body, p)), 'not in furniture');
+ assert.ok(districts.some(d => body.x >= d.x && body.y >= d.y && body.x + body.w <= d.x + d.w && body.y + body.h <= d.y + d.h), 'never on the black ground');
+ for (let j = i + 1; j < bodies.length; j++) assert.ok(!overlaps(body, bodies[j]), 'cars never collide');
+ });
+ }
+});
+
+test('wall name signs sit clear above the doors', () => {
+ const { buildings } = city();
+ for (const b of buildings) for (const q of wallSigns(b, [b.x - 50, b.y - 50], 5)) assert.ok(q.c[2] - q.v[2] >= DOOR.height + 1, `sign bottom ${q.c[2] - q.v[2]}`);
+});
+
+test('cars wait for someone standing in the lane ahead', () => {
+ const { model } = city();
+ const cars = spawnCars(trafficLoops(model.blocks), seededRandom(3)), car = cars[0], { x, y, dir } = carPose(car);
+ const blocked = cars.filter(c => c.loop === car.loop), others = cars.find(c => c.loop !== car.loop);
+ const before = blocked.map(c => c.s), other = others.s;
+ stepCars(cars, 1, [x + dir[0] * 4, y + dir[1] * 4]);
+ assert.deepEqual(blocked.map(c => c.s), before);
+ assert.ok(others.s > other, 'other loops keep moving');
+ stepCars(cars, 1, [x - dir[0] * 4, y - dir[1] * 4]);
+ assert.ok(car.s > before[0], 'drives on once the way is clear');
+});