| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- using System;
- using System.Collections.Generic;
- using System.IO;
- using System.Linq;
- using System.Text.Json;
- using Microsoft.Xna.Framework.Graphics;
- using MonoGame.Extended.Tilemaps.LDtk.Converters;
- using MonoGame.Extended.Tilemaps.Parsers;
- namespace MonoGame.Extended.Tilemaps.LDtk
- {
- /// <summary>
- /// Parser for LDtk JSON tilemap files.
- /// </summary>
- /// <remarks>
- /// LDtk is a modern, open-source 2D level editor. This parser supports LDtk format version 1.5.3
- /// and converts LDtk projects to the format-agnostic Tilemap API.
- /// <para>
- /// For more information about LDtk, visit: https://ldtk.io
- /// </para>
- /// </remarks>
- public class LDtkJsonParser : ITilemapParser
- {
- private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true,
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString
- };
- private readonly string _baseDirectory;
- /// <summary>
- /// Initializes a new instance of the <see cref="LDtkJsonParser"/> class.
- /// </summary>
- /// <param name="baseDirectory">
- /// Optional base directory for resolving relative file paths. If provided, file paths in
- /// <see cref="ParseFromFile"/> will be resolved relative to this directory.
- /// If <see langword="null"/>, paths are resolved from the file's own location.
- /// </param>
- public LDtkJsonParser(string baseDirectory = null)
- {
- _baseDirectory = baseDirectory;
- }
- /// <summary>
- /// Gets the file extensions supported by this parser.
- /// </summary>
- public IReadOnlyList<string> SupportedExtensions { get; } = new[] { ".ldtk" };
- /// <summary>
- /// Determines whether this parser can parse the specified file.
- /// </summary>
- /// <param name="filePath">The path to the file to check.</param>
- /// <returns><see langword="true"/> if the file can be parsed; otherwise, <see langword="false"/>.</returns>
- public bool CanParse(string filePath)
- {
- if (string.IsNullOrEmpty(filePath))
- return false;
- string extension = Path.GetExtension(filePath);
- return SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
- }
- /// <summary>
- /// Parses a tilemap from the specified file.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
- /// <returns>A <see cref="Tilemap"/> representing the first level in the project.</returns>
- /// <exception cref="ArgumentNullException">
- /// <paramref name="filePath"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
- /// </exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- public Tilemap ParseFromFile(string filePath, GraphicsDevice graphicsDevice)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- if (graphicsDevice == null)
- throw new ArgumentNullException(nameof(graphicsDevice));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- using (FileStream stream = File.OpenRead(fullPath))
- {
- string directory = Path.GetDirectoryName(fullPath);
- return ParseFromStream(stream, graphicsDevice, directory);
- }
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to parse LDtk file: {fullPath}", ex);
- }
- }
- /// <summary>
- /// Parses a tilemap from the specified stream.
- /// </summary>
- /// <param name="stream">The stream containing the LDtk project data.</param>
- /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
- /// <param name="basePath">
- /// Optional base path for resolving relative file references. If not provided,
- /// uses the base directory from the constructor, or the current directory if neither is set.
- /// </param>
- /// <returns>A <see cref="Tilemap"/> representing the first level in the project.</returns>
- /// <exception cref="ArgumentNullException">
- /// <paramref name="stream"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
- /// </exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the stream.</exception>
- public Tilemap ParseFromStream(Stream stream, GraphicsDevice graphicsDevice, string basePath = null)
- {
- if (stream == null)
- throw new ArgumentNullException(nameof(stream));
- if (graphicsDevice == null)
- throw new ArgumentNullException(nameof(graphicsDevice));
- try
- {
- LDtkProject project = JsonSerializer.Deserialize<LDtkProject>(stream, JsonOptions);
- if (project == null)
- throw new TilemapParseException("Failed to deserialize LDtk project: result was null.");
- if (project.Levels == null || project.Levels.Count == 0)
- throw new TilemapParseException("LDtk project contains no levels.");
- // Use provided basePath, fall back to constructor base directory, then current directory
- string resolvedBasePath = basePath ?? _baseDirectory ?? Directory.GetCurrentDirectory();
- // Parse first level by default
- LDtkLevel firstLevel = project.Levels[0];
- return ConvertLevel(firstLevel, project, graphicsDevice, resolvedBasePath);
- }
- catch (JsonException ex)
- {
- throw new TilemapParseException("Failed to parse LDtk JSON data.", ex);
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException("An unexpected error occurred while parsing LDtk data.", ex);
- }
- }
- /// <summary>
- /// Parses a specific level from an LDtk project file.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <param name="levelIdentifier">The identifier of the level to parse.</param>
- /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
- /// <returns>A <see cref="Tilemap"/> representing the specified level.</returns>
- /// <exception cref="ArgumentNullException">
- /// <paramref name="filePath"/>, <paramref name="levelIdentifier"/>, or
- /// <paramref name="graphicsDevice"/> is <see langword="null"/>.
- /// </exception>
- /// <exception cref="ArgumentException">The specified level was not found in the project.</exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- public Tilemap ParseLevel(string filePath, string levelIdentifier, GraphicsDevice graphicsDevice)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- if (levelIdentifier == null)
- throw new ArgumentNullException(nameof(levelIdentifier));
- if (graphicsDevice == null)
- throw new ArgumentNullException(nameof(graphicsDevice));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- string projectDirectory = Path.GetDirectoryName(fullPath);
- LDtkProject project = LoadProject(fullPath);
- LDtkLevel level = project.Levels.FirstOrDefault(l => l.Identifier == levelIdentifier);
- if (level == null)
- throw new ArgumentException($"Level '{levelIdentifier}' not found in project.", nameof(levelIdentifier));
- return ConvertLevel(level, project, graphicsDevice, projectDirectory);
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to parse level '{levelIdentifier}' from LDtk file: {fullPath}", ex);
- }
- }
- /// <summary>
- /// Parses all levels from an LDtk project file.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
- /// <returns>A collection of <see cref="Tilemap"/> objects representing all levels in the project.</returns>
- /// <exception cref="ArgumentNullException">
- /// <paramref name="filePath"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
- /// </exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- public IReadOnlyList<Tilemap> ParseAllLevels(string filePath, GraphicsDevice graphicsDevice)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- if (graphicsDevice == null)
- throw new ArgumentNullException(nameof(graphicsDevice));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- string projectDirectory = Path.GetDirectoryName(fullPath);
- LDtkProject project = LoadProject(fullPath);
- List<Tilemap> tilemaps = new List<Tilemap>(project.Levels.Count);
- foreach (LDtkLevel level in project.Levels)
- {
- Tilemap tilemap = ConvertLevel(level, project, graphicsDevice, projectDirectory);
- tilemaps.Add(tilemap);
- }
- return tilemaps;
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to parse LDtk file: {fullPath}", ex);
- }
- }
- /// <summary>
- /// Gets the world position of a level from its properties.
- /// </summary>
- /// <param name="tilemap">The tilemap to get world position from.</param>
- /// <returns>A tuple containing the world X and Y coordinates, or null if not available.</returns>
- /// <remarks>
- /// LDtk stores world coordinates in the level properties. This helper method extracts them
- /// for use in world-space level positioning.
- /// </remarks>
- public static (int worldX, int worldY)? GetWorldPosition(Tilemap tilemap)
- {
- if (tilemap == null)
- return null;
- bool hasWorldX = tilemap.Properties.TryGetValue("LDtk_WorldX", out TilemapPropertyValue worldXValue);
- bool hasWorldY = tilemap.Properties.TryGetValue("LDtk_WorldY", out TilemapPropertyValue worldYValue);
- if (hasWorldX && hasWorldY)
- {
- return (worldXValue.AsInt(), worldYValue.AsInt());
- }
- return null;
- }
- /// <summary>
- /// Finds a level by its world IID (Instance Identifier).
- /// </summary>
- /// <param name="tilemaps">The collection of tilemaps to search.</param>
- /// <param name="worldIid">The world IID to search for.</param>
- /// <returns>The tilemap with matching world IID, or null if not found.</returns>
- public static Tilemap FindLevelByIid(IEnumerable<Tilemap> tilemaps, string worldIid)
- {
- if (tilemaps == null || string.IsNullOrEmpty(worldIid))
- return null;
- return tilemaps.FirstOrDefault(t =>
- t.Properties.TryGetValue("LDtk_Iid", out TilemapPropertyValue iidValue) &&
- iidValue.AsString() == worldIid);
- }
- /// <summary>
- /// Gets the table of contents from an LDtk project file.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <returns>A dictionary mapping entity identifiers to lists of their instance data.</returns>
- /// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- /// <remarks>
- /// <para>
- /// The table of contents lists all entity instances that have their "exportToToc" flag enabled
- /// in the LDtk editor. This provides quick access to important entities (like spawn points,
- /// checkpoints, collectibles) without parsing entire levels.
- /// </para>
- /// <para>
- /// Each entry contains:
- /// - World coordinates (worldX, worldY)
- /// - Dimensions (widPx, heiPx)
- /// - IID references (entityIid, layerIid, levelIid, worldIid)
- /// - Custom field values (only fields marked for ToC export)
- /// </para>
- /// <para>
- /// Example usage:
- /// <code>
- /// var toc = LDtkJsonParser.GetTableOfContents("world.ldtk");
- /// if (toc.TryGetValue("PlayerStart", out var spawns))
- /// {
- /// foreach (var spawn in spawns)
- /// {
- /// Console.WriteLine($"Spawn at ({spawn.WorldX}, {spawn.WorldY})");
- /// }
- /// }
- /// </code>
- /// </para>
- /// </remarks>
- public Dictionary<string, List<LDtkTocInstanceData>> GetTableOfContents(string filePath)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- LDtkProject project = LoadProject(fullPath);
- Dictionary<string, List<LDtkTocInstanceData>> toc = new Dictionary<string, List<LDtkTocInstanceData>>();
- if (project.Toc != null)
- {
- foreach (LDtkTableOfContentEntry entry in project.Toc)
- {
- if (!string.IsNullOrEmpty(entry.Identifier) && entry.InstancesData != null)
- {
- toc[entry.Identifier] = entry.InstancesData;
- }
- }
- }
- return toc;
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to read table of contents from LDtk file: {fullPath}", ex);
- }
- }
- /// <summary>
- /// Parses all levels from a specific world in a multi-world LDtk project.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <param name="worldIdentifier">The identifier of the world to parse.</param>
- /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
- /// <returns>A collection of <see cref="Tilemap"/> objects representing all levels in the specified world.</returns>
- /// <exception cref="ArgumentNullException">
- /// <paramref name="filePath"/>, <paramref name="worldIdentifier"/>, or
- /// <paramref name="graphicsDevice"/> is <see langword="null"/>.
- /// </exception>
- /// <exception cref="ArgumentException">The specified world was not found in the project.</exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- /// <remarks>
- /// <para>
- /// LDtk 1.4.0+ supports multiple worlds within a single project. Use this method to parse
- /// levels from a specific world when the "Multi-worlds" advanced option is enabled.
- /// </para>
- /// <para>
- /// For projects without multi-worlds enabled, use <see cref="ParseAllLevels"/> instead,
- /// which handles both single-world and multi-world projects automatically.
- /// </para>
- /// </remarks>
- public IReadOnlyList<Tilemap> ParseWorld(string filePath, string worldIdentifier, GraphicsDevice graphicsDevice)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- if (worldIdentifier == null)
- throw new ArgumentNullException(nameof(worldIdentifier));
- if (graphicsDevice == null)
- throw new ArgumentNullException(nameof(graphicsDevice));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- string projectDirectory = Path.GetDirectoryName(fullPath);
- LDtkProject project = LoadProject(fullPath);
- // Check if multi-worlds is enabled
- if (project.Worlds != null && project.Worlds.Count > 0)
- {
- LDtkWorld world = project.Worlds.FirstOrDefault(w => w.Identifier == worldIdentifier);
- if (world == null)
- throw new ArgumentException($"World '{worldIdentifier}' not found in project.", nameof(worldIdentifier));
- List<Tilemap> tilemaps = new List<Tilemap>(world.Levels.Count);
- foreach (LDtkLevel level in world.Levels)
- {
- Tilemap tilemap = ConvertLevel(level, project, graphicsDevice, projectDirectory);
- // Store world information in properties
- tilemap.Properties.SetString("LDtk_WorldIid", world.Iid);
- tilemap.Properties.SetString("LDtk_WorldIdentifier", world.Identifier);
- tilemaps.Add(tilemap);
- }
- return tilemaps;
- }
- else
- {
- // Project doesn't use multi-worlds
- throw new InvalidOperationException(
- "This LDtk project does not have multi-worlds enabled. " +
- "Use ParseAllLevels() instead, or enable multi-worlds in LDtk project settings.");
- }
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (ArgumentException)
- {
- throw;
- }
- catch (InvalidOperationException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to parse world '{worldIdentifier}' from LDtk file: {fullPath}", ex);
- }
- }
- /// <summary>
- /// Gets the list of world identifiers from an LDtk project file.
- /// </summary>
- /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
- /// <returns>A list of world identifiers, or an empty list if multi-worlds is not enabled.</returns>
- /// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
- /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
- /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
- public IReadOnlyList<string> GetWorldIdentifiers(string filePath)
- {
- if (filePath == null)
- throw new ArgumentNullException(nameof(filePath));
- // Resolve full path using base directory if provided
- string fullPath = _baseDirectory != null
- ? Path.Combine(_baseDirectory, filePath)
- : filePath;
- if (!File.Exists(fullPath))
- throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
- try
- {
- LDtkProject project = LoadProject(fullPath);
- if (project.Worlds != null && project.Worlds.Count > 0)
- {
- return project.Worlds
- .Where(w => !string.IsNullOrEmpty(w.Identifier))
- .Select(w => w.Identifier)
- .ToList();
- }
- return new List<string>();
- }
- catch (TilemapParseException)
- {
- throw;
- }
- catch (Exception ex)
- {
- throw new TilemapParseException($"Failed to read world list from LDtk file: {fullPath}", ex);
- }
- }
- private LDtkProject LoadProject(string filePath)
- {
- string json = File.ReadAllText(filePath);
- LDtkProject project = JsonSerializer.Deserialize<LDtkProject>(json, JsonOptions);
- if (project == null)
- throw new TilemapParseException($"Failed to deserialize LDtk project from file: {filePath}");
- return project;
- }
- private Tilemap ConvertLevel(LDtkLevel level, LDtkProject project, GraphicsDevice graphicsDevice, string projectDirectory)
- {
- string baseDirectory = projectDirectory ?? Directory.GetCurrentDirectory();
- // Load external level file if needed
- if (!string.IsNullOrEmpty(level.ExternalRelPath))
- {
- string levelPath = Path.Combine(baseDirectory, level.ExternalRelPath);
- if (File.Exists(levelPath))
- {
- string levelJson = File.ReadAllText(levelPath);
- LDtkLevel externalLevel = JsonSerializer.Deserialize<LDtkLevel>(levelJson, JsonOptions);
- if (externalLevel != null)
- {
- // Use the external level data, but keep the identifier/metadata from main file
- level = externalLevel;
- }
- }
- }
- return LDtkLevelConverter.Convert(level, project, graphicsDevice, baseDirectory);
- }
- }
- }
|