From 48320eb0e635976609081255ea01c19f286572ab Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:54:00 +0200 Subject: [PATCH] feat(city): explore and walk screens with panels and help Co-Authored-By: Claude Fable 5.1 --- crates/city/src/screens/explore.rs | 551 +++++++++++++++++++++++++++++ crates/city/src/screens/help.rs | 90 +++++ crates/city/src/screens/mod.rs | 29 +- 3 files changed, 647 insertions(+), 23 deletions(-) create mode 100644 crates/city/src/screens/explore.rs create mode 100644 crates/city/src/screens/help.rs diff --git a/crates/city/src/screens/explore.rs b/crates/city/src/screens/explore.rs new file mode 100644 index 0000000..6014e53 --- /dev/null +++ b/crates/city/src/screens/explore.rs @@ -0,0 +1,551 @@ +//! The map screen: free-camera Explore mode and the Walk mode with the `@`. + +use crate::app::{AppState, Screen, ScreenCmd, TransitionKind}; +use crate::render::minimap::Minimap; +use crate::render::overlay::Digital; +use crate::render::traffic::Traffic; +use crate::render::viewport::{Camera, MapView, Overlay, tile_to_screen}; +use crate::render::weather::Weather; +use crate::screens::configure::Configure; +use crate::screens::files::Files; +use crate::screens::generating::Generating; +use crate::screens::help; +use crate::screens::title::Title; +use crate::ui::panel::{boxed, inspector_lines, legend, status_bar, strata_selector}; +use crate::walker::Player; +use citygen::rng::hash64; +use citygen::{Dir, Pos, Rect as TileRect, StratumKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Widget}; +use std::cell::Cell; +use std::time::Instant; + +const PANEL_W: u16 = 34; +const MINIMAP_W: u16 = 26; +const MINIMAP_H: u16 = 13; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Explore, + Walk, +} + +/// Frames per second over the last second. +pub struct FpsMeter { + last: Instant, + frames: u32, + pub fps: f32, +} + +impl Default for FpsMeter { + fn default() -> Self { + FpsMeter { + last: Instant::now(), + frames: 0, + fps: 0.0, + } + } +} + +impl FpsMeter { + pub fn frame(&mut self) { + self.frames += 1; + let elapsed = self.last.elapsed().as_secs_f32(); + if elapsed >= 1.0 { + self.fps = self.frames as f32 / elapsed; + self.frames = 0; + self.last = Instant::now(); + } + } +} + +pub struct MapScreen { + pub mode: Mode, + pub stratum: StratumKind, + pub cam: Camera, + pub show_inspector: bool, + pub show_minimap: bool, + pub show_legend: bool, + pub digital: bool, + pub help: bool, + traffic: Traffic, + fps: FpsMeter, + cam_ready: bool, + /// The map area of the last frame, so `update` knows the view size. + last_map: Cell, +} + +fn dir_for(code: KeyCode) -> Option { + Some(match code { + KeyCode::Up | KeyCode::Char('k') => Dir::N, + KeyCode::Down | KeyCode::Char('j') => Dir::S, + KeyCode::Left | KeyCode::Char('h') => Dir::W, + KeyCode::Right | KeyCode::Char('l') => Dir::E, + KeyCode::Char('y') => Dir::NW, + KeyCode::Char('u') => Dir::NE, + KeyCode::Char('b') => Dir::SW, + KeyCode::Char('n') => Dir::SE, + _ => return None, + }) +} + +impl MapScreen { + pub fn explore(_tick: u64) -> Self { + MapScreen { + mode: Mode::Explore, + stratum: StratumKind::Street, + cam: Camera::default(), + show_inspector: true, + show_minimap: true, + show_legend: false, + digital: false, + help: false, + traffic: Traffic::default(), + fps: FpsMeter::default(), + cam_ready: false, + last_map: Cell::new(Rect::new(0, 0, 100, 28)), + } + } + + /// The tile the inspector describes: the player in Walk mode, the view centre otherwise. + fn cursor(&self, st: &AppState) -> Pos { + match (self.mode, &st.player) { + (Mode::Walk, Some(p)) => p.pos, + _ => Pos::new(self.cam.tx, self.cam.ty), + } + } + + fn layout(&self, area: Rect) -> (Rect, Option, Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(1)]) + .split(area); + let status = rows[1]; + if self.show_inspector && rows[0].width > PANEL_W + 20 { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Length(PANEL_W)]) + .split(rows[0]); + (cols[0], Some(cols[1]), status) + } else { + (rows[0], None, status) + } + } + + fn change_stratum(&mut self, delta: i32) { + let i = self.stratum.index() as i32 + delta; + if let Some(k) = StratumKind::from_index(i.clamp(0, 3) as usize) { + self.stratum = k; + } + } + + fn enter_walk(&mut self, st: &mut AppState) { + let Some(city) = st.city.as_ref() else { return }; + if st.player.is_none() { + st.player = Some(Player::spawn(city, hash64(city.seed, st.tick))); + } + if let Some(p) = &st.player { + self.stratum = p.stratum; + self.cam.snap(p.pos); + } + self.mode = Mode::Walk; + } + + fn handle_common(&mut self, st: &mut AppState, k: &KeyEvent) -> Option { + match k.code { + KeyCode::Char('i') => self.show_inspector = !self.show_inspector, + KeyCode::Char('m') => self.show_minimap = !self.show_minimap, + KeyCode::Char('e') => self.show_legend = !self.show_legend, + KeyCode::Char('d') => self.digital = !self.digital, + KeyCode::Char('?') => self.help = !self.help, + KeyCode::Char('s') => { + let name = st.city.as_ref().map(|c| c.name.clone())?; + return Some(ScreenCmd::Switch { + to: Box::new(Files::save(st.tick, &name)), + fx: TransitionKind::None, + }); + } + KeyCode::Char('q') => { + return Some(ScreenCmd::Switch { + to: Box::new(Title::new(st.tick)), + fx: TransitionKind::SlideUp, + }); + } + KeyCode::Esc => { + if self.help { + self.help = false; + } else { + return Some(ScreenCmd::Switch { + to: Box::new(Title::new(st.tick)), + fx: TransitionKind::SlideUp, + }); + } + } + _ => return None, + } + Some(ScreenCmd::Stay) + } + + fn handle_explore(&mut self, st: &mut AppState, k: &KeyEvent) -> Option { + let fast = k.modifiers.contains(KeyModifiers::SHIFT) + || matches!(k.code, KeyCode::Char('H' | 'J' | 'K' | 'L')); + let step = if fast { 8 } else { 1 }; + let code = match k.code { + KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()), + other => other, + }; + match code { + _ if dir_for(code).is_some() + && !matches!(code, KeyCode::Char('y' | 'u' | 'b' | 'n')) => + { + let (dx, dy) = dir_for(code).expect("checked").delta(); + self.cam.pan(dx * step, dy * step); + } + KeyCode::Char('[') => self.change_stratum(-1), + KeyCode::Char(']') => self.change_stratum(1), + KeyCode::Char('w') => self.enter_walk(st), + KeyCode::Char('r') => { + let params = st.params.clone().with_seed(hash64(st.params.seed, st.tick)); + return Some(match Generating::new(params, st.tick) { + Ok(next) => ScreenCmd::Switch { + to: Box::new(next), + fx: TransitionKind::Glitch, + }, + Err(e) => { + st.set_status(e.to_string(), 90); + ScreenCmd::Stay + } + }); + } + KeyCode::Char('g') => { + return Some(ScreenCmd::Switch { + to: Box::new(Configure::new(st.params.clone(), st.tick)), + fx: TransitionKind::SlideUp, + }); + } + _ => return None, + } + Some(ScreenCmd::Stay) + } + + fn handle_walk(&mut self, st: &mut AppState, k: &KeyEvent) -> Option { + let city = st.city.as_ref()?; + let player = st.player.as_mut()?; + match k.code { + KeyCode::Tab => self.mode = Mode::Explore, + KeyCode::Char('<') | KeyCode::Char('>') => { + let up = k.code == KeyCode::Char('>'); + match player.use_connector(city, up) { + Some(dest) => { + self.stratum = dest; + player.update_fov(city); + st.set_status(format!("now on {}", dest.label()), 60); + } + None => st.set_status( + if up { + "nothing leads up from here" + } else { + "nothing leads down from here" + }, + 60, + ), + } + } + KeyCode::Char('.') => player.update_fov(city), + code => { + let dir = dir_for(code)?; + if player.try_move(city, dir) { + player.update_fov(city); + } + } + } + Some(ScreenCmd::Stay) + } +} + +impl Screen for MapScreen { + fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { + self.fps.frame(); + let Some(size) = st.city.as_ref().map(|c| c.size) else { + if keys + .iter() + .any(|k| matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)) + { + return ScreenCmd::Switch { + to: Box::new(Title::new(st.tick)), + fx: TransitionKind::SlideUp, + }; + } + return ScreenCmd::Stay; + }; + if !self.cam_ready { + self.cam.snap(size.center()); + self.cam_ready = true; + } + for k in keys { + if let Some(cmd) = self.handle_common(st, k) { + if !matches!(cmd, ScreenCmd::Stay) { + return cmd; + } + continue; + } + let cmd = match self.mode { + Mode::Explore => self.handle_explore(st, k), + Mode::Walk => self.handle_walk(st, k), + }; + if let Some(cmd) = cmd + && !matches!(cmd, ScreenCmd::Stay) + { + return cmd; + } + } + let map = self.last_map.get(); + if let (Mode::Walk, Some(p)) = (self.mode, &st.player) { + self.cam.follow(p.pos, map.width, map.height); + } + self.cam.clamp(size, map.width, map.height); + self.cam.update(); + if let Some(city) = st.city.as_ref() { + let origin = self.cam.origin(map.width, map.height); + let view = TileRect::new(origin.x, origin.y, map.width as i32, map.height as i32); + self.traffic.update(city, self.stratum, view, st.tick); + } + ScreenCmd::Stay + } + + fn draw(&self, st: &AppState, f: &mut Frame) { + let area = f.area(); + let Some(city) = st.city.as_ref() else { + f.render_widget( + Paragraph::new("no city loaded · q back").alignment(Alignment::Center), + Rect::new(area.x, area.y + area.height / 2, area.width, 1), + ); + return; + }; + let (map, panel, status) = self.layout(area); + self.last_map.set(map); + let origin = self.cam.origin(map.width, map.height); + let cursor = self.cursor(st); + let focus = if self.digital { + Digital::node_under(city, self.stratum, cursor) + } else { + None + }; + let digital = Digital { + enabled: self.digital, + focus, + }; + let overlays: [&dyn Overlay; 3] = [&Weather, &self.traffic, &digital]; + let walking = self.mode == Mode::Walk + && st + .player + .as_ref() + .is_some_and(|p| p.stratum == self.stratum); + let vis = if walking { + st.player.as_ref().map(|p| p.vis()) + } else { + None + }; + MapView { + city, + stratum: self.stratum, + origin, + tick: st.tick, + style: &st.style, + vis, + overlays: &overlays, + cursor: if self.mode == Mode::Explore { + Some(cursor) + } else { + None + }, + } + .render(map, f.buffer_mut()); + + if let Some(p) = &st.player + && p.stratum == self.stratum + && let Some((sx, sy)) = tile_to_screen(map, origin, p.pos) + { + f.buffer_mut()[(sx, sy)].set_char('@').set_style( + Style::default() + .fg(Color::Rgb(255, 255, 255)) + .add_modifier(Modifier::BOLD), + ); + } + + if self.show_minimap && map.width > MINIMAP_W + 4 && map.height > MINIMAP_H + 4 { + let mm = Rect::new( + map.right() - MINIMAP_W - 1, + map.bottom() - MINIMAP_H - 1, + MINIMAP_W, + MINIMAP_H, + ); + let block = boxed("map"); + let inner = block.inner(mm); + f.render_widget(block, mm); + Minimap { + city, + stratum: self.stratum, + view: TileRect::new(origin.x, origin.y, map.width as i32, map.height as i32), + player: st + .player + .as_ref() + .filter(|p| p.stratum == self.stratum) + .map(|p| p.pos), + style: &st.style, + } + .render(inner, f.buffer_mut()); + } + + if let Some(panel) = panel { + let block = boxed(if self.show_legend { + "LEGEND" + } else { + "INSPECT" + }); + let inner = block.inner(panel); + f.render_widget(block, panel); + let mut lines: Vec = strata_selector(self.stratum, st.tick); + lines.push(Line::from("")); + if self.show_legend { + lines.extend(legend(self.stratum)); + } else { + 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, + ); + } + + let hints = match self.mode { + Mode::Explore => "hjkl pan · [] level · w walk · d net · e legend · s save · ? keys", + 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)), + status, + ); + + if self.help { + help::draw(f, self.mode); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, Screen, ScreenCmd}; + use crate::screens::testutil::{buffer_text, key}; + use citygen::{GenParams, Generator, SizePreset, StratumKind}; + use crossterm::event::KeyCode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn state() -> AppState { + let mut st = AppState::new(); + st.city = Some( + Generator::run(GenParams { + seed: 6, + size: SizePreset::Custom { w: 120, h: 60 }, + districts: 5, + ..Default::default() + }) + .unwrap(), + ); + st + } + + #[test] + fn explore_and_walk_round_trip() { + let mut st = state(); + let mut s = MapScreen::explore(0); + let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap(); + t.draw(|f| s.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!(text.contains("Street"), "{text}"); + s.update(&mut st, &[key(KeyCode::Char(']'))]); + assert_eq!(s.stratum, StratumKind::Skyways); + s.update(&mut st, &[key(KeyCode::Char('['))]); + s.update(&mut st, &[key(KeyCode::Char('['))]); + assert_eq!(s.stratum, StratumKind::Undercity); + s.update(&mut st, &[key(KeyCode::Char('['))]); + assert_eq!(s.stratum, StratumKind::Undercity); + s.update(&mut st, &[key(KeyCode::Char('w'))]); + assert!(st.player.is_some()); + assert_eq!(s.mode, Mode::Walk); + assert_eq!(s.stratum, StratumKind::Street); + for d in ['h', 'j', 'k', 'l', 'y', 'u', 'b', 'n'] { + st.tick += 1; + s.update(&mut st, &[key(KeyCode::Char(d))]); + } + s.update(&mut st, &[key(KeyCode::Char('.'))]); + s.update(&mut st, &[key(KeyCode::Char('<'))]); + t.draw(|f| s.draw(&st, f)).unwrap(); + assert!(buffer_text(t.backend().buffer()).contains('@')); + s.update( + &mut st, + &[ + key(KeyCode::Char('d')), + key(KeyCode::Char('m')), + key(KeyCode::Char('i')), + ], + ); + assert!(s.digital && !s.show_minimap && !s.show_inspector); + t.draw(|f| s.draw(&st, f)).unwrap(); + s.update(&mut st, &[key(KeyCode::Char('?'))]); + t.draw(|f| s.draw(&st, f)).unwrap(); + assert!(buffer_text(t.backend().buffer()).contains("move")); + s.update(&mut st, &[key(KeyCode::Esc)]); + assert!(!s.help); + s.update(&mut st, &[key(KeyCode::Tab)]); + assert_eq!(s.mode, Mode::Explore); + assert!(matches!( + s.update(&mut st, &[key(KeyCode::Char('s'))]), + ScreenCmd::Switch { .. } + )); + assert!(matches!( + s.update(&mut st, &[key(KeyCode::Char('q'))]), + ScreenCmd::Switch { .. } + )); + } + + #[test] + fn explore_pans_and_switches_to_other_screens() { + let mut st = state(); + let mut s = MapScreen::explore(0); + let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap(); + t.draw(|f| s.draw(&st, f)).unwrap(); + let before = (s.cam.tx, s.cam.ty); + s.update(&mut st, &[key(KeyCode::Right), key(KeyCode::Down)]); + assert_ne!((s.cam.tx, s.cam.ty), before); + assert!(matches!( + s.update(&mut st, &[key(KeyCode::Char('r'))]), + ScreenCmd::Switch { .. } + )); + assert!(matches!( + s.update(&mut st, &[key(KeyCode::Char('g'))]), + ScreenCmd::Switch { .. } + )); + let mut empty = AppState::new(); + let mut t2 = Terminal::new(TestBackend::new(100, 30)).unwrap(); + t2.draw(|f| s.draw(&empty, f)).unwrap(); + assert!(matches!( + s.update(&mut empty, &[key(KeyCode::Char('w'))]), + ScreenCmd::Stay + )); + } +} diff --git a/crates/city/src/screens/help.rs b/crates/city/src/screens/help.rs new file mode 100644 index 0000000..71c127c --- /dev/null +++ b/crates/city/src/screens/help.rs @@ -0,0 +1,90 @@ +//! The key reference overlay. + +use crate::screens::explore::Mode; +use crate::ui::panel::boxed; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph}; + +const EXPLORE: &[(&str, &str)] = &[ + ("↑↓←→ hjkl", "pan the camera (shift: fast)"), + ("[ ]", "stratum down / up"), + ("w", "walk: drop the @ into the city"), + ("i m e d", "inspector · minimap · legend · digital overlay"), + ("s", "save city"), + ("r", "regenerate with a fresh seed"), + ("g", "configure"), + ("?", "this help"), + ("q esc", "back to title"), +]; + +const WALK: &[(&str, &str)] = &[ + ("hjkl yubn ↑↓←→", "move"), + ("< >", "take stairs, lift, ladder or manhole"), + (".", "wait a tick"), + ("tab", "back to free camera"), + ("i m e d", "inspector · minimap · legend · digital overlay"), + ("s", "save city"), + ("?", "this help"), + ("q esc", "back to title"), +]; + +/// Draws the key list for `mode` centred over whatever is already on screen. +pub fn draw(f: &mut Frame, mode: Mode) { + let area = f.area(); + let w = area.width.min(60); + let h = area.height.min(16); + let rect = Rect::new( + area.x + (area.width - w) / 2, + area.y + (area.height - h) / 2, + w, + h, + ); + f.render_widget(Clear, rect); + let title = match mode { + Mode::Explore => "KEYS · EXPLORE", + Mode::Walk => "KEYS · WALK", + }; + let block = boxed(title); + let inner = block.inner(rect); + f.render_widget(block, rect); + let rows = match mode { + Mode::Explore => EXPLORE, + Mode::Walk => WALK, + }; + let lines: Vec = rows + .iter() + .map(|(k, what)| { + Line::from(vec![ + Span::styled( + format!(" {k:<16}"), + Style::default().fg(Color::Rgb(0, 229, 255)), + ), + Span::styled( + what.to_string(), + Style::default().fg(Color::Rgb(205, 212, 222)), + ), + ]) + }) + .collect(); + f.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::screens::explore::Mode; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn help_fits_small_and_large_frames() { + for (w, h) in [(100u16, 30u16), (200, 60), (20, 5)] { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(|f| draw(f, Mode::Explore)).unwrap(); + t.draw(|f| draw(f, Mode::Walk)).unwrap(); + } + } +} diff --git a/crates/city/src/screens/mod.rs b/crates/city/src/screens/mod.rs index 7ba5eb5..5fb1649 100644 --- a/crates/city/src/screens/mod.rs +++ b/crates/city/src/screens/mod.rs @@ -1,39 +1,22 @@ //! Screens: one module per screen, plus shared test helpers. -use crate::app::{AppState, Screen, ScreenCmd}; -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::Frame; - pub mod configure; +pub mod explore; pub mod files; pub mod generating; +pub mod help; pub mod title; -/// Draws nothing; stands in until the title screen lands. -pub struct Blank; - -impl Screen for Blank { - fn update(&mut self, _st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { - if keys - .iter() - .any(|k| matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)) - { - ScreenCmd::Quit - } else { - ScreenCmd::Stay - } - } - fn draw(&self, _st: &AppState, _f: &mut Frame) {} -} +use crate::app::Screen; /// The screen the app starts on. pub fn initial() -> impl Screen { title::Title::new(0) } -/// 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) +/// The screen shown once a city exists: the map in explore mode. +pub fn explore_screen(tick: u64) -> Box { + Box::new(explore::MapScreen::explore(tick)) } #[cfg(test)]