Walk and fly through the city
Adds walk and fly cameras alongside the helicopter view, switched with 1/2/3 or the city toolbar. Walking uses mouse-look with pointer lock (or drag), WASD, Shift to run and circle-vs-footprint collision with sub-stepping so long frames cannot tunnel through buildings; flying follows the view pitch with Space/C for altitude and clears low roofs. Double-clicking a street drops you there with an animated descent, facing the longest open street. A crosshair names the building ahead (E or click inspects it), the address bar shows the folder you are in, and a minimap shows your heading and teleports on click.
This commit is contained in:
+165
-6
@@ -1,5 +1,5 @@
|
|||||||
import { LandscapeRenderer, KIND } from './renderer.js';
|
import { LandscapeRenderer, KIND } from './renderer.js';
|
||||||
import { layoutCity, squarifiedLayout, spatialIndex, pickRay } from './city.js';
|
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, MOVE } from './city.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()||'';
|
||||||
@@ -126,8 +126,150 @@ function computeCity(){
|
|||||||
}
|
}
|
||||||
cityIndex=spatialIndex(cityEntries);
|
cityIndex=spatialIndex(cityEntries);
|
||||||
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
|
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
|
||||||
|
buildMinimap();updateCityHud();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ----
|
||||||
|
const heldKeys=new Set(),MOVE_KEYS=new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight','Space','KeyC']);
|
||||||
|
let minimapBase=null,cityTargetEntry=null,lastAddress='';
|
||||||
|
const CITY_HELP={
|
||||||
|
heli:'<b>Drag</b> orbit · <b>Shift+drag</b> pan · <b>Scroll</b> zoom · <b>Double-click</b> a street to walk',
|
||||||
|
walk:'<b>Click</b> to look · <b>WASD</b> move · <b>Shift</b> run · <b>E</b> inspect · <b>Esc</b> release mouse',
|
||||||
|
fly:'<b>Click</b> to look · <b>WASD</b> fly · <b>Space</b>/<b>C</b> up/down · <b>Shift</b> fast · <b>E</b> inspect',
|
||||||
|
};
|
||||||
|
|
||||||
|
function setCityView(view,at){
|
||||||
|
const c=renderer.city;if(!cityModel||(view===c.view&&!at))return;
|
||||||
|
cancelCameraAnimation();
|
||||||
|
const {eye,forward}=renderer.cityPose();
|
||||||
|
if(view==='heli'){
|
||||||
|
// Rise out of the street, keeping the heading.
|
||||||
|
Object.assign(c,{view,x:c.ex,y:c.ey,yaw:c.lookYaw,pitch:.62,distance:Math.max(3,c.ez)});
|
||||||
|
animateCameraTo({distance:Math.max(180,c.ez*2)},c,1000);
|
||||||
|
if(document.pointerLockElement)document.exitPointerLock();
|
||||||
|
}else{
|
||||||
|
const fromHeli=c.view==='heli';
|
||||||
|
c.view=view;
|
||||||
|
if(fromHeli||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])))});
|
||||||
|
const target=at||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,cityIndex);
|
||||||
|
animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw,lookPitch:view==='walk'?0:-.3},c,1100);
|
||||||
|
}else if(view==='walk'){
|
||||||
|
const spot=collide(c.ex,c.ey,MOVE.radius*3,cityIndex);
|
||||||
|
animateCameraTo({ex:spot.x,ey:spot.y,ez:MOVE.eyeHeight,lookPitch:0},c,800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCityHud();dirty=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of eight headings, the one with the longest clear view at eye level, preferring the current one.
|
||||||
|
function openHeading(x,y,current){
|
||||||
|
let best=current,clear=-1;
|
||||||
|
for(let i=0;i<8;i++){
|
||||||
|
const yaw=current+i*Math.PI/4,dir=[-Math.sin(yaw),-Math.cos(yaw),0];
|
||||||
|
const distance=Math.min(pickRay([x,y,MOVE.eyeHeight],dir,cityEntries,400)?.distance??400,400);
|
||||||
|
if(distance>clear+5){clear=distance;best=yaw;}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepCity(seconds){
|
||||||
|
const down=code=>heldKeys.has(code)?1:0;
|
||||||
|
stepCamera(renderer.city,{
|
||||||
|
forward:down('KeyW')+down('ArrowUp')-down('KeyS')-down('ArrowDown'),
|
||||||
|
right:down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft'),
|
||||||
|
up:down('Space')-down('KeyC'),
|
||||||
|
run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'),
|
||||||
|
},seconds,cityIndex,cityModel);
|
||||||
|
dirty=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookCity(dx,dy){
|
||||||
|
const c=renderer.city;
|
||||||
|
c.lookYaw-=dx*.0022;c.lookPitch=Math.max(-1.45,Math.min(1.45,c.lookPitch-dy*.0022));dirty=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCityKey(event){
|
||||||
|
const c=renderer.city;
|
||||||
|
if(/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return false;
|
||||||
|
const views={Digit1:'heli',Digit2:'walk',Digit3:'fly'};
|
||||||
|
if(views[event.code]){event.preventDefault();setCityView(views[event.code]);return true;}
|
||||||
|
if(event.code==='KeyE'){event.preventDefault();inspectCityTarget(true);return true;}
|
||||||
|
if(c.view!=='heli'&&MOVE_KEYS.has(event.code)){event.preventDefault();cancelCameraAnimation();heldKeys.add(event.code);dirty=true;return true;}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document.addEventListener('keyup',event=>heldKeys.delete(event.code));
|
||||||
|
window.addEventListener('blur',()=>heldKeys.clear());
|
||||||
|
document.addEventListener('mousemove',event=>{if(document.pointerLockElement===glCanvas&&renderer.mode==='city')lookCity(event.movementX,event.movementY);});
|
||||||
|
document.addEventListener('pointerlockchange',()=>{if(!document.pointerLockElement)heldKeys.clear();updateCityHud();});
|
||||||
|
|
||||||
|
function inspectCityTarget(openPanel){
|
||||||
|
if(!cityTargetEntry)return;
|
||||||
|
selectFile(cityTargetEntry.file);
|
||||||
|
if(openPanel){switchTab('inspector');if(document.pointerLockElement)document.exitPointerLock();}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCityHud(){
|
||||||
|
const inCity=renderer.mode==='city'&&!!cityModel,view=renderer.city.view;
|
||||||
|
$('#cityHud').hidden=!inCity;$('#minimap').hidden=!inCity;$('#cityAddress').hidden=!inCity;
|
||||||
|
$('#crosshair').hidden=!inCity||view==='heli';
|
||||||
|
if(!inCity||view==='heli'){$('#cityTarget').hidden=true;cityTargetEntry=null;}
|
||||||
|
$$('[data-city-view]').forEach(button=>button.classList.toggle('active',button.dataset.cityView===view));
|
||||||
|
$('#cityHelp').innerHTML=CITY_HELP[view];
|
||||||
|
if(!inCity&&document.pointerLockElement)document.exitPointerLock();
|
||||||
|
}
|
||||||
|
$$('[data-city-view]').forEach(button=>button.addEventListener('click',()=>{button.blur();setCityView(button.dataset.cityView);}));
|
||||||
|
|
||||||
|
// Crosshair target, street address and minimap, refreshed with each city frame.
|
||||||
|
function updateCityReadouts(){
|
||||||
|
const c=renderer.city,{eye,forward}=renderer.cityPose();
|
||||||
|
if(c.view!=='heli'){
|
||||||
|
const hit=pickRay(eye,forward,cityEntries,90),entry=hit?.entry||null;
|
||||||
|
if(entry!==cityTargetEntry){
|
||||||
|
cityTargetEntry=entry;const target=$('#cityTarget');target.hidden=!entry;
|
||||||
|
if(entry)target.innerHTML=`${escapeHtml(entry.file.name)}<kbd>E</kbd><small>${escapeHtml(entry.file.path)} · ${format(entry.file.lines)} lines</small>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [px,py]=c.view==='heli'?[c.x,c.y]:[c.ex,c.ey],block=blockAt(cityModel.blocks,px,py);
|
||||||
|
const address=[currentName,...(block?.node.path.split('/')||[])].join(' / ');
|
||||||
|
if(address!==lastAddress){lastAddress=address;$('#cityAddress').textContent=address;}
|
||||||
|
drawMinimap();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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.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)continue;ctx.fillStyle=`rgba(${item.color.map(v=>Math.round(v*255)).join(',')},.85)`;ctx.fillRect(ox+b.x*scale,oy+b.y*scale,Math.max(.6,b.w*scale),Math.max(.6,b.h*scale));}
|
||||||
|
minimapBase=canvas;
|
||||||
|
}
|
||||||
|
function drawMinimap(){
|
||||||
|
const canvas=$('#minimap');if(canvas.hidden||!minimapBase)return;
|
||||||
|
const dpr=2;if(canvas.width!==MINIMAP.width*dpr){canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr;}
|
||||||
|
const ctx=canvas.getContext('2d'),c=renderer.city,{scale,ox,oy}=minimapTransform();
|
||||||
|
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(minimapBase,0,0);ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||||
|
const heli=c.view==='heli',x=ox+(heli?c.x:c.ex)*scale,y=oy+(heli?c.y:c.ey)*scale,yaw=heli?c.yaw:c.lookYaw;
|
||||||
|
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){ctx.strokeStyle='#fff08a';ctx.lineWidth=1.5;ctx.strokeRect(ox+b.x*scale-1.5,oy+b.y*scale-1.5,b.w*scale+3,b.h*scale+3);}}
|
||||||
|
// Heading arrow: yaw 0 points north (up the map).
|
||||||
|
ctx.save();ctx.translate(x,y);ctx.rotate(-yaw);
|
||||||
|
ctx.fillStyle='rgba(54,211,194,.18)';ctx.beginPath();ctx.moveTo(0,0);ctx.arc(0,0,26,-Math.PI/2-.5,-Math.PI/2+.5);ctx.closePath();ctx.fill();
|
||||||
|
ctx.fillStyle='#fff';ctx.strokeStyle='#07090d';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(0,-7);ctx.lineTo(5,5);ctx.lineTo(0,2.5);ctx.lineTo(-5,5);ctx.closePath();ctx.stroke();ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
$('#minimap').addEventListener('pointerdown',event=>{
|
||||||
|
event.stopPropagation();if(!cityModel)return;
|
||||||
|
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);}
|
||||||
|
});
|
||||||
|
|
||||||
function fitCity(){
|
function fitCity(){
|
||||||
if(!cityModel)return;
|
if(!cityModel)return;
|
||||||
Object.assign(renderer.city,{view:'heli',x:cityModel.width/2,y:cityModel.height/2,yaw:-.35,pitch:.72,distance:Math.max(cityModel.width,cityModel.height)*1.05});
|
Object.assign(renderer.city,{view:'heli',x:cityModel.width/2,y:cityModel.height/2,yaw:-.35,pitch:.72,distance:Math.max(cityModel.width,cityModel.height)*1.05});
|
||||||
@@ -185,7 +327,7 @@ function pan3d(dx,dy){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dragCity(dx,dy,action){
|
function dragCity(dx,dy,action){
|
||||||
const c=renderer.city;if(c.view!=='heli')return;
|
const c=renderer.city;
|
||||||
if(action==='pan'){const scale=c.distance*.0016,cy=Math.cos(c.yaw),sy=Math.sin(c.yaw);c.x+=(-dx*cy-dy*sy)*scale;c.y+=(dx*sy-dy*cy)*scale;}
|
if(action==='pan'){const scale=c.distance*.0016,cy=Math.cos(c.yaw),sy=Math.sin(c.yaw);c.x+=(-dx*cy-dy*sy)*scale;c.y+=(dx*sy-dy*cy)*scale;}
|
||||||
else{c.yaw-=dx*.007;c.pitch=Math.max(.08,Math.min(1.5,c.pitch+dy*.006));}
|
else{c.yaw-=dx*.007;c.pitch=Math.max(.08,Math.min(1.5,c.pitch+dy*.006));}
|
||||||
}
|
}
|
||||||
@@ -474,6 +616,7 @@ function drawOverlay(){
|
|||||||
function drawCityOverlay(ctx){
|
function drawCityOverlay(ctx){
|
||||||
if(showCode){fillCodeOverview();uploadCodeOverview();}
|
if(showCode){fillCodeOverview();uploadCodeOverview();}
|
||||||
if(!cityModel)return;
|
if(!cityModel)return;
|
||||||
|
updateCityReadouts();
|
||||||
const occupied=[],w=renderer.width,h=renderer.height;let count=0;
|
const occupied=[],w=renderer.width,h=renderer.height;let count=0;
|
||||||
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}}
|
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}}
|
||||||
for(const block of cityModel.blocks){
|
for(const block of cityModel.blocks){
|
||||||
@@ -606,6 +749,8 @@ function drawCodeTexture(ctx,item,rect){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function animate(now){
|
function animate(now){
|
||||||
|
const frameSeconds=Math.min(.1,(now-lastFrame)/1000);
|
||||||
|
if(renderer.mode==='city'&&renderer.city.view!=='heli'&&!cameraAnimation&&heldKeys.size)stepCity(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;
|
||||||
@@ -638,6 +783,12 @@ function pickAt(x,y){
|
|||||||
viewport.addEventListener('pointerdown',event=>{
|
viewport.addEventListener('pointerdown',event=>{
|
||||||
// Only drags that start on the map; pointer capture would otherwise swallow clicks on floating controls.
|
// Only drags that start on the map; pointer capture would otherwise swallow clicks on floating controls.
|
||||||
if(event.button>2||event.target!==glCanvas)return;
|
if(event.button>2||event.target!==glCanvas)return;
|
||||||
|
if(renderer.mode==='city'&&renderer.city.view!=='heli'&&event.pointerType==='mouse'&&event.button===0&&glCanvas.requestPointerLock){
|
||||||
|
// Mouse-look: the first click captures the pointer, later clicks select the building in the crosshair.
|
||||||
|
event.preventDefault();
|
||||||
|
if(document.pointerLockElement===glCanvas)inspectCityTarget(false);else glCanvas.requestPointerLock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pan=renderer.mode!=='2d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey);
|
const pan=renderer.mode!=='2d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey);
|
||||||
if(renderer.mode==='2d'&&event.button!==0)return;
|
if(renderer.mode==='2d'&&event.button!==0)return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -646,11 +797,12 @@ viewport.addEventListener('pointerdown',event=>{
|
|||||||
viewport.setPointerCapture(event.pointerId);viewport.classList.add('dragging',pan?'panning':'orbiting');
|
viewport.setPointerCapture(event.pointerId);viewport.classList.add('dragging',pan?'panning':'orbiting');
|
||||||
});
|
});
|
||||||
viewport.addEventListener('pointermove',event=>{
|
viewport.addEventListener('pointermove',event=>{
|
||||||
|
if(document.pointerLockElement)return;
|
||||||
const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top;
|
const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top;
|
||||||
if(pointer.down&&event.pointerId===pointer.id){
|
if(pointer.down&&event.pointerId===pointer.id){
|
||||||
const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY;
|
const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY;
|
||||||
if(renderer.mode==='2d'){renderer.camera.x-=dx/renderer.camera.zoom;renderer.camera.y-=dy/renderer.camera.zoom;}
|
if(renderer.mode==='2d'){renderer.camera.x-=dx/renderer.camera.zoom;renderer.camera.y-=dy/renderer.camera.zoom;}
|
||||||
else if(renderer.mode==='city')dragCity(dx,dy,pointer.action);
|
else if(renderer.mode==='city'){if(renderer.city.view==='heli')dragCity(dx,dy,pointer.action);else lookCity(dx,dy);}
|
||||||
else if(pointer.action==='pan')pan3d(dx,dy);
|
else if(pointer.action==='pan')pan3d(dx,dy);
|
||||||
else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));}
|
else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));}
|
||||||
dirty=true;$('#tooltip').hidden=true;return;
|
dirty=true;$('#tooltip').hidden=true;return;
|
||||||
@@ -668,9 +820,15 @@ viewport.addEventListener('pointercancel',finishPointer);
|
|||||||
viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;});
|
viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;});
|
||||||
viewport.addEventListener('contextmenu',event=>{if(renderer.mode!=='2d')event.preventDefault();});
|
viewport.addEventListener('contextmenu',event=>{if(renderer.mode!=='2d')event.preventDefault();});
|
||||||
viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false});
|
viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false});
|
||||||
viewport.addEventListener('dblclick',event=>{const rect=viewport.getBoundingClientRect(),file=pickAt(event.clientX-rect.left,event.clientY-rect.top);if(file){selectFile(file);focusFile(file);}});
|
viewport.addEventListener('dblclick',event=>{
|
||||||
|
const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,file=pickAt(x,y);
|
||||||
|
if(renderer.mode==='city'&&renderer.city.view!=='heli')return;
|
||||||
|
if(file){selectFile(file);focusFile(file);return;}
|
||||||
|
// Double-clicking a street from the helicopter drops you there at eye level.
|
||||||
|
if(renderer.mode==='city'){const {origin,dir}=renderer.ray(x,y),hit=groundHit(origin,dir);if(hit)setCityView('walk',hit);}
|
||||||
|
});
|
||||||
|
|
||||||
$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;if(renderer.mode==='city'&&!cityFitted)fitCity();$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;}));
|
$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;if(renderer.mode==='city'&&!cityFitted)fitCity();$('#cameraHelp').hidden=renderer.mode!=='3d';updateCityHud();dirty=true;}));
|
||||||
$$('[data-metric]').forEach(button=>button.addEventListener('click',()=>{$$('[data-metric]').forEach(v=>v.classList.toggle('active',v===button));currentMetric=button.dataset.metric;computeLayout();}));
|
$$('[data-metric]').forEach(button=>button.addEventListener('click',()=>{$$('[data-metric]').forEach(v=>v.classList.toggle('active',v===button));currentMetric=button.dataset.metric;computeLayout();}));
|
||||||
$('#codeButton').classList.toggle('active',showCode);$('#codeButton').addEventListener('click',event=>{showCode=!showCode;event.currentTarget.classList.toggle('active',showCode);dirty=true;});
|
$('#codeButton').classList.toggle('active',showCode);$('#codeButton').addEventListener('click',event=>{showCode=!showCode;event.currentTarget.classList.toggle('active',showCode);dirty=true;});
|
||||||
$('#homeButton').addEventListener('click',()=>{selectFile(null);fitScene();});
|
$('#homeButton').addEventListener('click',()=>{selectFile(null);fitScene();});
|
||||||
@@ -705,6 +863,7 @@ document.addEventListener('keydown',event=>{
|
|||||||
if(event.key==='/'&&document.activeElement!==$('#searchInput')){event.preventDefault();$('#searchInput').focus();}
|
if(event.key==='/'&&document.activeElement!==$('#searchInput')){event.preventDefault();$('#searchInput').focus();}
|
||||||
if(event.key==='Escape'){$('#searchInput').blur();$('#tooltip').hidden=true;}
|
if(event.key==='Escape'){$('#searchInput').blur();$('#tooltip').hidden=true;}
|
||||||
if((event.metaKey||event.ctrlKey)&&event.key==='o'){event.preventDefault();$('#loadDialog').showModal();}
|
if((event.metaKey||event.ctrlKey)&&event.key==='o'){event.preventDefault();$('#loadDialog').showModal();}
|
||||||
|
if(renderer.mode==='city'&&handleCityKey(event))return;
|
||||||
if(renderer.mode!=='3d'||/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return;
|
if(renderer.mode!=='3d'||/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return;
|
||||||
cancelCameraAnimation();
|
cancelCameraAnimation();
|
||||||
const key=event.key.toLowerCase(),orbitStep=.065,panStep=22/Math.max(.8,renderer.camera.distance);
|
const key=event.key.toLowerCase(),orbitStep=.065,panStep=22/Math.max(.8,renderer.camera.distance);
|
||||||
@@ -819,7 +978,7 @@ $('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=tr
|
|||||||
$('#fitButton').addEventListener('click',fitScene);
|
$('#fitButton').addEventListener('click',fitScene);
|
||||||
function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='city')dollyCity(-direction*.22);else if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;}
|
function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='city')dollyCity(-direction*.22);else if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;}
|
||||||
$('#zoomInButton').addEventListener('click',()=>zoomStep(1));$('#zoomOutButton').addEventListener('click',()=>zoomStep(-1));
|
$('#zoomInButton').addEventListener('click',()=>zoomStep(1));$('#zoomOutButton').addEventListener('click',()=>zoomStep(-1));
|
||||||
$$('.canvas-controls, .breadcrumb, .progress-card, .legend, .camera-help').forEach(control=>{['pointerdown','dblclick','wheel'].forEach(type=>control.addEventListener(type,event=>event.stopPropagation()));});
|
$$('.canvas-controls, .breadcrumb, .progress-card, .legend, .camera-help, .city-hud, .minimap').forEach(control=>{['pointerdown','dblclick','wheel'].forEach(type=>control.addEventListener(type,event=>event.stopPropagation()));});
|
||||||
$$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{
|
$$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{
|
||||||
const poses={isometric:{yaw:-.35,pitch:.78},top:{yaw:0,pitch:.045},front:{yaw:0,pitch:1.35}};
|
const poses={isometric:{yaw:-.35,pitch:.78},top:{yaw:0,pitch:.045},front:{yaw:0,pitch:1.35}};
|
||||||
const from={...renderer.camera},to={...from,...poses[button.dataset.cameraPreset]};
|
const from={...renderer.camera},to={...from,...poses[button.dataset.cameraPreset]};
|
||||||
|
|||||||
+68
@@ -140,3 +140,71 @@ export function pickRay(origin, dir, entries, maxDistance = Infinity) {
|
|||||||
}
|
}
|
||||||
return best && { entry: best, distance };
|
return best && { entry: best, distance };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pushes a circle of `radius` at (x, y) out of every building it overlaps.
|
||||||
|
export function collide(x, y, radius, index) {
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
let moved = false;
|
||||||
|
for (const b of index.near(x, y, radius + 1)) {
|
||||||
|
const nx = Math.max(b.x, Math.min(x, b.x + b.w)), ny = Math.max(b.y, Math.min(y, b.y + b.h));
|
||||||
|
let dx = x - nx, dy = y - ny;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
if (distance >= radius) continue;
|
||||||
|
if (distance === 0) {
|
||||||
|
// Centre inside the footprint: leave through the nearest wall.
|
||||||
|
const exits = [[b.x - radius - x, 0], [b.x + b.w + radius - x, 0], [0, b.y - radius - y], [0, b.y + b.h + radius - y]];
|
||||||
|
[dx, dy] = exits.reduce((best, exit) => Math.hypot(...exit) < Math.hypot(...best) ? exit : best);
|
||||||
|
x += dx; y += dy;
|
||||||
|
} else {
|
||||||
|
x = nx + dx / distance * radius; y = ny + dy / distance * radius;
|
||||||
|
}
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal forward and right vectors for a heading; yaw 0 faces -y (north on the map).
|
||||||
|
export function heading(yaw) {
|
||||||
|
return { forward: [-Math.sin(yaw), -Math.cos(yaw)], right: [Math.cos(yaw), -Math.sin(yaw)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOVE = { walk: 7, run: 18, fly: 40, flyFast: 160, radius: .45, eyeHeight: 1.7 };
|
||||||
|
|
||||||
|
// Advances a walk/fly camera by dt seconds. input: {forward, right, up} in -1..1 and run (bool).
|
||||||
|
export function stepCamera(camera, input, dt, index, bounds) {
|
||||||
|
// Sub-steps keep a long frame from carrying the walker through a thin building.
|
||||||
|
for (; dt > .05; dt -= .05) stepCamera(camera, input, .05, index, bounds);
|
||||||
|
const fly = camera.view === 'fly', speed = (fly ? (input.run ? MOVE.flyFast : MOVE.fly) : (input.run ? MOVE.run : MOVE.walk)) * dt;
|
||||||
|
const { forward, right } = heading(camera.lookYaw);
|
||||||
|
let mx = forward[0] * input.forward + right[0] * input.right, my = forward[1] * input.forward + right[1] * input.right;
|
||||||
|
const length = Math.hypot(mx, my);
|
||||||
|
if (length > 1) { mx /= length; my /= length; }
|
||||||
|
let x = camera.ex + mx * speed, y = camera.ey + my * speed;
|
||||||
|
if (fly) {
|
||||||
|
// Flying follows the view pitch, so looking down and pressing forward descends.
|
||||||
|
const vertical = Math.sin(camera.lookPitch) * input.forward + input.up;
|
||||||
|
camera.ez = Math.max(MOVE.eyeHeight, Math.min(3000, camera.ez + vertical * speed));
|
||||||
|
} else {
|
||||||
|
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));
|
||||||
|
return camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deepest folder block containing a point, for the "you are here" address.
|
||||||
|
export function blockAt(blocks, x, y) {
|
||||||
|
let best = null;
|
||||||
|
for (const block of blocks) if (block.depth > 0 && x >= block.x && x <= block.x + block.w && y >= block.y && y <= block.y + block.h && (!best || block.depth > best.depth)) best = block;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a ray meets the ground plane (z = 0), or null if it points up.
|
||||||
|
export function groundHit([ox, oy, oz], [dx, dy, dz]) {
|
||||||
|
if (dz >= -1e-6) return null;
|
||||||
|
const t = -oz / dz;
|
||||||
|
return [ox + dx * t, oy + dy * t];
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,18 @@
|
|||||||
<span><b>Scroll</b> dolly</span>
|
<span><b>Scroll</b> dolly</span>
|
||||||
<span><b>F</b> reset</span>
|
<span><b>F</b> reset</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="city-hud" id="cityHud" hidden>
|
||||||
|
<div class="city-views" role="group" aria-label="City camera">
|
||||||
|
<button type="button" data-city-view="heli" title="Helicopter (1)"><kbd>1</kbd> Helicopter</button>
|
||||||
|
<button type="button" data-city-view="walk" title="Walk (2)"><kbd>2</kbd> Walk</button>
|
||||||
|
<button type="button" data-city-view="fly" title="Fly (3)"><kbd>3</kbd> Fly</button>
|
||||||
|
</div>
|
||||||
|
<span class="city-help" id="cityHelp"></span>
|
||||||
|
</div>
|
||||||
|
<div class="crosshair" id="crosshair" hidden></div>
|
||||||
|
<div class="city-target" id="cityTarget" hidden></div>
|
||||||
|
<div class="city-address" id="cityAddress" hidden></div>
|
||||||
|
<canvas class="minimap" id="minimap" width="220" height="150" hidden title="Click to travel"></canvas>
|
||||||
<nav class="breadcrumb" id="breadcrumb" aria-label="Selected file path">
|
<nav class="breadcrumb" id="breadcrumb" aria-label="Selected file path">
|
||||||
<button id="homeButton" aria-label="Fit entire codebase">←</button>
|
<button id="homeButton" aria-label="Fit entire codebase">←</button>
|
||||||
<span id="breadcrumbText">No codebase loaded</span>
|
<span id="breadcrumbText">No codebase loaded</span>
|
||||||
|
|||||||
+2
-2
@@ -165,7 +165,7 @@ export class LandscapeRenderer {
|
|||||||
this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer();
|
this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer();
|
||||||
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
|
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
|
||||||
// City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position.
|
// City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position.
|
||||||
this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, eye: [0, 0, EYE_HEIGHT], lookYaw: 0, lookPitch: 0 };
|
this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, ex: 0, ey: 0, ez: EYE_HEIGHT, lookYaw: 0, lookPitch: 0 };
|
||||||
this.mode = '2d';
|
this.mode = '2d';
|
||||||
this.resize();
|
this.resize();
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ export class LandscapeRenderer {
|
|||||||
const eye = [c.x + sy * cp * c.distance, c.y + cy * cp * c.distance, sp * c.distance];
|
const eye = [c.x + sy * cp * c.distance, c.y + cy * cp * c.distance, sp * c.distance];
|
||||||
return { eye, forward: [-sy * cp, -cy * cp, -sp] };
|
return { eye, forward: [-sy * cp, -cy * cp, -sp] };
|
||||||
}
|
}
|
||||||
return { eye: c.eye, forward: direction(c.lookYaw, c.lookPitch) };
|
return { eye: [c.ex, c.ey, c.ez], forward: direction(c.lookYaw, c.lookPitch) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recomputes the 3D view-projection from the active camera; runs on every resize check.
|
// Recomputes the 3D view-projection from the active camera; runs on every resize check.
|
||||||
|
|||||||
+17
-1
@@ -149,7 +149,7 @@ kbd { font: 500 10px/1.5 var(--mono); border: 1px solid var(--line-strong); bord
|
|||||||
#glCanvas, #overlayCanvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
#glCanvas, #overlayCanvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
#overlayCanvas { pointer-events: none; }
|
#overlayCanvas { pointer-events: none; }
|
||||||
|
|
||||||
.breadcrumb, .canvas-controls, .camera-help, .legend, .tooltip, .progress-card {
|
.breadcrumb, .canvas-controls, .camera-help, .legend, .tooltip, .progress-card, .city-hud, .city-target, .city-address, .minimap {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
-webkit-backdrop-filter: var(--glass);
|
-webkit-backdrop-filter: var(--glass);
|
||||||
@@ -275,6 +275,22 @@ kbd { font: 500 10px/1.5 var(--mono); border: 1px solid var(--line-strong); bord
|
|||||||
.camera-help b { color: var(--muted); font-weight: 600; }
|
.camera-help b { color: var(--muted); font-weight: 600; }
|
||||||
@keyframes rise { from { opacity: 0; transform: translateY(4px); } }
|
@keyframes rise { from { opacity: 0; transform: translateY(4px); } }
|
||||||
|
|
||||||
|
.city-hud { position: absolute; z-index: 2; left: 16px; top: 16px; max-width: calc(100% - 32px); display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 5px 12px 5px 5px; border-radius: 12px; color: var(--dim); font-size: 10.5px; }
|
||||||
|
.city-views { display: flex; gap: 2px; }
|
||||||
|
.city-views button { display: flex; align-items: center; gap: 6px; padding: 5px 10px 5px 6px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 500; cursor: pointer; }
|
||||||
|
.city-views button:hover { color: #fff; background: var(--hover); }
|
||||||
|
.city-views button.active { color: #fff; border-color: var(--accent-line); background: var(--accent-soft); }
|
||||||
|
.city-views kbd { padding: 0 4px; font-size: 9px; }
|
||||||
|
.city-help b { color: var(--muted); font-weight: 600; }
|
||||||
|
.crosshair { position: absolute; z-index: 2; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; pointer-events: none; }
|
||||||
|
.crosshair::before, .crosshair::after { content: ""; position: absolute; background: rgba(255,255,255,.85); box-shadow: 0 0 6px rgba(0,0,0,.8); }
|
||||||
|
.crosshair::before { left: 8px; top: 0; width: 2px; height: 18px; }
|
||||||
|
.crosshair::after { left: 0; top: 8px; width: 18px; height: 2px; }
|
||||||
|
.city-target { position: absolute; z-index: 2; left: 50%; top: calc(50% + 22px); transform: translateX(-50%); padding: 6px 11px; border-radius: 10px; color: #fff; font-weight: 500; white-space: nowrap; pointer-events: none; text-align: center; }
|
||||||
|
.city-target small { display: block; color: var(--muted); font-weight: 400; font-size: 10.5px; }
|
||||||
|
.city-target kbd { margin-left: 6px; }
|
||||||
|
.city-address { position: absolute; z-index: 2; left: 16px; top: 64px; max-width: calc(100% - 32px); padding: 6px 12px; border-radius: 999px; color: var(--text); font: 500 11px var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||||
|
.minimap { position: absolute; z-index: 2; right: 16px; bottom: 62px; width: 220px; height: 150px; border-radius: 12px; cursor: crosshair; }
|
||||||
.progress-card { position: absolute; z-index: 4; left: 50%; top: 20px; width: min(440px, calc(100% - 30px)); padding: 18px 44px 16px 18px; border-radius: 16px; transform: translateX(-50%); animation: drop .25s ease-out; }
|
.progress-card { position: absolute; z-index: 4; left: 50%; top: 20px; width: min(440px, calc(100% - 30px)); padding: 18px 44px 16px 18px; border-radius: 16px; transform: translateX(-50%); animation: drop .25s ease-out; }
|
||||||
@keyframes drop { from { opacity: 0; transform: translate(-50%, -8px); } }
|
@keyframes drop { from { opacity: 0; transform: translate(-50%, -8px); } }
|
||||||
.progress-copy { display: flex; flex-direction: column; gap: 3px; }
|
.progress-copy { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
|||||||
@@ -59,3 +59,49 @@ test('spatial index finds boxes near a point', () => {
|
|||||||
assert.deepEqual([...index.near(2, 2, 3)], [a]);
|
assert.deepEqual([...index.near(2, 2, 3)], [a]);
|
||||||
assert.deepEqual([...index.near(100, 100, 3)], []);
|
assert.deepEqual([...index.near(100, 100, 3)], []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { collide, heading, stepCamera, blockAt, groundHit, MOVE } from '../web/city.js';
|
||||||
|
|
||||||
|
test('collision pushes a walker out of buildings but leaves streets alone', () => {
|
||||||
|
const box = { x: 0, y: 0, w: 10, h: 10, height: 20 }, index = spatialIndex([box]);
|
||||||
|
assert.deepEqual(collide(20, 20, .5, index), { x: 20, y: 20 });
|
||||||
|
const edge = collide(10.2, 5, .5, index);
|
||||||
|
assert.ok(Math.abs(edge.x - 10.5) < 1e-9 && edge.y === 5);
|
||||||
|
const inside = collide(9, 5, .5, index);
|
||||||
|
assert.ok(Math.abs(inside.x - 10.5) < 1e-9, `inside exits nearest wall: ${inside.x}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headings are orthogonal and yaw 0 faces north', () => {
|
||||||
|
for (const yaw of [0, .7, 2, -2.5]) {
|
||||||
|
const { forward, right } = heading(yaw);
|
||||||
|
assert.ok(Math.abs(forward[0] * right[0] + forward[1] * right[1]) < 1e-12);
|
||||||
|
}
|
||||||
|
assert.deepEqual(heading(0).forward.map(v => Math.round(v)), [-0, -1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walking moves at walking speed, collides, and stays on the ground', () => {
|
||||||
|
const index = spatialIndex([{ x: -5, y: -20, w: 10, h: 5, height: 30 }]);
|
||||||
|
const cam = { view: 'walk', ex: 0, ey: 0, ez: 1.7, lookYaw: 0, lookPitch: .5 };
|
||||||
|
stepCamera(cam, { forward: 1, right: 0, up: 1 }, 1, index, { width: 100, height: 100 });
|
||||||
|
assert.ok(Math.abs(cam.ey + MOVE.walk) < 1e-9 && cam.ez === MOVE.eyeHeight);
|
||||||
|
stepCamera(cam, { forward: 1, right: 0, up: 0 }, 2, index, { width: 100, height: 100 });
|
||||||
|
assert.ok(cam.ey >= -15 + MOVE.radius - 1e-9, `stopped at the wall, got ${cam.ey}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flying climbs with the view and clears low roofs', () => {
|
||||||
|
const index = spatialIndex([{ x: -5, y: -20, w: 10, h: 5, height: 10 }]);
|
||||||
|
const cam = { view: 'fly', ex: 0, ey: 0, ez: 50, lookYaw: 0, lookPitch: 0 };
|
||||||
|
stepCamera(cam, { forward: 1, right: 0, up: 0 }, 1, index, { width: 100, height: 100 });
|
||||||
|
assert.ok(cam.ey < -30, 'flew over the low building');
|
||||||
|
stepCamera(cam, { forward: 0, right: 0, up: -1 }, 10, index, { width: 100, height: 100 });
|
||||||
|
assert.equal(cam.ez, MOVE.eyeHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addresses resolve to the deepest block and rays find the ground', () => {
|
||||||
|
const blocks = [{ depth: 0, x: 0, y: 0, w: 100, h: 100 }, { depth: 1, x: 0, y: 0, w: 50, h: 50, node: { path: 'src' } }, { depth: 2, x: 10, y: 10, w: 10, h: 10, node: { path: 'src/ui' } }];
|
||||||
|
assert.equal(blockAt(blocks, 15, 15).node.path, 'src/ui');
|
||||||
|
assert.equal(blockAt(blocks, 40, 40).node.path, 'src');
|
||||||
|
assert.equal(blockAt(blocks, 90, 90), null);
|
||||||
|
assert.deepEqual(groundHit([0, 0, 10], [0, Math.SQRT1_2, -Math.SQRT1_2]).map(v => Math.round(v)), [0, 10]);
|
||||||
|
assert.equal(groundHit([0, 0, 10], [0, 0, 1]), null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user