From c8d8deeb0f01b467fa7b9c48e4c4c33b5e03349c Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 16:44:14 +0200 Subject: [PATCH] feat(citygen): flavor pack and theme table Co-Authored-By: Claude Fable 5.1 --- crates/citygen/src/flavor.rs | 158 ++++++++++++++++++++ crates/citygen/src/lib.rs | 4 + crates/citygen/src/theme.rs | 271 +++++++++++++++++++++++++++++++++++ flavor/names.ron | 65 +++++++++ 4 files changed, 498 insertions(+) create mode 100644 crates/citygen/src/flavor.rs create mode 100644 crates/citygen/src/theme.rs create mode 100644 flavor/names.ron diff --git a/crates/citygen/src/flavor.rs b/crates/citygen/src/flavor.rs new file mode 100644 index 0000000..280e62f --- /dev/null +++ b/crates/citygen/src/flavor.rs @@ -0,0 +1,158 @@ +//! Flavor pack: names and signage glyphs, loaded from RON. + +use crate::params::GenError; +use crate::world::{BuildingKind, Theme}; +use rand::Rng; +use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; + +const DEFAULT_PACK: &str = include_str!("../../../flavor/names.ron"); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NameParts { + pub prefixes: Vec, + pub suffixes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CorpParts { + pub first: Vec, + pub second: Vec, + pub suffix: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FlavorPack { + pub city: NameParts, + pub districts: Vec<(Theme, NameParts)>, + pub corp: CorpParts, + pub shops: Vec<(BuildingKind, Vec)>, + pub landmarks: Vec, + pub signage: Vec, +} + +fn pick<'a>(rng: &mut impl Rng, items: &'a [String]) -> &'a str { + items.choose(rng).map(String::as_str).unwrap_or("") +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +impl FlavorPack { + /// Loads a named pack. Only `default` is built in. + pub fn load(name: &str) -> Result { + match name { + "default" => FlavorPack::parse(DEFAULT_PACK), + other => Err(GenError::Flavor(format!("unknown flavor pack `{other}`"))), + } + } + + pub fn parse(text: &str) -> Result { + let pack: FlavorPack = ron::from_str(text).map_err(|e| GenError::Flavor(e.to_string()))?; + if pack.signage.is_empty() || pack.city.prefixes.is_empty() || pack.city.suffixes.is_empty() + { + return Err(GenError::Flavor( + "flavor pack is missing required lists".into(), + )); + } + Ok(pack) + } + + pub fn city_name(&self, rng: &mut impl Rng) -> String { + format!( + "{}{}", + pick(rng, &self.city.prefixes), + pick(rng, &self.city.suffixes) + ) + } + + pub fn district_name(&self, theme: Theme, rng: &mut impl Rng) -> String { + match self.districts.iter().find(|(t, _)| *t == theme) { + Some((_, parts)) => format!( + "{}{}", + pick(rng, &parts.prefixes), + pick(rng, &parts.suffixes) + ), + None => format!("{} {}", theme.label(), rng.random_range(1..100)), + } + } + + pub fn corp_name(&self, rng: &mut impl Rng) -> String { + let stem = format!( + "{}{}", + pick(rng, &self.corp.first), + pick(rng, &self.corp.second) + ); + format!("{}{}", capitalize(&stem), pick(rng, &self.corp.suffix)) + } + + /// A two-syllable made-up word for shop names. + fn syllable_word(&self, rng: &mut impl Rng) -> String { + let stem = format!( + "{}{}", + pick(rng, &self.corp.first).to_lowercase(), + pick(rng, &self.corp.second) + ); + capitalize(&stem) + } + + pub fn shop_name(&self, kind: BuildingKind, rng: &mut impl Rng) -> Option { + let (_, templates) = self.shops.iter().find(|(k, _)| *k == kind)?; + let template = pick(rng, templates); + let word = self.syllable_word(rng); + Some(template.replace("{syl}", &word)) + } + + pub fn landmark_name(&self, corp: &str, district: &str, rng: &mut impl Rng) -> String { + pick(rng, &self.landmarks) + .replace("{corp}", corp) + .replace("{district}", district) + } + + pub fn sign_glyph(&self, rng: &mut impl Rng) -> char { + *self.signage.choose(rng).unwrap_or(&'#') + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rng::stage_rng; + use crate::world::{BuildingKind, Theme}; + + fn unicode_single_width(c: char) -> bool { + let u = c as u32; + u >= 0x20 + && !(0x1100..=0x115F).contains(&u) + && !(0x2E80..=0xA4CF).contains(&u) + && !(0xAC00..=0xD7A3).contains(&u) + && !(0xF900..=0xFAFF).contains(&u) + && !(0xFE30..=0xFE4F).contains(&u) + && !(0xFF00..=0xFF60).contains(&u) + && !(0xFFE0..=0xFFE6).contains(&u) + } + + #[test] + fn default_flavor_loads_and_names_are_deterministic() { + let f = FlavorPack::load("default").unwrap(); + assert!(FlavorPack::load("nope").is_err()); + let mut a = stage_rng(7, 9); + let mut b = stage_rng(7, 9); + assert_eq!(f.city_name(&mut a), f.city_name(&mut b)); + for t in Theme::ALL { + assert!(!f.district_name(t, &mut a).is_empty()); + } + assert!(f.shop_name(BuildingKind::NoodleBar, &mut a).is_some()); + assert!(f.shop_name(BuildingKind::CorpTower, &mut a).is_none()); + assert!(!f.corp_name(&mut a).is_empty()); + assert!(f.landmark_name("Kirotek", "Apex Plaza", &mut a).len() > 5); + assert!(f.signage.iter().all(|c| unicode_single_width(*c))); + assert!(f.signage.len() >= 40); + let _ = f.sign_glyph(&mut a); + } +} diff --git a/crates/citygen/src/lib.rs b/crates/citygen/src/lib.rs index babebff..93893fd 100644 --- a/crates/citygen/src/lib.rs +++ b/crates/citygen/src/lib.rs @@ -1,18 +1,22 @@ //! World model and generator for the cyberpunk city. +pub mod flavor; pub mod geom; pub mod ids; pub mod net; pub mod params; pub mod rng; +pub mod theme; pub mod tile; pub mod validate; pub mod world; +pub use flavor::FlavorPack; pub use geom::{Dir, Grid, Pos, Rect, Size}; pub use ids::*; pub use net::*; pub use params::*; +pub use theme::*; pub use tile::*; pub use validate::*; pub use world::*; diff --git a/crates/citygen/src/theme.rs b/crates/citygen/src/theme.rs new file mode 100644 index 0000000..5ade094 --- /dev/null +++ b/crates/citygen/src/theme.rs @@ -0,0 +1,271 @@ +//! Static per-theme generation parameters. + +use crate::tile::Hue; +use crate::world::{BuildingKind, HeightClass, Theme}; + +/// How a district carves its streets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreetStyle { + Grid, + Organic, + Superblock, + Rows, + Shattered, + Strip, +} + +/// Tunables for one theme. Fractions are probabilities in 0..1. +#[derive(Clone, Debug)] +pub struct ThemeSpec { + pub base_wealth: f32, + pub base_decay: f32, + pub street_style: StreetStyle, + /// Target block size (w, h). + pub block: (i32, i32), + /// Minimum lot size (w, h). + pub min_lot: (i32, i32), + pub mix: &'static [(BuildingKind, u32)], + pub tall: f32, + pub mid: f32, + pub basement: f32, + pub plaza: f32, + pub neon: f32, + pub holo: f32, + pub lamp_spacing: i32, + pub security: u8, + pub hues: &'static [Hue], +} + +use BuildingKind as B; + +static CORP_CORE: ThemeSpec = ThemeSpec { + base_wealth: 0.9, + base_decay: 0.1, + street_style: StreetStyle::Grid, + block: (24, 12), + min_lot: (10, 7), + mix: &[ + (B::CorpTower, 5), + (B::Office, 4), + (B::Hotel, 1), + (B::Parking, 1), + ], + tall: 0.7, + mid: 0.3, + basement: 0.6, + plaza: 0.25, + neon: 0.3, + holo: 0.5, + lamp_spacing: 8, + security: 200, + hues: &[Hue::Cyan, Hue::White, Hue::Blue], +}; +static NIGHT_MARKET: ThemeSpec = ThemeSpec { + base_wealth: 0.4, + base_decay: 0.5, + street_style: StreetStyle::Organic, + block: (16, 9), + min_lot: (5, 4), + mix: &[ + (B::Shop, 4), + (B::NoodleBar, 4), + (B::Bar, 2), + (B::Market, 2), + (B::Clinic, 1), + (B::Pawn, 1), + (B::Apartment, 2), + ], + tall: 0.05, + mid: 0.3, + basement: 0.1, + plaza: 0.3, + neon: 0.8, + holo: 0.3, + lamp_spacing: 12, + security: 60, + hues: &[Hue::Magenta, Hue::Orange, Hue::Yellow], +}; +static MEGABLOCKS: ThemeSpec = ThemeSpec { + base_wealth: 0.3, + base_decay: 0.6, + street_style: StreetStyle::Superblock, + block: (40, 20), + min_lot: (18, 10), + mix: &[(B::Megablock, 6), (B::Apartment, 3), (B::Shop, 1)], + tall: 0.9, + mid: 0.1, + basement: 0.5, + plaza: 0.1, + neon: 0.2, + holo: 0.2, + lamp_spacing: 10, + security: 110, + hues: &[Hue::Yellow, Hue::White, Hue::Orange], +}; +static COMBAT_ZONE: ThemeSpec = ThemeSpec { + base_wealth: 0.1, + base_decay: 0.9, + street_style: StreetStyle::Shattered, + block: (16, 10), + min_lot: (5, 4), + mix: &[ + (B::Apartment, 3), + (B::Shop, 2), + (B::Bar, 2), + (B::Pawn, 2), + (B::Clinic, 1), + (B::Garage, 1), + ], + tall: 0.05, + mid: 0.2, + basement: 0.1, + plaza: 0.15, + neon: 0.3, + holo: 0.05, + lamp_spacing: 16, + security: 20, + hues: &[Hue::Red, Hue::Yellow, Hue::Orange], +}; +static DOCKS: ThemeSpec = ThemeSpec { + base_wealth: 0.3, + base_decay: 0.6, + street_style: StreetStyle::Rows, + block: (30, 14), + min_lot: (12, 8), + mix: &[ + (B::Warehouse, 6), + (B::Depot, 2), + (B::Bar, 1), + (B::Garage, 1), + ], + tall: 0.0, + mid: 0.2, + basement: 0.2, + plaza: 0.1, + neon: 0.2, + holo: 0.05, + lamp_spacing: 14, + security: 70, + hues: &[Hue::Cyan, Hue::Green, Hue::Orange], +}; +static INDUSTRIAL: ThemeSpec = ThemeSpec { + base_wealth: 0.25, + base_decay: 0.7, + street_style: StreetStyle::Rows, + block: (28, 14), + min_lot: (12, 8), + mix: &[ + (B::Factory, 5), + (B::Warehouse, 3), + (B::Depot, 2), + (B::Garage, 1), + ], + tall: 0.05, + mid: 0.3, + basement: 0.4, + plaza: 0.05, + neon: 0.15, + holo: 0.05, + lamp_spacing: 14, + security: 90, + hues: &[Hue::Yellow, Hue::Green, Hue::White], +}; +static NEON_STRIP: ThemeSpec = ThemeSpec { + base_wealth: 0.6, + base_decay: 0.3, + street_style: StreetStyle::Strip, + block: (18, 10), + min_lot: (6, 4), + mix: &[ + (B::Club, 4), + (B::Bar, 3), + (B::Arcade, 3), + (B::Hotel, 2), + (B::Shop, 1), + ], + tall: 0.2, + mid: 0.5, + basement: 0.2, + plaza: 0.2, + neon: 0.95, + holo: 0.6, + lamp_spacing: 6, + security: 120, + hues: &[Hue::Pink, Hue::Blue, Hue::Magenta], +}; +static OLD_TOWN: ThemeSpec = ThemeSpec { + base_wealth: 0.5, + base_decay: 0.4, + street_style: StreetStyle::Organic, + block: (14, 9), + min_lot: (5, 4), + mix: &[ + (B::Apartment, 4), + (B::Shop, 2), + (B::Temple, 1), + (B::NoodleBar, 2), + (B::Bar, 1), + (B::Clinic, 1), + ], + tall: 0.0, + mid: 0.25, + basement: 0.15, + plaza: 0.25, + neon: 0.4, + holo: 0.1, + lamp_spacing: 12, + security: 80, + hues: &[Hue::Orange, Hue::Green, Hue::White], +}; + +impl Theme { + pub fn spec(self) -> &'static ThemeSpec { + 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, + } + } +} + +impl BuildingKind { + /// Height classes this kind may take, preferred first. + pub fn height_classes(self) -> &'static [HeightClass] { + match self { + B::CorpTower | B::Megablock => &[HeightClass::Tall], + B::Office | B::Hotel => &[HeightClass::Mid, HeightClass::Tall], + B::Apartment => &[HeightClass::Mid, HeightClass::Low], + B::Factory | B::Parking => &[HeightClass::Low, HeightClass::Mid], + _ => &[HeightClass::Low], + } + } +} + +#[cfg(test)] +mod tests { + use crate::world::{BuildingKind, HeightClass, Theme}; + + #[test] + fn theme_mix_weights_nonzero() { + for t in Theme::ALL { + let s = t.spec(); + assert!(s.mix.iter().map(|(_, w)| *w).sum::() > 0); + assert!(s.min_lot.0 >= 4 && s.min_lot.1 >= 3); + assert!(!s.hues.is_empty()); + } + assert_eq!( + BuildingKind::CorpTower.height_classes(), + &[HeightClass::Tall] + ); + assert_eq!( + BuildingKind::Office.height_classes(), + &[HeightClass::Mid, HeightClass::Tall] + ); + assert_eq!(BuildingKind::Shop.height_classes(), &[HeightClass::Low]); + } +} diff --git a/flavor/names.ron b/flavor/names.ron new file mode 100644 index 0000000..182474d --- /dev/null +++ b/flavor/names.ron @@ -0,0 +1,65 @@ +// Default flavor pack: original names in the shared cyberpunk register. +// Swap this file (or add another and select it via GenParams.flavor) to retheme a city. +FlavorPack( + city: NameParts( + prefixes: ["Neo", "New", "Night", "Grey", "Ash", "Iron", "Hollow", "Saint", "Port", "North", "Low", "Vanta", "Chrome", "Static", "Ember"], + suffixes: ["haven", "port", "gate", "spire", "fall", "reach", "burg", "side", "mouth", "wick", "crest", "shore", "vale", "march", "field"], + ), + districts: [ + (CorpCore, NameParts( + prefixes: ["Apex", "Meridian", "Zenith", "Crown", "Axis", "Prime", "Nexus", "Vector", "Summit", "Core", "Helix", "Aurum"], + suffixes: [" Plaza", " Center", " Heights", " District", " Row", " Quarter", " Terrace", " Exchange", " Circle", " Mile", " Spire", " Square"], + )), + (NightMarket, NameParts( + prefixes: ["Kowloon", "Lantern", "Ginza", "Saigon", "Jade", "Paper", "Smoke", "Bazaar", "Spice", "Dragon", "Lotus", "Silk"], + suffixes: [" Market", " Alley", " Walk", " Ward", " Lanes", " Steps", " Street", " Yard", " Court", " Strip", " Row", " Block"], + )), + (Megablocks, NameParts( + prefixes: ["Block", "Stack", "Tower", "Unit", "Sector", "Hive", "Cell", "Slab", "Tier", "Bay", "Deck", "Quad"], + suffixes: [" 7", " 12", " 19", " 23", " 31", " 44", " 56", " 61", " 77", " 88", " 93", " 101"], + )), + (CombatZone, NameParts( + prefixes: ["Rust", "Scrap", "Cinder", "Dead", "Broken", "Razor", "Burnout", "Gravel", "Wreck", "Hollow", "Dust", "Scar"], + suffixes: [" Row", " Yards", " Flats", " End", " Hollow", " Cut", " Run", " Pit", " Zone", " Sprawl", " Line", " Reach"], + )), + (Docks, NameParts( + prefixes: ["Harbor", "Pier", "Tide", "Salt", "Anchor", "Cargo", "Quay", "Wharf", "Bilge", "Keel", "Gull", "Brine"], + suffixes: [" Point", " Docks", " Landing", " Basin", " Wharf", " Slip", " Reach", " Yard", " Front", " Bank", " Mole", " Strand"], + )), + (Industrial, NameParts( + prefixes: ["Furnace", "Foundry", "Reactor", "Kiln", "Crucible", "Smelter", "Turbine", "Boiler", "Coil", "Refinery", "Slag", "Pylon"], + suffixes: [" Works", " Yards", " Park", " Annex", " Fields", " Belt", " Grid", " Works", " Row", " Stacks", " Pit", " Sector"], + )), + (NeonStrip, NameParts( + prefixes: ["Neon", "Velvet", "Mirage", "Pulse", "Glitter", "Chrome", "Halo", "Afterglow", "Vapor", "Siren", "Candy", "Strobe"], + suffixes: [" Strip", " Boulevard", " Mile", " Drag", " Lane", " Avenue", " Parade", " Run", " Way", " Walk", " Row", " Line"], + )), + (OldTown, NameParts( + prefixes: ["Old", "Temple", "Cobble", "Lantern", "Ivy", "Chapel", "Mill", "Orchard", "Clock", "Tanner", "Weaver", "Bell"], + suffixes: [" Town", " Hill", " Quarter", " Close", " Lane", " Green", " Yard", " Gate", " Walk", " Cross", " Rise", " Row"], + )), + ], + corp: CorpParts( + first: ["Arasu", "Kiro", "Nov", "Hex", "Mira", "Tess", "Oro", "Vel", "Kai", "Zen", "Dai", "Sol", "Yuki", "Bran", "Ark", "Lum"], + second: ["tek", "dyne", "corp", "soft", "gen", "ware", "sys", "line", "med", "sec", "net", "bio", "form", "tron", "core", "flux"], + suffix: ["", " Industries", " Group", " Holdings", " Systems", " Biotech", " Dynamics", " Securities", " Logistics", " Media", " Capital", " Labs"], + ), + shops: [ + (NoodleBar, ["{syl} Noodles", "Ramen {syl}", "{syl} Kitchen", "Hot {syl}", "{syl} Bowl"]), + (Bar, ["The {syl}", "{syl} Lounge", "Bar {syl}", "{syl} & Static", "Dive {syl}"]), + (Club, ["Club {syl}", "{syl} Afterdark", "Velvet {syl}", "{syl} Pulse"]), + (Clinic, ["{syl} Ripperdoc", "Clinic {syl}", "{syl} Chrome", "Doc {syl}"]), + (Shop, ["{syl} Goods", "{syl} Mart", "24h {syl}", "{syl} Supply"]), + (Pawn, ["{syl} Pawn", "{syl} & Sons", "Cash {syl}"]), + (Arcade, ["{syl} Arcade", "Neon {syl}", "{syl} Braindance"]), + (Hotel, ["Hotel {syl}", "{syl} Capsule", "The {syl} Inn"]), + (Temple, ["{syl} Temple", "Shrine of {syl}", "{syl} Mission"]), + (Market, ["{syl} Market", "{syl} Bazaar"]), + (Garage, ["{syl} Motors", "{syl} Autoworks"]), + (Warehouse, ["{syl} Storage", "{syl} Freight"]), + (Factory, ["{syl} Fabrication", "{syl} Assembly"]), + (Depot, ["{syl} Depot", "{syl} Transit"]), + ], + landmarks: ["{corp} Tower", "{district} Terminal", "The {district} Arcology", "{corp} Plaza", "Old {district} Station"], + signage: ['ヲ','ァ','ィ','ゥ','ェ','ォ','ャ','ュ','ョ','ッ','ー','ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ','サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト','ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ','マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ','ル','レ','ロ','ワ','ン','Ж','Ф','Ц','Ш','Я','Δ','Σ','Ψ','Ω','¥','$','€','§','¤'], +)