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 } private const int SwatchHeight = 24; private const int RedBarSpacing = 32; private const int GreenBarSpacing = 42; private const int BlueBarSpacing = 52; private const int MinSwatchWidth = 306; private const int MaxSwatchWidth = 411; private static ReactionScannerService? s_instance; public static ReactionScannerService Instance => s_instance ??= new ReactionScannerService(); private int _swatchHeight = 0; private int _minSwatchWidth = 0; private int _maxSwatchWidth = 0; private int _swatchWidth = 0; private int _redBarSpacing = 0; private int _greenBarSpacing = 0; private int _blueBarSpacing = 0; private int _swatchTopBorder = 0; private int _swatchBottomBorder = 0; private int _swatchLeftBorder = 0; private int _swatchRightBorder = 0; private int _swatchTestWidth = 0; private bool _lockedSwatchWidth = false; // Current Status public bool IsCaptured { get; private set; } private readonly PaintColor _recordedColor = new(); public PaintColor RecordedColor { get => _recordedColor; private set => _recordedColor.Set(value); } private int _pixelMultiplier = 1; 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.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() { _swatchWidth = MinSwatchWidth * _pixelMultiplier; _swatchHeight = SwatchHeight * _pixelMultiplier; _redBarSpacing = RedBarSpacing * _pixelMultiplier; _greenBarSpacing = GreenBarSpacing * _pixelMultiplier; _blueBarSpacing = BlueBarSpacing * _pixelMultiplier; _swatchTopBorder = 2 * _pixelMultiplier; _swatchBottomBorder = 2 * _pixelMultiplier; _swatchLeftBorder = 2 * _pixelMultiplier; _swatchRightBorder = 2 * _pixelMultiplier; _minSwatchWidth = MinSwatchWidth * _pixelMultiplier; _maxSwatchWidth = MaxSwatchWidth * _pixelMultiplier; _swatchTestWidth = (int)(0.1f * MinSwatchWidth * _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 + _swatchTopBorder); // 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 - _swatchLeftBorder - _swatchRightBorder; int swatchSolidHeight = _swatchHeight - _swatchTopBorder - _swatchBottomBorder; int swatchSolidLeftX = x + _swatchLeftBorder; int swatchSolidTopY = y + _swatchTopBorder; 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; } // Find the right side of the swatch, if necessary. if (!_lockedSwatchWidth) { int xCutoff = Math.Min(x + _maxSwatchWidth - _swatchRightBorder, _targetBitmap.Width); int newRight = swatchSolidRightX; for (int px = swatchSolidRightX; px < xCutoff; px++) { if (pixels.DoesPixelMatch(px, swatchSolidTopY, ColorMatcher.IsMatch) && pixels.DoesPixelMatch(px, swatchSolidBottomY, ColorMatcher.IsMatch)) { newRight = px; } else break; } swatchSolidRightX = newRight; _swatchWidth = swatchSolidRightX + _swatchRightBorder - x + _pixelMultiplier; WriteLog(LogVerbosity.High, "Setting new swatch width of {0}.", _swatchWidth); } // scan down the right and left sides for (int yOff = _pixelMultiplier; yOff < (swatchSolidHeight - _pixelMultiplier); ++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 = _pixelMultiplier; xOff < (swatchSolidWidth - _pixelMultiplier); ++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 = _pixelMultiplier; result && i < _swatchHeight - _pixelMultiplier; ++i) { result &= pixels.DoesPixelMatch(x, y + i, PixelColor.IsDark); if (!result) break; } 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 background above and below the swatch bool borderError = false; int bgErrorCount = 0; for (i = 0; result && (i < _swatchWidth - _pixelMultiplier); ++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 background // The row just above is shaded, so check 2 above if (y > 1) { bool isUiBackground = pixels.DoesPixelMatch(x + i, y - _swatchTopBorder, PixelColor.IsUiBackground); bgErrorCount += isUiBackground ? 0 : 1; } else { ++bgErrorCount; } // Checking along the bottom of the swatch for background if (y < pixels.Height) { bool isUiBackground = pixels.DoesPixelMatch(x + i, y + _swatchHeight, PixelColor.IsUiBackground); bgErrorCount += isUiBackground ? 0 : 1; } else { ++bgErrorCount; } } result &= (bgErrorCount < (_swatchWidth / 20)); // allow up to 5% error rate checking for background texture, because this seems to be inconsistent if (!result && ((i > (_swatchWidth * 0.8)) || (bgErrorCount >= (_swatchWidth / 20)))) { if (!borderError && (bgErrorCount < _swatchWidth)) { WriteLog(LogVerbosity.Normal, "Found a potential swatch candidate of width {0} at {1},{2} that had {3} failures matching background color", i, x, y, bgErrorCount); } } 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); if (foundSwatch) { WriteLog(LogVerbosity.Normal, "Found probable swatch at {0},{1} - checking border slices", x, y); int borderXOffset = 0; for (borderXOffset = _swatchLeftBorder; 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) { if (!_lockedSwatchWidth) { // Reset our guess at swatch width. _swatchWidth = MinSwatchWidth; } return false; } // WE FOUND THE SWATCH! // Now we know where the color bars are. _lockedSwatchWidth = true; int redPixelCount = pixels.LengthOfColorAt(x, y + _redBarSpacing, PixelColor.IsRed); reactedColor.Red = (byte)Math.Round(redPixelCount * 255f / _swatchWidth); int greenPixelCount = pixels.LengthOfColorAt(x, y + _greenBarSpacing, PixelColor.IsGreen); reactedColor.Green = (byte)Math.Round(greenPixelCount * 255f / _swatchWidth); int bluePixelCount = pixels.LengthOfColorAt(x, y + _blueBarSpacing, PixelColor.IsBlue); reactedColor.Blue = (byte)Math.Round(bluePixelCount * 255f / _swatchWidth); 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 - _swatchTopBorder - _swatchBottomBorder) / 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)) - _minSwatchWidth; // + patchTestSize; endY = Math.Min(_targetBitmap.Height - 2, Math.Max(2, endY)) - (_blueBarSpacing + 10); // + patchTestSize; Debug.WriteLine("startX: " + startX + " endX: " + endX + " startY: " + startY + " endY: " + endY + " with patch test size of " + patchTestSize); 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 - _swatchLeftBorder + _pixelMultiplier); x < roughX; ++x) { for (int y = Math.Max(0, roughY - patchTestSize - _swatchTopBorder + _pixelMultiplier); 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, bool firstBuffer) { // A 3-reagent reaction. Reaction? reaction1; Reaction? reaction2; if (firstBuffer) { reaction1 = profile.FindReaction(reagent0, reagent1); reaction2 = profile.FindReaction(reagent0, reagent2); } else { reaction1 = profile.FindReaction(reagent0, reagent2); reaction2 = profile.FindReaction(reagent1, 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); } } }