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 10 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
2 changes: 1 addition & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
6 changes: 5 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 = ["serde", "serde_json", "regex"]

[lib]
name = "tiled"
Expand All @@ -36,6 +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 = { 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"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
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"
}
10 changes: 10 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +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.
Expand Down Expand Up @@ -120,6 +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!(
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::capture_path`] and [`WorldPattern::capture_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
150 changes: 150 additions & 0 deletions src/world.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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 in the world file.
pub maps: Option<Vec<WorldMap>>,
/// Optional regex pattern to load maps.
pub 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)]
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.
#[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,
}

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<WorldMap, Error> {
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::<i32>()
.unwrap();
let y = captures
.get(2)
.ok_or(Error::CapturesNotFound)?
.as_str()
.parse::<i32>()
.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<PathBuf>) -> Result<Vec<WorldMap>, Error> {
paths
.iter()
.map(|path| self.capture_path(path.as_path()))
.collect::<Result<Vec<_>, _>>()
}
}

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 world: World = match serde_json::from_str(&world_string) {
Ok(world) => world,
Err(err) => {
return Err(Error::JsonDecodingError(err));
}
};

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

#[cfg(feature = "world")]
#[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);
}

#[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();

assert_eq!(e.maps.is_none(), true);

let patterns = e.patterns.unwrap();

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 unmatched_map = patterns[0].capture_path(&PathBuf::from("bad_map.tmx"));
assert_eq!(unmatched_map.is_err(), true);

let overlow_map = patterns[2].capture_path(&PathBuf::from("map-x999-y999.tmx"));
assert_eq!(overlow_map.is_err(), true);
}

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