281 lines
10 KiB
C#
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();
|
|
}
|
|
}
|