Skip to content
This repository was archived by the owner on Jul 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions Animation2Tilemap.Test/Factories/TilemapFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ public void CreateFromTileset_WithBasicTileset_CreatesCorrectTilemap()
}
};

var frameTimes = new List<int> { 100, 200 };

_tilemapDataServiceMock
.Setup(x => x.SerializeData(It.IsAny<uint[]>(), TileLayerFormat.Csv))
.Returns("1,2");

// Act
var result = _factory.CreateFromTileset(tileset);
var result = _factory.CreateFromTileset(tileset, frameTimes);

// Assert
Assert.Equal("1.0", result.Version);
Expand All @@ -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<uint[]>(arr => arr[0] == 1 && arr[1] == 2),
Expand Down Expand Up @@ -104,16 +113,55 @@ public void CreateFromTileset_WithDifferentFormats_UsesCorrectEncoding(
}
};

var frameTimes = new List<int> { 100 };

_tilemapDataServiceMock
.Setup(x => x.SerializeData(It.IsAny<uint[]>(), 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<TilesetTile>(),
HashAccumulations = new Dictionary<Point, uint>
{
{ new Point(0, 0), 1u }
}
};

var frameTimes = new List<int>();

_tilemapDataServiceMock
.Setup(x => x.SerializeData(It.IsAny<uint[]>(), TileLayerFormat.Csv))
.Returns("1");

// Act
var result = _factory.CreateFromTileset(tileset, frameTimes);

// Assert
Assert.Null(tileset.Properties);
}
}
8 changes: 6 additions & 2 deletions Animation2Tilemap.Test/Services/ImageLoaderServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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
}
}

Expand All @@ -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
}
}
}
3 changes: 3 additions & 0 deletions Animation2Tilemap/Entities/Tileset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ public class Tileset

[XmlIgnore]
public Size OriginalSize { get; set; }

[XmlElement("properties")]
public List<TilesetProperty>? Properties { get; set; }
}
15 changes: 15 additions & 0 deletions Animation2Tilemap/Entities/TilesetProperty.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
2 changes: 1 addition & 1 deletion Animation2Tilemap/Factories/Contracts/ITilemapFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ namespace Animation2Tilemap.Factories.Contracts;

public interface ITilemapFactory
{
Tilemap CreateFromTileset(Tileset tileset);
Tilemap CreateFromTileset(Tileset tileset, List<int> frameTimes);
}
14 changes: 13 additions & 1 deletion Animation2Tilemap/Factories/TilemapFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public TilemapFactory(MainWorkflowOptions options, ITilemapDataService tilemapDa
_tileLayerFormat = options.TileLayerFormat;
}

public Tilemap CreateFromTileset(Tileset tileset)
public Tilemap CreateFromTileset(Tileset tileset, List<int> frameTimes)
{
var hashToTileId = tileset.RegisteredTiles
.Where(t => t.Animation?.Hash != null)
Expand Down Expand Up @@ -52,6 +52,18 @@ TileLayerFormat.Base64GZip or
_ => null
};

// Add frame timing information to tileset properties
if (frameTimes.Count > 0)
{
tileset.Properties ??= new List<TilesetProperty>();
tileset.Properties.Add(new TilesetProperty
{
Name = "frameTimes",
Type = "string",
Value = string.Join(",", frameTimes)
});
}

return new Tilemap
{
Version = "1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ namespace Animation2Tilemap.Services.Contracts;

public interface IImageLoaderService
{
bool TryLoadImages(out Dictionary<string, List<Image<Rgba32>>> images);
bool TryLoadImages(out Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>> images);
}
30 changes: 19 additions & 11 deletions Animation2Tilemap/Services/ImageLoaderService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Serilog;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Formats.Gif;

namespace Animation2Tilemap.Services;

Expand All @@ -29,7 +30,7 @@ public ImageLoaderService(
_assumeAnimation = options.AssumeAnimation;
}

public bool TryLoadImages(out Dictionary<string, List<Image<Rgba32>>> images)
public bool TryLoadImages(out Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>> images)
{
images = [];

Expand Down Expand Up @@ -94,9 +95,9 @@ public bool TryLoadImages(out Dictionary<string, List<Image<Rgba32>>> images)
return true;
}

private Dictionary<string, List<Image<Rgba32>>> LoadFromDirectory(string path, out bool suitableForAnimation)
private Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>> LoadFromDirectory(string path, out bool suitableForAnimation)
{
var images = new Dictionary<string, List<Image<Rgba32>>>();
var images = new Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>>();
var stopwatch = Stopwatch.StartNew();
var files = Directory
.GetFiles(path, "*.*")
Expand Down Expand Up @@ -131,36 +132,43 @@ private Dictionary<string, List<Image<Rgba32>>> 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",
images.Count, files.Count, totalFrames, stopwatch.ElapsedMilliseconds);
return images;
}

private List<Image<Rgba32>> LoadFromFile(string file)
private List<(Image<Rgba32> Image, int FrameTime)> LoadFromFile(string file)
{
var frames = new List<Image<Rgba32>>();
var frames = new List<(Image<Rgba32> 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<Rgba32>());
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<Rgba32>(), frameTime));
}

_logger.Information("Loaded {FrameCount} frame(s) from {Path}", image.Frames.Count, file);
Expand All @@ -178,7 +186,7 @@ private List<Image<Rgba32>> LoadFromFile(string file)
}
}

private void TransformImagesToAnimation(ref Dictionary<string, List<Image<Rgba32>>> images)
private void TransformImagesToAnimation(ref Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>> images)
{
List<string> fileNames = images.Keys.Select(Path.GetFileNameWithoutExtension).ToList()!;

Expand All @@ -191,7 +199,7 @@ private void TransformImagesToAnimation(ref Dictionary<string, List<Image<Rgba32

_logger.Information("Using {Name} as the animation name.", name);
var frames = images.Values.Select(v => v.First()).ToList();
images = new Dictionary<string, List<Image<Rgba32>>>
images = new Dictionary<string, List<(Image<Rgba32> Image, int FrameTime)>>
{
{
name, frames
Expand Down
6 changes: 3 additions & 3 deletions Animation2Tilemap/Workflows/MainWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down