diff --git a/DALib/Comparers/NaturalStringComparer.cs b/DALib/Comparers/NaturalStringComparer.cs new file mode 100644 index 0000000..c0f0091 --- /dev/null +++ b/DALib/Comparers/NaturalStringComparer.cs @@ -0,0 +1,109 @@ +#region +using System.Collections.Generic; +#endregion + +namespace DALib.Comparers; + +/// +/// A natural string comparer intended to work similarly to how windows orders files. Strings with numbers will be +/// compared numerically, rather than lexicographically. +/// +public sealed class NaturalStringComparer : IComparer +{ + /// + /// Comparers are basically static methods, no point in creating many instances + /// + public static NaturalStringComparer Instance { get; } = new(); + + /// + public int Compare(string? x, string? y) + { + if (ReferenceEquals(x, y)) + return 0; + + if (x == null) + return -1; + + if (y == null) + return 1; + + var ix = 0; + var iy = 0; + + while ((ix < x.Length) && (iy < y.Length)) + if (char.IsDigit(x[ix]) && char.IsDigit(y[iy])) + { + // Compare numeric portions + var numResult = CompareNumericPortion( + x, + y, + ref ix, + ref iy); + + if (numResult != 0) + return numResult; + } else + { + // Compare single characters + if (x[ix] != y[iy]) + return x[ix] + .CompareTo(y[iy]); + + ix++; + iy++; + } + + // Handle case where one string is exhausted + if (ix < x.Length) + return 1; // x has remaining characters + + if (iy < y.Length) + return -1; // y has remaining characters + + return 0; // both strings exhausted simultaneously + } + + private static int CompareNumericPortion( + string x, + string y, + ref int ix, + ref int iy) + { + // Skip leading zeros in x + while ((ix < x.Length) && (x[ix] == '0')) + ix++; + + // Skip leading zeros in y + while ((iy < y.Length) && (y[iy] == '0')) + iy++; + + // Count significant digits + var xDigitStart = ix; + var yDigitStart = iy; + + while ((ix < x.Length) && char.IsDigit(x[ix])) + ix++; + + while ((iy < y.Length) && char.IsDigit(y[iy])) + iy++; + + var xDigitCount = ix - xDigitStart; + var yDigitCount = iy - yDigitStart; + + // Compare by digit count first (longer number is larger) + if (xDigitCount != yDigitCount) + return xDigitCount.CompareTo(yDigitCount); + + // Same number of significant digits, compare lexicographically + for (var i = 0; i < xDigitCount; i++) + { + var xDigit = x[xDigitStart + i]; + var yDigit = y[yDigitStart + i]; + + if (xDigit != yDigit) + return xDigit.CompareTo(yDigit); + } + + return 0; // Numbers are equal + } +} \ No newline at end of file diff --git a/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs b/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs new file mode 100644 index 0000000..797f0ec --- /dev/null +++ b/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace DALib.Comparers; + +/// +/// Compares two strings and returns a value indicating whether one is less than, equal to, or greater than the other. +/// Underscore will be considered less than any other character +/// +public sealed class PreferUnderscoreIgnoreCaseStringComparer : IComparer +{ + /// + /// Gets the default instance of the class. + /// + public static IComparer Instance { get; } = new PreferUnderscoreIgnoreCaseStringComparer(); + + /// + public int Compare(string? x, string? y) + { + if (StringComparer.OrdinalIgnoreCase.Equals(x, y)) + return 0; + + if (x == null) + return -1; + + if (y == null) + return 1; + + var xLength = x.Length; + var yLength = y.Length; + var minLength = xLength < yLength ? xLength : yLength; + + for (var i = 0; i < minLength; i++) + { + var xChar = char.ToUpperInvariant(x[i]); + var yChar = char.ToUpperInvariant(y[i]); + + if (xChar == yChar) + continue; + + if (xChar == '_') + return -1; + + if (yChar == '_') + return 1; + + return xChar.CompareTo(yChar); + } + + return xLength.CompareTo(yLength); + } +} \ No newline at end of file diff --git a/DALib/DALib.csproj b/DALib/DALib.csproj index d006b40..5cf9139 100644 --- a/DALib/DALib.csproj +++ b/DALib/DALib.csproj @@ -1,7 +1,6 @@  - net8.0 DALib 0.5.3 README.md @@ -22,6 +21,8 @@ enable false true + net9.0 + latestmajor @@ -30,8 +31,8 @@ - - + + diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index 39cce39..dc51f55 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -1,13 +1,17 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; using System.IO; using System.IO.MemoryMappedFiles; using System.Linq; using System.Text; +using System.Threading; using DALib.Abstractions; +using DALib.Comparers; using DALib.Definitions; using DALib.Extensions; +using KGySoft.CoreLibraries; namespace DALib.Data; @@ -16,7 +20,10 @@ namespace DALib.Data; /// public class DataArchive : KeyedCollection, ISavable, IDisposable { - private bool IsDisposed; + /// + /// Whether the archive has been disposed. + /// + private int Disposed; /// /// The base stream of the archive @@ -57,22 +64,31 @@ protected DataArchive(Stream stream, bool newFormat = false) stream.Seek(-4, SeekOrigin.Current); - Add( - new DataArchiveEntry( - this, - name, - startAddress, - endAddress - startAddress)); + try + { + Add( + new DataArchiveEntry( + this, + name, + startAddress, + endAddress - startAddress)); + } catch + { + if (!newFormat) + throw; + } } } /// public virtual void Dispose() { - GC.SuppressFinalize(this); + if (Interlocked.CompareExchange(ref Disposed, 1, 0) == 1) + return; BaseStream.Dispose(); - IsDisposed = true; + + GC.SuppressFinalize(this); } /// @@ -267,9 +283,186 @@ public virtual void Patch(string entryName, ISavable item) Add(entry); } - internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(IsDisposed, this); + internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Disposed == 1, this); #region SaveTo + /// + /// Sorts the archive entries in a custom ordering + /// + /// + /// This all looks really complicated, but what's happening is conceptually simple... + /// + /// + /// + /// if the entry name is parsable as an integer, sort it as an integer + /// + /// + /// + /// + /// analyze the all entries in the archive and group them by their "prefix" + /// + /// + /// + /// + /// for each group, find the common numeric identifier length + /// + /// + /// + /// this isnt an exact process because there can be entries with /no/ numbers, and entries + /// /with/ numbers for the same prefix + /// + /// + /// + /// + /// there can also be entries with different lengths of numeric identifiers because they have + /// numeric tails (think khan archive entries ending in 01, 02, etc) + /// + /// + /// + /// + /// + /// + /// + /// sort by prefix (underscore are considered "less than" other characters) + /// + /// + /// + /// + /// then by common numeric identifier (no identifier is smallest) + /// + /// + /// + /// + /// then by tail (if there is a tail) + /// + /// + /// + /// + /// then by extension + /// + /// + /// + /// + public void Sort() + { + ThrowIfDisposed(); + + //split entries entry names based on a regex + //group the entries by their "prefix" + //the prefix is all of the letters in the entry name up till the first digit + var entryPartsGroupedByPrefix = Items.Select( + entry => + { + var entryName = Path.GetFileNameWithoutExtension(entry.EntryName); + var extension = Path.GetExtension(entry.EntryName); + + //if the entry name is a number, we can't split it + //just sort it based on that number as a string + if (int.TryParse(entryName, out _)) + return new + { + Entry = entry, + Prefix = entryName, + NumericId = "", + Tail = "", + Extension = extension + }; + + var parts = RegexCache.EntryNameRegex + .Matches(entryName)[0].Groups; + + return new + { + Entry = entry, + Prefix = parts[1].Value, + NumericId = parts[2].Value, + Tail = parts[3].Value, + Extension = extension + }; + }) + .GroupBy(parts => parts.Prefix, StringComparer.OrdinalIgnoreCase) + .ToList(); + + //look at each prefix grouping and extract a common length for the numeric part of the entry name + //sometimes there will be a combination of entries with and without numeric identifiers + //we prefer to store a non-zero length if possible + var prefixToCommonIdentifierLength = entryPartsGroupedByPrefix.ToDictionary( + group => group.Key, + group => + { + //order by numeric id length, take the first 3 entries + var first3 = group.Select(parts => parts.NumericId.Length) + .OrderBy(len => len) + .Take(3) + .ToList(); + + //prefer to store a non-zero id length + return first3 switch + { + { Count: 0 } => 0, + { Count: 1 } => first3[0], + { Count: 2 or 3 } => first3.FirstOrDefault(num => num > 0), + _ => throw new UnreachableException("We take 3, handling counts 0-3 should be all conditions") + }; + }, + StringComparer.OrdinalIgnoreCase); + + //now that we know the common length for the id for each prefix + //adjust the numeric id and tail to have the correct pieces of the entry name + var correctedParts = entryPartsGroupedByPrefix.SelectMany(group => group) + .Select( + parts => + { + var commonLength = prefixToCommonIdentifierLength[parts.Prefix]; + + //regex parsed more digits for this entry than some of the others with same prefix + //so we move those extra digits to the tail + if (parts.NumericId.Length > commonLength) + return parts with + { + NumericId = parts.NumericId[..commonLength], + Tail = parts.NumericId[commonLength..] + parts.Tail + }; + + return parts; + }); + + Items.Clear(); + + var orderedEntries = correctedParts + + //SORT BY PREFIX, UNDERSCORES ARE SPECIAL + .OrderBy(parts => parts.Prefix, PreferUnderscoreIgnoreCaseStringComparer.Instance) + + //THEN BY COMMON NUMERIC IDENTIFIER + .ThenBy( + parts => + { + //grab the common length of the identifier for the prefix + var commonIdentifierLength = prefixToCommonIdentifierLength[parts.Prefix]; + + //if it's 0, or the numeric id is shorter than the common length, return -1 + //the numeric id can be shorter than the length if... + //an entry was found WITH a numeric id, and another entry was found WITHOUT a numeric id + //we prefer to take a numeric id if one seems like it exists + if ((commonIdentifierLength == 0) || (parts.NumericId.Length < commonIdentifierLength)) + return -1; + + //parse the numeric id and sort by it + return int.Parse(parts.NumericId); + }) + + //THEN BY TAIL, UNDERSCORES ARE SPECIAL + .ThenBy(parts => parts.Tail, PreferUnderscoreIgnoreCaseStringComparer.Instance) + + //THEN BY EXTENSION, UNDERSCORES ARE SPECIAL BUT PROBABLY DONT ACTUALLY MATTER + .ThenBy(parts => parts.Extension, PreferUnderscoreIgnoreCaseStringComparer.Instance) + .Select(parts => parts.Entry) + .ToList(); + + Items.AddRange(orderedEntries); + } + /// /// /// Thrown if the object is already disposed. @@ -328,9 +521,9 @@ public virtual void Save(Stream stream) //plus 4 bytes for the final entry's end address (which could also be considered the total number of bytes) var address = HEADER_LENGTH + Count * ENTRY_HEADER_LENGTH + 4; - var entries = this.ToList(); + Sort(); - foreach (var entry in entries) + foreach (var entry in Items) { //reconstruct the name field with the required terminator var nameStr = entry.EntryName; @@ -343,7 +536,7 @@ public virtual void Save(Stream stream) nameStr = nameStr.PadRight(CONSTANTS.DATA_ARCHIVE_ENTRY_NAME_LENGTH, '\0'); //get bytes for the name field (binaryWriter.Write(string) doesn't work for this) - var nameStrBytes = Encoding.ASCII.GetBytes(nameStr); + var nameStrBytes = Encoding.UTF8.GetBytes(nameStr); writer.Write(address); writer.Write(nameStrBytes); @@ -353,7 +546,7 @@ public virtual void Save(Stream stream) writer.Write(address); - foreach (var entry in entries) + foreach (var entry in Items) { using var segment = entry.ToStreamSegment(); segment.CopyTo(stream); diff --git a/DALib/Definitions/Flags.cs b/DALib/Definitions/Flags.cs new file mode 100644 index 0000000..0c435e9 --- /dev/null +++ b/DALib/Definitions/Flags.cs @@ -0,0 +1,25 @@ +using System; + +namespace DALib.Definitions; + +/// +/// Tils flags as used in sotp.dat +/// +[Flags] +public enum TileFlags : byte +{ + /// + /// Tile is a normal tile + /// + None = 0, + + /// + /// Tile is a wall + /// + Wall = 15, + + /// + /// Tile has luminosity based transparency (dark = more transparent) + /// + Transparent = 128 +} \ No newline at end of file diff --git a/DALib/Definitions/RegexCache.cs b/DALib/Definitions/RegexCache.cs new file mode 100644 index 0000000..b5ec124 --- /dev/null +++ b/DALib/Definitions/RegexCache.cs @@ -0,0 +1,10 @@ +using System.Text.RegularExpressions; + +namespace DALib.Definitions; + +// ReSharper disable once PartialTypeWithSinglePart +internal static partial class RegexCache +{ + [GeneratedRegex(@"(\D+)(\d+)?(.+)?", RegexOptions.Compiled)] + internal static partial Regex EntryNameRegex { get; } +} \ No newline at end of file diff --git a/DALib/Drawing/EpfFile.cs b/DALib/Drawing/EpfFile.cs index 3757bda..e00e1bb 100644 --- a/DALib/Drawing/EpfFile.cs +++ b/DALib/Drawing/EpfFile.cs @@ -1,13 +1,17 @@ -using System.Collections.Generic; +#region +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using DALib.Abstractions; using DALib.Data; using DALib.Extensions; using DALib.Utility; using SkiaSharp; +#endregion namespace DALib.Drawing; @@ -129,7 +133,7 @@ public void Save(Stream stream) writer.Write(PixelHeight); writer.Write(UnknownBytes); - var footerStartAddress = this.Sum(frame => frame.Data.Length); + var footerStartAddress = this.Sum(frame => Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight)); writer.Write(footerStartAddress); @@ -137,8 +141,9 @@ public void Save(Stream stream) for (var i = 0; i < Count; i++) { var frame = this[i]; + var length = Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight); - writer.Write(frame.Data); + writer.Write(frame.Data[..length]); } var dataIndex = 0; @@ -148,7 +153,8 @@ public void Save(Stream stream) { var frame = this[i]; - var dataEndAddress = dataIndex + frame.Data.Length; + var length = Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight); + var dataEndAddress = dataIndex + length; writer.Write(frame.Top); writer.Write(frame.Left); @@ -157,7 +163,7 @@ public void Save(Stream stream) writer.Write(dataIndex); writer.Write(dataEndAddress); - dataIndex += frame.Data.Length; + dataIndex += length; } } #endregion @@ -243,29 +249,34 @@ public static Palettized FromImages(QuantizerOptions options, IEnumerab public static Palettized FromImages(QuantizerOptions options, params SKImage[] orderedFrames) { ImageProcessor.PreserveNonTransparentBlacks(orderedFrames); - + //ImageProcessor.CropTransparentPixels(orderedFrames); + using var quantized = ImageProcessor.QuantizeMultiple(options, orderedFrames); (var images, var palette) = quantized; + + // Crop all transparent edges and get the top-left offsets + var anchorPoints = ImageProcessor.CropTransparentPixels(images); - var imageWidth = (short)orderedFrames.Select(img => img.Width) - .Max(); + var imageWidth = (short)images.Select(img => img.Width) + .Max(); - var imageHeight = (short)orderedFrames.Select(img => img.Height) - .Max(); + var imageHeight = (short)images.Select(img => img.Height) + .Max(); var epfFile = new EpfFile(imageWidth, imageHeight); for (var i = 0; i < images.Count; i++) { var image = images[i]; + var dims = anchorPoints[i]; epfFile.Add( new EpfFrame { - Top = 0, - Left = 0, - Right = (short)image.Width, - Bottom = (short)image.Height, + Top = (short)dims.Y, + Left = (short)dims.X, + Right = (short)(dims.X + image.Width), + Bottom = (short)(dims.Y + image.Height), Data = image.GetPalettizedPixelData(palette) }); } diff --git a/DALib/Drawing/Graphics.cs b/DALib/Drawing/Graphics.cs index 8fd651d..0c4236b 100644 --- a/DALib/Drawing/Graphics.cs +++ b/DALib/Drawing/Graphics.cs @@ -1,11 +1,12 @@ -using DALib.Data; +using System; +using System.Linq; +using System.Text; +using DALib.Data; using DALib.Definitions; using DALib.Extensions; using DALib.Memory; using DALib.Utility; using SkiaSharp; -using System; -using System.Text; namespace DALib.Drawing; @@ -62,14 +63,30 @@ public static SKImage RenderImage(MpfFrame frame, Palette palette) /// /// An optional custom offset used to move the image down, since these images are rendered from the bottom up /// - public static SKImage RenderImage(HpfFile hpf, Palette palette, int yOffset = 0) - => SimpleRender( + /// + /// An optional flag to enable transparency. Some foreground images has luminosity based transparency. This is + /// controlled via SOTP with the flag + /// + public static SKImage RenderImage( + HpfFile hpf, + Palette palette, + int yOffset = 0, + bool transparency = false) + { + if (transparency) + { + var semiTransparentColors = palette.Select(color => color.WithLuminanceAlpha()); + palette = new Palette(semiTransparentColors); + } + + return SimpleRender( 0, yOffset, hpf.PixelWidth, hpf.PixelHeight, hpf.Data, palette); + } /// /// Renders a palettized SPF frame @@ -89,6 +106,34 @@ public static SKImage RenderImage(SpfFrame spf, Palette spfPrimaryColorPalette) spf.Data!, spfPrimaryColorPalette); + /// + /// Renders a palette + /// + public static SKImage RenderImage(Palette palette) + { + using var bitmap = new SKBitmap(16 * 5, 16 * 5); + + using (var canvas = new SKCanvas(bitmap)) + for (var y = 0; y < 16; y++) + for (var x = 0; x < 16; x++) + { + var color = palette[x + y * 16]; + + using var paint = new SKPaint(); + paint.Color = color; + paint.IsAntialias = true; + + canvas.DrawRect( + x * 5, + y * 5, + 5, + 5, + paint); + } + + return SKImage.FromBitmap(bitmap); + } + /// /// Renders a colorized SpfFrame /// @@ -114,7 +159,11 @@ public static SKImage RenderImage(SpfFrame spf) /// public static SKImage RenderImage(EfaFrame efa, EfaBlendingType efaBlendingType = EfaBlendingType.Luminance) { - using var bitmap = new SKBitmap(efa.ImagePixelWidth, efa.ImagePixelHeight); + using var bitmap = new SKBitmap( + efa.ImagePixelWidth, + efa.ImagePixelHeight, + SKColorType.Bgra8888, + SKAlphaType.Unpremul); using var pixMap = bitmap.PeekPixels(); var pixelBuffer = pixMap.GetPixelSpan(); @@ -183,50 +232,57 @@ public static SKImage RenderImage(EfaFrame efa, EfaBlendingType efaBlendingType /// The amount of padding to add to the height of the file and beginning rendering position /// /// - /// A that can be reused to share caches between multiple map renderings. + /// A that can be reused to share caches between + /// multiple map renderings. /// - - public static SKImage RenderMap(MapFile map, DataArchive seoDat, DataArchive iaDat, int foregroundPadding = 512, + public static SKImage RenderMap( + MapFile map, + DataArchive seoDat, + DataArchive iaDat, + int foregroundPadding = 512, MapImageCache? cache = null) => RenderMap( map, Tileset.FromArchive("tilea", seoDat), PaletteLookup.FromArchive("mpt", seoDat) - .Freeze(), + .Freeze(), PaletteLookup.FromArchive("stc", iaDat) - .Freeze(), - iaDat, foregroundPadding, cache); + .Freeze(), + iaDat, + foregroundPadding, + cache); /// /// Renders a MapFile, given already extracted information /// /// - /// The to render + /// The to render /// /// - /// A representing a collection of background tiles + /// A representing a collection of background tiles /// /// - /// for background tiles + /// for background tiles /// /// - /// for foreground tiles + /// for foreground tiles /// /// - /// IA for reading foreground tile files + /// IA for reading foreground tile files /// /// /// The amount of padding to add to the height of the file and beginning rendering position /// /// - /// A that can be reused to share caches between multiple map renderings. + /// A that can be reused to share caches between + /// multiple map renderings. /// public static SKImage RenderMap( MapFile map, Tileset tiles, PaletteLookup bgPaletteLookup, PaletteLookup fgPaletteLookup, - DataArchive iaDat, + DataArchive iaDat, int foregroundPadding = 512, MapImageCache? cache = null) { @@ -240,13 +296,14 @@ public static SKImage RenderMap( //calculate width and height var width = (map.Width + map.Height + 1) * CONSTANTS.HALF_TILE_WIDTH; - var height = (map.Width + map.Height + 1 ) * CONSTANTS.HALF_TILE_HEIGHT + foregroundPadding; + var height = (map.Width + map.Height + 1) * CONSTANTS.HALF_TILE_HEIGHT + foregroundPadding; using var bitmap = new SKBitmap(width, height); using var canvas = new SKCanvas(bitmap); //the first tile drawn is the center tile at the top (0, 0) var bgInitialDrawX = (map.Height - 1) * CONSTANTS.HALF_TILE_WIDTH; var bgInitialDrawY = foregroundPadding; + try { //render background tiles and draw them to the canvas @@ -292,7 +349,7 @@ public static SKImage RenderMap( var rfgIndex = tile.RightForeground; //render left foreground - var lfgImage = cache.LeftForegroundCache.GetOrCreate( + var lfgImage = cache.ForegroundCache.GetOrCreate( lfgIndex, index => { @@ -305,14 +362,13 @@ public static SKImage RenderMap( //for each X axis iteration, we want to move the draw position half a tile to the right and down from the initial draw position var lfgDrawX = fgInitialDrawX + x * CONSTANTS.HALF_TILE_WIDTH; - var lfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - lfgImage.Height + - CONSTANTS.HALF_TILE_HEIGHT; + var lfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - lfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; - if ((lfgIndex % 10000) > 1) + if (lfgIndex.IsRenderedTileIndex()) canvas.DrawImage(lfgImage, lfgDrawX, lfgDrawY); //render right foreground - var rfgImage = cache.RightForegroundCache.GetOrCreate( + var rfgImage = cache.ForegroundCache.GetOrCreate( rfgIndex, index => { @@ -325,10 +381,9 @@ public static SKImage RenderMap( //for each X axis iteration, we want to move the draw position half a tile to the right and down from the initial draw position var rfgDrawX = fgInitialDrawX + (x + 1) * CONSTANTS.HALF_TILE_WIDTH; - var rfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - rfgImage.Height + - CONSTANTS.HALF_TILE_HEIGHT; + var rfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - rfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; - if ((rfgIndex % 10000) > 1) + if (rfgIndex.IsRenderedTileIndex()) canvas.DrawImage(rfgImage, rfgDrawX, rfgDrawY); } @@ -336,14 +391,13 @@ public static SKImage RenderMap( fgInitialDrawX -= CONSTANTS.HALF_TILE_WIDTH; fgInitialDrawY += CONSTANTS.HALF_TILE_HEIGHT; } + return SKImage.FromBitmap(bitmap); - } - finally + } finally { if (dispose) cache.Dispose(); } - } /// diff --git a/DALib/Drawing/MpfFile.cs b/DALib/Drawing/MpfFile.cs index bde016b..1e5245e 100644 --- a/DALib/Drawing/MpfFile.cs +++ b/DALib/Drawing/MpfFile.cs @@ -60,6 +60,19 @@ public sealed class MpfFile : Collection, ISavable /// public MpfHeaderType HeaderType { get; set; } + /// + /// The number of frames in the standing animation including optional frames. If your normal standing animation has 4 + /// frames, but there are 2 extra frames that should occasionally be played, then you would put 6 here. (4 normal + /// frames + 2 optional frames) + /// + public byte OptionalAnimationFrameCount { get; set; } + + /// + /// Specifies the ratio of playing the optional standing frames. For example, if this is set to 50, it will play the + /// optional frames 50% of the time + /// + public byte OptionalAnimationRatio { get; set; } + /// /// The palette number used to colorize this image /// @@ -76,7 +89,7 @@ public sealed class MpfFile : Collection, ISavable public short PixelWidth { get; set; } /// - /// The number of frames for the standing animation + /// The number of frames for the standing animation without the optional frames /// public byte StandingFrameCount { get; set; } @@ -85,21 +98,6 @@ public sealed class MpfFile : Collection, ISavable /// public byte StandingFrameIndex { get; set; } - /// - /// Specifies the ratio of switching to other frames from the frames designated in StopMotionFrameCount. For example, - /// if you set this to 10 for a guy spinning nunchucks, about once every 10 times, he hits his own head. (about once - /// every 10 times the client will play the StopMotion frames) - /// - public byte StopMotionFailureRatio { get; set; } - - /// - /// Number of frames in the stationary action (For example, a guy continuously spinning nunchucks would need two frames - /// for the spinning action, so it should be written as 2) Usually, it should be written as 0. If it's written as 0, it - /// continuously repeats the entire action frame and if it's a number other than 0, it repeats only that many frames - /// and occasionally animates with the remaining frames. - /// - public byte StopMotionFrameCount { get; set; } - /// /// Unknown header bytes at the beginning of the file. Only used if HeaderType is set to Unknown /// @@ -195,8 +193,8 @@ private MpfFile(Stream stream) case MpfFormatType.MultipleAttacks: StandingFrameIndex = reader.ReadByte(); StandingFrameCount = reader.ReadByte(); - StopMotionFrameCount = reader.ReadByte(); - StopMotionFailureRatio = reader.ReadByte(); + OptionalAnimationFrameCount = reader.ReadByte(); + OptionalAnimationRatio = reader.ReadByte(); AttackFrameIndex = reader.ReadByte(); AttackFrameCount = reader.ReadByte(); Attack2StartIndex = reader.ReadByte(); @@ -212,8 +210,8 @@ private MpfFile(Stream stream) AttackFrameCount = reader.ReadByte(); StandingFrameIndex = reader.ReadByte(); StandingFrameCount = reader.ReadByte(); - StopMotionFrameCount = reader.ReadByte(); - StopMotionFailureRatio = reader.ReadByte(); + OptionalAnimationFrameCount = reader.ReadByte(); + OptionalAnimationRatio = reader.ReadByte(); break; } @@ -307,8 +305,8 @@ public void Save(Stream stream) writer.Write((short)FormatType); writer.Write(StandingFrameIndex); writer.Write(StandingFrameCount); - writer.Write(StopMotionFrameCount); - writer.Write(StopMotionFailureRatio); + writer.Write(OptionalAnimationFrameCount); + writer.Write(OptionalAnimationRatio); writer.Write(AttackFrameIndex); writer.Write(AttackFrameCount); writer.Write(Attack2StartIndex); @@ -321,8 +319,8 @@ public void Save(Stream stream) writer.Write(AttackFrameCount); writer.Write(StandingFrameIndex); writer.Write(StandingFrameCount); - writer.Write(StopMotionFrameCount); - writer.Write(StopMotionFailureRatio); + writer.Write(OptionalAnimationFrameCount); + writer.Write(OptionalAnimationRatio); } var startAddress = 0; diff --git a/DALib/Drawing/Tileset.cs b/DALib/Drawing/Tileset.cs index 43ec749..a8ec5f2 100644 --- a/DALib/Drawing/Tileset.cs +++ b/DALib/Drawing/Tileset.cs @@ -1,14 +1,14 @@ -using DALib.Abstractions; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using DALib.Abstractions; using DALib.Data; using DALib.Definitions; using DALib.Extensions; using DALib.Utility; using SkiaSharp; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Text; namespace DALib.Drawing; @@ -62,9 +62,7 @@ public void Save(Stream stream) { using var writer = new BinaryWriter(stream, Encoding.Default, true); - var tileCount = (int)(stream.Length / CONSTANTS.TILE_SIZE); - - for (var i = 0; i < tileCount; i++) + for (var i = 0; i < Count; i++) { var tile = this[i]; diff --git a/DALib/Extensions/IntExtensions.cs b/DALib/Extensions/IntExtensions.cs new file mode 100644 index 0000000..60546ee --- /dev/null +++ b/DALib/Extensions/IntExtensions.cs @@ -0,0 +1,29 @@ +namespace DALib.Extensions; + +/// +/// Provides extension methods for the struct +/// +public static class IntExtensions +{ + /// + /// Determined if the tile index is rendered by the client + /// + /// + /// The tile index to check + /// + /// + /// + /// true + /// + /// if the tile index is rendered by the client; otherwise, + /// + /// false + /// + /// . + /// + /// + /// 0-12 and 10000-10012 are not rendered by the client... 20000-20012 are rendered but are kinda buggy if you get + /// close to them + /// + public static bool IsRenderedTileIndex(this int tileIndex) => (tileIndex > 10012) || ((tileIndex % 10000) > 12); +} \ No newline at end of file diff --git a/DALib/Extensions/NumberExtensions.cs b/DALib/Extensions/NumberExtensions.cs new file mode 100644 index 0000000..03ba41a --- /dev/null +++ b/DALib/Extensions/NumberExtensions.cs @@ -0,0 +1,25 @@ +using System.Numerics; + +namespace DALib.Extensions; + +/// +/// Provides extension methods for numbers. +/// +/// +/// +public static class NumberExtensions where T: INumber +{ + /// + /// Whether the type is an integer type + /// + public static bool IsIntegerType { get; } = (typeof(T) == typeof(sbyte)) + || (typeof(T) == typeof(byte)) + || (typeof(T) == typeof(short)) + || (typeof(T) == typeof(ushort)) + || (typeof(T) == typeof(int)) + || (typeof(T) == typeof(uint)) + || (typeof(T) == typeof(long)) + || (typeof(T) == typeof(ulong)) + || (typeof(T) == typeof(nint)) + || (typeof(T) == typeof(nuint)); +} \ No newline at end of file diff --git a/DALib/Extensions/PalettizedExtensions.cs b/DALib/Extensions/PalettizedExtensions.cs index 9c373df..7204646 100644 --- a/DALib/Extensions/PalettizedExtensions.cs +++ b/DALib/Extensions/PalettizedExtensions.cs @@ -4,6 +4,7 @@ using DALib.Definitions; using DALib.Drawing; using DALib.Utility; +using SkiaSharp; namespace DALib.Extensions; @@ -87,10 +88,29 @@ public static Palettized RemapPalette(this Palettized palettiz { (var epf, var palette) = palettized; + var reversedPalette = palette.Reverse() + .ToList(); + //create a dictionary that maps the old color indexes to the new color indexes var colorIndexMap = palette.Select((c, i) => (c, i)) - .DistinctBy(set => set.c) - .ToFrozenDictionary(set => (byte)set.i, set => (byte)newPalette.IndexOf(set.c)); + .ToFrozenDictionary( + set => (byte)set.i, + set => + { + var newIndex = newPalette.IndexOf(set.c); + + //if we couldn't find the color, and the color is what we use to preserve blacks + //look for a black that isn't in index 0 + //search backwards, then reverse the index + if ((newIndex == -1) && (set.c == CONSTANTS.RGB555_ALMOST_BLACK)) + { + var reversedIndex = reversedPalette.IndexOf(SKColors.Black); + + return (byte)(reversedPalette.Count - reversedIndex - 1); + } + + return (byte)newIndex; + }); foreach (var frame in epf) for (var i = 0; i < frame.Data.Length; i++) @@ -103,4 +123,47 @@ public static Palettized RemapPalette(this Palettized palettiz Palette = newPalette }; } + + /// + /// Remaps the frame data of the given palettized hpf file to use the indexes of the new palette, assuming the colors + /// are the same + /// + public static Palettized RemapPalette(this Palettized palettized, Palette newPalette) + { + (var hpf, var palette) = palettized; + + var reversedPalette = palette.Reverse() + .ToList(); + + //create a dictionary that maps the old color indexes to the new color indexes + var colorIndexMap = palette.Select((c, i) => (c, i)) + .ToFrozenDictionary( + set => (byte)set.i, + set => + { + var newIndex = newPalette.IndexOf(set.c); + + //if we couldn't find the color, and the color is what we use to preserve blacks + //look for a black that isn't in index 0 + //search backwards, then reverse the index + if ((newIndex == -1) && (set.c == CONSTANTS.RGB555_ALMOST_BLACK)) + { + var reversedIndex = reversedPalette.IndexOf(SKColors.Black); + + return (byte)(reversedPalette.Count - reversedIndex - 1); + } + + return (byte)newIndex; + }); + + for (var i = 0; i < hpf.Data.Length; i++) + hpf.Data[i] = colorIndexMap[hpf.Data[i]]; + + //return the epffile with the new palette + return new Palettized + { + Entity = hpf, + Palette = newPalette + }; + } } \ No newline at end of file diff --git a/DALib/Extensions/SKColorExtensions.cs b/DALib/Extensions/SKColorExtensions.cs index a3029b3..c6479d4 100644 --- a/DALib/Extensions/SKColorExtensions.cs +++ b/DALib/Extensions/SKColorExtensions.cs @@ -9,6 +9,33 @@ namespace DALib.Extensions; /// public static class SKColorExtensions { + /// + /// Adjusts the brightness of an image by a percentage + /// + public static SKBitmap AdjustBrightness(this SKBitmap bitmap, float percent) + { + // @formatter:off + using var filter = SKColorFilter.CreateColorMatrix([ percent, 0, 0, 0, 0, + 0, percent, 0, 0, 0, + 0, 0, percent, 0, 0, + 0, 0, 0, 1, 0 ]); + // @formatter:on + + var newBitmap = bitmap.Copy(); + using var canvas = new SKCanvas(newBitmap); + + using var paint = new SKPaint(); + paint.ColorFilter = filter; + + canvas.DrawBitmap( + bitmap, + 0, + 0, + paint); + + return newBitmap; + } + /// /// Calculates the luminance of a color using the provided coefficient. /// @@ -19,6 +46,51 @@ public static class SKColorExtensions /// The coefficient to multiply the luminance by. Default value is 1.0f. /// public static float GetLuminance(this SKColor color, float coefficient = 1.0f) + { + var gamma = 2.0f; + + // Convert from [0..255] to [0..1]. + var r = color.Red / 255f; + var g = color.Green / 255f; + var b = color.Blue / 255f; + + // Convert to linear space (approx). + r = MathF.Pow(r, gamma); + g = MathF.Pow(g, gamma); + b = MathF.Pow(b, gamma); + + // Compute luminance in linear space. + // (Either the older 0.299/0.587/0.114 or the Rec. 709 ones: 0.2126/0.7152/0.0722) + /*var lumLinear = 0.2126f * r + 0.7152f * g + 0.0722f * b;*/ + var lumLinear = 0.299f * r + 0.587f * g + 0.114f * b; + + // Convert back to sRGB if needed. + var lumSrgb = MathF.Pow(lumLinear, 1f / gamma); + + return (byte)Math.Clamp(MathF.Round(lumSrgb * 255f * coefficient), 0, 255); + } + + public static SKColor GetRandomVividColor(Random? random = null) + { + random ??= Random.Shared; + + var hue = (float)(random.NextDouble() * 360.0f); // full hue range + var saturation = 80 + (float)(random.NextDouble() * 20); // 80–100% + var value = 80 + (float)(random.NextDouble() * 20); // 80–100% + + return SKColor.FromHsv(hue, saturation, value); + } + + /// + /// Calculates the luminance of a color using the provided coefficient. + /// + /// + /// The color whose luminance is being calculated. + /// + /// + /// The coefficient to multiply the luminance by. Default value is 1.0f. + /// + public static float GetSimpleLuminance(this SKColor color, float coefficient = 1.0f) => (0.299f * color.Red + 0.587f * color.Green + 0.114f * color.Blue) * coefficient; /// @@ -36,6 +108,35 @@ public static bool IsNearBlack(this SKColor color) Blue: <= CONSTANTS.RGB555_COLOR_LOSS_FACTOR }; + /// + /// Generates a random color within a given percent(positive or negative) of the given color + /// + /// + /// + /// + /// + /// + /// + public static SKColor Randomize(this SKColor color, float percent = 0.1f) + { + var random = new Random(); + var halfPercent = percent / 2f; + + var rFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + var gFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + var bFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + + var r = (byte)Math.Clamp(color.Red * rFactor, 0, 255); + var g = (byte)Math.Clamp(color.Green * gFactor, 0, 255); + var b = (byte)Math.Clamp(color.Blue * bFactor, 0, 255); + + return new SKColor( + r, + g, + b, + color.Alpha); + } + /// /// Returns a new SKColor with the alpha set based on the luminance of the color /// diff --git a/DALib/Extensions/SpanWriterExtensions.cs b/DALib/Extensions/SpanWriterExtensions.cs index 1599fdd..46849eb 100644 --- a/DALib/Extensions/SpanWriterExtensions.cs +++ b/DALib/Extensions/SpanWriterExtensions.cs @@ -1,4 +1,5 @@ using DALib.Memory; +using DALib.Utility; using SkiaSharp; namespace DALib.Extensions; @@ -17,16 +18,7 @@ public static class SpanWriterExtensions /// /// The RGB888 color to encode and write /// - public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) - { - var r = color.Red >> 3; - var g = color.Green >> 3; - var b = color.Blue >> 3; - - var rgb555 = (ushort)((r << 10) | (g << 5) | b); - - writer.WriteUInt16(rgb555); - } + public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) => writer.WriteUInt16(ColorCodec.EncodeRgb555(color)); /// /// Writes the given SKColor as a 16-bit RGB565 encoded color to the SpanWriter. @@ -37,14 +29,5 @@ public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) /// /// The RGB888 color to encode and write /// - public static void WriteRgb565Color(ref this SpanWriter writer, SKColor color) - { - var r = color.Red >> 3; - var g = color.Green >> 2; - var b = color.Blue >> 3; - - var rgb565 = (ushort)((r << 11) | (g << 5) | b); - - writer.WriteUInt16(rgb565); - } + public static void WriteRgb565Color(ref this SpanWriter writer, SKColor color) => writer.WriteUInt16(ColorCodec.EncodeRgb565(color)); } \ No newline at end of file diff --git a/DALib/Utility/ColorCodec.cs b/DALib/Utility/ColorCodec.cs index e6e54ba..7ae216e 100644 --- a/DALib/Utility/ColorCodec.cs +++ b/DALib/Utility/ColorCodec.cs @@ -20,21 +20,21 @@ public static SKColor DecodeRgb555(ushort encodedColor) var g = (byte)((encodedColor >> 5) & CONSTANTS.FIVE_BIT_MASK); var b = (byte)(encodedColor & CONSTANTS.FIVE_BIT_MASK); - r = MathEx.ScaleRange( + r = MathEx.ScaleRangeByteOptimized( r, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - g = MathEx.ScaleRange( + g = MathEx.ScaleRangeByteOptimized( g, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - b = MathEx.ScaleRange( + b = MathEx.ScaleRangeByteOptimized( b, 0, CONSTANTS.FIVE_BIT_MASK, @@ -56,21 +56,21 @@ public static SKColor DecodeRgb565(ushort encodedColor) var g = (byte)((encodedColor >> 5) & CONSTANTS.SIX_BIT_MASK); var b = (byte)(encodedColor & CONSTANTS.FIVE_BIT_MASK); - r = MathEx.ScaleRange( + r = MathEx.ScaleRangeByteOptimized( r, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - g = MathEx.ScaleRange( + g = MathEx.ScaleRangeByteOptimized( g, 0, CONSTANTS.SIX_BIT_MASK, 0, byte.MaxValue); - b = MathEx.ScaleRange( + b = MathEx.ScaleRangeByteOptimized( b, 0, CONSTANTS.FIVE_BIT_MASK, @@ -88,21 +88,21 @@ public static SKColor DecodeRgb565(ushort encodedColor) /// public static ushort EncodeRgb555(SKColor color) { - var r = MathEx.ScaleRange( + var r = MathEx.ScaleRangeByteOptimized( color.Red, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var g = MathEx.ScaleRange( + var g = MathEx.ScaleRangeByteOptimized( color.Green, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var b = MathEx.ScaleRange( + var b = MathEx.ScaleRangeByteOptimized( color.Blue, 0, byte.MaxValue, @@ -120,21 +120,21 @@ public static ushort EncodeRgb555(SKColor color) /// public static ushort EncodeRgb565(SKColor color) { - var r = MathEx.ScaleRange( + var r = MathEx.ScaleRangeByteOptimized( color.Red, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var g = MathEx.ScaleRange( + var g = MathEx.ScaleRangeByteOptimized( color.Green, 0, byte.MaxValue, 0, CONSTANTS.SIX_BIT_MASK); - var b = MathEx.ScaleRange( + var b = MathEx.ScaleRangeByteOptimized( color.Blue, 0, byte.MaxValue, diff --git a/DALib/Utility/ImageProcessor.cs b/DALib/Utility/ImageProcessor.cs index a28adbb..b801ad8 100644 --- a/DALib/Utility/ImageProcessor.cs +++ b/DALib/Utility/ImageProcessor.cs @@ -96,6 +96,112 @@ public static void PreserveNonTransparentBlacks(IList images) for (var i = 0; i < images.Count; i++) images[i] = PreserveNonTransparentBlacks(images[i]); } + + + /// + /// Crops an image to remove transparent pixels from all edges and returns the top-left offset + /// + /// + /// The image to crop + /// + /// + /// The top-left offset of the cropped content relative to the original image + /// + /// + /// A new image with transparent pixels removed from all edges, or the original image if it's already fully cropped + /// + public static SKImage CropTransparentPixels(SKImage image, out SKPoint topLeft) + { + using var bitmap = SKBitmap.FromImage(image); + + var transparentColor = SKColors.Black; + + var width = bitmap.Width; + var height = bitmap.Height; + + // Find the bounding box of non-transparent pixels + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var pixel = bitmap.GetPixel(x, y); + + // Check if pixel is not transparent + if (pixel != transparentColor) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + } + + // If no non-transparent pixels found, return a 1x1 transparent image + if ((maxX < minX) || (maxY < minY)) + { + using var emptyBitmap = new SKBitmap(1, 1); + emptyBitmap.SetPixel(0, 0, transparentColor); + + topLeft = new SKPoint(0, 0); + image.Dispose(); + + return SKImage.FromBitmap(emptyBitmap); + } + + // Store the top-left offset + topLeft = new SKPoint(minX, minY); + + // Calculate the cropped dimensions + var croppedWidth = maxX - minX + 1; + var croppedHeight = maxY - minY + 1; + + // If the image is already fully cropped, return the original + if ((minX == 0) && (minY == 0) && (croppedWidth == width) && (croppedHeight == height)) + { + topLeft = new SKPoint(0, 0); + return image; + } + + // Create a new bitmap with the cropped dimensions + using var croppedBitmap = new SKBitmap(croppedWidth, croppedHeight); + + // Extract the subset from the original bitmap + bitmap.ExtractSubset( + croppedBitmap, + new SKRectI(minX, minY, maxX + 1, maxY + 1)); + + var result = SKImage.FromBitmap(croppedBitmap); + + // Dispose the original image since we're returning a new one + image.Dispose(); + + return result; + } + + /// + /// Crops transparent pixels from all edges of each image in the provided list and returns the top-left offsets. + /// + /// + /// The list of images to process, where each image will have its transparent pixels cropped. + /// + /// + /// An array of SKPoint containing the top-left offset for each cropped image + /// + public static SKPoint[] CropTransparentPixels(IList images) + { + var offsets = new SKPoint[images.Count]; + + for (var i = 0; i < images.Count; i++) + images[i] = CropTransparentPixels(images[i], out offsets[i]); + + return offsets; + } /// /// Quantizes the given image using the specified quantizer options. @@ -129,8 +235,23 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima //don't quantize/dither if the image already has less than maximum number of colors if (colorCount <= options.MaxColors) + { + //all colors must be fully opaque or fully transparent + existingPixels = existingPixels.Select( + pixel => + { + //normally the quantizer would set all fully transparent pixels to black + //but since we didn't quantize the image, we have to do it manually + if (pixel.Alpha == 0) + return SKColors.Black; + + //all other colors are set to be fully opaque + return pixel.WithAlpha(byte.MaxValue); + }) + .ToArray(); + existingPixels.CopyTo(pixelBuffer); - else if (options.Ditherer is not null) + } else if (options.Ditherer is not null) { using var dSession = options.Ditherer!.Initialize(source, qSession); @@ -163,17 +284,7 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima if (colorCount <= options.MaxColors) palette = new Palette( - existingPixels.Select( - c => - { - //normally the quantizer would set all fully transparent pixels to black - //but since we didn't quantize the image, we have to do it manually - if (c.Alpha == 0) - return SKColors.Black; - - return c; - }) - .Distinct() + existingPixels.Distinct() .OrderBy(c => c.GetLuminance())); else palette = qSession.Palette!.ToDALibPalette(); @@ -185,6 +296,28 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima }; } + /// + /// Merges multiple palettes into a single palette. + /// + /// + /// The collection of palettes to be merged. Each palette is represented as an enumerable of colors. + /// + /// + /// Thrown when the merged palette exceeds the maximum number of colors allowed. + /// + public static Palette MergePalettes(params IEnumerable palettes) + { + var uniqueColors = palettes.SelectMany(p => p) + .Distinct() + .ToList(); + + if (uniqueColors.Count > CONSTANTS.COLORS_PER_PALETTE) + throw new InvalidOperationException("Merged palette has more than 256 colors"); + + return new Palette(uniqueColors); + } + + /// /// Quantizes the given images using the specified quantizer options /// @@ -207,7 +340,7 @@ public static Palettized QuantizeMultiple(QuantizerOptions op using var quantizedMosaic = Quantize(options, mosaic); using var bitmap = SKBitmap.FromImage(quantizedMosaic.Entity); - var quantizedImages = new List(); + var quantizedImages = new SKImageCollection([]); var x = 0; for (var i = 0; i < images.Length; i++) @@ -232,7 +365,7 @@ public static Palettized QuantizeMultiple(QuantizerOptions op return new Palettized { - Entity = new SKImageCollection(quantizedImages), + Entity = quantizedImages, Palette = quantizedMosaic.Palette }; } diff --git a/DALib/Utility/MapImageCache.cs b/DALib/Utility/MapImageCache.cs index 6575cf8..6d5e267 100644 --- a/DALib/Utility/MapImageCache.cs +++ b/DALib/Utility/MapImageCache.cs @@ -3,51 +3,52 @@ namespace DALib.Utility; /// -/// A collection of caches that can be used as a shared cache for rendering multiple maps. +/// A collection of caches that can be used as a shared cache for rendering multiple +/// maps. /// -public class MapImageCache: IDisposable +public sealed class MapImageCache : IDisposable { /// - /// Initializes a new instance of the class. + /// The background cache + /// + public SKImageCache BackgroundCache { get; } + + /// + /// The left foreground cache + /// + public SKImageCache ForegroundCache { get; } + + /// + /// Initializes a new instance of the class. /// public MapImageCache() { BackgroundCache = new SKImageCache(); - LeftForegroundCache = new SKImageCache(); - RightForegroundCache = new SKImageCache(); + ForegroundCache = new SKImageCache(); } + /// - /// Initializes a new instance of the class using the specified caches. + /// Initializes a new instance of the class using the specified caches. /// - /// The background cache - /// The left foreground cache - /// The right foreground cache - public MapImageCache(SKImageCache bgCache, SKImageCache lfgCache, SKImageCache rfgCache) + /// + /// The background cache + /// + /// + /// The left foreground cache + /// + /// + /// The right foreground cache + /// + public MapImageCache(SKImageCache bgCache, SKImageCache fgCache) { BackgroundCache = bgCache; - LeftForegroundCache = lfgCache; - RightForegroundCache = rfgCache; + ForegroundCache = fgCache; } - /// - /// The background cache - /// - public SKImageCache BackgroundCache { get; } - /// - /// The left foreground cache - /// - public SKImageCache LeftForegroundCache { get; } - /// - /// The right foreground cache - /// - public SKImageCache RightForegroundCache { get; } - /// - public virtual void Dispose() + /// + public void Dispose() { BackgroundCache.Dispose(); - LeftForegroundCache.Dispose(); - RightForegroundCache.Dispose(); - GC.SuppressFinalize(this); + ForegroundCache.Dispose(); } -} - +} \ No newline at end of file diff --git a/DALib/Utility/MathEx.cs b/DALib/Utility/MathEx.cs index bb88f47..a298161 100644 --- a/DALib/Utility/MathEx.cs +++ b/DALib/Utility/MathEx.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using DALib.Extensions; namespace DALib.Utility; @@ -40,25 +41,26 @@ public static T2 ScaleRange( T2 newMax) where T1: INumber where T2: INumber { - var ret = ScaleRange( - double.CreateTruncating(num), - double.CreateTruncating(min), - double.CreateTruncating(max), - double.CreateTruncating(newMin), - double.CreateTruncating(newMax)); + if (min.Equals(max)) + throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); + + // Compute the ratio as double for higher precision + var ratio = double.CreateChecked(num - min) / double.CreateChecked(max - min); - //get a rounded value and a truncated value - var roundedValue = Math.Round(ret, MidpointRounding.AwayFromZero); - var truncatedValue = T2.CreateTruncating(ret); + // Compute the scaled value + var scaledValue = ratio * double.CreateChecked(newMax - newMin) + double.CreateChecked(newMin); - //take whichever value is closer to the true value - var roundedDiff = Math.Abs(roundedValue - ret); - var truncatedDiff = Math.Abs(double.CreateTruncating(truncatedValue) - ret); + // Determine if T2 is an integer type + if (NumberExtensions.IsIntegerType) + { + // Round the scaled value to the nearest integer + var roundedValue = Math.Round(scaledValue, MidpointRounding.AwayFromZero); - if (roundedDiff < truncatedDiff) - return T2.CreateTruncating(roundedValue); + return T2.CreateChecked(roundedValue); + } - return truncatedValue; + // For floating-point types, return the scaled value directly + return T2.CreateChecked(scaledValue); } /// @@ -85,16 +87,20 @@ public static T2 ScaleRange( /// /// This method assumes that the input number is within the original range. No clamping or checking is performed. /// - public static double ScaleRange( - double num, - double min, - double max, - double newMin, - double newMax) + public static byte ScaleRangeByteOptimized( + byte num, + byte min, + byte max, + byte newMin, + byte newMax) { - if (min.Equals(max)) + if (min == max) throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); - return (newMax - newMin) * (num - min) / (max - min) + newMin; + // Cast to float (or double) to avoid truncation + var ratio = (float)(num - min) / (max - min); + var newValue = (newMax - newMin) * ratio + newMin; + + return (byte)Math.Round(newValue); } } \ No newline at end of file diff --git a/DALib/Utility/SKImageCache.cs b/DALib/Utility/SKImageCache.cs index 730ce84..c15c4cc 100644 --- a/DALib/Utility/SKImageCache.cs +++ b/DALib/Utility/SKImageCache.cs @@ -1,6 +1,7 @@ -using SkiaSharp; using System; using System.Collections.Generic; +using System.Runtime.InteropServices; +using SkiaSharp; namespace DALib.Utility; @@ -10,17 +11,18 @@ namespace DALib.Utility; /// /// The type of the cache key. /// -public class SKImageCache(IEqualityComparer? comparer = null) : IDisposable where TKey: IEquatable +public sealed class SKImageCache(IEqualityComparer? comparer = null) : IDisposable where TKey: IEquatable { private readonly Dictionary Cache = new(comparer); /// - public virtual void Dispose() + public void Dispose() { - foreach (var image in Cache.Values) - image.Dispose(); + foreach (var key in Cache.Keys) + CollectionsMarshal.GetValueRefOrNullRef(Cache, key) + .Dispose(); - GC.SuppressFinalize(this); + Cache.Clear(); } /// @@ -33,7 +35,7 @@ public virtual void Dispose() /// /// The function used to create a new SKImage for the specified key. /// - public virtual SKImage GetOrCreate(TKey key, Func create) + public SKImage GetOrCreate(TKey key, Func create) { if (Cache.TryGetValue(key, out var image)) return image; diff --git a/DALib/Utility/SKImageCollection.cs b/DALib/Utility/SKImageCollection.cs index 2e7a203..b77f6ab 100644 --- a/DALib/Utility/SKImageCollection.cs +++ b/DALib/Utility/SKImageCollection.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using SkiaSharp; + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract namespace DALib.Utility; @@ -10,17 +11,20 @@ namespace DALib.Utility; /// /// Represents a disposable collection of SKImages. /// -public class SKImageCollection(IEnumerable images) : Collection( - images.Where(frame => frame is not null) - .ToList()), - IDisposable +public sealed class SKImageCollection(IEnumerable images) : Collection( + images.Where(frame => frame is not null) + .ToList()), + IDisposable { /// - public virtual void Dispose() + public void Dispose() { - foreach (var image in Items) - image.Dispose(); + for (var i = 0; i < Items.Count; i++) + { + var item = Items[i]; + item.Dispose(); + } - GC.SuppressFinalize(this); + Items.Clear(); } } \ No newline at end of file