From 427dcb85304bf0257bdb0bab4d6c3398c2a61e00 Mon Sep 17 00:00:00 2001 From: JToTheThree Date: Tue, 17 Dec 2024 23:42:21 -0600 Subject: [PATCH] Remove automatic dir pattern testing --- .github/workflows/rust.yml | 2 +- CHANGELOG.md | 4 + Cargo.toml | 9 +- README.md | 4 +- assets/world/map-x00-y00-empty.tmx | 1 - assets/world/map-x01-y01-empty.tmx | 1 - assets/world/world_pattern.world | 14 ++ assets/world/world_pattern_bad.world | 12 -- src/error.rs | 7 + src/lib.rs | 2 + src/loader.rs | 11 +- src/world.rs | 230 ++++++++++----------------- tests/lib.rs | 28 ++-- 13 files changed, 143 insertions(+), 182 deletions(-) delete mode 100644 assets/world/map-x00-y00-empty.tmx delete mode 100644 assets/world/map-x01-y01-empty.tmx delete mode 100644 assets/world/world_pattern_bad.world diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8c665cc..109da25 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,7 +30,7 @@ jobs: run: cargo build --lib --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --verbose --all-features rustfmt: runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 237872e..be1acfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] +### Added +- Added a new crate feature `world` to enable support for parsing `World` files. + ## [0.13.0] ### Added - Added a `source` member to `Tileset`, `Map` and `Template`, which stores the resource path they have been loaded from. (#303) diff --git a/Cargo.toml b/Cargo.toml index d56d191..eeb00cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tiled" -version = "0.13.0" +version = "0.14.0" description = "A rust crate for loading maps created by the Tiled editor" categories = ["game-development"] keywords = ["gamedev", "tiled", "tmx", "map"] @@ -14,6 +14,7 @@ include = ["src/**/*.rs", "README.md", "LICENSE", "CHANGELOG.md"] [features] default = ["zstd"] wasm = ["zstd/wasm"] +world = ["serde", "serde_json", "regex"] [lib] name = "tiled" @@ -36,9 +37,9 @@ base64 = "0.22.1" xml-rs = "0.8.4" zstd = { version = "0.13.1", optional = true, default-features = false } flate2 = "1.0.28" -serde = "1.0.215" -serde_json = "1.0.133" -regex = "1.11.1" +serde = { version = "1.0.216", optional = true } +serde_json = { version = "1.0.133", optional = true } +regex = { version = "1.11.1", optional = true } [dev-dependencies.sfml] version = "0.21.0" diff --git a/README.md b/README.md index 62fb65b..1f437fa 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ let mut loader = Loader::with_reader( // Doing this embedding is useful for places where the OS filesystem is not available (e.g. WASM applications). |path: &std::path::Path| -> std::io::Result<_> { if path == std::path::Path::new("/my-map.tmx") { - Ok(std::io::Cursor::new(include_bytes!("../assets/tiled_csv.tmx"))) + Ok(std::io::Cursor::new(include_bytes!("assets/tiled_csv.tmx"))) } else { Err(std::io::ErrorKind::NotFound.into()) } @@ -86,7 +86,7 @@ impl tiled::ResourceReader for MyReader { // really dumb example implementation that just keeps resources in memory fn read_from(&mut self, path: &std::path::Path) -> std::result::Result { if path == std::path::Path::new("my_map.tmx") { - Ok(Cursor::new(include_bytes!("../assets/tiled_xml.tmx"))) + Ok(Cursor::new(include_bytes!("assets/tiled_xml.tmx"))) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found")) } diff --git a/assets/world/map-x00-y00-empty.tmx b/assets/world/map-x00-y00-empty.tmx deleted file mode 100644 index c4c14ba..0000000 --- a/assets/world/map-x00-y00-empty.tmx +++ /dev/null @@ -1 +0,0 @@ -Empty Test File \ No newline at end of file diff --git a/assets/world/map-x01-y01-empty.tmx b/assets/world/map-x01-y01-empty.tmx deleted file mode 100644 index c4c14ba..0000000 --- a/assets/world/map-x01-y01-empty.tmx +++ /dev/null @@ -1 +0,0 @@ -Empty Test File \ No newline at end of file diff --git a/assets/world/world_pattern.world b/assets/world/world_pattern.world index 50c604b..5e307ba 100644 --- a/assets/world/world_pattern.world +++ b/assets/world/world_pattern.world @@ -6,6 +6,20 @@ "multiplierY": 480, "offsetX": 240, "offsetY": -240 + }, + { + "regexp": "overworld-x0*(\\d+)-y0*(\\d+).tmx", + "multiplierX": 640, + "multiplierY": 480, + "offsetX": 4192, + "offsetY": 4192 + }, + { + "regexp": "OVERFLOW-x0*(\\d+)-y0*(\\d+).tmx", + "multiplierX": 50000000, + "multiplierY": 50000000, + "offsetX": 4192, + "offsetY": 4192 } ], "type": "world" diff --git a/assets/world/world_pattern_bad.world b/assets/world/world_pattern_bad.world deleted file mode 100644 index c40ed34..0000000 --- a/assets/world/world_pattern_bad.world +++ /dev/null @@ -1,12 +0,0 @@ -{ - "patterns": [ - { - "regexp": "map-x0*(\\\\dddd+)-y0*(\\\\dffd+)-.\\.tmx", - "multiplierX": 12000000, - "multiplierY": 48000000000000000000000000000000000000, - "offsetX": 240, - "offsetY": -240 - } - ], - "type": "world" -} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 08f31bb..434c2d6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -57,8 +57,12 @@ pub enum Error { CsvDecodingError(CsvDecodingError), /// An error occurred when parsing an XML file, such as a TMX or TSX file. XmlDecodingError(xml::reader::Error), + #[cfg(feature = "world")] /// An error occurred when attempting to deserialize a JSON file. JsonDecodingError(serde_json::Error), + #[cfg(feature = "world")] + /// No regex captures were found. + CapturesNotFound, /// The XML stream ended before the document was fully parsed. PrematureEnd(String), /// The path given is invalid because it isn't contained in any folder. @@ -122,7 +126,10 @@ impl fmt::Display for Error { Error::Base64DecodingError(e) => write!(fmt, "{}", e), Error::CsvDecodingError(e) => write!(fmt, "{}", e), Error::XmlDecodingError(e) => write!(fmt, "{}", e), + #[cfg(feature = "world")] Error::JsonDecodingError(e) => write!(fmt, "{}", e), + #[cfg(feature = "world")] + Error::CapturesNotFound => write!(fmt, "No captures found in pattern"), Error::PrematureEnd(e) => write!(fmt, "{}", e), Error::PathIsNotFile => { write!( diff --git a/src/lib.rs b/src/lib.rs index 0a3b38d..6905b50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod template; mod tile; mod tileset; mod util; +#[cfg(feature = "world")] mod world; pub use animation::*; @@ -35,4 +36,5 @@ pub use reader::*; pub use template::*; pub use tile::*; pub use tileset::*; +#[cfg(feature = "world")] pub use world::*; diff --git a/src/loader.rs b/src/loader.rs index 8bb53e7..b9e48be 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -1,9 +1,12 @@ use std::path::Path; use crate::{ - DefaultResourceCache, FilesystemResourceReader, Map, ResourceCache, ResourceReader, Result, Tileset, World + DefaultResourceCache, FilesystemResourceReader, Map, ResourceCache, ResourceReader, Result, Tileset }; +#[cfg(feature = "world")] +use crate::World; + /// A type used for loading [`Map`]s and [`Tileset`]s. /// /// Internally, it holds a [`ResourceCache`] that, as its name implies, caches intermediate loading @@ -181,9 +184,11 @@ impl Loader { crate::parse::xml::parse_tileset(path.as_ref(), &mut self.reader, &mut self.cache) } - /// Parses a file hopefully containing a Tiled world and tries to parse it. + /// Parses a file hopefully containing a Tiled world and tries to parse it. All external files + /// will be loaded relative to the path given. + #[cfg(feature = "world")] pub fn load_world(&mut self, path: impl AsRef) -> Result { - crate::world::parse_world(path.as_ref(), &mut self.reader, &mut self.cache) + crate::world::parse_world(path.as_ref(), &mut self.reader) } /// Returns a reference to the loader's internal [`ResourceCache`]. diff --git a/src/world.rs b/src/world.rs index ea2c60a..22d9d24 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,22 +1,24 @@ use std::{ - fs, io::Read, path::{Path, PathBuf} + io::Read, + path::{Path, PathBuf}, }; use regex::Regex; use serde::Deserialize; -use crate::{Error, Map, ResourceCache, ResourceReader}; +use crate::{Error, ResourceReader}; -/// A World is a collection of maps and their layout in the game world. +/// A World is a list of maps files or regex patterns that define a layout of TMX maps. +/// You can use the loader to further load the maps defined by the world. #[derive(Deserialize, PartialEq, Clone, Debug)] pub struct World { - /// The path first used in a ['ResourceReader'] to load this world. + /// The path first used in a [`ResourceReader`] to load this world. #[serde(skip_deserializing)] pub source: PathBuf, - /// The maps present in this world. + /// The [`WorldMap`]s defined in the world file. pub maps: Option>, /// Optional regex pattern to load maps. - patterns: Option>, + pub patterns: Option>, /// The type of world, which is arbitrary and set by the user. #[serde(rename = "type")] pub world_type: Option, @@ -40,7 +42,7 @@ pub struct WorldMap { /// A WorldPattern defines a regex pattern to automatically determine which maps to load and how to lay them out. #[derive(Deserialize, PartialEq, Clone, Debug)] -struct WorldPattern { +pub struct WorldPattern { /// The regex pattern to match against filenames. The first two capture groups should be the x integer and y integer positions. pub regexp: String, /// The multiplier for the x position. @@ -57,158 +59,92 @@ struct WorldPattern { pub offset_y: i32, } -/// Parse a Tiled World file from a path. -/// If a the Patterns field is present, it will attempt to build the maps list based on the regex patterns. -/// -/// ## Parameters -/// - `world_path`: The path to the world file. -/// -/// ## Example -/// ``` -/// # use tiled::Loader; -/// # -/// # fn main() { -/// # let mut loader = Loader::new(); -/// # let world = loader.load_world("assets/world/world_basic.world").unwrap(); -/// # -/// # for map in world.maps.unwrap() { -/// # println!("Map: {:?}", map); -/// # } -/// # } -/// ``` +impl WorldPattern { + /// Utility function to test a single path against the defined regexp field and returns a parsed WorldMap if it matches. + /// Returns none if the filename does not match the pattern. + pub fn capture_path(&self, path: &Path) -> Result { + let re = Regex::new(&self.regexp).unwrap(); + let captures = re + .captures(path.to_str().unwrap()) + .ok_or(Error::CapturesNotFound)?; + + let x = captures + .get(1) + .ok_or(Error::CapturesNotFound)? + .as_str() + .parse::() + .unwrap(); + let y = captures + .get(2) + .ok_or(Error::CapturesNotFound)? + .as_str() + .parse::() + .unwrap(); + + // Calculate x and y positions based on the multiplier and offset. + let x = x + .checked_mul(self.multiplier_x as i32) + .ok_or(Error::InvalidPropertyValue { + description: "multiplierX causes overflow".to_string(), + })? + .checked_add(self.offset_x) + .ok_or(Error::InvalidPropertyValue { + description: "offsetX causes overflow".to_string(), + })?; + + let y = y + .checked_mul(self.multiplier_y as i32) + .ok_or(Error::InvalidPropertyValue { + description: "multiplierY causes overflow".to_string(), + })? + .checked_add(self.offset_y) + .ok_or(Error::InvalidPropertyValue { + description: "offsetY causes overflow".to_string(), + })?; + + Ok(WorldMap { + filename: path.to_str().unwrap().to_owned(), + x, + y, + width: None, + height: None, + }) + } + + /// Utility function to test a list of paths against the defined regexp field. + /// Returns a parsed list of WorldMaps from any matched filenames. + pub fn capture_paths(&self, paths: Vec) -> Result, Error> { + paths + .iter() + .map(|path| self.capture_path(path.as_path())) + .collect::, _>>() + } +} + pub(crate) fn parse_world( world_path: &Path, reader: &mut impl ResourceReader, - cache: &mut impl ResourceCache, ) -> Result { - let mut path = reader.read_from(&world_path).map_err(|err| Error::ResourceLoadingError { - path: world_path.to_owned(), - err: Box::new(err), - })?; + let mut path = reader + .read_from(&world_path) + .map_err(|err| Error::ResourceLoadingError { + path: world_path.to_owned(), + err: Box::new(err), + })?; let mut world_string = String::new(); - path.read_to_string(&mut world_string).map_err(|err| Error::ResourceLoadingError { - path: world_path.to_owned(), - err: Box::new(err), - })?; + path.read_to_string(&mut world_string) + .map_err(|err| Error::ResourceLoadingError { + path: world_path.to_owned(), + err: Box::new(err), + })?; - let mut world: World = match serde_json::from_str(&world_string) { + let world: World = match serde_json::from_str(&world_string) { Ok(world) => world, Err(err) => { return Err(Error::JsonDecodingError(err)); } }; - if world.patterns.is_some() { - world.maps = match parse_world_pattern(world_path, &world.clone().patterns.unwrap()) { - Ok(maps) => Some(maps), - Err(err) => return Err(err), - }; - } - Ok(world) } - -/// If "patterns" key is present, it will attempt to build the maps list based on the regex patterns. -fn parse_world_pattern(path: &Path, patterns: &Vec) -> Result, Error> { - let mut maps = Vec::new(); - - let parent_dir = path.parent().ok_or(Error::ResourceLoadingError { - path: path.to_owned(), - err: Box::new(std::io::Error::from(std::io::ErrorKind::NotFound)), - })?; - - // There's no documentation on why "patterns" is a JSON array, so we'll just blast them into same maps list. - for pattern in patterns { - let files = fs::read_dir(parent_dir).map_err(|err| Error::ResourceLoadingError { - path: parent_dir.to_owned(), - err: Box::new(err), - })?; - - let re = Regex::new(&pattern.regexp).unwrap(); - let files = files - .filter_map(|entry| entry.ok()) - .filter(|entry| re.is_match(entry.path().file_name().unwrap().to_str().unwrap())) - .map(|entry| { - let filename = entry - .path() - .file_name() - .ok_or_else(|| Error::ResourceLoadingError { - path: path.to_owned(), - err: "Failed to get file name".into(), - })? - .to_str() - .ok_or_else(|| Error::ResourceLoadingError { - path: path.to_owned(), - err: "Failed to convert file name to string".into(), - })? - .to_owned(); - - let captures = re.captures(&filename).unwrap(); - - let x = captures - .get(1) - .ok_or_else(|| Error::ResourceLoadingError { - path: path.to_owned(), - err: format!("Failed to parse x pattern from file {}", filename).into(), - })? - .as_str() - .parse::() - .map_err(|e| Error::ResourceLoadingError { - path: path.to_owned(), - err: Box::new(e), - })?; - - let x = match x - .checked_mul(pattern.multiplier_x as i32) - .and_then(|x| x.checked_add(pattern.offset_x)) - { - Some(x) => x, - None => { - return Err(Error::ResourceLoadingError { - path: path.to_owned(), - err: "Arithmetic Overflow on multiplierX and offsetX".into(), - }) - } - }; - let y = captures - .get(2) - .ok_or_else(|| Error::ResourceLoadingError { - path: path.to_owned(), - err: format!("Failed to parse y pattern from file {}", filename).into(), - })? - .as_str() - .parse::() - .map_err(|e| Error::ResourceLoadingError { - path: path.to_owned(), - err: Box::new(e), - })?; - let y = match y - .checked_mul(pattern.multiplier_y as i32) - .and_then(|y| y.checked_add(pattern.offset_y)) - { - Some(y) => y, - None => { - return Err(Error::ResourceLoadingError { - path: path.to_owned(), - err: "Arithmetic Overflow on multiplierY and offsetY".into(), - }) - } - }; - Ok(WorldMap { - filename, - x, - y, - width: Some(pattern.multiplier_x), - height: Some(pattern.multiplier_y), - }) - }) - .collect::>(); - - for file in files { - maps.push(file?); - } - } - - Ok(maps) -} diff --git a/tests/lib.rs b/tests/lib.rs index e983c5a..a3539b8 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -68,6 +68,7 @@ fn test_external_tileset() { compare_everything_but_sources(&r, &e); } +#[cfg(feature = "world")] #[test] fn test_loading_world() { let mut loader = Loader::new(); @@ -85,27 +86,32 @@ fn test_loading_world() { assert_eq!(maps.len(), 2); } +#[cfg(feature = "world")] #[test] fn test_loading_world_pattern() { let mut loader = Loader::new(); let e = loader.load_world("assets/world/world_pattern.world").unwrap(); - let maps = e.maps.unwrap(); + assert_eq!(e.maps.is_none(), true); - assert_eq!(e.world_type.unwrap(), "world"); - assert_eq!(maps.len(), 2); -} + let patterns = e.patterns.unwrap(); -#[test] -fn test_bad_loading_world_pattern() { - let mut loader = Loader::new(); + assert_eq!(patterns.len(), 3); + + let map1 = patterns[0].capture_path(&PathBuf::from("assets/world/map-x04-y04-plains.tmx")).unwrap(); + assert_eq!(map1.filename, "assets/world/map-x04-y04-plains.tmx"); + assert_eq!(map1.x, 2800); + assert_eq!(map1.y, 1680); + + let map2 = patterns[1].capture_path(&PathBuf::from("overworld-x02-y02.tmx")).unwrap(); + assert_eq!(map2.filename, "overworld-x02-y02.tmx"); - let e = loader.load_world("assets/world/world_bad_pattern.world"); - assert!(e.is_err()); + let unmatched_map = patterns[0].capture_path(&PathBuf::from("bad_map.tmx")); + assert_eq!(unmatched_map.is_err(), true); - let e = loader.load_world("assets/world/world_pattern_bad.world"); - assert!(e.is_err()); + let overlow_map = patterns[2].capture_path(&PathBuf::from("map-x999-y999.tmx")); + assert_eq!(overlow_map.is_err(), true); } #[test]