diff --git a/.gitignore b/.gitignore new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +.vscode/ +.vs/ + +bin/ +obj/ + +*.user \ No newline at end of file diff --git a/.hgeol b/.hgeol new file mode 100644 --- /dev/null +++ b/.hgeol @@ -0,0 +1,7 @@ +[repository] +native = LF + +[patterns] +** = native +**.vcproj = CRLF + diff --git a/.hgignore b/.hgignore new file mode 100644 --- /dev/null +++ b/.hgignore @@ -0,0 +1,19 @@ +syntax: glob + +.idea/ +.vscode/ +.vs/ + +bin/ +obj/ + +*.user + +mac/old/ +*.userprefs +*.orig +*.swp +*.zip +*.dmg +.DS_Store +mac/bin diff --git a/App.axaml b/App.axaml new file mode 100644 --- /dev/null +++ b/App.axaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + M28 6a6 6 0 1 1-6-6A6 6 0 0 1 28 6Zm2 18s0-10-8-10-8 10-8 10ZM12 10H8V6H4v4H0v4H4v4H8V14h4Z + M 16 8 8 0 0 8 h 5 v 8 H 11 V 8 Z + M 16 8 8 16 0 8 H 5 V 0 H 11 v 8 z + M 9 8 A 1 1 0 0 1 8 9 1 1 0 0 1 7 8 1 1 0 0 1 8 7 1 1 0 0 1 9 8 Z m 5.62 8 C 12.62 16 8.88 13.22 5.83 10.17 2.15 6.5 -1.12 1.87 0.37 0.37 v 0 c 1.5 -1.49 6.13 1.79 9.8 5.46 3.67 3.67 7 8.3 5.46 9.8 A 1.38 1.38 0 0 1 14.62 16 Z M 1.08 1.08 c -0.5 0.5 1 3.91 5.46 8.38 4.46 4.47 7.88 6 8.38 5.46 0.5 -0.54 -1 -3.91 -5.46 -8.38 C 5 2.07 1.58 0.58 1.08 1.08 Z M 1.38 16 a 1.4 1.4 0 0 1 -1 -0.37 c -1.49 -1.5 1.78 -6.13 5.46 -9.8 3.68 -3.67 8.3 -6.95 9.8 -5.46 v 0 c 1.49 1.5 -1.78 6.13 -5.46 9.8 C 7.12 13.22 3.41 16 1.38 16 Z M 14.61 1 c -1 0 -4.14 1.61 -8.07 5.55 -4.48 4.47 -6 7.88 -5.46 8.38 0.54 0.5 3.91 -1 8.38 -5.46 4.47 -4.46 6 -7.88 5.46 -8.38 A 0.43 0.43 0 0 0 14.61 1 Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,87 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using DesertPaintCodex.Services; +using DesertPaintCodex.Views; + +namespace DesertPaintCodex +{ + internal class App : Application + { + private WelcomeView? _welcomeView; + private MainWindow? _mainWindow; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + ShowWelcomeView(); + base.OnFrameworkInitializationCompleted(); + } + + public void ReturnToWelcome() + { + ShowWelcomeView(); + CloseMainWindow(); + } + + private void ShowWelcomeView() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + _welcomeView = new WelcomeView(); + _welcomeView.Closed += OnWelcomeViewClosed; + _welcomeView.Show(); + } + + private void ShowMainWindow() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + + desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; + _mainWindow = new MainWindow(); + _mainWindow.Closing += _mainWindow.OnMainWindowClosing; + desktop.MainWindow = _mainWindow; + desktop.MainWindow.Show(); + } + + private void Shutdown() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + desktop.Shutdown(); + } + + private void CloseMainWindow() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + if (_mainWindow != null) + { + desktop.MainWindow.Closing -= _mainWindow.OnMainWindowClosing; + } + desktop.MainWindow.Close(); + _mainWindow = null; + desktop.MainWindow = null; + } + + private void OnWelcomeViewClosed(object? obj, EventArgs args) + { + if (_welcomeView != null) _welcomeView.Closed -= OnWelcomeViewClosed; + _welcomeView = null; + + if (ProfileManager.CurrentProfile == null) + { + Shutdown(); + } + else + { + ShowMainWindow(); + } + } + } +} \ No newline at end of file diff --git a/Assets/desert_paint_codex_icon.ico b/Assets/desert_paint_codex_icon.ico new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..30a6c5de3ae3dd4a9fd83035e8344534266b5dbd GIT binary patch literal 5694 zc%1E++e;K-7>9qJnru?X&0}WfVbwhBdDmUH(9OV%vdHj6(2AA9E}K!3!Iq(vT4YJY z3LzE+c9Y;$r>nXs2%?KFy6q3>59qRY4m-OW)_&uHM9s51vo9Y!-@M=Zy)&=_q|v9n z9dzD``XYe7sHcO5a2@ivga8VLn;_96V9BS*s?0#^PEVm_F#_AbAe`YabaZt7eyqQs`?|0D|F5s&wx{M>TT=8rDf(=s_;-Ep zhWyxAYihn{WBsF!nVIzUe7pGTWYd4=TNw3q^Y>V-wp4txhTC{OA7%&Z@>70(xD$1^ zR^OHAvpqE8uqFD;byso5>&#_~VRyuI3tP`8Vza2a_Ut9h(hL^HJ0?bLE+?{^LX(M3 zdw7xJI!3)xvblmBpji-_f&3C_0=}9_FO+qT{v0<6L=%uD`+PC`)wxQFjiPU)pqgjR zpjOd2%Dz#MXrvV=KA*G(xr$y$af9qPP*}{mg6QSN_XmOkg?pn3B++xCer=*(w@E(I z|LkWb^V1Yxd}Csib#Vi?DSlQmAElhyycM!vfoKc!|quoEZ~{W4$|*#>r6Ag2N2a#ib#yk3pItfnt^fc4 diff --git a/Converters/EnumBooleanConverter.cs b/Converters/EnumBooleanConverter.cs new file mode 100644 --- /dev/null +++ b/Converters/EnumBooleanConverter.cs @@ -0,0 +1,30 @@ + +using System; +using Avalonia.Data.Converters; + +namespace DesertPaintCodex.Converters +{ + public class EnumBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is not Enum en) return false; + + string? parameterString = parameter.ToString(); + if (parameterString == null) return false; + + object parameterValue = Enum.Parse(value.GetType(), parameterString); + return parameterValue.Equals(value); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is not bool) return false; + + string? parameterString = parameter.ToString(); + if (parameterString == null) return false; + + return Enum.Parse(targetType, parameterString); + } + } +} \ No newline at end of file diff --git a/Converters/NotEnumBooleanConverter.cs b/Converters/NotEnumBooleanConverter.cs new file mode 100644 --- /dev/null +++ b/Converters/NotEnumBooleanConverter.cs @@ -0,0 +1,23 @@ +using System; + +namespace DesertPaintCodex.Converters +{ + public class NotEnumBooleanConverter + { + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is not Enum en) return false; + + string? parameterString = parameter.ToString(); + if (parameterString == null) return false; + + object parameterValue = Enum.Parse(value.GetType(), parameterString); + return !parameterValue.Equals(value); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new InvalidOperationException("NotEnumBooleanoConverter can only be used OneWay."); + } + } +} \ No newline at end of file diff --git a/Converters/PaintToBrushConverter.cs b/Converters/PaintToBrushConverter.cs new file mode 100644 --- /dev/null +++ b/Converters/PaintToBrushConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using DesertPaintCodex.Models; + +namespace DesertPaintCodex.Converters +{ + /// + /// Converts PaintColor to a SolidColorBrush. + /// + public class PaintToBrushConverter : IValueConverter + { + private static readonly SolidColorBrush NoColor = new(); + private static readonly Dictionary BrushPool = new(); + + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not PaintColor paintColor) return NoColor; + + if (BrushPool.TryGetValue(paintColor, out SolidColorBrush? brush)) return brush; + + brush = new SolidColorBrush(new Color(0xFF, paintColor.Red, paintColor.Green, paintColor.Blue)); + BrushPool.Add(paintColor, brush); + + return brush; + } + + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not SolidColorBrush brush) return new PaintColor(0, 0, 0); + Color color = brush.Color; + return new PaintColor(color.R, color.G, color.B); + } + } +} \ No newline at end of file diff --git a/Data/colors.txt b/Data/colors.txt new file mode 100644 --- /dev/null +++ b/Data/colors.txt @@ -0,0 +1,182 @@ +#F0F8FF AliceBlue +#9966CC Amethyst +#FAEBD7 AntiqueWhite +#00FFFF Aqua +#7FFFD4 Aquamarine +#F0FFFF Azure +#FF91AF BakerMillerPink +#E3CF57 Banana +#7C0A02 BarnRed +#8E388E Beet +#F5F5DC Beige +#FFE4C4 Bisque +#010101 Black +#FFEBCD BlanchedAlmond +#FF6700 BlazeOrange +#0000FF Blue +#8A2BE2 BlueViolet +#873260 Boysenberry +#FF007F BrightPink +#A52A2A Brown +#800020 BurgundyRed +#DEB887 BurlyWood +#8A360F BurntSienna +#8A3324 BurntUmber +#5F9EA0 CadetBlue +#FF6103 CadmiumOrange +#FF9912 CadmiumYellow +#E07020 Carrot +#7FFF00 Chartreuse +#D2691E Chocolate +#3D59AB CobaltBlue +#3D9140 CobaltGreen +#FF7F50 Coral +#6495ED CornflowerBlue +#FFF8DC Cornsilk +#DC143C Crimson +#00008B DarkBlue +#008B8B DarkCyan +#B8860B DarkGoldenRod +#006400 DarkGreen +#A9A9A9 DarkGrey +#1A2421 DarkJungleGreen +#BDB76B DarkKhaki +#8B008B DarkMagenta +#556B2F DarkOliveGreen +#FF8C00 DarkOrange +#9932CC DarkOrchid +#8B0000 DarkRed +#E9967A DarkSalmon +#560319 DarkScarlet +#8FBC8F DarkSeaGreen +#3C1414 DarkSienna +#483D8B DarkSlateBlue +#2F4F4F DarkSlateGrey +#00CED1 DarkTurquoise +#9400D3 DarkViolet +#FF1493 DeepPink +#00BFFF DeepSkyBlue +#696969 DimGrey +#1E90FF DodgerBlue +#00009C DukeBlue +#FCE6C9 EggshellWhite +#00C957 EmeraldGreen +#D19275 Feldspar +#B22222 FireBrick +#FFFAF0 FloralWhite +#228B22 ForestGreen +#FF00FF Fuchsia +#DCDCDC Gainsboro +#F8F8FF GhostWhite +#FFD700 Gold +#DAA520 GoldenRod +#008000 Green +#ADFF2F GreenYellow +#808080 Grey +#F0FFF0 HoneyDew +#FF69B4 HotPink +#002395 ImperialBlue +#CD5C5C IndianRed +#4B0082 Indigo +#FFFFF0 Ivory +#F0E68C Khaki +#E6E6FA Lavender +#FFF0F5 LavenderBlush +#7CFC00 LawnGreen +#FFFACD LemonChiffon +#1A1110 Licorice +#ADD8E6 LightBlue +#F08080 LightCoral +#E0FFFF LightCyan +#FAFAD2 LightGoldenRodYellow +#90EE90 LightGreen +#D3D3D3 LightGrey +#FFB6C1 LightPink +#FFA07A LightSalmon +#20B2AA LightSeaGreen +#87CEFA LightSkyBlue +#8470FF LightSlateBlue +#778899 LightSlateGrey +#B0C4DE LightSteelBlue +#FFFFE0 LightYellow +#00FF00 Lime +#32CD32 LimeGreen +#FAF0E6 Linen +#800000 Maroon +#66CDAA MediumAquaMarine +#0000CD MediumBlue +#BA55D3 MediumOrchid +#9370DB MediumPurple +#3CB371 MediumSeaGreen +#7B68EE MediumSlateBlue +#00FA9A MediumSpringGreen +#48D1CC MediumTurquoise +#C71585 MediumVioletRed +#E3A869 Melon +#191970 MidnightBlue +#F5FFFA MintCream +#FFE4E1 MistyRose +#FFE4B5 Moccasin +#FFDEAD NavajoWhite +#000080 Navy +#FDF5E6 OldLace +#808000 Olive +#6B8E23 OliveDrab +#FFA500 Orange +#FF4500 OrangeRed +#DA70D6 Orchid +#002147 OxfordBlue +#EEE8AA PaleGoldenRod +#98FB98 PaleGreen +#AFEEEE PaleTurquoise +#DB7093 PaleVioletRed +#FFEFD5 PapayaWhip +#FFDAB9 PeachPuff +#33A1C9 Peacock +#32127A PersianIndigo +#F77FBE PersianPink +#CD853F Peru +#FFC0CB Pink +#DDA0DD Plum +#B0E0E6 PowderBlue +#003153 PrussianBlue +#800080 Purple +#C76114 RawSienna +#FF0000 Red +#860111 RedDevil +#004040 RichBlack +#BC8F8F RosyBrown +#4169E1 RoyalBlue +#9B111E RubyRed +#8B4513 SaddleBrown +#FA8072 Salmon +#F4A460 SandyBrown +#92000A Sangria +#308014 SapGreen +#2E8B57 SeaGreen +#321414 SealBrown +#FFF5EE SeaShell +#A0522D Sienna +#C0C0C0 Silver +#87CEEB SkyBlue +#6A5ACD SlateBlue +#708090 SlateGrey +#100C08 SmokeyBlack +#FFFAFA Snow +#00FF7F SpringGreen +#4682B4 SteelBlue +#CC3366 SteelPink +#D2B48C Tan +#008080 Teal +#D8BFD8 Thistle +#FF6347 Tomato +#40E0D0 Turquoise +#66023C TyrianPurple +#EE82EE Violet +#D02090 VioletRed +#F5DEB3 Wheat +#FFFFFF White +#F5F5F5 WhiteSmoke +#FFFF00 Yellow +#9ACD32 YellowGreen +#0014A8 Zaffre diff --git a/Data/ingredients.txt b/Data/ingredients.txt new file mode 100644 --- /dev/null +++ b/Data/ingredients.txt @@ -0,0 +1,20 @@ +// Desert Paint Lab Ingredients +// Name | Practical Paint Name | RGB values +// These should be kept in the order they show up on the paint bench +// NOTE: EarthLight was replaced with Falcon's Bait in T9. + +Cabbage Juice | Cabbage | 128, 64, 144 +Carrot | Carrot | 224, 112, 32 +Clay | Clay | 128, 96, 32 +Dead Tongue | DeadTongue | 112, 64, 64 +Toad Skin | ToadSkin | 48, 96, 48 +Falcons Bait | FalconsBait | 128, 240, 224 +Red Sand | RedSand | 144, 16, 24 +Lead | Lead | 80, 80, 96 +Silver Powder | Silver | 16, 16, 32 +Iron | Iron | 96, 48, 32 +Copper | Copper | 64, 192, 192 +Sulfur | Sulfur | catalyst +Potash | Potash | catalyst +Lime | Lime | catalyst +Saltpeter | Saltpeter | catalyst diff --git a/Data/template/clips.txt b/Data/template/clips.txt new file mode 100644 --- /dev/null +++ b/Data/template/clips.txt @@ -0,0 +1,1 @@ + \ No newline at end of file diff --git a/Data/template/dp_reactions.txt b/Data/template/dp_reactions.txt new file mode 100644 --- /dev/null +++ b/Data/template/dp_reactions.txt @@ -0,0 +1,1 @@ + \ No newline at end of file diff --git a/Data/template/ingredients.txt b/Data/template/ingredients.txt new file mode 100644 --- /dev/null +++ b/Data/template/ingredients.txt @@ -0,0 +1,21 @@ +// Ingredients are in the form: +// Name | RGB values | cost | enabled (Y/N) | bulk/normal | max items per paint (1-20) +// +// It is recommended to only change the cost value +// It is not recommended to set many of the ingredients above 10 per paint + +Cabbage | 128, 64, 144 | 8 | Y | bulk | 10 +Carrot | 224, 112, 32 | 8 | Y | bulk | 10 +Clay | 128, 96, 32 | 10 | Y | bulk | 20 +DeadTongue | 112, 64, 64 | 500 | Y | normal | 4 +ToadSkin | 48, 96, 48 | 500 | Y | normal | 4 +FalconsBait | 128, 240, 224 | 10000 | Y | normal | 4 +RedSand | 144, 16, 24 | 4 | Y | bulk | 20 +Lead | 80, 80, 96 | 50 | Y | normal | 6 +Silver | 16, 16, 32 | 50 | Y | normal | 6 +Iron | 96, 48, 32 | 30 | Y | normal | 8 +Copper | 64, 192, 192 | 30 | Y | normal | 8 +Sulfur | catalyst | 15 | Y | normal | 1 +Potash | catalyst | 50 | Y | normal | 1 +Lime | catalyst | 20 | Y | normal | 1 +Saltpeter | catalyst | 10 | Y | normal | 1 diff --git a/DesertPaintCodex.csproj b/DesertPaintCodex.csproj new file mode 100644 --- /dev/null +++ b/DesertPaintCodex.csproj @@ -0,0 +1,36 @@ + + + WinExe + net5.0 + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,25 @@ +The MIT License (MIT) +===================== + +Copyright © `2021` `Tess Snider, Jason Maltzen` + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/Models/ClipType.cs b/Models/ClipType.cs new file mode 100644 --- /dev/null +++ b/Models/ClipType.cs @@ -0,0 +1,17 @@ + +using System; + +namespace DesertPaintCodex.Models +{ + [Flags] + public enum ClipType + { + None = 0, + RedLow = 0x01, + GreenLow = 0x02, + BlueLow = 0x04, + RedHigh = 0x08, + GreenHigh = 0x10, + BlueHigh = 0x20 + } +} diff --git a/Models/GeneratorRecipe.cs b/Models/GeneratorRecipe.cs new file mode 100644 --- /dev/null +++ b/Models/GeneratorRecipe.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text; +using JetBrains.Annotations; + +namespace DesertPaintCodex.Models +{ + public class GeneratorRecipe : INotifyPropertyChanged + { + private PaintColor _color; + public PaintColor Color { get => _color; set { _color = value; NotifyPropertyChanged(nameof(Color)); } } + + private string _recipe = ""; + public string Recipe { get => _recipe; set { _recipe = value; NotifyPropertyChanged(nameof(Recipe)); } } + + + public GeneratorRecipe(PaintColor color) + { + _color = color; + } + + public void DraftRecipe(PaintRecipe recipe) + { + StringBuilder sb = new(); + for (int i = 0; i < recipe.Reagents.Count; i++) + { + sb.Append(recipe.Reagents[i].Quantity); + sb.Append(' '); + sb.Append(recipe.Reagents[i].Name); + if (i != recipe.Reagents.Count - 1) + { + sb.Append(", "); + } + } + + Recipe = sb.ToString(); + } + + public void ClearRecipe() + { + Recipe = ""; + } + + + public event PropertyChangedEventHandler? PropertyChanged; + + [NotifyPropertyChangedInvocator] + private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} \ No newline at end of file diff --git a/Models/InterfaceSize.cs b/Models/InterfaceSize.cs new file mode 100644 --- /dev/null +++ b/Models/InterfaceSize.cs @@ -0,0 +1,13 @@ + +namespace DesertPaintCodex.Models +{ + public enum InterfaceSize + { + Tiny = 0, + Small = 1, + Medium = 2, + Large = 3, + Huge = 4, + Count + } +} diff --git a/Models/PaintColor.cs b/Models/PaintColor.cs new file mode 100644 --- /dev/null +++ b/Models/PaintColor.cs @@ -0,0 +1,83 @@ +using System; + +namespace DesertPaintCodex.Models +{ + public class PaintColor + { + private const byte LightTextValueCutoff = 150; + private const float RedIntensity = 0.299f; + private const float GreenIntensity = 0.587f; + private const float BlueIntensity = 0.114f; + + public byte Red { get; set; } + public byte Blue { get; set; } + public byte Green { get; set; } + public string Name { get; private set; } + + public bool UseWhiteText => Red * RedIntensity + Green * GreenIntensity + Blue * BlueIntensity < LightTextValueCutoff; + + public PaintColor() + { + Name = "Undefined"; + Red = 0; + Green = 0; + Blue = 0; + } + + public PaintColor(string name, string hexRed, string hexGreen, string hexBlue) + { + Name = name; + Red = byte.Parse(hexRed, System.Globalization.NumberStyles.AllowHexSpecifier); + Green = byte.Parse(hexGreen, System.Globalization.NumberStyles.AllowHexSpecifier); + Blue = byte.Parse(hexBlue, System.Globalization.NumberStyles.AllowHexSpecifier); + } + + public PaintColor(byte red, byte green, byte blue) + { + Name = "Undefined"; + Red = red; + Green = green; + Blue = blue; + } + + public PaintColor(PaintColor other) + { + Name = other.Name; + Red = other.Red; + Green = other.Green; + Blue = other.Blue; + } + + public int GetDistanceSquared(PaintColor otherColor) + { + return (int)(Math.Pow(Red - otherColor.Red, 2) + + Math.Pow(Green - otherColor.Green, 2) + + Math.Pow(Blue - otherColor.Blue, 2)); + } + + public void Clear() + { + Red = 0; + Green = 0; + Blue = 0; + } + + public void Set(PaintColor other) + { + Red = other.Red; + Green = other.Green; + Blue = other.Blue; + Name = other.Name; + } + + public string ToHexString() + { + return "#" + Red.ToString("X2") + Green.ToString("X2") + Blue.ToString("X2"); + } + + public override string ToString() + { + return "[" + Name + ", " + Red + ", " + Green + ", " + Blue + "]"; + } + } +} diff --git a/Models/PaintRecipe.cs b/Models/PaintRecipe.cs new file mode 100644 --- /dev/null +++ b/Models/PaintRecipe.cs @@ -0,0 +1,343 @@ +using DesertPaintCodex.Services; +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace DesertPaintCodex.Models +{ + public class PaintRecipe + { + public const uint PaintRecipeMinConcentration = 10; + public const uint RibbonRecipeMinConcentration = 50; + + private struct RGB + { + public int R; + public int G; + public int B; + }; + + public class ReagentQuantity + { + public readonly string Name; + public uint Quantity; + + public ReagentQuantity(string name, uint quantity) + { + Name = name; + Quantity = quantity; + } + + public ReagentQuantity(ReagentQuantity other) + { + Name = other.Name; + Quantity = other.Quantity; + } + + public override string ToString() + { + return $"{Name} {Quantity}"; + } + }; + + + private readonly List _recipe = new(); + private readonly List _reagents = new(); + + private readonly PaintColor _reactedColor = new(); + private readonly PaintColor _baseColor = new(); + + private bool _recalculate; + + public PaintRecipe() + { + } + + public PaintRecipe(PaintRecipe other) + { + _recalculate = true; + foreach (string reagentName in other._reagents) + { + _reagents.Add(reagentName); + } + foreach (ReagentQuantity copyReagent in other._recipe) + { + ReagentQuantity ingredient = new(copyReagent); + _recipe.Add(ingredient); + } + } + + public void CopyFrom(PaintRecipe other) + { + _recalculate = true; + _reagents.Clear(); + foreach (string reagentName in other._reagents) + { + _reagents.Add(reagentName); + } + _recipe.Clear(); + foreach (ReagentQuantity otherIngredient in other._recipe) + { + ReagentQuantity ingredient = new(otherIngredient); + _recipe.Add(ingredient); + } + } + + public override string ToString() + { + string result = PaletteService.FindNearest(ReactedColor); + result += " |"; + foreach (ReagentQuantity ri in _recipe) + { + result += " " + ri; + } + return result; + } + + public List Reagents => _recipe; + + public PaintColor ReactedColor + { + get + { + if (!_recalculate) return _reactedColor; + ComputeBaseColor(); + ComputeReactedColor(); + _recalculate = false; + return _reactedColor; + } + } + + public PaintColor BaseColor + { + get + { + if (!_recalculate) return _baseColor; + ComputeBaseColor(); + ComputeReactedColor(); + _recalculate = false; + return _baseColor; + } + } + + public void AddReagent(string reagentName, uint quantity = 1) + { + if (quantity == 0) + { + return; + } + Reagent reagent = ReagentService.GetReagent(reagentName); + + ReagentQuantity? reagentQty = _recipe.Find(x => x.Name.Equals(reagentName)); + if (reagentQty != null) + { + if (!reagent.IsCatalyst) + { + reagentQty.Quantity += quantity; + } + } + else + { + ReagentQuantity newReagentQty = new(reagentName, reagent.IsCatalyst ? 1 : quantity); + _recipe.Add(newReagentQty); + } + _reagents.Add(reagentName); + _recalculate = true; + } + + public void Clear() + { + _reagents.Clear(); + _recipe.Clear(); + _recalculate = true; + } + + private static byte CalculateColor(int baseSum, uint pigmentCount, int reactSum) + { + // Changed to Math.Floor from Math.Round, since Round appears to be incorrect. + return (byte)Math.Max(Math.Min(Math.Floor((((float)baseSum / pigmentCount) + reactSum)), 255), 0); + } + + // Compute the color including reactions based on the player's profile + private void ComputeReactedColor() + { + RGB baseRGB; + baseRGB.R = 0; + baseRGB.G = 0; + baseRGB.B = 0; + RGB reactionColor; + reactionColor.R = 0; + reactionColor.G = 0; + reactionColor.B = 0; + + uint pigmentCount = 0; + + ReactionSet? reactions = ProfileManager.CurrentProfile?.Reactions; + Debug.Assert(reactions != null); + + // track visited reagents so the reaction is only applied once + HashSet reagentSet = new(); + List prevReagents = new(); + + foreach (ReagentQuantity ingredient in _recipe) + { + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) + { + continue; + } + + Reagent reagent = ReagentService.GetReagent(reagentName); + + if (!reagent.IsCatalyst) + { + Debug.Assert(reagent.Color != null); + baseRGB.R += (reagent.Color.Red * (int)ingredient.Quantity); + baseRGB.G += (reagent.Color.Green * (int)ingredient.Quantity); + baseRGB.B += (reagent.Color.Blue * (int)ingredient.Quantity); + pigmentCount += ingredient.Quantity; + } + + if (!reagentSet.Contains(reagentName) && reagentSet.Count <= 4) + { + reagentSet.Add(reagentName); + // Run reactions. + foreach (Reagent otherReagent in prevReagents) + { + Reaction? reaction = reactions.Find(otherReagent, reagent); + if (reaction != null) + { + reactionColor.R += reaction.Red; + reactionColor.G += reaction.Green; + reactionColor.B += reaction.Blue; + } + } + prevReagents.Add(reagent); + } + } + _reactedColor.Red = CalculateColor(baseRGB.R, pigmentCount, reactionColor.R); + _reactedColor.Green = CalculateColor(baseRGB.G, pigmentCount, reactionColor.G); + _reactedColor.Blue = CalculateColor(baseRGB.B, pigmentCount, reactionColor.B); + } + + // Compute the base color without any reactions + private void ComputeBaseColor() + { + RGB baseRGB; + baseRGB.R = 0; + baseRGB.G = 0; + baseRGB.B = 0; + uint pigmentCount = 0; + foreach (ReagentQuantity ingredient in _recipe) + { + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) + { + continue; + } + + Reagent reagent = ReagentService.GetReagent(reagentName); + + if (reagent.IsCatalyst) continue; + + Debug.Assert(reagent.Color != null); + baseRGB.R += (reagent.Color.Red * (int)ingredient.Quantity); + baseRGB.G += (reagent.Color.Green * (int)ingredient.Quantity); + baseRGB.B += (reagent.Color.Blue * (int)ingredient.Quantity); + pigmentCount += ingredient.Quantity; + } + _baseColor.Red = CalculateColor(baseRGB.R, pigmentCount, 0); + _baseColor.Green = CalculateColor(baseRGB.G, pigmentCount, 0); + _baseColor.Blue = CalculateColor(baseRGB.B, pigmentCount, 0); + } + + // Compute the base color without any reactions + public uint Cost + { + get + { + uint cost = 0; + foreach (ReagentQuantity ingredient in _recipe) + { + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) + { + continue; + } + + Reagent reagent = ReagentService.GetReagent(reagentName); + cost += (reagent.Cost * ingredient.Quantity); + } + return cost; + } + } + + public uint GetBulkCost(uint quantity) + { + uint cost = 0; + foreach (ReagentQuantity ingredient in _recipe) + { + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) + { + continue; + } + + Reagent reagent = ReagentService.GetReagent(reagentName); + cost += (reagent.Cost * ingredient.Quantity); + } + uint batchCount = (uint)Math.Ceiling((double)quantity / cost); // number of batches require to make quantity + return batchCount * cost; + } + + public bool IsValidForConcentration(uint concentration) + { + uint weight = 0; + foreach (ReagentQuantity ingredient in _recipe) + { + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) + { + continue; + } + + Reagent reagent = ReagentService.GetReagent(reagentName); + if (!reagent.IsCatalyst) + { + weight += ingredient.Quantity; + } + } + return (weight >= concentration); + } + public bool HasMissingReactions() + { + ReactionSet? reactions = ProfileManager.CurrentProfile?.Reactions; + Debug.Assert(reactions != null); + + HashSet reagentSet = new(); + List prevReagents = new(); + + foreach (ReagentQuantity ingredient in _recipe) + { + if (reagentSet.Count > 4) return false; + + string reagentName = ingredient.Name; + if (string.IsNullOrEmpty(reagentName)) continue; + if (reagentSet.Contains(reagentName)) continue; + reagentSet.Add(reagentName); + + Reagent reagent = ReagentService.GetReagent(reagentName); + + // Find reactions. + foreach (Reagent otherReagent in prevReagents) + { + Reaction? reaction = reactions.Find(otherReagent, reagent); + if (reaction == null) return true; + } + prevReagents.Add(reagent); + } + + return false; + } + } +} diff --git a/Models/PlayerProfile.cs b/Models/PlayerProfile.cs new file mode 100644 --- /dev/null +++ b/Models/PlayerProfile.cs @@ -0,0 +1,612 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using DesertPaintCodex.Util; +using DesertPaintCodex.Services; + +namespace DesertPaintCodex.Models +{ + public class PlayerProfile + { + private const string PaintRecipeFile = "dp_recipes.txt"; + private const string RibbonRecipeFile = "dp_ribbons.txt"; + + private readonly string _reactFile; + private readonly string _settingsFile; + private readonly string _clipFile; + + private static readonly Regex _recipeHeaderRegex = new(@"^--- Recipe: (?(\w*\s)*\w+)\s*"); + private static readonly Regex _recipeIngredientRegex = new(@"(?(\w+\s)?\w+)\s*\|\s*(?\d+)\s*"); + + private Settings ProfileSettings { get; } = new(); + + public string Directory { get; } + + public string Name { get; private set; } + + public ReactionSet Reactions { get; } = new(); + + private Dictionary> Clippers { get; } = new(); + + public string ReagentFile { get; } + + public Dictionary Recipes { get; } = new(); + + public Dictionary RibbonRecipes { get; } = new(); + + public int RecipeCount + { + get + { + int count = 0; + foreach (PaintRecipe recipe in Recipes.Values) + { + if (recipe.IsValidForConcentration(PaintRecipe.PaintRecipeMinConcentration)) + { + ++count; + } + } + return count; + } + } + + public int RibbonCount + { + get + { + int count = 0; + foreach (PaintRecipe recipe in RibbonRecipes.Values) + { + if (recipe.IsValidForConcentration(PaintRecipe.RibbonRecipeMinConcentration)) + { + ++count; + } + } + return count; + } + } + + public PlayerProfile(string name, string directory) + { + Name = name; + Directory = directory; + _reactFile = Path.Combine(directory, "dp_reactions.txt"); + ReagentFile = Path.Combine(directory, "ingredients.txt"); + _settingsFile = Path.Combine(directory, "settings"); + _clipFile = Path.Combine(directory, "clips.txt"); + foreach (PaintColor color in PaletteService.Colors) + { + Recipes.Add(color.Name, new PaintRecipe()); + } + foreach (PaintColor color in PaletteService.Colors) + { + RibbonRecipes.Add(color.Name, new PaintRecipe()); + } + } + + public bool Initialize() + { + // Copy template files into new directory. + string? templatePath = FileUtils.FindApplicationResourceDirectory("template"); + + if (templatePath == null) + { + return false; + } + + // Create new directory. + System.IO.Directory.CreateDirectory(Directory); + + DirectoryInfo di = new(templatePath); + FileInfo[] templateFiles = di.GetFiles(); + + foreach (FileInfo file in templateFiles) + { + string destFile = Path.Combine(Directory, file.Name); + File.Copy(file.FullName, destFile, true); + if (!File.Exists(destFile)) return false; + } + return true; + } + + private static void WriteReaction(TextWriter writer, string reagent1, string reagent2, string r, string g, string b) + { + writer.Write(reagent1); + writer.Write(" "); + writer.Write(reagent2); + writer.Write(" "); + writer.Write(r); + writer.Write(" "); + writer.Write(g); + writer.Write(" "); + writer.WriteLine(b); + } + + public static void ConvertFromPP(string ppFile, string dpFile) + { + using StreamReader reader = new(ppFile); + using StreamWriter writer = new(dpFile); + string? line; + while ((line = reader.ReadLine()) != null) + { + string[] tokens = line.Split('|'); + //if ((tokens.Length > 0) && (tokens [0] != "//")) + if ((tokens.Length != 5) && (tokens[0].Trim() != "//")) + { + string reagent1 = tokens[0].Trim(); + string reagent2 = tokens[1].Trim(); + string colorCode = tokens[2].Trim(); + string change1 = tokens[3].Trim(); + string change2 = tokens[4].Trim(); + // Write reaction. + switch (colorCode) + { + case "W": + WriteReaction(writer, reagent1, reagent2, change1, change1, change1); + WriteReaction(writer, reagent2, reagent1, change2, change2, change2); + break; + case "R": + WriteReaction(writer, reagent1, reagent2, change1, "0", "0"); + WriteReaction(writer, reagent2, reagent1, change2, "0", "0"); + break; + case "G": + WriteReaction(writer, reagent1, reagent2, "0", change1, "0"); + WriteReaction(writer, reagent2, reagent1, "0", change2, "0"); + break; + case "B": + WriteReaction(writer, reagent1, reagent2, "0", "0", change1); + WriteReaction(writer, reagent2, reagent1, "0", "0", change2); + break; + } + } + } + } + + public bool SaveToPP(string ppFile) + { + Reaction? reaction1, reaction2; + using (StreamWriter writer = new(ppFile)) + { + foreach (string reagentName1 in ReagentService.Names) + { + // TODO: could be more efficient by only iterating over the names after reagent1 + foreach (string reagentName2 in ReagentService.Names) + { + if (reagentName1.Equals(reagentName2)) continue; + + Reagent reagent1 = ReagentService.GetReagent(reagentName1); + Reagent reagent2 = ReagentService.GetReagent(reagentName2); + reaction1 = Reactions.Find(reagent1, reagent2); + + if (reaction1 is not {Exported: false}) continue; + + reaction2 = Reactions.Find(reagent2, reagent1); + + if (reaction2 == null) continue; + + writer.Write(reagent1.PracticalPaintName + " | " + reagent2.PracticalPaintName + " | "); + if ((Math.Abs(reaction1.Red) > Math.Abs(reaction1.Green)) || + (Math.Abs(reaction2.Red) > Math.Abs(reaction2.Green))) + { + writer.WriteLine("R | " + reaction1.Red + " | " + reaction2.Red); + } + else if ((Math.Abs(reaction1.Green) > Math.Abs(reaction1.Red)) || + (Math.Abs(reaction2.Green) > Math.Abs(reaction2.Red))) + { + writer.WriteLine("G | " + reaction1.Green + " | " + reaction2.Green); + } + else if ((Math.Abs(reaction1.Blue) > Math.Abs(reaction1.Red)) || + (Math.Abs(reaction2.Blue) > Math.Abs(reaction2.Red))) + { + writer.WriteLine("B | " + reaction1.Blue + " | " + reaction2.Blue); + } + else + { + writer.WriteLine("W | " + reaction1.Red + " | " + reaction2.Red); + } + reaction1.Exported = true; + reaction2.Exported = true; + } + } + } + + // Clear Exported flags. + foreach (string reagentName1 in ReagentService.Names) + { + // TODO: could be more efficient by only iterating over the names after reagent1 + foreach (string reagentName2 in ReagentService.Names) + { + if (reagentName1.Equals(reagentName2)) + { + continue; + } + Reagent reagent1 = ReagentService.GetReagent(reagentName1); + Reagent reagent2 = ReagentService.GetReagent(reagentName2); + reaction1 = Reactions.Find(reagent1, reagent2); + if (reaction1 != null) + { + reaction1.Exported = false; + } + reaction2 = Reactions.Find(reagent2, reagent1); + if (reaction2 != null) + { + reaction2.Exported = false; + } + } + } + return true; + } + + public void ImportFromPP(string importDir) + { + // Convert old file. + ConvertFromPP( + Path.Combine(importDir, "reactions.txt"), + _reactFile); + try + { + // If there is an ingredients file, move it in. + File.Copy( + Path.Combine(importDir, "ingredients.txt"), + Path.Combine(Directory, "ingredients.txt"), + true); + } + catch (Exception) + { + // If there is no ingredients file, we don't really care. + } + } + + public void Import(string file) + { + ZipFile.ExtractToDirectory(file, Directory); + } + + public void Export(string file) + { + ZipFile.CreateFromDirectory(Directory, file); + } + + public bool Load() + { + string? line; + ProfileSettings.Reset(); + ProfileSettings.Load(_settingsFile); + Reactions.Clear(); + if (File.Exists(ReagentFile)) + { + ReagentService.LoadProfileReagents(ReagentFile); + } + else + { + return false; + } + ReagentService.InitializeReactions(Reactions); + if (!File.Exists(_reactFile)) + { + return false; + } + using (StreamReader reader = new(_reactFile)) + { + while ((line = reader.ReadLine()) != null) + { + string[] tokens = line.Split(' '); + if (tokens.Length == 5) + { + Reagent reagent1 = ReagentService.GetReagent(tokens[0].Trim()); + Reagent reagent2 = ReagentService.GetReagent(tokens[1].Trim()); + Reaction reaction = new( + int.Parse(tokens[2].Trim()), + int.Parse(tokens[3].Trim()), + int.Parse(tokens[4].Trim()) + ); + Reactions.Set(reagent1, reagent2, reaction); + } + } + } + + if (!File.Exists(_clipFile)) return true; + { + using StreamReader reader = new StreamReader(_clipFile); + while ((line = reader.ReadLine()) != null) + { + string[] tokens = line.Split(' '); + + if (tokens.Length != 3) continue; + + string reagent1 = tokens[0].Trim(); + if (!Clippers.ContainsKey(reagent1)) + { + Clippers.Add(reagent1, new Dictionary()); + } + Clippers[reagent1][tokens[1].Trim()] = (ClipType)int.Parse(tokens[2].Trim()); + } + } + + return true; + } + + public void Save() + { + ProfileSettings.Save(_settingsFile); + Reaction? reaction; + using (StreamWriter writer = new(_reactFile, false)) + { + foreach (string reagentName1 in ReagentService.Names) + { + // TODO: could be more efficient by only iterating over the names after reagent1 + foreach (string reagentName2 in ReagentService.Names) + { + if (reagentName1.Equals(reagentName2)) + { + continue; + } + Reagent reagent1 = ReagentService.GetReagent(reagentName1); + Reagent reagent2 = ReagentService.GetReagent(reagentName2); + reaction = Reactions.Find(reagent1, reagent2); + if (reaction != null) + { + writer.WriteLine(reagent1.PracticalPaintName + " " + reagent2.PracticalPaintName + " " + + reaction.Red + " " + reaction.Green + " " + reaction.Blue); + } + } + } + } + using (StreamWriter writer = new StreamWriter(_clipFile, false)) + { + foreach (var item1 in Clippers) + { + foreach (var item2 in item1.Value) + { + writer.WriteLine(item1.Key + " " + item2.Key + " " + (int)item2.Value); + } + } + } + } + + public ClipType PairClipStatus(string reagent1, string reagent2) + { + if (Clippers.TryGetValue(reagent1, out var item1)) + { + if (item1.TryGetValue(reagent2, out var clipType)) + { + return clipType; + } + } + return ClipType.None; + } + + private void LoadRecipes(Dictionary recipeDict, string filename, uint concentration) + { + foreach (PaintRecipe recipe in recipeDict.Values) + { + recipe.Clear(); + } + string recipeFile = Path.Combine(Directory, filename); + bool inRecipe = false; + PaintRecipe testRecipe = new(); + string? currentRecipeColor = null; + + if (!File.Exists(recipeFile)) return; + + using StreamReader reader = new(recipeFile); + + string? line; + while ((line = reader.ReadLine()) != null) + { + Match match = _recipeHeaderRegex.Match(line); + if (match.Success) + { + // Store previous recipe. + if ((currentRecipeColor != null) && testRecipe.IsValidForConcentration(concentration)) + { + SetRecipe(currentRecipeColor, testRecipe); + } + testRecipe.Clear(); + currentRecipeColor = match.Groups["colorname"].Value; + inRecipe = true; + } + else if (inRecipe) + { + match = _recipeIngredientRegex.Match(line); + + if (!match.Success) continue; + + string ingredient = match.Groups["ingredient"].Value; + uint quantity = uint.Parse(match.Groups["quantity"].Value); + + testRecipe.AddReagent(ingredient, quantity); + } + } + + if (!inRecipe || (currentRecipeColor == null)) return; + + // Store final recipe. + if (testRecipe.IsValidForConcentration(concentration)) + { + SetRecipe(currentRecipeColor, testRecipe); + } + } + + private void SaveRecipes(Dictionary recipeDict, string filename) + { + string recipeFile = Path.Combine(Directory, filename); + + using StreamWriter writer = new(recipeFile, false); + + foreach (KeyValuePair pair in recipeDict) + { + writer.WriteLine("--- Recipe: {0}", pair.Key); + foreach (PaintRecipe.ReagentQuantity ingredient in pair.Value.Reagents) + { + writer.WriteLine("{0,-14} | {1}", ingredient.Name, ingredient.Quantity); + } + } + } + + private void DeleteRecipes(Dictionary recipeDict, string filename) + { + string recipeFile = Path.Combine(Directory, filename); + + File.Delete(recipeFile); + recipeDict.Clear(); + } + + public void LoadRecipes() + { + LoadRecipes(Recipes, PaintRecipeFile, PaintRecipe.PaintRecipeMinConcentration); + LoadRecipes(RibbonRecipes, RibbonRecipeFile, PaintRecipe.RibbonRecipeMinConcentration); + } + + public void SaveRecipes() + { + SaveRecipes(Recipes, PaintRecipeFile); + SaveRecipes(RibbonRecipes, RibbonRecipeFile); + } + + public void ClearRecipes() + { + DeleteRecipes(Recipes, PaintRecipeFile); + DeleteRecipes(RibbonRecipes, RibbonRecipeFile); + } + + public void ClearPaintRecipes() + { + DeleteRecipes(Recipes, PaintRecipeFile); + } + + public void ClearRibbonRecipes() + { + DeleteRecipes(RibbonRecipes, RibbonRecipeFile); + } + + public void ExportWikiRecipes(string file) + { + StreamWriter writer = new(file); + ExportWikiFormat(writer, Recipes); + } + + public void ExportWikiRibbons(string file) + { + StreamWriter writer = new StreamWriter(file); + ExportWikiFormat(writer, this.RibbonRecipes); + } + + public void ExportWikiRecipes(TextWriter writer) + { + ExportWikiFormat(writer, this.Recipes); + } + + public void ExportWikiRibbons(TextWriter writer) + { + ExportWikiFormat(writer, this.RibbonRecipes); + } + + public static void ExportWikiFormat(TextWriter writer, Dictionary recipeDict) + { + using (writer) + { + writer.WriteLine("{| class='wikitable sortable' border=\"1\" style=\"background-color:#DEB887;\""); + writer.WriteLine("! Color !! Recipe !! Missing Reactions? || Verified"); + foreach (PaintColor color in PaletteService.Colors) + { + writer.WriteLine("|-"); + string colorLine = "| "; + colorLine += "style=\"font-weight: bold; background-color: #" + color.Red.ToString("X2") + color.Green.ToString("X2") + color.Blue.ToString("X2") + ";"; + + if (color.UseWhiteText) + { + // dark color gets light text + colorLine += " color: #FFFFFF;"; + } + else + { + colorLine += "color: #000000;"; + } + colorLine += "\" | " + color.Name + " || "; + if (recipeDict.TryGetValue(color.Name, out PaintRecipe? recipe)) + { + foreach (PaintRecipe.ReagentQuantity ingredient in recipe.Reagents) + { + colorLine += " " + ingredient; + } + } + else + { + // no recipe + } + colorLine += " || "; + + if (recipe == null) + { + colorLine += "?"; + } + else if (recipe.HasMissingReactions()) + { + colorLine += "Y"; + } + else + { + colorLine += "N"; + } + + colorLine += " || N"; + writer.WriteLine(colorLine); + } + writer.WriteLine("|}"); + } + } + + public Reaction? FindReaction(Reagent? reagent1, Reagent? reagent2) + { + if ((reagent1 == null) || (reagent2 == null)) return null; + return Reactions.Find(reagent1, reagent2); + } + + public void SetReaction(Reagent reagent1, Reagent reagent2, Reaction reaction) + { + Reactions.Set(reagent1, reagent2, reaction); + } + + public void ClearReaction(Reagent reagent1, Reagent reagent2) + { + Reactions.Remove(reagent1, reagent2); + } + + public void SetRecipe(PaintRecipe recipe) + { + SetRecipe(PaletteService.FindNearest(recipe.ReactedColor), recipe); + } + + public void SetRecipe(string colorName, PaintRecipe recipe) + { + if (Recipes.TryGetValue(colorName, out PaintRecipe? profileRecipe)) + { + profileRecipe.CopyFrom(recipe); + } + else + { + Recipes.Add(colorName, new PaintRecipe(recipe)); + } + } + + public void SetRibbonRecipe(PaintRecipe recipe) + { + SetRibbonRecipe(PaletteService.FindNearest(recipe.ReactedColor), recipe); + } + + public void SetRibbonRecipe(string colorName, PaintRecipe recipe) + { + if (RibbonRecipes.TryGetValue(colorName, out PaintRecipe? profileRecipe)) + { + profileRecipe.CopyFrom(recipe); + } + else + { + RibbonRecipes.Add(colorName, new PaintRecipe(recipe)); + } + } + } +} diff --git a/Models/Reaction.cs b/Models/Reaction.cs new file mode 100644 --- /dev/null +++ b/Models/Reaction.cs @@ -0,0 +1,22 @@ +namespace DesertPaintCodex.Models +{ + public class Reaction + { + public int Red { get; } + public int Green { get; } + public int Blue { get; } + public bool Exported { get; set; } + + public Reaction(int r, int g, int b) + { + Red = r; + Green = g; + Blue = b; + } + + public override string ToString() + { + return "[" + Red + ", " + Green + ", " + Blue + "]"; + } + } +} diff --git a/Models/ReactionSet.cs b/Models/ReactionSet.cs new file mode 100644 --- /dev/null +++ b/Models/ReactionSet.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace DesertPaintCodex.Models +{ + public class ReactionSet + { + // ingredient -> [ingredient, reaction] + private readonly Dictionary> _reactions = new(); + + public Reaction? Find(Reagent reagent1, Reagent reagent2) + { + Reaction? reaction = null; + _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary? secondReagentDict); + secondReagentDict?.TryGetValue(reagent2.PracticalPaintName, out reaction); + return reaction; + } + + public void Set(Reagent reagent1, Reagent reagent2, Reaction? reaction) + { + _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary? secondReagentDict); + if (secondReagentDict == null) + { + secondReagentDict = new Dictionary(); + _reactions.Add(reagent1.PracticalPaintName, secondReagentDict); + } + secondReagentDict[reagent2.PracticalPaintName] = reaction; + } + + public void Remove(Reagent reagent1, Reagent reagent2) + { + _reactions.TryGetValue(reagent1.PracticalPaintName, out Dictionary? secondReagentDict); + secondReagentDict?.Remove(reagent2.PracticalPaintName); + } + + public void Clear() + { + _reactions.Clear(); + } + } +} diff --git a/Models/ReactionTest.cs b/Models/ReactionTest.cs new file mode 100644 --- /dev/null +++ b/Models/ReactionTest.cs @@ -0,0 +1,293 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using DesertPaintCodex.Services; +using JetBrains.Annotations; + +namespace DesertPaintCodex.Models +{ + public class ReactionTest : INotifyPropertyChanged, IProgress, IComparable + { + public enum TestState + { + Unset = -1, + Untested, + Scanning, + LabNotFound, + ClippedResult, + GoodResult, + Saved + } + + public Reagent Reagent1 { get; } + public Reagent Reagent2 { get; } + + private Reagent? _bufferReagent; + public Reagent? BufferReagent + { + get => _bufferReagent; + set + { + if (_bufferReagent == value) return; + _bufferReagent = value; + _recipe.Clear(); + if (_bufferReagent == null) + { + _recipe.AddReagent(Reagent1.Name); + _recipe.AddReagent(Reagent2.Name); + } + else + { + _recipe.AddReagent(_bufferReagent.Name); + _recipe.AddReagent(Reagent1.Name); + _recipe.AddReagent(Reagent2.Name); + } + NotifyPropertyChanged(nameof(BufferReagent)); + NotifyPropertyChanged(nameof(HypotheticalColor)); + NotifyPropertyChanged(nameof(CanScan)); + } + } + + private ClipType _clipType; + public ClipType Clipped { + get => _clipType; + set + { + _clipType = value; + NotifyPropertyChanged(nameof(Clipped)); + } + } + + public bool IsAllCatalysts { get; } + + private Reaction? _reaction; + public Reaction? Reaction { get => _reaction; set { _reaction = value; NotifyPropertyChanged(nameof(Reaction)); } } + + private Reaction? _badReaction; + public Reaction? BadReaction { get => _badReaction; set { _badReaction = value; NotifyPropertyChanged(nameof(BadReaction)); } } + + private int _scanProgress; + public int ScanProgress { get => _scanProgress; set { _scanProgress = value; NotifyPropertyChanged(nameof(ScanProgress)); } } + + + private TestState _state; + + public TestState State + { + get => _state; + set + { + _state = value; + NotifyPropertyChanged(nameof(State)); + NotifyPropertyChanged(nameof(Requires3Way)); + NotifyPropertyChanged(nameof(CanScan)); + NotifyPropertyChanged(nameof(IsScanning)); + NotifyPropertyChanged(nameof(HasResults)); + NotifyPropertyChanged(nameof(HasReaction)); + NotifyPropertyChanged(nameof(CanClear)); + NotifyPropertyChanged(nameof(CanSave)); + NotifyPropertyChanged(nameof(NoLab)); + } + } + + public bool Requires3Way => + (State == TestState.ClippedResult) || IsAllCatalysts; + + public bool CanScan => (State == TestState.Untested) || (State == TestState.LabNotFound) || + ((State == TestState.ClippedResult) && (BufferReagent != null)); + + public bool IsScanning => State == TestState.Scanning; + + public bool HasResults => State is TestState.ClippedResult or TestState.GoodResult or TestState.LabNotFound; + + public bool HasReaction => State is TestState.ClippedResult or TestState.GoodResult or TestState.Saved; + + public bool CanClear => State is TestState.ClippedResult or TestState.GoodResult or TestState.Saved; + + public bool CanSave => State == TestState.GoodResult; + + public bool NoLab => State == TestState.LabNotFound; + + + public PaintColor? HypotheticalColor => (IsAllCatalysts && (BufferReagent == null)) ? null : _recipe.BaseColor; + + private PaintColor? _observedColor; + public PaintColor? ObservedColor { get => _observedColor; set { _observedColor = value; NotifyPropertyChanged(nameof(ObservedColor)); } } + + private readonly PaintRecipe _recipe = new(); + + public bool IsStub { get; } + + + public ReactionTest(Reagent reagent1, Reagent reagent2, Reaction? reaction, ClipType clipType, bool isStub = false) + { + Reagent1 = reagent1; + Reagent2 = reagent2; + IsAllCatalysts = reagent1.IsCatalyst && reagent2.IsCatalyst; + Clipped = clipType; + Reaction = reaction; + State = (reaction != null) ? TestState.Saved : + (clipType == ClipType.None) ? TestState.Untested : TestState.ClippedResult; + _recipe.AddReagent(reagent1.Name); + _recipe.AddReagent(reagent2.Name); + IsStub = isStub; + } + + + #region Actions + public async Task StartScan() + { + Clipped = ClipType.None; + ScanProgress = 0; + Reaction = null; + BadReaction = null; + State = TestState.Scanning; + bool foundLab = await ReactionScannerService.Instance.CaptureReactionAsync(this); + if (foundLab) + { + ObservedColor = ReactionScannerService.Instance.RecordedColor; + if (_observedColor != null) + { + Clipped = _observedColor.Red switch + { + 0 => ClipType.RedLow, + 255 => ClipType.RedHigh, + _ => ClipType.None + } + | _observedColor.Green switch + { + 0 => ClipType.GreenLow, + 255 => ClipType.GreenHigh, + _ => ClipType.None + } + | _observedColor.Blue switch + { + 0 => ClipType.BlueLow, + 255 => ClipType.BlueHigh, + _ => ClipType.None + }; + + if (Clipped == ClipType.None) + { + State = TestState.GoodResult; + Reaction = CalculateReaction(); + + } + else + { + State = TestState.ClippedResult; + BadReaction = CalculateReaction(); + } + } + } + else + { + Debug.WriteLine("ERROR: Lab UI not found."); + State = TestState.LabNotFound; + } + } + + public void CancelScan() + { + ReactionScannerService.Instance.CancelScan(); + State = TestState.Untested; + } + + public void MarkInert() + { + Reaction = new Reaction(0, 0, 0); + State = TestState.GoodResult; + } + + public void ClearReaction() + { + PlayerProfile? profile = ProfileManager.CurrentProfile; + if (profile == null) return; + profile.Reactions.Remove(Reagent1, Reagent2); + if (State == TestState.Saved) + { + profile.Save(); + } + + Reaction = null; + BadReaction = null; + State = TestState.Untested; + } + + public void SaveReaction() + { + PlayerProfile? profile = ProfileManager.CurrentProfile; + if (profile == null) return; + profile.Reactions.Set(Reagent1, Reagent2, Reaction); + profile.Save(); + State = TestState.Saved; + } + + #endregion + + #region Internals + + private Reaction? CalculateReaction() + { + if (ProfileManager.CurrentProfile == null) return null; + if (HypotheticalColor == null) return null; + if (ObservedColor == null) return null; + + if (Requires3Way) + { + if (BufferReagent == null) return null; + return ReactionScannerService.Calculate3WayReaction(ProfileManager.CurrentProfile, HypotheticalColor, + ObservedColor, BufferReagent, Reagent1, Reagent2); + } + return ReactionScannerService.CalculateReaction(HypotheticalColor, ObservedColor); + } + + + #endregion + + + + #region Interface Implementations + + public event PropertyChangedEventHandler? PropertyChanged; + + [NotifyPropertyChangedInvocator] + private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public void Report(float value) + { + ScanProgress = (int)Math.Round(value * 100); + } + + #endregion + + public int CompareTo(ReactionTest? other) + { + if (other == null) return 1; + + if (Clipped == ClipType.None) + { + if (other.Clipped != ClipType.None) return -1; + } + else if (other.Clipped == ClipType.None) return 1; + + if (IsAllCatalysts) + { + if (!other.IsAllCatalysts) return 1; + } + else if (other.IsAllCatalysts) return -1; + + return string.CompareOrdinal(Reagent1.Name, other.Reagent1.Name) switch + { + < 0 => -1, + > 0 => 1, + _ => string.CompareOrdinal(Reagent2.Name, other.Reagent2.Name) + }; + } + } +} diff --git a/Models/ReactionTestService.cs b/Models/ReactionTestService.cs new file mode 100644 --- /dev/null +++ b/Models/ReactionTestService.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using DesertPaintCodex.Services; +using DynamicData; + +namespace DesertPaintCodex.Models +{ + public static class ReactionTestService + { + private static readonly List _allTests = new(); + + static ReactionTestService() + { + + } + + public static void Initialize() + { + _allTests.Clear(); + PlayerProfile? profile = ProfileManager.CurrentProfile; + + Debug.Assert(profile != null); + + List reagentNames = ReagentService.Names; + + foreach (Reagent reagent1 in reagentNames.Select(ReagentService.GetReagent)) + { + foreach (Reagent reagent2 in reagentNames.Select(ReagentService.GetReagent)) + { + if (reagent1 == reagent2) continue; + + Reaction? reaction = profile.FindReaction(reagent1, reagent2); + ClipType clipType = profile.PairClipStatus(reagent1.Name, reagent2.Name); + + ReactionTest test = new(reagent1, reagent2, reaction, clipType) + { + Clipped = clipType, + Reaction = reaction, + + }; + + _allTests.Add(test); + } + } + } + + public static void PopulateRemainingTests(ObservableCollection collection) + { + collection.Clear(); + collection.AddRange(_allTests.Where(test => test.State != ReactionTest.TestState.Saved).OrderBy(test => test)); + } + + public static void PopulateCompletedTests(ObservableCollection collection) + { + collection.Clear(); + collection.AddRange(_allTests.Where(test => test.State == ReactionTest.TestState.Saved).OrderBy(test => test)); + } + + } +} \ No newline at end of file diff --git a/Models/Reagent.cs b/Models/Reagent.cs new file mode 100644 --- /dev/null +++ b/Models/Reagent.cs @@ -0,0 +1,68 @@ +using System; + +namespace DesertPaintCodex.Models +{ + public class Reagent + { + private uint _cost; + private uint _recipeMax = 10; + + public bool IsCatalyst { get; } + public PaintColor? Color { get; } + public string Name { get; } + public string PracticalPaintName { get; } + public bool Enabled { get; set; } + + public uint Cost + { + get => _cost; + set => _cost = Math.Max(1, value); + } + + public uint RecipeMax + { + get => _recipeMax; + set + { + if (!IsCatalyst) + { + _recipeMax = Math.Max(0, value); + } + } + } + + // catalyst + public Reagent(string name, string ppName) + { + Name = name; + PracticalPaintName = ppName; + Cost = 2; + Enabled = true; + RecipeMax = 1; + IsCatalyst = true; + } + + public Reagent(string name, string ppName, byte red, byte green, byte blue) + { + Color = new PaintColor(red, green, blue); + Name = name; + PracticalPaintName = ppName; + Cost = 1; + RecipeMax = 10; + Enabled = true; + IsCatalyst = false; + } + + public override string ToString() + { + if (IsCatalyst) + { + return "[" + Name + ", catalyst]"; + } + else + { + return "[" + Name + ", " + Color + "]"; + } + } + } +} diff --git a/Models/RecipeItem.cs b/Models/RecipeItem.cs new file mode 100644 --- /dev/null +++ b/Models/RecipeItem.cs @@ -0,0 +1,101 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; + +namespace DesertPaintCodex.Models +{ + public class RecipeItem : INotifyPropertyChanged + { + private const uint PigmentMax = 25; + private Reagent _reagent; + + public Reagent Reagent + { + get => _reagent; + set + { + _reagent = value; + NotifyPropertyChanged(nameof(Reagent)); + } + } + + private uint _quantity; + + public uint Quantity + { + get => _quantity; + set + { + _quantity = value; + NotifyPropertyChanged(nameof(Quantity)); + } + } + + private uint _maxQty; + + public uint MaxQty + { + get => _maxQty; + set + { + _maxQty = value; + NotifyPropertyChanged(nameof(MaxQty)); + } + } + + private bool _first; + + public bool First + { + get => _first; + set + { + _first = value; + NotifyPropertyChanged(nameof(First)); + } + } + + private bool _last; + + public bool Last + { + get => _last; + set + { + _last = value; + NotifyPropertyChanged(nameof(Last)); + } + } + + private bool _unused; + public bool Unused + { + get => _unused; + set + { + _unused = value; + NotifyPropertyChanged(nameof(Unused)); + } + } + + public RecipeItem(Reagent reagent, uint quantity) + { + _reagent = reagent; + Quantity = quantity; + MaxQty = reagent.IsCatalyst ? 1 : PigmentMax; + } + + public override string ToString() + { + return $"{Reagent.Name} {Quantity}"; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + [NotifyPropertyChangedInvocator] + private void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} \ No newline at end of file diff --git a/Models/RecipeSearchNode.cs b/Models/RecipeSearchNode.cs new file mode 100644 --- /dev/null +++ b/Models/RecipeSearchNode.cs @@ -0,0 +1,438 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace DesertPaintCodex.Models +{ + public class RecipeSearchNode + { + private readonly uint[] _reagents; + private readonly uint _invalidReagent; + + private int _nextReagentPos; + public int ReagentCount => _nextReagentPos; + + public uint MinConcentration { get; set; } = 10; + + private readonly bool[] _reagentInUse; + private readonly List _costSortedReagents; + public PaintRecipe? TestRecipe { get; set; } + + public uint CurrentTargetQuantity { get; set; } + public uint MaxConcentration { get; set; } + public uint UsedQuantity { get; private set; } + public uint CatalystCount { get; set; } + public uint FullQuantityDepth { get; set; } + public uint FullQuantity { get; set; } + public uint MinReagents { get; set; } + + private uint _maxReagents; + public uint MaxReagents + { + get => _maxReagents; + set + { + _maxReagents = value; + CurrentWeights = new uint[_maxReagents]; + } + } + + public uint[] CurrentWeights { get; private set; } + + public uint LastReagent => _reagents[_nextReagentPos - 1]; + + public RecipeSearchNode(RecipeSearchNode other) + { + _costSortedReagents = new List(other._costSortedReagents); + _reagents = new uint[_costSortedReagents.Count]; + _invalidReagent = (uint)_costSortedReagents.Count; + for (int i = 0; i < _costSortedReagents.Count; ++i) + { + this._reagents[i] = other._reagents[i]; + } + _reagentInUse = new bool[_costSortedReagents.Count]; + for (uint i = 0; i < _costSortedReagents.Count; ++i) + { + _reagentInUse[i] = other._reagentInUse[i]; + } + _nextReagentPos = other.ReagentCount; + + CurrentTargetQuantity = other.CurrentTargetQuantity; + MaxConcentration = other.MaxConcentration; + UsedQuantity = other.UsedQuantity; + CatalystCount = other.CatalystCount; + FullQuantityDepth = other.FullQuantityDepth; + FullQuantity = other.FullQuantity; + MinReagents = other.MinReagents; + MaxReagents = other.MaxReagents; + CurrentWeights = new uint[MaxReagents]; + for (int i = 0; i < MaxReagents; ++i) + { + CurrentWeights[i] = other.CurrentWeights[i]; + } + } + + public RecipeSearchNode(List costSortedReagents, uint[] reagents) + { + _costSortedReagents = new List(costSortedReagents); + _reagents = new uint[costSortedReagents.Count]; + _invalidReagent = (uint)costSortedReagents.Count; + _nextReagentPos = reagents.Length; + for (int i = _reagents.Length - 1; i >= _reagents.Length; --i) + { + reagents[i] = _invalidReagent; + } + for (int i = reagents.Length - 1; i >= 0; --i) + { + _reagents[i] = reagents[i]; + if (reagents[i] == _invalidReagent) + { + _nextReagentPos = i; + } + } + _reagentInUse = new bool[costSortedReagents.Count]; + for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx) + { + _reagentInUse[reagentIdx] = false; + } + foreach (uint reagentIdx in this._reagents) + { + if (reagentIdx != _invalidReagent) + { + _reagentInUse[reagentIdx] = true; + } + } + + MinReagents = (uint) _nextReagentPos; + MaxReagents = (uint) _nextReagentPos; + CurrentWeights = new uint[MaxReagents]; + UsedQuantity = 0; + } + + // top-level search + public RecipeSearchNode(List costSortedReagents, uint startReagent) + { + _costSortedReagents = new List(costSortedReagents); + _reagents = new uint[costSortedReagents.Count]; + _invalidReagent = (uint)costSortedReagents.Count; + _nextReagentPos = 0; + for (int i = 0; i < _reagents.Length; ++i) + { + _reagents[i] = _invalidReagent; + } + _reagentInUse = new bool[costSortedReagents.Count]; + for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx) + { + _reagentInUse[reagentIdx] = false; + } + _reagents[_nextReagentPos++] = NextFreeReagent(startReagent); + //Console.WriteLine("Added reagent {0} at pos {1}", this.reagents[nextReagentPos-1], nextReagentPos-1); + MinReagents = 1; // don't iterate up beyond the start reagent + MaxReagents = 1; + CurrentWeights = new uint[MaxReagents]; + UsedQuantity = 0; + } + + public RecipeSearchNode(List costSortedReagents) + { + _costSortedReagents = costSortedReagents; + _reagents = new uint[costSortedReagents.Count]; + _invalidReagent = (uint)costSortedReagents.Count; + _nextReagentPos = 0; + for (int i = 0; i < _reagents.Length; ++i) + { + _reagents[i] = _invalidReagent; + } + _reagentInUse = new bool[costSortedReagents.Count]; + for (uint reagentIdx = 0; reagentIdx < costSortedReagents.Count; ++reagentIdx) + { + _reagentInUse[reagentIdx] = false; + } + _reagents[_nextReagentPos++] = NextFreeReagent(0); + MinReagents = 0; + MaxReagents = 1; + CurrentWeights = new uint[MaxReagents]; + UsedQuantity = 0; + } + + public Reagent GetReagent(int idx) + { + return _costSortedReagents[(int)_reagents[idx]]; + } + + public void RemoveLastReagent() + { + uint reagentIdx = _reagents[_nextReagentPos - 1]; + ReleaseReagent(reagentIdx); + if (_costSortedReagents[(int)reagentIdx].IsCatalyst) + { + --CatalystCount; + } + _reagents[_nextReagentPos - 1] = _invalidReagent; + --_nextReagentPos; + } + + public void ReplaceLastReagent(uint reagentIdx) + { + uint oldReagentIdx = _reagents[_nextReagentPos - 1]; + ReleaseReagent(oldReagentIdx); + _reagents[_nextReagentPos - 1] = reagentIdx; + if (_costSortedReagents[(int)oldReagentIdx].IsCatalyst) + { + --CatalystCount; + } + if (_costSortedReagents[(int)reagentIdx].IsCatalyst) + { + ++CatalystCount; + } + } + + public uint NextFreeReagent(uint startIdx) + { + uint idx = startIdx; + for (; idx < _costSortedReagents.Count; ++idx) + { + bool inUse = _reagentInUse[idx]; + if ((inUse == false) && (_costSortedReagents[(int)idx].Enabled)) + { + //Console.WriteLine("Found free reagent idx {0}", idx); + _reagentInUse[idx] = true; + return idx; + } + } + //Console.WriteLine("Failed to find free reagent."); + return (uint)_costSortedReagents.Count; + } + + private void ReleaseReagent(uint reagentIdx) + { + _reagentInUse[reagentIdx] = false; + } + + public bool AddNextReagent() + { + if (ReagentCount >= MaxReagents) return false; + + uint nextReagent = NextFreeReagent(0); + _reagents[_nextReagentPos++] = nextReagent; + if (_costSortedReagents[(int)nextReagent].IsCatalyst) + { + ++CatalystCount; + } + InitForQuantity(CurrentTargetQuantity); + + return true; + } + + public void InitForQuantity(uint quantity) + { + //System.Console.WriteLine("Init for quantity: {0}, reagent count: {1} ({2} catalysts)", quantity, ReagentCount, CatalystCount); + CurrentTargetQuantity = quantity; + if (CurrentTargetQuantity < (MinConcentration + CatalystCount)) + { + // invalid quantity + return; + } + UsedQuantity = 0; + uint remainingReagents = ((uint)_nextReagentPos - CatalystCount); + uint remainingWeight = CurrentTargetQuantity - CatalystCount; + for (int i = 0; i < _nextReagentPos; ++i) + { + Reagent reagent = GetReagent(i); + + if (reagent.IsCatalyst) + { + //Console.WriteLine("Init catalyst {0} weight 1", reagent.Name); + CurrentWeights[i] = 1; + ++UsedQuantity; + } + else + { + uint reagentMaxWeight = reagent.RecipeMax; + if (ReagentCount <= FullQuantityDepth) + { + reagentMaxWeight = Math.Max(FullQuantity, reagentMaxWeight); + } + uint weight = Math.Min(remainingWeight - (remainingReagents-1), reagentMaxWeight); + //Console.WriteLine("Init reagent {0} weight {1}", reagent.Name, weight); + remainingWeight -= weight; + CurrentWeights[i] = weight; + UsedQuantity += weight; + } + --remainingReagents; + } + } + + public void SetWeight(int idx, uint quantity) + { + UsedQuantity -= CurrentWeights[idx]; + CurrentWeights[idx] = quantity; + UsedQuantity += quantity; + } + + public void SaveState(StreamWriter writer) + { + writer.WriteLine("---SearchNode---"); + writer.WriteLine("MinReagents: {0}", MinReagents); + writer.WriteLine("MaxReagents: {0}", MaxReagents); + writer.WriteLine("Reagents: {0}", _nextReagentPos); + for (int i = 0; i < _nextReagentPos; ++i) + { + uint idx = _reagents[i]; + uint weight = CurrentWeights[i]; + writer.WriteLine("Reagent: {0},{1},{2}", idx, _reagentInUse[idx] ? 1 : 0, weight); + } + // pulled from parent: List costSortedReagents; + // new on construct: PaintRecipe testRecipe = null; + writer.WriteLine("CurrentTargetQuantity: {0}", CurrentTargetQuantity); + writer.WriteLine("MinConcentration: {0}", MinConcentration); + writer.WriteLine("MaxConcentration: {0}", MaxConcentration); + writer.WriteLine("UsedQuantity: {0}", UsedQuantity); + writer.WriteLine("CatalystCount: {0}", CatalystCount); + writer.WriteLine("FullQuantity: {0}", FullQuantity); + writer.WriteLine("FullQuantityDepth: {0}", FullQuantityDepth); + writer.WriteLine("---EndNode---"); + + } + + static readonly Regex keyValueRegex = new Regex(@"(\w+)\:\s*(.*)\s*$"); + static readonly Regex reagentPartsRegex = new Regex(@"(?\d+),(?\d+),(?\d+)"); + public bool LoadState(StreamReader reader) + { + string? line = reader.ReadLine(); + + if ((line == null) || !line.Equals("---SearchNode---")) + { + return false; + } + + bool success = true; + while ((line = reader.ReadLine()) != null) + { + if (line.Equals("---EndNode---")) + { + break; + } + Match match = keyValueRegex.Match(line); + if (match.Success) + { + switch (match.Groups[1].Value) + { + case "Reagents": + { + //int reagentCount = int.Parse(match.Groups[2].Value); + for (int i = 0; i < _reagents.Length; ++i) + { + _reagents[i] = _invalidReagent; + _reagentInUse[i] = false; + } + _nextReagentPos = 0; + } + break; + case "Reagent": + { + Match reagentInfo = reagentPartsRegex.Match(match.Groups[2].Value); + if (reagentInfo.Success) + { + uint reagentId = uint.Parse(reagentInfo.Groups["id"].Value); + int isInUse = int.Parse(reagentInfo.Groups["inUse"].Value); + uint weight = uint.Parse(reagentInfo.Groups["weight"].Value); + _reagents[_nextReagentPos] = reagentId; + CurrentWeights[_nextReagentPos] = weight; + if (isInUse != 0) + { + if (reagentId != _invalidReagent) + { + _reagentInUse[reagentId] = true; + } + } + ++_nextReagentPos; + } + else + { + success = false; + } + } + break; + case "CurrentTargetQuantity": + { + uint value = uint.Parse(match.Groups[2].Value); + CurrentTargetQuantity = value; + } + break; + case "MaxQuantity": + { + uint value = uint.Parse(match.Groups[2].Value); + MaxConcentration = value; + } + break; + case "MinConcentration": + { + uint value = uint.Parse(match.Groups[2].Value); + MinConcentration = value; + } + break; + case "MaxConcentration": + { + uint value = uint.Parse(match.Groups[2].Value); + MaxConcentration = value; + } + break; + case "MinReagents": + { + uint value = uint.Parse(match.Groups[2].Value); + MinReagents = value; + } + break; + case "MaxReagents": + { + uint value = uint.Parse(match.Groups[2].Value); + MaxReagents = value; + } + break; + case "UsedQuantity": + { + uint value = uint.Parse(match.Groups[2].Value); + UsedQuantity = value; + } + break; + case "CatalystCount": + { + uint value = uint.Parse(match.Groups[2].Value); + CatalystCount = value; + } + break; + case "InitialCount": + { + uint value = uint.Parse(match.Groups[2].Value); + MinReagents = value; + } + break; + case "FullQuantity": + { + uint value = uint.Parse(match.Groups[2].Value); + FullQuantity = value; + } + break; + case "FullQuantityDepth": + { + uint value = uint.Parse(match.Groups[2].Value); + FullQuantityDepth = value; + } + break; + default: + success = false; + break; + } + } + else + { + success = false; + break; + } + } + return success; + } + } +} \ No newline at end of file diff --git a/Models/ScreenMetrics.cs b/Models/ScreenMetrics.cs new file mode 100644 --- /dev/null +++ b/Models/ScreenMetrics.cs @@ -0,0 +1,19 @@ +using Avalonia; + +namespace DesertPaintCodex.Models +{ + public class ScreenMetrics + { + public PixelRect Bounds { get; set; } + public bool IsPrimary { get; set; } + + public string PositionToStr => $"{Bounds.X}, {Bounds.Y}"; + public string SizeToStr => $"{Bounds.Width}x{Bounds.Height}"; + + public ScreenMetrics(PixelRect bounds, bool isPrimary) + { + Bounds = bounds; + IsPrimary = isPrimary; + } + } +} diff --git a/Models/Settings.cs b/Models/Settings.cs new file mode 100644 --- /dev/null +++ b/Models/Settings.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace DesertPaintCodex.Models +{ + internal class Settings + { + private readonly Dictionary _settings = new(); + + public bool TryGet(string key, out int value) + { + value = 0; + return _settings.TryGetValue(key.ToLower(), out string? valStr) + && int.TryParse(valStr, out value); + } + public bool TryGet(string key, out bool value) + { + value = false; + return _settings.TryGetValue(key.ToLower(), out string? valStr) + && bool.TryParse(valStr, out value); + + } + public void Set(string key, int value) + { + _settings[key.ToLower()] = value.ToString(); + } + public void Set(string key, bool value) + { + _settings[key.ToLower()] = value.ToString(); + } + + public void Reset() + { + _settings.Clear(); + } + + public void Save(string settingsPath) + { + using StreamWriter writer = new(settingsPath); + foreach (KeyValuePair pair in _settings) + { + writer.WriteLine("{0}={1}", pair.Key, pair.Value); + } + } + + private static readonly Regex OptionEntry = new(@"(?[^#=][^=]*)=(?.*)$"); + + public bool Load(string settingsPath) + { + if (!File.Exists(settingsPath)) return false; + using StreamReader reader = new(settingsPath); + string? line; + while ((line = reader.ReadLine()) != null) + { + Match match = OptionEntry.Match(line); + + if (!match.Success) continue; + + string optName = match.Groups["opt"].Value.ToLower(); + string optVal = match.Groups["optval"].Value.Trim(); + if (optName.Equals("debug")) + { + // convert + optName = "enabledebugmenu"; + } + _settings[optName.ToLower()] = optVal; + } + + return true; + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 --- /dev/null +++ b/Program.cs @@ -0,0 +1,23 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.ReactiveUI; + +namespace DesertPaintCodex +{ + 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. + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace() + .UseReactiveUI(); + } +} diff --git a/README.md b/README.md new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Desert Paint Codex + +(Formerly _Desert Paint Lab_) + +This is a tool for players to record their [Pigment Laboratory](https://atitd.wiki/tale10/Pigment_Laboratory) +ingredient reactions in [A Tale in the Desert](http://www.atitd.com/). + +### Latest Releases + +You can find the latest releases at https://desert-paint-lab.malkyne.org/ + +### Features + +* Scans your screen for the Pigment Lab reaction values, without any math, measuring, or manual number entry. +* Warns you when a reaction clipped outside of readable range. +* Supports multiple character profiles. +* Can export to [PracticalPaint](http://www.atitd.org/wiki/tale7/File:PracticalPaint.Zip) data format. +* Includes simulator for experimenting with recipes without spending ingredients. +* Includes a recipe generator + +## Tale 10 + +* Desert Paint Lab is now Desert Paint Codex, and has a whole new UI, for Tale 10. +* Untested reactions are now kept in a list to the left, so you can immediately see what still needs testing. +* The application has not yet been updated for ATITD's new UI. This will be coming soon. + + +## Tale 9 + +* New papyrus texture in interfaces +* Earth Light was replaced by Falcon's Bait mushrooms. Practical Paint still uses the name EarthLight internally, so the data files still use that name here as well. +* macOS is now a 64-bit app. Windows remains 32-bit due to incompatibilities with the UI library. +* macOS build does not support Catalina (is not notarized) + +### Known issues + +* A batcher installed on a Pigment Lab will prevent finding the right part of the screen with the resulting color. + + +## Directions + +### Download Desert Paint Lab + +You can always find the available downloads, on [the download page](https://desert-paint-lab.malkyne.org/). + +The most recent version is [T9 Release 7](https://desert-paint-lab.malkyne.org/win32/DesertPaintLab_T9_V7.zip). For Mac, you can download a [dmg containing a Mac app bundle](https://desert-paint-lab.malkyne.org/macOS/DesertPaintLab_T9_V7.dmg). + +Desert Paint Codex now uses Net Core, instead of Mono. The package should come with everything you need to make it work. + + +### Set Up a Profile + +The first time you run Desert Paint Lab, it will prompt you to make a profile. You may either create a new profile, or import an existing PracticalPaint reactions.txt file. + +### Start Testing! + +Add two ingredients to your Pigment Lab. Select those same ingredients in Desert Paint Lab. Then, with the Pigment Lab dialog unobstructred, select the **Capture** button. Once you are satisfied with the result, click the **Record** button. The data will automatically be added to your profile. + +### Clipped? Huh? + +Occasionally, you will see a warning dialog that informs you that a "Reaction clipped." That means that one or more of the color components moved outside of the testable range. This makes it impossible to calculate the reaction from these two ingredients. + +You can solve this by doing a three-way test. + +1. In your clipped reaction, let's call your **1st** ingredient **A** and your **2nd** ingredient **B**. +1. Select a **non-catalyst** ingredient **C** for which you have already tested **C + A** and **C + B**. It is OK if there was a reaction in those earlier tests. Desert Paint Lab can work out the math. +1. In Desert Paint Lab, select ingredients as follows: + 1. **C** + 2. **A** + 3. **B** +1. Add the ingredients in your Pigment Lab _in exactly that order_. +1. Capture. +1. Record. + +For clipped reactions, your **C** ingredient should be one that corrects for the clipped color. Here are some suggested **C** ingredients for clipping correction: + + +Clip Color | Clip Direction | Correction | Suggested "C" Ingredient +------------|----------------|------------|--------------------------- +Red | Low | +Red | Carrot (224) or Red Sand (144) +Red | High | -Red | Silver Powder (16) or Toad Skin (48) +Green | Low | +Green | Falcon's Bait (240) or Copper (192) +Green | High | -Green | Red Sand (16) or Silver Powder (16) +Blue | Low | +Blue | Falcon's Bait (224) or Copper (192) +Blue | High | -Blue | Red Sand (24) or Clay (32) or Carrot (32) or Silver Powder (32) + + +In many cases, it may be easiest to go back and do these three-way tests after you have finished all of your other testing. + +### Catalysts + +For catalyst to catalyst reaction tests, follow the three-way instructions, as above. + +### Finishing Up + +When you're done testing your reactions, you can either use the built-in Pigment Lab simulator (`Window > Run Simulator`) to experiment with recipes, without dipping into your precious ingredient stocks. Alternatively, you can export your reactions in PracticalPaint format. + +## Generating Recipes + +### What do all these settings do? + +* _Minimum Ingredients_ is the minimum number of ingredients the generator will use in a recipe. It's best to leave this at 1 so the simple single-ingredient recipes are included (like 10 carrots for 'carrot') +* _Maximum Ingredients_ limits how many ingredients will be used in a recipe. Ingredients after the 5th will still affect the resulting color, but won't use their reactions. +* _Silk Ribbon Recipes_ sets the generator up to create recipes for silk ribbons. Ribbons use a base concentration of 50 instead of the 10 that is used for paint. +* _Maximum Concentration_ is the maximum recipe concentration. Recipes with a concentration above 10 will still produce a single db of paint, but can use larger quantities of ingredients to make smaller color adjustments. Increasing this will increase the time required to search all the possible combinations. +* _Full Quantity Depth_ is the number of ingredients in the recipe that will use up to the "full quantity" value as the limit for those ingredients over the ingredient's maximum. +* _Full Quantity_ is the maximum quantity of any ingredient to use up to the Full Quantity Depth. For example, with a Full Quantity of 15 and a Full Quantity Depth of 3, a recipe could use up to 15 of each of the first 3 ingredients. After that it will be limited by the setting for that specific ingredient. + +## Known Issues + +### Slowness + +If you are running on a multi-screen system, or a very high-resolution screen, you may find that Desert Paint Lab is rather slow in determining paint reactions. That's because you have a lot of screen real-estate to scan, to look for the Pigment Lab dialog. You can speed up the scanning process by ensuring that your Pigment Lab Dialog is as far to the upper-left of the screen as possible. Also see the advanced settings (below) for some additional options that may help. + +### Multiple Displays + +When Desert Paint Lab asks for the resolution for a multiple-display setup, enter the combined resolution of all displays. Generally the detected default for this will be correct. For example, two 1920x1080 displays in a side-by-side configuration would be a combined resolution of 3840x1080. In a stacked configuration, they would be 1920x2160. + +### Retina / High-Density Screens + +High DPI screens may be displaying the game at something other than a 1:1 game-pixel to screen-pixel ratio. These screens didn't exist, back when Desert Paint Lab was created. The current version now prompts you for your screen resolution when it starts to work around this. If you see a crash when trying to capture a reaction, it's likely because the resolution is incorrect. For example, Snoerr's MacBook Pro has a retina screen with a resolution of 2880x1800. Mono (and therefore DesertPaintLab) detects it as having a resolution of 1440x900. He enters his resolution as 2880x1800 and sets the Game pixel width in screen pixels to 2. + +## Mac user FAQ + +Q: How do I find my native screen resolution? + +A: You can find it by opening up "About this Mac" from the Apple menu and clicking on the "Displays" tab. That should show the screen size and resolution to enter. + +## Advanced Settings + +The settings file has several advanced options that are not available in the interface. Some of these are for debugging, while others are useful for improving the speed of finding the pigment lab interface. The settings file is found in AppData\Local\DesertPaintLab\settings on Windows, and in ~/.local/share/DesertPaintLab/settings on macOS. + +* _scanarea.min.x_ sets the starting horizontal offset when searching for the pigment lab. This is useful in multi-screen setups to skip any leftmost displays. For example, a dual-screen setup with two side-by-side 3840x2160 displays where the game runs on the right screen would set this to 3840 to bypass scanning the left display. +* _scanarea.min.y_ Like scanarea.min.x, but skips the upper section of display +* _scanarea.max.x_ defines the rightmost edge of the area to scan +* _scanarea.max.y_ defines the bottom edge of the area to scan +* _enabledebugmenu_ enables debug tool menu to help track down problems. This also causes DPL to write debug log files during scanning and recipe generation. This can slow down scanning and recipe searching. +* _debugscreenshot_ automatically saves out a screenshot when searching for the pigment lab to help in debugging +* _logging.verbosity_ sets the verbosity of the logging between 0 and 3. The higher the value, the more detail is included in the log file. + +## For Developers + +This application is written in C#, and it uses the [Avalonia UI Framework](https://avaloniaui.net/). You can learn more about getting set up to use Avalonia [here](https://docs.avaloniaui.net/docs/getting-started/). + +## Older Versions + +The old [Desert Paint Lab repository](https://repos.malkyne.org/ATITD-Tools/Desert-Paint-Lab) is still publicly available, in case anyone wishes to reference the old code, or tinker with it. However, it will no longer be maintained in any way by Afrah. \ No newline at end of file diff --git a/Services/PaletteService.cs b/Services/PaletteService.cs new file mode 100644 --- /dev/null +++ b/Services/PaletteService.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; + +namespace DesertPaintCodex.Services +{ + internal static class PaletteService + { + private static readonly Regex ColorEntry = new(@"\#(?\w\w)(?\w\w)(?\w\w)\s*(?\w+)"); + + public static List Colors { get; } = new(); + + private static bool _initialized = false; + + + public static void Initialize() + { + if (_initialized) return; + + string? colorsPath = FileUtils.FindApplicationResourceFile("colors.txt"); + Debug.Assert(colorsPath != null); + + Load(colorsPath); + } + + public static void Load(string file) + { + using StreamReader reader = new StreamReader(file); + string? line; + while ((line = reader.ReadLine()) != null) + { + Match match = ColorEntry.Match(line); + if (match.Success) + { + Colors.Add(new PaintColor(match.Groups["name"].Value, + match.Groups["red"].Value, + match.Groups["green"].Value, + match.Groups["blue"].Value)); + } + } + } + + public static int Count => Colors.Count; + + public static string FindNearest(PaintColor color) + { + int bestDistSq = int.MaxValue; + PaintColor? bestColor = null; + + foreach (PaintColor paintColor in Colors) + { + int distSq = paintColor.GetDistanceSquared(color); + + if (distSq >= bestDistSq) continue; + + bestDistSq = distSq; + bestColor = paintColor; + } + + Debug.Assert(bestColor != null); + + return bestColor.Name; + } + } +} diff --git a/Services/ProfileManager.cs b/Services/ProfileManager.cs new file mode 100644 --- /dev/null +++ b/Services/ProfileManager.cs @@ -0,0 +1,84 @@ + +using System; +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; +using System.Collections.Generic; +using System.IO; + +namespace DesertPaintCodex.Services +{ + internal static class ProfileManager + { + private static bool _areProfilesLoaded; + private static readonly List _profileList = new(); + + public static PlayerProfile? CurrentProfile { get; private set; } + public static bool HasProfileLoaded => CurrentProfile != null; + + public static List GetProfileList() + { + // If it's already loaded, return the cached list. + if (_areProfilesLoaded) + { + return _profileList; + } + + // Otherwise, load the list. + string appDataPath = FileUtils.AppDataPath; + if (!Directory.Exists(appDataPath)) + { + Directory.CreateDirectory(appDataPath); + } + + DirectoryInfo di = new(appDataPath); + DirectoryInfo[] dirs = di.GetDirectories(); + foreach (DirectoryInfo dir in dirs) + { + if (dir.Name != "template") + { + _profileList.Add(dir.Name); + } + } + + _areProfilesLoaded = true; + return _profileList; + } + + public static PlayerProfile LoadProfile(string name) + { + CurrentProfile = new PlayerProfile(name, Path.Combine(FileUtils.AppDataPath, name)); + CurrentProfile.Load(); + return CurrentProfile; + } + + public static PlayerProfile CreateNewProfile(string name) + { + CurrentProfile = new PlayerProfile(name, Path.Combine(FileUtils.AppDataPath, name)); + CurrentProfile.Initialize(); + // Invalidate profile list, so it will reload next time. + _profileList.Clear(); + _areProfilesLoaded = false; + return CurrentProfile; + } + + public static int GetProfileCount() + { + // This is a function instead of a property, because it may be slow. + List profiles = GetProfileList(); + return profiles.Count; + } + + public static bool HasProfiles() + { + // This is a function instead of a property, because it may be slow. + List profiles = GetProfileList(); + return profiles.Count > 0; + } + + public static void UnloadProfile() + { + CurrentProfile = null; + } + + } +} diff --git a/Services/ReactionScannerService.cs b/Services/ReactionScannerService.cs new file mode 100644 --- /dev/null +++ b/Services/ReactionScannerService.cs @@ -0,0 +1,526 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Drawing; +using System.Drawing.Imaging; +using System.Threading; +using System.Threading.Tasks; +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; + +namespace DesertPaintCodex.Services +{ + public class ReactionScannerService + { + private enum LogVerbosity + { + Low, + Normal, + High, + Excessive + } + + // Swatch is 301x20 solid color, 1px darker left/top, 1px black outer left/top, 1px light right/bottom, 1px bright right/bottom + // top-right and bottom-left are bright instead of black + + // Color bars are 4x302 solid color, 2px border on all sides (darker left/top, lighter bottom/right + + private static readonly int[] SWATCH_HEIGHT = + { + 24, // tiny + 24, // small + 24, // medium + 24, // large + 24 // huge + }; + + private static readonly int[] SWATCH_WIDTH = + { + 306, // tiny + 306, // small + 306, // medium + 320, // large + 350 // huge + }; + + private static readonly int[] COLOR_BAR_WIDTH = + { + 306, // tiny + 306, // small -- includes left and right borders + 306, // medium + 320, // large + 350 // huge + }; + + private static readonly int[] RED_BAR_SPACING = + { + 32, // tiny + 32, // small + 32, // medium + 32, // large + 32 // huge + }; + + private static readonly int[] GREEN_BAR_SPACING = + { + 42, // tiny + 42, // small + 42, // medium + 42, // large + 42 // huge + }; + + private static readonly int[] BLUE_BAR_SPACING = + { + 52, // tiny + 52, // small + 52, // medium + 52, // large + 52 // huge + }; + // width to test on ends of swatch (10% on either end) + private static readonly int[] SWATCH_TEST_WIDTH = + { + 26, // tiny + 26, // small + 26, // medium + 28, // large + 31 // huge + }; + + private static ReactionScannerService? s_instance; + public static ReactionScannerService Instance => s_instance ??= new ReactionScannerService(); + + private int _swatchHeight = SWATCH_HEIGHT[(int)Constants.DefaultInterfaceSize]; + private int _swatchWidth = SWATCH_WIDTH[(int)Constants.DefaultInterfaceSize]; + private int _swatchTestWidth = SWATCH_TEST_WIDTH[(int)Constants.DefaultInterfaceSize]; + private int _colorBarWidth = COLOR_BAR_WIDTH[(int)Constants.DefaultInterfaceSize]; + private int _redBarSpacing = RED_BAR_SPACING[(int)Constants.DefaultInterfaceSize]; + private int _greenBarSpacing = GREEN_BAR_SPACING[(int)Constants.DefaultInterfaceSize]; + private int _blueBarSpacing = BLUE_BAR_SPACING[(int)Constants.DefaultInterfaceSize]; + + // Current Status + public bool IsCaptured { get; private set; } + + private PaintColor _recordedColor = new PaintColor(); + public PaintColor RecordedColor { + get => _recordedColor; + private set => _recordedColor.Set(value); + } + + private int _pixelMultiplier = 1; + + private InterfaceSize _interfaceSize = Constants.DefaultInterfaceSize; + + private bool _firstRun = true; + private int _lastSwatchX = -1; + private int _lastSwatchY = -1; + private int _screenX = 0; + private int _screenY = 0; + + private Bitmap _targetBitmap; + + private LogVerbosity _logVerbosity = LogVerbosity.Normal; + + private CancellationTokenSource? _canceler; + + public StreamWriter? Log { get; set; } + + private void WriteLog(LogVerbosity verbosity, string format, params object[] args) + { + // Dumping this to Debug, for now. + if (verbosity <= _logVerbosity) + { + Debug.WriteLine(format, args); + } + + /* + if ((Log != null) && (verbosity <= _logVerbosity)) + { + Log.WriteLine(format, args); + } + */ + } + + public ReactionScannerService() + { + RefreshFromSettings(); + Debug.Assert(_targetBitmap != null); + } + + public void RefreshFromSettings() + { + SettingsService.Get(SettingKey.PixelMultiplier, out _pixelMultiplier, Constants.DefaultPixelMultiplier); + SettingsService.Get(SettingKey.InterfaceSize, out int interfaceSize, (int)Constants.DefaultInterfaceSize); + if (Enum.IsDefined(typeof(InterfaceSize), interfaceSize)) + { + _interfaceSize = (InterfaceSize)interfaceSize; + } + + SettingsService.Get(SettingKey.ScreenX, out _screenX, Constants.DefaultScreenX); + SettingsService.Get(SettingKey.ScreenY, out _screenY, Constants.DefaultScreenY); + + SettingsService.Get(SettingKey.ScreenWidth, out int screenWidth, Constants.DefaultScreenWidth); + SettingsService.Get(SettingKey.ScreenHeight, out int screenHeight, Constants.DefaultScreenHeight); + + _targetBitmap = new Bitmap(screenWidth, screenHeight); + + UpdateSwatchSizes(); + } + + + private void UpdateSwatchSizes() + { + _swatchHeight = SWATCH_HEIGHT[(int)_interfaceSize] * _pixelMultiplier; + _swatchWidth = SWATCH_WIDTH[(int)_interfaceSize] * _pixelMultiplier; + _colorBarWidth = COLOR_BAR_WIDTH[(int)_interfaceSize] * _pixelMultiplier; + _redBarSpacing = RED_BAR_SPACING[(int)_interfaceSize] * _pixelMultiplier; + _greenBarSpacing = GREEN_BAR_SPACING[(int)_interfaceSize] * _pixelMultiplier; + _blueBarSpacing = BLUE_BAR_SPACING[(int)_interfaceSize] * _pixelMultiplier; + _swatchTestWidth = SWATCH_TEST_WIDTH[(int)_interfaceSize] * _pixelMultiplier; + } + + public void CancelScan() + { + _canceler?.Cancel(); + } + + private static class ColorMatcher + { + public static Color Color; + + public static bool IsMatch(Color otherColor) + { + return PixelColor.IsMatch(Color, otherColor); + } + } + + private bool IsPossibleSwatchSlice(Pixels pixels, int x, int y) + { + // 1.) Check if the top pixel is a dark pixel. + if (!PixelColor.IsDark(pixels.ColorAt(x, y))) return false; + + // 2.) grab the swatch color 2 down from top border + ColorMatcher.Color = pixels.ColorAt(x, y + 2); + + // Scan the column from 2 below the top to 3 above the bottom to ensure the color matches + for (int i = 2; i < (_swatchHeight-3); ++i) + { + if (!pixels.DoesPixelMatch(x, y + i, ColorMatcher.IsMatch)) return false; + } + + return true; + } + + private bool IsPossibleSwatchUpperLeft(Pixels pixels, int x, int y) + { + bool result = true; + + int swatchSolidWidth = _swatchWidth - 4; + int swatchSolidHeight = _swatchHeight - 5; // 2 top and 3 bottom pixels are slightly different colors + int swatchSolidLeftX = x + 2; + int swatchSolidTopY = y + 2; + int swatchSolidRightX = swatchSolidLeftX + swatchSolidWidth - 1; + int swatchSolidBottomY = swatchSolidTopY + swatchSolidHeight - 1; + + Color swatchColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidTopY); + ColorMatcher.Color = swatchColor; + + // Check the other 3 corners of the swatch size for color match + Color testColor = Color.Black; + bool upperRightResult = pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidTopY, ColorMatcher.IsMatch); + if (!upperRightResult) + { + testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidTopY); + WriteLog(LogVerbosity.Excessive, "Upper-right mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidRightX, swatchSolidTopY, swatchColor.R, swatchColor.G, swatchColor.B, x, y); + } + bool lowerLeftResult = pixels.DoesPixelMatch(swatchSolidLeftX, swatchSolidBottomY, ColorMatcher.IsMatch); + if (!lowerLeftResult) + { + testColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidBottomY); + WriteLog(LogVerbosity.Excessive, "Lower-left mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidLeftX, swatchSolidBottomY, swatchColor.R, swatchColor.G, swatchColor.B, x, y); + } + bool lowerRightResult = pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidBottomY, ColorMatcher.IsMatch); + if (!lowerRightResult) + { + testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidBottomY); + WriteLog(LogVerbosity.Excessive, "Lower-right mismatch for {8}, {9} - found {0},{1},{2} at {3}, {4} expected {5},{6},{7}", testColor.R, testColor.G, testColor.B, swatchSolidRightX, swatchSolidBottomY, swatchColor.R, swatchColor.G, swatchColor.B, x, y); + } + + result &= upperRightResult; + result &= lowerLeftResult; + result &= lowerRightResult; + if (!result) + { + // Box corners test failed + WriteLog(LogVerbosity.High, "Failed to find left edge for potential swatch of color {2}, {3}, {4} at {0}, {1}", x, y, swatchColor.R, swatchColor.G, swatchColor.B); + return false; + } + + // scan down the right and left sides + for (int yOff = 1; yOff < (swatchSolidHeight - 1); ++yOff) + { + result &= pixels.DoesPixelMatch(swatchSolidLeftX, swatchSolidTopY + yOff, ColorMatcher.IsMatch); + if (!result) + { + testColor = pixels.ColorAt(swatchSolidLeftX, swatchSolidTopY + yOff); + break; + } + result &= pixels.DoesPixelMatch(swatchSolidRightX, swatchSolidTopY + yOff, ColorMatcher.IsMatch); + if (!result) + { + testColor = pixels.ColorAt(swatchSolidRightX, swatchSolidTopY + yOff); + break; + } + } + if (!result) + { + WriteLog(LogVerbosity.Normal, "Failed to find left/right edges for potential swatch of color {2}, {3}, {4} at {0}, {1} [failed color = {5},{6},{7}]", x, y, swatchColor.R, swatchColor.G, swatchColor.B, testColor.R, testColor.G, testColor.B); + return false; + } + for (int xOff = 1; xOff < (swatchSolidWidth - 1); ++xOff) + { + result &= pixels.DoesPixelMatch(swatchSolidLeftX + xOff, swatchSolidTopY, ColorMatcher.IsMatch); + if (!result) + { + testColor = pixels.ColorAt(swatchSolidLeftX + xOff, swatchSolidTopY); + break; + } + result &= pixels.DoesPixelMatch(swatchSolidLeftX + xOff, swatchSolidBottomY, ColorMatcher.IsMatch); + if (!result) + { + testColor = pixels.ColorAt(swatchSolidLeftX + xOff, swatchSolidBottomY); + break; + } + } + if (!result) + { + WriteLog(LogVerbosity.Normal, "Failed to match upper/lower edges for potential swatch of color {2}, {3}, {4} at {0}, {1} [failed color = {5},{6},{7}]", x, y, swatchColor.R, swatchColor.G, swatchColor.B, testColor.R, testColor.G, testColor.B); + return false; + } + + + // test the left edge for dark pixels -- the bottom-most pixel is bright now + int i = 0; + for (i = 1; result && i < _swatchHeight - 1; ++i) + { + result &= pixels.DoesPixelMatch(x, y + i, PixelColor.IsDark); + } + if (!result) + { + // No dark border on the left side + WriteLog(LogVerbosity.Normal, "Failed to find left border for potential swatch of color {2}, {3}, {4} at {0}, {1}", x, y, swatchColor.R, swatchColor.G, swatchColor.B); + return false; + } + + // test the dark top border and for papyrus above and below the swatch + bool borderError = false; + int papyErrorCount = 0; + for (i = 0; result && (i < _swatchWidth - 1); ++i) + { + bool isBorder = pixels.DoesPixelMatch(x + i, y, PixelColor.IsDark); + result &= isBorder; + if (!isBorder) + { + WriteLog(LogVerbosity.Normal, "Probable swatch at {0},{1} failed upper border test at {2},{3}", x, y, x + i, y); + borderError = true; + } + + // Checking along the top of the swatch for papyrus + // The row just above is shaded, so check 2 above + if (y > 1) + { + bool isPapyrus = pixels.DoesPixelMatch(x + i, y - 2, PixelColor.IsPapyrus); + papyErrorCount += isPapyrus ? 0 : 1; + + } + else + { + ++papyErrorCount; + } + + // Checking along the bottom of the swatch for papyrus + if (y < pixels.Height) + { + bool isPapyrus = pixels.DoesPixelMatch(x + i, y + _swatchHeight, PixelColor.IsPapyrus); + papyErrorCount += isPapyrus ? 0 : 1; + } + else + { + ++papyErrorCount; + } + } + + result &= (papyErrorCount < (_swatchWidth / 20)); // allow up to 5% error rate checking for papy texture, because this seems to be inconsistent + if (!result && ((i > (_swatchWidth*0.8)) || (papyErrorCount >= (_swatchWidth/20)))) + { + if (!borderError && (papyErrorCount < _swatchWidth)) + { + WriteLog(LogVerbosity.Normal, "Found a potential swatch candidate of width {0} at {1},{2} that had {3} failures matching papyrus texture", i, x, y, papyErrorCount); + } + } + + return result; + } + + private bool TestPosition(int x, int y, Pixels pixels, ref PaintColor reactedColor) + { + // Check 4 corners of solid area and left/right solid bar areas + bool foundSwatch = IsPossibleSwatchUpperLeft(pixels, x, y); // ((pixel_r < 0x46) && (pixel_g < 0x46) && (pixel_b < 0x46)); + if (foundSwatch) + { + WriteLog(LogVerbosity.Normal, "Found probable swatch at {0},{1} - checking border slices", x, y); + int borderXOffset = 0; + for (borderXOffset = 2; foundSwatch && (borderXOffset < _swatchTestWidth); ++borderXOffset) + { + foundSwatch &= IsPossibleSwatchSlice(pixels, x + borderXOffset, y); + if (!foundSwatch) + { + WriteLog(LogVerbosity.Normal, "Failed slice test at {0},{1}", x + borderXOffset, y); + break; + } + foundSwatch &= IsPossibleSwatchSlice(pixels, x + _swatchWidth - borderXOffset, y); + if (!foundSwatch) + { + WriteLog(LogVerbosity.Normal, "Failed slice test at {0},{1}", x + _swatchWidth - borderXOffset, y); + break; + } + } + } + + if (!foundSwatch) return false; + // WE FOUND THE SWATCH! + // Now we know where the color bars are. + int redPixelCount = pixels.LengthOfColorAt(x, y + _redBarSpacing, PixelColor.IsRed); + reactedColor.Red = (byte)Math.Round((float)redPixelCount * 255f / (float)_colorBarWidth); + + int greenPixelCount = pixels.LengthOfColorAt(x, y + _greenBarSpacing, PixelColor.IsGreen); + reactedColor.Green = (byte)Math.Round((float)greenPixelCount * 255f / (float)_colorBarWidth); + + int bluePixelCount = pixels.LengthOfColorAt(x, y + _blueBarSpacing, PixelColor.IsBlue); + reactedColor.Blue = (byte)Math.Round((float)bluePixelCount * 255f / (float)_colorBarWidth); + WriteLog(LogVerbosity.Low, "Found the color swatch at {0}, {1}. Color={2} Red={3}px Green={4}px Blue={5}px", x, y, reactedColor, redPixelCount, greenPixelCount, bluePixelCount); + return true; + } + + public async Task CaptureReactionAsync(IProgress progress) + { + _canceler = new CancellationTokenSource(); // You can't re-use these, sadly. + return await Task.Run(() => CaptureReaction(progress, _canceler.Token)); + } + + private bool CaptureReaction(IProgress progress, CancellationToken cancellToken) + { + PaintColor reactedColor = new(); + IsCaptured = false; + _recordedColor.Clear(); + + using (var g = Graphics.FromImage(_targetBitmap)) + { + Debug.WriteLine("Scan starting at [" + _screenX + ", " + _screenY + "]"); + g.CopyFromScreen(_screenX, _screenY, 0, 0, _targetBitmap.Size, CopyPixelOperation.SourceCopy); + } + + _targetBitmap.Save(Path.Combine(ProfileManager.CurrentProfile?.Directory ?? "", "screenshot.png"), ImageFormat.Png); + + Pixels pixels = new(_targetBitmap, _pixelMultiplier); + + IsCaptured = false; + if (!_firstRun) + { + // If this is not the first run, let's check the last location, to see if the UI is still there. + if (TestPosition(_lastSwatchX, _lastSwatchY, pixels, ref reactedColor)) + { + IsCaptured = true; + RecordedColor = reactedColor; + return true; + } + + _firstRun = true; + } + + SettingsService.Get("Log.Verbosity", out var verbosityIdx, 1); + _logVerbosity = (LogVerbosity)verbosityIdx; + + int patchTestSize = ((_swatchHeight - 5) / 2) - 1; + + SettingsService.Get("ScanArea.Min.X", out var startX, 0); + SettingsService.Get("ScanArea.Min.Y", out var startY, 0); + SettingsService.Get("ScanArea.Max.X", out var endX, _targetBitmap.Width); + SettingsService.Get("ScanArea.Max.Y", out var endY, _targetBitmap.Height); + + startX = Math.Max(2, Math.Min(startX, _targetBitmap.Width - 2)); + startY = Math.Max(2, Math.Min(startY, _targetBitmap.Height - 2)); + endX = Math.Min(_targetBitmap.Width - 2, Math.Max(2, endX)) - _colorBarWidth; // + patchTestSize; + endY = Math.Min(_targetBitmap.Height - 2, Math.Max(2, endY)) - (_blueBarSpacing + 10); // + patchTestSize; + + Debug.WriteLine("startX: " + startX + " endX: " + endX + " startY: " + startY + " endY: " + endY); + + int xSpan = endX - startX; + int ySpan = endY - startY; + int total = xSpan * ySpan; + + for (int roughX = startX; roughX < endX ; roughX += patchTestSize) + { + int xMark = roughX - startX; + + for (int roughY = startY; roughY < endY; roughY += patchTestSize) + { + progress.Report((float)((xMark * ySpan) + (roughY - startY)) / total); + + cancellToken.ThrowIfCancellationRequested(); + + if (!pixels.IsSolidPatchAt(roughX, roughY, patchTestSize, patchTestSize)) continue; + Color patchColor = pixels.ColorAt(roughX, roughY); + + WriteLog(LogVerbosity.Excessive, "Found a solid patch of {2},{3},{4} at {0}, {1}", roughX, roughY, patchColor.R, patchColor.G, patchColor.B); + for (int x = Math.Max(0, roughX - patchTestSize); x < roughX; ++x) + { + for (int y = Math.Max(0, roughY - patchTestSize); y < roughY; ++y) + { + WriteLog(LogVerbosity.Excessive, "Searching for potential swatch at {0},{1} after found square at {2},{3}", x, y, roughX, roughY); + + if (!TestPosition(x, y, pixels, ref reactedColor)) continue; + + RecordedColor = reactedColor; + _lastSwatchX = x; + _lastSwatchY = y; + _firstRun = false; + IsCaptured = true; + return true; + } + } + WriteLog(LogVerbosity.Excessive, "False-positive patch of color {0},{1},{2} at {3},{4}", patchColor.R, patchColor.G, patchColor.B, roughX, roughY); + } + } + return false; + } + + + public static Reaction Calculate3WayReaction(PlayerProfile profile, PaintColor expectedColor, PaintColor reactedColor, Reagent reagent0, Reagent reagent1, Reagent reagent2) + { + // A 3-reagent reaction. + Reaction? reaction1 = profile.FindReaction(reagent0, reagent1); + Reaction? reaction2 = profile.FindReaction(reagent0, reagent2); + + Debug.Assert(reaction1 != null); + Debug.Assert(reaction2 != null); + + int r = reactedColor.Red - expectedColor.Red - reaction1.Red - reaction2.Red; + int g = reactedColor.Green - expectedColor.Green - reaction1.Green - reaction2.Green; + int b = reactedColor.Blue - expectedColor.Blue - reaction1.Blue - reaction2.Blue; + + return new Reaction(r, g, b); + } + + + public static Reaction CalculateReaction(PaintColor expectedColor, PaintColor reactedColor) + { + // A 2-reagent reaction. + int r = reactedColor.Red - expectedColor.Red; + int g = reactedColor.Green - expectedColor.Green; + int b = reactedColor.Blue - expectedColor.Blue; + return new Reaction(r, g, b); + } + } +} \ No newline at end of file diff --git a/Services/ReagentService.cs b/Services/ReagentService.cs new file mode 100644 --- /dev/null +++ b/Services/ReagentService.cs @@ -0,0 +1,204 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; + +namespace DesertPaintCodex.Services +{ + internal static class ReagentService + { + // PP format + private static readonly Regex _reagentRegex = new(@"(?\w+)\s*\|\s*(?\d+),\s*(?\d+),\s*(?\d+)\s*\|\s*(?\d+)\s*\|\s*(?[YN])\s*\|\s*(?(bulk|normal))\s*\|\s*(?\d+).*"); + private static readonly Regex _catalystRegex = new(@"(?\w+)\s*\|\s*catalyst\s*\|\s*(?\d+)\s*\|\s*(?[YN])\s*\|\s*(?(bulk|normal)).*"); + private static readonly Regex _internalReagentRegex = new(@"(?(\w*\s)*\w+)\s*\|\s*(?\w+)\s*\|\s*(?\d+),\s*(?\d+),\s*(?\d+).*"); + private static readonly Regex _internalCatalystRegex = new(@"(?(\w+\s)*\w+)\s*\|\s*(?\w+)\s*\|\s*catalyst.*"); + + private static readonly Dictionary _reagents = new(); + private static readonly Dictionary _nameLookup = new(); // pp name to our name + + private static string _lastReagentsFile = string.Empty; + + private static bool _initialized = false; + + public static List Names { get; } = new(); + + + public static void Initialize() + { + if (_initialized) return; + + string? reagentsPath = FileUtils.FindApplicationResourceFile("ingredients.txt"); + Debug.Assert(reagentsPath != null); + + Load(reagentsPath); + } + + // Loads reagent name/colors + public static void Load(string file) + { + if (_lastReagentsFile == file) return; + _lastReagentsFile = file; + + _reagents.Clear(); + _nameLookup.Clear(); + Names.Clear(); + + using StreamReader reader = new(file); + string? line; + while ((line = reader.ReadLine()) != null) + { + Match match = _internalReagentRegex.Match(line); + if (match.Success) + { + string name = match.Groups["name"].Value; + string ppname = match.Groups["ppname"].Value; + _reagents.Add(name, + new Reagent(name, ppname, + byte.Parse(match.Groups["red"].Value), + byte.Parse(match.Groups["green"].Value), + byte.Parse(match.Groups["blue"].Value))); + // nameStore.AppendValues(name); + Names.Add(name); + _nameLookup.Add(ppname, name); + } + else + { + match = _internalCatalystRegex.Match(line); + + if (!match.Success) continue; + + string name = match.Groups["name"].Value; + string ppname = match.Groups["ppname"].Value; + _reagents.Add(name, new Reagent(ppname, ppname)); + // nameStore.AppendValues(name); + Names.Add(name); + _nameLookup.Add(ppname, name); + } + } + } + + public static void LoadProfileReagents(string file) + { + Initialize(); + + using StreamReader reader = new(file); + string? line; + while ((line = reader.ReadLine()) != null) + { + Match match = _reagentRegex.Match(line); + if (match.Success) + { + string ppname = match.Groups["name"].Value; + if (_nameLookup.TryGetValue(ppname, out string? name)) + { + Reagent reagent = GetReagent(name); + if (reagent.IsCatalyst) continue; + reagent.Enabled = match.Groups["enabled"].Value.Equals("Y"); + reagent.Cost = uint.Parse(match.Groups["cost"].Value); + reagent.RecipeMax = uint.Parse(match.Groups["max"].Value); + } + else + { + // bad name? + } + } + else + { + match = _catalystRegex.Match(line); + + if (!match.Success) continue; + + string ppname = match.Groups["name"].Value; + if (_nameLookup.TryGetValue(ppname, out string? name)) + { + Reagent reagent = GetReagent(name); + + if (reagent is not {IsCatalyst: true}) continue; + + reagent.Enabled = match.Groups["enabled"].Value.Equals("Y"); + reagent.Cost = uint.Parse(match.Groups["cost"].Value); + } + else + { + // bad name? + } + } + } + } + + public static void SaveProfileReagents(string file) + { + Initialize(); + + using StreamWriter writer = new(file); + writer.WriteLine("// Ingredients are in the form:"); + writer.WriteLine("// Name | RGB values | cost | enabled (Y/N) | bulk/normal | max items per paint (1-20)"); + writer.WriteLine("//"); + writer.WriteLine("// It is recommended to only change the cost value"); + writer.WriteLine("// It is not recommended to set many of the ingredients above 10 per paint"); + + List sortedReagents = new(_reagents.Count); + sortedReagents.AddRange(_reagents.Values); + sortedReagents.Sort((x, y) => + ((x.IsCatalyst && !y.IsCatalyst) ? + 1 : ((y.IsCatalyst && !x.IsCatalyst) ? + -1 : string.Compare(x.PracticalPaintName, y.PracticalPaintName, StringComparison.InvariantCulture)))); + + foreach (Reagent reagent in sortedReagents) + { + if (!reagent.IsCatalyst) + { + writer.WriteLine("{0,-10} | {1,3}, {2,3}, {3,3} | {4,7} | {5} | {6} | {7}", + reagent.PracticalPaintName, + reagent.Color?.Red, reagent.Color?.Blue, reagent.Color?.Green, + reagent.Cost, + reagent.Enabled ? "Y" : "N", + reagent.RecipeMax >= 10 ? " bulk" : "normal", + reagent.RecipeMax); + } + else + { + writer.WriteLine("{0,-10} | catalyst | {1,7} | {2} | normal | 1", + reagent.PracticalPaintName, + reagent.Cost, + reagent.Enabled ? "Y" : "N"); + } + } + } + + + public static void InitializeReactions(ReactionSet reactions) + { + Initialize(); + foreach (KeyValuePair pair1 in _reagents) + { + foreach (KeyValuePair pair2 in _reagents) + { + if (pair1.Key != pair2.Key) + { + reactions.Set(pair1.Value, pair2.Value, null); + } + } + } + } + + public static Reagent GetReagent(string reagentName) + { + Initialize(); + + if (_reagents.TryGetValue(reagentName, out Reagent? returnVal)) return returnVal; + // convert pp name to our internal name + if (_nameLookup.TryGetValue(reagentName, out string? otherName)) + { + _reagents.TryGetValue(otherName, out returnVal); + } + + Debug.Assert(returnVal != null); + + return returnVal; + } + } +} diff --git a/Services/RecipeGenerator.cs b/Services/RecipeGenerator.cs new file mode 100644 --- /dev/null +++ b/Services/RecipeGenerator.cs @@ -0,0 +1,837 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using DesertPaintCodex.Models; + +namespace DesertPaintCodex.Services +{ + public class NewRecipeEventArgs : EventArgs + { + public NewRecipeEventArgs(string color, PaintRecipe recipe) + { + Color = color; + Recipe = recipe; + } + + public string Color { get; } + + public PaintRecipe Recipe { get; } + } + + public class RecipeGenerator + { + public enum SearchType { + DepthFirst, + BreadthFirst + }; + + public SearchType Mode { get; set; } + + public uint MinConcentration { get; private set; } // minimum paint concentration + public uint MaxConcentration { get; private set; } // maximum paint concentration + public uint MaxReagents { get; private set; } // maximum number of reagents to use in the recipe + public uint MinReagents { get; private set; } // minimum number of reagents to use in the recipe + public uint FullQuantityDepth { get; private set; } // at or equal this number of reagents, ignore ingredient settings for max quantity + public uint FullQuantity { get; private set; } // The max number of a reagent to use at full quantity + + public uint MaxThreads { get; set; } = 15; + + private bool _running = false; + + private int _runningThreads = 0; + + private readonly Dictionary _recipeCosts = new (); + private readonly Dictionary _recipes = new(); + + private uint _totalReagents; + + private readonly List _costSortedReagents = new(); + + private readonly ConcurrentQueue _searchQueue = new(); + + private ulong _recipeCount = 0; + + private readonly List _generatorThreads = new(); + private readonly object _workerLock = new(); + + private bool _requestCancel = false; + + private StreamWriter? _log; + + // events + public event EventHandler? Finished; + public event EventHandler? Progress; + public event EventHandler? NewRecipe; + + public RecipeGenerator() + { + Mode = SearchType.BreadthFirst; + foreach (PaintColor color in PaletteService.Colors) + { + _recipes.Add(color.Name, new PaintRecipe()); + _recipeCosts.Add(color.Name, uint.MaxValue); + } + MinReagents = 1; + MaxReagents = 5; + MinConcentration = 10; + MaxConcentration = 20; + } + + public Dictionary Recipes => _recipes; + + public ulong RecipeCount => _recipeCount; + + public bool CanResume => (!_running && (_searchQueue.Count > 0)); + + public string? Log + { + set => _log = value != null ? new StreamWriter(value) : null; + } + + private class ReagentCostSort : IComparer + { + public int Compare(Reagent? reagent1, Reagent? reagent2) + { + if (reagent1 == null) + { + if (reagent2 == null) return 0; + return -1; + } + if (reagent2 == null) return 1; + + return (int)reagent1.Cost - (int)reagent2.Cost; + } + } + + public void InitRecipes(Dictionary initialRecipes) + { + if (_running) + { + return; + } + _recipeCosts.Clear(); + _recipes.Clear(); + foreach (PaintRecipe recipe in initialRecipes.Values) + { + //PaintRecipe recipeCopy = new PaintRecipe(recipe); + AddCheapestRecipe(recipe); + } + } + + private void InitSortedReagents() + { + _costSortedReagents.Clear(); + foreach (string name in ReagentService.Names) + { + Reagent reagent = ReagentService.GetReagent(name); + _costSortedReagents.Add(reagent); + } + _costSortedReagents.Sort(new ReagentCostSort()); + } + + // Generate paint recipes. + // minConcentration - the minimum permitted concentration in a recipe (10 for paint, 50 for ribbons) + // maxConcentration - the maximum concentration for a recipe + // minReagents - the minimum number of ingredients in a recipe + // maxReagents - the maximum number of ingredients allowed in a recipe + // fullQuantityDepth - at this depth of ingredient or below, allow up to fullQuantity of any reagent + // fullQuantity - the maximum amount of any reagent to permit up to the full quantity depth. After that, reagents are limited by the + // per-reagent value + public void BeginRecipeGeneration(uint minConcentration, uint maxConcentration, uint minReagents, uint maxReagents, uint fullQuantityDepth, uint fullQuantity) + { + if (_running) + { + // Already running - don't start again + return; + } + + MinConcentration = minConcentration; + MaxConcentration = maxConcentration; + MinReagents = minReagents; + MaxReagents = maxReagents; + FullQuantity = fullQuantity; + FullQuantityDepth = fullQuantityDepth; + + // first, sort reagents by cost. + InitSortedReagents(); + + _totalReagents = (uint)_costSortedReagents.Count; + + // Pre-populate recipes list with: + // 1) 1-ingredient recipes @ min concentration for all enabled ingredients with a count >= min concentration + // 2) any previously-generated recipes + int enabledReagentCount = 0; + PaintRecipe recipe = new(); + foreach (var reagent in _costSortedReagents.Where(reagent => reagent.Enabled)) + { + if (!reagent.IsCatalyst && ((reagent.RecipeMax >= minConcentration) || ((FullQuantityDepth > 0) && (FullQuantity >= minConcentration)))) + { + recipe.Clear(); + recipe.AddReagent(reagent.Name, minConcentration); + AddCheapestRecipe(recipe); + } + ++enabledReagentCount; + } + MaxReagents = (uint)Math.Min(enabledReagentCount, MaxReagents); + MinReagents = Math.Min(MinReagents, MaxReagents); + + _searchQueue.Clear(); + + for (uint reagentIdx = 0; reagentIdx < _costSortedReagents.Count; ++reagentIdx) + { + if (!_costSortedReagents[(int) reagentIdx].Enabled) continue; + + // queue up all combinations of MinReagents + RecipeSearchNode initialNode = new(_costSortedReagents, reagentIdx) + { + FullQuantity = FullQuantity, + FullQuantityDepth = FullQuantityDepth, + MinConcentration = minConcentration, + MaxConcentration = maxConcentration, + MinReagents = minReagents, + MaxReagents = maxReagents + }; + + if (MinReagents > 1) + { + while (NextReagentSetBreadthFirst(initialNode, 1, minReagents)) + { + if (initialNode.ReagentCount != minReagents) continue; + + //Console.WriteLine("Initial node at size {0}/{1} with recipe: {2}", initialNode.ReagentCount, minReagents, initialNode.TestRecipe.ToString()); + RecipeSearchNode searchNode = new RecipeSearchNode(initialNode); + _searchQueue.Enqueue(searchNode); + } + } + else + { + _searchQueue.Enqueue(initialNode); + } + } + + _recipeCount = 0; + + _log?.WriteLine("Begin recipe generation: MaxConcentration={0} MinReagents={1} MaxReagents={2} FullQuantity={3} FullQuantityDepth={4}", MaxConcentration, MinReagents, MaxReagents, FullQuantity, FullQuantityDepth); + + // start worker threads to do the actual work + ResumeRecipeGeneration(); + } + + public void ResumeRecipeGeneration() + { + if (_running) + { + // Already running - don't start again + return; + } + _running = true; + _requestCancel = false; + + _log?.WriteLine("Resuming recipe generation: pre-threads={0} reagent count={1} search queue={2}", _runningThreads, _costSortedReagents.Count, _searchQueue.Count); + _runningThreads = 0; // presumably! + + int threadCount = Math.Min(Math.Min(_costSortedReagents.Count, _searchQueue.Count), (int)MaxThreads); + if (threadCount == 0) + { + Finished?.Invoke(this, EventArgs.Empty); + } + _generatorThreads.Clear(); + Console.WriteLine("Starting {0} generator threads.", threadCount); + for (int i = 0; i < threadCount; ++i) + { + Thread thr = new(Generate) {Priority = ThreadPriority.BelowNormal}; + _generatorThreads.Add(thr); + } + foreach (Thread thr in _generatorThreads) + { + thr.Start(); + } + } + + public bool SaveState(string file) + { + if (_running) + { + // can't save state while running + return false; + } + + lock(_workerLock) + { + using StreamWriter writer = new(file, false); + writer.WriteLine("MinReagents: {0}", MinReagents); + writer.WriteLine("MaxReagents: {0}", MaxReagents); + writer.WriteLine("FullQuantityDepth: {0}", FullQuantityDepth); + writer.WriteLine("FullQuantity: {0}", FullQuantity); + writer.WriteLine("TotalReagents: {0}", _totalReagents); + writer.WriteLine("RecipeCount: {0}", _recipeCount); + writer.WriteLine("SearchType: {0}", Mode.ToString()); + foreach (KeyValuePair pair in _recipes) + { + PaintRecipe recipe = pair.Value; + string colorName = PaletteService.FindNearest(recipe.ReactedColor); + writer.WriteLine("BeginRecipe: {0}", colorName); + foreach (PaintRecipe.ReagentQuantity reagent in recipe.Reagents) + { + writer.WriteLine("Ingredient: {0}={1}", reagent.Name, reagent.Quantity); + } + writer.WriteLine("EndRecipe: {0}", colorName); + } + writer.WriteLine("SearchNodes: {0}", _searchQueue.Count); + foreach (RecipeSearchNode node in _searchQueue) + { + node.SaveState(writer); + } + } + return true; + } + + private static readonly Regex _keyValueRegex = new Regex(@"(?\w+)\:\s*(?.+)\s*"); + private static readonly Regex _reagentRegex = new Regex(@"(?(\w+\s)*\w+)\s*=\s*(?\d)\s*"); + + public bool LoadState(string file) + { + // cannot be running, and reactions must be set + if (_running) + { + return false; + } + + InitSortedReagents(); + bool success = true; + + PaintRecipe? currentRecipe = null; + using StreamReader reader = new(file, false); + string? line; + while (success && ((line = reader.ReadLine()) != null)) + { + Match match = _keyValueRegex.Match(line); + if (match.Success) + { + string value = match.Groups["value"].Value; + switch(match.Groups["key"].Value) + { + case "MinReagents": + MinReagents = uint.Parse(value); + MaxReagents = Math.Max(MinReagents, MaxReagents); + break; + case "MaxReagents": + MaxReagents = uint.Parse(value); + MinReagents = Math.Min(MinReagents, MaxReagents); + break; + case "FullQuantityDepth": + FullQuantityDepth = uint.Parse(value); + break; + case "FullQuantity": + FullQuantity = uint.Parse(value); + break; + case "TotalReagents": + _totalReagents = uint.Parse(value); + break; + case "RecipeCount": + if (!ulong.TryParse(value, out _recipeCount)) + { + // must have rolled to negative - try as an int and convert + long recipeCountInt = int.Parse(value); + _recipeCount = (ulong)(recipeCountInt & 0x00000000ffffffffL); + } + break; + case "BeginRecipe": + currentRecipe = new PaintRecipe(); + break; + case "EndRecipe": + if (currentRecipe != null) + { + PaintColor color = currentRecipe.ReactedColor; + uint cost = currentRecipe.Cost; + _recipes[color.Name] = currentRecipe; // replace + _recipeCosts[color.Name] = cost; + currentRecipe = null; + } + break; + case "Ingredient": + if (currentRecipe != null) + { + Match ingredientMatch = _reagentRegex.Match(match.Groups["value"].Value); + if (ingredientMatch.Success) + { + uint quantity = uint.Parse(ingredientMatch.Groups["quantity"].Value); + currentRecipe.AddReagent(ingredientMatch.Groups["ingredient"].Value, quantity); + } + else + { + success = false; + } + } + break; + case "SearchNodes": + int nodeCount = int.Parse(match.Groups["value"].Value); + for (int i = 0; i < nodeCount; ++i) + { + RecipeSearchNode node = new(_costSortedReagents) + { + FullQuantity = FullQuantity, + FullQuantityDepth = FullQuantityDepth, + MinReagents = MinReagents, + MaxReagents = MaxReagents, + MaxConcentration = MaxConcentration + }; + success = success && node.LoadState(reader); + if (success) + { + _searchQueue.Enqueue(node); + } + } + break; + case "SearchType": + Mode = (SearchType)Enum.Parse(typeof(SearchType), match.Groups["value"].Value); + break; + default: + success = false; + break; + } + } + else + { + success = false; + break; + } + } + return success; + } + + private void Generate() + { + lock(_workerLock) + { + ++_runningThreads; + } + + bool ok; + do + { + RecipeSearchNode? node; + lock (_workerLock) + { + ok = _searchQueue.TryDequeue(out node); + } + + if (!ok) continue; + + Debug.Assert(node != null); + + if (Mode == SearchType.DepthFirst) + { + uint targetQuantity = (node.ReagentCount <= FullQuantityDepth) + ? ((uint)node.ReagentCount * FullQuantity) + : node.MaxConcentration + 1; + do { + --targetQuantity; + node.InitForQuantity(targetQuantity); + } while (targetQuantity > MinConcentration && (node.CurrentTargetQuantity != node.UsedQuantity)); + + while ((ok = IterateDepthFirst(node)) && !_requestCancel) + { + Progress?.Invoke(this, EventArgs.Empty); + } + } + else + { + // breadth-first search + uint targetQuantity = MinConcentration - 1; + uint quantityLimit = (node.ReagentCount <= FullQuantityDepth) + ? (FullQuantity * (uint)node.ReagentCount) + : node.MaxConcentration; + do { + ++targetQuantity; + node.InitForQuantity(targetQuantity); + } while ((targetQuantity < quantityLimit) && (node.CurrentTargetQuantity != node.UsedQuantity)); + + while ((ok = IterateBreadthFirst(node)) && !_requestCancel) + { + Progress?.Invoke(this, EventArgs.Empty); + } + } + if (ok) + { + // stopped because cancel was requested - requeue the node in its current state for resume + _searchQueue.Enqueue(node); + } + } while (!_requestCancel && ok); + + bool done; + lock(_workerLock) + { + --_runningThreads; + //generatorThreads.Remove(Thread.CurrentThread); + + done = (_runningThreads == 0); + } + + if (!done) return; + + _running = false; + _requestCancel = false; + Finished?.Invoke(this, EventArgs.Empty); + } + + // Add the cheapest recipe to the recipe list + // returns the discarded recipe from the pair (or null if no original recipe to replace) + private void AddCheapestRecipe(PaintRecipe recipe) + { + if (!recipe.IsValidForConcentration(MinConcentration)) return; + + string colorName = PaletteService.FindNearest(recipe.ReactedColor); + lock (_workerLock) + { + if (_recipeCosts.TryGetValue(colorName, out var cost)) + { + if (cost <= recipe.Cost) return; + + _recipes[colorName].CopyFrom(recipe); + _recipeCosts[colorName] = recipe.Cost; + _log?.WriteLine("New recipe (cost {0}): {1}", recipe.Cost, recipe); + + if (NewRecipe == null) return; + + NewRecipeEventArgs args = new(colorName, recipe); + NewRecipe(this, args); + } + else + { + // This would be an error! + _recipeCosts.Add(colorName, recipe.Cost); + _recipes.Add(colorName, new PaintRecipe(recipe)); + + if (NewRecipe == null) return; + + NewRecipeEventArgs args = new(colorName, recipe); + NewRecipe(this, args); + } + } + } + + private bool IterateDepthFirst(RecipeSearchNode node) + { + TestCurrentRecipe(node); + + // pick recipe quantities at current recipe ingredients/size + if (NextRecipe(node)) + { + lock(_workerLock) + { + ++_recipeCount; + } + //System.Console.WriteLine("Found next recipe at size {0} qty {1}", node.Reagents.Count, node.CurrentTargetQuantity); + return true; + } + + if (NextRecipeSize(node)) + { + //System.Console.WriteLine("Found next recipe size {0}", node.CurrentTargetQuantity); + return true; + } + + // Search for next ingredient combo - all quantity combos for previous were searched + //System.Console.WriteLine("Finding next ingredient combo"); + do + { + if (node.AddNextReagent()) continue; + + while ((node.ReagentCount > node.MinReagents) && (node.LastReagent == (_totalReagents-1))) + { + node.RemoveLastReagent(); + } + if (node.ReagentCount == node.MinReagents) + { + // done + return false; + } + uint nextReagent = node.NextFreeReagent(node.LastReagent); + while ((node.ReagentCount > node.MinReagents) && (nextReagent >= _totalReagents)) + { + // No more reagents to try at this level + node.RemoveLastReagent(); + if (node.ReagentCount > node.MinReagents) + { + nextReagent = node.NextFreeReagent(node.LastReagent); + } + } + if (node.ReagentCount == node.MinReagents) + { + // done + return false; + } + node.ReplaceLastReagent(nextReagent); + } while (node.MaxConcentration < (node.MinConcentration + node.CatalystCount)); + node.InitForQuantity(node.MaxConcentration); + + //string outStr = "{0} : {1} : "; + //for (int i = 0; i < currentReagents.Count; ++i) + //{ + // Reagent reagent = costSortedReagents[(int)currentReagents[i]]; + // if (i > 0) + // { + // outStr += ", "; + // } + // outStr += reagent.Name + " (" + reagent.Cost + ")"; + //} + //Console.WriteLine(outStr, currentReagents.Count, recipeCount); + return true; + } + + private bool IterateBreadthFirst(RecipeSearchNode node) + { + // pick recipe quantities at current recipe ingredients/size + TestCurrentRecipe(node); + lock(_workerLock) + { + ++_recipeCount; + } + + // search all quantities of current recipe + if (NextRecipe(node)) + { + //System.Console.WriteLine("Found next recipe at size {0} qty {1}", node.ReagentCount, node.CurrentTargetQuantity); + return true; + } + + // Try next quantity + uint newQuantity; + uint quantityLimit = ((uint)node.ReagentCount <= FullQuantityDepth) ? ((uint)node.ReagentCount * FullQuantity) : node.MaxConcentration; + do { + newQuantity = node.CurrentTargetQuantity + 1; + //Console.WriteLine("Try quantity {0}", newQuantity); + if (newQuantity > quantityLimit) continue; + + node.InitForQuantity(newQuantity); + if (node.CurrentTargetQuantity <= node.UsedQuantity) + { + //if (log != null) { lock(log) { log.WriteLine("Update quantity to {0}", node.CurrentTargetQuantity); } } + return true; + } + } while (newQuantity < quantityLimit); + + bool ok = NextReagentSetBreadthFirst(node, node.MinReagents, node.MaxReagents); + return ok; + } + + private bool NextReagentSetBreadthFirst(RecipeSearchNode node, uint minReagents, uint maxReagents) + { + // search all variants at this depth of recipe + // increase recipe depth + + // next reagent in last position + // if at end, pop reagent + //Console.WriteLine("Finding new recipe after quantity {0}/{1} used {2}", newQuantity, node.MaxConcentration, node.UsedQuantity); + node.InitForQuantity(node.MinConcentration + node.CatalystCount); // reset quantity + int currentDepth = node.ReagentCount; + bool recipeFound; + do { + //Console.WriteLine("Current depth: {0}/{1}", currentDepth, node.MaxReagents); + do { + recipeFound = false; + + // back out until we find a node that can be incremented + if (currentDepth <= minReagents) continue; + + while (node.ReagentCount > minReagents) + { + if (node.LastReagent < (_totalReagents - 1)) + { + var nextReagent = node.NextFreeReagent(node.LastReagent); + if (nextReagent < _totalReagents) + { + //Console.WriteLine("Replace last reagent with {0}", nextReagent); + node.ReplaceLastReagent(nextReagent); + break; + } + else + { + // shouldn't happen + //Console.WriteLine("No available reagents at depth {0}!", node.ReagentCount); + node.RemoveLastReagent(); + if (node.ReagentCount == minReagents) + { + // just popped the last reagent at the top level + ++currentDepth; + //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1}", currentDepth, node.MaxReagents); } } + } + } + } + else + { + //Console.WriteLine("Pop last reagent"); + node.RemoveLastReagent(); + if (node.ReagentCount == minReagents) + { + // just popped the last reagent at the top level + ++currentDepth; + //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1} [pop last reagent at top level]", currentDepth, node.MaxReagents); } } + } + } + } + // fill in the nodes up to the current depth + if (node.ReagentCount >= minReagents && currentDepth <= maxReagents) + { + recipeFound = true; + while (node.ReagentCount < currentDepth) + { + if (! node.AddNextReagent()) + { + //if (log != null) { lock(log) { log.WriteLine("Failed to add reagent {0}/{1}", node.ReagentCount+1, currentDepth); } } + recipeFound = false; + } + } + } + //Console.WriteLine("Catalysts: {0} Reagents: {1} Min: {2}", node.CatalystCount, node.ReagentCount, node.MinReagents); + } while ((node.CatalystCount >= node.ReagentCount) && (node.ReagentCount >= minReagents)); // make sure to skip all-catalyst combinations + if (recipeFound) + { + break; + } + else + { + ++currentDepth; + //if (log != null) { lock(log) { log.WriteLine("Increased depth to {0}/{1} [no recipe]", currentDepth, node.MaxReagents); } } + } + } while (currentDepth <= maxReagents); + + if (!recipeFound) return false; + + node.InitForQuantity(node.MinConcentration+node.CatalystCount); // minimum quantity for this recipe + + node.TestRecipe ??= new PaintRecipe(); + node.TestRecipe.Clear(); + + for (int i = 0; i < node.ReagentCount; ++i) + { + node.TestRecipe.AddReagent(node.GetReagent(i).Name, node.CurrentWeights[i]); + } + + return recipeFound; + } + + private void TestCurrentRecipe(RecipeSearchNode node) + { + node.TestRecipe ??= new PaintRecipe(); + node.TestRecipe.Clear(); + for (int i = 0; i < node.ReagentCount; ++i) + { + node.TestRecipe.AddReagent(node.GetReagent(i).Name, node.CurrentWeights[i]); + } + AddCheapestRecipe(node.TestRecipe); + //if (log != null) { lock(log) { log.WriteLine("Tested recipe: {0}", node.TestRecipe); } } + } + + private bool NextRecipe(RecipeSearchNode node) + { + // check for the next recipe + uint remainingWeight = node.CurrentTargetQuantity - node.CatalystCount; + if (remainingWeight < MinConcentration) + { + // not possible to make a valid recipe + //Console.WriteLine("Insufficient remaining weight"); + return false; + } + //uint remainingReagents = (uint)node.Reagents.Count - node.CatalystCount; + + uint depth = (uint)node.ReagentCount; + uint weightToConsume = 0; + uint spaceBelow = 0; + int reagentsBelow = 0; + for (int i = (int)depth-1 ; i >= 0; --i) + { + uint currentWeight = node.CurrentWeights[i]; + + if ((spaceBelow >= (weightToConsume+1)) && (currentWeight > 1)) + { + // reduce this node by 1, allocate remaining weight to reagents below it + node.SetWeight(i, currentWeight-1); + weightToConsume += 1; + for (int j = i+1; j < depth; ++j) + { + --reagentsBelow; + Reagent reagent = node.GetReagent(j); + uint allocated = (uint)Math.Min(reagent.IsCatalyst ? 1 : (depth <= FullQuantityDepth ? FullQuantity : reagent.RecipeMax), weightToConsume - reagentsBelow); + if (allocated > 100) + { + Console.WriteLine("ACK: allocated = {0}", allocated); + } + node.SetWeight(j, allocated); + weightToConsume -= allocated; + } + break; + } + else + { + Reagent reagent = node.GetReagent(i); + spaceBelow += (reagent.IsCatalyst ? 1 : (depth <= FullQuantityDepth ? FullQuantity : reagent.RecipeMax)); + weightToConsume += currentWeight; + ++reagentsBelow; + } + } + + //int recipeWeight = 0; + //foreach (int weight in node.CurrentWeights) + //{ + // recipeWeight += weight; + //} + //if ((weightToConsume != 0) || (recipeWeight != node.CurrentTargetQuantity)) + //{ + // Console.WriteLine("Failed recipe with leftover weight {0} ({1}/{2}):", weightToConsume, recipeWeight, node.CurrentTargetQuantity); + // for (int i = 0; i < node.Reagents.Count; ++i) + // { + // Console.WriteLine(" > {0} {1}", node.Reagent(i).Name, node.CurrentWeights[i]); + // } + //} + + return (weightToConsume == 0); + } + + private static bool NextRecipeSize(RecipeSearchNode node) + { + uint newQuantity = node.CurrentTargetQuantity - 1; + if (newQuantity < (node.MinConcentration + node.CatalystCount)) + { + return false; + } + + node.InitForQuantity(newQuantity); + return node.CurrentTargetQuantity <= node.UsedQuantity; + } + + public void Wait() + { + if (_generatorThreads.Count <= 0) return; + + foreach (Thread thr in _generatorThreads) + { + thr.Join(); + } + _generatorThreads.Clear(); + } + + public void Stop() + { + _requestCancel = true; + } + + public void Reset() + { + foreach (PaintRecipe recipe in _recipes.Values) + { + recipe.Clear(); + } + foreach (string key in _recipeCosts.Keys) + { + _recipeCosts[key] = uint.MaxValue; + } + } + } +} \ No newline at end of file diff --git a/Services/SettingKey.cs b/Services/SettingKey.cs new file mode 100644 --- /dev/null +++ b/Services/SettingKey.cs @@ -0,0 +1,13 @@ +namespace DesertPaintCodex.Services +{ + public static class SettingKey + { + public const string InterfaceSize = "interfacesize"; + public const string PixelMultiplier = "pixelmultiplier"; + public const string ScreenIndex = "screenindex"; + public const string ScreenX = "screenx"; + public const string ScreenY = "screeny"; + public const string ScreenWidth = "screenwidth"; + public const string ScreenHeight = "screenheight"; + } +} \ No newline at end of file diff --git a/Services/SettingsService.cs b/Services/SettingsService.cs new file mode 100644 --- /dev/null +++ b/Services/SettingsService.cs @@ -0,0 +1,64 @@ + +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; + +namespace DesertPaintCodex.Services +{ + internal static class SettingsService + { + private static bool _loaded = false; + + private static readonly Settings _settings = new(); + + public static void Get(string key, out int value, int defaultValue) + { + LoadIfNeeded(); + if (!_settings.TryGet(key, out value)) + { + value = defaultValue; + } + } + + public static void Get(string key, out bool value, bool defaultValue) + { + LoadIfNeeded(); + if (!_settings.TryGet(key, out value)) + { + value = defaultValue; + } + } + + public static void Set(string key, int value) + { + LoadIfNeeded(); + _settings.Set(key, value); + } + + public static void Set(string key, bool value) + { + LoadIfNeeded(); + _settings.Set(key, value); + } + + + public static void Save() + { + LoadIfNeeded(); + string settingsPath = System.IO.Path.Combine(FileUtils.AppDataPath, "settings"); + _settings.Save(settingsPath); + } + + public static bool Load() + { + _loaded = true; + string settingsPath = System.IO.Path.Combine(FileUtils.AppDataPath, "settings"); + return _settings.Load(settingsPath); + } + + private static void LoadIfNeeded() + { + if (_loaded) return; + Load(); + } + } +} diff --git a/Util/Constants.cs b/Util/Constants.cs new file mode 100644 --- /dev/null +++ b/Util/Constants.cs @@ -0,0 +1,37 @@ +using DesertPaintCodex.Models; + +namespace DesertPaintCodex.Util +{ + public static class Constants + { + public const InterfaceSize DefaultInterfaceSize = InterfaceSize.Small; + public static int DefaultPixelMultiplier = 1; + public static int DefaultScreenWidth = 1920; + public static int DefaultScreenHeight = 1080; + public static int DefaultScreenX = 0; + public static int DefaultScreenY = 0; + + private static ReactionTest? _stubReactionTest = null; + + public static ReactionTest StubReactionTest + { + get + { + if (_stubReactionTest == null) + { + _stubReactionTest = new( + new Reagent("Toad Skin", "ToadSkin", 48, 96, 48), + new Reagent("Falcons Bait", "FalconsBait", 128, 240, 224), + null, ClipType.None, true); + } + + return _stubReactionTest; + } + + } + + + + + } +} \ No newline at end of file diff --git a/Util/DialogUtil.cs b/Util/DialogUtil.cs new file mode 100644 --- /dev/null +++ b/Util/DialogUtil.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using ReactiveUI; + +namespace DesertPaintCodex.Util +{ + public static class DialogUtil + { + public static async Task ShowDialog( + InteractionContext interaction) where View : Window, new() + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + View dialog = new() {DataContext = interaction.Input}; + ReturnType result = await dialog.ShowDialog(desktop.MainWindow); + interaction.SetOutput(result); + } + } + } +} \ No newline at end of file diff --git a/Util/FileUtils.cs b/Util/FileUtils.cs new file mode 100644 --- /dev/null +++ b/Util/FileUtils.cs @@ -0,0 +1,74 @@ +using System; + +namespace DesertPaintCodex.Util +{ + internal static class FileUtils + { + public static string AppDataPath => + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "DesertPaintCodex"); + + public static string? FindApplicationResourceDirectory(string dirName) + { + return FindApplicationResource(dirName, System.IO.Directory.Exists); + } + + public static string? FindApplicationResourceFile(string fileName) + { + return FindApplicationResource(fileName, System.IO.File.Exists); + } + + private static string? FindApplicationResource(string path, Func verify) + { + string resultPath; + + string? appPath = + System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + + if (appPath != null) + { + resultPath = System.IO.Path.Combine(appPath, path); + if (verify(resultPath)) return resultPath; + + resultPath = System.IO.Path.Combine(appPath, "Data", path); + if (verify(resultPath)) return resultPath; + } + + // try "Resources/data" in case this is a Mac app bundle + resultPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Resources), "data", path); + if (verify(resultPath)) return resultPath; + + // try "Resources" in case this is a Mac app bundle + resultPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Resources), path); + if (verify(resultPath)) return resultPath; + + if (appPath != null) + { + resultPath = System.IO.Path.Combine(appPath, "Resources", "Data", path); + if (verify(resultPath)) return resultPath; + + resultPath = System.IO.Path.Combine(appPath, "Resources", path); + if (verify(resultPath)) return resultPath; + } + + return null; + } + + public static string FindNumberedFile(string baseName, string extension, string folder) + { + string filename = ""; + int i = 0; + do + { + ++i; + filename = System.IO.Path.Combine(folder, $"{baseName}_{i}.{extension}"); + } + while (System.IO.File.Exists(filename)); + + return filename; + } + } +} diff --git a/Util/PixelColor.cs b/Util/PixelColor.cs new file mode 100644 --- /dev/null +++ b/Util/PixelColor.cs @@ -0,0 +1,50 @@ +using System; +using System.Drawing; +using DesertPaintCodex.Services; + +namespace DesertPaintCodex.Util +{ + public class PixelColor + { + private const int ColorTolerance = 3; + + public static bool IsMatch(Color colorA, Color colorB) + { + return ((Math.Abs(colorA.R - colorB.R) <= ColorTolerance) && + (Math.Abs(colorA.G - colorB.G) <= ColorTolerance) && + (Math.Abs(colorA.B - colorB.B) <= ColorTolerance)); + } + + public static bool IsDark(Color color) + { + return (color.R < 0x47) && (color.G < 0x47) && (color.B < 0x47); + } + + public static bool IsPapyrus(Color color) + { + // red between 208 and 244 + // 240 and 255 + // green between 192 and 237 + // 223 and 250 + // blue between 145 and 205 + // 178 and 232 + //return ((r > 0xD0) && (g >= 0xC0) && (b >= 0x91)) && + // ((r < 0xF4) && (g <= 0xED) && (b <= 0xCD)); + return ((color.R >= 0xD0) && (color.R <= 0xFF) && (color.G >= 0xC0) && (color.G <= 0xFA) && (color.B >= 0x91) && (color.B <= 0xE8)); + } + + public static bool IsRed(Color color) + { + return (color.R > 0x9F) && (color.G < 0x70) && (color.B < 0x70); + } + public static bool IsGreen(Color color) + { + return (color.R < 0x70) && (color.G > 0x9F) && (color.B < 0x70); + } + public static bool IsBlue(Color color) + { + return (color.R < 0x70) && (color.G < 0x70) && (color.B > 0x9F); + } + + } +} \ No newline at end of file diff --git a/Util/Pixels.cs b/Util/Pixels.cs new file mode 100644 --- /dev/null +++ b/Util/Pixels.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; +using System.Drawing; + +namespace DesertPaintCodex.Util +{ + public class Pixels + { + private readonly Bitmap _bitmap; + private readonly int _pixelMultiplier; + + public int Height => _bitmap.Height / _pixelMultiplier; + public int Width => _bitmap.Width / _pixelMultiplier; + + public Pixels(Bitmap bitmap, int pixellMutliplier) + { + _bitmap = bitmap; + _pixelMultiplier = pixellMutliplier; + } + + public Color ColorAt(int x, int y) + { + return _bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier); + } + + public bool DoesPixelMatch(int x, int y, Func matchFunc) + { + int xMult = x * _pixelMultiplier; + int yMult = y * _pixelMultiplier; + + if (xMult < 0 || xMult >= _bitmap.Width || yMult < 0 || yMult >= _bitmap.Height) + { + Debug.WriteLine("Problem at position [" + x + ", " + y + "]"); + } + + return matchFunc(_bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier)); + } + + // Compute length of horizontal bar starting at x,y using matching function + public int LengthOfColorAt(int x, int y, Func matchFunc) + { + int count = 0; + for (int xVal = x; xVal < Width; ++xVal) + { + if (!matchFunc(_bitmap.GetPixel(xVal * _pixelMultiplier, y * _pixelMultiplier))) break; + ++count; + } + return count; + } + + public bool IsSolidPatchAt(int x, int y, int patchWidth, int patchHeight) + { + if ((x + patchWidth >= Width) || (y + patchHeight >= Height)) return false; + Color color = _bitmap.GetPixel(x * _pixelMultiplier, y * _pixelMultiplier); + + // Debug.WriteLine("color at {0},{1} = {2},{3},{4}", x, y, color.R, color.G, color.B); + + for (int pX = 0; pX < patchWidth; pX++) + { + for (int pY = 0; pY < patchHeight; pY++) + { + if (!PixelColor.IsMatch(color, _bitmap.GetPixel((x + pX) * _pixelMultiplier, (y + pY) * _pixelMultiplier))) + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/ViewLocator.cs b/ViewLocator.cs new file mode 100644 --- /dev/null +++ b/ViewLocator.cs @@ -0,0 +1,32 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using DesertPaintCodex.ViewModels; + +namespace DesertPaintCodex +{ + internal class ViewLocator : IDataTemplate + { + public bool SupportsRecycling => false; + + public IControl Build(object data) + { + var name = data.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + else + { + return new TextBlock { Text = "Not Found: " + name }; + } + } + + public bool Match(object data) + { + return data is ViewModelBase; + } + } +} \ No newline at end of file diff --git a/ViewModels/AboutViewModel.cs b/ViewModels/AboutViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/AboutViewModel.cs @@ -0,0 +1,21 @@ +using System.Reactive; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class AboutViewModel : ViewModelBase + { + private const string AboutTextLiteral = + @"Desert Paint Codex (formerly Desert Paint Lab) is an Open Source project available for free under the MIT License."; + + private string _aboutText = AboutTextLiteral; + public string AboutText { get => _aboutText; private set => this.RaiseAndSetIfChanged(ref _aboutText, value); } + + public AboutViewModel() + { + CloseDialog = ReactiveCommand.Create(() => { }); + } + + public ReactiveCommand CloseDialog { get; } + } +} \ No newline at end of file diff --git a/ViewModels/CreateProfileViewModel.cs b/ViewModels/CreateProfileViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/CreateProfileViewModel.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Reactive; +using System.Reactive.Linq; +using DesertPaintCodex.Services; +using ReactiveUI; + + +namespace DesertPaintCodex.ViewModels +{ + public class CreateProfileViewModel : ViewModelBase + { + private string _profileName = string.Empty; + public string ProfileName { get => _profileName; set => this.RaiseAndSetIfChanged(ref _profileName, value); } + + private readonly ObservableAsPropertyHelper _duplicateWarning; + public bool DuplicateWarning => _duplicateWarning.Value; + + public CreateProfileViewModel() + { + List profiles = ProfileManager.GetProfileList(); + + // Duplicate warning display logic. + this.WhenAnyValue(x => x.ProfileName) + .Select(profileName => + (!string.IsNullOrWhiteSpace(profileName) && + profiles.Contains(profileName))) + .ToProperty(this, + x => x.DuplicateWarning, + out _duplicateWarning); + + // TODO: Use proper validation to decorate the TextBox, as well. + + // OK button enabling logic. + var okEnabled = this.WhenAnyValue( + x => x.ProfileName, + x => !string.IsNullOrWhiteSpace(x) && + !profiles.Contains(x)); + + // Button commands. + Cancel = ReactiveCommand.Create(() => { }); + Ok = ReactiveCommand.Create(() => { + ProfileManager.CreateNewProfile(_profileName); + }, okEnabled); + } + + public ReactiveCommand Cancel { get; } + public ReactiveCommand Ok { get; } + } +} diff --git a/ViewModels/ExperimentLogViewModel.cs b/ViewModels/ExperimentLogViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/ExperimentLogViewModel.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using ReactiveUI; +using DesertPaintCodex.Services; +using DesertPaintCodex.Models; +using DesertPaintCodex.Util; +using DynamicData.Binding; + +namespace DesertPaintCodex.ViewModels +{ + public class ExperimentLogViewModel : ViewModelBase + { + public ObservableCollection RemainingTests { get; } = new(); + public ObservableCollection CompletedTests { get; } = new(); + + private int _selectedList = 0; + public int SelectedList { get => _selectedList; private set => this.RaiseAndSetIfChanged(ref _selectedList, value); } + + private int _selectedRemainingTest = 0; + public int SelectedRemainingTest { get => _selectedRemainingTest; private set => this.RaiseAndSetIfChanged(ref _selectedRemainingTest, value); } + + private int _selectedCompletedTest = 0; + public int SelectedCompletedTest { get => _selectedCompletedTest; private set => this.RaiseAndSetIfChanged(ref _selectedCompletedTest, value); } + + private ReactionTestViewModel _testView; + public ReactionTestViewModel TestView { get => _testView; private set => this.RaiseAndSetIfChanged(ref _testView, value); } + + private readonly List> _testLists = new(); + + public ExperimentLogViewModel() + { + PlayerProfile? profile = ProfileManager.CurrentProfile; + Debug.Assert(profile != null); + + _testLists.Add(RemainingTests); + _testLists.Add(CompletedTests); + + ReactionTestService.PopulateRemainingTests(RemainingTests); + ReactionTestService.PopulateCompletedTests(CompletedTests); + + // If we have no remaining tests, switch to the completed list. + _testView = new ReactionTestViewModel(); + if (RemainingTests.Count > 0) + { + SelectedList = 0; + _testView.ReactionTest = RemainingTests[0]; + } + else + { + SelectedList = 1; + _testView.ReactionTest = CompletedTests[0]; + } + + this.WhenAnyPropertyChanged(nameof(SelectedList), nameof(SelectedRemainingTest), nameof(SelectedCompletedTest)) + .Subscribe(_ => _testView.ReactionTest = GetSelectedReactionTest()); + + _testView.ReactionTest = GetSelectedReactionTest(); + + TestView.SaveReaction.Subscribe(_ => OnSaveReaction()); + TestView.ClearReaction.Subscribe(_ => OnClearReaction()); + } + + private ReactionTest GetSelectedReactionTest() + { + int itemIndex = (SelectedList == 0) ? SelectedRemainingTest : SelectedCompletedTest; + if (itemIndex < 0) return Constants.StubReactionTest; + var list = _testLists[SelectedList]; + return (itemIndex >= list.Count) ? Constants.StubReactionTest : list[itemIndex]; + } + + private void OnSaveReaction() + { + ReactionTest test = TestView.ReactionTest; + int oldPos = RemainingTests.IndexOf(test); + + // Move test to Completed Tests. + InsertTestIntoList(test, CompletedTests); + RemainingTests.RemoveAt(oldPos); + + // Select next Remaining Test. + if (RemainingTests.Count == 0) return; + SelectedRemainingTest = (oldPos == RemainingTests.Count) ? RemainingTests.Count - 1 : oldPos; + + // If we have just inserted our first completed test, select it. + if (CompletedTests.Count == 1) + { + SelectedCompletedTest = 0; + } + } + + private void OnClearReaction() + { + ReactionTest test = TestView.ReactionTest; + if (!CompletedTests.Contains(test)) return; + + int oldPos = CompletedTests.IndexOf(test); + + // Move test to Remaining Tests. + InsertTestIntoList(test, RemainingTests); + CompletedTests.RemoveAt(oldPos); + + // Select next Completed Test. + if (CompletedTests.Count == 0) return; + SelectedCompletedTest = (oldPos == CompletedTests.Count) ? CompletedTests.Count - 1 : oldPos; + + // If we have just inserted our first remaining test, select it. + if (RemainingTests.Count == 1) + { + SelectedRemainingTest = 0; + } + } + + private static void InsertTestIntoList(ReactionTest test, IList list) + { + int i; + for (i = 0; i < list.Count; i++) + { + if (test.CompareTo(list[i]) < 0) break; + } + list.Insert(i, test); + } + } +} diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + private string _statusText = string.Empty; + public string StatusText { get => _statusText; private set => this.RaiseAndSetIfChanged(ref _statusText, value); } + + private static readonly List ZipFileExtensions = new() { $"*.zip;" }; + private static readonly FileDialogFilter ZipDialogFilter = new() {Extensions = ZipFileExtensions}; + private static readonly List ZipDialogFilters = new() { ZipDialogFilter }; + private static readonly List NoFilters = new(); + + public MainWindowViewModel() + { + if (!ProfileManager.HasProfileLoaded && ProfileManager.HasProfiles()) + { + ProfileManager.LoadProfile(ProfileManager.GetProfileList()[0]); + } + Debug.Assert(ProfileManager.HasProfileLoaded); + + PaletteService.Initialize(); + ReactionTestService.Initialize(); + + ShowAboutDialog = new Interaction(); + ShowScreenSettingsDialog = new Interaction(); + + StatusText = "USER PROFILE: " + ProfileManager.CurrentProfile?.Name; + Exit = ReactiveCommand.Create(() => { }); + } + + public async void ManageProfiles() + { + if (Application.Current is not App app) return; + + if (await ValidateSafeExit()) + { + ProfileManager.UnloadProfile(); + app.ReturnToWelcome(); + } + } + + public static async void ImportProfile() + { + string? fileName = await GetLoadFileName("Open Zipped Profile", ZipDialogFilters); + if (!string.IsNullOrEmpty(fileName)) + { + ProfileManager.CurrentProfile?.Import(fileName); + } + } + + public static async void ExportProfile() + { + string? fileName = await GetSaveFileName("Save Zipped Profile", ZipDialogFilters); + if (!string.IsNullOrEmpty(fileName)) + { + ProfileManager.CurrentProfile?.Import(fileName); + } + } + + public static async void ExportForPP() + { + string? fileName = await GetSaveFileName("Save Practical Paint File", NoFilters); + if (!string.IsNullOrEmpty(fileName)) + { + ProfileManager.CurrentProfile?.SaveToPP(fileName); + } + } + + public static async void ExportPaintRecipes() + { + string? fileName = await GetSaveFileName("Export Paint Recipes", NoFilters); + if (!string.IsNullOrEmpty(fileName)) + { + ProfileManager.CurrentProfile?.ExportWikiRecipes(fileName); + } + } + + public static async void ExportRibbonRecipes() + { + string? fileName = await GetSaveFileName("Export Ribbon Recipes", NoFilters); + if (!string.IsNullOrEmpty(fileName)) + { + ProfileManager.CurrentProfile?.ExportWikiRibbons(fileName); + } + } + + public static async void CopyPaintRecipes() + { + StringWriter writer = new(); + ProfileManager.CurrentProfile?.ExportWikiRecipes(writer); + IClipboard clipboard = Application.Current.Clipboard; + await writer.FlushAsync(); + await clipboard.SetTextAsync(writer.ToString()); + writer.Close(); + } + + public static async void CopyRibbonRecipes() + { + StringWriter writer = new(); + ProfileManager.CurrentProfile?.ExportWikiRibbons(writer); + IClipboard clipboard = Application.Current.Clipboard; + await writer.FlushAsync(); + await clipboard.SetTextAsync(writer.ToString()); + writer.Close(); + } + + public async Task ShowScreenSettings() + { + await ShowScreenSettingsDialog.Handle(new ScreenSettingsViewModel()); + } + + public async Task ShowAbout() + { + await ShowAboutDialog.Handle(new AboutViewModel()); + } + + public async Task ValidateSafeExit() + { + // TODO: Determine if there's unsaved stuff we need to deal with. + // return await ShowYesNoBox("Leaving so Soon?", "[A potential reason not to quit goes here]"); + await Task.Delay(1); // Stub to prevent warnings. + return true; + } + + private static async Task GetLoadFileName(string title, List filters) + { + if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return null; + + // TODO: Figure out why the file filters aren't working. + + OpenFileDialog dialog = new() + { + Title = title, + Filters = NoFilters, // filters, + AllowMultiple = false + }; + + string[] files = await dialog.ShowAsync(desktop.MainWindow); + return files.Length > 0 ? files[0] : null; + } + + + private static async Task GetSaveFileName(string title, List filters) + { + if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return null; + + // TODO: Figure out why the file filters aren't working. + + SaveFileDialog dialog = new() + { + Title = title, + Filters = NoFilters, // filters + }; + + return await dialog.ShowAsync(desktop.MainWindow); + } + + public Interaction ShowAboutDialog { get; } + public Interaction ShowScreenSettingsDialog { get; } + + public ReactiveCommand Exit { get; } + } +} diff --git a/ViewModels/MessageBoxViewModel.cs b/ViewModels/MessageBoxViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/MessageBoxViewModel.cs @@ -0,0 +1,38 @@ +using Avalonia.Media; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class MessageBoxViewModel : ViewModelBase + { + private string? _title; + public string? Title { get => _title; private set => this.RaiseAndSetIfChanged(ref _title, value); } + + private string? _message; + public string? Message { get => _message; private set => this.RaiseAndSetIfChanged(ref _message, value); } + + private string? _optionAText; + public string? OptionAText { get => _optionAText; private set => this.RaiseAndSetIfChanged(ref _optionAText, value); } + + private string? _optionBText; + public string? OptionBText { get => _optionBText; private set => this.RaiseAndSetIfChanged(ref _optionBText, value); } + + private string? _optionCText; + public string? OptionCText { get => _optionCText; private set => this.RaiseAndSetIfChanged(ref _optionCText, value); } + + + public MessageBoxViewModel() : this("A Message For You", "This is a test message for use in developer mode", "Ok") {} + + public MessageBoxViewModel(string title, string message, string optionA, string? optionB = null, string? optionC = null) + { + Title = title; + Message = message; + PickOption = ReactiveCommand.Create((string value) => int.Parse(value)); + OptionAText = optionA; + OptionBText = optionB; + OptionCText = optionC; + } + + public ReactiveCommand PickOption { get; } + } +} \ No newline at end of file diff --git a/ViewModels/ReactionTestViewModel.cs b/ViewModels/ReactionTestViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/ReactionTestViewModel.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; +using DesertPaintCodex.Util; +using ReactiveUI; +using DynamicData; + +namespace DesertPaintCodex.ViewModels +{ + public class ReactionTestViewModel : ViewModelBase + { + private ReactionTest _reactionTest = Constants.StubReactionTest; + public ReactionTest ReactionTest + { + get => _reactionTest; + set => this.RaiseAndSetIfChanged(ref _reactionTest, value); + } + + private readonly List _allPigmentList = new(); + public ObservableCollection BufferPigmentList { get; } = new(); + + + public ReactionTestViewModel() + { + List reagentNames = ReagentService.Names; + + foreach (var reagent in reagentNames.Select(ReagentService.GetReagent).Where(x => !x.IsCatalyst)) + { + _allPigmentList.Add(reagent); + } + + this.WhenAnyValue(x => x.ReactionTest) + .Subscribe(_ => UpdateDerivedState()); + + ShowScreenSettingsDialog = new Interaction(); + SaveReaction = ReactiveCommand.Create(() => ReactionTest.SaveReaction()); + ClearReaction = ReactiveCommand.Create(() => ReactionTest.ClearReaction()); + } + + private void UpdateDerivedState() + { + // There are more "reactive" ways to pull this off, but I don't have time to figure out all those recipes + // right now. + SetupTest(); + } + + + private void SetupTest() + { + if (ReactionTest.Requires3Way) + { + FilterBufferPigments(); + } + } + + private void FilterBufferPigments() + { + // Avalonia doesn't really have proper filtering for listy controls yet. + BufferPigmentList.Clear(); + + BufferPigmentList.AddRange(_allPigmentList.Where(pigment => + pigment != ReactionTest.Reagent1 && pigment != ReactionTest.Reagent2)); + } + + public async void Analyze() + { + Debug.WriteLine("Analyze"); + try + { + await ReactionTest.StartScan(); + } + catch (OperationCanceledException) + { + Debug.WriteLine("Scan canceled."); + } + } + + public void MarkInert() + { + ReactionTest.MarkInert(); + } + + public void CancelScan() + { + ReactionTest.CancelScan(); + } + + public async Task OpenScreenSettings() + { + await ShowScreenSettingsDialog.Handle(new ScreenSettingsViewModel()); + } + + public async Task ShowClipInfo() + { + await ShowInfoBox("Clipped Reactions", "The Pigment Lab is only capable of displaying color channel values in the 0-255 range. However, sometimes, your reactions will push one or more channels outside of that range. When that happens, the value will be clamped either to 0 or 255. In this case, we say that the reaction was \"Clipped.\"\n\nThis is nothing to panic about, though. We solve this issue by using a third (buffer) reagent to offset the extreme value, so that it falls within measurable range, similar to how we test Catalyst+Catalyst reactions. Desert Paint Codex will automatically do the math for this, but it does move clipped reactions towards the end of your test list, because you'll want any reactions between the buffer reagent and your two test reagents already known, prior to running your buffered test."); + } + + public Interaction ShowScreenSettingsDialog { get; } + + public ReactiveCommand ClearReaction { get; } + public ReactiveCommand SaveReaction { get; } + } +} \ No newline at end of file diff --git a/ViewModels/RecipeGeneratorViewModel.cs b/ViewModels/RecipeGeneratorViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/RecipeGeneratorViewModel.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Threading.Tasks; +using Avalonia.Threading; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class RecipeGeneratorViewModel : ViewModelBase + { + private const long UpdateInterval = 2000; // ms + private const long SaveInterval = 30000; // ms + private const string PaintStateFile = "dp_generator_state"; + private const string RibbonStateFile = "dp_generator_ribbon_state"; + + private int _selectedView = 0; + public int SelectedView { get => _selectedView; private set => this.RaiseAndSetIfChanged(ref _selectedView, value); } + + private float _progress = 0; + public float Progress { get => _progress; private set => this.RaiseAndSetIfChanged(ref _progress, value); } + + private int _permutationCount = 0; + public int PermutationCount { get => _permutationCount; private set => this.RaiseAndSetIfChanged(ref _permutationCount, value); } + + + private DateTime _mostRecentTime; + public DateTime MostRecentTime { get => _mostRecentTime; private set => this.RaiseAndSetIfChanged(ref _mostRecentTime, value); } + + private GeneratorRecipe? _mostRecentRecipe; + public GeneratorRecipe? MostRecentRecipe { get => _mostRecentRecipe; private set => this.RaiseAndSetIfChanged(ref _mostRecentRecipe, value); } + + + private int _productIndex = 0; + public int ProductIndex { get => _productIndex; private set => this.RaiseAndSetIfChanged(ref _productIndex, value); } + + private int _maxReagents; + public int MaxReagents { get => _maxReagents; private set => this.RaiseAndSetIfChanged(ref _maxReagents, value); } + + private int _maxConcentration; + public int MaxConcentration { get => _maxConcentration; private set => this.RaiseAndSetIfChanged(ref _maxConcentration, value); } + + private int _fullQuantity; + public int FullQuantity { get => _fullQuantity; private set => this.RaiseAndSetIfChanged(ref _fullQuantity, value); } + + private int _fullQuantityDepth; + public int FullQuantityDepth { get => _fullQuantityDepth; private set => this.RaiseAndSetIfChanged(ref _fullQuantityDepth, value); } + + private int _maxConcentrationMax; + public int MaxConcentrationMax { get => _maxConcentrationMax; private set => this.RaiseAndSetIfChanged(ref _maxConcentrationMax, value); } + + private int _fullQuantitynMax; + public int FullQuantityMax { get => _fullQuantitynMax; private set => this.RaiseAndSetIfChanged(ref _fullQuantitynMax, value); } + + + #region Flags + + private bool _isPaused = false; + public bool IsPaused { get => _isPaused; private set => this.RaiseAndSetIfChanged(ref _isPaused, value); } + + private bool _isInProgress = false; + public bool IsInProgress { get => _isInProgress; private set => this.RaiseAndSetIfChanged(ref _isInProgress, value); } + + private bool _isRunning = false; + public bool IsRunning { get => _isRunning; private set => this.RaiseAndSetIfChanged(ref _isRunning, value); } + + private bool _canStart = false; + public bool CanStart { get => _canStart; private set => this.RaiseAndSetIfChanged(ref _canStart, value); } + + private bool _canClear = false; + public bool CanClear { get => _canClear; private set => this.RaiseAndSetIfChanged(ref _canClear, value); } + + #endregion // Flags + + #region Collections + + public ObservableCollection Reagents { get; } = new(); + public ObservableCollection AllRecipes { get; } = new(); + + #endregion // Collections + + private readonly RecipeGenerator _generator; + + private DateTime _lastSave = DateTime.Now; + private readonly PlayerProfile _profile; + private uint _minConcentration; + + private readonly ConcurrentQueue _pendingNewRecipes = new(); + private volatile int _newRecipeCount; + private volatile bool _updatesAvailable; + private readonly DispatcherTimer _updateTimer; + + private bool _ribbonMode; + private bool _unsavedRecipes; + private bool _saving; + + + public RecipeGeneratorViewModel() + { + List reagentNames = ReagentService.Names; + foreach (string name in reagentNames) + { + Reagents.Add(ReagentService.GetReagent(name)); + } + + SettingsService.Get("Generator.RibbonMode", out bool ribbonMode, false); + ProductIndex = ribbonMode ? 1 : 0; + _ribbonMode = ribbonMode; + + PlayerProfile? profile = ProfileManager.CurrentProfile; + Debug.Assert(profile != null); + _profile = profile; + + _profile.LoadRecipes(); + + _generator = new RecipeGenerator(); + + _generator.Progress += OnProgress; + _generator.NewRecipe += OnNewRecipe; + _generator.Finished += OnGeneratorStopped; + + SettingsService.Get("Generator.Logging", out bool logGenerator, false); + if (logGenerator) + { + string logDir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + _generator.Log = System.IO.Path.Combine(logDir, "dpl_generator.txt"); + } + + if (ribbonMode) + { + InitStateForRibbons(); + } + else + { + InitStateForPaint(); + } + + _updateTimer = new DispatcherTimer {Interval = TimeSpan.FromMilliseconds(UpdateInterval)}; + _updateTimer.Tick += Update; + + this.WhenAnyValue(x => x.ProductIndex).Subscribe(_ => ChangeMode()); + } + + public void Start() + { + IsInProgress = true; + IsRunning = true; + ReagentService.SaveProfileReagents(_profile.ReagentFile); + SaveSettings(_ribbonMode ? "Ribbon" : "Paint"); + _updateTimer.Start(); + SelectedView = 1; + + _generator.BeginRecipeGeneration( + _minConcentration, + (uint)MaxConcentration, + 1, + (uint)MaxReagents, + (uint)FullQuantityDepth, + (uint)FullQuantity); + } + + + public void End() + { + IsPaused = false; + IsRunning = false; + IsInProgress = false; + _updateTimer.Stop(); + _profile.SaveRecipes(); + + CanClear = true; + } + + public void Pause() + { + IsPaused = true; + IsRunning = false; + _updateTimer.Stop(); + _generator.Stop(); + } + + public void Resume() + { + IsPaused = false; + IsRunning = true; + _updateTimer.Start(); + SelectedView = 1; + + _generator.ResumeRecipeGeneration(); + + } + + public async Task Clear() + { + bool result = await ShowYesNoBox("Clear Recipes", "Are you sure you want to clear your recipes?"); + if (result) + { + CanClear = false; + if (_ribbonMode) + { + _profile.ClearRibbonRecipes(); + } + else + { + _profile.ClearPaintRecipes(); + } + + foreach (GeneratorRecipe recipe in AllRecipes) + { + recipe.ClearRecipe(); + } + + _generator.Reset(); + } + } + + private void InitStateForPaint() + { + MaxConcentrationMax = 20; + FullQuantityMax = 20; + InitState("Paint", PaintRecipe.PaintRecipeMinConcentration, _profile.Recipes, PaintStateFile); + + } + + private void InitStateForRibbons() + { + MaxConcentrationMax = 100; + FullQuantityMax = 100; + InitState("Ribbon", PaintRecipe.RibbonRecipeMinConcentration, _profile.RibbonRecipes, RibbonStateFile); + } + + private void InitState(string mode, uint minConcentration, Dictionary recipes, string stateFileName) + { + _minConcentration = minConcentration; + _generator.InitRecipes(recipes); + + string stateFile = System.IO.Path.Combine(_profile.Directory, stateFileName); + if (System.IO.File.Exists(stateFile)) + { + _generator.LoadState(stateFile); + + if (_generator.CanResume) + { + IsInProgress = true; + IsPaused = true; + + MaxReagents = (int) _generator.MaxReagents; + MaxConcentration = (int) _generator.MaxConcentration; + FullQuantity = (int) _generator.FullQuantity; + FullQuantityDepth = (int) _generator.FullQuantityDepth; + + SaveSettings(mode); + } + else + { + IsInProgress = false; + IsPaused = false; + CanClear = true; + } + } + else if (mode == "Paint") + { + LoadPaintSettings(); + } + else + { + LoadRibbonSettings(); + } + + AllRecipes.Clear(); + + foreach (PaintColor color in PaletteService.Colors) + { + GeneratorRecipe genRecipe = new(color); + if (recipes.TryGetValue(color.Name, out PaintRecipe? recipe)) // && recipe.IsValidForConcentration(minConcentration)) // This check is redundant, since they're checked when loading. + { + genRecipe.DraftRecipe(recipe); + } + AllRecipes.Add(genRecipe); + } + } + + #region Event Handlers + private void OnProgress(object? sender, EventArgs args) + { + _newRecipeCount = (int)_generator.RecipeCount; + _updatesAvailable = true; + } + + private void OnNewRecipe(object? sender, NewRecipeEventArgs args) + { + PaintRecipe recipe = new(args.Recipe); + _pendingNewRecipes.Enqueue(recipe); + _updatesAvailable = true; + } + + + private void OnGeneratorStopped(object? sender, EventArgs args) + { + SaveState(); + + if (_saving) + { + _generator.ResumeRecipeGeneration(); + _lastSave = DateTime.Now; + _saving = false; + return; + } + + if (IsPaused) return; + + End(); + } + + private void Update(object? sender, EventArgs e) + { + if (!_updatesAvailable) return; + if (_saving) return; + + _updatesAvailable = false; + + DateTime now = DateTime.Now; + + // Update test count. + PermutationCount = _newRecipeCount; + + // Pull in new recipes. + if (!_pendingNewRecipes.IsEmpty) + { + GeneratorRecipe? lastRecipe = null; + while (_pendingNewRecipes.TryDequeue(out PaintRecipe? newRecipe)) + { + string recipeColor = PaletteService.FindNearest(newRecipe.ReactedColor); + foreach (GeneratorRecipe recipe in AllRecipes) + { + if (recipe.Color.Name != recipeColor) continue; + recipe.DraftRecipe(newRecipe); + lastRecipe = recipe; + if (_ribbonMode) + { + _profile.SetRibbonRecipe(recipeColor, newRecipe); + } + else + { + _profile.SetRecipe(recipeColor, newRecipe); + } + break; + } + } + + // If at least one recipe was processed, let's check to see if it's time to save, and + // highlight that recipe. + if (lastRecipe != null) + { + _unsavedRecipes = true; + MostRecentRecipe = lastRecipe; + MostRecentTime = now; + } + } + + // Save if it is time. + if (!((now - _lastSave).TotalMilliseconds > SaveInterval)) return; + + if (_unsavedRecipes) + { + _profile.SaveRecipes(); + _unsavedRecipes = false; + } + + _saving = true; + _generator.Stop(); + } + + #endregion + + + private void SavePaintSettings() + { + SaveSettings("Paint"); + } + + private void LoadPaintSettings() + { + LoadSettings("Paint", 5, 20, 20, 4); + } + + private void SaveRibbonSettings() + { + SaveSettings("Ribbon"); + } + + private void LoadRibbonSettings() + { + LoadSettings("Ribbon", 5, 75, 75, 4); + } + + + private void SaveSettings(string mode) + { + SettingsService.Set($"Generator.{mode}.MaxReagents", MaxReagents); + SettingsService.Set($"Generator.{mode}.MaxConcentration", MaxConcentration); + SettingsService.Set($"Generator.{mode}.FullQuantity", FullQuantity); + SettingsService.Set($"Generator.{mode}.FulLQuantityDepth", FullQuantityDepth); + } + + private void LoadSettings(string mode, int defaultMaxReagents, int defaultMaxConcentration, + int defaultFullQuantity, int defaultFullQuantityDepth) + { + SettingsService.Get($"Generator.{mode}.MaxReagents", out int maxReagents, defaultMaxReagents); + SettingsService.Get($"Generator.{mode}.MaxConcentration", out int maxConcentration, defaultMaxConcentration); + SettingsService.Get($"Generator.{mode}.FullQuantity", out int fullQuantity, defaultFullQuantity); + SettingsService.Get($"Generator.{mode}.FullQuantityDepth", out int fullQuantityDepth, defaultFullQuantityDepth); + + MaxReagents = maxReagents; + MaxConcentration = maxConcentration; + FullQuantity = fullQuantity; + FullQuantityDepth = fullQuantityDepth; + } + + private void SaveState() + { + _generator.SaveState(System.IO.Path.Combine(_profile.Directory, + _ribbonMode ? RibbonStateFile : PaintStateFile)); + } + + private void ChangeMode() + { + if (ProductIndex == 0) + { + if (!_ribbonMode) return; + _ribbonMode = false; + InitStateForPaint(); + } + else + { + if (_ribbonMode) return; + _ribbonMode = true; + InitStateForRibbons(); + } + } + } +} diff --git a/ViewModels/ScreenSettingsViewModel.cs b/ViewModels/ScreenSettingsViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/ScreenSettingsViewModel.cs @@ -0,0 +1,90 @@ + +using Avalonia.Controls; +using Avalonia.Platform; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; +using ReactiveUI; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Reactive; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using DesertPaintCodex.Util; + +namespace DesertPaintCodex.ViewModels +{ + public class ScreenSettingsViewModel : ViewModelBase + { + public ObservableCollection InterfaceSizes { get; } = new(); + public ObservableCollection Screens { get; } = new(); + + private int _selectedInterfaceSize; + public int SelectedInterfaceSize { get => _selectedInterfaceSize; private set => this.RaiseAndSetIfChanged(ref _selectedInterfaceSize, value); } + + private int _pixeMultiplier; + public int PixelMultiplier { get => _pixeMultiplier; private set => this.RaiseAndSetIfChanged(ref _pixeMultiplier, value); } + + private int _screenIndex; + public int ScreenIndex { get => _screenIndex; private set => this.RaiseAndSetIfChanged(ref _screenIndex, value); } + + public ScreenSettingsViewModel() + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + IScreenImpl screen = desktop.MainWindow.PlatformImpl.Screen; + for (int i = 0; i < (int)InterfaceSize.Count; i++) + { + InterfaceSizes.Add(((InterfaceSize)i).ToString()); + } + + IReadOnlyList screens = screen.AllScreens; + + for (int i = 0; i < screens.Count; i++) + { + Screens.Add(new ScreenMetrics(screens[i].Bounds, screens[i].Primary)); + } + + SettingsService.Get(SettingKey.InterfaceSize, out _selectedInterfaceSize, (int)Constants.DefaultInterfaceSize); + SettingsService.Get(SettingKey.PixelMultiplier, out _pixeMultiplier, Constants.DefaultPixelMultiplier); + SettingsService.Get(SettingKey.ScreenIndex, out _screenIndex, 0); + + _screenIndex = System.Math.Min(_screenIndex, screens.Count - 1); // In case the user has fewer screens than last time. + + Save = ReactiveCommand.Create(() => { + SettingsService.Set(SettingKey.InterfaceSize, _selectedInterfaceSize); + SettingsService.Set(SettingKey.PixelMultiplier, _pixeMultiplier); + SettingsService.Set(SettingKey.ScreenIndex, _screenIndex); + SettingsService.Set(SettingKey.ScreenX, screens[_screenIndex].Bounds.X); + SettingsService.Set(SettingKey.ScreenY, screens[_screenIndex].Bounds.Y); + SettingsService.Set(SettingKey.ScreenWidth, screens[_screenIndex].Bounds.Width); + SettingsService.Set(SettingKey.ScreenHeight, screens[_screenIndex].Bounds.Height); + + SettingsService.Save(); + + ReactionScannerService.Instance.RefreshFromSettings(); + }); + } + else + { + // Placeholder stuff. + + for (int i = 0; i < (int)InterfaceSize.Count; i++) + { + InterfaceSizes.Add(((InterfaceSize)i).ToString()); + } + + Screens.Add(new ScreenMetrics(new Avalonia.PixelRect(0, 0, 1920, 1080), true)); + Screens.Add(new ScreenMetrics(new Avalonia.PixelRect(1920, 0, 1920, 1080), false)); + + SelectedInterfaceSize = 0; + PixelMultiplier = 1; + ScreenIndex = 0; + + Save = ReactiveCommand.Create(() => { }); + } + } + + public ReactiveCommand Save { get; } + } +} diff --git a/ViewModels/SelectProfileViewModel.cs b/ViewModels/SelectProfileViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/SelectProfileViewModel.cs @@ -0,0 +1,46 @@ +using System.Reactive; +using ReactiveUI; +using System.Collections.Generic; +using System.Diagnostics; +using DesertPaintCodex.Services; + +namespace DesertPaintCodex.ViewModels +{ + public class SelectProfileViewModel : ViewModelBase + { + public List Profiles { get; private set; } + + private string? _selectedItem; + public string? SelectedItem { get => _selectedItem; set => this.RaiseAndSetIfChanged(ref _selectedItem, value); } + + public SelectProfileViewModel() + { + Profiles = ProfileManager.GetProfileList(); + + var okEnabled = this.WhenAnyValue( + x => x.SelectedItem, + x => !string.IsNullOrWhiteSpace(x)); + + NewProfile = ReactiveCommand.Create(() => { }); + + Ok = ReactiveCommand.Create(() => + { + Debug.Assert(_selectedItem != null); + ProfileManager.LoadProfile(_selectedItem); + }, okEnabled); + + Cancel = ReactiveCommand.Create(() => { }); + + if (Profiles.Count > 0) + { + SelectedItem = Profiles[0]; + } + } + + public ReactiveCommand NewProfile { get; } + + public ReactiveCommand Ok { get; } + + public ReactiveCommand Cancel { get; } + } +} diff --git a/ViewModels/SimulatorViewModel.cs b/ViewModels/SimulatorViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/SimulatorViewModel.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using Avalonia; +using Avalonia.Input.Platform; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class SimulatorViewModel : ViewModelBase + { + private PaintColor? _paintColor; + public PaintColor? PaintColor + { + get => _paintColor; + set => this.RaiseAndSetIfChanged(ref _paintColor, value); + } + + private bool _hasMissingReactions; + public bool HasMissingReactions + { + get => _hasMissingReactions; + set => this.RaiseAndSetIfChanged(ref _hasMissingReactions, value); + } + + private bool _isGoodRecipe; + + public bool IsGoodRecipe + { + get => _isGoodRecipe; + set => this.RaiseAndSetIfChanged(ref _isGoodRecipe, value); + } + + public ObservableCollection Reagents { get; } = new(); + public ObservableCollection ActiveReagents { get; } = new(); + public ObservableCollection RecipeItems { get; } = new(); + + + private readonly PaintRecipe _currentRecipe = new(); + + public SimulatorViewModel() + { + List reagentNames = ReagentService.Names; + for (int i = 0; i < reagentNames.Count; i++) + { + Reagents.Add(ReagentService.GetReagent(reagentNames[i])); + } + + ActiveReagents.CollectionChanged += OnActiveReagentsChanged; + + RecipeItems + .ToObservableChangeSet() + .AutoRefresh(item => item.Quantity) + .Subscribe(_ => Refresh()); + } + + public async void CopyToClipboard() + { + IClipboard clipboard = Application.Current.Clipboard; + await clipboard.SetTextAsync(_currentRecipe.ToString()); + } + + public void MoveItemUp(RecipeItem item) + { + int pos = RecipeItems.IndexOf(item); + if (pos <= 0) return; + + RecipeItems.RemoveAt(pos); + RecipeItems.Insert(pos - 1, item); + + Refresh(); + } + + public void MoveItemDown(RecipeItem item) + { + int pos = RecipeItems.IndexOf(item); + if ((pos < 0) || (pos >= RecipeItems.Count - 1)) return; + + RecipeItems.RemoveAt(pos); + RecipeItems.Insert(pos + 1, item); + + Refresh(); + } + + private void UpdateRecipe() + { + _currentRecipe.Clear(); + foreach (RecipeItem entry in RecipeItems) + { + if (!entry.Unused) + { + _currentRecipe.AddReagent(entry.Reagent.Name, entry.Quantity); + } + } + + PaintColor = null; // TODO: Find a better way to kick the paint swatch when reassigning color from the same ref. + PaintColor = _currentRecipe.ReactedColor; + HasMissingReactions = _currentRecipe.HasMissingReactions(); + IsGoodRecipe = !HasMissingReactions && _currentRecipe.IsValidForConcentration(10); + } + + private void OnActiveReagentsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + HashSet unmatchedReagents = new(ActiveReagents); + + for (int i = RecipeItems.Count - 1; i >= 0; i--) + { + bool found = false; + foreach (Reagent reagent in ActiveReagents) + { + if (reagent == RecipeItems[i].Reagent) + { + unmatchedReagents.Remove(reagent); + found = true; + break; + } + } + + if (!found) RecipeItems.RemoveAt(i); + } + + foreach (Reagent reagent in unmatchedReagents) + { + RecipeItems.Add(new RecipeItem(reagent, 1)); + } + + Refresh(); + } + + private void Refresh() + { + UpdateFlags(); + UpdateRecipe(); + } + + private void UpdateFlags() + { + int activeIngredients = 0; + for (int i = 0; i < RecipeItems.Count; i++) + { + RecipeItem item = RecipeItems[i]; + item.First = i == 0; + item.Last = i == RecipeItems.Count - 1; + if (item.Quantity > 0) + { + activeIngredients++; + } + item.Unused = ((activeIngredients > 5) && item.Reagent.IsCatalyst) || (item.Quantity == 0); + } + } + } +} diff --git a/ViewModels/ValidatableViewModelBase.cs b/ViewModels/ValidatableViewModelBase.cs new file mode 100644 --- /dev/null +++ b/ViewModels/ValidatableViewModelBase.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +namespace DesertPaintCodex.ViewModels +{ + public class ValidatableViewModelBase : ViewModelBase, INotifyDataErrorInfo + { + private static readonly string [] NoErrors = new string[0]; + private Dictionary > _errorsByPropertyName = new () ; + + public IEnumerable GetErrors(string? propertyName) + { + if (propertyName != null && _errorsByPropertyName. TryGetValue(propertyName, out var errorList)) + { + return errorList; + } + return NoErrors; + } + + protected virtual void SetError(string propertyName, string error) + { + if (_errorsByPropertyName.TryGetValue(propertyName, out var errorList)) + { + if (!errorList.Contains(error)) + { + errorList.Add(error); + } + } + else + { + _errorsByPropertyName.Add(propertyName, new List {error}); + } + + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + + protected virtual void RemoveErrors(string propertyName) + { + if (_errorsByPropertyName.ContainsKey(propertyName)) + { + _errorsByPropertyName.Remove(propertyName); + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + } + + public bool HasErrors => _errorsByPropertyName.Count > 0 ; + + public event EventHandler? ErrorsChanged; + } +} \ No newline at end of file diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Reactive.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Platform; +using DesertPaintCodex.Views; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class ViewModelBase : ReactiveObject + { + public ViewModelBase() + { + ShowMessageBoxDialog = new Interaction(); + } + + public void OpenBrowser(string url) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // If no associated application/json MimeType is found xdg-open opens retun error + // but it tries to open it anyway using the console editor (nano, vim, other..) + ShellExec($"xdg-open {url}", waitForExit: false); + } + else + { + using (Process.Start(new ProcessStartInfo + { + FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? url : "open", + Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? $"-e {url}" : "", + CreateNoWindow = true, + UseShellExecute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + })){} + } + } + + private static void ShellExec(string cmd, bool waitForExit = true) + { + var escapedArgs = cmd.Replace("\"", "\\\""); + + using var process = Process.Start( + new ProcessStartInfo + { + FileName = "/bin/sh", + Arguments = $"-c \"{escapedArgs}\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + } + ); + if (waitForExit) + { + process?.WaitForExit(); + } + } + + public async Task ShowMessageBox(string title, string message, string? optionA = null, string? optionB = null, string? optionC = null) + { + if (string.IsNullOrEmpty(optionA)) + { + optionA = "Ok"; + } + + return await ShowMessageBoxDialog.Handle(new MessageBoxViewModel(title, message, optionA, optionB, optionC)); + } + + public async Task ShowYesNoBox(string title, string message) + { + int rawVal = await ShowMessageBox(title, message, "No", "Yes"); + return rawVal == 1; + } + + public async Task ShowOkCancelBox(string title, string message) + { + int rawVal = await ShowMessageBox(title, message, "Cancel", "Ok"); + return rawVal == 1; + } + + public async Task ShowInfoBox(string title, string message) + { + await ShowMessageBox(title, message, "Close"); + } + + public Interaction ShowMessageBoxDialog { get; } + } +} diff --git a/ViewModels/WelcomeViewModel.cs b/ViewModels/WelcomeViewModel.cs new file mode 100644 --- /dev/null +++ b/ViewModels/WelcomeViewModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using DesertPaintCodex.Services; +using ReactiveUI; + +namespace DesertPaintCodex.ViewModels +{ + public class WelcomeViewModel : ViewModelBase + { + private SelectProfileViewModel? _selectProfileVM; + private CreateProfileViewModel? _createProfileVM; + + private ViewModelBase? _profileActivity; + public ViewModelBase? ProfileActivity { get => _profileActivity; private set => this.RaiseAndSetIfChanged(ref _profileActivity, value); } + + public WelcomeViewModel() + { + FinishWelcome = ReactiveCommand.Create(() => { }); + + if (ProfileManager.HasProfiles()) + { + ShowSelectProfile(); + } + else + { + ShowCreateProfile(); + } + } + + private void ShowSelectProfile() + { + if (_selectProfileVM == null) + { + _selectProfileVM = new SelectProfileViewModel(); + _selectProfileVM.NewProfile.Subscribe(_ => ShowCreateProfile()); + Observable.Merge(_selectProfileVM.Ok, _selectProfileVM.Cancel) + .Take(1) + .InvokeCommand(FinishWelcome); + } + + ProfileActivity = _selectProfileVM; + } + + private void ShowCreateProfile() + { + if (_createProfileVM == null) + { + _createProfileVM = new CreateProfileViewModel(); + Observable.Merge(_createProfileVM.Ok, _createProfileVM.Cancel).Subscribe(_ => ShowSelectProfile()); + } + + ProfileActivity = _createProfileVM; + } + + public ReactiveCommand FinishWelcome { get; } + } +} \ No newline at end of file diff --git a/Views/AboutView.axaml b/Views/AboutView.axaml new file mode 100644 --- /dev/null +++ b/Views/AboutView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + DESERT PAINT CODEX - T10 + + + Authors: Afrah, Snoerr + + + + + + + + + \ No newline at end of file diff --git a/Views/AboutView.axaml.cs b/Views/AboutView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/AboutView.axaml.cs @@ -0,0 +1,35 @@ +using System; +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + +namespace DesertPaintCodex.Views +{ + public class AboutView : ReactiveWindow + { + public AboutView() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + PointerPressed += (_, e) => + { + PlatformImpl?.BeginMoveDrag(e); + }; + + this.WhenActivated(disposables=> + { + ViewModel?.CloseDialog.Subscribe(_ => Close()).DisposeWith(disposables); + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file diff --git a/Views/ClipArrowsView.axaml b/Views/ClipArrowsView.axaml new file mode 100644 --- /dev/null +++ b/Views/ClipArrowsView.axaml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/Views/ClipArrowsView.axaml.cs b/Views/ClipArrowsView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/ClipArrowsView.axaml.cs @@ -0,0 +1,66 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Markup.Xaml; +using DesertPaintCodex.Models; + +namespace DesertPaintCodex.Views +{ + public class ClipArrowsView : UserControl + { + public static readonly DirectProperty FlagsProperty = AvaloniaProperty.RegisterDirect( nameof(Flags), + o => o.Flags, + (o, v) => o.Flags = v); + private ClipType _flags; + public ClipType Flags { get => _flags; set => SetAndRaise(FlagsProperty, ref _flags, value); } + + private readonly Path _redArrow; + private readonly Path _greenArrow; + private readonly Path _blueArrow; + + public ClipArrowsView() + { + InitializeComponent(); + + _redArrow = this.FindControl("RedArrow"); + _greenArrow = this.FindControl("GreenArrow"); + _blueArrow = this.FindControl("BlueArrow"); + + FlagsProperty.Changed.AddClassHandler((x, _) => x.UpdateFlags()); + + UpdateFlags(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void UpdateFlags() + { + UpdateArrow(_redArrow, ClipType.RedHigh, ClipType.RedLow); + UpdateArrow(_greenArrow, ClipType.GreenHigh, ClipType.GreenLow); + UpdateArrow(_blueArrow, ClipType.BlueHigh, ClipType.BlueLow); + } + + private void UpdateArrow(IControl arrow, ClipType highFlag, ClipType lowFlag) + { + if (_flags.HasFlag(highFlag)) + { + arrow.Classes.Remove("DownArrow"); + arrow.Classes.Add("UpArrow"); + arrow.IsVisible = true; + } + else if (_flags.HasFlag(lowFlag)) + { + arrow.Classes.Remove("UpArrow"); + arrow.Classes.Add("DownArrow"); + arrow.IsVisible = true; + } + else + { + arrow.IsVisible = false; + } + } + } +} \ No newline at end of file diff --git a/Views/CreateProfileView.axaml b/Views/CreateProfileView.axaml new file mode 100644 --- /dev/null +++ b/Views/CreateProfileView.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + Select a name for your profile: + + + + + + Profile name already in use. + + \ No newline at end of file diff --git a/Views/CreateProfileView.axaml.cs b/Views/CreateProfileView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/CreateProfileView.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; + +namespace DesertPaintCodex.Views +{ + public class CreateProfileView : ReactiveUserControl + { + public CreateProfileView() + { + InitializeComponent(); + var nameInput = this.FindControl("NameInput"); + if (nameInput != null) + { + nameInput.AttachedToVisualTree += (s,e) => nameInput.Focus(); + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/EmbeddedWarningBox.axaml b/Views/EmbeddedWarningBox.axaml new file mode 100644 --- /dev/null +++ b/Views/EmbeddedWarningBox.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/EmbeddedWarningBox.axaml.cs b/Views/EmbeddedWarningBox.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/EmbeddedWarningBox.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DesertPaintCodex.Views +{ + public class EmbeddedWarningBox : UserControl + { + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + public string Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); } + + public static readonly StyledProperty MessageProperty = + AvaloniaProperty.Register(nameof(Message)); + public string Message { get => GetValue(MessageProperty); set => SetValue(MessageProperty, value); } + + + public EmbeddedWarningBox() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file diff --git a/Views/ExperimentLogView.axaml b/Views/ExperimentLogView.axaml new file mode 100644 --- /dev/null +++ b/Views/ExperimentLogView.axaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/ExperimentLogView.axaml.cs b/Views/ExperimentLogView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/ExperimentLogView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; + + +namespace DesertPaintCodex.Views +{ + public class ExperimentLogView : ReactiveUserControl + { + public ExperimentLogView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Will overwrite the current profile with a profile from a zipped folder. + + + + + Will export the current profile to a zipped folder. + + + + + Will generate a Practical Paint reactions file from the current profile. + + + + + + + + Exports recipes in Wiki table format. + + + + + Exports recipes in Wiki table format. + + + + + Copies recipes in Wiki table format. + + + + + Copies recipes in Wiki table format. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/MainWindow.axaml.cs @@ -0,0 +1,50 @@ +using System; +using System.ComponentModel; +using System.Reactive; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.Util; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + +namespace DesertPaintCodex.Views +{ + public class MainWindow : ReactiveWindow + { + private bool _closeValidated; + + public MainWindow() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + this.WhenActivated(disposables=> + { + ViewModel?.Exit.Subscribe(_ => Close()).DisposeWith(disposables); + ViewModel?.ShowMessageBoxDialog.RegisterHandler(DialogUtil.ShowDialog).DisposeWith(disposables); + ViewModel?.ShowAboutDialog.RegisterHandler(DialogUtil.ShowDialog).DisposeWith(disposables); + ViewModel?.ShowScreenSettingsDialog.RegisterHandler(DialogUtil.ShowDialog).DisposeWith(disposables); + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + public async void OnMainWindowClosing(object? sender, CancelEventArgs e) + { + if (_closeValidated) return; + if (ViewModel == null) return; + e.Cancel = true; + if (!await ViewModel.ValidateSafeExit()) return; + _closeValidated = true; + Close(); + } + } +} \ No newline at end of file diff --git a/Views/MessageBoxView.axaml b/Views/MessageBoxView.axaml new file mode 100644 --- /dev/null +++ b/Views/MessageBoxView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/MessageBoxView.axaml.cs b/Views/MessageBoxView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/MessageBoxView.axaml.cs @@ -0,0 +1,29 @@ +using System; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + + +namespace DesertPaintCodex.Views +{ + public class MessageBoxView : ReactiveWindow + { + public MessageBoxView() + { + InitializeComponent(); + +#if DEBUG + this.AttachDevTools(); +#endif + + this.WhenActivated(d => d(ViewModel?.PickOption.Subscribe(val => Close(val))!)); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file diff --git a/Views/PaintSwatchView.axaml b/Views/PaintSwatchView.axaml new file mode 100644 --- /dev/null +++ b/Views/PaintSwatchView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + #FF0000 + Miller-Baker Pink + + + + + diff --git a/Views/PaintSwatchView.axaml.cs b/Views/PaintSwatchView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/PaintSwatchView.axaml.cs @@ -0,0 +1,75 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using DesertPaintCodex.Models; +using DesertPaintCodex.Services; + + +namespace DesertPaintCodex.Views +{ + public class PaintSwatchView : UserControl + { + private static readonly SolidColorBrush NoColor = new(); + public static readonly DirectProperty ColorProperty = AvaloniaProperty.RegisterDirect( nameof(Color), + o => o.Color, + (o, v) => o.Color = v); + + private PaintColor? _color; + public PaintColor? Color { get => _color; set => SetAndRaise(ColorProperty, ref _color, value); } + + public static readonly StyledProperty ShowNameProperty = AvaloniaProperty.Register(nameof(ShowName)); + public bool ShowName { get => GetValue(ShowNameProperty); set => SetValue(ShowNameProperty, value); } + + private readonly Border _colorSwatch; + private readonly TextBlock _hexCode; + private readonly TextBlock _colorName; + + + public PaintSwatchView() + { + InitializeComponent(); + _colorSwatch = this.FindControl("ColorSwatch"); + _hexCode = this.FindControl("HexCode"); + _colorName = this.FindControl("ColorName"); + ColorProperty.Changed.AddClassHandler((x, _) => x.UpdateColor()); + ShowNameProperty.Changed.AddClassHandler((x, _) => x.UpdateColorName()); + UpdateColor(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void UpdateColor() + { + UpdateColorSwatch(); + UpdateHexCode(); + UpdateColorName(); + } + + private void UpdateColorSwatch() + { + _colorSwatch.Background = Color == null ? NoColor : new SolidColorBrush(new Color(0xFF, Color.Red, Color.Green, Color.Blue)); + } + + private void UpdateHexCode() + { + _hexCode.Text = Color?.ToHexString() ?? string.Empty; + } + + private void UpdateColorName() + { + if (!ShowName) + { + _colorName.IsVisible = false; + } + else + { + _colorName.IsVisible = true; + _colorName.Text = Color == null ? "[Unknown]" : PaletteService.FindNearest(Color); + } + } + } +} diff --git a/Views/ReactionTestView.axaml b/Views/ReactionTestView.axaml new file mode 100644 --- /dev/null +++ b/Views/ReactionTestView.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + REAGENTS + + + + Buffer: + + + + + + + + + + + + Reagent #1: + + + + + Reagent #2: + + + + + + + + + HYPOTHETICAL COLOR + + + + + + + + + + + + + + + TEST RESULT + + + + + + + + REACTION OBSERVED + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/ReactionTestView.axaml.cs b/Views/ReactionTestView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/ReactionTestView.axaml.cs @@ -0,0 +1,29 @@ +using System.Reactive; +using System.Reactive.Disposables; +using Avalonia.Markup.Xaml; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + +namespace DesertPaintCodex.Views +{ + public class ReactionTestView : UserControlBase + { + public ReactionTestView() + { + InitializeComponent(); + + this.WhenActivated(disposables=> + { + ViewModel?.ShowScreenSettingsDialog.RegisterHandler(ShowDialog).DisposeWith(disposables); + }); + + } + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + + } + +} \ No newline at end of file diff --git a/Views/ReactionUnitView.axaml b/Views/ReactionUnitView.axaml new file mode 100644 --- /dev/null +++ b/Views/ReactionUnitView.axaml @@ -0,0 +1,40 @@ + + + + + + + + + inert + + + + 88 + R + + + + + + 88 + G + + + + + + 88 + B + + + + + diff --git a/Views/ReactionUnitView.axaml.cs b/Views/ReactionUnitView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/ReactionUnitView.axaml.cs @@ -0,0 +1,100 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Markup.Xaml; +using DesertPaintCodex.Models; + +namespace DesertPaintCodex.Views +{ + public class ReactionUnitView : UserControl + { + public static readonly DirectProperty ReactionProperty = AvaloniaProperty.RegisterDirect( nameof(Reaction), + o => o.Reaction, + (o, v) => o.Reaction = v); + private Reaction? _reaction; + public Reaction? Reaction { get => _reaction; set => SetAndRaise(ReactionProperty, ref _reaction, value); } + + private readonly Border _inertBox; + + private readonly Border _redBox; + private readonly Border _greenBox; + private readonly Border _blueBox; + + private readonly TextBlock _redQty; + private readonly TextBlock _greenQty; + private readonly TextBlock _blueQty; + + private readonly Path _redArrow; + private readonly Path _greenArrow; + private readonly Path _blueArrow; + + + public ReactionUnitView() + { + InitializeComponent(); + + _inertBox = this.FindControl("InertBox"); + + _redBox = this.FindControl("RedBox"); + _greenBox = this.FindControl("GreenBox"); + _blueBox = this.FindControl("BlueBox"); + + _redQty = _redBox.FindControl("RedQty"); + _greenQty = _greenBox.FindControl("GreenQty"); + _blueQty = _blueBox.FindControl("BlueQty"); + + _redArrow = _redBox.FindControl("RedArrow"); + _greenArrow = _greenBox.FindControl("GreenArrow"); + _blueArrow = _blueBox.FindControl("BlueArrow"); + + ReactionProperty.Changed.AddClassHandler((x, _) => x.UpdateReaction()); + + UpdateReaction(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void UpdateReaction() + { + if (_reaction == null) + { + _inertBox.IsVisible = false; + _redBox.IsVisible = false; + _greenBox.IsVisible = false; + _blueBox.IsVisible = false; + return; + } + + _inertBox.IsVisible = (_reaction.Red == 0) && (_reaction.Green == 0) && (_reaction.Blue == 0); + + UpdateColor(_redBox, _redQty, _redArrow, _reaction.Red); + UpdateColor(_greenBox, _greenQty, _greenArrow, _reaction.Green); + UpdateColor(_blueBox, _blueQty, _blueArrow, _reaction.Blue); + } + + private void UpdateColor(IControl box, TextBlock qty, IControl arrow, int value) + { + switch (value) + { + case > 0: + arrow.Classes.Remove("DownArrow"); + arrow.Classes.Add("UpArrow"); + qty.Text = value.ToString(); + box.IsVisible = true; + break; + case < 0: + arrow.Classes.Remove("UpArrow"); + arrow.Classes.Add("DownArrow"); + qty.Text = value.ToString(); + box.IsVisible = true; + break; + default: + box.IsVisible = false; + break; + } + } + } +} \ No newline at end of file diff --git a/Views/RecipeGeneratorView.axaml b/Views/RecipeGeneratorView.axaml new file mode 100644 --- /dev/null +++ b/Views/RecipeGeneratorView.axaml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REAGENTS + + + + + + + + + + ADDITIONAL SETTINGS + + + + Product + + + + + + + Max Reagents + + + + ? + + + MAXIMUM REAGENTS + The maximum number of reagents to try to mix together to produce a color. + +Larger numbers will make it easier to find evasive recipes, but they will also exponentially increase the amount of time it takes the finish the generator process. + + + + + Max Concentration + + + ? + + + MAXIMUM CONCENTRATION + The maximum deben of paint to add, to attempt to produce a color. + +Larger numbers will make it easier to find evasive recipes, but they will also exponentially increase the amount of time it takes the finish the generator process. + + + + + + + + + + + + + + + + + + RECIPE LIST + + + paint blends tested + + + + + MOST RECENT DISCOVERY + + + + + + + + + + + + + + + + diff --git a/Views/RecipeGeneratorView.axaml.cs b/Views/RecipeGeneratorView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/RecipeGeneratorView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; + +namespace DesertPaintCodex.Views +{ + public class RecipeGeneratorView : UserControlBase + { + public RecipeGeneratorView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/ScreenSettingsView.axaml b/Views/ScreenSettingsView.axaml new file mode 100644 --- /dev/null +++ b/Views/ScreenSettingsView.axaml @@ -0,0 +1,67 @@ + + + + + + + + Which screen is the game on? + + + + + + + + + + Game UI Size + + + ? + + + Game UI Size + [Coming Soon] + + + + + + Screen pixels to game pixels: + + + + ? + + + Screen Pixels to Game Pixels + [Coming Soon] + + + + + + + + + + + + + + + + + + + diff --git a/Views/ScreenSettingsView.axaml.cs b/Views/ScreenSettingsView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/ScreenSettingsView.axaml.cs @@ -0,0 +1,31 @@ +using System; +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + + +namespace DesertPaintCodex.Views +{ + public class ScreenSettingsView : ReactiveWindow + { + public ScreenSettingsView() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + this.WhenActivated(disposables=> + { + ViewModel?.Save.Subscribe(_ => Close()).DisposeWith(disposables); + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/SelectProfileView.axaml b/Views/SelectProfileView.axaml new file mode 100644 --- /dev/null +++ b/Views/SelectProfileView.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + Select a profile to open: + + + + + + + + + + + diff --git a/Views/SelectProfileView.axaml.cs b/Views/SelectProfileView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/SelectProfileView.axaml.cs @@ -0,0 +1,23 @@ + +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; + + +namespace DesertPaintCodex.Views +{ + public class SelectProfileView : ReactiveUserControl + + { + public SelectProfileView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/SimulatorView.axaml b/Views/SimulatorView.axaml new file mode 100644 --- /dev/null +++ b/Views/SimulatorView.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + REAGENTS + + + + + + + + + + + + + + + + RECIPE + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/SimulatorView.axaml.cs b/Views/SimulatorView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/SimulatorView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; + +namespace DesertPaintCodex.Views +{ + public class SimulatorView : ReactiveUserControl + { + public SimulatorView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Views/UserControlBase.cs b/Views/UserControlBase.cs new file mode 100644 --- /dev/null +++ b/Views/UserControlBase.cs @@ -0,0 +1,39 @@ +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + +namespace DesertPaintCodex.Views +{ + public class UserControlBase : ReactiveUserControl where T : ViewModelBase + { + public UserControlBase() + { + this.WhenActivated((disposables) => + { + ViewModel?.ShowMessageBoxDialog.RegisterHandler(ShowDialog).DisposeWith(disposables); + }); + } + + #region Dialog handlers + + protected async Task ShowDialog( + InteractionContext interaction) where View : Window, new() + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + View dialog = new() {DataContext = interaction.Input}; + ReturnType result = await dialog.ShowDialog(desktop.MainWindow); + interaction.SetOutput(result); + } + } + + #endregion + + + } +} \ No newline at end of file diff --git a/Views/WelcomeView.axaml b/Views/WelcomeView.axaml new file mode 100644 --- /dev/null +++ b/Views/WelcomeView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + DESERT PAINT CODEX - T10 + + + diff --git a/Views/WelcomeView.axaml.cs b/Views/WelcomeView.axaml.cs new file mode 100644 --- /dev/null +++ b/Views/WelcomeView.axaml.cs @@ -0,0 +1,53 @@ +using System; +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.Platform; +using Avalonia.ReactiveUI; +using DesertPaintCodex.ViewModels; +using ReactiveUI; + + +namespace DesertPaintCodex.Views +{ + public class WelcomeView : ReactiveWindow + { + public WelcomeView() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + // Extract some reasonable screen defaults. + IScreenImpl screenInfo = PlatformImpl.Screen; + for (int i = 0; i < screenInfo.ScreenCount; i++) + { + Screen screen = screenInfo.AllScreens[i]; + if (!screen.Primary) continue; + Util.Constants.DefaultScreenWidth = screen.Bounds.Width; + Util.Constants.DefaultScreenHeight = screen.Bounds.Height; + Util.Constants.DefaultScreenX = screen.Bounds.X; + Util.Constants.DefaultScreenY = screen.Bounds.Y; + break; + } + + // Make this window draggable. + PointerPressed += (_, e) => + { + PlatformImpl?.BeginMoveDrag(e); + }; + + // Handle the close command. + this.WhenActivated(disposables=> + { + ViewModel?.FinishWelcome.Subscribe(_ => Close()).DisposeWith(disposables); + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} \ No newline at end of file