feat(app): dev mode gates the free camera and inspector; minimap moves into the inspector

Dev mode (DEV_DEFAULT = true for now, backtick toggles it at runtime) unlocks
Explore mode, stratum browsing and the inspector panel. Without it the map
screen always opens in Walk mode and Tab and i are inert. The minimap now
lives in the lower part of the inspector panel; the player's own map will be
a different thing later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 22:32:24 +02:00
parent e235d8012b
commit 287ade702f
5 changed files with 144 additions and 38 deletions

View File

@@ -51,6 +51,11 @@ WezTerm). Saved cities go to `./cities/*.city`.
## Keys ## Keys
**Dev mode** is on by default for now (`DEV_DEFAULT` in `crates/cyberpunk/src/app.rs`)
and shows a `DEV` badge in the status bar. It unlocks the free camera, stratum
browsing and the inspector with its minimap. Press `` ` `` to flip it at runtime;
with it off you are always on foot.
**Title** `↑↓` select · `enter` confirm · `q` quit **Title** `↑↓` select · `enter` confirm · `q` quit
**Configure** `↑↓` field · `←→` adjust · type digits into the seed · `enter` **Configure** `↑↓` field · `←→` adjust · type digits into the seed · `enter`
@@ -60,7 +65,7 @@ on `RANDOM SEED` / `GENERATE` · `esc` back
end · `r` reseed after an error · `esc` back end · `r` reseed after an error · `esc` back
**Explore** `hjkl` / arrows pan the cursor (shift: fast) · `[` `]` stratum down / up (Net on top) · **Explore** `hjkl` / arrows pan the cursor (shift: fast) · `[` `]` stratum down / up (Net on top) ·
`w` walk · `i` inspector · `m` minimap · `e` legend · `d` digital overlay · `w` walk · `i` inspector · `m` minimap (inside the inspector) · `e` legend · `d` digital overlay ·
`s` save · `r` regenerate · `g` configure · `?` keys · `q` title `s` save · `r` regenerate · `g` configure · `?` keys · `q` title
**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or **Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or

View File

@@ -18,6 +18,9 @@ pub const MIN_W: u16 = 100;
pub const MIN_H: u16 = 30; pub const MIN_H: u16 = 30;
pub const FRAME: Duration = Duration::from_micros(33_333); pub const FRAME: Duration = Duration::from_micros(33_333);
/// Whether the app starts in dev mode: free camera, map browsing and the inspector.
pub const DEV_DEFAULT: bool = true;
/// State shared by every screen. /// State shared by every screen.
pub struct AppState { pub struct AppState {
pub params: GenParams, pub params: GenParams,
@@ -25,6 +28,8 @@ pub struct AppState {
pub player: Option<crate::walker::Player>, pub player: Option<crate::walker::Player>,
pub style: StylePack, pub style: StylePack,
pub tick: u64, pub tick: u64,
/// Dev mode: free camera, stratum browsing and the inspector. Toggled with `` ` ``.
pub dev: bool,
status: Option<(String, u64)>, status: Option<(String, u64)>,
} }
@@ -42,6 +47,7 @@ impl AppState {
player: None, player: None,
style: StylePack::load().expect("embedded style pack is valid"), style: StylePack::load().expect("embedded style pack is valid"),
tick: 0, tick: 0,
dev: DEV_DEFAULT,
status: None, status: None,
} }
} }

View File

@@ -25,7 +25,6 @@ use std::cell::Cell;
use std::time::Instant; use std::time::Instant;
const PANEL_W: u16 = 34; const PANEL_W: u16 = 34;
const MINIMAP_W: u16 = 26;
const MINIMAP_H: u16 = 13; const MINIMAP_H: u16 = 13;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -122,13 +121,13 @@ impl MapScreen {
} }
} }
fn layout(&self, area: Rect) -> (Rect, Option<Rect>, Rect) { fn layout(&self, area: Rect, dev: bool) -> (Rect, Option<Rect>, Rect) {
let rows = Layout::default() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(1)]) .constraints([Constraint::Min(5), Constraint::Length(1)])
.split(area); .split(area);
let status = rows[1]; let status = rows[1];
if self.show_inspector && rows[0].width > PANEL_W + 20 { if self.show_inspector && dev && rows[0].width > PANEL_W + 20 {
let cols = Layout::default() let cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(20), Constraint::Length(PANEL_W)]) .constraints([Constraint::Min(20), Constraint::Length(PANEL_W)])
@@ -165,7 +164,18 @@ impl MapScreen {
fn handle_common(&mut self, st: &mut AppState, k: &KeyEvent) -> Option<ScreenCmd> { fn handle_common(&mut self, st: &mut AppState, k: &KeyEvent) -> Option<ScreenCmd> {
match k.code { match k.code {
KeyCode::Char('i') => self.show_inspector = !self.show_inspector, KeyCode::Char('`') => {
st.dev = !st.dev;
st.set_status(
if st.dev {
"dev mode on"
} else {
"dev mode off"
},
60,
);
}
KeyCode::Char('i') if st.dev => self.show_inspector = !self.show_inspector,
KeyCode::Char('m') => self.show_minimap = !self.show_minimap, KeyCode::Char('m') => self.show_minimap = !self.show_minimap,
KeyCode::Char('e') => self.show_legend = !self.show_legend, KeyCode::Char('e') => self.show_legend = !self.show_legend,
KeyCode::Char('d') => self.digital = !self.digital, KeyCode::Char('d') => self.digital = !self.digital,
@@ -247,10 +257,11 @@ impl MapScreen {
} }
fn handle_walk(&mut self, st: &mut AppState, k: &KeyEvent) -> Option<ScreenCmd> { fn handle_walk(&mut self, st: &mut AppState, k: &KeyEvent) -> Option<ScreenCmd> {
let dev = st.dev;
let city = st.city.as_ref()?; let city = st.city.as_ref()?;
let player = st.player.as_mut()?; let player = st.player.as_mut()?;
match k.code { match k.code {
KeyCode::Tab => { KeyCode::Tab if dev => {
self.cursor = player.pos; self.cursor = player.pos;
self.mode = Mode::Explore; self.mode = Mode::Explore;
} }
@@ -357,6 +368,10 @@ impl Screen for MapScreen {
return cmd; return cmd;
} }
} }
if !st.dev && self.mode == Mode::Explore {
// Without dev mode there is no free camera: the player is always on foot.
self.enter_walk(st);
}
let map = self.last_map.get(); let map = self.last_map.get();
match (self.mode, &st.player) { match (self.mode, &st.player) {
(Mode::Walk, Some(p)) => self.cam.follow(p.pos, map.width, map.height), (Mode::Walk, Some(p)) => self.cam.follow(p.pos, map.width, map.height),
@@ -381,7 +396,7 @@ impl Screen for MapScreen {
); );
return; return;
}; };
let (map, panel, status) = self.layout(area); let (map, panel, status) = self.layout(area, st.dev);
self.last_map.set(map); self.last_map.set(map);
let origin = self.cam.origin(map.width, map.height); let origin = self.cam.origin(map.width, map.height);
let cursor = self.focus_pos(st); let cursor = self.focus_pos(st);
@@ -446,30 +461,6 @@ impl Screen for MapScreen {
} }
} }
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 { if let Some(panel) = panel {
let block = boxed(if self.show_legend { let block = boxed(if self.show_legend {
"LEGEND" "LEGEND"
@@ -478,6 +469,16 @@ impl Screen for MapScreen {
}); });
let inner = block.inner(panel); let inner = block.inner(panel);
f.render_widget(block, panel); f.render_widget(block, panel);
// The minimap takes the lower part of the panel when there is room for it.
let (text_area, map_area) = if self.show_minimap && inner.height > MINIMAP_H + 8 {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(MINIMAP_H + 2)])
.split(inner);
(rows[0], Some(rows[1]))
} else {
(inner, None)
};
let mut lines: Vec<Line> = strata_selector(self.stratum, st.tick); let mut lines: Vec<Line> = strata_selector(self.stratum, st.tick);
lines.push(Line::from("")); lines.push(Line::from(""));
if self.show_legend { if self.show_legend {
@@ -488,27 +489,46 @@ impl Screen for MapScreen {
} }
f.render_widget( f.render_widget(
Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }),
inner, text_area,
); );
if let Some(mm) = map_area {
let block = boxed("map");
let mi = 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(mi, f.buffer_mut());
}
} }
let jacked = st.player.as_ref().is_some_and(|p| p.jacked_in()); let jacked = st.player.as_ref().is_some_and(|p| p.jacked_in());
let cam = if st.dev { " · tab camera" } else { "" };
let hints = match (self.mode, jacked) { let hints = match (self.mode, jacked) {
(Mode::Explore, _) => { (Mode::Explore, _) => {
"hjkl pan · [] level · w walk · d overlay · e legend · s save · ? keys" "hjkl pan · [] level · w walk · d overlay · e legend · s save · ? keys".to_string()
} }
(Mode::Walk, true) => { (Mode::Walk, true) => {
"⚡ jacked in · hjkl move · enter jack out · tab camera · d overlay · ? keys" format!("⚡ jacked in · hjkl move · enter jack out{cam} · d overlay · ? keys")
} }
(Mode::Walk, false) => "hjkl move · <> stairs · enter jack in · tab camera · ? keys", (Mode::Walk, false) => format!("hjkl move · <> stairs · enter jack in{cam} · ? keys"),
}; };
f.render_widget( f.render_widget(
Paragraph::new(status_bar( Paragraph::new(status_bar(
Some(city), Some(city),
self.stratum, self.stratum,
self.fps.fps, self.fps.fps,
hints, &hints,
st.status_text(), st.status_text(),
st.dev,
)), )),
status, status,
); );
@@ -741,4 +761,65 @@ mod tests {
t.draw(|f| s.draw(&st, f)).unwrap(); t.draw(|f| s.draw(&st, f)).unwrap();
assert!(buffer_text(t.backend().buffer()).contains("jack in")); assert!(buffer_text(t.backend().buffer()).contains("jack in"));
} }
#[test]
fn dev_mode_off_locks_the_player_into_walk_mode() {
let mut st = state();
st.dev = false;
let mut s = MapScreen::explore(0);
let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap();
s.update(&mut st, &[]);
assert_eq!(s.mode, Mode::Walk);
assert!(st.player.is_some());
s.update(&mut st, &[key(KeyCode::Tab)]);
assert_eq!(s.mode, Mode::Walk, "tab must not open the free camera");
s.update(&mut st, &[key(KeyCode::Char('i'))]);
st.tick += 500;
t.draw(|f| s.draw(&st, f)).unwrap();
let text = buffer_text(t.backend().buffer());
assert!(!text.contains("INSPECT"), "{text}");
assert!(!text.contains("tab camera"), "{text}");
assert!(!text.contains("DEV"), "{text}");
// The backtick flips dev mode at runtime; the camera and inspector come back.
s.update(&mut st, &[key(KeyCode::Char('`'))]);
assert!(st.dev);
s.update(&mut st, &[key(KeyCode::Tab)]);
assert_eq!(s.mode, Mode::Explore);
st.tick += 500;
t.draw(|f| s.draw(&st, f)).unwrap();
let text = buffer_text(t.backend().buffer());
assert!(text.contains("INSPECT") && text.contains("DEV"), "{text}");
// Flipping it off again drops straight back into the walk.
s.update(&mut st, &[key(KeyCode::Char('`'))]);
assert!(!st.dev);
assert_eq!(s.mode, Mode::Walk);
}
#[test]
fn minimap_lives_in_the_lower_part_of_the_inspector() {
let mut st = state();
let mut s = MapScreen::explore(0);
let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap();
s.update(&mut st, &[]);
t.draw(|f| s.draw(&st, f)).unwrap();
let text = buffer_text(t.backend().buffer());
let panel_x = 120 - PANEL_W as usize;
let rows: Vec<(usize, usize)> = text
.lines()
.enumerate()
.filter_map(|(y, l)| l.find("map").map(|x| (y, x)))
.collect();
assert!(!rows.is_empty(), "no minimap box: {text}");
assert!(
rows.iter().all(|(_, x)| *x >= panel_x),
"minimap drawn outside the panel: {rows:?}"
);
assert!(
rows.iter().all(|(y, _)| *y > 20),
"minimap not in the lower part: {rows:?}"
);
s.update(&mut st, &[key(KeyCode::Char('m'))]);
t.draw(|f| s.draw(&st, f)).unwrap();
assert!(!buffer_text(t.backend().buffer()).contains("map"));
}
} }

