Skip to content

Commit

Permalink
World File Parsing (#320)
Browse files Browse the repository at this point in the history
* add world file parsing

* add world file parsing

* clarity and cleanup

* read world from reader

* add option to preload maps in world

* fix documentation, revert auto load maps

* remove tmx map variable from worldmap

* Remove automatic dir pattern testing

* formatting and better docs

* formatting and better docs

* Moved pattern utils to World impl

* Missed dep for world feature and cargo build step

* match_path and match_paths

* add match_path to WorldPattern

* reduce utf-8 checks on path iteration

* match_path_impl and readme update

* empty vecs instead of option

* empty vecs instead of option

* Oddities with readme

* fix load_world docs

* fix source not populating
  • Loading branch information
JtotheThree authored Dec 29, 2024
1 parent 8f21563 commit c434e1b
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 6 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ jobs:
run: sudo apt-get install -y libsfml-dev libcsfml-dev libasound2-dev libudev-dev

- name: Build library
run: cargo build --lib --verbose
run: cargo build --lib --all-features --verbose

- name: Run tests
run: cargo test --verbose
run: cargo test --all-features --verbose

rustfmt:
runs-on: ubuntu-24.04
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -14,6 +14,7 @@ include = ["src/**/*.rs", "README.md", "LICENSE", "CHANGELOG.md"]
[features]
default = ["zstd"]
wasm = ["zstd/wasm"]
world = ["regex", "serde", "serde_json", "serde_regex"]

[lib]
name = "tiled"
Expand All @@ -36,6 +37,10 @@ base64 = "0.22.1"
xml-rs = "0.8.4"
zstd = { version = "0.13.1", optional = true, default-features = false }
flate2 = "1.0.28"
serde = { version = "1.0.216", optional = true }
serde_json = { version = "1.0.133", optional = true }
serde_regex = { version = "1.1.0", optional = true }
regex = { version = "1.11.1", optional = true }

[dev-dependencies.sfml]
version = "0.21.0"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rs-tiled
```toml
tiled = "0.13.0"
tiled = "0.14.0"
```

[![Rust](https://github.com/mapeditor/rs-tiled/actions/workflows/rust.yml/badge.svg)](https://github.com/mapeditor/rs-tiled/actions/workflows/rust.yml)
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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<Self::Resource, Self::Error> {
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"))
}
Expand Down
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"
}
26 changes: 26 additions & 0 deletions assets/world/world_pattern.world
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"patterns": [
{
"regexp": "map-x0*(\\d+)-y0*(\\d+)-.*\\.tmx",
"multiplierX": 640,
"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"
}
18 changes: 18 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ 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")]
/// Filename does not match any pattern in the world file.
NoMatchFound {
/// The filename that was not matched.
path: String,
},
/// A parameter is out of range or results in arithmetic underflow or overflow.
RangeError(String),
/// 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 +131,13 @@ 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::NoMatchFound { path } => {
write!(fmt, "No match found for path: '{}'", path)
}
Error::RangeError(e) => write!(fmt, "Range error: {}", e),
Error::PrematureEnd(e) => write!(fmt, "{}", e),
Error::PathIsNotFile => {
write!(
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ mod template;
mod tile;
mod tileset;
mod util;
#[cfg(feature = "world")]
mod world;

pub use animation::*;
pub use cache::*;
Expand All @@ -34,3 +36,5 @@ pub use reader::*;
pub use template::*;
pub use tile::*;
pub use tileset::*;
#[cfg(feature = "world")]
pub use world::*;
15 changes: 15 additions & 0 deletions src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use crate::{
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
Expand Down Expand Up @@ -182,6 +185,18 @@ impl<Cache: ResourceCache, Reader: ResourceReader> Loader<Cache, Reader> {
crate::parse::xml::parse_tileset(path.as_ref(), &mut self.reader, &mut self.cache)
}

#[cfg(feature = "world")]
/// Parses a file hopefully containing a Tiled world.
///
/// The returned [`World`] provides the deserialized data from the world file. It does not load
/// any maps or tilesets.
/// ## Note
/// The ['WorldPattern`] struct provides [`WorldPattern::match_path`] and [`WorldPattern::match_paths`]
/// as utility functions to test paths and return parsed [`WorldMap`]s.
pub fn load_world(&mut self, path: impl AsRef<Path>) -> Result<World> {
crate::world::parse_world(path.as_ref(), &mut self.reader)
}

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

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

use crate::{Error, ResourceReader};

/// 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.
#[serde(skip_deserializing)]
pub source: PathBuf,
/// The [`WorldMap`]s defined by the world file.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub maps: Vec<WorldMap>,
/// Optional regex pattern to load maps.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub patterns: Vec<WorldPattern>,
}

impl World {
/// Utility function to test a single path against all defined patterns.
/// Returns a parsed [`WorldMap`] on the first matched pattern or an error if no patterns match.
pub fn match_path(&self, path: impl AsRef<Path>) -> Result<WorldMap, Error> {
let path_str = path.as_ref().to_str().expect("obtaining valid UTF-8 path");

for pattern in self.patterns.iter() {
match pattern.match_path_impl(path_str) {
Ok(world_map) => return Ok(world_map),
// We ignore match errors here as the path may be matched by another pattern.
Err(Error::NoMatchFound { .. }) => continue,
Err(err) => return Err(err),
}
}

Err(Error::NoMatchFound {
path: path_str.to_owned(),
})
}

/// Utility function to test a vec of filenames against all defined patterns.
/// Returns a vec of results with the parsed [`WorldMap`]s if it matches the pattern.
pub fn match_paths<P: AsRef<Path>>(&self, paths: &[P]) -> Vec<Result<WorldMap, Error>> {
paths
.into_iter()
.map(|path| self.match_path(path))
.collect()
}
}

/// 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<i32>,
/// The optional height of the map.
pub height: Option<i32>,
}

/// A WorldPattern defines a regex pattern to automatically determine which maps to load and how to lay them out.
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WorldPattern {
/// The regex pattern to match against filenames.
/// The first two capture groups should be the x integer and y integer positions.
#[serde(with = "serde_regex")]
pub regexp: Regex,
/// The multiplier for the x position.
pub multiplier_x: i32,
/// The multiplier for the y position.
pub multiplier_y: i32,
/// The offset for the x position.
pub offset_x: i32,
/// The offset for the y position.
pub offset_y: i32,
}

impl PartialEq for WorldPattern {
fn eq(&self, other: &Self) -> bool {
self.multiplier_x == other.multiplier_x
&& self.multiplier_y == other.multiplier_y
&& self.offset_x == other.offset_x
&& self.offset_y == other.offset_y
&& self.regexp.to_string() == other.regexp.to_string()
}
}

impl WorldPattern {
/// Utility function to test a path against this pattern.
/// Returns a parsed [`WorldMap`] on the first matched pattern or an error if no patterns match.
pub fn match_path(&self, path: impl AsRef<Path>) -> Result<WorldMap, Error> {
let path_str = path.as_ref().to_str().expect("obtaining valid UTF-8 path");

self.match_path_impl(path_str)
}

pub(crate) fn match_path_impl(&self, path: &str) -> Result<WorldMap, Error> {
let captures = match self.regexp.captures(path) {
Some(captures) => captures,
None => {
return Err(Error::NoMatchFound {
path: path.to_owned(),
})
}
};

let x = match captures.get(1) {
Some(x) => x.as_str().parse::<i32>().unwrap(),
None => {
return Err(Error::NoMatchFound {
path: path.to_owned(),
})
}
};

let y = match captures.get(2) {
Some(y) => y.as_str().parse::<i32>().unwrap(),
None => {
return Err(Error::NoMatchFound {
path: path.to_owned(),
})
}
};

// Calculate x and y positions based on the multiplier and offset.
let x = x
.checked_mul(self.multiplier_x)
.ok_or(Error::RangeError(
"Capture x * multiplierX causes overflow".to_string(),
))?
.checked_add(self.offset_x)
.ok_or(Error::RangeError(
"Capture x * multiplierX + offsetX causes overflow".to_string(),
))?;

let y = y
.checked_mul(self.multiplier_y)
.ok_or(Error::RangeError(
"Capture y * multiplierY causes overflow".to_string(),
))?
.checked_add(self.offset_y)
.ok_or(Error::RangeError(
"Capture y * multiplierY + offsetY causes overflow".to_string(),
))?;

Ok(WorldMap {
filename: path.to_owned(),
x,
y,
width: None,
height: None,
})
}
}

pub(crate) fn parse_world(
world_path: &Path,
reader: &mut impl ResourceReader,
) -> 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 =
serde_json::from_str(&world_string).map_err(|err| Error::JsonDecodingError(err))?;

world.source = world_path.to_owned();

Ok(world)
}
Loading

0 comments on commit c434e1b

Please sign in to comment.