diff --git a/crates/citygen/src/generate/arterials.rs b/crates/citygen/src/generate/arterials.rs index f47226c..39ed42d 100644 --- a/crates/citygen/src/generate/arterials.rs +++ b/crates/citygen/src/generate/arterials.rs @@ -130,7 +130,7 @@ impl StageRunner for ArterialsStage { #[cfg(test)] mod tests { use crate::generate::testutil::small; - use crate::geom::Rect; + use crate::geom::{Grid, Rect}; use crate::params::WaterMode; use crate::pathing::components; use crate::tile::Terrain; @@ -152,7 +152,16 @@ mod tests { .any(|p| st.tiles.get(p).is_some_and(|t| t.terrain == Terrain::Road)); assert!(near, "district {:?} centre not on road", d.id); } - let comps = components(st.tiles.size(), |p| st.tiles[p].terrain == Terrain::Road); + // The arterial stamps themselves form one connected network. + let mut mask = Grid::filled(st.tiles.size(), false); + for path in &c.arterials { + for p in path { + mask.fill_rect(Rect::new(p.x - 1, p.y - 1, 4, 4), true); + } + } + let comps = components(st.tiles.size(), |p| { + mask[p] && matches!(st.tiles[p].terrain, Terrain::Road | Terrain::Bridge) + }); assert_eq!(comps.len(), 1); } diff --git a/crates/citygen/src/generate/mod.rs b/crates/citygen/src/generate/mod.rs index 8c43858..a9b72fe 100644 --- a/crates/citygen/src/generate/mod.rs +++ b/crates/citygen/src/generate/mod.rs @@ -2,6 +2,7 @@ pub mod arterials; pub mod districts; +pub mod streets; pub mod terrain; use crate::flavor::FlavorPack; @@ -225,6 +226,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box { Stage::Terrain => Box::new(terrain::TerrainStage::default()), Stage::Districts => Box::new(districts::DistrictsStage::default()), Stage::Arterials => Box::new(arterials::ArterialsStage::default()), + Stage::Streets => Box::new(streets::StreetsStage::default()), _ => Box::new(NoopStage), } } diff --git a/crates/citygen/src/generate/streets.rs b/crates/citygen/src/generate/streets.rs new file mode 100644 index 0000000..6de03ae --- /dev/null +++ b/crates/citygen/src/generate/streets.rs @@ -0,0 +1,495 @@ +//! Stage 4: per-district street subdivision and block extraction. + +use super::{Block, GenCtx, Stage, StageRunner, StepOut}; +use crate::geom::{Pos, Rect}; +use crate::ids::DistrictId; +use crate::params::GenError; +use crate::rng::chance; +use crate::theme::StreetStyle; +use crate::tile::{Feature, Terrain}; +use crate::world::StratumKind; +use rand::Rng; +use rand_chacha::ChaCha8Rng; + +#[derive(Default)] +pub(crate) struct StreetsStage { + next: usize, + rng: Option, +} + +/// Paints `terrain` at `p` only on untouched land of this district. +fn paint_cell(ctx: &mut GenCtx, p: Pos, d: DistrictId, terrain: Terrain) { + if ctx.is_land(p) + && ctx.in_district(p, d) + && ctx.terrain(StratumKind::Street, p) == Terrain::Void + { + ctx.set_terrain(StratumKind::Street, p, terrain); + if terrain != Terrain::Sidewalk { + ctx.scratch.road.set(p, true); + } + } +} + +/// A straight street segment to paint. +#[derive(Clone, Copy)] +struct Line { + terrain: Terrain, + width: i32, + sidewalk: bool, +} + +impl Line { + const fn road(width: i32, sidewalk: bool) -> Self { + Line { + terrain: Terrain::Road, + width, + sidewalk, + } + } + const ALLEY: Line = Line { + terrain: Terrain::Alley, + width: 1, + sidewalk: false, + }; +} + +/// A vertical street starting at column `x`, rows `y0..y1`. +fn paint_vline(ctx: &mut GenCtx, x: i32, y0: i32, y1: i32, line: Line, d: DistrictId) { + for y in y0..y1 { + for dx in 0..line.width { + paint_cell(ctx, Pos::new(x + dx, y), d, line.terrain); + } + if line.sidewalk { + paint_cell(ctx, Pos::new(x - 1, y), d, Terrain::Sidewalk); + paint_cell(ctx, Pos::new(x + line.width, y), d, Terrain::Sidewalk); + } + } +} + +/// A horizontal street starting at row `y`, columns `x0..x1`. +fn paint_hline(ctx: &mut GenCtx, y: i32, x0: i32, x1: i32, line: Line, d: DistrictId) { + for x in x0..x1 { + for dy in 0..line.width { + paint_cell(ctx, Pos::new(x, y + dy), d, line.terrain); + } + if line.sidewalk { + paint_cell(ctx, Pos::new(x, y - 1), d, Terrain::Sidewalk); + paint_cell(ctx, Pos::new(x, y + line.width), d, Terrain::Sidewalk); + } + } +} + +/// Positions of parallel street lines across `[lo, hi)` with the given pitch and a random offset. +fn line_positions(rng: &mut ChaCha8Rng, lo: i32, hi: i32, pitch: i32) -> Vec { + let offset = rng.random_range(0..pitch.max(1)); + let mut out = Vec::new(); + let mut x = lo + offset; + while x < hi { + out.push(x); + x += pitch; + } + out +} + +/// Blocks between consecutive line positions; `gap` is the street width plus sidewalks. +fn cells_between(lines: &[i32], lo: i32, hi: i32, gap: i32, pad: i32) -> Vec<(i32, i32)> { + let mut edges = vec![lo - gap]; + edges.extend_from_slice(lines); + edges.push(hi); + edges + .windows(2) + .map(|w| (w[0] + gap + pad, w[1] - pad)) + .filter(|(a, b)| b > a) + .collect() +} + +struct Carver<'a> { + ctx: &'a mut GenCtx, + rng: &'a mut ChaCha8Rng, + d: DistrictId, + bounds: Rect, + candidates: Vec, +} + +impl Carver<'_> { + fn grid(&mut self, bw: i32, bh: i32, sw: i32, sidewalk: bool) -> (Vec, Vec) { + let pad = if sidewalk { 1 } else { 0 }; + let b = self.bounds; + let xs = line_positions(self.rng, b.x, b.right(), bw + sw + 2 * pad); + let ys = line_positions(self.rng, b.y, b.bottom(), bh + sw + 2 * pad); + for x in &xs { + paint_vline( + self.ctx, + *x, + b.y, + b.bottom(), + Line::road(sw, sidewalk), + self.d, + ); + } + for y in &ys { + paint_hline( + self.ctx, + *y, + b.x, + b.right(), + Line::road(sw, sidewalk), + self.d, + ); + } + for (x0, x1) in cells_between(&xs, b.x, b.right(), sw, pad) { + for (y0, y1) in cells_between(&ys, b.y, b.bottom(), sw, pad) { + self.candidates.push(Rect::new(x0, y0, x1 - x0, y1 - y0)); + } + } + (xs, ys) + } + + fn organic(&mut self, rect: Rect, max_w: i32, max_h: i32, depth: u8) { + const MIN_W: i32 = 8; + const MIN_H: i32 = 6; + let split_w = rect.w > max_w && rect.w > 2 * MIN_W; + let split_h = rect.h > max_h && rect.h > 2 * MIN_H; + if depth == 0 || (!split_w && !split_h) { + self.candidates.push(rect); + return; + } + let vertical = if split_w && split_h { + rect.w >= rect.h + } else { + split_w + }; + let jitter = self.rng.random_range(0.35..0.65); + if vertical { + let s = (rect.x + (rect.w as f32 * jitter) as i32) + .clamp(rect.x + MIN_W, rect.right() - MIN_W - 1); + paint_vline(self.ctx, s, rect.y, rect.bottom(), Line::ALLEY, self.d); + self.organic( + Rect::new(rect.x, rect.y, s - rect.x, rect.h), + max_w, + max_h, + depth - 1, + ); + self.organic( + Rect::new(s + 1, rect.y, rect.right() - s - 1, rect.h), + max_w, + max_h, + depth - 1, + ); + } else { + let s = (rect.y + (rect.h as f32 * jitter) as i32) + .clamp(rect.y + MIN_H, rect.bottom() - MIN_H - 1); + paint_hline(self.ctx, s, rect.x, rect.right(), Line::ALLEY, self.d); + self.organic( + Rect::new(rect.x, rect.y, rect.w, s - rect.y), + max_w, + max_h, + depth - 1, + ); + self.organic( + Rect::new(rect.x, s + 1, rect.w, rect.bottom() - s - 1), + max_w, + max_h, + depth - 1, + ); + } + } + + fn rows(&mut self, row_h: i32, access_pitch: i32) { + let b = self.bounds; + let ys = line_positions(self.rng, b.y, b.bottom(), row_h + 4); + let xs = line_positions(self.rng, b.x, b.right(), access_pitch); + for y in &ys { + paint_hline(self.ctx, *y, b.x, b.right(), Line::road(2, true), self.d); + } + for x in &xs { + paint_vline(self.ctx, *x, b.y, b.bottom(), Line::road(2, true), self.d); + } + for (x0, x1) in cells_between(&xs, b.x, b.right(), 2, 1) { + for (y0, y1) in cells_between(&ys, b.y, b.bottom(), 2, 1) { + self.candidates.push(Rect::new(x0, y0, x1 - x0, y1 - y0)); + } + } + } + + fn shattered(&mut self) { + let (xs, ys) = self.grid(16, 10, 2, false); + let b = self.bounds; + // Cut 30% of the vertical segments between consecutive horizontal streets. + let mut ybounds = vec![b.y - 2]; + ybounds.extend_from_slice(&ys); + ybounds.push(b.bottom()); + for x in &xs { + for w in ybounds.windows(2) { + if !chance(self.rng, 0.3) { + continue; + } + let (y0, y1) = (w[0] + 2, w[1]); + if y1 - y0 < 3 { + continue; + } + let mut cut = Vec::new(); + for y in y0..y1 { + for dx in 0..2 { + let p = Pos::new(x + dx, y); + let mine = self.ctx.in_district(p, self.d) + && self.ctx.terrain(StratumKind::Street, p) == Terrain::Road + && !self.ctx.scratch.arterial[p]; + if mine { + self.ctx.set_terrain(StratumKind::Street, p, Terrain::Alley); + cut.push(p); + } + } + } + if let (Some(first), Some(last)) = (cut.first().copied(), cut.last().copied()) { + self.ctx + .set_feature(StratumKind::Street, first, Some(Feature::Barrier)); + self.ctx + .set_feature(StratumKind::Street, last, Some(Feature::Barrier)); + } + } + } + } + + fn strip(&mut self) { + let b = self.bounds; + let center = self.ctx.city.district(self.d).center; + let horizontal = b.w >= b.h; + if horizontal { + let y = center.y - 1; + paint_hline(self.ctx, y, b.x, b.right(), Line::road(3, true), self.d); + let xs = line_positions(self.rng, b.x, b.right(), 10); + for x in &xs { + paint_vline(self.ctx, *x, b.y, b.bottom(), Line::ALLEY, self.d); + } + for (x0, x1) in cells_between(&xs, b.x, b.right(), 1, 0) { + self.candidates + .push(Rect::new(x0, b.y, x1 - x0, y - 1 - b.y)); + self.candidates + .push(Rect::new(x0, y + 4, x1 - x0, b.bottom() - (y + 4))); + } + } else { + let x = center.x - 1; + paint_vline(self.ctx, x, b.y, b.bottom(), Line::road(3, true), self.d); + let ys = line_positions(self.rng, b.y, b.bottom(), 10); + for y in &ys { + paint_hline(self.ctx, *y, b.x, b.right(), Line::ALLEY, self.d); + } + for (y0, y1) in cells_between(&ys, b.y, b.bottom(), 1, 0) { + self.candidates + .push(Rect::new(b.x, y0, x - 1 - b.x, y1 - y0)); + self.candidates + .push(Rect::new(x + 4, y0, b.right() - (x + 4), y1 - y0)); + } + } + } + + /// Shrinks a candidate until every cell is untouched land of this district. + fn fit_block(&self, mut r: Rect) -> Option { + let bad = |ctx: &GenCtx, p: Pos| { + !ctx.is_land(p) + || !ctx.in_district(p, self.d) + || ctx.terrain(StratumKind::Street, p) != Terrain::Void + }; + for _ in 0..12 { + if r.w < 5 || r.h < 4 { + return None; + } + let top = (r.x..r.right()) + .filter(|x| bad(self.ctx, Pos::new(*x, r.y))) + .count(); + let bottom = (r.x..r.right()) + .filter(|x| bad(self.ctx, Pos::new(*x, r.bottom() - 1))) + .count(); + let left = (r.y..r.bottom()) + .filter(|y| bad(self.ctx, Pos::new(r.x, *y))) + .count(); + let right = (r.y..r.bottom()) + .filter(|y| bad(self.ctx, Pos::new(r.right() - 1, *y))) + .count(); + if top + bottom + left + right == 0 { + let interior_bad = r.inset(1).iter().any(|p| bad(self.ctx, p)); + if !interior_bad { + return Some(r); + } + // An interior obstacle (arterial crossing); shrink the side nearest to it. + let p = r + .inset(1) + .iter() + .find(|p| bad(self.ctx, *p)) + .expect("interior_bad"); + if (p.x - r.x) * r.h < (p.y - r.y) * r.w { + r = Rect::new(p.x + 1, r.y, r.right() - p.x - 1, r.h); + } else { + r = Rect::new(r.x, p.y + 1, r.w, r.bottom() - p.y - 1); + } + continue; + } + let top_f = top as f32 / r.w as f32; + let bottom_f = bottom as f32 / r.w as f32; + let left_f = left as f32 / r.h as f32; + let right_f = right as f32 / r.h as f32; + let max = top_f.max(bottom_f).max(left_f).max(right_f); + if max == top_f { + r = Rect::new(r.x, r.y + 1, r.w, r.h - 1); + } else if max == bottom_f { + r = Rect::new(r.x, r.y, r.w, r.h - 1); + } else if max == left_f { + r = Rect::new(r.x + 1, r.y, r.w - 1, r.h); + } else { + r = Rect::new(r.x, r.y, r.w - 1, r.h); + } + } + None + } +} + +impl StreetsStage { + fn carve_district(&mut self, ctx: &mut GenCtx, idx: usize) { + let district = ctx.city.districts[idx].clone(); + let spec = district.theme.spec(); + let rng = self.rng.as_mut().expect("begin sets rng"); + let mut carver = Carver { + ctx, + rng, + d: district.id, + bounds: district.bounds, + candidates: Vec::new(), + }; + match spec.street_style { + StreetStyle::Grid => { + carver.grid(spec.block.0, spec.block.1, 2, true); + } + StreetStyle::Superblock => { + carver.grid(spec.block.0, spec.block.1, 2, true); + } + StreetStyle::Organic => { + let (max_w, max_h) = (spec.block.0 * 3 / 2, spec.block.1 * 3 / 2); + let b = carver.bounds; + carver.organic(b, max_w, max_h, 7); + } + StreetStyle::Rows => carver.rows(14, 24), + StreetStyle::Shattered => carver.shattered(), + StreetStyle::Strip => carver.strip(), + } + let candidates = std::mem::take(&mut carver.candidates); + let mut blocks: Vec = Vec::new(); + for c in candidates { + let Some(c) = c.clamp_to(carver.bounds) else { + continue; + }; + if let Some(fitted) = carver.fit_block(c) + && blocks.iter().all(|b| !b.rect.intersects(fitted)) + { + blocks.push(Block { + rect: fitted, + district: district.id, + }); + } + } + carver.ctx.scratch.blocks.extend(blocks); + } +} + +impl StageRunner for StreetsStage { + fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { + self.rng = Some(ctx.rng(Stage::Streets)); + self.next = 0; + ctx.scratch.blocks.clear(); + Ok(()) + } + + fn step(&mut self, ctx: &mut GenCtx) -> Result { + let n = ctx.city.districts.len(); + if self.next >= n { + return Ok(StepOut::Finished); + } + self.carve_district(ctx, self.next); + self.next += 1; + Ok(StepOut::Continue(self.next as f32 / n as f32)) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::testutil::small; + use crate::generate::{Generator, Stage}; + use crate::params::{GenParams, SizePreset, WaterMode}; + use crate::pathing::components; + use crate::tile::{Feature, Terrain}; + use crate::world::{StratumKind, Theme}; + + #[test] + fn blocks_are_inside_districts_and_off_roads() { + let ctx = Generator::run_until( + GenParams { + seed: 31, + water: WaterMode::Bay, + districts: 7, + size: SizePreset::Custom { w: 120, h: 60 }, + ..Default::default() + }, + Stage::Streets, + ); + let st = ctx.city.stratum(StratumKind::Street).unwrap(); + let blocks = &ctx.scratch.blocks; + assert!(blocks.len() > 20, "only {} blocks", blocks.len()); + for b in blocks { + assert!(b.rect.w >= 5 && b.rect.h >= 4, "{:?}", b.rect); + for p in b.rect.iter() { + let t = &st.tiles[p]; + assert_eq!(t.district, Some(b.district), "{p:?} in {:?}", b.rect); + assert!( + !matches!( + t.terrain, + Terrain::Road | Terrain::Alley | Terrain::Water | Terrain::Sidewalk + ), + "{p:?} is {:?}", + t.terrain + ); + } + } + for (i, a) in blocks.iter().enumerate() { + for b in &blocks[i + 1..] { + assert!( + !a.rect.intersects(b.rect), + "{:?} overlaps {:?}", + a.rect, + b.rect + ); + } + } + } + + #[test] + fn street_network_is_connected_per_street_level() { + let c = small(31, WaterMode::None, 6); + let st = c.stratum(StratumKind::Street).unwrap(); + let comps = components(st.tiles.size(), |p| { + matches!( + st.tiles[p].terrain, + Terrain::Road | Terrain::Alley | Terrain::Sidewalk + ) + }); + let total: usize = comps.iter().map(|c| c.len()).sum(); + let share = comps[0].len() as f32 / total as f32; + assert!(share > 0.97, "main street component only {share}"); + } + + #[test] + fn combat_zone_has_barriers_and_grid_has_none() { + let c = small(31, WaterMode::None, 7); + let st = c.stratum(StratumKind::Street).unwrap(); + let count = |theme: Theme| { + st.tiles + .iter() + .filter(|(_, t)| { + t.feature == Some(Feature::Barrier) + && t.district.map(|d| c.district(d).theme) == Some(theme) + }) + .count() + }; + assert!(count(Theme::CombatZone) > 0); + assert_eq!(count(Theme::CorpCore), 0); + } +}