Add wandering residents to the city

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.
This commit is contained in:
Andy Dixon
2026-06-20 10:52:58 +01:00
parent 33db29defd
commit 9bc5ba483c
5 changed files with 162 additions and 7 deletions
+21 -5
View File
@@ -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=`<div class="settings-status"><span><i></i>Workspace preferences</span><small>${renderer.mode.toUpperCase()} VIEW</small></div>`;
$('#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<palettes.length)paletteIndex=saved.palette;if(typeof saved.source==='boolean')showCode=saved.source;$('#fps').hidden=saved.fps!==true;}catch{$('#fps').hidden=true;}
function savePreferences(){try{localStorage.setItem('codenavigator.preferences',JSON.stringify({palette:paletteIndex,source:showCode,fps:!$('#fps').hidden,residents:showResidents}));}catch{}}
try{const saved=JSON.parse(localStorage.getItem('codenavigator.preferences')||'{}');if(Number.isInteger(saved.palette)&&saved.palette>=0&&saved.palette<palettes.length)paletteIndex=saved.palette;if(typeof saved.source==='boolean')showCode=saved.source;if(typeof saved.residents==='boolean')showResidents=saved.residents;$('#fps').hidden=saved.fps!==true;}catch{$('#fps').hidden=true;}
$('#codeButton').classList.toggle('active',showCode);
$('#paletteSelect').addEventListener('change',event=>{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);