diff --git a/.github/workflows/Info.plist b/.github/workflows/Info.plist new file mode 100644 index 000000000..8ddaa60ce --- /dev/null +++ b/.github/workflows/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleIdentifier + io.github.underminersteam.undertalemodtool + CFBundleName + UTMTA + CFBundleDisplayName + UndertaleModToolAvalonia + CFBundleVersion + 0.0.0 + LSMinimumSystemVersion + 10.12 + CFBundleExecutable + UndertaleModToolAvalonia + CFBundleInfoDictionaryVersion + 6.0 + NSHighResolutionCapable + + + \ No newline at end of file diff --git a/.github/workflows/publish_gui_avalonia.yml b/.github/workflows/publish_gui_avalonia.yml new file mode 100644 index 000000000..1750a36a0 --- /dev/null +++ b/.github/workflows/publish_gui_avalonia.yml @@ -0,0 +1,76 @@ +name: Publish UndertaleModToolAvalonia + +on: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_gui: + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + configuration: [Release] + bundled: [true] + singlefile: [false] + include: + - os: ubuntu-latest + rid: linux-x64 + - os: macOS-latest + rid: osx-x64 + - os: windows-latest + rid: win-x64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v7 + with: + submodules: true + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Publish ${{ matrix.os }} + run: | + dotnet publish UndertaleModToolAvalonia -c ${{ matrix.configuration }} -r ${{ matrix.rid }} --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=${{ matrix.singlefile }} --output Publish/${{ matrix.os }} + cp ./README.md ./Publish/${{ matrix.os }} + cp ./SCRIPTS.md ./Publish/${{ matrix.os }} + cp ./LICENSE.txt ./Publish/${{ matrix.os }} + + - name: ubuntu-latest specific + if: ${{ matrix.os == 'ubuntu-latest' }} + run: | + echo "artifact_location=Publish/ubuntu-latest/" >> "$GITHUB_ENV" + + - name: windows-latest specific + if: ${{ matrix.os == 'windows-latest' }} + run: | + echo "artifact_location=Publish/windows-latest/" >> "$GITHUB_ENV" + + - name: macOS-latest specific + if: ${{ matrix.os == 'macOS-latest' }} + run: | + APP_NAME="./Publish/macOS-latest-app/UndertaleModToolAvalonia.app" + + mkdir -p "$APP_NAME/Contents/MacOS" + + cp "./.github/workflows/Info.plist" "$APP_NAME/Contents/Info.plist" + cp -a "./Publish/macOS-latest/." "$APP_NAME/Contents/MacOS" + + echo "artifact_location=Publish/macOS-latest-app/" >> "$GITHUB_ENV" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: UndertaleModToolAvalonia-${{ matrix.os }}-${{ github.sha }} + path: ${{ env.artifact_location }} diff --git a/UndertaleModLib/Models/UndertaleAnimationCurve.cs b/UndertaleModLib/Models/UndertaleAnimationCurve.cs index 0cc64de7f..05e1216da 100644 --- a/UndertaleModLib/Models/UndertaleAnimationCurve.cs +++ b/UndertaleModLib/Models/UndertaleAnimationCurve.cs @@ -38,7 +38,7 @@ public enum GraphTypeEnum : uint /// /// The channels this animation curve has. /// - public UndertaleSimpleList Channels { get; set; } + public UndertaleSimpleList Channels { get; set; } = new UndertaleSimpleList(); /// public void Serialize(UndertaleWriter writer) @@ -158,7 +158,7 @@ public enum CurveType : uint /// /// The points in the channel. /// - public UndertaleSimpleList Points { get; set; } + public UndertaleSimpleList Points { get; set; } = new UndertaleSimpleList(); /// public void Serialize(UndertaleWriter writer) @@ -230,37 +230,38 @@ public void Dispose() /// /// A point which can exist on a . /// + [PropertyChanged.AddINotifyPropertyChangedInterface] public class Point : UndertaleObject { /// /// The X coordinate of this point. GameMaker abbreviates this to "h". /// - public float X; + public float X { get; set; } /// /// The Y coordinate of this point. GameMaker abbreviates this to "v". /// - public float Value; + public float Value { get; set; } /// /// The Y position for the first bezier handle. Only used if the Channel is set to Bezier. /// - public float BezierX0; - + public float BezierX0 { get; set; } + /// /// The Y position for the first bezier handle. Only used if the Channel is set to Bezier. /// - public float BezierY0; - + public float BezierY0 { get; set; } + /// /// The X position for the second bezier handle. Only used if the Channel is set to Bezier. /// - public float BezierX1; - + public float BezierX1 { get; set; } + /// /// The Y position for the second bezier handle. Only used if the Channel is set to Bezier. /// - public float BezierY1; + public float BezierY1 { get; set; } /// public void Serialize(UndertaleWriter writer) diff --git a/UndertaleModLib/Models/UndertaleGeneralInfo.cs b/UndertaleModLib/Models/UndertaleGeneralInfo.cs index 297efc374..ba9eccf7e 100644 --- a/UndertaleModLib/Models/UndertaleGeneralInfo.cs +++ b/UndertaleModLib/Models/UndertaleGeneralInfo.cs @@ -11,7 +11,7 @@ namespace UndertaleModLib.Models; /// General info about a data file. /// [PropertyChanged.AddINotifyPropertyChangedInterface] -public class UndertaleGeneralInfo : UndertaleObject, IDisposable +public partial class UndertaleGeneralInfo : UndertaleObject, IDisposable { /// /// Information flags a data file can use. diff --git a/UndertaleModLib/Models/UndertaleRoom.cs b/UndertaleModLib/Models/UndertaleRoom.cs index 119ebeb50..18459f730 100644 --- a/UndertaleModLib/Models/UndertaleRoom.cs +++ b/UndertaleModLib/Models/UndertaleRoom.cs @@ -1114,6 +1114,26 @@ protected void OnPropertyChanged([CallerMemberName] string name = null) /// public int YOffset => Y + SpriteYOffset; + public GameObject Clone() + { + return new() + { + X = this.X, + Y = this.Y, + ObjectDefinition = this.ObjectDefinition, + InstanceID = this.InstanceID, + CreationCode = this.CreationCode, + ScaleX = this.ScaleX, + ScaleY = this.ScaleY, + Color = this.Color, + Rotation = this.Rotation, + PreCreateCode = this.PreCreateCode, + ImageSpeed = this.ImageSpeed, + ImageIndex = this.ImageIndex, + Nonexistent = this.Nonexistent, + }; + } + /// public virtual void Serialize(UndertaleWriter writer) { diff --git a/UndertaleModLib/Models/UndertaleSprite.cs b/UndertaleModLib/Models/UndertaleSprite.cs index b9886bc00..6a8dea2fd 100644 --- a/UndertaleModLib/Models/UndertaleSprite.cs +++ b/UndertaleModLib/Models/UndertaleSprite.cs @@ -171,7 +171,7 @@ public int OriginYWrapper public UndertaleSequence V2Sequence; - public NineSlice V3NineSlice; + public NineSlice V3NineSlice { get; set; } /// public event PropertyChangedEventHandler PropertyChanged; diff --git a/UndertaleModLib/Models/UndertaleVariable.cs b/UndertaleModLib/Models/UndertaleVariable.cs index 8b5a43439..315fde426 100644 --- a/UndertaleModLib/Models/UndertaleVariable.cs +++ b/UndertaleModLib/Models/UndertaleVariable.cs @@ -6,7 +6,7 @@ namespace UndertaleModLib.Models; /// /// A variable entry in a GameMaker data file. /// -// TODO: INotifyPropertyChanged +[PropertyChanged.AddINotifyPropertyChangedInterface] public class UndertaleVariable : UndertaleNamedResource, ISearchable, UndertaleInstruction.IReferencedObject, IDisposable, IGMVariable { /// The name of the Variable. diff --git a/UndertaleModLib/UndertaleChunks.cs b/UndertaleModLib/UndertaleChunks.cs index c6ef9d251..45da9a435 100644 --- a/UndertaleModLib/UndertaleChunks.cs +++ b/UndertaleModLib/UndertaleChunks.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; using System.IO; using UndertaleModLib.Models; using UndertaleModLib.Util; @@ -237,7 +240,7 @@ internal override uint UnserializeObjectCount(UndertaleReader reader) public class UndertaleChunkEXTN : UndertaleListChunk { public override string Name => "EXTN"; - public List productIdData = new List(); + public ObservableCollection productIdData { get; set; } = new ObservableCollection(); // TODO: Capitalize property private bool checkedFor2022_6 = false; private bool checkedFor2023_4 = false; @@ -361,7 +364,7 @@ internal override void UnserializeChunk(UndertaleReader reader) // Strange data for each extension, some kind of unique identifier based on // the product ID for each of them - productIdData = new List(); + productIdData.Clear(); if (UndertaleExtension.ProductDataEligible(reader.undertaleData)) { for (int i = 0; i < List.Count; i++) @@ -397,6 +400,14 @@ internal override uint UnserializeObjectCount(UndertaleReader reader) return base.UnserializeObjectCount(reader); } + + public class ByteArrayWrapper(byte[] v) + { + public byte[] ByteArray { get; set; } = v; + + public static implicit operator byte[](ByteArrayWrapper v) => v.ByteArray; + public static implicit operator ByteArrayWrapper(byte[] v) => new(v); + } } public class UndertaleChunkSOND : UndertaleListChunk @@ -1746,7 +1757,7 @@ public class UndertaleChunkVARI : UndertaleChunk public uint VarCount2 { get; set; } public uint MaxLocalVarCount { get; set; } public bool DifferentVarCounts { get; set; } - public List List = new List(); + public ObservableCollection List = new ObservableCollection(); [Obsolete] public uint InstanceVarCount { get => VarCount1; set => VarCount1 = value; } @@ -1796,7 +1807,7 @@ internal override void UnserializeChunk(UndertaleReader reader) else varLength = 12; List.Clear(); - List.Capacity = (int)(Length / varLength); + //List.Capacity = (int)(Length / varLength); while (reader.Position + varLength <= startPosition + Length) List.Add(reader.ReadUndertaleObject()); } diff --git a/UndertaleModLib/UndertaleData.cs b/UndertaleModLib/UndertaleData.cs index e05791052..32d04d882 100644 --- a/UndertaleModLib/UndertaleData.cs +++ b/UndertaleModLib/UndertaleData.cs @@ -719,6 +719,147 @@ public static UndertaleData CreateNew() return data; } + /// + /// Creates a new resource based on type of the list. + /// + public static UndertaleResource CreateResource(IList list) + { + Type resourceType = list.GetType().GetGenericArguments()[0]; + return (Activator.CreateInstance(resourceType) as UndertaleResource)!; + } + + /// + /// Get the default name of a resource based on a list (e.g., sprite0) + /// + public static string GetDefaultResourceName(IList list) + { + Type resourceType = list.GetType().GetGenericArguments()[0]; + if (resourceType == typeof(UndertaleTexturePageItem) || + resourceType == typeof(UndertaleEmbeddedAudio) || + resourceType == typeof(UndertaleEmbeddedTexture)) + { + return null; + } + + string typeName = resourceType.Name.Replace("Undertale", "").Replace("GameObject", "Object").ToLower(); + string resourceName = typeName + list.Count; + + return resourceName; + } + + /// + /// Initialize newly created resource. + /// + /// The resource to initialize. + /// The list where the resource might reside. + /// Name to set to resource if supported. + public IList InitializeResource(UndertaleResource resource, IList list, string resourceName) + { + IList newResources = []; + + // Set up name + if (resource is UndertaleNamedResource namedResource) + { + UndertaleString name = resource switch + { + // UTMT only names. + UndertaleTexturePageItem => new UndertaleString("PageItem " + list.Count), + UndertaleEmbeddedAudio => new UndertaleString("EmbeddedSound " + list.Count), + UndertaleEmbeddedTexture => new UndertaleString("Texture " + list.Count), + _ => Strings.MakeString(resourceName), + }; + + namedResource.Name = name; + } + + if (resource is UndertaleString _string) + { + _string.Content = resourceName; + } + else if (resource is UndertaleRoom room) + { + if (IsVersionAtLeast(2)) + { + room.Caption = null; + room.Backgrounds.Clear(); + if (IsVersionAtLeast(2024, 13)) + { + room.Flags |= IsVersionAtLeast(2024, 13) ? UndertaleRoom.RoomEntryFlags.IsGM2024_13 : UndertaleRoom.RoomEntryFlags.IsGMS2; + room.InstanceCreationOrderIDs ??= new(); + } + else + { + room.Flags |= UndertaleRoom.RoomEntryFlags.IsGMS2; + if (IsVersionAtLeast(2, 3)) + { + room.Flags |= UndertaleRoom.RoomEntryFlags.IsGMS2_3; + } + } + } + else + { + room.Caption = Strings.MakeString(""); + } + } + else if (resource is UndertaleScript script) + { + if (IsVersionAtLeast(2, 3)) + { + script.Code = UndertaleCode.CreateEmptyEntry(this, Strings.MakeString($"gml_GlobalScript_{script.Name.Content}")); + if (GlobalInitScripts is IList globalInitScripts) + { + globalInitScripts.Add(new UndertaleGlobalInit() + { + Code = script.Code, + }); + } + } + else + { + script.Code = UndertaleCode.CreateEmptyEntry(this, Strings.MakeString($"gml_Script_{script.Name.Content}")); + } + + newResources.Add(script.Code); + } + else if (resource is UndertaleCode code) + { + if (CodeLocals is not null) + { + code.LocalsCount = 1; + UndertaleCodeLocals.CreateEmptyEntry(this, code.Name); + } + else + { + code.WeirdLocalFlag = true; + } + } + else if (resource is UndertaleExtension extension) + { + extension.FolderName = Strings.MakeString(""); + extension.ClassName = Strings.MakeString(""); + extension.Version = Strings.MakeString("1.0.0"); + + if (GeneralInfo?.Major >= 2 || + (GeneralInfo?.Major == 1 && GeneralInfo?.Build >= 1773) || + (GeneralInfo?.Major == 1 && GeneralInfo?.Build == 1539)) + { + var newProductID = new byte[] { 0xBA, 0x5E, 0xBA, 0x11, 0xBA, 0xDD, 0x06, 0x60, 0xBE, 0xEF, 0xED, 0xBA, 0x0B, 0xAB, 0xBA, 0xBE }; + FORM.EXTN.productIdData.Add(newProductID); + } + } + else if (resource is UndertaleShader shader) + { + shader.GLSL_ES_Vertex = Strings.MakeString(""); + shader.GLSL_ES_Fragment = Strings.MakeString(""); + shader.GLSL_Vertex = Strings.MakeString(""); + shader.GLSL_Fragment = Strings.MakeString(""); + shader.HLSL9_Vertex = Strings.MakeString(""); + shader.HLSL9_Fragment = Strings.MakeString(""); + } + + return newResources; + } + /// public void Dispose() { diff --git a/UndertaleModLib/UndertaleIO.cs b/UndertaleModLib/UndertaleIO.cs index d48f6616f..dc6075d36 100644 --- a/UndertaleModLib/UndertaleIO.cs +++ b/UndertaleModLib/UndertaleIO.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; @@ -23,6 +24,7 @@ public interface UndertaleResourceRef : UndertaleObject int SerializeById(UndertaleWriter writer); } + [PropertyChanged.AddINotifyPropertyChangedInterface] public class UndertaleResourceById : UndertaleResourceRef, IStaticChildObjectsSize, IDisposable where T : UndertaleResource, new() where ChunkT : UndertaleListChunk { /// diff --git a/UndertaleModLib/UndertaleModLib.csproj b/UndertaleModLib/UndertaleModLib.csproj index 4db33e9c8..b6aa5b7fb 100644 --- a/UndertaleModLib/UndertaleModLib.csproj +++ b/UndertaleModLib/UndertaleModLib.csproj @@ -10,8 +10,6 @@ 0.9.2.0 0.9.2.0 embedded - AnyCPU;x64 - win-x64;win-x86 en en en diff --git a/UndertaleModLib/Util/GMImage.cs b/UndertaleModLib/Util/GMImage.cs index fb06b6703..e7e9ad6c1 100644 --- a/UndertaleModLib/Util/GMImage.cs +++ b/UndertaleModLib/Util/GMImage.cs @@ -968,6 +968,14 @@ public Span GetRawImageData() return _data.AsSpan(); } + /// + /// Returns the image data in the current format. + /// + public byte[] GetData() + { + return _data; + } + /// /// Writes this image, in its current format (as seen on disk), to the current position of the specified . /// diff --git a/UndertaleModLib/Util/TextureWorker.cs b/UndertaleModLib/Util/TextureWorker.cs index e727bcdc5..b7ece5e38 100644 --- a/UndertaleModLib/Util/TextureWorker.cs +++ b/UndertaleModLib/Util/TextureWorker.cs @@ -14,6 +14,11 @@ public class TextureWorker : IDisposable private Dictionary embeddedDictionary = new(); private readonly object embeddedDictionaryLock = new(); + private static MagickReadSettings magickReadSettings = new() + { + ColorSpace = ColorSpace.sRGB, + }; + /// /// Retrieves an image representing the supplied texture page. /// @@ -53,6 +58,19 @@ public void ExportAsPNG(UndertaleTexturePageItem texPageItem, string filePath, s SaveImageToFile(image, filePath); } + /// + /// Exports the given texture page item, as a PNG, to the supplied stream. (With or without padding.) + /// + /// Texture page item to export. + /// Stream to export to. + /// Image name to be used when throwing exceptions. + /// True if padding should be exported; false otherwise. + public void ExportAsPNG(UndertaleTexturePageItem texPageItem, Stream stream, string imageName = null, bool includePadding = false) + { + using var image = GetTextureFor(texPageItem, imageName ?? "Image", includePadding); + SaveImageToStream(image, stream); + } + /// /// Creates an image representing the sole texture page item supplied, with or without padding. /// @@ -115,11 +133,16 @@ public IMagickImage GetTextureFor(UndertaleTexturePageItem texPageItem, st /// An image, in uncompressed BGRA format, containing the contents of the image file at the given path. public static MagickImage ReadBGRAImageFromFile(string filePath) { - MagickReadSettings settings = new() - { - ColorSpace = ColorSpace.sRGB, - }; - MagickImage image = new(filePath, settings); + return ConfigureReadBGRAImage(new(filePath, magickReadSettings)); + } + + public static MagickImage ReadBGRAImageFromStream(Stream stream) + { + return ConfigureReadBGRAImage(new(stream, magickReadSettings)); + } + + private static MagickImage ConfigureReadBGRAImage(MagickImage image) + { image.Alpha(AlphaOption.Set); image.Format = MagickFormat.Bgra; image.Depth = 8; @@ -259,6 +282,16 @@ public static void ExportCollisionMaskPNG(UndertaleSprite.MaskEntry mask, string public static void SaveImageToFile(IMagickImage image, string filePath) { using var stream = new FileStream(filePath, FileMode.Create); + SaveImageToStream(image, stream); + } + + /// + /// Saves the provided image as a PNG file, at the specified stream. + /// + /// Image to save. + /// Stream to save the image to. + public static void SaveImageToStream(IMagickImage image, Stream stream) + { image.Write(stream, MagickFormat.Png32); } diff --git a/UndertaleModTool.sln b/UndertaleModTool.sln index 18e2bfe07..0f573cd0d 100644 --- a/UndertaleModTool.sln +++ b/UndertaleModTool.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.32014.148 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11505.172 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UndertaleModTool", "UndertaleModTool\UndertaleModTool.csproj", "{C703A485-DBFB-4601-8A26-42E64CCF812F}" EndProject @@ -17,6 +17,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UndertaleModLibTests", "Und EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Underanalyzer", "Underanalyzer\Underanalyzer\Underanalyzer.csproj", "{A171D18F-274E-427A-AFB3-D7A161F5B9CF}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UndertaleModToolAvalonia", "UndertaleModToolAvalonia\UndertaleModToolAvalonia.csproj", "{96FA167A-9D79-3328-BCB4-0564D2575718}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +83,14 @@ Global {A171D18F-274E-427A-AFB3-D7A161F5B9CF}.Release|Any CPU.Build.0 = Release|Any CPU {A171D18F-274E-427A-AFB3-D7A161F5B9CF}.Release|x64.ActiveCfg = Release|Any CPU {A171D18F-274E-427A-AFB3-D7A161F5B9CF}.Release|x64.Build.0 = Release|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Debug|x64.ActiveCfg = Debug|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Debug|x64.Build.0 = Debug|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Release|Any CPU.Build.0 = Release|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Release|x64.ActiveCfg = Release|Any CPU + {96FA167A-9D79-3328-BCB4-0564D2575718}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs b/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs index ac07d94f1..eb2f400a9 100644 --- a/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs +++ b/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs @@ -178,7 +178,9 @@ private bool SelectTileRegion(object sender, MouseButtonEventArgs e) e.Handled = true; - int tileIndex = bg.GMS2TileIds.FindIndex(x => x.ID == tileID); + var tileIndex = bg.GMS2TileIds + .Select((item, index) => new { Item = item, Index = index }) + .FirstOrDefault(x => x.Item.ID == tileID)?.Index ?? -1; if (tileIndex == -1) return false; diff --git a/UndertaleModTool/Editors/UndertaleRoomEditor/UndertaleTileEditor.xaml.cs b/UndertaleModTool/Editors/UndertaleRoomEditor/UndertaleTileEditor.xaml.cs index 0dbce7edd..19ba3d03d 100644 --- a/UndertaleModTool/Editors/UndertaleRoomEditor/UndertaleTileEditor.xaml.cs +++ b/UndertaleModTool/Editors/UndertaleRoomEditor/UndertaleTileEditor.xaml.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -560,9 +561,9 @@ private void FindPaletteCursor() PaletteCursorVisibility = Visibility.Visible; uint brushTile = BrushTilesData.TileData[0][0] & TILE_INDEX; - int index = PaletteTilesData.Background.GMS2TileIds.FindIndex( - id => id.ID == brushTile - ); + int index = PaletteTilesData.Background.GMS2TileIds + .Select((item, index) => new { Item = item, Index = index }) + .FirstOrDefault(id => id.Item.ID == brushTile)?.Index ?? -1; if (index == -1) index = 0; MovePaletteCursor((int)(index / PaletteTilesData.Background.GMS2ItemsPerTileCount)); diff --git a/UndertaleModToolAvalonia/App.axaml b/UndertaleModToolAvalonia/App.axaml new file mode 100644 index 000000000..9e5026456 --- /dev/null +++ b/UndertaleModToolAvalonia/App.axaml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/UndertaleModToolAvalonia/App.axaml.cs b/UndertaleModToolAvalonia/App.axaml.cs new file mode 100644 index 000000000..352e0e311 --- /dev/null +++ b/UndertaleModToolAvalonia/App.axaml.cs @@ -0,0 +1,64 @@ +using System; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using Microsoft.Extensions.DependencyInjection; + +namespace UndertaleModToolAvalonia; + +public partial class App : Application +{ + public static string VersionString = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?.?.?.?"; + public static string InformationalVersionString = Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion ?? ""; + + public static IServiceProvider Services = null!; + public static IStyle? CurrentCustomStyles = null; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + // Dependency injection. + ServiceCollection collection = new(); + collection.AddSingleton(); + + Services = collection.BuildServiceProvider(); + + MainViewModel vm = Services.GetRequiredService(); + vm.Initialize(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = vm, + }; + + Window.WindowOpenedEvent.AddClassHandler(typeof(Window), (source, e) => + { + if (source is Window window) + { + bool isDark = (ActualThemeVariant == ThemeVariant.Dark); + window.SetDarkTitleBar(isDark); + } + }); + + ActualThemeVariantChanged += (s, e) => + { + bool isDark = (ActualThemeVariant == ThemeVariant.Dark); + foreach (var window in desktop.Windows) + { + window.SetDarkTitleBar(isDark); + } + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/UndertaleModToolAvalonia/Assets/Icon.ico b/UndertaleModToolAvalonia/Assets/Icon.ico new file mode 100644 index 000000000..16e06e564 Binary files /dev/null and b/UndertaleModToolAvalonia/Assets/Icon.ico differ diff --git a/UndertaleModToolAvalonia/Assets/SyntaxASM.xshd b/UndertaleModToolAvalonia/Assets/SyntaxASM.xshd new file mode 100644 index 000000000..809893d42 --- /dev/null +++ b/UndertaleModToolAvalonia/Assets/SyntaxASM.xshd @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + ^;.*|(?:^|\n)\..* + + + + ^(?:\:|\>).*|(?:^|\n)\..* + + + + \"(?:\\.|[^"\\])*\"@\d+ + + + + \[[0-9a-zA-Z_\-]+\] + + + + + self + other + all + noone + global + undefined + local + builtin + arg + + + + + (?:^|\n)\d+(?=:) + + + + + \[(?:array|stacktop)\] + | + argc\=\d+ + | + <drop> + + + + + \b0x[0-9a-fA-F]+ # hex number + | \$[0-9a-fA-F]+ # alternate hex number + | + (?: -?\d+(?:\.[0-9]+)? # digits with optional . and - + | -?\.[0-9]+ # start with ., then digits + ) + \b + + + + + \bargument[0-9]\b | + \bargument1[0-5]\b + + + + + [_a-zA-Z@][_a-zA-Z0-9@]*(?=\() + + + + + @+[_a-zA-Z][_a-zA-Z0-9]*@+(?=\() + + + + + ^(bf|bt|b|exit|ret|pushenv|popenv) + + + ^[a-z]+(?=\.|\s) + + + + + [_a-zA-Z][_a-zA-Z0-9]* + + + \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Assets/SyntaxGML.xshd b/UndertaleModToolAvalonia/Assets/SyntaxGML.xshd new file mode 100644 index 000000000..b29094dfe --- /dev/null +++ b/UndertaleModToolAvalonia/Assets/SyntaxGML.xshd @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + " + " + + + + + + + + ' + ' + + + + + + + + \$" + " + + + + + + + + + + + if + else + do + while + for + repeat + switch + case + default + break + continue + with + new + constructor + function + return + exit + var + until + and + or + xor + begin + end + then + mod + div + throw + static + try + catch + finally + enum + + + + + true + false + self + other + all + noone + global + undefined + + + + + \b0x[0-9a-fA-F]+ # hex number + | \$[0-9a-fA-F]+ # alternate hex number + | \#[0-9a-fA-F]{6} # css color hex number + | + (?: -?\d+(?:\.[0-9]+)? # digits with optional . and - + | -?\.[0-9]+ # start with ., then digits + ) + \b + + + + + \bargument[0-9]\b | + \bargument1[0-5]\b + + + + + [_a-zA-Z][_a-zA-Z0-9]*(?=\() + + + + + @+[_a-zA-Z][_a-zA-Z0-9]*@+(?=\() + + + + + [_a-zA-Z][_a-zA-Z0-9]* + + + \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml b/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml new file mode 100644 index 000000000..4c1ff2824 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml.cs b/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml.cs new file mode 100644 index 000000000..eb8102f17 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/DataExplorerView.axaml.cs @@ -0,0 +1,344 @@ +using System.Collections; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class DataExplorerView : UserControl +{ + HierarchicalTreeDataGridSource? treeDataGridSource; + + public DataExplorerView() + { + InitializeComponent(); + + MainTreeDataGrid.AddHandler(TreeDataGrid.PointerReleasedEvent, MainTreeDataGrid_PointerReleased_HandledEventsToo, handledEventsToo: true); + } + + protected override void OnInitialized() + { + if (DataContext is not DataExplorerViewModel vm) + return; + + vm.OnExpandItemOnTree = ExpandItemOnTree; + vm.OnSelectValueInTree = SelectValueInTree; + + treeDataGridSource = new HierarchicalTreeDataGridSource(vm.TreeDataGridData) + { + Columns = { + new HierarchicalExpanderColumn( + new TemplateColumn(null, + new FuncDataTemplate((value, namescope) => + { + if (value is null) + return null; + + TextBlock textBlock = new() { Text = value.Text }; + + if (value.Value is UndertaleNamedResource namedResource) + { + textBlock[!TextBlock.TextProperty] = CompiledBinding.Create(x => + ((UndertaleNamedResource)x.Value!).Name.Content); + + if (namedResource is UndertaleCode code) + { + textBlock.BindClass("DisableForeground", CompiledBinding.Create(x => + ((UndertaleCode)x.Value!).ParentEntry, converter: ObjectConverters.IsNotNull), null!); + } + } + else if (value.Value is UndertaleString _string) + { + textBlock[!TextBlock.TextProperty] = CompiledBinding.Create(x => + ((UndertaleString)x.Value!).Content); + } + else if (value.Value is null) + { + textBlock.Text = "(null)"; + textBlock.Classes.Add("DisableForeground"); + } + + return textBlock; + }), width: GridLength.Star + ), + childSelector: x => x.Children) + } + }; + + treeDataGridSource.RowSelection!.SingleSelect = false; + + MainTreeDataGrid.Source = treeDataGridSource; + + base.OnInitialized(); + } + + #region Events + private void MainTreeDataGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + OpenItemFromTreeDataGridControl(e.Source); + } + + private void MainTreeDataGrid_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + OpenItemFromTreeDataGridControl(e.Source); + } + } + + private void MainTreeDataGrid_PointerReleased_HandledEventsToo(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + { + OpenItemFromTreeDataGridControl(e.Source, inNewTab: true); + } + } + + private void OpenItemFromTreeDataGridControl(object? source, bool inNewTab = false) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + if (source is Control control) + { + TreeDataGridRow? row = control.FindLogicalAncestorOfType(includeSelf: true); + if (row?.DataContext is DataExplorerViewModel.Item item) + { + if (row.Rows?[row.RowIndex] is HierarchicalRow hierarchicalRow) + { + hierarchicalRow.IsExpanded = !hierarchicalRow.IsExpanded; + } + _ = vm.MainVM.TabOpen(item.Value, inNewTab); + } + } + } + #endregion + + #region Context menus + + public void ContextMenu_Add_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + DataExplorerViewModel.Item? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.MainVM.Data is not null) + { + // This could probably be better + IList list = (item.Value switch + { + "AudioGroups" => vm.MainVM.Data.AudioGroups as IList, + "Sounds" => vm.MainVM.Data.Sounds as IList, + "Sprites" => vm.MainVM.Data.Sprites as IList, + "Backgrounds" => vm.MainVM.Data.Backgrounds as IList, + "Paths" => vm.MainVM.Data.Paths as IList, + "Scripts" => vm.MainVM.Data.Scripts as IList, + "Shaders" => vm.MainVM.Data.Shaders as IList, + "Fonts" => vm.MainVM.Data.Fonts as IList, + "Timelines" => vm.MainVM.Data.Timelines as IList, + "GameObjects" => vm.MainVM.Data.GameObjects as IList, + "Rooms" => vm.MainVM.Data.Rooms as IList, + "Extensions" => vm.MainVM.Data.Extensions as IList, + "TexturePageItems" => vm.MainVM.Data.TexturePageItems as IList, + "Code" => vm.MainVM.Data.Code as IList, + "Variables" => vm.MainVM.Data.Variables as IList, + "Functions" => vm.MainVM.Data.Functions as IList, + "CodeLocals" => vm.MainVM.Data.CodeLocals as IList, + "Strings" => vm.MainVM.Data.Strings as IList, + "EmbeddedTextures" => vm.MainVM.Data.EmbeddedTextures as IList, + "EmbeddedAudio" => vm.MainVM.Data.EmbeddedAudio as IList, + "TextureGroupInformation" => vm.MainVM.Data.TextureGroupInfo as IList, + "EmbeddedImages" => vm.MainVM.Data.EmbeddedImages as IList, + "AnimationCurves" => vm.MainVM.Data.AnimationCurves as IList, + "ParticleSystems" => vm.MainVM.Data.ParticleSystems as IList, + "ParticleSystemEmitters" => vm.MainVM.Data.ParticleSystemEmitters as IList, + _ => null, + })!; + + vm.MainVM.DataItemAdd(list); + } + } + + public void ContextMenu_Open_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm || vm.MainVM.Data is null) + return; + + DataExplorerViewModel.Item? item = GetItemFromTreeDataGridControl(e.Source); + _ = vm.MainVM.TabOpen(item?.Value); + } + + public void ContextMenu_OpenInNewTab_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm || vm.MainVM.Data is null || treeDataGridSource is null) + return; + + foreach (var item in treeDataGridSource.RowSelection!.SelectedItems) + { + _ = vm.MainVM.TabOpen(item?.Value, inNewTab: true); + } + } + + public async void ContextMenu_CopyName_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm || vm.MainVM.Data is null || treeDataGridSource is null) + return; + + List names = new(); + + foreach (var item in treeDataGridSource.RowSelection!.SelectedItems) + { + string name = item?.Value switch + { + UndertaleNamedResource namedResource => namedResource.Name?.Content, + UndertaleString _string => _string.Content, + _ => null, + } ?? ""; + + names.Add(name); + } + + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + await topLevel.Clipboard!.SetTextAsync(string.Join("\n", names)); + } + + public async void ContextMenu_FindReferences_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + DataExplorerViewModel.Item? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && item.Value is UndertaleResource resource && vm.MainVM.Data is not null) + { + vm.MainVM.OpenFindReferences(resource); + } + } + + public async void ContextMenu_Move_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + DataExplorerViewModel.Item? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.MainVM.Data is not null && vm.MainVM.View is not null) + { + UndertaleResource resource = (item.Value as UndertaleResource)!; + IList list = vm.MainVM.Data[resource.GetType()]; + int oldIndex = list.IndexOf(resource); + + string? input = await vm.MainVM.View.TextBoxDialog("Swap to position:", oldIndex.ToString()); + if (input is null) + return; + + if (!int.TryParse(input, out int newIndex)) + { + await vm.MainVM.View.MessageDialog($"\"{input}\" is not a integer"); + return; + } + if (newIndex < 0 || newIndex >= list.Count) + { + await vm.MainVM.View.MessageDialog($"{newIndex} is out of range of the list"); + return; + } + + object? temp = list[newIndex]; + list[newIndex] = list[oldIndex]; + list[oldIndex] = temp; + } + } + + public async void ContextMenu_Remove_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + DataExplorerViewModel.Item? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.MainVM.Data is not null) + { + UndertaleResource resource = (item.Value as UndertaleResource)!; + vm.MainVM.DataItemRemove(resource); + } + } + + static DataExplorerViewModel.Item? GetItemFromTreeDataGridControl(object? source) + { + if (source is Control control) + { + TreeDataGridRow? row = control.FindLogicalAncestorOfType(includeSelf: true); + if (row?.DataContext is DataExplorerViewModel.Item item) + { + return item; + } + } + + return null; + } + #endregion + + public void ExpandItemOnTree(DataExplorerViewModel.Item item) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + IndexPath? foundIndex = FindTreeIndexPathFromValue(item, vm.TreeDataGridData); + + if (foundIndex is IndexPath index) + { + var source = (MainTreeDataGrid.Source as HierarchicalTreeDataGridSource)!; + source.Expand(index); + } + } + + public void SelectValueInTree(object value) + { + if (DataContext is not DataExplorerViewModel vm) + return; + + IndexPath? foundIndex = FindTreeIndexPathFromValue(value, vm.TreeDataGridData); + + if (foundIndex is IndexPath index) + { + var source = (MainTreeDataGrid.Source as HierarchicalTreeDataGridSource)!; + source.Expand(index); + + int rowIndex = MainTreeDataGrid.Rows!.ModelIndexToRowIndex(index); + MainTreeDataGrid.RowsPresenter!.BringIntoView(rowIndex); + + source.RowSelection!.Select(index); + } + } + + static IndexPath? FindTreeIndexPathFromValue(object value, IList? list, IndexPath indexPath = new()) + { + if (list is null) + return null; + + for (int i = 0; i < list.Count; i++) + { + DataExplorerViewModel.Item? item = list[i]; + if (item.Value == value || item == value) + { + return indexPath.Append(i); + } + + IndexPath? result = FindTreeIndexPathFromValue(value, item.Children, indexPath.Append(i)); + if (result is not null) + return result; + } + + return null; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/DataExplorerViewModel.cs b/UndertaleModToolAvalonia/Controls/DataExplorerViewModel.cs new file mode 100644 index 000000000..64c5722be --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/DataExplorerViewModel.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class DataExplorerViewModel : ObservableObject +{ + public MainViewModel MainVM; + + [ObservableProperty] + public partial ObservableCollection TreeDataGridData { get; set; } = []; + + public delegate void OnExpandItemOnTreeDelegate(DataExplorerViewModel.Item item); + public OnExpandItemOnTreeDelegate? OnExpandItemOnTree; + + public delegate void OnSelectValueInTreeDelegate(object value); + public OnSelectValueInTreeDelegate? OnSelectValueInTree; + + readonly List observableCollectionViewList = []; + + public DataExplorerViewModel(MainViewModel mainVM) + { + MainVM = mainVM; + } + + public void UpdateFromData() + { + TreeDataGridData.Clear(); + + observableCollectionViewList.Clear(); + + if (MainVM.Data is null) + return; + + Item dataItem = new() + { + Value = MainVM.Data, + Text = "Data", + Children = [], + }; + + void AddItem(object? item, string value, string text) + { + if (item is not null) + dataItem.Children.Add(new() { Value = value, Text = text }); + } + + void AddList(IList? list, string value, string text) where T : class? + { + if (list is not null) + dataItem.Children.Add(new() { Tag = "list", Value = value, Text = text, Children = CreateListObservableCollectionView(list) }); + } + + AddItem(MainVM.Data.GeneralInfo, "GeneralInfo", "General info"); + AddItem(MainVM.Data.GlobalInitScripts, "GlobalInitScripts", "Global init scripts"); + AddItem(MainVM.Data.GameEndScripts, "GameEndScripts", "Game End scripts"); + + AddList(MainVM.Data.AudioGroups, "AudioGroups", "Audio groups"); + AddList(MainVM.Data.Sounds, "Sounds", "Sounds"); + AddList(MainVM.Data.Sprites, "Sprites", "Sprites"); + AddList(MainVM.Data.Backgrounds, "Backgrounds", "Backgrounds & Tile sets"); + AddList(MainVM.Data.Paths, "Paths", "Paths"); + AddList(MainVM.Data.Scripts, "Scripts", "Scripts"); + AddList(MainVM.Data.Shaders, "Shaders", "Shaders"); + AddList(MainVM.Data.Fonts, "Fonts", "Fonts"); + AddList(MainVM.Data.Timelines, "Timelines", "Timelines"); + AddList(MainVM.Data.GameObjects, "GameObjects", "Game objects"); + AddList(MainVM.Data.Rooms, "Rooms", "Rooms"); + AddList(MainVM.Data.Extensions, "Extensions", "Extensions"); + AddList(MainVM.Data.TexturePageItems, "TexturePageItems", "Texture page items"); + AddList(MainVM.Data.Code, "Code", "Code"); + AddList(MainVM.Data.Variables, "Variables", "Variables"); + AddList(MainVM.Data.Functions, "Functions", "Functions"); + AddList(MainVM.Data.CodeLocals, "CodeLocals", "Code locals"); + AddList(MainVM.Data.Strings, "Strings", "Strings"); + AddList(MainVM.Data.EmbeddedTextures, "EmbeddedTextures", "Embedded textures"); + AddList(MainVM.Data.EmbeddedAudio, "EmbeddedAudio", "Embedded audio"); + AddList(MainVM.Data.TextureGroupInfo, "TextureGroupInformation", "Texture group information"); + AddList(MainVM.Data.EmbeddedImages, "EmbeddedImages", "Embedded images"); + AddList(MainVM.Data.AnimationCurves, "AnimationCurves", "Animation curves"); + AddList(MainVM.Data.ParticleSystems, "ParticleSystems", "Particle systems"); + AddList(MainVM.Data.ParticleSystemEmitters, "ParticleSystemEmitters", "Particle system emitters"); + + TreeDataGridData.Add(dataItem); + } + + ObservableCollectionView.CustomObservableCollection? CreateListObservableCollectionView(IList? list) where T : class? + { + if (list is not null) + { + ObservableCollectionView view = new(list, + transform: x => new Item() { Text = "", Value = x }, + filter: item => AssetNameContainsText(item.Value, MainVM.FilterText)); + + observableCollectionViewList.Add(view); + + return view.Output; + } + return null; + } + + public void SetFilter() + { + foreach (ObservableCollectionView view in observableCollectionViewList) + { + view.SetFilter(item => AssetNameContainsText(((Item)item!).Value, MainVM.FilterText)); + } + } + + public void SetSort() + { + Comparison? comparison = null; + if (MainVM.IsSorted) + { + comparison = static (a, b) => + { + string? aName = AssetGetName(((Item)a!).Value); + string? bName = AssetGetName(((Item)b!).Value); + + if (aName is null && bName is null) return 0; + if (aName is null) return 1; + if (bName is null) return -1; + + return aName.CompareTo(bName, StringComparison.Ordinal); + }; + } + + foreach (ObservableCollectionView view in observableCollectionViewList) + { + view.SetSort(comparison); + } + } + + static bool AssetNameContainsText(object? asset, string text) + { + if (text == "") + return true; + + string? name = AssetGetName(asset); + + if (name is null) + return false; + + return name.Contains(text, StringComparison.OrdinalIgnoreCase); + } + + static string? AssetGetName(object? asset) + { + return asset switch + { + UndertaleNamedResource namedResource => namedResource.Name?.Content, + UndertaleString _string => _string.Content, + _ => null, + }; + } + + public partial class Item : ObservableObject + { + [ObservableProperty] + public partial string Text { get; set; } = ""; + public object? Value { get; set; } + public object? Tag { get; set; } + + [ObservableProperty] + public partial IList? Children { get; set; } + } +} diff --git a/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml b/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml new file mode 100644 index 000000000..69c74fe55 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + ↑ + ↓ + + + + diff --git a/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml.cs b/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml.cs new file mode 100644 index 000000000..2b10258ab --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/EditableDataGrid.axaml.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections; +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +public partial class EditableDataGrid : UserControl +{ + public static readonly StyledProperty ItemsSourceProperty = AvaloniaProperty.Register( + nameof(ItemsSource)); + public IList ItemsSource + { + get { return GetValue(ItemsSourceProperty); } + set { SetValue(ItemsSourceProperty, value); } + } + + public static readonly StyledProperty ItemFactoryProperty = AvaloniaProperty.Register( + nameof(ItemFactory)); + public Delegate? ItemFactory + { + get { return GetValue(ItemFactoryProperty); } + set { SetValue(ItemFactoryProperty, value); } + } + + public static readonly StyledProperty?> SelectionChangedProperty = AvaloniaProperty.Register?>( + nameof(SelectionChanged)); + public Action? SelectionChanged + { + get { return GetValue(SelectionChangedProperty); } + set { SetValue(SelectionChangedProperty, value); } + } + + public static readonly StyledProperty HeadersVisibilityProperty = AvaloniaProperty.Register( + nameof(HeadersVisibility), DataGridHeadersVisibility.All); + public DataGridHeadersVisibility HeadersVisibility + { + get { return GetValue(HeadersVisibilityProperty); } + set { SetValue(HeadersVisibilityProperty, value); } + } + + public ObservableCollection Columns + { + get => DataGridControl.Columns; + set + { + DataGridControl.Columns.Clear(); + foreach (DataGridColumn column in value) + { + DataGridControl.Columns.Add(column); + } + } + } + + public EditableDataGrid() + { + InitializeComponent(); + + DataGridControl.SelectionChanged += (object? sender, SelectionChangedEventArgs e) => + { + // HACK: Hack to make it so a temporary deselection when moving items doesn't stop the repeat button. + Dispatcher.UIThread.Post(() => + { + RemoveButton.IsEnabled = (DataGridControl.SelectedIndex != -1); + MoveUpButton.IsEnabled = (DataGridControl.SelectedIndex > 0); + MoveDownButton.IsEnabled = (DataGridControl.SelectedIndex < ItemsSource.Count - 1); + }); + + SelectionChanged?.Invoke(DataGridControl.SelectedItem); + }; + DataGridControl.GotFocus += (object? sender, FocusChangedEventArgs e) => + { + if (e.Source is Control control) + { + DataGridRow? row = control.FindLogicalAncestorOfType(); + if (row is not null) + row.IsSelected = true; + } + }; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ItemFactoryProperty) + { + AddButton.IsVisible = ItemFactory is not null; + RemoveButton.IsVisible = ItemFactory is not null; + } + } + + public void Add() + { + object item; + if (ItemFactory is Func itemFactory) + { + item = itemFactory(); + } + else if (ItemFactory is Func itemFactoryWithIndex) + { + item = itemFactoryWithIndex(ItemsSource.Count); + } + else + { + throw new InvalidOperationException($"ItemFactory ({ItemFactory}) is not Func or Func"); + } + + ItemsSource.Add(item); + DataGridControl.SelectedIndex = ItemsSource.Count - 1; + } + + public void Remove() + { + if (DataGridControl.SelectedItem is not null) + { + int index = DataGridControl.SelectedIndex; + ItemsSource.RemoveAt(index); + + if (index == ItemsSource.Count) + DataGridControl.SelectedIndex = index - 1; + else + DataGridControl.SelectedIndex = index; + } + } + + public void MoveUp() + { + if (DataGridControl.SelectedItem is not null && DataGridControl.SelectedIndex > 0) + { + int index = DataGridControl.SelectedIndex; + object? item = ItemsSource[index]; + ItemsSource[index] = ItemsSource[index - 1]; + ItemsSource[index - 1] = item; + DataGridControl.SelectedIndex = index - 1; + } + } + + public void MoveDown() + { + if (DataGridControl.SelectedItem is not null && DataGridControl.SelectedIndex < ItemsSource.Count - 1) + { + int index = DataGridControl.SelectedIndex; + object? item = ItemsSource[index]; + ItemsSource[index] = ItemsSource[index + 1]; + ItemsSource[index + 1] = item; + DataGridControl.SelectedIndex = index + 1; + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml b/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml new file mode 100644 index 000000000..372b0b89f --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml.cs b/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml.cs new file mode 100644 index 000000000..a9343f5d0 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/FlagsBoxView.axaml.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Interactivity; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace UndertaleModToolAvalonia; + +public partial class FlagsBoxView : UserControl +{ + public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register( + nameof(Value), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + public Enum Value + { + get { return GetValue(ValueProperty); } + set { SetValue(ValueProperty, value); } + } + + public partial class Flag : ObservableObject + { + public Enum FlagEnum; + public string Name { get; set; } + + [ObservableProperty] + public partial bool Checked { get; set; } + + public Flag(Enum flagEnum, string name, bool _checked) + { + FlagEnum = flagEnum; + Name = name; + Checked = _checked; + } + } + + public ObservableCollection Flags { get; set; } = new(); + + public FlagsBoxView() + { + InitializeComponent(); + + if (this.Resources["FlagEnumToStringConverter"] is FlagEnumToStringConverter converter) + { + converter.View = this; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ValueProperty) + { + // Update checkboxes to fit with value. + if (change.NewValue is Enum enumValue) + { + foreach (Enum flagEnum in Enum.GetValues(enumValue.GetType())) + { + Flag? f = Flags.FirstOrDefault(x => (dynamic)(x!.FlagEnum) == (dynamic)flagEnum, null); + if (f is not null) + { + f.Checked = enumValue.HasFlag(flagEnum); + } + else + { + Flags.Add(new Flag( + flagEnum: flagEnum, + name: flagEnum.ToString(), + _checked: enumValue.HasFlag(flagEnum))); + } + } + } + } + } + + public void Checked_IsCheckChanged(object? sender, RoutedEventArgs e) + { + CheckBox checkBox = (sender as CheckBox)!; + if (checkBox.DataContext is Flag flag) + { + if (checkBox.IsChecked == true) + { + Value = (dynamic)Value | (dynamic)flag.FlagEnum; + } + else + { + Value = (dynamic)Value & ~((dynamic)flag.FlagEnum); + } + } + } +} + +public class FlagEnumToStringConverter : IValueConverter +{ + public FlagsBoxView? View { get; set; } + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is Enum valueEnum) + return valueEnum.ToString(); + return BindingOperations.DoNothing; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + DataValidationErrors.SetError(View!.ValueTextBox, null); + if (value is string valueString) + { + if (Enum.TryParse(View!.Value.GetType(), valueString, out object? result)) + { + return result; + } + else + { + // Can't do this for some unknowable reason. + // return new BindingNotification(new InvalidCastException(), BindingErrorType.Error); + DataValidationErrors.SetError(View!.ValueTextBox, new InvalidCastException()); + return BindingOperations.DoNothing; + + } + } + return BindingOperations.DoNothing; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/SKImageViewer.cs b/UndertaleModToolAvalonia/Controls/SKImageViewer.cs new file mode 100644 index 000000000..378d1f6c0 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/SKImageViewer.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Microsoft.Extensions.DependencyInjection; +using SkiaSharp; +using UndertaleModLib.Models; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public class SKImageViewer : Control +{ + public static readonly StyledProperty ImageProperty = + AvaloniaProperty.Register(nameof(Image)); + + public object? Image + { + get => GetValue(ImageProperty); + set => SetValue(ImageProperty, value); + } + + public static readonly StyledProperty> BindingsProperty = + AvaloniaProperty.Register>(nameof(Bindings)); + + public IList Bindings + { + get => GetValue(BindingsProperty); + set => SetValue(BindingsProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ImageProperty) + { + if (Image is UndertaleTexturePageItem texturePageItem) + { + // Bind these values to a property so we can get updates when they change. + // Yeah, I know this sucks. + IList bindings = + [ + CompiledBinding.Create(source: texturePageItem, expression: x => x.TexturePage.TextureData.Image), + CompiledBinding.Create(source: texturePageItem, expression: x => x.SourceX), + CompiledBinding.Create(source: texturePageItem, expression: x => x.SourceY), + CompiledBinding.Create(source: texturePageItem, expression: x => x.SourceWidth), + CompiledBinding.Create(source: texturePageItem, expression: x => x.SourceHeight), + CompiledBinding.Create(source: texturePageItem, expression: x => x.TargetX), + CompiledBinding.Create(source: texturePageItem, expression: x => x.TargetY), + CompiledBinding.Create(source: texturePageItem, expression: x => x.TargetWidth), + CompiledBinding.Create(source: texturePageItem, expression: x => x.TargetHeight), + CompiledBinding.Create(source: texturePageItem, expression: x => x.BoundingWidth), + CompiledBinding.Create(source: texturePageItem, expression: x => x.BoundingHeight), + ]; + + MultiBinding multiBinding = new() + { + Bindings = bindings, + Converter = new FuncMultiValueConverter>(x => new List(x)) + }; + + Bind(BindingsProperty, multiBinding); + } + else + { + ClearValue(BindingsProperty); + Invalidate(); + } + } + else if (change.Property == BindingsProperty) + { + // Only invalidate if not in transitional null state. If new value is actually null, it'll invalidate above in the type check. + if (change.NewValue is not null && Image is not null) + Invalidate(); + } + } + + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + SKImageDrawOperation? skImageDrawOperation; + double scaling = 1; + + public SKImageViewer() + { + ClipToBounds = true; + } + + void Invalidate() + { + SKImageDrawOperation? GetSKImageDrawOperation() + { + if (Image is UndertaleTexturePageItem texturePageItem) + { + if (texturePageItem.TexturePage is not null) + { + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texturePageItem); + + if (image is not null) + { + return new SKImageDrawOperation(image, + SKRect.Create(texturePageItem.TargetX, texturePageItem.TargetY, texturePageItem.TargetWidth, texturePageItem.TargetHeight)); + } + } + } + else if (Image is GMImage gmImage) + { + SKImage image = mainVM.ImageCache.GetCachedImageFromGMImage(gmImage); + return new SKImageDrawOperation(image); + } + else if (Image is UndertaleSprite.MaskEntry maskEntry) + { + int maskSize = maskEntry.Width * maskEntry.Height; + byte[] pixels = new byte[maskSize]; + + for (int y = 0; y < maskEntry.Height; y++) + { + int rowWidth = (maskEntry.Width + 7) / 8; + int byteRowIndex = y * rowWidth; + + for (int x = 0; x < maskEntry.Width; x++) + { + int i = y * maskEntry.Width + x; + int byteIndex = byteRowIndex + (x / 8); + int bitIndex = x % 8; + + pixels[i] = (maskEntry.Data[byteIndex] & (1 << (7 - bitIndex))) != 0 ? (byte)255 : (byte)0; + } + } + + SKImage image = SKImage.FromPixelCopy(new SKImageInfo(maskEntry.Width, maskEntry.Height, SKColorType.Gray8), pixels); + return new SKImageDrawOperation(image); + } + return null; + } + + Size size = GetSize(); + Width = size.Width; + Height = size.Height; + + skImageDrawOperation = GetSKImageDrawOperation(); + + InvalidateMeasure(); + InvalidateVisual(); + } + + Size GetSize() + { + if (Image is UndertaleTexturePageItem texturePageItem) + return new Size(texturePageItem.BoundingWidth, texturePageItem.BoundingHeight) * scaling; + else if (Image is GMImage gmImage) + return new Size(gmImage.Width, gmImage.Height) * scaling; + else if (Image is UndertaleSprite.MaskEntry maskEntry) + return new Size(maskEntry.Width, maskEntry.Height) * scaling; + + return new Size(0, 0); + } + + protected override Size MeasureOverride(Size availableSize) + { + return GetSize(); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + var pointerPosition = e.GetPosition(this); + + if (e.Delta.Y > 0) + { + scaling *= 2; + } + else if (e.Delta.Y < 0) + { + scaling /= 2; + } + + Invalidate(); + e.Handled = true; + } + } + + public override void Render(DrawingContext context) + { + Size size = GetSize(); + + using (context.PushTransform(Matrix.CreateScale(scaling, scaling))) + { + var gridSize = 8; + var brush1 = new SolidColorBrush(new Color(255, 102, 102, 102)); + var brush2 = new SolidColorBrush(new Color(255, 153, 153, 153)); + + context.DrawRectangle(brush1, null, new Rect(0, 0, size.Width / scaling, size.Height / scaling)); + + for (int x = 0; x < Bounds.Width / scaling / gridSize; x++) + for (int y = 0; y < Bounds.Height / scaling / gridSize; y++) + { + if ((x + y) % 2 != 0) + context.DrawRectangle(brush2, null, new Rect(x * gridSize, y * gridSize, gridSize, gridSize)); + } + + if (skImageDrawOperation is not null) + context.Custom(skImageDrawOperation); + } + } + + class SKImageDrawOperation : ICustomDrawOperation + { + bool enableRender = true; + SKImage image; + SKRect? dest; + + public Rect Bounds { get; } + + public SKImageDrawOperation(SKImage image, SKRect? dest = null) + { + this.image = image; + this.dest = dest; + + Bounds = dest?.ToAvaloniaRect() ?? new Rect(0, 0, image.Width, image.Height); + } + + public void Render(ImmediateDrawingContext context) + { + if (!enableRender) + return; + + try + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) + return; + + using var lease = leaseFeature.Lease(); + SKCanvas canvas = lease.SkCanvas; + + if (dest is SKRect destSKRect) + { + canvas.DrawImage(image, destSKRect, SKSamplingOptions.Default); + } + else + { + canvas.DrawImage(image, 0, 0, SKSamplingOptions.Default); + } + } + catch (Exception ex) + { + enableRender = false; + Program.HandleException(ex); + } + } + + public bool Equals(ICustomDrawOperation? other) => false; + public bool HitTest(Point p) => Bounds.Contains(p); + public void Dispose() { } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/TabItemViewModel.cs b/UndertaleModToolAvalonia/Controls/TabItemViewModel.cs new file mode 100644 index 000000000..dc3a66d69 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/TabItemViewModel.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace UndertaleModToolAvalonia; + +public interface ITabContent +{ + /// + /// Runs after the tab content is attached to a tab, i.e. when it becomes a tab's content. + /// + void OnAttached() { } + + /// + /// Runs before the tab content is detached from a tab, i.e. when it stops being a tab's content. + /// + void OnDetached() { } + + /// + /// Runs before , when the data file is not closing. Use it to save the tab's temporary contents to the data file. + /// Return true if saving was successful; if false is returned, detaching will not continue. + /// + async Task OnSave() => true; +} + +public partial class TabItemViewModel : ObservableObject +{ + [ObservableProperty] + public partial ITabContent Content { get; set; } = null!; + + [ObservableProperty] + public partial bool IsSelected { get; set; } = false; + + [ObservableProperty] + public partial bool CanGoBack { get; set; } = false; + + [ObservableProperty] + public partial bool CanGoForward { get; set; } = false; + + private readonly List history = []; + private int historyPosition = -1; + + public TabItemViewModel(ITabContent content, bool isSelected = false) + { + Content = content; + IsSelected = isSelected; + + history.Add(Content); + historyPosition = 0; + } + + public void OnOpen() + { + Content.OnAttached(); + } + + public void OnClose() + { + Content.OnDetached(); + } + + public async Task Save() + { + return await Content.OnSave(); + } + + public async Task GoTo(ITabContent content) + { + if (content == Content) + return true; + + if (!await Content.OnSave()) + return false; + + Content.OnDetached(); + + Content = content; + + history.RemoveRange(historyPosition + 1, history.Count - (historyPosition + 1)); + + history.Add(content); + historyPosition++; + + CanGoBack = true; + CanGoForward = false; + + Content.OnAttached(); + + return true; + } + + public async Task GoBack() + { + if (!await Content.OnSave()) + return false; + + Content.OnDetached(); + + historyPosition--; + Content = history[historyPosition]; + + CanGoBack = (historyPosition != 0); + CanGoForward = true; + + Content.OnAttached(); + + return true; + } + + public async Task GoForward() + { + if (!await Content.OnSave()) + return false; + + Content.OnDetached(); + + historyPosition++; + Content = history[historyPosition]; + + CanGoBack = true; + CanGoForward = (historyPosition != history.Count - 1); + + Content.OnAttached(); + + return true; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/TabsView.axaml b/UndertaleModToolAvalonia/Controls/TabsView.axaml new file mode 100644 index 000000000..29891e879 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/TabsView.axaml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Controls/TabsView.axaml.cs b/UndertaleModToolAvalonia/Controls/TabsView.axaml.cs new file mode 100644 index 000000000..b1c3b886f --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/TabsView.axaml.cs @@ -0,0 +1,90 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; + +namespace UndertaleModToolAvalonia; + +public partial class TabsView : UserControl +{ + public TabsView() + { + InitializeComponent(); + } + + void TabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (DataContext is TabsViewModel vm) + { + object? tabSelected = e.AddedItems.Count > 0 ? e.AddedItems[0] : null; + foreach (TabItemViewModel tab in vm.Tabs) + { + tab.IsSelected = (tab == tabSelected); + } + } + } + + void TabControl_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle) + { + if (DataContext is TabsViewModel vm) + { + if (e.Source is Control control) + { + TabStrip? tabControl = control.FindLogicalAncestorOfType(); + if (tabControl is not null && tabControl == sender) + { + TabStripItem? tabItem = control.FindLogicalAncestorOfType(); + if (tabItem is not null && tabItem.DataContext is TabItemViewModel vmTabItem) + { + _ = vm.TabClose(vmTabItem); + } + } + } + } + } + } + + void TabMenu_Select_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not TabsViewModel vm) + return; + + if (e.Source is Control control) + { + TabStripItem? tabItem = control.FindLogicalAncestorOfType(); + if (tabItem is not null && tabItem.DataContext is TabItemViewModel vmTabItem) + { + if (vmTabItem?.Content is IUndertaleResourceViewModel vmResourceView) + { + vm.MainVM.DataExplorer.OnSelectValueInTree?.Invoke(vmResourceView.Resource); + } + } + } + } + + void TabMenu_Close_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is TabsViewModel vm) + { + if (e.Source is Control control) + { + TabStripItem? tabItem = control.FindLogicalAncestorOfType(); + if (tabItem is not null && tabItem.DataContext is TabItemViewModel vmTabItem) + { + _ = vm.TabClose(vmTabItem); + } + } + } + } + + void TabMenu_CloseAll_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is TabsViewModel vm) + { + _ = vm.TabCloseAll(); + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/TabsViewModel.cs b/UndertaleModToolAvalonia/Controls/TabsViewModel.cs new file mode 100644 index 000000000..a2fd2d6ad --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/TabsViewModel.cs @@ -0,0 +1,202 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class TabsViewModel : ObservableObject +{ + public MainViewModel MainVM; + + public ObservableCollection Tabs { get; set; } = []; + + [ObservableProperty] + public partial TabItemViewModel? TabSelected { get; set; } + + [ObservableProperty] + public partial int TabSelectedIndex { get; set; } + + public TabsViewModel(MainViewModel mainVM) + { + MainVM = mainVM; + } + + public async Task TabOpen(object? item, bool inNewTab = false) + { + ITabContent? content = item switch + { + DescriptionViewModel vm => vm, + _ => null, + }; + + if (content is null) + { + if (MainVM.Data is null) + return null; + + content = item switch + { + "GeneralInfo" => new GeneralInfoViewModel(MainVM.Data), + "GlobalInitScripts" => new GlobalInitScriptsViewModel(MainVM.Data.FORM.GLOB.List), + "GameEndScripts" => new GameEndScriptsViewModel(MainVM.Data.FORM.GMEN.List), + UndertaleAudioGroup r => new UndertaleAudioGroupViewModel(r), + UndertaleSound r => new UndertaleSoundViewModel(r, MainVM.ServiceProvider), + UndertaleSprite r => new UndertaleSpriteViewModel(r, MainVM.ServiceProvider), + UndertaleBackground r => new UndertaleBackgroundViewModel(r), + UndertalePath r => new UndertalePathViewModel(r), + UndertaleScript r => new UndertaleScriptViewModel(r), + UndertaleShader r => new UndertaleShaderViewModel(r, MainVM.ServiceProvider), + UndertaleFont r => new UndertaleFontViewModel(r), + UndertaleTimeline r => new UndertaleTimelineViewModel(r), + UndertaleGameObject r => new UndertaleGameObjectViewModel(r, MainVM.ServiceProvider), + UndertaleRoom r => new UndertaleRoomViewModel(r, MainVM.ServiceProvider), + "Extensions" => new UndertaleExtensionChunkViewModel(MainVM.Data.FORM.EXTN), + UndertaleExtension r => new UndertaleExtensionViewModel(r, MainVM.ServiceProvider), + UndertaleTexturePageItem r => new UndertaleTexturePageItemViewModel(r, MainVM.ServiceProvider), + UndertaleCode r => new UndertaleCodeViewModel(r, MainVM.ServiceProvider), + UndertaleVariable r => new UndertaleVariableViewModel(r), + UndertaleFunction r => new UndertaleFunctionViewModel(r), + UndertaleCodeLocals r => new UndertaleCodeLocalsViewModel(r), + UndertaleString r => new UndertaleStringViewModel(r), + UndertaleEmbeddedTexture r => new UndertaleEmbeddedTextureViewModel(r, MainVM.ServiceProvider), + UndertaleEmbeddedAudio r => new UndertaleEmbeddedAudioViewModel(r, MainVM.ServiceProvider), + UndertaleTextureGroupInfo r => new UndertaleTextureGroupInfoViewModel(r), + UndertaleEmbeddedImage r => new UndertaleEmbeddedImageViewModel(r), + UndertaleAnimationCurve r => new UndertaleAnimationCurveViewModel(r), + UndertaleParticleSystem r => new UndertaleParticleSystemViewModel(r), + UndertaleParticleSystemEmitter r => new UndertaleParticleSystemEmitterViewModel(r), + _ => null, + }; + } + + if (content is not null) + { + if (!inNewTab && TabSelected is not null) + { + if (!await TabGoTo(content)) + return null; + return TabSelected; + } + else + { + TabItemViewModel tab = new(content); + Tabs.Add(tab); + TabSelected = tab; + tab.OnOpen(); + return tab; + } + } + + return null; + } + + [RelayCommand] + public async Task TabClose(TabItemViewModel tab) + { + if (!await tab.Save()) + return; + + tab.OnClose(); + + TabItemViewModel? selected = TabSelected; + int index = TabSelectedIndex; + + Tabs.Remove(tab); + + if (TabSelected != selected) + { + if (index >= Tabs.Count) + index = Tabs.Count - 1; + + TabSelectedIndex = index; + } + } + + public async Task TabCloseAll() + { + foreach (TabItemViewModel tab in Tabs.ToList()) + { + await TabClose(tab); + } + } + + public async void TabCloseSelected() + { + if (TabSelected is not null) + _ = TabClose(TabSelected); + } + + public void TabCloseAllWithoutSaving() + { + foreach (TabItemViewModel tab in Tabs.ToList()) + { + tab.OnClose(); + } + Tabs.Clear(); + } + + public async Task TabSaveAll() + { + bool savedAll = true; + + foreach (TabItemViewModel tab in Tabs) + { + if (!await tab.Save()) + savedAll = false; + } + + return savedAll; + } + + public async Task TabGoTo(ITabContent content) + { + if (TabSelected is not null) + if (!await TabSelected.GoTo(content)) + return false; + + MainVM.UpdateSelectedTabProperties(); + return true; + } + + public void TabSetToPrevious() + { + if (TabSelectedIndex > 0) + TabSelectedIndex--; + else + TabSelectedIndex = Tabs.Count - 1; + } + + public void TabSetToNext() + { + if (TabSelectedIndex < Tabs.Count - 1) + TabSelectedIndex++; + else + TabSelectedIndex = 0; + } + + public async void TabGoBack() + { + if (TabSelected is not null) + if (!await TabSelected.GoBack()) + return; + + MainVM.UpdateSelectedTabProperties(); + } + + public async void TabGoForward() + { + if (TabSelected is not null) + if (!await TabSelected.GoForward()) + return; + + MainVM.UpdateSelectedTabProperties(); + } + + partial void OnTabSelectedChanged(TabItemViewModel? value) + { + MainVM.UpdateSelectedTabProperties(); + } +} diff --git a/UndertaleModToolAvalonia/Controls/TilePicker.cs b/UndertaleModToolAvalonia/Controls/TilePicker.cs new file mode 100644 index 000000000..ce3ee74a7 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/TilePicker.cs @@ -0,0 +1,500 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Microsoft.Extensions.DependencyInjection; +using SkiaSharp; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class LegacyTilePicker : RoomOrLegacyTilePicker +{ + public static readonly StyledProperty SelectedTileSpriteProperty = + AvaloniaProperty.Register(nameof(SelectedTileSprite), + defaultBindingMode: BindingMode.OneWay); + + public UndertaleSprite? SelectedTileSprite + { + get => GetValue(SelectedTileSpriteProperty); + set => SetValue(SelectedTileSpriteProperty, value); + } + + public override UndertaleTexturePageItem? GetTexturePageItem() + { + return SelectedTileSprite?.Textures.FirstOrDefault()?.Texture; + } +} + +public class RoomTilePicker : RoomOrLegacyTilePicker +{ + public static readonly StyledProperty SelectedTileBackgroundProperty = + AvaloniaProperty.Register(nameof(SelectedTileBackground), + defaultBindingMode: BindingMode.OneWay); + + public UndertaleBackground? SelectedTileBackground + { + get => GetValue(SelectedTileBackgroundProperty); + set => SetValue(SelectedTileBackgroundProperty, value); + } + + public override UndertaleTexturePageItem? GetTexturePageItem() + { + return SelectedTileBackground?.Texture; + } +} + +public abstract class RoomOrLegacyTilePicker : TilePicker +{ + public static readonly StyledProperty SelectedTileSourceRectProperty = + AvaloniaProperty.Register(nameof(SelectedTileSourceRect), + defaultBindingMode: BindingMode.TwoWay); + + public Rect? SelectedTileSourceRect + { + get => GetValue(SelectedTileSourceRectProperty); + set => SetValue(SelectedTileSourceRectProperty, value); + } + + public static readonly StyledProperty TileWidthProperty = + AvaloniaProperty.Register(nameof(TileWidth), + defaultBindingMode: BindingMode.OneWay); + + public uint TileWidth + { + get => GetValue(TileWidthProperty); + set => SetValue(TileWidthProperty, value); + } + + public static readonly StyledProperty TileHeightProperty = + AvaloniaProperty.Register(nameof(TileHeight), + defaultBindingMode: BindingMode.OneWay); + + public uint TileHeight + { + get => GetValue(TileHeightProperty); + set => SetValue(TileHeightProperty, value); + } + + public override void Render(DrawingContext context) + { + if (GetTexturePageItem() is UndertaleTexturePageItem texturePageItem) + { + context.Custom(new CustomDrawOperation() + { + Bounds = new Rect(0, 0, Bounds.Width, Bounds.Height), + Translation = translation, + Scaling = scaling, + SelectedColor = selectedColor, + TexturePageItem = texturePageItem, + SelectedTileSourceRect = SelectedTileSourceRect, + }); + } + + base.Render(context); + } + + public abstract UndertaleTexturePageItem? GetTexturePageItem(); + + public override void SelectTileAt(Point point) + { + UndertaleTexturePageItem? texturePageItem = GetTexturePageItem(); + if (texturePageItem is null) + return; + + point -= translation; + point /= scaling; + + double x = Math.Floor(point.X / TileWidth) * TileWidth; + double y = Math.Floor(point.Y / TileHeight) * TileHeight; + + if (x < 0 || y < 0 || x + TileWidth > texturePageItem.BoundingWidth || y + TileHeight > texturePageItem.BoundingHeight) + return; + + SelectedTileSourceRect = new(x, y, TileWidth, TileHeight); + } + + public override void SelectTileTo(Point point) + { + UndertaleTexturePageItem? texturePageItem = GetTexturePageItem(); + if (texturePageItem is null) + return; + + point -= translation; + point /= scaling; + + double x = Math.Floor(point.X / TileWidth) * TileWidth; + double y = Math.Floor(point.Y / TileHeight) * TileHeight; + + if (x < 0 || y < 0 || x + TileWidth > texturePageItem.BoundingWidth || y + TileHeight > texturePageItem.BoundingHeight) + return; + + if (SelectedTileSourceRect is Rect rect) + { + double rectLeft = (x < rect.X) ? x : rect.Left; + double rectTop = (y < rect.Y) ? y : rect.Top; + double rectRight = (x >= rect.Right) ? (x + TileWidth) : rect.Right; + double rectBottom = (y >= rect.Bottom) ? (y + TileHeight) : rect.Bottom; + + SelectedTileSourceRect = new(rectLeft, rectTop, rectRight - rectLeft, rectBottom - rectTop); + } + else + { + SelectedTileSourceRect = new(x, y, TileWidth, TileHeight); + } + } + + public new class CustomDrawOperation : TilePicker.CustomDrawOperation + { + public required Rect? SelectedTileSourceRect; + + public override void DrawTiles(SKCanvas canvas, SKImage image) + { + if (TexturePageItem is null) + return; + + canvas.DrawImage(image, SKRect.Create(TexturePageItem.TargetX, TexturePageItem.TargetY, TexturePageItem.TargetWidth, TexturePageItem.TargetHeight), SKSamplingOptions.Default); + + selectedTileRect = SelectedTileSourceRect?.ToSKRect(); + } + } +} + +public class LayerTilePicker : TilePicker +{ + public static readonly StyledProperty SelectedTileDataProperty = + AvaloniaProperty.Register(nameof(SelectedTileData), + defaultBindingMode: BindingMode.TwoWay); + + public uint SelectedTileData + { + get => GetValue(SelectedTileDataProperty); + set => SetValue(SelectedTileDataProperty, value); + } + + public static readonly StyledProperty TileSetColumnsProperty = + AvaloniaProperty.Register(nameof(TileSetColumns), + defaultBindingMode: BindingMode.TwoWay, defaultValue: 0); + + public uint TileSetColumns + { + get => GetValue(TileSetColumnsProperty); + set => SetValue(TileSetColumnsProperty, value); + } + + public override void Render(DrawingContext context) + { + if (DataContext is UndertaleRoom.Layer.LayerTilesData layerTilesData + && layerTilesData.Background is UndertaleBackground background + && background.Texture is UndertaleTexturePageItem texturePageItem) + { + context.Custom(new CustomDrawOperation() + { + Bounds = new Rect(0, 0, Bounds.Width, Bounds.Height), + Translation = translation, + Scaling = scaling, + SelectedColor = selectedColor, + TexturePageItem = texturePageItem, + Background = background, + SelectedTileData = SelectedTileData, + VisualColumns = TileSetColumns, + }); + } + + base.Render(context); + } + + public override void SelectTileAt(Point point) + { + if (DataContext is UndertaleRoom.Layer.LayerTilesData layerTilesData) + { + if (layerTilesData?.Background?.Texture is not null) + { + UndertaleBackground background = layerTilesData.Background; + + point -= translation; + point /= scaling; + + uint x = (uint)(point.X / background.GMS2TileWidth); + uint y = (uint)(point.Y / background.GMS2TileHeight); + + uint visualColumns = TileSetColumns != 0 ? TileSetColumns : background.GMS2TileColumns; + + uint id = x + (y * visualColumns); + + if (x >= visualColumns) + return; + if (id >= background.GMS2TileCount) + return; + + SelectedTileData = id; + } + } + } + + public override void SelectTileTo(Point point) => SelectTileAt(point); // TODO: Multiple tile select + + public new class CustomDrawOperation : TilePicker.CustomDrawOperation + { + public required uint SelectedTileData; + public required uint VisualColumns = 0; + public required UndertaleBackground Background; + + public override void DrawTiles(SKCanvas canvas, SKImage image) + { + uint tileW = Background.GMS2TileWidth; + uint tileH = Background.GMS2TileHeight; + uint borderX = Background.GMS2OutputBorderX; + uint borderY = Background.GMS2OutputBorderY; + uint tileColumns = Background.GMS2TileColumns; + uint tileCount = Background.GMS2TileCount; + + ushort targetX = TexturePageItem.TargetX; + ushort targetY = TexturePageItem.TargetY; + ushort sourceX = TexturePageItem.SourceX; + ushort sourceY = TexturePageItem.SourceY; + + if (VisualColumns == 0) + VisualColumns = tileColumns; + + var sx = -targetX + borderX; + var sy = -targetY + borderY; + + uint dx = 0; + uint dy = 0; + + var tileColumn = 0; + var destColumn = 0; + + for (uint i = 0; i < tileCount; i++) + { + canvas.DrawImage(image, SKRect.Create(sx, sy, tileW, tileH), SKRect.Create(dx, dy, tileW, tileH), SKSamplingOptions.Default); + + tileColumn++; + if (tileColumn < tileColumns) + { + sx += tileW + borderX * 2; + } + else + { + sx = -targetX + borderX; + sy += tileH + borderY * 2; + tileColumn = 0; + } + + destColumn++; + if (destColumn < VisualColumns) + { + dx += tileW; + } + else + { + dx = 0; + dy += tileH; + destColumn = 0; + } + } + + uint selectedTileId = SelectedTileData & UndertaleRoomViewModel.TILE_ID; + float selectedTileX = (selectedTileId % VisualColumns) * tileW; + float selectedTileY = (selectedTileId / VisualColumns) * tileH; + + if (selectedTileId < tileCount) + { + selectedTileRect = SKRect.Create(selectedTileX, selectedTileY, tileW, tileH); + } + } + } +} + +public abstract class TilePicker : Control +{ + protected Vector translation; + protected double scaling = 1; + + protected SKColor selectedColor; + + Point translationMoveOffset; + + public TilePicker() + { + ClipToBounds = true; + } + + protected override void OnInitialized() + { + selectedColor = this.GetSolidColorBrushResource("SystemControlHighlightAccentBrush").Color.ToSKColor().WithAlpha(128); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + var pointerPoint = e.GetCurrentPoint(this); + if (pointerPoint.Properties.IsLeftButtonPressed) + { + SelectTileAt(pointerPoint.Position); + } + else if (pointerPoint.Properties.IsMiddleButtonPressed) + { + TranslationMoveOnPressed(pointerPoint.Position); + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + // + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + var pointerPoint = e.GetCurrentPoint(this); + if (pointerPoint.Properties.IsLeftButtonPressed) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Alt)) + { + SelectTileTo(pointerPoint.Position); + } + else + { + SelectTileAt(pointerPoint.Position); + } + } + else if (pointerPoint.Properties.IsMiddleButtonPressed) + { + TranslationMoveOnMoved(pointerPoint.Position); + } + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + var pointerPosition = e.GetPosition(this); + + if (e.Delta.Y > 0) + { + translation *= 2; + translation -= pointerPosition; + scaling *= 2; + } + else if (e.Delta.Y < 0) + { + scaling /= 2; + translation += pointerPosition; + translation /= 2; + } + + translation = new(Math.Round(translation.X), Math.Round(translation.Y)); + e.Handled = true; + } + } + + public override void Render(DrawingContext context) + { + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + topLevel.RequestAnimationFrame(_ => + { + InvalidateVisual(); + }); + } + + public abstract void SelectTileAt(Point point); + public abstract void SelectTileTo(Point point); + + void TranslationMoveOnPressed(Point point) + { + translationMoveOffset = point - translation; + } + + void TranslationMoveOnMoved(Point point) + { + translation = point - translationMoveOffset; + InvalidateVisual(); + } + + public abstract class CustomDrawOperation : ICustomDrawOperation + { + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public required Vector Translation; + public required double Scaling; + public required SKColor SelectedColor; + public required UndertaleTexturePageItem TexturePageItem; + + protected SKRect? selectedTileRect = null; + + public Rect Bounds { get; set; } + + public void Render(ImmediateDrawingContext context) + { + try + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) + return; + + using var lease = leaseFeature.Lease(); + SKCanvas canvas = lease.SkCanvas; + + // Checkered background + + int gridSize = 8; + SKPaint gridColor1 = new() { Color = new SKColor(102, 102, 102) }; + SKPaint gridColor2 = new() { Color = new SKColor(153, 153, 153) }; + + canvas.DrawRect(SKRect.Create(0, 0, (float)Bounds.Width, (float)Bounds.Height), gridColor1); + + for (int x = 0; x < Bounds.Width / gridSize; x++) + for (int y = 0; y < Bounds.Height / gridSize; y++) + { + if ((x + y) % 2 != 0) + canvas.DrawRect(SKRect.Create(x * gridSize, y * gridSize, gridSize, gridSize), gridColor2); + } + + // Tiles + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(TexturePageItem); + + if (image is null) + return; + + selectedTileRect = null; + + canvas.Save(); + canvas.Translate(Translation.ToSKPoint()); + canvas.Scale((float)Scaling); + + DrawTiles(canvas, image); + + if (selectedTileRect is SKRect rect) + { + float s = 1 / (float)Scaling; + rect.Right -= s; + rect.Bottom -= s; + + rect.Inflate(s, s); + canvas.DrawRect(rect, new SKPaint() { Style = SKPaintStyle.Stroke, Color = SelectedColor }); + + rect.Inflate(s, s); + canvas.DrawRect(rect, new SKPaint() { Style = SKPaintStyle.Stroke, Color = SelectedColor }); + } + + canvas.Restore(); + } + catch (Exception ex) + { + Program.HandleException(ex); + } + } + + public abstract void DrawTiles(SKCanvas canvas, SKImage image); + + public bool Equals(ICustomDrawOperation? other) => false; + public bool HitTest(Point p) => Bounds.Contains(p); + public void Dispose() { } + } +} diff --git a/UndertaleModToolAvalonia/Controls/UndertalePathEditor.cs b/UndertaleModToolAvalonia/Controls/UndertalePathEditor.cs new file mode 100644 index 000000000..ad4d63235 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertalePathEditor.cs @@ -0,0 +1,78 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class UndertalePathEditor : Control +{ + readonly double padding = 4; + + protected override Size MeasureOverride(Size availableSize) + { + Rect bounds = GetBounds(); + return new Size(bounds.Width, bounds.Height); + } + + public override void Render(DrawingContext context) + { + if (DataContext is not UndertalePathViewModel vm) + return; + if (vm.Path.Points.Count == 0) + return; + + SolidColorBrush axisBrush = this.GetSolidColorBrushResource("SystemControlBackgroundBaseLowBrush"); + SolidColorBrush pathBrush = this.GetSolidColorBrushResource("SystemControlForegroundAccentBrush"); + + Rect bounds = GetBounds(); + + context.PushTransform(Matrix.CreateTranslation(-bounds.Left, -bounds.Top)); + + Pen axisPen = new(axisBrush); + context.DrawLine(axisPen, new(bounds.Left + padding + 0.5, 0 + 0.5), new(bounds.Right - padding + 0.5, 0 + 0.5)); + context.DrawLine(axisPen, new(0 + 0.5, bounds.Top + padding + 0.5), new(0 + 0.5, bounds.Bottom - padding + 0.5)); + + PathGeometry geometry = new(); + PathFigure pathFigure = new() { StartPoint = new(vm.Path.Points[0].X, vm.Path.Points[0].Y), IsClosed = vm.Path.IsClosed, IsFilled = false }; + + // TODO: vm.Path.IsSmooth + foreach (UndertalePath.PathPoint point in vm.Path.Points) + { + pathFigure.Segments?.Add(new LineSegment() { Point = new(point.X, point.Y) }); + } + + geometry.Figures?.Add(pathFigure); + + context.DrawGeometry(null, new Pen(pathBrush, thickness: 2), geometry); + + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + topLevel.RequestAnimationFrame(_ => + { + InvalidateMeasure(); + InvalidateVisual(); + }); + } + + Rect GetBounds() + { + if (DataContext is not UndertalePathViewModel vm) + return new(0, 0, 0, 0); + + float left = 0, top = 0, right = 0, bottom = 0; + + foreach (UndertalePath.PathPoint point in vm.Path.Points) + { + if (point.X < left) + left = point.X; + if (point.Y < top) + top = point.Y; + if (point.X > right) + right = point.X; + if (point.Y > bottom) + bottom = point.Y; + } + + return new Rect(left - padding, top - padding, -left + right + padding * 2, -top + bottom + padding * 2); + } +} diff --git a/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml new file mode 100644 index 000000000..36b386d79 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml.cs b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml.cs new file mode 100644 index 000000000..a360d5ed8 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.DragAndDrop; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +using AddFuncType = Func>; + +public partial class UndertaleResourceReferenceView : UserControl +{ + public static readonly StyledProperty ReferenceProperty = AvaloniaProperty.Register( + nameof(Reference), defaultBindingMode: BindingMode.TwoWay); + public UndertaleResource? Reference + { + get { return GetValue(ReferenceProperty); } + set { SetValue(ReferenceProperty, value); } + } + + public static readonly StyledProperty ReferenceTypeProperty = AvaloniaProperty.Register( + nameof(ReferenceType)); + public Type ReferenceType + { + get { return GetValue(ReferenceTypeProperty); } + set { SetValue(ReferenceTypeProperty, value); } + } + + public static readonly StyledProperty AddFuncProperty = AvaloniaProperty.Register( + nameof(AddFunc)); + public AddFuncType? AddFunc + { + get { return GetValue(AddFuncProperty); } + set { SetValue(AddFuncProperty, value); } + } + + public static readonly StyledProperty AddFuncArgumentProperty = AvaloniaProperty.Register( + nameof(AddFuncArgument)); + public object? AddFuncArgument + { + get { return GetValue(AddFuncArgumentProperty); } + set { SetValue(AddFuncArgumentProperty, value); } + } + + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public UndertaleResourceReferenceView() + { + InitializeComponent(); + + ReferenceTextBox.AddHandler(TextBox.KeyDownEvent, TextBox_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ReferenceTypeProperty) + { + string name = ReferenceType.Name; + if (name[.."Undertale".Length] == "Undertale") + { + name = name["Undertale".Length..]; + } + ReferenceTextBox.PlaceholderText = "(" + name + " reference)"; + } + } + + private void TextBox_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + e.Handled = true; + UpdateReferenceToText(); + } + } + + private void TextBox_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + { + OpenInNewTab(); + } + } + + private void TextBox_DoubleTapped(object? sender, TappedEventArgs e) + { + Open(); + } + + private void TextBox_LostFocus(object? sender, RoutedEventArgs e) + { + UpdateReferenceToText(); + } + + void UpdateReferenceToText() + { + if (mainVM.Data is not null) + { + string? text = ReferenceTextBox.Text; + + UndertaleResource? ParseResourceText() + { + IList list; + + try + { + list = mainVM.Data[ReferenceType]; + } + catch (Exception e) when (e is NotSupportedException or MissingMemberException) + { + return null; + } + + if (int.TryParse(text, out int id) && id < list.Count) + { + return list[id] as UndertaleResource; + } + + return list + .OfType() + .FirstOrDefault(x => x.Name?.Content?.Equals(text, StringComparison.OrdinalIgnoreCase) ?? false); + } + + if (string.IsNullOrEmpty(text)) + { + Reference = null; + } + else if (ParseResourceText() is UndertaleResource reference) + { + Reference = reference; + } + + // Update text box to reflect current reference value + BindingOperations.GetBindingExpressionBase(ReferenceTextBox, TextBox.TextProperty)?.UpdateTarget(); + } + } + + public async void Add() + { + if (AddFunc is not null) + { + UndertaleResource? reference = await AddFunc(AddFuncArgument); + if (reference is not null) + Reference = reference; + } + } + + public void Open() + { + _ = mainVM.TabOpen(Reference); + } + + public void OpenInNewTab() + { + _ = mainVM.TabOpen(Reference, inNewTab: true); + } + + public void Remove() + { + Reference = null; + } +} + +public class UndertaleReferenceDropHandler : DropHandlerBase +{ + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is UndertaleResourceReferenceView vm) + { + if (sourceContext is DataExplorerViewModel.Item item && item.Value is UndertaleResource resource && vm.ReferenceType.IsInstanceOfType(resource)) + { + return true; + } + } + return false; + } + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is UndertaleResourceReferenceView vm) + { + if (sourceContext is DataExplorerViewModel.Item item && item.Value is UndertaleResource resource && vm.ReferenceType.IsInstanceOfType(resource)) + { + vm.Reference = resource; + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/UndertaleRoomEditor.cs b/UndertaleModToolAvalonia/Controls/UndertaleRoomEditor.cs new file mode 100644 index 000000000..957783663 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleRoomEditor.cs @@ -0,0 +1,1052 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Avalonia.Xaml.Interactions.DragAndDrop; +using Avalonia.Xaml.Interactivity; +using SkiaSharp; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class UndertaleRoomEditor : Control +{ + public record RoomItem( + object Object, + UndertaleRoom.Layer? Layer = null, + RoomItemSelectable? Selectable = null + ); + public record RoomItemProperties(int X, int Y); + public record RoomItemSelectable( + object Category, + Rect Bounds, + double Rotation, + Point Pivot, + Func GetProperties, + Action SetProperties + ); + + enum InteractionMode + { + Items, + Tiles, + RoomTiles, + } + + UndertaleRoomViewModel? vm; + + bool enableRender = true; + + readonly RoomRenderer rendererInstance = new(); + + double customDrawOperationTime; + + // Room controls + Vector translation = new(0, 0); + double scaling = 1; + + bool translationMoving = false; + bool translationHasMoved = false; + Point translationMoveOffset = new(0, 0); + + Point pointerPosition; + Point pointerPositionInRoom; + + Point itemMoveOffset = new(0, 0); + + object? hoveredItem; + + uint? hoveredTile = null; + + public UndertaleRoomEditor() + { + ClipToBounds = true; + Focusable = true; + + Interaction.SetBehaviors(this, [new ContextDropBehavior() { Handler = new UndertaleReferenceDropHandler() }]); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + vm = (DataContext as UndertaleRoomViewModel)!; + vm?.Room.SetupRoom(); + + translation = new(0, 0); + scaling = 1; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + PointerPoint pointerPoint = e.GetCurrentPoint(this); + InteractionMode interactionMode = GetInteractionMode(); + + bool shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + var roomItems = Updater.MakeRoomItems(vm!.Room); + + if (pointerPoint.Properties.IsMiddleButtonPressed) + { + TranslationMoveOnPressed(); + } + + if (interactionMode == InteractionMode.Items) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + ItemMoveOnPressed(roomItems); + } + } + else if (interactionMode == InteractionMode.Tiles) + { + UndertaleRoom.Layer? tilesLayer = GetSelectedTilesLayer(); + if (tilesLayer is not null) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + SetLayerTileAtPointer(tilesLayer, vm!.SelectedTileData); + } + else if (pointerPoint.Properties.IsRightButtonPressed) + { + SetLayerTileAtPointer(tilesLayer, 0); + } + } + } + else if (interactionMode == InteractionMode.RoomTiles) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + SetRoomTileAtPointer(roomItems, vm!.SelectedTileResource, vm!.SelectedTileSourceRect, overrideGrid: shift); + } + else if (pointerPoint.Properties.IsRightButtonPressed) + { + RemoveRoomTileAtPointer(roomItems); + } + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + PointerPoint pointerPoint = e.GetCurrentPoint(this); + InteractionMode interactionMode = GetInteractionMode(); + UndertaleRoom.Layer? tilesLayer = GetSelectedTilesLayer(); + + bool shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + var roomItems = Updater.MakeRoomItems(vm!.Room); + + pointerPosition = e.GetPosition(this); + pointerPositionInRoom = (pointerPosition - translation) / scaling; + + TranslationMoveOnMoved(); + + if (interactionMode == InteractionMode.Items) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + ItemMoveOnMoved(roomItems, overrideGrid: shift); + roomItems = Updater.MakeRoomItems(vm!.Room); + } + } + else if (interactionMode == InteractionMode.Tiles) + { + if (tilesLayer is not null) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + SetLayerTileAtPointer(tilesLayer, vm!.SelectedTileData); + } + else if (pointerPoint.Properties.IsRightButtonPressed) + { + SetLayerTileAtPointer(tilesLayer, 0); + } + } + } + else if (interactionMode == InteractionMode.RoomTiles) + { + if (pointerPoint.Properties.IsLeftButtonPressed) + { + // TODO: Add dragging that respects size of tile + SetRoomTileAtPointer(roomItems, vm!.SelectedTileResource, vm!.SelectedTileSourceRect, overrideGrid: shift); + } + else if (pointerPoint.Properties.IsRightButtonPressed) + { + RemoveRoomTileAtPointer(roomItems); + } + } + + hoveredItem = null; + hoveredTile = null; + + if (interactionMode == InteractionMode.Items) + { + ItemHoverOnMoved(roomItems); + } + else if (interactionMode == InteractionMode.Tiles) + { + if (tilesLayer is not null) + { + hoveredTile = GetLayerTileAtPointer(tilesLayer); + } + } + else if (interactionMode == InteractionMode.RoomTiles) + { + hoveredItem = GetRoomTileAtPointer(roomItems); + } + + vm!.StatusText = $"({Math.Floor(pointerPositionInRoom.X)}, {Math.Floor(pointerPositionInRoom.Y)})"; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + InteractionMode interactionMode = GetInteractionMode(); + UndertaleRoom.Layer? tilesLayer = GetSelectedTilesLayer(); + + if (interactionMode == InteractionMode.Tiles) + { + if (tilesLayer is not null) + { + if (e.InitialPressMouseButton == MouseButton.Middle) + { + if (!translationHasMoved) + { + uint? tile = GetLayerTileAtPointer(tilesLayer); + if (tile is not null) + vm!.SelectedTileData = (uint)tile; + } + } + } + } + else if (interactionMode == InteractionMode.RoomTiles) + { + if (e.InitialPressMouseButton == MouseButton.Middle) + { + if (!translationHasMoved) + { + var roomItems = Updater.MakeRoomItems(vm!.Room); + + UndertaleRoom.Tile? tile = GetRoomTileAtPointer(roomItems); + if (tile is not null) + { + vm!.SelectedTileResource = tile.ObjectDefinition; + vm!.SelectedTileSourceRect = new(tile.SourceX, tile.SourceY, tile.Width, tile.Height); + } + } + } + } + + TranslationMoveOnReleased(); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + if (e.Delta.Y > 0) + { + translation *= 2; + translation -= pointerPosition; + scaling *= 2; + } + else if (e.Delta.Y < 0) + { + scaling /= 2; + translation += pointerPosition; + translation /= 2; + } + + translation = new Vector(Math.Round(translation.X), Math.Round(translation.Y)); + + vm!.Zoom = scaling; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.PhysicalKey == PhysicalKey.Space) + { + TranslationMoveOnPressed(); + } + else if (e.PhysicalKey == PhysicalKey.F) + { + var roomItems = Updater.MakeRoomItems(vm!.Room); + FocusOnSelectedItem(roomItems); + } + } + + protected override void OnKeyUp(KeyEventArgs e) + { + if (e.PhysicalKey == PhysicalKey.Space) + { + TranslationMoveOnReleased(); + } + } + + public override void Render(DrawingContext context) + { + if (IsEffectivelyVisible) + { + scaling = vm?.Zoom ?? 1; + + context.Custom(new CustomDrawOperation(this)); + +#if DEBUG + RenderDebugText(context); +#endif + } + + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + topLevel.RequestAnimationFrame(_ => + { + InvalidateVisual(); + }); + } + + InteractionMode GetInteractionMode() + { + if (GetSelectedTilesLayer() is not null) + { + return InteractionMode.Tiles; + } + else if (IsRoomTilesSelected()) + { + return InteractionMode.RoomTiles; + } + else + { + return InteractionMode.Items; + } + } + + UndertaleRoom.Layer? GetSelectedTilesLayer() + { + if (vm!.RoomTreeItemsSelectedItem is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Tiles } tilesLayer) + { + return tilesLayer; + } + return null; + } + + UndertaleRoom.Layer? GetSelectedLegacyTilesLayer() + { + if (vm!.RoomTreeItemsSelectedItem is UndertalePointerList && vm!.CategorySelected is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + return layer; + return null; + } + + bool IsRoomTilesSelected() + { + if (vm!.PropertiesContent is UndertaleRoomViewModel.TilesViewModel) + return true; + return false; + } + + void TranslationMoveOnPressed() + { + Focus(); + translationMoving = true; + translationMoveOffset = pointerPosition - translation; + } + + void TranslationMoveOnMoved() + { + if (translationMoving) + { + translationHasMoved = true; + translation = pointerPosition - translationMoveOffset; + } + } + + void TranslationMoveOnReleased() + { + translationMoving = false; + translationHasMoved = false; + } + + void ItemHoverOnMoved(List roomItems) + { + foreach (RoomItem roomItem in roomItems.Reverse()) + { + if (roomItem.Selectable is null) + continue; + + if (vm!.IsSelectAnyLayerEnabled || vm!.CategorySelected is null || roomItem.Selectable.Category == vm!.CategorySelected) + if (RectContainsPoint(roomItem.Selectable.Bounds, roomItem.Selectable.Rotation, roomItem.Selectable.Pivot, pointerPositionInRoom)) + { + hoveredItem = roomItem.Object; + break; + } + } + } + + void ItemMoveOnPressed(List roomItems) + { + RoomItem? hoveredRoomItem = GetRoomItemOfItem(roomItems, hoveredItem); + + if (hoveredRoomItem is not null && hoveredRoomItem.Selectable is not null) + { + RoomItemProperties properties = hoveredRoomItem.Selectable.GetProperties(); + itemMoveOffset = new(pointerPositionInRoom.X - properties.X, pointerPositionInRoom.Y - properties.Y); + + vm!.RoomTreeItemsSelectedItem = hoveredRoomItem.Object; + } + else + { + if (!vm!.IsSelectAnyLayerEnabled) + vm!.RoomTreeItemsSelectedItem = vm.FindItemFromCategory(vm!.CategorySelected); + else + vm!.RoomTreeItemsSelectedItem = null; + } + } + + void ItemMoveOnMoved(List roomItems, bool overrideGrid) + { + if (vm!.IsLocked) + return; + + RoomItem? roomItem = GetSelectedRoomItem(roomItems); + if (roomItem is not null && roomItem.Selectable is not null) + { + double x = pointerPositionInRoom.X - itemMoveOffset.X; + double y = pointerPositionInRoom.Y - itemMoveOffset.Y; + + if (vm!.IsGridEnabled != overrideGrid) + { + x = (Math.Floor(pointerPositionInRoom.X / vm.GridWidth) * vm.GridWidth) + - (Math.Floor(itemMoveOffset.X / vm.GridWidth) * vm.GridWidth); + y = (Math.Floor(pointerPositionInRoom.Y / vm.GridHeight) * vm.GridHeight) + - (Math.Floor(itemMoveOffset.Y / vm.GridHeight) * vm.GridHeight); + } + + roomItem.Selectable.SetProperties(new((int)x, (int)y)); + } + } + + RoomItem? GetRoomItemOfItem(List roomItems, object? item) + { + if (item is null) + return null; + return roomItems.Find(x => x.Object == item); + } + + RoomItem? GetSelectedRoomItem(List roomItems) + { + RoomItem? res = GetRoomItemOfItem(roomItems, vm?.RoomTreeItemsSelectedItem); + + if (res is not null && res.Selectable is not null) + return res; + return null; + } + + void FocusOnSelectedItem(List roomItems) + { + RoomItem? item = GetSelectedRoomItem(roomItems); + if (item is not null && item.Selectable is not null) + { + translation = new(-item.Selectable.Bounds.X * scaling + (Bounds.Width / 2), -item.Selectable.Bounds.Y * scaling + (Bounds.Height / 2)); + } + } + + bool GetLayerTileIndexesAtPointer(UndertaleRoom.Layer tilesLayer, out (int x, int y) point) + { + point = default; + + if (tilesLayer.TilesData.Background is null) + return false; + + int x = (int)Math.Floor((pointerPositionInRoom.X - tilesLayer.XOffset) / tilesLayer.TilesData.Background.GMS2TileWidth); + int y = (int)Math.Floor((pointerPositionInRoom.Y - tilesLayer.YOffset) / tilesLayer.TilesData.Background.GMS2TileHeight); + + if (y >= 0 && x >= 0 + && y < tilesLayer.TilesData.TileData.Length + && x < tilesLayer.TilesData.TileData[y].Length) + { + point = (x, y); + return true; + } + + return false; + } + + uint? GetLayerTileAtPointer(UndertaleRoom.Layer tilesLayer) + { + if (GetLayerTileIndexesAtPointer(tilesLayer, out (int x, int y) point)) + return tilesLayer.TilesData.TileData[point.y][point.x]; + + return null; + } + + void SetLayerTileAtPointer(UndertaleRoom.Layer tilesLayer, uint tileData) + { + if (vm!.IsLocked) + return; + + if (GetLayerTileIndexesAtPointer(tilesLayer, out (int x, int y) point)) + { + if ((tileData & UndertaleRoomViewModel.TILE_ID) < tilesLayer.TilesData.Background.GMS2TileCount) + tilesLayer.TilesData.TileData[point.y][point.x] = tileData; + } + } + + UndertaleRoom.Tile? GetRoomTileAtExactPosition(List roomItems, double x, double y) + { + foreach (RoomItem roomItem in roomItems.Reverse()) + { + if (roomItem.Selectable is null) + continue; + + UndertaleRoom.Layer? legacyTilesLayer = GetSelectedLegacyTilesLayer(); + + if (roomItem.Selectable.Category.Equals((object?)legacyTilesLayer ?? "Tiles") && roomItem.Object is UndertaleRoom.Tile tile + && tile.X == x && tile.Y == y) + { + return tile; + } + } + return null; + } + + UndertaleRoom.Tile? GetRoomTileAtPointer(List roomItems) + { + foreach (RoomItem roomItem in roomItems.Reverse()) + { + if (roomItem.Selectable is null) + continue; + + UndertaleRoom.Layer? legacyTilesLayer = GetSelectedLegacyTilesLayer(); + + if (roomItem.Selectable.Category.Equals((object?)legacyTilesLayer ?? "Tiles") && roomItem.Object is UndertaleRoom.Tile tile + && RectContainsPoint(roomItem.Selectable.Bounds, roomItem.Selectable.Rotation, roomItem.Selectable.Pivot, pointerPositionInRoom)) + { + return tile; + } + } + return null; + } + + void SetRoomTileAtPointer(List roomItems, UndertaleNamedResource? resource, Rect? sourceRect, bool overrideGrid) + { + if (vm!.IsLocked) + return; + + if (resource is not null && sourceRect is Rect sourceRectNN) + { + double x = pointerPositionInRoom.X; + double y = pointerPositionInRoom.Y; + + if (vm!.IsGridEnabled != overrideGrid) + { + x = (Math.Floor(pointerPositionInRoom.X / vm.GridWidth) * vm.GridWidth); + y = (Math.Floor(pointerPositionInRoom.Y / vm.GridHeight) * vm.GridHeight); + } + + UndertaleRoom.Tile? tile = GetRoomTileAtExactPosition(roomItems, x, y); + if (tile is null) + { + UndertaleRoom.Layer? legacyTilesLayer = GetSelectedLegacyTilesLayer(); + if (legacyTilesLayer is not null) + { + tile = vm!.AddLegacyTileInstance(legacyTilesLayer); + } + else + { + tile = vm!.AddTile(); + } + } + + tile.ObjectDefinition = resource; + tile.SourceX = (int)sourceRectNN.X; + tile.SourceY = (int)sourceRectNN.Y; + tile.Width = (uint)sourceRectNN.Width; + tile.Height = (uint)sourceRectNN.Height; + + tile.X = (int)x; + tile.Y = (int)y; + } + } + + void RemoveRoomTileAtPointer(List roomItems) + { + if (vm!.IsLocked) + return; + + if (hoveredItem is UndertaleRoom.Tile tile) + { + UndertaleRoom.Layer? legacyTilesLayer = GetSelectedLegacyTilesLayer(); + if (legacyTilesLayer is not null) + { + vm.RemoveAsset(legacyTilesLayer.AssetsData.LegacyTiles, tile); + } + else + { + vm.RemoveTile(tile); + } + hoveredItem = null; + } + } + + void RenderDebugText(DrawingContext context) + { + // Debug text + Point roomMousePosition = ((pointerPosition - translation) / scaling); + + static string GetTileInfo(uint? tile) + { + if (tile is uint tileNN) + { + uint tileId = tileNN & UndertaleRoomViewModel.TILE_ID; + uint tileOrientation = tileNN >> 28; + + float scaleX = (((tileOrientation >> 0) & 1) == 0) ? 1 : -1; + float scaleY = (((tileOrientation >> 1) & 1) == 0) ? 1 : -1; + float rotate = (((tileOrientation >> 2) & 1) == 0) ? 0 : 90; + return $"id: {tileId} xs: {scaleX} ys: {scaleY} r: {rotate}"; + } + + return ""; + } + + context.DrawText(new FormattedText( + $"mouse: ({pointerPosition.X}, {pointerPosition.Y})\n" + + $"view: ({-translation.X}, {-translation.Y}, {-translation.X + Bounds.Width}, {-translation.Y + Bounds.Height})\n" + + $"category: {vm?.CategorySelected}\n" + + $"custom render time: <{customDrawOperationTime} ms\n" + + $"hovered item: {hoveredItem}\n" + + $"hovered tile: {GetTileInfo(hoveredTile)}\n" + + $"selected tile: {GetTileInfo(vm?.SelectedTileData)}", + CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 12, new SolidColorBrush(Colors.White)), + new Point(0, 0)); + } + + static bool RectContainsPoint(Rect rect, double rotation, Point pivot, Point point) + { + return rect.Contains(point.Transform(Matrix.CreateRotation(double.DegreesToRadians(rotation), pivot))); + } + + class CustomDrawOperation : ICustomDrawOperation + { + readonly UndertaleRoomEditor editor; + + readonly UndertaleRoomViewModel vm; + readonly Vector translation; + readonly double scaling; + + readonly UndertaleRoom room; + readonly List roomItems; + + readonly List renderCommands; + + readonly uint roomWidth; + readonly uint roomHeight; + + readonly RoomItem? selectedRoomItem; + readonly RoomItem? hoveredRoomItem; + + readonly bool isGridEnabled; + readonly uint gridWidth; + readonly uint gridHeight; + + readonly SKColor selectedColor; + + public Rect Bounds { get; set; } + + public CustomDrawOperation(UndertaleRoomEditor editor) + { + this.editor = editor; + Bounds = new(0, 0, editor.Bounds.Width, editor.Bounds.Height); + + vm = editor.vm!; + translation = editor.translation; + scaling = editor.scaling; + + room = vm.Room; + + // TODO: Remove this + roomItems = Updater.MakeRoomItems(room); + + renderCommands = new RoomRenderer.RenderCommandsBuilder(room).RenderCommands; + + roomWidth = room.Width; + roomHeight = room.Height; + + selectedRoomItem = editor.GetSelectedRoomItem(roomItems); + hoveredRoomItem = editor.GetRoomItemOfItem(roomItems, editor.hoveredItem); + + isGridEnabled = vm.IsGridEnabled; + gridWidth = vm.GridWidth; + gridHeight = vm.GridHeight; + + selectedColor = editor.GetSolidColorBrushResource("SystemControlHighlightAccentBrush").Color.ToSKColor().WithAlpha(128); + } + + public void Render(ImmediateDrawingContext context) + { + if (!editor.enableRender) + return; + + try + { + Stopwatch stopWatch = new(); + stopWatch.Start(); + + // + + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) + return; + + using var lease = leaseFeature.Lease(); + SKCanvas canvas = lease.SkCanvas; + canvas.Save(); + + // + + // Fill background of entire control + canvas.DrawRect(0, 0, (float)Bounds.Width, (float)Bounds.Height, new SKPaint { Color = SKColors.Gray }); + + // Draw room outline + canvas.DrawRect((float)translation.X - 1, + (float)translation.Y - 1, + (float)Math.Ceiling(roomWidth * scaling + 1), + (float)Math.Ceiling(roomHeight * scaling + 1), + new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Stroke }); + + // Transform + canvas.Translate((float)translation.X, (float)translation.Y); + canvas.Scale((float)scaling); + + editor.rendererInstance.RenderCommands(renderCommands, canvas); + + if (isGridEnabled) + { + if (gridWidth * scaling >= 2) + for (uint x = 0; x < roomWidth; x += gridWidth) + { + canvas.DrawLine(x, 0, x, roomHeight, new SKPaint { Color = SKColors.White.WithAlpha(64), BlendMode = SKBlendMode.Difference }); + } + + if (gridHeight * scaling >= 2) + for (uint y = 0; y < roomHeight; y += gridHeight) + { + canvas.DrawLine(0, y, roomWidth, y, new SKPaint { Color = SKColors.White.WithAlpha(64), BlendMode = SKBlendMode.Difference }); + } + } + + if (selectedRoomItem is not null && selectedRoomItem.Selectable is not null) + { + SKRect rect = selectedRoomItem.Selectable.Bounds.ToSKRect(); + + canvas.Save(); + canvas.RotateDegrees((float)-selectedRoomItem.Selectable.Rotation, + (float)(selectedRoomItem.Selectable.Pivot.X), + (float)(selectedRoomItem.Selectable.Pivot.Y)); + canvas.DrawRect(rect, new SKPaint { Color = selectedColor, StrokeWidth = (float)(2 / scaling), Style = SKPaintStyle.Stroke }); + canvas.Restore(); + } + + if (hoveredRoomItem is not null && hoveredRoomItem.Selectable is not null) + { + SKRect rect = hoveredRoomItem.Selectable.Bounds.ToSKRect(); + + canvas.Save(); + canvas.RotateDegrees((float)-hoveredRoomItem.Selectable.Rotation, + (float)(hoveredRoomItem.Selectable.Pivot.X), + (float)(hoveredRoomItem.Selectable.Pivot.Y)); + canvas.DrawRect(rect, new SKPaint { Color = selectedColor, Style = SKPaintStyle.Stroke }); + canvas.Restore(); + } + + canvas.Restore(); + + stopWatch.Stop(); + editor.customDrawOperationTime = Math.Ceiling(stopWatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + editor.enableRender = false; + Program.HandleException(ex); + } + } + + public bool Equals(ICustomDrawOperation? other) => false; + public bool HitTest(Point p) => Bounds.Contains(p); + public void Dispose() { } + } + + public class Updater() + { + public UndertaleRoom? Room = null; + public readonly List RoomItems = []; + + public static List MakeRoomItems(UndertaleRoom room) + { + var updater = new Updater() + { + Room = room, + }; + updater.Update(); + return updater.RoomItems; + } + + public void Update() + { + RoomItems.Clear(); + + if (Room is null) + return; + + if (Room.Flags.HasFlag(UndertaleRoom.RoomEntryFlags.IsGMS2) || Room.Flags.HasFlag(UndertaleRoom.RoomEntryFlags.IsGM2024_13)) + { + IOrderedEnumerable layers = Room.Layers.Reverse().OrderByDescending(x => x.LayerDepth); + foreach (UndertaleRoom.Layer layer in layers) + { + if (!layer.IsVisible) + continue; + + switch (layer.LayerType) + { + case UndertaleRoom.LayerType.Path: + case UndertaleRoom.LayerType.Path2: + break; + case UndertaleRoom.LayerType.Background: + UpdateLayerBackground(layer); + break; + case UndertaleRoom.LayerType.Instances: + UpdateGameObjects(layer.InstancesData.Instances, layer); + break; + case UndertaleRoom.LayerType.Assets: + UpdateTiles(layer.AssetsData.LegacyTiles, layer); + UpdateSprites(layer.AssetsData.Sprites, layer); + // layer.AssetsData.Sequences + // layer.AssetsData.NineSlices + // layer.AssetsData.ParticleSystems + // layer.AssetsData.TextItems + break; + case UndertaleRoom.LayerType.Tiles: + UpdateLayerTiles(layer); + break; + case UndertaleRoom.LayerType.Effect: + // layer.EffectData + break; + } + } + } + else + { + UpdateBackgrounds(Room.Backgrounds, foregrounds: false); + UpdateTiles(Room.Tiles); + UpdateGameObjects(Room.GameObjects); + UpdateBackgrounds(Room.Backgrounds, foregrounds: true); + } + } + + void UpdateBackgrounds(IList backgrounds, bool foregrounds) + { + foreach (var background in backgrounds) + { + if (background.Foreground == foregrounds) + { + RoomItems.Add(new( + Object: background + )); + } + } + } + + void UpdateLayerBackground(UndertaleRoom.Layer layer) + { + RoomItems.Add(new RoomItem( + Object: layer + )); + } + + void UpdateTiles(IList roomTiles, UndertaleRoom.Layer? layer = null) + { + IOrderedEnumerable orderedRoomTiles = roomTiles.OrderByDescending(x => x.TileDepth); + foreach (UndertaleRoom.Tile roomTile in orderedRoomTiles) + { + float x = (layer?.XOffset ?? 0) + roomTile.X; + float y = (layer?.YOffset ?? 0) + roomTile.Y; + float w = roomTile.Width * roomTile.ScaleX; + float h = roomTile.Height * roomTile.ScaleY; + + RoomItems.Add(new RoomItem( + Object: roomTile, + Layer: layer, + Selectable: new( + Category: layer is not null ? layer : "Tiles", + Bounds: new Rect(x, y, w, h).Normalize(), + Rotation: 0, + Pivot: new Point(x, y), + GetProperties: () => + { + return new(roomTile.X, roomTile.Y); + }, + SetProperties: (properties) => + { + roomTile.X = properties.X; + roomTile.Y = properties.Y; + } + ) + )); + } + } + + void UpdateLayerTiles(UndertaleRoom.Layer layer) + { + RoomItems.Add(new RoomItem( + Object: layer + )); + } + + void UpdateSprites(IList roomSprites, UndertaleRoom.Layer layer) + { + foreach (UndertaleRoom.SpriteInstance roomSprite in roomSprites) + { + if (roomSprite.Sprite is null) + continue; + if (!(roomSprite.FrameIndex >= 0 && roomSprite.FrameIndex < roomSprite.Sprite.Textures.Count)) + continue; + + UndertaleTexturePageItem texture = roomSprite.Sprite.Textures[(int)roomSprite.FrameIndex].Texture; + + RoomItems.Add(new( + Object: roomSprite, + Layer: layer, + Selectable: new( + Category: layer, + Bounds: new Rect( + layer.XOffset + roomSprite.X - roomSprite.Sprite.OriginX * roomSprite.ScaleX, + layer.YOffset + roomSprite.Y - roomSprite.Sprite.OriginY * roomSprite.ScaleY, + texture.BoundingWidth * roomSprite.ScaleX, + texture.BoundingHeight * roomSprite.ScaleY + ).Normalize(), + Rotation: roomSprite.Rotation, + Pivot: new Point(layer.XOffset + roomSprite.X, layer.YOffset + roomSprite.Y), + GetProperties: () => + { + return new(roomSprite.X, roomSprite.Y); + }, + SetProperties: (properties) => + { + roomSprite.X = properties.X; + roomSprite.Y = properties.Y; + } + ) + )); + } + } + + void UpdateGameObjects(IList roomGameObjects, UndertaleRoom.Layer? layer = null) + { + foreach (UndertaleRoom.GameObject roomGameObject in roomGameObjects) + { + UndertaleGameObject? gameObject = roomGameObject.ObjectDefinition; + if (gameObject is null || + gameObject.Sprite is null || + !(roomGameObject.ImageIndex >= 0 && roomGameObject.ImageIndex < gameObject.Sprite.Textures.Count)) + continue; + + UndertaleTexturePageItem texture = gameObject.Sprite.Textures[roomGameObject.ImageIndex].Texture; + + RoomItems.Add(new( + Object: roomGameObject, + Selectable: new( + Category: layer is not null ? layer : "GameObjects", + Bounds: new Rect( + roomGameObject.X - gameObject.Sprite.OriginX * roomGameObject.ScaleX, + roomGameObject.Y - gameObject.Sprite.OriginY * roomGameObject.ScaleY, + texture.BoundingWidth * roomGameObject.ScaleX, + texture.BoundingHeight * roomGameObject.ScaleY + ).Normalize(), + Rotation: roomGameObject.Rotation, + Pivot: new Point( + roomGameObject.X, + roomGameObject.Y), + GetProperties: () => + { + return new(roomGameObject.X, roomGameObject.Y); + }, + SetProperties: (properties) => + { + roomGameObject.X = properties.X; + roomGameObject.Y = properties.Y; + } + ) + )); + } + } + } + + public class UndertaleReferenceDropHandler : DropHandlerBase + { + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (sender is UndertaleRoomEditor editor + && sourceContext is DataExplorerViewModel.Item item + && item.Value is UndertaleResource resource + && targetContext is UndertaleRoomViewModel vm) + { + if (resource is UndertaleGameObject gameObject) + { + return (vm.CategorySelected is "GameObjects" + || vm.CategorySelected is UndertaleRoom.Layer layer && layer.LayerType == UndertaleRoom.LayerType.Instances); + } + else if (resource is UndertaleSprite sprite) + { + return vm.CategorySelected is UndertaleRoom.Layer layer && layer.LayerType == UndertaleRoom.LayerType.Assets; + } + } + return false; + } + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (sender is UndertaleRoomEditor editor + && sourceContext is DataExplorerViewModel.Item item + && item.Value is UndertaleResource resource + && targetContext is UndertaleRoomViewModel vm) + { + Point pointerPosition = e.GetPosition(editor); + Point pointerPositionInRoom = (pointerPosition - editor.translation) / editor.scaling; + int x = (int)pointerPositionInRoom.X; + int y = (int)pointerPositionInRoom.Y; + + if (resource is UndertaleGameObject gameObject) + { + if (vm.CategorySelected is "GameObjects") + { + vm.AddGameObjectInstance(layer: null, gameObject, x, y); + } + else if (vm.CategorySelected is UndertaleRoom.Layer layer && layer.LayerType == UndertaleRoom.LayerType.Instances) + { + vm.AddGameObjectInstance(layer, gameObject: gameObject, x, y); + } + else + { + return false; + } + + return true; + } + else if (resource is UndertaleSprite sprite) + { + if (vm.CategorySelected is UndertaleRoom.Layer layer && layer.LayerType == UndertaleRoom.LayerType.Assets) + { + vm.AddSpriteInstance(layer, sprite, x, y); + } + else + { + return false; + } + } + } + return false; + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml new file mode 100644 index 000000000..726f58498 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs new file mode 100644 index 000000000..ab8fefa18 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs @@ -0,0 +1,130 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.DragAndDrop; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleStringReferenceView : UserControl +{ + public static readonly StyledProperty ReferenceProperty = AvaloniaProperty.Register( + nameof(Reference), defaultBindingMode: BindingMode.TwoWay); + public UndertaleString Reference + { + get { return GetValue(ReferenceProperty); } + set { SetValue(ReferenceProperty, value); } + } + + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public UndertaleStringReferenceView() + { + InitializeComponent(); + + UpdateTextBoxWatermark(); + ReferenceTextBox.AddHandler(TextBox.KeyDownEvent, TextBox_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ReferenceProperty) + { + UpdateTextBoxWatermark(); + } + } + + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + base.OnDetachedFromLogicalTree(e); + + UpdateReferenceToText(); + } + + private void TextBox_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + e.Handled = true; + UpdateReferenceToText(); + } + } + + private void TextBox_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + { + OpenInNewTab(); + } + } + + private void TextBox_LostFocus(object? sender, RoutedEventArgs e) + { + UpdateReferenceToText(); + } + + public void Add() + { + if (mainVM.Data is not null) + Reference = mainVM.Data.Strings.MakeString(""); + } + + public void Open() + { + _ = mainVM.TabOpen(Reference); + } + + public void OpenInNewTab() + { + _ = mainVM.TabOpen(Reference, inNewTab: true); + } + + void UpdateReferenceToText() + { + if (mainVM.Data is not null && ReferenceTextBox.Text is not null) + { + Reference = mainVM.Data.Strings.MakeString(ReferenceTextBox.Text); + } + } + + void UpdateTextBoxWatermark() + { + ReferenceTextBox.PlaceholderText = (Reference is null) ? "(string reference)" : ""; + } +} + +public class UndertaleStringDropHandler : DropHandlerBase +{ + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is UndertaleStringReferenceView vm) + { + if (sourceContext is DataExplorerViewModel.Item item && item.Value is UndertaleString resource) + { + return true; + } + } + return false; + } + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is UndertaleStringReferenceView vm) + { + if (sourceContext is DataExplorerViewModel.Item item && item.Value is UndertaleString resource) + { + vm.Reference = resource; + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/AudioPlayer.cs b/UndertaleModToolAvalonia/Core/AudioPlayer.cs new file mode 100644 index 000000000..0e5f45d68 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/AudioPlayer.cs @@ -0,0 +1,109 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using SDL3; + +namespace UndertaleModToolAvalonia; + +public class AudioPlayer : IDisposable +{ + static Action mainThreadAction = null!; + + static IntPtr mixer = IntPtr.Zero; + + IntPtr audio; + IntPtr track; + + readonly Mixer.TrackStoppedCallback trackStoppedCallback; + GCHandle trackStoppedCallbackHandle; + + public AudioPlayer(byte[] data) + { + // Don't allow this be deallocated until the sound stops. + trackStoppedCallback = new(OnTrackStoppped); + trackStoppedCallbackHandle = GCHandle.Alloc(trackStoppedCallback); + + // Load audio + GCHandle dataHandle = GCHandle.Alloc(data, GCHandleType.Pinned); + + IntPtr io = SDL.IOFromConstMem(dataHandle.AddrOfPinnedObject(), (nuint)data.Length); + if (io == IntPtr.Zero) + throw new InvalidOperationException($"{SDL.GetError()}"); + + audio = Mixer.LoadAudioIO(mixer, io, predecode: true, closeio: true); + + dataHandle.Free(); + + if (audio == IntPtr.Zero) + { + // TODO: Show some kind of error + return; + } + + // Create track and play + track = Mixer.CreateTrack(mixer); + if (track == IntPtr.Zero) + throw new InvalidOperationException($"{SDL.GetError()}"); + + if (!Mixer.SetTrackAudio(track, audio)) + throw new InvalidOperationException($"{SDL.GetError()}"); + + if (!Mixer.PlayTrack(track, 0)) + throw new InvalidOperationException($"{SDL.GetError()}"); + + if (!Mixer.SetTrackStoppedCallback(track, trackStoppedCallback, IntPtr.Zero)) + throw new InvalidOperationException($"{SDL.GetError()}"); + } + + public static void Init(Action _mainThreadAction) + { + if ((SDL.WasInit(SDL.InitFlags.Audio) & SDL.InitFlags.Audio) == 0) + { + SDL.SetHint(SDL.Hints.AppName, Assembly.GetExecutingAssembly().GetName().Name ?? ""); + + if (!SDL.Init(SDL.InitFlags.Audio)) + throw new InvalidOperationException($"{SDL.GetError()}"); + + if (!Mixer.Init()) + throw new InvalidOperationException($"{SDL.GetError()}"); + } + + if (mixer == IntPtr.Zero) + { + mixer = Mixer.CreateMixerDevice(SDL.AudioDeviceDefaultPlayback, IntPtr.Zero); + if (mixer == IntPtr.Zero) + throw new InvalidOperationException($"{SDL.GetError()}"); + } + + mainThreadAction = _mainThreadAction; + } + + public void Stop() + { + Dispose(); + } + + public void Dispose() + { + // If those are null, nothing happens. They also don't call the track stopped callback. + Mixer.DestroyTrack(track); + Mixer.DestroyAudio(audio); + + if (trackStoppedCallbackHandle.IsAllocated) + trackStoppedCallbackHandle.Free(); + + track = IntPtr.Zero; + audio = IntPtr.Zero; + + GC.SuppressFinalize(this); + } + + void OnTrackStoppped(IntPtr userdata, IntPtr track) + { + // The callback happens in a separate thread, so we defer to the main thread. + mainThreadAction(() => + { + Dispose(); + }); + } +} diff --git a/UndertaleModToolAvalonia/Core/Extensions.cs b/UndertaleModToolAvalonia/Core/Extensions.cs new file mode 100644 index 000000000..dc2bda1a9 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/Extensions.cs @@ -0,0 +1,81 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +public static class Extensions +{ + /// + /// Waits on a without blocking the main thread. + /// + public static TResult WaitOnDispatcherFrame(this Task task) + { + if (!task.IsCompleted) + { + DispatcherFrame frame = new(); + _ = task.ContinueWith(static (_, s) => ((DispatcherFrame)s!).Continue = false, frame); + Dispatcher.UIThread.PushFrame(frame); + } + + return task.GetAwaiter().GetResult(); + } + + /// + /// Waits on a without blocking the main thread. + /// + public static void WaitOnDispatcherFrame(this Task task) + { + if (!task.IsCompleted) + { + DispatcherFrame frame = new(); + _ = task.ContinueWith(static (_, s) => ((DispatcherFrame)s!).Continue = false, frame); + Dispatcher.UIThread.PushFrame(frame); + } + + task.GetAwaiter().GetResult(); + } + + /// + /// Returns the SolidColorBrush resource in the key. Throws if key is invalid. + /// + /// + public static SolidColorBrush GetSolidColorBrushResource(this StyledElement styledElement, string key) + { + if (styledElement.TryFindResource(key, styledElement.ActualThemeVariant, out object? resource)) + { + if (resource is SolidColorBrush brush) + return brush; + } + throw new InvalidOperationException($"Key {key} is not a valid resource"); + } + + public static void SetDarkTitleBar(this Window window, bool isDark) + { + if (OperatingSystem.IsWindows()) + { + WindowsDllImports.SetDarkTitleBar(window, isDark); + window.Activate(); + } + } +} + +public static class WindowsDllImports +{ + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); + + public static void SetDarkTitleBar(Window window, bool isDark) + { + nint? handle = window.TryGetPlatformHandle()?.Handle; + if (handle is null) return; + + int value = isDark ? 1 : 0; + // Attribute 20: DWMWA_USE_IMMERSIVE_DARK_MODE + _ = DwmSetWindowAttribute(handle.Value, 20, ref value, sizeof(int)); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs b/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs new file mode 100644 index 000000000..35f1ba7f2 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Avalonia.Platform.Storage; + +namespace UndertaleModToolAvalonia; + +public static class FilePickerFileTypes +{ + static readonly FilePickerFileType AllSingle = new("All files") + { + Patterns = ["*"], + }; + + static readonly FilePickerFileType BINSingle = new("BIN files (.bin)") + { + Patterns = ["*.bin"], + }; + + static readonly FilePickerFileType DataSingle = new("GameMaker data files (.win, .unx, .ios, .droid, audiogroup*.dat)") + { + Patterns = ["*.win", "*.unx", "*.ios", "*.droid", "audiogroup*.dat"], + }; + + static readonly FilePickerFileType PNGSingle = new("PNG files (.png)") + { + Patterns = ["*.png"], + }; + + static readonly FilePickerFileType QOISingle = new("QOI files (.qoi)") + { + Patterns = ["*.qoi"], + }; + + static readonly FilePickerFileType BZ2Single = new("BZ2 files (.bz2)") + { + Patterns = ["*.bz2"], + }; + + static readonly FilePickerFileType WAVSingle = new("WAV files (.wav)") + { + Patterns = ["*.wav"], + }; + + static readonly FilePickerFileType CSSingle = new("C# scripts (.csx)") + { + Patterns = ["*.csx"], + }; + + static readonly FilePickerFileType JSONSingle = new("JSON files (.json)") + { + Patterns = ["*.json"], + }; + + public static readonly IReadOnlyList All = [AllSingle]; + public static readonly IReadOnlyList BIN = [BINSingle, AllSingle]; + public static readonly IReadOnlyList Data = [DataSingle, AllSingle]; + public static readonly IReadOnlyList Image = [PNGSingle, AllSingle]; + public static readonly IReadOnlyList PNG = [PNGSingle, AllSingle]; + public static readonly IReadOnlyList QOI = [QOISingle, AllSingle]; + public static readonly IReadOnlyList BZ2 = [BZ2Single, AllSingle]; + public static readonly IReadOnlyList WAV = [WAVSingle, AllSingle]; + public static readonly IReadOnlyList CS = [CSSingle, AllSingle]; + public static readonly IReadOnlyList JSON = [JSONSingle, AllSingle]; +} diff --git a/UndertaleModToolAvalonia/Core/IView.cs b/UndertaleModToolAvalonia/Core/IView.cs new file mode 100644 index 000000000..5a3b1a7c3 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/IView.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Platform.Storage; + +namespace UndertaleModToolAvalonia; + +public interface IView +{ + private Control View => (Control)this; + + public async Task> OpenFileDialog(FilePickerOpenOptions options) + { + TopLevel topLevel = TopLevel.GetTopLevel(View)!; + return await topLevel.StorageProvider.OpenFilePickerAsync(options); + } + + public async Task SaveFileDialog(FilePickerSaveOptions options) + { + TopLevel topLevel = TopLevel.GetTopLevel(View)!; + return await topLevel.StorageProvider.SaveFilePickerAsync(options); + } + + public async Task> OpenFolderDialog(FolderPickerOpenOptions options) + { + TopLevel topLevel = TopLevel.GetTopLevel(View)!; + return await topLevel.StorageProvider.OpenFolderPickerAsync(options); + } + + public async Task LaunchUriAsync(Uri uri) + { + TopLevel topLevel = TopLevel.GetTopLevel(View)!; + return await topLevel.Launcher.LaunchUriAsync(uri); + } + + public async Task MessageDialog(string message, string? title = null, MessageWindow.Buttons buttons = MessageWindow.Buttons.OK) + { + Window window = View.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + return await new MessageWindow(message, title, buttons).ShowDialog(window); + } + + public async Task TextBoxDialog(string message, string text = "", string? title = null, bool isMultiline = false, bool isReadOnly = false) + { + Window window = View.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + return await new TextBoxWindow(message, text, title, isMultiline, isReadOnly).ShowDialog(window); + } + + public ILoaderWindow LoaderOpen() + { + Window window = View.FindLogicalAncestorOfType(true) ?? throw new InvalidOperationException("Window not found"); + LoaderWindow loaderWindow = new(); + loaderWindow.ShowDelayed(window); + return loaderWindow; + } + + public IInputElement? GetFocusedElement() + { + TopLevel topLevel = TopLevel.GetTopLevel(View)!; + return topLevel.FocusManager?.GetFocusedElement(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/ImageCache.cs b/UndertaleModToolAvalonia/Core/ImageCache.cs new file mode 100644 index 000000000..a3cc9a156 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ImageCache.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using SkiaSharp; +using UndertaleModLib.Models; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public class ImageCache +{ + abstract record ImageKey(); + record GMImageImageKey(GMImage GMImage) : ImageKey; + record TexturePageItemImageKey(GMImage GMImage, ushort SourceX, ushort SourceY, + ushort SourceWidth, ushort SourceHeight) : ImageKey; + record TileImageKey(GMImage GMImage, ushort SourceX, ushort SourceY, ushort TargetX, ushort TargetY, + int TileSourceX, int TileSourceY, uint Width, uint Height) : ImageKey; + record LayerTileImageKey(GMImage GMImage, ushort SourceX, ushort SourceY, uint TileId, + uint TileColumns, uint TileWidth, uint TileHeight, uint TileBorderX, uint TileBorderY) : ImageKey; + + readonly Dictionary> imageCache = []; + + public SKImage GetImageFromGMImage(GMImage gmImage) + { + byte[] data = gmImage.ConvertToRawBgra().GetData(); + SKImageInfo info = new(gmImage.Width, gmImage.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul); + + SKImage? image = SKImage.FromPixelCopy(info, data); + + if (image is null) + { + throw new InvalidOperationException("Could not create image"); + } + + return image; + } + + public SKImage GetCachedImageFromGMImage(GMImage gmImage) + { + GMImageImageKey key = new(gmImage); + + SKImage? image = null; + if (imageCache.TryGetValue(key, out var reference)) + reference.TryGetTarget(out image); + + if (image is null) + { + image = GetImageFromGMImage(gmImage); + imageCache[key] = new WeakReference(image); + } + + return image; + } + + public SKImage? GetCachedImageFromTexturePageItem(UndertaleTexturePageItem texturePageItem) + { + if (texturePageItem.TexturePage is null + || texturePageItem.TexturePage.TextureData is null + || texturePageItem.TexturePage.TextureData.Image is null) + return null; + + TexturePageItemImageKey key = new( + texturePageItem.TexturePage.TextureData.Image, + texturePageItem.SourceX, + texturePageItem.SourceY, + texturePageItem.SourceWidth, + texturePageItem.SourceHeight); + + SKImage? image = null; + if (imageCache.TryGetValue(key, out var reference)) + reference.TryGetTarget(out image); + + if (image is null) + { + GMImage gmImage = texturePageItem.TexturePage.TextureData.Image; + + var rect = SKRectI.Create( + texturePageItem.SourceX, + texturePageItem.SourceY, + texturePageItem.SourceWidth, + texturePageItem.SourceHeight); + + if (!SKRectI.Create(gmImage.Width, gmImage.Height).Contains(rect)) + return null; + + image = GetCachedImageFromGMImage(gmImage) + .Subset(rect); + + if (image is null) + return null; + + imageCache[key] = new WeakReference(image); + } + + return image; + } + + public SKImage? GetCachedImageFromTile(UndertaleRoom.Tile tile) + { + if (tile.Tpag is null || tile.Tpag.TexturePage is null || tile.Width == 0 || tile.Height == 0) + return null; + + TileImageKey key = new( + tile.Tpag.TexturePage.TextureData.Image, + tile.Tpag.SourceX, + tile.Tpag.SourceY, + tile.Tpag.TargetX, + tile.Tpag.TargetY, + tile.SourceX, + tile.SourceY, + tile.Width, + tile.Height); + + SKImage? image = null; + if (imageCache.TryGetValue(key, out var reference)) + reference.TryGetTarget(out image); + + if (image is null) + { + // Don't allow tile to exceed texture page item's borders + int l = tile.Tpag.SourceX + Math.Max(0, tile.SourceX - tile.Tpag.TargetX); + int t = tile.Tpag.SourceY + Math.Max(0, tile.SourceY - tile.Tpag.TargetY); + int r = (int)Math.Min(l + tile.Width, tile.Tpag.SourceX + tile.Tpag.SourceWidth); + int b = (int)Math.Min(t + tile.Height, tile.Tpag.SourceY + tile.Tpag.SourceHeight); + + if (l >= r || t >= b) + return null; + + // Assuming source and target are in the same scale. + image = GetCachedImageFromGMImage(tile.Tpag.TexturePage.TextureData.Image) + .Subset(new SKRectI(l, t, r, b)); + + if (image is not null) + imageCache[key] = new WeakReference(image); + } + + return image; + } + + public void Clear() + { + imageCache.Clear(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/ImportExport.cs b/UndertaleModToolAvalonia/Core/ImportExport.cs new file mode 100644 index 000000000..e2c228d28 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ImportExport.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using ImageMagick; +using SkiaSharp; +using UndertaleModLib.Models; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public static class ImportExport +{ + public static async Task ImportEmbeddedAudio(UndertaleEmbeddedAudio embeddedAudio, Stream stream) + { + byte[] bytes = new byte[stream.Length]; + await stream.ReadExactlyAsync(bytes); + + embeddedAudio.Data = bytes; + } + + public static async Task ExportEmbeddedAudio(UndertaleEmbeddedAudio embeddedAudio, Stream stream) + { + await stream.WriteAsync(embeddedAudio.Data); + } + + public static async Task ImportEmbeddedTexture(UndertaleEmbeddedTexture embeddedTexture, Stream stream) + { + byte[] bytes = new byte[stream.Length]; + await stream.ReadExactlyAsync(bytes); + + GMImage gmImage = GMImage.FromPng(bytes, verifyHeader: true); + gmImage.ConvertToFormat(embeddedTexture.TextureData.Image.Format); + + embeddedTexture.TextureData.Image = gmImage; + embeddedTexture.TextureWidth = gmImage.Width; + embeddedTexture.TextureHeight = gmImage.Height; + } + + public static async Task ExportEmbeddedTexture(UndertaleEmbeddedTexture embeddedTexture, Stream stream) + { + await stream.WriteAsync(embeddedTexture.TextureData.Image.GetData()); + } + + public static async Task ExportEmbeddedTextureAsPNG(UndertaleEmbeddedTexture embeddedTexture, Stream stream) + { + embeddedTexture.TextureData.Image.SavePng(stream); + } + + public static async Task ExportRoomAsPNG(UndertaleRoom room, Stream stream) + { + // NOTE: This is a CPU bitmap, unlike the GPU surface used when rendering in the UI. + SKBitmap bitmap = new((int)room.Width, (int)room.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul); + SKCanvas canvas = new(bitmap); + + RoomRenderer renderer = new(); + renderer.RenderCommands(new RoomRenderer.RenderCommandsBuilder(room).RenderCommands, canvas); + + bool result = bitmap.Encode(stream, SKEncodedImageFormat.Png, 100); + if (!result) + throw new InvalidOperationException(); + } + + public static async Task ImportSpriteCollisionMaskData(UndertaleSprite sprite, int collisionMaskIndex, Stream stream, MainViewModel mainVM) + { + byte[] bytes = new byte[stream.Length]; + await stream.ReadExactlyAsync(bytes); + + (int width, int height) = sprite.CalculateMaskDimensions(mainVM.Data); + UndertaleSprite.MaskEntry maskEntry = new(bytes, width, height); + + sprite.CollisionMasks[collisionMaskIndex] = maskEntry; + } + + public static async Task ExportSpriteCollisionMaskData(UndertaleSprite sprite, int collisionMaskIndex, Stream stream) + { + await stream.WriteAsync(sprite.CollisionMasks[collisionMaskIndex].Data); + } + + public static async Task ImportTexturePageItem(UndertaleTexturePageItem texturePageItem, Stream stream) + { + using MagickImage image = TextureWorker.ReadBGRAImageFromStream(stream); + + var format = texturePageItem.TexturePage.TextureData.Image.Format; + if (format == GMImage.ImageFormat.Dds) + throw new InvalidOperationException("Can't import into DDS texture"); + + if (image.Width != texturePageItem.SourceWidth || image.Height != texturePageItem.SourceHeight) + throw new InvalidOperationException($"Size of image ({image.Width},{image.Height}) does not match texture page item source size ({(texturePageItem.SourceWidth)},{texturePageItem.SourceHeight})"); + + texturePageItem.ReplaceTexture(image); + } + + public static async Task ExportTexturePageItemAsPNG(UndertaleTexturePageItem texturePageItem, Stream stream, bool includePadding) + { + using var textureWorker = new TextureWorker(); + textureWorker.ExportAsPNG(texturePageItem, stream, texturePageItem.Name.Content, includePadding); + } +} diff --git a/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs b/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs new file mode 100644 index 000000000..c49a045ee --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs @@ -0,0 +1,423 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +public abstract class ObservableCollectionView +{ + public abstract void SetFilter(Predicate? filter); + public abstract void SetSort(Comparison? sort); +} + +/// +/// This class allows you to filter and transform an input observable collection, providing an output observable collection, which will be kept in sync with the input. +/// +/// Type of item in input collection. +/// Type of item in output collection. +public class ObservableCollectionView : ObservableCollectionView + where TInput : class? + where TOutput : class? +{ + public class CustomObservableCollection : IList, IList, INotifyCollectionChanged + where T : class? + { + public readonly record struct IndexValue(int Index, T Value); + + readonly List internalList = new(); + + bool isNotSendingEvents = false; + bool isDelayingEvents = false; + readonly List delayedEvents = []; + + public void StartDelayingEvents() + { + isDelayingEvents = true; + } + + public void FinishDelayingEvents() + { + isDelayingEvents = false; + + // HACK: Don't you love magic numbers? + if (delayedEvents.Count > 100) + { + SendReset(); + } + else + { + foreach (NotifyCollectionChangedEventArgs e in delayedEvents) + { + if (CollectionChanged is not null) + CollectionChanged(this, e); + } + } + + delayedEvents.Clear(); + } + + public void StartNoEvents() + { + isNotSendingEvents = true; + } + + public void FinishNoEvents() + { + isNotSendingEvents = false; + SendReset(); + } + + public IndexValue this[int index] + { + get => internalList[index]; + set + { + IndexValue oldItem = internalList[index]; + + internalList[index] = value; + SendEvent(new(NotifyCollectionChangedAction.Replace, value.Value, oldItem.Value, index)); + } + } + + public void SetIndex(int index, int itemIndex) + { + internalList[index] = internalList[index] with { Index = itemIndex }; + } + + public void IncreaseAllIndexesIfGreaterOrEqualThan(int increment, int ifGreaterOrEqualThan) + { + for (int i = 0; i < internalList.Count; i++) + { + IndexValue item = internalList[i]; + if (item.Index >= ifGreaterOrEqualThan) + { + internalList[i] = internalList[i] with { Index = item.Index + increment }; + } + } + } + + public void AddIndexValue(IndexValue item) + { + internalList.Add(item); + SendEvent(new(NotifyCollectionChangedAction.Add, item.Value, internalList.Count - 1)); + } + + public void InsertIndexValue(int index, IndexValue item) + { + internalList.Insert(index, item); + SendEvent(new(NotifyCollectionChangedAction.Add, item.Value, index)); + } + + public void RemoveAtIndexValue(int index) + { + IndexValue item = internalList[index]; + + internalList.RemoveAt(index); + SendEvent(new(NotifyCollectionChangedAction.Remove, item.Value, index)); + } + + public void ClearIndexValue() + { + internalList.Clear(); + SendEvent(new(NotifyCollectionChangedAction.Reset)); + } + + public void MoveIndexValue(int oldIndex, int newIndex) + { + IndexValue removedItem = internalList[oldIndex]; + internalList.RemoveAt(oldIndex); + internalList.Insert(newIndex, removedItem); + + SendEvent(new(NotifyCollectionChangedAction.Move, removedItem.Value, newIndex, oldIndex)); + } + + public int BinarySearchIndexValue(IndexValue item, IComparer comparer) + { + return internalList.BinarySearch(item, comparer); + } + + public void SortIndexValue(IComparer comparer) + { + internalList.Sort(comparer); + SendEvent(new(NotifyCollectionChangedAction.Reset)); + } + + public int IndexOfIndex(int itemIndex) + { + return internalList.FindIndex(x => x.Index == itemIndex); + } + + void SendReset() + { + if (CollectionChanged is not null) + CollectionChanged(this, new(NotifyCollectionChangedAction.Reset)); + } + + void SendEvent(NotifyCollectionChangedEventArgs e) + { + if (isNotSendingEvents) + return; + if (isDelayingEvents) + delayedEvents.Add(e); + else if (CollectionChanged is not null) + { + Dispatcher.UIThread.Invoke(() => CollectionChanged(this, e)); + } + } + + // INotifyCollectionChanged + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + // IList + public int Count => internalList.Count; + public bool IsReadOnly => true; + public bool IsFixedSize => ((IList)internalList).IsFixedSize; + public bool IsSynchronized => false; + public object SyncRoot => ((IList)internalList).SyncRoot; + + object? IList.this[int index] + { + get => internalList[index].Value; + set => throw new NotSupportedException(); + } + T IList.this[int index] + { + get => internalList[index].Value; + set => throw new NotSupportedException(); + } + + public int IndexOf(object? value) => internalList.FindIndex(x => x.Value == value); + public int IndexOf(T item) => IndexOf((object?)item); + public bool Contains(object? value) => internalList.FindIndex(x => x.Value == value) != -1; + public bool Contains(T item) => Contains((object?)item); + public void CopyTo(Array array, int index) + { + T[] newArray = [.. internalList.Select(x => x.Value)]; + Array.Copy(newArray, 0, array, index, newArray.Length); + } + public void CopyTo(T[] array, int arrayIndex) => CopyTo((Array)array, arrayIndex); + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() + { + foreach (var item in internalList) + { + yield return item.Value; + } + } + + int IList.Add(object? value) => throw new NotSupportedException(); + void ICollection.Add(T item) => throw new NotSupportedException(); + void IList.Insert(int index, object? value) => throw new NotSupportedException(); + void IList.Insert(int index, T item) => throw new NotSupportedException(); + void IList.RemoveAt(int index) => throw new NotSupportedException(); + void IList.RemoveAt(int index) => throw new NotSupportedException(); + void IList.Remove(object? value) => throw new NotSupportedException(); + bool ICollection.Remove(T item) => throw new NotSupportedException(); + void IList.Clear() => throw new NotSupportedException(); + void ICollection.Clear() => throw new NotSupportedException(); + } + + public CustomObservableCollection Output { get; } = []; + + readonly IList input; + readonly Func? transformFunc; + Predicate? filterPredicate; + Comparison? sortComparision; + + readonly Comparer.IndexValue> sortComparer; + + public ObservableCollectionView(IList input, Func? transform = null, Predicate? filter = null, Comparison? sort = null) + { + this.input = input; + this.filterPredicate = filter; + this.transformFunc = transform; + this.sortComparision = sort; + + if (input is INotifyCollectionChanged inputNotifyCollectionChanged) + inputNotifyCollectionChanged.CollectionChanged += OnInputCollectionChanged; + else + throw new InvalidOperationException($"ObservableCollectionView input ({input}) does not implement INotifyCollectionChanged"); + + sortComparer = Comparer.IndexValue>.Create((x, y) => + { + int r = 0; + if (this.sortComparision is not null) + r = this.sortComparision(x.Value, y.Value); + if (r == 0) + r = x.Index.CompareTo(y.Index); + return r; + }); + + Reset(); + } + + public void SetFilter(Predicate? filter) + { + filterPredicate = filter; + Filter(); + } + public override void SetFilter(Predicate? _filterPredicate) => SetFilter((Predicate?)_filterPredicate); + + public void SetSort(Comparison? sort) + { + sortComparision = sort; + Sort(); + } + public override void SetSort(Comparison? sort) => SetSort((Comparison?)sort); + + void OnInputCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + OnInputAdd(e); + break; + + case NotifyCollectionChangedAction.Remove: + OnInputRemove(e); + break; + + case NotifyCollectionChangedAction.Replace: + OnInputReplace(e); + break; + + case NotifyCollectionChangedAction.Move: + OnInputMove(e); + break; + + case NotifyCollectionChangedAction.Reset: + OnInputReset(); + break; + } + } + + void OnInputAdd(NotifyCollectionChangedEventArgs e) + { + if (e.NewItems!.Count != 1) + throw new InvalidOperationException("Modifying multiple items is not supported"); + + // Increase all indexes greater than inserted input indexes + Output.IncreaseAllIndexesIfGreaterOrEqualThan(e.NewItems.Count, e.NewStartingIndex); + + TInput item = (TInput)e.NewItems[0]!; + TOutput transformedItem = TransformItem(item); + + if (DoesPassFilter(transformedItem)) + { + // TODO: Because sorting can change, this may not have the correct index + // Find where in output to insert + int i = Output.BinarySearchIndexValue(new(e.NewStartingIndex, transformedItem), sortComparer); + + if (i < 0) + i = ~i; + + Output.InsertIndexValue(i, new(e.NewStartingIndex, transformedItem)); + } + } + + void OnInputRemove(NotifyCollectionChangedEventArgs e) + { + if (e.OldItems!.Count != 1) + throw new InvalidOperationException("Modifying multiple items is not supported"); + + // Find where in output to remove + int i = Output.IndexOfIndex(e.OldStartingIndex); + if (i != -1) + { + Output.RemoveAtIndexValue(i); + } + + Output.IncreaseAllIndexesIfGreaterOrEqualThan(-e.OldItems.Count, e.OldStartingIndex + e.OldItems.Count); + } + + void OnInputReplace(NotifyCollectionChangedEventArgs e) + { + if (e.OldItems!.Count != 1 || e.NewItems!.Count != 1) + throw new InvalidOperationException("Modifying multiple items is not supported"); + + TInput item = (TInput)e.NewItems[0]!; + TOutput transformedItem = TransformItem(item); + + bool passes = DoesPassFilter(transformedItem); + + // Find where in output to remove + int i = Output.IndexOfIndex(e.OldStartingIndex); + if (i != -1) + { + Output.RemoveAtIndexValue(i); + } + + // If it passes, find where in output to insert + if (passes) + { + // TODO: Because sorting can change, this may not have the correct index + i = Output.BinarySearchIndexValue(new(e.OldStartingIndex, transformedItem), sortComparer); + + if (i < 0) + i = ~i; + + Output.InsertIndexValue(i, new(e.OldStartingIndex, transformedItem)); + } + } + + void OnInputMove(NotifyCollectionChangedEventArgs e) + { + // TODO: Actually call Move(). + OnInputRemove(e); + OnInputAdd(e); + } + + void OnInputReset() + { + Reset(); + } + + void Filter() + { + // TODO: Maybe not do this? + Reset(); + } + + void Sort() + { + Output.SortIndexValue(sortComparer); + } + + void Reset() + { + Output.StartNoEvents(); + + Output.ClearIndexValue(); + + for (int inputIndex = 0; inputIndex < input.Count; inputIndex++) + { + TInput item = input[inputIndex]; + TOutput transformedItem = TransformItem(item); + + if (DoesPassFilter(transformedItem)) + { + Output.AddIndexValue(new(inputIndex, transformedItem)); + } + } + + Output.SortIndexValue(sortComparer); + + Output.FinishNoEvents(); + } + + bool DoesPassFilter(TOutput item) => filterPredicate is null || filterPredicate(item); + + TOutput TransformItem(TInput item) + { + if (transformFunc is not null) + return transformFunc(item); + + if (item is TOutput itemAsTOutput) + return itemAsTOutput; + + throw new InvalidOperationException("Input and output types are different without a transform function"); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/RoomRenderer.cs b/UndertaleModToolAvalonia/Core/RoomRenderer.cs new file mode 100644 index 000000000..90dc902d5 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/RoomRenderer.cs @@ -0,0 +1,635 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Media; +using Avalonia.Skia; +using Microsoft.Extensions.DependencyInjection; +using SkiaSharp; +using UndertaleModLib.Models; +using UndertaleModLib.Util; +using static UndertaleModToolAvalonia.RoomRenderer.RenderCommandsBuilder; + +namespace UndertaleModToolAvalonia; + +public class RoomRenderer +{ + public class RenderCommandsBuilder + { + public interface IRenderCommand; + + public readonly record struct NinePatchData(int Left, int Top, int Right, int Bottom, + UndertaleSprite.NineSlice.TileMode TileModeCenter, + UndertaleSprite.NineSlice.TileMode TileModeLeft, + UndertaleSprite.NineSlice.TileMode TileModeTop, + UndertaleSprite.NineSlice.TileMode TileModeRight, + UndertaleSprite.NineSlice.TileMode TileModeBottom); + + public readonly record struct BackgroundColorRenderCommand(uint RoomWidth, uint RoomHeight, uint Color) + : IRenderCommand; + public readonly record struct BackgroundRenderCommand(SKImage Image, + ushort SourceX, ushort SourceY, ushort SourceWidth, ushort SourceHeight, + ushort TargetX, ushort TargetY, ushort TargetWidth, ushort TargetHeight, ushort BoundingWidth, ushort BoundingHeight, + float X, float Y, float ScaleX, float ScaleY, uint Color, bool TiledHorizontally, bool TiledVertically, uint RoomWidth, uint RoomHeight) + : IRenderCommand; + public readonly record struct TileRenderCommand(SKImage Image, + ushort SourceX, ushort SourceY, ushort SourceWidth, ushort SourceHeight, + ushort TargetX, ushort TargetY, ushort TargetWidth, ushort TargetHeight, + int TileSourceX, int TileSourceY, float X, float Y, float ScaleX, float ScaleY) + : IRenderCommand; + public readonly record struct GameObjectRenderCommand(SKImage Image, + ushort SourceX, ushort SourceY, ushort SourceWidth, ushort SourceHeight, + ushort TargetX, ushort TargetY, ushort TargetWidth, ushort TargetHeight, ushort BoundingWidth, ushort BoundingHeight, + int X, int Y, float ScaleX, float ScaleY, uint Color, float Rotation, int OriginX, int OriginY, + NinePatchData? NinePatch) + : IRenderCommand; + public readonly record struct SpriteRenderCommand(SKImage Image, + ushort SourceX, ushort SourceY, ushort SourceWidth, ushort SourceHeight, + ushort TargetX, ushort TargetY, ushort TargetWidth, ushort TargetHeight, + float X, float Y, float ScaleX, float ScaleY, uint Color, float Rotation, int OriginX, int OriginY) + : IRenderCommand; + public readonly record struct LayerTilesRenderCommand(SKImage Image, + ushort SourceX, ushort SourceY, ushort SourceWidth, ushort SourceHeight, + ushort TargetX, ushort TargetY, ushort TargetWidth, ushort TargetHeight, + float X, float Y, uint[][] TileData, uint TileDataW, uint TileDataH, uint TileColumns, uint TileW, uint TileH, uint OutputBorderX, uint OutputBorderY) + : IRenderCommand; + + public readonly UndertaleRoom Room; + public readonly List RenderCommands = []; + + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public RenderCommandsBuilder(UndertaleRoom room) + { + Room = room; + + if (!(Room.Flags.HasFlag(UndertaleRoom.RoomEntryFlags.IsGMS2) || Room.Flags.HasFlag(UndertaleRoom.RoomEntryFlags.IsGM2024_13))) + { + AddBackgroundColor(Room.BackgroundColor); + AddBackgrounds(Room.Backgrounds, foregrounds: false); + // TODO: Order tiles and game objects by depth + AddTiles(Room.Tiles); + AddGameObjects(Room.GameObjects); + AddBackgrounds(Room.Backgrounds, foregrounds: true); + } + else + { + IOrderedEnumerable layers = Room.Layers.Reverse().OrderByDescending(x => x.LayerDepth); + foreach (UndertaleRoom.Layer layer in layers) + { + if (!layer.IsVisible) + continue; + + switch (layer.LayerType) + { + case UndertaleRoom.LayerType.Path: + case UndertaleRoom.LayerType.Path2: + break; + case UndertaleRoom.LayerType.Background: + AddLayerBackground(layer); + break; + case UndertaleRoom.LayerType.Instances: + AddGameObjects(layer.InstancesData.Instances); + break; + case UndertaleRoom.LayerType.Assets: + AddTiles(layer.AssetsData.LegacyTiles, layer); + AddSprites(layer.AssetsData.Sprites, layer); + // layer.AssetsData.Sequences + // layer.AssetsData.NineSlices + // layer.AssetsData.ParticleSystems + // layer.AssetsData.TextItems + break; + case UndertaleRoom.LayerType.Tiles: + AddLayerTiles(layer); + break; + //case UndertaleRoom.LayerType.Effect: + // layer.EffectData + //break; + } + } + } + } + + void AddBackgroundColor(uint color) + { + RenderCommands.Add(new BackgroundColorRenderCommand( + RoomWidth: Room.Width, + RoomHeight: Room.Height, + Color: color + )); + } + + void AddBackgrounds(IList roomBackgrounds, bool foregrounds) + { + foreach (UndertaleRoom.Background roomBackground in roomBackgrounds) + { + if (roomBackground.Foreground == foregrounds) + { + if (!roomBackground.Enabled) + continue; + + UndertaleTexturePageItem? texture = roomBackground.BackgroundDefinition?.Texture; + if (texture is null) + continue; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texture); + if (image is null) + continue; + + roomBackground.UpdateStretch(); + + RenderCommands.Add(new BackgroundRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + BoundingWidth: texture.BoundingWidth, + BoundingHeight: texture.BoundingHeight, + X: roomBackground.X, + Y: roomBackground.Y, + ScaleX: roomBackground.CalcScaleX, + ScaleY: roomBackground.CalcScaleY, + Color: 0xFFFFFFFF, + TiledHorizontally: roomBackground.TiledHorizontally, + TiledVertically: roomBackground.TiledVertically, + RoomWidth: Room.Width, + RoomHeight: Room.Height + )); + } + } + } + + void AddTiles(IList roomTiles, UndertaleRoom.Layer? layer = null) + { + IOrderedEnumerable orderedRoomTiles = roomTiles.OrderByDescending(x => x.TileDepth); + foreach (UndertaleRoom.Tile roomTile in orderedRoomTiles) + { + SKImage? image = mainVM.ImageCache.GetCachedImageFromTile(roomTile); + if (image is null) + continue; + + UndertaleTexturePageItem? texture = roomTile.Tpag; + if (texture is null) + continue; + + RenderCommands.Add(new TileRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + TileSourceX: roomTile.SourceX, + TileSourceY: roomTile.SourceY, + X: (layer?.XOffset ?? 0) + roomTile.X - Math.Min(roomTile.SourceX - texture.TargetX, 0), + Y: (layer?.YOffset ?? 0) + roomTile.Y - Math.Min(roomTile.SourceX - texture.TargetX, 0), + ScaleX: roomTile.ScaleX, + ScaleY: roomTile.ScaleY + )); + } + } + + void AddGameObjects(IList roomGameObjects) + { + foreach (UndertaleRoom.GameObject roomGameObject in roomGameObjects) + { + UndertaleTexturePageItem? texture = roomGameObject.ObjectDefinition?.Sprite?.Textures?.ElementAtOrDefault(roomGameObject.ImageIndex)?.Texture; + if (texture is null) + continue; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texture); + if (image is null) + continue; + + // image, source xywh, target xywh, x/y offset, scale x/y, color, rotation, origin x/y + RenderCommands.Add(new GameObjectRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + BoundingWidth: texture.BoundingWidth, + BoundingHeight: texture.BoundingHeight, + X: roomGameObject.X, + Y: roomGameObject.Y, + ScaleX: roomGameObject.ScaleX, + ScaleY: roomGameObject.ScaleY, + Color: roomGameObject.Color, + Rotation: -roomGameObject.Rotation, + OriginX: roomGameObject.ObjectDefinition!.Sprite.OriginX, + OriginY: roomGameObject.ObjectDefinition!.Sprite.OriginY, + NinePatch: GetNinePatchData(roomGameObject.ObjectDefinition!.Sprite.V3NineSlice, texture) + )); + } + } + + void AddSprites(IList roomSprites, UndertaleRoom.Layer layer) + { + foreach (UndertaleRoom.SpriteInstance roomSprite in roomSprites) + { + UndertaleTexturePageItem? texture = roomSprite.Sprite?.Textures?.ElementAtOrDefault((int)roomSprite.FrameIndex)?.Texture; + if (texture is null) + continue; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texture); + if (image is null) + continue; + + RenderCommands.Add(new SpriteRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + X: layer.XOffset + roomSprite.X, + Y: layer.YOffset + roomSprite.Y, + ScaleX: roomSprite.ScaleX, + ScaleY: roomSprite.ScaleY, + Color: roomSprite.Color, + Rotation: -roomSprite.Rotation, + OriginX: roomSprite.Sprite!.OriginX, + OriginY: roomSprite.Sprite!.OriginY + )); + } + } + + void AddLayerBackground(UndertaleRoom.Layer layer) + { + if (!layer.BackgroundData.Visible) + return; + + if (layer.BackgroundData.Sprite is null) + { + AddBackgroundColor(layer.BackgroundData.Color); + return; + } + + UndertaleTexturePageItem? texture = layer.BackgroundData.Sprite?.Textures?.ElementAtOrDefault((int)layer.BackgroundData.FirstFrame)?.Texture; + if (texture is null) + return; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texture); + if (image is null) + return; + + layer.BackgroundData.UpdateScale(); + + // image, source xywh, target xywh, x/y offset, scale x/y, color, tile h/v, parent w/h + RenderCommands.Add(new BackgroundRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + BoundingWidth: texture.BoundingWidth, + BoundingHeight: texture.BoundingHeight, + X: layer.XOffset, + Y: layer.YOffset, + ScaleX: layer.BackgroundData.CalcScaleX, + ScaleY: layer.BackgroundData.CalcScaleY, + Color: layer.BackgroundData.Color, + TiledHorizontally: layer.BackgroundData.TiledHorizontally, + TiledVertically: layer.BackgroundData.TiledVertically, + RoomWidth: Room.Width, + RoomHeight: Room.Height + )); + } + + void AddLayerTiles(UndertaleRoom.Layer layer) + { + UndertaleTexturePageItem? texture = layer.TilesData.Background?.Texture; + if (texture is null) + return; + + GMImage? gmImage = texture.TexturePage?.TextureData?.Image; + if (gmImage is null) + return; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromGMImage(gmImage); + if (image is null) + return; + + // image, source xywh, target xywh, x/y offset, tilesdata, tile columns, tile w/h, border x/y + RenderCommands.Add(new LayerTilesRenderCommand( + Image: image, + SourceX: texture.SourceX, + SourceY: texture.SourceY, + SourceWidth: texture.SourceWidth, + SourceHeight: texture.SourceHeight, + TargetX: texture.TargetX, + TargetY: texture.TargetY, + TargetWidth: texture.TargetWidth, + TargetHeight: texture.TargetHeight, + X: layer.XOffset, + Y: layer.YOffset, + TileData: layer.TilesData.TileData.Select(x => x.ToArray()).ToArray(), + TileDataW: layer.TilesData.TilesX, + TileDataH: layer.TilesData.TilesY, + TileColumns: layer.TilesData.Background!.GMS2TileColumns, + TileW: layer.TilesData.Background!.GMS2TileWidth, + TileH: layer.TilesData.Background!.GMS2TileHeight, + OutputBorderX: layer.TilesData.Background!.GMS2OutputBorderX, + OutputBorderY: layer.TilesData.Background!.GMS2OutputBorderY + )); + } + + static NinePatchData? GetNinePatchData(UndertaleSprite.NineSlice? nineSlice, UndertaleTexturePageItem texturePageItem) + { + if (nineSlice is null) + return null; + + return new( + nineSlice.Left - texturePageItem.TargetX, + nineSlice.Top - texturePageItem.TargetY, + texturePageItem.BoundingWidth - nineSlice.Right - texturePageItem.TargetX, + texturePageItem.BoundingHeight - nineSlice.Bottom - texturePageItem.TargetY, + nineSlice.TileModes[0], + nineSlice.TileModes[1], + nineSlice.TileModes[2], + nineSlice.TileModes[3], + nineSlice.TileModes[4]); + } + } + + SKCanvas Canvas = null!; + + // Used to keep the images alive while room is open + List usedImages = []; + List currentUsedImages = []; + + readonly List vertices = []; + readonly List texs = []; + + public void RenderCommands(List renderCommands, SKCanvas canvas) + { + Canvas = canvas; + + foreach (var renderCommand in renderCommands) + { + switch (renderCommand) + { + case BackgroundColorRenderCommand c: + RenderBackgroundColorRenderCommand(c); + break; + case BackgroundRenderCommand c: + RenderBackgroundRenderCommand(c); + break; + case TileRenderCommand c: + RenderTileRenderCommand(c); + break; + case GameObjectRenderCommand c: + RenderGameObjectRenderCommand(c); + break; + case SpriteRenderCommand c: + RenderSpriteRenderCommand(c); + break; + case LayerTilesRenderCommand c: + RenderLayerTilesRenderCommand(c); + break; + } + } + + (currentUsedImages, usedImages) = (usedImages, currentUsedImages); + currentUsedImages.Clear(); + } + + void RenderBackgroundColorRenderCommand(BackgroundColorRenderCommand c) + { + Color color = UndertaleColor.ToColor(c.Color); + Canvas.DrawRect(0, 0, c.RoomWidth, c.RoomHeight, new SKPaint { Color = color.ToSKColor() }); + } + + void RenderBackgroundRenderCommand(BackgroundRenderCommand c) + { + currentUsedImages.Add(c.Image); + + var w = c.BoundingWidth * c.ScaleX; + var h = c.BoundingHeight * c.ScaleY; + + var startX = c.TiledHorizontally ? ((c.X % w) - w) : c.X; + var startY = c.TiledVertically ? ((c.Y % h) - h) : c.Y; + + var endX = c.TiledHorizontally ? c.RoomWidth : (startX + w); + var endY = c.TiledVertically ? c.RoomHeight : (startY + h); + + SKPaint skPaint = new() + { + ColorFilter = SKColorFilter.CreateBlendMode(UndertaleColor.ToColor(c.Color).ToSKColor(), SKBlendMode.Modulate), + }; + + for (var x = startX; x < endX; x += w) + { + for (var y = startY; y < endY; y += h) + { + Canvas.Save(); + if (c.TiledHorizontally || c.TiledVertically) + { + // TODO: Only clip in direction of tiling + Canvas.ClipRect(new SKRect(0, 0, c.RoomWidth, c.RoomHeight)); + } + Canvas.Translate(x, y); + Canvas.Scale(c.ScaleX, c.ScaleY); + Canvas.DrawImage(c.Image, SKRect.Create(c.TargetX, c.TargetY, c.TargetWidth, c.TargetHeight), SKSamplingOptions.Default, skPaint); + Canvas.Restore(); + } + } + } + + void RenderTileRenderCommand(TileRenderCommand c) + { + currentUsedImages.Add(c.Image); + + Canvas.Save(); + + Canvas.Translate(c.X, c.Y); + Canvas.Scale(c.ScaleX, c.ScaleY); + Canvas.DrawImage(c.Image, 0, 0, SKSamplingOptions.Default); + Canvas.Restore(); + } + + void RenderGameObjectRenderCommand(GameObjectRenderCommand c) + { + // TODO: roomGameObject.ImageSpeed + + currentUsedImages.Add(c.Image); + + Canvas.Save(); + Canvas.Translate(c.X, c.Y); + Canvas.RotateDegrees(c.Rotation); + + // TODO: Don't use DrawImageNinePatch, draw it manually so tile modes can be used. + // Also, add nine patches to sprites below + + SKPaint paint = new() + { + ColorFilter = SKColorFilter.CreateBlendMode(UndertaleColor.ToColor(c.Color).ToSKColor(), SKBlendMode.Modulate), + }; + + if (c.NinePatch is NinePatchData ninePatch) + { + // Width = scaled bounding width - unscaled left + right side (which is bounding width - target width), similarly for height + + SKRect destination = SKRect.Create( + (-c.OriginX * c.ScaleX) + c.TargetX, + (-c.OriginY * c.ScaleY) + c.TargetY, + (c.BoundingWidth * c.ScaleX) - (c.BoundingWidth - c.TargetWidth), + (c.BoundingHeight * c.ScaleY) - (c.BoundingHeight - c.TargetHeight)); + + //SKRectI center = new(ninePatch.Left, ninePatch.Top, ninePatch.Right, ninePatch.Bottom); + + // TODO: This is necessary so it doesn't crash if the center is outside the actual texture item. + // Can only be fixed by not using DrawImageNinePatch probably + SKRectI center = new(Math.Max(ninePatch.Left, 0), Math.Max(ninePatch.Top, 0), + Math.Min(ninePatch.Right, c.Image.Width), Math.Min(ninePatch.Bottom, c.Image.Height)); + + Canvas.DrawImageNinePatch(c.Image, center, destination, paint); + } + else + { + SKRect destination = SKRect.Create( + -c.OriginX + c.TargetX, + -c.OriginY + c.TargetY, + c.TargetWidth, + c.TargetHeight); + + Canvas.Scale(c.ScaleX, c.ScaleY); + Canvas.DrawImage(c.Image, destination, SKSamplingOptions.Default, paint); + } + + Canvas.Restore(); + } + + void RenderSpriteRenderCommand(SpriteRenderCommand c) + { + // TODO: roomSprite.AnimationSpeed + // TODO: roomSprite.AnimationSpeedType + + currentUsedImages.Add(c.Image); + + Canvas.Save(); + Canvas.Translate(c.X, c.Y); + Canvas.RotateDegrees(c.Rotation); + Canvas.Scale(c.ScaleX, c.ScaleY); + + Canvas.DrawImage(c.Image, SKRect.Create(-c.OriginX + c.TargetX, -c.OriginY + c.TargetY, c.TargetWidth, c.TargetHeight), SKSamplingOptions.Default, new SKPaint() + { + ColorFilter = SKColorFilter.CreateBlendMode(UndertaleColor.ToColor(c.Color).ToSKColor(), SKBlendMode.Modulate), + }); + + Canvas.Restore(); + } + + void RenderLayerTilesRenderCommand(LayerTilesRenderCommand c) + { + currentUsedImages.Add(c.Image); + + vertices.Clear(); + texs.Clear(); + + static void AddQuad(List list, float x1, float y1, float x2, float y2, uint transform) + { + // Flip X + if ((transform & 1) != 0) + (x1, x2) = (x2, x1); + + // Flip Y + if (((transform >> 1) & 1) != 0) + (y1, y2) = (y2, y1); + + SKPoint topLeft; + SKPoint bottomLeft; + SKPoint topRight; + SKPoint bottomRight; + + // Rotate 90 degrees clockwise + if (((transform >> 2) & 1) == 0) + { + topLeft = new SKPoint(x1, y1); + bottomLeft = new SKPoint(x1, y2); + topRight = new SKPoint(x2, y1); + bottomRight = new SKPoint(x2, y2); + } + else + { + topLeft = new SKPoint(x1, y2); + bottomLeft = new SKPoint(x2, y2); + topRight = new SKPoint(x1, y1); + bottomRight = new SKPoint(x2, y1); + } + + list.Add(topLeft); + list.Add(bottomLeft); + list.Add(topRight); + + list.Add(topRight); + list.Add(bottomLeft); + list.Add(bottomRight); + } + + SKRect bounds = Canvas.LocalClipBounds; + + uint startX = Math.Max(0, (uint)(bounds.Left - c.X) / c.TileW); + uint startY = Math.Max(0, (uint)(bounds.Top - c.Y) / c.TileH); + uint endX = Math.Min((uint)Math.Ceiling((bounds.Right - c.X) / c.TileW), c.TileDataW); + uint endY = Math.Min((uint)Math.Ceiling((bounds.Bottom - c.Y) / c.TileH), c.TileDataH); + + for (uint y = startY; y < endY; y++) + for (uint x = startX; x < endX; x++) + { + uint tile = c.TileData[y][x]; + uint tileId = tile & UndertaleRoomViewModel.TILE_ID; + + if (tileId != 0) + { + uint tileOrientation = tile >> 28; + + float posX = x * c.TileW; + float posY = y * c.TileH; + + uint tileX = tileId % c.TileColumns; + uint tileY = tileId / c.TileColumns; + + float xx = c.SourceX + (tileX * (c.TileW + c.OutputBorderX * 2) + c.OutputBorderX); + float yy = c.SourceY + (tileY * (c.TileH + c.OutputBorderY * 2) + c.OutputBorderY); + + AddQuad(texs, xx, yy, xx + c.TileW, yy + c.TileH, tileOrientation); + AddQuad(vertices, posX, posY, posX + c.TileW, posY + c.TileH, 0); + } + } + + using SKShader shader = SKShader.CreateImage(c.Image); + + Canvas.Save(); + Canvas.Translate(c.X, c.Y); + SKPoint[] verticesArray = vertices.ToArray(); + SKPoint[] texsArray = texs.ToArray(); + + using SKPaint paint = new() { Shader = shader }; + + using SKVertices? verticesCopy = SKVertices.CreateCopy(SKVertexMode.Triangles, verticesArray, texsArray, null); + + if (verticesCopy is not null) + Canvas.DrawVertices(verticesCopy, SKBlendMode.Modulate, paint); + + Canvas.Restore(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Core/Scripting.cs b/UndertaleModToolAvalonia/Core/Scripting.cs new file mode 100644 index 000000000..c6fdd15d4 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/Scripting.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.DependencyInjection; +using Underanalyzer.Decompiler; +using UndertaleModLib; +using UndertaleModLib.Decompiler; +using UndertaleModLib.Models; +using UndertaleModLib.Project; +using UndertaleModLib.Scripting; + +namespace UndertaleModToolAvalonia; + +public class Scripting +{ + public readonly MainViewModel MainVM; + + public Scripting(IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + } + + public async Task RunScript(string text, string? filePath = null) + { + try + { + MainVM.IsEnabled = false; + + Script script = CSharpScript.Create(text, ScriptOptions.Default + .AddImports( + "System", + "System.Collections.Generic", + "System.Linq", + "System.IO", + "System.Text", + "System.Text.RegularExpressions", + "System.Threading.Tasks", + "UndertaleModLib", + "UndertaleModLib.Compiler", + "UndertaleModLib.Decompiler", + "UndertaleModLib.Models", + "UndertaleModLib.Scripting") + .AddReferences( + "System.Core", + "UndertaleModLib") + .WithFilePath(filePath) + .WithFileEncoding(Encoding.Default) + .WithEmitDebugInformation(true), + typeof(IScriptInterface)); + + ImmutableArray diagnostics = await Task.Run(() => script.Compile()); + + IEnumerable errors = diagnostics.Where((Diagnostic diagnostic) => diagnostic.Severity == DiagnosticSeverity.Error); + if (errors.Any()) + { + string message = String.Join("\n", errors); + await MainVM.View!.MessageDialog(message, title: "Script compilation error"); + + return null; + } + + ScriptGlobals scripting = new(this, filePath); + + try + { + ScriptState state = await script.RunAsync(scripting); + return state.ReturnValue; + } + catch (ScriptException e) + { + await MainVM.View!.MessageDialog(e.Message, title: "Error from script"); + } + catch (Exception e) + { + await MainVM.View!.MessageDialog(e.ToString(), title: "Script execution error"); + } + finally + { + scripting.Dispose(); + } + } + finally + { + MainVM.IsEnabled = true; + } + + return null; + } +} + +public class ScriptGlobals : IScriptInterface, IDisposable +{ + private readonly MainViewModel mainVM; + private readonly string? scriptPath; + + private ILoaderWindow? loaderWindow; + private int loaderValue; + + public ScriptGlobals(Scripting scripting, string? scriptPath) + { + mainVM = scripting.MainVM; + this.scriptPath = scriptPath; + } + + public void Dispose() + { + loaderWindow?.Close(); + loaderWindow = null; + } + + public UndertaleData? Data => mainVM.Data; + + public ProjectContext? Project => mainVM.Project; + + public string? FilePath => mainVM.DataPath; + + public string? ScriptPath => scriptPath; + + public object Highlighted => throw new NotImplementedException(); + + public object Selected => throw new NotImplementedException(); + + public bool CanSave => throw new NotImplementedException(); + + public bool ScriptExecutionSuccess => throw new NotImplementedException(); + + public string ScriptErrorMessage => throw new NotImplementedException(); + + public string? ExePath => Path.GetDirectoryName(Environment.ProcessPath); + + public string ScriptErrorType => throw new NotImplementedException(); + + public bool IsAppClosed => throw new NotImplementedException(); + + public Action MainThreadAction => Dispatcher.UIThread.Invoke; + + public void AddProgress(int amount) + { + loaderValue += amount; + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }); + } + + public void AddProgressParallel(int amount) + { + Interlocked.Add(ref loaderValue, amount); + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }, DispatcherPriority.Background); + } + + public void ChangeSelection(object newSelection, bool inNewTab = false) + { + // TODO: Implement + } + + public Task ClickableSearchOutput(string title, string query, int resultsCount, IOrderedEnumerable>> resultsDict, bool showInDecompiledView, IOrderedEnumerable? failedList = null) + { + throw new NotImplementedException(); + } + + public Task ClickableSearchOutput(string title, string query, int resultsCount, IDictionary> resultsDict, bool showInDecompiledView, IEnumerable? failedList = null) + { + throw new NotImplementedException(); + } + + public void EnableUI() + { + mainVM.IsEnabled = true; + } + + public string GetDecompiledText(string codeName, GlobalDecompileContext? context = null, IDecompileSettings? settings = null) + { + return GetDecompiledText(mainVM.Data!.Code.ByName(codeName), context, settings); + } + + public string GetDecompiledText(UndertaleCode code, GlobalDecompileContext? context = null, IDecompileSettings? settings = null) + { + context ??= new(mainVM.Data); + settings ??= mainVM.Data!.ToolInfo.DecompilerSettings; + + return new DecompileContext(context, code, settings).DecompileToString(); + } + + public string GetDisassemblyText(string codeName) + { + return GetDisassemblyText(mainVM.Data!.Code.ByName(codeName)); + } + + public string GetDisassemblyText(UndertaleCode code) + { + return code.Disassemble(mainVM.Data!.Variables, mainVM.Data!.CodeLocals?.For(code)); + } + + public int GetProgress() + { + return loaderValue; + } + + public void HideProgressBar() + { + loaderWindow?.Close(); + loaderWindow = null; + } + + public void IncrementProgress() + { + loaderValue++; + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }); + } + + public void IncrementProgressParallel() + { + Interlocked.Increment(ref loaderValue); + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }, DispatcherPriority.Background); + } + + public void InitializeScriptDialog() + { + // TODO: Implement + } + + public bool LintUMTScript(string path) + { + throw new NotImplementedException(); + } + + public bool MakeNewDataFile() + { + mainVM.NewData(); + return true; + } + + public string? PromptChooseDirectory() + { + IReadOnlyList folders = Task.Run(() => mainVM.View!.OpenFolderDialog(new() + { + Title = "Select directory", + })).Result; + + if (folders.Count != 1) + return null; + + return folders[0].TryGetLocalPath(); + } + + public string? PromptLoadFile(string? defaultExt, string? filter) + { + // TODO: filter + var files = Task.Run(() => mainVM.View!.OpenFileDialog(new FilePickerOpenOptions() + { + Title = "Load file", + FileTypeFilter = FilePickerFileTypes.All, + })).Result; + + if (files.Count != 1) + return null; + + return files[0].TryGetLocalPath(); + } + + public string? PromptSaveFile(string defaultExt, string filter) + { + // TODO: filter + var file = Task.Run(() => mainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Save file", + FileTypeChoices = FilePickerFileTypes.All, + DefaultExtension = defaultExt, + })).Result; + + if (file is null) + return null; + + return file.TryGetLocalPath(); + } + + public bool RunUMTScript(string path) + { + throw new NotImplementedException(); + } + + public void ScriptError(string error, string title = "Error", bool SetConsoleText = true) + { + mainVM.View!.MessageDialog(error, title).WaitOnDispatcherFrame(); + + if (SetConsoleText) + { + mainVM.CommandTextBoxText = error; + } + } + + public string? ScriptInputDialog(string title, string label, string defaultInput, string cancelText, string submitText, bool isMultiline, bool preventClose) + { + // TODO: cancelText, submitText, preventClose + return mainVM.View!.TextBoxDialog(label, defaultInput, title: title, isMultiline: isMultiline).WaitOnDispatcherFrame(); + } + + public void ScriptMessage(string message) + { + mainVM.View!.MessageDialog(message, title: "Script message").WaitOnDispatcherFrame(); + } + + public void ScriptOpenURL(string url) + { + mainVM.View!.LaunchUriAsync(new(url)).Wait(); + } + + public bool ScriptQuestion(string message) + { + return mainVM.View!.MessageDialog(message, "Script question", MessageWindow.Buttons.YesNo).WaitOnDispatcherFrame() == MessageWindow.Result.Yes; + } + + public void ScriptWarning(string message) + { + mainVM.View!.MessageDialog(message, title: "Script warning").WaitOnDispatcherFrame(); + } + + public void SetFinishedMessage(bool isFinishedMessageEnabled) + { + // TODO: Implement + } + + public void SetProgress(int value) + { + loaderValue = value; + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }); + } + + public void SetProgressBar(string message, string status, double progressValue, double maxValue) + { + loaderValue = (int)progressValue; + + Dispatcher.UIThread.Invoke(() => + { + loaderWindow ??= mainVM.View!.LoaderOpen(); + loaderWindow.EnsureShown(); + loaderWindow.SetMessage(message); + loaderWindow.SetStatus(status); + loaderWindow.SetValue(loaderValue); + loaderWindow.SetMaximum((int)maxValue); + }); + } + + public void SetProgressBar() + { + Dispatcher.UIThread.Invoke(() => + { + loaderWindow ??= mainVM.View!.LoaderOpen(); + loaderWindow.EnsureShown(); + }); + } + + public void SetUMTConsoleText(string message) + { + mainVM.CommandTextBoxText = message; + } + + public string? SimpleTextInput(string title, string label, string defaultValue, bool allowMultiline, bool showDialog = true) + { + // TODO: showDialog + return mainVM.View!.TextBoxDialog(label, defaultValue, title: title, isMultiline: allowMultiline).WaitOnDispatcherFrame(); + } + + public void SimpleTextOutput(string title, string label, string message, bool allowMultiline) + { + mainVM.View!.TextBoxDialog(label, message, title: title, isMultiline: allowMultiline, isReadOnly: true).WaitOnDispatcherFrame(); + } + + public void StartProgressBarUpdater() + { + // TODO: Implement + } + + public Task StopProgressBarUpdater() + { + // TODO: Implement + return Task.CompletedTask; + } + + public void UpdateProgressBar(string message, string status, double progressValue, double maxValue) + { + SetProgressBar(message, status, progressValue, maxValue); + } + + public void UpdateProgressStatus(string status) + { + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetTextToMessageAndStatus(status: status); + }); + } + + public void UpdateProgressValue(double progressValue) + { + loaderValue = (int)progressValue; + + Dispatcher.UIThread.Post(() => + { + loaderWindow?.SetValue(loaderValue); + }); + } +} diff --git a/UndertaleModToolAvalonia/Core/SettingsFile.cs b/UndertaleModToolAvalonia/Core/SettingsFile.cs new file mode 100644 index 000000000..9ce28f31a --- /dev/null +++ b/UndertaleModToolAvalonia/Core/SettingsFile.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Text.Json; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; + +namespace UndertaleModToolAvalonia; + +public partial class SettingsFile +{ + static readonly string roamingAppData = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UndertaleModToolAvalonia"); + + public SettingsFile() { } + + public static (SettingsFile settingsFile, Exception? exception) Load() + { + SettingsFile? settings = null; + + // Load Settings.json + string settingsPath = Path.Join(roamingAppData, "Settings.json"); + + if (File.Exists(settingsPath)) + { + try + { + string json = File.ReadAllText(settingsPath); + settings = JsonSerializer.Deserialize(json, new JsonSerializerOptions() + { + AllowTrailingCommas = true, + }); + + if (settings is not null) + { + // NOTE: Check for upgrades here. + settings.Version = App.VersionString; + } + } + catch (Exception ex) + { + return (new SettingsFile(), ex); + } + } + + settings ??= new SettingsFile(); + return (settings, null); + } + + public static Exception? LoadStyles() + { + // Load Styles.xaml + string stylesPath = Path.Join(roamingAppData, "Styles.xaml"); + + if (File.Exists(stylesPath)) + { + Styles styles; + try + { + string xaml = File.ReadAllText(stylesPath); + styles = AvaloniaRuntimeXamlLoader.Parse(xaml); + } + catch (Exception ex) + { + return ex; + } + + if (App.CurrentCustomStyles is not null) + App.Current!.Styles.Remove(App.CurrentCustomStyles); + + App.CurrentCustomStyles = styles; + App.Current!.Styles.Add(styles); + } + + return null; + } + + public Exception? Save() + { + Directory.CreateDirectory(roamingAppData); + + string json = JsonSerializer.Serialize(this, new JsonSerializerOptions() + { + WriteIndented = true, + }); + + try + { + File.WriteAllText(Path.Join(roamingAppData, "Settings.json"), json); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ex; + } + return null; + } + + public string Version { get; set; } = App.VersionString; + + public enum ThemeValue + { + SystemDefault = 0, + Light = 1, + Dark = 2, + } + + public ThemeValue Theme + { + get; + set + { + field = value; + App.Current?.RequestedThemeVariant = value switch + { + ThemeValue.SystemDefault => ThemeVariant.Default, + ThemeValue.Light => ThemeVariant.Light, + ThemeValue.Dark => ThemeVariant.Dark, + _ => throw new NotImplementedException(), + }; + } + } + + public bool StartMaximized { get; set; } = true; + + public bool OpenNewResourceAfterCreatingIt { get; set; } = false; + public bool EnableSyntaxHighlighting { get; set; } = true; + public bool AutomaticallyCompileAndDecompileCodeOnLostFocus { get; set; } = true; + + public bool EnableRoomGridByDefault { get; set; } = false; + public uint DefaultRoomGridWidth { get; set; } = 20; + public uint DefaultRoomGridHeight { get; set; } = 20; + + public bool EnableSelectAnyLayerByDefault { get; set; } = true; + + public bool EnableProjectBackup { get; set; } = true; + + public bool AlwaysSaveDataInProjectDestination { get; set; } = true; + + public string InstanceIdPrefix { get; set; } = "inst_"; + + public Underanalyzer.Decompiler.DecompileSettings DecompileSettings { get; set; } = new(); +} diff --git a/UndertaleModToolAvalonia/Core/TaskQueue.cs b/UndertaleModToolAvalonia/Core/TaskQueue.cs new file mode 100644 index 000000000..13cbdd5e6 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/TaskQueue.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace UndertaleModToolAvalonia; + +/// +/// Executes tasks in a queue ordering. Not thread safe. +/// +public class TaskQueue +{ + public int Count => queue.Count; + + readonly Queue queue = new(); + + public Task Add(Func taskFunc) + { + var taskQueueItem = new TaskQueueItemNonGeneric(taskFunc, new TaskCompletionSource()); + + if (queue.Count == 0) + { + queue.Enqueue(taskQueueItem); + Process(); + } + else + { + queue.Enqueue(taskQueueItem); + } + + return taskQueueItem.TaskCompletionSource.Task; + } + + public Task Add(Func> taskFunc) + { + var taskQueueItem = new TaskQueueItemGeneric(taskFunc, new TaskCompletionSource()); + + if (queue.Count == 0) + { + queue.Enqueue(taskQueueItem); + Process(); + } + else + { + queue.Enqueue(taskQueueItem); + } + + return taskQueueItem.TaskCompletionSource.Task; + } + + async void Process() + { + while (queue.Count > 0) + { + TaskQueueItem taskQueueItem = queue.Peek(); + try + { + Task task = taskQueueItem.TaskFunc(); + await task; + queue.Dequeue(); + taskQueueItem.SetTaskCompletionSourceFromTask(task); + } + catch (Exception ex) + { + queue.Dequeue(); + taskQueueItem.SetTaskCompletionSourceException(ex); + } + } + } + + abstract class TaskQueueItem(Func taskFunc) + { + public readonly Func TaskFunc = taskFunc; + public abstract void SetTaskCompletionSourceFromTask(Task task); + public abstract void SetTaskCompletionSourceException(Exception ex); + } + + class TaskQueueItemNonGeneric(Func taskFunc, TaskCompletionSource taskCompletionSource) : TaskQueueItem(taskFunc) + { + public readonly TaskCompletionSource TaskCompletionSource = taskCompletionSource; + public override void SetTaskCompletionSourceFromTask(Task task) => TaskCompletionSource.SetFromTask(task); + public override void SetTaskCompletionSourceException(Exception ex) => TaskCompletionSource.SetException(ex); + } + + class TaskQueueItemGeneric(Func taskFunc, TaskCompletionSource taskCompletionSource) : TaskQueueItem(taskFunc) + { + public readonly TaskCompletionSource TaskCompletionSource = taskCompletionSource; + public override void SetTaskCompletionSourceFromTask(Task task) => TaskCompletionSource.SetFromTask((Task)task); + public override void SetTaskCompletionSourceException(Exception ex) => TaskCompletionSource.SetException(ex); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/AutoGridBehavior.cs b/UndertaleModToolAvalonia/Helpers/AutoGridBehavior.cs new file mode 100644 index 000000000..3efd092b2 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/AutoGridBehavior.cs @@ -0,0 +1,67 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace UndertaleModToolAvalonia; + +/// +/// Automatically sets Grid.Row and Grid.Column on every child of the attached Grid, in order, according to ColumnDefinitions. +/// If RowDefinitions is empty, set it to a list of Autos that matches the amount of rows. +/// +public class AutoGridBehavior : AvaloniaObject +{ + public static readonly AttachedProperty EnableProperty = AvaloniaProperty.RegisterAttached( + "Enable", false, false, BindingMode.OneTime); + + static AutoGridBehavior() + { + EnableProperty.Changed.AddClassHandler(HandleEnableChanged); + } + + private static void HandleEnableChanged(Grid grid, AvaloniaPropertyChangedEventArgs args) + { + if (args.NewValue is not null && (bool)args.NewValue) + { + grid.Initialized += (object? sender, EventArgs _) => + { + int totalColumns = grid.ColumnDefinitions.Count; + + int currColumn = 0; + int currRow = 0; + + for (int i = 0; i < grid.Children.Count; i++) + { + Grid.SetColumn(grid.Children[i], currColumn); + Grid.SetRow(grid.Children[i], currRow); + + currColumn++; + if (currColumn >= totalColumns) + { + currColumn = 0; + currRow++; + } + } + + if (grid.RowDefinitions.Count == 0) + { + if (currColumn != 0) + currRow++; + + for (int i = 0; i < currRow; i++) + grid.RowDefinitions.Add(new RowDefinition(0, GridUnitType.Auto)); + } + }; + } + } + + public static void SetEnable(AvaloniaObject element, bool enableValue) + { + element.SetValue(EnableProperty, enableValue); + } + + public static bool GetEnable(AvaloniaObject element) + { + return element.GetValue(EnableProperty); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/ByteArrayToStringConverter.cs b/UndertaleModToolAvalonia/Helpers/ByteArrayToStringConverter.cs new file mode 100644 index 000000000..b0d1132b1 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/ByteArrayToStringConverter.cs @@ -0,0 +1,44 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +public class ByteArrayToStringConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is byte[] byteArray) + return BitConverter.ToString(byteArray).Replace("-", " "); + return "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + int byteArrayLength = 16; + if (value is string stringValue) + { + string[] hexValues = stringValue.Split(" "); + if (hexValues.Length != byteArrayLength) + { + return new BindingNotification(new InvalidOperationException(), BindingErrorType.DataValidationError); + } + byte[] bytes = new byte[hexValues.Length]; + + try + { + for (int i = 0; i < hexValues.Length; i++) + { + bytes[i] = System.Convert.ToByte(hexValues[i], 16); + } + } + catch (Exception e) when (e is FormatException || e is ArgumentOutOfRangeException || e is OverflowException) + { + return new BindingNotification(new InvalidOperationException(), BindingErrorType.DataValidationError); + } + return bytes; + } + return new BindingNotification(new InvalidOperationException(), BindingErrorType.DataValidationError); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/CodePointToCharConverter.cs b/UndertaleModToolAvalonia/Helpers/CodePointToCharConverter.cs new file mode 100644 index 000000000..2f18499c4 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/CodePointToCharConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +public class CodePointToCharConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ushort valueUshort) + { + return (char)valueUshort; + } + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string valueString) + { + if (!string.IsNullOrEmpty(valueString)) + { + return (ushort)valueString[0]; + } + } + return null; + } +} diff --git a/UndertaleModToolAvalonia/Helpers/DataExplorerItemToContextMenuConverter.cs b/UndertaleModToolAvalonia/Helpers/DataExplorerItemToContextMenuConverter.cs new file mode 100644 index 000000000..c2a6a9505 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/DataExplorerItemToContextMenuConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +public class DataExplorerItemToContextMenuConverter : IValueConverter +{ + public ContextMenu? ListMenu { get; set; } + public ContextMenu? SingleMenu { get; set; } + public ContextMenu? ResourceMenu { get; set; } + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is DataExplorerViewModel.Item treeItem) + { + if (treeItem.Tag?.Equals("list") ?? false) + return ListMenu; + if (treeItem.Value is UndertaleResource) + return ResourceMenu; + if (treeItem.Value is "GeneralInfo" or "GlobalInitScripts" or "GameEndScripts") + return SingleMenu; + } + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/EnumTypeToValuesConverter.cs b/UndertaleModToolAvalonia/Helpers/EnumTypeToValuesConverter.cs new file mode 100644 index 000000000..474ceecd8 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/EnumTypeToValuesConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +public class EnumTypeToValuesConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is Type enumType) + { + return Enum.GetValues(enumType); + } + return BindingNotification.UnsetValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/EventsToExtendedEventConverter.cs b/UndertaleModToolAvalonia/Helpers/EventsToExtendedEventConverter.cs new file mode 100644 index 000000000..33415f558 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/EventsToExtendedEventConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class EventsToExtendedEventConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is UndertalePointerList> list) + { + List newList = new(); + for (int i = 0; i < list.Count; i++) + { + newList.Add(new ExtendedEvent( + subEvents: list[i], + eventType: (EventType)i, + eventName: ((EventType)i).ToString())); + } + return newList; + } + return BindingNotification.UnsetValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class ExtendedEvent(UndertalePointerList subEvents, EventType eventType, string eventName) +{ + public UndertalePointerList SubEvents { get; set; } = subEvents; + public EventType EventType { get; set; } = eventType; + public string EventName { get; set; } = eventName; +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/IdToUndertaleGameObjectConverter.cs b/UndertaleModToolAvalonia/Helpers/IdToUndertaleGameObjectConverter.cs new file mode 100644 index 000000000..7e8f10866 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/IdToUndertaleGameObjectConverter.cs @@ -0,0 +1,38 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +// TODO: Don't use a static field for services. Also somehow make this update when the ids change. +public class IdToUndertaleGameObjectConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is uint id) + { + MainViewModel mainVM = App.Services.GetRequiredService(); + + int intId = (int)id; + if (intId > 0 && intId < mainVM.Data!.GameObjects.Count) + { + return mainVM.Data!.GameObjects[intId]; + } + } + + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is UndertaleGameObject gameObject) + { + MainViewModel mainVM = App.Services.GetRequiredService(); + return mainVM.Data!.GameObjects.IndexOf(gameObject); + } + return BindingOperations.DoNothing; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/LayerAssetsDataToAllAssetsList.cs b/UndertaleModToolAvalonia/Helpers/LayerAssetsDataToAllAssetsList.cs new file mode 100644 index 000000000..36ce78179 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/LayerAssetsDataToAllAssetsList.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class LayerAssetsDataToAllAssetsList : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is UndertaleRoom.Layer.LayerAssetsData layerAssetsData) + { + var allAssets = new List>(); + if (layerAssetsData.LegacyTiles != null) + allAssets.Add(layerAssetsData.LegacyTiles); + if (layerAssetsData.Sprites != null) + allAssets.Add(layerAssetsData.Sprites); + if (layerAssetsData.Sequences != null) + allAssets.Add(layerAssetsData.Sequences); + if (layerAssetsData.NineSlices != null) + allAssets.Add(layerAssetsData.NineSlices); + if (layerAssetsData.ParticleSystems != null) + allAssets.Add(layerAssetsData.ParticleSystems); + if (layerAssetsData.TextItems != null) + allAssets.Add(layerAssetsData.TextItems); + return allAssets; + } + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/LayerTypeTemplateSelector.cs b/UndertaleModToolAvalonia/Helpers/LayerTypeTemplateSelector.cs new file mode 100644 index 000000000..93d93cce6 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/LayerTypeTemplateSelector.cs @@ -0,0 +1,75 @@ +using System; +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class LayerTypeTemplateSelector : ITreeDataTemplate +{ + public ITreeDataTemplate? PathTemplate { get; set; } + public ITreeDataTemplate? BackgroundTemplate { get; set; } + public ITreeDataTemplate? InstancesTemplate { get; set; } + public ITreeDataTemplate? AssetsTemplate { get; set; } + public ITreeDataTemplate? TilesTemplate { get; set; } + public ITreeDataTemplate? EffectTemplate { get; set; } + + public bool Match(object? data) + { + if (data is UndertaleRoom.Layer layer) + { + return layer.LayerType switch + { + UndertaleRoom.LayerType.Path => PathTemplate is not null, + UndertaleRoom.LayerType.Path2 => PathTemplate is not null, + UndertaleRoom.LayerType.Background => BackgroundTemplate is not null, + UndertaleRoom.LayerType.Instances => InstancesTemplate is not null, + UndertaleRoom.LayerType.Assets => AssetsTemplate is not null, + UndertaleRoom.LayerType.Tiles => TilesTemplate is not null, + UndertaleRoom.LayerType.Effect => EffectTemplate is not null, + _ => false, + }; + } + return false; + } + + public IDisposable BindChildren(AvaloniaObject target, AvaloniaProperty targetProperty, object item) + { + if (item is UndertaleRoom.Layer layer) + { + return layer.LayerType switch + { + UndertaleRoom.LayerType.Path => PathTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Path2 => PathTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Background => BackgroundTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Instances => InstancesTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Assets => AssetsTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Tiles => TilesTemplate?.BindChildren(target, targetProperty, item), + UndertaleRoom.LayerType.Effect => EffectTemplate?.BindChildren(target, targetProperty, item), + _ => null, + } ?? Disposable.Empty; + } + return Disposable.Empty; + } + + public Control? Build(object? param) + { + if (param is UndertaleRoom.Layer layer) + { + return layer.LayerType switch + { + UndertaleRoom.LayerType.Path => PathTemplate?.Build(layer), + UndertaleRoom.LayerType.Path2 => PathTemplate?.Build(layer), + UndertaleRoom.LayerType.Background => BackgroundTemplate?.Build(layer), + UndertaleRoom.LayerType.Instances => InstancesTemplate?.Build(layer), + UndertaleRoom.LayerType.Assets => AssetsTemplate?.Build(layer), + UndertaleRoom.LayerType.Tiles => TilesTemplate?.Build(layer), + UndertaleRoom.LayerType.Effect => EffectTemplate?.Build(layer), + _ => null, + }; + } + return null; + } +} diff --git a/UndertaleModToolAvalonia/Helpers/LevelToWidthConverter.cs b/UndertaleModToolAvalonia/Helpers/LevelToWidthConverter.cs new file mode 100644 index 000000000..fba17a99e --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/LevelToWidthConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +public class LevelToWidthConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int level) + { + return level * 20; + } + return 0; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/RoomItemToContextMenuConverter.cs b/UndertaleModToolAvalonia/Helpers/RoomItemToContextMenuConverter.cs new file mode 100644 index 000000000..a35e5559c --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/RoomItemToContextMenuConverter.cs @@ -0,0 +1,60 @@ +using System; +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class RoomItemToContextMenuConverter : IValueConverter +{ + public ContextMenu? GameObjectListMenu { get; set; } + public ContextMenu? GameObjectMenu { get; set; } + public ContextMenu? TileListMenu { get; set; } + public ContextMenu? TileMenu { get; set; } + public ContextMenu? LayersListMenu { get; set; } + public ContextMenu? GenericLayerMenu { get; set; } + public ContextMenu? InstancesLayerMenu { get; set; } + public ContextMenu? AssetsLayerMenu { get; set; } + public ContextMenu? SpriteInstanceMenu { get; set; } + public ContextMenu? SequenceInstanceMenu { get; set; } + public ContextMenu? ParticleSystemInstanceMenu { get; set; } + public ContextMenu? TextItemInstanceMenu { get; set; } + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + switch (value) + { + case UndertaleRoomViewModel.RoomTreeItem { Tag: "GameObjects" }: + return GameObjectListMenu; + case UndertaleRoom.GameObject: + return GameObjectMenu; + case UndertaleRoomViewModel.RoomTreeItem { Tag: "Tiles" }: + return TileListMenu; + case UndertaleRoom.Tile: + return TileMenu; + case UndertaleRoomViewModel.RoomTreeItem { Tag: "Layers" }: + return LayersListMenu; + case UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Instances }: + return InstancesLayerMenu; + case UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets }: + return AssetsLayerMenu; + case UndertaleRoom.Layer: + return GenericLayerMenu; + case UndertaleRoom.SpriteInstance: + return SpriteInstanceMenu; + case UndertaleRoom.SequenceInstance: + return SequenceInstanceMenu; + case UndertaleRoom.ParticleSystemInstance: + return ParticleSystemInstanceMenu; + case UndertaleRoom.TextItemInstance: + return TextItemInstanceMenu; + } + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/TabStripItemDropHandler.cs b/UndertaleModToolAvalonia/Helpers/TabStripItemDropHandler.cs new file mode 100644 index 000000000..ae2237b90 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/TabStripItemDropHandler.cs @@ -0,0 +1,45 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Xaml.Interactions.DragAndDrop; + +namespace UndertaleModToolAvalonia; + +public class TabStripItemDropHandler : DropHandlerBase +{ + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (sourceContext is TabItemViewModel) + { + return true; + } + + return false; + } + + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is MainViewModel mainVM) + { + if (e.Source is Control control && sourceContext is TabItemViewModel draggedTabItem) + { + int draggedIndex = mainVM.Tabs.Tabs.IndexOf(draggedTabItem); + int droppedIndex = mainVM.Tabs.Tabs.Count - 1; + + var sourceTabStripItem = control.FindLogicalAncestorOfType(); + if (sourceTabStripItem is not null && sourceTabStripItem.DataContext is TabItemViewModel droppedTabItem) + { + droppedIndex = mainVM.Tabs.Tabs.IndexOf(droppedTabItem); + } + + MoveItem(mainVM.Tabs.Tabs, draggedIndex, droppedIndex); + + mainVM.Tabs.TabSelected = draggedTabItem; + return true; + } + } + + return false; + } +} diff --git a/UndertaleModToolAvalonia/Helpers/UndertaleColorToColorConverter.cs b/UndertaleModToolAvalonia/Helpers/UndertaleColorToColorConverter.cs new file mode 100644 index 000000000..f30ef3a96 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/UndertaleColorToColorConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace UndertaleModToolAvalonia; + +public class UndertaleColorToColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is uint color) + { + return UndertaleColor.ToColor(color); + } + return BindingOperations.DoNothing; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is Color color) + { + return UndertaleColor.FromColor(color); + } + return BindingOperations.DoNothing; + } +} + +public static class UndertaleColor +{ + public static uint FromColor(Color color) + { + return (uint)((color.A << 24) | (color.B << 16) | (color.G << 8) | color.R); + } + + public static Color ToColor(uint color) + { + return Color.FromArgb((byte)((color >> 24) & 0xff), (byte)(color & 0xff), (byte)((color >> 8) & 0xff), (byte)((color >> 16) & 0xff)); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Helpers/ValueDataTemplate.cs b/UndertaleModToolAvalonia/Helpers/ValueDataTemplate.cs new file mode 100644 index 000000000..39fea0914 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/ValueDataTemplate.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls.Templates; +using Avalonia.Markup.Xaml.Templates; + +namespace UndertaleModToolAvalonia; + +public class ValueDataTemplate : DataTemplate, IDataTemplate +{ + public object? Value { get; set; } + + bool IDataTemplate.Match(object? data) + { + return (DataType is null || DataType.IsInstanceOfType(data)) + && (Value is null || (data?.Equals(Value) ?? false)); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/VersionCompareConverter.cs b/UndertaleModToolAvalonia/Helpers/VersionCompareConverter.cs new file mode 100644 index 000000000..711733139 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/VersionCompareConverter.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.Linq; +using Avalonia.Data; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +/// +/// Checks if a version is greater then or equal, or less than, some value. Bind it to MainViewModel.DataVersion. +/// Parameter follows this pattern: [operation][major[.minor[.release[.build]]]] +/// Operation can be GE (greater or equal) or L (less than). +/// Usage: +/// +/// {Binding $parent[l:MainView].((l:MainViewModel)DataContext).DataVersion, +/// Converter={StaticResource VersionCompareConverter}, +/// ConverterParameter=2} +/// +/// +public class VersionCompareConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ValueTuple _version && parameter is string compareString) + { + (uint Major, uint Minor, uint Release, uint Build) version = _version; + uint[] versionList = [version.Major, version.Minor, version.Release, version.Build]; + + string operation = "GE"; + + if (compareString.StartsWith("GE")) + { + operation = "GE"; + compareString = compareString[("GE".Length)..]; + } + else if (compareString.StartsWith("L")) + { + operation = "L"; + compareString = compareString[("L".Length)..]; + } + + uint[] versionCompareList = [.. compareString.Split('.').Select(x => uint.Parse(x))]; + + for (int i = 0; i < versionCompareList.Length; i++) + { + if (versionList[i] != versionCompareList[i]) + if (operation == "GE") + return versionList[i] > versionCompareList[i]; + else if (operation == "L") + return versionList[i] < versionCompareList[i]; + } + + if (operation == "GE") + return true; + else if (operation == "L") + return false; + } + + return new BindingNotification(new InvalidOperationException(), BindingErrorType.Error); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/UndertaleModToolAvalonia/Helpers/ZoomConverter.cs b/UndertaleModToolAvalonia/Helpers/ZoomConverter.cs new file mode 100644 index 000000000..be27b3259 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/ZoomConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; + +namespace UndertaleModToolAvalonia; + +public class ZoomConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is double zoom) + return (zoom * 100) + "%"; + return BindingOperations.DoNothing; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string zoom) + { + if (double.TryParse(zoom.Replace("%", ""), out double result)) + { + return result / 100; + } + } + return BindingOperations.DoNothing; + } +} diff --git a/UndertaleModToolAvalonia/Program.cs b/UndertaleModToolAvalonia/Program.cs new file mode 100644 index 000000000..28f244137 --- /dev/null +++ b/UndertaleModToolAvalonia/Program.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia; +using SDL3; + +namespace UndertaleModToolAvalonia; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += (source, e) => + { + HandleException((e.ExceptionObject as Exception)!); + }; + + TaskScheduler.UnobservedTaskException += (source, e) => + { + HandleException(e.Exception); + }; + + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args, Avalonia.Controls.ShutdownMode.OnMainWindowClose); + } + catch (Exception ex) + { + HandleException(ex); + throw; + } + } + + public static void HandleException(Exception ex) + { + string localAppData = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UndertaleModToolAvalonia"); + Directory.CreateDirectory(localAppData); + + File.WriteAllText(Path.Join(localAppData, "CrashLog.txt"), ex.ToString()); + + SDL.ShowSimpleMessageBox(SDL3.SDL.MessageBoxFlags.Error, + "UndertaleModToolAvalonia " + App.VersionString, ex.ToString(), 0); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .With(new SkiaOptions() { MaxGpuResourceSizeBytes = null }) + .LogToTrace(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/DescriptionViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/DescriptionViewModel.cs new file mode 100644 index 000000000..47f36f014 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/DescriptionViewModel.cs @@ -0,0 +1,12 @@ +namespace UndertaleModToolAvalonia; + +public class DescriptionViewModel : ITabContent +{ + public string Heading { get; set; } + public string Description { get; set; } + public DescriptionViewModel(string heading, string description) + { + Heading = heading; + Description = description; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml new file mode 100644 index 000000000..c2902a7bd --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml.cs new file mode 100644 index 000000000..9c34bc642 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class GameEndScriptsView : UserControl +{ + public GameEndScriptsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsViewModel.cs new file mode 100644 index 000000000..d84f8f1ef --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GameEndScriptsViewModel.cs @@ -0,0 +1,16 @@ +using System.Collections.ObjectModel; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class GameEndScriptsViewModel : ITabContent +{ + public ObservableCollection GameEndScripts { get; } + + public GameEndScriptsViewModel(ObservableCollection gameEndScripts) + { + GameEndScripts = gameEndScripts; + } + + public static UndertaleGlobalInit CreateGlobalInit() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml new file mode 100644 index 000000000..444646212 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml.cs new file mode 100644 index 000000000..c411aca1c --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class GeneralInfoView : UserControl +{ + public GeneralInfoView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GeneralInfoViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoViewModel.cs new file mode 100644 index 000000000..61cec4a71 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GeneralInfoViewModel.cs @@ -0,0 +1,21 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class GeneralInfoViewModel : ITabContent +{ + public UndertaleGeneralInfo GeneralInfo { get; } + public UndertaleOptions Options { get; } + public UndertaleLanguage Language { get; } + + public GeneralInfoViewModel(UndertaleData data) + { + GeneralInfo = data.GeneralInfo; + Options = data.Options; + Language = data.Language; + } + + public static UndertaleResourceById CreateRoomOrderItem() => new(); + public static UndertaleOptions.Constant CreateConstant() => new(); +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml new file mode 100644 index 000000000..d714ff5ae --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml.cs new file mode 100644 index 000000000..f8a8d2992 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class GlobalInitScriptsView : UserControl +{ + public GlobalInitScriptsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsViewModel.cs new file mode 100644 index 000000000..22bac0431 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/GlobalInitScriptsViewModel.cs @@ -0,0 +1,16 @@ +using System.Collections.ObjectModel; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class GlobalInitScriptsViewModel : ITabContent +{ + public ObservableCollection GlobalInitScripts { get; } + + public GlobalInitScriptsViewModel(ObservableCollection globalInitScripts) + { + GlobalInitScripts = globalInitScripts; + } + + public static UndertaleGlobalInit CreateGlobalInit() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/IUndertaleResourceViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/IUndertaleResourceViewModel.cs new file mode 100644 index 000000000..eb518b39a --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/IUndertaleResourceViewModel.cs @@ -0,0 +1,8 @@ +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +interface IUndertaleResourceViewModel : ITabContent +{ + UndertaleResource Resource { get; } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml new file mode 100644 index 000000000..8ebe5b4a4 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml.cs new file mode 100644 index 000000000..e321635fc --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleAnimationCurveView : UserControl +{ + public UndertaleAnimationCurveView() + { + InitializeComponent(); + + DataContextChanged += (_, __) => + { + if (DataContext is UndertaleAnimationCurveViewModel vm) + { + vm.ChannelSelectedChanged(ChannelsDataGrid.DataGridControl.SelectedItem); + } + }; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveViewModel.cs new file mode 100644 index 000000000..33390e6c5 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveViewModel.cs @@ -0,0 +1,27 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleAnimationCurveViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public UndertaleResource Resource => AnimationCurve; + public UndertaleAnimationCurve AnimationCurve { get; } + + [ObservableProperty] + public partial UndertaleAnimationCurve.Channel? ChannelSelected { get; set; } + + public UndertaleAnimationCurveViewModel(UndertaleAnimationCurve animationCurve) + { + AnimationCurve = animationCurve; + } + + public static UndertaleAnimationCurve.Channel CreateChannel() => new(); + public static UndertaleAnimationCurve.Channel.Point CreatePoint() => new(); + + public void ChannelSelectedChanged(object? item) + { + ChannelSelected = (UndertaleAnimationCurve.Channel?)item; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml new file mode 100644 index 000000000..6f56179ac --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml.cs new file mode 100644 index 000000000..453c74e31 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleAudioGroupView : UserControl +{ + public UndertaleAudioGroupView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupViewModel.cs new file mode 100644 index 000000000..67ab7b6e3 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAudioGroupViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleAudioGroupViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => AudioGroup; + public UndertaleAudioGroup AudioGroup { get; } + + public UndertaleAudioGroupViewModel(UndertaleAudioGroup audioGroup) + { + AudioGroup = audioGroup; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml new file mode 100644 index 000000000..4b0ec12fc --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml.cs new file mode 100644 index 000000000..cba476c76 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleBackgroundView : UserControl +{ + public UndertaleBackgroundView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundViewModel.cs new file mode 100644 index 000000000..f29f96dbe --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundViewModel.cs @@ -0,0 +1,26 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleBackgroundViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Background; + public UndertaleBackground Background { get; } + + public UndertaleBackgroundViewModel(UndertaleBackground background) + { + Background = background; + } + + public static UndertaleBackground.TileID CreateTileID() => new(); + + public void AutoTileIDs() + { + Background.GMS2TileIds.Clear(); + + for (uint i = 0; i < Background.GMS2TileCount; i++) + for (uint j = 0; j < Background.GMS2ItemsPerTileCount; j++) + Background.GMS2TileIds.Add(new UndertaleBackground.TileID() { ID = i }); + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml new file mode 100644 index 000000000..dc6ce1bd8 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml.cs new file mode 100644 index 000000000..70f0be500 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeLocalsView : UserControl +{ + public UndertaleCodeLocalsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsViewModel.cs new file mode 100644 index 000000000..175c6ebf3 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsViewModel.cs @@ -0,0 +1,18 @@ +using System; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeLocalsViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => CodeLocals; + public UndertaleCodeLocals CodeLocals { get; } + + public UndertaleCodeLocalsViewModel(UndertaleCodeLocals codeLocals) + { + CodeLocals = codeLocals; + } + + public static Func CreateLocalVar => index => new UndertaleCodeLocals.LocalVar() { Index = (uint)index }; +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml new file mode 100644 index 000000000..f6a952a03 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + Not compiled + Compiled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs new file mode 100644 index 000000000..97a271017 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs @@ -0,0 +1,938 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using System.Threading.Tasks; +using System.Xml; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Platform; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Highlighting.Xshd; +using AvaloniaEdit.Rendering; +using UndertaleModLib; +using UndertaleModLib.Compiler; +using UndertaleModLib.Models; +using static UndertaleModToolAvalonia.UndertaleCodeViewModel; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeView : UserControl +{ + static IHighlightingDefinition? GMLHighlightingDefinition = null; + static IHighlightingDefinition? ASMHighlightingDefinition = null; + static uint HighlightingMajorVersion = 0; + + static readonly Dictionary ScriptsCache = new(); + static readonly Dictionary FunctionsCache = new(); + static readonly Dictionary CodeCache = new(); + static readonly Dictionary NamedResourcesCache = new(); + + readonly List codeLocalsCache = new(); + + readonly NumberGenerator gmlNumberGenerator; + readonly NameGenerator gmlNameGenerator; + readonly NameGenerator asmNameGenerator; + + (TextLocation, TextLocation) lastCaretLocations; + + // TODO: Don't use static variable + private static double ZoomFontSize = -1; + + public UndertaleCodeView() + { + InitializeComponent(); + + gmlNumberGenerator = new(this); + gmlNameGenerator = new(this); + asmNameGenerator = new(this); + + InitializeTextEditor(GMLTextEditor); + InitializeTextEditor(ASMTextEditor); + + GMLTextEditor.TextArea.GotFocus += GMLTextEditor_GotFocus; + ASMTextEditor.TextArea.GotFocus += ASMTextEditor_GotFocus; + + GMLTextEditor.TextArea.LostFocus += GMLTextEditor_LostFocus; + ASMTextEditor.TextArea.LostFocus += ASMTextEditor_LostFocus; + } + + protected override async void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == DataContextProperty) + { + if (change.OldValue is UndertaleCodeViewModel oldVM) + { + oldVM.PropertyChanged -= DataContext_PropertyChanged; + } + + if (change.NewValue is not UndertaleCodeViewModel vm) + return; + + vm.PropertyChanged += DataContext_PropertyChanged; + + if (vm.MainVM.Settings.EnableSyntaxHighlighting) + { + // Reload highlighting if major version changed + if (HighlightingMajorVersion != vm.MainVM.Data!.GeneralInfo.Major) + { + GMLHighlightingDefinition = null; + ASMHighlightingDefinition = null; + } + + HighlightingMajorVersion = vm.MainVM.Data!.GeneralInfo.Major; + + GMLHighlightingDefinition ??= LoadHighlightingDefinition("GML"); + GMLTextEditor.SyntaxHighlighting = GMLHighlightingDefinition; + + ASMHighlightingDefinition ??= LoadHighlightingDefinition("ASM"); + ASMTextEditor.SyntaxHighlighting = ASMHighlightingDefinition; + + if (!GMLTextEditor.TextArea.TextView.ElementGenerators.Contains(gmlNumberGenerator)) + GMLTextEditor.TextArea.TextView.ElementGenerators.Add(gmlNumberGenerator); + + if (!GMLTextEditor.TextArea.TextView.ElementGenerators.Contains(gmlNameGenerator)) + GMLTextEditor.TextArea.TextView.ElementGenerators.Add(gmlNameGenerator); + + if (!ASMTextEditor.TextArea.TextView.ElementGenerators.Contains(asmNameGenerator)) + ASMTextEditor.TextArea.TextView.ElementGenerators.Add(asmNameGenerator); + } + else + { + GMLTextEditor.SyntaxHighlighting = null; + ASMTextEditor.SyntaxHighlighting = null; + GMLHighlightingDefinition = null; + ASMHighlightingDefinition = null; + + GMLTextEditor.TextArea.TextView.ElementGenerators.Remove(gmlNumberGenerator); + GMLTextEditor.TextArea.TextView.ElementGenerators.Remove(gmlNameGenerator); + ASMTextEditor.TextArea.TextView.ElementGenerators.Remove(asmNameGenerator); + } + + UpdateHighlightingCache(); + + GMLTextEditor.TextArea.Caret.Location = default; + ASMTextEditor.TextArea.Caret.Location = default; + lastCaretLocations = default; + + vm.GMLTabState = TabState.NeedsDecompile; + vm.ASMTabState = TabState.NeedsDecompile; + + await GoToLastGoToLocation(); + await vm.DecompileCurrent(); + + GMLTextEditor.Document.UndoStack.ClearAll(); + ASMTextEditor.Document.UndoStack.ClearAll(); + } + } + + private void DataContext_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (DataContext is UndertaleCodeViewModel vm) + { + switch (e.PropertyName) + { + case nameof(UndertaleCodeViewModel.IsCodeProcessing): + if (vm.IsCodeProcessing) + { + // Save carets + lastCaretLocations = (GMLTextEditor.TextArea.Caret.Location, ASMTextEditor.TextArea.Caret.Location); + } + else + { + // Load carets + GMLTextEditor.TextArea.Caret.Location = lastCaretLocations.Item1; + ASMTextEditor.TextArea.Caret.Location = lastCaretLocations.Item2; + } + break; + + case nameof(UndertaleCodeViewModel.LastGoToLocation): + _ = GoToLastGoToLocation(); + break; + } + } + } + + static IHighlightingDefinition LoadHighlightingDefinition(string name) + { + using (XmlReader reader = XmlReader.Create(AssetLoader.Open(new Uri($"avares://{Assembly.GetExecutingAssembly().FullName}/Assets/Syntax{name}.xshd")))) + { + IHighlightingDefinition definition = HighlightingLoader.Load(reader, HighlightingManager.Instance); + + // Remove string escaping rule from GMS1, since it doesn't have that. + if (HighlightingMajorVersion < 2) + { + foreach (HighlightingSpan span in definition.MainRuleSet.Spans) + { + string expression = span.StartExpression.ToString(); + if (expression == "\"" || expression == "'") + span.RuleSet.Spans.Clear(); + } + } + + return definition; + } + } + + void UpdateHighlightingCache() + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + ScriptsCache.Clear(); + FunctionsCache.Clear(); + CodeCache.Clear(); + NamedResourcesCache.Clear(); + codeLocalsCache.Clear(); + + if (!vm.MainVM.Settings.EnableSyntaxHighlighting) + return; + + UndertaleData? data = vm.MainVM.Data; + if (data is null) + return; + + foreach (var script in data.Scripts) + { + if (script is null || script.Name is null) + continue; + ScriptsCache[script.Name.Content] = script; + } + + foreach (var function in data.Functions) + { + if (function is null || function.Name is null) + continue; + FunctionsCache[function.Name.Content] = function; + } + + foreach (var code in data.Code) + { + if (code is null || code.Name is null) + continue; + CodeCache[code.Name.Content] = code; + } + + // NOTE: Remember to add new types + IEnumerable?[] objLists = [ + data.Sounds, + data.Sprites, + data.Backgrounds, + data.Paths, + data.Scripts, + data.Fonts, + data.GameObjects, + data.Rooms, + data.Extensions, + data.Shaders, + data.Timelines, + data.AnimationCurves, + data.Sequences, + data.AudioGroups + ]; + + foreach (IEnumerable? list in objLists) + { + if (list is null) + continue; + + foreach (var obj in list) + { + if (obj is UndertaleNamedResource namedObj && namedObj.Name is not null) + NamedResourcesCache[namedObj.Name.Content] = namedObj; + } + } + + UndertaleCodeLocals? locals = data.CodeLocals?.ByName(vm.Code.Name?.Content); + if (locals != null) + { + foreach (var local in locals.Locals) + codeLocalsCache.Add(local.Name.Content); + codeLocalsCache.Sort(); + } + + GMLTextEditor.TextArea.TextView.Redraw(); + ASMTextEditor.TextArea.TextView.Redraw(); + } + + void InitializeTextEditor(TextEditor textEditor) + { + textEditor.AddHandler(PointerWheelChangedEvent, TextEditor_MouseWheelChanged, RoutingStrategies.Tunnel); + textEditor.AddHandler(KeyDownEvent, TextEditor_KeyDown, RoutingStrategies.Tunnel); + + textEditor.Options.ConvertTabsToSpaces = true; + textEditor.Options.HighlightCurrentLine = true; + } + + public async Task GoToLastGoToLocation() + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (vm.LastGoToLocation is not (Tab tab, int line, int column) location) + return; + + vm.SelectedTab = location.Tab; + + await vm.DecompileCurrent(); + + TextEditor textEditor = (location.Tab == Tab.GML) ? GMLTextEditor : ASMTextEditor; + + textEditor.TextArea.Caret.Location = new(location.Line, location.Column); + textEditor.TextArea.Focus(); + + void OnLayoutUpdated(object? sender, EventArgs e) + { + textEditor.ScrollTo(location.Line, location.Column); + textEditor.LayoutUpdated -= OnLayoutUpdated; + } + + textEditor.LayoutUpdated += OnLayoutUpdated; + + // HACK: I don't know how to check if the layout has updated already here or not, so I just invalidate it to call the above function. + textEditor.InvalidateMeasure(); + + vm.LastGoToLocation = null; + } + + private void GMLTextEditor_GotFocus(object? sender, FocusChangedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.GMLFocused = true; + + UpdateHighlightingCache(); + } + + private void ASMTextEditor_GotFocus(object? sender, FocusChangedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.ASMFocused = true; + + UpdateHighlightingCache(); + } + + private void GMLTextEditor_LostFocus(object? sender, FocusChangedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (e.NavigationMethod == NavigationMethod.Unspecified) + return; + + if (vm.GMLFocused && vm.MainVM.Settings.AutomaticallyCompileAndDecompileCodeOnLostFocus) + { + vm.GMLFocused = false; + vm.CompileAndDecompileTab(Tab.GML); + } + } + + private void ASMTextEditor_LostFocus(object? sender, FocusChangedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (e.NavigationMethod == NavigationMethod.Unspecified) + return; + + if (vm.ASMFocused && vm.MainVM.Settings.AutomaticallyCompileAndDecompileCodeOnLostFocus) + { + vm.ASMFocused = false; + vm.CompileAndDecompileTab(Tab.ASM); + } + } + + private void GMLTextEditor_TextChanged(object? sender, EventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (!vm.IsCodeProcessing) + { + vm.GMLTabState = TabState.NeedsCompile; + vm.MainVM.Project?.MarkAssetForExport(vm.Code); + } + } + + private void ASMTextEditor_TextChanged(object? sender, EventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (!vm.IsCodeProcessing) + { + vm.ASMTabState = TabState.NeedsCompile; + } + } + + private void TextEditor_MouseWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + e.Handled = true; + AddZoomFontSize(Math.Sign(e.Delta.Y)); + } + } + + private void TextEditor_KeyDown(object? sender, KeyEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + if (e.PhysicalKey is PhysicalKey.Equal or PhysicalKey.NumPadAdd) + { + e.Handled = true; + AddZoomFontSize(1); + } + else if (e.PhysicalKey is PhysicalKey.Minus or PhysicalKey.NumPadSubtract) + { + e.Handled = true; + AddZoomFontSize(-1); + } + } + } + + private void AddZoomFontSize(double zoomValue) + { + if (ZoomFontSize == -1) + { + // NOTE: Bad + ZoomFontSize = GMLTextEditor.FontSize; + } + + ZoomFontSize = Math.Clamp(Math.Floor(ZoomFontSize + zoomValue), 1, double.PositiveInfinity); + + IEnumerable list = [GMLTextEditor, ASMTextEditor]; + foreach (TextEditor textEditor in list) + { + TextView view = textEditor.TextArea.TextView; + + var line1 = view.GetDocumentLineByVisualTop(view.ScrollOffset.Y); + var lineToScrollTo = line1.LineNumber; + + var line2 = line1.NextLine; + + if (line2 != null) + { + var line1Top = view.GetVisualTopByDocumentLine(line1.LineNumber); + var line2Top = view.GetVisualTopByDocumentLine(line2.LineNumber); + + var height = (line2Top - line1Top); + + var yAboveScrollOffset = view.ScrollOffset.Y - line1Top; + + // If more than halfway above the scroll offset, jump to next line + if (yAboveScrollOffset > height / 2) + lineToScrollTo = line2.LineNumber; + } + + textEditor.FontSize = ZoomFontSize; + + textEditor.ScrollTo(lineToScrollTo, 1, VisualYPosition.LineTop, 0, 0); + } + } + + // TODO: This code was mostly copied over, so it would be great if it could be made nicer. Or maybe do things differently. + public class NumberGenerator : VisualLineElementGenerator + { + readonly UndertaleCodeView codeView; + readonly ContextMenu contextMenu = new(); + + // + readonly Dictionary lineNumberSections = []; + + public NumberGenerator(UndertaleCodeView codeView) + { + this.codeView = codeView; + contextMenu.Placement = PlacementMode.Pointer; + } + + public override void StartGeneration(ITextRunConstructionContext context) + { + base.StartGeneration(context); + + // Find sections of line that are highlighted as numbers + lineNumberSections.Clear(); + + DocumentLine documentLine = context.VisualLine.FirstDocumentLine; + if (documentLine.Length != 0) + { + int line = documentLine.LineNumber; + + IHighlighter highlighter = (IHighlighter)CurrentContext.TextView.GetService(typeof(IHighlighter)); + HighlightedLine highlightedLine = highlighter.HighlightLine(line); + + foreach (var section in highlightedLine.Sections) + { + if (section.Color.Name == "Number") + lineNumberSections[section.Offset] = section.Length; + } + } + } + + public override int GetFirstInterestedOffset(int startOffset) + { + foreach ((int offset, _) in lineNumberSections) + { + if (startOffset <= offset) + return offset; + } + return -1; + } + + public override VisualLineElement? ConstructElement(int offset) + { + if (!lineNumberSections.TryGetValue(offset, out int length)) + return null; + + TextDocument document = CurrentContext.Document; + TextView textView = CurrentContext.TextView; + TextEditor textEditor = (TextEditor)textView.GetService(typeof(TextEditor)); + + UndertaleCodeViewModel codeViewModel = (UndertaleCodeViewModel)codeView.DataContext!; + UndertaleData data = codeViewModel.MainVM.Data!; + + string text = document.GetText(offset, length); + ClickVisualLineText visualLine = new(text, CurrentContext.VisualLine, length); + + visualLine.Clicked += bool (string text, MouseButton button, bool controlKey) => + { + if (!textEditor.TextArea.IsFocused) + return false; + + if (button != MouseButton.Right) + return false; + + if (!int.TryParse(text, out int id)) + return false; + + int documentOffset = visualLine.ParentVisualLine.StartOffset + visualLine.RelativeTextOffset; + + List possibleObjects = []; + + if (id >= 0) + { + // NOTE: Remember to add new types + if (id < data.Sprites.Count) + possibleObjects.Add(data.Sprites[id]); + if (id < data.Rooms.Count) + possibleObjects.Add(data.Rooms[id]); + if (id < data.GameObjects.Count) + possibleObjects.Add(data.GameObjects[id]); + if (id < data.Backgrounds.Count) + possibleObjects.Add(data.Backgrounds[id]); + if (id < data.Scripts.Count) + possibleObjects.Add(data.Scripts[id]); + if (id < data.Paths.Count) + possibleObjects.Add(data.Paths[id]); + if (id < data.Fonts.Count) + possibleObjects.Add(data.Fonts[id]); + if (id < data.Sounds.Count) + possibleObjects.Add(data.Sounds[id]); + if (id < data.Shaders.Count) + possibleObjects.Add(data.Shaders[id]); + if (id < data.Timelines.Count) + possibleObjects.Add(data.Timelines[id]); + if (id < data.AnimationCurves?.Count) + possibleObjects.Add(data.AnimationCurves[id]); + if (id < data.Sequences?.Count) + possibleObjects.Add(data.Sequences[id]); + if (id < data.ParticleSystems?.Count) + possibleObjects.Add(data.ParticleSystems[id]); + if (id < data.AudioGroups?.Count) + possibleObjects.Add(data.AudioGroups[id]); + } + + contextMenu.Items.Clear(); + + foreach (UndertaleNamedResource? obj in possibleObjects) + { + if (obj?.Name is null) + continue; + + MenuItem item = new(); + item.Header = obj.ToString()?.Replace("_", "__"); + item.Click += (_, _) => + { + document.Replace(documentOffset, text.Length, obj.Name.Content, null); + }; + contextMenu.Items.Add(item); + } + + if (id >= 0) + { + string color = "0x" + id.ToString("X6"); + + MenuItem item = new(); + item.Header = color + " (color)"; + item.Click += (_, _) => + { + document.Replace(documentOffset, text.Length, color, null); + }; + contextMenu.Items.Add(item); + } + + BuiltinList list = data.BuiltinList; + + MenuItem constantsMenuItem = new(); + constantsMenuItem.Header = "Constants"; + + foreach (var (constantName, constantValue) in list.Constants) + { + if (constantValue == id) + { + MenuItem item = new(); + item.Header = constantName.Replace("_", "__"); + item.Click += (_, _) => + { + document.Replace(documentOffset, text.Length, constantName, null); + }; + constantsMenuItem.Items.Add(item); + } + } + + if (constantsMenuItem.Items.Count > 0) + contextMenu.Items.Add(constantsMenuItem); + + contextMenu.Items.Add(new MenuItem() { Header = id + " (number)", IsEnabled = false }); + + codeViewModel.GMLFocused = false; + codeViewModel.ASMFocused = false; + + contextMenu.Open(textEditor); + + return true; + }; + + return visualLine; + } + } + + public class NameGenerator : VisualLineElementGenerator + { + static readonly SolidColorBrush FunctionBrush = new(Color.FromRgb(0xFF, 0xB8, 0x71)); + static readonly SolidColorBrush GlobalBrush = new(Color.FromRgb(0xF9, 0x7B, 0xF9)); + static readonly SolidColorBrush ConstantBrush = new(Color.FromRgb(0xFF, 0x80, 0x80)); + static readonly SolidColorBrush InstanceBrush = new(Color.FromRgb(0x58, 0xE3, 0x5A)); + static readonly SolidColorBrush LocalBrush = new(Color.FromRgb(0xFF, 0xF8, 0x99)); + + readonly UndertaleCodeView codeView; + readonly ContextMenu contextMenu = new(); + + // + readonly Dictionary lineNameSections = []; + + public NameGenerator(UndertaleCodeView codeView) + { + this.codeView = codeView; + contextMenu.Placement = PlacementMode.Pointer; + } + + public override void StartGeneration(ITextRunConstructionContext context) + { + base.StartGeneration(context); + + // Find sections of line that are highlighted as identifiers or functions + lineNameSections.Clear(); + + DocumentLine documentLine = context.VisualLine.FirstDocumentLine; + if (documentLine.Length != 0) + { + int line = documentLine.LineNumber; + + IHighlighter highlighter = (IHighlighter)CurrentContext.TextView.GetService(typeof(IHighlighter)); + HighlightedLine highlightedLine = highlighter.HighlightLine(line); + + foreach (var section in highlightedLine.Sections) + { + if (section.Color.Name == "Identifier" || section.Color.Name == "Function") + lineNameSections[section.Offset] = section.Length; + } + } + } + + public override int GetFirstInterestedOffset(int startOffset) + { + foreach ((int offset, _) in lineNameSections) + { + if (startOffset <= offset) + return offset; + } + return -1; + } + + public override VisualLineElement? ConstructElement(int offset) + { + if (!lineNameSections.TryGetValue(offset, out int length)) + return null; + + TextDocument document = CurrentContext.Document; + TextView textView = CurrentContext.TextView; + TextEditor textEditor = (TextEditor)textView.GetService(typeof(TextEditor)); + + UndertaleCodeViewModel codeViewModel = (UndertaleCodeViewModel)codeView.DataContext!; + UndertaleData data = codeViewModel.MainVM.Data!; + + string text = document.GetText(offset, length); + + bool isFunction = (offset + length + 1 < CurrentContext.VisualLine.LastDocumentLine.EndOffset) && + (document.GetCharAt(offset + length) == '('); + + UndertaleNamedResource? namedResource = null; + bool nonResourceReference = false; + + // Process the content of this identifier/function + if (isFunction) + { + namedResource = null; + + if (!data.IsVersionAtLeast(2, 3)) // in GMS2.3 every custom "function" is in fact a member variable and scripts are never referenced directly + ScriptsCache.TryGetValue(text, out namedResource); + + if (namedResource == null) + { + FunctionsCache.TryGetValue(text, out namedResource); + if (data.IsVersionAtLeast(2, 3)) + { + if (namedResource != null) + { + if (namedResource.Name is not null && CodeCache.TryGetValue(namedResource.Name.Content, out _)) + namedResource = null; // in GMS2.3 every custom "function" is in fact a member variable, and the names in functions make no sense (they have the gml_Script_ prefix) + } + else + { + // Resolve 2.3 sub-functions for their parent entry + if (data.GlobalFunctions?.TryGetFunction(text, out Underanalyzer.IGMFunction? f) == true) + { + ScriptsCache.TryGetValue(f.Name.Content, out namedResource); + namedResource = (namedResource as UndertaleScript)?.Code?.ParentEntry; + } + } + } + } + if (namedResource == null) + { + if (data.BuiltinList.Functions.ContainsKey(text)) + { + ColorVisualLineText res = new(text, CurrentContext.VisualLine, length, FunctionBrush); + res.Bold = true; + return res; + } + } + } + else + { + NamedResourcesCache.TryGetValue(text, out namedResource); + if (data.IsVersionAtLeast(2, 3)) + { + if (namedResource is UndertaleScript) + namedResource = null; // in GMS2.3 scripts are never referenced directly + + if (data.GlobalFunctions?.TryGetFunction(text, out Underanalyzer.IGMFunction? globalFunc) == true && + globalFunc is UndertaleFunction utGlobalFunc) + { + // Try getting script that this function reference belongs to + if (NamedResourcesCache.TryGetValue("gml_Script_" + text, out namedResource) && namedResource is UndertaleScript script) + { + // Highlight like a function as well + namedResource = script.Code; + isFunction = true; + } + } + + if (namedResource == null) + { + // Try to get basic function + if (FunctionsCache.TryGetValue(text, out namedResource)) + { + isFunction = true; + } + } + + if (namedResource == null) + { + // Try resolving to room instance ID + string instanceIdPrefix = data.ToolInfo.InstanceIdPrefix(); + if (text.StartsWith(instanceIdPrefix) && + int.TryParse(text[instanceIdPrefix.Length..], out int id) && id >= 100000) + { + // TODO: We currently mark this as a non-resource reference, but ideally + // we resolve this to the room that this instance ID occurs in. + // However, we should only do this when actually clicking on it. + nonResourceReference = true; + } + } + } + } + if (namedResource == null && !nonResourceReference) + { + // Check for variable name colors + if (offset >= 7) + { + if (document.GetText(offset - 7, 7) == "global.") + { + return new ColorVisualLineText(text, CurrentContext.VisualLine, length, GlobalBrush); + } + } + if (data.BuiltinList.Constants.ContainsKey(text)) + return new ColorVisualLineText(text, CurrentContext.VisualLine, length, ConstantBrush); + if (data.BuiltinList.GlobalVars.ContainsKey(text) || + data.BuiltinList.InstanceVars.ContainsKey(text) || + data.BuiltinList.GlobalArrayVars.ContainsKey(text)) + return new ColorVisualLineText(text, CurrentContext.VisualLine, length, InstanceBrush); + if (codeView.codeLocalsCache.BinarySearch(text) >= 0) + return new ColorVisualLineText(text, CurrentContext.VisualLine, length, LocalBrush); + return null; + } + + ClickVisualLineText visualLine = new(text, CurrentContext.VisualLine, length, isFunction ? FunctionBrush : ConstantBrush); + if (isFunction) + { + // Make function references bold as well as a different color + visualLine.Bold = true; + } + if (namedResource is not null) + { + // Add click operation when we have a resource + visualLine.Clicked += bool (string text, MouseButton button, bool controlKey) => + { + if (!textEditor.TextArea.IsFocused) + return false; + + if (button == MouseButton.Right) + { + contextMenu.Items.Clear(); + + MenuItem openMenuItem = new(); + openMenuItem.Header = "Open"; + openMenuItem.Click += (_, _) => + { + textEditor.TextArea.Focus(); + _ = codeViewModel.MainVM.TabOpen(namedResource, false); + }; + contextMenu.Items.Add(openMenuItem); + + MenuItem openInNewTabMenuItem = new(); + openInNewTabMenuItem.Header = "Open in new tab"; + openInNewTabMenuItem.Click += (_, _) => + { + textEditor.TextArea.Focus(); + _ = codeViewModel.MainVM.TabOpen(namedResource, true); + }; + contextMenu.Items.Add(openInNewTabMenuItem); + + codeViewModel.GMLFocused = false; + codeViewModel.ASMFocused = false; + + contextMenu.Open(textEditor); + return true; + } + if (button == MouseButton.Middle || (button == MouseButton.Left && controlKey)) + { + textEditor.TextArea.Focus(); + _ = codeViewModel.MainVM.TabOpen(namedResource, true); + return true; + } + return false; + }; + } + + return visualLine; + } + } + + public class ColorVisualLineText : VisualLineText + { + private string Text { get; set; } + private Brush? ForegroundBrush { get; set; } + public bool Bold { get; set; } = false; + + public ColorVisualLineText(string text, VisualLine parentVisualLine, int length, Brush? foregroundBrush) : base(parentVisualLine, length) + { + Text = text; + ForegroundBrush = foregroundBrush; + } + + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) + { + if (ForegroundBrush != null) + TextRunProperties.SetForegroundBrush(ForegroundBrush); + if (Bold) + TextRunProperties.SetTypeface(new Typeface(TextRunProperties.Typeface.FontFamily, FontStyle.Normal, FontWeight.Bold, FontStretch.Normal)); + return base.CreateTextRun(startVisualColumn, context); + } + + protected override VisualLineText CreateInstance(int length) + { + return new ColorVisualLineText(Text, ParentVisualLine, length, null); + } + } + + public class ClickVisualLineText : VisualLineText + { + public delegate bool ClickHandler(string text, MouseButton button, bool controlKey); + public event ClickHandler? Clicked; + + private string Text { get; set; } + private Brush? ForegroundBrush { get; set; } + public bool Bold { get; set; } = false; + + public ClickVisualLineText(string text, VisualLine parentVisualLine, int length, Brush? foregroundBrush = null) : base(parentVisualLine, length) + { + Text = text; + ForegroundBrush = foregroundBrush; + } + + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) + { + if (ForegroundBrush != null) + TextRunProperties.SetForegroundBrush(ForegroundBrush); + if (Bold) + TextRunProperties.SetTypeface(new Typeface(TextRunProperties.Typeface.FontFamily, FontStyle.Normal, FontWeight.Bold, FontStretch.Normal)); + return base.CreateTextRun(startVisualColumn, context); + } + + protected override void OnQueryCursor(PointerEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + if (e.Source is InputElement element) + element.Cursor = new Cursor(StandardCursorType.Hand); + + e.Handled = true; + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + MouseButton button = e.GetCurrentPoint(null).Properties.PointerUpdateKind.GetMouseButton(); + + if (Clicked != null) + { + bool controlKey = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (Clicked(Text, button, controlKey)) + { + e.Handled = true; + } + } + } + + protected override VisualLineText CreateInstance(int length) + { + ClickVisualLineText res = new(Text, ParentVisualLine, length); + res.Clicked += Clicked; + return res; + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs new file mode 100644 index 000000000..465f5ee26 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Compiler; +using UndertaleModLib.Decompiler; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public enum Tab + { + GML = 0, + ASM = 1, + } + + public enum TabState + { + Ok, + NeedsCompile, + NeedsDecompile, + Error, + } + + public MainViewModel MainVM; + public UndertaleResource Resource => Code; + public UndertaleCode Code { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCompiled))] + public partial Tab SelectedTab { get; set; } + + [ObservableProperty] + public partial (Tab Tab, int Line, int Column)? LastGoToLocation { get; set; } = null; + + [ObservableProperty] + public partial TextDocument? GMLTextDocument { get; set; } + + [ObservableProperty] + public partial TextDocument? ASMTextDocument { get; set; } + + [ObservableProperty] + public partial bool IsCodeProcessing { get; set; } = false; + + public bool IsCompiled + { + get + { + return SelectedTab switch + { + Tab.GML => GMLTabState, + Tab.ASM => ASMTabState, + _ => throw new NotImplementedException(), + } is TabState.Ok; + } + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCompiled))] + public partial TabState GMLTabState { get; set; } = TabState.NeedsDecompile; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCompiled))] + public partial TabState ASMTabState { get; set; } = TabState.NeedsDecompile; + + public bool GMLFocused = false; + public bool ASMFocused = false; + + ILoaderWindow? loaderWindow; + IInputElement? lastFocusedElement; + + readonly TaskQueue taskQueue = new(); + readonly TaskCompletionSource gmlReady = new(); + readonly TaskCompletionSource asmReady = new(); + + public UndertaleCodeViewModel(UndertaleCode code, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Code = code; + + taskQueue.Add(() => gmlReady.Task); + taskQueue.Add(() => asmReady.Task); + } + + async Task ITabContent.OnSave() + { + return await DoCodeProcess(async () => + { + if (GetTabState(SelectedTab) is TabState.NeedsCompile) + { + await CompileFromTab(SelectedTab); + + if (GetTabState(SelectedTab) is TabState.Error) + { + return false; + } + + if (GetTabState(SelectedTab) is TabState.NeedsDecompile) + { + await DecompileToTab(SelectedTab); + } + } + + return true; + }); + } + + public void CompileAndDecompileCurrent() => CompileAndDecompileTab(SelectedTab, force: true); + + public void CompileAndDecompileGML() => CompileAndDecompileTab(Tab.GML, force: true); + + public void CompileAndDecompileASM() => CompileAndDecompileTab(Tab.ASM, force: true); + + public void GoToLocation(Tab tab, int lineNumber, int columnNumber) + { + LastGoToLocation = (tab, lineNumber, columnNumber); + } + + public async void CompileAndDecompileTab(Tab tab, bool force = false) + { + await DoCodeProcess(async () => + { + if (force || (GetTabState(tab) is TabState.NeedsCompile or TabState.Error)) + { + await CompileFromTab(tab); + } + + if (GetTabState(tab) is TabState.NeedsDecompile) + { + await DecompileToTab(tab); + } + }); + } + + public async Task DecompileCurrent() + { + await DoCodeProcess(async () => + { + if (GetTabState(SelectedTab) is TabState.NeedsDecompile) + { + await DecompileToTab(SelectedTab); + } + }); + } + + async partial void OnGMLTextDocumentChanged(TextDocument? value) + { + if (value is null) + return; + + gmlReady.TrySetResult(); + } + + async partial void OnASMTextDocumentChanged(TextDocument? value) + { + if (value is null) + return; + + asmReady.TrySetResult(); + } + + async partial void OnSelectedTabChanged(Tab oldValue, Tab newValue) + { + await DoCodeProcess(async () => + { + if (GetTabState(oldValue) is TabState.NeedsCompile) + { + await CompileFromTab(oldValue); + } + + if (GetTabState(newValue) is TabState.NeedsDecompile) + { + await DecompileToTab(newValue); + } + }); + } + + async Task DoCodeProcess(Func funcTask) + { + if (taskQueue.Count == 0) + CodeProcessStart(); + + await taskQueue.Add(funcTask); + + if (taskQueue.Count == 0) + CodeProcessEnd(); + } + async Task DoCodeProcess(Func> funcTask) + { + if (taskQueue.Count == 0) + CodeProcessStart(); + + T result = await taskQueue.Add(funcTask); + + if (taskQueue.Count == 0) + CodeProcessEnd(); + + return result; + } + + void CodeProcessStart() + { + lastFocusedElement = MainVM.View!.GetFocusedElement(); + + IsCodeProcessing = true; + + loaderWindow = MainVM.View!.LoaderOpen(); + } + + void CodeProcessEnd() + { + loaderWindow?.Close(); + loaderWindow = null; + + IsCodeProcessing = false; + + lastFocusedElement?.Focus(); + } + + TabState GetTabState(Tab tab) => tab switch + { + Tab.GML => GMLTabState, + Tab.ASM => ASMTabState, + _ => throw new NotImplementedException(), + }; + + Task CompileFromTab(Tab tab) => tab switch + { + Tab.GML => CompileFromGML(), + Tab.ASM => CompileFromASM(), + _ => throw new NotImplementedException(), + }; + + Task DecompileToTab(Tab tab) => tab switch + { + Tab.GML => DecompileToGML(), + Tab.ASM => DecompileToASM(), + _ => throw new NotImplementedException(), + }; + + async Task DecompileToGML() + { + Debug.WriteLine("DecompileToGML"); + + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Decompiling to GML..."); + + string text; + + if (MainVM.Project is null || !MainVM.Project.TryGetCodeSource(Code, out text)) + { + GlobalDecompileContext context = new(MainVM.Data); + + try + { + text = await Task.Run(() => new Underanalyzer.Decompiler.DecompileContext(context, Code, MainVM.Data!.ToolInfo.DecompilerSettings).DecompileToString()); + } + catch (Underanalyzer.Decompiler.DecompilerException e) + { + GMLTabState = TabState.Error; + if (string.IsNullOrEmpty(GMLTextDocument!.Text)) + { + GMLTextDocument!.Text = """#error"""; + } + + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(e.ToString(), + title: "GML decompilation error - UndertaleModTooAvalonia v" + App.VersionString); + return false; + } + } + + GMLTextDocument!.Text = text; + GMLTabState = TabState.Ok; + + return true; + } + + async Task CompileFromGML() + { + Debug.WriteLine("CompileFromGML"); + + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Compiling from GML..."); + + CompileGroup group = new(MainVM.Data); + group.MainThreadAction = Dispatcher.UIThread.Invoke; + group.QueueCodeReplace(Code, GMLTextDocument!.Text); + CompileResult result = await Task.Run(() => group.Compile()); + + if (!result.Successful) + { + GMLTabState = TabState.Error; + + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(result.PrintAllErrors(codeEntryNames: false), + title: "GML compilation error - UndertaleModTooAvalonia v" + App.VersionString); + return false; + } + + if (MainVM.Project is not null) + { + MainVM.Project.UpdateCodeSource(Code, GMLTextDocument!.Text); + } + + GMLTabState = TabState.NeedsDecompile; // TODO: Maybe not? + ASMTabState = TabState.NeedsDecompile; + + return true; + } + + async Task DecompileToASM() + { + Debug.WriteLine("DecompileToASM"); + + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Decompiling from ASM..."); + + string text; + + try + { + text = await Task.Run(() => Code.Disassemble(MainVM.Data!.Variables, MainVM.Data!.CodeLocals?.For(Code))); + } + catch (Exception e) + { + ASMTabState = TabState.Error; + if (string.IsNullOrEmpty(ASMTextDocument!.Text)) + { + ASMTextDocument!.Text = """#error"""; + } + + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(e.ToString(), + title: "ASM decompilation error - UndertaleModTooAvalonia v" + App.VersionString); + return false; + } + + ASMTextDocument!.Text = text; + + ASMTabState = TabState.Ok; + + return true; + } + + async Task CompileFromASM() + { + Debug.WriteLine("CompileFromASM"); + + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Compiling from ASM..."); + + if (MainVM.Project is not null && MainVM.Project.TryGetCodeSource(Code, out _)) + { + // The user really shouldn't be editing disassembly - warn them about this in detail + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog("Editing disassembly while in an open project (even through scripts) can cause " + + "desyncs with source code in the project.\n\n" + + "The source code will not change unless you directly modify it, " + + "or if you remove the code asset from the project entirely."); + } + + try + { + string text = ASMTextDocument!.Text; + List instructions = await Task.Run(() => Assembler.Assemble(text, MainVM.Data)); + Code.Replace(instructions); + } + catch (Exception e) + { + ASMTabState = TabState.Error; + + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(e.ToString(), + title: "ASM compilation error - UndertaleModTooAvalonia v" + App.VersionString); + return false; + } + + GMLTabState = TabState.NeedsDecompile; + ASMTabState = TabState.NeedsDecompile; // TODO: Maybe not? + + return true; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml new file mode 100644 index 000000000..4b503ef5a --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml.cs new file mode 100644 index 000000000..66f71cd4d --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedAudioView : UserControl +{ + public UndertaleEmbeddedAudioView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioViewModel.cs new file mode 100644 index 000000000..230889797 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedAudioViewModel.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedAudioViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => EmbeddedAudio; + public UndertaleEmbeddedAudio EmbeddedAudio { get; } + + AudioPlayer? audioPlayer = null; + + public UndertaleEmbeddedAudioViewModel(UndertaleEmbeddedAudio embeddedAudio, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + EmbeddedAudio = embeddedAudio; + } + + void ITabContent.OnDetached() + { + StopAudio(); + } + + public async void PlayAudio() + { + audioPlayer?.Stop(); + audioPlayer = new(EmbeddedAudio.Data); + } + + public async void StopAudio() + { + audioPlayer?.Stop(); + audioPlayer = null; + } + + public async void ImportAudio() + { + IReadOnlyList files = await MainVM.View!.OpenFileDialog(new FilePickerOpenOptions + { + Title = "Import audio", + FileTypeFilter = FilePickerFileTypes.WAV, + }); + + if (files.Count != 1) + return; + + using (Stream stream = await files[0].OpenReadAsync()) + { + await ImportExport.ImportEmbeddedAudio(EmbeddedAudio, stream); + } + } + + public async void ExportAudio() + { + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Export audio", + FileTypeChoices = FilePickerFileTypes.WAV, + DefaultExtension = ".wav", + SuggestedFileName = $"{EmbeddedAudio.Name?.Content ?? "audio"}.wav", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportEmbeddedAudio(EmbeddedAudio, stream); + } + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml new file mode 100644 index 000000000..7292c5b13 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml.cs new file mode 100644 index 000000000..098250d6e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedImageView : UserControl +{ + public UndertaleEmbeddedImageView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageViewModel.cs new file mode 100644 index 000000000..aa8203ac7 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedImageViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedImageViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => EmbeddedImage; + public UndertaleEmbeddedImage EmbeddedImage { get; } + + public UndertaleEmbeddedImageViewModel(UndertaleEmbeddedImage embeddedImage) + { + EmbeddedImage = embeddedImage; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml new file mode 100644 index 000000000..7a1bdf54d --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml.cs new file mode 100644 index 000000000..d4b1eaf4e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedTextureView : UserControl +{ + public UndertaleEmbeddedTextureView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureViewModel.cs new file mode 100644 index 000000000..57ae00888 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleEmbeddedTextureViewModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleEmbeddedTextureViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => EmbeddedTexture; + public UndertaleEmbeddedTexture EmbeddedTexture { get; } + + public UndertaleEmbeddedTextureViewModel(UndertaleEmbeddedTexture embeddedTexture, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + EmbeddedTexture = embeddedTexture; + } + + public async void ImportImage() + { + // TODO: Allow formats other than PNG, either directly or to convert it + IReadOnlyList files = await MainVM.View!.OpenFileDialog(new FilePickerOpenOptions + { + Title = "Import image", + FileTypeFilter = FilePickerFileTypes.Image, + }); + + if (files.Count != 1) + return; + + using (Stream stream = await files[0].OpenReadAsync()) + { + await ImportExport.ImportEmbeddedTexture(EmbeddedTexture, stream); + } + } + + public async void ExportImage() + { + (IReadOnlyList filePickerFileTypeList, string extension) type = EmbeddedTexture.TextureData.Image.Format switch + { + GMImage.ImageFormat.Png => (FilePickerFileTypes.PNG, "png"), + GMImage.ImageFormat.Qoi => (FilePickerFileTypes.QOI, "qoi"), + GMImage.ImageFormat.Bz2Qoi => (FilePickerFileTypes.BZ2, "bz2"), + _ => (FilePickerFileTypes.BIN, "bin"), + }; + + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Export image", + FileTypeChoices = type.filePickerFileTypeList, + DefaultExtension = $"*.{type.extension}", + SuggestedFileName = $"{EmbeddedTexture.Name?.Content ?? "image"}.{type.extension}", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportEmbeddedTexture(EmbeddedTexture, stream); + } + } + + public async void ExportImageAsPNG() + { + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Export image as PNG", + FileTypeChoices = FilePickerFileTypes.PNG, + DefaultExtension = ".png", + SuggestedFileName = $"{EmbeddedTexture.Name?.Content ?? "image"}.png", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportEmbeddedTextureAsPNG(EmbeddedTexture, stream); + } + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml new file mode 100644 index 000000000..48a5c2448 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml.cs new file mode 100644 index 000000000..59fe83e19 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleExtensionChunkView : UserControl +{ + public UndertaleExtensionChunkView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkViewModel.cs new file mode 100644 index 000000000..2725933d7 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionChunkViewModel.cs @@ -0,0 +1,17 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UndertaleModLib; +using static UndertaleModLib.UndertaleChunkEXTN; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleExtensionChunkViewModel : ObservableObject, ITabContent +{ + public UndertaleChunkEXTN ExtensionChunk { get; } + + public UndertaleExtensionChunkViewModel(UndertaleChunkEXTN extensionChunk) + { + ExtensionChunk = extensionChunk; + } + + public static ByteArrayWrapper CreateByteArray() => new byte[16]; +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml new file mode 100644 index 000000000..2862b66c3 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml.cs new file mode 100644 index 000000000..32b5ff299 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleExtensionView : UserControl +{ + public UndertaleExtensionView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionViewModel.cs new file mode 100644 index 000000000..6eea29d72 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionViewModel.cs @@ -0,0 +1,71 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleExtensionViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Extension; + public UndertaleExtension Extension { get; } + + [ObservableProperty] + public partial UndertaleExtensionFile? FilesSelected { get; set; } + + [ObservableProperty] + public partial UndertaleExtensionFunction? FunctionsSelected { get; set; } + + public UndertaleExtensionViewModel(UndertaleExtension extension, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Extension = extension; + } + + public void FilesSelectedChanged(object? item) + { + FilesSelected = (UndertaleExtensionFile?)item!; + } + + public void FunctionsSelectedChanged(object? item) + { + FunctionsSelected = (UndertaleExtensionFunction?)item!; + } + + public UndertaleExtensionFile CreateExtensionFile() + { + return new() + { + Filename = MainVM.Data!.Strings.MakeString($"NewExtensionFile{Extension.Files.Count}.dll"), + Kind = UndertaleExtensionKind.Dll, + Functions = [], + }; + } + + public UndertaleExtensionFunction CreateExtensionFunction() + { + return new() + { + Name = MainVM.Data!.Strings.MakeString($"new_extension_function_{FilesSelected!.Functions.Count}"), + ID = MainVM.Data!.ExtensionFindLastId(), + Kind = 11, // TODO: Probably find out what this is + RetType = UndertaleExtensionVarType.Double, + ExtName = MainVM.Data!.Strings.MakeString($"new_extension_function_{FilesSelected!.Functions.Count}_ext"), + Arguments = [], + }; + } + + public static UndertaleExtensionFunctionArg CreateExtensionFunctionArg() => new(); + + public UndertaleExtensionOption CreateExtensionOption() + { + return new() + { + Name = MainVM.Data!.Strings.MakeString($"extensionOption{Extension.Options.Count}"), + Value = MainVM.Data!.Strings.MakeString(""), + }; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml new file mode 100644 index 000000000..5a5ebf608 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml.cs new file mode 100644 index 000000000..995979e39 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleFontView : UserControl +{ + public UndertaleFontView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFontViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontViewModel.cs new file mode 100644 index 000000000..cd85a000e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontViewModel.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleFontViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Font; + public UndertaleFont Font { get; } + + [ObservableProperty] + public partial UndertaleFont.Glyph? GlyphsSelected { get; set; } + + public UndertaleFontViewModel(UndertaleFont font) + { + Font = font; + } + + public void GlyphsSelectedChanged(object? item) + { + GlyphsSelected = (UndertaleFont.Glyph?)item!; + } + + public void SortGlyphs() + { + List sortedGlyphs = Font.Glyphs.OrderBy(x => x.Character).ToList(); + + Font.Glyphs.Clear(); + foreach (UndertaleFont.Glyph glyph in sortedGlyphs) + Font.Glyphs.Add(glyph); + } + + public void UpdateRange() + { + IEnumerable characters = Font.Glyphs.Select(x => x.Character); + Font.RangeStart = characters.Min(); + Font.RangeEnd = characters.Max(); + } + + public static UndertaleFont.Glyph CreateGlyph() => new(); + public static UndertaleFont.Glyph.GlyphKerning CreateGlyphKerning() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml new file mode 100644 index 000000000..d7bf24f40 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml.cs new file mode 100644 index 000000000..4f4300172 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleFunctionView : UserControl +{ + public UndertaleFunctionView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionViewModel.cs new file mode 100644 index 000000000..8c82e200e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFunctionViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleFunctionViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Function; + public UndertaleFunction Function { get; } + + public UndertaleFunctionViewModel(UndertaleFunction function) + { + Function = function; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml new file mode 100644 index 000000000..3927abda5 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml.cs new file mode 100644 index 000000000..9bc562cd5 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleGameObjectView : UserControl +{ + public UndertaleGameObjectView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectViewModel.cs new file mode 100644 index 000000000..08110c710 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectViewModel.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleGameObjectViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => GameObject; + public UndertaleGameObject GameObject { get; } + + public UndertaleGameObjectViewModel(UndertaleGameObject gameObject, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + GameObject = gameObject; + } + + public static UndertaleGameObject.UndertalePhysicsVertex CreatePhysicsVertex() => new(); + public static UndertaleGameObject.Event CreateEvent() => new(); + public static UndertaleGameObject.EventAction CreateEventAction() => new(); + + public async Task CreateEventActionCode(object? argument) + { + if (argument is not IList list || list is not [EventType eventType, uint eventSubtype]) + return null; + + return GameObject?.EventHandlerFor(eventType, eventSubtype, MainVM.Data); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml new file mode 100644 index 000000000..76cb5b769 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml.cs new file mode 100644 index 000000000..f47b4bf73 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleParticleSystemEmitterView : UserControl +{ + public UndertaleParticleSystemEmitterView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterViewModel.cs new file mode 100644 index 000000000..ee8144fe9 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemEmitterViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleParticleSystemEmitterViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => ParticleSystemEmitter; + public UndertaleParticleSystemEmitter ParticleSystemEmitter { get; } + + public UndertaleParticleSystemEmitterViewModel(UndertaleParticleSystemEmitter particleSystemEmitter) + { + ParticleSystemEmitter = particleSystemEmitter; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml new file mode 100644 index 000000000..296cb8891 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml.cs new file mode 100644 index 000000000..e8e732594 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleParticleSystemView : UserControl +{ + public UndertaleParticleSystemView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemViewModel.cs new file mode 100644 index 000000000..9c258567e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleParticleSystemViewModel.cs @@ -0,0 +1,17 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleParticleSystemViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => ParticleSystem; + public UndertaleParticleSystem ParticleSystem { get; } + + public UndertaleParticleSystemViewModel(UndertaleParticleSystem particleSystem) + { + ParticleSystem = particleSystem; + } + + public static UndertaleResourceById CreateParticleSystemEmitterItem() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml new file mode 100644 index 000000000..1dff18089 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml.cs new file mode 100644 index 000000000..04d9f525a --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertalePathView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertalePathView : UserControl +{ + public UndertalePathView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertalePathViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertalePathViewModel.cs new file mode 100644 index 000000000..6b3cfa9b8 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertalePathViewModel.cs @@ -0,0 +1,17 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertalePathViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Path; + public UndertalePath Path { get; } + + public UndertalePathViewModel(UndertalePath path) + { + Path = path; + } + + public static UndertalePath.PathPoint CreatePathPoint() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml new file mode 100644 index 000000000..88f119d5b --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml @@ -0,0 +1,876 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ↑ + ↓ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lock + Select any layer + Grid + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs new file mode 100644 index 000000000..2627a7632 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleRoomView : UserControl +{ + public UndertaleRoomView() + { + InitializeComponent(); + + DataContextChanged += (_, _) => + { + if (DataContext is UndertaleRoomViewModel vm) + { + vm.PropertyChanged += (source, e) => + { + if (e.PropertyName == nameof(UndertaleRoomViewModel.RoomTreeItemsSelectedItem)) + { + var item = vm.RoomTreeItemsSelectedItem; + ExpandToTreeViewItem(GetTreeViewItem(RoomItemsTreeView, item)); + } + }; + + vm.RoomTreeItemsSelectedItem = RoomItemsTreeView.Items[0]; + } + }; + } + + private void MoveUp_Click(object? sender, RoutedEventArgs e) + { + MoveSelectedRoomItem(-1); + } + + private void MoveDown_Click(object? sender, RoutedEventArgs e) + { + MoveSelectedRoomItem(1); + } + + private void MoveSelectedRoomItem(int distance) + { + if (DataContext is UndertaleRoomViewModel vm) + { + var treeViewItem = GetTreeViewItem(RoomItemsTreeView, RoomItemsTreeView.SelectedItem); + if (treeViewItem is null) + return; + + IList? list = null; + Action? moveAction = null; + + TreeViewItem? parentTreeViewItem = treeViewItem.FindLogicalAncestorOfType(); + + switch (parentTreeViewItem?.DataContext) + { + case UndertaleRoomViewModel.RoomTreeItem { Tag: "GameObjects" }: + list = vm.Room.GameObjects; + moveAction = vm.Room.GameObjects.Move; + break; + case UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Instances } layer: + list = layer.InstancesData.Instances; + moveAction = layer.InstancesData.Instances.Move; + break; + case UndertaleRoomViewModel.RoomTreeItem { Tag: "Tiles" }: + list = vm.Room.Tiles; + moveAction = vm.Room.Tiles.Move; + break; + case UndertalePointerList legacyTiles: + list = legacyTiles; + moveAction = legacyTiles.Move; + break; + case UndertalePointerList sprites: + list = sprites; + moveAction = sprites.Move; + break; + case UndertalePointerList sequences: + list = sequences; + moveAction = sequences.Move; + break; + case UndertalePointerList particleSystems: + list = particleSystems; + moveAction = particleSystems.Move; + break; + case UndertalePointerList textItems: + list = textItems; + moveAction = textItems.Move; + break; + } + + if (list is not null && moveAction is not null) + { + var item = treeViewItem.DataContext; + var oldIndex = list.IndexOf(item); + var newIndex = oldIndex + distance; + + if (oldIndex != -1 && newIndex >= 0 && newIndex < list.Count) + { + moveAction(oldIndex, newIndex); + } + } + } + } + + private void ContextMenu_AddGameObjectInstance_GameObjectList_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + { + vm.AddGameObjectInstance(); + } + } + + private void ContextMenu_RemoveGameObjectInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.GameObject instance) + { + // Find if called from "Game objects" list or from an instances layer + TreeViewItem? parentTreeViewItem = treeViewItem.FindLogicalAncestorOfType(); + if (parentTreeViewItem?.DataContext is UndertaleRoomViewModel.RoomTreeItem { Tag: "GameObjects" }) + { + vm.RemoveGameObjectInstance(instance); + } + else if (parentTreeViewItem?.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Instances } layer) + { + vm.RemoveGameObjectInstance(instance, layer); + } + } + } + + private void ContextMenu_AddTile_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + { + vm.AddTile(); + } + } + + private void ContextMenu_RemoveTile_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Tile tile) + { + // Find if called from "Tiles" list or from an asset layer + TreeViewItem? parentTreeViewItem = treeViewItem.FindLogicalAncestorOfType(); + if (parentTreeViewItem?.DataContext is UndertaleRoomViewModel.RoomTreeItem { Tag: "Tiles" }) + { + vm.RemoveTile(tile); + } + else if (parentTreeViewItem?.DataContext is UndertalePointerList legacyTiles) + { + vm.RemoveTile(tile, legacyTiles); + } + } + } + + private void ContextMenu_AddBackgroundLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Background); + } + + private void ContextMenu_AddInstancesLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Instances); + } + + private void ContextMenu_AddTilesLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Tiles); + } + + private void ContextMenu_AddPathLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Path2); + } + + private void ContextMenu_AddAssetsLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Assets); + } + + private void ContextMenu_AddEffectLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm) + vm.AddLayer(UndertaleRoom.LayerType.Effect); + } + + private void ContextMenu_ToggleVisibility_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer layer) + { + layer.IsVisible = !layer.IsVisible; + } + } + + private void ContextMenu_RemoveLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer layer) + { + vm.RemoveLayer(layer); + } + } + + private void ContextMenu_AddGameObjectInstance_InstancesLayer_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Instances } layer) + { + vm.AddGameObjectInstance(layer); + } + } + + private void ContextMenu_AddLegacyTileInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + { + vm.AddLegacyTileInstance(layer); + } + } + + private void ContextMenu_AddSpriteInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + { + vm.AddSpriteInstance(layer); + } + } + + private void ContextMenu_AddSequenceInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + { + vm.AddSequenceInstance(layer); + } + } + + private void ContextMenu_AddParticleSystemInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + { + vm.AddParticleSystemInstance(layer); + } + } + + private void ContextMenu_AddTextItemInstance_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem + && treeViewItem.DataContext is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Assets } layer) + { + vm.AddTextItemInstance(layer); + } + } + + private void ContextMenu_RemoveAsset_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem) + { + TreeViewItem? parentTreeViewItem = treeViewItem.FindLogicalAncestorOfType(); + + if (parentTreeViewItem?.DataContext is IList list && treeViewItem.DataContext is not null) + { + vm.RemoveAsset(list, treeViewItem.DataContext); + } + } + } + + private void ContextMenu_Copy_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem) + { + vm.MainVM.InternalClipboard = treeViewItem.DataContext; + } + } + + private void ContextMenu_Paste_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is UndertaleRoomViewModel vm + && sender is Control control + && control.FindLogicalAncestorOfType() is TreeViewItem treeViewItem) + { + if (vm.MainVM.InternalClipboard is UndertaleRoom.GameObject instance) + { + // Find if called from "Game objects" list or from an instances layer or instance itself + + object? where = treeViewItem?.DataContext; + UndertaleRoom.GameObject? selectedInstance = null; + + if (where is UndertaleRoom.GameObject _selectedInstance) + { + TreeViewItem? parentTreeViewItem = treeViewItem.FindLogicalAncestorOfType(); + if (parentTreeViewItem is not null) + { + where = parentTreeViewItem.DataContext; + selectedInstance = _selectedInstance; + } + } + + if (where is UndertaleRoomViewModel.RoomTreeItem { Tag: "GameObjects" }) + { + int index = selectedInstance is not null ? vm.Room.GameObjects.IndexOf(selectedInstance) : vm.Room.GameObjects.Count; + + UndertaleRoom.GameObject newInstance = instance.Clone(); + newInstance.InstanceID = vm.MainVM.Data!.GeneralInfo.LastObj++; + + vm.Room.GameObjects.Insert(index, newInstance); + } + else if (where is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Instances } layer) + { + int index = selectedInstance is not null ? layer.InstancesData.Instances.IndexOf(selectedInstance) : layer.InstancesData.Instances.Count; + + UndertaleRoom.GameObject newInstance = instance.Clone(); + newInstance.InstanceID = vm.MainVM.Data!.GeneralInfo.LastObj++; + + vm.Room.GameObjects.Add(newInstance); + layer.InstancesData.Instances.Insert(index, newInstance); + } + } + } + } + + // Gets a TreeViewItem even if it's not "realized" yet. + // NOTE: Mostly taken from: + // https://learn.microsoft.com/en-us/dotnet/desktop/wpf/controls/how-to-find-a-treeviewitem-in-a-treeview + // So I'm not sure exactly why and how this works. + private static TreeViewItem? GetTreeViewItem(ItemsControl? container, object? item) + { + if (container != null && item != null) + { + if (container.DataContext == item) + { + return container as TreeViewItem; + } + + container.ApplyTemplate(); + + ItemsPresenter? itemsPresenter = container.FindDescendantOfType(); + + // This actually makes the child items. + itemsPresenter?.ApplyTemplate(); + + for (int i = 0, count = container.Items.Count; i < count; i++) + { + TreeViewItem? subContainer; + subContainer = (TreeViewItem?)container.ContainerFromIndex(i); + + if (subContainer != null) + { + TreeViewItem? resultContainer = GetTreeViewItem(subContainer, item); + if (resultContainer != null) + { + return resultContainer; + } + } + } + } + + return null; + } + + static void ExpandToTreeViewItem(TreeViewItem? treeViewItem) + { + if (treeViewItem is null) + return; + + // Recursively expand parents of this item + TreeViewItem currentViewItem = treeViewItem; + while (currentViewItem.Parent is TreeViewItem parentTreeViewItem) + { + parentTreeViewItem.IsExpanded = true; + currentViewItem = parentTreeViewItem; + } + + treeViewItem.BringIntoView(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs new file mode 100644 index 000000000..ddad76eaa --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; +using static UndertaleModLib.Models.UndertaleRoom; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleRoomViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public const uint TILE_ID = 0b00000000000001111111111111111111; + public const uint TILE_FLIP_H = 0b00010000000000000000000000000000; + public const uint TILE_FLIP_V = 0b00100000000000000000000000000000; + public const uint TILE_ROTATE = 0b01000000000000000000000000000000; + + public MainViewModel MainVM; + public UndertaleResource Resource => Room; + public UndertaleRoom Room { get; } + + public ObservableCollection RoomTreeItems { get; set; } = []; + + [ObservableProperty] + public partial object? RoomTreeItemsSelectedItem { get; set; } + + [ObservableProperty] + public partial object? PropertiesContent { get; set; } + + [ObservableProperty] + public partial object? CategorySelected { get; set; } + + [ObservableProperty] + public partial string StatusText { get; set; } = ""; + + [ObservableProperty] + public partial bool IsLocked { get; set; } = false; + + [ObservableProperty] + public partial bool IsSelectAnyLayerEnabled { get; set; } = false; + + [ObservableProperty] + public partial bool IsGridEnabled { get; set; } = false; + + [ObservableProperty] + public partial uint GridWidth { get; set; } = 20; + + [ObservableProperty] + public partial uint GridHeight { get; set; } = 20; + + [ObservableProperty] + public partial double Zoom { get; set; } = 1; + + [ObservableProperty] + public partial uint SelectedTileData { get; set; } = 0; + + [ObservableProperty] + public partial uint TileSetColumns { get; set; } = 0; + + [ObservableProperty] + public partial UndertaleNamedResource? SelectedTileResource { get; set; } + + [ObservableProperty] + public partial Rect? SelectedTileSourceRect { get; set; } + + readonly TilesViewModel tilesViewModel = new(); + + public UndertaleRoomViewModel(UndertaleRoom room, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Room = room; + + IsSelectAnyLayerEnabled = MainVM.Settings.EnableSelectAnyLayerByDefault; + IsGridEnabled = MainVM.Settings.EnableRoomGridByDefault; + GridWidth = MainVM.Settings.DefaultRoomGridWidth; + GridHeight = MainVM.Settings.DefaultRoomGridHeight; + + bool isGMS2 = MainVM.Data!.IsVersionAtLeast(2); + + if (!isGMS2) + RoomTreeItems.Add(new("Backgrounds", "Backgrounds", Room.Backgrounds)); + + RoomTreeItems.Add(new("Views", "Views", Room.Views)); + + if (!isGMS2) + { + RoomTreeItems.Add(new("Game objects", "GameObjects", Room.GameObjects)); + RoomTreeItems.Add(new("Tiles", "Tiles", Room.Tiles)); + } + + if (isGMS2) + RoomTreeItems.Add(new("Layers", "Layers", Room.Layers)); + + } + + public void AddLayer(LayerType type) + { + // TODO: Move this to library + string name = $"New {type switch + { + LayerType.Background => "background", + LayerType.Instances => "instances", + LayerType.Assets => "assets", + LayerType.Tiles => "tiles", + LayerType.Effect => "effect", + LayerType.Path2 => "path", + _ => "unknown", + }} layer"; + + uint layerId = 0; + foreach (UndertaleRoom? room in MainVM.Data!.Rooms) + { + if (room is null) + continue; + foreach (Layer roomLayer in room.Layers) + { + if (roomLayer.LayerId > layerId) + layerId = roomLayer.LayerId; + } + } + layerId += 1; + + int layerDepth = 0; + if (Room.Layers.Count > 0) + layerDepth = Room.Layers.Select(layer => layer.LayerDepth).Max() + 1; + + Layer layer = new() + { + LayerName = MainVM.Data!.Strings.MakeString(name), + LayerId = layerId, + LayerDepth = (int)layerDepth, + LayerType = type, + Data = type switch + { + LayerType.Background => new Layer.LayerBackgroundData(), + LayerType.Instances => new Layer.LayerInstancesData(), + LayerType.Assets => new Layer.LayerAssetsData(), + LayerType.Tiles => new Layer.LayerTilesData(), + LayerType.Effect => new Layer.LayerEffectData(), + _ => null, + }, + ParentRoom = Room, + }; + + if (layer.LayerType == LayerType.Assets) + { + layer.AssetsData.LegacyTiles ??= new UndertalePointerList(); + layer.AssetsData.Sprites ??= new UndertalePointerList(); + layer.AssetsData.Sequences ??= new UndertalePointerList(); + + if (!MainVM.Data.IsVersionAtLeast(2, 3, 2)) + layer.AssetsData.NineSlices ??= new UndertalePointerList(); + + if (MainVM.Data.IsNonLTSVersionAtLeast(2023, 2)) + layer.AssetsData.ParticleSystems ??= new UndertalePointerList(); + + if (MainVM.Data.IsVersionAtLeast(2024, 6)) + layer.AssetsData.TextItems ??= new UndertalePointerList(); + } + else if (layer.LayerType == LayerType.Tiles) + { + layer.TilesData.TileData ??= []; + } + + Room.Layers.Add(layer); + } + + public void RemoveLayer(Layer layer) + { + if (layer.LayerType == LayerType.Instances) + { + foreach (UndertaleRoom.GameObject? instance in layer.InstancesData.Instances) + { + Room.GameObjects.Remove(instance); + Room.InstanceCreationOrderIDs.InstanceIDs.Remove(instance.InstanceID); + } + } + + Room.Layers.Remove(layer); + } + + public void AddGameObjectInstance(Layer? layer = null, UndertaleGameObject? gameObject = null, int x = 0, int y = 0) + { + GameObject instance = new() + { + InstanceID = MainVM.Data!.GeneralInfo.LastObj++, + ObjectDefinition = gameObject, + X = x, + Y = y, + }; + Room.GameObjects.Add(instance); + + layer?.InstancesData.Instances.Add(instance); + } + + public void RemoveGameObjectInstance(GameObject instance, Layer? layer = null) + { + layer?.InstancesData.Instances.Remove(instance); + Room.GameObjects.Remove(instance); + Room.InstanceCreationOrderIDs.InstanceIDs.Remove(instance.InstanceID); + } + + public Tile AddTile() + { + Tile tile = new() + { + InstanceID = MainVM.Data!.GeneralInfo.LastTile++, + }; + Room.Tiles.Add(tile); + return tile; + } + + public void RemoveTile(Tile tile, UndertalePointerList? legacyTilesList = null) + { + if (legacyTilesList is not null) + { + legacyTilesList.Remove(tile); + } + else + { + Room.Tiles.Remove(tile); + } + } + + public Tile AddLegacyTileInstance(Layer layer) + { + Tile tile = new() + { + InstanceID = MainVM.Data!.GeneralInfo.LastTile++, + spriteMode = true, + }; + + layer.AssetsData.LegacyTiles.Add(tile); + + return tile; + } + + public void AddSpriteInstance(Layer layer, UndertaleSprite? sprite = null, int x = 0, int y = 0) + { + SpriteInstance spriteInstance = new() + { + Name = SpriteInstance.GenerateRandomName(MainVM.Data), + Sprite = sprite, + X = x, + Y = y, + }; + + layer.AssetsData.Sprites.Add(spriteInstance); + } + + public void AddSequenceInstance(Layer layer) + { + SequenceInstance sequenceInstance = new() + { + // Uses the same naming scheme as a sprite + Name = SpriteInstance.GenerateRandomName(MainVM.Data), + }; + + layer.AssetsData.Sequences?.Add(sequenceInstance); + } + + public void AddParticleSystemInstance(Layer layer) + { + ParticleSystemInstance particleSystemInstance = new() + { + Name = ParticleSystemInstance.GenerateRandomName(MainVM.Data), + InstanceID = ++MainVM.Data!.LastParticleSystemInstanceID, + }; + + layer.AssetsData.ParticleSystems?.Add(particleSystemInstance); + } + + public void AddTextItemInstance(Layer layer) + { + TextItemInstance textItemInstance = new() + { + Name = TextItemInstance.GenerateRandomName(MainVM.Data), + }; + + layer.AssetsData.TextItems?.Add(textItemInstance); + } + + public void RemoveAsset(IList list, object asset) + { + list.Remove(asset); + } + + public static UndertaleRoom.EffectProperty CreateEffectProperty() => new(); + + public object? FindItemFromCategory(object? category) + { + if ("GameObjects".Equals(category)) + return RoomTreeItems.First(x => x.Tag == "GameObjects"); + if ("Tiles".Equals(category)) + return RoomTreeItems.First(x => x.Tag == "Tiles"); + return category; + } + + public object? FindCategoryOfItem(object? item) + { + // NOTE: This sucks. Ideally we'd have this information from the DataContext of the item directly. + if (item is null) + return null; + + bool isGMS2 = MainVM.Data!.IsVersionAtLeast(2); + + object? category = item switch + { + RoomTreeItem { Tag: "GameObjects" } => "GameObjects", + GameObject => !isGMS2 ? "GameObjects" : null, + RoomTreeItem { Tag: "Tiles" } => "Tiles", + Tile => !isGMS2 ? "Tiles" : null, + RoomTreeItem => null, + _ => null, + }; + + if (category is not null) + return category; + + foreach (Layer? layer in Room.Layers) + { + if (layer is null) + continue; + + if (layer.LayerType == LayerType.Instances) + { + if (item == layer) + return layer; + + var instance = layer.InstancesData.Instances.FirstOrDefault(x => x == item); + if (instance is not null) + return layer; + } + else if (layer.LayerType == LayerType.Assets) + { + if (item == layer) + return layer; + + bool CheckAssetTypeList(IEnumerable? assetTypeList) + { + if (assetTypeList is null) + return false; + + if (item == assetTypeList) + return true; + + var instance = assetTypeList.FirstOrDefault(x => x == item); + if (instance is not null) + return true; + + return false; + } + + if (CheckAssetTypeList(layer.AssetsData.LegacyTiles) + || CheckAssetTypeList(layer.AssetsData.Sprites) + || CheckAssetTypeList(layer.AssetsData.Sequences) + || CheckAssetTypeList(layer.AssetsData.NineSlices) + || CheckAssetTypeList(layer.AssetsData.ParticleSystems) + || CheckAssetTypeList(layer.AssetsData.TextItems)) + return layer; + } + else if (layer.LayerType == LayerType.Tiles) + { + if (item == layer) + return layer; + } + } + + return null; + } + + public async void AutoSizeTileLayer() + { + if (PropertiesContent is Layer layer) + { + if (layer.TilesData is null + || layer.TilesData.Background is null + || layer.TilesData.Background.GMS2TileWidth == 0 + || layer.TilesData.Background.GMS2TileHeight == 0) + return; + + layer.TilesData.TilesX = (uint)Math.Ceiling((double)Room.Width / layer.TilesData.Background.GMS2TileWidth); + layer.TilesData.TilesY = (uint)Math.Ceiling((double)Room.Height / layer.TilesData.Background.GMS2TileHeight); + } + } + + public void SelectedTileDataFlipX() + { + SelectedTileData ^= ((SelectedTileData & TILE_ROTATE) == 0) ? TILE_FLIP_H : TILE_FLIP_V; + } + + public void SelectedTileDataFlipY() + { + SelectedTileData ^= ((SelectedTileData & TILE_ROTATE) == 0) ? TILE_FLIP_V : TILE_FLIP_H; + } + + public void SelectedTileDataRotateClockwise() + { + if ((SelectedTileData & TILE_ROTATE) != 0) + { + SelectedTileData ^= TILE_ROTATE | TILE_FLIP_H | TILE_FLIP_V; + } + else + { + SelectedTileData ^= TILE_ROTATE; + } + } + + public async void SaveAsImage() + { + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Save image", + FileTypeChoices = FilePickerFileTypes.PNG, + DefaultExtension = ".png", + SuggestedFileName = $"{Room.Name?.Content ?? "Room"}.png", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportRoomAsPNG(Room, stream); + } + } + + partial void OnRoomTreeItemsSelectedItemChanged(object? value) + { + PropertiesContent = RoomTreeItemsSelectedItem switch + { + TreeViewItem { Name: "RoomTreeViewItem" } => Room, + TreeViewItem => null, + RoomTreeItem { Tag: "Tiles" } => tilesViewModel, + RoomTreeItem => null, + UndertalePointerList => tilesViewModel, + UndertalePointerList => null, + UndertalePointerList => null, + UndertalePointerList => null, + UndertalePointerList => null, + object o => o, + _ => null, + }; + + CategorySelected = FindCategoryOfItem(RoomTreeItemsSelectedItem); + } + + public class RoomTreeItem(string header, string tag, IEnumerable itemsSource) + { + public string Header { get; set; } = header; + public string Tag { get; set; } = tag; + public IEnumerable ItemsSource { get; set; } = itemsSource; + } + + public class TilesViewModel + { + // Nothing here, it only exists so I can put it as a DataType in XAML + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml new file mode 100644 index 000000000..a9ea0eb06 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml.cs new file mode 100644 index 000000000..611f90442 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleScriptView : UserControl +{ + public UndertaleScriptView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptViewModel.cs new file mode 100644 index 000000000..d40f8660b --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleScriptViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleScriptViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Script; + public UndertaleScript Script { get; } + + public UndertaleScriptViewModel(UndertaleScript script) + { + Script = script; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml new file mode 100644 index 000000000..fddcd8306 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml.cs new file mode 100644 index 000000000..56576f594 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleShaderView : UserControl +{ + public UndertaleShaderView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderViewModel.cs new file mode 100644 index 000000000..8feca1858 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderViewModel.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleShaderViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Shader; + public UndertaleShader Shader { get; } + + public UndertaleShaderViewModel(UndertaleShader shader, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Shader = shader; + } + + public static UndertaleShader.VertexShaderAttribute CreateVertexShaderAttribute() => new(); + + UndertaleShader.UndertaleRawShaderData? GetRawShaderDataFromString(string parameter) + { + return parameter switch + { + "HLSL11_VertexData" => Shader.HLSL11_VertexData, + "HLSL11_PixelData" => Shader.HLSL11_PixelData, + "PSSL_VertexData" => Shader.PSSL_VertexData, + "PSSL_PixelData" => Shader.PSSL_PixelData, + "Cg_PSVita_VertexData" => Shader.Cg_PSVita_VertexData, + "Cg_PSVita_PixelData" => Shader.Cg_PSVita_PixelData, + "Cg_PS3_VertexData" => Shader.Cg_PS3_VertexData, + "Cg_PS3_PixelData" => Shader.Cg_PS3_PixelData, + _ => throw new NotImplementedException(), + }; + } + + [RelayCommand] + public async Task ImportRawShaderData(string parameter) + { + IReadOnlyList files = await MainVM.View!.OpenFileDialog(new FilePickerOpenOptions + { + Title = "Import shader", + FileTypeFilter = FilePickerFileTypes.BIN, + }); + + if (files.Count != 1) + return; + + using Stream stream = await files[0].OpenReadAsync(); + byte[] bytes = new byte[stream.Length]; + await stream.ReadExactlyAsync(bytes); + + UndertaleShader.UndertaleRawShaderData? rawShaderData = GetRawShaderDataFromString(parameter); + + if (rawShaderData is null) + { + rawShaderData = new UndertaleShader.UndertaleRawShaderData(); + + Action setRawShaderData = parameter switch + { + "HLSL11_VertexData" => () => Shader.HLSL11_VertexData = rawShaderData, + "HLSL11_PixelData" => () => Shader.HLSL11_PixelData = rawShaderData, + "PSSL_VertexData" => () => Shader.PSSL_VertexData = rawShaderData, + "PSSL_PixelData" => () => Shader.PSSL_PixelData = rawShaderData, + "Cg_PSVita_VertexData" => () => Shader.Cg_PSVita_VertexData = rawShaderData, + "Cg_PSVita_PixelData" => () => Shader.Cg_PSVita_PixelData = rawShaderData, + "Cg_PS3_VertexData" => () => Shader.Cg_PS3_VertexData = rawShaderData, + "Cg_PS3_PixelData" => () => Shader.Cg_PS3_PixelData = rawShaderData, + _ => throw new NotImplementedException(), + }; + setRawShaderData(); + } + + rawShaderData.IsNull = false; + rawShaderData.Data = bytes; + } + + [RelayCommand] + public async Task ExportRawShaderData(string parameter) + { + UndertaleShader.UndertaleRawShaderData? rawShaderData = GetRawShaderDataFromString(parameter); + + if (rawShaderData is null) + return; + + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Export shader", + FileTypeChoices = FilePickerFileTypes.BIN, + DefaultExtension = ".bin", + SuggestedFileName = (Shader.Name?.Content ?? "shader") + "_" + parameter + ".bin", + }); + + if (file is null) + return; + + using Stream stream = await file.OpenWriteAsync(); + + stream.Write(rawShaderData.Data); + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml new file mode 100644 index 000000000..7a5b16bcf --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml.cs new file mode 100644 index 000000000..7c11be213 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSoundView : UserControl +{ + public UndertaleSoundView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundViewModel.cs new file mode 100644 index 000000000..7a6b7e15b --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundViewModel.cs @@ -0,0 +1,109 @@ +using System; +using System.ComponentModel; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSoundViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Sound; + public UndertaleSound Sound { get; } + + [ObservableProperty] + public partial bool IsBuiltinAudioGroup { get; set; } + + [ObservableProperty] + public partial bool IsExternal { get; set; } + + AudioPlayer? audioPlayer = null; + + public UndertaleSoundViewModel(UndertaleSound sound, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Sound = sound; + + UpdateSoundProperties(); + } + + void ITabContent.OnAttached() + { + Sound.PropertyChanged += OnSoundPropertyChanged; + } + + void ITabContent.OnDetached() + { + Sound.PropertyChanged -= OnSoundPropertyChanged; + StopAudio(); + } + + public async void PlayAudio() + { + audioPlayer?.Stop(); + + if (!IsExternal) + { + if (IsBuiltinAudioGroup) + { + if (Sound.AudioFile is not null) + { + audioPlayer = new(Sound.AudioFile.Data); + } + } + else if (Sound.AudioGroup is not null) + { + if (GetAudioGroupSoundData() is byte[] data) + { + audioPlayer = new(data); + } + } + } + else + { + // TODO: Play external sound + } + } + + public async void StopAudio() + { + audioPlayer?.Stop(); + audioPlayer = null; + } + + void OnSoundPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(UndertaleSound.AudioGroup) + || e.PropertyName == nameof(UndertaleSound.Flags)) + { + UpdateSoundProperties(); + } + } + + void UpdateSoundProperties() + { + IsExternal = !Sound.Flags.HasFlag(UndertaleSound.AudioEntryFlags.IsEmbedded); + IsBuiltinAudioGroup = Sound.Flags.HasFlag(UndertaleSound.AudioEntryFlags.IsEmbedded) + && (Sound.AudioGroup is null || (MainVM.Data!.AudioGroups.IndexOf(Sound.AudioGroup) == MainVM.Data!.GetBuiltinSoundGroupID())); + } + + byte[]? GetAudioGroupSoundData() + { + UndertaleData? audioGroupData = MainVM.GetAudioGroupData(Sound.GroupID); + + if (audioGroupData is null) + return null; + + if (Sound.AudioID >= audioGroupData.EmbeddedAudio.Count) + return null; + + UndertaleEmbeddedAudio audioGroupEmbeddedAudio = audioGroupData.EmbeddedAudio[Sound.AudioID]; + + return audioGroupEmbeddedAudio.Data; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml new file mode 100644 index 000000000..47d358083 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml.cs new file mode 100644 index 000000000..36ef0d607 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSpriteView : UserControl +{ + public UndertaleSpriteView() + { + InitializeComponent(); + + DataContextChanged += (_, __) => + { + if (DataContext is UndertaleSpriteViewModel vm) + { + vm.TexturesSelectedChanged(TexturesDataGrid.DataGridControl.SelectedItem); + vm.CollisionMasksSelectedChanged(CollisionMasksDataGrid.DataGridControl.SelectedItem); + } + }; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteViewModel.cs new file mode 100644 index 000000000..aeffd57a7 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteViewModel.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSpriteViewModel : ObservableObject, IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Sprite; + public UndertaleSprite Sprite { get; } + + [ObservableProperty] + public partial UndertaleSprite.TextureEntry? TexturesSelected { get; set; } + + [ObservableProperty] + public partial UndertaleSprite.MaskEntry? CollisionMasksSelected { get; set; } + + [ObservableProperty] + public partial bool ShowNineSlice { get; set; } + + [ObservableProperty] + public partial bool EnableNineSlice { get; set; } + + public UndertaleSpriteViewModel(UndertaleSprite sprite, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + Sprite = sprite; + + if (Sprite.Textures.Count > 0) + TexturesSelected = Sprite.Textures[0]; + if (Sprite.CollisionMasks.Count > 0) + CollisionMasksSelected = Sprite.CollisionMasks[0]; + + UpdateSpriteProperties(); + } + + void ITabContent.OnAttached() + { + Sprite.PropertyChanged += OnSpritePropertyChanged; + } + + void ITabContent.OnDetached() + { + Sprite.PropertyChanged -= OnSpritePropertyChanged; + } + + void OnSpritePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(UndertaleSprite.SVersion): + case nameof(UndertaleSprite.V3NineSlice): + UpdateSpriteProperties(); + break; + } + } + + void UpdateSpriteProperties() + { + if (Sprite.SVersion >= 2) + { + // TODO: sequences + } + if (Sprite.SVersion >= 3) + { + ShowNineSlice = true; + EnableNineSlice = Sprite.V3NineSlice is not null; + } + else + { + ShowNineSlice = false; + EnableNineSlice = false; + } + } + + partial void OnEnableNineSliceChanged(bool value) + { + if (value) + Sprite.V3NineSlice ??= new(); + else + Sprite.V3NineSlice = null; + } + + public void TexturesSelectedChanged(object? item) + { + if (item is null) + { + if (Sprite.Textures.Count > 0) + TexturesSelected = Sprite.Textures[0]; + else + TexturesSelected = null; + } + else + TexturesSelected = (UndertaleSprite.TextureEntry?)item!; + } + public void CollisionMasksSelectedChanged(object? item) + { + if (item is null) + { + if (Sprite.CollisionMasks.Count > 0) + CollisionMasksSelected = Sprite.CollisionMasks[0]; + else + CollisionMasksSelected = null; + } + else + CollisionMasksSelected = (UndertaleSprite.MaskEntry?)item!; + } + + public async void ExportAllTexturesAsPNGs() + { + string GetFileNameOfTexture(int i) => $"{Sprite.Name?.Content ?? "image"}_{i}.png"; + + IReadOnlyList folders = await MainVM.View!.OpenFolderDialog(new FolderPickerOpenOptions() + { + Title = "Export all textures into folder", + }); + + if (folders.Count != 1) + return; + + IStorageFolder folder = folders[0]; + + List filesThatAlreadyExist = []; + for (int i = 0; i < Sprite.Textures.Count; i++) + { + var fileName = GetFileNameOfTexture(i); + if (await folder.GetFileAsync(fileName) is not null) + { + filesThatAlreadyExist.Add(fileName); + } + } + + if (filesThatAlreadyExist.Count > 0) + { + MessageWindow.Result result = await MainVM.View!.MessageDialog($"The following files already exist. Do you want to replace them?" + + $"\n\n{string.Join("\n", filesThatAlreadyExist)}", buttons: MessageWindow.Buttons.YesCancel); + + if (result != MessageWindow.Result.Yes) + return; + } + + for (int i = 0; i < Sprite.Textures.Count; i++) + { + var fileName = GetFileNameOfTexture(i); + var texture = Sprite.Textures[i].Texture; + + IStorageFile? file = await folder.CreateFileAsync(fileName); + if (file is null) + { + await MainVM.View!.MessageDialog($"Error: Could not create file \"{fileName}\""); + return; + } + + using (var stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportTexturePageItemAsPNG(texture, stream, includePadding: true); + } + } + } + + public async void ImportCollisionMaskData() + { + if (CollisionMasksSelected is null) + return; + + IReadOnlyList files = await MainVM.View!.OpenFileDialog(new FilePickerOpenOptions + { + Title = "Import collision mask data", + FileTypeFilter = FilePickerFileTypes.BIN, + }); + + if (files.Count != 1) + return; + + using (Stream stream = await files[0].OpenReadAsync()) + { + await ImportExport.ImportSpriteCollisionMaskData(Sprite, Sprite.CollisionMasks.IndexOf(CollisionMasksSelected), stream, MainVM); + } + } + + public async void ExportCollisionMaskData() + { + if (CollisionMasksSelected is null) + return; + + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Export collision mask data", + FileTypeChoices = FilePickerFileTypes.BIN, + DefaultExtension = ".bin", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportSpriteCollisionMaskData(Sprite, Sprite.CollisionMasks.IndexOf(CollisionMasksSelected), stream); + } + } + + public static UndertaleSprite.TextureEntry CreateTextureEntry() => new(); + public static UndertaleSprite.MaskEntry CreateMaskEntry() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml new file mode 100644 index 000000000..3e4602017 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml.cs new file mode 100644 index 000000000..ae3feb43a --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleStringView : UserControl +{ + public UndertaleStringView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleStringViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringViewModel.cs new file mode 100644 index 000000000..2bac58516 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleStringViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleStringViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => String; + public UndertaleString String { get; } + + public UndertaleStringViewModel(UndertaleString _string) + { + String = _string; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml new file mode 100644 index 000000000..8872885d9 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml.cs new file mode 100644 index 000000000..7af038ea7 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleTextureGroupInfoView : UserControl +{ + public UndertaleTextureGroupInfoView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoViewModel.cs new file mode 100644 index 000000000..bb9f5051f --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoViewModel.cs @@ -0,0 +1,20 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class UndertaleTextureGroupInfoViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => TextureGroupInfo; + public UndertaleTextureGroupInfo TextureGroupInfo { get; } + + public UndertaleTextureGroupInfoViewModel(UndertaleTextureGroupInfo textureGroupInfo) + { + TextureGroupInfo = textureGroupInfo; + } + + public static UndertaleResourceById CreateEmbeddedTextureItem() => new(); + public static UndertaleResourceById CreateSpriteItem() => new(); + public static UndertaleResourceById CreateFontItem() => new(); + public static UndertaleResourceById CreateBackgroundItem() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml new file mode 100644 index 000000000..c17132361 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml.cs new file mode 100644 index 000000000..ce9238a74 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleTexturePageItemView : UserControl +{ + public UndertaleTexturePageItemView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemViewModel.cs new file mode 100644 index 000000000..857bb1881 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemViewModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleTexturePageItemViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => TexturePageItem; + public UndertaleTexturePageItem TexturePageItem { get; } + + public UndertaleTexturePageItemViewModel(UndertaleTexturePageItem texturePageItem, IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + TexturePageItem = texturePageItem; + } + + public async void LoadImageWithoutPadding() + { + LoadImage(); + } + + public async void SaveImageWithoutPadding() + { + SaveImage(includePadding: false); + } + + public async void SaveImageWithPadding() + { + SaveImage(includePadding: true); + } + + async void LoadImage() + { + IReadOnlyList files = await MainVM.View!.OpenFileDialog(new FilePickerOpenOptions + { + Title = "Load image without padding", + FileTypeFilter = FilePickerFileTypes.Image, + }); + + if (files.Count != 1) + return; + + using (Stream stream = await files[0].OpenReadAsync()) + { + try + { + await ImportExport.ImportTexturePageItem(TexturePageItem, stream); + } + catch (Exception ex) + { + await MainVM.View.MessageDialog(ex.ToString(), title: "Load image error"); + } + } + } + + async void SaveImage(bool includePadding) + { + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = $"Save image {(includePadding ? "with padding" : "without padding")}", + FileTypeChoices = FilePickerFileTypes.PNG, + DefaultExtension = ".png", + SuggestedFileName = TexturePageItem.Name?.Content ?? "image", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + try + { + await ImportExport.ExportTexturePageItemAsPNG(TexturePageItem, stream, includePadding); + } + catch (Exception ex) + { + await MainVM.View.MessageDialog(ex.ToString(), title: "Save image error"); + } + } + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml new file mode 100644 index 000000000..0aeafc331 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml.cs new file mode 100644 index 000000000..983d2cf3b --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleTimelineView : UserControl +{ + public UndertaleTimelineView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineViewModel.cs new file mode 100644 index 000000000..9f62934ab --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTimelineViewModel.cs @@ -0,0 +1,24 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class UndertaleTimelineViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Timeline; + public UndertaleTimeline Timeline { get; } + + public UndertaleTimelineViewModel(UndertaleTimeline timeline) + { + Timeline = timeline; + } + + public static UndertaleTimeline.UndertaleTimelineMoment CreateMoment() + { + UndertaleTimeline.UndertaleTimelineMoment moment = new(); + moment.Event = []; + return moment; + } + + public static UndertaleGameObject.EventAction CreateEventAction() => new(); +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml new file mode 100644 index 000000000..c5a45e890 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml.cs new file mode 100644 index 000000000..7959a45ee --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleVariableView : UserControl +{ + public UndertaleVariableView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableViewModel.cs new file mode 100644 index 000000000..45347adea --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleVariableViewModel.cs @@ -0,0 +1,15 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleVariableViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Variable; + public UndertaleVariable Variable { get; } + + public UndertaleVariableViewModel(UndertaleVariable variable) + { + Variable = variable; + } +} diff --git a/UndertaleModToolAvalonia/Styles.axaml b/UndertaleModToolAvalonia/Styles.axaml new file mode 100644 index 000000000..6b88840a1 --- /dev/null +++ b/UndertaleModToolAvalonia/Styles.axaml @@ -0,0 +1,295 @@ + + + $Default + 12 + 5,4 + 4 + 0 + 24 + 12 + 0 + 28 + 28 + 0 + 0 + + + + Consolas,Liberation Mono,Menlo + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj b/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj new file mode 100644 index 000000000..bc779ae9d --- /dev/null +++ b/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj @@ -0,0 +1,51 @@ + + + net10.0 + enable + latest + true + 0.9.2.0 + + + + WinExe + true + app.manifest + en + Assets\Icon.ico + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/FindReferencesViewModel.cs b/UndertaleModToolAvalonia/Windows/FindReferencesViewModel.cs new file mode 100644 index 000000000..26c422058 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/FindReferencesViewModel.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class FindReferencesViewModel : ObservableObject +{ + public IView? View; + + public MainViewModel MainVM { get; } + + [ObservableProperty] + public partial bool IsEnabled { get; set; } = true; + + [ObservableProperty] + public partial UndertaleResource? Resource { get; set; } + + [ObservableProperty] + public partial ObservableCollection Results { get; set; } = []; + + ILoaderWindow? loaderWindow; + + public FindReferencesViewModel(IServiceProvider serviceProvider, UndertaleResource? resource = null) + { + MainVM = serviceProvider.GetRequiredService(); + Resource = resource; + } + + public async void FindReferences() + { + Results.Clear(); + + if (MainVM.Data is null) + return; + if (Resource is null) + return; + + // Set up loader window + loaderWindow = View!.LoaderOpen(); + loaderWindow.SetText("Finding references..."); + + IsEnabled = false; + MainVM.IsEnabled = false; + + Results = await Task.Run(() => + { + UndertaleResource? resource = Resource; + + UndertaleData data = MainVM.Data; + Assembly currentAssembly = typeof(UndertaleData).Assembly; + + ObservableCollection results = []; + Stack propStack = new(); + + // TODO: This is really bad, remove + void RecurseProperties(object obj, UndertaleObject? topObject = null, string propChain = "") + { + Type objType = obj.GetType(); + + if (propStack.Contains(obj)) + return; + + propStack.Push(obj); + + if (obj is UndertaleObject objResource && objResource is UndertaleResource or UndertaleGeneralInfo or UndertaleOptions or UndertaleLanguage or UndertaleGlobalInit) + { + // Update top resource + if (topObject is null) + { + topObject = objResource; + propChain = ""; + } + } + + // Check if value is the correct one + if (obj == resource) + { + if (topObject is not null) + results.Add(new(topObject, propChain)); + } + + // If it's a resource, and not the current top object, stop right there, + // there's no point in looping anymore since those should already be dealt with in the first pass + if (obj is UndertaleResource && obj != topObject) + { + propStack.Pop(); + return; + } + + // If it's a list, loop its values + if (obj is IList objList) + { + foreach (object? item in objList) + { + if (item is not null) + { + var t = item.GetType(); + if (t.IsPrimitive || t == typeof(string) || t.IsEnum) + break; + + // Add [] + RecurseProperties(item, topObject, $"{propChain}[{item}]"); + } + } + } + // If not, then loop its properties + // Must be in the same assembly, to avoid complications + else if (objType.Assembly == currentAssembly) + { + PropertyInfo[] props = objType.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (PropertyInfo prop in props) + { + // Ignore properties that require indexes + if (prop.GetIndexParameters().Length != 0) + continue; + + object? propValue = prop.GetValue(obj); + if (propValue is null) + continue; + + var t = prop.PropertyType; + if (t.IsPrimitive || t == typeof(string) || t.IsEnum) + continue; + + // Add . if not empty + RecurseProperties(propValue, topObject, $"{(propChain != "" ? propChain + "." : "")}{prop.Name}"); + } + } + + propStack.Pop(); + } + + RecurseProperties(data); + + return results; + }); + + // Close loader window + loaderWindow.Close(); + + IsEnabled = true; + MainVM.IsEnabled = true; + } + + public void OpenResult(FindReferencesResult result, bool inNewTab = false) + { + _ = MainVM.TabOpen(result.Resource, inNewTab); + } + + public class FindReferencesResult + { + public string AssetType { get; } + public string Name { get; } + public string Property { get; } + + public UndertaleObject Resource; + + public FindReferencesResult(UndertaleObject resource, string property) + { + AssetType = resource.GetType().Name; + if (resource is UndertaleNamedResource namedResource) + { + Name = namedResource.Name?.Content ?? ""; + } + else + { + Name = resource.ToString() ?? ""; + } + Property = property; + Resource = resource; + } + } +} diff --git a/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml b/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml new file mode 100644 index 000000000..7fb1ef20e --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml.cs new file mode 100644 index 000000000..0a96cbbec --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/FindReferencesWindow.axaml.cs @@ -0,0 +1,83 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace UndertaleModToolAvalonia; + +public partial class FindReferencesWindow : Window, IView +{ + public FindReferencesWindow() + { + InitializeComponent(); + + DataContextChanged += (_, _) => + { + if (DataContext is FindReferencesViewModel vm) + { + vm.View = this; + } + }; + + ResultsDataGrid.AddHandler(DataGrid.KeyDownEvent, DataGrid_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (DataContext is FindReferencesViewModel vm) + { + if (vm.Resource is not null) + { + vm.FindReferences(); + } + } + } + + void OpenResultFromSource(object? source, bool inNewTab = false) + { + if (DataContext is FindReferencesViewModel vm) + if (source is Control control) + if (control.DataContext is FindReferencesViewModel.FindReferencesResult result) + { + vm.OpenResult(result, inNewTab); + } + } + + private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + OpenResultFromSource(e.Source); + } + + private void DataGrid_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + { + OpenResultFromSource(e.Source, inNewTab: true); + } + } + + private void DataGrid_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (DataContext is FindReferencesViewModel vm) + if (e.Key == Key.Enter) + if (ResultsDataGrid.SelectedItem is FindReferencesViewModel.FindReferencesResult result) + { + e.Handled = true; + vm.OpenResult(result); + } + } + + private void DataGridRow_Open_Click(object? sender, RoutedEventArgs e) + { + OpenResultFromSource(e.Source); + } + + private void DataGridRow_OpenInNewTab_Click(object? sender, RoutedEventArgs e) + { + OpenResultFromSource(e.Source, inNewTab: true); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml b/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml new file mode 100644 index 000000000..1edcc701e --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml.cs new file mode 100644 index 000000000..d9f00038d --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml.cs @@ -0,0 +1,115 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +public interface ILoaderWindow +{ + public void EnsureShown(); + void SetMessage(string message); + void SetStatus(string status); + void SetValue(int value); + void SetMaximum(int maximum); + void SetText(string text); + void SetTextToMessageAndStatus(string status); + void Close(); +} + +public partial class LoaderWindow : Window, ILoaderWindow +{ + public string TitleText { get; set; } = "UndertaleModToolAvalonia v" + App.VersionString; + + int value; + string? message; + string? status; + int maximum = -1; + bool canShow = true; + Window? showOwner; + + public LoaderWindow() + { + Initialize(); + } + + public void Initialize() + { + InitializeComponent(); + + Closing += (object? sender, WindowClosingEventArgs e) => + { + if (!e.IsProgrammatic) + e.Cancel = true; + else + canShow = false; + }; + } + + public void ShowDelayed(Window owner) + { + showOwner = owner; + Task.Delay(100).ContinueWith(_ => + { + Dispatcher.UIThread.Post(() => + { + if (canShow) + { + canShow = false; + ShowDialog(owner); + } + }); + }); + } + + public void EnsureShown() + { + if (showOwner is not null && canShow) + { + canShow = false; + ShowDialog(showOwner); + } + } + + public void UpdateText() + { + MessageTextBlock.Text = $"{(!String.IsNullOrEmpty(message) ? message + " - " : "")}{value}/{maximum}{(!String.IsNullOrEmpty(status) ? ": " + status : "")}"; + } + + public void SetMessage(string message) + { + this.message = message; + UpdateText(); + } + + public void SetStatus(string status) + { + this.status = status; + UpdateText(); + } + + public void SetValue(int value) + { + this.value = value; + LoadingProgressBar.Value = value; + UpdateText(); + } + + public void SetMaximum(int maximum) + { + this.maximum = maximum; + LoadingProgressBar.IsIndeterminate = false; + LoadingProgressBar.Maximum = maximum; + UpdateText(); + } + + public void SetText(string text) + { + MessageTextBlock.Text = text; + } + + public void SetTextToMessageAndStatus(string status) + { + MessageTextBlock.Text = $"{(!String.IsNullOrEmpty(message) ? message + " " : "")} - {status}"; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/MainView.axaml b/UndertaleModToolAvalonia/Windows/MainView.axaml new file mode 100644 index 000000000..62205c0a2 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainView.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sort + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/MainView.axaml.cs b/UndertaleModToolAvalonia/Windows/MainView.axaml.cs new file mode 100644 index 000000000..96fd373b3 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainView.axaml.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +public partial class MainView : UserControl, IView +{ + ProjectAssetsWindow? projectAssetsWindow = null; + + public MainView() + { + InitializeComponent(); + + DataContextChanged += (_, __) => + { + if (DataContext is MainViewModel vm) + { + vm.View = this; + } + }; + + Loaded += (_, __) => + { + if (DataContext is MainViewModel vm) + { + vm.OnLoaded(); + } + }; + + CommandTextBox.AddHandler(TextBox.KeyDownEvent, CommandTextBox_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + public async Task OpenSettingsDialog(IServiceProvider serviceProvider) + { + Window window = this.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + await new SettingsWindow() + { + DataContext = new SettingsViewModel(serviceProvider), + }.ShowDialog(window); + } + + public void OpenSearchInCode(IServiceProvider serviceProvider) + { + Window window = this.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + new SearchInCodeWindow() + { + DataContext = new SearchInCodeViewModel(serviceProvider), + }.Show(window); + } + + public void OpenFindReferences(IServiceProvider serviceProvider, UndertaleResource? resource = null) + { + Window window = this.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + new FindReferencesWindow() + { + DataContext = new FindReferencesViewModel(serviceProvider, resource), + }.Show(window); + } + + public void OpenProjectAssets(IServiceProvider serviceProvider) + { + Window window = this.FindLogicalAncestorOfType() ?? throw new InvalidOperationException("Window not found"); + + if (projectAssetsWindow is not null) + { + projectAssetsWindow.Focus(); + } + else + { + projectAssetsWindow = new ProjectAssetsWindow(serviceProvider); + projectAssetsWindow.Closed += (_, _) => + { + projectAssetsWindow = null; + }; + projectAssetsWindow.Show(window); + } + } + + public void CloseProjectAssets() + { + projectAssetsWindow?.Close(); + projectAssetsWindow = null; + } + + private async void CommandTextBox_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (DataContext is MainViewModel vm) + if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + e.Handled = true; + object? result = await vm.Scripting.RunScript(vm.CommandTextBoxText); + vm.CommandTextBoxText = result?.ToString() ?? ""; + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/MainViewModel.cs b/UndertaleModToolAvalonia/Windows/MainViewModel.cs new file mode 100644 index 000000000..1e5a564c6 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainViewModel.cs @@ -0,0 +1,1203 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UndertaleModLib; +using UndertaleModLib.Models; +using UndertaleModLib.Project; +using UndertaleModLib.Util; + +namespace UndertaleModToolAvalonia; + +public partial class MainViewModel : ObservableObject +{ + // Set this when testing. + public IView? View; + + // Services + public readonly IServiceProvider ServiceProvider; + + /// Error messages to be displayed after the view has been loaded. + public List LazyErrorMessages = []; + + // Settings + public SettingsFile Settings { get; init; } + + // Scripting + public Scripting Scripting = null!; + + // Window + public string Title => $"UndertaleModToolAvalonia - v" + + (App.VersionString) + + $"{(Project?.Name is not null ? " - " + Project.Name : "")}" + + $"{(Data?.GeneralInfo is not null ? " - " + Data.GeneralInfo.ToString() : "")}" + + $"{(DataPath is not null ? " [" + DataPath + "]" : "")}"; + + [ObservableProperty] + public partial WindowState WindowState { get; set; } = WindowState.Maximized; + + [ObservableProperty] + public partial bool IsEnabled { get; set; } = true; + + // Data + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + public partial UndertaleData? Data { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + public partial string? DataPath { get; set; } + + [ObservableProperty] + public partial (uint Major, uint Minor, uint Release, uint Build) DataVersion { get; set; } + + Dictionary audioGroupDataList = []; + + IStorageFolder? lastDataLocation; + + // Project + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + public partial ProjectContext? Project { get; set; } + + // Left panel + public DataExplorerViewModel DataExplorer { get; set; } + + [ObservableProperty] + public partial string FilterText { get; set; } = ""; + + [ObservableProperty] + public partial bool IsSorted { get; set; } = false; + + // Tabs + public TabsViewModel Tabs { get; set; } + + [ObservableProperty] + public partial bool TabIsMarkedForExport { get; set; } = false; + + [ObservableProperty] + public partial bool TabCanMarkedForExport { get; set; } = false; + + [ObservableProperty] + public partial string TabSelectedResourceIdString { get; set; } = "None"; + + // Command text box + [ObservableProperty] + public partial string CommandTextBoxText { get; set; } = ""; + + // Image cache + public ImageCache ImageCache = new(); + + // Internal clipboard + public object? InternalClipboard = null; + + public MainViewModel(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + + Settings = LoadSettings(); + + WindowState = Settings.StartMaximized ? WindowState.Maximized : WindowState.Normal; + + AudioPlayer.Init(f => Dispatcher.UIThread.Post(f)); + + DataExplorer = new(this); + Tabs = new(this); + + _ = TabOpen(new DescriptionViewModel( + "Welcome to UndertaleModTool!", + "Open a data file to get started, then double click on the items on the left to view them.")); + } + + SettingsFile LoadSettings() + { + (SettingsFile settingsFile, Exception? ex) = SettingsFile.Load(); + + if (ex is not null) + { + LazyErrorMessages.Add($"Error when loading settings file:\n{ex.Message}\nDefault settings loaded."); + } + + Exception? stylesEx = SettingsFile.LoadStyles(); + if (stylesEx is not null) + { + LazyErrorMessages.Add($"Error when loading styles file:\n{stylesEx.Message}"); + } + + return settingsFile; + } + + public void Initialize() + { + Scripting = new(ServiceProvider); + } + + public async void OnLoaded() + { + foreach (string message in LazyErrorMessages) + { + await View!.MessageDialog(message); + } + LazyErrorMessages.Clear(); + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.Args?.Length >= 1) + { + try + { + using FileStream stream = File.OpenRead(desktop.Args[0]); + if (await LoadData(stream)) + { + DataPath = stream.Name; + } + } + catch (SystemException e) + { + await View!.MessageDialog($"Error opening data file from argument: {e.Message}"); + } + } + } + } + + [RelayCommand] + public async Task OpenDroppedFiles(IEnumerable? files) + { + if (files is null) + return; + + var list = files.ToList(); + if (list.Count != 1) + return; + + if (list[0] is not IStorageFile file) + return; + + if (!await AskFileSave("Save data file before opening a new one?")) + return; + + CloseData(); + + using Stream stream = await file.OpenReadAsync(); + + if (await LoadData(stream)) + { + DataPath = file.TryGetLocalPath(); + lastDataLocation = await file.GetParentAsync(); + } + } + + partial void OnDataChanged(UndertaleData? value) + { + if (Data is not null) + { + if (Data.GeneralInfo is not null) + Data.GeneralInfo.PropertyChanged += DataGeneralInfoChangedHandler; + + Data.ToolInfo.InstanceIdPrefix = () => Settings.InstanceIdPrefix; + Data.ToolInfo.DecompilerSettings = Settings.DecompileSettings; + } + + UpdateVersion(); + + DataExplorer.UpdateFromData(); + + if (Data is not null) + { + DataExplorer.OnExpandItemOnTree?.Invoke(DataExplorer.TreeDataGridData[0]); + } + } + + partial void OnFilterTextChanged(string value) + { + DataExplorer.SetFilter(); + } + + partial void OnIsSortedChanged(bool value) + { + DataExplorer.SetSort(); + } + + /// Ask if user wants to save the current file before continuing. + /// Returns true if either it saved successfully, or if the user didn't want to save, or if there is no file loaded. + public async Task AskFileSave(string message) + { + if (Data is null) + return true; + + var result = await View!.MessageDialog(message, buttons: MessageWindow.Buttons.YesNoCancel); + if (result == MessageWindow.Result.Yes) + { + if (await FileSaveTask()) + { + return true; + } + } + else if (result == MessageWindow.Result.No) + { + return true; + } + + return false; + } + + /// Ask if user wants to save the current project before continuing. + /// Returns true if either it saved successfully, or if the user didn't want to save, or if there is no project loaded, or if the project has no unexported assets. + public async Task AskProjectSave(string message) + { + if (Project is null || !Project.HasUnexportedAssets) + return true; + + var result = await View!.MessageDialog(message, buttons: MessageWindow.Buttons.YesNoCancel); + if (result == MessageWindow.Result.Yes) + { + if (await ProjectSaveTask()) + { + return true; + } + } + else if (result == MessageWindow.Result.No) + { + return true; + } + + return false; + } + + public void NewData() + { + CloseData(); + + Data = UndertaleData.CreateNew(); + DataPath = null; + } + + public async Task LoadData(Stream stream) + { + IsEnabled = false; + + ILoaderWindow w = View!.LoaderOpen(); + w.SetText("Opening data file..."); + + try + { + List warnings = []; + bool hadImportantWarnings = false; + + UndertaleData data = await Task.Run(() => UndertaleIO.Read(stream, + (string warning, bool isImportant) => + { + warnings.Add(warning); + if (isImportant) + { + hadImportantWarnings = true; + } + }, + (string message) => + { + Dispatcher.UIThread.Post(() => w.SetText($"Opening data file... {message}")); + }) + ); + + if (warnings.Count > 0) + { + w.EnsureShown(); + await View!.MessageDialog($"Warnings occurred when loading the data file:\n\n" + + $"{(hadImportantWarnings ? "Data loss will likely occur when trying to save.\n" : "")}" + + $"{String.Join("\n", warnings)}"); + } + + // TODO: Add other checks for possible stuff. + + Data = data; + + return true; + } + catch (Exception e) + { + w.EnsureShown(); + await View!.MessageDialog($"Error opening data file: {e.Message}"); + + return false; + } + finally + { + IsEnabled = true; + w.Close(); + } + } + + public UndertaleData? GetAudioGroupData(int audioGroupId) + { + if (audioGroupDataList.TryGetValue(audioGroupId, out UndertaleData? value)) + { + return value; + } + + return LoadAudioGroupData(audioGroupId); + } + + public UndertaleData? LoadAudioGroupData(int audioGroupId) + { + if (Data is null) + return null; + + if (audioGroupId >= Data.AudioGroups.Count) + return null; + + UndertaleAudioGroup audioGroup = Data.AudioGroups[audioGroupId]; + + string relativePath = audioGroup.Path?.Content ?? $"audiogroup{audioGroupId}.dat"; + + string path = Paths.JoinVerifyWithinDirectory(Path.GetDirectoryName(DataPath), relativePath); + + if (File.Exists(path)) + { + try + { + using FileStream stream = File.OpenRead(path); + + UndertaleData audioGroupData = UndertaleIO.Read(stream, + (string warning, bool isImportant) => + { + //warnings.Add(warning); + if (isImportant) + { + //hadImportantWarnings = true; + } + }, + (string message) => + { + //Dispatcher.UIThread.Post(() => w.SetText($"Opening data file... {message}")); + } + ); + + audioGroupDataList[audioGroupId] = audioGroupData; + return audioGroupData; + } + catch (Exception e) + { + return null; + } + } + + return null; + } + + public void UnloadAudioGroupData(int audioGroupId) + { + audioGroupDataList.Remove(audioGroupId); + } + + public async Task SaveData(Stream stream) + { + IsEnabled = false; + + ILoaderWindow w = View!.LoaderOpen(); + w.SetText("Saving data file..."); + + try + { + await Task.Run(() => + { + // TODO: RecompileAllCodeSourcesOnProjectSave setting + if (Project is not null) + { + Project.RecompileAllCodeSources(); + } + + UndertaleIO.Write(stream, Data, message => + { + Dispatcher.UIThread.Post(() => w.SetText($"Saving data file... {message}")); + }); + }); + + return true; + } + catch (ProjectException e) + { + w.EnsureShown(); + await View!.MessageDialog($"Recompile error:\n{e.Message}"); + } + catch (Exception e) + { + w.EnsureShown(); + await View!.MessageDialog($"Error saving data file:\n{e.Message}"); + } + finally + { + IsEnabled = true; + w.Close(); + } + + return false; + } + + public void CloseData() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + foreach (var window in desktop.Windows.ToList()) + { + if (window is SearchInCodeWindow or FindReferencesWindow or ProjectAssetsWindow) + { + window.Close(); + } + } + } + + Tabs.TabCloseAllWithoutSaving(); + + ClearProject(); + + Data = null; + DataPath = null; + + audioGroupDataList.Clear(); + } + + public void UpdateVersion() + { + DataVersion = Data is not null && Data.GeneralInfo is not null ? (Data.GeneralInfo.Major, Data.GeneralInfo.Minor, Data.GeneralInfo.Release, Data.GeneralInfo.Build) : default; + } + + void DataGeneralInfoChangedHandler(object? sender, PropertyChangedEventArgs e) + { + if (Data is not null && e.PropertyName is + nameof(UndertaleGeneralInfo.Major) or nameof(UndertaleGeneralInfo.Minor) or + nameof(UndertaleGeneralInfo.Release) or nameof(UndertaleGeneralInfo.Build)) + { + UpdateVersion(); + } + } + + // Menus + public async void FileNew() + { + if (await AskProjectSave("There are assets marked to be exported in the current project. Save project before closing it?") + && await AskFileSave("Save data file before creating a new one?")) + { + NewData(); + } + } + + public async void FileOpen() + { + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before closing it?")) + return; + if (!await AskFileSave("Save data file before opening a new one?")) + return; + + var files = await View!.OpenFileDialog(new FilePickerOpenOptions() + { + Title = "Open data file", + FileTypeFilter = FilePickerFileTypes.Data, + SuggestedStartLocation = lastDataLocation, + }); + + if (files.Count != 1) + return; + + CloseData(); + + using Stream stream = await files[0].OpenReadAsync(); + + if (await LoadData(stream)) + { + DataPath = files[0].TryGetLocalPath(); + lastDataLocation = await files[0].GetParentAsync(); + } + } + + public async void FileSave() + { + await FileSaveTask(); + } + + public async Task FileSaveTask() + { + if (Data is null) + return false; + + if (!await Tabs.TabSaveAll()) + return false; + + if (Project is not null) + { + bool saveInProjectDestination = true; + + if (!Settings.AlwaysSaveDataInProjectDestination) + { + var result = await View!.MessageDialog("Save to the project's designated data file for saving?", buttons: MessageWindow.Buttons.YesNoCancel); + if (result == MessageWindow.Result.Yes) + { + saveInProjectDestination = true; + } + else if (result == MessageWindow.Result.No) + { + // If pressed No, continue saving as if there's no project. + saveInProjectDestination = false; + } + else + { + return false; + } + } + + if (saveInProjectDestination) + { + if (!await SaveDataToFilePath(Project.SaveDataPath, useTempFile: false)) + { + return false; + } + DataPath = Project.SaveDataPath; + return true; + } + } + + IStorageFile? file = await View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Save data file", + FileTypeChoices = FilePickerFileTypes.Data, + SuggestedFileName = Path.GetFileName(DataPath), + SuggestedStartLocation = lastDataLocation, + }); + + if (file is null) + return false; + + string path = file.TryGetLocalPath() ?? throw new PlatformNotSupportedException(); + + bool saved = await SaveDataToFilePath(path, useTempFile: true); + if (saved) + { + DataPath = path; + lastDataLocation = await file.GetParentAsync(); + return true; + } + + return false; + } + + async Task SaveDataToFilePath(string filePath, bool useTempFile = true) + { + bool writeFileCreated = false; + + string tempPath = filePath + "temp"; + string writeFilePath = useTempFile ? tempPath : filePath; + var writeFileMode = useTempFile ? FileMode.CreateNew : FileMode.Create; + + try + { + using (FileStream stream = File.Open(writeFilePath, writeFileMode, FileAccess.Write)) + { + writeFileCreated = true; + await SaveData(stream); + + if (useTempFile) + { + stream.Flush(flushToDisk: true); + } + } + + if (useTempFile) + { + File.Move(tempPath, filePath, overwrite: true); + } + } + catch (IOException ex) + { + // Delete file only if it was created right now, not if it was pre-existing. + if (writeFileCreated) + { + File.Delete(tempPath); + } + await View!.MessageDialog($"Error saving data file:\n{ex.Message}"); + return false; + } + + return true; + } + + public async void FileClose() + { + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before closing it?")) + return; + if (!await AskFileSave("Save data file before closing?")) + return; + + CloseData(); + } + + public async void FileTempRun() + { + // TODO: Ideally, if the project system is being used, this would actually not use a temp file, but instead just save it to the destination file. + if (Data is null) + return; + + string? runnerName = Data.GeneralInfo?.FileName?.Content; + if (runnerName is null) + { + await View!.MessageDialog($"Error: File name in general info not set."); + return; + } + + if (DataPath is null) + return; + + // Save to temp + + string tempFileName = Path.GetTempFileName(); + + if (!await SaveDataToFilePath(tempFileName, useTempFile: false)) + { + return; + } + + string? runnerPath; + + if (Project is not null) + { + runnerPath = Paths.TryJoinVerifyWithinDirectory(Project.SaveDirectory, $"{runnerName}.exe"); + } + else + { + runnerPath = Paths.TryJoinVerifyWithinDirectory(Path.GetDirectoryName(DataPath), $"{runnerName}.exe"); + } + + if (runnerPath is null || !File.Exists(runnerPath)) + { + await View!.MessageDialog($"Error: Invalid or non-existent runner. ({runnerPath})"); + return; + } + + StartRunnerProcess(runnerPath, dataPath: tempFileName, workingDirectory: Path.GetDirectoryName(DataPath)); + } + + public async void FileRun() + { + if (Data is null) + return; + + string? runnerName = Data.GeneralInfo?.FileName?.Content; + if (runnerName is null) + { + await View!.MessageDialog($"Error: File name in general info not set."); + return; + } + + string question = $"Save data file before running? {(DataPath is null + ? " It must be saved before running." + : $"If it's not saved, the data file at the last location will be used (\"{DataPath}\").")}"; + + if (!await AskFileSave(question)) + return; + + if (DataPath is null) + return; + + string? runnerPath; + + if (Project is not null) + { + runnerPath = Paths.TryJoinVerifyWithinDirectory(Project.SaveDirectory, $"{runnerName}.exe"); + } + else + { + runnerPath = Paths.TryJoinVerifyWithinDirectory(Path.GetDirectoryName(DataPath), $"{runnerName}.exe"); + } + + if (runnerPath is null || !File.Exists(runnerPath)) + { + await View!.MessageDialog($"Error: Invalid or non-existent runner. ({runnerPath})"); + return; + } + + StartRunnerProcess(runnerPath); + } + + public async void FileRunWithOther() + { + if (Data is null) + return; + + string question = $"Save data file before running? {(DataPath is null + ? " It must be saved before running." + : $"If it's not saved, the data file at the last location will be used (\"{DataPath}\").")}"; + + if (!await AskFileSave(question)) + return; + + if (DataPath is null) + return; + + var files = await View!.OpenFileDialog(new FilePickerOpenOptions() + { + Title = "Open runner", + FileTypeFilter = FilePickerFileTypes.All, + }); + + if (files.Count != 1) + return; + + string runnerPath = files[0].TryGetLocalPath() ?? string.Empty; + if (runnerPath == string.Empty) + return; + + if (!File.Exists(DataPath)) + return; + + StartRunnerProcess(runnerPath); + } + + void StartRunnerProcess(string runnerPath, string? dataPath = null, string? workingDirectory = null) + { + dataPath ??= DataPath; + workingDirectory ??= Path.GetDirectoryName(dataPath); + + // "launcher" allows game_change data files to still access files above the data path. + Process.Start(new ProcessStartInfo(runnerPath, $"-game \"{dataPath}\" launcher") { WorkingDirectory = workingDirectory }); + } + + public void FileClearAudioGroupCache() + { + audioGroupDataList.Clear(); + GC.Collect(); + } + + public async void FileSettings() + { + if (View is MainView mainView) + await mainView.OpenSettingsDialog(ServiceProvider); + } + + public void FileExit() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + + public void ToolsSearchInCode() + { + if (View is MainView mainView) + mainView.OpenSearchInCode(ServiceProvider); + } + + public void ToolsFindReferences() + { + OpenFindReferences(); + } + + public async void ScriptsRunOtherScript() + { + var files = await View!.OpenFileDialog(new FilePickerOpenOptions() + { + Title = "Run script", + FileTypeFilter = FilePickerFileTypes.CS, + }); + + if (files.Count != 1) + return; + + string text; + using (Stream stream = await files[0].OpenReadAsync()) + { + using StreamReader streamReader = new(stream); + text = streamReader.ReadToEnd(); + } + + string? filePath = files[0].TryGetLocalPath(); + + await Scripting.RunScript(text, filePath); + CommandTextBoxText = $"{Path.GetFileName(filePath) ?? "Script"} finished!"; + } + + public async void ScriptsRunScript(string filePath) + { + string text = File.ReadAllText(filePath); + + await Scripting.RunScript(text, filePath); + CommandTextBoxText = $"{Path.GetFileName(filePath) ?? "Script"} finished!"; + } + + void ClearProject() + { + Project = null; + + if (View is MainView mainView) + mainView.CloseProjectAssets(); + } + + void SetProject(ProjectContext projectContext) + { + Project = projectContext; + Project.UnexportedAssetsChanged += (s, e) => + { + UpdateSelectedTabProperties(); + }; + + UpdateSelectedTabProperties(); + } + + async Task AskProjectDestinationDataFile() + { + // Destination data file + // TODO: Check if same as source and if empty directory + IStorageFile? destinationDataFile = await View!.SaveFileDialog(new() + { + Title = "Select destination data file location", + FileTypeChoices = FilePickerFileTypes.Data, + }); + string? destinationDataPath = destinationDataFile?.TryGetLocalPath(); + + return destinationDataPath; + } + + public async void ProjectNew() + { + // TODO: Ask for source data file if nothing is opened + if (Data is null || DataPath is null) + return; + + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before creating a new one?")) + return; + + ClearProject(); + + // Project name + string? projectName = await View!.TextBoxDialog("Project name:", $"{Data.GeneralInfo?.DisplayName?.Content ?? "New"} Mod"); + if (projectName is null) + return; + + // Project folder + IReadOnlyList projectFolderList = await View!.OpenFolderDialog(new() { Title = "Select project folder" }); + string? projectFolderPath = projectFolderList.ElementAtOrDefault(0)?.TryGetLocalPath(); + + if (projectFolderPath is null) + return; + + string projectFilePath = Path.Join(projectFolderPath, "project.json"); + + // Destination data file + string? destinationDataPath = await AskProjectDestinationDataFile(); + if (destinationDataPath is null) + return; + + ProjectContext projectContext; + try + { + projectContext = new(Data, DataPath, destinationDataPath, projectFilePath, projectName.Trim(), Dispatcher.UIThread.Invoke); + } + catch (ProjectException e) + { + await View!.MessageDialog($"Failed to create new project:\n{e.Message}"); + return; + } + catch (Exception e) + { + await View!.MessageDialog($"Error occurred when creating new project:\n{e}"); + return; + } + + DataPath = destinationDataPath; + SetProject(projectContext); + } + + public async void ProjectOpen() + { + // TODO: Ask for source data file if nothing is opened + if (Data is null || DataPath is null) + return; + + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before opening a new one?")) + return; + + ClearProject(); + + // Project file + IReadOnlyList projectFileList = await View!.OpenFileDialog(new() + { + Title = "Select project.json file", + FileTypeFilter = FilePickerFileTypes.JSON, + }); + string? projectFilePath = projectFileList.ElementAtOrDefault(0)?.TryGetLocalPath(); + if (projectFilePath is null) + return; + + // Destination data file + string? destinationDataPath = await AskProjectDestinationDataFile(); + if (destinationDataPath is null) + return; + + ProjectContext projectContext; + try + { + projectContext = ProjectContext.CreateWithDataFilePaths(DataPath, destinationDataPath, projectFilePath); + projectContext.Import(Data, Settings.EnableProjectBackup ? null : new GameFileNoOpBackup(), Dispatcher.UIThread.Invoke); + } + catch (ProjectException e) + { + await View!.MessageDialog($"Failed to load project:\n{e.Message}"); + return; + } + catch (Exception e) + { + await View!.MessageDialog($"Error occurred when loading project:\n{e}"); + return; + } + + DataPath = destinationDataPath; + SetProject(projectContext); + } + + public async void ProjectSave() + { + await ProjectSaveTask(); + } + + public async Task ProjectSaveTask() + { + if (Project is null || Data is null || DataPath is null) + return false; + + try + { + Project.Export(true); + return true; + } + catch (ProjectException e) + { + await View!.MessageDialog($"Failed to save project:\n{e.Message}"); + } + catch (Exception e) + { + await View!.MessageDialog($"Error occurred when saving project:\n{e}"); + } + + return false; + } + + public async void ProjectReload() + { + if (Project is null) + return; + + if (Project.LoadDataPath is null || Project.SaveDataPath is null || Project.MainFilePath is null) + return; + + string sourceDataPath = Project.LoadDataPath; + string destinationDataPath = Project.SaveDataPath; + string projectFilePath = Project.MainFilePath; + + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before reloading?")) + return; + + ClearProject(); + + ProjectContext projectContext; + try + { + projectContext = ProjectContext.CreateWithDataFilePaths(sourceDataPath, destinationDataPath, projectFilePath); + projectContext.Import(Data, Settings.EnableProjectBackup ? null : new GameFileNoOpBackup(), Dispatcher.UIThread.Invoke); + } + catch (ProjectException e) + { + await View!.MessageDialog($"Failed to load project:\n{e.Message}"); + return; + } + catch (Exception e) + { + await View!.MessageDialog($"Error occurred when reloading project:\n{e}"); + return; + } + + DataPath = destinationDataPath; + SetProject(projectContext); + } + + public async void ProjectViewUnexportedAssets() + { + if (Project is null || Data is null || DataPath is null) + return; + + if (View is MainView mainView) + mainView.OpenProjectAssets(ServiceProvider); + } + + public async void ProjectClose() + { + if (!await AskProjectSave("There are assets marked to be exported in the current project. Save project before closing?")) + return; + + ClearProject(); + } + + public async void HelpGitHub() + { + await View!.LaunchUriAsync(new Uri("https://github.com/UnderminersTeam/UndertaleModTool")); + } + + public async void HelpAbout() + { + await View!.MessageDialog($"UndertaleModTool v{App.VersionString} " + + $"by the Underminers team" + + $"\nhttps://github.com/UnderminersTeam/UndertaleModTool" + + $"\nLicensed under the GNU General Public License Version 3." + + $"\n" + + $"\nInformational version: {App.InformationalVersionString}" + , + title: "About"); + } + + public async void DataItemAdd(IList list) + { + if (Data is null || list is null) + return; + + UndertaleResource res = UndertaleData.CreateResource(list); + + string? name = UndertaleData.GetDefaultResourceName(list); + if (name is not null) + { + name = await View!.TextBoxDialog("Name of new asset:", name); + if (name is null) + return; + + static bool IsValidAssetIdentifier(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + + char firstChar = name[0]; + if (!char.IsAsciiLetter(firstChar) && firstChar != '_') + return false; + + foreach (char c in name.Skip(1)) + if (!char.IsAsciiLetterOrDigit(c) && c != '_') + return false; + + return true; + } + + if (!IsValidAssetIdentifier(name)) + { + await View!.MessageDialog($"Asset name \"{name}\" is not a valid identifier. Only letters, digits and underscore allowed, and it must not start with a digit."); + return; + } + } + + var newResources = Data.InitializeResource(res, list, name); + + if (res is UndertaleRoom room) + { + if (await View!.MessageDialog("Add the new room to the end of the room order list?", buttons: MessageWindow.Buttons.YesNo) == MessageWindow.Result.Yes) + Data.GeneralInfo?.RoomOrder.Add(new(room)); + } + + list.Add(res); + + if (Project is not null && res is IProjectAsset { ProjectExportable: true } projectAsset) + { + Project.MarkAssetForExport(projectAsset); + + foreach (UndertaleResource newResource in newResources) + { + if (newResource is IProjectAsset { ProjectExportable: true } newProjectAsset) + { + Project.MarkAssetForExport(newProjectAsset); + } + } + } + + if (Settings.OpenNewResourceAfterCreatingIt) + { + _ = TabOpen(res, inNewTab: true); + } + } + + public async void DataItemRemove(UndertaleResource resource) + { + if (Data is null) + return; + + if (await View!.MessageDialog($"Delete {resource}?\nNote that the code often references objects by ID, " + + $"so this operation is likely to break stuff because other items will shift up!", + buttons: MessageWindow.Buttons.YesNo) == MessageWindow.Result.Yes) + { + // TODO: Maybe do something about all references to this. + Data[resource.GetType()].Remove(resource); + + if (Project is not null && resource is IProjectAsset projectAsset) + { + Project.UnmarkAssetForExport(projectAsset); + } + + // TODO: Close tabs, remove histories + } + } + + public void OpenFindReferences(UndertaleResource? resource = null) + { + if (View is MainView mainView) + mainView.OpenFindReferences(ServiceProvider, resource); + } + + // Tabs + public Task TabOpen(object? item, bool inNewTab = false) => Tabs.TabOpen(item, inNewTab); + + // Bottom bar + public void UpdateSelectedTabProperties() + { + if (Data is not null && Tabs.TabSelected?.Content is IUndertaleResourceViewModel vm) + { + TabSelectedResourceIdString = Data.IndexOf(vm.Resource).ToString(); + + if (Project is not null) + { + if (vm.Resource is IProjectAsset { ProjectExportable: true } projectAsset) + { + TabIsMarkedForExport = Project.IsAssetMarkedForExport(projectAsset); + TabCanMarkedForExport = true; + return; + } + } + } + else + { + TabSelectedResourceIdString = "None"; + } + + TabIsMarkedForExport = false; + TabCanMarkedForExport = false; + } + + partial void OnTabIsMarkedForExportChanged(bool value) + { + if (Project is not null + && Tabs.TabSelected?.Content is IUndertaleResourceViewModel vm + && vm.Resource is IProjectAsset { ProjectExportable: true } projectAsset) + { + if (TabIsMarkedForExport) + { + Project.MarkAssetForExport(projectAsset); + } + else + { + Project.UnmarkAssetForExport(projectAsset); + } + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/MainWindow.axaml b/UndertaleModToolAvalonia/Windows/MainWindow.axaml new file mode 100644 index 000000000..d9e8145a4 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainWindow.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs new file mode 100644 index 000000000..de9287c57 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + + UpdateScriptsMenu(); + } + + void NativeMenuItem_Click_ReloadScriptsList(object? sender, EventArgs e) => UpdateScriptsMenu(); + + async void UpdateScriptsMenu() + { + NativeMenuItem? scripts = NativeMenu.GetMenu(this)?.Items + .OfType() + .First(x => x.Header == "_Scripts"); + + if (scripts is not null && scripts.Menu is not null) + { + while (scripts.Menu.Items.Count > 3) + { + scripts.Menu.Items.RemoveAt(scripts.Menu.Items.Count - 1); + } + + string scriptsDir = Path.Join(AppContext.BaseDirectory, "Scripts"); + + if (Directory.Exists(scriptsDir)) + { + scripts.Menu.Items.Add(new NativeMenuItem("Loading...") { IsEnabled = false }); + + var items = await Task.Run(() => GetScriptMenuItems(scriptsDir)); + + scripts.Menu.Items.RemoveAt(3); + + foreach (var menu in items) + { + scripts.Menu.Add(menu); + } + } + } + } + + IEnumerable GetScriptMenuItems(string dir) + { + foreach (string file in Directory.EnumerateDirectories(dir)) + { + NativeMenuItem item = new(Path.GetFileName(file).Replace("_", "__")); + + item.Menu = [.. GetScriptMenuItems(file)]; + + if (item.Menu.Items.Count > 0) + { + yield return item; + } + } + + foreach (string file in Directory.EnumerateFiles(dir)) + { + if (Path.GetExtension(file) == ".csx") + { + NativeMenuItem item = new(Path.GetFileName(file).Replace("_", "__")); + item.Click += (source, e) => + { + if (DataContext is MainViewModel vm) + { + vm.ScriptsRunScript(file); + } + }; + yield return item; + } + } + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (!e.IsProgrammatic) + { + if (DataContext is MainViewModel vm && vm.Data is not null) + { + e.Cancel = true; + + async void AskSaveBeforeClose() + { + if (await vm.AskProjectSave("There are assets marked to be exported in the current project. Save project before quitting?") + && await vm.AskFileSave("Save data file before quitting?")) + Close(); + } + + AskSaveBeforeClose(); + } + } + + base.OnClosing(e); + } +} diff --git a/UndertaleModToolAvalonia/Windows/MessageWindow.axaml b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml new file mode 100644 index 000000000..a0efe7a5a --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/MessageWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml.cs new file mode 100644 index 000000000..8ff478f8d --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml.cs @@ -0,0 +1,87 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input.Platform; + +namespace UndertaleModToolAvalonia; + +public partial class MessageWindow : Window +{ + public string Message { get; set; } = "Message."; + public string TitleText { get; set; } = "UndertaleModToolAvalonia v" + App.VersionString; + public bool HasOKButton { get; set; } = false; + public bool HasYesButton { get; set; } = false; + public bool HasNoButton { get; set; } = false; + public bool HasCancelButton { get; set; } = false; + + public enum Buttons + { + None = 0, + OK, + YesNo, + YesNoCancel, + YesCancel, + } + + public enum Result + { + None = 0, + OK, + Yes, + No, + Cancel, + } + + public MessageWindow() + { + Initialize(); + } + + public MessageWindow(string message, string? title = null, Buttons buttons = Buttons.OK) + { + Message = message; + + if (title is not null) + TitleText = title; + + HasOKButton = buttons is Buttons.OK; + HasYesButton = buttons is Buttons.YesNo or Buttons.YesNoCancel or Buttons.YesCancel; + HasNoButton = buttons is Buttons.YesNo or Buttons.YesNoCancel; + HasCancelButton = buttons is Buttons.YesCancel or Buttons.YesNoCancel; + + Initialize(); + } + + public void Initialize() + { + InitializeComponent(); + + double frameHeight = (FrameSize is not null) ? (FrameSize!.Value.Height - ClientSize.Height) : 0; + MaxHeight = (Screens.Primary?.WorkingArea.Height - frameHeight) ?? Double.PositiveInfinity; + } + + public void OkClick() + { + Close(Result.OK); + } + + public void YesClick() + { + Close(Result.Yes); + } + + public void NoClick() + { + Close(Result.No); + } + + public void CancelClick() + { + Close(Result.Cancel); + } + + public async void Copy() + { + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + await topLevel.Clipboard!.SetTextAsync(Message); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml b/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml new file mode 100644 index 000000000..8d3095be9 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml.cs new file mode 100644 index 000000000..04bfd5581 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/ProjectAssetsWindow.axaml.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.DragAndDrop; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Project; + +namespace UndertaleModToolAvalonia; + +public partial class ProjectAssetsWindow : Window +{ + public MainViewModel MainVM { get; } + + private bool _preventUpdateList = false; + + public readonly record struct UnexportedAsset(string Name, string AssetType, IProjectAsset ProjectAsset); + + public ProjectAssetsWindow(IServiceProvider serviceProvider) + { + InitializeComponent(); + + MainVM = serviceProvider.GetRequiredService(); + + AssetsDataGrid.AddHandler(DataGrid.KeyDownEvent, DataGrid_KeyDown_Tunnel, RoutingStrategies.Tunnel); + + if (MainVM.Project is ProjectContext project) + { + UpdateList(); + project.UnexportedAssetsChanged += UpdateList; + } + } + + private void UpdateList(object? sender, EventArgs e) + { + UpdateList(); + } + + private void UpdateList() + { + if (MainVM.Project is null) + return; + + // If list is temporarily prevented from being updated, don't do anything + if (_preventUpdateList) + { + return; + } + + // Populate with current project assets + List assets = MainVM.Project + .EnumerateUnexportedAssets() + .Select((IProjectAsset asset) => new UnexportedAsset(asset.ProjectName, asset.ProjectAssetType.ToInterfaceName(), asset)) + .ToList(); + + // Sort assets by type and name + assets.Sort((a, b) => + { + if (a.AssetType.CompareTo(b.AssetType) is int i && i != 0) + { + return i; + } + if (a.Name.CompareTo(b.Name) is int j && j != 0) + { + return j; + } + return 0; + }); + + // Update data grid + AssetsDataGrid.ItemsSource = assets; + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (MainVM.Project is ProjectContext project) + { + project.UnexportedAssetsChanged -= UpdateList; + } + } + + private void OpenSelectedListViewItem(bool inNewTab = false) + { + if (AssetsDataGrid.SelectedItems is [UnexportedAsset asset, ..]) + { + OpenUnexportedAsset(asset, inNewTab); + } + } + + private void OpenUnexportedAsset(UnexportedAsset asset, bool inNewTab = false) + { + if (asset.ProjectAsset is not UndertaleObject obj) + { + return; + } + + _ = MainVM.TabOpen(obj, inNewTab); + } + + private void UnmarkSelectedListViewItemsForExport() + { + if (MainVM.Project is ProjectContext projectContext) + { + _preventUpdateList = true; + foreach (UnexportedAsset asset in AssetsDataGrid.SelectedItems) + { + projectContext.UnmarkAssetForExport(asset.ProjectAsset); + } + _preventUpdateList = false; + UpdateList(); + } + } + + private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + if (e.Source is Control control) + if (control.DataContext is UnexportedAsset asset) + OpenUnexportedAsset(asset); + } + + private void DataGrid_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + { + if (e.Source is Control control) + if (control.DataContext is UnexportedAsset asset) + OpenUnexportedAsset(asset, inNewTab: true); + } + } + + private void DataGrid_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + OpenSelectedListViewItem(); + e.Handled = true; + } + else if (e.Key == Key.Delete) + { + UnmarkSelectedListViewItemsForExport(); + e.Handled = true; + } + } + + private void DataGridRow_Open_Click(object sender, RoutedEventArgs e) + { + OpenSelectedListViewItem(); + e.Handled = true; + } + + private void DataGridRow_OpenInNewTab_Click(object sender, RoutedEventArgs e) + { + OpenSelectedListViewItem(inNewTab: true); + e.Handled = true; + } + + private void DataGridRow_UnmarkForExport_Click(object sender, RoutedEventArgs e) + { + UnmarkSelectedListViewItemsForExport(); + e.Handled = true; + } +} + +public class ProjectAssetsWindowDataGridDropHandler : DropHandlerBase +{ + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is ProjectAssetsWindow window && window.MainVM.Project is ProjectContext project + && sourceContext is DataExplorerViewModel.Item item && item.Value is IProjectAsset projectAsset) + { + return true; + } + + return false; + } + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (targetContext is ProjectAssetsWindow window && window.MainVM.Project is ProjectContext project + && sourceContext is DataExplorerViewModel.Item item && item.Value is IProjectAsset projectAsset) + { + project.MarkAssetForExport(projectAsset); + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/SearchInCodeViewModel.cs b/UndertaleModToolAvalonia/Windows/SearchInCodeViewModel.cs new file mode 100644 index 000000000..c2e5a26ea --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SearchInCodeViewModel.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using UndertaleModLib; +using UndertaleModLib.Decompiler; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class SearchInCodeViewModel : ObservableObject +{ + // Set this when testing. + public IView? View; + + public MainViewModel MainVM { get; } + + [ObservableProperty] + public partial bool IsEnabled { get; set; } = true; + + [ObservableProperty] + public partial string SearchText { get; set; } = ""; + + [ObservableProperty] + public partial bool IsCaseSensitive { get; set; } = false; + + [ObservableProperty] + public partial bool IsRegexSearch { get; set; } = false; + + [ObservableProperty] + public partial bool IsInAssembly { get; set; } = false; + + [ObservableProperty] + public partial ObservableCollection Results { get; set; } = []; + + [ObservableProperty] + public partial string StatusBarText { get; set; } = "Ready."; + + string searchText = null!; + Regex searchTextRegex = null!; + + GlobalDecompileContext? globalDecompileContext; + + ConcurrentDictionary> resultsByCodeDict = new(); + int resultCount = 0; + int failedCount = 0; + + ILoaderWindow? loaderWindow; + int currentCodeEntriesCount = 0; + bool postToLoader = true; + + bool resultsAreInAssembly = false; + + public SearchInCodeViewModel(IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + } + + public async void Search() + { + if (MainVM.Data is null) + { + StatusBarText = "Error: No data file loaded."; + return; + } + + if (MainVM.Data.IsYYC()) + { + StatusBarText = "Error: Can't search code in YYC game, there's no code to search."; + return; + } + + searchText = SearchText.Replace("\r\n", "\n"); + + if (String.IsNullOrEmpty(searchText)) + { + StatusBarText = "Error: No text to search."; + return; + } + + if (IsRegexSearch) + { + try + { + searchTextRegex = new(searchText, IsCaseSensitive ? RegexOptions.Compiled : RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + catch (ArgumentException e) + { + StatusBarText = $"Error: Invalid regex ({e.Message})"; + return; + } + } + + // Set up loader window + loaderWindow = View!.LoaderOpen(); + loaderWindow.SetMaximum(MainVM.Data.Code.Count); + loaderWindow.SetValue(0); + loaderWindow.SetMessage("Searching..."); + loaderWindow.EnsureShown(); + + IsEnabled = false; + MainVM.IsEnabled = false; + + // Search codes in parallel + globalDecompileContext = new(MainVM.Data); + + await Task.Run(() => Parallel.ForEach(MainVM.Data.Code, SearchInUndertaleCode)); + + // Sort results + loaderWindow.SetText("Sorting..."); + + List sortedResultsList = new(resultCount); + + await Task.Run(() => + { + var sortedResultsByCodeDict = resultsByCodeDict.OrderBy(entry => MainVM.Data.Code.IndexOf(entry.Key)); + + foreach (var result in sortedResultsByCodeDict) + { + UndertaleCode code = result.Key; + foreach (var (lineNumber, columnNumber, lineText) in result.Value) + { + sortedResultsList.Add(new(code, lineNumber, columnNumber, lineText)); + } + } + }); + + resultsAreInAssembly = IsInAssembly; + + Results = [.. sortedResultsList]; + + // Set status bar text + string str = $"{resultCount} result{(resultCount != 1 ? "s" : "")} found in {resultsByCodeDict.Count} code entr{(resultsByCodeDict.Count != 1 ? "ies" : "y")}."; + if (failedCount > 0) + { + str += $" {failedCount} code entr{(failedCount != 1 ? "ies" : "y")} with an error."; + } + StatusBarText = str; + + // Reset variables + resultsByCodeDict = new(); + resultCount = 0; + failedCount = 0; + currentCodeEntriesCount = 0; + postToLoader = true; + + // Close loader window + loaderWindow.Close(); + + IsEnabled = true; + MainVM.IsEnabled = true; + } + + void SearchInUndertaleCode(UndertaleCode code) + { + if (postToLoader) + { + postToLoader = false; + Dispatcher.UIThread.Post(() => + { + loaderWindow!.SetValue(currentCodeEntriesCount); + postToLoader = true; + }, DispatcherPriority.Background); + } + + if (code is not null && code.ParentEntry is null) + { + string codeText = String.Empty; + + if (!IsInAssembly) + { + if (MainVM.Project is null || !MainVM.Project.TryGetCodeSource(code, out codeText)) + { + try + { + codeText = new Underanalyzer.Decompiler.DecompileContext(globalDecompileContext!, code, MainVM.Data!.ToolInfo.DecompilerSettings).DecompileToString(); + } + catch (Underanalyzer.Decompiler.DecompilerException) + { + Interlocked.Increment(ref failedCount); + return; + } + } + } + else + { + try + { + codeText = code.Disassemble(MainVM.Data!.Variables, MainVM.Data!.CodeLocals?.For(code)); + } + catch (Exception) + { + Interlocked.Increment(ref failedCount); + return; + } + } + + List results = []; + + if (IsRegexSearch) + { + MatchCollection matches = searchTextRegex.Matches(codeText); + foreach (Match match in matches) + { + results.Add(match.Index); + } + } + else + { + StringComparison comparisonType = IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + int index = 0; + while ((index = codeText.IndexOf(searchText, index, comparisonType)) != -1) + { + results.Add(index); + index += searchText.Length; + } + } + + bool nameWritten = false; + + int lineNumber = 0; + int lineStartIndex = 0; + + foreach (int index in results) + { + // Continue from previous line count since results are in order + for (int i = lineStartIndex; i < index; ++i) + { + if (codeText[i] == '\n') + { + lineNumber++; + lineStartIndex = i + 1; + } + } + + int columnNumber = index - lineStartIndex; + + // Start at match.Index so it's only one line in case the search was multiline + int lineEndIndex = codeText.IndexOf('\n', index); + lineEndIndex = lineEndIndex == -1 ? codeText.Length : lineEndIndex; + + string lineText = codeText[lineStartIndex..lineEndIndex]; + + if (nameWritten == false) + { + resultsByCodeDict[code] = []; + nameWritten = true; + } + resultsByCodeDict[code].Add((lineNumber + 1, columnNumber + 1, lineText)); + + Interlocked.Increment(ref resultCount); + } + } + + Interlocked.Increment(ref currentCodeEntriesCount); + } + + public async void OpenSearchResult(SearchResult searchResult, bool inNewTab = false) + { + var tab = await MainVM.TabOpen(searchResult.Code, inNewTab); + if (tab is not null && tab.Content is UndertaleCodeViewModel vm) + { + vm.GoToLocation(!resultsAreInAssembly ? UndertaleCodeViewModel.Tab.GML : UndertaleCodeViewModel.Tab.ASM, searchResult.LineNumber, searchResult.ColumnNumber); + } + } + + public class SearchResult + { + public string Location { get; set; } + public string Text { get; set; } + + public UndertaleCode Code; + public int LineNumber; + public int ColumnNumber; + + public SearchResult(UndertaleCode code, int lineNumber, int columnNumber, string text) + { + Code = code; + LineNumber = lineNumber; + ColumnNumber = columnNumber; + + Location = (code.Name?.Content ?? "") + ":" + lineNumber + "," + columnNumber; + Text = text.Trim(); + } + } +} diff --git a/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml new file mode 100644 index 000000000..350eaca34 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml.cs new file mode 100644 index 000000000..a3f99b04a --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml.cs @@ -0,0 +1,88 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace UndertaleModToolAvalonia; + +public partial class SearchInCodeWindow : Window, IView +{ + // TODO: Open multiple results + + public SearchInCodeWindow() + { + InitializeComponent(); + + DataContextChanged += (_, _) => + { + if (DataContext is SearchInCodeViewModel vm) + { + vm.View = this; + } + }; + + Loaded += (_, _) => + { + SearchTextTextBox.Focus(); + }; + + SearchTextTextBox.AddHandler(TextBox.KeyDownEvent, TextBox_KeyDown_Tunnel, RoutingStrategies.Tunnel); + ResultsDataGrid.AddHandler(DataGrid.KeyDownEvent, DataGrid_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + private void TextBox_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (DataContext is SearchInCodeViewModel vm) + if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + e.Handled = true; + vm.Search(); + } + } + + private void DataGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + if (DataContext is SearchInCodeViewModel vm) + if (e.Source is Control control) + if (control.DataContext is SearchInCodeViewModel.SearchResult searchResult) + vm.OpenSearchResult(searchResult); + } + + private void DataGrid_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle + && ((e.Source as Visual)?.GetTransformedBounds()?.Contains(e.GetPosition(null)) ?? false)) + if (DataContext is SearchInCodeViewModel vm) + if (e.Source is Control control) + if (control.DataContext is SearchInCodeViewModel.SearchResult searchResult) + vm.OpenSearchResult(searchResult, inNewTab: true); + } + + private void DataGrid_KeyDown_Tunnel(object? sender, KeyEventArgs e) + { + if (DataContext is SearchInCodeViewModel vm) + if (ResultsDataGrid.SelectedItem is SearchInCodeViewModel.SearchResult searchResult) + if (e.Key == Key.Enter) + { + e.Handled = true; + vm.OpenSearchResult(searchResult); + } + } + + private void DataGridRow_Open_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is SearchInCodeViewModel vm) + if (e.Source is Control control) + if (control.DataContext is SearchInCodeViewModel.SearchResult searchResult) + vm.OpenSearchResult(searchResult); + } + + private void DataGridRow_OpenInNewTab_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is SearchInCodeViewModel vm) + if (e.Source is Control control) + if (control.DataContext is SearchInCodeViewModel.SearchResult searchResult) + vm.OpenSearchResult(searchResult, inNewTab: true); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/SettingsViewModel.cs b/UndertaleModToolAvalonia/Windows/SettingsViewModel.cs new file mode 100644 index 000000000..447a74ddc --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsViewModel.cs @@ -0,0 +1,56 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; + +namespace UndertaleModToolAvalonia; + +public partial class SettingsViewModel : ObservableObject +{ + public MainViewModel MainVM { get; } + + public enum IndentStyleValue + { + FourSpaces = 0, + TwoSpaces = 1, + Tabs = 2, + Custom = 3, + } + + public IndentStyleValue IndentStyle + { + get; + set + { + field = value; + + MainVM.Settings.DecompileSettings.IndentString = value switch + { + IndentStyleValue.FourSpaces => " ", + IndentStyleValue.TwoSpaces => " ", + IndentStyleValue.Tabs => "\t", + IndentStyleValue.Custom => MainVM.Settings.DecompileSettings.IndentString, + _ => throw new NotImplementedException(), + }; + + IsCustomIndentStyle = value is IndentStyleValue.Custom; + } + } + + [ObservableProperty] + public partial bool IsCustomIndentStyle { get; set; } + + public SettingsViewModel(IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + + IndentStyle = MainVM.Settings.DecompileSettings.IndentString switch + { + " " => IndentStyleValue.FourSpaces, + " " => IndentStyleValue.TwoSpaces, + "\t" => IndentStyleValue.Tabs, + _ => IndentStyleValue.Custom, + }; + + IsCustomIndentStyle = (IndentStyle is IndentStyleValue.Custom); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml new file mode 100644 index 000000000..799e72daf --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml @@ -0,0 +1,182 @@ + + + + + + Interface + + + + + + System default + Light + Dark + + + + + + + + + + + + + + + + + + + + + GML + + + + + + + + + + + + + + + + + + + + 4 spaces + 2 spaces + Tabs + Custom + + + + + + + + + + + + + + + + + + + + + + + GML: Add Empty Lines + + + + + + + + + + + + + GML: Cleanups + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs new file mode 100644 index 000000000..0c806161b --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs @@ -0,0 +1,23 @@ +using System; +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class SettingsWindow : Window +{ + public SettingsWindow() + { + InitializeComponent(); + + Closing += async (_, _) => + { + if (DataContext is SettingsViewModel vm) + { + if (vm.MainVM.Settings.Save() is Exception ex) + { + await vm.MainVM.View!.MessageDialog($"Error when saving settings file:\n{ex.Message}"); + } + } + }; + } +} diff --git a/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml b/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml new file mode 100644 index 000000000..54925d48e --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml.cs new file mode 100644 index 000000000..24239b8ae --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml.cs @@ -0,0 +1,41 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class TextBoxWindow : Window +{ + public string Message { get; set; } = "Message."; + public string TitleText { get; set; } = "UndertaleModToolAvalonia v" + App.VersionString; + + public TextBoxWindow() { } + + public TextBoxWindow(string message, string text = "", string? title = null, bool isMultiline = false, bool isReadOnly = false) + { + Message = message; + + if (title is not null) + TitleText = title; + + InitializeComponent(); + + TextTextBox.Text = text; + TextTextBox.IsReadOnly = isReadOnly; + TextTextBox.AcceptsReturn = isMultiline; + + Loaded += (_, __) => + { + if (!isReadOnly) + TextTextBox.Focus(); + }; + } + + public void OkClick() + { + Close(TextTextBox.Text); + } + + public void CancelClick() + { + Close(null); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/app.manifest b/UndertaleModToolAvalonia/app.manifest new file mode 100644 index 000000000..1a6ceb81e --- /dev/null +++ b/UndertaleModToolAvalonia/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + +