poe2-bot/src/Poe2Trade.Navigation/NavigationExecutor.cs
2026-02-13 17:36:33 -05:00

281 lines
10 KiB
C#

using System.Diagnostics;
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 WorldMap _worldMap;
private NavigationState _state = NavigationState.Idle;
private bool _stopped;
private int _stuckCounter;
private MapPosition? _lastPosition;
private static readonly Random Rng = new();
public event Action<NavigationState>? StateChanged;
public NavigationState State => _state;
public NavigationExecutor(IGameController game, MinimapConfig? config = null)
{
_game = game;
_config = config ?? new MinimapConfig();
_capture = new MinimapCapture(_config);
_worldMap = new WorldMap(_config);
_capture.ModeChanged += _ =>
{
_worldMap.Rebootstrap();
_stuckCounter = 0;
_lastPosition = null;
};
}
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()
{
_worldMap.Reset();
_capture.ResetAdaptation();
_stopped = false;
_stuckCounter = 0;
_lastPosition = null;
SetState(NavigationState.Idle);
Log.Information("Navigation reset (new area)");
}
public async Task RunExploreLoop()
{
_stopped = false;
Log.Information("Starting explore loop");
var lastMoveTime = 0L;
var lastClickTime = 0L;
var heldKeys = new HashSet<int>(); // currently held WASD keys
try
{
while (!_stopped)
{
var frameStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
try
{
// 1. Capture + track every frame
SetState(NavigationState.Capturing);
using var frame = _capture.CaptureFrame();
if (frame == null)
{
Log.Warning("Failed to capture minimap frame");
await Helpers.Sleep(_config.CaptureIntervalMs);
continue;
}
SetState(NavigationState.Processing);
var mode = _capture.DetectedMode;
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, mode);
if (_worldMap.LastMatchSucceeded)
_capture.CommitWallColors();
// Stuck detection
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 (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)
{
Log.Information("Map fully explored");
SetState(NavigationState.Completed);
break;
}
SetState(NavigationState.Moving);
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;
}
// 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)
SetState(NavigationState.Idle);
Log.Information("Explore loop ended");
}
/// <summary>
/// Convert a direction vector to WASD key holds. Releases keys no longer needed,
/// presses new ones. Supports diagonal movement (two keys at once).
/// </summary>
private async Task UpdateWasdKeys(HashSet<int> held, double dirX, double dirY)
{
var wanted = new HashSet<int>();
// 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
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 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);
/// <summary>
/// Capture one frame, track position, stitch into world map.
/// Returns PNG bytes for the requested debug stage (or world map viewport by default).
/// </summary>
public byte[]? ProcessFrame(MinimapDebugStage stage = MinimapDebugStage.WorldMap)
{
var sw = Stopwatch.StartNew();
var captureStart = sw.Elapsed.TotalMilliseconds;
using var frame = _capture.CaptureFrame();
var captureMs = sw.Elapsed.TotalMilliseconds - captureStart;
if (frame == null) return null;
var stitchStart = sw.Elapsed.TotalMilliseconds;
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, _capture.DetectedMode);
if (_worldMap.LastMatchSucceeded)
_capture.CommitWallColors();
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
var renderStart = sw.Elapsed.TotalMilliseconds;
byte[]? result;
if (stage == MinimapDebugStage.WorldMap)
result = _worldMap.GetViewportSnapshot(pos);
else
result = _capture.CaptureStage(stage);
var renderMs = sw.Elapsed.TotalMilliseconds - renderStart;
Log.Information("ProcessFrame: capture={Capture:F1}ms stitch={Stitch:F1}ms render={Render:F1}ms total={Total:F1}ms",
captureMs, stitchMs, renderMs, sw.Elapsed.TotalMilliseconds);
return result;
}
public void SaveDebugCapture() => _capture.SaveDebugCapture();
public void Dispose()
{
_capture.Dispose();
_worldMap.Dispose();
}
}