feat(citygen): districts stage with themed Voronoi regions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
343
crates/citygen/src/generate/districts.rs
Normal file
343
crates/citygen/src/generate/districts.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
//! Stage 2: district seeds, Voronoi regions, theme assignment, wealth and decay fields.
|
||||
|
||||
use super::{GenCtx, Stage, StageRunner, StepOut};
|
||||
use crate::geom::{Pos, Rect};
|
||||
use crate::ids::DistrictId;
|
||||
use crate::params::GenError;
|
||||
use crate::tile::Terrain;
|
||||
use crate::world::{District, StratumKind, Theme};
|
||||
use noise::{NoiseFn, OpenSimplex};
|
||||
use rand::Rng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DistrictsStage {
|
||||
seeds: Vec<Pos>,
|
||||
centroid: Pos,
|
||||
row: i32,
|
||||
finalized: bool,
|
||||
noise: Option<OpenSimplex>,
|
||||
}
|
||||
|
||||
const FILLER_THEMES: [Theme; 4] = [
|
||||
Theme::Megablocks,
|
||||
Theme::NightMarket,
|
||||
Theme::NeonStrip,
|
||||
Theme::OldTown,
|
||||
];
|
||||
|
||||
impl DistrictsStage {
|
||||
/// Poisson-style rejection sampling over land tiles; relaxes spacing until `n` seeds fit.
|
||||
fn place_seeds(rng: &mut ChaCha8Rng, land: &[Pos], n: usize) -> Vec<Pos> {
|
||||
let mut spacing = (land.len() as f32 / n as f32).sqrt() * 0.7;
|
||||
let mut seeds: Vec<Pos> = Vec::with_capacity(n);
|
||||
while seeds.len() < n {
|
||||
let mut attempts = 0;
|
||||
while seeds.len() < n && attempts < 4000 {
|
||||
attempts += 1;
|
||||
let cand = land[rng.random_range(0..land.len())];
|
||||
let ok = seeds.iter().all(|s| s.chebyshev(cand) as f32 >= spacing);
|
||||
if ok {
|
||||
seeds.push(cand);
|
||||
}
|
||||
}
|
||||
spacing *= 0.8;
|
||||
if spacing < 1.0 && seeds.len() < n {
|
||||
// Degenerate map: fall back to any distinct land tiles.
|
||||
for p in land {
|
||||
if seeds.len() == n {
|
||||
break;
|
||||
}
|
||||
if !seeds.contains(p) {
|
||||
seeds.push(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
seeds
|
||||
}
|
||||
|
||||
fn nearest_seed(&self, p: Pos) -> DistrictId {
|
||||
let mut best = 0;
|
||||
let mut best_d = i64::MAX;
|
||||
for (i, s) in self.seeds.iter().enumerate() {
|
||||
let d = s.dist2(p);
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
DistrictId(best as u32)
|
||||
}
|
||||
|
||||
fn assign_themes(&self, ctx: &GenCtx) -> Vec<Theme> {
|
||||
let n = self.seeds.len();
|
||||
let mut themes: Vec<Option<Theme>> = vec![None; n];
|
||||
let st = ctx
|
||||
.city
|
||||
.stratum(StratumKind::Street)
|
||||
.expect("street stratum");
|
||||
|
||||
let core = (0..n)
|
||||
.min_by_key(|i| self.seeds[*i].dist2(self.centroid))
|
||||
.expect("at least one seed");
|
||||
themes[core] = Some(Theme::CorpCore);
|
||||
|
||||
// Combat zone: farthest from the core.
|
||||
let combat = (0..n)
|
||||
.filter(|i| themes[*i].is_none())
|
||||
.max_by_key(|i| self.seeds[*i].dist2(self.seeds[core]))
|
||||
.expect("at least two seeds");
|
||||
themes[combat] = Some(Theme::CombatZone);
|
||||
|
||||
// Docks: the remaining district with the most water-adjacent tiles.
|
||||
let mut water_adj = vec![0usize; n];
|
||||
for (p, t) in st.tiles.iter() {
|
||||
if let Some(d) = t.district {
|
||||
let touches = p
|
||||
.neighbors4()
|
||||
.any(|q| st.tiles.get(q).is_some_and(|u| u.terrain == Terrain::Water));
|
||||
if touches {
|
||||
water_adj[d.index()] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let docks = (0..n)
|
||||
.filter(|i| themes[*i].is_none() && water_adj[*i] > 0)
|
||||
.max_by_key(|i| water_adj[*i]);
|
||||
if let Some(d) = docks {
|
||||
themes[d] = Some(Theme::Docks);
|
||||
}
|
||||
|
||||
// Industrial: next to the docks, or nearest an edge when there is no water.
|
||||
let size = ctx.size();
|
||||
let edge_dist = |p: Pos| {
|
||||
p.x.min(p.y)
|
||||
.min(size.w as i32 - 1 - p.x)
|
||||
.min(size.h as i32 - 1 - p.y)
|
||||
};
|
||||
let industrial = (0..n)
|
||||
.filter(|i| themes[*i].is_none())
|
||||
.min_by_key(|i| match docks {
|
||||
Some(d) => self.seeds[*i].dist2(self.seeds[d]),
|
||||
None => edge_dist(self.seeds[*i]) as i64,
|
||||
});
|
||||
if let Some(i) = industrial {
|
||||
themes[i] = Some(Theme::Industrial);
|
||||
}
|
||||
|
||||
let mut filler = FILLER_THEMES.iter().cycle();
|
||||
themes
|
||||
.into_iter()
|
||||
.map(|t| t.unwrap_or_else(|| *filler.next().expect("cycle never ends")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn finalize(&mut self, ctx: &mut GenCtx) {
|
||||
let n = self.seeds.len();
|
||||
let size = ctx.size();
|
||||
let noise = self.noise.as_ref().expect("begin sets noise");
|
||||
let params = ctx.params().clone();
|
||||
|
||||
let mut bounds: Vec<Option<Rect>> = vec![None; n];
|
||||
{
|
||||
let st = ctx
|
||||
.city
|
||||
.stratum(StratumKind::Street)
|
||||
.expect("street stratum");
|
||||
for (p, t) in st.tiles.iter() {
|
||||
if let Some(d) = t.district {
|
||||
let b = &mut bounds[d.index()];
|
||||
*b = Some(match *b {
|
||||
None => Rect::new(p.x, p.y, 1, 1),
|
||||
Some(r) => {
|
||||
let x0 = r.x.min(p.x);
|
||||
let y0 = r.y.min(p.y);
|
||||
let x1 = r.right().max(p.x + 1);
|
||||
let y1 = r.bottom().max(p.y + 1);
|
||||
Rect::new(x0, y0, x1 - x0, y1 - y0)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let themes = self.assign_themes(ctx);
|
||||
|
||||
let clamp = |v: f32| v.clamp(0.0, 1.0);
|
||||
let mut districts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let theme = themes[i];
|
||||
let spec = theme.spec();
|
||||
let c = self.seeds[i];
|
||||
let nz = |salt: f64| noise.get([c.x as f64 * 0.01, c.y as f64 * 0.01, salt]) as f32;
|
||||
let wealth = clamp(spec.base_wealth + params.wealth_skew * 0.3 + nz(1.0) * 0.15);
|
||||
let decay = clamp(spec.base_decay + (params.decay - 0.4) * 0.5 + nz(2.0) * 0.15);
|
||||
districts.push(District {
|
||||
id: DistrictId(i as u32),
|
||||
name: String::new(),
|
||||
theme,
|
||||
center: c,
|
||||
wealth,
|
||||
decay,
|
||||
bounds: bounds[i].unwrap_or(Rect::new(c.x, c.y, 1, 1)),
|
||||
});
|
||||
}
|
||||
|
||||
// Per-tile fields modulate the district value with fine noise.
|
||||
for y in 0..size.h as i32 {
|
||||
for x in 0..size.w as i32 {
|
||||
let p = Pos::new(x, y);
|
||||
let Some(d) = ctx
|
||||
.city
|
||||
.tile(StratumKind::Street, p)
|
||||
.and_then(|t| t.district)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let dist = &districts[d.index()];
|
||||
let n1 = noise.get([x as f64 * 0.03, y as f64 * 0.03, 5.0]) as f32;
|
||||
let n2 = noise.get([x as f64 * 0.03, y as f64 * 0.03, 6.0]) as f32;
|
||||
ctx.scratch.wealth.set(p, clamp(dist.wealth + n1 * 0.1));
|
||||
ctx.scratch.decay.set(p, clamp(dist.decay + n2 * 0.1));
|
||||
}
|
||||
}
|
||||
ctx.city.districts = districts;
|
||||
}
|
||||
}
|
||||
|
||||
impl StageRunner for DistrictsStage {
|
||||
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> {
|
||||
let mut rng = ctx.rng(Stage::Districts);
|
||||
self.noise = Some(OpenSimplex::new((ctx.seed ^ 0xD15) as u32));
|
||||
let land: Vec<Pos> = ctx
|
||||
.scratch
|
||||
.land
|
||||
.iter()
|
||||
.filter(|(_, l)| **l)
|
||||
.map(|(p, _)| p)
|
||||
.collect();
|
||||
if land.is_empty() {
|
||||
return Err(GenError::InvalidParams {
|
||||
field: "water",
|
||||
reason: "no land left to build on".into(),
|
||||
});
|
||||
}
|
||||
let (sx, sy) = land.iter().fold((0i64, 0i64), |(ax, ay), p| {
|
||||
(ax + p.x as i64, ay + p.y as i64)
|
||||
});
|
||||
self.centroid = Pos::new(
|
||||
(sx / land.len() as i64) as i32,
|
||||
(sy / land.len() as i64) as i32,
|
||||
);
|
||||
self.seeds = Self::place_seeds(&mut rng, &land, ctx.params().districts as usize);
|
||||
self.row = 0;
|
||||
self.finalized = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError> {
|
||||
let size = ctx.size();
|
||||
let h = size.h as i32;
|
||||
if self.row < h {
|
||||
let y = self.row;
|
||||
for x in 0..size.w as i32 {
|
||||
let p = Pos::new(x, y);
|
||||
if !ctx.is_land(p) {
|
||||
continue;
|
||||
}
|
||||
let d = self.nearest_seed(p);
|
||||
for k in StratumKind::PHYSICAL {
|
||||
if let Some(t) = ctx.city.tile_mut(k, p) {
|
||||
t.district = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.row += 1;
|
||||
return Ok(StepOut::Continue(0.9 * self.row as f32 / h as f32));
|
||||
}
|
||||
if !self.finalized {
|
||||
self.finalize(ctx);
|
||||
self.finalized = true;
|
||||
return Ok(StepOut::Continue(1.0));
|
||||
}
|
||||
Ok(StepOut::Finished)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::generate::testutil::small;
|
||||
use crate::params::WaterMode;
|
||||
use crate::tile::Terrain;
|
||||
use crate::world::{StratumKind, Theme};
|
||||
|
||||
#[test]
|
||||
fn every_land_tile_has_a_district_and_counts_match() {
|
||||
let c = small(5, WaterMode::Bay, 6);
|
||||
assert_eq!(c.districts.len(), 6);
|
||||
let st = c.stratum(StratumKind::Street).unwrap();
|
||||
for (_, t) in st.tiles.iter() {
|
||||
if t.terrain != Terrain::Water {
|
||||
assert!(t.district.is_some());
|
||||
} else {
|
||||
assert!(t.district.is_none());
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6);
|
||||
for (i, d) in c.districts.iter().enumerate() {
|
||||
assert_eq!(d.id.index(), i);
|
||||
assert!(d.bounds.contains(d.center));
|
||||
assert_eq!(st.tiles[d.center].district, Some(d.id));
|
||||
}
|
||||
// other strata carry the same district ids
|
||||
let sk = c.stratum(StratumKind::Skyways).unwrap();
|
||||
for (p, t) in st.tiles.iter() {
|
||||
assert_eq!(sk.tiles[p].district, t.district);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_rules_hold() {
|
||||
let c = small(11, WaterMode::Bay, 7);
|
||||
let themes: Vec<_> = c.districts.iter().map(|d| d.theme).collect();
|
||||
assert_eq!(themes.iter().filter(|t| **t == Theme::CorpCore).count(), 1);
|
||||
assert_eq!(themes.iter().filter(|t| **t == Theme::Docks).count(), 1);
|
||||
assert!(themes.contains(&Theme::Industrial) && themes.contains(&Theme::CombatZone));
|
||||
let core = c
|
||||
.districts
|
||||
.iter()
|
||||
.find(|d| d.theme == Theme::CorpCore)
|
||||
.unwrap();
|
||||
let cz = c
|
||||
.districts
|
||||
.iter()
|
||||
.find(|d| d.theme == Theme::CombatZone)
|
||||
.unwrap();
|
||||
let far = c
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| d.theme != Theme::CorpCore)
|
||||
.map(|d| d.center.dist2(core.center))
|
||||
.max()
|
||||
.unwrap();
|
||||
assert_eq!(cz.center.dist2(core.center), far);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_water_means_no_docks_but_industrial_present() {
|
||||
let c = small(2, WaterMode::None, 5);
|
||||
assert!(c.districts.iter().all(|d| d.theme != Theme::Docks));
|
||||
assert!(c.districts.iter().any(|d| d.theme == Theme::Industrial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wealth_and_decay_are_clamped() {
|
||||
let c = small(9, WaterMode::River, 8);
|
||||
for d in &c.districts {
|
||||
assert!((0.0..=1.0).contains(&d.wealth));
|
||||
assert!((0.0..=1.0).contains(&d.decay));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The resumable generator: eleven chunked stages over a growing `City`.
|
||||
|
||||
pub mod districts;
|
||||
pub mod terrain;
|
||||
|
||||
use crate::flavor::FlavorPack;
|
||||
@@ -221,6 +222,7 @@ impl StageRunner for NoopStage {
|
||||
pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
|
||||
match stage {
|
||||
Stage::Terrain => Box::new(terrain::TerrainStage::default()),
|
||||
Stage::Districts => Box::new(districts::DistrictsStage::default()),
|
||||
_ => Box::new(NoopStage),
|
||||
}
|
||||
}
|
||||
@@ -344,6 +346,26 @@ impl Generator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared fixtures for stage tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil {
|
||||
use super::Generator;
|
||||
use crate::params::{GenParams, SizePreset, WaterMode};
|
||||
use crate::world::City;
|
||||
|
||||
/// A 120x60 city with the given seed, water mode and district count.
|
||||
pub fn small(seed: u64, water: WaterMode, districts: u8) -> City {
|
||||
Generator::run(GenParams {
|
||||
seed,
|
||||
water,
|
||||
districts,
|
||||
size: SizePreset::Custom { w: 120, h: 60 },
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("seed {seed}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user