Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

World File Parsing #320

Merged
merged 21 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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"

[dev-dependencies.sfml]
version = "0.21.0"
Expand Down
1 change: 1 addition & 0 deletions assets/world/map-x00-y00-empty.tmx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Empty Test File
1 change: 1 addition & 0 deletions assets/world/map-x01-y01-empty.tmx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Empty Test File
20 changes: 20 additions & 0 deletions assets/world/world_basic.world
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"maps": [
{
"fileName": "map01.tmx",
"height": 640,
"width": 960,
"x": 0,
"y": 0
},
{
"fileName": "map02.tmx",
"height": 640,
"width": 960,
"x": 960,
"y": 0
}
],
"onlyShowAdjacentMaps": false,
"type": "world"
}
12 changes: 12 additions & 0 deletions assets/world/world_pattern.world
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"patterns": [
{
"regexp": "map-x0*(\\d+)-y0*(\\d+)-.*\\.tmx",
"multiplierX": 640,
"multiplierY": 480,
"offsetX": 240,
"offsetY": -240
}
],
"type": "world"
}
12 changes: 12 additions & 0 deletions assets/world/world_pattern_bad.world
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"patterns": [
{
"regexp": "map-x0*(\\\\dddd+)-y0*(\\\\dffd+)-.\\.tmx",
"multiplierX": 12000000,
"multiplierY": 48000000000000000000000000000000000000,
"offsetX": 240,
"offsetY": -240
}
],
"type": "world"
}
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub enum Error {
CsvDecodingError(CsvDecodingError),
/// An error occurred when parsing an XML file, such as a TMX or TSX file.
XmlDecodingError(xml::reader::Error),
/// An error occurred when attempting to deserialize a JSON file.
JsonDecodingError(serde_json::Error),
/// 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.
Expand Down Expand Up @@ -120,6 +122,7 @@ impl fmt::Display for Error {
Error::Base64DecodingError(e) => write!(fmt, "{}", e),
Error::CsvDecodingError(e) => write!(fmt, "{}", e),
Error::XmlDecodingError(e) => write!(fmt, "{}", e),
Error::JsonDecodingError(e) => write!(fmt, "{}", e),
Error::PrematureEnd(e) => write!(fmt, "{}", e),
Error::PathIsNotFile => {
write!(
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod template;
mod tile;
mod tileset;
mod util;
mod world;

pub use animation::*;
pub use cache::*;
Expand All @@ -34,3 +35,4 @@ pub use reader::*;
pub use template::*;
pub use tile::*;
pub use tileset::*;
pub use world::*;
8 changes: 6 additions & 2 deletions src/loader.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use std::path::Path;

use crate::{
DefaultResourceCache, FilesystemResourceReader, Map, ResourceCache, ResourceReader, Result,
Tileset,
DefaultResourceCache, FilesystemResourceReader, Map, ResourceCache, ResourceReader, Result, Tileset, World
};

/// A type used for loading [`Map`]s and [`Tileset`]s.
Expand Down Expand Up @@ -182,6 +181,11 @@ impl<Cache: ResourceCache, Reader: ResourceReader> Loader<Cache, Reader> {
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.
pub fn load_world(&mut self, path: impl AsRef<Path>) -> Result<World> {
crate::world::parse_world(path.as_ref(), &mut self.reader, &mut self.cache)
}

/// Returns a reference to the loader's internal [`ResourceCache`].
pub fn cache(&self) -> &Cache {
&self.cache
Expand Down
214 changes: 214 additions & 0 deletions src/world.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
use std::{
fs, io::Read, path::{Path, PathBuf}
};

use regex::Regex;
use serde::Deserialize;

use crate::{Error, Map, ResourceCache, ResourceReader};

/// A World is a collection of maps and their layout in the game world.
#[derive(Deserialize, PartialEq, Clone, Debug)]
pub struct World {
/// The path first used in a ['ResourceReader'] to load this world.
#[serde(skip_deserializing)]
pub source: PathBuf,
/// The maps present in this world.
pub maps: Option<Vec<WorldMap>>,
/// Optional regex pattern to load maps.
patterns: Option<Vec<WorldPattern>>,
/// The type of world, which is arbitrary and set by the user.
#[serde(rename = "type")]
pub world_type: Option<String>,
}

/// A WorldMap provides the information for a map in the world and its layout.
#[derive(Deserialize, PartialEq, Clone, Debug)]
pub struct WorldMap {
/// The filename of the tmx map.
#[serde(rename = "fileName")]
pub filename: String,
/// The x position of the map.
pub x: i32,
/// The y position of the map.
pub y: i32,
/// The optional width of the map.
pub width: Option<u32>,
/// The optional height of the map.
pub height: Option<u32>,
}

/// 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 {
/// 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.
#[serde(rename = "multiplierX")]
pub multiplier_x: u32,
/// The multiplier for the y position.
#[serde(rename = "multiplierY")]
pub multiplier_y: u32,
/// The offset for the x position.
#[serde(rename = "offsetX")]
pub offset_x: i32,
/// The offset for the y position.
#[serde(rename = "offsetY")]
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);
/// # }
/// # }
/// ```
pub(crate) fn parse_world(
world_path: &Path,
reader: &mut impl ResourceReader,
cache: &mut impl ResourceCache,
) -> Result<World, Error> {
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),
})?;

let mut 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<WorldPattern>) -> Result<Vec<WorldMap>, 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::<i32>()
.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::<i32>()
.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::<Vec<_>>();

for file in files {
maps.push(file?);
}
}

Ok(maps)
}
40 changes: 40 additions & 0 deletions tests/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ fn test_external_tileset() {
compare_everything_but_sources(&r, &e);
}

#[test]
fn test_loading_world() {
let mut loader = Loader::new();

let e = loader.load_world("assets/world/world_basic.world").unwrap();

let maps = e.maps.unwrap();

assert_eq!(e.world_type.unwrap(), "world");
assert_eq!(maps[0].filename, "map01.tmx");
assert_eq!(maps[1].x, 960);
assert_eq!(maps[1].y, 0);
assert_eq!(maps[1].width, Some(960));
assert_eq!(maps[1].height, Some(640));
assert_eq!(maps.len(), 2);
}

#[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.world_type.unwrap(), "world");
assert_eq!(maps.len(), 2);
}

#[test]
fn test_bad_loading_world_pattern() {
let mut loader = Loader::new();

let e = loader.load_world("assets/world/world_bad_pattern.world");
assert!(e.is_err());

let e = loader.load_world("assets/world/world_pattern_bad.world");
assert!(e.is_err());
}

#[test]
fn test_cache() {
let mut loader = Loader::new();
Expand Down
Loading