From 9bc5ba483cf3175220df6aaab492bf1398d6dedc Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 20 Jun 2026 10:52:58 +0100 Subject: [PATCH] Add wandering residents to the city MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Little glowing green stick figures, in the spirit of Darwinia, stroll the streets: each walks toward a random reachable point in plain sight on the navigation grid, pauses, and about a third of the time heads toward a nearby resident instead, so they gather in small crowds. They are camera-facing sprites drawn procedurally in the fragment shader with a walk cycle, flicker and halo, grown with altitude so crowds stay visible from the helicopter, and fogged like everything else. About one resident per 2,500 m² of city (40 to 600), spawned only on the city's own streets. Because they keep the city animating, Settings has a City residents switch (saved with the other preferences). The simulation is seedable and tested to keep everyone on walkable ground. --- web/app.js | 26 +++++++++++++--- web/city.js | 47 ++++++++++++++++++++++++++++- web/index.html | 1 + web/renderer.js | 72 +++++++++++++++++++++++++++++++++++++++++++- webtest/nav.test.mjs | 23 ++++++++++++++ 5 files changed, 162 insertions(+), 7 deletions(-) diff --git a/web/app.js b/web/app.js index daf5c0e..0af25dc 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, 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, MOVE } from './city.js'; import { LabelAtlas } from './labels.js'; import { apiFetch, progressEvents } from './api.mjs'; @@ -46,7 +46,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; -let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[]; +let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true; let currentSnapshot=null; let currentName=''; let currentMetric='lines'; @@ -129,6 +129,7 @@ function computeCity(){ } cityIndex=spatialIndex(cityEntries); navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel); + spawnResidents(); // Each folder's own buildings (not its subfolders'), for street-corner signs. const byDirectory=new Map(); for(const b of cityEntries){const list=byDirectory.get(b.file.directory||'');if(list)list.push(b);else byDirectory.set(b.file.directory||'',[b]);} @@ -778,6 +779,19 @@ function updateRoutes(){ renderer.setRoutes(cityRoutes);dirty=true; } +// Residents: roughly one per 2,500 m² of city, capped so large repos stay cheap to simulate. +function spawnResidents(){ + wanderers=showResidents&&navGrid?spawnWanderers(navGrid,Math.max(40,Math.min(600,Math.round(cityModel.width*cityModel.height/2500))),Math.random,cityModel):[]; + if(!wanderers.length)renderer.setWanderers(new Float32Array(0)); +} +let residentBuffer=new Float32Array(0); +function updateResidents(seconds){ + stepWanderers(wanderers,navGrid,seconds); + if(residentBuffer.length!==wanderers.length*4)residentBuffer=new Float32Array(wanderers.length*4); + wanderers.forEach((w,i)=>residentBuffer.set([w.x,w.y,w.phase,w.seed],i*4)); + renderer.setWanderers(residentBuffer); +} + // Search hits become light columns visible across the city. function updateBeacons(){ if(!cityModel){renderer.setBeacons([]);return;} @@ -996,6 +1010,7 @@ function animate(now){ const frameSeconds=Math.min(.1,(now-lastFrame)/1000); 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(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; @@ -1088,7 +1103,7 @@ $('#paletteButton').addEventListener('click',()=>{paletteIndex=(paletteIndex+1)% const settingsDialog=$('#settingsDialog'); $('#settingsButton').addEventListener('click',()=>{ $('#settingsContent').innerHTML=`
Workspace preferences${renderer.mode.toUpperCase()} VIEW
`; - $('#paletteSelect').value=String(paletteIndex);$('#sourceToggle').checked=showCode;$('#fpsToggle').checked=!$('#fps').hidden; + $('#paletteSelect').value=String(paletteIndex);$('#sourceToggle').checked=showCode;$('#fpsToggle').checked=!$('#fps').hidden;$('#residentsToggle').checked=showResidents; if(!settingsDialog.open)settingsDialog.showModal(); }); settingsDialog.addEventListener('click',event=>{if(event.target===settingsDialog)settingsDialog.close();}); @@ -1217,12 +1232,13 @@ async function checkBackend(){ } // Preferences stay on this device and never contain repository or server details. -function savePreferences(){try{localStorage.setItem('codenavigator.preferences',JSON.stringify({palette:paletteIndex,source:showCode,fps:!$('#fps').hidden}));}catch{}} -try{const saved=JSON.parse(localStorage.getItem('codenavigator.preferences')||'{}');if(Number.isInteger(saved.palette)&&saved.palette>=0&&saved.palette=0&&saved.palette{paletteIndex=Number(event.target.value);computeLayout();buildLegend();renderInspector();renderHistory();savePreferences();}); $('#sourceToggle').addEventListener('change',event=>{showCode=event.target.checked;$('#codeButton').classList.toggle('active',showCode);dirty=true;savePreferences();}); $('#fpsToggle').addEventListener('change',event=>{$('#fps').hidden=!event.target.checked;savePreferences();}); +$('#residentsToggle').addEventListener('change',event=>{showResidents=event.target.checked;if(cityModel)spawnResidents();dirty=true;savePreferences();}); $('#paletteButton').addEventListener('click',savePreferences);$('#codeButton').addEventListener('click',savePreferences); $('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;}); $('#fitButton').addEventListener('click',fitScene); diff --git a/web/city.js b/web/city.js index e9ba93c..669666b 100644 --- a/web/city.js +++ b/web/city.js @@ -439,7 +439,7 @@ class MinHeap { } // Straight line between two points stays on walkable cells (sampled at half-cell steps). -function clearLine(grid, a, b) { +export function clearLine(grid, a, b) { const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / (grid.cell * .5)); for (let s = 1; s < steps; s++) if (!walkable(grid, a[0] + (b[0] - a[0]) * s / steps, a[1] + (b[1] - a[1]) * s / steps)) return false; return true; @@ -508,3 +508,48 @@ export function routesFrom(grid, source, targets) { return simplifyRoute(grid, cells.reverse()); }); } + +// Wandering residents: little figures that stroll between points they can see, pause, and often +// drift toward one another so they gather in small crowds. +export function spawnWanderers(grid, count, random = Math.random, bounds = null) { + const out = []; + for (let tries = 0; out.length < count && tries < count * 50; tries++) { + const i = Math.floor(random() * grid.cols * grid.rows); + if (grid.blocked[i]) continue; + const [x, y] = cellCenter(grid, i); + if (bounds && (x < 0 || y < 0 || x > bounds.width || y > bounds.height)) continue; // streets, not the empty margin + out.push({ x, y, tx: x, ty: y, speed: .9 + random() * .9, phase: random() * 6.28, pause: random() * 3, seed: random() }); + } + return out; +} + +function chooseTarget(w, all, grid, random) { + for (let attempt = 0; attempt < 6; attempt++) { + let tx, ty; + const friend = random() < .35 ? all[Math.floor(random() * all.length)] : null; + if (friend && friend !== w && Math.hypot(friend.x - w.x, friend.y - w.y) < 45) { + tx = friend.x + (random() - .5) * 4; ty = friend.y + (random() - .5) * 4; + } else { + const angle = random() * Math.PI * 2, distance = 6 + random() * 34; + tx = w.x + Math.cos(angle) * distance; ty = w.y + Math.sin(angle) * distance; + } + if (walkable(grid, tx, ty) && clearLine(grid, [w.x, w.y], [tx, ty])) { w.tx = tx; w.ty = ty; return; } + } + w.pause = .5 + random() * 1.5; // boxed in for now; look around and try again +} + +export function stepWanderers(all, grid, dt, random = Math.random) { + for (const w of all) { + if (w.pause > 0) { w.pause -= dt; if (w.pause <= 0) chooseTarget(w, all, grid, random); continue; } + const dx = w.tx - w.x, dy = w.ty - w.y, distance = Math.hypot(dx, dy), step = Math.min(distance, w.speed * dt); + if (distance < .3) { w.pause = random() < .5 ? random() * 3 : 0; if (w.pause <= 0) chooseTarget(w, all, grid, random); continue; } + const nx = w.x + dx / distance * step, ny = w.y + dy / distance * step; + if (!walkable(grid, nx, ny)) { chooseTarget(w, all, grid, random); continue; } + w.x = nx; w.y = ny; w.phase += step * 3.2; // stride cycles per metre + } +} + +// Deterministic generator for tests and reproducible crowds. +export function seededRandom(seed) { + return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; +} diff --git a/web/index.html b/web/index.html index d68eeb7..6702324 100644 --- a/web/index.html +++ b/web/index.html @@ -161,6 +161,7 @@
+
Keyboard essentials
/ Search F Fit landscape Esc Close
diff --git a/web/renderer.js b/web/renderer.js index c2eed33..5103c0c 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -254,6 +254,56 @@ void main(){ }`; const ROUTE_FLOATS = 6; +// Residents: camera-facing sprites drawn procedurally as glowing stick figures with a walk cycle. +const WANDERER_VS = `#version 300 es +precision highp float; +layout(location=0) in vec2 aCorner; +layout(location=1) in vec4 aPosPhaseSeed; +uniform mat4 uViewProj; +uniform vec2 uRight; +uniform float uScale; +out vec2 vLocal; +out float vPhase; +out float vDepth; +flat out float vSeed; +const vec2 SIZE = vec2(.5, 1.35); +void main(){ + vLocal = vec2(aCorner.x * 2.0 - 1.0, aCorner.y) * SIZE; + vPhase = aPosPhaseSeed.z; vSeed = aPosPhaseSeed.w; + vec3 world = vec3(aPosPhaseSeed.xy + uRight * vLocal.x * uScale, vLocal.y * uScale); + gl_Position = uViewProj * vec4(world, 1.0); + vDepth = gl_Position.w; +}`; + +const WANDERER_FS = `#version 300 es +precision highp float; +in vec2 vLocal; +in float vPhase; +in float vDepth; +flat in float vSeed; +uniform float uTime; +uniform float uFogDensity; +out vec4 outColor; +float segment(vec2 p, vec2 a, vec2 b){ vec2 pa = p - a, ba = b - a; return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0)); } +void main(){ + vec2 p = vLocal; + float swing = sin(vPhase), bob = abs(cos(vPhase)) * .03; + vec2 hip = vec2(0.0, .5 + bob), neck = vec2(0.0, .92 + bob); + float d = length(p - vec2(0.0, 1.08 + bob)) - .1; // head + d = min(d, segment(p, hip, neck) - .045); // body + d = min(d, segment(p, neck - vec2(0.0, .06), vec2(-.26, .66 + swing * .1 + bob)) - .03); // arms + d = min(d, segment(p, neck - vec2(0.0, .06), vec2(.26, .66 - swing * .1 + bob)) - .03); + d = min(d, segment(p, hip, vec2(-.16 * swing - .04, 0.0)) - .035); // legs + d = min(d, segment(p, hip, vec2(.16 * swing + .04, 0.0)) - .035); + float aa = fwidth(d) + .002; + float body = 1.0 - smoothstep(0.0, aa, d), halo = exp(-max(d, 0.0) * 18.0) * .35; + float flicker = .85 + .15 * sin(uTime * 7.0 + vSeed * 40.0); + vec3 green = vec3(.32, 1.0, .38) * flicker; + float fog = exp(-pow(vDepth * uFogDensity, 2.0)); + if(body + halo < .01) discard; + outColor = vec4(green * (body + halo) * fog, 0.0); +}`; + const SIGN_FS = `#version 300 es precision highp float; in vec2 vUv; @@ -320,6 +370,11 @@ export class LandscapeRenderer { [[0, 0], [1, 12], [2, 24]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, TRAIL_FLOATS * 4, offset); }); this.beaconLayer = this.boxLayer(); this.routeProgram = program(gl, ROUTE_VS, ROUTE_FS); this.routeUniforms = uniformsOf(gl, this.routeProgram); + this.wandererProgram = program(gl, WANDERER_VS, WANDERER_FS); this.wandererUniforms = uniformsOf(gl, this.wandererProgram); + this.wandererLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; + gl.bindVertexArray(this.wandererLayer.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, this.wandererLayer.buffer); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 16, 0); gl.vertexAttribDivisor(1, 1); this.routeLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; gl.bindVertexArray(this.routeLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer); [[0, 0], [1, 12]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, ROUTE_FLOATS * 4, offset); }); @@ -425,6 +480,12 @@ export class LandscapeRenderer { this.routeLayer.count = vertices.length / ROUTE_FLOATS; } + // Resident positions as packed [x, y, phase, seed] per figure; called every frame while they walk. + setWanderers(packed) { + const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.wandererLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW); + this.wandererLayer.count = packed.length / 4; + } + // beams: [{ x, y, w, h, height, color }] rising from search hits. setBeacons(beams) { const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE); @@ -432,7 +493,7 @@ export class LandscapeRenderer { this.upload(this.beaconLayer, packed); } - get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0); } + get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0); } syncAtlas() { const gl = this.gl, atlas = this.atlas; if (!atlas) return; @@ -581,6 +642,15 @@ export class LandscapeRenderer { // Light effects add colour and never occlude: additive blending without depth writes. gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE); if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); } + if (this.wandererLayer.count) { + const w = this.wandererUniforms, f = this.forward, len = Math.hypot(f[0], f[1]) || 1, c = this.city; + gl.useProgram(this.wandererProgram); gl.bindVertexArray(this.wandererLayer.vao); + gl.uniformMatrix4fv(w.uViewProj, false, this.viewProj); gl.uniform2f(w.uRight, -f[1] / len, f[0] / len); + // From the air they'd be sub-pixel; grow them so crowds read as drifting green specks. + gl.uniform1f(w.uScale, 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); + gl.uniform1f(w.uTime, performance.now() / 1000); gl.uniform1f(w.uFogDensity, fog); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this.wandererLayer.count); + } if (this.routeLayer.count) { const r = this.routeUniforms; gl.useProgram(this.routeProgram); gl.bindVertexArray(this.routeLayer.vao); diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs index 392cc73..4d0cf2e 100644 --- a/webtest/nav.test.mjs +++ b/webtest/nav.test.mjs @@ -57,3 +57,26 @@ test('routing a large generated city stays fast (guards against queue flooding)' assert.ok(routes.every(Boolean), 'every building in a connected city is reachable'); routes.forEach(route => { for (const [x, y] of route) assert.ok(walkable(grid, x, y)); }); }); + +import { spawnWanderers, stepWanderers, seededRandom } from '../web/city.js'; + +test('wanderers spawn and stay on walkable ground while they roam', () => { + const grid = buildNavGrid([a, b, wall], [], bounds), random = seededRandom(7); + const crowd = spawnWanderers(grid, 60, random); + assert.equal(crowd.length, 60); + const start = crowd.map(w => [w.x, w.y]); + for (let frame = 0; frame < 900; frame++) { + stepWanderers(crowd, grid, 1 / 30, random); + if (frame % 30 === 0) for (const w of crowd) assert.ok(walkable(grid, w.x, w.y), `walker at ${w.x},${w.y}`); + } + const moved = crowd.filter((w, i) => Math.hypot(w.x - start[i][0], w.y - start[i][1]) > 3).length; + assert.ok(moved > 40, `most walkers wandered off (${moved})`); + assert.ok(crowd.every(w => Number.isFinite(w.phase))); + assert.ok(spawnWanderers(grid, 30, random, { width: 20, height: 20 }).every(w => w.x >= 0 && w.x <= 20 && w.y >= 0 && w.y <= 20), 'spawns inside the city bounds'); +}); + +test('a seeded crowd is reproducible', () => { + const grid = buildNavGrid([a], [], bounds); + const run = () => { const r = seededRandom(3), c = spawnWanderers(grid, 10, r); for (let i = 0; i < 100; i++) stepWanderers(c, grid, .05, r); return c.map(w => [w.x.toFixed(3), w.y.toFixed(3)]); }; + assert.deepEqual(run(), run()); +});