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