perf(citygen): bound each lane search to its endpoints' box; document cyberspace

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 21:51:15 +02:00
parent 0dcead3a7a
commit 835a9eb763
2 changed files with 70 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ The generator builds a city as walkable tile maps on four strata:
| Stratum | What lives there |
|---|---|
| Net | cyberspace: a dotted grid, a construct on every building, district hubs, link lanes, ICE at the gates |
| Heights | rooftops of the tall towers, helipads, bridges between them |
| Skyways | elevated walkways, mid-rise lobbies, platforms over intersections |
| Street | districts, arterials, blocks, buildings, markets, neon |
@@ -19,8 +20,12 @@ Every building you see at street level is the same building you stand on at
Heights. Districts have themes (corp core, night market, megablocks, combat
zone, docks, industrial, neon strip, old town) that drive street layout,
building mix, palette and signage. A Net graph of subnets, nodes and links is
anchored to the physical tiles and can be shown as a digital overlay. That
graph is the seed of the cyberspace side of the game.
anchored to the physical tiles and can be shown as a digital overlay.
The same graph is cyberspace. Jack in at any street terminal (walk up to it and
press `enter`): your body stays at the port and you emerge in the Net at the
same spot, in a wireframe world laid out one to one with the city. `enter`
again jacks out. In Explore mode `]` past Heights shows the Net.
Same seed and parameters always produce a byte-identical city.
@@ -54,18 +59,19 @@ on `RANDOM SEED` / `GENERATE` · `esc` back
**Generating** `space` pause · `+` `-` steps per frame · `enter` skip to the
end · `r` reseed after an error · `esc` back
**Explore** `hjkl` / arrows pan (shift: fast) · `[` `]` stratum down / up ·
**Explore** `hjkl` / arrows pan the cursor (shift: fast) · `[` `]` stratum down / up (Net on top) ·
`w` walk · `i` inspector · `m` minimap · `e` legend · `d` digital overlay ·
`s` save · `r` regenerate · `g` configure · `?` keys · `q` title
**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or
manhole · `.` wait · `tab` back to the free camera · panels as above
manhole · `enter` jack in at an adjacent terminal / jack out · `.` wait ·
`tab` back to the free camera · panels as above
## Layout
```
crates/citygen world model + city generator (no terminal dependencies)
crates/cyberpunk the game: ratatui app, screens, renderer, and the `gen` CLI
crates/cyberpunk the game: ratatui app, screens, renderer, walking and jacking in, the `gen` CLI
flavor/ names.ron (names, signage glyphs) · style.ron (palettes, glyph sets)
docs/superpowers/specs design specs
docs/superpowers/plans implementation plans

View File

@@ -1,7 +1,7 @@
//! Stage 12: cyberspace — the Net stratum as a grid with constructs, hubs, lanes and ports.
use super::{GenCtx, StageRunner, StepOut};
use crate::geom::{Pos, Rect};
use crate::geom::{Pos, Rect, Size};
use crate::ids::{BuildingId, DistrictId};
use crate::net::{Node, NodeKind, SubnetOwner};
use crate::params::GenError;
@@ -16,6 +16,8 @@ const GRID_LIGHT: u8 = 200;
const HUB_RADIUS: i32 = 2;
const NODE_SEARCH: i32 = 6;
const LANE_LIGHT: u8 = 255;
/// How far a lane may stray from the box spanned by its endpoints.
const LANE_MARGIN: i32 = 16;
enum Work {
Begin,
@@ -65,6 +67,35 @@ fn outward(r: Rect, p: Pos) -> Pos {
}
}
/// A* confined to the bounding box of `start` and `goal` grown by `margin`, so each
/// search allocates a small grid instead of the whole map. A detour that leaves the
/// box is not found.
fn bounded_astar(
size: Size,
start: Pos,
goal: Pos,
margin: i32,
cost: impl Fn(Pos, Pos) -> Option<u32>,
) -> Option<Vec<Pos>> {
let x0 = (start.x.min(goal.x) - margin).max(0);
let y0 = (start.y.min(goal.y) - margin).max(0);
let x1 = (start.x.max(goal.x) + margin + 1).min(size.w as i32);
let y1 = (start.y.max(goal.y) + margin + 1).min(size.h as i32);
if x1 <= x0 || y1 <= y0 {
return None;
}
let sub = Size {
w: (x1 - x0) as u32,
h: (y1 - y0) as u32,
};
let to_local = |p: Pos| Pos::new(p.x - x0, p.y - y0);
let to_world = |p: Pos| Pos::new(p.x + x0, p.y + y0);
astar(sub, to_local(start), to_local(goal), |a, b| {
cost(to_world(a), to_world(b))
})
.map(|path| path.into_iter().map(to_world).collect())
}
fn backbone_anchor(ctx: &GenCtx, d: DistrictId) -> Option<Pos> {
ctx.city
.net
@@ -252,7 +283,7 @@ impl CyberspaceStage {
let size = ctx.size();
let path = {
let city = &ctx.city;
astar(size, start, goal, |_, q| {
bounded_astar(size, start, goal, LANE_MARGIN, |_, q| {
let t = city.tile(K, q)?;
if q == start || q == goal {
return Some(1);
@@ -599,4 +630,30 @@ mod tests {
}
}
}
#[test]
fn lane_search_is_bounded_to_the_endpoints_box() {
use crate::geom::Size;
let size = Size { w: 200, h: 60 };
// A wall across x = 50 whose only opening, y = 58, lies far outside the margin.
let walled = |_: Pos, q: Pos| {
if q.x == 50 && q.y != 58 {
None
} else {
Some(1u32)
}
};
let (a, b) = (Pos::new(40, 10), Pos::new(60, 10));
assert!(
crate::pathing::astar(size, a, b, walled).is_some(),
"unbounded search finds the detour"
);
assert!(
super::bounded_astar(size, a, b, 16, walled).is_none(),
"bounded search gives up"
);
let free = |_: Pos, _: Pos| Some(1u32);
let path = super::bounded_astar(size, a, b, 16, free).unwrap();
assert_eq!((path[0], *path.last().unwrap()), (a, b));
}
}