Files
cyberpunk/crates/city/src/screens/generating.rs
Jeffrey Wilcke 3c75674489 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>
2026-10-12 18:02:46 +02:00

369 lines
13 KiB
Rust

//! The generation screen: the city grows in the viewport while a stage checklist fills.
use crate::app::{AppState, Screen, ScreenCmd, TransitionKind};
use crate::render::viewport::{Camera, MapView};
use crate::render::weather::Weather;
use crate::ui::panel::boxed;
use citygen::rng::hash64;
use citygen::{GenParams, Generator, Pos, Progress, Stage, StratumKind};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
const PANEL_W: u16 = 36;
const BAR_W: usize = 16;
pub struct Generating {
generator: Option<Generator>,
params: GenParams,
pub steps_per_frame: u32,
pub paused: bool,
current: Option<(Stage, f32)>,
done: Vec<Stage>,
cam: Camera,
error: Option<String>,
started: u64,
}
/// Short stage names for the checklist.
fn short(stage: Stage) -> &'static str {
match stage {
Stage::Terrain => "Terrain",
Stage::Districts => "Districts",
Stage::Arterials => "Arterials",
Stage::Streets => "Streets",
Stage::Lots => "Buildings",
Stage::Verticals => "Strata",
Stage::Interiors => "Interiors",
Stage::Connectivity => "Links",
Stage::Dressing => "Neon",
Stage::Naming => "Names",
Stage::Net => "Net",
}
}
impl Generating {
pub fn new(params: GenParams, tick: u64) -> anyhow::Result<Self> {
let generator = Generator::new(params.clone())?;
let mut cam = Camera::default();
cam.snap(generator.city().size.center());
Ok(Generating {
generator: Some(generator),
params,
steps_per_frame: 8,
paused: false,
current: None,
done: Vec::new(),
cam,
error: None,
started: tick,
})
}
/// Something that changes whenever generation advances; used by tests.
#[cfg(test)]
pub fn progress_marker(&self) -> (usize, Option<(Stage, u32)>) {
(
self.done.len(),
self.current.map(|(s, p)| (s, (p * 1000.0) as u32)),
)
}
fn step_once(&mut self) -> bool {
let Some(generator) = self.generator.as_mut() else {
return false;
};
match generator.step() {
Progress::Running { stage, pct } => {
if let Some((prev, _)) = self.current
&& prev != stage
&& !self.done.contains(&prev)
{
self.done.push(prev);
}
self.current = Some((stage, pct));
true
}
Progress::Done => {
if let Some((prev, _)) = self.current
&& !self.done.contains(&prev)
{
self.done.push(prev);
}
false
}
}
}
fn finish(&mut self, st: &mut AppState) -> ScreenCmd {
let Some(generator) = self.generator.take() else {
return ScreenCmd::Stay;
};
match generator.finish() {
Ok(city) => {
st.params = self.params.clone();
st.city = Some(city);
st.player = None;
ScreenCmd::Switch {
to: crate::screens::explore_screen(st.tick),
fx: TransitionKind::Glitch,
}
}
Err(e) => {
self.error = Some(e.to_string());
ScreenCmd::Stay
}
}
}
}
impl Screen for Generating {
fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd {
let mut skip = false;
for k in keys {
match k.code {
KeyCode::Char(' ') => self.paused = !self.paused,
KeyCode::Char('+') | KeyCode::Char('=') => {
self.steps_per_frame = (self.steps_per_frame * 2).min(64)
}
KeyCode::Char('-') => self.steps_per_frame = (self.steps_per_frame / 2).max(1),
KeyCode::Enter => skip = true,
KeyCode::Char('r') => {
let seed = hash64(self.params.seed, st.tick);
match Generating::new(self.params.clone().with_seed(seed), st.tick) {
Ok(next) => {
return ScreenCmd::Switch {
to: Box::new(next),
fx: TransitionKind::Glitch,
};
}
Err(e) => st.set_status(e.to_string(), 90),
}
}
KeyCode::Esc => {
return ScreenCmd::Switch {
to: Box::new(crate::screens::configure::Configure::new(
self.params.clone(),
st.tick,
)),
fx: TransitionKind::SlideUp,
};
}
_ => {}
}
}
if self.error.is_some() || self.generator.is_none() {
return ScreenCmd::Stay;
}
let mut running = true;
if skip {
while running {
running = self.step_once();
}
} else if !self.paused {
for _ in 0..self.steps_per_frame {
running = self.step_once();
if !running {
break;
}
}
}
if !running {
return self.finish(st);
}
ScreenCmd::Stay
}
fn draw(&self, st: &AppState, f: &mut Frame) {
let area = f.area();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(PANEL_W)])
.split(area);
let map_area = cols[0];
let panel = cols[1];
if let Some(generator) = &self.generator {
let city = generator.city();
let mut cam = self.cam;
let drift = ((st.tick as f32) / 90.0).sin() * city.size.w as f32 / 6.0;
cam.target(Pos::new(
city.size.center().x + drift as i32,
city.size.center().y,
));
cam.clamp(city.size, map_area.width, map_area.height);
cam.x += (cam.tx as f32 - cam.x) * 0.1;
cam.y += (cam.ty as f32 - cam.y) * 0.1;
MapView {
city,
stratum: StratumKind::Street,
origin: cam.origin(map_area.width, map_area.height),
tick: st.tick,
style: &st.style,
vis: None,
overlays: &[&Weather],
cursor: None,
}
.render(map_area, f.buffer_mut());
}
let title = format!("GENERATING seed {}", self.params.seed);
let block = boxed(&title);
let inner = block.inner(panel);
f.render_widget(block, panel);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
format!(
" {}x{} {} districts",
self.params.size.size().w,
self.params.size.size().h,
self.params.districts
),
Style::default().fg(Color::Rgb(120, 130, 145)),
)));
lines.push(Line::from(""));
for stage in Stage::ALL {
let done = self.done.contains(&stage);
let active = self.current.map(|(s, _)| s) == Some(stage) && !done;
let line = if done {
Line::from(Span::styled(
format!(" [✓] {:<10}", short(stage)),
Style::default().fg(Color::Rgb(90, 160, 120)),
))
} else if active {
let pct = self.current.map(|(_, p)| p).unwrap_or(0.0);
let filled = (pct * BAR_W as f32).round() as usize;
let bar: String = (0..BAR_W)
.map(|i| if i < filled { '▓' } else { '░' })
.collect();
Line::from(vec![
Span::styled(
format!(" [▸] {:<10} ", short(stage)),
Style::default()
.fg(Color::Rgb(0, 229, 255))
.add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(Color::Rgb(0, 200, 230))),
Span::styled(
format!(" {:>3.0}%", pct * 100.0),
Style::default().fg(Color::Rgb(180, 190, 200)),
),
])
} else {
Line::from(Span::styled(
format!(" [ ] {:<10}", short(stage)),
Style::default().fg(Color::Rgb(80, 88, 100)),
))
};
lines.push(line);
}
lines.push(Line::from(""));
if let Some((stage, _)) = self.current {
lines.push(Line::from(Span::styled(
format!(" {}…", stage.label()),
Style::default().fg(Color::Rgb(160, 170, 185)),
)));
}
if let Some(e) = &self.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {e}"),
Style::default().fg(Color::Rgb(255, 80, 100)),
)));
lines.push(Line::from(Span::styled(
" r reseed · esc back",
Style::default().fg(Color::Rgb(120, 130, 145)),
)));
} else {
let state = if self.paused { "paused" } else { "running" };
lines.push(Line::from(Span::styled(
format!(" {state} · x{} per frame", self.steps_per_frame),
Style::default().fg(Color::Rgb(120, 130, 145)),
)));
}
let hint_y = inner.bottom().saturating_sub(1);
f.render_widget(Paragraph::new(lines), inner);
if hint_y > inner.y {
f.render_widget(
Paragraph::new(Span::styled(
" space pause · +/- speed · enter skip",
Style::default().fg(Color::Rgb(100, 110, 125)),
)),
Rect::new(inner.x, hint_y, inner.width, 1),
);
}
let _ = self.started;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppState, Screen, ScreenCmd};
use crate::screens::testutil::{buffer_text, key};
use citygen::{GenParams, SizePreset};
use crossterm::event::KeyCode;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn params() -> GenParams {
GenParams {
seed: 2,
size: SizePreset::Custom { w: 80, h: 40 },
districts: 4,
..Default::default()
}
}
#[test]
fn generating_reaches_explore() {
let mut st = AppState::new();
let mut g = Generating::new(params(), 0).unwrap();
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
t.draw(|f| g.draw(&st, f)).unwrap();
let text = buffer_text(t.backend().buffer());
assert!(text.contains("Terrain") && text.contains("Net"), "{text}");
let mut cmd = ScreenCmd::Stay;
for _ in 0..100_000 {
st.tick += 1;
cmd = g.update(&mut st, &[key(KeyCode::Enter)]);
if !matches!(cmd, ScreenCmd::Stay) {
break;
}
}
assert!(matches!(cmd, ScreenCmd::Switch { .. }));
assert!(st.city.is_some());
assert_eq!(st.params.seed, 2);
}
#[test]
fn generating_pauses_and_changes_speed() {
let mut st = AppState::new();
let mut g = Generating::new(params(), 0).unwrap();
g.update(&mut st, &[key(KeyCode::Char(' '))]);
assert!(g.paused);
let before = g.progress_marker();
for _ in 0..5 {
st.tick += 1;
g.update(&mut st, &[]);
}
assert_eq!(g.progress_marker(), before);
g.update(&mut st, &[key(KeyCode::Char(' '))]);
g.update(&mut st, &[key(KeyCode::Char('+'))]);
assert_eq!(g.steps_per_frame, 16);
for _ in 0..10 {
g.update(&mut st, &[key(KeyCode::Char('-'))]);
}
assert_eq!(g.steps_per_frame, 1);
assert!(matches!(
g.update(&mut st, &[key(KeyCode::Esc)]),
ScreenCmd::Switch { .. }
));
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
t.draw(|f| g.draw(&st, f)).unwrap();
}
}