diff --git a/Animation2Tilemap.Test/Factories/TilemapFactoryTests.cs b/Animation2Tilemap.Test/Factories/TilemapFactoryTests.cs index 0a39394..c29de1a 100644 --- a/Animation2Tilemap.Test/Factories/TilemapFactoryTests.cs +++ b/Animation2Tilemap.Test/Factories/TilemapFactoryTests.cs @@ -47,12 +47,14 @@ public void CreateFromTileset_WithBasicTileset_CreatesCorrectTilemap() } }; + var frameTimes = new List { 100, 200 }; + _tilemapDataServiceMock .Setup(x => x.SerializeData(It.IsAny(), TileLayerFormat.Csv)) .Returns("1,2"); // Act - var result = _factory.CreateFromTileset(tileset); + var result = _factory.CreateFromTileset(tileset, frameTimes); // Assert Assert.Equal("1.0", result.Version); @@ -70,6 +72,13 @@ public void CreateFromTileset_WithBasicTileset_CreatesCorrectTilemap() Assert.Null(result.TilemapLayer.Data.Compression); Assert.Equal("1,2", result.TilemapLayer.Data.Text); + // Verify frame timing information was added to tileset properties + Assert.NotNull(tileset.Properties); + var frameTimesProperty = tileset.Properties.FirstOrDefault(p => p.Name == "frameTimes"); + Assert.NotNull(frameTimesProperty); + Assert.Equal("string", frameTimesProperty.Type); + Assert.Equal("100,200", frameTimesProperty.Value); + _tilemapDataServiceMock.Verify( x => x.SerializeData( It.Is(arr => arr[0] == 1 && arr[1] == 2), @@ -104,16 +113,55 @@ public void CreateFromTileset_WithDifferentFormats_UsesCorrectEncoding( } }; + var frameTimes = new List { 100 }; + _tilemapDataServiceMock .Setup(x => x.SerializeData(It.IsAny(), format)) .Returns("data"); // Act - var result = factory.CreateFromTileset(tileset); + var result = factory.CreateFromTileset(tileset, frameTimes); // Assert Assert.NotNull(result.TilemapLayer?.Data); Assert.Equal(expectedEncoding, result.TilemapLayer.Data.Encoding); Assert.Equal(expectedCompression, result.TilemapLayer.Data.Compression); + + // Verify frame timing information was added to tileset properties + Assert.NotNull(tileset.Properties); + var frameTimesProperty = tileset.Properties.FirstOrDefault(p => p.Name == "frameTimes"); + Assert.NotNull(frameTimesProperty); + Assert.Equal("string", frameTimesProperty.Type); + Assert.Equal("100", frameTimesProperty.Value); + } + + [Fact] + public void CreateFromTileset_WithEmptyFrameTimes_DoesNotAddFrameTimesProperty() + { + // Arrange + var tileset = new Tileset + { + Name = "test", + TileWidth = 32, + TileHeight = 32, + OriginalSize = new Size(32, 32), + RegisteredTiles = new List(), + HashAccumulations = new Dictionary + { + { new Point(0, 0), 1u } + } + }; + + var frameTimes = new List(); + + _tilemapDataServiceMock + .Setup(x => x.SerializeData(It.IsAny(), TileLayerFormat.Csv)) + .Returns("1"); + + // Act + var result = _factory.CreateFromTileset(tileset, frameTimes); + + // Assert + Assert.Null(tileset.Properties); } } \ No newline at end of file diff --git a/Animation2Tilemap.Test/Services/ImageLoaderServiceTests.cs b/Animation2Tilemap.Test/Services/ImageLoaderServiceTests.cs index ea8caa0..48cc035 100644 --- a/Animation2Tilemap.Test/Services/ImageLoaderServiceTests.cs +++ b/Animation2Tilemap.Test/Services/ImageLoaderServiceTests.cs @@ -78,6 +78,7 @@ public void TryLoadImages_ShouldLoadSingleImage_WhenInputPathPointsToFile() Assert.True(result); Assert.Single(images); Assert.Single(images.First().Value); + Assert.Equal(100, images.First().Value[0].FrameTime); // Default frame time for non-GIF } [Fact] @@ -97,6 +98,8 @@ public void TryLoadImages_ShouldLoadGifAnimation_WhenInputPathPointsToFile() Assert.True(result); Assert.Single(images); Assert.Equal(18, images.First().Value.Count); + // Verify that frame times are present (they should be non-zero for a GIF) + Assert.All(images.First().Value, frame => Assert.True(frame.FrameTime > 0)); } [Fact] @@ -119,6 +122,7 @@ public void TryLoadImages_ShouldProcessFramesIndividually_WhenInputPathPointsToD foreach (var image in images) { Assert.Single(image.Value); + Assert.Equal(100, image.Value[0].FrameTime); // Default frame time for non-GIF } } @@ -143,9 +147,9 @@ public void TryLoadImages_ShouldProcessFramesAsAnimation_WhenInputPathPointsToDi Assert.Equal(8, images["anim"].Count); Assert.Single(images); - foreach (var image in images["anim"]) + foreach (var frame in images["anim"]) { - Assert.Single(image.Frames); + Assert.Equal(100, frame.FrameTime); // Default frame time for non-GIF } } } \ No newline at end of file diff --git a/Animation2Tilemap/Entities/Tileset.cs b/Animation2Tilemap/Entities/Tileset.cs index 1536c09..9a5b4d5 100644 --- a/Animation2Tilemap/Entities/Tileset.cs +++ b/Animation2Tilemap/Entities/Tileset.cs @@ -41,4 +41,7 @@ public class Tileset [XmlIgnore] public Size OriginalSize { get; set; } + + [XmlElement("properties")] + public List? Properties { get; set; } } \ No newline at end of file diff --git a/Animation2Tilemap/Entities/TilesetProperty.cs b/Animation2Tilemap/Entities/TilesetProperty.cs new file mode 100644 index 0000000..78bd219 --- /dev/null +++ b/Animation2Tilemap/Entities/TilesetProperty.cs @@ -0,0 +1,15 @@ +using System.Xml.Serialization; + +namespace Animation2Tilemap.Entities; + +public class TilesetProperty +{ + [XmlAttribute("name")] + public string Name { get; set; } = null!; + + [XmlAttribute("type")] + public string Type { get; set; } = null!; + + [XmlAttribute("value")] + public string Value { get; set; } = null!; +} \ No newline at end of file diff --git a/Animation2Tilemap/Factories/Contracts/ITilemapFactory.cs b/Animation2Tilemap/Factories/Contracts/ITilemapFactory.cs index 10aa0d7..3656555 100644 --- a/Animation2Tilemap/Factories/Contracts/ITilemapFactory.cs +++ b/Animation2Tilemap/Factories/Contracts/ITilemapFactory.cs @@ -4,5 +4,5 @@ namespace Animation2Tilemap.Factories.Contracts; public interface ITilemapFactory { - Tilemap CreateFromTileset(Tileset tileset); + Tilemap CreateFromTileset(Tileset tileset, List frameTimes); } \ No newline at end of file diff --git a/Animation2Tilemap/Factories/TilemapFactory.cs b/Animation2Tilemap/Factories/TilemapFactory.cs index 2b86f3a..53cb11a 100644 --- a/Animation2Tilemap/Factories/TilemapFactory.cs +++ b/Animation2Tilemap/Factories/TilemapFactory.cs @@ -17,7 +17,7 @@ public TilemapFactory(MainWorkflowOptions options, ITilemapDataService tilemapDa _tileLayerFormat = options.TileLayerFormat; } - public Tilemap CreateFromTileset(Tileset tileset) + public Tilemap CreateFromTileset(Tileset tileset, List frameTimes) { var hashToTileId = tileset.RegisteredTiles .Where(t => t.Animation?.Hash != null) @@ -52,6 +52,18 @@ TileLayerFormat.Base64GZip or _ => null }; + // Add frame timing information to tileset properties + if (frameTimes.Count > 0) + { + tileset.Properties ??= new List(); + tileset.Properties.Add(new TilesetProperty + { + Name = "frameTimes", + Type = "string", + Value = string.Join(",", frameTimes) + }); + } + return new Tilemap { Version = "1.0", diff --git a/Animation2Tilemap/Services/Contracts/IImageLoaderService.cs b/Animation2Tilemap/Services/Contracts/IImageLoaderService.cs index 31a11d5..73b3bef 100644 --- a/Animation2Tilemap/Services/Contracts/IImageLoaderService.cs +++ b/Animation2Tilemap/Services/Contracts/IImageLoaderService.cs @@ -5,5 +5,5 @@ namespace Animation2Tilemap.Services.Contracts; public interface IImageLoaderService { - bool TryLoadImages(out Dictionary>> images); + bool TryLoadImages(out Dictionary Image, int FrameTime)>> images); } \ No newline at end of file diff --git a/Animation2Tilemap/Services/ImageLoaderService.cs b/Animation2Tilemap/Services/ImageLoaderService.cs index 35ff7f0..56d706f 100644 --- a/Animation2Tilemap/Services/ImageLoaderService.cs +++ b/Animation2Tilemap/Services/ImageLoaderService.cs @@ -5,6 +5,7 @@ using Serilog; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Formats.Gif; namespace Animation2Tilemap.Services; @@ -29,7 +30,7 @@ public ImageLoaderService( _assumeAnimation = options.AssumeAnimation; } - public bool TryLoadImages(out Dictionary>> images) + public bool TryLoadImages(out Dictionary Image, int FrameTime)>> images) { images = []; @@ -94,9 +95,9 @@ public bool TryLoadImages(out Dictionary>> images) return true; } - private Dictionary>> LoadFromDirectory(string path, out bool suitableForAnimation) + private Dictionary Image, int FrameTime)>> LoadFromDirectory(string path, out bool suitableForAnimation) { - var images = new Dictionary>>(); + var images = new Dictionary Image, int FrameTime)>>(); var stopwatch = Stopwatch.StartNew(); var files = Directory .GetFiles(path, "*.*") @@ -131,13 +132,13 @@ private Dictionary>> LoadFromDirectory(string path, o continue; } - var current = frames[0]; + var current = frames[0].Image; if (previous != null && previous.Size.Equals(current.Size) == false || frames.Count > 1) { suitableForAnimation = false; } - previous = frames[0]; + previous = frames[0].Image; } _logger.Information("Loaded {ImageCount} of {InputCount} file(s) containing a total of {FrameCount} frame(s). Took: {Elapsed}ms", @@ -145,22 +146,29 @@ private Dictionary>> LoadFromDirectory(string path, o return images; } - private List> LoadFromFile(string file) + private List<(Image Image, int FrameTime)> LoadFromFile(string file) { - var frames = new List>(); + var frames = new List<(Image Image, int FrameTime)>(); try { using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); - Image.DetectFormat(stream); + var format = Image.DetectFormat(stream); stream.Position = 0; var image = Image.Load(stream); frames = []; + for (var i = 0; i < image.Frames.Count; i++) { var frame = image.Frames.CloneFrame(i); - frames.Add(frame.CloneAs()); + int frameTime = 100; + if (format.Name == "GIF") + { + var meta = image.Frames[i].Metadata.GetGifMetadata(); + frameTime = meta?.FrameDelay is int delay ? delay * 10 : 100; + } + frames.Add((frame.CloneAs(), frameTime)); } _logger.Information("Loaded {FrameCount} frame(s) from {Path}", image.Frames.Count, file); @@ -178,7 +186,7 @@ private List> LoadFromFile(string file) } } - private void TransformImagesToAnimation(ref Dictionary>> images) + private void TransformImagesToAnimation(ref Dictionary Image, int FrameTime)>> images) { List fileNames = images.Keys.Select(Path.GetFileNameWithoutExtension).ToList()!; @@ -191,7 +199,7 @@ private void TransformImagesToAnimation(ref Dictionary v.First()).ToList(); - images = new Dictionary>> + images = new Dictionary Image, int FrameTime)>> { { name, frames diff --git a/Animation2Tilemap/Workflows/MainWorkflow.cs b/Animation2Tilemap/Workflows/MainWorkflow.cs index 25f6198..5d077d1 100644 --- a/Animation2Tilemap/Workflows/MainWorkflow.cs +++ b/Animation2Tilemap/Workflows/MainWorkflow.cs @@ -55,18 +55,18 @@ public void Run() try { - if (_imageAlignmentService.TryAlignImage(fileName, frames) == false) + if (_imageAlignmentService.TryAlignImage(fileName, frames.Select(f => f.Image).ToList()) == false) { return; } var taskStopwatch = Stopwatch.StartNew(); - var tileset = _tilesetFactory.CreateFromImage(fileName, frames); + var tileset = _tilesetFactory.CreateFromImage(fileName, frames.Select(f => f.Image).ToList()); _logger.Verbose("Created tileset from {FileName}. Took: {Elapsed}ms", fileName, taskStopwatch.ElapsedMilliseconds); taskStopwatch.Restart(); - var tilemap = _tilemapFactory.CreateFromTileset(tileset); + var tilemap = _tilemapFactory.CreateFromTileset(tileset, frames.Select(f => f.FrameTime).ToList()); _logger.Verbose("Created tilemap from tileset {FileName}. Took: {Elapsed}ms", fileName, taskStopwatch.ElapsedMilliseconds);