Wall the city in and paste posters on it

A 14 m concrete wall now encloses the city 24 m beyond its outermost
blocks. It is in the collision index (not the pick index), blocked on
the navigation grid so residents and routes stay inside, and walk/fly
positions are clamped within it so nobody leaves by flying over.

Posters are pasted on the city side at random spots, sizes and tilts
(up to 25 degrees), one per ~35 m, overlapping like a real hoarding.
They are painted into one atlas from civic slogans plus facts about the
loaded codebase (welcome, tallest tower, largest district, most
connected file), seeded by the repository name so each city's wall is
stable. The wall shader draws concrete courses and joints with a lit
coping, and the minimap outlines the wall. README covers routes,
residents, the wall and security alerts.
This commit is contained in:
Andy Dixon
2026-07-05 13:29:21 +01:00
parent a3f9ef2d9d
commit 630636b3af
6 changed files with 207 additions and 13 deletions
+43 -9
View File
@@ -1,6 +1,6 @@
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, MOVE } from './city.js';
import { LabelAtlas } from './labels.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, MOVE } from './city.js';
import { LabelAtlas, PosterAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs';
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
@@ -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=[],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 currentName='';
let currentMetric='lines';
@@ -128,7 +128,10 @@ function computeCity(){
instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)});
}
cityIndex=spatialIndex(cityEntries);
navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel);
// The wall keeps walkers, residents and routes inside; it's in the collision index but not the pick index.
cityWallBoxes=cityWalls(cityModel);
collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes]);
navGrid=buildNavGrid([...cityEntries,...cityWallBoxes],cityModel.blocks,cityModel);
updateFires();
spawnResidents();
// Each folder's own buildings (not its subfolders'), for street-corner signs.
@@ -137,6 +140,8 @@ function computeCity(){
cityFolders=cityModel.blocks.map(block=>({block,buildings:byDirectory.get(block.node.path)||[],label:folderLabel(block.node.path,currentName)})).filter(folder=>folder.buildings.length);
const tally=node=>{let files=node.files.length,lines=node.files.reduce((n,f)=>n+(f.lines||0),0);for(const child of node.children.values()){const t=tally(child);files+=t.files;lines+=t.lines;}return{files,lines};};
cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,...tally(block.node),top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))}));
for(const wall of cityWallBoxes)instances.push({...wall,color:[.55,.5,1],kind:KIND.wall});
pastePosters();
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
buildMinimap();updateCityHud();updateTrails();updateRoutes();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
}
@@ -171,11 +176,11 @@ function setCityView(view,at){
if(fromHeli)Object.assign(c,{ex:eye[0],ey:eye[1],ez:eye[2],lookYaw:Math.atan2(-forward[0],-forward[1]),lookPitch:Math.asin(Math.max(-1,Math.min(1,forward[2])))});
// With routes showing, walking starts at the selected building's door facing down the first route.
const route=!at&&view==='walk'&&cityRoutes[0]?.points.length>1?cityRoutes[0].points:null;
const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,cityIndex);
const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex);
const facing=route?Math.atan2(-(route[1][0]-route[0][0]),-(route[1][1]-route[0][1])):null;
animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:facing??(view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw),lookPitch:view==='walk'?(route?-.18:0):-.3},c,1100);
}else if(view==='walk'){
const spot=collide(c.ex,c.ey,MOVE.radius*3,cityIndex);
const spot=collide(c.ex,c.ey,MOVE.radius*3,collisionIndex);
animateCameraTo({ex:spot.x,ey:spot.y,ez:MOVE.eyeHeight,lookPitch:0},c,800);
}
}
@@ -200,7 +205,7 @@ function stepCity(seconds){
right:Math.max(-1,Math.min(1,down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft')+touchMove.right)),
up:down('Space')-down('KeyC'),
run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'),
},seconds,cityIndex,cityModel);
},seconds,collisionIndex,{width:cityModel.width,height:cityModel.height,edge:WALL.margin-MOVE.radius});
dirty=true;
}
@@ -359,12 +364,14 @@ function updateCityReadouts(){
const MINIMAP={width:220,height:150,pad:8};
function minimapTransform(){
const scale=Math.min((MINIMAP.width-MINIMAP.pad*2)/cityModel.width,(MINIMAP.height-MINIMAP.pad*2)/cityModel.height);
// Leave room for the city wall around the edge.
const scale=Math.min((MINIMAP.width-MINIMAP.pad*2)/(cityModel.width+WALL.margin*2),(MINIMAP.height-MINIMAP.pad*2)/(cityModel.height+WALL.margin*2));
return {scale,ox:(MINIMAP.width-cityModel.width*scale)/2,oy:(MINIMAP.height-cityModel.height*scale)/2};
}
function buildMinimap(){
const dpr=2,canvas=document.createElement('canvas');canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr;
const ctx=canvas.getContext('2d'),{scale,ox,oy}=minimapTransform();ctx.scale(dpr,dpr);
ctx.strokeStyle='rgba(141,125,255,.55)';ctx.lineWidth=1.5;ctx.strokeRect(ox-WALL.margin*scale,oy-WALL.margin*scale,(cityModel.width+WALL.margin*2)*scale,(cityModel.height+WALL.margin*2)*scale);
ctx.fillStyle='rgba(141,125,255,.08)';
for(const block of cityModel.blocks)if(block.depth===1)ctx.fillRect(ox+block.x*scale,oy+block.y*scale,block.w*scale,block.h*scale);
for(const item of layoutItems){const b=item.city;if(!b||!fileAlerts.has(item.path))continue;ctx.fillStyle='#ff4d3d';ctx.beginPath();ctx.arc(ox+(b.x+b.w/2)*scale,oy+(b.y+b.h/2)*scale,2.6,0,Math.PI*2);ctx.fill();}
@@ -393,7 +400,7 @@ $('#minimap').addEventListener('pointerdown',event=>{
const rect=event.currentTarget.getBoundingClientRect(),{scale,ox,oy}=minimapTransform();
const x=(event.clientX-rect.left-ox)/scale,y=(event.clientY-rect.top-oy)/scale,c=renderer.city;
if(c.view==='heli')animateCameraTo({x,y},c,700);
else{const spot=c.view==='walk'?collide(x,y,MOVE.radius*3,cityIndex):{x,y};animateCameraTo({ex:spot.x,ey:spot.y},c,700);}
else{const spot=c.view==='walk'?collide(x,y,MOVE.radius*3,collisionIndex):{x,y};animateCameraTo({ex:spot.x,ey:spot.y},c,700);}
});
function fitCity(){
@@ -781,6 +788,33 @@ function updateRoutes(){
renderer.setRoutes(cityRoutes);dirty=true;
}
// Posters on the city wall: civic slogans plus facts about this codebase, placed from a seed of
// the repository name so the same city always has the same wall.
function pastePosters(){
const byComplexity=[...files].sort((a,b)=>(b.complexity||0)-(a.complexity||0))[0];
const biggest=[...cityDistricts].sort((a,b)=>b.lines-a.lines)[0];
const connected=[...referenceDegree.entries()].sort((a,b)=>b[1]-a[1])[0];
const designs=[
{title:`Welcome to ${currentName}`,body:`${format(files.length)} files · ${format(sceneStats.totalLines)} lines of code`},
{title:'Refactor mercilessly',body:'Leave every file better than you found it.'},
{title:'Tests are love letters',body:'to whoever touches this code next.'},
{title:'YAGNI',body:"You aren't gonna need it. Probably."},
{title:'Delete dead code',body:'The best line is the one you remove.'},
{title:'Beware circular imports',body:'Report suspicious dependencies at the nearest minimap.'},
{title:'Read the docs',body:'Then write the docs you wished you had read.'},
{title:'Ship small',body:'Big-bang releases are loud for a reason.'},
{title:'Name things well',body:'There are only two hard problems.'},
{title:'Keep it boring',body:'Clever code is a 3 a.m. page waiting to happen.'},
{title:'Fires are not a vibe',body:'Security alerts are everyone’s job.'},
...(byComplexity?[{title:'Tallest tower',body:`${byComplexity.name}, complexity ${format(byComplexity.complexity)}`}]:[]),
...(biggest?[{title:`Visit ${biggest.block.node.name}`,body:`The largest district: ${format(biggest.files)} files`}]:[]),
...(connected?[{title:'Most connected',body:`${fileById.get(connected[0])?.name||'?'} · ${format(connected[1])} links`}]:[]),
];
let seed=0;for(const char of currentName)seed=(seed*31+char.charCodeAt(0))|0;
const atlas=new PosterAtlas(designs);
renderer.setPosters(atlas,posterQuads(cityWallBoxes,designs.length,seededRandom(seed)).map(quad=>({...quad,uv:atlas.uvs[quad.poster]})));
}
// 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):[];
+42 -2
View File
@@ -190,8 +190,10 @@ export function stepCamera(camera, input, dt, index, bounds) {
camera.ez = MOVE.eyeHeight;
}
if (camera.ez < 400) ({ x, y } = collide(x, y, MOVE.radius, { near: (px, py, r) => [...index.near(px, py, r)].filter(b => b.height + .5 > camera.ez - MOVE.eyeHeight) }));
camera.ex = Math.max(-150, Math.min(bounds.width + 150, x));
camera.ey = Math.max(-150, Math.min(bounds.height + 150, y));
// Never beyond the city wall, even when flying over it.
const edge = bounds.edge ?? 150;
camera.ex = Math.max(-edge, Math.min(bounds.width + edge, x));
camera.ey = Math.max(-edge, Math.min(bounds.height + edge, y));
return camera;
}
@@ -600,3 +602,41 @@ export function tapeQuads(b, aspect, direction = 1) {
}
return out;
}
// ---- The city wall and its posters ----
export const WALL = { margin: 24, height: 14, thickness: 2 };
// Four boxes enclosing the city `margin` metres beyond its edge; `inward` is the city-facing normal.
export function cityWalls(bounds, { margin, height, thickness } = WALL) {
const x0 = -margin - thickness, y0 = -margin - thickness, x1 = bounds.width + margin, y1 = bounds.height + margin, span = x1 + thickness - x0;
return [
{ x: x0, y: y0, w: span, h: thickness, height, inward: [0, 1] },
{ x: x0, y: y1, w: span, h: thickness, height, inward: [0, -1] },
{ x: x0, y: y0, w: thickness, h: y1 + thickness - y0, height, inward: [1, 0] },
{ x: x1, y: y0, w: thickness, h: y1 + thickness - y0, height, inward: [-1, 0] },
];
}
// Posters pasted on the city side of the walls at random spots, sizes and tilts (up to ±25°),
// one per ~35 m of wall. Each quad carries `poster`, an index into `designs`.
export function posterQuads(walls, designs, random = Math.random) {
const out = [];
for (const wall of walls) {
const [nx, ny] = wall.inward, length = nx ? wall.h : wall.w;
const along = [ny, -nx]; // left to right for someone facing the wall from inside
const face = [wall.x + wall.w / 2 + nx * wall.w / 2, wall.y + wall.h / 2 + ny * wall.h / 2];
for (let i = 0, count = Math.max(1, Math.round(length / 35)); i < count; i++) {
const height = 3 + random() * 2.5, width = height * .68, tilt = (random() - .5) * .87;
const offset = (random() - .5) * (length - 12), z = 2.2 + height / 2 + random() * (wall.height - height - 3.5);
const cos = Math.cos(tilt), sin = Math.sin(tilt), lift = .05 + i * .002; // later posters sit on top
out.push({
c: [face[0] + along[0] * offset + nx * lift, face[1] + along[1] * offset + ny * lift, z],
u: [along[0] * cos * width / 2, along[1] * cos * width / 2, sin * width / 2],
v: [-along[0] * sin * height / 2, -along[1] * sin * height / 2, cos * height / 2],
poster: Math.floor(random() * designs),
});
}
}
return out;
}
+60
View File
@@ -84,3 +84,63 @@ export class LabelAtlas {
return { x: d.x0, y: d.y0, image: this.ctx.getImageData(d.x0, d.y0, w, h) };
}
}
// Posters for the city wall, painted into one atlas. Each design is { title, body, theme }.
const POSTER_THEMES = [
{ paper: '#f2e6c9', ink: '#1d1d1f', accent: '#d7263d' },
{ paper: '#1b2a4a', ink: '#f5efe0', accent: '#f5c518' },
{ paper: '#d7263d', ink: '#fff6e8', accent: '#141414' },
{ paper: '#3ddc97', ink: '#0b1f17', accent: '#0b1f17' },
{ paper: '#8d7dff', ink: '#ffffff', accent: '#36d3c2' },
{ paper: '#f5c518', ink: '#141414', accent: '#141414' },
];
export class PosterAtlas {
constructor(designs, width = 256, height = 376, columns = 8) {
const rows = Math.ceil(designs.length / columns);
this.canvas = document.createElement('canvas');
this.canvas.width = width * columns; this.canvas.height = height * rows;
const ctx = this.canvas.getContext('2d');
this.uvs = designs.map((design, i) => {
const x = (i % columns) * width, y = Math.floor(i / columns) * height;
ctx.save(); ctx.translate(x, y); paintPoster(ctx, design, width, height, POSTER_THEMES[i % POSTER_THEMES.length], i); ctx.restore();
return { u: x / this.canvas.width, v: y / this.canvas.height, du: width / this.canvas.width, dv: height / this.canvas.height };
});
}
}
function wrap(ctx, text, maxWidth) {
const lines = []; let line = '';
for (const word of text.split(/\s+/)) {
const next = line ? `${line} ${word}` : word;
if (line && ctx.measureText(next).width > maxWidth) { lines.push(line); line = word; } else line = next;
}
if (line) lines.push(line);
return lines;
}
function paintPoster(ctx, { title, body }, w, h, theme, variant) {
const inset = 6;
ctx.fillStyle = theme.paper; ctx.fillRect(inset, inset, w - inset * 2, h - inset * 2);
// Weathering: faint speckles and a darker fold line.
ctx.fillStyle = 'rgba(0,0,0,.06)';
for (let i = 0; i < 70; i++) ctx.fillRect(inset + ((i * 73 + variant * 31) % (w - inset * 2)), inset + ((i * 137 + variant * 17) % (h - inset * 2)), 2, 2);
ctx.fillRect(inset, h * .52, w - inset * 2, 1.5);
// A bold graphic per variant.
ctx.fillStyle = theme.accent;
const shape = variant % 4;
if (shape === 0) { ctx.beginPath(); ctx.arc(w / 2, 104, 52, 0, Math.PI * 2); ctx.fill(); }
else if (shape === 1) { for (let i = 0; i < 6; i++) { ctx.beginPath(); ctx.moveTo(inset + i * 44, inset); ctx.lineTo(inset + i * 44 + 22, inset); ctx.lineTo(inset + i * 44 - 30, 150); ctx.lineTo(inset + i * 44 - 52, 150); ctx.fill(); } }
else if (shape === 2) { ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2 + 64, 160); ctx.lineTo(w / 2 - 64, 160); ctx.closePath(); ctx.fill(); ctx.fillStyle = theme.paper; ctx.font = '900 64px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('!', w / 2, 150); }
else { ctx.fillRect(inset, 40, w - inset * 2, 22); ctx.fillRect(inset, 84, w - inset * 2, 22); ctx.fillRect(inset, 128, w - inset * 2, 22); }
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = theme.ink;
ctx.font = '900 34px Inter, system-ui, sans-serif';
let y = 206;
for (const line of wrap(ctx, title.toUpperCase(), w - 36).slice(0, 3)) { ctx.fillText(line, w / 2, y, w - 30); y += 36; }
ctx.font = '600 17px Inter, system-ui, sans-serif';
y += 6;
for (const line of wrap(ctx, body, w - 44).slice(0, 4)) { ctx.fillText(line, w / 2, y, w - 30); y += 21; }
ctx.font = '700 10px Inter, system-ui, sans-serif'; ctx.globalAlpha = .7;
ctx.fillText('CODENAVIGATOR CIVIC AUTHORITY', w / 2, h - 18);
ctx.globalAlpha = 1;
}
+25 -2
View File
@@ -16,7 +16,7 @@ const VERTICES = new Float32Array([
]);
// Instance kinds in the box program.
export const KIND = { building: 0, block: 1, ground: 2, beacon: 3 };
export const KIND = { building: 0, block: 1, ground: 2, beacon: 3, wall: 4 };
const FLOATS_PER_INSTANCE = 16;
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
@@ -107,6 +107,18 @@ void main(){
return;
}
if(vKind.x == 2){ outColor = vec4(fog(vec3(.03, .036, .052)), uAlpha); return; } // asphalt
if(vKind.x == 4){
// City wall: concrete courses and panel joints in metres, lit coping along the top.
vec2 faceUv = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz;
float along = (vFace < 3 ? vUnit.x * vSize.x : vUnit.y * vSize.y), up = vUnit.z * vSize.z;
float joints = max(step(.97, fract(along / 6.0)), step(.95, fract(up / 2.8)));
vec3 concrete = vec3(.12, .13, .16) * (.65 + .35 * vUnit.z) * (1.0 - joints * .35);
if(vFace == 0) concrete = vec3(.2, .21, .26);
float edgeWall = min(min(faceUv.x, 1.0 - faceUv.x), min(faceUv.y, 1.0 - faceUv.y));
float rim = 1.0 - smoothstep(0.0, fwidth(edgeWall) * 1.5 + .002, edgeWall);
outColor = vec4(fog(mix(concrete, vec3(.55, .5, 1.0) * .8, rim * step(.9, vUnit.z + (vFace == 0 ? 1.0 : 0.0)))), uAlpha);
return;
}
#endif
if(uMode < .5 && vFace > 0) discard;
vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz;
@@ -418,7 +430,7 @@ export class LandscapeRenderer {
this.landscape = this.boxLayer(); this.cityLayer = 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.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.posterLayer = this.quadLayer(); this.posterTexture = this.texture();
this.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1;
this.trailProgram = program(gl, TRAIL_VS, TRAIL_FS); this.trailUniforms = uniformsOf(gl, this.trailProgram);
this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
@@ -487,6 +499,16 @@ export class LandscapeRenderer {
this.signLayer.count = quads.length;
}
// Posters on the city wall: a PosterAtlas and quads with uv rects; uploaded once per city.
setPosters(atlas, quads) {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.posterTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.canvas); gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.bindBuffer(gl.ARRAY_BUFFER, this.posterLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, LandscapeRenderer.packQuads(quads), gl.STATIC_DRAW);
this.posterLayer.count = quads.length;
}
// Source code on one wall: a canvas and its quad, or null to hide.
setFacade(canvas, quad) {
const gl = this.gl;
@@ -763,6 +785,7 @@ export class LandscapeRenderer {
this.syncAtlas();
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false);
gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-2, -8);
this.drawQuads(this.posterLayer, this.posterTexture);
this.drawQuads(this.facadeLayer, this.facadeTexture);
this.drawQuads(this.signLayer, this.atlasTexture);
gl.disable(gl.POLYGON_OFFSET_FILL); gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);