diff --git a/crates/cyberpunk/src/screens/explore.rs b/crates/cyberpunk/src/screens/explore.rs index d3f5f16..81d8fc3 100644 --- a/crates/cyberpunk/src/screens/explore.rs +++ b/crates/cyberpunk/src/screens/explore.rs @@ -14,7 +14,7 @@ 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 citygen::{City, Dir, Pos, Rect as TileRect, StratumKind}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -139,9 +139,14 @@ impl MapScreen { } } - 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) { + fn change_stratum(&mut self, st: &AppState, delta: i32) { + let max = st + .city + .as_ref() + .map(|c| c.strata.len() as i32 - 1) + .unwrap_or(3); + let i = (self.stratum.index() as i32 + delta).clamp(0, max); + if let Some(k) = StratumKind::from_index(i as usize) { self.stratum = k; } } @@ -214,8 +219,8 @@ impl MapScreen { (self.cursor.y + dy * step).clamp(0, size.h as i32 - 1), ); } - KeyCode::Char('[') => self.change_stratum(-1), - KeyCode::Char(']') => self.change_stratum(1), + KeyCode::Char('[') => self.change_stratum(st, -1), + KeyCode::Char(']') => self.change_stratum(st, 1), KeyCode::Char('w') => self.enter_walk(st), KeyCode::Char('r') => { let params = st.params.clone().with_seed(hash64(st.params.seed, st.tick)); @@ -249,6 +254,28 @@ impl MapScreen { self.cursor = player.pos; self.mode = Mode::Explore; } + KeyCode::Enter => { + if player.jacked_in() { + match player.jack_out(city) { + Ok(()) => { + self.stratum = player.stratum; + self.cam.snap(player.pos); + st.set_status("jacked out", 60); + } + Err(e) => st.set_status(e, 60), + } + } else { + match player.jack_in(city) { + Ok(port) => { + self.stratum = StratumKind::Net; + self.cam.snap(port); + let where_ = port_name(city, port); + st.set_status(format!("jacked in at {where_}"), 90); + } + Err(e) => st.set_status(e, 60), + } + } + } KeyCode::Char('<') | KeyCode::Char('>') => { let up = k.code == KeyCode::Char('>'); match player.use_connector(city, up) { @@ -279,6 +306,20 @@ impl MapScreen { } } +/// What a port is called in the status line: its building, else its district. +fn port_name(city: &City, port: Pos) -> String { + let tile = city.tile(StratumKind::Street, port); + tile.and_then(|t| t.building) + .and_then(|b| city.building_opt(b)) + .and_then(|b| b.name.clone()) + .or_else(|| { + tile.and_then(|t| t.district) + .and_then(|d| city.district_opt(d)) + .map(|d| d.name.clone()) + }) + .unwrap_or_else(|| "the Net".to_string()) +} + impl Screen for MapScreen { fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { self.fps.frame(); @@ -344,7 +385,7 @@ impl Screen for MapScreen { self.last_map.set(map); let origin = self.cam.origin(map.width, map.height); let cursor = self.focus_pos(st); - let focus = if self.digital { + let focus = if self.digital || self.stratum == StratumKind::Net { Digital::node_under(city, self.stratum, cursor) } else { None @@ -380,15 +421,29 @@ impl Screen for MapScreen { } .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 let Some(p) = &st.player { + if let Some((bk, bp)) = p.body + && bk == self.stratum + && let Some((sx, sy)) = tile_to_screen(map, origin, bp) + { + f.buffer_mut()[(sx, sy)].set_char('@').set_style( + Style::default() + .fg(Color::Rgb(110, 120, 130)) + .add_modifier(Modifier::DIM), + ); + } + if p.stratum == self.stratum + && let Some((sx, sy)) = tile_to_screen(map, origin, p.pos) + { + let fg = if p.stratum == StratumKind::Net { + Color::Rgb(0, 255, 200) + } else { + Color::Rgb(255, 255, 255) + }; + f.buffer_mut()[(sx, sy)] + .set_char('@') + .set_style(Style::default().fg(fg).add_modifier(Modifier::BOLD)); + } } if self.show_minimap && map.width > MINIMAP_W + 4 && map.height > MINIMAP_H + 4 { @@ -437,9 +492,15 @@ impl Screen for MapScreen { ); } - 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", + let jacked = st.player.as_ref().is_some_and(|p| p.jacked_in()); + let hints = match (self.mode, jacked) { + (Mode::Explore, _) => { + "hjkl pan · [] level · w walk · d net · e legend · s save · ? keys" + } + (Mode::Walk, true) => { + "⚡ jacked in · hjkl move · enter jack out · tab camera · d net · ? keys" + } + (Mode::Walk, false) => "hjkl move · <> stairs · enter jack in · tab camera · ? keys", }; f.render_widget( Paragraph::new(status_bar( @@ -608,4 +669,75 @@ mod tests { let text = buffer_text(t.backend().buffer()); assert!(text.contains("119,59"), "{text}"); } + + #[test] + fn enter_jacks_in_and_the_net_is_the_fifth_stratum() { + 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(); + for _ in 0..4 { + s.update(&mut st, &[key(KeyCode::Char(']'))]); + } + assert_eq!(s.stratum, StratumKind::Net); + s.update(&mut st, &[key(KeyCode::Char(']'))]); + assert_eq!(s.stratum, StratumKind::Net); + t.draw(|f| s.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!(text.contains("▌ Net"), "{text}"); + assert!( + text.contains('═') || text.contains('┃') || text.contains('━'), + "{text}" + ); + + // Walk up to a public port and jack in. + s.update(&mut st, &[key(KeyCode::Char('['))]); + s.update(&mut st, &[key(KeyCode::Char('w'))]); + let city = st.city.as_ref().unwrap(); + let jack = city + .connectors + .iter() + .find(|k| k.kind == citygen::ConnectorKind::Jack && k.public) + .unwrap() + .pos; + let stand = jack + .neighbors8() + .find(|n| city.walkable(StratumKind::Street, *n)) + .unwrap(); + { + let p = st.player.as_mut().unwrap(); + p.pos = stand; + p.stratum = StratumKind::Street; + } + s.update(&mut st, &[key(KeyCode::Enter)]); + let p = st.player.as_ref().unwrap(); + assert!(p.jacked_in()); + assert_eq!(s.stratum, StratumKind::Net); + assert_eq!(p.pos, jack); + st.tick += 500; + t.draw(|f| s.draw(&st, f)).unwrap(); + let text = buffer_text(t.backend().buffer()); + assert!(text.contains("jack out"), "{text}"); + assert!(text.contains("jacked in"), "{text}"); + // The body shows on the Street while the avatar is in the Net. + s.mode = Mode::Explore; + s.stratum = StratumKind::Street; + s.cursor = stand; + s.cam.snap(stand); + t.draw(|f| s.draw(&st, f)).unwrap(); + let map = s.last_map.get(); + let origin = s.cam.origin(map.width, map.height); + let (sx, sy) = tile_to_screen(map, origin, stand).unwrap(); + assert_eq!(t.backend().buffer()[(sx, sy)].symbol(), "@"); + s.mode = Mode::Walk; + s.stratum = StratumKind::Net; + s.update(&mut st, &[key(KeyCode::Enter)]); + let p = st.player.as_ref().unwrap(); + assert!(!p.jacked_in()); + assert_eq!((p.stratum, p.pos), (StratumKind::Street, stand)); + assert_eq!(s.stratum, StratumKind::Street); + s.update(&mut st, &[key(KeyCode::Char('?'))]); + t.draw(|f| s.draw(&st, f)).unwrap(); + assert!(buffer_text(t.backend().buffer()).contains("jack in")); + } } diff --git a/crates/cyberpunk/src/screens/help.rs b/crates/cyberpunk/src/screens/help.rs index 71c127c..12420d2 100644 --- a/crates/cyberpunk/src/screens/help.rs +++ b/crates/cyberpunk/src/screens/help.rs @@ -10,7 +10,7 @@ use ratatui::widgets::{Clear, Paragraph}; const EXPLORE: &[(&str, &str)] = &[ ("↑↓←→ hjkl", "pan the camera (shift: fast)"), - ("[ ]", "stratum down / up"), + ("[ ]", "stratum down / up (Net on top)"), ("w", "walk: drop the @ into the city"), ("i m e d", "inspector · minimap · legend · digital overlay"), ("s", "save city"), @@ -23,6 +23,7 @@ const EXPLORE: &[(&str, &str)] = &[ const WALK: &[(&str, &str)] = &[ ("hjkl yubn ↑↓←→", "move"), ("< >", "take stairs, lift, ladder or manhole"), + ("enter", "jack in at an adjacent terminal / jack out"), (".", "wait a tick"), ("tab", "back to free camera"), ("i m e d", "inspector · minimap · legend · digital overlay"), diff --git a/crates/cyberpunk/src/ui/panel.rs b/crates/cyberpunk/src/ui/panel.rs index 759f20a..8ce30cd 100644 --- a/crates/cyberpunk/src/ui/panel.rs +++ b/crates/cyberpunk/src/ui/panel.rs @@ -42,9 +42,13 @@ pub fn inspector_lines( out.push(kv("terrain", "outside the map")); return out; }; - out.push(kv("terrain", format!("{:?}", t.terrain).to_lowercase())); - if let Some(f) = t.feature { - out.push(kv("feature", format!("{f:?}").to_lowercase())); + out.push(kv("terrain", t.terrain.label())); + match t.feature { + Some(citygen::Feature::Node(k)) => { + out.push(kv("feature", format!("node · {}", k.label()))); + } + Some(f) => out.push(kv("feature", format!("{f:?}").to_lowercase())), + None => {} } if let Some(d) = t.district.and_then(|d| city.district_opt(d)) { out.push(kv("district", d.name.clone())); @@ -121,9 +125,9 @@ pub fn legend(stratum: StratumKind) -> Vec> { .collect() } -/// The four physical strata, top first, with the current one marked. +/// Every stratum, top first (Net above Heights), with the current one marked. pub fn strata_selector(current: StratumKind, tick: u64) -> Vec> { - StratumKind::PHYSICAL + StratumKind::ALL .iter() .rev() .map(|k| { diff --git a/crates/cyberpunk/src/walker/mod.rs b/crates/cyberpunk/src/walker/mod.rs index 6e387f3..958d505 100644 --- a/crates/cyberpunk/src/walker/mod.rs +++ b/crates/cyberpunk/src/walker/mod.rs @@ -15,7 +15,6 @@ pub struct Player { pub pos: Pos, pub stratum: StratumKind, /// Where the body waits while jacked in: (stratum, tile). None when not jacked in. - #[allow(dead_code)] // read by the map screen from task 9 pub body: Option<(StratumKind, Pos)>, /// What has been seen on each stratum, indexed by `StratumKind::index`. memory: Vec>, @@ -76,13 +75,11 @@ impl Player { } } - #[allow(dead_code)] // wired by the map screen from task 9 pub fn jacked_in(&self) -> bool { self.body.is_some() } /// Jacks in at a Jack connector on this tile or an 8-neighbour of it, leaving the body here. - #[allow(dead_code)] // wired by the map screen from task 9 pub fn jack_in(&mut self, city: &City) -> Result { if self.jacked_in() { return Err("already jacked in"); @@ -108,7 +105,6 @@ impl Player { } /// Returns to the body. - #[allow(dead_code)] // wired by the map screen from task 9 pub fn jack_out(&mut self, city: &City) -> Result<(), &'static str> { let (k, p) = self.body.take().ok_or("not jacked in")?; self.stratum = k;