diff --git a/core/src/lib.rs b/core/src/lib.rs index b640c90..6482889 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,4 +8,5 @@ pub mod codec; pub mod hash; +pub mod map; pub mod rng; diff --git a/core/src/map.rs b/core/src/map.rs new file mode 100644 index 0000000..e6312e0 --- /dev/null +++ b/core/src/map.rs @@ -0,0 +1,190 @@ +//! Tile map of the shared world. + +use crate::codec::{DecodeError, Reader, Writer}; + +/// Longest accepted map edge, in tiles. +pub const MAX_MAP_SIZE: u32 = 1024; + +/// Tile coordinate. `x` grows to the right, `y` grows downwards. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pos { + pub x: i32, + pub y: i32, +} + +impl Pos { + pub const fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +/// What a tile is made of. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Terrain { + /// Open ground that can be walked on. + Ground = 0, + /// Rock that a stone pickaxe can break. + SoftRock = 1, + /// Rock that needs a copper pickaxe. + HardRock = 2, + /// Indestructible rock at the edge of the map. + Bedrock = 3, +} + +impl Terrain { + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Terrain::Ground), + 1 => Some(Terrain::SoftRock), + 2 => Some(Terrain::HardRock), + 3 => Some(Terrain::Bedrock), + _ => None, + } + } + + pub fn is_walkable(self) -> bool { + matches!(self, Terrain::Ground) + } + + /// Number of hits that break this terrain, or `None` if it cannot be dug. + pub fn hits_to_break(self) -> Option { + match self { + Terrain::SoftRock => Some(3), + Terrain::HardRock => Some(6), + Terrain::Ground | Terrain::Bedrock => None, + } + } + + /// Lowest pickaxe tier that can dig this terrain, or `None` if it cannot + /// be dug. + pub fn required_tool_tier(self) -> Option { + match self { + Terrain::SoftRock => Some(1), + Terrain::HardRock => Some(2), + Terrain::Ground | Terrain::Bedrock => None, + } + } +} + +/// One cell of the map. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tile { + pub terrain: Terrain, + /// Hits this tile has taken so far. Always below the number of hits that + /// break its terrain, and zero for terrain that cannot be dug. + pub damage: u8, +} + +impl Tile { + pub const fn new(terrain: Terrain) -> Self { + Self { terrain, damage: 0 } + } +} + +/// Rectangular grid of tiles. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Map { + width: u32, + height: u32, + tiles: Vec, +} + +impl Map { + /// Creates a map in which every tile has the given terrain. + /// + /// # Panics + /// Panics if an edge is zero or longer than [`MAX_MAP_SIZE`]. + pub fn filled(width: u32, height: u32, terrain: Terrain) -> Self { + assert!(width > 0 && height > 0, "map must not be empty"); + assert!( + width <= MAX_MAP_SIZE && height <= MAX_MAP_SIZE, + "map is too large" + ); + let count = (width as usize) * (height as usize); + Self { + width, + height, + tiles: vec![Tile::new(terrain); count], + } + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + fn index(&self, pos: Pos) -> Option { + if pos.x < 0 || pos.y < 0 { + return None; + } + let (x, y) = (pos.x as u32, pos.y as u32); + if x >= self.width || y >= self.height { + return None; + } + Some((y as usize) * (self.width as usize) + (x as usize)) + } + + pub fn in_bounds(&self, pos: Pos) -> bool { + self.index(pos).is_some() + } + + pub fn get(&self, pos: Pos) -> Option { + self.index(pos).map(|index| self.tiles[index]) + } + + /// Replaces the tile at `pos`. Returns `false` if `pos` is outside the + /// map. + pub fn set(&mut self, pos: Pos, tile: Tile) -> bool { + match self.index(pos) { + Some(index) => { + self.tiles[index] = tile; + true + } + None => false, + } + } + + /// All tiles, row by row: the tile at `pos` has index `y * width + x`. + pub fn tiles(&self) -> &[Tile] { + &self.tiles + } + + pub fn encode(&self, w: &mut Writer) { + w.u32(self.width); + w.u32(self.height); + for tile in &self.tiles { + w.u8(tile.terrain as u8); + w.u8(tile.damage); + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let width = r.u32()?; + let height = r.u32()?; + if width == 0 || height == 0 || width > MAX_MAP_SIZE || height > MAX_MAP_SIZE { + return Err(DecodeError::InvalidValue("map size")); + } + let count = (width as usize) * (height as usize); + if r.remaining() < count * 2 { + return Err(DecodeError::UnexpectedEnd); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let terrain = Terrain::from_u8(r.u8()?).ok_or(DecodeError::InvalidValue("terrain"))?; + let damage = r.u8()?; + if damage > 0 && damage >= terrain.hits_to_break().unwrap_or(0) { + return Err(DecodeError::InvalidValue("tile damage")); + } + tiles.push(Tile { terrain, damage }); + } + Ok(Self { + width, + height, + tiles, + }) + } +} diff --git a/core/tests/map.rs b/core/tests/map.rs new file mode 100644 index 0000000..e078e6a --- /dev/null +++ b/core/tests/map.rs @@ -0,0 +1,132 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::map::{Map, Pos, Terrain, Tile}; + +#[test] +fn a_filled_map_has_the_requested_size_and_terrain() { + let map = Map::filled(4, 3, Terrain::SoftRock); + assert_eq!(map.width(), 4); + assert_eq!(map.height(), 3); + assert_eq!(map.tiles().len(), 12); + assert_eq!(map.get(Pos::new(3, 2)), Some(Tile::new(Terrain::SoftRock))); +} + +#[test] +fn positions_outside_the_map_are_rejected() { + let mut map = Map::filled(4, 3, Terrain::Ground); + for pos in [ + Pos::new(-1, 0), + Pos::new(0, -1), + Pos::new(4, 0), + Pos::new(0, 3), + ] { + assert!(!map.in_bounds(pos), "{pos:?}"); + assert_eq!(map.get(pos), None, "{pos:?}"); + assert!(!map.set(pos, Tile::new(Terrain::Bedrock)), "{pos:?}"); + } + assert!(map.in_bounds(Pos::new(3, 2))); +} + +#[test] +fn set_changes_exactly_one_tile() { + let mut map = Map::filled(4, 3, Terrain::Ground); + assert!(map.set(Pos::new(1, 2), Tile::new(Terrain::HardRock))); + let hard_rock = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert_eq!(hard_rock, 1); + assert_eq!(map.tiles()[2 * 4 + 1].terrain, Terrain::HardRock); +} + +#[test] +fn only_rock_can_be_dug() { + assert_eq!(Terrain::Ground.hits_to_break(), None); + assert_eq!(Terrain::Bedrock.hits_to_break(), None); + assert_eq!(Terrain::SoftRock.hits_to_break(), Some(3)); + assert_eq!(Terrain::HardRock.hits_to_break(), Some(6)); + assert_eq!(Terrain::Ground.required_tool_tier(), None); + assert_eq!(Terrain::Bedrock.required_tool_tier(), None); + assert_eq!(Terrain::SoftRock.required_tool_tier(), Some(1)); + assert_eq!(Terrain::HardRock.required_tool_tier(), Some(2)); +} + +#[test] +fn only_ground_can_be_walked_on() { + assert!(Terrain::Ground.is_walkable()); + assert!(!Terrain::SoftRock.is_walkable()); + assert!(!Terrain::HardRock.is_walkable()); + assert!(!Terrain::Bedrock.is_walkable()); +} + +#[test] +fn terrain_numbers_are_stable() { + for terrain in [ + Terrain::Ground, + Terrain::SoftRock, + Terrain::HardRock, + Terrain::Bedrock, + ] { + assert_eq!(Terrain::from_u8(terrain as u8), Some(terrain)); + } + assert_eq!(Terrain::from_u8(4), None); +} + +#[test] +fn a_map_survives_encoding() { + let mut map = Map::filled(5, 4, Terrain::Ground); + map.set( + Pos::new(2, 1), + Tile { + terrain: Terrain::SoftRock, + damage: 2, + }, + ); + let mut w = Writer::new(); + map.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Map::decode(&mut r), Ok(map)); + assert_eq!(r.finish(), Ok(())); +} + +fn encoded_map(width: u32, height: u32, tiles: &[(u8, u8)]) -> Vec { + let mut w = Writer::new(); + w.u32(width); + w.u32(height); + for (terrain, damage) in tiles { + w.u8(*terrain); + w.u8(*damage); + } + w.into_bytes() +} + +#[test] +fn decoding_rejects_impossible_maps() { + let cases: [(Vec, DecodeError); 6] = [ + ( + encoded_map(0, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(2_000, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(1, 1, &[(9, 0)]), + DecodeError::InvalidValue("terrain"), + ), + ( + encoded_map(1, 1, &[(0, 1)]), + DecodeError::InvalidValue("tile damage"), + ), + ( + encoded_map(1, 1, &[(1, 3)]), + DecodeError::InvalidValue("tile damage"), + ), + (encoded_map(2, 2, &[(0, 0)]), DecodeError::UnexpectedEnd), + ]; + for (bytes, expected) in cases { + assert_eq!(Map::decode(&mut Reader::new(&bytes)), Err(expected)); + } +}