diff --git a/README.md b/README.md index 8aa38a1..303c82c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Configuration: - `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). - 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), `city.js` (layout, collision, picking, sign placement, tour and links), `labels.js` (text atlas), diff --git a/web/app.js b/web/app.js index 368b5c3..60c5aa7 100644 --- a/web/app.js +++ b/web/app.js @@ -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):[]; diff --git a/web/city.js b/web/city.js index e37cbcb..750bc7a 100644 --- a/web/city.js +++ b/web/city.js @@ -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; +} diff --git a/web/labels.js b/web/labels.js index 8a9944f..3d2f0d9 100644 --- a/web/labels.js +++ b/web/labels.js @@ -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; +} diff --git a/web/renderer.js b/web/renderer.js index d39c105..c9db5cb 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -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); diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs index 4d0cf2e..4978cc8 100644 --- a/webtest/nav.test.mjs +++ b/webtest/nav.test.mjs @@ -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)]); }; 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'); +});