From 835a9eb7638294a7658441ee40ac6667d507b69c Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 21:51:15 +0200 Subject: [PATCH] perf(citygen): bound each lane search to its endpoints' box; document cyberspace Co-Authored-By: Claude Fable 5.1 --- README.md | 16 ++++-- crates/citygen/src/generate/cyberspace.rs | 61 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index be023cb..7a6637f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/citygen/src/generate/cyberspace.rs b/crates/citygen/src/generate/cyberspace.rs index e93263b..0d62565 100644 --- a/crates/citygen/src/generate/cyberspace.rs +++ b/crates/citygen/src/generate/cyberspace.rs @@ -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, +) -> Option> { + 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 { 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)); + } }