diff --git a/Cargo.lock b/Cargo.lock index 9b4a05e..8e627ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,7 @@ name = "citygen" version = "0.1.0" dependencies = [ "noise", + "postcard", "rand 0.9.5", "rand_chacha", "ron", diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4882dd --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# cyberpunk + +A terminal game in a full cyberpunk setting. This is the first piece: a seeded, +deterministic **city generator** and the full-screen terminal app around it. + +The generator builds a multi-level city as walkable tile maps on four strata: + +| Stratum | What lives there | +|---|---| +| Heights | rooftops of the tall towers, helipads, bridges between them | +| Skyways | elevated walkways, mid-rise lobbies, platforms over intersections | +| Street | districts, arterials, blocks, buildings, markets, neon | +| Undercity | tunnels under the roads, sewers, a rail line, basements, black clinics | + +Every building you see at street level is the same building you stand on at +Heights. Districts have themes (corp core, night market, megablocks, combat +zone, docks, industrial, neon strip, old town) that drive street layout, +building mix, palette and signage. A Net graph of subnets, nodes and links is +anchored to the physical tiles and can be shown as a digital overlay. + +Same seed and parameters always produce a byte-identical city. + +## Run + +```sh +cargo run --release -p city +``` + +Headless generation, for scripts and the game later: + +```sh +cargo run --release -p city -- gen --seed 42 --size medium --out cities/demo.city +cargo run --release -p city -- gen --seed 42 --size large --json --out /tmp/demo.json +``` + +Sizes: `small` (160x80), `medium` (240x120), `large` (320x160) or `WxH`. +Other flags: `--districts 4..12`, `--water none|bay|river`, `--density 0..1`. + +Needs a truecolor terminal of at least 100x30 cells (iTerm2, Ghostty, kitty, +WezTerm). Saved cities go to `./cities/*.city`. + +## Keys + +**Title** `↑↓` select · `enter` confirm · `q` quit + +**Configure** `↑↓` field · `←→` adjust · type digits into the seed · `enter` +on `RANDOM SEED` / `GENERATE` · `esc` back + +**Generating** `space` pause · `+` `-` steps per frame · `enter` skip to the +end · `r` reseed after an error · `esc` back + +**Explore** `hjkl` / arrows pan (shift: fast) · `[` `]` stratum down / up · +`w` walk · `i` inspector · `m` minimap · `e` legend · `d` digital overlay · +`s` save · `r` regenerate · `g` configure · `?` keys · `q` title + +**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or +manhole · `.` wait · `tab` back to the free camera · panels as above + +## Layout + +``` +crates/citygen world model + generator (no terminal dependencies) +crates/city the ratatui app and the `gen` CLI +flavor/ names.ron (names, signage glyphs) · style.ron (palettes, glyph sets) +docs/superpowers/specs design spec +docs/superpowers/plans implementation plan +``` + +## Develop + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo test -p citygen --test seeds -- --ignored # 250-seed sweep +``` diff --git a/crates/city/src/anim/mod.rs b/crates/city/src/anim/mod.rs index 5d82b53..bf4c633 100644 --- a/crates/city/src/anim/mod.rs +++ b/crates/city/src/anim/mod.rs @@ -1,4 +1,8 @@ //! Tick-driven animation primitives: easing, tweens, timelines. +//! +//! This is a small library surface for every screen, so parts of it (extra easings, +//! `Timeline`) are exercised by tests before any screen uses them. +#![allow(dead_code)] pub mod effects; diff --git a/crates/city/src/cli.rs b/crates/city/src/cli.rs index 9ea6787..d8563c5 100644 --- a/crates/city/src/cli.rs +++ b/crates/city/src/cli.rs @@ -109,7 +109,9 @@ pub fn run() -> Result<()> { if json { let file = std::fs::File::create(&out) .with_context(|| format!("creating {}", out.display()))?; - serde_json::to_writer_pretty(file, &city)?; + let mut writer = std::io::BufWriter::new(file); + serde_json::to_writer_pretty(&mut writer, &city)?; + std::io::Write::flush(&mut writer)?; } else { save_city(&out, &city)?; } diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index de7b459..f66dc68 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -1,8 +1,5 @@ //! `city`: generate, explore and walk a cyberpunk city in the terminal. -// Screens, widgets and overlays land task by task; lifted once the app is wired up. -#![allow(dead_code)] - mod anim; mod app; mod cli; diff --git a/crates/city/src/render/minimap.rs b/crates/city/src/render/minimap.rs index 9307838..dcad8f0 100644 --- a/crates/city/src/render/minimap.rs +++ b/crates/city/src/render/minimap.rs @@ -56,7 +56,10 @@ impl Widget for Minimap<'_> { water += 1; } if theme.is_none() { - theme = t.district.map(|d| self.city.district(d).theme); + theme = t + .district + .and_then(|d| self.city.district_opt(d)) + .map(|d| d.theme); } } let cell = &mut buf[(area.x + cx, area.y + cy)]; diff --git a/crates/city/src/render/mod.rs b/crates/city/src/render/mod.rs index eb87bff..f681ac6 100644 --- a/crates/city/src/render/mod.rs +++ b/crates/city/src/render/mod.rs @@ -9,7 +9,7 @@ pub mod traffic; pub mod viewport; pub mod weather; -use citygen::{BuildingKind, ConnectorKind, Pos, StratumKind, Theme}; +use citygen::{BuildingKind, ConnectorKind, StratumKind, Theme}; use ratatui::buffer::Buffer; use ratatui::style::{Color, Modifier, Style}; use serde::{Deserialize, Serialize}; @@ -95,7 +95,6 @@ pub struct RenderCtx<'a> { pub stratum: StratumKind, pub light: u8, pub tick: u64, - pub pos: Pos, pub vis: Visibility, pub connector: Option, pub kind: Option, diff --git a/crates/city/src/render/tileset.rs b/crates/city/src/render/tileset.rs index c5705cb..2fbfd21 100644 --- a/crates/city/src/render/tileset.rs +++ b/crates/city/src/render/tileset.rs @@ -337,9 +337,7 @@ mod tests { use crate::render::lighting::apply_vis; use crate::render::style::StylePack; use crate::render::{RenderCtx, Visibility}; - use citygen::{ - ALL_FEATURES, ALL_TERRAIN, Feature, Hue, Pos, StratumKind, Terrain, Theme, Tile, - }; + use citygen::{ALL_FEATURES, ALL_TERRAIN, Feature, Hue, StratumKind, Terrain, Theme, Tile}; use unicode_width::UnicodeWidthChar; #[test] @@ -389,7 +387,6 @@ mod tests { stratum: StratumKind::Street, light, tick, - pos: Pos::new(1, 1), vis: Visibility::Visible, connector: None, kind: None, diff --git a/crates/city/src/render/viewport.rs b/crates/city/src/render/viewport.rs index ec9a2c6..0578824 100644 --- a/crates/city/src/render/viewport.rs +++ b/crates/city/src/render/viewport.rs @@ -188,14 +188,19 @@ impl Widget for MapView<'_> { .unwrap_or(Visibility::Visible); let ctx = RenderCtx { style: self.style, - theme: tile.district.map(|d| self.city.district(d).theme), + theme: tile + .district + .and_then(|d| self.city.district_opt(d)) + .map(|d| d.theme), stratum: self.stratum, light: st.light.get(p).copied().unwrap_or(0), tick: self.tick, - pos: p, vis, connector, - kind: tile.building.map(|b| self.city.building(b).kind), + kind: tile + .building + .and_then(|b| self.city.building_opt(b)) + .map(|b| b.kind), wall_mask: mask, }; apply_vis(cell(tile, &ctx), vis).write(buf, sx, sy); @@ -282,6 +287,46 @@ mod tests { ); } + #[test] + fn mapview_renders_every_partial_generation_state() { + use citygen::{Generator, Progress}; + let s = StylePack::load().unwrap(); + let mut g = Generator::new(GenParams { + seed: 11, + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }) + .unwrap(); + let mut t = Terminal::new(TestBackend::new(90, 45)).unwrap(); + let mut steps = 0u32; + loop { + let progress = g.step(); + steps += 1; + if steps.is_multiple_of(7) || matches!(progress, Progress::Done) { + for k in StratumKind::PHYSICAL { + t.draw(|f| { + MapView { + city: g.city(), + stratum: k, + origin: Pos::new(-2, -2), + tick: steps as u64, + style: &s, + vis: None, + overlays: &[&Weather], + cursor: None, + } + .render(f.area(), f.buffer_mut()) + }) + .unwrap(); + } + } + if matches!(progress, Progress::Done) { + break; + } + } + } + #[test] fn camera_follow_has_deadzone_and_eases() { let mut cam = Camera::default(); diff --git a/crates/city/src/screens/explore.rs b/crates/city/src/screens/explore.rs index 6014e53..69b3139 100644 --- a/crates/city/src/screens/explore.rs +++ b/crates/city/src/screens/explore.rs @@ -417,13 +417,6 @@ impl Screen for MapScreen { let node = focus.map(|id| city.net.node(id)); lines.extend(inspector_lines(city, self.stratum, cursor, node)); } - if let Some(msg) = st.status_text() { - lines.push(Line::from("")); - lines.push(Line::styled( - format!(" {msg}"), - Style::default().fg(Color::Rgb(255, 200, 80)), - )); - } f.render_widget( Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), inner, @@ -435,7 +428,13 @@ impl Screen for MapScreen { Mode::Walk => "hjkl move · <> stairs · tab camera · d net · ? keys", }; f.render_widget( - Paragraph::new(status_bar(Some(city), self.stratum, self.fps.fps, hints)), + Paragraph::new(status_bar( + Some(city), + self.stratum, + self.fps.fps, + hints, + st.status_text(), + )), status, ); diff --git a/crates/city/src/screens/generating.rs b/crates/city/src/screens/generating.rs index bffe112..b415a70 100644 --- a/crates/city/src/screens/generating.rs +++ b/crates/city/src/screens/generating.rs @@ -64,6 +64,7 @@ impl Generating { } /// Something that changes whenever generation advances; used by tests. + #[cfg(test)] pub fn progress_marker(&self) -> (usize, Option<(Stage, u32)>) { ( self.done.len(), diff --git a/crates/city/src/ui/menu.rs b/crates/city/src/ui/menu.rs index f156da7..c249dd0 100644 --- a/crates/city/src/ui/menu.rs +++ b/crates/city/src/ui/menu.rs @@ -38,13 +38,6 @@ impl Menu { pub fn entrance(&self, i: usize, tick: u64) -> f32 { Tween::new(self.opened + i as u64 * 4, 8, Easing::OutCubic).progress(tick) } - - pub fn settled(&self, tick: u64) -> bool { - self.items - .iter() - .enumerate() - .all(|(i, _)| self.entrance(i, tick) >= 1.0) - } } pub struct MenuView<'a> { diff --git a/crates/city/src/ui/panel.rs b/crates/city/src/ui/panel.rs index f569cbc..759f20a 100644 --- a/crates/city/src/ui/panel.rs +++ b/crates/city/src/ui/panel.rs @@ -46,8 +46,7 @@ pub fn inspector_lines( if let Some(f) = t.feature { out.push(kv("feature", format!("{f:?}").to_lowercase())); } - if let Some(d) = t.district { - let d = city.district(d); + if let Some(d) = t.district.and_then(|d| city.district_opt(d)) { out.push(kv("district", d.name.clone())); out.push(kv("theme", d.theme.label())); out.push(kv( @@ -55,8 +54,7 @@ pub fn inspector_lines( format!("{:.0}% decay {:.0}%", d.wealth * 100.0, d.decay * 100.0), )); } - if let Some(b) = t.building { - let b = city.building(b); + if let Some(b) = t.building.and_then(|b| city.building_opt(b)) { out.push(kv( "building", b.name.clone().unwrap_or_else(|| b.kind.label().to_string()), @@ -149,6 +147,7 @@ pub fn status_bar( stratum: StratumKind, fps: f32, hints: &str, + status: Option<&str>, ) -> Line<'static> { let mut spans = Vec::new(); if let Some(c) = city { @@ -169,7 +168,15 @@ pub fn status_bar( format!("· {fps:.0} fps "), Style::default().fg(DIM), )); - spans.push(Span::styled(format!("· {hints}"), Style::default().fg(DIM))); + match status { + Some(msg) => spans.push(Span::styled( + format!("· {msg}"), + Style::default() + .fg(Color::Rgb(255, 200, 80)) + .add_modifier(Modifier::BOLD), + )), + None => spans.push(Span::styled(format!("· {hints}"), Style::default().fg(DIM))), + } Line::from(spans) } @@ -198,8 +205,16 @@ mod tests { let sel = strata_selector(StratumKind::Skyways, 0); let text: String = sel.iter().map(|l| l.to_string() + "\n").collect(); assert!(text.contains("Skyways") && text.contains("▌")); - let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit"); - assert!(bar.to_string().contains(&c.name)); + let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit", None); + assert!(bar.to_string().contains(&c.name) && bar.to_string().contains("q quit")); + let bar = status_bar( + Some(&c), + StratumKind::Street, + 30.0, + "q quit", + Some("saved x"), + ); + assert!(bar.to_string().contains("saved x") && !bar.to_string().contains("q quit")); assert!(!legend(StratumKind::Undercity).is_empty()); } } diff --git a/crates/city/src/walker/mod.rs b/crates/city/src/walker/mod.rs index 64306b5..a692b01 100644 --- a/crates/city/src/walker/mod.rs +++ b/crates/city/src/walker/mod.rs @@ -24,7 +24,8 @@ impl Player { let is_core = |p: Pos| { city.tile(street, p) .and_then(|t| t.district) - .is_some_and(|d| city.district(d).theme == Theme::CorpCore) + .and_then(|d| city.district_opt(d)) + .is_some_and(|d| d.theme == Theme::CorpCore) }; let public: Vec = city .connectors diff --git a/crates/citygen/src/world.rs b/crates/citygen/src/world.rs index cbc77c0..892032a 100644 --- a/crates/citygen/src/world.rs +++ b/crates/citygen/src/world.rs @@ -366,13 +366,23 @@ impl City { self.connectors.iter().filter(move |c| c.pos == p) } + /// Panics if the id is unknown; use `district_opt` on a city that may still be generating. pub fn district(&self, id: DistrictId) -> &District { &self.districts[id.index()] } + pub fn district_opt(&self, id: DistrictId) -> Option<&District> { + self.districts.get(id.index()) + } + + /// Panics if the id is unknown; use `building_opt` on a city that may still be generating. pub fn building(&self, id: BuildingId) -> &Building { &self.buildings[id.index()] } + + pub fn building_opt(&self, id: BuildingId) -> Option<&Building> { + self.buildings.get(id.index()) + } } #[cfg(test)]