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