diff --git a/crates/citygen/src/lib.rs b/crates/citygen/src/lib.rs index b68ced8..8d3ed14 100644 --- a/crates/citygen/src/lib.rs +++ b/crates/citygen/src/lib.rs @@ -2,6 +2,14 @@ pub mod geom; 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 ids::*; +pub use net::*; +pub use params::*; +pub use tile::*; +pub use world::*; diff --git a/crates/citygen/src/net.rs b/crates/citygen/src/net.rs new file mode 100644 index 0000000..e93f817 --- /dev/null +++ b/crates/citygen/src/net.rs @@ -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, + pub nodes: Vec, + pub links: Vec, +} + +/// 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, +} + +impl Net { + pub fn nodes_on(&self, k: StratumKind) -> impl Iterator { + 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()] + } +} diff --git a/crates/citygen/src/params.rs b/crates/citygen/src/params.rs new file mode 100644 index 0000000..680ff9c --- /dev/null +++ b/crates/citygen/src/params.rs @@ -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(), + } + } +} diff --git a/crates/citygen/src/tile.rs b/crates/citygen/src/tile.rs new file mode 100644 index 0000000..9cc81b9 --- /dev/null +++ b/crates/citygen/src/tile.rs @@ -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, + pub district: Option, + pub building: Option, + /// 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); + } +} diff --git a/crates/citygen/src/world.rs b/crates/citygen/src/world.rs new file mode 100644 index 0000000..cbc77c0 --- /dev/null +++ b/crates/citygen/src/world.rs @@ -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 { + 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 { + match self { + StratumKind::Undercity => Some(StratumKind::Street), + StratumKind::Street => Some(StratumKind::Skyways), + StratumKind::Skyways => Some(StratumKind::Heights), + _ => None, + } + } + + pub fn below(self) -> Option { + 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, + /// 0 dark .. 255 bright. + pub light: Grid, +} + +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 { + (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, +} + +#[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, + /// 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, Option)> { + 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, + pub districts: Vec, + pub buildings: Vec, + pub connectors: Vec, + /// Centre paths of the arterial roads, in generation order. + pub arterials: Vec>, + 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 { + 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::>(), StratumKind::PHYSICAL.to_vec()); + let s = StrataSpan::from_class(HeightClass::Mid, false); + assert_eq!( + s.iter().collect::>(), + 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); + } +}