feat(city): explore and walk screens with panels and help

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:54:00 +02:00
parent df448525c2
commit 48320eb0e6
3 changed files with 647 additions and 23 deletions

View File

@@ -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<Rect>,
}
fn dir_for(code: KeyCode) -> Option<Dir> {
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>, 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<ScreenCmd> {
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<ScreenCmd> {
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<ScreenCmd> {
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<Line> = 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
));
}
}

View File

@@ -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<Line> = 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();
}
}
}

View File

@@ -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<dyn Screen> {
Box::new(Blank)
/// The screen shown once a city exists: the map in explore mode.
pub fn explore_screen(tick: u64) -> Box<dyn Screen> {
Box::new(explore::MapScreen::explore(tick))
}
#[cfg(test)]