From c643ee1dfcb8d44b8cd198c768f31dd7659f2ff9 Mon Sep 17 00:00:00 2001 From: Griffin Smith Date: Sun, 7 Jul 2019 12:41:15 -0400 Subject: Add messages, with global lookup map Add support for messages, along with a global lookup map and random choice of messages. --- src/display/draw_box.rs | 17 +++++ src/display/viewport.rs | 107 ++++++++++++++++++---------- src/game.rs | 55 ++++++++++---- src/main.rs | 8 +++ src/messages.rs | 186 ++++++++++++++++++++++++++++++++++++++++++++++++ src/messages.toml | 2 + src/settings.rs | 1 + src/types/mod.rs | 6 ++ 8 files changed, 332 insertions(+), 50 deletions(-) create mode 100644 src/messages.rs create mode 100644 src/messages.toml (limited to 'src') diff --git a/src/display/draw_box.rs b/src/display/draw_box.rs index 986f09a49f7c..5dc1627a298d 100644 --- a/src/display/draw_box.rs +++ b/src/display/draw_box.rs @@ -1,10 +1,12 @@ use crate::display::utils::clone_times; use crate::display::utils::times; +use crate::types::BoundingBox; use crate::types::Dimensions; use itertools::Itertools; use proptest::prelude::Arbitrary; use proptest::strategy; use proptest_derive::Arbitrary; +use std::io::{self, Write}; // Box Drawing // 0 1 2 3 4 5 6 7 8 9 A B C D E F @@ -164,6 +166,21 @@ pub fn make_box(style: BoxStyle, dims: Dimensions) -> String { } } +/// Draw the box described by the given BoundingBox's position and dimensions to +/// the given output, with the given style +pub fn draw_box( + out: &mut W, + bbox: BoundingBox, + style: BoxStyle, +) -> io::Result<()> { + write!( + out, + "{}{}", + bbox.position.cursor_goto(), + make_box(style, bbox.dimensions) + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/display/viewport.rs b/src/display/viewport.rs index bd2fac07146f..24cc5272cb8e 100644 --- a/src/display/viewport.rs +++ b/src/display/viewport.rs @@ -1,5 +1,8 @@ +use super::BoxStyle; use super::Draw; -use super::{make_box, BoxStyle}; +use crate::display::draw_box::draw_box; +use crate::display::utils::clone_times; +use crate::display::utils::times; use crate::types::{BoundingBox, Position, Positioned}; use std::fmt::{self, Debug}; use std::io::{self, Write}; @@ -10,36 +13,47 @@ pub struct Viewport { /// Generally the size of the terminal, and positioned at 0, 0 pub outer: BoundingBox, + /// The box describing the game part of the viewport. + pub game: BoundingBox, + /// The box describing the inner part of the viewport /// - /// Its position is relative to `outer.inner()`, and its size should generally not - /// be smaller than outer + /// Its position is relative to `outer.inner()`, and its size should + /// generally not be smaller than outer pub inner: BoundingBox, /// The actual screen that the viewport writes to pub out: W, } - -impl Debug for Viewport { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "Viewport {{ outer: {:?}, inner: {:?}, out: }}", - self.outer, self.inner - ) +impl Viewport { + pub fn new(outer: BoundingBox, inner: BoundingBox, out: W) -> Self { + Viewport { + outer, + inner, + out, + game: outer.move_tr_corner(Position { x: 0, y: 1 }), + } } -} -impl Viewport { /// Returns true if the (inner-relative) position of the given entity is /// visible within this viewport - fn visible(&self, ent: &E) -> bool { - self.on_screen(ent.position()).within(self.outer.inner()) + pub fn visible(&self, ent: &E) -> bool { + self.on_screen(ent.position()).within(self.game.inner()) } /// Convert the given inner-relative position to one on the actual screen fn on_screen(&self, pos: Position) -> Position { - pos + self.inner.position + self.outer.inner().position + pos + self.inner.position + self.game.inner().position + } +} + +impl Debug for Viewport { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Viewport {{ outer: {:?}, inner: {:?}, out: }}", + self.outer, self.inner + ) } } @@ -49,25 +63,46 @@ impl Viewport { if !self.visible(entity) { return Ok(()); } - write!( - self, - "{}", - (entity.position() - + self.inner.position - + self.outer.inner().position) - .cursor_goto() - )?; + self.cursor_goto(entity.position())?; entity.do_draw(self) } - /// Clear whatever is drawn at the given inner-relative position, if visible + /// Move the cursor to the given inner-relative position + pub fn cursor_goto(&mut self, pos: Position) -> io::Result<()> { + write!(self, "{}", self.on_screen(pos).cursor_goto()) + } + + /// Clear whatever single character is drawn at the given inner-relative + /// position, if visible pub fn clear(&mut self, pos: Position) -> io::Result<()> { write!(self, "{} ", self.on_screen(pos).cursor_goto(),) } /// Initialize this viewport by drawing its outer box to the screen pub fn init(&mut self) -> io::Result<()> { - write!(self, "{}", make_box(BoxStyle::Thin, self.outer.dimensions)) + draw_box(self, self.game, BoxStyle::Thin) + } + + /// Write a message to the message area on the screen + /// + /// Will overwrite any message already present, and if the given message is + /// longer than the screen will truncate. This means callers should handle + /// message buffering and ellipsisization + pub fn write_message(&mut self, msg: &str) -> io::Result<()> { + write!( + self, + "{}{}{}", + self.outer.position.cursor_goto(), + if msg.len() <= self.outer.dimensions.w as usize { + msg + } else { + &msg[0..self.outer.dimensions.w as usize] + }, + clone_times::<_, String>( + " ".to_string(), + self.outer.dimensions.w - msg.len() as u16 + ), + ) } } @@ -99,24 +134,24 @@ mod tests { #[test] fn test_visible() { - assert!(Viewport { - outer: BoundingBox::at_origin(Dimensions { w: 10, h: 10 }), - inner: BoundingBox { + assert!(Viewport::new( + BoundingBox::at_origin(Dimensions { w: 10, h: 10 }), + BoundingBox { position: Position { x: -10, y: -10 }, dimensions: Dimensions { w: 15, h: 15 }, }, - out: (), - } + () + ) .visible(&Position { x: 13, y: 13 })); - assert!(!Viewport { - outer: BoundingBox::at_origin(Dimensions { w: 10, h: 10 }), - inner: BoundingBox { + assert!(!Viewport::new( + BoundingBox::at_origin(Dimensions { w: 10, h: 10 }), + BoundingBox { position: Position { x: -10, y: -10 }, dimensions: Dimensions { w: 15, h: 15 }, }, - out: (), - } + (), + ) .visible(&Position { x: 1, y: 1 })); } diff --git a/src/game.rs b/src/game.rs index 6274ef573f58..b619f13423f5 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,17 +1,21 @@ +use crate::display::{self, Viewport}; +use crate::entities::Character; +use crate::messages::message; use crate::settings::Settings; +use crate::types::command::Command; use crate::types::Positioned; use crate::types::{BoundingBox, Dimensions, Position}; +use rand::rngs::SmallRng; +use rand::SeedableRng; use std::io::{self, StdinLock, StdoutLock, Write}; use termion::input::Keys; use termion::input::TermRead; use termion::raw::RawTerminal; -use crate::display::{self, Viewport}; -use crate::entities::Character; -use crate::types::command::Command; - type Stdout<'a> = RawTerminal>; +type Rng = SmallRng; + /// The full state of a running Game pub struct Game<'a> { settings: Settings, @@ -23,6 +27,12 @@ pub struct Game<'a> { /// The player character character: Character, + + /// The messages that have been said to the user, in forward time order + messages: Vec, + + /// A global random number generator for the game + rng: Rng, } impl<'a> Game<'a> { @@ -33,18 +43,21 @@ impl<'a> Game<'a> { w: u16, h: u16, ) -> Game<'a> { + let rng = match settings.seed { + Some(seed) => SmallRng::seed_from_u64(seed), + None => SmallRng::from_entropy(), + }; Game { - settings: settings, - viewport: Viewport { - outer: BoundingBox::at_origin(Dimensions { w, h }), - inner: BoundingBox::at_origin(Dimensions { - w: w - 2, - h: h - 2, - }), - out: stdout, - }, + settings, + rng, + viewport: Viewport::new( + BoundingBox::at_origin(Dimensions { w, h }), + BoundingBox::at_origin(Dimensions { w: w - 2, h: h - 2 }), + stdout, + ), keys: stdin.keys(), character: Character::new(), + messages: Vec::new(), } } @@ -53,15 +66,29 @@ impl<'a> Game<'a> { !pos.within(self.viewport.inner) } + /// Draw all the game entities to the screen fn draw_entities(&mut self) -> io::Result<()> { self.viewport.draw(&self.character) } + /// Get a message from the global map based on the rng in this game + fn message(&mut self, name: &str) -> &'static str { + message(name, &mut self.rng) + } + + /// Say a message to the user + fn say(&mut self, message_name: &str) -> io::Result<()> { + let message = self.message(message_name); + self.messages.push(message.to_string()); + self.viewport.write_message(message) + } + /// Run the game pub fn run(mut self) -> io::Result<()> { info!("Running game"); self.viewport.init()?; self.draw_entities()?; + self.say("global.welcome")?; self.flush()?; loop { let mut old_position = None; @@ -86,7 +113,7 @@ impl<'a> Game<'a> { self.viewport.clear(old_pos)?; self.viewport.draw(&self.character)?; } - None => () + None => (), } self.flush()?; debug!("{:?}", self.character); diff --git a/src/main.rs b/src/main.rs index f2c3d00f96d7..6b0ae18181ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,18 +3,26 @@ extern crate termion; extern crate log; extern crate config; extern crate log4rs; +extern crate serde; +extern crate toml; #[macro_use] extern crate serde_derive; #[macro_use] extern crate clap; #[macro_use] extern crate prettytable; +#[macro_use] +extern crate lazy_static; +#[macro_use] +extern crate maplit; + mod display; mod game; #[macro_use] mod types; mod entities; +mod messages; mod settings; use clap::App; diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 000000000000..2b9f098f98ca --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,186 @@ +use rand::seq::SliceRandom; +use rand::Rng; +use serde::de::MapAccess; +use serde::de::SeqAccess; +use serde::de::Visitor; +use std::collections::HashMap; +use std::fmt; +use std::marker::PhantomData; + +#[derive(Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +enum Message<'a> { + Single(&'a str), + Choice(Vec<&'a str>), +} + +impl<'a> Message<'a> { + fn resolve(&self, rng: &mut R) -> Option<&'a str> { + use Message::*; + match self { + Single(msg) => Some(*msg), + Choice(msgs) => msgs.choose(rng).map(|msg| *msg), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum NestedMap<'a> { + Direct(Message<'a>), + Nested(HashMap<&'a str, NestedMap<'a>>), +} + +impl<'a> NestedMap<'a> { + fn lookup(&'a self, path: &str) -> Option<&'a Message<'a>> { + use NestedMap::*; + let leaf = + path.split(".") + .fold(Some(self), |current, key| match current { + Some(Nested(m)) => m.get(key), + _ => None, + }); + match leaf { + Some(Direct(msg)) => Some(msg), + _ => None, + } + } +} + +struct NestedMapVisitor<'a> { + marker: PhantomData NestedMap<'a>>, +} + +impl<'a> NestedMapVisitor<'a> { + fn new() -> Self { + NestedMapVisitor { + marker: PhantomData, + } + } +} + +impl<'de> Visitor<'de> for NestedMapVisitor<'de> { + type Value = NestedMap<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "A message, a list of messages, or a nested map of messages", + ) + } + + fn visit_borrowed_str(self, v: &'de str) -> Result { + Ok(NestedMap::Direct(Message::Single(v))) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut choices = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(choice) = seq.next_element()? { + choices.push(choice); + } + Ok(NestedMap::Direct(Message::Choice(choices))) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut nested = HashMap::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((k, v)) = map.next_entry()? { + nested.insert(k, v); + } + Ok(NestedMap::Nested(nested)) + } +} + +impl<'de> serde::Deserialize<'de> for NestedMap<'de> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(NestedMapVisitor::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_nested_map() { + let src = r#" +[global] +hello = "Hello World!" + +[foo.bar] +single = "Single" +choice = ["Say this", "Or this"] +"#; + let result = toml::from_str(src); + assert_eq!( + result, + Ok(NestedMap::Nested(hashmap! { + "global" => NestedMap::Nested(hashmap!{ + "hello" => NestedMap::Direct(Message::Single("Hello World!")), + }), + "foo" => NestedMap::Nested(hashmap!{ + "bar" => NestedMap::Nested(hashmap!{ + "single" => NestedMap::Direct(Message::Single("Single")), + "choice" => NestedMap::Direct(Message::Choice( + vec!["Say this", "Or this"] + )) + }) + }) + })) + ) + } + + #[test] + fn test_lookup() { + let map: NestedMap<'static> = toml::from_str( + r#" +[global] +hello = "Hello World!" + +[foo.bar] +single = "Single" +choice = ["Say this", "Or this"] +"#, + ) + .unwrap(); + + assert_eq!( + map.lookup("global.hello"), + Some(&Message::Single("Hello World!")) + ); + assert_eq!( + map.lookup("foo.bar.single"), + Some(&Message::Single("Single")) + ); + assert_eq!( + map.lookup("foo.bar.choice"), + Some(&Message::Choice(vec!["Say this", "Or this"])) + ); + } +} + +static MESSAGES_RAW: &'static str = include_str!("messages.toml"); + +lazy_static! { + static ref MESSAGES: NestedMap<'static> = + toml::from_str(MESSAGES_RAW).unwrap(); +} + +/// Look up a game message based on the given (dot-separated) name, with the +/// given random generator used to select from choice-based messages +pub fn message(name: &str, rng: &mut R) -> &'static str { + use Message::*; + MESSAGES + .lookup(name) + .and_then(|msg| msg.resolve(rng)) + .unwrap_or_else(|| { + error!("Message not found: {}", name); + "Message not found" + }) +} diff --git a/src/messages.toml b/src/messages.toml new file mode 100644 index 000000000000..04746462d580 --- /dev/null +++ b/src/messages.toml @@ -0,0 +1,2 @@ +[global] +welcome = "Welcome to Xanthous! It's dangerous out there, why not stay inside?" diff --git a/src/settings.rs b/src/settings.rs index 06f0d4e9d7de..8444bf80eec8 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -48,6 +48,7 @@ impl Logging { #[derive(Debug, Deserialize)] pub struct Settings { + pub seed: Option, pub logging: Logging, } diff --git a/src/types/mod.rs b/src/types/mod.rs index 146dfac9d99b..ab66a50cc218 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -66,6 +66,12 @@ impl BoundingBox { pub fn inner(self) -> BoundingBox { self + UNIT_POSITION - UNIT_DIMENSIONS - UNIT_DIMENSIONS } + + /// Moves the top right corner of the bounding box by the offset specified + /// by the given position, keeping the lower right corner in place + pub fn move_tr_corner(self, offset: Position) -> BoundingBox { + self + offset - Dimensions { w: offset.x as u16, h: offset.y as u16 } + } } impl ops::Add for BoundingBox { -- cgit 1.4.1