From 6191498618a5be6b30862a18c5887ab8a871ed41 Mon Sep 17 00:00:00 2001 From: Daniel Cronqvist Date: Thu, 24 Apr 2025 08:15:50 +0200 Subject: [PATCH] Add Raylib example project and helper methods for resolving tilesets and finding source rectangles of tiles --- .../DotTiled.Example.Raylib.csproj | 22 ++ .../DotTiled.Example.Raylib/Program.cs | 203 ++++++++++++++++++ src/DotTiled.Tests/UnitTests/MapTests.cs | 132 ++++++++++++ .../UnitTests/Tilesets/TilesetTests.cs | 114 ++++++++++ src/DotTiled.sln | 9 + src/DotTiled/Layers/TileLayer.cs | 24 +++ src/DotTiled/Map.cs | 28 +++ src/DotTiled/Tilesets/Tileset.cs | 66 ++++++ 8 files changed, 598 insertions(+) create mode 100644 src/DotTiled.Examples/DotTiled.Example.Raylib/DotTiled.Example.Raylib.csproj create mode 100644 src/DotTiled.Examples/DotTiled.Example.Raylib/Program.cs create mode 100644 src/DotTiled.Tests/UnitTests/MapTests.cs create mode 100644 src/DotTiled.Tests/UnitTests/Tilesets/TilesetTests.cs 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 + }; + } }