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
+5
View File
@@ -34,6 +34,11 @@ Configuration:
- `T` or **Tour** flies over the largest districts. **Copy link** shares the repository, view, - `T` or **Tour** flies over the largest districts. **Copy link** shares the repository, view,
camera and selected file as a URL fragment (GitHub repositories only). camera and selected file as a URL fragment (GitHub repositories only).
- Touch: left thumb joystick, right thumb look, pinch to zoom. - Touch: left thumb joystick, right thumb look, pinch to zoom.
- Selecting a file also lays glowing routes along the streets to each file it imports.
- Residents wander the streets (toggle in Settings). A wall with posters encloses the city.
- Signed in with GitHub, files with open code scanning or Dependabot alerts smoke (and burn
for critical/high) and are wrapped in hazard tape. The GitHub App needs read access to
code scanning alerts and Dependabot alerts.
Frontend modules (plain ES modules, no build step): `camera.js` (matrices and orbit pose), Frontend modules (plain ES modules, no build step): `camera.js` (matrices and orbit pose),
`city.js` (layout, collision, picking, sign placement, tour and links), `labels.js` (text atlas), `city.js` (layout, collision, picking, sign placement, tour and links), `labels.js` (text atlas),
+43 -9
View File
@@ -1,6 +1,6 @@
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, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, 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, MOVE } from './city.js';
import { LabelAtlas } from './labels.js'; import { LabelAtlas, PosterAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs'; import { apiFetch, progressEvents } from './api.mjs';
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||''; const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
@@ -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=[],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 currentSnapshot=null;
let currentName=''; let currentName='';
let currentMetric='lines'; 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)}); 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); 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(); updateFires();
spawnResidents(); 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.
@@ -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); 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};}; 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))})); 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}); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
buildMinimap();updateCityHud();updateTrails();updateRoutes();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); 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])))}); 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. // 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 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; 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); 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'){ }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); 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)), right:Math.max(-1,Math.min(1,down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft')+touchMove.right)),
up:down('Space')-down('KeyC'), up:down('Space')-down('KeyC'),
run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'), 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; dirty=true;
} }
@@ -359,12 +364,14 @@ function updateCityReadouts(){
const MINIMAP={width:220,height:150,pad:8}; const MINIMAP={width:220,height:150,pad:8};
function minimapTransform(){ 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}; return {scale,ox:(MINIMAP.width-cityModel.width*scale)/2,oy:(MINIMAP.height-cityModel.height*scale)/2};
} }
function buildMinimap(){ function buildMinimap(){
const dpr=2,canvas=document.createElement('canvas');canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr; 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); 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)'; 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 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();} 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 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; 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); 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(){ function fitCity(){
@@ -781,6 +788,33 @@ function updateRoutes(){
renderer.setRoutes(cityRoutes);dirty=true; 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. // Residents: roughly one per 2,500 m² of city, capped so large repos stay cheap to simulate.
function spawnResidents(){ function spawnResidents(){
wanderers=showResidents&&navGrid?spawnWanderers(navGrid,Math.max(40,Math.min(600,Math.round(cityModel.width*cityModel.height/2500))),Math.random,cityModel):[]; 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; 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) })); 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)); // Never beyond the city wall, even when flying over it.
camera.ey = Math.max(-150, Math.min(bounds.height + 150, y)); 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; return camera;
} }
@@ -600,3 +602,41 @@ export function tapeQuads(b, aspect, direction = 1) {
} }
return out; 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) }; 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. // 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; const FLOATS_PER_INSTANCE = 16;
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. // Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
@@ -107,6 +107,18 @@ void main(){
return; return;
} }
if(vKind.x == 2){ outColor = vec4(fog(vec3(.03, .036, .052)), uAlpha); return; } // asphalt 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 #endif
if(uMode < .5 && vFace > 0) discard; if(uMode < .5 && vFace > 0) discard;
vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; 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.landscape = this.boxLayer(); this.cityLayer = this.boxLayer();
this.signProgram = program(gl, SIGN_VS, SIGN_FS); this.signUniforms = uniformsOf(gl, this.signProgram); 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.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.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.trailProgram = program(gl, TRAIL_VS, TRAIL_FS); this.trailUniforms = uniformsOf(gl, this.trailProgram);
this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
@@ -487,6 +499,16 @@ export class LandscapeRenderer {
this.signLayer.count = quads.length; 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. // Source code on one wall: a canvas and its quad, or null to hide.
setFacade(canvas, quad) { setFacade(canvas, quad) {
const gl = this.gl; const gl = this.gl;
@@ -763,6 +785,7 @@ export class LandscapeRenderer {
this.syncAtlas(); this.syncAtlas();
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false);
gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-2, -8); 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.facadeLayer, this.facadeTexture);
this.drawQuads(this.signLayer, this.atlasTexture); 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); gl.disable(gl.POLYGON_OFFSET_FILL); gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+32
View File
@@ -80,3 +80,35 @@ test('a seeded crowd is reproducible', () => {
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)]); }; 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()); assert.deepEqual(run(), run());
}); });
import { cityWalls, posterQuads, WALL, stepCamera, readableFrom, spatialIndex } from '../web/city.js';
test('the wall encloses the city and nobody gets past it', () => {
const bounds = { width: 200, height: 120 }, walls = cityWalls(bounds);
const inside = (x, y) => walls.every(w => !(x > w.x && x < w.x + w.w && y > w.y && y < w.y + w.h));
assert.ok(inside(100, 60) && inside(-WALL.margin + 1, -WALL.margin + 1) && !inside(-WALL.margin - 1, 60));
const index = spatialIndex(walls), edge = WALL.margin - .5;
const walker = { view: 'walk', ex: 100, ey: 60, ez: 1.7, lookYaw: Math.PI / 2, lookPitch: 0 }; // heading west
stepCamera(walker, { forward: 1, right: 0, up: 0 }, 60, index, { ...bounds, edge });
assert.ok(walker.ex >= -WALL.margin, `walker stopped at the wall: ${walker.ex}`);
const flyer = { view: 'fly', ex: 100, ey: 60, ez: 500, lookYaw: Math.PI / 2, lookPitch: 0 };
stepCamera(flyer, { forward: 1, right: 0, up: 0 }, 60, index, { ...bounds, edge });
assert.ok(flyer.ex >= -edge - 1e-9, `flyer stayed inside: ${flyer.ex}`);
// Routes and residents can't reach the outside either.
const grid = buildNavGrid(walls, [], bounds);
assert.ok(walkable(grid, 100, 60) && !walkable(grid, -WALL.margin - 1, 60));
});
test('posters are pasted on the city side of the walls, tilted and readable from inside', () => {
const bounds = { width: 400, height: 300 }, walls = cityWalls(bounds), random = seededRandom(11);
const posters = posterQuads(walls, 12, random);
assert.ok(posters.length >= 36, `about one per 35 m (${posters.length})`);
for (const p of posters) {
assert.ok(readableFrom(p, [200, 150, 5]), 'faces the city');
assert.ok(p.c[0] > -WALL.margin - .01 && p.c[0] < bounds.width + WALL.margin + .01 && p.c[1] > -WALL.margin - .01 && p.c[1] < bounds.height + WALL.margin + .01, `on the inner face: ${p.c}`);
const tilt = Math.atan2(p.u[2], Math.hypot(p.u[0], p.u[1]));
assert.ok(Math.abs(tilt) <= .44 && p.c[2] - Math.hypot(...p.v) >= 1.5 && p.c[2] + Math.hypot(...p.v) <= WALL.height + .5);
assert.ok(Number.isInteger(p.poster) && p.poster >= 0 && p.poster < 12);
}
assert.ok(new Set(posters.map(p => p.u[2].toFixed(2))).size > 10, 'arbitrary angles');
});