fix(citygen): robustness found by the extended seed sweep
- arterials merge onto existing roads and keep distance otherwise - isolated strata components get a drop-in public connector - blockless districts fall back to the largest clean rectangle - sliver districts merge into neighbours (district count is a maximum) - dressing removes blocking features that cut tiles off the street Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -62,17 +62,24 @@ fn plan_edges(centers: &[Pos], extra: usize) -> Vec<(Pos, Pos)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ArterialsStage {
|
impl ArterialsStage {
|
||||||
|
/// Routes one arterial. Existing arterial tiles are cheap so roads merge instead of
|
||||||
|
/// running in parallel; tiles beside an arterial are dear so new roads keep their
|
||||||
|
/// distance; water is crossable at bridge cost.
|
||||||
fn route(&self, ctx: &GenCtx, a: Pos, b: Pos) -> Option<Vec<Pos>> {
|
fn route(&self, ctx: &GenCtx, a: Pos, b: Pos) -> Option<Vec<Pos>> {
|
||||||
let land = &ctx.scratch.land;
|
let land = &ctx.scratch.land;
|
||||||
let arterial = &ctx.scratch.arterial;
|
let arterial = &ctx.scratch.arterial;
|
||||||
astar(ctx.size(), a, b, |_, to| {
|
let size = ctx.size();
|
||||||
|
astar(size, a, b, |_, to| {
|
||||||
if !land[to] {
|
if !land[to] {
|
||||||
Some(40)
|
return Some(40);
|
||||||
} else if arterial[to] {
|
|
||||||
Some(10)
|
|
||||||
} else {
|
|
||||||
Some(11)
|
|
||||||
}
|
}
|
||||||
|
if arterial[to] {
|
||||||
|
return Some(2);
|
||||||
|
}
|
||||||
|
let near = Rect::new(to.x - 3, to.y - 3, 7, 7)
|
||||||
|
.iter()
|
||||||
|
.any(|q| size.contains(q) && arterial[q]);
|
||||||
|
Some(if near { 16 } else { 10 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid<bool>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
if drop_in_connector(city, k, &comp) {
|
||||||
|
for p in &comp {
|
||||||
|
reached.set(*p, true);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let in_building = comp
|
let in_building = comp
|
||||||
.iter()
|
.iter()
|
||||||
.any(|p| city.tile(k, *p).is_some_and(|t| t.building.is_some()));
|
.any(|p| city.tile(k, *p).is_some_and(|t| t.building.is_some()));
|
||||||
@@ -204,6 +210,49 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid<bool>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gives an isolated component its own public connector from the street directly below
|
||||||
|
/// (or, for rooftops, a ladder from a walkway directly below). Returns true on success.
|
||||||
|
fn drop_in_connector(city: &mut City, k: StratumKind, comp: &[Pos]) -> bool {
|
||||||
|
let street_ok = |city: &City, p: Pos| {
|
||||||
|
city.tile(StratumKind::Street, p).is_some_and(|t| {
|
||||||
|
t.feature.is_none()
|
||||||
|
&& matches!(
|
||||||
|
t.terrain,
|
||||||
|
Terrain::Sidewalk | Terrain::Alley | Terrain::Plaza | Terrain::Road
|
||||||
|
)
|
||||||
|
}) && city.connectors_at(p).next().is_none()
|
||||||
|
};
|
||||||
|
let spot = comp.iter().copied().find(|p| {
|
||||||
|
city.tile(k, *p)
|
||||||
|
.is_some_and(|t| t.feature.is_none() && !t.is_connector())
|
||||||
|
&& match k {
|
||||||
|
StratumKind::Heights => {
|
||||||
|
city.walkable(StratumKind::Skyways, *p)
|
||||||
|
&& city.connectors_at(*p).next().is_none()
|
||||||
|
}
|
||||||
|
_ => street_ok(city, *p),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let Some(pos) = spot else { return false };
|
||||||
|
let (kind, serves) = match k {
|
||||||
|
StratumKind::Undercity => (
|
||||||
|
ConnectorKind::Manhole,
|
||||||
|
vec![StratumKind::Undercity, StratumKind::Street],
|
||||||
|
),
|
||||||
|
StratumKind::Skyways => (
|
||||||
|
ConnectorKind::Stair,
|
||||||
|
vec![StratumKind::Street, StratumKind::Skyways],
|
||||||
|
),
|
||||||
|
StratumKind::Heights => (
|
||||||
|
ConnectorKind::Ladder,
|
||||||
|
vec![StratumKind::Skyways, StratumKind::Heights],
|
||||||
|
),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
ConnectivityStage::add_connector(city, kind, pos, serves);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
impl ConnectivityStage {
|
impl ConnectivityStage {
|
||||||
fn has_public(city: &City, d: usize, target: StratumKind) -> bool {
|
fn has_public(city: &City, d: usize, target: StratumKind) -> bool {
|
||||||
let id = city.districts[d].id;
|
let id = city.districts[d].id;
|
||||||
|
|||||||
@@ -133,7 +133,60 @@ impl DistrictsStage {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assigns every land tile to its nearest seed on all physical strata.
|
||||||
|
fn assign_all(&self, ctx: &mut GenCtx) {
|
||||||
|
let size = ctx.size();
|
||||||
|
for y in 0..size.h as i32 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges districts too small to hold a block into their neighbours, keeping at least
|
||||||
|
/// the minimum district count. Returns true if anything changed.
|
||||||
|
fn merge_slivers(&mut self, ctx: &mut GenCtx) -> bool {
|
||||||
|
let land = ctx.scratch.land.count(|&l| l);
|
||||||
|
let mut merged = false;
|
||||||
|
loop {
|
||||||
|
let n = self.seeds.len();
|
||||||
|
if n <= 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let min_tiles = (600usize).max(land / (3 * n));
|
||||||
|
let st = ctx.city.stratum(StratumKind::Street).expect("street");
|
||||||
|
let mut counts = vec![0usize; n];
|
||||||
|
for (_, t) in st.tiles.iter() {
|
||||||
|
if let Some(d) = t.district {
|
||||||
|
counts[d.index()] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some((smallest, _)) = counts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, c)| **c < min_tiles)
|
||||||
|
.min_by_key(|(_, c)| **c)
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.seeds.remove(smallest);
|
||||||
|
self.assign_all(ctx);
|
||||||
|
merged = true;
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
fn finalize(&mut self, ctx: &mut GenCtx) {
|
fn finalize(&mut self, ctx: &mut GenCtx) {
|
||||||
|
self.merge_slivers(ctx);
|
||||||
let n = self.seeds.len();
|
let n = self.seeds.len();
|
||||||
let size = ctx.size();
|
let size = ctx.size();
|
||||||
let noise = self.noise.as_ref().expect("begin sets noise");
|
let noise = self.noise.as_ref().expect("begin sets noise");
|
||||||
@@ -274,7 +327,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_land_tile_has_a_district_and_counts_match() {
|
fn every_land_tile_has_a_district_and_counts_match() {
|
||||||
let c = small(5, WaterMode::Bay, 6);
|
let c = small(5, WaterMode::Bay, 6);
|
||||||
assert_eq!(c.districts.len(), 6);
|
// The requested count is a maximum: slivers merge into neighbours.
|
||||||
|
assert!(
|
||||||
|
(4..=6).contains(&c.districts.len()),
|
||||||
|
"{}",
|
||||||
|
c.districts.len()
|
||||||
|
);
|
||||||
|
let n = c.districts.len();
|
||||||
let st = c.stratum(StratumKind::Street).unwrap();
|
let st = c.stratum(StratumKind::Street).unwrap();
|
||||||
for (_, t) in st.tiles.iter() {
|
for (_, t) in st.tiles.iter() {
|
||||||
if t.terrain != Terrain::Water {
|
if t.terrain != Terrain::Water {
|
||||||
@@ -285,7 +344,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect();
|
let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect();
|
||||||
ids.dedup();
|
ids.dedup();
|
||||||
assert_eq!(ids.len(), 6);
|
assert_eq!(ids.len(), n);
|
||||||
for (i, d) in c.districts.iter().enumerate() {
|
for (i, d) in c.districts.iter().enumerate() {
|
||||||
assert_eq!(d.id.index(), i);
|
assert_eq!(d.id.index(), i);
|
||||||
assert!(d.bounds.contains(d.center));
|
assert!(d.bounds.contains(d.center));
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! Stage 9: features, light maps and per-tile variants.
|
//! Stage 9: features, light maps and per-tile variants.
|
||||||
|
|
||||||
use super::{GenCtx, Stage, StageRunner, StepOut};
|
use super::{GenCtx, Stage, StageRunner, StepOut};
|
||||||
|
use crate::generate::connectivity::reachable_from_street;
|
||||||
use crate::geom::{Pos, Rect};
|
use crate::geom::{Pos, Rect};
|
||||||
use crate::params::GenError;
|
use crate::params::GenError;
|
||||||
use crate::pathing::components;
|
use crate::pathing::components;
|
||||||
use crate::rng::{chance, tile_hash};
|
use crate::rng::{chance, tile_hash};
|
||||||
use crate::tile::{Feature, Terrain};
|
use crate::tile::{Feature, Terrain, Tile};
|
||||||
use crate::world::{StratumKind, Theme};
|
use crate::world::{StratumKind, Theme};
|
||||||
use rand::seq::IndexedRandom;
|
use rand::seq::IndexedRandom;
|
||||||
use rand_chacha::ChaCha8Rng;
|
use rand_chacha::ChaCha8Rng;
|
||||||
@@ -14,6 +15,7 @@ use std::collections::VecDeque;
|
|||||||
enum Work {
|
enum Work {
|
||||||
Facade(usize),
|
Facade(usize),
|
||||||
Streets(usize),
|
Streets(usize),
|
||||||
|
Verify,
|
||||||
Lights(StratumKind),
|
Lights(StratumKind),
|
||||||
Variants(StratumKind),
|
Variants(StratumKind),
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,39 @@ impl DressingStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blocking features must never cut tiles off from the street: any blocker beside an
|
||||||
|
/// unreached walkable tile is removed, repeatedly, until nothing is cut off.
|
||||||
|
fn verify(ctx: &mut GenCtx) {
|
||||||
|
for _ in 0..6 {
|
||||||
|
let reach = reachable_from_street(&ctx.city);
|
||||||
|
let mut to_clear: Vec<(StratumKind, Pos)> = Vec::new();
|
||||||
|
for k in StratumKind::PHYSICAL {
|
||||||
|
let st = ctx.city.stratum(k).expect("stratum");
|
||||||
|
for (p, t) in st.tiles.iter() {
|
||||||
|
if !t.is_walkable() || reach[k.index()][p] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for n in p.neighbors4() {
|
||||||
|
let blocker = st.tiles.get(n).is_some_and(|u| {
|
||||||
|
u.feature.is_some()
|
||||||
|
&& !u.is_walkable()
|
||||||
|
&& Tile::new(u.terrain).is_walkable()
|
||||||
|
});
|
||||||
|
if blocker {
|
||||||
|
to_clear.push((k, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to_clear.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (k, p) in to_clear {
|
||||||
|
ctx.set_feature(k, p, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn lights(ctx: &mut GenCtx, k: StratumKind) {
|
fn lights(ctx: &mut GenCtx, k: StratumKind) {
|
||||||
let size = ctx.size();
|
let size = ctx.size();
|
||||||
let base = base_light(k);
|
let base = base_light(k);
|
||||||
@@ -286,6 +321,7 @@ impl StageRunner for DressingStage {
|
|||||||
.extend((0..ctx.city.buildings.len()).map(Work::Facade));
|
.extend((0..ctx.city.buildings.len()).map(Work::Facade));
|
||||||
self.queue
|
self.queue
|
||||||
.extend((0..ctx.city.districts.len()).map(Work::Streets));
|
.extend((0..ctx.city.districts.len()).map(Work::Streets));
|
||||||
|
self.queue.push_back(Work::Verify);
|
||||||
self.queue
|
self.queue
|
||||||
.extend(StratumKind::PHYSICAL.into_iter().map(Work::Lights));
|
.extend(StratumKind::PHYSICAL.into_iter().map(Work::Lights));
|
||||||
self.queue
|
self.queue
|
||||||
@@ -301,6 +337,7 @@ impl StageRunner for DressingStage {
|
|||||||
match work {
|
match work {
|
||||||
Work::Facade(i) => self.facade(ctx, i),
|
Work::Facade(i) => self.facade(ctx, i),
|
||||||
Work::Streets(d) => self.streets(ctx, d),
|
Work::Streets(d) => self.streets(ctx, d),
|
||||||
|
Work::Verify => Self::verify(ctx),
|
||||||
Work::Lights(k) => Self::lights(ctx, k),
|
Work::Lights(k) => Self::lights(ctx, k),
|
||||||
Work::Variants(k) => Self::variants(ctx, k),
|
Work::Variants(k) => Self::variants(ctx, k),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,6 +368,36 @@ impl Carver<'_> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Greedy largest clean rectangle in the district bounds (at least 5x4), for districts
|
||||||
|
/// whose street style produced no usable block.
|
||||||
|
fn largest_clean_rect(&self) -> Option<Rect> {
|
||||||
|
let b = self.bounds;
|
||||||
|
let mut best: Option<Rect> = None;
|
||||||
|
for start in b.iter() {
|
||||||
|
if self.is_bad(start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut w = 0;
|
||||||
|
while start.x + w < b.right() && w < 40 && !self.is_bad(Pos::new(start.x + w, start.y))
|
||||||
|
{
|
||||||
|
w += 1;
|
||||||
|
}
|
||||||
|
let mut h = 1;
|
||||||
|
while start.y + h < b.bottom() && h < 24 {
|
||||||
|
let row_clean = (0..w).all(|dx| !self.is_bad(Pos::new(start.x + dx, start.y + h)));
|
||||||
|
if !row_clean {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h += 1;
|
||||||
|
}
|
||||||
|
let r = Rect::new(start.x, start.y, w, h);
|
||||||
|
if r.w >= 5 && r.h >= 4 && best.is_none_or(|o| r.area() > o.area()) {
|
||||||
|
best = Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
/// Turns one candidate into clean blocks: trims dead edges, and when the rest is still
|
/// Turns one candidate into clean blocks: trims dead edges, and when the rest is still
|
||||||
/// dirty, splits it along the longer axis (painting an alley on the cut) and recurses.
|
/// dirty, splits it along the longer axis (painting an alley on the cut) and recurses.
|
||||||
fn fit_blocks(&mut self, r: Rect, depth: u8, out: &mut Vec<Rect>) {
|
fn fit_blocks(&mut self, r: Rect, depth: u8, out: &mut Vec<Rect>) {
|
||||||
@@ -459,6 +489,14 @@ impl StreetsStage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if blocks.is_empty()
|
||||||
|
&& let Some(rect) = carver.largest_clean_rect()
|
||||||
|
{
|
||||||
|
blocks.push(Block {
|
||||||
|
rect,
|
||||||
|
district: district.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
// Every block gets a walkable ring so each perimeter lot can open a door.
|
// Every block gets a walkable ring so each perimeter lot can open a door.
|
||||||
for b in &blocks {
|
for b in &blocks {
|
||||||
for p in b.rect.grow(1).border() {
|
for p in b.rect.grow(1).border() {
|
||||||
|
|||||||
Reference in New Issue
Block a user