diff --git a/UoFiddler.Controls/Classes/PackedSheet.cs b/UoFiddler.Controls/Classes/PackedSheet.cs new file mode 100644 index 00000000..9ad89ebf --- /dev/null +++ b/UoFiddler.Controls/Classes/PackedSheet.cs @@ -0,0 +1,78 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace UoFiddler.Controls.Classes +{ + /// + /// JSON descriptor of a packed animation sprite sheet: where each frame of each direction sits on + /// the sheet image and the frame's center. + /// + public class PackedAnimation + { + [JsonPropertyName("meta")] public PackedMeta Meta { get; set; } + [JsonPropertyName("frames")] public List Frames { get; set; } + } + + /// + /// JSON descriptor of a packed item (static art) sprite sheet. + /// + public class PackedItems + { + [JsonPropertyName("meta")] public PackedMeta Meta { get; set; } + [JsonPropertyName("items")] public List Items { get; set; } + } + + public class PackedMeta + { + /// Sheet image file name, next to the JSON file. + [JsonPropertyName("image")] public string Image { get; set; } + [JsonPropertyName("size")] public PackedSize Size { get; set; } + [JsonPropertyName("format")] public string Format { get; set; } + } + + public class PackedFrameEntry + { + [JsonPropertyName("direction")] public int Direction { get; set; } + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("frame")] public PackedRect Frame { get; set; } + [JsonPropertyName("center")] public PackedPoint Center { get; set; } + } + + public class PackedItemEntry + { + /// Item (static art) id. + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("frame")] public PackedRect Frame { get; set; } + } + + public class PackedRect + { + [JsonPropertyName("x")] public int X { get; set; } + [JsonPropertyName("y")] public int Y { get; set; } + [JsonPropertyName("w")] public int W { get; set; } + [JsonPropertyName("h")] public int H { get; set; } + } + + public class PackedPoint + { + [JsonPropertyName("x")] public int X { get; set; } + [JsonPropertyName("y")] public int Y { get; set; } + } + + public class PackedSize + { + [JsonPropertyName("w")] public int W { get; set; } + [JsonPropertyName("h")] public int H { get; set; } + } +} diff --git a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs new file mode 100644 index 00000000..4e8a2975 --- /dev/null +++ b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs @@ -0,0 +1,269 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Text.Json; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Places images left to right in rows on a sprite sheet, starting a new row when the next image + /// would pass the maximum width. Every image is surrounded by spacing pixels. + /// + public sealed class SpriteSheetLayout + { + private readonly int _maxWidth; + private readonly int _spacing; + private int _x; + private int _y; + private int _rowHeight; + + public SpriteSheetLayout(int maxWidth, int spacing) + { + _maxWidth = maxWidth; + _spacing = Math.Max(0, spacing); + _x = _spacing; + _y = _spacing; + } + + public int Width { get; private set; } + + public int Height { get; private set; } + + /// + /// Starts a new row unless the current one is still empty. + /// + public void NewRow() + { + if (_x <= _spacing) + { + return; + } + + _y += _rowHeight + _spacing; + _x = _spacing; + _rowHeight = 0; + } + + /// Start a new row when the image would pass the maximum width. + public PackedRect Place(int width, int height, bool wrap = true) + { + // An image wider than the sheet still goes into an empty row rather than leaving a blank one + if (wrap && _x > _spacing && _x + width > _maxWidth) + { + NewRow(); + } + + var rect = new PackedRect { X = _x, Y = _y, W = width, H = height }; + + _rowHeight = Math.Max(_rowHeight, height); + _x += width + _spacing; + + Width = Math.Max(Width, _x); + Height = Math.Max(Height, _y + _rowHeight + _spacing); + + return rect; + } + } + + /// + /// Shared sprite sheet work for packed animation and item exports: composing and reading the + /// sheet image, the guide image and the JSON descriptor. + /// + public static class SpriteSheetPacker + { + public const string ImageFormatName = "RGBA8888"; + + private static string _lastFolder; + + /// + /// Folder of the last sheet saved or opened in this session, shared by all sheet dialogs so an + /// import starts where the export went. Starts in the profile's output path. + /// + public static string LastFolder + { + get => string.IsNullOrEmpty(_lastFolder) ? Options.OutputPath : _lastFolder; + set => _lastFolder = value; + } + + private static readonly JsonSerializerOptions _writeOptions = new JsonSerializerOptions { WriteIndented = true }; + + // Case-insensitive so item sheets written by the original PR (PascalCase names) still load + private static readonly JsonSerializerOptions _readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + /// + /// Draws onto a transparent sheet at . + /// + public static Bitmap Compose(int width, int height, IReadOnlyList images, IReadOnlyList rects) + { + var sheet = new Bitmap(Math.Max(1, width), Math.Max(1, height), PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(sheet)) + { + g.Clear(Color.Transparent); + for (int i = 0; i < images.Count; ++i) + { + PackedRect rect = rects[i]; + g.DrawImage(images[i], new Rectangle(rect.X, rect.Y, rect.W, rect.H)); + } + } + + return sheet; + } + + public static PackedMeta CreateMeta(string imageFile, Bitmap sheet) + { + return new PackedMeta + { + Image = Path.GetFileName(imageFile), + Size = new PackedSize { W = sheet.Width, H = sheet.Height }, + Format = ImageFormatName + }; + } + + public static void WriteJson(string fileName, T value) + { + File.WriteAllText(fileName, JsonSerializer.Serialize(value, _writeOptions)); + } + + public static T ReadJson(string fileName) where T : class + { + T value; + try + { + value = JsonSerializer.Deserialize(File.ReadAllText(fileName), _readOptions); + } + catch (JsonException ex) + { + throw new InvalidDataException($"{Path.GetFileName(fileName)} is not a valid packed sheet: {ex.Message}", ex); + } + + return value ?? throw new InvalidDataException($"{Path.GetFileName(fileName)} is empty."); + } + + /// + /// Loads the sheet image named in . Only the file name is used, so the image + /// is always read from the JSON file's folder. + /// + public static Bitmap LoadSheet(string jsonFile, PackedMeta meta) + { + if (string.IsNullOrWhiteSpace(meta?.Image)) + { + throw new InvalidDataException($"{Path.GetFileName(jsonFile)} does not name a sheet image."); + } + + string imageFile = Path.Combine(Path.GetDirectoryName(jsonFile) ?? string.Empty, Path.GetFileName(meta.Image)); + if (!File.Exists(imageFile)) + { + throw new FileNotFoundException($"Sheet image not found: {imageFile}", imageFile); + } + + using (var loaded = new Bitmap(imageFile)) + { + // Copy so the image file is not kept locked + return loaded.Clone(new Rectangle(0, 0, loaded.Width, loaded.Height), PixelFormat.Format32bppArgb); + } + } + + public static bool IsInside(PackedRect rect, Bitmap sheet) + { + return rect != null && rect.W > 0 && rect.H > 0 && rect.X >= 0 && rect.Y >= 0 && + rect.X + rect.W <= sheet.Width && rect.Y + rect.H <= sheet.Height; + } + + /// + /// Copies out of as a 32bpp ARGB bitmap. + /// + public static Bitmap Crop(Bitmap sheet, PackedRect rect) + { + return sheet.Clone(new Rectangle(rect.X, rect.Y, rect.W, rect.H), PixelFormat.Format32bppArgb); + } + + /// + /// Converts of to an ARGB1555 animation frame. + /// Pixels under half alpha become transparent; black is kept visible as 0,0,8 because 0,0,0 is + /// transparent in the client (same rule as the Animation Editor import). + /// + /// Flip the frame horizontally. + public static unsafe Bitmap CropAnimationFrame(Bitmap sheet, PackedRect rect, bool mirror = false) + { + var frame = new Bitmap(rect.W, rect.H, PixelFormat.Format16bppArgb1555); + + BitmapData src = sheet.LockBits(new Rectangle(rect.X, rect.Y, rect.W, rect.H), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + BitmapData dst = frame.LockBits(new Rectangle(0, 0, rect.W, rect.H), ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555); + try + { + for (int y = 0; y < rect.H; ++y) + { + uint* srcLine = (uint*)((byte*)src.Scan0 + (y * src.Stride)); + ushort* dstLine = (ushort*)((byte*)dst.Scan0 + (y * dst.Stride)); + + for (int x = 0; x < rect.W; ++x) + { + int dstX = mirror ? rect.W - 1 - x : x; + uint argb = srcLine[x]; + if ((argb >> 24) < 0x80) + { + dstLine[dstX] = 0; + continue; + } + + int color = 0x8000 | (int)(((argb >> 9) & 0x7C00) | ((argb >> 6) & 0x03E0) | ((argb >> 3) & 0x001F)); + dstLine[dstX] = (ushort)(color == 0x8000 ? 0x8001 : color); + } + } + } + finally + { + sheet.UnlockBits(src); + frame.UnlockBits(dst); + } + + return frame; + } + + /// + /// Saves a copy of with every rect outlined in red, a green cross on each + /// anchor and each label in the rect's top-left corner. + /// + public static void SaveGuide(string fileName, Bitmap sheet, IReadOnlyList rects, IReadOnlyList anchors = null, + IReadOnlyList labels = null, Font font = null) + { + using (var guide = new Bitmap(sheet)) + using (var g = Graphics.FromImage(guide)) + using (var rectPen = new Pen(Color.Red, 1)) + using (var anchorPen = new Pen(Color.Lime, 1)) + { + for (int i = 0; i < rects.Count; ++i) + { + PackedRect rect = rects[i]; + g.DrawRectangle(rectPen, rect.X, rect.Y, rect.W - 1, rect.H - 1); + + if (anchors?[i] is { } anchor) + { + g.DrawLine(anchorPen, anchor.X - 3, anchor.Y, anchor.X + 3, anchor.Y); + g.DrawLine(anchorPen, anchor.X, anchor.Y - 3, anchor.X, anchor.Y + 3); + } + + if (labels != null && font != null) + { + g.DrawString(labels[i], font, Brushes.Black, rect.X + 1, rect.Y + 1); + } + } + + guide.Save(fileName, ImageFormat.Png); + } + } + } +} diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs b/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs new file mode 100644 index 00000000..864fb7f6 --- /dev/null +++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs @@ -0,0 +1,99 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class ItemRangeInputForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + PromptLabel = new System.Windows.Forms.Label(); + RangeTextBox = new System.Windows.Forms.TextBox(); + OkButton = new System.Windows.Forms.Button(); + CancelDialogButton = new System.Windows.Forms.Button(); + SuspendLayout(); + // + // PromptLabel + // + PromptLabel.AutoSize = true; + PromptLabel.Location = new System.Drawing.Point(12, 12); + PromptLabel.Name = "PromptLabel"; + PromptLabel.Size = new System.Drawing.Size(231, 15); + PromptLabel.Text = "Item range (e.g. 100-200 or 0x64-0xC8):"; + // + // RangeTextBox + // + RangeTextBox.Location = new System.Drawing.Point(12, 30); + RangeTextBox.Name = "RangeTextBox"; + RangeTextBox.Size = new System.Drawing.Size(320, 23); + RangeTextBox.TabIndex = 0; + // + // OkButton + // + OkButton.Location = new System.Drawing.Point(176, 66); + OkButton.Name = "OkButton"; + OkButton.Size = new System.Drawing.Size(75, 25); + OkButton.TabIndex = 1; + OkButton.Text = "OK"; + OkButton.UseVisualStyleBackColor = true; + OkButton.Click += OnClickOk; + // + // CancelDialogButton + // + CancelDialogButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + CancelDialogButton.Location = new System.Drawing.Point(257, 66); + CancelDialogButton.Name = "CancelDialogButton"; + CancelDialogButton.Size = new System.Drawing.Size(75, 25); + CancelDialogButton.TabIndex = 2; + CancelDialogButton.Text = "Cancel"; + CancelDialogButton.UseVisualStyleBackColor = true; + // + // ItemRangeInputForm + // + AcceptButton = OkButton; + CancelButton = CancelDialogButton; + ClientSize = new System.Drawing.Size(344, 103); + Controls.Add(PromptLabel); + Controls.Add(RangeTextBox); + Controls.Add(OkButton); + Controls.Add(CancelDialogButton); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "ItemRangeInputForm"; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Export Items to Sheet"; + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label PromptLabel; + private System.Windows.Forms.TextBox RangeTextBox; + private System.Windows.Forms.Button OkButton; + private System.Windows.Forms.Button CancelDialogButton; + } +} diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.cs b/UoFiddler.Controls/Forms/ItemRangeInputForm.cs new file mode 100644 index 00000000..bf32c84c --- /dev/null +++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.cs @@ -0,0 +1,80 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Windows.Forms; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + /// + /// Asks for an inclusive item id range, in decimal or 0x hex. + /// + public partial class ItemRangeInputForm : Form + { + /// Most items one sheet may hold. + public const int MaxRangeLength = 1024; + + private readonly int _maxId; + + public ItemRangeInputForm(int maxId, int initialStart, int initialEnd) + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + _maxId = maxId; + + if (initialStart >= 0 && initialEnd >= initialStart) + { + RangeTextBox.Text = $"0x{initialStart:X4}-0x{initialEnd:X4}"; + RangeTextBox.SelectAll(); + } + } + + public int StartId { get; private set; } + + public int EndId { get; private set; } + + private void OnClickOk(object sender, EventArgs e) + { + string[] parts = RangeTextBox.Text.Split('-', StringSplitOptions.TrimEntries); + if (parts.Length != 2 || + !Utils.ConvertStringToInt(parts[0], out int start, 0, _maxId) || + !Utils.ConvertStringToInt(parts[1], out int end, 0, _maxId)) + { + ShowError($"Enter the range as start-end, each between 0 and 0x{_maxId:X} (e.g. 100-200 or 0x64-0xC8)."); + return; + } + + if (start > end) + { + ShowError("The start of the range is past its end."); + return; + } + + if (end - start + 1 > MaxRangeLength) + { + ShowError($"A sheet can hold at most {MaxRangeLength} items."); + return; + } + + StartId = start; + EndId = end; + DialogResult = DialogResult.OK; + Close(); + } + + private static void ShowError(string message) + { + MessageBox.Show(message, "Export Items to Sheet", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } +} diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.resx b/UoFiddler.Controls/Forms/ItemRangeInputForm.resx new file mode 100644 index 00000000..459ff736 --- /dev/null +++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs new file mode 100644 index 00000000..235d490f --- /dev/null +++ b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs @@ -0,0 +1,219 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class PackOptionsForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + DirectionsLabel = new System.Windows.Forms.Label(); + DirectionsListBox = new System.Windows.Forms.CheckedListBox(); + MaxWidthLabel = new System.Windows.Forms.Label(); + MaxWidthUpDown = new System.Windows.Forms.NumericUpDown(); + Preset1024Button = new System.Windows.Forms.Button(); + Preset2048Button = new System.Windows.Forms.Button(); + Preset4096Button = new System.Windows.Forms.Button(); + SpacingLabel = new System.Windows.Forms.Label(); + SpacingUpDown = new System.Windows.Forms.NumericUpDown(); + OneRowPerDirectionCheckBox = new System.Windows.Forms.CheckBox(); + ExportAllActionsCheckBox = new System.Windows.Forms.CheckBox(); + OkButton = new System.Windows.Forms.Button(); + CancelDialogButton = new System.Windows.Forms.Button(); + ((System.ComponentModel.ISupportInitialize)MaxWidthUpDown).BeginInit(); + ((System.ComponentModel.ISupportInitialize)SpacingUpDown).BeginInit(); + SuspendLayout(); + // + // DirectionsLabel + // + DirectionsLabel.AutoSize = true; + DirectionsLabel.Location = new System.Drawing.Point(12, 12); + DirectionsLabel.Name = "DirectionsLabel"; + DirectionsLabel.Size = new System.Drawing.Size(63, 15); + DirectionsLabel.Text = "Directions:"; + // + // DirectionsListBox + // + DirectionsListBox.CheckOnClick = true; + DirectionsListBox.IntegralHeight = false; + DirectionsListBox.Location = new System.Drawing.Point(12, 30); + DirectionsListBox.Name = "DirectionsListBox"; + DirectionsListBox.Size = new System.Drawing.Size(190, 140); + DirectionsListBox.TabIndex = 0; + // + // MaxWidthLabel + // + MaxWidthLabel.AutoSize = true; + MaxWidthLabel.Location = new System.Drawing.Point(212, 12); + MaxWidthLabel.Name = "MaxWidthLabel"; + MaxWidthLabel.Size = new System.Drawing.Size(98, 15); + MaxWidthLabel.Text = "Max sheet width:"; + // + // MaxWidthUpDown + // + MaxWidthUpDown.Increment = new decimal(new int[] { 64, 0, 0, 0 }); + MaxWidthUpDown.Location = new System.Drawing.Point(212, 30); + MaxWidthUpDown.Maximum = new decimal(new int[] { 8192, 0, 0, 0 }); + MaxWidthUpDown.Minimum = new decimal(new int[] { 256, 0, 0, 0 }); + MaxWidthUpDown.Name = "MaxWidthUpDown"; + MaxWidthUpDown.Size = new System.Drawing.Size(162, 23); + MaxWidthUpDown.TabIndex = 1; + MaxWidthUpDown.ThousandsSeparator = true; + MaxWidthUpDown.Value = new decimal(new int[] { 2048, 0, 0, 0 }); + // + // Preset1024Button + // + Preset1024Button.Location = new System.Drawing.Point(212, 59); + Preset1024Button.Name = "Preset1024Button"; + Preset1024Button.Size = new System.Drawing.Size(50, 25); + Preset1024Button.TabIndex = 2; + Preset1024Button.Text = "1024"; + Preset1024Button.UseVisualStyleBackColor = true; + Preset1024Button.Click += OnClickPreset; + // + // Preset2048Button + // + Preset2048Button.Location = new System.Drawing.Point(268, 59); + Preset2048Button.Name = "Preset2048Button"; + Preset2048Button.Size = new System.Drawing.Size(50, 25); + Preset2048Button.TabIndex = 3; + Preset2048Button.Text = "2048"; + Preset2048Button.UseVisualStyleBackColor = true; + Preset2048Button.Click += OnClickPreset; + // + // Preset4096Button + // + Preset4096Button.Location = new System.Drawing.Point(324, 59); + Preset4096Button.Name = "Preset4096Button"; + Preset4096Button.Size = new System.Drawing.Size(50, 25); + Preset4096Button.TabIndex = 4; + Preset4096Button.Text = "4096"; + Preset4096Button.UseVisualStyleBackColor = true; + Preset4096Button.Click += OnClickPreset; + // + // SpacingLabel + // + SpacingLabel.AutoSize = true; + SpacingLabel.Location = new System.Drawing.Point(212, 92); + SpacingLabel.Name = "SpacingLabel"; + SpacingLabel.Size = new System.Drawing.Size(52, 15); + SpacingLabel.Text = "Spacing:"; + // + // SpacingUpDown + // + SpacingUpDown.Location = new System.Drawing.Point(212, 110); + SpacingUpDown.Maximum = new decimal(new int[] { 20, 0, 0, 0 }); + SpacingUpDown.Name = "SpacingUpDown"; + SpacingUpDown.Size = new System.Drawing.Size(80, 23); + SpacingUpDown.TabIndex = 5; + // + // OneRowPerDirectionCheckBox + // + OneRowPerDirectionCheckBox.AutoSize = true; + OneRowPerDirectionCheckBox.Location = new System.Drawing.Point(12, 182); + OneRowPerDirectionCheckBox.Name = "OneRowPerDirectionCheckBox"; + OneRowPerDirectionCheckBox.Size = new System.Drawing.Size(146, 19); + OneRowPerDirectionCheckBox.TabIndex = 6; + OneRowPerDirectionCheckBox.Text = "One row per direction"; + OneRowPerDirectionCheckBox.UseVisualStyleBackColor = true; + OneRowPerDirectionCheckBox.CheckedChanged += OnCheckedChangedOneRowPerDirection; + // + // ExportAllActionsCheckBox + // + ExportAllActionsCheckBox.AutoSize = true; + ExportAllActionsCheckBox.Location = new System.Drawing.Point(12, 207); + ExportAllActionsCheckBox.Name = "ExportAllActionsCheckBox"; + ExportAllActionsCheckBox.Size = new System.Drawing.Size(209, 19); + ExportAllActionsCheckBox.TabIndex = 7; + ExportAllActionsCheckBox.Text = "Export all actions of the body"; + ExportAllActionsCheckBox.UseVisualStyleBackColor = true; + // + // OkButton + // + OkButton.Location = new System.Drawing.Point(218, 240); + OkButton.Name = "OkButton"; + OkButton.Size = new System.Drawing.Size(75, 25); + OkButton.TabIndex = 8; + OkButton.Text = "OK"; + OkButton.UseVisualStyleBackColor = true; + OkButton.Click += OnClickOk; + // + // CancelDialogButton + // + CancelDialogButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + CancelDialogButton.Location = new System.Drawing.Point(299, 240); + CancelDialogButton.Name = "CancelDialogButton"; + CancelDialogButton.Size = new System.Drawing.Size(75, 25); + CancelDialogButton.TabIndex = 9; + CancelDialogButton.Text = "Cancel"; + CancelDialogButton.UseVisualStyleBackColor = true; + // + // PackOptionsForm + // + AcceptButton = OkButton; + CancelButton = CancelDialogButton; + ClientSize = new System.Drawing.Size(386, 277); + Controls.Add(DirectionsLabel); + Controls.Add(DirectionsListBox); + Controls.Add(MaxWidthLabel); + Controls.Add(MaxWidthUpDown); + Controls.Add(Preset1024Button); + Controls.Add(Preset2048Button); + Controls.Add(Preset4096Button); + Controls.Add(SpacingLabel); + Controls.Add(SpacingUpDown); + Controls.Add(OneRowPerDirectionCheckBox); + Controls.Add(ExportAllActionsCheckBox); + Controls.Add(OkButton); + Controls.Add(CancelDialogButton); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "PackOptionsForm"; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Pack Frames"; + ((System.ComponentModel.ISupportInitialize)MaxWidthUpDown).EndInit(); + ((System.ComponentModel.ISupportInitialize)SpacingUpDown).EndInit(); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label DirectionsLabel; + private System.Windows.Forms.CheckedListBox DirectionsListBox; + private System.Windows.Forms.Label MaxWidthLabel; + private System.Windows.Forms.NumericUpDown MaxWidthUpDown; + private System.Windows.Forms.Button Preset1024Button; + private System.Windows.Forms.Button Preset2048Button; + private System.Windows.Forms.Button Preset4096Button; + private System.Windows.Forms.Label SpacingLabel; + private System.Windows.Forms.NumericUpDown SpacingUpDown; + private System.Windows.Forms.CheckBox OneRowPerDirectionCheckBox; + private System.Windows.Forms.CheckBox ExportAllActionsCheckBox; + private System.Windows.Forms.Button OkButton; + private System.Windows.Forms.Button CancelDialogButton; + } +} diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.cs b/UoFiddler.Controls/Forms/PackOptionsForm.cs new file mode 100644 index 00000000..7409bf4a --- /dev/null +++ b/UoFiddler.Controls/Forms/PackOptionsForm.cs @@ -0,0 +1,99 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + /// + /// Options for packing animation frames into a sprite sheet. + /// + public partial class PackOptionsForm : Form + { + /// + /// Directions 0-4 are stored in the anim files; 5-7 are drawn by mirroring 3-1. + /// + public static readonly string[] DirectionNames = + { + "South", "South West", "West", "North West", "North", "North East", "East", "South East" + }; + + /// Number of directions stored in the anim files. + public const int StoredDirections = 5; + + /// + /// The stored direction (5-7) is mirrored from. + /// + public static int GetMirroredSource(int direction) => 8 - direction; + + public PackOptionsForm() + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + + for (int i = 0; i < DirectionNames.Length; ++i) + { + // Mirrored directions are off by default: they import back into their stored direction + string text = i < StoredDirections + ? $"{i} {DirectionNames[i]}" + : $"{i} {DirectionNames[i]} (mirror of {GetMirroredSource(i)})"; + DirectionsListBox.Items.Add(text, i < StoredDirections); + } + } + + public List SelectedDirections { get; } = new List(); + + public int MaxWidth => (int)MaxWidthUpDown.Value; + + public int Spacing => (int)SpacingUpDown.Value; + + public bool OneRowPerDirection => OneRowPerDirectionCheckBox.Checked; + + public bool ExportAllActions => ExportAllActionsCheckBox.Checked; + + private void OnClickPreset(object sender, EventArgs e) + { + MaxWidthUpDown.Value = decimal.Parse(((Button)sender).Text); + } + + private void OnCheckedChangedOneRowPerDirection(object sender, EventArgs e) + { + // Rows follow the directions, so the width limit does not apply + bool limitWidth = !OneRowPerDirectionCheckBox.Checked; + MaxWidthUpDown.Enabled = limitWidth; + Preset1024Button.Enabled = limitWidth; + Preset2048Button.Enabled = limitWidth; + Preset4096Button.Enabled = limitWidth; + } + + private void OnClickOk(object sender, EventArgs e) + { + SelectedDirections.Clear(); + foreach (int index in DirectionsListBox.CheckedIndices) + { + SelectedDirections.Add(index); + } + + if (SelectedDirections.Count == 0) + { + MessageBox.Show("Select at least one direction.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + DialogResult = DialogResult.OK; + Close(); + } + } +} diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.resx b/UoFiddler.Controls/Forms/PackOptionsForm.resx new file mode 100644 index 00000000..459ff736 --- /dev/null +++ b/UoFiddler.Controls/Forms/PackOptionsForm.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs index f3cd59f1..04a18ae1 100644 --- a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs @@ -67,6 +67,11 @@ private void InitializeComponent() asAnimatedGifnoLoopingToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); alignFramesSeparator = new System.Windows.Forms.ToolStripSeparator(); alignFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + packFramesSeparator = new System.Windows.Forms.ToolStripSeparator(); + packFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + importAnimationToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + unpackFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + bulkUnpackFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); listView1 = new ListViewWithScrollbar(); contextMenuStrip3 = new System.Windows.Forms.ContextMenuStrip(components); exportFrameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -273,9 +278,9 @@ private void InitializeComponent() // // contextMenuStrip1 // - contextMenuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { extractImageToolStripMenuItem, extractAnimationToolStripMenuItem }); + contextMenuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { extractImageToolStripMenuItem, extractAnimationToolStripMenuItem, importAnimationToolStripMenuItem }); contextMenuStrip1.Name = "contextMenuStrip1"; - contextMenuStrip1.Size = new System.Drawing.Size(174, 48); + contextMenuStrip1.Size = new System.Drawing.Size(174, 70); // // extractImageToolStripMenuItem // @@ -314,7 +319,7 @@ private void InitializeComponent() // // extractAnimationToolStripMenuItem // - extractAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { asBmpToolStripMenuItem1, asTiffToolStripMenuItem1, asJpgToolStripMenuItem1, asPngToolStripMenuItem1, asAnimatedGifToolStripMenuItem, asAnimatedGifnoLoopingToolStripMenuItem, alignFramesSeparator, alignFramesToolStripMenuItem }); + extractAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { asBmpToolStripMenuItem1, asTiffToolStripMenuItem1, asJpgToolStripMenuItem1, asPngToolStripMenuItem1, asAnimatedGifToolStripMenuItem, asAnimatedGifnoLoopingToolStripMenuItem, alignFramesSeparator, alignFramesToolStripMenuItem, packFramesSeparator, packFramesToolStripMenuItem }); extractAnimationToolStripMenuItem.Name = "extractAnimationToolStripMenuItem"; extractAnimationToolStripMenuItem.Size = new System.Drawing.Size(173, 22); extractAnimationToolStripMenuItem.Text = "Export Animation.."; @@ -373,6 +378,42 @@ private void InitializeComponent() alignFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22); alignFramesToolStripMenuItem.Text = "Align Frames on One Canvas"; alignFramesToolStripMenuItem.ToolTipText = "Bmp/Tiff/Jpg/Png: draw every frame on one canvas, anchored at its center like the Gif export, so frames don't jump. Png and Tiff get a transparent background."; + // + // packFramesSeparator + // + packFramesSeparator.Name = "packFramesSeparator"; + packFramesSeparator.Size = new System.Drawing.Size(224, 6); + // + // packFramesToolStripMenuItem + // + packFramesToolStripMenuItem.Name = "packFramesToolStripMenuItem"; + packFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22); + packFramesToolStripMenuItem.Text = "Pack Frames to Sheet (Png + Json).."; + packFramesToolStripMenuItem.ToolTipText = "Pack the frames of the chosen directions into one sprite sheet with a Json file holding each frame's position and center."; + packFramesToolStripMenuItem.Click += OnClickPackFrames; + // + // importAnimationToolStripMenuItem + // + importAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { unpackFramesToolStripMenuItem, bulkUnpackFramesToolStripMenuItem }); + importAnimationToolStripMenuItem.Name = "importAnimationToolStripMenuItem"; + importAnimationToolStripMenuItem.Size = new System.Drawing.Size(173, 22); + importAnimationToolStripMenuItem.Text = "Import Animation.."; + // + // unpackFramesToolStripMenuItem + // + unpackFramesToolStripMenuItem.Name = "unpackFramesToolStripMenuItem"; + unpackFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22); + unpackFramesToolStripMenuItem.Text = "Unpack Frames from Sheet.."; + unpackFramesToolStripMenuItem.ToolTipText = "Import a packed sheet (Json + Png) into the selected action."; + unpackFramesToolStripMenuItem.Click += OnClickUnpackFrames; + // + // bulkUnpackFramesToolStripMenuItem + // + bulkUnpackFramesToolStripMenuItem.Name = "bulkUnpackFramesToolStripMenuItem"; + bulkUnpackFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22); + bulkUnpackFramesToolStripMenuItem.Text = "Bulk Unpack Frames from Folder.."; + bulkUnpackFramesToolStripMenuItem.ToolTipText = "Import every packed sheet anim__.json of the selected body from a folder."; + bulkUnpackFramesToolStripMenuItem.Click += OnClickBulkUnpackFrames; // // listView1 // @@ -745,6 +786,11 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem asAnimatedGifToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem asAnimatedGifnoLoopingToolStripMenuItem; private System.Windows.Forms.ToolStripSeparator alignFramesSeparator; + private System.Windows.Forms.ToolStripSeparator packFramesSeparator; + private System.Windows.Forms.ToolStripMenuItem packFramesToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem importAnimationToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem unpackFramesToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem bulkUnpackFramesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem alignFramesToolStripMenuItem; private System.Windows.Forms.TabControl tabControl1; private System.Windows.Forms.TabPage tabPage1; diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.cs b/UoFiddler.Controls/UserControls/AnimationListControl.cs index fe6ba8f1..dfe66c9f 100644 --- a/UoFiddler.Controls/UserControls/AnimationListControl.cs +++ b/UoFiddler.Controls/UserControls/AnimationListControl.cs @@ -16,6 +16,8 @@ using System.Drawing.Imaging; using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Windows.Forms; using System.Xml; using Ultima; @@ -584,21 +586,30 @@ private static int CompareNodeByGraphic(TreeNode x, TreeNode y) return ((int[])x.Tag)[0].CompareTo(((int[])y.Tag)[0]); } - private void AddUopActionNodes(TreeNode parent, int body, int actionType) + /// + /// Lists the actions defined for : every defined UOP action for UOP bodies, + /// the named-action table for MUL bodies. + /// + private List GetDefinedActions(int body, int actionType) { // UOP animations are not bounded by the legacy per-type group counts (Low=13, High=22, // People=34): a UOP body can define action bins well past them (the client scans up to - // MaxAnimActions - "gargoyle is like 78"). List every defined UOP action, naming those within - // the type's table and falling back to a generic "ActionN" for the higher UOP-specific bins. - var definedActions = Animations.GetUopDefinedActions(body); - foreach (int i in definedActions) + // MaxAnimActions - "gargoyle is like 78"). + if (Animations.IsUopBody(body)) { - string actionName = i < GetActionNames[actionType].Length - ? GetActionNames[actionType][i] - : $"Action{i}"; + return Animations.GetUopDefinedActions(body); + } - parent.Nodes.Add(new TreeNode($"{i} {actionName}") { Tag = i }); + var actions = new List(); + for (int i = 0; i < GetActionNames[actionType].GetLength(0); ++i) + { + if (Animations.IsActionDefined(body, i, 0)) + { + actions.Add(i); + } } + + return actions; } /// @@ -668,21 +679,14 @@ private void PopulateActionNodes(TreeNode bodyNode) TreeViewMobs.BeginUpdate(); try { - if (Animations.IsUopBody(graphic)) - { - AddUopActionNodes(bodyNode, graphic, actionType); - } - else + // Actions past the type's name table (UOP-specific bins) get a generic "ActionN" + foreach (int i in GetDefinedActions(graphic, actionType)) { - for (int i = 0; i < GetActionNames[actionType].GetLength(0); ++i) - { - if (!Animations.IsActionDefined(graphic, i, 0)) - { - continue; - } + string actionName = i < GetActionNames[actionType].Length + ? GetActionNames[actionType][i] + : $"Action{i}"; - bodyNode.Nodes.Add(new TreeNode($"{i} {GetActionNames[actionType][i]}") { Tag = i }); - } + bodyNode.Nodes.Add(new TreeNode($"{i} {actionName}") { Tag = i }); } } finally @@ -1396,6 +1400,595 @@ private void ShowFrameBoundsCheckBox_Click(object sender, EventArgs e) MainPictureBox.ShowFrameBounds = !MainPictureBox.ShowFrameBounds; ShowFrameBoundsCheckBox.Checked = MainPictureBox.ShowFrameBounds; } + + /// + /// The action-name table of the body node for , found from the tree + /// selection or, when the body was picked from the thumbnail list, by searching the tree. + /// + private int GetSelectedBodyActionType() + { + TreeNode node = TreeViewMobs.SelectedNode; + if (node?.Tag is int && node.Parent?.Tag is int[]) + { + node = node.Parent; + } + + if (node?.Tag is int[] selectedTag && selectedTag[0] == _currentSelect) + { + return GetActionNameType(selectedTag[1]); + } + + string rootName = _displayType == 1 ? "Equipment" : "Mobs"; + foreach (TreeNode root in TreeViewMobs.Nodes) + { + if (root.Name != rootName) + { + continue; + } + + foreach (TreeNode bodyNode in root.Nodes) + { + if (bodyNode.Tag is int[] tag && tag[0] == _currentSelect) + { + return GetActionNameType(tag[1]); + } + } + } + + // Largest name table, so no defined MUL action is missed + return (int)MobType.Human; + } + + private void OnClickPackFrames(object sender, EventArgs e) + { + if (_currentSelect == 0) + { + MessageBox.Show("No animation selected.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using var optionsForm = new PackOptionsForm(); + if (optionsForm.ShowDialog(this) != DialogResult.OK) + { + return; + } + + using var folderDialog = new FolderBrowserDialog + { + Description = "Select the folder to save the packed sheets in", + ShowNewFolderButton = true, + SelectedPath = SpriteSheetPacker.LastFolder + }; + if (folderDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SpriteSheetPacker.LastFolder = folderDialog.SelectedPath; + + string outDir = folderDialog.SelectedPath; + int body = _currentSelect; + + var saved = new List(); + try + { + using (new WaitCursorScope(this)) + { + List actions = optionsForm.ExportAllActions + ? GetDefinedActions(body, GetSelectedBodyActionType()) + : new List { _currentSelectAction }; + + foreach (int action in actions) + { + string baseName = PackAnimation(outDir, body, action, optionsForm); + if (baseName != null) + { + saved.Add(baseName); + } + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.ExternalException) + { + MessageBox.Show($"Failed to pack frames: {ex.Message}", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + if (saved.Count == 0) + { + MessageBox.Show("No frames found to pack.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + string message = saved.Count == 1 + ? $"Packed sheet {Path.GetFileName(saved[0])} (.png, .json and _guide.png) saved successfully." + : $"{saved.Count} packed sheets anim_{body}_ (.png, .json and _guide.png) saved successfully."; + FileSavedDialog.Show(FindForm(), outDir, message); + } + + /// + /// Packs the frames of the chosen directions of one action into anim_{body}_{action}.png + /// with a .json descriptor, a _guide.png showing frame bounds and anchors and, with one + /// row per direction, a _rows.txt naming the rows. + /// + /// The output path without extension, or null if the action has no frames. + private static string PackAnimation(string outDir, int body, int action, PackOptionsForm options) + { + var layout = new SpriteSheetLayout(options.MaxWidth, options.Spacing); + var entries = new List(); + var images = new List(); + var rows = new StringBuilder(); + int rowCount = 0; + + try + { + foreach (int direction in options.SelectedDirections) + { + // No hue: pack the stored colours, not a body.def hue, so the sheet imports back unchanged + int hue = 0; + AnimationFrame[] frames = Animations.GetAnimation(body, action, direction, ref hue, true, false); + if (frames == null || !frames.Any(frame => frame?.Bitmap != null)) + { + continue; + } + + if (options.OneRowPerDirection) + { + layout.NewRow(); + rows.AppendLine($"Row {rowCount++}: {direction} {PackOptionsForm.DirectionNames[direction]}"); + } + + for (int i = 0; i < frames.Length; ++i) + { + AnimationFrame frame = frames[i]; + if (frame?.Bitmap == null) + { + continue; + } + + PackedRect rect = layout.Place(frame.Bitmap.Width, frame.Bitmap.Height, wrap: !options.OneRowPerDirection); + entries.Add(new PackedFrameEntry + { + Direction = direction, + Index = i, + Frame = rect, + Center = new PackedPoint { X = frame.Center.X, Y = frame.Center.Y } + }); + + // The frames are cache-owned + images.Add(new Bitmap(frame.Bitmap)); + } + } + + if (entries.Count == 0) + { + return null; + } + + string baseName = Path.Combine(outDir, $"anim_{body}_{action}"); + string imageFile = baseName + ".png"; + List rects = entries.Select(entry => entry.Frame).ToList(); + + using (Bitmap sheet = SpriteSheetPacker.Compose(layout.Width, layout.Height, images, rects)) + { + sheet.Save(imageFile, ImageFormat.Png); + SpriteSheetPacker.WriteJson(baseName + ".json", new PackedAnimation + { + Meta = SpriteSheetPacker.CreateMeta(imageFile, sheet), + Frames = entries + }); + + // The anchor is where the frame's center is drawn, as in GetAlignedLocation + List anchors = entries + .Select(entry => (Point?)new Point(entry.Frame.X + entry.Center.X, entry.Frame.Y + entry.Frame.H + entry.Center.Y)) + .ToList(); + SpriteSheetPacker.SaveGuide(baseName + "_guide.png", sheet, rects, anchors); + } + + if (options.OneRowPerDirection) + { + File.WriteAllText(baseName + "_rows.txt", rows.ToString()); + } + + return baseName; + } + finally + { + foreach (Bitmap image in images) + { + image.Dispose(); + } + } + } + + private const string SaveAnimationsHint = + "Save with Settings > Animation Edit > Misc > Save; this tab shows the new frames after the files are reloaded."; + + private static readonly Regex _packedAnimationFileName = new Regex(@"^anim_(\d+)_(\d+)$", RegexOptions.CultureInvariant); + + /// + /// Body and action from a packed sheet file name anim_{body}_{action}.json. + /// + private static bool TryParsePackedAnimationName(string fileName, out int body, out int action) + { + body = action = 0; + Match match = _packedAnimationFileName.Match(Path.GetFileNameWithoutExtension(fileName)); + return match.Success && + int.TryParse(match.Groups[1].Value, out body) && + int.TryParse(match.Groups[2].Value, out action); + } + + private bool CanUnpackInto(int body, string title) + { + if (body == 0) + { + MessageBox.Show("No animation selected.", title, MessageBoxButtons.OK, MessageBoxIcon.Information); + return false; + } + + // AnimationEdit only writes the anim.mul files + if (Animations.IsUopBody(body)) + { + MessageBox.Show($"Body {body} is stored in a UOP file. Frames can only be imported into bodies from the anim.mul files.", + title, MessageBoxButtons.OK, MessageBoxIcon.Information); + return false; + } + + return true; + } + + /// True to replace the existing frames, false to append, null to cancel. + private static bool? AskReplaceFrames(string title, string what) + { + DialogResult result = MessageBox.Show( + $"{what}\n\nYes = replace the existing frames\nNo = append to the existing frames\nCancel = abort", + title, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question, MessageBoxDefaultButton.Button3); + + return result switch + { + DialogResult.Yes => true, + DialogResult.No => false, + _ => null + }; + } + + private static bool IsUnpackException(Exception ex) + { + // Bitmap reports an unreadable image as ArgumentException or OutOfMemoryException + return ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or + OutOfMemoryException or System.Runtime.InteropServices.ExternalException; + } + + private void OnClickUnpackFrames(object sender, EventArgs e) + { + const string title = "Unpack Frames"; + + int body = _currentSelect; + int action = _currentSelectAction; + if (!CanUnpackInto(body, title)) + { + return; + } + + using var fileDialog = new OpenFileDialog + { + InitialDirectory = SpriteSheetPacker.LastFolder, + Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*", + Title = "Select the Json file of a packed sheet" + }; + if (fileDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SpriteSheetPacker.LastFolder = Path.GetDirectoryName(fileDialog.FileName); + + string what = $"Import {Path.GetFileName(fileDialog.FileName)} into body {body}, action {action}?"; + if (TryParsePackedAnimationName(fileDialog.FileName, out int packedBody, out int packedAction) && + (packedBody != body || packedAction != action)) + { + what += $"\nThe sheet was packed from body {packedBody}, action {packedAction}."; + } + + bool? replace = AskReplaceFrames(title, what); + if (replace == null) + { + return; + } + + UnpackResult result; + try + { + using (new WaitCursorScope(this)) + { + result = UnpackAnimation(fileDialog.FileName, body, action, replace.Value); + } + } + catch (Exception ex) when (IsUnpackException(ex)) + { + MessageBox.Show($"Failed to import frames: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + Options.ChangedUltimaClass["Animations"] = true; + + MessageBox.Show($"Imported {result.Frames} frames in {result.Directions} direction(s) into body {body}, action {action}.{result.Info}{result.Notes}\n\n{SaveAnimationsHint}", + title, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void OnClickBulkUnpackFrames(object sender, EventArgs e) + { + const string title = "Bulk Unpack Frames"; + + int body = _currentSelect; + if (!CanUnpackInto(body, title)) + { + return; + } + + using var folderDialog = new FolderBrowserDialog + { + Description = $"Select the folder holding the packed sheets anim_{body}_.json", + ShowNewFolderButton = false, + SelectedPath = SpriteSheetPacker.LastFolder + }; + if (folderDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SpriteSheetPacker.LastFolder = folderDialog.SelectedPath; + + // Only sheets of the selected body are imported; their actions come from the file names + var sheets = new SortedDictionary(); + int otherFiles = 0; + foreach (string file in Directory.GetFiles(folderDialog.SelectedPath, "*.json")) + { + if (TryParsePackedAnimationName(file, out int packedBody, out int packedAction) && packedBody == body) + { + sheets[packedAction] = file; + } + else + { + otherFiles++; + } + } + + if (sheets.Count == 0) + { + MessageBox.Show($"No packed sheets anim_{body}_.json found in {folderDialog.SelectedPath}.", + title, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + bool? replace = AskReplaceFrames(title, $"Import {sheets.Count} packed sheet(s) into body {body}, actions {string.Join(", ", sheets.Keys)}?"); + if (replace == null) + { + return; + } + + int imported = 0; + int frames = 0; + var errors = new List(); + using (new WaitCursorScope(this)) + { + foreach (var (action, file) in sheets) + { + try + { + UnpackResult result = UnpackAnimation(file, body, action, replace.Value); + imported++; + frames += result.Frames; + if (result.Notes.Length > 0) + { + errors.Add($"{Path.GetFileName(file)}:{result.Notes.Replace('\n', ' ')}"); + } + } + catch (Exception ex) when (IsUnpackException(ex)) + { + errors.Add($"{Path.GetFileName(file)}: {ex.Message}"); + } + } + } + + if (imported > 0) + { + Options.ChangedUltimaClass["Animations"] = true; + } + + string message = $"Imported {imported} of {sheets.Count} sheet(s), {frames} frames, into body {body}."; + if (otherFiles > 0) + { + message += $"\nIgnored {otherFiles} other Json file(s)."; + } + + if (errors.Count > 0) + { + const int maxShown = 10; + message += "\n\n" + string.Join("\n", errors.Take(maxShown)); + if (errors.Count > maxShown) + { + message += $"\n... and {errors.Count - maxShown} more."; + } + } + + if (imported > 0) + { + message += $"\n\n{SaveAnimationsHint}"; + } + + MessageBox.Show(message, title, MessageBoxButtons.OK, errors.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information); + } + + private sealed class UnpackResult + { + public int Frames { get; set; } + public int Directions { get; set; } + + /// What was skipped, each line starting with a line break; empty if nothing was. + public string Notes { get; set; } = string.Empty; + + /// Other remarks, such as mirrored directions flipped back, formatted like . + public string Info { get; set; } = string.Empty; + } + + /// + /// Imports a packed sheet into of (a MUL body). + /// The sheet is read and checked before anything changes, so a failure leaves the animation as it was. + /// + /// Replace each imported direction's frames instead of appending to them. + private static UnpackResult UnpackAnimation(string jsonFile, int body, int action, bool replace) + { + string name = Path.GetFileName(jsonFile); + PackedAnimation packed = SpriteSheetPacker.ReadJson(jsonFile); + if (packed.Frames == null || packed.Frames.Count == 0) + { + throw new InvalidDataException($"{name} lists no frames."); + } + + int fileBody = body; + Animations.Translate(ref fileBody); + int fileType = BodyConverter.Convert(ref fileBody); + if (action < 0 || action >= Animations.GetActionCapacity(fileBody, fileType)) + { + throw new InvalidDataException($"Body {body} has no action {action}."); + } + + var result = new UnpackResult(); + var invalidDirections = new SortedSet(); + var unflippedDirections = new SortedSet(); + var shadowedDirections = new SortedSet(); + int skippedFrames = 0; + var directions = new SortedDictionary>(); + + // A mirrored direction (5-7) is only imported when the sheet does not also hold the stored one + var storedOnSheet = packed.Frames + .Where(entry => entry != null && entry.Direction >= 0 && entry.Direction < PackOptionsForm.StoredDirections) + .Select(entry => entry.Direction) + .ToHashSet(); + + try + { + using (Bitmap sheet = SpriteSheetPacker.LoadSheet(jsonFile, packed.Meta)) + { + foreach (PackedFrameEntry entry in packed.Frames.Where(entry => entry != null).OrderBy(entry => entry.Index)) + { + if (entry.Direction < 0 || entry.Direction >= PackOptionsForm.DirectionNames.Length) + { + invalidDirections.Add(entry.Direction); + continue; + } + + bool mirrored = entry.Direction >= PackOptionsForm.StoredDirections; + int target = mirrored ? PackOptionsForm.GetMirroredSource(entry.Direction) : entry.Direction; + if (mirrored && storedOnSheet.Contains(target)) + { + shadowedDirections.Add(entry.Direction); + continue; + } + + if (!SpriteSheetPacker.IsInside(entry.Frame, sheet)) + { + skippedFrames++; + continue; + } + + if (!directions.TryGetValue(target, out var frames)) + { + directions[target] = frames = new List<(Bitmap, int, int)>(); + } + + // The client draws 5-7 by flipping 3-1 and mirroring the center: undo both + int centerX = entry.Center?.X ?? 0; + int centerY = entry.Center?.Y ?? 0; + if (mirrored) + { + centerX = entry.Frame.W - centerX; + unflippedDirections.Add(entry.Direction); + } + + frames.Add((SpriteSheetPacker.CropAnimationFrame(sheet, entry.Frame, mirrored), centerX, centerY)); + } + } + + if (directions.Count == 0) + { + throw new InvalidDataException($"No frame of {name} could be imported."); + } + + var targets = new Dictionary(); + foreach (int direction in directions.Keys) + { + targets[direction] = GetAnimationEditTarget(fileType, fileBody, action, direction, body); + } + + foreach (var (direction, frames) in directions) + { + AnimIdx edit = targets[direction]; + if (replace) + { + edit.ClearFrames(); + } + + // One palette for the whole direction, fitted next to any frames that stay + edit.FitPalette(frames.Select(frame => frame.Bitmap)); + foreach (var (bitmap, centerX, centerY) in frames) + { + edit.AddFrame(bitmap, centerX, centerY); + } + + result.Frames += frames.Count; + result.Directions++; + } + } + finally + { + foreach (var frames in directions.Values) + { + foreach (var (bitmap, _, _) in frames) + { + bitmap.Dispose(); + } + } + } + + if (unflippedDirections.Count > 0) + { + result.Info += $"\nDirection(s) {string.Join(", ", unflippedDirections)} flipped back into " + + $"{string.Join(", ", unflippedDirections.Select(PackOptionsForm.GetMirroredSource))}."; + } + + if (shadowedDirections.Count > 0) + { + result.Notes += $"\nSkipped mirrored direction(s) {string.Join(", ", shadowedDirections)}: the sheet also holds " + + $"{string.Join(", ", shadowedDirections.Select(PackOptionsForm.GetMirroredSource))}."; + } + + if (invalidDirections.Count > 0) + { + result.Notes += $"\nSkipped invalid direction(s) {string.Join(", ", invalidDirections)}."; + } + + if (skippedFrames > 0) + { + result.Notes += $"\nSkipped {skippedFrames} frame(s) outside the sheet image."; + } + + return result; + } + + private static AnimIdx GetAnimationEditTarget(int fileType, int fileBody, int action, int direction, int body) + { + try + { + return AnimationEdit.GetAnimation(fileType, fileBody, action, direction); + } + catch (Exception ex) when (ex is NullReferenceException or IndexOutOfRangeException) + { + // The anim file of this body is missing or too short for it + throw new InvalidDataException($"Body {body} (anim{(fileType == 1 ? string.Empty : fileType.ToString())}.mul body {fileBody}) has no entry for action {action}, direction {direction}.", ex); + } + } } public class AlphaSorter : IComparer diff --git a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs index de21dc8a..6a15e63e 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs @@ -98,6 +98,9 @@ private void InitializeComponent() toolStripSeparator4 = new ToolStripSeparator(); MiscToolStripDropDownButton = new ToolStripDropDownButton(); ExportAllToolStripMenuItem = new ToolStripMenuItem(); + packedSheetSeparator = new ToolStripSeparator(); + exportItemsToSheetToolStripMenuItem = new ToolStripMenuItem(); + importItemsFromSheetToolStripMenuItem = new ToolStripMenuItem(); asBmpToolStripMenuItem = new ToolStripMenuItem(); asTiffToolStripMenuItem = new ToolStripMenuItem(); asJpgToolStripMenuItem = new ToolStripMenuItem(); @@ -555,7 +558,7 @@ private void InitializeComponent() // MiscToolStripDropDownButton // MiscToolStripDropDownButton.DisplayStyle = ToolStripItemDisplayStyle.Text; - MiscToolStripDropDownButton.DropDownItems.AddRange(new ToolStripItem[] { ExportAllToolStripMenuItem }); + MiscToolStripDropDownButton.DropDownItems.AddRange(new ToolStripItem[] { ExportAllToolStripMenuItem, packedSheetSeparator, exportItemsToSheetToolStripMenuItem, importItemsFromSheetToolStripMenuItem }); MiscToolStripDropDownButton.ImageTransparentColor = System.Drawing.Color.Magenta; MiscToolStripDropDownButton.Name = "MiscToolStripDropDownButton"; MiscToolStripDropDownButton.Size = new System.Drawing.Size(45, 25); @@ -567,6 +570,27 @@ private void InitializeComponent() ExportAllToolStripMenuItem.Name = "ExportAllToolStripMenuItem"; ExportAllToolStripMenuItem.Size = new System.Drawing.Size(129, 22); ExportAllToolStripMenuItem.Text = "Export all.."; + // + // packedSheetSeparator + // + packedSheetSeparator.Name = "packedSheetSeparator"; + packedSheetSeparator.Size = new System.Drawing.Size(206, 6); + // + // exportItemsToSheetToolStripMenuItem + // + exportItemsToSheetToolStripMenuItem.Name = "exportItemsToSheetToolStripMenuItem"; + exportItemsToSheetToolStripMenuItem.Size = new System.Drawing.Size(209, 22); + exportItemsToSheetToolStripMenuItem.Text = "Export Range to Sheet.."; + exportItemsToSheetToolStripMenuItem.ToolTipText = "Pack a range of items into one sprite sheet (Png) with a Json file holding each item's position."; + exportItemsToSheetToolStripMenuItem.Click += OnClickExportItemsToSheet; + // + // importItemsFromSheetToolStripMenuItem + // + importItemsFromSheetToolStripMenuItem.Name = "importItemsFromSheetToolStripMenuItem"; + importItemsFromSheetToolStripMenuItem.Size = new System.Drawing.Size(209, 22); + importItemsFromSheetToolStripMenuItem.Text = "Import from Sheet.."; + importItemsFromSheetToolStripMenuItem.ToolTipText = "Replace the items listed in a packed sheet's Json file with their images from the sheet."; + importItemsFromSheetToolStripMenuItem.Click += OnClickImportItemsFromSheet; // // asBmpToolStripMenuItem // @@ -658,6 +682,9 @@ private void InitializeComponent() private ContextMenuStrip TileViewContextMenuStrip; private PictureBox DetailPictureBox; private ToolStripMenuItem ExportAllToolStripMenuItem; + private ToolStripSeparator packedSheetSeparator; + private ToolStripMenuItem exportItemsToSheetToolStripMenuItem; + private ToolStripMenuItem importItemsFromSheetToolStripMenuItem; private ToolStripMenuItem extractToolStripMenuItem; private ToolStripMenuItem findNextFreeSlotToolStripMenuItem; private ToolStripStatusLabel GraphicLabel; diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index 13eec260..c05922c7 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -1078,6 +1078,298 @@ private void ExportAllItemImages(ImageFormat imageFormat) } } + private void OnClickExportItemsToSheet(object sender, EventArgs e) + { + const string title = "Export Items to Sheet"; + + List selected = GetSelectedGraphicIds(); + int initialStart = selected.Count > 0 ? selected[0] : -1; + int initialEnd = selected.Count > 0 ? Math.Min(selected[^1], initialStart + ItemRangeInputForm.MaxRangeLength - 1) : -1; + + using var rangeForm = new ItemRangeInputForm(Art.GetMaxItemId(), initialStart, initialEnd); + if (rangeForm.ShowDialog(this) != DialogResult.OK) + { + return; + } + + using var folderDialog = new FolderBrowserDialog + { + Description = "Select the folder to save the packed sheet in", + ShowNewFolderButton = true, + SelectedPath = SpriteSheetPacker.LastFolder + }; + if (folderDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SpriteSheetPacker.LastFolder = folderDialog.SelectedPath; + + string baseName; + int count; + try + { + using (new WaitCursorScope(this)) + { + baseName = PackItems(rangeForm.StartId, rangeForm.EndId, folderDialog.SelectedPath, out count); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.ExternalException) + { + MessageBox.Show($"Failed to export items: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + if (baseName == null) + { + MessageBox.Show("No valid items found in the range.", title, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + FileSavedDialog.Show(FindForm(), folderDialog.SelectedPath, + $"Packed {count} items into {Path.GetFileName(baseName)} (.png, .json and _guide.png)."); + } + + /// + /// Packs the valid items of - into + /// items_{start}-{end}.png with a .json descriptor and a _guide.png. + /// + /// The output path without extension, or null if the range has no valid item. + private string PackItems(int startId, int endId, string outDir, out int count) + { + const int maxWidth = 1024; + const int spacing = 2; + + var layout = new SpriteSheetLayout(maxWidth, spacing); + var entries = new List(); + var images = new List(); + count = 0; + + try + { + for (int id = startId; id <= endId; ++id) + { + if (!Art.IsValidStatic(id)) + { + continue; + } + + Bitmap bitmap = Art.GetStatic(id); + if (bitmap == null) + { + continue; + } + + entries.Add(new PackedItemEntry { Index = id, Frame = layout.Place(bitmap.Width, bitmap.Height) }); + + // Art bitmaps are cache-owned + images.Add(new Bitmap(bitmap)); + } + + if (entries.Count == 0) + { + return null; + } + + string baseName = Path.Combine(outDir, $"items_{Utils.FormatExportId(startId)}-{Utils.FormatExportId(endId)}"); + string imageFile = baseName + ".png"; + List rects = entries.Select(entry => entry.Frame).ToList(); + + using (Bitmap sheet = SpriteSheetPacker.Compose(layout.Width, layout.Height, images, rects)) + { + sheet.Save(imageFile, ImageFormat.Png); + SpriteSheetPacker.WriteJson(baseName + ".json", new PackedItems + { + Meta = SpriteSheetPacker.CreateMeta(imageFile, sheet), + Items = entries + }); + + List labels = entries.Select(entry => $"0x{entry.Index:X4}").ToList(); + SpriteSheetPacker.SaveGuide(baseName + "_guide.png", sheet, rects, labels: labels, font: Font); + } + + count = entries.Count; + return baseName; + } + finally + { + foreach (Bitmap image in images) + { + image.Dispose(); + } + } + } + + private void OnClickImportItemsFromSheet(object sender, EventArgs e) + { + const string title = "Import Items from Sheet"; + + using var fileDialog = new OpenFileDialog + { + InitialDirectory = SpriteSheetPacker.LastFolder, + Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*", + Title = "Select the Json file of a packed item sheet" + }; + if (fileDialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SpriteSheetPacker.LastFolder = Path.GetDirectoryName(fileDialog.FileName); + + // Read and check the whole sheet before anything is replaced + var items = new List<(int Id, Bitmap Bitmap)>(); + var skipped = new List(); + int handedOver = 0; + try + { + try + { + using (new WaitCursorScope(this)) + { + ReadPackedItems(fileDialog.FileName, items, skipped); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or + ArgumentException or OutOfMemoryException or System.Runtime.InteropServices.ExternalException) + { + MessageBox.Show($"Failed to read the sheet: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + string skippedText = FormatSkipped(skipped); + if (items.Count == 0) + { + MessageBox.Show($"No item of {Path.GetFileName(fileDialog.FileName)} could be imported.{skippedText}", + title, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + int existing = items.Count(item => Art.IsValidStatic(item.Id)); + if (MessageBox.Show( + $"Import {items.Count} items (0x{items[0].Id:X4}-0x{items[^1].Id:X4}), replacing {existing} existing item(s)?{skippedText}", + title, MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2) != DialogResult.Yes) + { + return; + } + + foreach (var (id, bitmap) in items) + { + Art.ReplaceStatic(id, bitmap); + handedOver++; + ControlEvents.FireItemChangeEvent(this, id); + + // Ids that were free slots join the list when only valid items are shown + if (!_showFreeSlots) + { + int position = _itemList.BinarySearch(id); + if (position < 0) + { + _itemList.Insert(~position, id); + } + } + } + } + finally + { + // Art owns the bitmaps it was given + foreach (var (_, bitmap) in items.Skip(handedOver)) + { + bitmap.Dispose(); + } + } + + Options.ChangedUltimaClass["Art"] = true; + + ItemsTileView.VirtualListSize = _itemList.Count; + ItemsTileView.Invalidate(); + if (_selectedGraphicId >= 0) + { + UpdateToolStripLabels(_selectedGraphicId); + UpdateDetail(_selectedGraphicId); + } + } + + /// + /// Reads every importable item of a packed item sheet into , sorted by id, + /// and describes the ones it leaves out in . + /// + private static void ReadPackedItems(string jsonFile, List<(int Id, Bitmap Bitmap)> items, List skipped) + { + PackedItems packed = SpriteSheetPacker.ReadJson(jsonFile); + if (packed.Items == null || packed.Items.Count == 0) + { + throw new InvalidDataException($"{Path.GetFileName(jsonFile)} lists no items."); + } + + int maxId = Art.GetMaxItemId(); + var seen = new HashSet(); + try + { + using (Bitmap sheet = SpriteSheetPacker.LoadSheet(jsonFile, packed.Meta)) + { + foreach (PackedItemEntry entry in packed.Items.Where(entry => entry != null).OrderBy(entry => entry.Index)) + { + string id = $"0x{entry.Index:X4}"; + if (entry.Index < 0 || entry.Index > maxId) + { + skipped.Add($"{id}: past the last item id 0x{maxId:X4}"); + continue; + } + + if (!seen.Add(entry.Index)) + { + skipped.Add($"{id}: listed more than once"); + continue; + } + + if (!SpriteSheetPacker.IsInside(entry.Frame, sheet)) + { + skipped.Add($"{id}: outside the sheet image"); + continue; + } + + Bitmap bitmap = SpriteSheetPacker.Crop(sheet, entry.Frame); + if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) + { + bitmap.Dispose(); + skipped.Add($"{id}: too large for the MUL format ({estimatedSize:N0} of 65,535 ushorts)"); + continue; + } + + items.Add((entry.Index, bitmap)); + } + } + } + catch + { + foreach (var (_, bitmap) in items) + { + bitmap.Dispose(); + } + + items.Clear(); + throw; + } + } + + private static string FormatSkipped(List skipped) + { + if (skipped.Count == 0) + { + return string.Empty; + } + + const int maxShown = 10; + string text = $"\n\nSkipped {skipped.Count}:\n" + string.Join("\n", skipped.Take(maxShown)); + if (skipped.Count > maxShown) + { + text += $"\n... and {skipped.Count - maxShown} more."; + } + + return text; + } + private void OnClickPreLoad(object sender, EventArgs e) { if (PreLoader.IsBusy)