LDtkJsonParser.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Linq;
  5. using System.Text.Json;
  6. using Microsoft.Xna.Framework.Graphics;
  7. using MonoGame.Extended.Tilemaps.LDtk.Converters;
  8. using MonoGame.Extended.Tilemaps.Parsers;
  9. namespace MonoGame.Extended.Tilemaps.LDtk
  10. {
  11. /// <summary>
  12. /// Parser for LDtk JSON tilemap files.
  13. /// </summary>
  14. /// <remarks>
  15. /// LDtk is a modern, open-source 2D level editor. This parser supports LDtk format version 1.5.3
  16. /// and converts LDtk projects to the format-agnostic Tilemap API.
  17. /// <para>
  18. /// For more information about LDtk, visit: https://ldtk.io
  19. /// </para>
  20. /// </remarks>
  21. public class LDtkJsonParser : ITilemapParser
  22. {
  23. private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
  24. {
  25. PropertyNameCaseInsensitive = true,
  26. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  27. NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString
  28. };
  29. private readonly string _baseDirectory;
  30. /// <summary>
  31. /// Initializes a new instance of the <see cref="LDtkJsonParser"/> class.
  32. /// </summary>
  33. /// <param name="baseDirectory">
  34. /// Optional base directory for resolving relative file paths. If provided, file paths in
  35. /// <see cref="ParseFromFile"/> will be resolved relative to this directory.
  36. /// If <see langword="null"/>, paths are resolved from the file's own location.
  37. /// </param>
  38. public LDtkJsonParser(string baseDirectory = null)
  39. {
  40. _baseDirectory = baseDirectory;
  41. }
  42. /// <summary>
  43. /// Gets the file extensions supported by this parser.
  44. /// </summary>
  45. public IReadOnlyList<string> SupportedExtensions { get; } = new[] { ".ldtk" };
  46. /// <summary>
  47. /// Determines whether this parser can parse the specified file.
  48. /// </summary>
  49. /// <param name="filePath">The path to the file to check.</param>
  50. /// <returns><see langword="true"/> if the file can be parsed; otherwise, <see langword="false"/>.</returns>
  51. public bool CanParse(string filePath)
  52. {
  53. if (string.IsNullOrEmpty(filePath))
  54. return false;
  55. string extension = Path.GetExtension(filePath);
  56. return SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
  57. }
  58. /// <summary>
  59. /// Parses a tilemap from the specified file.
  60. /// </summary>
  61. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  62. /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
  63. /// <returns>A <see cref="Tilemap"/> representing the first level in the project.</returns>
  64. /// <exception cref="ArgumentNullException">
  65. /// <paramref name="filePath"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
  66. /// </exception>
  67. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  68. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  69. public Tilemap ParseFromFile(string filePath, GraphicsDevice graphicsDevice)
  70. {
  71. if (filePath == null)
  72. throw new ArgumentNullException(nameof(filePath));
  73. if (graphicsDevice == null)
  74. throw new ArgumentNullException(nameof(graphicsDevice));
  75. // Resolve full path using base directory if provided
  76. string fullPath = _baseDirectory != null
  77. ? Path.Combine(_baseDirectory, filePath)
  78. : filePath;
  79. if (!File.Exists(fullPath))
  80. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  81. try
  82. {
  83. using (FileStream stream = File.OpenRead(fullPath))
  84. {
  85. string directory = Path.GetDirectoryName(fullPath);
  86. return ParseFromStream(stream, graphicsDevice, directory);
  87. }
  88. }
  89. catch (TilemapParseException)
  90. {
  91. throw;
  92. }
  93. catch (Exception ex)
  94. {
  95. throw new TilemapParseException($"Failed to parse LDtk file: {fullPath}", ex);
  96. }
  97. }
  98. /// <summary>
  99. /// Parses a tilemap from the specified stream.
  100. /// </summary>
  101. /// <param name="stream">The stream containing the LDtk project data.</param>
  102. /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
  103. /// <param name="basePath">
  104. /// Optional base path for resolving relative file references. If not provided,
  105. /// uses the base directory from the constructor, or the current directory if neither is set.
  106. /// </param>
  107. /// <returns>A <see cref="Tilemap"/> representing the first level in the project.</returns>
  108. /// <exception cref="ArgumentNullException">
  109. /// <paramref name="stream"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
  110. /// </exception>
  111. /// <exception cref="TilemapParseException">An error occurred while parsing the stream.</exception>
  112. public Tilemap ParseFromStream(Stream stream, GraphicsDevice graphicsDevice, string basePath = null)
  113. {
  114. if (stream == null)
  115. throw new ArgumentNullException(nameof(stream));
  116. if (graphicsDevice == null)
  117. throw new ArgumentNullException(nameof(graphicsDevice));
  118. try
  119. {
  120. LDtkProject project = JsonSerializer.Deserialize<LDtkProject>(stream, JsonOptions);
  121. if (project == null)
  122. throw new TilemapParseException("Failed to deserialize LDtk project: result was null.");
  123. if (project.Levels == null || project.Levels.Count == 0)
  124. throw new TilemapParseException("LDtk project contains no levels.");
  125. // Use provided basePath, fall back to constructor base directory, then current directory
  126. string resolvedBasePath = basePath ?? _baseDirectory ?? Directory.GetCurrentDirectory();
  127. // Parse first level by default
  128. LDtkLevel firstLevel = project.Levels[0];
  129. return ConvertLevel(firstLevel, project, graphicsDevice, resolvedBasePath);
  130. }
  131. catch (JsonException ex)
  132. {
  133. throw new TilemapParseException("Failed to parse LDtk JSON data.", ex);
  134. }
  135. catch (TilemapParseException)
  136. {
  137. throw;
  138. }
  139. catch (Exception ex)
  140. {
  141. throw new TilemapParseException("An unexpected error occurred while parsing LDtk data.", ex);
  142. }
  143. }
  144. /// <summary>
  145. /// Parses a specific level from an LDtk project file.
  146. /// </summary>
  147. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  148. /// <param name="levelIdentifier">The identifier of the level to parse.</param>
  149. /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
  150. /// <returns>A <see cref="Tilemap"/> representing the specified level.</returns>
  151. /// <exception cref="ArgumentNullException">
  152. /// <paramref name="filePath"/>, <paramref name="levelIdentifier"/>, or
  153. /// <paramref name="graphicsDevice"/> is <see langword="null"/>.
  154. /// </exception>
  155. /// <exception cref="ArgumentException">The specified level was not found in the project.</exception>
  156. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  157. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  158. public Tilemap ParseLevel(string filePath, string levelIdentifier, GraphicsDevice graphicsDevice)
  159. {
  160. if (filePath == null)
  161. throw new ArgumentNullException(nameof(filePath));
  162. if (levelIdentifier == null)
  163. throw new ArgumentNullException(nameof(levelIdentifier));
  164. if (graphicsDevice == null)
  165. throw new ArgumentNullException(nameof(graphicsDevice));
  166. // Resolve full path using base directory if provided
  167. string fullPath = _baseDirectory != null
  168. ? Path.Combine(_baseDirectory, filePath)
  169. : filePath;
  170. if (!File.Exists(fullPath))
  171. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  172. try
  173. {
  174. string projectDirectory = Path.GetDirectoryName(fullPath);
  175. LDtkProject project = LoadProject(fullPath);
  176. LDtkLevel level = project.Levels.FirstOrDefault(l => l.Identifier == levelIdentifier);
  177. if (level == null)
  178. throw new ArgumentException($"Level '{levelIdentifier}' not found in project.", nameof(levelIdentifier));
  179. return ConvertLevel(level, project, graphicsDevice, projectDirectory);
  180. }
  181. catch (TilemapParseException)
  182. {
  183. throw;
  184. }
  185. catch (Exception ex)
  186. {
  187. throw new TilemapParseException($"Failed to parse level '{levelIdentifier}' from LDtk file: {fullPath}", ex);
  188. }
  189. }
  190. /// <summary>
  191. /// Parses all levels from an LDtk project file.
  192. /// </summary>
  193. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  194. /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
  195. /// <returns>A collection of <see cref="Tilemap"/> objects representing all levels in the project.</returns>
  196. /// <exception cref="ArgumentNullException">
  197. /// <paramref name="filePath"/> or <paramref name="graphicsDevice"/> is <see langword="null"/>.
  198. /// </exception>
  199. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  200. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  201. public IReadOnlyList<Tilemap> ParseAllLevels(string filePath, GraphicsDevice graphicsDevice)
  202. {
  203. if (filePath == null)
  204. throw new ArgumentNullException(nameof(filePath));
  205. if (graphicsDevice == null)
  206. throw new ArgumentNullException(nameof(graphicsDevice));
  207. // Resolve full path using base directory if provided
  208. string fullPath = _baseDirectory != null
  209. ? Path.Combine(_baseDirectory, filePath)
  210. : filePath;
  211. if (!File.Exists(fullPath))
  212. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  213. try
  214. {
  215. string projectDirectory = Path.GetDirectoryName(fullPath);
  216. LDtkProject project = LoadProject(fullPath);
  217. List<Tilemap> tilemaps = new List<Tilemap>(project.Levels.Count);
  218. foreach (LDtkLevel level in project.Levels)
  219. {
  220. Tilemap tilemap = ConvertLevel(level, project, graphicsDevice, projectDirectory);
  221. tilemaps.Add(tilemap);
  222. }
  223. return tilemaps;
  224. }
  225. catch (TilemapParseException)
  226. {
  227. throw;
  228. }
  229. catch (Exception ex)
  230. {
  231. throw new TilemapParseException($"Failed to parse LDtk file: {fullPath}", ex);
  232. }
  233. }
  234. /// <summary>
  235. /// Gets the world position of a level from its properties.
  236. /// </summary>
  237. /// <param name="tilemap">The tilemap to get world position from.</param>
  238. /// <returns>A tuple containing the world X and Y coordinates, or null if not available.</returns>
  239. /// <remarks>
  240. /// LDtk stores world coordinates in the level properties. This helper method extracts them
  241. /// for use in world-space level positioning.
  242. /// </remarks>
  243. public static (int worldX, int worldY)? GetWorldPosition(Tilemap tilemap)
  244. {
  245. if (tilemap == null)
  246. return null;
  247. bool hasWorldX = tilemap.Properties.TryGetValue("LDtk_WorldX", out TilemapPropertyValue worldXValue);
  248. bool hasWorldY = tilemap.Properties.TryGetValue("LDtk_WorldY", out TilemapPropertyValue worldYValue);
  249. if (hasWorldX && hasWorldY)
  250. {
  251. return (worldXValue.AsInt(), worldYValue.AsInt());
  252. }
  253. return null;
  254. }
  255. /// <summary>
  256. /// Finds a level by its world IID (Instance Identifier).
  257. /// </summary>
  258. /// <param name="tilemaps">The collection of tilemaps to search.</param>
  259. /// <param name="worldIid">The world IID to search for.</param>
  260. /// <returns>The tilemap with matching world IID, or null if not found.</returns>
  261. public static Tilemap FindLevelByIid(IEnumerable<Tilemap> tilemaps, string worldIid)
  262. {
  263. if (tilemaps == null || string.IsNullOrEmpty(worldIid))
  264. return null;
  265. return tilemaps.FirstOrDefault(t =>
  266. t.Properties.TryGetValue("LDtk_Iid", out TilemapPropertyValue iidValue) &&
  267. iidValue.AsString() == worldIid);
  268. }
  269. /// <summary>
  270. /// Gets the table of contents from an LDtk project file.
  271. /// </summary>
  272. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  273. /// <returns>A dictionary mapping entity identifiers to lists of their instance data.</returns>
  274. /// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
  275. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  276. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  277. /// <remarks>
  278. /// <para>
  279. /// The table of contents lists all entity instances that have their "exportToToc" flag enabled
  280. /// in the LDtk editor. This provides quick access to important entities (like spawn points,
  281. /// checkpoints, collectibles) without parsing entire levels.
  282. /// </para>
  283. /// <para>
  284. /// Each entry contains:
  285. /// - World coordinates (worldX, worldY)
  286. /// - Dimensions (widPx, heiPx)
  287. /// - IID references (entityIid, layerIid, levelIid, worldIid)
  288. /// - Custom field values (only fields marked for ToC export)
  289. /// </para>
  290. /// <para>
  291. /// Example usage:
  292. /// <code>
  293. /// var toc = LDtkJsonParser.GetTableOfContents("world.ldtk");
  294. /// if (toc.TryGetValue("PlayerStart", out var spawns))
  295. /// {
  296. /// foreach (var spawn in spawns)
  297. /// {
  298. /// Console.WriteLine($"Spawn at ({spawn.WorldX}, {spawn.WorldY})");
  299. /// }
  300. /// }
  301. /// </code>
  302. /// </para>
  303. /// </remarks>
  304. public Dictionary<string, List<LDtkTocInstanceData>> GetTableOfContents(string filePath)
  305. {
  306. if (filePath == null)
  307. throw new ArgumentNullException(nameof(filePath));
  308. // Resolve full path using base directory if provided
  309. string fullPath = _baseDirectory != null
  310. ? Path.Combine(_baseDirectory, filePath)
  311. : filePath;
  312. if (!File.Exists(fullPath))
  313. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  314. try
  315. {
  316. LDtkProject project = LoadProject(fullPath);
  317. Dictionary<string, List<LDtkTocInstanceData>> toc = new Dictionary<string, List<LDtkTocInstanceData>>();
  318. if (project.Toc != null)
  319. {
  320. foreach (LDtkTableOfContentEntry entry in project.Toc)
  321. {
  322. if (!string.IsNullOrEmpty(entry.Identifier) && entry.InstancesData != null)
  323. {
  324. toc[entry.Identifier] = entry.InstancesData;
  325. }
  326. }
  327. }
  328. return toc;
  329. }
  330. catch (TilemapParseException)
  331. {
  332. throw;
  333. }
  334. catch (Exception ex)
  335. {
  336. throw new TilemapParseException($"Failed to read table of contents from LDtk file: {fullPath}", ex);
  337. }
  338. }
  339. /// <summary>
  340. /// Parses all levels from a specific world in a multi-world LDtk project.
  341. /// </summary>
  342. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  343. /// <param name="worldIdentifier">The identifier of the world to parse.</param>
  344. /// <param name="graphicsDevice">The graphics device used for loading textures.</param>
  345. /// <returns>A collection of <see cref="Tilemap"/> objects representing all levels in the specified world.</returns>
  346. /// <exception cref="ArgumentNullException">
  347. /// <paramref name="filePath"/>, <paramref name="worldIdentifier"/>, or
  348. /// <paramref name="graphicsDevice"/> is <see langword="null"/>.
  349. /// </exception>
  350. /// <exception cref="ArgumentException">The specified world was not found in the project.</exception>
  351. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  352. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  353. /// <remarks>
  354. /// <para>
  355. /// LDtk 1.4.0+ supports multiple worlds within a single project. Use this method to parse
  356. /// levels from a specific world when the "Multi-worlds" advanced option is enabled.
  357. /// </para>
  358. /// <para>
  359. /// For projects without multi-worlds enabled, use <see cref="ParseAllLevels"/> instead,
  360. /// which handles both single-world and multi-world projects automatically.
  361. /// </para>
  362. /// </remarks>
  363. public IReadOnlyList<Tilemap> ParseWorld(string filePath, string worldIdentifier, GraphicsDevice graphicsDevice)
  364. {
  365. if (filePath == null)
  366. throw new ArgumentNullException(nameof(filePath));
  367. if (worldIdentifier == null)
  368. throw new ArgumentNullException(nameof(worldIdentifier));
  369. if (graphicsDevice == null)
  370. throw new ArgumentNullException(nameof(graphicsDevice));
  371. // Resolve full path using base directory if provided
  372. string fullPath = _baseDirectory != null
  373. ? Path.Combine(_baseDirectory, filePath)
  374. : filePath;
  375. if (!File.Exists(fullPath))
  376. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  377. try
  378. {
  379. string projectDirectory = Path.GetDirectoryName(fullPath);
  380. LDtkProject project = LoadProject(fullPath);
  381. // Check if multi-worlds is enabled
  382. if (project.Worlds != null && project.Worlds.Count > 0)
  383. {
  384. LDtkWorld world = project.Worlds.FirstOrDefault(w => w.Identifier == worldIdentifier);
  385. if (world == null)
  386. throw new ArgumentException($"World '{worldIdentifier}' not found in project.", nameof(worldIdentifier));
  387. List<Tilemap> tilemaps = new List<Tilemap>(world.Levels.Count);
  388. foreach (LDtkLevel level in world.Levels)
  389. {
  390. Tilemap tilemap = ConvertLevel(level, project, graphicsDevice, projectDirectory);
  391. // Store world information in properties
  392. tilemap.Properties.SetString("LDtk_WorldIid", world.Iid);
  393. tilemap.Properties.SetString("LDtk_WorldIdentifier", world.Identifier);
  394. tilemaps.Add(tilemap);
  395. }
  396. return tilemaps;
  397. }
  398. else
  399. {
  400. // Project doesn't use multi-worlds
  401. throw new InvalidOperationException(
  402. "This LDtk project does not have multi-worlds enabled. " +
  403. "Use ParseAllLevels() instead, or enable multi-worlds in LDtk project settings.");
  404. }
  405. }
  406. catch (TilemapParseException)
  407. {
  408. throw;
  409. }
  410. catch (ArgumentException)
  411. {
  412. throw;
  413. }
  414. catch (InvalidOperationException)
  415. {
  416. throw;
  417. }
  418. catch (Exception ex)
  419. {
  420. throw new TilemapParseException($"Failed to parse world '{worldIdentifier}' from LDtk file: {fullPath}", ex);
  421. }
  422. }
  423. /// <summary>
  424. /// Gets the list of world identifiers from an LDtk project file.
  425. /// </summary>
  426. /// <param name="filePath">The path to the LDtk project file (.ldtk).</param>
  427. /// <returns>A list of world identifiers, or an empty list if multi-worlds is not enabled.</returns>
  428. /// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
  429. /// <exception cref="FileNotFoundException">The specified file does not exist.</exception>
  430. /// <exception cref="TilemapParseException">An error occurred while parsing the file.</exception>
  431. public IReadOnlyList<string> GetWorldIdentifiers(string filePath)
  432. {
  433. if (filePath == null)
  434. throw new ArgumentNullException(nameof(filePath));
  435. // Resolve full path using base directory if provided
  436. string fullPath = _baseDirectory != null
  437. ? Path.Combine(_baseDirectory, filePath)
  438. : filePath;
  439. if (!File.Exists(fullPath))
  440. throw new FileNotFoundException($"LDtk file not found: {fullPath}", fullPath);
  441. try
  442. {
  443. LDtkProject project = LoadProject(fullPath);
  444. if (project.Worlds != null && project.Worlds.Count > 0)
  445. {
  446. return project.Worlds
  447. .Where(w => !string.IsNullOrEmpty(w.Identifier))
  448. .Select(w => w.Identifier)
  449. .ToList();
  450. }
  451. return new List<string>();
  452. }
  453. catch (TilemapParseException)
  454. {
  455. throw;
  456. }
  457. catch (Exception ex)
  458. {
  459. throw new TilemapParseException($"Failed to read world list from LDtk file: {fullPath}", ex);
  460. }
  461. }
  462. private LDtkProject LoadProject(string filePath)
  463. {
  464. string json = File.ReadAllText(filePath);
  465. LDtkProject project = JsonSerializer.Deserialize<LDtkProject>(json, JsonOptions);
  466. if (project == null)
  467. throw new TilemapParseException($"Failed to deserialize LDtk project from file: {filePath}");
  468. return project;
  469. }
  470. private Tilemap ConvertLevel(LDtkLevel level, LDtkProject project, GraphicsDevice graphicsDevice, string projectDirectory)
  471. {
  472. string baseDirectory = projectDirectory ?? Directory.GetCurrentDirectory();
  473. // Load external level file if needed
  474. if (!string.IsNullOrEmpty(level.ExternalRelPath))
  475. {
  476. string levelPath = Path.Combine(baseDirectory, level.ExternalRelPath);
  477. if (File.Exists(levelPath))
  478. {
  479. string levelJson = File.ReadAllText(levelPath);
  480. LDtkLevel externalLevel = JsonSerializer.Deserialize<LDtkLevel>(levelJson, JsonOptions);
  481. if (externalLevel != null)
  482. {
  483. // Use the external level data, but keep the identifier/metadata from main file
  484. level = externalLevel;
  485. }
  486. }
  487. }
  488. return LDtkLevelConverter.Convert(level, project, graphicsDevice, baseDirectory);
  489. }
  490. }
  491. }