diff --git a/Tilemap2Animation.Test/CommandLineOptions/FrameDelayOptionTests.cs b/Tilemap2Animation.Test/CommandLineOptions/FrameDelayOptionTests.cs deleted file mode 100644 index 1e4cfa4..0000000 --- a/Tilemap2Animation.Test/CommandLineOptions/FrameDelayOptionTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Tilemap2Animation.CommandLineOptions; - -namespace Tilemap2Animation.Test.CommandLineOptions; - -public class FrameDelayOptionTests -{ - private readonly FrameDelayOption _sut; - - public FrameDelayOptionTests() - { - _sut = new FrameDelayOption(); - } - - [Fact] - public void Option_HasCorrectName() - { - // Assert - Assert.Equal("frame-delay", _sut.Option.Name); - } - - [Fact] - public void Option_HasCorrectAlias() - { - // Assert - Assert.Contains("-d", _sut.Option.Aliases); - } - - [Fact] - public void Option_IsNotRequired() - { - // Assert - Assert.False(_sut.Option.IsRequired); - } -} \ No newline at end of file diff --git a/Tilemap2Animation.Test/Services/AnimationGeneratorServiceTests.cs b/Tilemap2Animation.Test/Services/AnimationGeneratorServiceTests.cs index 1c934c7..3fb0e73 100644 --- a/Tilemap2Animation.Test/Services/AnimationGeneratorServiceTests.cs +++ b/Tilemap2Animation.Test/Services/AnimationGeneratorServiceTests.cs @@ -12,6 +12,7 @@ public class AnimationGeneratorServiceTests private readonly Mock _tilesetImageServiceMock; private readonly Mock _tilemapServiceMock; private readonly AnimationGeneratorService _sut; + private const int DefaultAnimationDuration = 100; // Mirrored from AnimationGeneratorService public AnimationGeneratorServiceTests() { @@ -58,13 +59,12 @@ public void CalculateTotalAnimationDuration_WithValidTileset_ReturnsCorrectDurat } } }; - var frameDelay = 50; // ms // Act - var result = _sut.CalculateTotalAnimationDuration(tileset, frameDelay); + var result = _sut.CalculateTotalAnimationDuration(tileset); // Assert - Assert.Equal(900, result); // The actual LCM of 300 and 450, with frameDelay 50 + Assert.Equal(900, result); // LCM(300, 450) = 900 } [Fact] @@ -119,15 +119,13 @@ public void CalculateTotalAnimationDurationForMultipleTilesets_WithValidTilesets (1001, tileset2, tilesetImage2) }; - var frameDelay = 50; // ms - try { // Act - var result = _sut.CalculateTotalAnimationDurationForMultipleTilesets(tilesets, frameDelay); + var result = _sut.CalculateTotalAnimationDurationForMultipleTilesets(tilesets); // Assert - Assert.Equal(900, result); // The actual LCM of 300 and 450, with frameDelay 50 + Assert.Equal(900, result); // LCM(300, 450) = 900 } finally { @@ -152,15 +150,13 @@ public void CalculateTotalAnimationDurationForMultipleTilesets_WithNoAnimatedTil (1, tileset, tilesetImage) }; - var frameDelay = 50; // ms - try { // Act - var result = _sut.CalculateTotalAnimationDurationForMultipleTilesets(tilesets, frameDelay); + var result = _sut.CalculateTotalAnimationDurationForMultipleTilesets(tilesets); // Assert - Assert.Equal(frameDelay, result); // Should just return the frame delay when no animations exist + Assert.Equal(DefaultAnimationDuration, result); // Should return default duration } finally { @@ -177,13 +173,11 @@ public void CalculateTotalAnimationDuration_WithNoAnimatedTiles_ReturnsFrameDela Tiles = new List() // No animated tiles }; - var frameDelay = 50; // ms - // Act - var result = _sut.CalculateTotalAnimationDuration(tileset, frameDelay); + var result = _sut.CalculateTotalAnimationDuration(tileset); // Assert - Assert.Equal(frameDelay, result); // Should just return the frame delay when no animations exist + Assert.Equal(DefaultAnimationDuration, result); // Should return default duration } [Fact] @@ -241,13 +235,12 @@ public async Task GenerateAnimationFramesFromMultipleTilesetsAsync_DrawsRegularT var (frames, delays) = await _sut.GenerateAnimationFramesFromMultipleTilesetsAsync( tilemap, tilesets, - layerDataByName, - 100); // frameDelay + layerDataByName); // Assert - Assert.Single(frames); // Should have created one frame + Assert.Single(frames); // Should have created one frame (static image case) Assert.Single(delays); // Should have one delay value - Assert.Equal(100, delays[0]); // Delay should match what we provided + Assert.Equal(DefaultAnimationDuration, delays[0]); // Delay should be default duration // Clean up foreach (var frame in frames) diff --git a/Tilemap2Animation.Test/Workflows/MainWorkflowTests.cs b/Tilemap2Animation.Test/Workflows/MainWorkflowTests.cs index c8689ae..d603c8e 100644 --- a/Tilemap2Animation.Test/Workflows/MainWorkflowTests.cs +++ b/Tilemap2Animation.Test/Workflows/MainWorkflowTests.cs @@ -42,8 +42,7 @@ public async Task ExecuteAsync_WithTmxInput_ProcessesCorrectly() var options = new MainWorkflowOptions { InputFile = testFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -85,8 +84,7 @@ public async Task ExecuteAsync_WithTmxInput_ProcessesCorrectly() _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( It.IsAny(), It.IsAny? TilesetImage)>>(), - It.IsAny>>(), - It.IsAny())) + It.IsAny>>())) .ReturnsAsync((frames, delays)); try @@ -122,8 +120,7 @@ public async Task ExecuteAsync_WithTsxInput_FindsTmxAndProcessesCorrectly() var options = new MainWorkflowOptions { InputFile = tsxFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -167,8 +164,7 @@ public async Task ExecuteAsync_WithTsxInput_FindsTmxAndProcessesCorrectly() _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( It.IsAny(), It.IsAny? TilesetImage)>>(), - It.IsAny>>(), - It.IsAny())) + It.IsAny>>())) .ReturnsAsync((frames, delays)); try @@ -205,19 +201,27 @@ public async Task ExecuteAsync_WithTsxInput_NoTmxFound_ThrowsException() var options = new MainWorkflowOptions { InputFile = tsxFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tileset = new Tileset { + Name = "test tileset", + TileWidth = 16, + TileHeight = 16, Image = new TilesetImage { Path = "test.png" } }; + + var dummyLayerData = new List { 0 }; + var frames = new List> { new Image(16, 16) }; + var delays = new List { 100 }; _tilemapServiceMock.Setup(x => x.FindTmxFilesReferencingTsxAsync(tsxFilePath)) - .ReturnsAsync(new List()); // No TMX files found + .ReturnsAsync(new List()); + _tilemapServiceMock.Setup(x => x.ParseLayerData(It.IsAny())) - .Returns(new List { 0 }); // For dummy layer + .Returns(dummyLayerData); + _tilesetServiceMock.Setup(x => x.DeserializeTsxAsync(tsxFilePath)).ReturnsAsync(tileset); _tilesetServiceMock.Setup(x => x.ResolveTilesetImagePath(It.IsAny(), It.IsAny())).Returns("test.png"); @@ -225,19 +229,34 @@ public async Task ExecuteAsync_WithTsxInput_NoTmxFound_ThrowsException() _tilesetImageServiceMock.Setup(x => x.LoadTilesetImageAsync(It.IsAny())).ReturnsAsync(tilesetImage); _tilesetImageServiceMock.Setup(x => x.ProcessTransparency(It.IsAny>(), It.IsAny())).Returns(tilesetImage); + _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( + It.IsAny(), + It.IsAny? TilesetImage)>>(), + It.IsAny>>())) + .ReturnsAsync((frames, delays)); + try { - // Act & Assert - var exception = await Assert.ThrowsAsync(() => _sut.ExecuteAsync(options)); - Assert.Contains("No valid tilesets with images are available to generate animation frames", exception.Message); + // Act + await _sut.ExecuteAsync(options); + + // Assert + _animationEncoderServiceMock.Verify(x => x.SaveAsGifAsync( + frames, + delays, + It.Is(s => s == outputPath)), + Times.Once); - // Verify _tilemapServiceMock.Verify(x => x.FindTmxFilesReferencingTsxAsync(tsxFilePath), Times.Once); _tilesetServiceMock.Verify(x => x.DeserializeTsxAsync(tsxFilePath), Times.Once); } finally { tilesetImage.Dispose(); + foreach (var frame in frames) + { + frame.Dispose(); + } } } @@ -253,8 +272,7 @@ public async Task ExecuteAsync_WithImageInput_FindsTsxAndTmxAndProcessesCorrectl var options = new MainWorkflowOptions { InputFile = imageFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -300,8 +318,7 @@ public async Task ExecuteAsync_WithImageInput_FindsTsxAndTmxAndProcessesCorrectl _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( It.IsAny(), It.IsAny? TilesetImage)>>(), - It.IsAny>>(), - It.IsAny())) + It.IsAny>>())) .ReturnsAsync((frames, delays)); try @@ -340,27 +357,66 @@ public async Task ExecuteAsync_WithImageInput_FindsTsxButNoTmx_ThrowsException() var options = new MainWorkflowOptions { InputFile = imageFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tileset = new Tileset { + Name = "test tileset", + TileWidth = 16, + TileHeight = 16, Image = new TilesetImage { Path = "test.png" } }; + + var dummyLayerData = new List { 0 }; + var frames = new List> { new Image(16, 16) }; + var delays = new List { 100 }; _tilesetServiceMock.Setup(x => x.FindTsxFilesReferencingImageAsync(imageFilePath)) .ReturnsAsync(new List { tsxFilePath }); _tilemapServiceMock.Setup(x => x.FindTmxFilesReferencingTsxAsync(tsxFilePath)) - .ReturnsAsync(new List()); // No TMX files found - _tilesetServiceMock.Setup(x => x.DeserializeTsxAsync(It.IsAny())).ReturnsAsync(tileset); + .ReturnsAsync(new List()); + + _tilesetServiceMock.Setup(x => x.DeserializeTsxAsync(tsxFilePath)).ReturnsAsync(tileset); + _tilesetServiceMock.Setup(x => x.ResolveTilesetImagePath(It.IsAny(), It.IsAny())).Returns("test.png"); - // Act & Assert - await Assert.ThrowsAsync(() => _sut.ExecuteAsync(options)); + var tilesetImage = new Image(32, 32); + _tilesetImageServiceMock.Setup(x => x.LoadTilesetImageAsync(It.IsAny())).ReturnsAsync(tilesetImage); + _tilesetImageServiceMock.Setup(x => x.ProcessTransparency(It.IsAny>(), It.IsAny())).Returns(tilesetImage); + + _tilemapServiceMock.Setup(x => x.ParseLayerData(It.IsAny())) + .Returns(dummyLayerData); - // Verify - _tilesetServiceMock.Verify(x => x.FindTsxFilesReferencingImageAsync(imageFilePath), Times.Once); - _tilemapServiceMock.Verify(x => x.FindTmxFilesReferencingTsxAsync(tsxFilePath), Times.Once); + _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( + It.IsAny(), + It.IsAny? TilesetImage)>>(), + It.IsAny>>())) + .ReturnsAsync((frames, delays)); + + try + { + // Act + await _sut.ExecuteAsync(options); + + // Assert + _animationEncoderServiceMock.Verify(x => x.SaveAsGifAsync( + frames, + delays, + It.Is(s => s == outputPath)), + Times.Once); + + _tilesetServiceMock.Verify(x => x.FindTsxFilesReferencingImageAsync(imageFilePath), Times.Once); + _tilemapServiceMock.Verify(x => x.FindTmxFilesReferencingTsxAsync(tsxFilePath), Times.Once); + _tilesetServiceMock.Verify(x => x.DeserializeTsxAsync(tsxFilePath), Times.Once); + } + finally + { + tilesetImage.Dispose(); + foreach (var frame in frames) + { + frame.Dispose(); + } + } } [Fact] @@ -373,12 +429,11 @@ public async Task ExecuteAsync_WithImageInput_NoTsxFound_ThrowsException() var options = new MainWorkflowOptions { InputFile = imageFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; _tilesetServiceMock.Setup(x => x.FindTsxFilesReferencingImageAsync(imageFilePath)) - .ReturnsAsync(new List()); // No TSX files found + .ReturnsAsync(new List()); // Act & Assert var exception = await Assert.ThrowsAsync(() => _sut.ExecuteAsync(options)); @@ -398,8 +453,7 @@ public async Task ExecuteAsync_WithNoLayers_ThrowsException() var options = new MainWorkflowOptions { InputFile = testFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -408,7 +462,7 @@ public async Task ExecuteAsync_WithNoLayers_ThrowsException() Height = 10, TileWidth = 16, TileHeight = 16, - Layers = new List(), // Empty layers + Layers = new List(), Tilesets = new List { new TilemapTileset { FirstGid = 1, Source = "test.tsx" } @@ -450,8 +504,7 @@ public async Task ExecuteAsync_WithNoValidTilesets_ThrowsException() var options = new MainWorkflowOptions { InputFile = testFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -502,8 +555,7 @@ public async Task ExecuteAsync_WithMultipleExtensions_ProcessesCorrectly() var options = new MainWorkflowOptions { InputFile = testFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; var tilemap = new Tilemap @@ -545,8 +597,7 @@ public async Task ExecuteAsync_WithMultipleExtensions_ProcessesCorrectly() _animationGeneratorServiceMock.Setup(x => x.GenerateAnimationFramesFromMultipleTilesetsAsync( It.IsAny(), It.IsAny? TilesetImage)>>(), - It.IsAny>>(), - It.IsAny())) + It.IsAny>>())) .ReturnsAsync((frames, delays)); try @@ -582,8 +633,7 @@ public async Task ExecuteAsync_WithUnsupportedExtension_ThrowsArgumentException( var options = new MainWorkflowOptions { InputFile = testFilePath, - OutputFile = outputPath, - FrameDelay = 100 + OutputFile = outputPath }; // Act & Assert diff --git a/Tilemap2Animation/CommandLineOptions/Binding/Tilemap2AnimationOptionsBinder.cs b/Tilemap2Animation/CommandLineOptions/Binding/Tilemap2AnimationOptionsBinder.cs index 76e6f72..d46c75f 100644 --- a/Tilemap2Animation/CommandLineOptions/Binding/Tilemap2AnimationOptionsBinder.cs +++ b/Tilemap2Animation/CommandLineOptions/Binding/Tilemap2AnimationOptionsBinder.cs @@ -12,7 +12,6 @@ public class Tilemap2AnimationOptionsBinder : BinderBase { private readonly Option _inputFileOption; private readonly Option _outputFileOption; - private readonly Option _frameDelayOption; private readonly Option _verboseOption; /// @@ -21,13 +20,11 @@ public class Tilemap2AnimationOptionsBinder : BinderBase /// The root command to bind options to /// The input file option /// The output file option - /// The frame delay option /// The verbose option public Tilemap2AnimationOptionsBinder( Command rootCommand, ICommandLineOption inputFileOption, ICommandLineOption outputFileOption, - ICommandLineOption frameDelayOption, ICommandLineOption verboseOption) { _inputFileOption = inputFileOption.Option; @@ -36,9 +33,6 @@ public Tilemap2AnimationOptionsBinder( _outputFileOption = outputFileOption.Option; rootCommand.AddOption(_outputFileOption); - _frameDelayOption = frameDelayOption.Option; - rootCommand.AddOption(_frameDelayOption); - _verboseOption = verboseOption.Option; rootCommand.AddOption(_verboseOption); } @@ -54,7 +48,6 @@ protected override MainWorkflowOptions GetBoundValue(BindingContext bindingConte { InputFile = bindingContext.ParseResult.GetValueForOption(_inputFileOption)!, OutputFile = bindingContext.ParseResult.GetValueForOption(_outputFileOption), - FrameDelay = bindingContext.ParseResult.GetValueForOption(_frameDelayOption), Verbose = bindingContext.ParseResult.GetValueForOption(_verboseOption) }; } diff --git a/Tilemap2Animation/CommandLineOptions/FrameDelayOption.cs b/Tilemap2Animation/CommandLineOptions/FrameDelayOption.cs deleted file mode 100644 index 5b11823..0000000 --- a/Tilemap2Animation/CommandLineOptions/FrameDelayOption.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.CommandLine; -using Tilemap2Animation.CommandLineOptions.Contracts; - -namespace Tilemap2Animation.CommandLineOptions; - -public class FrameDelayOption : ICommandLineOption -{ - public Option Option { get; } - - public FrameDelayOption() - { - Option = new Option( - name: "--frame-delay", - description: "The delay between frames in milliseconds. Defaults to 100ms.") - { - IsRequired = false - }; - Option.AddAlias("-d"); - Option.SetDefaultValue(100); - } -} \ No newline at end of file diff --git a/Tilemap2Animation/Program.cs b/Tilemap2Animation/Program.cs index a53472f..720737c 100644 --- a/Tilemap2Animation/Program.cs +++ b/Tilemap2Animation/Program.cs @@ -48,14 +48,12 @@ private static Tilemap2AnimationOptionsBinder BuildMainWorkflowOptionsBinder(Com { var inputFileOption = new InputFileOption(); var outputFileOption = new OutputFileOption(); - var frameDelayOption = new FrameDelayOption(); var verboseOption = new VerboseOption(); return new Tilemap2AnimationOptionsBinder( rootCommand, inputFileOption, outputFileOption, - frameDelayOption, verboseOption); } } \ No newline at end of file diff --git a/Tilemap2Animation/Services/AnimationGeneratorService.cs b/Tilemap2Animation/Services/AnimationGeneratorService.cs index 609c5f4..bfbea66 100644 --- a/Tilemap2Animation/Services/AnimationGeneratorService.cs +++ b/Tilemap2Animation/Services/AnimationGeneratorService.cs @@ -18,6 +18,8 @@ public class AnimationGeneratorService : IAnimationGeneratorService private const uint FlippedVerticallyFlag = 0x40000000; private const uint FlippedDiagonallyFlag = 0x20000000; private const uint TileIdMask = ~(FlippedHorizontallyFlag | FlippedVerticallyFlag | FlippedDiagonallyFlag); + private const int DefaultAnimationDuration = 100; // Default duration in ms if no animations + private const int MinimumFrameDuration = 10; // Minimum duration for a frame in ms to avoid issues with 0ms delays public AnimationGeneratorService(ITilesetImageService tilesetImageService, ITilemapService tilemapService) { @@ -25,30 +27,30 @@ public AnimationGeneratorService(ITilesetImageService tilesetImageService, ITile _tilemapService = tilemapService; } - public int CalculateTotalAnimationDuration(Tileset tileset, int frameDelay) + public int CalculateTotalAnimationDuration(Tileset tileset) { ArgumentNullException.ThrowIfNull(tileset); - if (frameDelay <= 0) throw new ArgumentException("Frame delay must be greater than 0.", nameof(frameDelay)); try { - // Find all animated tiles var animatedTiles = tileset.Tiles?.Where(t => t.Animation?.Frames != null && t.Animation.Frames.Count != 0) ?? Enumerable.Empty(); + var animationCycleDurations = animatedTiles + .Select(tile => tile.Animation!.Frames.Sum(f => f.Duration)) + .Where(duration => duration > 0) // Only consider positive durations + .ToList(); - var tilesetTiles = animatedTiles.ToList(); - if (tilesetTiles.Count == 0) + if (animationCycleDurations.Count == 0) { - // If no animated tiles, return frame delay as total duration - return frameDelay; + return DefaultAnimationDuration; // Return default if no positive-duration animations } - // Calculate the least common multiple of all animation durations - return tilesetTiles.Select(tile => tile.Animation!.Frames.Sum(f => f.Duration)).Aggregate(frameDelay, LeastCommonMultiple); + // Calculate the least common multiple of all positive animation durations + return animationCycleDurations.Aggregate(LeastCommonMultiple); } catch (Exception ex) { - Log.Error(ex, "Error calculating total animation duration"); - throw new InvalidOperationException($"Error calculating total animation duration: {ex.Message}", ex); + Log.Error(ex, "Error calculating total animation duration for a single tileset"); + throw new InvalidOperationException($"Error calculating total animation duration for a single tileset: {ex.Message}", ex); } } @@ -71,15 +73,12 @@ private static int GreatestCommonDivisor(int a, int b) public async Task<(List> Frames, List Delays)> GenerateAnimationFramesFromMultipleTilesetsAsync( Tilemap tilemap, List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets, - Dictionary> layerDataByName, - int frameDelay) + Dictionary> layerDataByName) { ArgumentNullException.ThrowIfNull(tilemap); ArgumentNullException.ThrowIfNull(tilesets); ArgumentNullException.ThrowIfNull(layerDataByName); - if (frameDelay <= 0) throw new ArgumentException("Frame delay must be greater than 0.", nameof(frameDelay)); - // Filter out any invalid tilesets var validTilesets = tilesets.Where(t => t.Tileset != null && t.TilesetImage != null).ToList(); if (validTilesets.Count == 0) @@ -89,57 +88,110 @@ private static int GreatestCommonDivisor(int a, int b) try { - // Calculate total animation duration across all tilesets - var totalDuration = CalculateTotalAnimationDurationForMultipleTilesets(validTilesets, frameDelay); + var totalDuration = CalculateTotalAnimationDurationForMultipleTilesets(validTilesets); - // Create a list to store all frames var frames = new List>(); var delays = new List(); + + // If totalDuration is the default, it means no actual animations are present or all have 0 duration. + // Create a single static frame. + if (totalDuration == DefaultAnimationDuration) + { + var staticFrame = await RenderFrameAtTimeAsync(tilemap, validTilesets, layerDataByName, 0); + frames.Add(staticFrame); + delays.Add(DefaultAnimationDuration); + return (frames, delays); + } - // Create frames for each time step - for (var time = 0; time < totalDuration; time += frameDelay) + // Collect all event times (times when any tile changes its animation frame) + var eventTimes = new SortedSet { 0 }; // Start with 0 + + foreach (var layer in tilemap.Layers) { - // Create a new frame - var frame = new Image(tilemap.Width * tilemap.TileWidth, tilemap.Height * tilemap.TileHeight); - - // Apply background color if specified - if (!string.IsNullOrEmpty(tilemap.BackgroundColor)) + if (!layerDataByName.TryGetValue(layer.Name ?? "", out var currentLayerData)) { - try - { - var bgColor = Rgba32.ParseHex(tilemap.BackgroundColor); - frame.Mutate(ctx => ctx.Fill(bgColor)); - Log.Debug("Applied background color: {TilemapBackgroundColor}", tilemap.BackgroundColor); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to parse background color: {TilemapBackgroundColor}", tilemap.BackgroundColor); - } + currentLayerData = _tilemapService.ParseLayerData(layer); } - - // In Tiled, layers are drawn from bottom to top - // The first layer in the collection is the bottommost layer - // So we iterate through layers in the same order they appear in the TMX file - foreach (var layer in tilemap.Layers) + + for (var tileIndex = 0; tileIndex < currentLayerData.Count; tileIndex++) { - Log.Debug("Processing layer: {LayerName}", layer.Name); - if (layerDataByName.TryGetValue(layer.Name ?? "", out var currentLayerData)) - { - // Draw tiles onto the frame for this layer using multiple tilesets - await DrawTilesOnFrameWithMultipleTilesetsAsync(frame, tilemap, validTilesets, currentLayerData, time); - } - else + var gid = currentLayerData[tileIndex]; + if (gid == 0) continue; + + var actualGid = gid & TileIdMask; + var (firstGid, tilesetDef, _) = GetTilesetForGid(actualGid, validTilesets); + + if (tilesetDef == null) continue; + + var localTileId = (int)(actualGid - firstGid); + var animatedTileDefinition = tilesetDef.Tiles?.FirstOrDefault(t => t.Id == localTileId); + + if (animatedTileDefinition?.Animation != null && animatedTileDefinition.Animation.Frames.Count > 0) { - // If we have no parsed data for this layer, parse it now - var parsedLayerData = _tilemapService.ParseLayerData(layer); - await DrawTilesOnFrameWithMultipleTilesetsAsync(frame, tilemap, validTilesets, parsedLayerData, time); + var tileAnimationFrames = animatedTileDefinition.Animation.Frames; + var tileCycleDuration = tileAnimationFrames.Sum(f => f.Duration); + + if (tileCycleDuration > 0) + { + for (var cycleStartTime = 0; cycleStartTime < totalDuration; cycleStartTime += tileCycleDuration) + { + var accumulatedDurationInCycle = 0; + foreach (var animFrame in tileAnimationFrames) + { + var eventTime = cycleStartTime + accumulatedDurationInCycle; + if (eventTime < totalDuration) // Only add event times within the total duration + { + eventTimes.Add(eventTime); + } + accumulatedDurationInCycle += animFrame.Duration; + // If the frame duration is 0, it means it shows indefinitely until next change or cycle end. + // We only need one event time at the start of this frame. + if (animFrame.Duration == 0) break; + } + } + } } } - + } + + eventTimes.Add(totalDuration); // Ensure the animation concludes at totalDuration + var sortedEventTimes = eventTimes.ToList(); + + for (var i = 0; i < sortedEventTimes.Count -1; i++) + { + var currentTime = sortedEventTimes[i]; + var nextTime = sortedEventTimes[i+1]; + var frameDuration = nextTime - currentTime; + + if (frameDuration <= 0) // Skip if duration is zero or negative (should not happen with SortedSet logic) + { + // If it happens, it might mean multiple animation changes at the exact same millisecond. + // We only need one frame for that instant. + continue; + } + + var frame = await RenderFrameAtTimeAsync(tilemap, validTilesets, layerDataByName, currentTime); frames.Add(frame); - delays.Add(frameDelay); + delays.Add(Math.Max(MinimumFrameDuration, frameDuration)); // Ensure a minimum frame duration } + // If, after processing, no frames were added (e.g. totalDuration was very small or event times were problematic) + // ensure at least one frame is present to avoid empty animation errors. + if (frames.Count == 0 && totalDuration > 0) + { + Log.Warning("No frames generated despite positive totalDuration ({TotalDuration}ms). Adding a single frame.", totalDuration); + var fallbackFrame = await RenderFrameAtTimeAsync(tilemap, validTilesets, layerDataByName, 0); + frames.Add(fallbackFrame); + delays.Add(Math.Max(MinimumFrameDuration, totalDuration)); + } + else if (frames.Count == 0 && totalDuration == 0) // Should be caught by DefaultAnimationDuration case, but as safety + { + Log.Warning("Total animation duration is 0 and no frames generated. Adding a single default frame."); + var fallbackFrame = await RenderFrameAtTimeAsync(tilemap, validTilesets, layerDataByName, 0); + frames.Add(fallbackFrame); + delays.Add(DefaultAnimationDuration); + } + return (frames, delays); } catch (Exception ex) @@ -148,42 +200,96 @@ private static int GreatestCommonDivisor(int a, int b) throw new InvalidOperationException($"Error generating animation frames with multiple tilesets: {ex.Message}", ex); } } + + private async Task> RenderFrameAtTimeAsync( + Tilemap tilemap, + List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> validTilesets, + Dictionary> layerDataByName, + int time) + { + var frame = new Image(tilemap.Width * tilemap.TileWidth, tilemap.Height * tilemap.TileHeight); + if (!string.IsNullOrEmpty(tilemap.BackgroundColor)) + { + try + { + var bgColor = Rgba32.ParseHex(tilemap.BackgroundColor); + frame.Mutate(ctx => ctx.Fill(bgColor)); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to parse background color: {TilemapBackgroundColor}", tilemap.BackgroundColor); + } + } + + foreach (var layer in tilemap.Layers) + { + if (layerDataByName.TryGetValue(layer.Name ?? "", out var currentLayerData)) + { + await DrawTilesOnFrameWithMultipleTilesetsAsync(frame, tilemap, validTilesets, currentLayerData, time); + } + else + { + var parsedLayerData = _tilemapService.ParseLayerData(layer); + await DrawTilesOnFrameWithMultipleTilesetsAsync(frame, tilemap, validTilesets, parsedLayerData, time); + } + } + return frame; + } + + private (int FirstGid, Tileset? Tileset, Image? TilesetImage) GetTilesetForGid(uint actualGid, List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets) + { + // Assumes tilesets are pre-sorted by FirstGid descending or this method sorts/finds appropriately. + // For simplicity, using the existing sorted list approach from DrawTilesOnFrameWithMultipleTilesetsAsync requires it to be passed or sorted here. + // The original DrawTilesOnFrameWithMultipleTilesetsAsync sorts them. Let's reuse that or ensure sorted list. + // For now, let's assume `tilesets` parameter is the `validTilesets` which should be used carefully or pre-sorted as needed. + // This is a simplified version for event time calculation; the actual drawing method has more robust tileset finding. + + var sortedTilesets = tilesets.OrderByDescending(t => t.FirstGid).ToList(); // Ensure sorted for correct selection + foreach (var tilesetInfo in sortedTilesets) + { + if (actualGid >= tilesetInfo.FirstGid && tilesetInfo.Tileset != null) + { + return tilesetInfo; + } + } + return (0, null, null); + } public int CalculateTotalAnimationDurationForMultipleTilesets( - List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets, - int frameDelay) + List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets) { ArgumentNullException.ThrowIfNull(tilesets); - if (frameDelay <= 0) throw new ArgumentException("Frame delay must be greater than 0.", nameof(frameDelay)); try { - // Get all animated tiles from all tilesets - var animationDurations = new List(); + var allAnimationCycleDurations = new List(); foreach (var (_, tileset, _) in tilesets) { if (tileset == null) continue; - // Find all animated tiles in this tileset var animatedTiles = tileset.Tiles?.Where(t => t.Animation?.Frames != null && t.Animation.Frames.Count != 0) ?? Enumerable.Empty(); - var tilesetTiles = animatedTiles.ToList(); - if (tilesetTiles.Count > 0) - { - // Add all animation durations from this tileset - animationDurations.AddRange(tilesetTiles.Select(tile => tile.Animation!.Frames.Sum(f => f.Duration))); - } + allAnimationCycleDurations.AddRange(animatedTiles + .Select(tile => tile.Animation!.Frames.Sum(f => f.Duration)) + .Where(duration => duration > 0)); // Only consider positive durations } - // If no animated tiles found in any tileset, return frame delay as total duration - if (animationDurations.Count == 0) + if (allAnimationCycleDurations.Count == 0) { - return frameDelay; + return DefaultAnimationDuration; // Return default if no positive-duration animations } - // Calculate the least common multiple of all animation durations - return animationDurations.Aggregate(frameDelay, LeastCommonMultiple); + // Remove duplicates before LCM calculation to avoid unnecessary computation, + // though LCM itself would handle duplicates correctly. + var distinctPositiveDurations = allAnimationCycleDurations.Distinct().ToList(); + + if (distinctPositiveDurations.Count == 0) // Should be covered by allAnimationCycleDurations.Count == 0, but for safety + { + return DefaultAnimationDuration; + } + + return distinctPositiveDurations.Aggregate(LeastCommonMultiple); } catch (Exception ex) { diff --git a/Tilemap2Animation/Services/Contracts/IAnimationGeneratorService.cs b/Tilemap2Animation/Services/Contracts/IAnimationGeneratorService.cs index af3b0fe..fc5fd15 100644 --- a/Tilemap2Animation/Services/Contracts/IAnimationGeneratorService.cs +++ b/Tilemap2Animation/Services/Contracts/IAnimationGeneratorService.cs @@ -9,10 +9,9 @@ public interface IAnimationGeneratorService Task<(List> Frames, List Delays)> GenerateAnimationFramesFromMultipleTilesetsAsync( Tilemap tilemap, List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets, - Dictionary> layerDataByName, - int frameDelay); + Dictionary> layerDataByName); - int CalculateTotalAnimationDuration(Tileset tileset, int frameDelay); + int CalculateTotalAnimationDuration(Tileset tileset); - int CalculateTotalAnimationDurationForMultipleTilesets(List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets, int frameDelay); + int CalculateTotalAnimationDurationForMultipleTilesets(List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)> tilesets); } \ No newline at end of file diff --git a/Tilemap2Animation/Workflows/MainWorkflow.cs b/Tilemap2Animation/Workflows/MainWorkflow.cs index f23733b..6a3c349 100644 --- a/Tilemap2Animation/Workflows/MainWorkflow.cs +++ b/Tilemap2Animation/Workflows/MainWorkflow.cs @@ -249,28 +249,48 @@ public async Task ExecuteAsync(MainWorkflowOptions options) // 5. Generate animation frames Log.Information("Generating animation frames..."); - var tilesetInfoForGenerator = tilemap.Tilesets - .Select(ts => - { - var tsxPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(resolvedTmxFilePath)!, ts.Source ?? "")); - tilesets.TryGetValue(tsxPath, out var loadedTileset); - tilesetImages.TryGetValue(tsxPath, out var loadedTilesetImage); - return (ts.FirstGid, Tileset: loadedTileset, TilesetImage: loadedTilesetImage); - }) - .Where(t => t.Tileset != null && t.TilesetImage != null) - .ToList(); - if (!tilesetInfoForGenerator.Any()) + var tilesetEntriesForGenerator = new List<(int FirstGid, Tileset? Tileset, Image? TilesetImage)>(); + var tmxDir = Path.GetDirectoryName(resolvedTmxFilePath) ?? "."; + + foreach (var entry in tilemap.Tilesets) { - throw new InvalidOperationException("No valid tilesets with images are available to generate animation frames."); + if (string.IsNullOrEmpty(entry.Source)) continue; + var tsxFullPath = Path.GetFullPath(Path.Combine(tmxDir, entry.Source)); + if (tilesets.TryGetValue(tsxFullPath, out var ts) && tilesetImages.TryGetValue(tsxFullPath, out var img)) + { + tilesetEntriesForGenerator.Add((entry.FirstGid, ts, img)); + } + else + { + Log.Warning("Tileset source {TilesetSource} from TMX not found in loaded tilesets. FirstGid: {FirstGid}", entry.Source, entry.FirstGid); + } + } + + if (tilesetEntriesForGenerator.Count == 0 && tilesets.Count > 0) + { + // This can happen if TMX has no tileset entries but we loaded a single TSX. + // Attempt to add the single loaded TSX if it's the case. + if (tilesets.Count == 1 && tilesetImages.Count == 1) + { + var singleTsx = tilesets.First(); + var singleImg = tilesetImages.First(); + // Find a suitable FirstGid, default to 1 if TMX had no tilesets. + var firstGid = tilemap.Tilesets.FirstOrDefault()?.FirstGid ?? 1; + tilesetEntriesForGenerator.Add((firstGid, singleTsx.Value, singleImg.Value)); + Log.Information("Added single loaded tileset to generator as TMX had no explicit entries."); + } + else + { + Log.Error("No valid tileset entries could be prepared for the animation generator, but tilesets were loaded. Check TMX tileset references."); + throw new InvalidOperationException("Could not prepare tileset entries for animation generator."); + } } - var (frames, delays) = await _animationGeneratorService.GenerateAnimationFramesFromMultipleTilesetsAsync( tilemap, - tilesetInfoForGenerator, - layerDataByName, - options.FrameDelay); + tilesetEntriesForGenerator, + layerDataByName); // Determine output file path var outputFilePath = options.OutputFile ?? Path.ChangeExtension(inputFile, ".gif"); diff --git a/Tilemap2Animation/Workflows/MainWorkflowOptions.cs b/Tilemap2Animation/Workflows/MainWorkflowOptions.cs index 1a3b266..1e3dbe8 100644 --- a/Tilemap2Animation/Workflows/MainWorkflowOptions.cs +++ b/Tilemap2Animation/Workflows/MainWorkflowOptions.cs @@ -6,7 +6,5 @@ public class MainWorkflowOptions public string? OutputFile { get; init; } - public int FrameDelay { get; init; } = 100; - public bool Verbose { get; init; } } \ No newline at end of file