feat(citygen): generator state machine, pathing helpers, terrain stage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 16:46:34 +02:00
parent c8d8deeb0f
commit 7c9928214f
5 changed files with 739 additions and 2 deletions

View File

@@ -0,0 +1,383 @@
//! The resumable generator: eleven chunked stages over a growing `City`.
pub mod terrain;
use crate::flavor::FlavorPack;
use crate::geom::{Grid, Pos, Rect, Size};
use crate::ids::{BuildingId, DistrictId};
use crate::params::{GenError, GenParams};
use crate::rng::stage_rng;
use crate::tile::{Feature, Terrain, Tile};
use crate::world::{BuildingKind, City, HeightClass, StratumKind};
use rand_chacha::ChaCha8Rng;
/// Generation stages, in execution order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Stage {
Terrain,
Districts,
Arterials,
Streets,
Lots,
Verticals,
Interiors,
Connectivity,
Dressing,
Naming,
Net,
}
impl Stage {
pub const ALL: [Stage; 11] = [
Stage::Terrain,
Stage::Districts,
Stage::Arterials,
Stage::Streets,
Stage::Lots,
Stage::Verticals,
Stage::Interiors,
Stage::Connectivity,
Stage::Dressing,
Stage::Naming,
Stage::Net,
];
pub fn label(self) -> &'static str {
match self {
Stage::Terrain => "Shaping terrain",
Stage::Districts => "Zoning districts",
Stage::Arterials => "Routing arterials",
Stage::Streets => "Carving streets",
Stage::Lots => "Raising buildings",
Stage::Verticals => "Stacking strata",
Stage::Interiors => "Fitting interiors",
Stage::Connectivity => "Linking levels",
Stage::Dressing => "Hanging neon",
Stage::Naming => "Naming places",
Stage::Net => "Wiring the Net",
}
}
pub fn index(self) -> u8 {
Stage::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8
}
}
/// What `Generator::step` reports.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Progress {
Running { stage: Stage, pct: f32 },
Done,
}
/// Output of one `StageRunner::step`.
pub(crate) enum StepOut {
Continue(f32),
Finished,
}
/// One stage, run as many small steps.
pub(crate) trait StageRunner {
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError>;
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError>;
}
// Scratch types are populated by stages that land in later tasks; the allows go when they do.
/// A rectangular block between streets, owned by one district.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub(crate) struct Block {
pub rect: Rect,
pub district: DistrictId,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub(crate) enum LotUse {
Building {
kind: BuildingKind,
class: HeightClass,
basement: bool,
},
Plaza,
Parking,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub(crate) struct Lot {
pub rect: Rect,
pub district: DistrictId,
pub use_: LotUse,
}
/// Intermediate state shared between stages; never serialized.
#[allow(dead_code)]
pub(crate) struct Scratch {
pub land: Grid<bool>,
pub wealth: Grid<f32>,
pub decay: Grid<f32>,
pub arterial: Grid<bool>,
pub road: Grid<bool>,
pub blocks: Vec<Block>,
pub lots: Vec<Lot>,
pub lobbies: Vec<(BuildingId, Pos)>,
pub water_edge: Option<crate::geom::Dir>,
}
impl Scratch {
fn new(size: Size) -> Self {
Scratch {
land: Grid::filled(size, true),
wealth: Grid::filled(size, 0.5),
decay: Grid::filled(size, 0.5),
arterial: Grid::filled(size, false),
road: Grid::filled(size, false),
blocks: Vec::new(),
lots: Vec::new(),
lobbies: Vec::new(),
water_edge: None,
}
}
}
/// Everything a stage can see and mutate.
#[allow(dead_code)]
pub(crate) struct GenCtx {
pub city: City,
pub flavor: FlavorPack,
pub seed: u64,
pub scratch: Scratch,
}
#[allow(dead_code)]
impl GenCtx {
pub fn rng(&self, stage: Stage) -> ChaCha8Rng {
stage_rng(self.seed, stage.index())
}
pub fn size(&self) -> Size {
self.city.size
}
pub fn params(&self) -> &GenParams {
&self.city.params
}
pub fn terrain(&self, k: StratumKind, p: Pos) -> Terrain {
self.city
.tile(k, p)
.map(|t| t.terrain)
.unwrap_or(Terrain::Void)
}
pub fn set_terrain(&mut self, k: StratumKind, p: Pos, t: Terrain) -> bool {
match self.city.tile_mut(k, p) {
Some(tile) => {
tile.terrain = t;
true
}
None => false,
}
}
pub fn set_feature(&mut self, k: StratumKind, p: Pos, f: Option<Feature>) -> bool {
match self.city.tile_mut(k, p) {
Some(tile) => {
tile.feature = f;
true
}
None => false,
}
}
pub fn tile(&self, k: StratumKind, p: Pos) -> Option<&Tile> {
self.city.tile(k, p)
}
pub fn in_district(&self, p: Pos, id: DistrictId) -> bool {
self.city
.tile(StratumKind::Street, p)
.is_some_and(|t| t.district == Some(id))
}
pub fn is_land(&self, p: Pos) -> bool {
self.scratch.land.get(p).copied().unwrap_or(false)
}
}
/// A stage that does nothing; placeholder until the real stage lands.
struct NoopStage;
impl StageRunner for NoopStage {
fn begin(&mut self, _ctx: &mut GenCtx) -> Result<(), GenError> {
Ok(())
}
fn step(&mut self, _ctx: &mut GenCtx) -> Result<StepOut, GenError> {
Ok(StepOut::Finished)
}
}
pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
match stage {
Stage::Terrain => Box::new(terrain::TerrainStage::default()),
_ => Box::new(NoopStage),
}
}
/// Drives the stages one small step at a time so a UI can draw between steps.
pub struct Generator {
ctx: GenCtx,
stage_idx: usize,
runner: Option<Box<dyn StageRunner>>,
error: Option<GenError>,
done: bool,
}
impl Generator {
pub fn new(params: GenParams) -> Result<Self, GenError> {
params.validate()?;
let flavor = FlavorPack::load(&params.flavor)?;
let city = City::empty(params.clone());
let scratch = Scratch::new(city.size);
Ok(Generator {
ctx: GenCtx {
seed: params.seed,
city,
flavor,
scratch,
},
stage_idx: 0,
runner: None,
error: None,
done: false,
})
}
/// Runs one small unit of work.
pub fn step(&mut self) -> Progress {
if self.done || self.error.is_some() {
return Progress::Done;
}
let stage = Stage::ALL[self.stage_idx];
if self.runner.is_none() {
let mut r = runner_for(stage);
if let Err(e) = r.begin(&mut self.ctx) {
self.error = Some(e);
return Progress::Done;
}
self.runner = Some(r);
}
let out = self
.runner
.as_mut()
.expect("runner set above")
.step(&mut self.ctx);
match out {
Ok(StepOut::Continue(pct)) => Progress::Running {
stage,
pct: pct.clamp(0.0, 1.0),
},
Ok(StepOut::Finished) => {
self.runner = None;
self.stage_idx += 1;
if self.stage_idx == Stage::ALL.len() {
self.done = true;
Progress::Done
} else {
Progress::Running {
stage: Stage::ALL[self.stage_idx],
pct: 0.0,
}
}
}
Err(e) => {
self.error = Some(e);
Progress::Done
}
}
}
/// The city so far; partial while running.
pub fn city(&self) -> &City {
&self.ctx.city
}
pub fn error(&self) -> Option<&GenError> {
self.error.as_ref()
}
pub fn is_done(&self) -> bool {
self.done || self.error.is_some()
}
/// Validates and returns the finished city.
pub fn finish(self) -> Result<City, GenError> {
if let Some(e) = self.error {
return Err(e);
}
self.ctx.city.validate().map_err(GenError::Invalid)?;
Ok(self.ctx.city)
}
/// Runs every stage to completion.
pub fn run(params: GenParams) -> Result<City, GenError> {
let mut g = Generator::new(params)?;
while let Progress::Running { .. } = g.step() {}
g.finish()
}
/// Test helper: run until `stage` has finished and hand back the context.
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn run_until(params: GenParams, stage: Stage) -> GenCtx {
let mut g = Generator::new(params).expect("valid params");
loop {
match g.step() {
Progress::Running { stage: s, .. } if s.index() > stage.index() => break,
Progress::Running { .. } => {}
Progress::Done => break,
}
}
assert!(g.error.is_none(), "{:?}", g.error);
g.ctx
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{GenParams, SizePreset};
#[test]
fn generator_walks_all_stages_in_order() {
let mut g = Generator::new(GenParams {
seed: 1,
size: SizePreset::Custom { w: 80, h: 40 },
districts: 4,
..Default::default()
})
.unwrap();
let mut seen = vec![];
while let Progress::Running { stage, pct } = g.step() {
assert!((0.0..=1.0).contains(&pct));
if seen.last() != Some(&stage) {
seen.push(stage);
}
}
assert_eq!(seen, Stage::ALL.to_vec());
assert!(g.error().is_none());
assert!(g.finish().is_ok());
}
#[test]
fn generator_rejects_invalid_params() {
assert!(
Generator::new(GenParams {
districts: 99,
..Default::default()
})
.is_err()
);
}
}

View File

@@ -0,0 +1,135 @@
//! Stage 1: water and land.
use super::{GenCtx, Stage, StageRunner, StepOut};
use crate::geom::{Dir, Pos};
use crate::params::{GenError, WaterMode};
use crate::tile::Terrain;
use crate::world::StratumKind;
use noise::{NoiseFn, OpenSimplex};
use rand::Rng;
use rand::seq::IndexedRandom;
#[derive(Default)]
pub(crate) struct TerrainStage {
row: i32,
noise: Option<OpenSimplex>,
edge: Option<Dir>,
river_axis_horizontal: bool,
river_offset: f64,
}
impl TerrainStage {
fn is_water(&self, ctx: &GenCtx, p: Pos) -> bool {
let size = ctx.size();
let (w, h) = (size.w as f64, size.h as f64);
let noise = self.noise.as_ref().expect("begin sets noise");
match ctx.params().water {
WaterMode::None => false,
WaterMode::Bay => {
let edge = self.edge.expect("begin sets edge");
// `t` runs along the edge, `d` is the distance from it.
let (t, d, extent) = match edge {
Dir::N => (p.x as f64, p.y as f64, h),
Dir::S => (p.x as f64, h - 1.0 - p.y as f64, h),
Dir::W => (p.y as f64, p.x as f64, w),
_ => (p.y as f64, w - 1.0 - p.x as f64, w),
};
let depth = 0.15 * extent + noise.get([t * 0.04, 7.3]) * 0.06 * extent;
d < depth
}
WaterMode::River => {
let (t, c, extent) = if self.river_axis_horizontal {
(p.x as f64, p.y as f64, h)
} else {
(p.y as f64, p.x as f64, w)
};
let centre =
extent / 2.0 + self.river_offset + noise.get([t * 0.02, 3.1]) * 0.25 * extent;
let half = 3.0 + (noise.get([t * 0.05, 9.7]) + 1.0);
(c - centre).abs() < half
}
}
}
}
impl StageRunner for TerrainStage {
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> {
let mut rng = ctx.rng(Stage::Terrain);
self.noise = Some(OpenSimplex::new(ctx.seed as u32));
self.edge = Dir::CARDINAL.choose(&mut rng).copied();
self.river_axis_horizontal = rng.random_bool(0.5);
self.river_offset = rng.random_range(-0.1..0.1) * ctx.size().h as f64;
ctx.scratch.water_edge = self.edge;
self.row = 0;
Ok(())
}
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError> {
let size = ctx.size();
if self.row >= size.h as i32 {
return Ok(StepOut::Finished);
}
let y = self.row;
for x in 0..size.w as i32 {
let p = Pos::new(x, y);
if self.is_water(ctx, p) {
ctx.scratch.land.set(p, false);
ctx.set_terrain(StratumKind::Street, p, Terrain::Water);
}
}
self.row += 1;
Ok(StepOut::Continue(self.row as f32 / size.h as f32))
}
}
#[cfg(test)]
mod tests {
use crate::generate::Generator;
use crate::params::{GenParams, SizePreset, WaterMode};
use crate::tile::Terrain;
use crate::world::StratumKind;
#[test]
fn bay_covers_ten_to_twenty_percent_of_street() {
for seed in 0..5 {
let c = Generator::run(GenParams {
seed,
water: WaterMode::Bay,
size: SizePreset::Custom { w: 120, h: 60 },
districts: 4,
..Default::default()
})
.unwrap();
let st = c.stratum(StratumKind::Street).unwrap();
let water = st.tiles.count(|t| t.terrain == Terrain::Water) as f32
/ st.tiles.size().area() as f32;
assert!((0.08..=0.25).contains(&water), "seed {seed}: {water}");
}
let c = Generator::run(GenParams {
seed: 3,
water: WaterMode::None,
size: SizePreset::Custom { w: 120, h: 60 },
districts: 4,
..Default::default()
})
.unwrap();
assert_eq!(
c.stratum(StratumKind::Street)
.unwrap()
.tiles
.count(|t| t.terrain == Terrain::Water),
0
);
let c = Generator::run(GenParams {
seed: 3,
water: WaterMode::River,
size: SizePreset::Custom { w: 120, h: 60 },
districts: 4,
..Default::default()
})
.unwrap();
let st = c.stratum(StratumKind::Street).unwrap();
let water = st.tiles.count(|t| t.terrain == Terrain::Water);
assert!(water > 300 && water < 1500, "river tiles {water}");
}
}

View File

@@ -1,10 +1,12 @@
//! World model and generator for the cyberpunk city. //! World model and generator for the cyberpunk city.
pub mod flavor; pub mod flavor;
pub mod generate;
pub mod geom; pub mod geom;
pub mod ids; pub mod ids;
pub mod net; pub mod net;
pub mod params; pub mod params;
pub mod pathing;
pub mod rng; pub mod rng;
pub mod theme; pub mod theme;
pub mod tile; pub mod tile;
@@ -12,6 +14,7 @@ pub mod validate;
pub mod world; pub mod world;
pub use flavor::FlavorPack; pub use flavor::FlavorPack;
pub use generate::{Generator, Progress, Stage};
pub use geom::{Dir, Grid, Pos, Rect, Size}; pub use geom::{Dir, Grid, Pos, Rect, Size};
pub use ids::*; pub use ids::*;
pub use net::*; pub use net::*;

View File

@@ -111,13 +111,13 @@ impl GenParams {
format!("{} is outside 4..=12", self.districts), format!("{} is outside 4..=12", self.districts),
)); ));
} }
if (w as u64) * (h as u64) < self.districts as u64 * 1000 { if (w as u64) * (h as u64) < self.districts as u64 * 800 {
return Err(bad( return Err(bad(
"districts", "districts",
format!( format!(
"too many districts for map size ({} districts need at least {} tiles, map has {})", "too many districts for map size ({} districts need at least {} tiles, map has {})",
self.districts, self.districts,
self.districts as u64 * 1000, self.districts as u64 * 800,
w as u64 * h as u64 w as u64 * h as u64
), ),
)); ));

View File

@@ -0,0 +1,216 @@
//! Grid search helpers shared by generation stages: flood fill, components, A*, BFS, Bresenham.
use crate::geom::{Grid, Pos, Size};
use std::cmp::Reverse;
use std::collections::{BinaryHeap, VecDeque};
/// All positions 4-connected to `start` through `passable`. Empty if `start` is not passable.
pub fn flood_fill(size: Size, start: Pos, passable: impl Fn(Pos) -> bool) -> Vec<Pos> {
let mut seen = Grid::filled(size, false);
let mut out = Vec::new();
if !size.contains(start) || !passable(start) {
return out;
}
let mut queue = VecDeque::from([start]);
seen.set(start, true);
while let Some(p) = queue.pop_front() {
out.push(p);
for n in p.neighbors4() {
if size.contains(n) && !seen[n] && passable(n) {
seen.set(n, true);
queue.push_back(n);
}
}
}
out
}
/// Every 4-connected component of passable tiles, largest first (ties: earliest first cell).
pub fn components(size: Size, passable: impl Fn(Pos) -> bool) -> Vec<Vec<Pos>> {
let mut seen = Grid::filled(size, false);
let mut comps: Vec<Vec<Pos>> = Vec::new();
for y in 0..size.h as i32 {
for x in 0..size.w as i32 {
let start = Pos::new(x, y);
if seen[start] || !passable(start) {
continue;
}
let mut comp = Vec::new();
let mut queue = VecDeque::from([start]);
seen.set(start, true);
while let Some(p) = queue.pop_front() {
comp.push(p);
for n in p.neighbors4() {
if size.contains(n) && !seen[n] && passable(n) {
seen.set(n, true);
queue.push_back(n);
}
}
}
comps.push(comp);
}
}
comps.sort_by_key(|c| Reverse(c.len()));
comps
}
/// 4-neighbour A*. `cost(from, to)` returns `None` for impassable steps. Path includes both ends.
pub fn astar(
size: Size,
start: Pos,
goal: Pos,
cost: impl Fn(Pos, Pos) -> Option<u32>,
) -> Option<Vec<Pos>> {
if !size.contains(start) || !size.contains(goal) {
return None;
}
let mut g = Grid::filled(size, u32::MAX);
let mut came: Grid<Option<Pos>> = Grid::filled(size, None);
let mut heap = BinaryHeap::new();
let mut counter = 0u32;
g.set(start, 0);
heap.push(Reverse((start.manhattan(goal) as u32, counter, start)));
while let Some(Reverse((_, _, p))) = heap.pop() {
if p == goal {
let mut path = vec![p];
let mut cur = p;
while let Some(prev) = came[cur] {
path.push(prev);
cur = prev;
}
path.reverse();
return Some(path);
}
let gp = g[p];
for n in p.neighbors4() {
if !size.contains(n) {
continue;
}
let Some(c) = cost(p, n) else { continue };
let ng = gp.saturating_add(c);
if ng < g[n] {
g.set(n, ng);
came.set(n, Some(p));
counter += 1;
heap.push(Reverse((ng + n.manhattan(goal) as u32, counter, n)));
}
}
}
None
}
/// Shortest 4-path from `start` through `passable` tiles to the nearest `target` tile,
/// at most `max` steps long. The target tile itself need not be passable.
pub fn bfs_nearest(
size: Size,
start: Pos,
passable: impl Fn(Pos) -> bool,
target: impl Fn(Pos) -> bool,
max: usize,
) -> Option<Vec<Pos>> {
if !size.contains(start) {
return None;
}
let mut came: Grid<Option<Pos>> = Grid::filled(size, None);
let mut dist = Grid::filled(size, usize::MAX);
let mut queue = VecDeque::from([start]);
dist.set(start, 0);
while let Some(p) = queue.pop_front() {
let d = dist[p];
if target(p) && p != start {
let mut path = vec![p];
let mut cur = p;
while let Some(prev) = came[cur] {
path.push(prev);
cur = prev;
}
path.reverse();
return Some(path);
}
if d >= max {
continue;
}
for n in p.neighbors4() {
if size.contains(n) && dist[n] == usize::MAX && (passable(n) || target(n)) {
dist.set(n, d + 1);
came.set(n, Some(p));
queue.push_back(n);
}
}
}
None
}
/// Integer line from `a` to `b`, both inclusive.
pub fn bresenham(a: Pos, b: Pos) -> Vec<Pos> {
let mut out = Vec::new();
let (mut x, mut y) = (a.x, a.y);
let dx = (b.x - a.x).abs();
let dy = -(b.y - a.y).abs();
let sx = if a.x < b.x { 1 } else { -1 };
let sy = if a.y < b.y { 1 } else { -1 };
let mut err = dx + dy;
loop {
out.push(Pos::new(x, y));
if x == b.x && y == b.y {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::{Pos, Size};
#[test]
fn astar_routes_around_block() {
let size = Size { w: 5, h: 5 };
let wall = |p: Pos| p.x == 2 && p.y != 4;
let path = astar(size, Pos::new(0, 0), Pos::new(4, 0), |_, to| {
if wall(to) { None } else { Some(1) }
})
.unwrap();
assert_eq!(path[0], Pos::new(0, 0));
assert_eq!(*path.last().unwrap(), Pos::new(4, 0));
assert!(path.iter().all(|p| !wall(*p)));
assert_eq!(path.len(), 13);
}
#[test]
fn components_are_largest_first() {
let size = Size { w: 6, h: 1 };
let pass = |p: Pos| p.x != 2;
let c = components(size, pass);
assert_eq!(c.len(), 2);
assert_eq!(c[0].len(), 3);
assert_eq!(c[1].len(), 2);
assert_eq!(flood_fill(size, Pos::new(0, 0), pass).len(), 2);
}
#[test]
fn bfs_nearest_finds_target() {
let size = Size { w: 10, h: 1 };
let p = bfs_nearest(size, Pos::new(0, 0), |_| true, |p| p.x == 7, 20).unwrap();
assert_eq!(p.len(), 8);
assert!(bfs_nearest(size, Pos::new(0, 0), |_| true, |p| p.x == 7, 3).is_none());
}
#[test]
fn bresenham_endpoints() {
let line = bresenham(Pos::new(0, 0), Pos::new(3, 1));
assert_eq!(line.first(), Some(&Pos::new(0, 0)));
assert_eq!(line.last(), Some(&Pos::new(3, 1)));
assert_eq!(line.len(), 4);
}
}