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:
+21
-5
@@ -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, 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 { LabelAtlas } from './labels.js';
|
||||||
import { apiFetch, progressEvents } from './api.mjs';
|
import { apiFetch, progressEvents } from './api.mjs';
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ let searchHits=[];
|
|||||||
let expandedCoverageLayer=null;
|
let expandedCoverageLayer=null;
|
||||||
let coverageActiveIndex=-1;
|
let coverageActiveIndex=-1;
|
||||||
let cameraAnimation=null;
|
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 currentSnapshot=null;
|
||||||
let currentName='';
|
let currentName='';
|
||||||
let currentMetric='lines';
|
let currentMetric='lines';
|
||||||
@@ -129,6 +129,7 @@ function computeCity(){
|
|||||||
}
|
}
|
||||||
cityIndex=spatialIndex(cityEntries);
|
cityIndex=spatialIndex(cityEntries);
|
||||||
navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel);
|
navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel);
|
||||||
|
spawnResidents();
|
||||||
// Each folder's own buildings (not its subfolders'), for street-corner signs.
|
// Each folder's own buildings (not its subfolders'), for street-corner signs.
|
||||||
const byDirectory=new Map();
|
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]);}
|
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;
|
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.
|
// Search hits become light columns visible across the city.
|
||||||
function updateBeacons(){
|
function updateBeacons(){
|
||||||
if(!cityModel){renderer.setBeacons([]);return;}
|
if(!cityModel){renderer.setBeacons([]);return;}
|
||||||
@@ -996,6 +1010,7 @@ function animate(now){
|
|||||||
const frameSeconds=Math.min(.1,(now-lastFrame)/1000);
|
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(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(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;
|
||||||
@@ -1088,7 +1103,7 @@ $('#paletteButton').addEventListener('click',()=>{paletteIndex=(paletteIndex+1)%
|
|||||||
const settingsDialog=$('#settingsDialog');
|
const settingsDialog=$('#settingsDialog');
|
||||||
$('#settingsButton').addEventListener('click',()=>{
|
$('#settingsButton').addEventListener('click',()=>{
|
||||||
$('#settingsContent').innerHTML=`<div class="settings-status"><span><i></i>Workspace preferences</span><small>${renderer.mode.toUpperCase()} VIEW</small></div>`;
|
$('#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();
|
if(!settingsDialog.open)settingsDialog.showModal();
|
||||||
});
|
});
|
||||||
settingsDialog.addEventListener('click',event=>{if(event.target===settingsDialog)settingsDialog.close();});
|
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.
|
// 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{}}
|
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;$('#fps').hidden=saved.fps!==true;}catch{$('#fps').hidden=true;}
|
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);
|
$('#codeButton').classList.toggle('active',showCode);
|
||||||
$('#paletteSelect').addEventListener('change',event=>{paletteIndex=Number(event.target.value);computeLayout();buildLegend();renderInspector();renderHistory();savePreferences();});
|
$('#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();});
|
$('#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();});
|
$('#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);
|
$('#paletteButton').addEventListener('click',savePreferences);$('#codeButton').addEventListener('click',savePreferences);
|
||||||
$('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;});
|
$('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;});
|
||||||
$('#fitButton').addEventListener('click',fitScene);
|
$('#fitButton').addEventListener('click',fitScene);
|
||||||
|
|||||||
+46
-1
@@ -439,7 +439,7 @@ class MinHeap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Straight line between two points stays on walkable cells (sampled at half-cell steps).
|
// 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));
|
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;
|
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;
|
return true;
|
||||||
@@ -508,3 +508,48 @@ export function routesFrom(grid, source, targets) {
|
|||||||
return simplifyRoute(grid, cells.reverse());
|
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; };
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
<div id="settingsContent"></div>
|
<div id="settingsContent"></div>
|
||||||
<div class="preference-row"><label for="paletteSelect">Landscape palette<small>Color-code the layers of your codebase</small></label><select id="paletteSelect"><option value="0">Botanical</option><option value="1">Prismatic</option><option value="2">Autumn</option></select></div>
|
<div class="preference-row"><label for="paletteSelect">Landscape palette<small>Color-code the layers of your codebase</small></label><select id="paletteSelect"><option value="0">Botanical</option><option value="1">Prismatic</option><option value="2">Autumn</option></select></div>
|
||||||
<div class="preference-row"><label for="sourceToggle">Source text<small>Show code directly in the landscape</small></label><input type="checkbox" class="switch" id="sourceToggle" checked></div>
|
<div class="preference-row"><label for="sourceToggle">Source text<small>Show code directly in the landscape</small></label><input type="checkbox" class="switch" id="sourceToggle" checked></div>
|
||||||
|
<div class="preference-row"><label for="residentsToggle">City residents<small>Little figures wandering the streets; keeps the city animating</small></label><input type="checkbox" class="switch" id="residentsToggle" checked></div>
|
||||||
<div class="preference-row"><label for="fpsToggle">Performance monitor<small>Display frame rate on the canvas</small></label><input type="checkbox" class="switch" id="fpsToggle"></div>
|
<div class="preference-row"><label for="fpsToggle">Performance monitor<small>Display frame rate on the canvas</small></label><input type="checkbox" class="switch" id="fpsToggle"></div>
|
||||||
<div class="shortcut-guide"><span>Keyboard essentials</span><div><kbd>/</kbd> Search <kbd>F</kbd> Fit landscape <kbd>Esc</kbd> Close</div></div>
|
<div class="shortcut-guide"><span>Keyboard essentials</span><div><kbd>/</kbd> Search <kbd>F</kbd> Fit landscape <kbd>Esc</kbd> Close</div></div>
|
||||||
<div class="settings-note">
|
<div class="settings-note">
|
||||||
|
|||||||
+71
-1
@@ -254,6 +254,56 @@ void main(){
|
|||||||
}`;
|
}`;
|
||||||
const ROUTE_FLOATS = 6;
|
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
|
const SIGN_FS = `#version 300 es
|
||||||
precision highp float;
|
precision highp float;
|
||||||
in vec2 vUv;
|
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); });
|
[[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.beaconLayer = this.boxLayer();
|
||||||
this.routeProgram = program(gl, ROUTE_VS, ROUTE_FS); this.routeUniforms = uniformsOf(gl, this.routeProgram);
|
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 };
|
this.routeLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
|
||||||
gl.bindVertexArray(this.routeLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer);
|
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); });
|
[[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;
|
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.
|
// beams: [{ x, y, w, h, height, color }] rising from search hits.
|
||||||
setBeacons(beams) {
|
setBeacons(beams) {
|
||||||
const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE);
|
const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE);
|
||||||
@@ -432,7 +493,7 @@ export class LandscapeRenderer {
|
|||||||
this.upload(this.beaconLayer, packed);
|
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() {
|
syncAtlas() {
|
||||||
const gl = this.gl, atlas = this.atlas; if (!atlas) return;
|
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.
|
// 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.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.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) {
|
if (this.routeLayer.count) {
|
||||||
const r = this.routeUniforms;
|
const r = this.routeUniforms;
|
||||||
gl.useProgram(this.routeProgram); gl.bindVertexArray(this.routeLayer.vao);
|
gl.useProgram(this.routeProgram); gl.bindVertexArray(this.routeLayer.vao);
|
||||||
|
|||||||
@@ -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');
|
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)); });
|
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());
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user