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:
Andy Dixon
2026-05-23 14:33:00 +01:00
parent 3036763d08
commit 76c846fdce
6 changed files with 310 additions and 9 deletions
+68
View File
@@ -140,3 +140,71 @@ export function pickRay(origin, dir, entries, maxDistance = Infinity) {
}
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];
}