feat(citygen): flavor pack and theme table

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 16:44:14 +02:00
parent 3a17d7e1cd
commit c8d8deeb0f
4 changed files with 498 additions and 0 deletions

View File

@@ -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<String>,
pub suffixes: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CorpParts {
pub first: Vec<String>,
pub second: Vec<String>,
pub suffix: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FlavorPack {
pub city: NameParts,
pub districts: Vec<(Theme, NameParts)>,
pub corp: CorpParts,
pub shops: Vec<(BuildingKind, Vec<String>)>,
pub landmarks: Vec<String>,
pub signage: Vec<char>,
}
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<FlavorPack, GenError> {
match name {
"default" => FlavorPack::parse(DEFAULT_PACK),
other => Err(GenError::Flavor(format!("unknown flavor pack `{other}`"))),
}
}
pub fn parse(text: &str) -> Result<FlavorPack, GenError> {
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<String> {
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);
}
}

View File

@@ -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::*;

271
crates/citygen/src/theme.rs Normal file
View File

@@ -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::<u32>() > 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]);
}
}