From d2be83972d200fb2beddb03eedd2f7e354d8f373 Mon Sep 17 00:00:00 2001 From: Daniel Cronqvist Date: Fri, 26 Jul 2024 00:36:57 +0200 Subject: [PATCH] First initial modelling, going to move serialization away from model --- .editorconfig | 8 + .gitignore | 1 + DotTiled.Tests/DotTiled.Tests.csproj | 27 + DotTiled.Tests/MapTests.cs | 259 +++++++++ DotTiled.sln | 28 + DotTiled/DotTiled.csproj | 9 + DotTiled/Map.cs | 809 +++++++++++++++++++++++++++ DotTiled/XML/ExtensionsXmlReader.cs | 109 ++++ DotTiled/XML/XmlHelpers.cs | 52 ++ Makefile | 0 10 files changed, 1302 insertions(+) create mode 100644 .editorconfig create mode 100644 DotTiled.Tests/DotTiled.Tests.csproj create mode 100644 DotTiled.Tests/MapTests.cs create mode 100644 DotTiled.sln create mode 100644 DotTiled/DotTiled.csproj create mode 100644 DotTiled/Map.cs create mode 100644 DotTiled/XML/ExtensionsXmlReader.cs create mode 100644 DotTiled/XML/XmlHelpers.cs create mode 100644 Makefile diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..70cefcd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*.cs] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a30d25..aa2d18e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.user *.userosscache *.sln.docstates +.vscode/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/DotTiled.Tests/DotTiled.Tests.csproj b/DotTiled.Tests/DotTiled.Tests.csproj new file mode 100644 index 0000000..fe5627e --- /dev/null +++ b/DotTiled.Tests/DotTiled.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/DotTiled.Tests/MapTests.cs b/DotTiled.Tests/MapTests.cs new file mode 100644 index 0000000..450e170 --- /dev/null +++ b/DotTiled.Tests/MapTests.cs @@ -0,0 +1,259 @@ +using System.Text; +using System.Xml.Serialization; +using DotTiled; + +namespace DotTiled.Tests; + +public class MapTests +{ + [Fact] + public void ReadXml_Always_SetsRequiredAttributes() + { + // Arrange + var xml = + """ + + + + """; + var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + + // Act + var map = Map.LoadFromStream(xmlStream); + + // Assert + // Assert all required properties are set + Assert.Equal("1.2", map.Version); + Assert.Equal("class", map.Class); + Assert.Equal(Orientation.Orthogonal, map.Orientation); + Assert.Equal(RenderOrder.RightDown, map.RenderOrder); + Assert.Equal(5, map.CompressionLevel); + Assert.Equal(10u, map.Width); + Assert.Equal(10u, map.Height); + Assert.Equal(32u, map.TileWidth); + Assert.Equal(32u, map.TileHeight); + Assert.Equal(0.5f, map.ParallaxOriginX); + Assert.Equal(0.5f, map.ParallaxOriginY); + Assert.Equal(1u, map.NextLayerId); + Assert.Equal(1u, map.NextObjectId); + Assert.True(map.Infinite); + + // Assert all optional properties are set to their default values + Assert.Null(map.TiledVersion); + Assert.Null(map.HexSideLength); + Assert.Null(map.StaggerAxis); + Assert.Null(map.StaggerIndex); + Assert.Null(map.BackgroundColor); + } + + public static IEnumerable ColorData => + new List + { + new object[] { "#ff0000", new TiledColor { R = 255, G = 0, B = 0, A = 255 } }, + new object[] { "#00ff00", new TiledColor { R = 0, G = 255, B = 0, A = 255 } }, + new object[] { "#0000ff", new TiledColor { R = 0, G = 0, B = 255, A = 255 } }, + new object[] { "#ffffff", new TiledColor { R = 255, G = 255, B = 255, A = 255 } }, + new object[] { "#000000", new TiledColor { R = 0, G = 0, B = 0, A = 255 } }, + new object[] { "#ff000000", new TiledColor { R = 0, G = 0, B = 0, A = 255 } }, + new object[] { "#fe000000", new TiledColor { R = 0, G = 0, B = 0, A = 254 } }, + new object[] { "#fe00ff00", new TiledColor { R = 0, G = 255, B = 0, A = 254 } }, + }; + + [Theory] + [MemberData(nameof(ColorData))] + public void ReadXml_WhenPresent_SetsOptionalAttributes(string color, TiledColor expectedColor) + { + // Arrange + var xml = + $""" + + + + """; + var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + + // Act + var map = Map.LoadFromStream(xmlStream); + + // Assert + // Assert all required properties are set + Assert.Equal("1.2", map.Version); + Assert.Equal("class", map.Class); + Assert.Equal(Orientation.Orthogonal, map.Orientation); + Assert.Equal(RenderOrder.RightDown, map.RenderOrder); + Assert.Equal(5, map.CompressionLevel); + Assert.Equal(10u, map.Width); + Assert.Equal(10u, map.Height); + Assert.Equal(32u, map.TileWidth); + Assert.Equal(32u, map.TileHeight); + Assert.Equal(10u, map.HexSideLength); + Assert.Equal(StaggerAxis.Y, map.StaggerAxis); + Assert.Equal(StaggerIndex.Odd, map.StaggerIndex); + Assert.Equal(0.5f, map.ParallaxOriginX); + Assert.Equal(0.5f, map.ParallaxOriginY); + Assert.Equal(expectedColor, map.BackgroundColor); + Assert.Equal(1u, map.NextLayerId); + Assert.Equal(1u, map.NextObjectId); + Assert.True(map.Infinite); + } + + [Fact] + public void ReadXml_Always_ReadsPropertiesCorrectly() + { + // Arrange + var xml = + """ + + + + + + + + + + + + + + + + + + + """; + var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + + // Act + var map = Map.LoadFromStream(xmlStream); + + // Assert + Assert.NotNull(map.Properties); + Assert.Equal(8, map.Properties.Count); + + Assert.Equal(PropertyType.String, map.Properties["string"].Type); + Assert.Equal("string", map.GetProperty("string").Value); + + Assert.Equal(PropertyType.Int, map.Properties["int"].Type); + Assert.Equal(42, map.GetProperty("int").Value); + + Assert.Equal(PropertyType.Float, map.Properties["float"].Type); + Assert.Equal(42.42f, map.GetProperty("float").Value); + + Assert.Equal(PropertyType.Bool, map.Properties["bool"].Type); + Assert.True(map.GetProperty("bool").Value); + + Assert.Equal(PropertyType.Color, map.Properties["color"].Type); + Assert.Equal(new TiledColor { R = 255, G = 0, B = 0, A = 255 }, map.GetProperty("color").Value); + + Assert.Equal(PropertyType.File, map.Properties["file"].Type); + Assert.Equal("file", map.GetProperty("file").Value); + + Assert.Equal(PropertyType.Object, map.Properties["object"].Type); + Assert.Equal(5, map.GetProperty("object").Value); + + Assert.Equal(PropertyType.Class, map.Properties["class"].Type); + var classProperty = map.GetProperty("class"); + Assert.Equal("TestClass", classProperty.PropertyType); + Assert.Equal(2, classProperty.Value.Count); + Assert.Equal("string", classProperty.GetProperty("TestClassString").Value); + Assert.Equal(43, classProperty.GetProperty("TestClassInt").Value); + } + + [Fact] + public void ReadXml_Always_1() + { + // Arrange + var xml = + """ + + + + + + + + + + + + + + + + + + + + """; + var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + + // Act + var map = Map.LoadFromStream(xmlStream); + } +} + diff --git a/DotTiled.sln b/DotTiled.sln new file mode 100644 index 0000000..86b44df --- /dev/null +++ b/DotTiled.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotTiled", "DotTiled\DotTiled.csproj", "{80A60DE7-D6AE-4CC7-825F-75308D83F36D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotTiled.Tests", "DotTiled.Tests\DotTiled.Tests.csproj", "{C1311A5A-5206-467C-B323-B131CA11FDB8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {80A60DE7-D6AE-4CC7-825F-75308D83F36D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80A60DE7-D6AE-4CC7-825F-75308D83F36D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80A60DE7-D6AE-4CC7-825F-75308D83F36D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80A60DE7-D6AE-4CC7-825F-75308D83F36D}.Release|Any CPU.Build.0 = Release|Any CPU + {C1311A5A-5206-467C-B323-B131CA11FDB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1311A5A-5206-467C-B323-B131CA11FDB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1311A5A-5206-467C-B323-B131CA11FDB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1311A5A-5206-467C-B323-B131CA11FDB8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/DotTiled/DotTiled.csproj b/DotTiled/DotTiled.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/DotTiled/DotTiled.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/DotTiled/Map.cs b/DotTiled/Map.cs new file mode 100644 index 0000000..5630f6d --- /dev/null +++ b/DotTiled/Map.cs @@ -0,0 +1,809 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace DotTiled; + +public static class Helpers +{ + public static void SetAtMostOnce(ref T? field, T value, string fieldName) + { + if (field is not null) + throw new XmlException($"{fieldName} already set"); + + field = value; + } +} + +public enum Orientation +{ + [XmlEnum(Name = "orthogonal")] + Orthogonal, + + [XmlEnum(Name = "isometric")] + Isometric, + + [XmlEnum(Name = "staggered")] + Staggered, + + [XmlEnum(Name = "hexagonal")] + Hexagonal +} + +public enum RenderOrder +{ + [XmlEnum(Name = "right-down")] + RightDown, + + [XmlEnum(Name = "right-up")] + RightUp, + + [XmlEnum(Name = "left-down")] + LeftDown, + + [XmlEnum(Name = "left-up")] + LeftUp +} + +public enum StaggerAxis +{ + [XmlEnum(Name = "x")] + X, + + [XmlEnum(Name = "y")] + Y +} + +public enum StaggerIndex +{ + [XmlEnum(Name = "even")] + Even, + + [XmlEnum(Name = "odd")] + Odd +} + +public class TiledColor : IParsable, IEquatable +{ + public required byte R { get; set; } + public required byte G { get; set; } + public required byte B { get; set; } + public byte A { get; set; } = 255; + + public static TiledColor Parse(string s, IFormatProvider? provider) + { + TryParse(s, provider, out var result); + return result ?? throw new FormatException($"Invalid format for TiledColor: {s}"); + } + + public static bool TryParse( + [NotNullWhen(true)] string? s, + IFormatProvider? provider, + [MaybeNullWhen(false)] out TiledColor result) + { + // Format: #RRGGBB or #AARRGGBB + if (s is null || s.Length != 7 && s.Length != 9 || s[0] != '#') + { + result = default; + return false; + } + + if (s.Length == 7) + { + result = new TiledColor + { + R = byte.Parse(s[1..3], NumberStyles.HexNumber, provider), + G = byte.Parse(s[3..5], NumberStyles.HexNumber, provider), + B = byte.Parse(s[5..7], NumberStyles.HexNumber, provider) + }; + } + else + { + result = new TiledColor + { + A = byte.Parse(s[1..3], NumberStyles.HexNumber, provider), + R = byte.Parse(s[3..5], NumberStyles.HexNumber, provider), + G = byte.Parse(s[5..7], NumberStyles.HexNumber, provider), + B = byte.Parse(s[7..9], NumberStyles.HexNumber, provider) + }; + } + + return true; + } + + public bool Equals(TiledColor? other) + { + if (other is null) + return false; + + return R == other.R && G == other.G && B == other.B && A == other.A; + } + + public override bool Equals(object? obj) => obj is TiledColor other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(R, G, B, A); +} + +public enum PropertyType +{ + [XmlEnum(Name = "string")] + String, + + [XmlEnum(Name = "int")] + Int, + + [XmlEnum(Name = "float")] + Float, + + [XmlEnum(Name = "bool")] + Bool, + + [XmlEnum(Name = "color")] + Color, + + [XmlEnum(Name = "file")] + File, + + [XmlEnum(Name = "object")] + Object, + + [XmlEnum(Name = "class")] + Class +} + +[XmlRoot(ElementName = "property")] +public interface IProperty : IXmlSerializable +{ + public string Name { get; set; } + public PropertyType Type { get; set; } +} + +[XmlRoot(ElementName = "property")] +public class BooleanProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required bool Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class ColorProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required TiledColor Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class FileProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required string Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class FloatProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required float Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class IntProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required int Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class ObjectProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required int Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class StringProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required string Value { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + Value = reader.GetRequiredAttribute("value"); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "property")] +public class ClassProperty : IProperty +{ + public required string Name { get; set; } + public required PropertyType Type { get; set; } + public required string PropertyType { get; set; } + public required Dictionary Value { get; set; } + + public T GetProperty(string propertyName) where T : IProperty => + (T)Value[propertyName]; + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + Name = reader.GetRequiredAttribute("name"); + Type = reader.GetRequiredAttributeEnum("type"); + PropertyType = reader.GetRequiredAttribute("propertytype"); + + // First read the start element + reader.ReadStartElement("property"); + // Then read the properties + Value = XmlHelpers.ReadProperties(reader); + // Finally read the end element + reader.ReadEndElement(); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +public enum ObjectAlignment +{ + [XmlEnum(Name = "unspecified")] + Unspecified, + + [XmlEnum(Name = "topleft")] + TopLeft, + + [XmlEnum(Name = "top")] + Top, + + [XmlEnum(Name = "topright")] + TopRight, + + [XmlEnum(Name = "left")] + Left, + + [XmlEnum(Name = "center")] + Center, + + [XmlEnum(Name = "right")] + Right, + + [XmlEnum(Name = "bottomleft")] + BottomLeft, + + [XmlEnum(Name = "bottom")] + Bottom, + + [XmlEnum(Name = "bottomright")] + BottomRight +} + +public enum TileRenderSize +{ + [XmlEnum(Name = "tile")] + Tile, + + [XmlEnum(Name = "grid")] + Grid +} + +public enum FillMode +{ + [XmlEnum(Name = "stretch")] + Stretch, + + [XmlEnum(Name = "preserve-aspect-fit")] + PreserveAspectFit +} + +public enum ImageFormat +{ + [XmlEnum(Name = "png")] + Png, + + [XmlEnum(Name = "gif")] + Gif, + + [XmlEnum(Name = "jpg")] + Jpg, + + [XmlEnum(Name = "bmp")] + Bmp +} + +public enum TiledDataEncoding +{ + [XmlEnum(Name = "csv")] + Csv, + + [XmlEnum(Name = "base64")] + Base64 +} + +public enum TiledDataCompression +{ + [XmlEnum(Name = "gzip")] + GZip, + + [XmlEnum(Name = "zlib")] + ZLib, + + [XmlEnum(Name = "zstd")] + ZStd +} + +[XmlRoot(ElementName = "data")] +public class TiledData : IXmlSerializable +{ + public TiledDataEncoding? Encoding { get; set; } + public TiledDataCompression? Compression { get; set; } + public required int[] Data { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + ReadXmlAttributes(reader); + ReadXmlElements(reader); + } + + private void ReadXmlAttributes(XmlReader reader) + { + Encoding = reader.GetOptionalAttributeEnum("encoding"); + Compression = reader.GetOptionalAttributeEnum("compression"); + } + + private void ReadXmlElements(XmlReader reader) + { + if (Encoding is null && Compression is null) + { + // Plain csv + reader.ReadStartElement("data"); + var dataAsCsvStringFromFile = reader.ReadContentAsString(); + var data = dataAsCsvStringFromFile + .Split((char[])['\n', '\r', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .ToArray(); + Data = data; + reader.ReadEndElement(); + } + + throw new NotImplementedException(); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "image")] +public class Image : IXmlSerializable +{ + public ImageFormat? Format { get; set; } + public string? ID { get; set; } = null; // Deprecated and unsupported + public string? Source { get; set; } + public TiledColor? TransparentColor { get; set; } + public uint? Width { get; set; } + public uint? Height { get; set; } + + private TiledData? _data = null; + public TiledData? Data + { + get => _data; + set => _data = value; + } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + ReadXmlAttributes(reader); + ReadXmlElements(reader); + } + + private void ReadXmlAttributes(XmlReader reader) + { + Format = reader.GetOptionalAttributeEnum("format"); + ID = reader.GetOptionalAttribute("id"); + Source = reader.GetOptionalAttribute("source"); + TransparentColor = reader.GetOptionalAttributeClass("trans"); + Width = reader.GetOptionalAttribute("width"); + Height = reader.GetOptionalAttribute("height"); + } + + private void ReadXmlElements(XmlReader reader) + { + reader.ReadStartElement("image"); + + while (reader.IsStartElement()) + { + var name = reader.Name; + Action action = name switch + { + "data" => () => Helpers.SetAtMostOnce(ref _data, reader.ReadElementAs(), "Data"), + _ => reader.Skip + }; + + action(); + + if (reader.NodeType == XmlNodeType.EndElement) + return; + } + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +public abstract class BaseTileset : IXmlSerializable +{ + public required string? FirstGID { get; set; } // Not set in tsx + public required string? Source { get; set; } // Not set in tsx + public required string Name { get; set; } + public required string Class { get; set; } + public required uint TileWidth { get; set; } + public required uint TileHeight { get; set; } + public required uint? Spacing { get; set; } + public required uint? Margin { get; set; } + public required uint TileCount { get; set; } + public required uint Columns { get; set; } + public required ObjectAlignment ObjectAlignment { get; set; } + public required TileRenderSize TileRenderSize { get; set; } + public required FillMode FillMode { get; set; } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + ReadXmlAttributes(reader); + ReadXmlElements(reader); + } + + private void ReadXmlAttributes(XmlReader reader) + { + FirstGID = reader.GetOptionalAttribute("firstgid"); + Source = reader.GetOptionalAttribute("source"); + Name = reader.GetRequiredAttribute("name"); + Class = reader.GetOptionalAttribute("class") ?? ""; // default value + TileWidth = reader.GetRequiredAttribute("tilewidth"); + TileHeight = reader.GetRequiredAttribute("tileheight"); + Spacing = reader.GetOptionalAttribute("spacing"); + Margin = reader.GetOptionalAttribute("margin"); + TileCount = reader.GetRequiredAttribute("tilecount"); + Columns = reader.GetRequiredAttribute("columns"); + ObjectAlignment = reader.GetOptionalAttributeEnum("objectalignment") ?? ObjectAlignment.Unspecified; + TileRenderSize = reader.GetOptionalAttributeEnum("tilerendersize") ?? TileRenderSize.Tile; + FillMode = reader.GetOptionalAttributeEnum("fillmode") ?? FillMode.Stretch; + } + + protected abstract void ReadXmlElements(XmlReader reader); + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "tileset")] +public class ImageTileset : BaseTileset +{ + private Image? _image = null; + public required Image Image + { + get => _image ?? throw new InvalidOperationException("Image not set"); // Should not be able to happen + set => _image = value; + } + + protected override void ReadXmlElements(XmlReader reader) + { + // Different types of tilesets + reader.ReadStartElement("tileset"); + + while (reader.IsStartElement()) + { + var name = reader.Name; + Action action = name switch + { + "image" => () => Helpers.SetAtMostOnce(ref _image, reader.ReadElementAs(), "Image"), + "tileoffset" => reader.Skip, + "tile" => reader.Skip, + "terraintypes" => reader.Skip, + "wangsets" => reader.Skip, + _ => reader.Skip + }; + + action(); + + if (reader.NodeType == XmlNodeType.EndElement) + return; + } + } +} + +[XmlRoot(ElementName = "layer")] +public class Layer : IXmlSerializable +{ + public required string ID { get; set; } + public required string Name { get; set; } + public required string Class { get; set; } + public required uint X { get; set; } + public required uint Y { get; set; } + public required uint Width { get; set; } + public required uint Height { get; set; } + public required float Opacity { get; set; } + public required bool Visible { get; set; } + public required TiledColor? TintColor { get; set; } + public required float OffsetX { get; set; } + public required float OffsetY { get; set; } + public required float ParallaxX { get; set; } + public required float ParallaxY { get; set; } + + private Dictionary? _properties = null; + public required Dictionary? Properties + { + get => _properties; + set => _properties = value; + } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + ReadXmlAttributes(reader); + ReadXmlElements(reader); + } + + private void ReadXmlAttributes(XmlReader reader) + { + ID = reader.GetRequiredAttribute("id"); + Name = reader.GetRequiredAttribute("name"); + Class = reader.GetOptionalAttribute("class") ?? ""; // default value + X = reader.GetRequiredAttribute("x"); + Y = reader.GetRequiredAttribute("y"); + Width = reader.GetRequiredAttribute("width"); + Height = reader.GetRequiredAttribute("height"); + Opacity = reader.GetRequiredAttribute("opacity"); + Visible = reader.GetRequiredAttribute("visible") == 1; + TintColor = reader.GetOptionalAttributeClass("tintcolor"); + OffsetX = reader.GetRequiredAttribute("offsetx"); + OffsetY = reader.GetRequiredAttribute("offsety"); + ParallaxX = reader.GetRequiredAttribute("parallaxx"); + ParallaxY = reader.GetRequiredAttribute("parallaxy"); + } + + private void ReadXmlElements(XmlReader reader) + { + reader.ReadStartElement("layer"); + + while (reader.IsStartElement()) + { + var name = reader.Name; + Action action = name switch + { + "properties" => () => Helpers.SetAtMostOnce(ref _properties, XmlHelpers.ReadProperties(reader), "Properties"), + "data" => reader.Skip, + _ => reader.Skip + }; + + action(); + } + + reader.ReadEndElement(); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} + +[XmlRoot(ElementName = "map")] +public class Map : IXmlSerializable +{ + public required string Version { get; set; } + public string? TiledVersion { get; set; } + public required string Class { get; set; } + public required Orientation Orientation { get; set; } + public required RenderOrder RenderOrder { get; set; } + public required int CompressionLevel { get; set; } + public required uint Width { get; set; } + public required uint Height { get; set; } + public required uint TileWidth { get; set; } + public required uint TileHeight { get; set; } + public uint? HexSideLength { get; set; } + public StaggerAxis? StaggerAxis { get; set; } + public StaggerIndex? StaggerIndex { get; set; } + public required float ParallaxOriginX { get; set; } + public required float ParallaxOriginY { get; set; } + public TiledColor? BackgroundColor { get; set; } + public required uint NextLayerId { get; set; } + public required uint NextObjectId { get; set; } + public required bool Infinite { get; set; } + + private Dictionary? _properties = null; + public required Dictionary? Properties + { + get => _properties; + set => _properties = value; + } + + public required List Tilesets { get; set; } = []; + + public T GetProperty(string propertyName) where T : IProperty + { + if (Properties is null) + throw new InvalidOperationException("Properties not set"); + + return (T)Properties[propertyName]; + } + + public static Map LoadFromStream(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8); + var serializer = new XmlSerializer(typeof(Map)); + return (Map)serializer.Deserialize(reader)!; + } + + public XmlSchema? GetSchema() => null; + + public void ReadXml(XmlReader reader) + { + ReadXmlAttributes(reader); + ReadXmlElements(reader, (s) => null); + } + + private void ReadXmlAttributes(XmlReader reader) + { + Version = reader.GetRequiredAttribute("version"); + TiledVersion = reader.GetOptionalAttribute("tiledversion"); + Class = reader.GetOptionalAttribute("class") ?? ""; // default value + Orientation = reader.GetRequiredAttributeEnum("orientation"); + RenderOrder = reader.GetRequiredAttributeEnum("renderorder"); + CompressionLevel = reader.GetRequiredAttribute("compressionlevel"); + Width = reader.GetRequiredAttribute("width"); + Height = reader.GetRequiredAttribute("height"); + TileWidth = reader.GetRequiredAttribute("tilewidth"); + TileHeight = reader.GetRequiredAttribute("tileheight"); + HexSideLength = reader.GetOptionalAttribute("hexsidelength"); + StaggerAxis = reader.GetOptionalAttributeEnum("staggeraxis"); + StaggerIndex = reader.GetOptionalAttributeEnum("staggerindex"); + ParallaxOriginX = reader.GetRequiredAttribute("parallaxoriginx"); + ParallaxOriginY = reader.GetRequiredAttribute("parallaxoriginy"); + BackgroundColor = reader.GetOptionalAttributeClass("backgroundcolor"); + NextLayerId = reader.GetRequiredAttribute("nextlayerid"); + NextObjectId = reader.GetRequiredAttribute("nextobjectid"); + Infinite = reader.GetRequiredAttribute("infinite") == 1; + } + + private void ReadXmlElements(XmlReader reader, Func tilesetResolver) + { + reader.ReadStartElement("map"); + + while (reader.IsStartElement()) + { + var name = reader.Name; + Action action = name switch + { + "properties" => () => Helpers.SetAtMostOnce(ref _properties, XmlHelpers.ReadProperties(reader), "Properties"), + "editorsettings" => reader.Skip, + "tileset" => () => Tilesets.Add(XmlHelpers.ReadTileset(reader, tilesetResolver)), + _ => reader.Skip + }; + + action(); + } + + reader.ReadEndElement(); + } + + public void WriteXml(XmlWriter writer) + { + throw new NotImplementedException(); + } +} diff --git a/DotTiled/XML/ExtensionsXmlReader.cs b/DotTiled/XML/ExtensionsXmlReader.cs new file mode 100644 index 0000000..700325e --- /dev/null +++ b/DotTiled/XML/ExtensionsXmlReader.cs @@ -0,0 +1,109 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Serialization; + +namespace DotTiled; + +internal static class ExtensionsXmlReader +{ + internal static string GetRequiredAttribute(this XmlReader reader, string attribute) + { + return reader.GetAttribute(attribute) ?? throw new XmlException($"{attribute} attribute is required"); ; + } + + internal static T GetRequiredAttribute(this XmlReader reader, string attribute) where T : IParsable + { + var value = reader.GetAttribute(attribute) ?? throw new XmlException($"{attribute} attribute is required"); + return T.Parse(value, CultureInfo.InvariantCulture); + } + + internal static T GetRequiredAttributeEnum(this XmlReader reader, string attribute) where T : Enum + { + var value = reader.GetAttribute(attribute) ?? throw new XmlException($"{attribute} attribute is required"); + return ParseEnumUsingXmlEnumAttribute(value); + } + + internal static string? GetOptionalAttribute(this XmlReader reader, string attribute, string? defaultValue = default) + { + return reader.GetAttribute(attribute) ?? defaultValue; + } + + internal static T? GetOptionalAttribute(this XmlReader reader, string attribute) where T : struct, IParsable + { + var value = reader.GetAttribute(attribute); + if (value is null) + return null; + + return T.Parse(value, CultureInfo.InvariantCulture); + } + + internal static T? GetOptionalAttributeClass(this XmlReader reader, string attribute) where T : class, IParsable + { + var value = reader.GetAttribute(attribute); + if (value is null) + return null; + + return T.Parse(value, CultureInfo.InvariantCulture); + } + + internal static T? GetOptionalAttributeEnum(this XmlReader reader, string attribute) where T : struct, Enum + { + var value = reader.GetAttribute(attribute); + return value != null ? ParseEnumUsingXmlEnumAttribute(value) : null; + } + + internal static T ParseEnumUsingXmlEnumAttribute(string value) where T : Enum + { + var enumType = typeof(T); + var enumValues = Enum.GetValues(enumType); + foreach (var enumValue in enumValues) + { + var enumMember = enumType.GetMember(enumValue.ToString()!)[0]; + var xmlEnumAttribute = enumMember.GetCustomAttributes(typeof(XmlEnumAttribute), false).FirstOrDefault() as XmlEnumAttribute; + if (xmlEnumAttribute?.Name == value) + return (T)enumValue; + } + + throw new XmlException($"Failed to parse enum value {value}"); + } + + internal static List ReadList(this XmlReader reader, string wrapper, string elementName, Func readElement) + { + var list = new List(); + + if (reader.IsEmptyElement) + return list; + + reader.ReadStartElement(wrapper); + while (reader.IsStartElement(elementName)) + { + list.Add(readElement(reader)); + + if (reader.NodeType == XmlNodeType.EndElement) + continue; // At end of list, no need to read again + + reader.Read(); + } + reader.ReadEndElement(); + + return list; + } + + public static T ReadElementAs(this XmlReader reader) where T : IXmlSerializable + { + var serializer = new XmlSerializer(typeof(T)); + return (T)serializer.Deserialize(reader)!; + } + + public static int CountDirectChildrenWithName(this XmlReader reader, string name) + { + var subTree = reader.ReadSubtree(); + int count = 0; + while (subTree.Read()) + { + if (subTree.NodeType == XmlNodeType.Element && subTree.Name == name) + count++; + } + return count; + } +} diff --git a/DotTiled/XML/XmlHelpers.cs b/DotTiled/XML/XmlHelpers.cs new file mode 100644 index 0000000..f8aa799 --- /dev/null +++ b/DotTiled/XML/XmlHelpers.cs @@ -0,0 +1,52 @@ +using System.Xml; +using System.Xml.Serialization; + +namespace DotTiled; + +public static class XmlHelpers +{ + public static Dictionary ReadProperties(XmlReader reader) + { + return reader.ReadList<(string PropName, IProperty Prop)>("properties", "property", + reader => + { + var type = reader.GetRequiredAttributeEnum("type"); + var propertyRuntimeType = type switch + { + PropertyType.String => typeof(StringProperty), + PropertyType.Int => typeof(IntProperty), + PropertyType.Float => typeof(FloatProperty), + PropertyType.Bool => typeof(BooleanProperty), + PropertyType.Color => typeof(ColorProperty), + PropertyType.File => typeof(FileProperty), + PropertyType.Object => typeof(ObjectProperty), + PropertyType.Class => typeof(ClassProperty), + _ => throw new XmlException("Invalid property type") + }; + + var serializer = new XmlSerializer(propertyRuntimeType); + var deserializedProperty = (IProperty)serializer.Deserialize(reader)!; + return (deserializedProperty.Name, deserializedProperty); + } + ).ToDictionary(x => x.PropName, x => x.Prop); + } + + public static BaseTileset ReadTileset(XmlReader reader, Func tilesetResolver) + { + var imageChildren = reader.CountDirectChildrenWithName("image"); + var tileChildren = reader.CountDirectChildrenWithName("tile"); + if (imageChildren == 0 && tileChildren == 0) + { + // This is a tileset that must have "source" set + var source = reader.GetRequiredAttribute("source"); + return tilesetResolver(source); + } + if (imageChildren == 1) + { + // This is a single image tileset + return reader.ReadElementAs(); + } + + throw new XmlException("Invalid tileset"); + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e69de29