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 | | 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 | | Heights | rooftops of the tall towers, helipads, bridges between them |
| Skyways | elevated walkways, mid-rise lobbies, platforms over intersections | | Skyways | elevated walkways, mid-rise lobbies, platforms over intersections |
| Street | districts, arterials, blocks, buildings, markets, neon | | 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 Heights. Districts have themes (corp core, night market, megablocks, combat
zone, docks, industrial, neon strip, old town) that drive street layout, 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 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 anchored to the physical tiles and can be shown as a digital overlay.
graph is the seed of the cyberspace side of the game.
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. 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 **Generating** `space` pause · `+` `-` steps per frame · `enter` skip to the
end · `r` reseed after an error · `esc` back 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 · `w` walk · `i` inspector · `m` minimap · `e` legend · `d` digital overlay ·
`s` save · `r` regenerate · `g` configure · `?` keys · `q` title `s` save · `r` regenerate · `g` configure · `?` keys · `q` title
**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or **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 ## Layout
``` ```
crates/citygen world model + city generator (no terminal dependencies) 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) flavor/ names.ron (names, signage glyphs) · style.ron (palettes, glyph sets)
docs/superpowers/specs design specs docs/superpowers/specs design specs
docs/superpowers/plans implementation plans 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. //! Stage 12: cyberspace — the Net stratum as a grid with constructs, hubs, lanes and ports.
use super::{GenCtx, StageRunner, StepOut}; use super::{GenCtx, StageRunner, StepOut};
use crate::geom::{Pos, Rect}; use crate::geom::{Pos, Rect, Size};
use crate::ids::{BuildingId, DistrictId}; use crate::ids::{BuildingId, DistrictId};
use crate::net::{Node, NodeKind, SubnetOwner}; use crate::net::{Node, NodeKind, SubnetOwner};
use crate::params::GenError; use crate::params::GenError;
@@ -16,6 +16,8 @@ const GRID_LIGHT: u8 = 200;
const HUB_RADIUS: i32 = 2; const HUB_RADIUS: i32 = 2;
const NODE_SEARCH: i32 = 6; const NODE_SEARCH: i32 = 6;
const LANE_LIGHT: u8 = 255; 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 { enum Work {
Begin, 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> { fn backbone_anchor(ctx: &GenCtx, d: DistrictId) -> Option<Pos> {
ctx.city ctx.city
.net .net
@@ -252,7 +283,7 @@ impl CyberspaceStage {
let size = ctx.size(); let size = ctx.size();
let path = { let path = {
let city = &ctx.city; let city = &ctx.city;
astar(size, start, goal, |_, q| { bounded_astar(size, start, goal, LANE_MARGIN, |_, q| {
let t = city.tile(K, q)?; let t = city.tile(K, q)?;
if q == start || q == goal { if q == start || q == goal {
return Some(1); 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));
}
} }