feat(screens): walk cyberspace, jack in on enter, body left behind

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 21:47:08 +02:00
parent 2dc2938b2e
commit 8981b237f9
4 changed files with 162 additions and 29 deletions

View File

@@ -14,7 +14,7 @@ use crate::screens::title::Title;
use crate::ui::panel::{boxed, inspector_lines, legend, status_bar, strata_selector}; use crate::ui::panel::{boxed, inspector_lines, legend, status_bar, strata_selector};
use crate::walker::Player; use crate::walker::Player;
use citygen::rng::hash64; 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
@@ -139,9 +139,14 @@ impl MapScreen {
} }
} }
fn change_stratum(&mut self, delta: i32) { fn change_stratum(&mut self, st: &AppState, delta: i32) {
let i = self.stratum.index() as i32 + delta; let max = st
if let Some(k) = StratumKind::from_index(i.clamp(0, 3) as usize) { .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; self.stratum = k;
} }
} }
@@ -214,8 +219,8 @@ impl MapScreen {
(self.cursor.y + dy * step).clamp(0, size.h as i32 - 1), (self.cursor.y + dy * step).clamp(0, size.h as i32 - 1),
); );
} }
KeyCode::Char('[') => self.change_stratum(-1), KeyCode::Char('[') => self.change_stratum(st, -1),
KeyCode::Char(']') => self.change_stratum(1), KeyCode::Char(']') => self.change_stratum(st, 1),
KeyCode::Char('w') => self.enter_walk(st), KeyCode::Char('w') => self.enter_walk(st),
KeyCode::Char('r') => { KeyCode::Char('r') => {
let params = st.params.clone().with_seed(hash64(st.params.seed, st.tick)); let params = st.params.clone().with_seed(hash64(st.params.seed, st.tick));
@@ -249,6 +254,28 @@ impl MapScreen {
self.cursor = player.pos; self.cursor = player.pos;
self.mode = Mode::Explore; 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('>') => { KeyCode::Char('<') | KeyCode::Char('>') => {
let up = k.code == KeyCode::Char('>'); let up = k.code == KeyCode::Char('>');
match player.use_connector(city, up) { 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 { impl Screen for MapScreen {
fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd { fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd {
self.fps.frame(); self.fps.frame();
@@ -344,7 +385,7 @@ impl Screen for MapScreen {
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);
let focus = if self.digital { let focus = if self.digital || self.stratum == StratumKind::Net {
Digital::node_under(city, self.stratum, cursor) Digital::node_under(city, self.stratum, cursor)
} else { } else {
None None
@@ -380,16 +421,30 @@ impl Screen for MapScreen {
} }
.render(map, f.buffer_mut()); .render(map, f.buffer_mut());
if let Some(p) = &st.player if let Some(p) = &st.player {
&& p.stratum == self.stratum if let Some((bk, bp)) = p.body
&& let Some((sx, sy)) = tile_to_screen(map, origin, p.pos) && bk == self.stratum
&& let Some((sx, sy)) = tile_to_screen(map, origin, bp)
{ {
f.buffer_mut()[(sx, sy)].set_char('@').set_style( f.buffer_mut()[(sx, sy)].set_char('@').set_style(
Style::default() Style::default()
.fg(Color::Rgb(255, 255, 255)) .fg(Color::Rgb(110, 120, 130))
.add_modifier(Modifier::BOLD), .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 { if self.show_minimap && map.width > MINIMAP_W + 4 && map.height > MINIMAP_H + 4 {
let mm = Rect::new( let mm = Rect::new(
@@ -437,9 +492,15 @@ impl Screen for MapScreen {
); );
} }
let hints = match self.mode { let jacked = st.player.as_ref().is_some_and(|p| p.jacked_in());
Mode::Explore => "hjkl pan · [] level · w walk · d net · e legend · s save · ? keys", let hints = match (self.mode, jacked) {
Mode::Walk => "hjkl move · <> stairs · tab camera · d net · ? keys", (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( f.render_widget(
Paragraph::new(status_bar( Paragraph::new(status_bar(
@@ -608,4 +669,75 @@ mod tests {
let text = buffer_text(t.backend().buffer()); let text = buffer_text(t.backend().buffer());
assert!(text.contains("119,59"), "{text}"); 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"));
}
} }

View File

@@ -10,7 +10,7 @@ use ratatui::widgets::{Clear, Paragraph};
const EXPLORE: &[(&str, &str)] = &[ const EXPLORE: &[(&str, &str)] = &[
("↑↓←→ hjkl", "pan the camera (shift: fast)"), ("↑↓←→ hjkl", "pan the camera (shift: fast)"),
("[ ]", "stratum down / up"), ("[ ]", "stratum down / up (Net on top)"),
("w", "walk: drop the @ into the city"), ("w", "walk: drop the @ into the city"),
("i m e d", "inspector · minimap · legend · digital overlay"), ("i m e d", "inspector · minimap · legend · digital overlay"),
("s", "save city"), ("s", "save city"),
@@ -23,6 +23,7 @@ const EXPLORE: &[(&str, &str)] = &[
const WALK: &[(&str, &str)] = &[ const WALK: &[(&str, &str)] = &[
("hjkl yubn ↑↓←→", "move"), ("hjkl yubn ↑↓←→", "move"),
("< >", "take stairs, lift, ladder or manhole"), ("< >", "take stairs, lift, ladder or manhole"),
("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"),
("i m e d", "inspector · minimap · legend · digital overlay"), ("i m e d", "inspector · minimap · legend · digital overlay"),

View File

@@ -42,9 +42,13 @@ pub fn inspector_lines(
out.push(kv("terrain", "outside the map")); out.push(kv("terrain", "outside the map"));
return out; return out;
}; };
out.push(kv("terrain", format!("{:?}", t.terrain).to_lowercase())); out.push(kv("terrain", t.terrain.label()));
if let Some(f) = t.feature { match t.feature {
out.push(kv("feature", format!("{f:?}").to_lowercase())); 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)) { if let Some(d) = t.district.and_then(|d| city.district_opt(d)) {
out.push(kv("district", d.name.clone())); out.push(kv("district", d.name.clone()));
@@ -121,9 +125,9 @@ pub fn legend(stratum: StratumKind) -> Vec<Line<'static>> {
.collect() .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<Line<'static>> { pub fn strata_selector(current: StratumKind, tick: u64) -> Vec<Line<'static>> {
StratumKind::PHYSICAL StratumKind::ALL
.iter() .iter()
.rev() .rev()
.map(|k| { .map(|k| {

View File

@@ -15,7 +15,6 @@ pub struct Player {
pub pos: Pos, pub pos: Pos,
pub stratum: StratumKind, pub stratum: StratumKind,
/// Where the body waits while jacked in: (stratum, tile). None when not jacked in. /// 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)>, pub body: Option<(StratumKind, Pos)>,
/// What has been seen on each stratum, indexed by `StratumKind::index`. /// What has been seen on each stratum, indexed by `StratumKind::index`.
memory: Vec<Grid<Visibility>>, memory: Vec<Grid<Visibility>>,
@@ -76,13 +75,11 @@ impl Player {
} }
} }
#[allow(dead_code)] // wired by the map screen from task 9
pub fn jacked_in(&self) -> bool { pub fn jacked_in(&self) -> bool {
self.body.is_some() self.body.is_some()
} }
/// Jacks in at a Jack connector on this tile or an 8-neighbour of it, leaving the body here. /// 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<Pos, &'static str> { pub fn jack_in(&mut self, city: &City) -> Result<Pos, &'static str> {
if self.jacked_in() { if self.jacked_in() {
return Err("already jacked in"); return Err("already jacked in");
@@ -108,7 +105,6 @@ impl Player {
} }
/// Returns to the body. /// 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> { pub fn jack_out(&mut self, city: &City) -> Result<(), &'static str> {
let (k, p) = self.body.take().ok_or("not jacked in")?; let (k, p) = self.body.take().ok_or("not jacked in")?;
self.stratum = k; self.stratum = k;