feat(citygen): tile, world and net data model

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 16:41:47 +02:00
parent 698a6a13db
commit 40b6972664
5 changed files with 828 additions and 0 deletions

View File

@@ -2,6 +2,14 @@
pub mod geom; pub mod geom;
pub mod ids; pub mod ids;
pub mod net;
pub mod params;
pub mod tile;
pub mod world;
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 params::*;
pub use tile::*;
pub use world::*;

89
crates/citygen/src/net.rs Normal file
View File

@@ -0,0 +1,89 @@
//! The Net: subnets, nodes and links anchored to physical tiles.
use crate::geom::Pos;
use crate::ids::{BuildingId, DistrictId, NodeId, SubnetId};
use crate::world::StratumKind;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Net {
pub subnets: Vec<Subnet>,
pub nodes: Vec<Node>,
pub links: Vec<Link>,
}
/// Undirected link between two nodes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Link(pub NodeId, pub NodeId);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SubnetOwner {
Building(BuildingId),
District(DistrictId),
City,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Subnet {
pub id: SubnetId,
pub owner: SubnetOwner,
pub security: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeKind {
Terminal,
Camera,
DoorLock,
LiftCtrl,
AdBoard,
LampGrid,
Server,
DataVault,
Ice,
Backbone,
}
impl NodeKind {
pub fn label(self) -> &'static str {
match self {
NodeKind::Terminal => "terminal",
NodeKind::Camera => "camera",
NodeKind::DoorLock => "door lock",
NodeKind::LiftCtrl => "lift control",
NodeKind::AdBoard => "ad board",
NodeKind::LampGrid => "lamp grid",
NodeKind::Server => "server",
NodeKind::DataVault => "data vault",
NodeKind::Ice => "ICE",
NodeKind::Backbone => "backbone",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
pub anchor: (StratumKind, Pos),
pub subnet: SubnetId,
pub building: Option<BuildingId>,
}
impl Net {
pub fn nodes_on(&self, k: StratumKind) -> impl Iterator<Item = &Node> {
self.nodes.iter().filter(move |n| n.anchor.0 == k)
}
pub fn node_at(&self, k: StratumKind, p: Pos) -> Option<&Node> {
self.nodes.iter().find(|n| n.anchor == (k, p))
}
pub fn subnet(&self, id: SubnetId) -> &Subnet {
&self.subnets[id.index()]
}
pub fn node(&self, id: NodeId) -> &Node {
&self.nodes[id.index()]
}
}

View File

@@ -0,0 +1,76 @@
//! Generation parameters.
use crate::geom::Size;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum SizePreset {
Small,
Medium,
Large,
Custom { w: u32, h: u32 },
}
impl SizePreset {
pub fn size(self) -> Size {
match self {
SizePreset::Small => Size { w: 160, h: 80 },
SizePreset::Medium => Size { w: 240, h: 120 },
SizePreset::Large => Size { w: 320, h: 160 },
SizePreset::Custom { w, h } => Size { w, h },
}
}
pub fn label(self) -> &'static str {
match self {
SizePreset::Small => "small",
SizePreset::Medium => "medium",
SizePreset::Large => "large",
SizePreset::Custom { .. } => "custom",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum WaterMode {
None,
Bay,
River,
}
impl WaterMode {
pub fn label(self) -> &'static str {
match self {
WaterMode::None => "none",
WaterMode::Bay => "bay",
WaterMode::River => "river",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenParams {
pub seed: u64,
pub size: SizePreset,
pub districts: u8,
pub density: f32,
pub wealth_skew: f32,
pub decay: f32,
pub water: WaterMode,
pub flavor: String,
}
impl Default for GenParams {
fn default() -> Self {
GenParams {
seed: 0,
size: SizePreset::Medium,
districts: 7,
density: 0.6,
wealth_skew: 0.0,
decay: 0.4,
water: WaterMode::Bay,
flavor: "default".into(),
}
}
}

216
crates/citygen/src/tile.rs Normal file
View File

@@ -0,0 +1,216 @@
//! Tiles: terrain, features and derived walkability.
use crate::ids::{BuildingId, DistrictId};
use serde::{Deserialize, Serialize};
/// Base terrain of a tile. Every tile has exactly one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Terrain {
Void,
Water,
Sewage,
Road,
Sidewalk,
Alley,
Plaza,
Floor,
Wall,
Door,
Window,
Rooftop,
Walkway,
Bridge,
Tunnel,
Rail,
Stair,
Lift,
Ladder,
Grate,
}
pub const ALL_TERRAIN: [Terrain; 20] = [
Terrain::Void,
Terrain::Water,
Terrain::Sewage,
Terrain::Road,
Terrain::Sidewalk,
Terrain::Alley,
Terrain::Plaza,
Terrain::Floor,
Terrain::Wall,
Terrain::Door,
Terrain::Window,
Terrain::Rooftop,
Terrain::Walkway,
Terrain::Bridge,
Terrain::Tunnel,
Terrain::Rail,
Terrain::Stair,
Terrain::Lift,
Terrain::Ladder,
Terrain::Grate,
];
/// Named neon colours; the renderer maps them to RGB.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Hue {
Cyan,
Magenta,
Pink,
Orange,
Yellow,
Green,
Blue,
Red,
White,
}
/// Optional dressing on top of the terrain.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Feature {
Neon(Hue),
HoloAd,
Lamp,
Vent,
Puddle,
Trash,
Barrier,
Stall,
Sign(char),
Terminal,
Camera,
AcUnit,
Helipad,
Pipe,
Cable,
}
pub const ALL_FEATURES: [Feature; 15] = [
Feature::Neon(Hue::Cyan),
Feature::HoloAd,
Feature::Lamp,
Feature::Vent,
Feature::Puddle,
Feature::Trash,
Feature::Barrier,
Feature::Stall,
Feature::Sign('ア'),
Feature::Terminal,
Feature::Camera,
Feature::AcUnit,
Feature::Helipad,
Feature::Pipe,
Feature::Cable,
];
/// One cell of a stratum.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tile {
pub terrain: Terrain,
pub feature: Option<Feature>,
pub district: Option<DistrictId>,
pub building: Option<BuildingId>,
/// Deterministic per-tile hash used for glyph variation and animation phase.
pub variant: u8,
}
impl Tile {
pub const VOID: Tile = Tile {
terrain: Terrain::Void,
feature: None,
district: None,
building: None,
variant: 0,
};
pub fn new(terrain: Terrain) -> Self {
Tile {
terrain,
..Tile::VOID
}
}
pub fn is_walkable(&self) -> bool {
use Terrain::*;
let terrain_ok = matches!(
self.terrain,
Road | Sidewalk
| Alley
| Plaza
| Floor
| Door
| Rooftop
| Walkway
| Bridge
| Tunnel
| Rail
| Stair
| Lift
| Ladder
| Grate
);
let feature_ok = !matches!(
self.feature,
Some(
Feature::Barrier
| Feature::Stall
| Feature::AcUnit
| Feature::Pipe
| Feature::Terminal
)
);
terrain_ok && feature_ok
}
pub fn blocks_sight(&self) -> bool {
matches!(self.terrain, Terrain::Wall | Terrain::Door)
}
/// Outdoor ground at street level: roads, sidewalks, alleys, plazas.
pub fn is_outdoor_ground(&self) -> bool {
matches!(
self.terrain,
Terrain::Road | Terrain::Sidewalk | Terrain::Alley | Terrain::Plaza
)
}
pub fn is_building_part(&self) -> bool {
matches!(
self.terrain,
Terrain::Wall | Terrain::Floor | Terrain::Door | Terrain::Window
)
}
/// A vertical connector tile.
pub fn is_connector(&self) -> bool {
matches!(
self.terrain,
Terrain::Stair | Terrain::Lift | Terrain::Ladder
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn walkability_rules() {
assert!(Tile::new(Terrain::Sidewalk).is_walkable());
assert!(!Tile::new(Terrain::Wall).is_walkable());
assert!(!Tile::new(Terrain::Water).is_walkable());
let mut t = Tile::new(Terrain::Road);
t.feature = Some(Feature::Barrier);
assert!(!t.is_walkable());
t.feature = Some(Feature::Lamp);
assert!(t.is_walkable());
assert!(Tile::new(Terrain::Wall).blocks_sight());
assert!(Tile::new(Terrain::Door).blocks_sight());
assert!(!Tile::new(Terrain::Window).blocks_sight());
assert!(!Tile::new(Terrain::Window).is_walkable());
assert!(Tile::new(Terrain::Plaza).is_outdoor_ground());
assert!(Tile::new(Terrain::Door).is_building_part());
assert_eq!(ALL_TERRAIN.len(), 20);
assert_eq!(ALL_FEATURES.len(), 15);
}
}

439
crates/citygen/src/world.rs Normal file
View File

@@ -0,0 +1,439 @@
//! The city: strata, districts, buildings, connectors.
use crate::geom::{Grid, Pos, Rect, Size};
use crate::ids::{BuildingId, DistrictId};
use crate::net::Net;
use crate::params::GenParams;
use crate::tile::Tile;
use serde::{Deserialize, Serialize};
/// A vertical level of the city. `Net` is reserved for the cyberspace projection.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum StratumKind {
Undercity,
Street,
Skyways,
Heights,
Net,
}
impl StratumKind {
pub const PHYSICAL: [StratumKind; 4] = [
StratumKind::Undercity,
StratumKind::Street,
StratumKind::Skyways,
StratumKind::Heights,
];
pub fn label(self) -> &'static str {
match self {
StratumKind::Undercity => "Undercity",
StratumKind::Street => "Street",
StratumKind::Skyways => "Skyways",
StratumKind::Heights => "Heights",
StratumKind::Net => "Net",
}
}
/// Position in the physical stack, bottom first.
pub fn index(self) -> usize {
match self {
StratumKind::Undercity => 0,
StratumKind::Street => 1,
StratumKind::Skyways => 2,
StratumKind::Heights => 3,
StratumKind::Net => 4,
}
}
pub fn from_index(i: usize) -> Option<StratumKind> {
match i {
0 => Some(StratumKind::Undercity),
1 => Some(StratumKind::Street),
2 => Some(StratumKind::Skyways),
3 => Some(StratumKind::Heights),
4 => Some(StratumKind::Net),
_ => None,
}
}
pub fn above(self) -> Option<StratumKind> {
match self {
StratumKind::Undercity => Some(StratumKind::Street),
StratumKind::Street => Some(StratumKind::Skyways),
StratumKind::Skyways => Some(StratumKind::Heights),
_ => None,
}
}
pub fn below(self) -> Option<StratumKind> {
match self {
StratumKind::Street => Some(StratumKind::Undercity),
StratumKind::Skyways => Some(StratumKind::Street),
StratumKind::Heights => Some(StratumKind::Skyways),
_ => None,
}
}
}
/// One full tile map plus its precomputed light.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Stratum {
pub kind: StratumKind,
pub tiles: Grid<Tile>,
/// 0 dark .. 255 bright.
pub light: Grid<u8>,
}
impl Stratum {
pub fn new(kind: StratumKind, size: Size) -> Self {
Stratum {
kind,
tiles: Grid::filled(size, Tile::VOID),
light: Grid::filled(size, 0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Theme {
CorpCore,
NightMarket,
Megablocks,
CombatZone,
Docks,
Industrial,
NeonStrip,
OldTown,
}
impl Theme {
pub const ALL: [Theme; 8] = [
Theme::CorpCore,
Theme::NightMarket,
Theme::Megablocks,
Theme::CombatZone,
Theme::Docks,
Theme::Industrial,
Theme::NeonStrip,
Theme::OldTown,
];
pub fn label(self) -> &'static str {
match self {
Theme::CorpCore => "Corp Core",
Theme::NightMarket => "Night Market",
Theme::Megablocks => "Megablocks",
Theme::CombatZone => "Combat Zone",
Theme::Docks => "Docks",
Theme::Industrial => "Industrial",
Theme::NeonStrip => "Neon Strip",
Theme::OldTown => "Old Town",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct District {
pub id: DistrictId,
pub name: String,
pub theme: Theme,
pub center: Pos,
pub wealth: f32,
pub decay: f32,
pub bounds: Rect,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BuildingKind {
CorpTower,
Office,
Megablock,
Apartment,
Hotel,
Shop,
NoodleBar,
Bar,
Club,
Arcade,
Pawn,
Clinic,
Temple,
Warehouse,
Factory,
Depot,
Garage,
Parking,
Market,
}
impl BuildingKind {
pub fn label(self) -> &'static str {
match self {
BuildingKind::CorpTower => "corp tower",
BuildingKind::Office => "office",
BuildingKind::Megablock => "megablock",
BuildingKind::Apartment => "apartment",
BuildingKind::Hotel => "hotel",
BuildingKind::Shop => "shop",
BuildingKind::NoodleBar => "noodle bar",
BuildingKind::Bar => "bar",
BuildingKind::Club => "club",
BuildingKind::Arcade => "arcade",
BuildingKind::Pawn => "pawn shop",
BuildingKind::Clinic => "clinic",
BuildingKind::Temple => "temple",
BuildingKind::Warehouse => "warehouse",
BuildingKind::Factory => "factory",
BuildingKind::Depot => "depot",
BuildingKind::Garage => "garage",
BuildingKind::Parking => "parking",
BuildingKind::Market => "market",
}
}
pub fn is_corp(self) -> bool {
matches!(
self,
BuildingKind::CorpTower | BuildingKind::Office | BuildingKind::Megablock
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HeightClass {
Low,
Mid,
Tall,
}
/// Inclusive range of physical strata a building occupies.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StrataSpan {
pub lo: StratumKind,
pub hi: StratumKind,
}
impl StrataSpan {
pub fn from_class(class: HeightClass, basement: bool) -> Self {
let hi = match class {
HeightClass::Low => StratumKind::Street,
HeightClass::Mid => StratumKind::Skyways,
HeightClass::Tall => StratumKind::Heights,
};
let lo = if basement {
StratumKind::Undercity
} else {
StratumKind::Street
};
StrataSpan { lo, hi }
}
pub fn contains(self, k: StratumKind) -> bool {
k.index() >= self.lo.index() && k.index() <= self.hi.index()
}
pub fn iter(self) -> impl Iterator<Item = StratumKind> {
(self.lo.index()..=self.hi.index()).filter_map(StratumKind::from_index)
}
pub fn len(self) -> usize {
self.hi.index() - self.lo.index() + 1
}
pub fn is_empty(self) -> bool {
false
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Building {
pub id: BuildingId,
pub kind: BuildingKind,
pub district: DistrictId,
/// Footprint at Street level; the same rect on every stratum of `span`.
pub footprint: Rect,
pub span: StrataSpan,
pub name: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConnectorKind {
Stair,
Lift,
Ladder,
Manhole,
Jack,
}
impl ConnectorKind {
pub fn label(self) -> &'static str {
match self {
ConnectorKind::Stair => "stairs",
ConnectorKind::Lift => "lift",
ConnectorKind::Ladder => "ladder",
ConnectorKind::Manhole => "manhole",
ConnectorKind::Jack => "jack",
}
}
}
/// A vertical link between strata at one position.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Connector {
pub kind: ConnectorKind,
pub pos: Pos,
/// Served strata, ordered low to high.
pub serves: Vec<StratumKind>,
/// Reachable from the street without entering a building.
pub public: bool,
}
impl Connector {
/// The strata directly below and above `from` in `serves`, or `None` if not served.
pub fn serves_from(
&self,
from: StratumKind,
) -> Option<(Option<StratumKind>, Option<StratumKind>)> {
let i = self.serves.iter().position(|k| *k == from)?;
let below = if i > 0 {
Some(self.serves[i - 1])
} else {
None
};
let above = self.serves.get(i + 1).copied();
Some((below, above))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct City {
pub seed: u64,
pub params: GenParams,
pub name: String,
pub size: Size,
pub strata: Vec<Stratum>,
pub districts: Vec<District>,
pub buildings: Vec<Building>,
pub connectors: Vec<Connector>,
/// Centre paths of the arterial roads, in generation order.
pub arterials: Vec<Vec<Pos>>,
pub net: Net,
}
impl City {
/// Four physical strata of `Void`, nothing else.
pub fn empty(params: GenParams) -> City {
let size = params.size.size();
City {
seed: params.seed,
name: String::new(),
size,
strata: StratumKind::PHYSICAL
.iter()
.map(|k| Stratum::new(*k, size))
.collect(),
districts: Vec::new(),
buildings: Vec::new(),
connectors: Vec::new(),
arterials: Vec::new(),
net: Net::default(),
params,
}
}
pub fn stratum(&self, k: StratumKind) -> Option<&Stratum> {
self.strata.iter().find(|s| s.kind == k)
}
pub fn stratum_mut(&mut self, k: StratumKind) -> Option<&mut Stratum> {
self.strata.iter_mut().find(|s| s.kind == k)
}
pub fn tile(&self, k: StratumKind, p: Pos) -> Option<&Tile> {
self.stratum(k).and_then(|s| s.tiles.get(p))
}
pub fn tile_mut(&mut self, k: StratumKind, p: Pos) -> Option<&mut Tile> {
self.stratum_mut(k).and_then(|s| s.tiles.get_mut(p))
}
pub fn walkable(&self, k: StratumKind, p: Pos) -> bool {
self.tile(k, p).is_some_and(|t| t.is_walkable())
}
pub fn connectors_at(&self, p: Pos) -> impl Iterator<Item = &Connector> {
self.connectors.iter().filter(move |c| c.pos == p)
}
pub fn district(&self, id: DistrictId) -> &District {
&self.districts[id.index()]
}
pub fn building(&self, id: BuildingId) -> &Building {
&self.buildings[id.index()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::{GenParams, SizePreset};
use crate::tile::Terrain;
#[test]
fn span_iter_and_class() {
let s = StrataSpan::from_class(HeightClass::Tall, true);
assert_eq!(s.iter().collect::<Vec<_>>(), StratumKind::PHYSICAL.to_vec());
let s = StrataSpan::from_class(HeightClass::Mid, false);
assert_eq!(
s.iter().collect::<Vec<_>>(),
vec![StratumKind::Street, StratumKind::Skyways]
);
assert!(s.contains(StratumKind::Skyways));
assert!(!s.contains(StratumKind::Heights));
assert_eq!(s.len(), 2);
}
#[test]
fn city_empty_has_four_void_strata() {
let c = City::empty(GenParams {
size: SizePreset::Custom { w: 10, h: 5 },
..GenParams::default()
});
assert_eq!(c.strata.len(), 4);
assert_eq!(
c.stratum(StratumKind::Heights).unwrap().tiles.size(),
Size { w: 10, h: 5 }
);
assert_eq!(
c.tile(StratumKind::Street, Pos::new(3, 3)).unwrap().terrain,
Terrain::Void
);
assert!(c.tile(StratumKind::Street, Pos::new(10, 3)).is_none());
assert!(!c.walkable(StratumKind::Street, Pos::new(3, 3)));
}
#[test]
fn connector_neighbors() {
let c = Connector {
kind: ConnectorKind::Lift,
pos: Pos::new(0, 0),
serves: vec![
StratumKind::Street,
StratumKind::Skyways,
StratumKind::Heights,
],
public: false,
};
assert_eq!(
c.serves_from(StratumKind::Skyways),
Some((Some(StratumKind::Street), Some(StratumKind::Heights)))
);
assert_eq!(
c.serves_from(StratumKind::Heights),
Some((Some(StratumKind::Skyways), None))
);
assert_eq!(c.serves_from(StratumKind::Undercity), None);
}
}