diff --git a/crates/city/src/app.rs b/crates/city/src/app.rs index 991d442..7a92fd5 100644 --- a/crates/city/src/app.rs +++ b/crates/city/src/app.rs @@ -19,6 +19,7 @@ pub const FRAME: Duration = Duration::from_micros(33_333); pub struct AppState { pub params: GenParams, pub city: Option, + pub player: Option, pub style: StylePack, pub tick: u64, status: Option<(String, u64)>, @@ -35,6 +36,7 @@ impl AppState { AppState { params: GenParams::default(), city: None, + player: None, style: StylePack::load().expect("embedded style pack is valid"), tick: 0, status: None, diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index e5deff5..de7b459 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -11,6 +11,7 @@ mod save; mod screens; mod term; mod ui; +mod walker; fn main() -> anyhow::Result<()> { cli::run() diff --git a/crates/city/src/screens/configure.rs b/crates/city/src/screens/configure.rs new file mode 100644 index 0000000..7150445 --- /dev/null +++ b/crates/city/src/screens/configure.rs @@ -0,0 +1,27 @@ +//! The configure screen: a form over `GenParams` with a live preview card. + +use crate::app::{AppState, Screen, ScreenCmd}; +use citygen::GenParams; +use crossterm::event::KeyEvent; +use ratatui::Frame; + +pub struct Configure { + params: GenParams, + opened: u64, +} + +impl Configure { + pub fn new(params: GenParams, tick: u64) -> Self { + Configure { + params, + opened: tick, + } + } +} + +impl Screen for Configure { + fn update(&mut self, _st: &mut AppState, _keys: &[KeyEvent]) -> ScreenCmd { + ScreenCmd::Stay + } + fn draw(&self, _st: &AppState, _f: &mut Frame) {} +} diff --git a/crates/city/src/screens/generating.rs b/crates/city/src/screens/generating.rs new file mode 100644 index 0000000..bffe112 --- /dev/null +++ b/crates/city/src/screens/generating.rs @@ -0,0 +1,367 @@ +//! 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, + params: GenParams, + pub steps_per_frame: u32, + pub paused: bool, + current: Option<(Stage, f32)>, + done: Vec, + cam: Camera, + error: Option, + 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 { + 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. + 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 = 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(); + } +} diff --git a/crates/city/src/screens/mod.rs b/crates/city/src/screens/mod.rs index f8f2ca0..d08479f 100644 --- a/crates/city/src/screens/mod.rs +++ b/crates/city/src/screens/mod.rs @@ -4,6 +4,9 @@ use crate::app::{AppState, Screen, ScreenCmd}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::Frame; +pub mod configure; +pub mod generating; + /// Draws nothing; stands in until the title screen lands. pub struct Blank; @@ -26,6 +29,11 @@ pub fn initial() -> impl Screen { Blank } +/// The screen shown once a city exists (explore mode). Wired to the map screen later. +pub fn explore_screen(_tick: u64) -> Box { + Box::new(Blank) +} + #[cfg(test)] pub(crate) mod testutil { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; diff --git a/crates/city/src/walker/mod.rs b/crates/city/src/walker/mod.rs new file mode 100644 index 0000000..ae645e1 --- /dev/null +++ b/crates/city/src/walker/mod.rs @@ -0,0 +1,5 @@ +//! The walker: the player's `@`, movement, connectors and field of view. + +/// The player. Filled in by the walker task; declared here so app state can hold one. +#[derive(Clone, Debug)] +pub struct Player;