adding stash calibration
This commit is contained in:
parent
23c581cff9
commit
3ae65d0e64
17 changed files with 848 additions and 111 deletions
|
|
@ -32,6 +32,8 @@ public class SavedSettings
|
||||||
public bool Headless { get; set; } = true;
|
public bool Headless { get; set; } = true;
|
||||||
public BotMode Mode { get; set; } = BotMode.Trading;
|
public BotMode Mode { get; set; } = BotMode.Trading;
|
||||||
public MapType MapType { get; set; } = MapType.TrialOfChaos;
|
public MapType MapType { get; set; } = MapType.TrialOfChaos;
|
||||||
|
public StashCalibration? StashCalibration { get; set; }
|
||||||
|
public StashCalibration? ShopCalibration { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConfigStore
|
public class ConfigStore
|
||||||
|
|
|
||||||
18
src/Poe2Trade.Core/StashCalibration.cs
Normal file
18
src/Poe2Trade.Core/StashCalibration.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Poe2Trade.Core;
|
||||||
|
|
||||||
|
public class StashTabInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public int Index { get; set; }
|
||||||
|
public int ClickX { get; set; }
|
||||||
|
public int ClickY { get; set; }
|
||||||
|
public bool IsFolder { get; set; }
|
||||||
|
public int GridCols { get; set; } = 12;
|
||||||
|
public List<StashTabInfo> SubTabs { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StashCalibration
|
||||||
|
{
|
||||||
|
public List<StashTabInfo> Tabs { get; set; } = [];
|
||||||
|
public long CalibratedAt { get; set; }
|
||||||
|
}
|
||||||
177
src/Poe2Trade.Inventory/StashCalibrator.cs
Normal file
177
src/Poe2Trade.Inventory/StashCalibrator.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
using Poe2Trade.Core;
|
||||||
|
using Poe2Trade.Game;
|
||||||
|
using Poe2Trade.Screen;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Inventory;
|
||||||
|
|
||||||
|
public class StashCalibrator
|
||||||
|
{
|
||||||
|
private readonly IScreenReader _screen;
|
||||||
|
private readonly IGameController _game;
|
||||||
|
|
||||||
|
// Tab bar sits above the stash grid
|
||||||
|
private static readonly Region TabBarRegion = new(23, 95, 840, 75);
|
||||||
|
// Sub-tab row between tab bar and folder grid (folders push grid down)
|
||||||
|
private static readonly Region SubTabRegion = new(23, 165, 840, 50);
|
||||||
|
// Horizontal gap (px) between OCR words to split into separate tab names
|
||||||
|
private const int TabGapThreshold = 25;
|
||||||
|
private const int PostClickDelay = 600;
|
||||||
|
|
||||||
|
public StashCalibrator(IScreenReader screen, IGameController game)
|
||||||
|
{
|
||||||
|
_screen = screen;
|
||||||
|
_game = game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calibrates an already-open stash/shop panel.
|
||||||
|
/// OCRs tab bar, clicks each tab, detects folders and grid size.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<StashCalibration> CalibrateOpenPanel()
|
||||||
|
{
|
||||||
|
var tabs = await OcrTabBar(TabBarRegion);
|
||||||
|
Log.Information("StashCalibrator: found {Count} tabs: {Names}",
|
||||||
|
tabs.Count, string.Join(", ", tabs.Select(t => t.Name)));
|
||||||
|
|
||||||
|
for (var i = 0; i < tabs.Count; i++)
|
||||||
|
{
|
||||||
|
var tab = tabs[i];
|
||||||
|
tab.Index = i;
|
||||||
|
|
||||||
|
// Click this tab
|
||||||
|
await _game.LeftClickAt(tab.ClickX, tab.ClickY);
|
||||||
|
await Helpers.Sleep(PostClickDelay);
|
||||||
|
|
||||||
|
// Check for sub-tabs (folder detection)
|
||||||
|
var subTabs = await OcrTabBar(SubTabRegion);
|
||||||
|
if (subTabs.Count > 0)
|
||||||
|
{
|
||||||
|
tab.IsFolder = true;
|
||||||
|
Log.Information("StashCalibrator: tab '{Name}' is a folder with {Count} sub-tabs",
|
||||||
|
tab.Name, subTabs.Count);
|
||||||
|
|
||||||
|
for (var j = 0; j < subTabs.Count; j++)
|
||||||
|
{
|
||||||
|
var sub = subTabs[j];
|
||||||
|
sub.Index = j;
|
||||||
|
|
||||||
|
// Click sub-tab to detect its grid size
|
||||||
|
await _game.LeftClickAt(sub.ClickX, sub.ClickY);
|
||||||
|
await Helpers.Sleep(PostClickDelay);
|
||||||
|
sub.GridCols = await DetectGridSize(isFolder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.SubTabs = subTabs;
|
||||||
|
// Folder's own grid cols = first sub-tab's (they're usually the same)
|
||||||
|
tab.GridCols = subTabs[0].GridCols;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tab.IsFolder = false;
|
||||||
|
tab.GridCols = await DetectGridSize(isFolder: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StashCalibration
|
||||||
|
{
|
||||||
|
Tabs = tabs,
|
||||||
|
CalibratedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OCR a region and group words into tab names by horizontal gap.
|
||||||
|
/// Returns list with screen-absolute click positions.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
|
||||||
|
{
|
||||||
|
var ocr = await _screen.Ocr(region);
|
||||||
|
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
|
||||||
|
if (allWords.Count == 0) return [];
|
||||||
|
|
||||||
|
return GroupWordsIntoTabs(allWords, region, TabGapThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups OCR words into tab names based on horizontal gaps.
|
||||||
|
/// Words within gapThreshold px → same tab. Larger gaps → separate tabs.
|
||||||
|
/// Converts region-relative coords to screen-absolute.
|
||||||
|
/// </summary>
|
||||||
|
private static List<StashTabInfo> GroupWordsIntoTabs(List<OcrWord> words, Region region, int gapThreshold)
|
||||||
|
{
|
||||||
|
// Sort left-to-right by X position
|
||||||
|
var sorted = words.OrderBy(w => w.X).ToList();
|
||||||
|
var tabs = new List<StashTabInfo>();
|
||||||
|
|
||||||
|
var currentWords = new List<OcrWord> { sorted[0] };
|
||||||
|
|
||||||
|
for (var i = 1; i < sorted.Count; i++)
|
||||||
|
{
|
||||||
|
var prev = currentWords[^1];
|
||||||
|
var curr = sorted[i];
|
||||||
|
var gap = curr.X - (prev.X + prev.Width);
|
||||||
|
|
||||||
|
if (gap > gapThreshold)
|
||||||
|
{
|
||||||
|
// Flush current group as a tab
|
||||||
|
tabs.Add(BuildTab(currentWords, region));
|
||||||
|
currentWords = [curr];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentWords.Add(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush last group
|
||||||
|
tabs.Add(BuildTab(currentWords, region));
|
||||||
|
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StashTabInfo BuildTab(List<OcrWord> words, Region region)
|
||||||
|
{
|
||||||
|
var name = string.Join(" ", words.Select(w => w.Text));
|
||||||
|
var minX = words.Min(w => w.X);
|
||||||
|
var maxX = words.Max(w => w.X + w.Width);
|
||||||
|
var minY = words.Min(w => w.Y);
|
||||||
|
var maxY = words.Max(w => w.Y + w.Height);
|
||||||
|
|
||||||
|
// Click center of the bounding box, converted to screen-absolute
|
||||||
|
var clickX = region.X + (minX + maxX) / 2;
|
||||||
|
var clickY = region.Y + (minY + maxY) / 2;
|
||||||
|
|
||||||
|
return new StashTabInfo
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
ClickX = clickX,
|
||||||
|
ClickY = clickY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect grid size (12 or 24 columns) by scanning with both layouts.
|
||||||
|
/// The correct layout aligns with actual cells, so empty cells match the
|
||||||
|
/// empty template → lower occupancy. The wrong layout misaligns → ~100% occupied.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> DetectGridSize(bool isFolder)
|
||||||
|
{
|
||||||
|
var layout12 = isFolder ? "stash12_folder" : "stash12";
|
||||||
|
var layout24 = isFolder ? "stash24_folder" : "stash24";
|
||||||
|
|
||||||
|
var scan12 = await _screen.Grid.Scan(layout12);
|
||||||
|
var scan24 = await _screen.Grid.Scan(layout24);
|
||||||
|
|
||||||
|
var total12 = scan12.Layout.Cols * scan12.Layout.Rows;
|
||||||
|
var total24 = scan24.Layout.Cols * scan24.Layout.Rows;
|
||||||
|
|
||||||
|
var rate12 = (double)scan12.Occupied.Count / total12;
|
||||||
|
var rate24 = (double)scan24.Occupied.Count / total24;
|
||||||
|
|
||||||
|
Log.Information("StashCalibrator: grid detection - 12col={Rate12:P1} ({Occ12}/{Tot12}), 24col={Rate24:P1} ({Occ24}/{Tot24})",
|
||||||
|
rate12, scan12.Occupied.Count, total12, rate24, scan24.Occupied.Count, total24);
|
||||||
|
|
||||||
|
return rate12 <= rate24 ? 12 : 24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -171,61 +171,27 @@ public class NavigationExecutor : IDisposable
|
||||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
if (NeedsRepath(now))
|
if (NeedsRepath(now))
|
||||||
{
|
{
|
||||||
if (_stuck.IsStuck)
|
if (TryRepath(pos, now))
|
||||||
SetState(NavigationState.Stuck);
|
|
||||||
else
|
|
||||||
SetState(NavigationState.Planning);
|
|
||||||
|
|
||||||
(double dirX, double dirY)? direction = null;
|
|
||||||
|
|
||||||
if (_checkpointGoal is { } cpGoal)
|
|
||||||
{
|
{
|
||||||
// Try to path to checkpoint goal
|
SetState(NavigationState.Completed);
|
||||||
direction = _worldMap.FindPathToTarget(pos, cpGoal);
|
break;
|
||||||
if (direction == null)
|
|
||||||
{
|
|
||||||
Log.Information("Checkpoint unreachable, clearing goal");
|
|
||||||
_checkpointGoal = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_checkpointGoal == null)
|
|
||||||
{
|
|
||||||
// Look for nearby checkpoint to collect opportunistically
|
|
||||||
var nearCp = _worldMap.GetNearestCheckpointOff(pos);
|
|
||||||
if (nearCp != null)
|
|
||||||
{
|
|
||||||
_checkpointGoal = nearCp.Value;
|
|
||||||
direction = _worldMap.FindPathToTarget(pos, nearCp.Value);
|
|
||||||
if (direction != null)
|
|
||||||
Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y);
|
|
||||||
else
|
|
||||||
_checkpointGoal = null; // unreachable, fall through to frontier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to normal frontier exploration
|
|
||||||
if (direction == null)
|
|
||||||
{
|
|
||||||
direction = _worldMap.FindNearestUnexplored(pos);
|
|
||||||
if (direction == null)
|
|
||||||
{
|
|
||||||
Log.Information("Map fully explored");
|
|
||||||
SetState(NavigationState.Completed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_currentPath = _worldMap.LastBfsPath;
|
|
||||||
_pathIndex = 0;
|
|
||||||
_lastPathTime = now;
|
|
||||||
|
|
||||||
if (_stuck.IsStuck)
|
|
||||||
_stuck.Reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Every frame: follow path waypoints → post direction to input loop
|
// 4. Follow path → if endpoint reached, immediately repath for seamless movement
|
||||||
var dir = FollowPath(pos);
|
var dir = FollowPath(pos);
|
||||||
|
if (dir == null)
|
||||||
|
{
|
||||||
|
_currentPath = null; // force NeedsRepath on retry
|
||||||
|
now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
if (TryRepath(pos, now))
|
||||||
|
{
|
||||||
|
SetState(NavigationState.Completed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = FollowPath(pos);
|
||||||
|
}
|
||||||
|
|
||||||
if (dir != null)
|
if (dir != null)
|
||||||
{
|
{
|
||||||
SetState(NavigationState.Moving);
|
SetState(NavigationState.Moving);
|
||||||
|
|
@ -365,6 +331,63 @@ public class NavigationExecutor : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compute a new path (checkpoint → nearby checkpoint → frontier).
|
||||||
|
/// Returns true if map is fully explored (no more frontiers).
|
||||||
|
/// </summary>
|
||||||
|
private bool TryRepath(MapPosition pos, long nowMs)
|
||||||
|
{
|
||||||
|
if (_stuck.IsStuck)
|
||||||
|
SetState(NavigationState.Stuck);
|
||||||
|
else
|
||||||
|
SetState(NavigationState.Planning);
|
||||||
|
|
||||||
|
(double dirX, double dirY)? direction = null;
|
||||||
|
|
||||||
|
if (_checkpointGoal is { } cpGoal)
|
||||||
|
{
|
||||||
|
direction = _worldMap.FindPathToTarget(pos, cpGoal);
|
||||||
|
if (direction == null)
|
||||||
|
{
|
||||||
|
Log.Information("Checkpoint unreachable, clearing goal");
|
||||||
|
_checkpointGoal = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_checkpointGoal == null)
|
||||||
|
{
|
||||||
|
var nearCp = _worldMap.GetNearestCheckpointOff(pos);
|
||||||
|
if (nearCp != null)
|
||||||
|
{
|
||||||
|
_checkpointGoal = nearCp.Value;
|
||||||
|
direction = _worldMap.FindPathToTarget(pos, nearCp.Value);
|
||||||
|
if (direction != null)
|
||||||
|
Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y);
|
||||||
|
else
|
||||||
|
_checkpointGoal = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction == null)
|
||||||
|
{
|
||||||
|
direction = _worldMap.FindNearestUnexplored(pos);
|
||||||
|
if (direction == null)
|
||||||
|
{
|
||||||
|
Log.Information("Map fully explored");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPath = _worldMap.LastBfsPath;
|
||||||
|
_pathIndex = 0;
|
||||||
|
_lastPathTime = nowMs;
|
||||||
|
|
||||||
|
if (_stuck.IsStuck)
|
||||||
|
_stuck.Reset();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
|
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -415,7 +438,11 @@ public class NavigationExecutor : IDisposable
|
||||||
var tdx = target.X - px;
|
var tdx = target.X - px;
|
||||||
var tdy = target.Y - py;
|
var tdy = target.Y - py;
|
||||||
var len = Math.Sqrt(tdx * tdx + tdy * tdy);
|
var len = Math.Sqrt(tdx * tdx + tdy * tdy);
|
||||||
if (len < 1) return null; // essentially at target
|
if (len < 1)
|
||||||
|
{
|
||||||
|
_pathIndex = _currentPath.Count; // mark consumed so NeedsRepath triggers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (tdx / len, tdy / len);
|
return (tdx / len, tdy / len);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,32 +40,57 @@ internal class PathFinder
|
||||||
var gridLen = gridW * gridW;
|
var gridLen = gridW * gridW;
|
||||||
|
|
||||||
var visited = new bool[gridLen];
|
var visited = new bool[gridLen];
|
||||||
var dist = new short[gridLen];
|
var cost = new int[gridLen];
|
||||||
var parentX = new short[gridLen];
|
var parentX = new short[gridLen];
|
||||||
var parentY = new short[gridLen];
|
var parentY = new short[gridLen];
|
||||||
|
|
||||||
var queue = new Queue<(int gx, int gy)>(4096);
|
|
||||||
var startGx = rr;
|
|
||||||
var startGy = rr;
|
|
||||||
var startIdx = startGy * gridW + startGx;
|
|
||||||
visited[startIdx] = true;
|
|
||||||
parentX[startIdx] = (short)startGx;
|
|
||||||
parentY[startIdx] = (short)startGy;
|
|
||||||
queue.Enqueue((startGx, startGy));
|
|
||||||
|
|
||||||
// 8-connected neighbors
|
// 8-connected neighbors
|
||||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
||||||
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
||||||
|
|
||||||
// Step A: BFS flood-fill, recording dist and parents
|
// Precompute wall proximity: count of 8 canvas-level neighbors that are Wall
|
||||||
|
var wallNear = new byte[gridLen];
|
||||||
|
for (var gy = 0; gy < gridW; gy++)
|
||||||
|
{
|
||||||
|
for (var gx = 0; gx < gridW; gx++)
|
||||||
|
{
|
||||||
|
var wx = cx + (gx - rr) * step;
|
||||||
|
var wy = cy + (gy - rr) * step;
|
||||||
|
if (wx < 1 || wx >= size - 1 || wy < 1 || wy >= size - 1) continue;
|
||||||
|
byte count = 0;
|
||||||
|
for (var d = 0; d < 8; d++)
|
||||||
|
{
|
||||||
|
if (canvas.At<byte>(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall)
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
wallNear[gy * gridW + gx] = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dijkstra setup
|
||||||
|
Array.Fill(cost, int.MaxValue);
|
||||||
|
var startGx = rr;
|
||||||
|
var startGy = rr;
|
||||||
|
var startIdx = startGy * gridW + startGx;
|
||||||
|
cost[startIdx] = 0;
|
||||||
|
parentX[startIdx] = (short)startGx;
|
||||||
|
parentY[startIdx] = (short)startGy;
|
||||||
|
|
||||||
|
var pq = new PriorityQueue<(int gx, int gy), int>(4096);
|
||||||
|
pq.Enqueue((startGx, startGy), 0);
|
||||||
|
|
||||||
|
// Step A: Dijkstra flood-fill with wall-proximity cost
|
||||||
var isFrontier = new bool[gridLen];
|
var isFrontier = new bool[gridLen];
|
||||||
var frontierCells = new List<(int gx, int gy)>();
|
var frontierCells = new List<(int gx, int gy)>();
|
||||||
|
|
||||||
while (queue.Count > 0)
|
while (pq.Count > 0)
|
||||||
{
|
{
|
||||||
var (gx, gy) = queue.Dequeue();
|
var (gx, gy) = pq.Dequeue();
|
||||||
var cellIdx = gy * gridW + gx;
|
var cellIdx = gy * gridW + gx;
|
||||||
|
|
||||||
|
if (visited[cellIdx]) continue;
|
||||||
|
visited[cellIdx] = true;
|
||||||
|
|
||||||
// Map grid coords back to canvas coords
|
// Map grid coords back to canvas coords
|
||||||
var wx = cx + (gx - rr) * step;
|
var wx = cx + (gx - rr) * step;
|
||||||
var wy = cy + (gy - rr) * step;
|
var wy = cy + (gy - rr) * step;
|
||||||
|
|
@ -105,11 +130,14 @@ internal class PathFinder
|
||||||
var cell = canvas.At<byte>(nwy, nwx);
|
var cell = canvas.At<byte>(nwy, nwx);
|
||||||
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
||||||
|
|
||||||
visited[idx] = true;
|
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
|
||||||
dist[idx] = (short)(dist[cellIdx] + 1);
|
if (newCost < cost[idx])
|
||||||
parentX[idx] = (short)gx;
|
{
|
||||||
parentY[idx] = (short)gy;
|
cost[idx] = newCost;
|
||||||
queue.Enqueue((ngx, ngy));
|
parentX[idx] = (short)gx;
|
||||||
|
parentY[idx] = (short)gy;
|
||||||
|
pq.Enqueue((ngx, ngy), newCost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +166,7 @@ internal class PathFinder
|
||||||
clusterCount++;
|
clusterCount++;
|
||||||
// Flood-fill this cluster
|
// Flood-fill this cluster
|
||||||
var clusterCells = new List<(int gx, int gy)>();
|
var clusterCells = new List<(int gx, int gy)>();
|
||||||
var minDist = dist[fIdx];
|
var minCost = cost[fIdx];
|
||||||
var entryGx = fgx;
|
var entryGx = fgx;
|
||||||
var entryGy = fgy;
|
var entryGy = fgy;
|
||||||
|
|
||||||
|
|
@ -151,9 +179,9 @@ internal class PathFinder
|
||||||
clusterCells.Add((cgx, cgy));
|
clusterCells.Add((cgx, cgy));
|
||||||
|
|
||||||
var cIdx = cgy * gridW + cgx;
|
var cIdx = cgy * gridW + cgx;
|
||||||
if (dist[cIdx] < minDist)
|
if (cost[cIdx] < minCost)
|
||||||
{
|
{
|
||||||
minDist = dist[cIdx];
|
minCost = cost[cIdx];
|
||||||
entryGx = cgx;
|
entryGx = cgx;
|
||||||
entryGy = cgy;
|
entryGy = cgy;
|
||||||
}
|
}
|
||||||
|
|
@ -170,21 +198,30 @@ internal class PathFinder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step D: Score this cluster
|
// Step D: Score this cluster — skip tiny nooks
|
||||||
|
const int MinClusterSize = 8;
|
||||||
var gain = clusterCells.Count;
|
var gain = clusterCells.Count;
|
||||||
var cost = (int)minDist;
|
if (gain < MinClusterSize) continue;
|
||||||
var score = gain / (cost + 1.0);
|
var pathCost = (int)minCost;
|
||||||
|
var score = gain / (pathCost + 1.0);
|
||||||
|
|
||||||
if (score > bestClusterScore)
|
if (score > bestClusterScore)
|
||||||
{
|
{
|
||||||
bestClusterScore = score;
|
bestClusterScore = score;
|
||||||
bestClusterGain = gain;
|
bestClusterGain = gain;
|
||||||
bestClusterCost = cost;
|
bestClusterCost = pathCost;
|
||||||
bestEntryGx = entryGx;
|
bestEntryGx = entryGx;
|
||||||
bestEntryGy = entryGy;
|
bestEntryGy = entryGy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bestEntryGx < 0)
|
||||||
|
{
|
||||||
|
Log.Information("BFS: all {Count} frontier clusters too small (< 8 cells)", clusterCount);
|
||||||
|
LastResult = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Step E: Trace path from entry cell of winning cluster back to start
|
// Step E: Trace path from entry cell of winning cluster back to start
|
||||||
var rawPath = new List<Point>();
|
var rawPath = new List<Point>();
|
||||||
var traceGx = bestEntryGx;
|
var traceGx = bestEntryGx;
|
||||||
|
|
@ -240,31 +277,60 @@ internal class PathFinder
|
||||||
var rr = searchRadius / step;
|
var rr = searchRadius / step;
|
||||||
var gridW = 2 * rr + 1;
|
var gridW = 2 * rr + 1;
|
||||||
|
|
||||||
var visited = new bool[gridW * gridW];
|
var gridLen = gridW * gridW;
|
||||||
var parentX = new short[gridW * gridW];
|
var visited = new bool[gridLen];
|
||||||
var parentY = new short[gridW * gridW];
|
var cost = new int[gridLen];
|
||||||
|
var parentX = new short[gridLen];
|
||||||
var queue = new Queue<(int gx, int gy)>(4096);
|
var parentY = new short[gridLen];
|
||||||
var startGx = rr;
|
|
||||||
var startGy = rr;
|
|
||||||
var startIdx = startGy * gridW + startGx;
|
|
||||||
visited[startIdx] = true;
|
|
||||||
parentX[startIdx] = (short)startGx;
|
|
||||||
parentY[startIdx] = (short)startGy;
|
|
||||||
queue.Enqueue((startGx, startGy));
|
|
||||||
|
|
||||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
||||||
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
||||||
|
|
||||||
|
// Precompute wall proximity: count of 8 canvas-level neighbors that are Wall
|
||||||
|
var wallNear = new byte[gridLen];
|
||||||
|
for (var gy = 0; gy < gridW; gy++)
|
||||||
|
{
|
||||||
|
for (var gx = 0; gx < gridW; gx++)
|
||||||
|
{
|
||||||
|
var wx = cx + (gx - rr) * step;
|
||||||
|
var wy = cy + (gy - rr) * step;
|
||||||
|
if (wx < 1 || wx >= canvasSize - 1 || wy < 1 || wy >= canvasSize - 1) continue;
|
||||||
|
byte count = 0;
|
||||||
|
for (var d = 0; d < 8; d++)
|
||||||
|
{
|
||||||
|
if (canvas.At<byte>(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall)
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
wallNear[gy * gridW + gx] = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dijkstra setup
|
||||||
|
Array.Fill(cost, int.MaxValue);
|
||||||
|
var startGx = rr;
|
||||||
|
var startGy = rr;
|
||||||
|
var startIdx = startGy * gridW + startGx;
|
||||||
|
cost[startIdx] = 0;
|
||||||
|
parentX[startIdx] = (short)startGx;
|
||||||
|
parentY[startIdx] = (short)startGy;
|
||||||
|
|
||||||
|
var pq = new PriorityQueue<(int gx, int gy), int>(4096);
|
||||||
|
pq.Enqueue((startGx, startGy), 0);
|
||||||
|
|
||||||
const int arrivalDist = 10;
|
const int arrivalDist = 10;
|
||||||
const int arrivalDist2 = arrivalDist * arrivalDist;
|
const int arrivalDist2 = arrivalDist * arrivalDist;
|
||||||
|
|
||||||
var foundGx = -1;
|
var foundGx = -1;
|
||||||
var foundGy = -1;
|
var foundGy = -1;
|
||||||
|
|
||||||
while (queue.Count > 0)
|
while (pq.Count > 0)
|
||||||
{
|
{
|
||||||
var (gx, gy) = queue.Dequeue();
|
var (gx, gy) = pq.Dequeue();
|
||||||
|
var cellIdx = gy * gridW + gx;
|
||||||
|
|
||||||
|
if (visited[cellIdx]) continue;
|
||||||
|
visited[cellIdx] = true;
|
||||||
|
|
||||||
var wx = cx + (gx - rr) * step;
|
var wx = cx + (gx - rr) * step;
|
||||||
var wy = cy + (gy - rr) * step;
|
var wy = cy + (gy - rr) * step;
|
||||||
|
|
||||||
|
|
@ -294,10 +360,14 @@ internal class PathFinder
|
||||||
var cell = canvas.At<byte>(nwy, nwx);
|
var cell = canvas.At<byte>(nwy, nwx);
|
||||||
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
||||||
|
|
||||||
visited[idx] = true;
|
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
|
||||||
parentX[idx] = (short)gx;
|
if (newCost < cost[idx])
|
||||||
parentY[idx] = (short)gy;
|
{
|
||||||
queue.Enqueue((ngx, ngy));
|
cost[idx] = newCost;
|
||||||
|
parentX[idx] = (short)gx;
|
||||||
|
parentY[idx] = (short)gy;
|
||||||
|
pq.Enqueue((ngx, ngy), newCost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Poe2Trade.GameLog;
|
||||||
using Poe2Trade.Inventory;
|
using Poe2Trade.Inventory;
|
||||||
using Poe2Trade.Screen;
|
using Poe2Trade.Screen;
|
||||||
using Poe2Trade.Trade;
|
using Poe2Trade.Trade;
|
||||||
|
using Poe2Trade.Ui.Overlay;
|
||||||
using Poe2Trade.Ui.ViewModels;
|
using Poe2Trade.Ui.ViewModels;
|
||||||
using Poe2Trade.Ui.Views;
|
using Poe2Trade.Ui.Views;
|
||||||
|
|
||||||
|
|
@ -66,8 +67,12 @@ public partial class App : Application
|
||||||
window.SetConfigStore(store);
|
window.SetConfigStore(store);
|
||||||
desktop.MainWindow = window;
|
desktop.MainWindow = window;
|
||||||
|
|
||||||
|
var overlay = new OverlayWindow(bot);
|
||||||
|
overlay.Show();
|
||||||
|
|
||||||
desktop.ShutdownRequested += async (_, _) =>
|
desktop.ShutdownRequested += async (_, _) =>
|
||||||
{
|
{
|
||||||
|
overlay.Close();
|
||||||
mainVm.Shutdown();
|
mainVm.Shutdown();
|
||||||
await bot.DisposeAsync();
|
await bot.DisposeAsync();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
19
src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs
Normal file
19
src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Poe2Trade.Navigation;
|
||||||
|
using Poe2Trade.Screen;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
public record OverlayState(
|
||||||
|
IReadOnlyList<DetectedEnemy> Enemies,
|
||||||
|
float InferenceMs,
|
||||||
|
HudSnapshot? Hud,
|
||||||
|
NavigationState NavState,
|
||||||
|
MapPosition NavPosition,
|
||||||
|
bool IsExploring,
|
||||||
|
double Fps);
|
||||||
|
|
||||||
|
public interface IOverlayLayer
|
||||||
|
{
|
||||||
|
void Draw(DrawingContext dc, OverlayState state);
|
||||||
|
}
|
||||||
60
src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs
Normal file
60
src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
public class DebugTextLayer : IOverlayLayer
|
||||||
|
{
|
||||||
|
private static readonly Typeface MonoTypeface = new("Consolas");
|
||||||
|
private static readonly IBrush TextBrush = new SolidColorBrush(Color.FromRgb(80, 255, 80));
|
||||||
|
private static readonly IBrush Background = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0));
|
||||||
|
|
||||||
|
private const double PadX = 8;
|
||||||
|
private const double PadY = 4;
|
||||||
|
private const double StartX = 10;
|
||||||
|
private const double StartY = 10;
|
||||||
|
private const double FontSize = 13;
|
||||||
|
|
||||||
|
public void Draw(DrawingContext dc, OverlayState state)
|
||||||
|
{
|
||||||
|
var lines = new List<string>(8)
|
||||||
|
{
|
||||||
|
$"FPS: {state.Fps:F0}",
|
||||||
|
$"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}",
|
||||||
|
$"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})",
|
||||||
|
$"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.Hud is { Timestamp: > 0 } hud)
|
||||||
|
{
|
||||||
|
lines.Add($"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure max width for background
|
||||||
|
double maxWidth = 0;
|
||||||
|
double totalHeight = 0;
|
||||||
|
var formatted = new List<FormattedText>(lines.Count);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var ft = new FormattedText(line, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
FlowDirection.LeftToRight, MonoTypeface, FontSize, TextBrush);
|
||||||
|
formatted.Add(ft);
|
||||||
|
if (ft.Width > maxWidth) maxWidth = ft.Width;
|
||||||
|
totalHeight += ft.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
dc.DrawRectangle(Background, null,
|
||||||
|
new Rect(StartX - PadX, StartY - PadY,
|
||||||
|
maxWidth + PadX * 2, totalHeight + PadY * 2));
|
||||||
|
|
||||||
|
// Draw text lines
|
||||||
|
var y = StartY;
|
||||||
|
foreach (var ft in formatted)
|
||||||
|
{
|
||||||
|
dc.DrawText(ft, new Point(StartX, y));
|
||||||
|
y += ft.Height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs
Normal file
36
src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
public class EnemyBoxLayer : IOverlayLayer
|
||||||
|
{
|
||||||
|
// Pre-allocated pens — zero allocation per frame
|
||||||
|
private static readonly IPen ConfirmedPen = new Pen(Brushes.Red, 2);
|
||||||
|
private static readonly IPen UnconfirmedPen = new Pen(Brushes.Yellow, 2);
|
||||||
|
private static readonly Typeface LabelTypeface = new("Consolas");
|
||||||
|
private static readonly IBrush LabelBackground = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0));
|
||||||
|
|
||||||
|
public void Draw(DrawingContext dc, OverlayState state)
|
||||||
|
{
|
||||||
|
foreach (var enemy in state.Enemies)
|
||||||
|
{
|
||||||
|
var pen = enemy.HealthBarConfirmed ? ConfirmedPen : UnconfirmedPen;
|
||||||
|
var rect = new Rect(enemy.X, enemy.Y, enemy.Width, enemy.Height);
|
||||||
|
dc.DrawRectangle(null, pen, rect);
|
||||||
|
|
||||||
|
// Confidence label above the box
|
||||||
|
var label = $"{enemy.Confidence:P0}";
|
||||||
|
var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
FlowDirection.LeftToRight, LabelTypeface, 12, pen.Brush);
|
||||||
|
|
||||||
|
var labelX = enemy.X;
|
||||||
|
var labelY = enemy.Y - text.Height - 2;
|
||||||
|
|
||||||
|
// Background for readability
|
||||||
|
dc.DrawRectangle(LabelBackground, null,
|
||||||
|
new Rect(labelX - 1, labelY - 1, text.Width + 2, text.Height + 2));
|
||||||
|
dc.DrawText(text, new Point(labelX, labelY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs
Normal file
47
src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
public class HudInfoLayer : IOverlayLayer
|
||||||
|
{
|
||||||
|
private static readonly IBrush LifeBrush = new SolidColorBrush(Color.FromRgb(200, 40, 40));
|
||||||
|
private static readonly IBrush ManaBrush = new SolidColorBrush(Color.FromRgb(40, 80, 200));
|
||||||
|
private static readonly IBrush BarBackground = new SolidColorBrush(Color.FromArgb(140, 20, 20, 20));
|
||||||
|
private static readonly IPen BarBorder = new Pen(Brushes.Gray, 1);
|
||||||
|
private static readonly Typeface ValueTypeface = new("Consolas");
|
||||||
|
|
||||||
|
// Bar dimensions — positioned bottom-center above globe area
|
||||||
|
private const double BarWidth = 200;
|
||||||
|
private const double BarHeight = 16;
|
||||||
|
private const double BarY = 1300; // above the globe at 2560x1440
|
||||||
|
private const double LifeBarX = 1130; // left of center
|
||||||
|
private const double ManaBarX = 1230; // right of center
|
||||||
|
|
||||||
|
public void Draw(DrawingContext dc, OverlayState state)
|
||||||
|
{
|
||||||
|
if (state.Hud == null || state.Hud.Timestamp == 0) return;
|
||||||
|
|
||||||
|
DrawBar(dc, LifeBarX, BarY, state.Hud.LifePct, LifeBrush, state.Hud.Life);
|
||||||
|
DrawBar(dc, ManaBarX, BarY, state.Hud.ManaPct, ManaBrush, state.Hud.Mana);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawBar(DrawingContext dc, double x, double y, float pct,
|
||||||
|
IBrush fillBrush, Screen.HudValues? values)
|
||||||
|
{
|
||||||
|
var outer = new Rect(x, y, BarWidth, BarHeight);
|
||||||
|
dc.DrawRectangle(BarBackground, BarBorder, outer);
|
||||||
|
|
||||||
|
var fillWidth = BarWidth * Math.Clamp(pct, 0, 1);
|
||||||
|
if (fillWidth > 0)
|
||||||
|
dc.DrawRectangle(fillBrush, null, new Rect(x, y, fillWidth, BarHeight));
|
||||||
|
|
||||||
|
if (values != null)
|
||||||
|
{
|
||||||
|
var label = $"{values.Current}/{values.Max}";
|
||||||
|
var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
FlowDirection.LeftToRight, ValueTypeface, 11, Brushes.White);
|
||||||
|
dc.DrawText(text, new Point(x + (BarWidth - text.Width) / 2, y + (BarHeight - text.Height) / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs
Normal file
104
src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Poe2Trade.Bot;
|
||||||
|
using Poe2Trade.Navigation;
|
||||||
|
using Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
public class OverlayCanvas : Control
|
||||||
|
{
|
||||||
|
private readonly List<IOverlayLayer> _layers = [];
|
||||||
|
private BotOrchestrator? _bot;
|
||||||
|
private DispatcherTimer? _timer;
|
||||||
|
private nint _hwnd;
|
||||||
|
private bool _shown;
|
||||||
|
|
||||||
|
// FPS tracking
|
||||||
|
private readonly Stopwatch _fpsWatch = new();
|
||||||
|
private int _frameCount;
|
||||||
|
private double _fps;
|
||||||
|
|
||||||
|
public void Initialize(BotOrchestrator bot)
|
||||||
|
{
|
||||||
|
_bot = bot;
|
||||||
|
|
||||||
|
_layers.Add(new EnemyBoxLayer());
|
||||||
|
_layers.Add(new HudInfoLayer());
|
||||||
|
_layers.Add(new DebugTextLayer());
|
||||||
|
|
||||||
|
_fpsWatch.Start();
|
||||||
|
|
||||||
|
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; // ~30fps
|
||||||
|
_timer.Tick += OnTick;
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_bot == null) return;
|
||||||
|
|
||||||
|
// Lazily grab the HWND once the window is realized
|
||||||
|
if (_hwnd == 0)
|
||||||
|
{
|
||||||
|
var handle = ((Window?)VisualRoot)?.TryGetPlatformHandle();
|
||||||
|
if (handle != null) _hwnd = handle.Handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide overlay based on game focus — use native Win32 calls
|
||||||
|
// to avoid Avalonia's Show() which activates the window and steals focus
|
||||||
|
if (_hwnd != 0)
|
||||||
|
{
|
||||||
|
var focused = _bot.Game.IsGameFocused();
|
||||||
|
if (focused && !_shown)
|
||||||
|
{
|
||||||
|
OverlayNativeMethods.ShowNoActivate(_hwnd);
|
||||||
|
_shown = true;
|
||||||
|
}
|
||||||
|
else if (!focused && _shown)
|
||||||
|
{
|
||||||
|
OverlayNativeMethods.HideWindow(_hwnd);
|
||||||
|
_shown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Render(DrawingContext dc)
|
||||||
|
{
|
||||||
|
if (_bot == null) return;
|
||||||
|
|
||||||
|
// Update FPS
|
||||||
|
_frameCount++;
|
||||||
|
var elapsed = _fpsWatch.Elapsed.TotalSeconds;
|
||||||
|
if (elapsed >= 1.0)
|
||||||
|
{
|
||||||
|
_fps = _frameCount / elapsed;
|
||||||
|
_frameCount = 0;
|
||||||
|
_fpsWatch.Restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build state snapshot from volatile sources
|
||||||
|
var detection = _bot.EnemyDetector.Latest;
|
||||||
|
var state = new OverlayState(
|
||||||
|
Enemies: detection.Enemies,
|
||||||
|
InferenceMs: detection.InferenceMs,
|
||||||
|
Hud: _bot.HudReader.Current,
|
||||||
|
NavState: _bot.Navigation.State,
|
||||||
|
NavPosition: _bot.Navigation.Position,
|
||||||
|
IsExploring: _bot.Navigation.IsExploring,
|
||||||
|
Fps: _fps);
|
||||||
|
|
||||||
|
foreach (var layer in _layers)
|
||||||
|
layer.Draw(dc, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shutdown()
|
||||||
|
{
|
||||||
|
_timer?.Stop();
|
||||||
|
_fpsWatch.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs
Normal file
36
src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
internal static partial class OverlayNativeMethods
|
||||||
|
{
|
||||||
|
private const int GWL_EXSTYLE = -20;
|
||||||
|
|
||||||
|
internal const int WS_EX_TRANSPARENT = 0x00000020;
|
||||||
|
internal const int WS_EX_LAYERED = 0x00080000;
|
||||||
|
internal const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||||
|
internal const int WS_EX_NOACTIVATE = 0x08000000;
|
||||||
|
|
||||||
|
private const int SW_SHOWNOACTIVATE = 4;
|
||||||
|
private const int SW_HIDE = 0;
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||||
|
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||||
|
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
internal static void MakeClickThrough(nint hwnd)
|
||||||
|
{
|
||||||
|
var style = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
|
||||||
|
SetWindowLongPtr(hwnd, GWL_EXSTYLE,
|
||||||
|
style | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||||
|
internal static void HideWindow(nint hwnd) => ShowWindow(hwnd, SW_HIDE);
|
||||||
|
}
|
||||||
13
src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml
Normal file
13
src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:overlay="using:Poe2Trade.Ui.Overlay"
|
||||||
|
x:Class="Poe2Trade.Ui.Overlay.OverlayWindow"
|
||||||
|
SystemDecorations="None"
|
||||||
|
Background="Transparent"
|
||||||
|
TransparencyLevelHint="Transparent"
|
||||||
|
Topmost="True"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Width="2560" Height="1440"
|
||||||
|
CanResize="False">
|
||||||
|
<overlay:OverlayCanvas x:Name="Canvas" />
|
||||||
|
</Window>
|
||||||
38
src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs
Normal file
38
src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Poe2Trade.Bot;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
public partial class OverlayWindow : Window
|
||||||
|
{
|
||||||
|
private readonly BotOrchestrator _bot = null!;
|
||||||
|
|
||||||
|
// Designer/XAML loader requires parameterless constructor
|
||||||
|
public OverlayWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
public OverlayWindow(BotOrchestrator bot)
|
||||||
|
{
|
||||||
|
_bot = bot;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnOpened(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnOpened(e);
|
||||||
|
|
||||||
|
// Position at top-left corner
|
||||||
|
Position = new Avalonia.PixelPoint(0, 0);
|
||||||
|
|
||||||
|
// Apply Win32 click-through extended styles
|
||||||
|
if (TryGetPlatformHandle() is { } handle)
|
||||||
|
OverlayNativeMethods.MakeClickThrough(handle.Handle);
|
||||||
|
|
||||||
|
Canvas.Initialize(_bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
Canvas.Shutdown();
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
using System.Text;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Poe2Trade.Bot;
|
using Poe2Trade.Bot;
|
||||||
|
using Poe2Trade.Core;
|
||||||
|
using Poe2Trade.Inventory;
|
||||||
using Poe2Trade.Screen;
|
using Poe2Trade.Screen;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
|
|
@ -27,17 +30,9 @@ public partial class DebugViewModel : ObservableObject
|
||||||
_bot = bot;
|
_bot = bot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool EnsureReady()
|
|
||||||
{
|
|
||||||
if (_bot.IsReady) return true;
|
|
||||||
DebugResult = "Bot not started yet. Press Start first.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task TakeScreenshot()
|
private async Task TakeScreenshot()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
|
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
|
||||||
|
|
@ -55,7 +50,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RunOcr()
|
private async Task RunOcr()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var text = await _bot.Screen.ReadFullScreen();
|
var text = await _bot.Screen.ReadFullScreen();
|
||||||
|
|
@ -71,7 +65,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task GoHideout()
|
private async Task GoHideout()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _bot.Game.FocusGame();
|
await _bot.Game.FocusGame();
|
||||||
|
|
@ -88,7 +81,7 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task FindTextOnScreen()
|
private async Task FindTextOnScreen()
|
||||||
{
|
{
|
||||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
if (string.IsNullOrWhiteSpace(FindText)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
|
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
|
||||||
|
|
@ -106,7 +99,7 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task FindAndClick()
|
private async Task FindAndClick()
|
||||||
{
|
{
|
||||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
if (string.IsNullOrWhiteSpace(FindText)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _bot.Game.FocusGame();
|
await _bot.Game.FocusGame();
|
||||||
|
|
@ -125,7 +118,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClickAt()
|
private async Task ClickAt()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
var x = (int)(ClickX ?? 0);
|
var x = (int)(ClickX ?? 0);
|
||||||
var y = (int)(ClickY ?? 0);
|
var y = (int)(ClickY ?? 0);
|
||||||
try
|
try
|
||||||
|
|
@ -144,7 +136,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ScanGrid()
|
private async Task ScanGrid()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
|
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
|
||||||
|
|
@ -178,7 +169,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClickAnge()
|
private async Task ClickAnge()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _bot.Game.FocusGame();
|
await _bot.Game.FocusGame();
|
||||||
|
|
@ -191,7 +181,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClickStash()
|
private async Task ClickStash()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _bot.Game.FocusGame();
|
await _bot.Game.FocusGame();
|
||||||
|
|
@ -204,7 +193,6 @@ public partial class DebugViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClickSalvage()
|
private async Task ClickSalvage()
|
||||||
{
|
{
|
||||||
if (!EnsureReady()) return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _bot.Game.FocusGame();
|
await _bot.Game.FocusGame();
|
||||||
|
|
@ -213,4 +201,99 @@ public partial class DebugViewModel : ObservableObject
|
||||||
}
|
}
|
||||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CalibrateStash()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
||||||
|
DebugResult = "Calibrating stash tabs...";
|
||||||
|
|
||||||
|
// Focus game and open stash
|
||||||
|
await _bot.Game.FocusGame();
|
||||||
|
await Helpers.Sleep(Delays.PostFocus);
|
||||||
|
|
||||||
|
var stashPos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||||
|
if (!stashPos.HasValue)
|
||||||
|
{
|
||||||
|
DebugResult = "STASH nameplate not found. Stand near your stash.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Helpers.Sleep(Delays.PostStashOpen);
|
||||||
|
|
||||||
|
// Calibrate stash
|
||||||
|
var stashCal = await calibrator.CalibrateOpenPanel();
|
||||||
|
|
||||||
|
// Close stash, try shop
|
||||||
|
await _bot.Game.PressEscape();
|
||||||
|
await Helpers.Sleep(Delays.PostEscape);
|
||||||
|
|
||||||
|
StashCalibration? shopCal = null;
|
||||||
|
var angePos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||||
|
if (angePos.HasValue)
|
||||||
|
{
|
||||||
|
await Helpers.Sleep(Delays.PostStashOpen);
|
||||||
|
// ANGE opens a dialog — click "Manage Shop" to open shop tabs
|
||||||
|
var managePos = await _bot.Screen.FindTextOnScreen("Manage Shop", fuzzy: true);
|
||||||
|
if (managePos.HasValue)
|
||||||
|
{
|
||||||
|
await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y);
|
||||||
|
await Helpers.Sleep(Delays.PostStashOpen);
|
||||||
|
}
|
||||||
|
shopCal = await calibrator.CalibrateOpenPanel();
|
||||||
|
await _bot.Game.PressEscape();
|
||||||
|
await Helpers.Sleep(Delays.PostEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
_bot.Store.UpdateSettings(s =>
|
||||||
|
{
|
||||||
|
s.StashCalibration = stashCal;
|
||||||
|
s.ShopCalibration = shopCal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
DebugResult = FormatCalibration(stashCal, shopCal);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DebugResult = $"Calibration failed: {ex.Message}";
|
||||||
|
Log.Error(ex, "Stash calibration failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCalibration(StashCalibration stash, StashCalibration? shop)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("=== STASH CALIBRATION ===");
|
||||||
|
FormatTabs(sb, stash.Tabs, indent: "");
|
||||||
|
|
||||||
|
if (shop != null)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("=== SHOP CALIBRATION ===");
|
||||||
|
FormatTabs(sb, shop.Tabs, indent: "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("(Shop: ANGE not found, skipped)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FormatTabs(StringBuilder sb, List<StashTabInfo> tabs, string indent)
|
||||||
|
{
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
{
|
||||||
|
var folder = tab.IsFolder ? " [FOLDER]" : "";
|
||||||
|
sb.AppendLine($"{indent}#{tab.Index} \"{tab.Name}\" @ ({tab.ClickX},{tab.ClickY}) grid={tab.GridCols}col{folder}");
|
||||||
|
if (tab.IsFolder)
|
||||||
|
{
|
||||||
|
FormatTabs(sb, tab.SubTabs, indent + " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@
|
||||||
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
||||||
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
||||||
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
||||||
|
<Button Content="Calibrate Stash" Command="{Binding CalibrateStashCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue