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:
@@ -51,6 +51,11 @@ WezTerm). Saved cities go to `./cities/*.city`.
|
||||
|
||||
## 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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or
|
||||
|
||||
@@ -18,6 +18,9 @@ pub const MIN_W: u16 = 100;
|
||||
pub const MIN_H: u16 = 30;
|
||||
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.
|
||||
pub struct AppState {
|
||||
pub params: GenParams,
|
||||
@@ -25,6 +28,8 @@ pub struct AppState {
|
||||
pub player: Option<crate::walker::Player>,
|
||||
pub style: StylePack,
|
||||
pub tick: u64,
|
||||
/// Dev mode: free camera, stratum browsing and the inspector. Toggled with `` ` ``.
|
||||
pub dev: bool,
|
||||
status: Option<(String, u64)>,
|
||||
}
|
||||
|
||||
@@ -42,6 +47,7 @@ impl AppState {
|
||||
player: None,
|
||||
style: StylePack::load().expect("embedded style pack is valid"),
|
||||
tick: 0,
|
||||
dev: DEV_DEFAULT,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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)]
|
||||
@@ -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()
|
||||
.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 {
|
||||
if self.show_inspector && dev && rows[0].width > PANEL_W + 20 {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.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> {
|
||||
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('e') => self.show_legend = !self.show_legend,
|
||||
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> {
|
||||
let dev = st.dev;
|
||||
let city = st.city.as_ref()?;
|
||||
let player = st.player.as_mut()?;
|
||||
match k.code {
|
||||
KeyCode::Tab => {
|
||||
KeyCode::Tab if dev => {
|
||||
self.cursor = player.pos;
|
||||
self.mode = Mode::Explore;
|
||||
}
|
||||
@@ -357,6 +368,10 @@ impl Screen for MapScreen {
|
||||
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();
|
||||
match (self.mode, &st.player) {
|
||||
(Mode::Walk, Some(p)) => self.cam.follow(p.pos, map.width, map.height),
|
||||
@@ -381,7 +396,7 @@ impl Screen for MapScreen {
|
||||
);
|
||||
return;
|
||||
};
|
||||
let (map, panel, status) = self.layout(area);
|
||||
let (map, panel, status) = self.layout(area, st.dev);
|
||||
self.last_map.set(map);
|
||||
let origin = self.cam.origin(map.width, map.height);
|
||||
let cursor = self.focus_pos(st);
|
||||
@@ -446,15 +461,39 @@ 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,
|
||||
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);
|
||||
// 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);
|
||||
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));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }),
|
||||
text_area,
|
||||
);
|
||||
if let Some(mm) = map_area {
|
||||
let block = boxed("map");
|
||||
let inner = block.inner(mm);
|
||||
let mi = block.inner(mm);
|
||||
f.render_widget(block, mm);
|
||||
Minimap {
|
||||
city,
|
||||
@@ -467,48 +506,29 @@ impl Screen for MapScreen {
|
||||
.map(|p| p.pos),
|
||||
style: &st.style,
|
||||
}
|
||||
.render(inner, f.buffer_mut());
|
||||
.render(mi, 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));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
(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) => {
|
||||
"⚡ 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(
|
||||
Paragraph::new(status_bar(
|
||||
Some(city),
|
||||
self.stratum,
|
||||
self.fps.fps,
|
||||
hints,
|
||||
&hints,
|
||||
st.status_text(),
|
||||
st.dev,
|
||||
)),
|
||||
status,
|
||||
);
|
||||
@@ -741,4 +761,65 @@ mod tests {
|
||||
t.draw(|f| s.draw(&st, f)).unwrap();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const EXPLORE: &[(&str, &str)] = &[
|
||||
("s", "save city"),
|
||||
("r", "regenerate with a fresh seed"),
|
||||
("g", "configure"),
|
||||
("`", "dev mode on / off"),
|
||||
("?", "this help"),
|
||||
("q esc", "back to title"),
|
||||
];
|
||||
@@ -25,7 +26,8 @@ const WALK: &[(&str, &str)] = &[
|
||||
("< >", "take stairs, lift, ladder or manhole"),
|
||||
("enter", "jack in at an adjacent terminal / jack out"),
|
||||
(".", "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"),
|
||||
("s", "save city"),
|
||||
("?", "this help"),
|
||||
|
||||
@@ -152,8 +152,18 @@ pub fn status_bar(
|
||||
fps: f32,
|
||||
hints: &str,
|
||||
status: Option<&str>,
|
||||
dev: bool,
|
||||
) -> Line<'static> {
|
||||
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 {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", c.name),
|
||||
@@ -209,7 +219,8 @@ mod tests {
|
||||
let sel = strata_selector(StratumKind::Skyways, 0);
|
||||
let text: String = sel.iter().map(|l| l.to_string() + "\n").collect();
|
||||
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"));
|
||||
let bar = status_bar(
|
||||
Some(&c),
|
||||
@@ -217,6 +228,7 @@ mod tests {
|
||||
30.0,
|
||||
"q quit",
|
||||
Some("saved x"),
|
||||
false,
|
||||
);
|
||||
assert!(bar.to_string().contains("saved x") && !bar.to_string().contains("q quit"));
|
||||
assert!(!legend(StratumKind::Undercity).is_empty());
|
||||
|
||||
Reference in New Issue
Block a user