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 BotMode Mode { get; set; } = BotMode.Trading;
|
||||
public MapType MapType { get; set; } = MapType.TrialOfChaos;
|
||||
public StashCalibration? StashCalibration { get; set; }
|
||||
public StashCalibration? ShopCalibration { get; set; }
|
||||
}
|
||||
|
||||
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();
|
||||
if (NeedsRepath(now))
|
||||
{
|
||||
if (_stuck.IsStuck)
|
||||
SetState(NavigationState.Stuck);
|
||||
else
|
||||
SetState(NavigationState.Planning);
|
||||
|
||||
(double dirX, double dirY)? direction = null;
|
||||
|
||||
if (_checkpointGoal is { } cpGoal)
|
||||
if (TryRepath(pos, now))
|
||||
{
|
||||
// Try to path to checkpoint goal
|
||||
direction = _worldMap.FindPathToTarget(pos, cpGoal);
|
||||
if (direction == null)
|
||||
{
|
||||
Log.Information("Checkpoint unreachable, clearing goal");
|
||||
_checkpointGoal = null;
|
||||
}
|
||||
SetState(NavigationState.Completed);
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
|
||||
/// </summary>
|
||||
|
|
@ -415,7 +438,11 @@ public class NavigationExecutor : IDisposable
|
|||
var tdx = target.X - px;
|
||||
var tdy = target.Y - py;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,32 +40,57 @@ internal class PathFinder
|
|||
var gridLen = gridW * gridW;
|
||||
|
||||
var visited = new bool[gridLen];
|
||||
var dist = new short[gridLen];
|
||||
var cost = new int[gridLen];
|
||||
var parentX = 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
|
||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 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 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;
|
||||
|
||||
if (visited[cellIdx]) continue;
|
||||
visited[cellIdx] = true;
|
||||
|
||||
// Map grid coords back to canvas coords
|
||||
var wx = cx + (gx - rr) * step;
|
||||
var wy = cy + (gy - rr) * step;
|
||||
|
|
@ -105,11 +130,14 @@ internal class PathFinder
|
|||
var cell = canvas.At<byte>(nwy, nwx);
|
||||
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
||||
|
||||
visited[idx] = true;
|
||||
dist[idx] = (short)(dist[cellIdx] + 1);
|
||||
parentX[idx] = (short)gx;
|
||||
parentY[idx] = (short)gy;
|
||||
queue.Enqueue((ngx, ngy));
|
||||
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
|
||||
if (newCost < cost[idx])
|
||||
{
|
||||
cost[idx] = newCost;
|
||||
parentX[idx] = (short)gx;
|
||||
parentY[idx] = (short)gy;
|
||||
pq.Enqueue((ngx, ngy), newCost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +166,7 @@ internal class PathFinder
|
|||
clusterCount++;
|
||||
// Flood-fill this cluster
|
||||
var clusterCells = new List<(int gx, int gy)>();
|
||||
var minDist = dist[fIdx];
|
||||
var minCost = cost[fIdx];
|
||||
var entryGx = fgx;
|
||||
var entryGy = fgy;
|
||||
|
||||
|
|
@ -151,9 +179,9 @@ internal class PathFinder
|
|||
clusterCells.Add((cgx, cgy));
|
||||
|
||||
var cIdx = cgy * gridW + cgx;
|
||||
if (dist[cIdx] < minDist)
|
||||
if (cost[cIdx] < minCost)
|
||||
{
|
||||
minDist = dist[cIdx];
|
||||
minCost = cost[cIdx];
|
||||
entryGx = cgx;
|
||||
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 cost = (int)minDist;
|
||||
var score = gain / (cost + 1.0);
|
||||
if (gain < MinClusterSize) continue;
|
||||
var pathCost = (int)minCost;
|
||||
var score = gain / (pathCost + 1.0);
|
||||
|
||||
if (score > bestClusterScore)
|
||||
{
|
||||
bestClusterScore = score;
|
||||
bestClusterGain = gain;
|
||||
bestClusterCost = cost;
|
||||
bestClusterCost = pathCost;
|
||||
bestEntryGx = entryGx;
|
||||
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
|
||||
var rawPath = new List<Point>();
|
||||
var traceGx = bestEntryGx;
|
||||
|
|
@ -240,31 +277,60 @@ internal class PathFinder
|
|||
var rr = searchRadius / step;
|
||||
var gridW = 2 * rr + 1;
|
||||
|
||||
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;
|
||||
var startIdx = startGy * gridW + startGx;
|
||||
visited[startIdx] = true;
|
||||
parentX[startIdx] = (short)startGx;
|
||||
parentY[startIdx] = (short)startGy;
|
||||
queue.Enqueue((startGx, startGy));
|
||||
var gridLen = gridW * gridW;
|
||||
var visited = new bool[gridLen];
|
||||
var cost = new int[gridLen];
|
||||
var parentX = new short[gridLen];
|
||||
var parentY = new short[gridLen];
|
||||
|
||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 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 arrivalDist2 = arrivalDist * arrivalDist;
|
||||
|
||||
var foundGx = -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 wy = cy + (gy - rr) * step;
|
||||
|
||||
|
|
@ -294,10 +360,14 @@ internal class PathFinder
|
|||
var cell = canvas.At<byte>(nwy, nwx);
|
||||
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
||||
|
||||
visited[idx] = true;
|
||||
parentX[idx] = (short)gx;
|
||||
parentY[idx] = (short)gy;
|
||||
queue.Enqueue((ngx, ngy));
|
||||
var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3;
|
||||
if (newCost < cost[idx])
|
||||
{
|
||||
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.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Poe2Trade.Ui.Overlay;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
using Poe2Trade.Ui.Views;
|
||||
|
||||
|
|
@ -66,8 +67,12 @@ public partial class App : Application
|
|||
window.SetConfigStore(store);
|
||||
desktop.MainWindow = window;
|
||||
|
||||
var overlay = new OverlayWindow(bot);
|
||||
overlay.Show();
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
overlay.Close();
|
||||
mainVm.Shutdown();
|
||||
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>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
|
|
@ -27,17 +30,9 @@ public partial class DebugViewModel : ObservableObject
|
|||
_bot = bot;
|
||||
}
|
||||
|
||||
private bool EnsureReady()
|
||||
{
|
||||
if (_bot.IsReady) return true;
|
||||
DebugResult = "Bot not started yet. Press Start first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TakeScreenshot()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
|
||||
|
|
@ -55,7 +50,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task RunOcr()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var text = await _bot.Screen.ReadFullScreen();
|
||||
|
|
@ -71,7 +65,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task GoHideout()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
|
|
@ -88,7 +81,7 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task FindTextOnScreen()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
if (string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
|
||||
|
|
@ -106,7 +99,7 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task FindAndClick()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
if (string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
|
|
@ -125,7 +118,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task ClickAt()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
var x = (int)(ClickX ?? 0);
|
||||
var y = (int)(ClickY ?? 0);
|
||||
try
|
||||
|
|
@ -144,7 +136,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task ScanGrid()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
|
||||
|
|
@ -178,7 +169,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task ClickAnge()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
|
|
@ -191,7 +181,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task ClickStash()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
|
|
@ -204,7 +193,6 @@ public partial class DebugViewModel : ObservableObject
|
|||
[RelayCommand]
|
||||
private async Task ClickSalvage()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
|
|
@ -213,4 +201,99 @@ public partial class DebugViewModel : ObservableObject
|
|||
}
|
||||
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="STASH" Command="{Binding ClickStashCommand}" />
|
||||
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
||||
<Button Content="Calibrate Stash" Command="{Binding CalibrateStashCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue