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
|
## 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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user