Skip to content

Commit c434e1b

Browse files
authored
World File Parsing (#320)
* 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
1 parent 8f21563 commit c434e1b

File tree

11 files changed

+347
-6
lines changed

11 files changed

+347
-6
lines changed

.github/workflows/rust.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ jobs:
2727
run: sudo apt-get install -y libsfml-dev libcsfml-dev libasound2-dev libudev-dev
2828

2929
- name: Build library
30-
run: cargo build --lib --verbose
30+
run: cargo build --lib --all-features --verbose
3131

3232
- name: Run tests
33-
run: cargo test --verbose
33+
run: cargo test --all-features --verbose
3434

3535
rustfmt:
3636
runs-on: ubuntu-24.04

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.14.0]
9+
### Added
10+
- Added a new crate feature `world` to enable support for parsing `World` files.
11+
812
## [0.13.0]
913
### Added
1014
- Added a `source` member to `Tileset`, `Map` and `Template`, which stores the resource path they have been loaded from. (#303)

Cargo.toml

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tiled"
3-
version = "0.13.0"
3+
version = "0.14.0"
44
description = "A rust crate for loading maps created by the Tiled editor"
55
categories = ["game-development"]
66
keywords = ["gamedev", "tiled", "tmx", "map"]
@@ -14,6 +14,7 @@ include = ["src/**/*.rs", "README.md", "LICENSE", "CHANGELOG.md"]
1414
[features]
1515
default = ["zstd"]
1616
wasm = ["zstd/wasm"]
17+
world = ["regex", "serde", "serde_json", "serde_regex"]
1718

1819
[lib]
1920
name = "tiled"
@@ -36,6 +37,10 @@ base64 = "0.22.1"
3637
xml-rs = "0.8.4"
3738
zstd = { version = "0.13.1", optional = true, default-features = false }
3839
flate2 = "1.0.28"
40+
serde = { version = "1.0.216", optional = true }
41+
serde_json = { version = "1.0.133", optional = true }
42+
serde_regex = { version = "1.1.0", optional = true }
43+
regex = { version = "1.11.1", optional = true }
3944

4045
[dev-dependencies.sfml]
4146
version = "0.21.0"

README.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# rs-tiled
22
```toml
3-
tiled = "0.13.0"
3+
tiled = "0.14.0"
44
```
55

66
[![Rust](https://github.com/mapeditor/rs-tiled/actions/workflows/rust.yml/badge.svg)](https://github.com/mapeditor/rs-tiled/actions/workflows/rust.yml)
@@ -52,7 +52,7 @@ let mut loader = Loader::with_reader(
5252
// Doing this embedding is useful for places where the OS filesystem is not available (e.g. WASM applications).
5353
|path: &std::path::Path| -> std::io::Result<_> {
5454
if path == std::path::Path::new("/my-map.tmx") {
55-
Ok(std::io::Cursor::new(include_bytes!("../assets/tiled_csv.tmx")))
55+
Ok(std::io::Cursor::new(include_bytes!("assets/tiled_csv.tmx")))
5656
} else {
5757
Err(std::io::ErrorKind::NotFound.into())
5858
}
@@ -86,7 +86,7 @@ impl tiled::ResourceReader for MyReader {
8686
// really dumb example implementation that just keeps resources in memory
8787
fn read_from(&mut self, path: &std::path::Path) -> std::result::Result<Self::Resource, Self::Error> {
8888
if path == std::path::Path::new("my_map.tmx") {
89-
Ok(Cursor::new(include_bytes!("../assets/tiled_xml.tmx")))
89+
Ok(Cursor::new(include_bytes!("assets/tiled_xml.tmx")))
9090
} else {
9191
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))
9292
}

assets/world/world_basic.world

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"maps": [
3+
{
4+
"fileName": "map01.tmx",
5+
"height": 640,
6+
"width": 960,
7+
"x": 0,
8+
"y": 0
9+
},
10+
{
11+
"fileName": "map02.tmx",
12+
"height": 640,
13+
"width": 960,
14+
"x": 960,
15+
"y": 0
16+
}
17+
],
18+
"onlyShowAdjacentMaps": false,
19+
"type": "world"
20+
}

assets/world/world_pattern.world

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"patterns": [
3+
{
4+
"regexp": "map-x0*(\\d+)-y0*(\\d+)-.*\\.tmx",
5+
"multiplierX": 640,
6+
"multiplierY": 480,
7+
"offsetX": 240,
8+
"offsetY": -240
9+
},
10+
{
11+
"regexp": "overworld-x0*(\\d+)-y0*(\\d+).tmx",
12+
"multiplierX": 640,
13+
"multiplierY": 480,
14+
"offsetX": 4192,
15+
"offsetY": 4192
16+
},
17+
{
18+
"regexp": "OVERFLOW-x0*(\\d+)-y0*(\\d+).tmx",
19+
"multiplierX": 50000000,
20+
"multiplierY": 50000000,
21+
"offsetX": 4192,
22+
"offsetY": 4192
23+
}
24+
],
25+
"type": "world"
26+
}

src/error.rs

+18
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ pub enum Error {
5757
CsvDecodingError(CsvDecodingError),
5858
/// An error occurred when parsing an XML file, such as a TMX or TSX file.
5959
XmlDecodingError(xml::reader::Error),
60+
#[cfg(feature = "world")]
61+
/// An error occurred when attempting to deserialize a JSON file.
62+
JsonDecodingError(serde_json::Error),
63+
#[cfg(feature = "world")]
64+
/// Filename does not match any pattern in the world file.
65+
NoMatchFound {
66+
/// The filename that was not matched.
67+
path: String,
68+
},
69+
/// A parameter is out of range or results in arithmetic underflow or overflow.
70+
RangeError(String),
6071
/// The XML stream ended before the document was fully parsed.
6172
PrematureEnd(String),
6273
/// The path given is invalid because it isn't contained in any folder.
@@ -120,6 +131,13 @@ impl fmt::Display for Error {
120131
Error::Base64DecodingError(e) => write!(fmt, "{}", e),
121132
Error::CsvDecodingError(e) => write!(fmt, "{}", e),
122133
Error::XmlDecodingError(e) => write!(fmt, "{}", e),
134+
#[cfg(feature = "world")]
135+
Error::JsonDecodingError(e) => write!(fmt, "{}", e),
136+
#[cfg(feature = "world")]
137+
Error::NoMatchFound { path } => {
138+
write!(fmt, "No match found for path: '{}'", path)
139+
}
140+
Error::RangeError(e) => write!(fmt, "Range error: {}", e),
123141
Error::PrematureEnd(e) => write!(fmt, "{}", e),
124142
Error::PathIsNotFile => {
125143
write!(

src/lib.rs

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ mod template;
2020
mod tile;
2121
mod tileset;
2222
mod util;
23+
#[cfg(feature = "world")]
24+
mod world;
2325

2426
pub use animation::*;
2527
pub use cache::*;
@@ -34,3 +36,5 @@ pub use reader::*;
3436
pub use template::*;
3537
pub use tile::*;
3638
pub use tileset::*;
39+
#[cfg(feature = "world")]
40+
pub use world::*;

src/loader.rs

+15
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use crate::{
55
Tileset,
66
};
77

8+
#[cfg(feature = "world")]
9+
use crate::World;
10+
811
/// A type used for loading [`Map`]s and [`Tileset`]s.
912
///
1013
/// Internally, it holds a [`ResourceCache`] that, as its name implies, caches intermediate loading
@@ -182,6 +185,18 @@ impl<Cache: ResourceCache, Reader: ResourceReader> Loader<Cache, Reader> {
182185
crate::parse::xml::parse_tileset(path.as_ref(), &mut self.reader, &mut self.cache)
183186
}
184187

188+
#[cfg(feature = "world")]
189+
/// Parses a file hopefully containing a Tiled world.
190+
///
191+
/// The returned [`World`] provides the deserialized data from the world file. It does not load
192+
/// any maps or tilesets.
193+
/// ## Note
194+
/// The ['WorldPattern`] struct provides [`WorldPattern::match_path`] and [`WorldPattern::match_paths`]
195+
/// as utility functions to test paths and return parsed [`WorldMap`]s.
196+
pub fn load_world(&mut self, path: impl AsRef<Path>) -> Result<World> {
197+
crate::world::parse_world(path.as_ref(), &mut self.reader)
198+
}
199+
185200
/// Returns a reference to the loader's internal [`ResourceCache`].
186201
pub fn cache(&self) -> &Cache {
187202
&self.cache

src/world.rs

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
use std::{
2+
io::Read,
3+
path::{Path, PathBuf},
4+
};
5+
6+
use regex::Regex;
7+
use serde::Deserialize;
8+
9+
use crate::{Error, ResourceReader};
10+
11+
/// A World is a list of maps files or regex patterns that define a layout of TMX maps.
12+
/// You can use the loader to further load the maps defined by the world.
13+
#[derive(Deserialize, PartialEq, Clone, Debug)]
14+
pub struct World {
15+
/// The path first used in a [`ResourceReader`] to load this world.
16+
#[serde(skip_deserializing)]
17+
pub source: PathBuf,
18+
/// The [`WorldMap`]s defined by the world file.
19+
#[serde(skip_serializing_if = "Vec::is_empty", default)]
20+
pub maps: Vec<WorldMap>,
21+
/// Optional regex pattern to load maps.
22+
#[serde(skip_serializing_if = "Vec::is_empty", default)]
23+
pub patterns: Vec<WorldPattern>,
24+
}
25+
26+
impl World {
27+
/// Utility function to test a single path against all defined patterns.
28+
/// Returns a parsed [`WorldMap`] on the first matched pattern or an error if no patterns match.
29+
pub fn match_path(&self, path: impl AsRef<Path>) -> Result<WorldMap, Error> {
30+
let path_str = path.as_ref().to_str().expect("obtaining valid UTF-8 path");
31+
32+
for pattern in self.patterns.iter() {
33+
match pattern.match_path_impl(path_str) {
34+
Ok(world_map) => return Ok(world_map),
35+
// We ignore match errors here as the path may be matched by another pattern.
36+
Err(Error::NoMatchFound { .. }) => continue,
37+
Err(err) => return Err(err),
38+
}
39+
}
40+
41+
Err(Error::NoMatchFound {
42+
path: path_str.to_owned(),
43+
})
44+
}
45+
46+
/// Utility function to test a vec of filenames against all defined patterns.
47+
/// Returns a vec of results with the parsed [`WorldMap`]s if it matches the pattern.
48+
pub fn match_paths<P: AsRef<Path>>(&self, paths: &[P]) -> Vec<Result<WorldMap, Error>> {
49+
paths
50+
.into_iter()
51+
.map(|path| self.match_path(path))
52+
.collect()
53+
}
54+
}
55+
56+
/// A WorldMap provides the information for a map in the world and its layout.
57+
#[derive(Deserialize, PartialEq, Clone, Debug)]
58+
pub struct WorldMap {
59+
/// The filename of the tmx map.
60+
#[serde(rename = "fileName")]
61+
pub filename: String,
62+
/// The x position of the map.
63+
pub x: i32,
64+
/// The y position of the map.
65+
pub y: i32,
66+
/// The optional width of the map.
67+
pub width: Option<i32>,
68+
/// The optional height of the map.
69+
pub height: Option<i32>,
70+
}
71+
72+
/// A WorldPattern defines a regex pattern to automatically determine which maps to load and how to lay them out.
73+
#[derive(Deserialize, Clone, Debug)]
74+
#[serde(rename_all = "camelCase")]
75+
pub struct WorldPattern {
76+
/// The regex pattern to match against filenames.
77+
/// The first two capture groups should be the x integer and y integer positions.
78+
#[serde(with = "serde_regex")]
79+
pub regexp: Regex,
80+
/// The multiplier for the x position.
81+
pub multiplier_x: i32,
82+
/// The multiplier for the y position.
83+
pub multiplier_y: i32,
84+
/// The offset for the x position.
85+
pub offset_x: i32,
86+
/// The offset for the y position.
87+
pub offset_y: i32,
88+
}
89+
90+
impl PartialEq for WorldPattern {
91+
fn eq(&self, other: &Self) -> bool {
92+
self.multiplier_x == other.multiplier_x
93+
&& self.multiplier_y == other.multiplier_y
94+
&& self.offset_x == other.offset_x
95+
&& self.offset_y == other.offset_y
96+
&& self.regexp.to_string() == other.regexp.to_string()
97+
}
98+
}
99+
100+
impl WorldPattern {
101+
/// Utility function to test a path against this pattern.
102+
/// Returns a parsed [`WorldMap`] on the first matched pattern or an error if no patterns match.
103+
pub fn match_path(&self, path: impl AsRef<Path>) -> Result<WorldMap, Error> {
104+
let path_str = path.as_ref().to_str().expect("obtaining valid UTF-8 path");
105+
106+
self.match_path_impl(path_str)
107+
}
108+
109+
pub(crate) fn match_path_impl(&self, path: &str) -> Result<WorldMap, Error> {
110+
let captures = match self.regexp.captures(path) {
111+
Some(captures) => captures,
112+
None => {
113+
return Err(Error::NoMatchFound {
114+
path: path.to_owned(),
115+
})
116+
}
117+
};
118+
119+
let x = match captures.get(1) {
120+
Some(x) => x.as_str().parse::<i32>().unwrap(),
121+
None => {
122+
return Err(Error::NoMatchFound {
123+
path: path.to_owned(),
124+
})
125+
}
126+
};
127+
128+
let y = match captures.get(2) {
129+
Some(y) => y.as_str().parse::<i32>().unwrap(),
130+
None => {
131+
return Err(Error::NoMatchFound {
132+
path: path.to_owned(),
133+
})
134+
}
135+
};
136+
137+
// Calculate x and y positions based on the multiplier and offset.
138+
let x = x
139+
.checked_mul(self.multiplier_x)
140+
.ok_or(Error::RangeError(
141+
"Capture x * multiplierX causes overflow".to_string(),
142+
))?
143+
.checked_add(self.offset_x)
144+
.ok_or(Error::RangeError(
145+
"Capture x * multiplierX + offsetX causes overflow".to_string(),
146+
))?;
147+
148+
let y = y
149+
.checked_mul(self.multiplier_y)
150+
.ok_or(Error::RangeError(
151+
"Capture y * multiplierY causes overflow".to_string(),
152+
))?
153+
.checked_add(self.offset_y)
154+
.ok_or(Error::RangeError(
155+
"Capture y * multiplierY + offsetY causes overflow".to_string(),
156+
))?;
157+
158+
Ok(WorldMap {
159+
filename: path.to_owned(),
160+
x,
161+
y,
162+
width: None,
163+
height: None,
164+
})
165+
}
166+
}
167+
168+
pub(crate) fn parse_world(
169+
world_path: &Path,
170+
reader: &mut impl ResourceReader,
171+
) -> Result<World, Error> {
172+
let mut path = reader
173+
.read_from(&world_path)
174+
.map_err(|err| Error::ResourceLoadingError {
175+
path: world_path.to_owned(),
176+
err: Box::new(err),
177+
})?;
178+
179+
let mut world_string = String::new();
180+
path.read_to_string(&mut world_string)
181+
.map_err(|err| Error::ResourceLoadingError {
182+
path: world_path.to_owned(),
183+
err: Box::new(err),
184+
})?;
185+
186+
let mut world: World =
187+
serde_json::from_str(&world_string).map_err(|err| Error::JsonDecodingError(err))?;
188+
189+
world.source = world_path.to_owned();
190+
191+
Ok(world)
192+
}

0 commit comments

Comments
 (0)