From 490fb8bdba35223e06b4161c62274a3a18f9a958 Mon Sep 17 00:00:00 2001 From: Boki Date: Fri, 13 Feb 2026 16:56:44 -0500 Subject: [PATCH] initial BFS movement --- src/Poe2Trade.Bot/BotOrchestrator.cs | 26 +++ src/Poe2Trade.Game/GameController.cs | 2 + src/Poe2Trade.Game/IGameController.cs | 2 + src/Poe2Trade.Game/InputSender.cs | 3 + .../NavigationExecutor.cs | 179 ++++++++++++------ src/Poe2Trade.Navigation/WorldMap.cs | 126 ++++++++---- .../ViewModels/MainWindowViewModel.cs | 21 +- 7 files changed, 258 insertions(+), 101 deletions(-) diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index b03ef54..63bbe9f 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -236,6 +236,32 @@ public class BotOrchestrator : IAsyncDisposable Log.Information("Bot started"); } + public async Task StartMapping() + { + LogWatcher.Start(); + await Game.FocusGame(); + + Navigation.StateChanged += _ => UpdateExecutorState(); + _started = true; + + Emit("info", "Starting map exploration..."); + State = "Exploring"; + _ = Navigation.RunExploreLoop().ContinueWith(t => + { + if (t.IsFaulted) + { + Log.Error(t.Exception!, "Explore loop failed"); + Emit("error", $"Explore loop failed: {t.Exception?.InnerException?.Message}"); + } + else + { + Emit("info", "Exploration finished"); + } + State = "Idle"; + StatusUpdated?.Invoke(); + }); + } + public async ValueTask DisposeAsync() { Log.Information("Shutting down bot..."); diff --git a/src/Poe2Trade.Game/GameController.cs b/src/Poe2Trade.Game/GameController.cs index 0f12ad9..06b0725 100644 --- a/src/Poe2Trade.Game/GameController.cs +++ b/src/Poe2Trade.Game/GameController.cs @@ -75,4 +75,6 @@ public class GameController : IGameController public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL); public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL); public Task ToggleMinimap() => _input.PressKey(InputSender.VK.TAB); + public Task KeyDown(int vkCode) => _input.KeyDown(vkCode); + public Task KeyUp(int vkCode) => _input.KeyUp(vkCode); } diff --git a/src/Poe2Trade.Game/IGameController.cs b/src/Poe2Trade.Game/IGameController.cs index 5879df1..36fbbc5 100644 --- a/src/Poe2Trade.Game/IGameController.cs +++ b/src/Poe2Trade.Game/IGameController.cs @@ -20,4 +20,6 @@ public interface IGameController Task HoldCtrl(); Task ReleaseCtrl(); Task ToggleMinimap(); + Task KeyDown(int vkCode); + Task KeyUp(int vkCode); } diff --git a/src/Poe2Trade.Game/InputSender.cs b/src/Poe2Trade.Game/InputSender.cs index e269ee5..af66dde 100644 --- a/src/Poe2Trade.Game/InputSender.cs +++ b/src/Poe2Trade.Game/InputSender.cs @@ -31,6 +31,9 @@ public class InputSender public const int A = 0x41; public const int C = 0x43; public const int I = 0x49; + public const int W = 0x57; + public const int S = 0x53; + public const int D = 0x44; } public async Task PressKey(int vkCode) diff --git a/src/Poe2Trade.Navigation/NavigationExecutor.cs b/src/Poe2Trade.Navigation/NavigationExecutor.cs index b6a54e1..e77c3e4 100644 --- a/src/Poe2Trade.Navigation/NavigationExecutor.cs +++ b/src/Poe2Trade.Navigation/NavigationExecutor.cs @@ -58,60 +58,61 @@ public class NavigationExecutor : IDisposable _stopped = false; Log.Information("Starting explore loop"); - // Open minimap overlay (Tab) - await _game.ToggleMinimap(); - await Helpers.Sleep(300); + var lastMoveTime = 0L; + var lastClickTime = 0L; + var heldKeys = new HashSet(); // currently held WASD keys - var lastMoveTime = long.MinValue; - - while (!_stopped) + try { - var frameStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - try + while (!_stopped) { - // 1. Capture + track every frame (~30 fps) - SetState(NavigationState.Capturing); - using var frame = _capture.CaptureFrame(); - if (frame == null) + var frameStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + try { - Log.Warning("Failed to capture minimap frame"); - await Helpers.Sleep(_config.CaptureIntervalMs); - continue; - } - - SetState(NavigationState.Processing); - var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask); - if (_worldMap.LastMatchSucceeded) - _capture.CommitWallColors(); - - // Stuck detection: position hasn't moved enough over several frames - if (_lastPosition != null) - { - var dx = pos.X - _lastPosition.X; - var dy = pos.Y - _lastPosition.Y; - if (Math.Sqrt(dx * dx + dy * dy) < _config.StuckThreshold) - _stuckCounter++; - else - _stuckCounter = 0; - } - _lastPosition = pos; - - // 2. Movement decisions at slower rate - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (now - lastMoveTime >= _config.MovementWaitMs) - { - lastMoveTime = now; - - if (_stuckCounter >= _config.StuckFrameCount) + // 1. Capture + track every frame + SetState(NavigationState.Capturing); + using var frame = _capture.CaptureFrame(); + if (frame == null) { - SetState(NavigationState.Stuck); - Log.Information("Stuck detected, clicking random direction"); - await ClickRandomDirection(); + Log.Warning("Failed to capture minimap frame"); + await Helpers.Sleep(_config.CaptureIntervalMs); + continue; } - else + + SetState(NavigationState.Processing); + var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask); + if (_worldMap.LastMatchSucceeded) + _capture.CommitWallColors(); + + // Stuck detection + if (_lastPosition != null) { - SetState(NavigationState.Planning); + var dx = pos.X - _lastPosition.X; + var dy = pos.Y - _lastPosition.Y; + if (Math.Sqrt(dx * dx + dy * dy) < _config.StuckThreshold) + _stuckCounter++; + else + _stuckCounter = 0; + } + _lastPosition = pos; + + // 2. Movement decisions (faster re-evaluation when stuck) + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var moveInterval = _stuckCounter >= _config.StuckFrameCount + ? 200 + : _config.MovementWaitMs; + + if (now - lastMoveTime >= moveInterval) + { + lastMoveTime = now; + + if (_stuckCounter >= _config.StuckFrameCount) + SetState(NavigationState.Stuck); + else + SetState(NavigationState.Planning); + + // BFS finds path through explored cells to nearest frontier var direction = _worldMap.FindNearestUnexplored(pos); if (direction == null) @@ -122,23 +123,45 @@ public class NavigationExecutor : IDisposable } SetState(NavigationState.Moving); - await ClickToMove(direction.Value.dirX, direction.Value.dirY); + await UpdateWasdKeys(heldKeys, direction.Value.dirX, direction.Value.dirY); + + if (_stuckCounter >= _config.StuckFrameCount) + _stuckCounter = 0; // reset after re-routing + } + + // 3. Occasional combat clicks near screen center + if (now - lastClickTime >= 1000 + Rng.Next(1000)) + { + lastClickTime = now; + var cx = 1280 + Rng.Next(-150, 150); + var cy = 720 + Rng.Next(-150, 150); + await _game.LeftClickAt(cx, cy); + await Helpers.Sleep(100 + Rng.Next(100)); + cx = 1280 + Rng.Next(-150, 150); + cy = 720 + Rng.Next(-150, 150); + await _game.RightClickAt(cx, cy); } } - } - catch (Exception ex) - { - Log.Error(ex, "Error in explore loop"); - SetState(NavigationState.Failed); - await Helpers.Sleep(1000); - continue; - } + catch (Exception ex) + { + Log.Error(ex, "Error in explore loop"); + SetState(NavigationState.Failed); + await Helpers.Sleep(1000); + continue; + } - // Sleep remainder of frame interval - var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - frameStart; - var sleepMs = _config.CaptureIntervalMs - (int)elapsed; - if (sleepMs > 0) - await Helpers.Sleep(sleepMs); + // Sleep remainder of frame interval + var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - frameStart; + var sleepMs = _config.CaptureIntervalMs - (int)elapsed; + if (sleepMs > 0) + await Helpers.Sleep(sleepMs); + } + } + finally + { + // Always release held keys + foreach (var key in heldKeys) + await _game.KeyUp(key); } if (_state != NavigationState.Completed) @@ -147,6 +170,39 @@ public class NavigationExecutor : IDisposable Log.Information("Explore loop ended"); } + /// + /// Convert a direction vector to WASD key holds. Releases keys no longer needed, + /// presses new ones. Supports diagonal movement (two keys at once). + /// + private async Task UpdateWasdKeys(HashSet held, double dirX, double dirY) + { + var wanted = new HashSet(); + + // Threshold for diagonal: if both components are significant, hold both keys + const double threshold = 0.3; + if (dirY < -threshold) wanted.Add(InputSender.VK.W); // up + if (dirY > threshold) wanted.Add(InputSender.VK.S); // down + if (dirX < -threshold) wanted.Add(InputSender.VK.A); // left + if (dirX > threshold) wanted.Add(InputSender.VK.D); // right + + // If direction is too weak, default to W + if (wanted.Count == 0) wanted.Add(InputSender.VK.W); + + // Release keys no longer wanted + foreach (var key in held.Except(wanted).ToList()) + { + await _game.KeyUp(key); + held.Remove(key); + } + + // Press newly wanted keys + foreach (var key in wanted.Except(held).ToList()) + { + await _game.KeyDown(key); + held.Add(key); + } + } + private async Task ClickToMove(double dirX, double dirY) { // Player is at minimap center on screen; click offset from center @@ -169,6 +225,7 @@ public class NavigationExecutor : IDisposable await ClickToMove(Math.Cos(angle), Math.Sin(angle)); } + public bool IsExploring => _state != NavigationState.Idle && _state != NavigationState.Completed && _state != NavigationState.Failed; public MapPosition Position => _worldMap.Position; public byte[] GetMapSnapshot() => _worldMap.GetMapSnapshot(); public byte[] GetViewportSnapshot(int viewSize = 400) => _worldMap.GetViewportSnapshot(_worldMap.Position, viewSize); diff --git a/src/Poe2Trade.Navigation/WorldMap.cs b/src/Poe2Trade.Navigation/WorldMap.cs index f69ed2a..99faa28 100644 --- a/src/Poe2Trade.Navigation/WorldMap.cs +++ b/src/Poe2Trade.Navigation/WorldMap.cs @@ -323,57 +323,119 @@ public class WorldMap : IDisposable return totalBlocks > 0 ? (double)cleanBlocks / totalBlocks : 1.0; } - public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 200) + /// + /// BFS through walkable (Explored) cells to find the nearest frontier + /// (Explored cell adjacent to Unknown/Fog). Returns direction toward the + /// first step on the shortest path, respecting walls. + /// + public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 400) { var cx = (int)Math.Round(pos.X); var cy = (int)Math.Round(pos.Y); - var bestAngle = double.NaN; - var bestScore = 0; - const int sectorCount = 16; - var fogRadius = _config.CaptureSize / 2; + // BFS at half resolution for speed (step=2 → ~200x200 effective grid for r=400) + const int step = 2; + var size = _config.CanvasSize; + var rr = searchRadius / step; + var gridW = 2 * rr + 1; - for (var sector = 0; sector < sectorCount; sector++) + // Visited grid + parent tracking (encode parent as single int) + var visited = new bool[gridW * gridW]; + var parentX = new short[gridW * gridW]; + var parentY = new short[gridW * gridW]; + + var queue = new Queue<(int gx, int gy)>(4096); + var startGx = rr; + var startGy = rr; + visited[startGy * gridW + startGx] = true; + parentX[startGy * gridW + startGx] = -1; + parentY[startGy * gridW + startGx] = -1; + queue.Enqueue((startGx, startGy)); + + // 8-connected neighbors + ReadOnlySpan dxs = [-1, 0, 1, -1, 1, -1, 0, 1]; + ReadOnlySpan dys = [-1, -1, -1, 0, 0, 1, 1, 1]; + + int foundGx = -1, foundGy = -1; + + while (queue.Count > 0) { - var angle = 2 * Math.PI * sector / sectorCount; - var score = 0; + var (gx, gy) = queue.Dequeue(); - for (var r = fogRadius - 20; r <= fogRadius + searchRadius; r += 5) + // Map grid coords back to canvas coords + var wx = cx + (gx - rr) * step; + var wy = cy + (gy - rr) * step; + + // Check if this explored cell borders Unknown/Fog (= frontier) + if (gx != startGx || gy != startGy) // skip player cell { - for (var spread = -15; spread <= 15; spread += 5) + for (var d = 0; d < 8; d++) { - 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; - - var cell = _canvas.At(sy, sx); - if (cell == (byte)MapCell.Fog) - score += 2; // prefer visible fog (we know there's unexplored area) - else if (cell == (byte)MapCell.Unknown) - score++; + var nx = wx + dxs[d] * step; + var ny = wy + dys[d] * step; + if (nx < 0 || nx >= size || ny < 0 || ny >= size) continue; + var neighbor = _canvas.At(ny, nx); + if (neighbor == (byte)MapCell.Unknown || neighbor == (byte)MapCell.Fog) + { + foundGx = gx; + foundGy = gy; + goto Found; + } } } - if (score > bestScore) + // Expand to walkable neighbors + for (var d = 0; d < 8; d++) { - bestScore = score; - bestAngle = angle; + var ngx = gx + dxs[d]; + var ngy = gy + dys[d]; + if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue; + + var idx = ngy * gridW + ngx; + if (visited[idx]) continue; + + var nwx = cx + (ngx - rr) * step; + var nwy = cy + (ngy - rr) * step; + if (nwx < 0 || nwx >= size || nwy < 0 || nwy >= size) continue; + + var cell = _canvas.At(nwy, nwx); + if (cell != (byte)MapCell.Explored) continue; // only walk through explored + + visited[idx] = true; + parentX[idx] = (short)gx; + parentY[idx] = (short)gy; + queue.Enqueue((ngx, ngy)); } } - if (bestScore == 0 || double.IsNaN(bestAngle)) + Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius); + return null; + + Found: + // Trace back to first step from start + var traceX = foundGx; + var traceY = foundGy; + while (true) { - Log.Information("No unexplored area found within search radius"); - return null; + var idx = traceY * gridW + traceX; + var px = parentX[idx]; + var py = parentY[idx]; + if (px == startGx && py == startGy) + break; // traceX/traceY is the first step + traceX = px; + traceY = py; } - 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); + var dirX = (double)(traceX - startGx); + var dirY = (double)(traceY - startGy); + var len = Math.Sqrt(dirX * dirX + dirY * dirY); + if (len < 0.001) return (1, 0); // shouldn't happen + + dirX /= len; + dirY /= len; + + var dist = Math.Sqrt((foundGx - startGx) * (foundGx - startGx) + (foundGy - startGy) * (foundGy - startGy)) * step; + Log.Debug("BFS: frontier at {Dist:F0}px, first step dir=({Dx:F2},{Dy:F2})", dist, dirX, dirY); return (dirX, dirY); } diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs index 3adfcc4..364abe5 100644 --- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -34,7 +34,7 @@ public partial class MainWindowViewModel : ObservableObject [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey); - private const int VK_F12 = 0x7B; + private const int VK_END = 0x23; [ObservableProperty] private string _state = "Idle"; @@ -130,7 +130,10 @@ public partial class MainWindowViewModel : ObservableObject { try { - await _bot.Start([]); + if (_bot.Mode == BotMode.Mapping) + await _bot.StartMapping(); + else + await _bot.Start([]); IsStarted = true; } catch (Exception ex) @@ -186,10 +189,10 @@ public partial class MainWindowViewModel : ObservableObject try { // F12 hotkey — edge-detect (trigger once per press) - var f12Down = (GetAsyncKeyState(VK_F12) & 0x8000) != 0; - if (f12Down && !f12WasDown) + var endDown = (GetAsyncKeyState(VK_END) & 0x8000) != 0; + if (endDown && !f12WasDown) { - Log.Information("F12 pressed — emergency stop"); + Log.Information("END pressed — emergency stop"); await _bot.Navigation.Stop(); _bot.Pause(); Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -198,10 +201,12 @@ public partial class MainWindowViewModel : ObservableObject State = "Stopped (F12)"; }); } - f12WasDown = f12Down; + f12WasDown = endDown; - // Minimap capture + display - var bytes = _bot.Navigation.ProcessFrame(SelectedMinimapStage); + // Minimap display: if explore loop owns capture, just render viewport + var bytes = _bot.Navigation.IsExploring + ? _bot.Navigation.GetViewportSnapshot() + : _bot.Navigation.ProcessFrame(SelectedMinimapStage); if (bytes != null) { var bmp = new Bitmap(new MemoryStream(bytes));