Traffic in the city, name signs above the doors, no stuck hover tooltip

Cars loop the blocks inside each folder on its grey streets, one lane per block
so loops never meet, and wait for anyone standing in the lane. Wall name signs
now sit over a metre above the top of the door. The hover tooltip hides when
the camera, view or mode changes or mouse-look starts, instead of staying up.
This commit is contained in:
Andy Dixon
2026-09-13 12:28:29 +01:00
parent 29271dead3
commit e2cffd4a72
4 changed files with 167 additions and 20 deletions
+32 -6
View File
@@ -1,5 +1,5 @@
import { LandscapeRenderer, KIND } from './renderer.js'; 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 { LabelAtlas, PosterAtlas } from './labels.js';
import { personFor, residentSeed, drawPortrait } from './people.js'; import { personFor, residentSeed, drawPortrait } from './people.js';
import { apiFetch, progressEvents } from './api.mjs'; import { apiFetch, progressEvents } from './api.mjs';
@@ -47,7 +47,7 @@ let searchHits=[];
let expandedCoverageLayer=null; let expandedCoverageLayer=null;
let coverageActiveIndex=-1; let coverageActiveIndex=-1;
let cameraAnimation=null; 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 cityModel=null,cityEntries=[],cityIndex=null,collisionIndex=null,cityWallBoxes=[],cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true;
let currentSnapshot=null; let currentSnapshot=null;
let currentName=''; 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}))}))]; 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}); 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=''; propState.key='';
cars=spawnCars(trafficLoops(cityModel.blocks),seededRandom(furnitureSeed^0x5bd1e995));
collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes,...obstacles]); collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes,...obstacles]);
updateFires(); updateFires();
spawnResidents(); 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 // 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. // thousands of small boxes are costly to draw everywhere at once.
function updateProps(){ function propRange(){
const c=renderer.city,heli=c.view==='heli'; 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; 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]};
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'; }
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; if(key===propState.key)return;
propState.key=key; propState.key=key;
const boxes=[]; const boxes=[];
@@ -990,6 +993,19 @@ function updateResidents(seconds){
renderer.setWanderers(residentBuffer); 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 ---- // ---- Security alerts from GitHub: burning buildings, hazard tape and inspector details ----
let fileAlerts=new Map(),alertState={loading:false,reason:'',statuses:null}; let fileAlerts=new Map(),alertState={loading:false,reason:'',statuses:null};
async function loadAlerts(){ 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(renderer.mode==='city'&&renderer.city.view!=='heli'&&!cameraAnimation&&(heldKeys.size||touchMove.forward||touchMove.right))stepCity(frameSeconds);
if(tour)advanceTour(now,frameSeconds); if(tour)advanceTour(now,frameSeconds);
if(renderer.mode==='city'&&wanderers.length)updateResidents(frameSeconds); if(renderer.mode==='city'&&wanderers.length)updateResidents(frameSeconds);
if(renderer.mode==='city')updateCars(frameSeconds);
if(cameraAnimation){ if(cameraAnimation){
const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4; 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; 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(progress>=1)cameraAnimation=null;dirty=true;
} }
if(renderer.animating)dirty=true; if(renderer.animating)dirty=true;
hideStaleTooltip();
if(dirty){ if(dirty){
// The overlay fills the code overview, so draw it before the GPU pass that samples it. // 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; 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));} 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; 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)}<small>${escapeHtml(item.path)} · ${format(item.lines)} lines</small>`;}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)}<small>${escapeHtml(item.path)} · ${format(item.lines)} lines</small>`;}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){ function finishPointer(event){
if(event.pointerType==='touch')endTouch(event); if(event.pointerType==='touch')endTouch(event);
if(!pointer.down||event.pointerId!==pointer.id)return; if(!pointer.down||event.pointerId!==pointer.id)return;
+82 -9
View File
@@ -73,12 +73,13 @@ export function layoutCity(files, name = '') {
const width = Math.sqrt(area * 1000 / 680), height = area / width; const width = Math.sqrt(area * 1000 / 680), height = area / width;
const buildings = new Map(), blocks = []; const buildings = new Map(), blocks = [];
const visit = (node, x, y, w, h, depth) => { const visit = (node, x, y, w, h, depth) => {
let half = 0;
if (depth > 0) { if (depth > 0) {
// Half a road on every side, so neighbouring blocks are a full road apart. // 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; 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 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 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); 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] }; 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. // 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 = 3.2) { export function wallSigns(b, eye, aspect, height = .9, z = DOOR.height + 1.6) {
const out = []; const out = [];
for (const wall of walls(b)) { 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; 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); const width = Math.min(segment * .85, aspect * height);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const offset = (i + .5) * segment - wall.width / 2; 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; 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 // 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). // 'solid', 'lamp' (emissive) or 'pool' (a pool of light on the pavement).
export function furnitureBoxes(item) { // Box builder in a local frame at (x, y): ax/ay are offsets along `along` and `out` (axis-aligned
const boxes = [], { x, y, out, along } = item; // unit vectors), sx/sy sizes in those directions.
const box = (ax, ay, sx, sy, z, height, color, part = 'solid') => { function localBoxes(boxes, x, y, along, out) {
// ax/ay are offsets along the edge and out towards the road; sx/sy sizes in those directions. 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 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; 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 }); 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) { switch (item.type) {
case 'light': case 'light':
box(0, 0, .18, .18, 0, 5.6, [.18, .18, .2]); box(0, 0, .18, .18, 0, 5.6, [.18, .18, .2]);
@@ -930,3 +935,71 @@ export function furnitureBoxes(item) {
} }
return boxes; 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;
}
+17 -4
View File
@@ -481,7 +481,7 @@ export class LandscapeRenderer {
this.codeTexture = gl.createTexture(); this.codeOn = false; this.codeTexture = gl.createTexture(); this.codeOn = false;
this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic'); 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.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.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.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(); 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; 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() { syncAtlas() {
const gl = this.gl, atlas = this.atlas; if (!atlas) return; 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))); 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) { setCity(instances, bounds) {
const pack = LandscapeRenderer.packBoxes; const pack = LandscapeRenderer.packBoxes;
const floor = instances.filter(b => b.kind === KIND.ground || b.kind === KIND.block); 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.useProgram(this.propProgram); this.setCameraUniforms(p); gl.uniform1f(p.uAlpha, alpha);
gl.uniform1f(p.uFogDensity, fog); gl.uniform3f(p.uFogColor, ...fogColor); 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') { if (this.mode === 'city') {
// Light effects add colour and never occlude: additive blending without depth writes. // Light effects add colour and never occlude: additive blending without depth writes.
gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE); 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.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); }
if (this.smokeLayer.count) { if (this.smokeLayer.count) {
// Camera-facing particles: the view matrix rows are the camera's right and up axes. // Camera-facing particles: the view matrix rows are the camera's right and up axes.
+36 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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; const ROUTE_MARGIN = 2;
function city() { 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})`); 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');
});