feat(citygen): undercity, skyways and heights verticals

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:01:05 +02:00
parent 7d3bd8d394
commit 466e587fb7
2 changed files with 408 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ pub mod districts;
pub mod lots;
pub mod streets;
pub mod terrain;
pub mod verticals;
use crate::flavor::FlavorPack;
use crate::geom::{Grid, Pos, Rect, Size};
@@ -229,6 +230,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
Stage::Arterials => Box::new(arterials::ArterialsStage::default()),
Stage::Streets => Box::new(streets::StreetsStage::default()),
Stage::Lots => Box::new(lots::LotsStage::default()),
Stage::Verticals => Box::new(verticals::VerticalsStage::default()),
_ => Box::new(NoopStage),
}
}

View File

@@ -0,0 +1,406 @@
//! Stage 6: Undercity, Skyways and Heights derived from the street-level plan.
use super::{GenCtx, Stage, StageRunner, StepOut};
use crate::geom::{Pos, Rect};
use crate::params::GenError;
use crate::rng::chance;
use crate::tile::Terrain;
use crate::world::{StratumKind, Theme};
use noise::{NoiseFn, OpenSimplex};
use rand::Rng;
use rand_chacha::ChaCha8Rng;
use std::collections::VecDeque;
enum Work {
Arterial(usize),
Block(usize),
Rail,
Building(usize),
Walkways(usize),
Platforms(usize),
Bridges(usize),
}
#[derive(Default)]
pub(crate) struct VerticalsStage {
queue: VecDeque<Work>,
total: usize,
rng: Option<ChaCha8Rng>,
}
/// Chebyshev gap between two rects (0 when touching or overlapping).
fn rect_gap(a: Rect, b: Rect) -> i32 {
let dx = (b.x - a.right()).max(a.x - b.right()).max(0);
let dy = (b.y - a.bottom()).max(a.y - b.bottom()).max(0);
dx.max(dy)
}
/// Overlapping range of two half-open intervals.
fn overlap(a0: i32, a1: i32, b0: i32, b1: i32) -> Option<(i32, i32)> {
let lo = a0.max(b0);
let hi = a1.min(b1);
(hi > lo).then_some((lo, hi))
}
fn paint_if_void(ctx: &mut GenCtx, k: StratumKind, p: Pos, t: Terrain) {
if ctx.is_land(p) && ctx.terrain(k, p) == Terrain::Void {
ctx.set_terrain(k, p, t);
}
}
impl VerticalsStage {
fn arterial(&self, ctx: &mut GenCtx, i: usize) {
let path = ctx.city.arterials[i].clone();
for (step, p) in path.iter().enumerate() {
let channel = Rect::new(p.x, p.y, 2, 2);
let stamp = Rect::new(p.x - 1, p.y - 1, 4, 4);
for q in stamp.iter() {
if !ctx.is_land(q) {
continue;
}
if channel.contains(q) {
let t = if step % 12 == 0 {
Terrain::Grate
} else {
Terrain::Sewage
};
let cur = ctx.terrain(StratumKind::Undercity, q);
if cur != Terrain::Grate {
ctx.set_terrain(StratumKind::Undercity, q, t);
}
} else {
paint_if_void(ctx, StratumKind::Undercity, q, Terrain::Tunnel);
}
}
}
}
fn block(&mut self, ctx: &mut GenCtx, i: usize) {
let rng = self.rng.as_mut().expect("begin sets rng");
if !chance(rng, 1.0 / 3.0) {
return;
}
let block = ctx.scratch.blocks[i];
for p in block.rect.grow(2).iter() {
let street = matches!(
ctx.terrain(StratumKind::Street, p),
Terrain::Road | Terrain::Alley | Terrain::Sidewalk
);
if street {
paint_if_void(ctx, StratumKind::Undercity, p, Terrain::Tunnel);
}
}
}
fn rail(&mut self, ctx: &mut GenCtx) {
let size = ctx.size();
let rng = self.rng.as_mut().expect("begin sets rng");
let h = size.h as i32;
let y0 = h / 2 + rng.random_range(-(h / 8)..=(h / 8));
let noise = OpenSimplex::new((ctx.seed ^ 0x8A11) as u32);
let mut prev_y = y0;
for x in 0..size.w as i32 {
let y = (y0 as f64 + noise.get([x as f64 * 0.02, 0.5]) * 4.0).round() as i32;
let (lo, hi) = (prev_y.min(y), prev_y.max(y));
for yy in lo..=hi {
let p = Pos::new(x, yy);
if ctx.is_land(p) {
ctx.set_terrain(StratumKind::Undercity, p, Terrain::Rail);
paint_if_void(
ctx,
StratumKind::Undercity,
p.offset(0, -1),
Terrain::Tunnel,
);
paint_if_void(ctx, StratumKind::Undercity, p.offset(0, 1), Terrain::Tunnel);
}
}
prev_y = y;
}
}
fn building(&self, ctx: &mut GenCtx, i: usize) {
let b = ctx.city.buildings[i].clone();
for k in b.span.iter() {
if k == StratumKind::Street {
continue;
}
for p in b.footprint.iter() {
let t = if k == StratumKind::Heights {
Terrain::Rooftop
} else if b.footprint.border().any(|q| q == p) {
Terrain::Wall
} else {
Terrain::Floor
};
if k == StratumKind::Undercity {
// Tunnels, sewers and rail run under buildings; basements fill the rest.
paint_if_void(ctx, k, p, t);
} else {
ctx.set_terrain(k, p, t);
}
}
}
}
/// Paints a 2-wide L-shaped walkway between the facing edges of two footprints.
fn connect_walkway(ctx: &mut GenCtx, a: Rect, b: Rect) -> bool {
let k = StratumKind::Skyways;
let horizontal_gap = b.x >= a.right() || a.x >= b.right();
let (start, end) = if horizontal_gap {
let (left, right) = if b.x >= a.right() { (a, b) } else { (b, a) };
let y_a = overlap(left.y, left.bottom(), right.y, right.bottom())
.map(|(lo, hi)| (lo + hi) / 2)
.unwrap_or(left.center().y);
let y_b = overlap(left.y, left.bottom(), right.y, right.bottom())
.map(|(lo, hi)| (lo + hi) / 2)
.unwrap_or(right.center().y);
(Pos::new(left.right(), y_a), Pos::new(right.x - 1, y_b))
} else {
let (top, bottom) = if b.y >= a.bottom() { (a, b) } else { (b, a) };
let x_a = overlap(top.x, top.right(), bottom.x, bottom.right())
.map(|(lo, hi)| (lo + hi) / 2)
.unwrap_or(top.center().x);
let x_b = overlap(top.x, top.right(), bottom.x, bottom.right())
.map(|(lo, hi)| (lo + hi) / 2)
.unwrap_or(bottom.center().x);
(Pos::new(x_a, top.bottom()), Pos::new(x_b, bottom.y - 1))
};
for first_horizontal in [horizontal_gap, !horizontal_gap] {
let mut tiles = Vec::new();
let corner = if first_horizontal {
Pos::new(end.x, start.y)
} else {
Pos::new(start.x, end.y)
};
for seg in [(start, corner), (corner, end)] {
let (p, q) = seg;
if p.y == q.y {
let (x0, x1) = (p.x.min(q.x), p.x.max(q.x));
for x in x0..=x1 {
tiles.push(Pos::new(x, p.y));
tiles.push(Pos::new(x, p.y + 1));
}
} else {
let (y0, y1) = (p.y.min(q.y), p.y.max(q.y));
for y in y0..=y1 {
tiles.push(Pos::new(p.x, y));
tiles.push(Pos::new(p.x + 1, y));
}
}
}
let clear = tiles.iter().all(|t| {
ctx.city.size.contains(*t)
&& matches!(ctx.terrain(k, *t), Terrain::Void | Terrain::Walkway)
&& !a.contains(*t)
&& !b.contains(*t)
});
if clear {
for t in tiles {
ctx.set_terrain(k, t, Terrain::Walkway);
}
return true;
}
}
false
}
fn walkways(&self, ctx: &mut GenCtx, i: usize) {
let a = ctx.city.buildings[i].clone();
if !a.span.contains(StratumKind::Skyways) {
return;
}
let mut partners: Vec<(i32, usize)> = ctx
.city
.buildings
.iter()
.enumerate()
.skip(i + 1)
.filter(|(_, b)| b.span.contains(StratumKind::Skyways))
.map(|(j, b)| (rect_gap(a.footprint, b.footprint), j))
.filter(|(gap, _)| *gap >= 1 && *gap <= 12)
.collect();
partners.sort();
for (_, j) in partners.into_iter().take(2) {
let b = ctx.city.buildings[j].footprint;
Self::connect_walkway(ctx, a.footprint, b);
}
}
fn platforms(&self, ctx: &mut GenCtx, d: usize) {
let district = ctx.city.districts[d].clone();
if !matches!(district.theme, Theme::CorpCore | Theme::NeonStrip) {
return;
}
let mut spots = Vec::new();
for p in district.bounds.iter() {
if !ctx.in_district(p, district.id)
|| ctx.terrain(StratumKind::Street, p) != Terrain::Road
{
continue;
}
let roads = p
.neighbors4()
.filter(|q| ctx.terrain(StratumKind::Street, *q) == Terrain::Road)
.count();
if roads >= 3 && (p.x + p.y) % 7 == 0 {
spots.push(p);
}
}
for p in spots {
for q in Rect::new(p.x - 2, p.y - 2, 5, 5).iter() {
paint_if_void(ctx, StratumKind::Skyways, q, Terrain::Walkway);
}
}
}
fn bridges(&self, ctx: &mut GenCtx, i: usize) {
let a = ctx.city.buildings[i].clone();
if !a.span.contains(StratumKind::Heights) {
return;
}
let k = StratumKind::Heights;
let partner = ctx
.city
.buildings
.iter()
.enumerate()
.skip(i + 1)
.filter(|(_, b)| b.span.contains(StratumKind::Heights))
.map(|(j, b)| (rect_gap(a.footprint, b.footprint), j))
.filter(|(gap, _)| *gap >= 1 && *gap <= 8)
.min();
let Some((_, j)) = partner else { return };
let b = ctx.city.buildings[j].footprint;
let fa = a.footprint;
let mut tiles = Vec::new();
if let Some((lo, hi)) = overlap(fa.y, fa.bottom(), b.y, b.bottom()) {
let y = (lo + hi) / 2;
let (x0, x1) = if b.x >= fa.right() {
(fa.right(), b.x)
} else {
(b.right(), fa.x)
};
tiles.extend((x0..x1).map(|x| Pos::new(x, y)));
} else if let Some((lo, hi)) = overlap(fa.x, fa.right(), b.x, b.right()) {
let x = (lo + hi) / 2;
let (y0, y1) = if b.y >= fa.bottom() {
(fa.bottom(), b.y)
} else {
(b.bottom(), fa.y)
};
tiles.extend((y0..y1).map(|y| Pos::new(x, y)));
}
if !tiles.is_empty() && tiles.iter().all(|t| ctx.terrain(k, *t) == Terrain::Void) {
for t in tiles {
ctx.set_terrain(k, t, Terrain::Bridge);
}
}
}
}
impl StageRunner for VerticalsStage {
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> {
self.rng = Some(ctx.rng(Stage::Verticals));
self.queue.clear();
self.queue
.extend((0..ctx.city.arterials.len()).map(Work::Arterial));
self.queue
.extend((0..ctx.scratch.blocks.len()).map(Work::Block));
self.queue.push_back(Work::Rail);
let n = ctx.city.buildings.len();
self.queue.extend((0..n).map(Work::Building));
self.queue.extend((0..n).map(Work::Walkways));
self.queue
.extend((0..ctx.city.districts.len()).map(Work::Platforms));
self.queue.extend((0..n).map(Work::Bridges));
self.total = self.queue.len().max(1);
Ok(())
}
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError> {
let Some(work) = self.queue.pop_front() else {
return Ok(StepOut::Finished);
};
match work {
Work::Arterial(i) => self.arterial(ctx, i),
Work::Block(i) => self.block(ctx, i),
Work::Rail => self.rail(ctx),
Work::Building(i) => self.building(ctx, i),
Work::Walkways(i) => self.walkways(ctx, i),
Work::Platforms(d) => self.platforms(ctx, d),
Work::Bridges(i) => self.bridges(ctx, i),
}
let done = self.total - self.queue.len();
Ok(StepOut::Continue(done as f32 / self.total as f32))
}
}
#[cfg(test)]
mod tests {
use crate::generate::testutil::small;
use crate::params::WaterMode;
use crate::tile::Terrain;
use crate::world::StratumKind;
#[test]
fn undercity_has_tunnels_under_arterials_and_a_rail() {
let c = small(51, WaterMode::Bay, 7);
let uc = c.stratum(StratumKind::Undercity).unwrap();
for path in &c.arterials {
for p in path {
assert!(
matches!(
uc.tiles[*p].terrain,
Terrain::Tunnel | Terrain::Sewage | Terrain::Grate | Terrain::Rail
),
"{p:?} is {:?}",
uc.tiles[*p].terrain
);
}
}
let rail = uc.tiles.count(|t| t.terrain == Terrain::Rail);
assert!(
rail >= (uc.tiles.size().w as f32 * 0.7) as usize,
"rail {rail}"
);
assert!(uc.tiles.count(|t| t.terrain == Terrain::Sewage) > 0);
assert!(uc.tiles.count(|t| t.terrain == Terrain::Grate) > 0);
}
#[test]
fn skyways_and_heights_follow_spans() {
let c = small(51, WaterMode::None, 7);
let sk = c.stratum(StratumKind::Skyways).unwrap();
let hi = c.stratum(StratumKind::Heights).unwrap();
let uc = c.stratum(StratumKind::Undercity).unwrap();
for b in &c.buildings {
let p = b.footprint.center();
if b.span.contains(StratumKind::Skyways) {
assert!(matches!(
sk.tiles[p].terrain,
Terrain::Floor | Terrain::Wall
));
} else {
// Air above a low roof may carry an elevated walkway.
assert!(matches!(
sk.tiles[p].terrain,
Terrain::Void | Terrain::Walkway
));
}
if b.span.contains(StratumKind::Heights) {
assert_eq!(hi.tiles[p].terrain, Terrain::Rooftop);
} else {
assert!(matches!(
hi.tiles[p].terrain,
Terrain::Void | Terrain::Bridge
));
}
if b.span.contains(StratumKind::Undercity) {
// Basement, or a tunnel/rail that already ran under the building.
assert_ne!(uc.tiles[p].terrain, Terrain::Void);
}
}
assert!(sk.tiles.count(|t| t.terrain == Terrain::Walkway) > 50);
}
}