diff --git a/.editorconfig b/.editorconfig
index 71746d2..63da728 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -235,6 +235,7 @@ dotnet_diagnostic.IDE0004.severity = silent
dotnet_diagnostic.IDE0005.severity = error
dotnet_diagnostic.IDE0008.severity = silent
dotnet_diagnostic.IDE0055.severity = silent
+dotnet_diagnostic.IDE0058.severity = silent
dotnet_diagnostic.IDE0160.severity = none
dotnet_diagnostic.CA1707.severity = silent
dotnet_diagnostic.CA1852.severity = none
diff --git a/src/DotTiled.Tests/DotTiled.Tests.csproj b/src/DotTiled.Tests/DotTiled.Tests.csproj
index eff4ec8..45d8f5a 100644
--- a/src/DotTiled.Tests/DotTiled.Tests.csproj
+++ b/src/DotTiled.Tests/DotTiled.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/DotTiled.Tests/Serialization/DefaultResourceCacheTests.cs b/src/DotTiled.Tests/Serialization/DefaultResourceCacheTests.cs
new file mode 100644
index 0000000..6108833
--- /dev/null
+++ b/src/DotTiled.Tests/Serialization/DefaultResourceCacheTests.cs
@@ -0,0 +1,78 @@
+using DotTiled.Serialization;
+
+namespace DotTiled.Tests;
+
+public class DefaultResourceCacheTests
+{
+ [Fact]
+ public void GetTemplate_TemplateDoesNotExist_ReturnsEmptyOptional()
+ {
+ // Arrange
+ var cache = new DefaultResourceCache();
+ var path = "template.tsx";
+
+ // Act
+ var result = cache.GetTemplate(path);
+
+ // Assert
+ Assert.False(result.HasValue);
+ }
+
+ [Fact]
+ public void GetTemplate_TemplateHasBeenInserted_ReturnsTemplate()
+ {
+ // Arrange
+ var cache = new DefaultResourceCache();
+ var path = "template.tsx";
+ var template = new Template
+ {
+ Object = new EllipseObject { }
+ };
+
+ // Act
+ cache.InsertTemplate(path, template);
+ var result = cache.GetTemplate(path);
+
+ // Assert
+ Assert.True(result.HasValue);
+ Assert.Same(template, result.Value);
+ }
+
+ [Fact]
+ public void GetTileset_TilesetDoesNotExist_ReturnsEmptyOptional()
+ {
+ // Arrange
+ var cache = new DefaultResourceCache();
+ var path = "tileset.tsx";
+
+ // Act
+ var result = cache.GetTileset(path);
+
+ // Assert
+ Assert.False(result.HasValue);
+ }
+
+ [Fact]
+ public void GetTileset_TilesetHasBeenInserted_ReturnsTileset()
+ {
+ // Arrange
+ var cache = new DefaultResourceCache();
+ var path = "tileset.tsx";
+ var tileset = new Tileset
+ {
+ Name = "Tileset",
+ TileWidth = 32,
+ TileHeight = 32,
+ TileCount = 1,
+ Columns = 1
+ };
+
+ // Act
+ cache.InsertTileset(path, tileset);
+ var result = cache.GetTileset(path);
+
+ // Assert
+ Assert.True(result.HasValue);
+ Assert.Same(tileset, result.Value);
+ }
+}
diff --git a/src/DotTiled.Tests/Serialization/LoaderTests.cs b/src/DotTiled.Tests/Serialization/LoaderTests.cs
new file mode 100644
index 0000000..41fa38b
--- /dev/null
+++ b/src/DotTiled.Tests/Serialization/LoaderTests.cs
@@ -0,0 +1,258 @@
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using NSubstitute;
+
+namespace DotTiled.Tests;
+
+public class LoaderTests
+{
+ [Fact]
+ public void LoadMap_Always_ReadsFromResourceReader()
+ {
+ // Arrange
+ var resourceReader = Substitute.For();
+ resourceReader.Read("map.tmx").Returns(
+ """
+
+
+ """);
+
+ var resourceCache = Substitute.For();
+ var customTypeDefinitions = Enumerable.Empty();
+ var loader = new Loader(resourceReader, resourceCache, customTypeDefinitions);
+
+ // Act
+ loader.LoadMap("map.tmx");
+
+ // Assert
+ resourceReader.Received(1).Read("map.tmx");
+ }
+
+ [Fact]
+ public void LoadMap_MapReferencesExternalTileset_ReadsTilesetFromResourceReaderAndAttemptsToRetrieveFromCache()
+ {
+ // Arrange
+ var resourceReader = Substitute.For();
+ resourceReader.Read("map.tmx").Returns(
+ """
+
+
+ """);
+
+ resourceReader.Read("tileset.tsx").Returns(
+ """
+
+
+
+
+
+
+ """);
+
+ var resourceCache = Substitute.For();
+ resourceCache.GetTileset(Arg.Any()).Returns(Optional.Empty);
+ resourceCache.GetTemplate(Arg.Any()).Returns(Optional.Empty);
+
+ var customTypeDefinitions = Enumerable.Empty();
+ var loader = new Loader(resourceReader, resourceCache, customTypeDefinitions);
+
+ // Act
+ loader.LoadMap("map.tmx");
+
+ // Assert
+ resourceReader.Received(1).Read("tileset.tsx");
+ resourceCache.Received(1).GetTileset("tileset.tsx");
+ }
+
+ [Fact]
+ public void LoadMap_MapReferencesExternalTemplate_ReadsTemplateFromResourceReaderAndAttemptsToRetrieveFromCache()
+ {
+ // Arrange
+ var resourceReader = Substitute.For();
+ resourceReader.Read("map.tmx").Returns(
+ """
+
+
+ """);
+
+ resourceReader.Read("tileset.tsx").Returns(
+ """
+
+
+
+
+
+
+ """);
+
+ resourceReader.Read("template.tx").Returns(
+ """
+
+
+
+
+ """);
+
+ var resourceCache = Substitute.For();
+ resourceCache.GetTileset(Arg.Any()).Returns(Optional.Empty);
+ resourceCache.GetTemplate(Arg.Any()).Returns(Optional.Empty);
+
+ var customTypeDefinitions = Enumerable.Empty();
+ var loader = new Loader(resourceReader, resourceCache, customTypeDefinitions);
+
+ // Act
+ loader.LoadMap("map.tmx");
+
+ // Assert
+ resourceReader.Received(1).Read("template.tx");
+ resourceCache.Received(1).GetTemplate("template.tx");
+ }
+
+ [Fact]
+ public void LoadMap_CacheReturnsTileset_ReturnsTilesetFromCache()
+ {
+ // Arrange
+ var resourceReader = Substitute.For();
+ resourceReader.Read("map.tmx").Returns(
+ """
+
+
+ """);
+
+ var resourceCache = Substitute.For();
+ resourceCache.GetTileset("tileset.tsx").Returns(new Optional(new Tileset { Name = "Tileset", TileWidth = 32, TileHeight = 32, TileCount = 1, Columns = 1 }));
+
+ var customTypeDefinitions = Enumerable.Empty();
+ var loader = new Loader(resourceReader, resourceCache, customTypeDefinitions);
+
+ // Act
+ loader.LoadMap("map.tmx");
+
+ // Assert
+ resourceReader.DidNotReceive().Read("tileset.tsx");
+ }
+
+ [Fact]
+ public void LoadMap_CacheReturnsTemplate_ReturnsTemplateFromCache()
+ {
+ // Arrange
+ var resourceReader = Substitute.For();
+ resourceReader.Read("map.tmx").Returns(
+ """
+
+
+ """);
+
+ resourceReader.Read("tileset.tsx").Returns(
+ """
+
+
+
+
+
+
+ """);
+
+ var resourceCache = Substitute.For();
+ resourceCache.GetTileset(Arg.Any()).Returns(Optional.Empty);
+ resourceCache.GetTemplate("template.tx").Returns(new Optional(new Template
+ {
+ Object = new PolygonObject
+ {
+ Points = [
+ new Vector2(0,0),
+ new Vector2(104,20),
+ new Vector2(35.6667f,32.3333f)
+ ],
+ Properties = [
+ new StringProperty { Name = "templateprop", Value = "helo there" }
+ ]
+ }
+ }));
+
+ var customTypeDefinitions = Enumerable.Empty();
+ var loader = new Loader(resourceReader, resourceCache, customTypeDefinitions);
+
+ // Act
+ loader.LoadMap("map.tmx");
+
+ // Assert
+ resourceReader.DidNotReceive().Read("template.tx");
+ }
+
+ private static string WhereAmI([CallerFilePath] string callerFilePath = "") => callerFilePath;
+
+ [Fact]
+ public void Test1()
+ {
+ var basePath = Path.GetDirectoryName(WhereAmI())!;
+ var mapPath = Path.Combine(basePath, "TestData/Map/map-with-external-tileset/map-with-external-tileset.tmx");
+ var loader = Loader.Default();
+ loader.LoadMap(mapPath);
+ }
+}
diff --git a/src/DotTiled/Optional.cs b/src/DotTiled/Optional.cs
index f0c2d37..c772fa9 100644
--- a/src/DotTiled/Optional.cs
+++ b/src/DotTiled/Optional.cs
@@ -89,6 +89,11 @@ public class Optional
///
public T GetValueOr(T defaultValue) => HasValue ? _value : defaultValue;
+ ///
+ /// Returns the current object if it has a value; otherwise, returns the specified default value.
+ ///
+ /// The object to be returned if the current object has no value.
+ ///
public Optional GetValueOrOptional(Optional defaultValue) => HasValue ? this : defaultValue;
///
diff --git a/src/DotTiled/Serialization/DefaultResourceCache.cs b/src/DotTiled/Serialization/DefaultResourceCache.cs
new file mode 100644
index 0000000..69dc8cf
--- /dev/null
+++ b/src/DotTiled/Serialization/DefaultResourceCache.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace DotTiled.Serialization;
+
+///
+/// A default implementation of that uses an in-memory dictionary to cache resources.
+///
+public class DefaultResourceCache : IResourceCache
+{
+ private readonly Dictionary _templates = [];
+ private readonly Dictionary _tilesets = [];
+
+ ///
+ public Optional GetTemplate(string path)
+ {
+ if (_templates.TryGetValue(path, out var template))
+ return new Optional(template);
+
+ return Optional.Empty;
+ }
+
+ ///
+ public Optional GetTileset(string path)
+ {
+ if (_tilesets.TryGetValue(path, out var tileset))
+ return new Optional(tileset);
+
+ return Optional.Empty;
+ }
+
+ ///
+ public void InsertTemplate(string path, Template template) => _templates[path] = template;
+
+ ///
+ public void InsertTileset(string path, Tileset tileset) => _tilesets[path] = tileset;
+}
diff --git a/src/DotTiled/Serialization/FileSystemResourceReader.cs b/src/DotTiled/Serialization/FileSystemResourceReader.cs
new file mode 100644
index 0000000..4329654
--- /dev/null
+++ b/src/DotTiled/Serialization/FileSystemResourceReader.cs
@@ -0,0 +1,21 @@
+using System.IO;
+
+namespace DotTiled.Serialization;
+
+///
+/// Uses the underlying host file system to read Tiled resources from a given path.
+///
+public class FileSystemResourceReader : IResourceReader
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FileSystemResourceReader() { }
+
+ ///
+ public string Read(string resourcePath)
+ {
+ using var streamReader = new StreamReader(resourcePath);
+ return streamReader.ReadToEnd();
+ }
+}
diff --git a/src/DotTiled/Serialization/IResourceCache.cs b/src/DotTiled/Serialization/IResourceCache.cs
new file mode 100644
index 0000000..72bc59c
--- /dev/null
+++ b/src/DotTiled/Serialization/IResourceCache.cs
@@ -0,0 +1,35 @@
+namespace DotTiled;
+
+///
+/// Interface for a cache that stores Tiled resources for faster retrieval and reuse.
+///
+public interface IResourceCache
+{
+ ///
+ /// Inserts a tileset into the cache with the given .
+ ///
+ /// The path to the tileset file.
+ /// The tileset to insert into the cache.
+ void InsertTileset(string path, Tileset tileset);
+
+ ///
+ /// Retrieves a tileset from the cache with the given .
+ ///
+ /// The path to the tileset file.
+ /// The tileset if it exists in the cache; otherwise, .
+ Optional GetTileset(string path);
+
+ ///
+ /// Inserts a template into the cache with the given .
+ ///
+ /// The path to the template file.
+ /// The template to insert into the cache.
+ void InsertTemplate(string path, Template template);
+
+ ///
+ /// Retrieves a template from the cache with the given .
+ ///
+ /// The path to the template file.
+ /// The template if it exists in the cache; otherwise, .
+ Optional GetTemplate(string path);
+}
diff --git a/src/DotTiled/Serialization/IResourceReader.cs b/src/DotTiled/Serialization/IResourceReader.cs
new file mode 100644
index 0000000..b318b83
--- /dev/null
+++ b/src/DotTiled/Serialization/IResourceReader.cs
@@ -0,0 +1,14 @@
+namespace DotTiled;
+
+///
+/// Able to read resources from a given path.
+///
+public interface IResourceReader
+{
+ ///
+ /// Reads a Tiled resource from a given path.
+ ///
+ /// The path to the Tiled resource, which can be a Map file, Tileset file, Template file, etc.
+ /// The content of the resource as a string.
+ string Read(string resourcePath);
+}
diff --git a/src/DotTiled/Serialization/Loader.cs b/src/DotTiled/Serialization/Loader.cs
new file mode 100644
index 0000000..8b4b9f0
--- /dev/null
+++ b/src/DotTiled/Serialization/Loader.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using DotTiled.Serialization;
+
+namespace DotTiled;
+
+///
+/// Able to load Tiled resources from a given path.
+///
+public class Loader
+{
+ private readonly IResourceReader _resourceReader;
+ private readonly IResourceCache _resourceCache;
+ private readonly IDictionary _customTypeDefinitions;
+
+ ///
+ /// Initializes a new instance of the class with the given , , and .
+ ///
+ /// A reader that is able to read Tiled resources from a given path.
+ /// A cache that stores Tiled resources for faster retrieval and reuse.
+ /// A collection of custom type definitions that can be used to resolve custom types in Tiled resources.
+ public Loader(
+ IResourceReader resourceReader,
+ IResourceCache resourceCache,
+ IEnumerable customTypeDefinitions)
+ {
+ _resourceReader = resourceReader;
+ _resourceCache = resourceCache;
+ _customTypeDefinitions = customTypeDefinitions.ToDictionary(ctd => ctd.Name);
+ }
+
+ ///
+ /// Creates a new instance of a with the default and .
+ ///
+ /// An optional collection of custom type definitions that can be used to resolve custom types in Tiled resources.
+ /// A new instance of a .
+ public static Loader Default(IEnumerable customTypeDefinitions = null) => new Loader(new FileSystemResourceReader(), new DefaultResourceCache(), customTypeDefinitions ?? []);
+
+ ///
+ /// Loads a map from the given .
+ ///
+ /// The path to the map file.
+ /// The loaded map.
+ public Map LoadMap(string mapPath)
+ {
+ var basePath = Path.GetDirectoryName(mapPath);
+ string mapContent = _resourceReader.Read(mapPath);
+ using var mapReader = new MapReader(mapContent, GetTilesetResolver(basePath), GetTemplateResolver(basePath), CustomTypeResolver);
+ return mapReader.ReadMap();
+ }
+
+ ///
+ /// Loads a tileset from the given .
+ ///
+ /// The path to the tileset file.
+ /// The loaded tileset.
+ public Tileset LoadTileset(string tilesetPath)
+ {
+ var basePath = Path.GetDirectoryName(tilesetPath);
+ string tilesetContent = _resourceReader.Read(tilesetPath);
+ using var tilesetReader = new TilesetReader(tilesetContent, GetTilesetResolver(basePath), GetTemplateResolver(basePath), CustomTypeResolver);
+ return tilesetReader.ReadTileset();
+ }
+
+ private Func GetTilesetResolver(string basePath)
+ {
+ return source =>
+ {
+ var tilesetPath = Path.Combine(basePath, source);
+ var cachedTileset = _resourceCache.GetTileset(source);
+ if (cachedTileset.HasValue)
+ return cachedTileset.Value;
+
+ string tilesetContent = _resourceReader.Read(tilesetPath);
+ using var tilesetReader = new TilesetReader(tilesetContent, GetTilesetResolver(basePath), GetTemplateResolver(basePath), CustomTypeResolver);
+ var tileset = tilesetReader.ReadTileset();
+ _resourceCache.InsertTileset(source, tileset);
+ return tileset;
+ };
+ }
+
+ private Func GetTemplateResolver(string basePath)
+ {
+ return source =>
+ {
+ var templatePath = Path.Combine(basePath, source);
+ var cachedTemplate = _resourceCache.GetTemplate(source);
+ if (cachedTemplate.HasValue)
+ return cachedTemplate.Value;
+
+ string templateContent = _resourceReader.Read(templatePath);
+ using var templateReader = new TemplateReader(templateContent, GetTilesetResolver(basePath), GetTemplateResolver(basePath), CustomTypeResolver);
+ var template = templateReader.ReadTemplate();
+ _resourceCache.InsertTemplate(source, template);
+ return template;
+ };
+ }
+
+ private ICustomTypeDefinition CustomTypeResolver(string name) => _customTypeDefinitions[name];
+}