diff --git a/Poe2Trade.sln b/Poe2Trade.sln index 7ab1630..557f2ba 100644 --- a/Poe2Trade.sln +++ b/Poe2Trade.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Inventory", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Ui", "src\Poe2Trade.Ui\Poe2Trade.Ui.csproj", "{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Navigation", "src\Poe2Trade.Navigation\Poe2Trade.Navigation.csproj", "{D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -68,6 +70,10 @@ Global {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.Build.0 = Release|Any CPU + {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} @@ -79,5 +85,6 @@ Global {188C4F87-153F-4182-B816-9FB56F08CF3A} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {F186DDC8-6843-43E9-8BD3-9F914C5E784E} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} EndGlobalSection EndGlobal diff --git a/assets/minimap-x.png b/assets/minimap-x.png new file mode 100644 index 0000000..cdd54d0 Binary files /dev/null and b/assets/minimap-x.png differ diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index f54c813..97f2734 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -3,6 +3,7 @@ using Poe2Trade.Core; using Poe2Trade.Game; using Poe2Trade.Inventory; using Poe2Trade.GameLog; +using Poe2Trade.Navigation; using Poe2Trade.Screen; using Poe2Trade.Trade; using Serilog; @@ -38,6 +39,7 @@ public class BotOrchestrator : IAsyncDisposable public IInventoryManager Inventory { get; } public TradeExecutor TradeExecutor { get; } public TradeQueue TradeQueue { get; } + public NavigationExecutor Navigation { get; } private readonly Dictionary _scrapExecutors = new(); // Events @@ -58,12 +60,24 @@ public class BotOrchestrator : IAsyncDisposable TradeExecutor = tradeExecutor; TradeQueue = tradeQueue; Links = links; + Navigation = new NavigationExecutor(game); _paused = store.Settings.Paused; } public bool IsReady => _started; public bool IsPaused => _paused; + public BotMode Mode + { + get => Config.Mode; + set + { + if (Config.Mode == value) return; + Store.UpdateSettings(s => s.Mode = value); + StatusUpdated?.Invoke(); + } + } + public string State { get => _state; @@ -142,6 +156,11 @@ public class BotOrchestrator : IAsyncDisposable return; } } + if (Navigation.State != NavigationState.Idle) + { + State = Navigation.State.ToString(); + return; + } State = "Idle"; } @@ -183,6 +202,7 @@ public class BotOrchestrator : IAsyncDisposable // Wire executor events TradeExecutor.StateChanged += _ => UpdateExecutorState(); + Navigation.StateChanged += _ => UpdateExecutorState(); TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); }; TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); }; Inventory.Updated += () => StatusUpdated?.Invoke(); diff --git a/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj b/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj index e39188c..a6e1157 100644 --- a/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj +++ b/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj @@ -11,5 +11,6 @@ + diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs index f617513..dcade9d 100644 --- a/src/Poe2Trade.Core/ConfigStore.cs +++ b/src/Poe2Trade.Core/ConfigStore.cs @@ -30,6 +30,8 @@ public class SavedSettings public double? WindowWidth { get; set; } public double? WindowHeight { get; set; } public bool Headless { get; set; } = true; + public BotMode Mode { get; set; } = BotMode.Trading; + public MapType MapType { get; set; } = MapType.TrialOfChaos; } public class ConfigStore diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs index d02c702..dba0b76 100644 --- a/src/Poe2Trade.Core/Types.cs +++ b/src/Poe2Trade.Core/Types.cs @@ -68,3 +68,16 @@ public enum PostAction Stash, Salvage } + +public enum BotMode +{ + Trading, + Mapping +} + +public enum MapType +{ + TrialOfChaos, + Temple, + Endgame +} diff --git a/src/Poe2Trade.Game/GameController.cs b/src/Poe2Trade.Game/GameController.cs index a37cda8..0f12ad9 100644 --- a/src/Poe2Trade.Game/GameController.cs +++ b/src/Poe2Trade.Game/GameController.cs @@ -74,4 +74,5 @@ public class GameController : IGameController public Task CtrlLeftClickAt(int x, int y) => _input.CtrlLeftClick(x, y); public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL); public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL); + public Task ToggleMinimap() => _input.PressKey(InputSender.VK.TAB); } diff --git a/src/Poe2Trade.Game/IGameController.cs b/src/Poe2Trade.Game/IGameController.cs index b23fc94..5879df1 100644 --- a/src/Poe2Trade.Game/IGameController.cs +++ b/src/Poe2Trade.Game/IGameController.cs @@ -19,4 +19,5 @@ public interface IGameController Task OpenInventory(); Task HoldCtrl(); Task ReleaseCtrl(); + Task ToggleMinimap(); } diff --git a/src/Poe2Trade.Navigation/MinimapCapture.cs b/src/Poe2Trade.Navigation/MinimapCapture.cs new file mode 100644 index 0000000..b411318 --- /dev/null +++ b/src/Poe2Trade.Navigation/MinimapCapture.cs @@ -0,0 +1,119 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using OpenCvSharp; +using OpenCvSharp.Extensions; +using Serilog; +using Region = Poe2Trade.Core.Region; +using Point = OpenCvSharp.Point; +using Size = OpenCvSharp.Size; + +namespace Poe2Trade.Navigation; + +public class MinimapCapture : IDisposable +{ + private readonly MinimapConfig _config; + private Mat? _circularMask; + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int nIndex); + + public MinimapCapture(MinimapConfig config) + { + _config = config; + BuildCircularMask(); + } + + private void BuildCircularMask() + { + var size = _config.CaptureSize; + _circularMask = new Mat(size, size, MatType.CV_8UC1, Scalar.Black); + var center = new Point(size / 2, size / 2); + Cv2.Circle(_circularMask, center, _config.FogRadius, Scalar.White, -1); + } + + public MinimapFrame? CaptureFrame() + { + var region = _config.CaptureRegion; + using var bitmap = CaptureScreen(region); + using var bgr = BitmapConverter.ToMat(bitmap); + + if (bgr.Empty()) + return null; + + // Apply circular mask to ignore area outside fog-of-war circle + using var masked = new Mat(); + Cv2.BitwiseAnd(bgr, bgr, masked, _circularMask!); + + // Convert to HSV + using var hsv = new Mat(); + Cv2.CvtColor(masked, hsv, ColorConversionCodes.BGR2HSV); + + // Classify explored areas + using var exploredMask = new Mat(); + Cv2.InRange(hsv, _config.ExploredLoHSV, _config.ExploredHiHSV, exploredMask); + + // Classify walls: dark pixels within the circular mask + using var valueChan = new Mat(); + Cv2.ExtractChannel(hsv, valueChan, 2); // V channel + using var darkMask = new Mat(); + Cv2.Threshold(valueChan, darkMask, _config.WallMaxValue, 255, ThresholdTypes.BinaryInv); + // Apply morphological close to connect wall fragments + using var wallKernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); + using var wallMask = new Mat(); + Cv2.MorphologyEx(darkMask, wallMask, MorphTypes.Close, wallKernel); + // Only within circular mask + Cv2.BitwiseAnd(wallMask, _circularMask!, wallMask); + + // Build classified mat: Unknown=0, Explored=1, Wall=2 + var classified = new Mat(_config.CaptureSize, _config.CaptureSize, MatType.CV_8UC1, Scalar.Black); + classified.SetTo(new Scalar((byte)MapCell.Explored), exploredMask); + classified.SetTo(new Scalar((byte)MapCell.Wall), wallMask); + // Ensure only within circular mask + Cv2.BitwiseAnd(classified, _circularMask!, classified); + + // Detect player marker (orange X) + using var playerMask = new Mat(); + Cv2.InRange(hsv, _config.PlayerLoHSV, _config.PlayerHiHSV, playerMask); + var playerOffset = FindCentroid(playerMask); + + // Convert to grayscale for phase correlation + var gray = new Mat(); + Cv2.CvtColor(masked, gray, ColorConversionCodes.BGR2GRAY); + // Apply circular mask to gray too + Cv2.BitwiseAnd(gray, _circularMask!, gray); + + return new MinimapFrame( + GrayMat: gray, + ClassifiedMat: classified, + PlayerOffset: playerOffset, + Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ); + } + + private Point2d FindCentroid(Mat mask) + { + var moments = Cv2.Moments(mask, true); + if (moments.M00 < 10) // not enough pixels + return new Point2d(0, 0); + + var cx = moments.M10 / moments.M00 - _config.CaptureSize / 2.0; + var cy = moments.M01 / moments.M00 - _config.CaptureSize / 2.0; + return new Point2d(cx, cy); + } + + private static Bitmap CaptureScreen(Region region) + { + var bitmap = new Bitmap(region.Width, region.Height, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bitmap); + g.CopyFromScreen(region.X, region.Y, 0, 0, + new System.Drawing.Size(region.Width, region.Height), + CopyPixelOperation.SourceCopy); + return bitmap; + } + + public void Dispose() + { + _circularMask?.Dispose(); + } +} diff --git a/src/Poe2Trade.Navigation/NavigationExecutor.cs b/src/Poe2Trade.Navigation/NavigationExecutor.cs new file mode 100644 index 0000000..b0997dd --- /dev/null +++ b/src/Poe2Trade.Navigation/NavigationExecutor.cs @@ -0,0 +1,156 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Serilog; + +namespace Poe2Trade.Navigation; + +public class NavigationExecutor : IDisposable +{ + private readonly IGameController _game; + private readonly MinimapConfig _config; + private readonly MinimapCapture _capture; + private readonly PositionTracker _tracker; + private readonly WorldMap _worldMap; + private NavigationState _state = NavigationState.Idle; + private bool _stopped; + private static readonly Random Rng = new(); + + public event Action? StateChanged; + public NavigationState State => _state; + + public NavigationExecutor(IGameController game, MinimapConfig? config = null) + { + _game = game; + _config = config ?? new MinimapConfig(); + _capture = new MinimapCapture(_config); + _tracker = new PositionTracker(_config); + _worldMap = new WorldMap(_config); + } + + private void SetState(NavigationState s) + { + _state = s; + StateChanged?.Invoke(s); + } + + public Task Stop() + { + _stopped = true; + SetState(NavigationState.Idle); + Log.Information("Navigation executor stopped"); + return Task.CompletedTask; + } + + public void Reset() + { + _tracker.Reset(); + _worldMap.Reset(); + _stopped = false; + SetState(NavigationState.Idle); + } + + public async Task RunExploreLoop() + { + _stopped = false; + Log.Information("Starting explore loop"); + + // Open minimap overlay (Tab) + await _game.ToggleMinimap(); + await Helpers.Sleep(300); + + while (!_stopped) + { + try + { + // 1. Capture frame + SetState(NavigationState.Capturing); + using var frame = _capture.CaptureFrame(); + if (frame == null) + { + Log.Warning("Failed to capture minimap frame"); + await Helpers.Sleep(200); + continue; + } + + // 2. Track position via phase correlation + SetState(NavigationState.Processing); + var pos = _tracker.UpdatePosition(frame.GrayMat); + + // 3. Stitch into world map + _worldMap.StitchFrame(frame.ClassifiedMat, pos); + + // 4. Check if stuck + if (_tracker.IsStuck) + { + SetState(NavigationState.Stuck); + Log.Information("Stuck detected, clicking random direction"); + await ClickRandomDirection(); + await Helpers.Sleep(_config.MovementWaitMs); + continue; + } + + // 5. Find best exploration direction + SetState(NavigationState.Planning); + var direction = _worldMap.FindNearestUnexplored(pos); + + if (direction == null) + { + Log.Information("Map fully explored"); + SetState(NavigationState.Completed); + break; + } + + // 6. Click to move in that direction + SetState(NavigationState.Moving); + await ClickToMove(direction.Value.dirX, direction.Value.dirY); + + // 7. Wait for character to walk + await Helpers.Sleep(_config.MovementWaitMs); + } + catch (Exception ex) + { + Log.Error(ex, "Error in explore loop"); + SetState(NavigationState.Failed); + await Helpers.Sleep(1000); + } + } + + if (_state != NavigationState.Completed) + SetState(NavigationState.Idle); + + Log.Information("Explore loop ended"); + } + + private async Task ClickToMove(double dirX, double dirY) + { + // Player is at minimap center on screen; click offset from center + var len = Math.Sqrt(dirX * dirX + dirY * dirY); + if (len < 0.001) return; + + var nx = dirX / len; + var ny = dirY / len; + + var clickX = _config.MinimapCenterX + (int)(nx * _config.ClickRadius); + var clickY = _config.MinimapCenterY + (int)(ny * _config.ClickRadius); + + Log.Debug("Click to move: ({X}, {Y}) dir=({Dx:F2}, {Dy:F2})", clickX, clickY, nx, ny); + await _game.LeftClickAt(clickX, clickY); + } + + private async Task ClickRandomDirection() + { + var angle = Rng.NextDouble() * 2 * Math.PI; + await ClickToMove(Math.Cos(angle), Math.Sin(angle)); + } + + public MapPosition Position => _tracker.Position; + public byte[] GetMapSnapshot() => _worldMap.GetMapSnapshot(); + public byte[] GetViewportSnapshot(int viewSize = 400) => _worldMap.GetViewportSnapshot(_tracker.Position, viewSize); + + public void Dispose() + { + _capture.Dispose(); + _tracker.Dispose(); + _worldMap.Dispose(); + } +} diff --git a/src/Poe2Trade.Navigation/NavigationTypes.cs b/src/Poe2Trade.Navigation/NavigationTypes.cs new file mode 100644 index 0000000..30075ad --- /dev/null +++ b/src/Poe2Trade.Navigation/NavigationTypes.cs @@ -0,0 +1,82 @@ +using Poe2Trade.Core; +using OpenCvSharp; + +namespace Poe2Trade.Navigation; + +public enum NavigationState +{ + Idle, + Capturing, + Processing, + Planning, + Moving, + Stuck, + Completed, + Failed +} + +public record MapPosition(double X, double Y); + +public enum MapCell : byte +{ + Unknown = 0, + Explored = 1, + Wall = 2 +} + +public record MinimapFrame( + Mat GrayMat, + Mat ClassifiedMat, + Point2d PlayerOffset, + long Timestamp +) : IDisposable +{ + public void Dispose() + { + GrayMat.Dispose(); + ClassifiedMat.Dispose(); + } +} + +public class MinimapConfig +{ + // Minimap center on screen (2560x1440) + public int MinimapCenterX { get; set; } = 1280; + public int MinimapCenterY { get; set; } = 700; + + // Capture region: 300x300 centered at minimap center + public Region CaptureRegion => new( + MinimapCenterX - CaptureSize / 2, + MinimapCenterY - CaptureSize / 2, + CaptureSize, CaptureSize); + + public int CaptureSize { get; set; } = 300; + + // Fog-of-war circle radius within the captured frame + public int FogRadius { get; set; } = 120; + + // HSV range for explored areas (blue/teal) + public Scalar ExploredLoHSV { get; set; } = new(85, 30, 30); + public Scalar ExploredHiHSV { get; set; } = new(145, 255, 255); + + // HSV range for player marker (orange X) + public Scalar PlayerLoHSV { get; set; } = new(5, 100, 100); + public Scalar PlayerHiHSV { get; set; } = new(25, 255, 255); + + // HSV range for walls (dark pixels) + public int WallMaxValue { get; set; } = 40; + + // Movement + public int ClickRadius { get; set; } = 100; + public int MovementWaitMs { get; set; } = 600; + + // World map canvas + public int CanvasSize { get; set; } = 4000; + + // Phase correlation confidence threshold + public double ConfidenceThreshold { get; set; } = 0.15; + + // Stuck detection + public double StuckThreshold { get; set; } = 2.0; + public int StuckFrameCount { get; set; } = 5; +} diff --git a/src/Poe2Trade.Navigation/Poe2Trade.Navigation.csproj b/src/Poe2Trade.Navigation/Poe2Trade.Navigation.csproj new file mode 100644 index 0000000..a14735f --- /dev/null +++ b/src/Poe2Trade.Navigation/Poe2Trade.Navigation.csproj @@ -0,0 +1,19 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/src/Poe2Trade.Navigation/PositionTracker.cs b/src/Poe2Trade.Navigation/PositionTracker.cs new file mode 100644 index 0000000..c716972 --- /dev/null +++ b/src/Poe2Trade.Navigation/PositionTracker.cs @@ -0,0 +1,93 @@ +using OpenCvSharp; +using Serilog; + +namespace Poe2Trade.Navigation; + +public class PositionTracker : IDisposable +{ + private readonly MinimapConfig _config; + private Mat? _prevGray; + private Mat? _hanningWindow; + private double _worldX; + private double _worldY; + private int _stuckCounter; + + public MapPosition Position => new(_worldX, _worldY); + public bool IsStuck => _stuckCounter >= _config.StuckFrameCount; + + public PositionTracker(MinimapConfig config) + { + _config = config; + _worldX = config.CanvasSize / 2.0; + _worldY = config.CanvasSize / 2.0; + } + + public MapPosition UpdatePosition(Mat currentGray) + { + if (_prevGray == null || _hanningWindow == null) + { + _prevGray = currentGray.Clone(); + _hanningWindow = new Mat(); + Cv2.CreateHanningWindow(_hanningWindow, currentGray.Size(), MatType.CV_64F); + return Position; + } + + // Convert to float64 for phase correlation + using var prev64 = new Mat(); + using var curr64 = new Mat(); + _prevGray.ConvertTo(prev64, MatType.CV_64F); + currentGray.ConvertTo(curr64, MatType.CV_64F); + + var shift = Cv2.PhaseCorrelate(prev64, curr64, _hanningWindow, out var confidence); + + if (confidence < _config.ConfidenceThreshold) + { + Log.Debug("Phase correlation low confidence: {Confidence:F3}", confidence); + _stuckCounter++; + _prevGray.Dispose(); + _prevGray = currentGray.Clone(); + return Position; + } + + // Negate: minimap scrolls opposite to player movement + var dx = -shift.X; + var dy = -shift.Y; + var displacement = Math.Sqrt(dx * dx + dy * dy); + + if (displacement < _config.StuckThreshold) + { + _stuckCounter++; + } + else + { + _stuckCounter = 0; + _worldX += dx; + _worldY += dy; + } + + Log.Debug("Position: ({X:F1}, {Y:F1}) dx={Dx:F1} dy={Dy:F1} conf={Conf:F3} stuck={Stuck}", + _worldX, _worldY, dx, dy, confidence, _stuckCounter); + + _prevGray.Dispose(); + _prevGray = currentGray.Clone(); + return Position; + } + + public void Reset() + { + _prevGray?.Dispose(); + _prevGray = null; + _hanningWindow?.Dispose(); + _hanningWindow = null; + _worldX = _config.CanvasSize / 2.0; + _worldY = _config.CanvasSize / 2.0; + _stuckCounter = 0; + Log.Information("Position tracker reset"); + } + + public void Dispose() + { + _prevGray?.Dispose(); + _hanningWindow?.Dispose(); + } +} diff --git a/src/Poe2Trade.Navigation/WorldMap.cs b/src/Poe2Trade.Navigation/WorldMap.cs new file mode 100644 index 0000000..dfbdfa3 --- /dev/null +++ b/src/Poe2Trade.Navigation/WorldMap.cs @@ -0,0 +1,158 @@ +using OpenCvSharp; +using Serilog; + +namespace Poe2Trade.Navigation; + +public class WorldMap : IDisposable +{ + private readonly MinimapConfig _config; + private readonly Mat _canvas; + + public WorldMap(MinimapConfig config) + { + _config = config; + _canvas = new Mat(config.CanvasSize, config.CanvasSize, MatType.CV_8UC1, Scalar.Black); + } + + public void StitchFrame(Mat classifiedMat, MapPosition position) + { + var halfSize = _config.CaptureSize / 2; + var canvasX = (int)Math.Round(position.X) - halfSize; + var canvasY = (int)Math.Round(position.Y) - halfSize; + + // Clamp to canvas bounds + var srcX = Math.Max(0, -canvasX); + var srcY = Math.Max(0, -canvasY); + var dstX = Math.Max(0, canvasX); + var dstY = Math.Max(0, canvasY); + var w = Math.Min(_config.CaptureSize - srcX, _config.CanvasSize - dstX); + var h = Math.Min(_config.CaptureSize - srcY, _config.CanvasSize - dstY); + + if (w <= 0 || h <= 0) return; + + var srcRect = new Rect(srcX, srcY, w, h); + var dstRect = new Rect(dstX, dstY, w, h); + + var srcRoi = new Mat(classifiedMat, srcRect); + var dstRoi = new Mat(_canvas, dstRect); + + // Only paste non-Unknown pixels; don't overwrite Explored with Wall + for (var row = 0; row < h; row++) + { + for (var col = 0; col < w; col++) + { + var srcVal = srcRoi.At(row, col); + if (srcVal == (byte)MapCell.Unknown) continue; + + var dstVal = dstRoi.At(row, col); + // Don't overwrite Explored with Wall (Explored is more reliable) + if (dstVal == (byte)MapCell.Explored && srcVal == (byte)MapCell.Wall) + continue; + + dstRoi.Set(row, col, srcVal); + } + } + } + + public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 200) + { + var cx = (int)Math.Round(pos.X); + var cy = (int)Math.Round(pos.Y); + + // Scan in angular sectors to find direction with most Unknown cells at frontier + var bestAngle = double.NaN; + var bestScore = 0; + const int sectorCount = 16; + var fogRadius = _config.FogRadius; + + for (var sector = 0; sector < sectorCount; sector++) + { + var angle = 2 * Math.PI * sector / sectorCount; + var score = 0; + + // Sample along a cone in this direction, at the fog boundary and beyond + for (var r = fogRadius - 20; r <= fogRadius + searchRadius; r += 5) + { + for (var spread = -15; spread <= 15; spread += 5) + { + var sampleAngle = angle + spread * Math.PI / 180; + var sx = cx + (int)(r * Math.Cos(sampleAngle)); + var sy = cy + (int)(r * Math.Sin(sampleAngle)); + + if (sx < 0 || sx >= _config.CanvasSize || sy < 0 || sy >= _config.CanvasSize) + continue; + + if (_canvas.At(sy, sx) == (byte)MapCell.Unknown) + score++; + } + } + + if (score > bestScore) + { + bestScore = score; + bestAngle = angle; + } + } + + if (bestScore == 0 || double.IsNaN(bestAngle)) + { + Log.Information("No unexplored area found within search radius"); + return null; + } + + var dirX = Math.Cos(bestAngle); + var dirY = Math.Sin(bestAngle); + Log.Debug("Best exploration direction: angle={Angle:F1}deg score={Score}", + bestAngle * 180 / Math.PI, bestScore); + return (dirX, dirY); + } + + public byte[] GetMapSnapshot() + { + Cv2.ImEncode(".png", _canvas, out var buf); + return buf; + } + + public byte[] GetViewportSnapshot(MapPosition center, int viewSize = 400) + { + var cx = (int)Math.Round(center.X); + var cy = (int)Math.Round(center.Y); + var half = viewSize / 2; + + // Clamp viewport to canvas + var x0 = Math.Clamp(cx - half, 0, _config.CanvasSize - viewSize); + var y0 = Math.Clamp(cy - half, 0, _config.CanvasSize - viewSize); + var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, viewSize)); + + // Colorize: Unknown=#0d1117, Explored=#1f4068, Wall=#3d2d1a + using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13)); // BGR #0d1117 + for (var r = 0; r < viewSize; r++) + for (var c = 0; c < viewSize; c++) + { + var v = roi.At(r, c); + if (v == (byte)MapCell.Explored) + colored.Set(r, c, new Vec3b(104, 64, 31)); // BGR #1f4068 + else if (v == (byte)MapCell.Wall) + colored.Set(r, c, new Vec3b(26, 45, 61)); // BGR #3d2d1a + } + + // Draw player dot + var px = cx - x0; + var py = cy - y0; + if (px >= 0 && px < viewSize && py >= 0 && py < viewSize) + Cv2.Circle(colored, new Point(px, py), 4, new Scalar(0, 140, 255), -1); // orange + + Cv2.ImEncode(".png", colored, out var buf); + return buf; + } + + public void Reset() + { + _canvas.SetTo(Scalar.Black); + } + + public void Dispose() + { + _canvas.Dispose(); + } +} diff --git a/src/Poe2Trade.Ui/App.axaml b/src/Poe2Trade.Ui/App.axaml index ad15f76..91e962e 100644 --- a/src/Poe2Trade.Ui/App.axaml +++ b/src/Poe2Trade.Ui/App.axaml @@ -14,5 +14,6 @@ + diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs index bd065e3..52aef57 100644 --- a/src/Poe2Trade.Ui/App.axaml.cs +++ b/src/Poe2Trade.Ui/App.axaml.cs @@ -49,6 +49,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); var provider = services.BuildServiceProvider(); @@ -58,6 +59,7 @@ public partial class App : Application var mainVm = provider.GetRequiredService(); mainVm.DebugVm = provider.GetRequiredService(); mainVm.SettingsVm = provider.GetRequiredService(); + mainVm.MappingVm = provider.GetRequiredService(); var window = new MainWindow { DataContext = mainVm }; window.SetConfigStore(store); diff --git a/src/Poe2Trade.Ui/Converters/ValueConverters.cs b/src/Poe2Trade.Ui/Converters/ValueConverters.cs index 4315a2e..e6de743 100644 --- a/src/Poe2Trade.Ui/Converters/ValueConverters.cs +++ b/src/Poe2Trade.Ui/Converters/ValueConverters.cs @@ -94,6 +94,23 @@ public class BoolToOverlayBrushConverter : IValueConverter => throw new NotSupportedException(); } +public class MapRequirementsConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value switch + { + MapType.TrialOfChaos => "Trial Token x1", + MapType.Temple => "Identity Scroll x20", + MapType.Endgame => "Identity Scroll x20", + _ => "", + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + public class CellBorderConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs index d55c0f7..116d020 100644 --- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Poe2Trade.Bot; using Poe2Trade.Core; +using Poe2Trade.Navigation; using Serilog; namespace Poe2Trade.Ui.ViewModels; @@ -42,6 +43,9 @@ public partial class MainWindowViewModel : ObservableObject private bool _isStarted; [ObservableProperty] private Bitmap? _inventoryImage; + [ObservableProperty] private Bitmap? _minimapImage; + [ObservableProperty] private string _navigationStateText = ""; + private long _lastMinimapUpdate; [ObservableProperty] private string _newUrl = ""; [ObservableProperty] private string _newLinkName = ""; @@ -49,13 +53,16 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private int _tradesCompleted; [ObservableProperty] private int _tradesFailed; [ObservableProperty] private int _activeLinksCount; + [ObservableProperty] private BotMode _botMode; public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap]; + public static BotMode[] BotModes { get; } = [BotMode.Trading, BotMode.Mapping]; public MainWindowViewModel(BotOrchestrator bot) { _bot = bot; _isPaused = bot.IsPaused; + _botMode = bot.Mode; for (var i = 0; i < 60; i++) InventoryCells.Add(new CellState()); @@ -66,12 +73,14 @@ public partial class MainWindowViewModel : ObservableObject { State = bot.State; IsPaused = bot.IsPaused; + BotMode = bot.Mode; var status = bot.GetStatus(); TradesCompleted = status.TradesCompleted; TradesFailed = status.TradesFailed; ActiveLinksCount = status.Links.Count(l => l.Active); OnPropertyChanged(nameof(Links)); UpdateInventoryGrid(); + UpdateMinimapImage(); }); }; @@ -99,6 +108,12 @@ public partial class MainWindowViewModel : ObservableObject // Sub-ViewModels for tabs public DebugViewModel? DebugVm { get; set; } public SettingsViewModel? SettingsVm { get; set; } + public MappingViewModel? MappingVm { get; set; } + + partial void OnBotModeChanged(BotMode value) + { + _bot.Mode = value; + } [RelayCommand(CanExecute = nameof(CanStart))] private async Task Start() @@ -198,4 +213,39 @@ public partial class MainWindowViewModel : ObservableObject OnPropertyChanged(nameof(InventoryFreeCells)); } + + private void UpdateMinimapImage() + { + var nav = _bot.Navigation; + var navState = nav.State; + NavigationStateText = navState == NavigationState.Idle ? "" : navState.ToString(); + + if (navState == NavigationState.Idle) + { + if (MinimapImage != null) + { + var old = MinimapImage; + MinimapImage = null; + old.Dispose(); + } + return; + } + + // Throttle: update at most once per second + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (now - _lastMinimapUpdate < 1000) return; + _lastMinimapUpdate = now; + + try + { + var bytes = nav.GetViewportSnapshot(); + var old = MinimapImage; + MinimapImage = new Bitmap(new MemoryStream(bytes)); + old?.Dispose(); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to update minimap image"); + } + } } diff --git a/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs new file mode 100644 index 0000000..6ae212e --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs @@ -0,0 +1,25 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Poe2Trade.Bot; +using Poe2Trade.Core; + +namespace Poe2Trade.Ui.ViewModels; + +public partial class MappingViewModel : ObservableObject +{ + private readonly BotOrchestrator _bot; + + [ObservableProperty] private MapType _selectedMapType; + + public static MapType[] MapTypes { get; } = [MapType.TrialOfChaos, MapType.Temple, MapType.Endgame]; + + public MappingViewModel(BotOrchestrator bot) + { + _bot = bot; + _selectedMapType = bot.Config.MapType; + } + + partial void OnSelectedMapTypeChanged(MapType value) + { + _bot.Store.UpdateSettings(s => s.MapType = value); + } +} diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index b4f27e0..109af8f 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -56,7 +56,10 @@ - + +