View File

@@ -16,6 +16,7 @@ const EXPLORE: &[(&str, &str)] = &[
("s", "save city"), ("s", "save city"),
("r", "regenerate with a fresh seed"), ("r", "regenerate with a fresh seed"),
("g", "configure"), ("g", "configure"),
("`", "dev mode on / off"),
("?", "this help"), ("?", "this help"),
("q esc", "back to title"), ("q esc", "back to title"),
]; ];
@@ -25,7 +26,8 @@ const WALK: &[(&str, &str)] = &[
("< >", "take stairs, lift, ladder or manhole"), ("< >", "take stairs, lift, ladder or manhole"),
("enter", "jack in at an adjacent terminal / jack out"), ("enter", "jack in at an adjacent terminal / jack out"),
(".", "wait a tick"), (".", "wait a tick"),
("tab", "back to free camera"), ("tab", "back to free camera (dev mode)"),
("`", "dev mode on / off"),
("i m e d", "inspector · minimap · legend · digital overlay"), ("i m e d", "inspector · minimap · legend · digital overlay"),
("s", "save city"), ("s", "save city"),
("?", "this help"), ("?", "this help"),

View File

@@ -152,8 +152,18 @@ pub fn status_bar(
fps: f32, fps: f32,
hints: &str, hints: &str,
status: Option<&str>, status: Option<&str>,
dev: bool,
) -> Line<'static> { ) -> Line<'static> {
let mut spans = Vec::new(); let mut spans = Vec::new();
if dev {
spans.push(Span::styled(
" DEV ",
Style::default()
.fg(Color::Rgb(10, 11, 16))
.bg(Color::Rgb(255, 200, 80))
.add_modifier(Modifier::BOLD),
));
}
if let Some(c) = city { if let Some(c) = city {
spans.push(Span::styled( spans.push(Span::styled(
format!(" {} ", c.name), format!(" {} ", c.name),
@@ -209,7 +219,8 @@ mod tests {
let sel = strata_selector(StratumKind::Skyways, 0); let sel = strata_selector(StratumKind::Skyways, 0);
let text: String = sel.iter().map(|l| l.to_string() + "\n").collect(); let text: String = sel.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("Skyways") && text.contains("▌")); assert!(text.contains("Skyways") && text.contains("▌"));
let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit", None); let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit", None, true);
assert!(bar.to_string().contains("DEV"));
assert!(bar.to_string().contains(&c.name) && bar.to_string().contains("q quit")); assert!(bar.to_string().contains(&c.name) && bar.to_string().contains("q quit"));
let bar = status_bar( let bar = status_bar(
Some(&c), Some(&c),
@@ -217,6 +228,7 @@ mod tests {
30.0, 30.0,
"q quit", "q quit",
Some("saved x"), Some("saved x"),
false,
); );
assert!(bar.to_string().contains("saved x") && !bar.to_string().contains("q quit")); assert!(bar.to_string().contains("saved x") && !bar.to_string().contains("q quit"));
assert!(!legend(StratumKind::Undercity).is_empty()); assert!(!legend(StratumKind::Undercity).is_empty());