an @-sign in a box
This commit is contained in:
		
						commit
						de081d7b1d
					
				
					 19 changed files with 2024 additions and 0 deletions
				
			
		
							
								
								
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | ||||||
|  | /target | ||||||
|  | **/*.rs.bk | ||||||
|  | debug.log | ||||||
							
								
								
									
										1145
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1145
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										21
									
								
								Cargo.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								Cargo.toml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | [package] | ||||||
|  | name = "xanthous" | ||||||
|  | version = "0.1.0" | ||||||
|  | authors = ["Griffin Smith <root@gws.fyi>"] | ||||||
|  | edition = "2018" | ||||||
|  | 
 | ||||||
|  | [dependencies] | ||||||
|  | config = "*" | ||||||
|  | itertools = "*" | ||||||
|  | lazy_static = "*" | ||||||
|  | log = "*" | ||||||
|  | log4rs = "*" | ||||||
|  | proptest = "0.9.3" | ||||||
|  | proptest-derive = "*" | ||||||
|  | serde = "^1.0.8" | ||||||
|  | serde_derive = "^1.0.8" | ||||||
|  | termion = "*" | ||||||
|  | clap = {version = "^2.33.0", features = ["yaml"]} | ||||||
|  | prettytable-rs = "^0.8" | ||||||
|  | 
 | ||||||
|  | [dev-dependencies] | ||||||
							
								
								
									
										2
									
								
								Config.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								Config.toml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,2 @@ | ||||||
|  | [logging] | ||||||
|  | level = "debug" | ||||||
							
								
								
									
										12
									
								
								proptest-regressions/display/draw_box.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								proptest-regressions/display/draw_box.txt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | ||||||
|  | # Seeds for failure cases proptest has generated in the past. It is | ||||||
|  | # automatically read and these particular cases re-run before any | ||||||
|  | # novel cases are generated. | ||||||
|  | # | ||||||
|  | # It is recommended to check this file in to source control so that | ||||||
|  | # everyone who runs the test benefits from these saved cases. | ||||||
|  | cc 7aff36a9f7b263e62434a3f61ada1d6aaf6ff4545a463548d96815a0e98cf5f1 # shrinks to dims = Dimensions { w: 0, h: 0 }, style = Thin | ||||||
|  | cc e4d96a13d6a8c7625e49d3545f6076d58152f3b5eb43fae65f0d407d1d34f96c # shrinks to dims = Dimensions { w: 1, h: 1 }, style = Thin | ||||||
|  | cc b5f0d7cb409896bd6692544c7c1f781174075c287d3b7a3b9dc73526ea489484 # shrinks to dims = Dimensions { w: 1, h: 1 }, style = Thin | ||||||
|  | cc 103b62b7c29c22adcbc23153638d3b37bad57aeec685d1eab38c49d0deed937f # shrinks to dims = Dimensions { w: 0, h: 1 }, style = Thin | ||||||
|  | cc 24c3858a543b0d8ff4966517040ec8c183ed311688d6863fd13facb5cdad7aa0 # shrinks to dims = Dimensions { w: 1, h: 1 }, style = Thin | ||||||
|  | cc 70a53a8b771937976a08a72d870b355a0995cc0251f45de4393c37a56a789b83 # shrinks to dims = Dimensions { w: 0, h: 0 }, style = Thin | ||||||
							
								
								
									
										7
									
								
								proptest-regressions/types/mod.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								proptest-regressions/types/mod.txt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | # Seeds for failure cases proptest has generated in the past. It is | ||||||
|  | # automatically read and these particular cases re-run before any | ||||||
|  | # novel cases are generated. | ||||||
|  | # | ||||||
|  | # It is recommended to check this file in to source control so that | ||||||
|  | # everyone who runs the test benefits from these saved cases. | ||||||
|  | cc a51cf37623f0e4024f4ba1450195be296d9b9e8ae954dbbf997ce5b57cd26792 # shrinks to a = Position { x: 44, y: 25 }, b = Position { x: 0, y: 25 }, c = Position { x: 0, y: 0 } | ||||||
							
								
								
									
										1
									
								
								rustfmt.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								rustfmt.toml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | max_width = 80 | ||||||
							
								
								
									
										14
									
								
								src/cli.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/cli.yml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | ||||||
|  | name: xanthous | ||||||
|  | version: "0.0" | ||||||
|  | author: Griffin Smith <root@gws.fyi> | ||||||
|  | about: hey, it's a terminal game | ||||||
|  | args: | ||||||
|  |   - config: | ||||||
|  |       short: c | ||||||
|  |       long: config | ||||||
|  |       value_name: FILE | ||||||
|  |       help: Sets a custom config file | ||||||
|  |       takes_value: true | ||||||
|  | subcommands: | ||||||
|  |   - debug: | ||||||
|  |       about: Writes debug information to the terminal and exits | ||||||
							
								
								
									
										205
									
								
								src/display/draw_box.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								src/display/draw_box.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,205 @@ | ||||||
|  | use crate::display::utils::clone_times; | ||||||
|  | use crate::display::utils::times; | ||||||
|  | use crate::types::Dimensions; | ||||||
|  | use itertools::Itertools; | ||||||
|  | use proptest::prelude::Arbitrary; | ||||||
|  | use proptest::strategy; | ||||||
|  | use proptest_derive::Arbitrary; | ||||||
|  | 
 | ||||||
|  | // Box Drawing
 | ||||||
|  | //  	    0 	1 	2 	3 	4 	5 	6 	7 	8 	9 	A 	B 	C 	D 	E 	F
 | ||||||
|  | // U+250x 	─ 	━ 	│ 	┃ 	┄ 	┅ 	┆ 	┇ 	┈ 	┉ 	┊ 	┋ 	┌ 	┍ 	┎ 	┏
 | ||||||
|  | // U+251x 	┐ 	┑ 	┒ 	┓ 	└ 	┕ 	┖ 	┗ 	┘ 	┙ 	┚ 	┛ 	├ 	┝ 	┞ 	┟
 | ||||||
|  | // U+252x 	┠ 	┡ 	┢ 	┣ 	┤ 	┥ 	┦ 	┧ 	┨ 	┩ 	┪ 	┫ 	┬ 	┭ 	┮ 	┯
 | ||||||
|  | // U+253x 	┰ 	┱ 	┲ 	┳ 	┴ 	┵ 	┶ 	┷ 	┸ 	┹ 	┺ 	┻ 	┼ 	┽ 	┾ 	┿
 | ||||||
|  | // U+254x 	╀ 	╁ 	╂ 	╃ 	╄ 	╅ 	╆ 	╇ 	╈ 	╉ 	╊ 	╋ 	╌ 	╍ 	╎ 	╏
 | ||||||
|  | // U+255x 	═ 	║ 	╒ 	╓ 	╔ 	╕ 	╖ 	╗ 	╘ 	╙ 	╚ 	╛ 	╜ 	╝ 	╞ 	╟
 | ||||||
|  | // U+256x 	╠ 	╡ 	╢ 	╣ 	╤ 	╥ 	╦ 	╧ 	╨ 	╩ 	╪ 	╫ 	╬ 	╭ 	╮ 	╯
 | ||||||
|  | // U+257x 	╰ 	╱ 	╲ 	╳ 	╴ 	╵ 	╶ 	╷ 	╸ 	╹ 	╺ 	╻ 	╼ 	╽ 	╾ 	╿
 | ||||||
|  | 
 | ||||||
|  | static BOX: char = '☐'; | ||||||
|  | 
 | ||||||
|  | static BOX_CHARS: [[char; 16]; 8] = [ | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '─', '━', '│', '┃', '┄', '┅', '┆', '┇', '┈', '┉', | ||||||
|  |         // 10
 | ||||||
|  |         '┊', '┋', '┌', '┍', '┎', '┏', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '┐', '┑', '┒', '┓', '└', '┕', '┖', '┗', '┘', '┙', | ||||||
|  |         '┚', '┛', '├', '┝', '┞', '┟', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '┠', '┡', '┢', '┣', '┤', '┥', '┦', '┧', '┨', '┩', | ||||||
|  |         '┪', '┫', '┬', '┭', '┮', '┯', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '┰', '┱', '┲', '┳', '┴', '┵', '┶', '┷', '┸', '┹', | ||||||
|  |         '┺', '┻', '┼', '┽', '┾', '┿', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '╀', '╁', '╂', '╃', '╄', '╅', '╆', '╇', '╈', '╉', | ||||||
|  |         '╊', '╋', '╌', '╍', '╎', '╏', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '═', '║', '╒', '╓', '╔', '╕', '╖', '╗', '╘', '╙', | ||||||
|  |         '╚', '╛', '╜', '╝', '╞', '╟', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '╠', '╡', '╢', '╣', '╤', '╥', '╦', '╧', '╨', '╩', | ||||||
|  |         '╪', '╫', '╬', '╭', '╮', '╯', | ||||||
|  |     ], | ||||||
|  |     [ | ||||||
|  |         // 0    1    2    3    4    5    6    7    8    9
 | ||||||
|  |         '╰', '╱', '╲', '╳', '╴', '╵', '╶', '╷', '╸', '╹', | ||||||
|  |         '╺', '╻', '╼', '╽', '╾', '╿', | ||||||
|  |     ], | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||||||
|  | pub enum BoxStyle { | ||||||
|  |     Thin, | ||||||
|  |     Thick, | ||||||
|  |     Dotted, | ||||||
|  |     ThickDotted, | ||||||
|  |     Dashed, | ||||||
|  |     ThickDashed, | ||||||
|  |     Double, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Arbitrary for BoxStyle { | ||||||
|  |     type Parameters = (); | ||||||
|  |     type Strategy = strategy::Just<Self>; | ||||||
|  |     fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { | ||||||
|  |         // TODO
 | ||||||
|  |         strategy::Just(BoxStyle::Thin) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | trait Stylable { | ||||||
|  |     fn style(self, style: BoxStyle) -> char; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | enum Corner { | ||||||
|  |     TopRight, | ||||||
|  |     TopLeft, | ||||||
|  |     BottomRight, | ||||||
|  |     BottomLeft, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Stylable for Corner { | ||||||
|  |     fn style(self, style: BoxStyle) -> char { | ||||||
|  |         use BoxStyle::*; | ||||||
|  |         use Corner::*; | ||||||
|  | 
 | ||||||
|  |         match (self, style) { | ||||||
|  |             (TopRight, Thin) => BOX_CHARS[1][0], | ||||||
|  |             (TopLeft, Thin) => BOX_CHARS[0][12], | ||||||
|  |             (BottomRight, Thin) => BOX_CHARS[1][8], | ||||||
|  |             (BottomLeft, Thin) => BOX_CHARS[1][4], | ||||||
|  |             _ => unimplemented!(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | enum Line { | ||||||
|  |     H, | ||||||
|  |     V, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Stylable for Line { | ||||||
|  |     fn style(self, style: BoxStyle) -> char { | ||||||
|  |         use BoxStyle::*; | ||||||
|  |         use Line::*; | ||||||
|  |         match (self, style) { | ||||||
|  |             (H, Thin) => BOX_CHARS[0][0], | ||||||
|  |             (V, Thin) => BOX_CHARS[0][2], | ||||||
|  |             _ => unimplemented!(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[must_use] | ||||||
|  | pub fn make_box(style: BoxStyle, dims: Dimensions) -> String { | ||||||
|  |     if dims.h == 0 || dims.w == 0 { | ||||||
|  |         "".to_string() | ||||||
|  |     } else if dims.h == 1 && dims.w == 1 { | ||||||
|  |         BOX.to_string() | ||||||
|  |     } else if dims.h == 1 { | ||||||
|  |         times(Line::H.style(style), dims.w) | ||||||
|  |     } else if dims.w == 1 { | ||||||
|  |         (0..dims.h).map(|_| Line::V.style(style)).join("\n\r") | ||||||
|  |     } else { | ||||||
|  |         let h_line: String = times(Line::H.style(style), dims.w - 2); | ||||||
|  |         let v_line = Line::V.style(style); | ||||||
|  |         let v_walls: String = clone_times( | ||||||
|  |             format!( | ||||||
|  |                 "{}{}{}\n\r", | ||||||
|  |                 v_line, | ||||||
|  |                 times::<_, String>(' ', dims.w - 2), | ||||||
|  |                 v_line | ||||||
|  |             ), | ||||||
|  |             dims.h - 2, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         format!( | ||||||
|  |             "{}{}{}\n\r{}{}{}{}", | ||||||
|  |             Corner::TopLeft.style(style), | ||||||
|  |             h_line, | ||||||
|  |             Corner::TopRight.style(style), | ||||||
|  |             v_walls, | ||||||
|  |             Corner::BottomLeft.style(style), | ||||||
|  |             h_line, | ||||||
|  |             Corner::BottomRight.style(style), | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use super::*; | ||||||
|  |     use proptest::prelude::*; | ||||||
|  | 
 | ||||||
|  |     #[test] | ||||||
|  |     fn make_thin_box() { | ||||||
|  |         let res = make_box(BoxStyle::Thin, Dimensions { w: 10, h: 10 }); | ||||||
|  |         assert_eq!( | ||||||
|  |             res, | ||||||
|  |             "┌────────┐
 | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r│        │ | ||||||
|  | \r└────────┘" | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     proptest! { | ||||||
|  |         #[test] | ||||||
|  |         fn box_has_height_lines(dims: Dimensions, style: BoxStyle) { | ||||||
|  |             let res = make_box(style, dims); | ||||||
|  |             prop_assume!((dims.w > 0 && dims.h > 0)); | ||||||
|  |             assert_eq!(res.split("\n\r").count(), dims.h as usize); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         #[test] | ||||||
|  |         fn box_lines_have_width_length(dims: Dimensions, style: BoxStyle) { | ||||||
|  |             let res = make_box(style, dims); | ||||||
|  |             prop_assume!(dims.w == 0 && dims.h == 0 || (dims.w > 0 && dims.h > 0)); | ||||||
|  |             assert!(res.split("\n\r").all(|l| l.chars().count() == dims.w as usize)); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/display/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/display/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | pub mod draw_box; | ||||||
|  | pub mod utils; | ||||||
|  | pub use draw_box::{make_box, BoxStyle}; | ||||||
|  | use std::io::{self, Write}; | ||||||
|  | use termion::{clear, cursor, style}; | ||||||
|  | 
 | ||||||
|  | pub fn clear<T: Write>(out: &mut T) -> io::Result<()> { | ||||||
|  |     write!(out, "{}{}{}", clear::All, style::Reset, cursor::Goto(1, 1)) | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/display/utils.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/display/utils.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | use std::iter::FromIterator; | ||||||
|  | 
 | ||||||
|  | pub fn times<A: Copy, B: FromIterator<A>>(elem: A, n: u16) -> B { | ||||||
|  |     (0..n).map(|_| elem).collect() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn clone_times<A: Clone, B: FromIterator<A>>(elem: A, n: u16) -> B { | ||||||
|  |     (0..n).map(|_| elem.clone()).collect() | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								src/entities/character.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/entities/character.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | ||||||
|  | use crate::types::{Position, Speed}; | ||||||
|  | 
 | ||||||
|  | const DEFAULT_SPEED: Speed = Speed(100); | ||||||
|  | 
 | ||||||
|  | pub struct Character { | ||||||
|  |     position: Position, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Character { | ||||||
|  |     pub fn speed(&self) -> Speed { | ||||||
|  |         Speed(100) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | positioned!(Character); | ||||||
							
								
								
									
										1
									
								
								src/entities/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/entities/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | pub mod character; | ||||||
							
								
								
									
										118
									
								
								src/game.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/game.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,118 @@ | ||||||
|  | use std::thread; | ||||||
|  | use crate::settings::Settings; | ||||||
|  | use crate::types::{BoundingBox, Dimensions, Position}; | ||||||
|  | use std::io::{self, StdinLock, StdoutLock, Write}; | ||||||
|  | use termion::cursor; | ||||||
|  | use termion::input::Keys; | ||||||
|  | use termion::input::TermRead; | ||||||
|  | use termion::raw::RawTerminal; | ||||||
|  | 
 | ||||||
|  | use crate::display; | ||||||
|  | use crate::types::command::Command; | ||||||
|  | 
 | ||||||
|  | /// The full state of a running Game
 | ||||||
|  | pub struct Game<'a> { | ||||||
|  |     settings: Settings, | ||||||
|  | 
 | ||||||
|  |     /// The box describing the viewport. Generally the size of the terminal, and
 | ||||||
|  |     /// positioned at 0, 0
 | ||||||
|  |     viewport: BoundingBox, | ||||||
|  | 
 | ||||||
|  |     /// An iterator on keypresses from the user
 | ||||||
|  |     keys: Keys<StdinLock<'a>>, | ||||||
|  | 
 | ||||||
|  |     stdout: RawTerminal<StdoutLock<'a>>, | ||||||
|  | 
 | ||||||
|  |     /// The position of the character
 | ||||||
|  |     character: Position, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<'a> Game<'a> { | ||||||
|  |     pub fn new( | ||||||
|  |         settings: Settings, | ||||||
|  |         stdout: RawTerminal<StdoutLock<'a>>, | ||||||
|  |         stdin: StdinLock<'a>, | ||||||
|  |         w: u16, | ||||||
|  |         h: u16, | ||||||
|  |     ) -> Game<'a> { | ||||||
|  |         Game { | ||||||
|  |             settings: settings, | ||||||
|  |             viewport: BoundingBox::at_origin(Dimensions { w, h }), | ||||||
|  |             keys: stdin.keys(), | ||||||
|  |             stdout: stdout, | ||||||
|  |             character: Position { x: 1, y: 1 }, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Returns true if there's a collision in the game at the given Position
 | ||||||
|  |     fn collision_at(&self, pos: Position) -> bool { | ||||||
|  |         !pos.within(self.viewport.inner()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Run the game
 | ||||||
|  |     pub fn run(mut self) { | ||||||
|  |         info!("Running game"); | ||||||
|  |         write!( | ||||||
|  |             self, | ||||||
|  |             "{}{}@{}", | ||||||
|  |             display::make_box( | ||||||
|  |                 display::BoxStyle::Thin, | ||||||
|  |                 self.viewport.dimensions | ||||||
|  |             ), | ||||||
|  |             cursor::Goto(2, 2), | ||||||
|  |             cursor::Left(1), | ||||||
|  |         ) | ||||||
|  |         .unwrap(); | ||||||
|  |         self.flush().unwrap(); | ||||||
|  |         loop { | ||||||
|  |             let mut character_moved = false; | ||||||
|  |             match Command::from_key(self.keys.next().unwrap().unwrap()) { | ||||||
|  |                 Some(Command::Quit) => { | ||||||
|  |                     info!("Quitting game due to user request"); | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 Some(Command::Move(direction)) => { | ||||||
|  |                     let new_pos = self.character + direction; | ||||||
|  |                     if !self.collision_at(new_pos) { | ||||||
|  |                         self.character = new_pos; | ||||||
|  |                         character_moved = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 _ => (), | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if character_moved { | ||||||
|  |                 debug!("char: {:?}", self.character); | ||||||
|  |                 write!( | ||||||
|  |                     self, | ||||||
|  |                     " {}@{}", | ||||||
|  |                     cursor::Goto(self.character.x + 1, self.character.y + 1,), | ||||||
|  |                     cursor::Left(1) | ||||||
|  |                 ) | ||||||
|  |                 .unwrap(); | ||||||
|  |             } | ||||||
|  |             self.flush().unwrap(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<'a> Drop for Game<'a> { | ||||||
|  |     fn drop(&mut self) { | ||||||
|  |         display::clear(self).unwrap(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<'a> Write for Game<'a> { | ||||||
|  |     fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | ||||||
|  |         self.stdout.write(buf) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn flush(&mut self) -> io::Result<()> { | ||||||
|  |         self.stdout.flush() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { | ||||||
|  |         self.stdout.write_all(buf) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										73
									
								
								src/main.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/main.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | ||||||
|  | extern crate termion; | ||||||
|  | #[macro_use] | ||||||
|  | extern crate log; | ||||||
|  | extern crate config; | ||||||
|  | extern crate log4rs; | ||||||
|  | #[macro_use] | ||||||
|  | extern crate serde_derive; | ||||||
|  | #[macro_use] | ||||||
|  | extern crate clap; | ||||||
|  | #[macro_use] | ||||||
|  | extern crate prettytable; | ||||||
|  | 
 | ||||||
|  | mod display; | ||||||
|  | mod game; | ||||||
|  | #[macro_use] | ||||||
|  | mod types; | ||||||
|  | mod entities; | ||||||
|  | mod settings; | ||||||
|  | 
 | ||||||
|  | use clap::App; | ||||||
|  | use game::Game; | ||||||
|  | use prettytable::format::consts::FORMAT_BOX_CHARS; | ||||||
|  | use settings::Settings; | ||||||
|  | 
 | ||||||
|  | use std::io::{self, StdinLock, StdoutLock}; | ||||||
|  | 
 | ||||||
|  | use termion::raw::IntoRawMode; | ||||||
|  | use termion::raw::RawTerminal; | ||||||
|  | 
 | ||||||
|  | fn init( | ||||||
|  |     settings: Settings, | ||||||
|  |     stdout: RawTerminal<StdoutLock<'_>>, | ||||||
|  |     stdin: StdinLock<'_>, | ||||||
|  |     w: u16, | ||||||
|  |     h: u16, | ||||||
|  | ) { | ||||||
|  |     let game = Game::new(settings, stdout, stdin, w, h); | ||||||
|  |     game.run() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | fn main() { | ||||||
|  |     let yaml = load_yaml!("cli.yml"); | ||||||
|  |     let matches = App::from_yaml(yaml).get_matches(); | ||||||
|  |     let settings = Settings::load().unwrap(); | ||||||
|  |     settings.logging.init_log(); | ||||||
|  |     let stdout = io::stdout(); | ||||||
|  |     let stdout = stdout.lock(); | ||||||
|  | 
 | ||||||
|  |     let stdin = io::stdin(); | ||||||
|  |     let stdin = stdin.lock(); | ||||||
|  | 
 | ||||||
|  |     let termsize = termion::terminal_size().ok(); | ||||||
|  |     // let termwidth = termsize.map(|(w, _)| w - 2).unwrap_or(70);
 | ||||||
|  |     // let termheight = termsize.map(|(_, h)| h - 2).unwrap_or(40);
 | ||||||
|  |     let (termwidth, termheight) = termsize.unwrap_or((70, 40)); | ||||||
|  | 
 | ||||||
|  |     match matches.subcommand() { | ||||||
|  |         ("debug", _) => { | ||||||
|  |             let mut table = table!( | ||||||
|  |                 [br->"termwidth", termwidth], | ||||||
|  |                 [br->"termheight", termheight], | ||||||
|  |                 [br->"logfile", settings.logging.file], | ||||||
|  |                 [br->"loglevel", settings.logging.level] | ||||||
|  |             ); | ||||||
|  |             table.set_format(*FORMAT_BOX_CHARS); | ||||||
|  |             table.printstd(); | ||||||
|  |         } | ||||||
|  |         _ => { | ||||||
|  |             let stdout = stdout.into_raw_mode().unwrap(); | ||||||
|  |             init(settings, stdout, stdin, termwidth, termheight); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								src/settings.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/settings.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | ||||||
|  | use config::{Config, ConfigError}; | ||||||
|  | use log::LevelFilter; | ||||||
|  | use log4rs::append::file::FileAppender; | ||||||
|  | use log4rs::config::{Appender, Root}; | ||||||
|  | use log4rs::encode::pattern::PatternEncoder; | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct Logging { | ||||||
|  |     #[serde(default = "Logging::default_level")] | ||||||
|  |     pub level: LevelFilter, | ||||||
|  | 
 | ||||||
|  |     #[serde(default = "Logging::default_file")] | ||||||
|  |     pub file: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Default for Logging { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         Logging { | ||||||
|  |             level: LevelFilter::Off, | ||||||
|  |             file: "debug.log".to_string(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Logging { | ||||||
|  |     pub fn init_log(&self) { | ||||||
|  |         let logfile = FileAppender::builder() | ||||||
|  |             .encoder(Box::new(PatternEncoder::new("{d} {l} - {m}\n"))) | ||||||
|  |             .build(self.file.clone()) | ||||||
|  |             .unwrap(); | ||||||
|  | 
 | ||||||
|  |         let config = log4rs::config::Config::builder() | ||||||
|  |             .appender(Appender::builder().build("logfile", Box::new(logfile))) | ||||||
|  |             .build(Root::builder().appender("logfile").build(self.level)) | ||||||
|  |             .unwrap(); | ||||||
|  | 
 | ||||||
|  |         log4rs::init_config(config).unwrap(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn default_level() -> LevelFilter { | ||||||
|  |         Logging::default().level | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn default_file() -> String { | ||||||
|  |         Logging::default().file | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct Settings { | ||||||
|  |     pub logging: Logging, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Settings { | ||||||
|  |     pub fn load() -> Result<Self, ConfigError> { | ||||||
|  |         let mut s = Config::new(); | ||||||
|  |         s.merge(config::File::with_name("Config").required(false))?; | ||||||
|  |         s.merge(config::Environment::with_prefix("XAN"))?; | ||||||
|  |         s.try_into() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/types/command.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/types/command.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | use super::Direction; | ||||||
|  | use super::Direction::*; | ||||||
|  | use termion::event::Key; | ||||||
|  | use termion::event::Key::Char; | ||||||
|  | 
 | ||||||
|  | pub enum Command { | ||||||
|  |     Quit, | ||||||
|  |     Move(Direction), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Command { | ||||||
|  |     pub fn from_key(k: Key) -> Option<Command> { | ||||||
|  |         use Command::*; | ||||||
|  |         match k { | ||||||
|  |             Char('q') => Some(Quit), | ||||||
|  |             Char('h') | Char('a') | Key::Left => Some(Move(Left)), | ||||||
|  |             Char('k') | Char('w') | Key::Up => Some(Move(Up)), | ||||||
|  |             Char('j') | Char('s') | Key::Down => Some(Move(Down)), | ||||||
|  |             Char('l') | Char('d') | Key::Right => Some(Move(Right)), | ||||||
|  |             _ => None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/types/direction.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/types/direction.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | use proptest_derive::Arbitrary; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub enum Direction { | ||||||
|  |     Left, | ||||||
|  |     Up, | ||||||
|  |     Down, | ||||||
|  |     Right, | ||||||
|  | } | ||||||
							
								
								
									
										296
									
								
								src/types/mod.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								src/types/mod.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,296 @@ | ||||||
|  | use std::cmp::Ordering; | ||||||
|  | use std::ops; | ||||||
|  | pub mod command; | ||||||
|  | pub mod direction; | ||||||
|  | pub use direction::Direction; | ||||||
|  | pub use direction::Direction::{Down, Left, Right, Up}; | ||||||
|  | use proptest_derive::Arbitrary; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub struct Dimensions { | ||||||
|  |     #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")] | ||||||
|  |     pub w: u16, | ||||||
|  | 
 | ||||||
|  |     #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")] | ||||||
|  |     pub h: u16, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub const ZERO_DIMENSIONS: Dimensions = Dimensions { w: 0, h: 0 }; | ||||||
|  | pub const UNIT_DIMENSIONS: Dimensions = Dimensions { w: 1, h: 1 }; | ||||||
|  | 
 | ||||||
|  | impl ops::Sub<Dimensions> for Dimensions { | ||||||
|  |     type Output = Dimensions; | ||||||
|  |     fn sub(self, dims: Dimensions) -> Dimensions { | ||||||
|  |         Dimensions { | ||||||
|  |             w: self.w - dims.w, | ||||||
|  |             h: self.h - dims.h, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub struct BoundingBox { | ||||||
|  |     pub dimensions: Dimensions, | ||||||
|  |     pub position: Position, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl BoundingBox { | ||||||
|  |     pub fn at_origin(dimensions: Dimensions) -> BoundingBox { | ||||||
|  |         BoundingBox { | ||||||
|  |             dimensions, | ||||||
|  |             position: ORIGIN, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn lr_corner(self) -> Position { | ||||||
|  |         self.position | ||||||
|  |             + (Position { | ||||||
|  |                 x: self.dimensions.w, | ||||||
|  |                 y: self.dimensions.h, | ||||||
|  |             }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Returns a bounding box representing the *inside* of this box if it was
 | ||||||
|  |     /// drawn on the screen.
 | ||||||
|  |     pub fn inner(self) -> BoundingBox { | ||||||
|  |         self + UNIT_POSITION - UNIT_DIMENSIONS - UNIT_DIMENSIONS | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl ops::Add<Position> for BoundingBox { | ||||||
|  |     type Output = BoundingBox; | ||||||
|  |     fn add(self, pos: Position) -> BoundingBox { | ||||||
|  |         BoundingBox { | ||||||
|  |             position: self.position + pos, | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl ops::Sub<Dimensions> for BoundingBox { | ||||||
|  |     type Output = BoundingBox; | ||||||
|  |     fn sub(self, dims: Dimensions) -> BoundingBox { | ||||||
|  |         BoundingBox { | ||||||
|  |             dimensions: self.dimensions - dims, | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub struct Position { | ||||||
|  |     /// x (horizontal) position
 | ||||||
|  |     #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")] | ||||||
|  |     pub x: u16, | ||||||
|  | 
 | ||||||
|  |     #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")] | ||||||
|  |     /// y (vertical) position
 | ||||||
|  |     pub y: u16, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub const ORIGIN: Position = Position { x: 0, y: 0 }; | ||||||
|  | pub const UNIT_POSITION: Position = Position { x: 1, y: 1 }; | ||||||
|  | 
 | ||||||
|  | impl Position { | ||||||
|  |     /// Returns true if this position exists within the bounds of the given box,
 | ||||||
|  |     /// inclusive
 | ||||||
|  |     pub fn within(self, b: BoundingBox) -> bool { | ||||||
|  |         (self > b.position - UNIT_POSITION) && self < (b.lr_corner()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl PartialOrd for Position { | ||||||
|  |     fn partial_cmp(&self, other: &Position) -> Option<Ordering> { | ||||||
|  |         if self.x == other.x && self.y == other.y { | ||||||
|  |             Some(Ordering::Equal) | ||||||
|  |         } else if self.x > other.x && self.y > other.y { | ||||||
|  |             Some(Ordering::Greater) | ||||||
|  |         } else if self.x < other.x && self.y < other.y { | ||||||
|  |             Some(Ordering::Less) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Implements (bounded) addition of a Dimension to a position.
 | ||||||
|  | ///
 | ||||||
|  | /// # Examples
 | ||||||
|  | ///
 | ||||||
|  | /// ```
 | ||||||
|  | /// let pos = Position { x: 1, y: 10 }
 | ||||||
|  | ///
 | ||||||
|  | /// let left_pos = pos + Direction::Left
 | ||||||
|  | /// assert_eq!(left, Position { x: 0, y: 10 })
 | ||||||
|  | ///
 | ||||||
|  | /// let right_pos = pos + Direction::Right
 | ||||||
|  | /// assert_eq!(right_pos, Position { x: 0, y: 10 })
 | ||||||
|  | /// ```
 | ||||||
|  | impl ops::Add<Direction> for Position { | ||||||
|  |     type Output = Position; | ||||||
|  |     fn add(self, dir: Direction) -> Position { | ||||||
|  |         match dir { | ||||||
|  |             Left => { | ||||||
|  |                 if self.x > 0 { | ||||||
|  |                     Position { | ||||||
|  |                         x: self.x - 1, | ||||||
|  |                         ..self | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     self | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Right => { | ||||||
|  |                 if self.x < std::u16::MAX { | ||||||
|  |                     Position { | ||||||
|  |                         x: self.x + 1, | ||||||
|  |                         ..self | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     self | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Up => { | ||||||
|  |                 if self.y > 0 { | ||||||
|  |                     Position { | ||||||
|  |                         y: self.y - 1, | ||||||
|  |                         ..self | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     self | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Down => { | ||||||
|  |                 if self.y < std::u16::MAX { | ||||||
|  |                     Position { | ||||||
|  |                         y: self.y + 1, | ||||||
|  |                         ..self | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     self | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl ops::Add<Position> for Position { | ||||||
|  |     type Output = Position; | ||||||
|  |     fn add(self, pos: Position) -> Position { | ||||||
|  |         Position { | ||||||
|  |             x: self.x + pos.x, | ||||||
|  |             y: self.y + pos.y, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl ops::Sub<Position> for Position { | ||||||
|  |     type Output = Position; | ||||||
|  |     fn sub(self, pos: Position) -> Position { | ||||||
|  |         Position { | ||||||
|  |             x: self.x - pos.x, | ||||||
|  |             y: self.y - pos.y, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub trait Positioned { | ||||||
|  |     fn x(&self) -> u16 { | ||||||
|  |         self.position().x | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn y(&self) -> u16 { | ||||||
|  |         self.position().y | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn position(&self) -> Position { | ||||||
|  |         Position { | ||||||
|  |             x: self.x(), | ||||||
|  |             y: self.y(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | macro_rules! positioned { | ||||||
|  |     ($name:ident) => { | ||||||
|  |         positioned!($name, position); | ||||||
|  |     }; | ||||||
|  |     ($name:ident, $attr:ident) => { | ||||||
|  |         impl crate::types::Positioned for $name { | ||||||
|  |             fn position(&self) -> Position { | ||||||
|  |                 self.$attr | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// A number of ticks
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub struct Ticks(pub u16); | ||||||
|  | 
 | ||||||
|  | /// A number of tiles
 | ||||||
|  | ///
 | ||||||
|  | /// Expressed in terms of a float to allow moving partial tiles in a number of
 | ||||||
|  | /// ticks
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Arbitrary)] | ||||||
|  | pub struct Tiles(pub f32); | ||||||
|  | 
 | ||||||
|  | /// The speed of an entity, expressed in ticks per tile
 | ||||||
|  | #[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)] | ||||||
|  | pub struct Speed(pub u32); | ||||||
|  | 
 | ||||||
|  | impl Speed { | ||||||
|  |     pub fn ticks_to_tiles(self, ticks: Ticks) -> Tiles { | ||||||
|  |         Tiles(ticks.0 as f32 / self.0 as f32) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use super::*; | ||||||
|  |     use proptest::prelude::*; | ||||||
|  | 
 | ||||||
|  |     proptest! { | ||||||
|  |         #[test] | ||||||
|  |         fn position_partialord_lt_transitive( | ||||||
|  |             a: Position, | ||||||
|  |             b: Position, | ||||||
|  |             c: Position | ||||||
|  |         ) { | ||||||
|  |             if a < b && b < c { | ||||||
|  |                 assert!(a < c) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         #[test] | ||||||
|  |         fn position_partialord_eq_transitive( | ||||||
|  |             a: Position, | ||||||
|  |             b: Position, | ||||||
|  |             c: Position | ||||||
|  |         ) { | ||||||
|  |             if a == b && b == c { | ||||||
|  |                 assert!(a == c) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         #[test] | ||||||
|  |         fn position_partialord_gt_transitive( | ||||||
|  |             a: Position, | ||||||
|  |             b: Position, | ||||||
|  |             c: Position, | ||||||
|  |         ) { | ||||||
|  |             if a > b && b > c { | ||||||
|  |                 assert!(a > c) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         #[test] | ||||||
|  |         fn position_partialord_antisymmetric(a: Position, b: Position) { | ||||||
|  |             if a < b { | ||||||
|  |                 assert!(!(a > b)) | ||||||
|  |             } else if a > b { | ||||||
|  |                 assert!(!(a < b)) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue