diff --git a/src/DotTiled.Examples/DotTiled.Example.Raylib/DotTiled.Example.Raylib.csproj b/src/DotTiled.Examples/DotTiled.Example.Raylib/DotTiled.Example.Raylib.csproj
new file mode 100644
index 0000000..5e549a0
--- /dev/null
+++ b/src/DotTiled.Examples/DotTiled.Example.Raylib/DotTiled.Example.Raylib.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ disable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DotTiled.Examples/DotTiled.Example.Raylib/Program.cs b/src/DotTiled.Examples/DotTiled.Example.Raylib/Program.cs
new file mode 100644
index 0000000..b5b78f3
--- /dev/null
+++ b/src/DotTiled.Examples/DotTiled.Example.Raylib/Program.cs
@@ -0,0 +1,203 @@
+using System.Numerics;
+using DotTiled.Serialization;
+using Raylib_cs;
+
+using RayColor = Raylib_cs.Color;
+
+namespace DotTiled.Example
+{
+ public class Program
+ {
+ public static void Main(string[] _)
+ {
+ // Initialize the Raylib window
+ Raylib.InitWindow(1280, 720, "DotTiled Example with Raylib");
+ Raylib.SetConfigFlags(ConfigFlags.VSyncHint);
+
+ // Load the Tiled map
+ var loader = Loader.Default();
+ var map = loader.LoadMap("assets/world.tmx");
+
+ // Load tileset textures
+ var tilesetTextures = LoadTilesetTextures(map);
+
+ // Extract layers from the map
+ var visualLayers = map.Layers.OfType().Single(l => l.Name == "Visuals").Layers.OfType();
+ var collisionLayer = map.Layers.OfType().Single(l => l.Name == "Collisions");
+ var pointsOfInterest = (ObjectLayer)map.Layers.Single(layer => layer.Name == "PointsOfInterest");
+
+ // Get the player's spawn point
+ var playerSpawnPoint = pointsOfInterest.Objects.Single(obj => obj.Name == "PlayerSpawn");
+ var playerPosition = new Vector2(playerSpawnPoint.X, playerSpawnPoint.Y);
+
+ // Set up the camera
+ var camera = new Camera2D
+ {
+ Target = playerPosition,
+ Offset = new Vector2(Raylib.GetScreenWidth() / 2, Raylib.GetScreenHeight() / 2),
+ Rotation = 0.0f,
+ Zoom = 1.0f
+ };
+
+ // Main game loop
+ while (!Raylib.WindowShouldClose())
+ {
+ // Update game logic
+ Update(ref playerPosition, collisionLayer, ref camera);
+
+ // Render the game
+ Render(map, visualLayers, tilesetTextures, playerPosition, camera);
+ }
+
+ // Clean up resources
+ Raylib.CloseWindow();
+ }
+
+ ///
+ /// Loads tileset textures from the map.
+ ///
+ private static Dictionary LoadTilesetTextures(Map map)
+ {
+ return map.Tilesets.ToDictionary(
+ tileset => tileset.Image.Value.Source.Value,
+ tileset => Raylib.LoadTexture(Path.Combine("assets", tileset.Image.Value.Source.Value))
+ );
+ }
+
+ ///
+ /// Updates the player's position and camera.
+ ///
+ private static void Update(ref Vector2 playerPosition, ObjectLayer collisionLayer, ref Camera2D camera)
+ {
+ // Define the player's rectangle
+ var playerRect = new Rectangle(playerPosition.X, playerPosition.Y, 12, 12);
+
+ // Handle player movement
+ var move = HandlePlayerInput();
+
+ // Check for collisions
+ foreach (var obj in collisionLayer.Objects.OfType())
+ {
+ var objRect = new Rectangle(obj.X, obj.Y, obj.Width, obj.Height);
+
+ // Horizontal collision
+ var movePlayerHRect = new Rectangle(playerRect.X + move.X, playerRect.Y, playerRect.Width, playerRect.Height);
+ if (Raylib.CheckCollisionRecs(movePlayerHRect, objRect))
+ {
+ move.X = 0;
+ }
+
+ // Vertical collision
+ var movePlayerVRect = new Rectangle(playerRect.X, playerRect.Y + move.Y, playerRect.Width, playerRect.Height);
+ if (Raylib.CheckCollisionRecs(movePlayerVRect, objRect))
+ {
+ move.Y = 0;
+ }
+ }
+
+ // Update player position
+ playerPosition += move;
+
+ // Smoothly update the camera target
+ var newCameraTarget = new Vector2(playerPosition.X, playerPosition.Y);
+ camera.Target += (newCameraTarget - camera.Target) * 15f * Raylib.GetFrameTime();
+ }
+
+ ///
+ /// Handles player input for movement.
+ ///
+ private static Vector2 HandlePlayerInput()
+ {
+ var move = Vector2.Zero;
+ var playerSpeed = 150 * Raylib.GetFrameTime();
+
+ if (Raylib.IsKeyDown(KeyboardKey.W)) move.Y -= playerSpeed;
+ if (Raylib.IsKeyDown(KeyboardKey.S)) move.Y += playerSpeed;
+ if (Raylib.IsKeyDown(KeyboardKey.A)) move.X -= playerSpeed;
+ if (Raylib.IsKeyDown(KeyboardKey.D)) move.X += playerSpeed;
+
+ return move;
+ }
+
+ ///
+ /// Renders the game, including layers and the player.
+ ///
+ private static void Render(Map map, IEnumerable visualLayers, Dictionary tilesetTextures, Vector2 playerPosition, Camera2D camera)
+ {
+ Raylib.BeginDrawing();
+ Raylib.ClearBackground(RayColor.Blank);
+ Raylib.BeginMode2D(camera);
+
+ // Render layers below the player
+ RenderLayers(map, visualLayers, tilesetTextures, ["Ground", "Ponds", "Paths", "HouseWalls", "HouseDoors", "FencesBushes"]);
+
+ // Draw the player
+ var playerVisualRect = new Rectangle(playerPosition.X, playerPosition.Y - 12, 12, 24);
+ Raylib.DrawRectangleRec(playerVisualRect, RayColor.Blue);
+
+ // Render layers above the player
+ RenderLayers(map, visualLayers, tilesetTextures, ["HouseRoofs"]);
+
+ Raylib.EndMode2D();
+ Raylib.EndDrawing();
+ }
+
+ ///
+ /// Renders specific layers from the map.
+ ///
+ private static void RenderLayers(Map map, IEnumerable visualLayers, Dictionary tilesetTextures, string[] layerNames)
+ {
+ foreach (var layerName in layerNames)
+ {
+ var layer = visualLayers.OfType().Single(l => l.Name == layerName);
+ RenderLayer(map, layer, tilesetTextures);
+ }
+ }
+
+ ///
+ /// Renders a single layer from the map.
+ ///
+ private static void RenderLayer(Map map, TileLayer layer, Dictionary tilesetTextures)
+ {
+ for (var y = 0; y < layer.Height; y++)
+ {
+ for (var x = 0; x < layer.Width; x++)
+ {
+ var tileGID = layer.GetGlobalTileIDAtCoord(x, y);
+ if (tileGID == 0) continue;
+
+ var tileset = map.ResolveTilesetForGlobalTileID(tileGID, out var localTileID);
+ var sourceRect = tileset.GetSourceRectangleForLocalTileID(localTileID);
+
+ // Source rec is shrunk by tiny amount to avoid ugly seams between tiles
+ // when the camera is at certain subpixel positions
+ var raylibSourceRect = ShrinkRectangle(new Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height), 0.01f);
+
+ var destinationRect = new Rectangle(x * tileset.TileWidth, y * tileset.TileHeight, tileset.TileWidth, tileset.TileHeight);
+
+ Raylib.DrawTexturePro(
+ tilesetTextures[tileset.Image.Value.Source.Value],
+ raylibSourceRect,
+ destinationRect,
+ Vector2.Zero,
+ 0,
+ RayColor.White
+ );
+ }
+ }
+ }
+
+ ///
+ /// Shrinks a rectangle by a specified amount.
+ ///
+ private static Rectangle ShrinkRectangle(Rectangle rect, float amount)
+ {
+ return new Rectangle(
+ rect.X + amount,
+ rect.Y + amount,
+ rect.Width - (2 * amount),
+ rect.Height - (2 * amount)
+ );
+ }
+ }
+}
diff --git a/src/DotTiled.Tests/UnitTests/MapTests.cs b/src/DotTiled.Tests/UnitTests/MapTests.cs
new file mode 100644
index 0000000..2c3591b
--- /dev/null
+++ b/src/DotTiled.Tests/UnitTests/MapTests.cs
@@ -0,0 +1,132 @@
+namespace DotTiled.Tests.UnitTests;
+
+public class MapTests
+{
+ [Fact]
+ public void ResolveTilesetForGlobalTileID_NoTilesets_ThrowsException()
+ {
+ // Arrange
+ var map = new Map
+ {
+ Version = "version",
+ Orientation = MapOrientation.Orthogonal,
+ Width = 10,
+ Height = 10,
+ TileWidth = 16,
+ TileHeight = 16,
+ NextLayerID = 1,
+ NextObjectID = 1
+ };
+
+ // Act & Assert
+ Assert.Throws(() => map.ResolveTilesetForGlobalTileID(1, out var _));
+ }
+
+ [Fact]
+ public void ResolveTilesetForGlobalTileID_GlobalTileIDOutOfRange_ThrowsException()
+ {
+ // Arrange
+ var map = new Map
+ {
+ Version = "version",
+ Orientation = MapOrientation.Orthogonal,
+ Width = 10,
+ Height = 10,
+ TileWidth = 16,
+ TileHeight = 16,
+ NextLayerID = 1,
+ NextObjectID = 1,
+ Tilesets = [
+ new Tileset
+ {
+ FirstGID = 1,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5
+ }
+ ]
+ };
+
+ // Act & Assert
+ Assert.Throws(() => map.ResolveTilesetForGlobalTileID(6, out var _));
+ }
+
+ [Fact]
+ public void ResolveTilesetForGlobalTileID_GlobalTileIDInRangeOfOnlyTileset_ReturnsTileset()
+ {
+ // Arrange
+ var tileset = new Tileset
+ {
+ FirstGID = 1,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5
+ };
+ var map = new Map
+ {
+ Version = "version",
+ Orientation = MapOrientation.Orthogonal,
+ Width = 10,
+ Height = 10,
+ TileWidth = 16,
+ TileHeight = 16,
+ NextLayerID = 1,
+ NextObjectID = 1,
+ Tilesets = [tileset]
+ };
+
+ // Act
+ var result = map.ResolveTilesetForGlobalTileID(3, out var localTileID);
+
+ // Assert
+ Assert.Equal(tileset, result);
+ Assert.Equal(2, (int)localTileID); // 3 - 1 = 2 (local tile ID)
+ }
+
+ [Fact]
+ public void ResolveTilesetForGlobalTileID_GlobalTileIDInRangeOfMultipleTilesets_ReturnsCorrectTileset()
+ {
+ // Arrange
+ var tileset1 = new Tileset
+ {
+ FirstGID = 1,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5
+ };
+ var tileset2 = new Tileset
+ {
+ FirstGID = 6,
+ Name = "Tileset2",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5
+ };
+ var map = new Map
+ {
+ Version = "version",
+ Orientation = MapOrientation.Orthogonal,
+ Width = 10,
+ Height = 10,
+ TileWidth = 16,
+ TileHeight = 16,
+ NextLayerID = 1,
+ NextObjectID = 1,
+ Tilesets = [tileset1, tileset2]
+ };
+
+ // Act
+ var result = map.ResolveTilesetForGlobalTileID(8, out var localTileID);
+
+ // Assert
+ Assert.Equal(tileset2, result);
+ Assert.Equal(2, (int)localTileID); // 8 - 6 = 2 (local tile ID)
+ }
+}
diff --git a/src/DotTiled.Tests/UnitTests/Tilesets/TilesetTests.cs b/src/DotTiled.Tests/UnitTests/Tilesets/TilesetTests.cs
new file mode 100644
index 0000000..4b0eb10
--- /dev/null
+++ b/src/DotTiled.Tests/UnitTests/Tilesets/TilesetTests.cs
@@ -0,0 +1,114 @@
+namespace DotTiled.Tests.UnitTests;
+
+public class TilesetTests
+{
+ [Fact]
+ public void GetSourceRectangleForLocalTileID_TileIDOutOfRange_ThrowsException()
+ {
+ // Arrange
+ var tileset = new Tileset
+ {
+ FirstGID = 0,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5
+ };
+
+ // Act & Assert
+ Assert.Throws(() => tileset.GetSourceRectangleForLocalTileID(6));
+ }
+
+ [Fact]
+ public void GetSourceRectangleForLocalTileID_ValidTileIDIsInTilesList_ReturnsCorrectRectangle()
+ {
+ // Arrange
+ var tileset = new Tileset
+ {
+ FirstGID = 0,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 2,
+ Columns = 2,
+ Tiles = [
+ new Tile
+ {
+ ID = 0,
+ X = 0,
+ Y = 0,
+ Width = 16,
+ Height = 16,
+ },
+ new Tile
+ {
+ ID = 1,
+ X = 16,
+ Y = 0,
+ Width = 16,
+ Height = 16,
+ }
+ ]
+ };
+
+ // Act
+ var rectangle = tileset.GetSourceRectangleForLocalTileID(1);
+
+ // Assert
+ Assert.Equal(16, rectangle.X);
+ Assert.Equal(0, rectangle.Y);
+ Assert.Equal(16, rectangle.Width);
+ Assert.Equal(16, rectangle.Height);
+ }
+
+ [Fact]
+ public void GetSourceRectangleForLocalTileID_ValidTileIDIsNotInTilesListNoMarginNoSpacing_ReturnsCorrectRectangle()
+ {
+ // Arrange
+ var tileset = new Tileset
+ {
+ FirstGID = 0,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5,
+ };
+
+ // Act
+ var rectangle = tileset.GetSourceRectangleForLocalTileID(3);
+
+ // Assert
+ Assert.Equal(48, rectangle.X);
+ Assert.Equal(0, rectangle.Y);
+ Assert.Equal(16, rectangle.Width);
+ Assert.Equal(16, rectangle.Height);
+ }
+
+ [Fact]
+ public void GetSourceRectangleForLocalTileID_ValidTileIDIsNotInTilesListWithMarginAndSpacing_ReturnsCorrectRectangle()
+ {
+ // Arrange
+ var tileset = new Tileset
+ {
+ FirstGID = 0,
+ Name = "Tileset1",
+ TileWidth = 16,
+ TileHeight = 16,
+ TileCount = 5,
+ Columns = 5,
+ Margin = 3,
+ Spacing = 1
+ };
+
+ // Act
+ var rectangle = tileset.GetSourceRectangleForLocalTileID(3);
+
+ // Assert
+ Assert.Equal(54, rectangle.X);
+ Assert.Equal(3, rectangle.Y);
+ Assert.Equal(16, rectangle.Width);
+ Assert.Equal(16, rectangle.Height);
+ }
+}
diff --git a/src/DotTiled.sln b/src/DotTiled.sln
index 7208481..5505021 100644
--- a/src/DotTiled.sln
+++ b/src/DotTiled.sln
@@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotTiled.Example.Console",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotTiled.Example.Godot", "DotTiled.Examples\DotTiled.Example.Godot\DotTiled.Example.Godot.csproj", "{7541A9B3-43A5-45A7-939E-6F542319D990}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DotTiled.Examples", "DotTiled.Examples", "{F3D6E648-AF8F-4EC9-A810-8C348DBB9924}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotTiled.Example.Raylib", "DotTiled.Examples\DotTiled.Example.Raylib\DotTiled.Example.Raylib.csproj", "{53585FB8-6E94-46F0-87E2-9692874E1714}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -44,9 +48,14 @@ Global
{7541A9B3-43A5-45A7-939E-6F542319D990}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7541A9B3-43A5-45A7-939E-6F542319D990}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{7541A9B3-43A5-45A7-939E-6F542319D990}.Release|Any CPU.Build.0 = Debug|Any CPU
+ {53585FB8-6E94-46F0-87E2-9692874E1714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {53585FB8-6E94-46F0-87E2-9692874E1714}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {53585FB8-6E94-46F0-87E2-9692874E1714}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {53585FB8-6E94-46F0-87E2-9692874E1714}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F9892295-6C2C-4ABD-9D6F-2AC81D2C6E67} = {8C54542E-3C2C-486C-9BEF-4C510391AFDA}
{7541A9B3-43A5-45A7-939E-6F542319D990} = {8C54542E-3C2C-486C-9BEF-4C510391AFDA}
+ {53585FB8-6E94-46F0-87E2-9692874E1714} = {F3D6E648-AF8F-4EC9-A810-8C348DBB9924}
EndGlobalSection
EndGlobal
diff --git a/src/DotTiled/Layers/TileLayer.cs b/src/DotTiled/Layers/TileLayer.cs
index 8d63180..2e37b99 100644
--- a/src/DotTiled/Layers/TileLayer.cs
+++ b/src/DotTiled/Layers/TileLayer.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace DotTiled;
///
@@ -29,4 +31,26 @@ public class TileLayer : BaseLayer
/// The tile layer data.
///
public Optional Data { get; set; } = Optional.Empty;
+
+ ///
+ /// Helper method to retrieve the Global Tile ID at a given coordinate in the layer.
+ ///
+ /// The X coordinate in the layer
+ /// The Y coordinate in the layer
+ /// The Global Tile ID at the given coordinate.
+ /// Thrown when either or are missing values.
+ /// Thrown when the given coordinate is not within bounds of the layer.
+ public uint GetGlobalTileIDAtCoord(int x, int y)
+ {
+ if (!Data.HasValue)
+ throw new InvalidOperationException("Data is not set.");
+
+ if (x < 0 || x >= Width || y < 0 || y >= Height)
+ throw new ArgumentException("Coordinates are out of bounds.");
+
+ if (!Data.Value.GlobalTileIDs.HasValue)
+ throw new InvalidOperationException("GlobalTileIDs is not set.");
+
+ return Data.Value.GlobalTileIDs.Value[(y * Width) + x];
+ }
}
diff --git a/src/DotTiled/Map.cs b/src/DotTiled/Map.cs
index 92965f5..8305e71 100644
--- a/src/DotTiled/Map.cs
+++ b/src/DotTiled/Map.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Globalization;
@@ -205,4 +206,31 @@ public class Map : HasPropertiesBase
/// Hierarchical list of layers. is a layer type which can contain sub-layers to create a hierarchy.
///
public List Layers { get; set; } = [];
+
+ ///
+ /// Resolves which tileset a global tile ID belongs to, and returns the corresponding local tile ID.
+ ///
+ /// The global tile ID to resolve.
+ /// The local tile ID within the tileset.
+ /// The tileset that contains the tile with the specified global tile ID.
+ /// Thrown when no tileset is found for the specified global tile ID.
+ public Tileset ResolveTilesetForGlobalTileID(uint globalTileID, out uint localTileID)
+ {
+ for (int i = Tilesets.Count - 1; i >= 0; i--)
+ {
+ var tileset = Tilesets[i];
+
+ if (globalTileID >= tileset.FirstGID.Value
+ && globalTileID < tileset.FirstGID.Value + tileset.TileCount)
+ {
+ localTileID = globalTileID - tileset.FirstGID.Value;
+ return tileset;
+ }
+ }
+
+ throw new ArgumentException(
+ $"No tileset found for global tile ID {globalTileID}.",
+ nameof(globalTileID)
+ );
+ }
}
diff --git a/src/DotTiled/Tilesets/Tileset.cs b/src/DotTiled/Tilesets/Tileset.cs
index b258705..4261261 100644
--- a/src/DotTiled/Tilesets/Tileset.cs
+++ b/src/DotTiled/Tilesets/Tileset.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
namespace DotTiled;
@@ -90,6 +92,32 @@ public enum FillMode
PreserveAspectFit
}
+///
+/// A helper class to specify where in a tileset image a tile is located.
+///
+public class SourceRectangle
+{
+ ///
+ /// The X coordinate of the tile in the tileset image.
+ ///
+ public int X { get; set; } = 0;
+
+ ///
+ /// The Y coordinate of the tile in the tileset image.
+ ///
+ public int Y { get; set; } = 0;
+
+ ///
+ /// The width of the tile in the tileset image.
+ ///
+ public int Width { get; set; } = 0;
+
+ ///
+ /// The height of the tile in the tileset image.
+ ///
+ public int Height { get; set; } = 0;
+}
+
///
/// A tileset is a collection of tiles that can be used in a tile layer, or by tile objects.
///
@@ -209,4 +237,42 @@ public class Tileset : HasPropertiesBase
/// If this tileset is based on a collection of images, then this list of tiles will contain the individual images that make up the tileset.
///
public List Tiles { get; set; } = [];
+
+ ///
+ /// Returns the source rectangle for a tile in this tileset given its local tile ID.
+ ///
+ /// The local tile ID of the tile.
+ /// A source rectangle describing the tile's position in the tileset's
+ /// Thrown when the local tile ID is out of range.
+ public SourceRectangle GetSourceRectangleForLocalTileID(uint localTileID)
+ {
+ if (localTileID >= TileCount)
+ throw new ArgumentException("The local tile ID is out of range.", nameof(localTileID));
+
+ var tileInTiles = Tiles.FirstOrDefault(t => t.ID == localTileID);
+ if (tileInTiles != null)
+ {
+ return new SourceRectangle
+ {
+ X = tileInTiles.X,
+ Y = tileInTiles.Y,
+ Width = tileInTiles.Width,
+ Height = tileInTiles.Height
+ };
+ }
+
+ var column = (int)(localTileID % Columns);
+ var row = (int)(localTileID / Columns);
+
+ var x = Margin + ((TileWidth + Spacing) * column);
+ var y = Margin + ((TileHeight + Spacing) * row);
+
+ return new SourceRectangle
+ {
+ X = x,
+ Y = y,
+ Width = TileWidth,
+ Height = TileHeight
+ };
+ }
}