diff --git a/.github/workflows/publish_gui_avalonia.yml b/.github/workflows/publish_gui_avalonia.yml new file mode 100644 index 000000000..aa606e291 --- /dev/null +++ b/.github/workflows/publish_gui_avalonia.yml @@ -0,0 +1,50 @@ +name: Publish UndertaleModToolAvalonia + +on: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_gui: + + 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@v4 + with: + submodules: true + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Publish ${{ matrix.os }} GUI + run: | + dotnet publish UndertaleModToolAvalonia.Desktop -c ${{ matrix.configuration }} -r ${{ matrix.rid }} --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=${{ matrix.singlefile }} --output Publish/${{ matrix.os }} + - name: Copy external files + run: | + cp ./README.md ./Publish/${{ matrix.os }} + cp ./SCRIPTS.md ./Publish/${{ matrix.os }} + cp ./LICENSE.txt ./Publish/${{ matrix.os }} + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: GUI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}-isSingleFile-${{ matrix.singlefile }} + path: Publish/${{ matrix.os }} diff --git a/UndertaleModLib/Models/UndertaleAnimationCurve.cs b/UndertaleModLib/Models/UndertaleAnimationCurve.cs index 60d2c6614..ed95f7cee 100644 --- a/UndertaleModLib/Models/UndertaleAnimationCurve.cs +++ b/UndertaleModLib/Models/UndertaleAnimationCurve.cs @@ -36,7 +36,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) @@ -156,7 +156,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) @@ -228,37 +228,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/UndertaleBackground.cs b/UndertaleModLib/Models/UndertaleBackground.cs index feddc7700..92d8f1def 100644 --- a/UndertaleModLib/Models/UndertaleBackground.cs +++ b/UndertaleModLib/Models/UndertaleBackground.cs @@ -153,7 +153,7 @@ public void Unserialize(UndertaleReader reader) /// /// Added in GameMaker Studio 2. /// - public List GMS2TileIds { get; set; } = new List(); + public UndertaleObservableList GMS2TileIds { get; set; } = new UndertaleObservableList(); /// /// Added in GameMaker 2024.14.1. @@ -222,7 +222,7 @@ public void Unserialize(UndertaleReader reader) GMS2TileCount = reader.ReadUInt32(); GMS2ExportedSpriteIndex = reader.ReadInt32(); GMS2FrameLength = reader.ReadInt64(); - GMS2TileIds = new List((int)GMS2TileCount * (int)GMS2ItemsPerTileCount); + GMS2TileIds = new((int)GMS2TileCount * (int)GMS2ItemsPerTileCount); for (int i = 0; i < GMS2TileCount * GMS2ItemsPerTileCount; i++) { TileID id = new TileID(); diff --git a/UndertaleModLib/Models/UndertaleGameObject.cs b/UndertaleModLib/Models/UndertaleGameObject.cs index d3d8fc54c..a997f3be9 100644 --- a/UndertaleModLib/Models/UndertaleGameObject.cs +++ b/UndertaleModLib/Models/UndertaleGameObject.cs @@ -139,7 +139,7 @@ public class UndertaleGameObject : UndertaleNamedResource, INotifyPropertyChange /// /// The vertices used for a of type . /// - public List PhysicsVertices { get; set; } = new List(); + public UndertaleObservableList PhysicsVertices { get; set; } = new UndertaleObservableList(); #endregion @@ -247,7 +247,7 @@ public void Unserialize(UndertaleReader reader) Awake = reader.ReadBoolean(); Kinematic = reader.ReadBoolean(); // Needs to be done manually because count is separated - PhysicsVertices.Capacity = physicsShapeVertexCount; + PhysicsVertices.SetCapacity(physicsShapeVertexCount); for (int i = 0; i < physicsShapeVertexCount; i++) { UndertalePhysicsVertex v = new UndertalePhysicsVertex(); diff --git a/UndertaleModLib/Models/UndertaleGeneralInfo.cs b/UndertaleModLib/Models/UndertaleGeneralInfo.cs index 27072bca8..1ff81cba6 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 cc123535f..268f7994d 100644 --- a/UndertaleModLib/Models/UndertaleRoom.cs +++ b/UndertaleModLib/Models/UndertaleRoom.cs @@ -1079,6 +1079,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) { @@ -2066,6 +2086,12 @@ public class LayerAssetsData : LayerData public UndertalePointerList ParticleSystems { get; set; } public UndertalePointerList TextItems { get; set; } + /// + /// List of the lists of types of assets. + /// UMT only. + /// + public List AllAssets { get; set; } = new List(); + /// public void Serialize(UndertaleWriter writer) { @@ -2128,6 +2154,8 @@ public void Unserialize(UndertaleReader reader) if (reader.undertaleData.IsVersionAtLeast(2024, 6)) reader.ReadUndertaleObject(TextItems); } + + InitializeAllAssets(); } /// @@ -2182,6 +2210,23 @@ public static uint UnserializeChildObjectCount(UndertaleReader reader) return count; } + public void InitializeAllAssets() + { + AllAssets.Clear(); + if (LegacyTiles != null) + AllAssets.Add(LegacyTiles); + if (Sprites != null) + AllAssets.Add(Sprites); + if (Sequences != null) + AllAssets.Add(Sequences); + if (NineSlices != null) + AllAssets.Add(NineSlices); + if (ParticleSystems != null) + AllAssets.Add(ParticleSystems); + if (TextItems != null) + AllAssets.Add(TextItems); + } + /// public void Dispose() { 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 0dcbf1052..3105b3581 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; @@ -1743,7 +1746,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; } @@ -1793,7 +1796,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 b374e42e5..d2168174b 100644 --- a/UndertaleModLib/UndertaleData.cs +++ b/UndertaleModLib/UndertaleData.cs @@ -687,6 +687,137 @@ 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 void InitializeResource(UndertaleResource resource, IList list, string resourceName) + { + // 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, createNew: true), + }; + + 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("", createNew: true); + } + } + else if (resource is UndertaleScript script) + { + if (IsVersionAtLeast(2, 3)) + { + script.Code = UndertaleCode.CreateEmptyEntry(this, Strings.MakeString($"gml_GlobalScript_{script.Name.Content}", createNew: true)); + 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}", createNew: true)); + } + } + 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) + { + 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("", createNew: true); + shader.GLSL_ES_Fragment = Strings.MakeString("", createNew: true); + shader.GLSL_Vertex = Strings.MakeString("", createNew: true); + shader.GLSL_Fragment = Strings.MakeString("", createNew: true); + shader.HLSL9_Vertex = Strings.MakeString("", createNew: true); + shader.HLSL9_Fragment = Strings.MakeString("", createNew: true); + } + } + /// public void Dispose() { diff --git a/UndertaleModLib/UndertaleIO.cs b/UndertaleModLib/UndertaleIO.cs index 109ba99ad..28ccff9c8 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 87d75214f..623e45321 100644 --- a/UndertaleModLib/UndertaleModLib.csproj +++ b/UndertaleModLib/UndertaleModLib.csproj @@ -10,8 +10,6 @@ 0.8.4.1 0.8.4.1 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/UndertaleModTool.sln b/UndertaleModTool.sln index 18e2bfe07..b9af375c7 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 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UndertaleModTool", "UndertaleModTool\UndertaleModTool.csproj", "{C703A485-DBFB-4601-8A26-42E64CCF812F}" EndProject @@ -17,6 +17,12 @@ 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 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UndertaleModToolAvalonia.Desktop", "UndertaleModToolAvalonia.Desktop\UndertaleModToolAvalonia.Desktop.csproj", "{314E047F-7297-F076-E9AC-8DEA76750E94}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UndertaleModToolAvalonia.Tests", "UndertaleModToolAvalonia.Tests\UndertaleModToolAvalonia.Tests.csproj", "{A7270EE0-D1D9-44EC-8E2E-15495FCD8772}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +87,30 @@ 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 + {314E047F-7297-F076-E9AC-8DEA76750E94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Debug|x64.ActiveCfg = Debug|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Debug|x64.Build.0 = Debug|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Release|Any CPU.Build.0 = Release|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Release|x64.ActiveCfg = Release|Any CPU + {314E047F-7297-F076-E9AC-8DEA76750E94}.Release|x64.Build.0 = Release|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Debug|x64.Build.0 = Debug|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Release|Any CPU.Build.0 = Release|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.Release|x64.ActiveCfg = Release|Any CPU + {A7270EE0-D1D9-44EC-8E2E-15495FCD8772}.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 779f08eb0..0aca286ae 100644 --- a/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs +++ b/UndertaleModTool/Editors/UndertaleBackgroundEditor.xaml.cs @@ -102,7 +102,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.Desktop/Program.cs b/UndertaleModToolAvalonia.Desktop/Program.cs new file mode 100644 index 000000000..dbeb09e85 --- /dev/null +++ b/UndertaleModToolAvalonia.Desktop/Program.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using Avalonia; +using SDL3; + +namespace UndertaleModToolAvalonia.Desktop; + +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) + { + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args, Avalonia.Controls.ShutdownMode.OnMainWindowClose); + } + catch (Exception e) + { + string localAppData = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UndertaleModToolAvalonia"); + Directory.CreateDirectory(localAppData); + + File.WriteAllText(Path.Join(localAppData, "CrashLog.txt"), e.ToString()); + + // TODO: Figure out a way to actually stop the UI and other threads. + SDL.ShowSimpleMessageBox(SDL3.SDL.MessageBoxFlags.Error, "UndertaleModToolAvalonia", $"{e}", 0); + throw; + } + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + // NOTE: Software rendering on Linux to avoid crashes, I don't know why that's happening. + .With(new X11PlatformOptions() { RenderingMode = [X11RenderingMode.Software] }) + .LogToTrace(); +} diff --git a/UndertaleModToolAvalonia.Desktop/UndertaleModToolAvalonia.Desktop.csproj b/UndertaleModToolAvalonia.Desktop/UndertaleModToolAvalonia.Desktop.csproj new file mode 100644 index 000000000..394d67a4e --- /dev/null +++ b/UndertaleModToolAvalonia.Desktop/UndertaleModToolAvalonia.Desktop.csproj @@ -0,0 +1,22 @@ + + + WinExe + + net10.0 + enable + true + app.manifest + 0.8.4.1 + en + ..\UndertaleModToolAvalonia\Assets\Icon.ico + + + + + + + + + + diff --git a/UndertaleModToolAvalonia.Desktop/app.manifest b/UndertaleModToolAvalonia.Desktop/app.manifest new file mode 100644 index 000000000..e0ce8d0ef --- /dev/null +++ b/UndertaleModToolAvalonia.Desktop/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia.Tests/HeadlessTest.cs b/UndertaleModToolAvalonia.Tests/HeadlessTest.cs new file mode 100644 index 000000000..618c90027 --- /dev/null +++ b/UndertaleModToolAvalonia.Tests/HeadlessTest.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.IO; +using Avalonia.Headless.XUnit; +using Microsoft.Extensions.DependencyInjection; + +namespace UndertaleModToolAvalonia.Tests; + +public class HeadlessTest +{ + [AvaloniaFact] + public async void Save_New_Data() + { + MainViewModel vm = App.Services.GetRequiredService(); + + var mainWindow = new MainWindow + { + DataContext = vm, + }; + mainWindow.Show(); + + await vm.NewData(); + + Assert.NotNull(vm.Data); + + using MemoryStream stream = new(); + + await vm.SaveData(stream); + Debug.WriteLine(stream.Length); + + Assert.Equal(2200, stream.Length); + } +} diff --git a/UndertaleModToolAvalonia.Tests/ObservableCollectionViewTest.cs b/UndertaleModToolAvalonia.Tests/ObservableCollectionViewTest.cs new file mode 100644 index 000000000..d711bd440 --- /dev/null +++ b/UndertaleModToolAvalonia.Tests/ObservableCollectionViewTest.cs @@ -0,0 +1,132 @@ +using System.Collections.ObjectModel; + +namespace UndertaleModToolAvalonia.Tests; + +public class ObservableCollectionViewTest +{ + [Fact] + public void Test_ObservableCollectionView() + { + var input = new ObservableCollection(); + var view = new ObservableCollectionView(input, null, null); + var output = view.Output; + + // Basics + input.Add("a"); + Assert.Equal(output, ["a"]); + + input.Insert(0, "b"); + input.Add("c"); + Assert.Equal(output, ["b", "a", "c"]); + + input[1] = "d"; + Assert.Equal(output, ["b", "d", "c"]); + + input.Move(2, 0); + Assert.Equal(output, ["c", "b", "d"]); + + input.Move(0, 2); + Assert.Equal(output, ["b", "d", "c"]); + + input.Remove("d"); + Assert.Equal(output, ["b", "c"]); + + input.Clear(); + Assert.Equal(output, []); + + // Filter + input.Add("A"); + input.Add("B"); + input.Add("C"); + input.Add("D"); + input.Add("E"); + Assert.Equal(output, ["A", "B", "C", "D", "E"]); + + view.SetFilter(x => x == "A"); + Assert.Equal(output, ["A"]); + + view.SetFilter(x => x == "E"); + Assert.Equal(output, ["E"]); + + view.SetFilter(x => true); + Assert.Equal(output, ["A", "B", "C", "D", "E"]); + + // Moving while filtered + // Yes old, yes new + input.Move(1, 3); + Assert.Equal(output, ["A", "C", "D", "B", "E"]); + + input.Move(3, 1); + Assert.Equal(output, ["A", "B", "C", "D", "E"]); + + // No old, yes new + view.SetFilter(x => x != "B"); + input.Move(1, 3); + Assert.Equal(output, ["A", "C", "D", "E"]); + + input.Move(3, 1); + Assert.Equal(output, ["A", "C", "D", "E"]); + + // Yes old, no new + view.SetFilter(x => x != "D"); + input.Move(1, 3); + Assert.Equal(output, ["A", "C", "B", "E"]); + + input.Move(3, 1); + Assert.Equal(output, ["A", "B", "C", "E"]); + + // No old, no new + view.SetFilter(x => x != "B" && x != "D"); + input.Move(1, 3); + Assert.Equal(output, ["A", "C", "E"]); + + input.Move(3, 1); + Assert.Equal(output, ["A", "C", "E"]); + + // Add while filtered and passing + view.SetFilter(x => x == "B"); + input.Add("B"); + Assert.Equal(output, ["B", "B"]); + + // Add while filtered and not passing + input.Add("F"); + Assert.Equal(output, ["B", "B"]); + + view.SetFilter(x => true); + Assert.Equal(output, ["A", "B", "C", "D", "E", "B", "F"]); + + // Remove while filtered and passing + view.SetFilter(x => x == "B"); + input.RemoveAt(5); + Assert.Equal(output, ["B"]); + + // Remove while filtered and not passing + input.Remove("F"); + Assert.Equal(output, ["B"]); + + view.SetFilter(x => true); + Assert.Equal(output, ["A", "B", "C", "D", "E"]); + + // Replace yes old, yes new + view.SetFilter(x => x == "B" || x == "b"); + input[1] = "b"; + Assert.Equal(output, ["b"]); + + // Replace yes old, no new + view.SetFilter(x => x == "b"); + input[1] = "B"; + Assert.Equal(output, []); + + // Replace no old, yes new + input[1] = "b"; + Assert.Equal(output, ["b"]); + + // Replace no old, no new + view.SetFilter(x => x == "C"); + input[1] = "B"; + Assert.Equal(output, ["C"]); + + view.SetFilter(x => true); + Assert.Equal(output, ["A", "B", "C", "D", "E"]); + } +} diff --git a/UndertaleModToolAvalonia.Tests/TestAppBuilder.cs b/UndertaleModToolAvalonia.Tests/TestAppBuilder.cs new file mode 100644 index 000000000..755c213d9 --- /dev/null +++ b/UndertaleModToolAvalonia.Tests/TestAppBuilder.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Headless; +using UndertaleModToolAvalonia.Tests; + +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace UndertaleModToolAvalonia.Tests; + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} diff --git a/UndertaleModToolAvalonia.Tests/UndertaleModToolAvalonia.Tests.csproj b/UndertaleModToolAvalonia.Tests/UndertaleModToolAvalonia.Tests.csproj new file mode 100644 index 000000000..760e90d37 --- /dev/null +++ b/UndertaleModToolAvalonia.Tests/UndertaleModToolAvalonia.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file diff --git a/UndertaleModToolAvalonia/App.axaml b/UndertaleModToolAvalonia/App.axaml new file mode 100644 index 000000000..670e03ad6 --- /dev/null +++ b/UndertaleModToolAvalonia/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/UndertaleModToolAvalonia/App.axaml.cs b/UndertaleModToolAvalonia/App.axaml.cs new file mode 100644 index 000000000..bbe3afa6a --- /dev/null +++ b/UndertaleModToolAvalonia/App.axaml.cs @@ -0,0 +1,47 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using Microsoft.Extensions.DependencyInjection; + +namespace UndertaleModToolAvalonia; + +public partial class App : Application +{ + public static IServiceProvider Services = null!; + public static IStyle? CurrentCustomStyles = null; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + // Line below is needed to remove Avalonia data validation. + // Without this line you will get duplicate validations from both Avalonia and CT + BindingPlugins.DataValidators.RemoveAt(0); + + // 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, + }; + } + + 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..b54a2af7e --- /dev/null +++ b/UndertaleModToolAvalonia/Assets/SyntaxASM.xshd @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + ^;.*|(?:^|\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]*(?=\() + + + + + ^(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..b2699a0b5 --- /dev/null +++ b/UndertaleModToolAvalonia/Assets/SyntaxGML.xshd @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + " + " + + + + + + + + ' + ' + + + + + + + + + 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]* + + + \ No newline at end of file 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..444581c53 --- /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, GotFocusEventArgs 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(); + } + + 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..e228e3c85 --- /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..a5c458b3a --- /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 PropertyChanged.SourceGenerator; + +namespace UndertaleModToolAvalonia; + +public partial class FlagsBoxView : UserControl +{ + public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register( + nameof(Value), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + public dynamic Value + { + get { return GetValue(ValueProperty); } + set { SetValue(ValueProperty, value); } + } + + public partial class Flag + { + public dynamic FlagEnum; + public string Name { get; set; } + + [Notify] private bool _Checked; + + public Flag(dynamic flagEnum, string name, bool _checked) + { + FlagEnum = flagEnum; + Name = name; + Checked = _checked; + } + } + + public ObservableCollection Flags { get; set; } = new ObservableCollection(); + + 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 (dynamic flagEnum in Enum.GetValues(enumValue.GetType())) + { + Flag? f = Flags.FirstOrDefault(x => (x!.FlagEnum) == 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 CheckBox_Checked(object? sender, RoutedEventArgs e) + { + CheckBox checkBox = (sender as CheckBox)!; + if (checkBox.DataContext is Flag flag) + { + if (checkBox.IsChecked == true) + { + Value |= flag.FlagEnum; + } + else + { + Value &= ~flag.FlagEnum; + Enum test = (Enum)Enum.ToObject(Value.GetType(), 42); + } + } + } +} + +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 because the type is dynamic, so the notification will be stored in Value. This may actually be a bug with Avalonia, I'm not sure. + // 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..a9cde1639 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/SKImageViewer.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Avalonia.Platform; +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) + { + // Bind these values to a property so we can get updates when they change. + IList bindings = + [ + new Binding("Image.TexturePage.TextureData.Image") + {Source = this}, + new Binding("Image.SourceX") + {Source = this}, + new Binding("Image.SourceY") + {Source = this}, + new Binding("Image.SourceWidth") + {Source = this}, + new Binding("Image.SourceHeight") + {Source = this}, + new Binding("Image.TargetX") + {Source = this}, + new Binding("Image.TargetY") + {Source = this}, + new Binding("Image.TargetWidth") + {Source = this}, + new Binding("Image.TargetHeight") + {Source = this}, + new Binding("Image.BoundingWidth") + {Source = this}, + new Binding("Image.BoundingHeight") + {Source = this}, + ]; + + MultiBinding multiBinding = new() + { + Bindings = bindings, + Converter = new FuncMultiValueConverter>(x => new List(x)) + }; + + Bind(BindingsProperty, multiBinding); + } + else + { + // NOTE: Unbind? + } + + Invalidate(); + } + else if (change.Property == BindingsProperty) + { + Invalidate(); + } + } + + readonly CustomDrawOperation customDrawOperation; + + public SKImageViewer() + { + ClipToBounds = true; + customDrawOperation = new CustomDrawOperation(); + } + + void Invalidate() + { + Size size = GetSize(); + Width = size.Width; + Height = size.Height; + + InvalidateMeasure(); + InvalidateVisual(); + } + + Size GetSize() + { + if (Image is UndertaleTexturePageItem texturePageItem) + return new Size(texturePageItem.BoundingWidth, texturePageItem.BoundingHeight); + else if (Image is GMImage gmImage) + return new Size(gmImage.Width, gmImage.Height); + else if (Image is UndertaleSprite.MaskEntry maskEntry) + return new Size(maskEntry.Width, maskEntry.Height); + + return new Size(0, 0); + } + + protected override Size MeasureOverride(Size availableSize) + { + return GetSize(); + } + + public override void Render(DrawingContext context) + { + Size size = GetSize(); + customDrawOperation.Bounds = new Rect(0, 0, size.Width, size.Height); + customDrawOperation.Image = Image; + + context.Custom(customDrawOperation); + } + + public class CustomDrawOperation : ICustomDrawOperation + { + public Rect Bounds { get; set; } + + public object? Image; + + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public CustomDrawOperation() + { + } + + public void Dispose() { } + + public bool Equals(ICustomDrawOperation? other) => false; + + public bool HitTest(Point p) => Bounds.Contains(p); + + public void Render(ImmediateDrawingContext context) + { + try + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) + return; + + using var lease = leaseFeature.Lease(); + SKCanvas canvas = lease.SkCanvas; + canvas.Save(); + + // Checkered background + int gridSize = 8; + SKPaint gridColor1 = new SKPaint { Color = new SKColor(102, 102, 102) }; + SKPaint gridColor2 = new SKPaint { 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); + } + + // Image + RenderImage(canvas); + + canvas.Restore(); + } + catch (Exception e) + { + Debugger.Break(); + throw; + } + } + + public void RenderImage(SKCanvas canvas) + { + if (Image is UndertaleTexturePageItem texturePageItem) + { + if (texturePageItem.TexturePage is not null) + { + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texturePageItem); + + if (image is not null) + { + canvas.DrawImage(image, SKRect.Create(texturePageItem.TargetX, texturePageItem.TargetY, texturePageItem.TargetWidth, texturePageItem.TargetHeight)); + } + } + } + else if (Image is GMImage gmImage) + { + SKImage image = mainVM.ImageCache.GetCachedImageFromGMImage(gmImage); + canvas.DrawImage(image, 0, 0); + } + else if (Image is UndertaleSprite.MaskEntry maskEntry) + { + int size = maskEntry.Width * maskEntry.Height; + byte[] pixels = new byte[size]; + + 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); + canvas.DrawImage(image, 0, 0); + } + } + } +} \ No newline at end of file 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..b3ed02a3a --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + \ 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..040e9e50b --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleResourceReferenceView.axaml.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.DragAndDrop; +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +using AddFuncType = Func>; + +public partial class UndertaleResourceReferenceView : UserControl +{ + public static readonly StyledProperty ReferenceProperty = AvaloniaProperty.Register( + nameof(Reference), defaultBindingMode: Avalonia.Data.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); } + } + + public UndertaleResourceReferenceView() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ReferenceTypeProperty) + { + this.Find("TextBox")!.Watermark = "(" + ReferenceType.Name + " reference)"; + } + } + + 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(); + } + + public async void Add() + { + if (AddFunc is not null) + { + UndertaleResource? reference = await AddFunc(AddFuncArgument); + if (reference is not null) + Reference = reference; + } + } + + public void Open() + { + MainViewModel mainView = (this.FindLogicalAncestorOfType()!.DataContext as MainViewModel)!; + mainView.TabOpen(Reference); + } + + public void OpenInNewTab() + { + MainViewModel mainView = (this.FindLogicalAncestorOfType()!.DataContext as MainViewModel)!; + mainView.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 MainViewModel.TreeDataGridItem 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 MainViewModel.TreeDataGridItem 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..a1e0f0dc6 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleRoomEditor.cs @@ -0,0 +1,877 @@ +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.Platform; +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, + } + + UndertaleRoomViewModel? vm; + + 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(); + + 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); + } + } + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + PointerPoint pointerPoint = e.GetCurrentPoint(this); + InteractionMode interactionMode = GetInteractionMode(); + UndertaleRoom.Layer? tilesLayer = GetSelectedTilesLayer(); + + 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); + 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); + } + } + } + + hoveredItem = null; + hoveredTile = null; + + if (tilesLayer is not null) + { + hoveredTile = GetLayerTileAtPointer(tilesLayer); + } + else + { + ItemHoverOnMoved(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; + } + } + } + } + + 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 + { + return InteractionMode.Items; + } + } + + UndertaleRoom.Layer? GetSelectedTilesLayer() + { + if (vm!.RoomTreeItemsSelectedItem is UndertaleRoom.Layer { LayerType: UndertaleRoom.LayerType.Tiles } tilesLayer) + { + return tilesLayer; + } + return null; + } + + 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) + { + 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) + { + 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 (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; + } + } + + 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 + { + public Rect Bounds { get; set; } + + 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 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 Dispose() { } + + public bool Equals(ICustomDrawOperation? other) => false; + + public bool HitTest(Point p) => Bounds.Contains(p); + + public void Render(ImmediateDrawingContext context) + { + 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 = 2, 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 e) + { + Debugger.Break(); + throw; + } + } + } + + 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 MainViewModel.TreeDataGridItem 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 MainViewModel.TreeDataGridItem 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/UndertaleRoomTilePicker.cs b/UndertaleModToolAvalonia/Controls/UndertaleRoomTilePicker.cs new file mode 100644 index 000000000..2d0962896 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleRoomTilePicker.cs @@ -0,0 +1,298 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Microsoft.Extensions.DependencyInjection; +using SkiaSharp; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public class UndertaleRoomTilePicker : Control +{ + 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); + + public uint TileSetColumns + { + get => GetValue(TileSetColumnsProperty); + set => SetValue(TileSetColumnsProperty, value); + } + + UndertaleRoom.Layer.LayerTilesData? layerTilesData; + + Vector translation; + double scaling = 1; + + Point translationMoveOffset; + + public UndertaleRoomTilePicker() + { + ClipToBounds = true; + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + layerTilesData = DataContext as UndertaleRoom.Layer.LayerTilesData; + } + + 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) + { + 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) + { + if (layerTilesData?.Background is not null) + { + context.Custom(new CustomDrawOperation() + { + Bounds = new Rect(0, 0, Bounds.Width, Bounds.Height), + Translation = translation, + Scaling = scaling, + Background = layerTilesData.Background, + SelectedTileData = SelectedTileData, + VisualColumns = TileSetColumns, + SelectedColor = this.GetSolidColorBrushResource("SystemControlHighlightAccentBrush").Color.ToSKColor().WithAlpha(128), + }); + } + + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + topLevel.RequestAnimationFrame(_ => + { + InvalidateVisual(); + }); + } + + void SelectTileAt(Point point) + { + if (layerTilesData?.Background is null) + return; + + 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; + } + + void TranslationMoveOnPressed(Point point) + { + translationMoveOffset = point - translation; + } + + void TranslationMoveOnMoved(Point point) + { + translation = point - translationMoveOffset; + InvalidateVisual(); + } + + public class CustomDrawOperation : ICustomDrawOperation + { + readonly MainViewModel mainVM = App.Services.GetRequiredService(); + + public required Vector Translation; + public required double Scaling; + public required UndertaleBackground Background; + public required uint SelectedTileData; + public required uint VisualColumns = 0; + + public required SKColor SelectedColor; + + public Rect Bounds { get; set; } + + public void Dispose() { } + + public bool Equals(ICustomDrawOperation? other) => false; + + public bool HitTest(Point p) => Bounds.Contains(p); + + public void Render(ImmediateDrawingContext context) + { + 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 + + var texturePageItem = Background.Texture; + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texturePageItem); + + if (image is null) + return; + + UndertaleTexturePageItem texture = Background.Texture; + + uint tileW = Background.GMS2TileWidth; + uint tileH = Background.GMS2TileHeight; + uint borderX = Background.GMS2OutputBorderX; + uint borderY = Background.GMS2OutputBorderY; + uint tileColumns = Background.GMS2TileColumns; + uint tileCount = Background.GMS2TileCount; + + if (VisualColumns == 0) + VisualColumns = tileColumns; + + ushort targetX = texture.TargetX; + ushort targetY = texture.TargetY; + ushort sourceX = texture.SourceX; + ushort sourceY = texture.SourceY; + + var sx = -targetX + borderX; + var sy = -targetY + borderY; + + uint dx = 0; + uint dy = 0; + + var tileColumn = 0; + var destColumn = 0; + + canvas.Save(); + canvas.Translate(Translation.ToSKPoint()); + canvas.Scale((float)Scaling); + + for (uint i = 0; i < tileCount; i++) + { + canvas.DrawImage(image, SKRect.Create(sx, sy, tileW, tileH), SKRect.Create(dx, dy, tileW, tileH)); + + 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) + { + float s = 1 / (float)Scaling; + SKRect rect = SKRect.Create(selectedTileX - s, selectedTileY - s, tileW + s, tileH + s); + + canvas.DrawRect(rect, new SKPaint() { Style = SKPaintStyle.Stroke, Color = SelectedColor }); + } + + canvas.Restore(); + } + } +} diff --git a/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml new file mode 100644 index 000000000..ad8ffec5c --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs new file mode 100644 index 000000000..f5ac08240 --- /dev/null +++ b/UndertaleModToolAvalonia/Controls/UndertaleStringReferenceView.axaml.cs @@ -0,0 +1,120 @@ +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 UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleStringReferenceView : UserControl +{ + public static readonly StyledProperty ReferenceProperty = AvaloniaProperty.Register( + nameof(Reference)); + public UndertaleString Reference + { + get { return GetValue(ReferenceProperty); } + set { SetValue(ReferenceProperty, value); } + } + + public UndertaleStringReferenceView() + { + InitializeComponent(); + UpdateTextBoxWatermark(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ReferenceProperty) + { + UpdateTextBoxWatermark(); + } + } + + private void TextBox_KeyDown(object? sender, KeyEventArgs e) + { + if (sender is TextBox textBox && e.Key == Key.Enter) + { + UpdateString(textBox); + } + } + + 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) + { + if (sender is TextBox textBox) + { + UpdateString(textBox); + } + } + + public void Open() + { + MainViewModel mainView = (this.FindLogicalAncestorOfType()!.DataContext as MainViewModel)!; + mainView.TabOpen(Reference); + } + + public void OpenInNewTab() + { + MainViewModel mainView = (this.FindLogicalAncestorOfType()!.DataContext as MainViewModel)!; + mainView.TabOpen(Reference, inNewTab: true); + } + + void UpdateString(TextBox textBox) + { + if (Reference is not null) + { + // TODO: Ask if user wants to change all references or just this one + BindingOperations.GetBindingExpressionBase(textBox, TextBox.TextProperty)!.UpdateSource(); + } + else + { + // TODO: Create new string + } + } + + void UpdateTextBoxWatermark() + { + this.Find("TextBox")!.Watermark = (Reference is null) ? "(UndertaleString 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 MainViewModel.TreeDataGridItem 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 MainViewModel.TreeDataGridItem 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..5914ea46f --- /dev/null +++ b/UndertaleModToolAvalonia/Core/Extensions.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +public static class Extensions +{ + /// + /// Waits on a Task without blocking the main thread. + /// + public static T 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(); + } + + /// + /// 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"); + } +} diff --git a/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs b/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs new file mode 100644 index 000000000..4491645ec --- /dev/null +++ b/UndertaleModToolAvalonia/Core/FilePickerFileTypes.cs @@ -0,0 +1,57 @@ +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"], + }; + + 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]; +} diff --git a/UndertaleModToolAvalonia/Core/IView.cs b/UndertaleModToolAvalonia/Core/IView.cs new file mode 100644 index 000000000..38957f737 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/IView.cs @@ -0,0 +1,81 @@ +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(); + 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(); + return await new TextBoxWindow(message, text, title, isMultiline, isReadOnly).ShowDialog(window); + } + + public ILoaderWindow LoaderOpen() + { + Window window = View.FindLogicalAncestorOfType(true) ?? throw new InvalidOperationException(); + LoaderWindow loaderWindow = new(); + loaderWindow.ShowDelayed(window); + return loaderWindow; + } + + public async Task SettingsDialog() + { + Window window = View.FindLogicalAncestorOfType() ?? throw new InvalidOperationException(); + await new SettingsWindow() + { + DataContext = new SettingsViewModel(), + }.ShowDialog(window); + } + + public void SearchInCodeOpen() + { + new SearchInCodeWindow() + { + DataContext = new SearchInCodeViewModel(), + }.Show(); + } + + 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..327d51d7b --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ImageCache.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +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) + { + // Faster shortcut + if (gmImage.Format == GMImage.ImageFormat.Png) + { + return SKImage.FromEncodedData(gmImage.GetData()); + } + + byte[] data = gmImage.ConvertToRawBgra().GetData(); + GCHandle gcHandle = GCHandle.Alloc(data, GCHandleType.Pinned); + + SKBitmap bitmap = new(); + + SKImageInfo info = new(gmImage.Width, gmImage.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul); + SKPixmap pixmap = new(info, gcHandle.AddrOfPinnedObject(), info.RowBytes); + SKImage? image = SKImage.FromPixels(pixmap, delegate + { gcHandle.Free(); }); + + if (image is null) + { + gcHandle.Free(); + throw new Exception("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..f5bf80875 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ImportExport.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Threading.Tasks; +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 ExportTexturePageItemAsPNG(UndertaleTexturePageItem texturePageItem, Stream stream, MainViewModel mainVM) + { + SKBitmap bitmap = new(texturePageItem.BoundingWidth, texturePageItem.BoundingHeight, SKColorType.Bgra8888, SKAlphaType.Unpremul); + SKCanvas canvas = new(bitmap); + + SKImage? image = mainVM.ImageCache.GetCachedImageFromTexturePageItem(texturePageItem); + + if (image is null) + throw new InvalidOperationException(); + + canvas.DrawImage(image, SKRect.Create(texturePageItem.TargetX, texturePageItem.TargetY, texturePageItem.TargetWidth, texturePageItem.TargetHeight)); + + bool result = bitmap.Encode(stream, SKEncodedImageFormat.Png, 100); + if (!result) + throw new InvalidOperationException(); + } +} diff --git a/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs b/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs new file mode 100644 index 000000000..eaed69799 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/ObservableCollectionView.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using Avalonia.Threading; + +namespace UndertaleModToolAvalonia; + +/// +/// 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 +{ + public class CustomObservableCollection : Collection, INotifyCollectionChanged + { + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + 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 SendReset() + { + if (CollectionChanged is not null) + CollectionChanged(this, new(NotifyCollectionChangedAction.Reset)); + } + + protected override void ClearItems() + { + base.ClearItems(); + + SendEvent(new(NotifyCollectionChangedAction.Reset)); + } + + protected override void InsertItem(int index, T item) + { + base.InsertItem(index, item); + + SendEvent(new(NotifyCollectionChangedAction.Add, item, index)); + } + + protected override void RemoveItem(int index) + { + T removedItem = this[index]; + base.RemoveItem(index); + + SendEvent(new(NotifyCollectionChangedAction.Remove, removedItem, index)); + } + + protected override void SetItem(int index, T item) + { + T originalItem = this[index]; + base.SetItem(index, item); + + SendEvent(new(NotifyCollectionChangedAction.Replace, item, originalItem, index)); + } + + public void Move(int oldIndex, int newIndex) + { + T removedItem = this[oldIndex]; + + base.RemoveItem(oldIndex); + base.InsertItem(newIndex, removedItem); + + SendEvent(new(NotifyCollectionChangedAction.Move, removedItem, newIndex, oldIndex)); + } + + void SendEvent(NotifyCollectionChangedEventArgs e) + { + if (isDelayingEvents) + delayedEvents.Add(e); + else if (CollectionChanged is not null) + { + Dispatcher.UIThread.Invoke(() => CollectionChanged(this, e)); + } + } + } + + public CustomObservableCollection Output { get; } = []; + + private readonly IList input; + + private readonly List outputIndexToInputIndexMap = []; + + private Predicate? filterPredicate; + + private readonly Func? transformFunc; + + public ObservableCollectionView(IList input, Predicate? filter = null, Func? transform = null) + { + this.input = input; + this.filterPredicate = filter; + this.transformFunc = transform; + + if (this.input is INotifyCollectionChanged inputNotifyCollectionChanged) + inputNotifyCollectionChanged.CollectionChanged += OnInputCollectionChanged; + else + throw new InvalidOperationException($"ObservableCollectionView input ({input}) does not implement INotifyCollectionChanged"); + + Filter(); + } + + public void SetFilter(Predicate? _filterPredicate) + { + filterPredicate = _filterPredicate; + Filter(); + } + + private 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; + } + } + + private void OnInputAdd(NotifyCollectionChangedEventArgs e) + { + TInput item = (TInput)e.NewItems![0]!; + + // Find where in output to insert + int i = outputIndexToInputIndexMap.BinarySearch(e.NewStartingIndex); + + if (i < 0) + i = ~i; + + if (DoesPassFilter(item)) + { + Output.Insert(i, TransformItem(item)); + outputIndexToInputIndexMap.Insert(i, e.NewStartingIndex); + i++; + } + + // Increase all indexes after + while (i < outputIndexToInputIndexMap.Count) + { + outputIndexToInputIndexMap[i]++; + i++; + } + } + + private void OnInputRemove(NotifyCollectionChangedEventArgs e) + { + // Find where in output to remove + int i = outputIndexToInputIndexMap.BinarySearch(e.OldStartingIndex); + if (i >= 0) + { + Output.RemoveAt(i); + outputIndexToInputIndexMap.RemoveAt(i); + } + else + { + // If not found, then get index after where it would be + i = ~i; + } + + // Decrease all indexes after + while (i < outputIndexToInputIndexMap.Count) + { + outputIndexToInputIndexMap[i]--; + i++; + } + } + + private void OnInputReplace(NotifyCollectionChangedEventArgs e) + { + TInput item = (TInput)e.NewItems![0]!; + bool passes = DoesPassFilter(item); + + // Find where item is in output + int i = outputIndexToInputIndexMap.BinarySearch(e.OldStartingIndex); + if (i >= 0) + { + // If found, replace it if passes, remove it if not + if (passes) + { + Output[i] = TransformItem(item); + } + else + { + Output.RemoveAt(i); + outputIndexToInputIndexMap.RemoveAt(i); + } + } + else + { + // If not found, insert it if it passes + i = ~i; + + if (passes) + { + Output.Insert(i, TransformItem(item)); + outputIndexToInputIndexMap.Insert(i, e.OldStartingIndex); + } + } + } + + private void OnInputMove(NotifyCollectionChangedEventArgs e) + { + // TODO: Actually call Move(). + OnInputRemove(e); + OnInputAdd(e); + } + + private void OnInputReset() + { + Output.Clear(); + outputIndexToInputIndexMap.Clear(); + + Filter(); + } + + private void Filter() + { + // TODO: This can obviously be improved by batch adding and removing everything instead of using the regular RemoveAt and Insert functions. + + Output.StartDelayingEvents(); + + // Remove all that don't pass from output. + for (int i = Output.Count - 1; i >= 0; i--) + { + if (!DoesPassFilter(input[outputIndexToInputIndexMap[i]])) + { + Output.RemoveAt(i); + outputIndexToInputIndexMap.RemoveAt(i); + } + } + + // Insert all that pass from input to output. + int outputIndex = 0; + for (int inputIndex = 0; inputIndex < input.Count; inputIndex++) + { + TInput inputItem = input[inputIndex]; + + // Find next output item that matches or passes after the current input index. + while (outputIndex < outputIndexToInputIndexMap.Count && outputIndexToInputIndexMap[outputIndex] < inputIndex) + { + outputIndex++; + } + + if (outputIndex >= outputIndexToInputIndexMap.Count) + { + // If past end of list, then add to end if it passes. + if (DoesPassFilter(inputItem)) + { + TOutput transformedInputItem = TransformItem(inputItem); + Output.Add(transformedInputItem); + outputIndexToInputIndexMap.Add(inputIndex); + outputIndex++; + } + } + else if (outputIndexToInputIndexMap[outputIndex] == inputIndex) + { + // If exactly on item, then we know if passes since otherwise it would've been removed before. + outputIndex++; + } + else if (outputIndexToInputIndexMap[outputIndex] > inputIndex) + { + // If past item, insert it before that if it passes. + if (DoesPassFilter(inputItem)) + { + TOutput transformedInputItem = TransformItem(inputItem); + Output.Insert(outputIndex, transformedInputItem); + outputIndexToInputIndexMap.Insert(outputIndex, inputIndex); + outputIndex++; + } + } + } + + Output.FinishDelayingEvents(); + } + + private bool DoesPassFilter(TInput item) => filterPredicate is null || filterPredicate(item); + + private 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..b1614ff0a --- /dev/null +++ b/UndertaleModToolAvalonia/Core/RoomRenderer.cs @@ -0,0 +1,573 @@ +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 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, + int X, int Y, float ScaleX, float ScaleY, uint Color, float Rotation, int OriginX, int OriginY) + : 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, + 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 + )); + } + } + + 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 + )); + } + } + + 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), 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); + 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); + Canvas.Scale(c.ScaleX, c.ScaleY); + + Canvas.DrawImage(c.Image, SKRect.Create(-c.OriginX + c.TargetX, -c.OriginY + c.TargetY, c.TargetWidth, c.TargetHeight), new SKPaint() + { + ColorFilter = SKColorFilter.CreateBlendMode(UndertaleColor.ToColor(c.Color).ToSKColor(), SKBlendMode.Modulate), + }); + + 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), 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); + + 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..c1b5aff69 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/Scripting.cs @@ -0,0 +1,436 @@ +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.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 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 DisableAllSyncBindings() + { + // TODO: Implement + } + + 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() + { + return mainVM.NewData().Result; + } + + 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 SyncBinding(string resourceType, bool enable) + { + // TODO: Implement + } + + 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..048e2dec6 --- /dev/null +++ b/UndertaleModToolAvalonia/Core/SettingsFile.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text.Json; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; + +namespace UndertaleModToolAvalonia; + +public partial class SettingsFile +{ + public MainViewModel MainVM = null!; + + public SettingsFile() { } + public SettingsFile(IServiceProvider serviceProvider) + { + MainVM = serviceProvider.GetRequiredService(); + } + + public static SettingsFile Load(IServiceProvider serviceProvider) + { + MainViewModel mainVM = serviceProvider.GetRequiredService(); + + SettingsFile? settings = null; + + string roamingAppData = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UndertaleModToolAvalonia"); + + // 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) + { + // Check for upgrades here. + settings.MainVM = mainVM; + settings.Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?.?.?.?"; + } + } + catch (Exception e) + { + mainVM.LazyErrorMessages.Add($"Error when loading settings file:\n{e.Message}\nDefault settings loaded."); + } + } + + settings ??= new SettingsFile(serviceProvider); + + // Load Styles.xaml + string stylesPath = Path.Join(roamingAppData, "Styles.xaml"); + + if (File.Exists(stylesPath)) + { + try + { + string xaml = File.ReadAllText(stylesPath); + Styles styles = AvaloniaRuntimeXamlLoader.Parse(xaml); + + if (App.CurrentCustomStyles is not null) + App.Current!.Styles.Remove(App.CurrentCustomStyles); + + App.CurrentCustomStyles = styles; + App.Current!.Styles.Add(styles); + } + catch (Exception e) + { + mainVM.LazyErrorMessages.Add($"Error when loading styles file:\n{e.Message}"); + } + } + + return settings; + } + + public async void Save() + { + string roamingAppData = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UndertaleModToolAvalonia"); + Directory.CreateDirectory(roamingAppData); + + string json = JsonSerializer.Serialize(this, new JsonSerializerOptions() + { + WriteIndented = true, + }); + + try + { + File.WriteAllText(Path.Join(roamingAppData, "Settings.json"), json); + } + catch (Exception e) + { + await MainVM.View!.MessageDialog($"Error when saving settings file: {e.Message}"); + } + } + + public string Version { get; set; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?.?.?.?"; + + public enum ThemeValue + { + SystemDefault = 0, + Light = 1, + Dark = 2, + } + + [Notify] + private ThemeValue _Theme; + + void OnThemeChanged() + { + if (App.Current is not null) + { + App.Current.RequestedThemeVariant = Theme 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 string InstanceIdPrefix { get; set; } = "inst_"; + + public Underanalyzer.Decompiler.DecompileSettings DecompileSettings { get; set; } = new(); +} 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/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/LayerTypeTemplateSelector.cs b/UndertaleModToolAvalonia/Helpers/LayerTypeTemplateSelector.cs new file mode 100644 index 000000000..3eec5316d --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/LayerTypeTemplateSelector.cs @@ -0,0 +1,73 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Data; +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 InstancedBinding? ItemsSelector(object item) + { + if (item is UndertaleRoom.Layer layer) + { + return layer.LayerType switch + { + UndertaleRoom.LayerType.Path => PathTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Path2 => PathTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Background => BackgroundTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Instances => InstancesTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Assets => AssetsTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Tiles => TilesTemplate?.ItemsSelector(layer), + UndertaleRoom.LayerType.Effect => EffectTemplate?.ItemsSelector(layer), + _ => null, + }; + } + return null; + } + + 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..d0feca3ef --- /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.IndexOf(draggedTabItem); + int droppedIndex = mainVM.Tabs.Count - 1; + + var sourceTabStripItem = control.FindLogicalAncestorOfType(); + if (sourceTabStripItem is not null && sourceTabStripItem.DataContext is TabItemViewModel droppedTabItem) + { + droppedIndex = mainVM.Tabs.IndexOf(droppedTabItem); + } + + MoveItem(mainVM.Tabs, draggedIndex, droppedIndex); + + mainVM.TabSelected = draggedTabItem; + return true; + } + } + + return false; + } +} diff --git a/UndertaleModToolAvalonia/Helpers/TreeDataGridItemToContextMenuConverter.cs b/UndertaleModToolAvalonia/Helpers/TreeDataGridItemToContextMenuConverter.cs new file mode 100644 index 000000000..bbddb1368 --- /dev/null +++ b/UndertaleModToolAvalonia/Helpers/TreeDataGridItemToContextMenuConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using UndertaleModLib; + +namespace UndertaleModToolAvalonia; + +public class TreeDataGridItemToContextMenuConverter : 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 MainViewModel.TreeDataGridItem 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/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/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..ea16c6d74 --- /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; set; } + + 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..d3d3ab5e4 --- /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..fdd0db249 --- /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; set; } + public UndertaleOptions Options { get; set; } + public UndertaleLanguage Language { get; set; } + + 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..f79c84c1c --- /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; set; } + + 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..878a5cdff --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleAnimationCurveViewModel.cs @@ -0,0 +1,27 @@ +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleAnimationCurveViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => AnimationCurve; + public UndertaleAnimationCurve AnimationCurve { get; set; } + + [Notify] + private UndertaleAnimationCurve.Channel? _ChannelSelected; + + 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..488f6817b --- /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; set; } + + public UndertaleAudioGroupViewModel(UndertaleAudioGroup audioGroup) + { + AudioGroup = audioGroup; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleBackgroundView.axaml new file mode 100644 index 000000000..252965e51 --- /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..225db250f --- /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; set; } + + 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..fc489954b --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeLocalsViewModel.cs @@ -0,0 +1,17 @@ +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeLocalsViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => CodeLocals; + public UndertaleCodeLocals CodeLocals { get; set; } + + public UndertaleCodeLocalsViewModel(UndertaleCodeLocals codeLocals) + { + CodeLocals = codeLocals; + } + + public static UndertaleCodeLocals.LocalVar CreateLocalVar(int index) => new() { Index = (uint)index }; +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml new file mode 100644 index 000000000..5f84b1469 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs new file mode 100644 index 000000000..1c81761f3 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeView.axaml.cs @@ -0,0 +1,836 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Xml; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Platform; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Highlighting.Xshd; +using AvaloniaEdit.Rendering; +using UndertaleModLib; +using UndertaleModLib.Compiler; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeView : UserControl, IUndertaleCodeView +{ + private static IHighlightingDefinition? GMLHighlightingDefinition = null; + private static IHighlightingDefinition? ASMHighlightingDefinition = null; + private static uint HighlightingMajorVersion = 0; + + private static readonly Dictionary ScriptsCache = new(); + private static readonly Dictionary FunctionsCache = new(); + private static readonly Dictionary CodeCache = new(); + private static readonly Dictionary NamedResourcesCache = new(); + + private readonly List codeLocalsCache = new(); + + private readonly NumberGenerator gmlNumberGenerator; + private readonly NameGenerator gmlNameGenerator; + private readonly NameGenerator asmNameGenerator; + + public (int, int) LastCaretOffsets; + + public UndertaleCodeView() + { + InitializeComponent(); + + gmlNumberGenerator = new(this); + gmlNameGenerator = new(this); + asmNameGenerator = new(this); + + DataContextChanged += (_, __) => + { + if (DataContext is UndertaleCodeViewModel vm) + { + vm.View = this; + if (vm.MainVM.Settings!.EnableSyntaxHighlighting) + { + // Reload highlighting if major version changed + if (HighlightingMajorVersion != vm.MainVM.Data!.GeneralInfo.Major) + { + UndertaleCodeView.GMLHighlightingDefinition = null; + UndertaleCodeView.ASMHighlightingDefinition = null; + } + + HighlightingMajorVersion = vm.MainVM.Data!.GeneralInfo.Major; + + UndertaleCodeView.GMLHighlightingDefinition ??= LoadHighlightingDefinition("GML"); + GMLTextEditor.SyntaxHighlighting = UndertaleCodeView.GMLHighlightingDefinition; + + UndertaleCodeView.ASMHighlightingDefinition ??= LoadHighlightingDefinition("ASM"); + ASMTextEditor.SyntaxHighlighting = UndertaleCodeView.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; + UndertaleCodeView.GMLHighlightingDefinition = null; + UndertaleCodeView.ASMHighlightingDefinition = null; + + GMLTextEditor.TextArea.TextView.ElementGenerators.Remove(gmlNumberGenerator); + GMLTextEditor.TextArea.TextView.ElementGenerators.Remove(gmlNameGenerator); + ASMTextEditor.TextArea.TextView.ElementGenerators.Remove(asmNameGenerator); + } + + UpdateHighlightingCache(); + } + }; + + InitializeTextEditor(GMLTextEditor); + InitializeTextEditor(ASMTextEditor); + + GMLTextEditor.TextArea.GotFocus += GMLTextEditor_GotFocus; + ASMTextEditor.TextArea.GotFocus += ASMTextEditor_GotFocus; + + GMLTextEditor.TextArea.LostFocus += GMLTextEditor_LostFocus; + ASMTextEditor.TextArea.LostFocus += ASMTextEditor_LostFocus; + } + + 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) + continue; + ScriptsCache[script.Name.Content] = script; + } + + foreach (var function in data.Functions) + { + if (function is null) + continue; + FunctionsCache[function.Name.Content] = function; + } + + foreach (var code in data.Code) + { + if (code 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) + 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.Options.ConvertTabsToSpaces = true; + textEditor.Options.HighlightCurrentLine = true; + } + + public void ProcessLastGoToLocation() + { + if (DataContext is UndertaleCodeViewModel vm) + { + if (!vm.IsCodeProcessing) + { + if (this.IsAttachedToVisualTree()) + { + GoToLastGoToLocation(); + } + else + { + void OnAttachedToLogicalTree(object? _, LogicalTreeAttachmentEventArgs __) + { + GoToLastGoToLocation(); + AttachedToLogicalTree -= OnAttachedToLogicalTree; + } + + AttachedToLogicalTree += OnAttachedToLogicalTree; + } + } + } + } + + public void GoToLastGoToLocation() + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (vm.LastGoToLocation is not (UndertaleCodeViewModel.Tab tab, int line) location) + return; + + vm.SelectedTab = location.Tab; + + TextEditor textEditor = (location.Tab == UndertaleCodeViewModel.Tab.GML) ? GMLTextEditor : ASMTextEditor; + + textEditor.TextArea.Caret.Column = 0; + textEditor.TextArea.Caret.Line = location.Line; + textEditor.Focus(); + + void OnLayoutUpdated(object? _, EventArgs __) + { + textEditor.ScrollToLine(location.Line); + 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, GotFocusEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.GMLFocused = true; + + UpdateHighlightingCache(); + } + + private void ASMTextEditor_GotFocus(object? sender, GotFocusEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.ASMFocused = true; + + UpdateHighlightingCache(); + } + + private void GMLTextEditor_LostFocus(object? sender, RoutedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (vm.GMLFocused && vm.MainVM.Settings!.AutomaticallyCompileAndDecompileCodeOnLostFocus) + { + vm.CompileAndDecompileGML(onlyIfOutdated: true); + vm.GMLFocused = false; + } + } + + private void ASMTextEditor_LostFocus(object? sender, RoutedEventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + if (vm.ASMFocused && vm.MainVM.Settings!.AutomaticallyCompileAndDecompileCodeOnLostFocus) + { + vm.CompileAndDecompileASM(onlyIfOutdated: true); + vm.ASMFocused = false; + } + } + + private void GMLTextEditor_TextChanged(object? sender, EventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.GMLOutdated = true; + } + + private void ASMTextEditor_TextChanged(object? sender, EventArgs e) + { + if (DataContext is not UndertaleCodeViewModel vm) + return; + + vm.ASMOutdated = true; + } + + // 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 += (text, button) => + { + if (button != MouseButton.Right) + return; + + if (!int.TryParse(text, out int id)) + return; + + 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]); + } + + contextMenu.Items.Clear(); + + foreach (UndertaleNamedResource? obj in possibleObjects) + { + if (obj 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; + + foreach (var (constantName, constantValue) in list.Constants) + { + if (constantValue == id) + { + MenuItem item = new(); + item.Header = constantName.Replace("_", "__") + " (constant)"; + item.Click += (_, _) => + { + document.Replace(documentOffset, text.Length, constantName, null); + }; + contextMenu.Items.Add(item); + + // TODO: Ideally it would show all constants, but that's too cluttered! + break; + } + } + + contextMenu.Items.Add(new MenuItem() { Header = id + " (number)", IsEnabled = false }); + + codeViewModel.GMLFocused = false; + codeViewModel.ASMFocused = false; + contextMenu.Open(textEditor); + }; + + 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 (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 line = new(text, CurrentContext.VisualLine, length, isFunction ? FunctionBrush : ConstantBrush); + if (isFunction) + { + // Make function references bold as well as a different color + line.Bold = true; + } + if (namedResource is not null) + { + // Add click operation when we have a resource + line.Clicked += (text, button) => + { + if (button == MouseButton.Right) + { + contextMenu.Items.Clear(); + + MenuItem openMenuItem = new(); + openMenuItem.Header = "Open"; + openMenuItem.Click += (sender, _) => + { + textEditor.TextArea.Focus(); + codeViewModel.MainVM.TabOpen(namedResource, false); + }; + contextMenu.Items.Add(openMenuItem); + + MenuItem openInNewTabMenuItem = new(); + openInNewTabMenuItem.Header = "Open in new tab"; + openInNewTabMenuItem.Click += (sender, _) => + { + textEditor.TextArea.Focus(); + codeViewModel.MainVM.TabOpen(namedResource, true); + }; + contextMenu.Items.Add(openInNewTabMenuItem); + + codeViewModel.GMLFocused = false; + codeViewModel.ASMFocused = false; + + contextMenu.Open(textEditor); + } + else if (button == MouseButton.Middle) + { + codeViewModel.MainVM.TabOpen(namedResource, true); + } + }; + } + + return line; + } + } + + 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 void ClickHandler(string text, MouseButton button); + 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); + } + + bool LinkIsClickable(PointerEventArgs e) + { + return !string.IsNullOrEmpty(Text) && e.KeyModifiers.HasFlag(KeyModifiers.Control); + } + + protected override void OnQueryCursor(PointerEventArgs e) + { + if (LinkIsClickable(e)) + { + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerEventArgs e) + { + if (e.Handled) + return; + + MouseButton button = e.GetCurrentPoint(null).Properties.PointerUpdateKind.GetMouseButton(); + + if ((button == MouseButton.Left && LinkIsClickable(e)) + || button == MouseButton.Middle + || button == MouseButton.Right) + { + if (Clicked != null) + { + Clicked(Text, button); + e.Handled = true; + } + } + } + + protected override VisualLineText CreateInstance(int length) + { + ClickVisualLineText res = new(Text, ParentVisualLine, length); + res.Clicked += Clicked; + return res; + } + } +} + +public interface IUndertaleCodeView +{ + private UndertaleCodeView View => (UndertaleCodeView)this; + + public void SaveCaretOffsets() + { + View.LastCaretOffsets = (View.GMLTextEditor.CaretOffset, View.ASMTextEditor.CaretOffset); + } + + public void RestoreCaretOffsets() + { + View.GMLTextEditor.CaretOffset = Math.Clamp(View.LastCaretOffsets.Item1, 0, View.GMLTextEditor.Text.Length); + View.ASMTextEditor.CaretOffset = Math.Clamp(View.LastCaretOffsets.Item2, 0, View.ASMTextEditor.Text.Length); + } + + public void ProcessLastGoToLocation(); + + public int GMLCaretOffset + { + get { return View.GMLTextEditor.CaretOffset; } + set { View.GMLTextEditor.CaretOffset = value; } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs new file mode 100644 index 000000000..0b4f7a786 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleCodeViewModel.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Compiler; +using UndertaleModLib.Decompiler; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleCodeViewModel : IUndertaleResourceViewModel +{ + public enum Tab + { + GML = 0, + ASM = 1, + } + + public IUndertaleCodeView? View; + + public MainViewModel MainVM; + public UndertaleResource Resource => Code; + public UndertaleCode Code { get; set; } + + [Notify] + private Tab _SelectedTab; + [Notify] + private (Tab Tab, int Line)? _LastGoToLocation = null; + + public TextDocument GMLTextDocument { get; set; } = new TextDocument(); + public TextDocument ASMTextDocument { get; set; } = new TextDocument(); + + public bool IsCodeProcessing = false; + + public bool GMLOutdated = true; + public bool ASMOutdated = true; + + public bool GMLFocused = false; + public bool ASMFocused = false; + + ILoaderWindow? loaderWindow; + IInputElement? lastFocusedElement; + + public UndertaleCodeViewModel(UndertaleCode code, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + + Code = code; + + DecompileAll(); + } + + public async Task DecompileToGML() + { + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Decompiling to GML..."); + + GlobalDecompileContext context = new(MainVM.Data); + + try + { + GMLTextDocument.Text = await Task.Run(() => new Underanalyzer.Decompiler.DecompileContext(context, Code, MainVM.Data!.ToolInfo.DecompilerSettings).DecompileToString()); + GMLOutdated = false; + } + catch (Underanalyzer.Decompiler.DecompilerException e) + { + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(e.ToString(), title: "GML decompilation error"); + return false; + } + + return true; + } + + public async Task 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) + { + loaderWindow?.EnsureShown(); + MessageWindow.Result undoChanges = await MainVM.View!.MessageDialog(result.PrintAllErrors(codeEntryNames: false) + + "\n\nUndo changes?", title: "GML compilation error", MessageWindow.Buttons.YesNo); + if (undoChanges == MessageWindow.Result.Yes) + { + await DecompileToGML(); + } + return false; + } + + return true; + } + + public async Task DecompileToASM() + { + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Decompiling from ASM..."); + + try + { + ASMTextDocument.Text = await Task.Run(() => Code.Disassemble(MainVM.Data!.Variables, MainVM.Data!.CodeLocals?.For(Code))); + ASMOutdated = false; + } + catch (Exception e) + { + loaderWindow?.EnsureShown(); + await MainVM.View!.MessageDialog(e.ToString(), title: "ASM decompilation error"); + return false; + } + + return true; + } + + public async Task CompileFromASM() + { + if (Code.ParentEntry is not null) + return false; + + loaderWindow?.SetText("Compiling from ASM..."); + + try + { + string text = ASMTextDocument.Text; + List instructions = await Task.Run(() => Assembler.Assemble(text, MainVM.Data)); + Code.Replace(instructions); + } + catch (Exception e) + { + loaderWindow?.EnsureShown(); + MessageWindow.Result undoChanges = await MainVM.View!.MessageDialog(e.ToString() + + "\n\nUndo changes?", title: "ASM compilation error", MessageWindow.Buttons.YesNo); + if (undoChanges == MessageWindow.Result.Yes) + { + await DecompileToASM(); + } + + return false; + } + + return true; + } + + public void GoToLocation(Tab tab, int lineNumber) + { + LastGoToLocation = (tab, lineNumber); + } + + void CodeProcessStart() + { + loaderWindow = MainVM.View!.LoaderOpen(); + + IsCodeProcessing = true; + + View?.SaveCaretOffsets(); + lastFocusedElement = MainVM.View.GetFocusedElement(); + MainVM.IsEnabled = false; + } + + void CodeProcessEnd() + { + loaderWindow!.Close(); + loaderWindow = null; + + IsCodeProcessing = false; + + MainVM.IsEnabled = true; + lastFocusedElement?.Focus(); + View?.RestoreCaretOffsets(); + } + + public async void DecompileAll() + { + CodeProcessStart(); + + await DecompileToGML(); + await DecompileToASM(); + + CodeProcessEnd(); + + View?.ProcessLastGoToLocation(); + } + + public void CompileAndDecompileGML() => CompileAndDecompileGML(false); + + public async void CompileAndDecompileGML(bool onlyIfOutdated) + { + if (!IsCodeProcessing && (onlyIfOutdated ? GMLOutdated : true)) + { + CodeProcessStart(); + + if (await CompileFromGML()) + { + await DecompileToGML(); + await DecompileToASM(); + } + + CodeProcessEnd(); + } + } + + public void CompileAndDecompileASM() => CompileAndDecompileASM(false); + + public async void CompileAndDecompileASM(bool onlyIfOutdated) + { + if (!IsCodeProcessing && (onlyIfOutdated ? ASMOutdated : true)) + { + CodeProcessStart(); + + if (await CompileFromASM()) + { + await DecompileToGML(); + await DecompileToASM(); + } + + CodeProcessEnd(); + } + } +} 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..038f21b4c --- /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; set; } + + AudioPlayer? audioPlayer = null; + + public UndertaleEmbeddedAudioViewModel(UndertaleEmbeddedAudio embeddedAudio, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + + EmbeddedAudio = embeddedAudio; + } + + public void 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}.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..9ffecd179 --- /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; set; } + + 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..7808a913b --- /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; set; } + + public UndertaleEmbeddedTextureViewModel(UndertaleEmbeddedTexture embeddedTexture, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).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}.{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}.png", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportEmbeddedTextureAsPNG(EmbeddedTexture, stream); + } + } +} 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..df1554137 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleExtensionViewModel.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleExtensionViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Extension; + public UndertaleExtension Extension { get; set; } + + [Notify] + private UndertaleExtensionFile? _FilesSelected; + [Notify] + private UndertaleExtensionFunction? _FunctionsSelected; + + public UndertaleExtensionViewModel(UndertaleExtension extension, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).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", createNew: true), + Kind = UndertaleExtensionKind.Dll, + Functions = [], + }; + } + + public UndertaleExtensionFunction CreateExtensionFunction() + { + return new() + { + Name = MainVM.Data!.Strings.MakeString($"new_extension_function_{FilesSelected!.Functions.Count}", createNew: true), + 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", createNew: true), + Arguments = [], + }; + } + + public static UndertaleExtensionFunctionArg CreateExtensionFunctionArg() => new(); + + public UndertaleExtensionOption CreateExtensionOption() + { + return new() + { + Name = MainVM.Data!.Strings.MakeString($"extensionOption{Extension.Options.Count}", createNew: true), + Value = MainVM.Data!.Strings.MakeString("", createNew: true), + }; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml new file mode 100644 index 000000000..69d70e4b4 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontView.axaml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..a533bab92 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleFontViewModel.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleFontViewModel : IUndertaleResourceViewModel +{ + public UndertaleResource Resource => Font; + public UndertaleFont Font { get; set; } + + [Notify] + private UndertaleFont.Glyph? _GlyphsSelected; + + 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..f95c002de --- /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; set; } + + public UndertaleFunctionViewModel(UndertaleFunction function) + { + Function = function; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml new file mode 100644 index 000000000..28b7332af --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleGameObjectView.axaml @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..eaad6c66a --- /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; set; } + + public UndertaleGameObjectViewModel(UndertaleGameObject gameObject, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).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..3fe89aebe --- /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; set; } + + 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..7cf405198 --- /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; set; } + + 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..97a75ef68 --- /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; set; } + + 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..38e42cb95 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml @@ -0,0 +1,707 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Select any layer + Grid + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs new file mode 100644 index 000000000..fe9f34114 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomView.axaml.cs @@ -0,0 +1,403 @@ +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 += (_, e) => + { + if (e.PropertyName == nameof(UndertaleRoomViewModel.RoomTreeItemsSelectedItem)) + { + var item = vm.RoomTreeItemsSelectedItem; + if (item is not null) + { + TreeViewItem? treeViewItem = GetTreeViewItem(RoomItemsTreeView, item); + + 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(); + } + } + }; + } + }; + } + + 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_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) + { + // TODO: Move to view model + 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; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs new file mode 100644 index 000000000..caea622f9 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleRoomViewModel.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using SkiaSharp; +using UndertaleModLib; +using UndertaleModLib.Models; +using static UndertaleModLib.Models.UndertaleRoom; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleRoomViewModel : 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; set; } + + public ObservableCollection RoomTreeItems { get; set; } = []; + + [Notify] + private object? _RoomTreeItemsSelectedItem; + + [Notify] + private object? _PropertiesContent; + + [Notify] + private object? _CategorySelected; + + [Notify] + private string _StatusText = ""; + + [Notify] + private bool _IsSelectAnyLayerEnabled = false; + + [Notify] + private bool _IsGridEnabled = false; + [Notify] + private uint _GridWidth = 20; + [Notify] + private uint _GridHeight = 20; + [Notify] + private double _Zoom = 1; + + [Notify] + private uint _SelectedTileData = 0; + [Notify] + private uint _TileSetColumns = 0; + + public UndertaleRoomViewModel(UndertaleRoom room, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).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, createNew: true), + 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(); + + layer.AssetsData.InitializeAllAssets(); + } + else if (layer.LayerType == LayerType.Tiles) + { + layer.TilesData.TileData ??= []; + } + + Room.Layers.Add(layer); + } + + public void RemoveLayer(Layer layer) + { + if (layer.LayerType == LayerType.Instances) + { + // TODO: Remove from InstanceCreationOrderIDs + foreach (UndertaleRoom.GameObject? instance in layer.InstancesData.Instances) + { + Room.GameObjects.Remove(instance); + } + } + + 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) + { + // TODO: Remove from InstanceCreationOrderIDs + layer?.InstancesData.Instances.Remove(instance); + Room.GameObjects.Remove(instance); + } + + public void AddTile() + { + Tile tile = new() + { + InstanceID = MainVM.Data!.GeneralInfo.LastTile++, + }; + Room.Tiles.Add(tile); + } + + public void RemoveTile(Tile tile, UndertalePointerList? legacyTilesList = null) + { + if (legacyTilesList is not null) + { + legacyTilesList.Remove(tile); + } + else + { + Room.Tiles.Remove(tile); + } + } + + public void AddLegacyTileInstance(Layer layer) + { + Tile tile = new() + { + InstanceID = MainVM.Data!.GeneralInfo.LastTile++, + spriteMode = true, + }; + + layer.AssetsData.LegacyTiles.Add(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 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 (var layer in Room.Layers) + { + 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; + + foreach (IEnumerable assetTypeList in layer.AssetsData.AllAssets.Cast>()) + { + if (item == assetTypeList) + return layer; + + var instance = assetTypeList.FirstOrDefault(x => x == item); + if (instance is not null) + 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); + } + } + + private void OnRoomTreeItemsSelectedItemChanged() + { + PropertiesContent = RoomTreeItemsSelectedItem switch + { + TreeViewItem { Name: "RoomTreeViewItem" } => Room, + TreeViewItem => null, + UndertalePointerList => null, + UndertalePointerList => null, + UndertalePointerList => null, + UndertalePointerList => null, + UndertalePointerList => null, + RoomTreeItem => 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; + } +} 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..761e84125 --- /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; set; } + + public UndertaleScriptViewModel(UndertaleScript script) + { + Script = script; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderView.axaml new file mode 100644 index 000000000..324cb8345 --- /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..ae81ddff5 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleShaderViewModel.cs @@ -0,0 +1,104 @@ +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 UndertaleShaderViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Shader; + public UndertaleShader Shader { get; set; } + + public UndertaleShaderViewModel(UndertaleShader shader, IServiceProvider? serviceProvider = null) + { + Shader = shader; + + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + } + + 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(), + }; + } + + public async void 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.ReadAsync(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; + } + + public async void 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 + "_" + 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..088ba27ad --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundView.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..bae747932 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSoundViewModel.cs @@ -0,0 +1,66 @@ +using System; +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSoundViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Sound; + public UndertaleSound Sound { get; } + + [Notify] + private bool _IsBuiltinAudioGroup; + + AudioPlayer? audioPlayer = null; + + public UndertaleSoundViewModel(UndertaleSound sound, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + + Sound = sound; + + UpdateIsBuiltinAudioGroup(); + } + + public void OnAttached() + { + Sound.PropertyChanged += OnSoundPropertyChanged; + } + + public void OnDetached() + { + Sound.PropertyChanged -= OnSoundPropertyChanged; + StopAudio(); + } + + public async void PlayAudio() + { + audioPlayer?.Stop(); + if (Sound.AudioFile is not null) + audioPlayer = new(Sound.AudioFile.Data); + } + + public async void StopAudio() + { + audioPlayer?.Stop(); + audioPlayer = null; + } + + void OnSoundPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(UndertaleSound.AudioGroup)) + { + UpdateIsBuiltinAudioGroup(); + } + } + + void UpdateIsBuiltinAudioGroup() + { + IsBuiltinAudioGroup = Sound.AudioGroup is null || (MainVM.Data!.AudioGroups.IndexOf(Sound.AudioGroup) == MainVM.Data!.GetBuiltinSoundGroupID()); + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml new file mode 100644 index 000000000..599b2c64c --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteView.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..f1a33ff8e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleSpriteViewModel.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using SkiaSharp; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class UndertaleSpriteViewModel : IUndertaleResourceViewModel +{ + public MainViewModel MainVM; + public UndertaleResource Resource => Sprite; + public UndertaleSprite Sprite { get; set; } + + [Notify] + private UndertaleSprite.TextureEntry? _TexturesSelected; + [Notify] + private UndertaleSprite.MaskEntry? _CollisionMasksSelected; + + public UndertaleSpriteViewModel(UndertaleSprite sprite, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + Sprite = sprite; + + if (Sprite.Textures.Count > 0) + TexturesSelected = Sprite.Textures[0]; + if (Sprite.CollisionMasks.Count > 0) + CollisionMasksSelected = Sprite.CollisionMasks[0]; + } + + 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}_{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, MainVM); + } + } + } + + 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..509349863 --- /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; set; } + + public UndertaleStringViewModel(UndertaleString _string) + { + String = _string; + } +} diff --git a/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml new file mode 100644 index 000000000..d4b8c1cab --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTextureGroupInfoView.axaml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..4363cfebd --- /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; set; } + + 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..f797ffb8e --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemView.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..b265c96f7 --- /dev/null +++ b/UndertaleModToolAvalonia/ResourceViews/UndertaleTexturePageItemViewModel.cs @@ -0,0 +1,40 @@ +using System; +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; set; } + + public UndertaleTexturePageItemViewModel(UndertaleTexturePageItem texturePageItem, IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + + TexturePageItem = texturePageItem; + } + + public async void SaveImage() + { + IStorageFile? file = await MainVM.View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Save image", + FileTypeChoices = FilePickerFileTypes.PNG, + DefaultExtension = ".png", + }); + + if (file is null) + return; + + using (Stream stream = await file.OpenWriteAsync()) + { + await ImportExport.ExportTexturePageItemAsPNG(TexturePageItem, stream, MainVM); + } + } +} 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..a13667ed8 --- /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; set; } + + 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..a10786224 --- /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; set; } + + public UndertaleVariableViewModel(UndertaleVariable variable) + { + Variable = variable; + } +} diff --git a/UndertaleModToolAvalonia/Styles.axaml b/UndertaleModToolAvalonia/Styles.axaml new file mode 100644 index 000000000..a925b62f4 --- /dev/null +++ b/UndertaleModToolAvalonia/Styles.axaml @@ -0,0 +1,257 @@ + + + $Default + 12 + 5,4 + 4 + 0 + 24 + 12 + 0 + 28 + 28 + 0 + 0 + + monospace,Consolas,Liberation Mono,Menlo + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj b/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj new file mode 100644 index 000000000..ce4b00949 --- /dev/null +++ b/UndertaleModToolAvalonia/UndertaleModToolAvalonia.csproj @@ -0,0 +1,44 @@ + + + net10.0 + enable + latest + true + 0.8.4.1 + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + 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..a45012aac --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/LoaderWindow.axaml.cs @@ -0,0 +1,109 @@ +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"; + + int value; + string? message; + string? status; + int maximum = -1; + bool hasClosed = false; + Window? showOwner; + + public LoaderWindow() + { + Initialize(); + } + + public void Initialize() + { + InitializeComponent(); + + Closing += (object? sender, WindowClosingEventArgs e) => + { + if (!e.IsProgrammatic) + e.Cancel = true; + else + hasClosed = true; + }; + } + + public void ShowDelayed(Window owner) + { + showOwner = owner; + Task.Delay(100).ContinueWith(_ => + { + Dispatcher.UIThread.Post(() => + { + if (!hasClosed) + Show(owner); + }); + }); + } + + public void EnsureShown() + { + if (showOwner is not null) + Show(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..5e06249cb --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainView.axaml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/MainView.axaml.cs b/UndertaleModToolAvalonia/Windows/MainView.axaml.cs new file mode 100644 index 000000000..2021cd906 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainView.axaml.cs @@ -0,0 +1,440 @@ +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.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Markup.Xaml.MarkupExtensions; +using Avalonia.VisualTree; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class MainView : UserControl, IView +{ + public MainView() + { + InitializeComponent(); + + DataContextChanged += (_, __) => + { + if (DataContext is MainViewModel vm) + { + vm.View = this; + + MainTreeDataGrid.Source = 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] = new Binding("Value.Name.Content"); + + if (namedResource is UndertaleCode code) + { + // NOTE: Doesn't update, but whatever. + if (code.ParentEntry is not null) + { + textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + } + } + } + else if (value.Value is UndertaleString _string) + { + textBlock[!TextBlock.TextProperty] = new Binding("Value.Content"); + } + else if (value.Value is null) + { + textBlock.Text = "(null)"; + textBlock[!TextBlock.ForegroundProperty] = new DynamicResourceExtension("SystemControlForegroundBaseMediumBrush"); + } + //else if (value.Value is UndertaleData data) + //{ + // textBlock[!TextBlock.TextProperty] = new Binding("Value.GeneralInfo"); + //} + + return textBlock; + }), width: GridLength.Star + ), + x => x.Children) + } + }; + } + }; + + Loaded += (_, __) => + { + if (DataContext is MainViewModel vm) + { + vm.OnLoaded(); + } + }; + + MainTreeDataGrid.AddHandler(TreeDataGrid.PointerReleasedEvent, MainTreeDataGrid_PointerReleased_HandledEventsToo, handledEventsToo: true); + CommandTextBox.AddHandler(TextBox.KeyDownEvent, CommandTextBox_KeyDown_Tunnel, RoutingStrategies.Tunnel); + } + + private void FilterTextBox_TextChanged(object? sender, TextChangedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + vm.SetFilterText(FilterTextBox.Text ?? ""); + } + } + + private MainViewModel.TreeDataGridItem? GetItemFromTreeDataGridControl(object? source) + { + if (DataContext is MainViewModel vm) + { + if (source is Control control) + { + TreeDataGridRow? row = control.FindLogicalAncestorOfType(includeSelf: true); + if (row?.DataContext is MainViewModel.TreeDataGridItem item) + { + return item; + } + } + } + return null; + } + + private void OpenItemFromTreeDataGridControl(object? source, bool inNewTab = false) + { + if (DataContext is MainViewModel vm) + { + if (source is Control control) + { + TreeDataGridRow? row = control.FindLogicalAncestorOfType(includeSelf: true); + if (row?.DataContext is MainViewModel.TreeDataGridItem item) + { + if (row.Rows?[row.RowIndex] is HierarchicalRow hierarchicalRow) + { + hierarchicalRow.IsExpanded = !hierarchicalRow.IsExpanded; + } + vm.TabOpen(item.Value, inNewTab); + } + } + } + } + + public void ExpandItemOnTree(MainViewModel.TreeDataGridItem item) + { + if (DataContext is not MainViewModel 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 MainViewModel vm) + return; + + IndexPath? foundIndex = FindTreeIndexPathFromValue(value, vm.TreeDataGridData); + + if (foundIndex is IndexPath index) + { + var source = (MainTreeDataGrid.Source as HierarchicalTreeDataGridSource)!; + source.Expand(index); + + MainTreeDataGrid.RowSelection!.SelectedIndex = index; + + int rowIndex = MainTreeDataGrid.Rows!.ModelIndexToRowIndex(index); + MainTreeDataGrid.RowsPresenter!.BringIntoView(rowIndex); + } + } + + public static IndexPath? FindTreeIndexPathFromValue(object value, IList? list, IndexPath indexPath = new()) + { + if (list is null) + return null; + + for (int i = 0; i < list.Count; i++) + { + MainViewModel.TreeDataGridItem? 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; + } + + private void MainTreeDataGrid_DoubleTapped(object? sender, TappedEventArgs e) + { + OpenItemFromTreeDataGridControl(e.Source); + } + + private void MainTreeDataGrid_KeyDown(object? sender, KeyEventArgs e) + { + if (e.PhysicalKey == PhysicalKey.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); + } + } + + public void ContextMenu_Add_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null) + { + // This could probably be better + IList list = (item.Value switch + { + "AudioGroups" => vm.Data.AudioGroups as IList, + "Sounds" => vm.Data.Sounds as IList, + "Sprites" => vm.Data.Sprites as IList, + "Backgrounds" => vm.Data.Backgrounds as IList, + "Paths" => vm.Data.Paths as IList, + "Scripts" => vm.Data.Scripts as IList, + "Shaders" => vm.Data.Shaders as IList, + "Fonts" => vm.Data.Fonts as IList, + "Timelines" => vm.Data.Timelines as IList, + "GameObjects" => vm.Data.GameObjects as IList, + "Rooms" => vm.Data.Rooms as IList, + "Extensions" => vm.Data.Extensions as IList, + "TexturePageItems" => vm.Data.TexturePageItems as IList, + "Code" => vm.Data.Code as IList, + "Variables" => vm.Data.Variables as IList, + "Functions" => vm.Data.Functions as IList, + "CodeLocals" => vm.Data.CodeLocals as IList, + "Strings" => vm.Data.Strings as IList, + "EmbeddedTextures" => vm.Data.EmbeddedTextures as IList, + "EmbeddedAudio" => vm.Data.EmbeddedAudio as IList, + "TextureGroupInformation" => vm.Data.TextureGroupInfo as IList, + "EmbeddedImages" => vm.Data.EmbeddedImages as IList, + "AnimationCurves" => vm.Data.AnimationCurves as IList, + "ParticleSystems" => vm.Data.ParticleSystems as IList, + "ParticleSystemEmitters" => vm.Data.ParticleSystemEmitters as IList, + _ => null, + })!; + + vm.DataItemAdd(list); + } + } + } + + public void ContextMenu_Open_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null) + { + vm.TabOpen(item.Value); + } + } + } + + public void ContextMenu_OpenInNewTab_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null) + { + vm.TabOpen(item.Value, inNewTab: true); + } + } + } + + public async void ContextMenu_CopyName_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null) + { + string? name = item.Value switch + { + UndertaleNamedResource namedResource => namedResource.Name.Content, + UndertaleString _string => _string.Content, + _ => null, + }; + + if (name is not null) + { + TopLevel topLevel = TopLevel.GetTopLevel(this)!; + await topLevel.Clipboard!.SetTextAsync(name); + } + } + } + } + + public async void ContextMenu_Move_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null && vm.View is not null) + { + UndertaleResource resource = (item.Value as UndertaleResource)!; + IList list = vm.Data[resource.GetType()]; + int oldIndex = list.IndexOf(resource); + + string? input = await vm.View.TextBoxDialog("Swap to position:", oldIndex.ToString()); + if (input is null) + return; + + if (!int.TryParse(input, out int newIndex)) + { + await vm.View.MessageDialog($"\"{input}\" is not a integer"); + return; + } + if (newIndex < 0 || newIndex >= list.Count) + { + await vm.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 MainViewModel vm) + { + MainViewModel.TreeDataGridItem? item = GetItemFromTreeDataGridControl(e.Source); + if (item is not null && vm.Data is not null) + { + // TODO: Maybe do something about all references to this. + UndertaleResource resource = (item.Value as UndertaleResource)!; + + if (await vm.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) + { + vm.Data[resource.GetType()].Remove(resource); + + // TODO: Close tabs, remove histories + } + } + } + } + + private void TabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + object? tabSelected = e.AddedItems.Count > 0 ? e.AddedItems[0] : null; + foreach (TabItemViewModel tab in vm.Tabs) + { + tab.IsSelected = (tab == tabSelected); + } + } + } + + private void TabControl_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Middle) + { + if (DataContext is MainViewModel 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); + } + } + } + } + } + } + + private void TabMenu_Select_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is not MainViewModel 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) + { + SelectValueInTree(vmResourceView.Resource); + } + } + } + } + + private void TabMenu_Close_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + if (e.Source is Control control) + { + TabStripItem? tabItem = control.FindLogicalAncestorOfType(); + if (tabItem is not null && tabItem.DataContext is TabItemViewModel vmTabItem) + { + vm.TabClose(vmTabItem); + } + } + } + } + + private void TabMenu_CloseAll_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + { + vm.TabCloseAll(); + } + } + + 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..9a4f900b4 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainViewModel.cs @@ -0,0 +1,822 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class MainViewModel +{ + // 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; set; } + + // Scripting + public Scripting Scripting = null!; + + // Window + public string Title => $"UndertaleModToolAvalonia - v" + + (Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?.?.?.?") + + $"{(Data?.GeneralInfo is not null ? " - " + Data.GeneralInfo.ToString() : "")}" + + $"{(DataPath is not null ? " [" + DataPath + "]" : "")}"; + + [Notify] + private WindowState _WindowState = WindowState.Maximized; + + [Notify] + private bool _IsEnabled = true; + + // Data + [Notify] + private UndertaleData? _Data; + [Notify] + private string? _DataPath; + [Notify] + private (uint Major, uint Minor, uint Release, uint Build) _DataVersion; + + // Tree data grid + public partial class TreeDataGridItem + { + [Notify] + private string _Text = ""; + public object? Value { get; set; } + public object? Tag { get; set; } + [Notify] + private IList? _Children; + } + + [Notify] + private ObservableCollection _TreeDataGridData = []; + + public event Action? FilterTextChanged; + + // Tabs + public ObservableCollection Tabs { get; set; } + + [Notify] + private TabItemViewModel? _TabSelected; + [Notify] + private int _TabSelectedIndex; + [Notify] + private string _TabSelectedResourceIdString = "None"; + + // Command text box + [Notify] + private string _CommandTextBoxText = ""; + + // Image cache + public ImageCache ImageCache = new(); + + // Internal clipboard + public object? InternalClipboard = null; + + public MainViewModel(IServiceProvider? serviceProvider = null) + { + ServiceProvider = serviceProvider ?? App.Services; + + AudioPlayer.Init(f => Dispatcher.UIThread.Post(f)); + + Tabs = [ + new TabItemViewModel(new DescriptionViewModel( + "Welcome to UndertaleModTool!", + "Open a data.win file to get started, then double click on the items on the left to view them."), + isSelected: true), + ]; + } + + public void Initialize() + { + Settings = SettingsFile.Load(ServiceProvider); + Scripting = new(ServiceProvider); + + WindowState = Settings.StartMaximized ? WindowState.Maximized : WindowState.Normal; + } + + 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}"); + } + } + } + } + + public async void 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(); + } + } + + // Called by [Notify] + public void OnDataChanged() + { + 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(); + + TreeDataGridData.Clear(); + + if (FilterTextChanged is not null) + foreach (Delegate item in FilterTextChanged.GetInvocationList()) + { + FilterTextChanged -= (Action)item; + } + + if (Data is not null) + { + IList? MakeChildren(IList? list) where T : notnull + { + if (list is not null) + { + ObservableCollectionView view = new(list, + transform: x => new TreeDataGridItem() { Text = "", Value = x }); + + FilterTextChanged += filterText => + { + view.SetFilter(item => AssetNameContainsText(item, filterText)); + }; + + return view.Output; + } + return null; + } + + var dataItem = new TreeDataGridItem() + { + Value = Data, + Text = "Data", + Children = [], + }; + + if (Data.GeneralInfo is not null) + dataItem.Children.Add(new() { Value = "GeneralInfo", Text = "General info" }); + if (Data.GlobalInitScripts is not null) + dataItem.Children.Add(new() { Value = "GlobalInitScripts", Text = "Global init scripts" }); + if (Data.GameEndScripts is not null) + dataItem.Children.Add(new() { Value = "GameEndScripts", Text = "Game End scripts" }); + + if (Data.AudioGroups is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "AudioGroups", Text = "Audio groups", + Children = MakeChildren(Data.AudioGroups)}); + if (Data.Sounds is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Sounds", Text = "Sounds", + Children = MakeChildren(Data.Sounds)}); + if (Data.Sprites is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Sprites", Text = "Sprites", + Children = MakeChildren(Data.Sprites)}); + if (Data.Backgrounds is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Backgrounds", Text = "Backgrounds & Tile sets", + Children = MakeChildren(Data.Backgrounds)}); + if (Data.Paths is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Paths", Text = "Paths", + Children = MakeChildren(Data.Paths)}); + if (Data.Scripts is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Scripts", Text = "Scripts", + Children = MakeChildren(Data.Scripts)}); + if (Data.Shaders is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Shaders", Text = "Shaders", + Children = MakeChildren(Data.Shaders)}); + if (Data.Fonts is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Fonts", Text = "Fonts", + Children = MakeChildren(Data.Fonts)}); + if (Data.Timelines is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Timelines", Text = "Timelines", + Children = MakeChildren(Data.Timelines)}); + if (Data.GameObjects is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "GameObjects", Text = "Game objects", + Children = MakeChildren(Data.GameObjects)}); + if (Data.Rooms is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Rooms", Text = "Rooms", + Children = MakeChildren(Data.Rooms)}); + if (Data.Extensions is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Extensions", Text = "Extensions", + Children = MakeChildren(Data.Extensions)}); + if (Data.TexturePageItems is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "TexturePageItems", Text = "Texture page items", + Children = MakeChildren(Data.TexturePageItems)}); + if (Data.Code is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Code", Text = "Code", + Children = MakeChildren(Data.Code)}); + if (Data.Variables is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Variables", Text = "Variables", + Children = MakeChildren(Data.Variables)}); + if (Data.Functions is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Functions", Text = "Functions", + Children = MakeChildren(Data.Functions)}); + if (Data.CodeLocals is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "CodeLocals", Text = "Code locals", + Children = MakeChildren(Data.CodeLocals)}); + if (Data.Strings is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "Strings", Text = "Strings", + Children = MakeChildren(Data.Strings)}); + if (Data.EmbeddedTextures is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "EmbeddedTextures", Text = "Embedded textures", + Children = MakeChildren(Data.EmbeddedTextures)}); + if (Data.EmbeddedAudio is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "EmbeddedAudio", Text = "Embedded audio", + Children = MakeChildren(Data.EmbeddedAudio)}); + if (Data.TextureGroupInfo is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "TextureGroupInformation", Text = "Texture group information", + Children = MakeChildren(Data.TextureGroupInfo)}); + if (Data.EmbeddedImages is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "EmbeddedImages", Text = "Embedded images", + Children = MakeChildren(Data.EmbeddedImages)}); + if (Data.AnimationCurves is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "AnimationCurves", Text = "Animation curves", + Children = MakeChildren(Data.AnimationCurves)}); + if (Data.ParticleSystems is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "ParticleSystems", Text = "Particle systems", + Children = MakeChildren(Data.ParticleSystems)}); + if (Data.ParticleSystemEmitters is not null) + dataItem.Children.Add(new() {Tag = "list", Value = "ParticleSystemEmitters", Text = "Particle system emitters", + Children = MakeChildren(Data.ParticleSystemEmitters)}); + + TreeDataGridData.Add(dataItem); + + // HACK: Dirty! But I don't wanna make a whole interface for that + if (View is MainView mainView) + mainView.ExpandItemOnTree(dataItem); + } + } + + private bool AssetNameContainsText(T asset, string text) + { + string? name = asset switch + { + UndertaleNamedResource namedResource => namedResource.Name.Content, + UndertaleString _string => _string.Content, + _ => null, + }; + + if (name is null) + return true; + + return name.Contains(text, StringComparison.OrdinalIgnoreCase); + } + + /// 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 FileSave()) + { + return true; + } + } + else if (result == MessageWindow.Result.No) + { + return true; + } + + return false; + } + + public Task NewData() + { + CloseData(); + + Data = UndertaleData.CreateNew(); + DataPath = null; + + return Task.FromResult(true); + } + + 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 async Task SaveData(Stream stream) + { + IsEnabled = false; + + ILoaderWindow w = View!.LoaderOpen(); + w.SetText("Saving data file..."); + + try + { + await Task.Run(() => UndertaleIO.Write(stream, Data, message => + { + Dispatcher.UIThread.Post(() => w.SetText($"Saving data file... {message}")); + })); + + return true; + } + catch (Exception e) + { + w.EnsureShown(); + await View!.MessageDialog($"Error saving data file: {e.Message}"); + } + finally + { + IsEnabled = true; + w.Close(); + } + + return false; + } + + public void CloseData() + { + Data = null; + DataPath = null; + + foreach (TabItemViewModel tab in Tabs) + { + tab.Content.OnDetached(); + } + + Tabs.Clear(); + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + foreach (var window in desktop.Windows.ToList()) + { + if (window is SearchInCodeWindow) + { + window.Close(); + } + } + } + } + + 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; + } + + private 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 AskFileSave("Save data file before creating a new one?")) + { + await NewData(); + } + } + + public async void FileOpen() + { + 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, + }); + + if (files.Count != 1) + return; + + CloseData(); + + using Stream stream = await files[0].OpenReadAsync(); + + if (await LoadData(stream)) + { + DataPath = files[0].TryGetLocalPath(); + } + } + + public async Task FileSave() + { + if (Data is null) + return false; + + IStorageFile? file = await View!.SaveFileDialog(new FilePickerSaveOptions() + { + Title = "Save data file", + FileTypeChoices = FilePickerFileTypes.Data, + DefaultExtension = ".win", + }); + + if (file is null) + return false; + + using Stream stream = await file.OpenWriteAsync(); + + if (await SaveData(stream)) + { + DataPath = file.TryGetLocalPath(); + return true; + } + + return false; + } + + public async void FileClose() + { + if (!await AskFileSave("Save data file before closing?")) + return; + + CloseData(); + } + + public async void FileRun() + { + // NOTE: The project system would make this a lot simpler! + 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; + + // "launcher" allows game_change data files to still access files above the data path. + Process.Start(new ProcessStartInfo(runnerPath, $"-game \"{DataPath}\" launcher") { WorkingDirectory = Path.GetDirectoryName(DataPath) }); + } + + public async void FileSettings() + { + await View!.SettingsDialog(); + } + + public void FileExit() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + + public void ToolsSearchInCode() + { + View!.SearchInCodeOpen(); + } + + 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 HelpGitHub() + { + await View!.LaunchUriAsync(new Uri("https://github.com/UnderminersTeam/UndertaleModTool")); + } + + public async void HelpAbout() + { + await View!.MessageDialog($"UndertaleModTool v{Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?.?.?.?"} " + + $"by the Underminers team\nLicensed under the GNU General Public License Version 3.", title: "About"); + } + + public void SetFilterText(string text) + { + FilterTextChanged?.Invoke(text); + } + + 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; + } + } + + 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 (Settings!.OpenNewResourceAfterCreatingIt) + { + TabOpen(res, inNewTab: true); + } + } + + public TabItemViewModel? TabOpen(object? item, bool inNewTab = false) + { + if (Data is null) + return null; + + ITabContent? content = item switch + { + DescriptionViewModel vm => vm, + "GeneralInfo" => new GeneralInfoViewModel(Data), + "GlobalInitScripts" => new GlobalInitScriptsViewModel((Data.GlobalInitScripts as ObservableCollection)!), + "GameEndScripts" => new GameEndScriptsViewModel((Data.GameEndScripts as ObservableCollection)!), + UndertaleAudioGroup r => new UndertaleAudioGroupViewModel(r), + UndertaleSound r => new UndertaleSoundViewModel(r), + UndertaleSprite r => new UndertaleSpriteViewModel(r), + UndertaleBackground r => new UndertaleBackgroundViewModel(r), + UndertalePath r => new UndertalePathViewModel(r), + UndertaleScript r => new UndertaleScriptViewModel(r), + UndertaleShader r => new UndertaleShaderViewModel(r), + UndertaleFont r => new UndertaleFontViewModel(r), + UndertaleTimeline r => new UndertaleTimelineViewModel(r), + UndertaleGameObject r => new UndertaleGameObjectViewModel(r), + UndertaleRoom r => new UndertaleRoomViewModel(r), + UndertaleExtension r => new UndertaleExtensionViewModel(r), + UndertaleTexturePageItem r => new UndertaleTexturePageItemViewModel(r), + UndertaleCode r => new UndertaleCodeViewModel(r), + 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), + UndertaleEmbeddedAudio r => new UndertaleEmbeddedAudioViewModel(r), + 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) + { + TabSelected.GoTo(content); + return TabSelected; + } + else + { + TabItemViewModel tab = new(content); + Tabs.Add(tab); + TabSelected = tab; + return tab; + } + } + + return null; + } + + public void TabClose(TabItemViewModel tab) + { + var selected = TabSelected; + var index = TabSelectedIndex; + + tab.Content.OnDetached(); + + Tabs.Remove(tab); + + if (TabSelected != selected) + { + if (index >= Tabs.Count) + index = Tabs.Count - 1; + + TabSelectedIndex = index; + } + } + + public void TabCloseSelected() + { + if (TabSelected is not null) + TabClose(TabSelected); + } + + public void TabCloseAll() + { + foreach (TabItemViewModel tab in Tabs.ToList()) + { + TabClose(tab); + } + } + + 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 void TabGoBack() + { + TabSelected?.GoBack(); + } + + public void TabGoForward() + { + TabSelected?.GoForward(); + } + + private void OnTabSelectedChanged() + { + if (Data is not null && TabSelected?.Content is IUndertaleResourceViewModel vm) + { + TabSelectedResourceIdString = Data.IndexOf(vm.Resource).ToString(); + } + else + { + TabSelectedResourceIdString = "None"; + } + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/MainWindow.axaml b/UndertaleModToolAvalonia/Windows/MainWindow.axaml new file mode 100644 index 000000000..5a9b253e5 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainWindow.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs new file mode 100644 index 000000000..4749dd841 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MainWindow.axaml.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (!e.IsProgrammatic) + { + if (DataContext is MainViewModel vm && vm.Data is not null) + { + e.Cancel = true; + + async void AskFileSaveBeforeClose() + { + if (await vm.AskFileSave("Save data file before quitting?")) + Close(); + } + + AskFileSaveBeforeClose(); + } + } + + base.OnClosing(e); + } +} diff --git a/UndertaleModToolAvalonia/Windows/MessageWindow.axaml b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml new file mode 100644 index 000000000..8a4877b5f --- /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..2b8156127 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/MessageWindow.axaml.cs @@ -0,0 +1,86 @@ +using System; +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class MessageWindow : Window +{ + public string Message { get; set; } = "Message."; + public string TitleText { get; set; } = "UndertaleModToolAvalonia"; + 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/SearchInCodeViewModel.cs b/UndertaleModToolAvalonia/Windows/SearchInCodeViewModel.cs new file mode 100644 index 000000000..4a1a41f2c --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SearchInCodeViewModel.cs @@ -0,0 +1,278 @@ +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 Microsoft.Extensions.DependencyInjection; +using PropertyChanged.SourceGenerator; +using UndertaleModLib; +using UndertaleModLib.Decompiler; +using UndertaleModLib.Models; + +namespace UndertaleModToolAvalonia; + +public partial class SearchInCodeViewModel +{ + // Set this when testing. + public IView? View; + + public MainViewModel MainVM { get; } + + [Notify] + private bool _IsEnabled = true; + + [Notify] + private string _SearchText = ""; + [Notify] + private bool _IsCaseSensitive = false; + [Notify] + private bool _IsRegexSearch = false; + [Notify] + private bool _IsInAssembly = false; + [Notify] + private ObservableCollection _Results = []; + [Notify] + private string _StatusBarText = "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; + + public SearchInCodeViewModel(IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).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, lineText) in result.Value) + { + sortedResultsList.Add(new(code, lineNumber, lineText)); + } + } + }); + + 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) + { + 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.CurrentCulture : StringComparison.CurrentCultureIgnoreCase; + + 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; + } + } + + // 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, lineText)); + + Interlocked.Increment(ref resultCount); + } + } + + Interlocked.Increment(ref currentCodeEntriesCount); + } + + public void OpenSearchResult(SearchResult searchResult, bool inNewTab = false) + { + var tab = MainVM.TabOpen(searchResult.Code, inNewTab); + if (tab is not null && tab.Content is UndertaleCodeViewModel vm) + { + vm.GoToLocation(!IsInAssembly ? UndertaleCodeViewModel.Tab.GML : UndertaleCodeViewModel.Tab.ASM, searchResult.LineNumber); + } + } + + public class SearchResult + { + public string Location { get; set; } + public string Text { get; set; } + + public UndertaleCode Code; + public int LineNumber; + + public SearchResult(UndertaleCode code, int lineNumber, string text) + { + Code = code; + LineNumber = lineNumber; + + Location = code.Name.Content + ":" + lineNumber; + Text = text.Trim(); + } + } +} diff --git a/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml new file mode 100644 index 000000000..2e3f86937 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SearchInCodeWindow.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + 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..c90e0916a --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsViewModel.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace UndertaleModToolAvalonia; + +public class SettingsViewModel +{ + public MainViewModel MainVM { get; } + + public SettingsViewModel(IServiceProvider? serviceProvider = null) + { + MainVM = (serviceProvider ?? App.Services).GetRequiredService(); + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml new file mode 100644 index 000000000..def2e9c35 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml @@ -0,0 +1,168 @@ + + + + Interface + + + + + + System default + Light + Dark + + + + + + + + + + + + + + + + + + + + + + + GML + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs new file mode 100644 index 000000000..37cc4bd18 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/SettingsWindow.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class SettingsWindow : Window +{ + public SettingsWindow() + { + InitializeComponent(); + + Closing += (_, __) => + { + if (DataContext is SettingsViewModel vm) + { + vm.MainVM.Settings?.Save(); + } + }; + } +} \ No newline at end of file diff --git a/UndertaleModToolAvalonia/Windows/TabItemViewModel.cs b/UndertaleModToolAvalonia/Windows/TabItemViewModel.cs new file mode 100644 index 000000000..efe4d64e3 --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/TabItemViewModel.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using PropertyChanged.SourceGenerator; + +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 to a tab, i.e. when it stops being a tab's content. + void OnDetached() { } +} + +public partial class TabItemViewModel +{ + [Notify] + private ITabContent _Content = null!; + [Notify] + private bool _IsSelected = false; + + [Notify] + private bool _CanGoBack = false; + [Notify] + private bool _CanGoForward = 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; + + Content.OnAttached(); + } + + public void GoTo(ITabContent content) + { + if (content == Content) + return; + + Content.OnDetached(); + + Content = content; + + history.RemoveRange(historyPosition + 1, history.Count - (historyPosition + 1)); + + history.Add(content); + historyPosition++; + + CanGoBack = true; + CanGoForward = false; + + Content.OnAttached(); + } + + public void GoBack() + { + Content.OnDetached(); + + historyPosition--; + Content = history[historyPosition]; + + CanGoBack = (historyPosition != 0); + CanGoForward = true; + + Content.OnAttached(); + } + + public void GoForward() + { + Content.OnDetached(); + + historyPosition++; + Content = history[historyPosition]; + + CanGoBack = true; + CanGoForward = (historyPosition != history.Count - 1); + + Content.OnAttached(); + } +} \ No newline at end of file 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..96c2d10dc --- /dev/null +++ b/UndertaleModToolAvalonia/Windows/TextBoxWindow.axaml.cs @@ -0,0 +1,39 @@ +using Avalonia.Controls; + +namespace UndertaleModToolAvalonia; + +public partial class TextBoxWindow : Window +{ + public string Message { get; set; } = "Message."; + public string TitleText { get; set; } = "UndertaleModToolAvalonia"; + + 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