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'); +});