docs: README, dead-code cleanup, smoke fixes
- README with run instructions and key reference - lift the crate-level dead_code allowance; scope it to the anim library surface - render paths tolerate a city that is still generating (district/building ids without entries), found by driving the app through a pty - status messages show in the status bar, not only inside the inspector - buffered JSON export (26 s -> 0.4 s for a large city) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -142,6 +142,7 @@ name = "citygen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"noise",
|
||||
"postcard",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"ron",
|
||||
|
||||
76
README.md
Normal file
76
README.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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<ConnDir>,
|
||||
pub kind: Option<BuildingKind>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Pos> = city
|
||||
.connectors
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user