overlay and calibration
This commit is contained in:
parent
3062993f7c
commit
3456e0d62a
24 changed files with 1193 additions and 439 deletions
|
|
@ -8,6 +8,7 @@ public class StashTabInfo
|
|||
public int ClickY { get; set; }
|
||||
public bool IsFolder { get; set; }
|
||||
public int GridCols { get; set; } = 12;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public List<StashTabInfo> SubTabs { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,14 @@ public class StashCalibrator
|
|||
/// </summary>
|
||||
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
|
||||
{
|
||||
// Save debug capture of the region
|
||||
Directory.CreateDirectory("debug");
|
||||
var tag = region == TabBarRegion ? "tabbar" : "subtab";
|
||||
await _screen.SaveRegion(region, $"debug/calibrate-{tag}-{DateTime.Now:HHmmss}.png");
|
||||
|
||||
var ocr = await _screen.Ocr(region);
|
||||
Log.Information("StashCalibrator: OCR region ({Tag}) raw text: '{Text}'", tag, ocr.Text);
|
||||
|
||||
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
|
||||
if (allWords.Count == 0) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,32 @@ using OpenCvSharp.Extensions;
|
|||
|
||||
static class ImagePreprocessor
|
||||
{
|
||||
/// <summary>
|
||||
/// CLAHE (Contrast Limited Adaptive Histogram Equalization) preprocessing.
|
||||
/// Enhances local contrast — works for both light and dark text on varying backgrounds.
|
||||
/// Pipeline: grayscale -> CLAHE -> upscale (keeps grayscale, lets EasyOCR handle binarization)
|
||||
/// </summary>
|
||||
public static Bitmap PreprocessClahe(Bitmap src, double clipLimit = 3.0, int tileSize = 8, int upscale = 2)
|
||||
{
|
||||
using var mat = BitmapConverter.ToMat(src);
|
||||
using var gray = new Mat();
|
||||
Cv2.CvtColor(mat, gray, ColorConversionCodes.BGRA2GRAY);
|
||||
|
||||
using var clahe = Cv2.CreateCLAHE(clipLimit, new OpenCvSharp.Size(tileSize, tileSize));
|
||||
using var enhanced = new Mat();
|
||||
clahe.Apply(gray, enhanced);
|
||||
|
||||
if (upscale > 1)
|
||||
{
|
||||
using var upscaled = new Mat();
|
||||
Cv2.Resize(enhanced, upscaled, new OpenCvSharp.Size(enhanced.Width * upscale, enhanced.Height * upscale),
|
||||
interpolation: InterpolationFlags.Cubic);
|
||||
return BitmapConverter.ToBitmap(upscaled);
|
||||
}
|
||||
|
||||
return BitmapConverter.ToBitmap(enhanced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-process an image for OCR using morphological white top-hat filtering.
|
||||
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ public class ScreenReader : IScreenReader
|
|||
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||
}
|
||||
|
||||
if (preprocess == "clahe")
|
||||
{
|
||||
using var processed = ImagePreprocessor.PreprocessClahe(bitmap);
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||
}
|
||||
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,12 +68,12 @@ public partial class App : Application
|
|||
desktop.MainWindow = window;
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||
|
||||
var overlay = new OverlayWindow(bot);
|
||||
overlay.Show();
|
||||
var overlay = new D2dOverlay(bot);
|
||||
overlay.Start();
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
overlay.Close();
|
||||
overlay.Shutdown();
|
||||
mainVm.Shutdown();
|
||||
await bot.DisposeAsync();
|
||||
};
|
||||
|
|
|
|||
152
src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs
Normal file
152
src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay;
|
||||
|
||||
/// <summary>Win32 P/Invoke for the D2D overlay window, DWM transparency, and frame timing.</summary>
|
||||
internal static partial class D2dNativeMethods
|
||||
{
|
||||
// --- Window styles ---
|
||||
internal const uint WS_POPUP = 0x80000000;
|
||||
internal const uint WS_VISIBLE = 0x10000000;
|
||||
internal const uint WS_EX_TOPMOST = 0x00000008;
|
||||
internal const uint WS_EX_TRANSPARENT = 0x00000020;
|
||||
internal const uint WS_EX_TOOLWINDOW = 0x00000080;
|
||||
internal const uint WS_EX_LAYERED = 0x00080000;
|
||||
internal const uint WS_EX_NOACTIVATE = 0x08000000;
|
||||
|
||||
// --- Window messages ---
|
||||
internal const uint WM_DESTROY = 0x0002;
|
||||
internal const uint WM_ERASEBKGND = 0x0014;
|
||||
|
||||
// --- ShowWindow / PeekMessage ---
|
||||
internal const int SW_SHOWNOACTIVATE = 4;
|
||||
internal const int SW_HIDE = 0;
|
||||
internal const uint PM_REMOVE = 0x0001;
|
||||
internal const int IDC_ARROW = 32512;
|
||||
internal const uint LWA_ALPHA = 0x02;
|
||||
|
||||
// --- High-resolution waitable timer (Windows 10 1803+) ---
|
||||
internal const uint CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002;
|
||||
internal const uint TIMER_ALL_ACCESS = 0x1F0003;
|
||||
internal const uint INFINITE = 0xFFFFFFFF;
|
||||
|
||||
internal delegate nint WndProcDelegate(nint hWnd, uint msg, nint wParam, nint lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WNDCLASSEXW
|
||||
{
|
||||
public uint cbSize;
|
||||
public uint style;
|
||||
public nint lpfnWndProc;
|
||||
public int cbClsExtra;
|
||||
public int cbWndExtra;
|
||||
public nint hInstance;
|
||||
public nint hIcon;
|
||||
public nint hCursor;
|
||||
public nint hbrBackground;
|
||||
public string? lpszMenuName;
|
||||
public string lpszClassName;
|
||||
public nint hIconSm;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MSG
|
||||
{
|
||||
public nint hwnd;
|
||||
public uint message;
|
||||
public nint wParam;
|
||||
public nint lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MARGINS
|
||||
{
|
||||
public int Left, Right, Top, Bottom;
|
||||
}
|
||||
|
||||
// --- user32.dll ---
|
||||
|
||||
// DllImport required: WNDCLASSEXW has managed string fields unsupported by LibraryImport source generator
|
||||
[DllImport("user32.dll", EntryPoint = "RegisterClassExW")]
|
||||
internal static extern ushort RegisterClassExW(ref WNDCLASSEXW wc);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "CreateWindowExW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial nint CreateWindowExW(
|
||||
uint dwExStyle, string lpClassName, string lpWindowName,
|
||||
uint dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
nint hWndParent, nint hMenu, nint hInstance, nint lpParam);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "DefWindowProcW")]
|
||||
internal static partial nint DefWindowProcW(nint hWnd, uint msg, nint wParam, nint lParam);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial void PostQuitMessage(int nExitCode);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "PeekMessageW")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool PeekMessageW(out MSG lpMsg, nint hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "DispatchMessageW")]
|
||||
internal static partial nint DispatchMessageW(ref MSG lpMsg);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool DestroyWindow(nint hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "LoadCursorW")]
|
||||
internal static partial nint LoadCursorW(nint hInstance, int lpCursorName);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetLayeredWindowAttributes(nint hwnd, uint crKey, byte bAlpha, uint dwFlags);
|
||||
|
||||
// --- kernel32.dll ---
|
||||
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW")]
|
||||
internal static partial nint GetModuleHandleW(nint lpModuleName);
|
||||
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateWaitableTimerExW")]
|
||||
internal static partial nint CreateWaitableTimerExW(
|
||||
nint lpTimerAttributes, nint lpTimerName, uint dwFlags, uint dwDesiredAccess);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetWaitableTimer(
|
||||
nint hTimer, ref long lpDueTime, int lPeriod,
|
||||
nint pfnCompletionRoutine, nint lpArgToCompletionRoutine,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool fResume);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
internal static partial uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool CloseHandle(nint hObject);
|
||||
|
||||
// --- dwmapi.dll / winmm.dll ---
|
||||
|
||||
[LibraryImport("dwmapi.dll")]
|
||||
internal static partial int DwmExtendFrameIntoClientArea(nint hWnd, ref MARGINS pMarInset);
|
||||
|
||||
[LibraryImport("winmm.dll")]
|
||||
internal static partial uint timeBeginPeriod(uint uPeriod);
|
||||
|
||||
[LibraryImport("winmm.dll")]
|
||||
internal static partial uint timeEndPeriod(uint uPeriod);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
internal static void HideWindow(nint hwnd) => ShowWindow(hwnd, SW_HIDE);
|
||||
}
|
||||
270
src/Poe2Trade.Ui/Overlay/D2dOverlay.cs
Normal file
270
src/Poe2Trade.Ui/Overlay/D2dOverlay.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime;
|
||||
using System.Runtime.InteropServices;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Ui.Overlay.Layers;
|
||||
using Vortice.Mathematics;
|
||||
using static Poe2Trade.Ui.Overlay.D2dNativeMethods;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Fullscreen transparent overlay rendered with Direct2D on a dedicated thread.
|
||||
/// Uses a software render target to avoid GPU contention with DXGI capture / YOLO / Avalonia.
|
||||
/// </summary>
|
||||
public sealed class D2dOverlay
|
||||
{
|
||||
private const int Width = 2560;
|
||||
private const int Height = 1440;
|
||||
private const double TargetFrameMs = 16.0; // ~60 fps
|
||||
private const int FocusCheckInterval = 60; // frames between focus checks (~1 Hz)
|
||||
private const string ClassName = "Poe2D2dOverlay";
|
||||
|
||||
private readonly BotOrchestrator _bot;
|
||||
private readonly List<ID2dOverlayLayer> _layers = [];
|
||||
private Thread? _thread;
|
||||
private volatile bool _shutdown;
|
||||
|
||||
// Must be stored as field to prevent GC collection of the delegate
|
||||
private WndProcDelegate? _wndProcDelegate;
|
||||
|
||||
public D2dOverlay(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_thread = new Thread(RenderLoop)
|
||||
{
|
||||
Name = "D2dOverlay",
|
||||
IsBackground = true,
|
||||
Priority = ThreadPriority.AboveNormal,
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
_shutdown = true;
|
||||
_thread?.Join(2000);
|
||||
}
|
||||
|
||||
private void RenderLoop()
|
||||
{
|
||||
nint hwnd = 0;
|
||||
nint hTimer = 0;
|
||||
try
|
||||
{
|
||||
hwnd = CreateOverlayWindow();
|
||||
if (hwnd == 0)
|
||||
{
|
||||
Console.WriteLine("[D2dOverlay] CreateOverlayWindow failed");
|
||||
return;
|
||||
}
|
||||
|
||||
SetLayeredWindowAttributes(hwnd, 0, 200, LWA_ALPHA);
|
||||
var margins = new MARGINS { Left = -1, Right = -1, Top = -1, Bottom = -1 };
|
||||
DwmExtendFrameIntoClientArea(hwnd, ref margins);
|
||||
|
||||
using var ctx = new D2dRenderContext(hwnd, Width, Height);
|
||||
|
||||
_layers.Add(new D2dEnemyBoxLayer(ctx));
|
||||
_layers.Add(new D2dHudInfoLayer());
|
||||
_layers.Add(new D2dDebugTextLayer());
|
||||
|
||||
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
|
||||
timeBeginPeriod(1);
|
||||
hTimer = CreateWaitableTimerExW(0, 0, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
||||
|
||||
// Pre-allocate per-frame arrays
|
||||
var layerCount = _layers.Count;
|
||||
var layerMs = new double[layerCount];
|
||||
var layerNames = new string[layerCount];
|
||||
for (int i = 0; i < layerCount; i++)
|
||||
layerNames[i] = _layers[i].GetType().Name;
|
||||
|
||||
var timing = new RenderTiming();
|
||||
var fpsWatch = Stopwatch.StartNew();
|
||||
int frameCount = 0;
|
||||
double fps = 0;
|
||||
int focusCounter = 0;
|
||||
bool shown = true;
|
||||
long lastFrameTimestamp = Stopwatch.GetTimestamp();
|
||||
|
||||
ShowNoActivate(hwnd);
|
||||
Console.WriteLine($"[D2dOverlay] Started (hwnd={hwnd:X})");
|
||||
|
||||
while (!_shutdown)
|
||||
{
|
||||
var frameStart = Stopwatch.GetTimestamp();
|
||||
var frameInterval = (frameStart - lastFrameTimestamp) * 1000.0 / Stopwatch.Frequency;
|
||||
lastFrameTimestamp = frameStart;
|
||||
|
||||
DrainMessages();
|
||||
UpdateFocusVisibility(ref focusCounter, ref shown, hwnd);
|
||||
UpdateFps(fpsWatch, ref frameCount, ref fps);
|
||||
|
||||
var state = BuildState(fps, timing);
|
||||
var snapMs = ElapsedMs(frameStart);
|
||||
|
||||
Render(ctx, state, layerMs, layerCount);
|
||||
|
||||
var totalRenderMs = ElapsedMs(frameStart);
|
||||
timing.SnapshotMs = snapMs;
|
||||
timing.LayerMs = layerMs;
|
||||
timing.LayerNames = layerNames;
|
||||
timing.TotalRenderMs = totalRenderMs;
|
||||
timing.IntervalMs = frameInterval;
|
||||
|
||||
WaitForNextFrame(frameStart, hTimer);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[D2dOverlay] Exception: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var layer in _layers)
|
||||
(layer as IDisposable)?.Dispose();
|
||||
if (hTimer != 0) CloseHandle(hTimer);
|
||||
timeEndPeriod(1);
|
||||
if (hwnd != 0) DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render loop helpers ---
|
||||
|
||||
private static void DrainMessages()
|
||||
{
|
||||
while (PeekMessageW(out var msg, 0, 0, 0, PM_REMOVE))
|
||||
{
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFocusVisibility(ref int counter, ref bool shown, nint hwnd)
|
||||
{
|
||||
if (++counter < FocusCheckInterval) return;
|
||||
counter = 0;
|
||||
|
||||
var gameExists = _bot.Game.GetWindowRect() != null;
|
||||
var focused = _bot.Game.IsGameFocused();
|
||||
|
||||
if (!shown && (focused || !gameExists))
|
||||
{
|
||||
ShowNoActivate(hwnd);
|
||||
shown = true;
|
||||
}
|
||||
else if (shown && gameExists && !focused)
|
||||
{
|
||||
HideWindow(hwnd);
|
||||
shown = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateFps(Stopwatch watch, ref int frameCount, ref double fps)
|
||||
{
|
||||
frameCount++;
|
||||
var elapsed = watch.Elapsed.TotalSeconds;
|
||||
if (elapsed >= 1.0)
|
||||
{
|
||||
fps = frameCount / elapsed;
|
||||
frameCount = 0;
|
||||
watch.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
private OverlayState BuildState(double fps, RenderTiming timing)
|
||||
{
|
||||
var detection = _bot.EnemyDetector.Latest;
|
||||
return 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,
|
||||
Timing: timing);
|
||||
}
|
||||
|
||||
private void Render(D2dRenderContext ctx, OverlayState state, double[] layerMs, int layerCount)
|
||||
{
|
||||
var rt = ctx.RenderTarget;
|
||||
rt.BeginDraw();
|
||||
rt.Clear(new Color4(0, 0, 0, 0));
|
||||
|
||||
for (int i = 0; i < layerCount; i++)
|
||||
{
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
_layers[i].Draw(ctx, state);
|
||||
layerMs[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
var hr = rt.EndDraw();
|
||||
if (hr.Failure)
|
||||
ctx.RecreateTarget();
|
||||
}
|
||||
|
||||
private static void WaitForNextFrame(long frameStart, nint hTimer)
|
||||
{
|
||||
var waitMs = TargetFrameMs - ElapsedMs(frameStart);
|
||||
if (waitMs > 0.5 && hTimer != 0)
|
||||
{
|
||||
var dueTime = -(long)(waitMs * 10_000); // negative = relative, 100ns units
|
||||
SetWaitableTimer(hTimer, ref dueTime, 0, 0, 0, false);
|
||||
WaitForSingleObject(hTimer, INFINITE);
|
||||
}
|
||||
}
|
||||
|
||||
private static double ElapsedMs(long since)
|
||||
=> (Stopwatch.GetTimestamp() - since) * 1000.0 / Stopwatch.Frequency;
|
||||
|
||||
// --- Window creation ---
|
||||
|
||||
private nint CreateOverlayWindow()
|
||||
{
|
||||
var hInstance = GetModuleHandleW(0);
|
||||
|
||||
_wndProcDelegate = WndProc;
|
||||
var fnPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
|
||||
|
||||
var wc = new WNDCLASSEXW
|
||||
{
|
||||
cbSize = (uint)Marshal.SizeOf<WNDCLASSEXW>(),
|
||||
lpfnWndProc = fnPtr,
|
||||
hInstance = hInstance,
|
||||
hCursor = LoadCursorW(0, IDC_ARROW),
|
||||
lpszClassName = ClassName,
|
||||
};
|
||||
|
||||
if (RegisterClassExW(ref wc) == 0) return 0;
|
||||
|
||||
const uint exStyle = WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED
|
||||
| WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
|
||||
|
||||
return CreateWindowExW(
|
||||
exStyle, ClassName, "Poe2Overlay",
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
0, 0, Width, Height,
|
||||
0, 0, hInstance, 0);
|
||||
}
|
||||
|
||||
private static nint WndProc(nint hWnd, uint msg, nint wParam, nint lParam)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
case WM_DESTROY:
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
default:
|
||||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs
Normal file
138
src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using Vortice.Direct2D1;
|
||||
using Vortice.DirectWrite;
|
||||
using Vortice.DXGI;
|
||||
using Vortice.Mathematics;
|
||||
using DWriteFactory = Vortice.DirectWrite.IDWriteFactory;
|
||||
using D2dFactoryType = Vortice.Direct2D1.FactoryType;
|
||||
using DwFactoryType = Vortice.DirectWrite.FactoryType;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay;
|
||||
|
||||
public sealed class D2dRenderContext : IDisposable
|
||||
{
|
||||
private readonly nint _hwnd;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
|
||||
// Factories
|
||||
public ID2D1Factory1 D2dFactory { get; }
|
||||
public DWriteFactory DWriteFactory { get; }
|
||||
|
||||
// Render target (recreated on device loss)
|
||||
public ID2D1HwndRenderTarget RenderTarget { get; private set; } = null!;
|
||||
|
||||
// Pre-created brushes
|
||||
public ID2D1SolidColorBrush Red { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush Yellow { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush Green { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush White { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush Gray { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush LifeBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush ManaBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush BarBgBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush LabelBgBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush DebugTextBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush TimingBrush { get; private set; } = null!;
|
||||
public ID2D1SolidColorBrush DebugBgBrush { get; private set; } = null!;
|
||||
|
||||
// Text formats
|
||||
public IDWriteTextFormat LabelFormat { get; } // 12pt — enemy labels
|
||||
public IDWriteTextFormat BarValueFormat { get; } // 11pt — bar values
|
||||
public IDWriteTextFormat DebugFormat { get; } // 13pt — debug overlay
|
||||
|
||||
public D2dRenderContext(nint hwnd, int width, int height)
|
||||
{
|
||||
_hwnd = hwnd;
|
||||
_width = width;
|
||||
_height = height;
|
||||
|
||||
D2dFactory = D2D1.D2D1CreateFactory<ID2D1Factory1>(D2dFactoryType.SingleThreaded);
|
||||
DWriteFactory = Vortice.DirectWrite.DWrite.DWriteCreateFactory<DWriteFactory>(DwFactoryType.Shared);
|
||||
|
||||
CreateRenderTarget();
|
||||
CreateBrushes();
|
||||
|
||||
// Text formats (these survive device loss — they're DWrite, not D2D)
|
||||
LabelFormat = DWriteFactory.CreateTextFormat("Consolas", 12f);
|
||||
BarValueFormat = DWriteFactory.CreateTextFormat("Consolas", 11f);
|
||||
DebugFormat = DWriteFactory.CreateTextFormat("Consolas", 13f);
|
||||
}
|
||||
|
||||
private void CreateRenderTarget()
|
||||
{
|
||||
var rtProps = new RenderTargetProperties
|
||||
{
|
||||
Type = RenderTargetType.Software,
|
||||
PixelFormat = new Vortice.DCommon.PixelFormat(Format.B8G8R8A8_UNorm, Vortice.DCommon.AlphaMode.Premultiplied),
|
||||
};
|
||||
var hwndProps = new HwndRenderTargetProperties
|
||||
{
|
||||
Hwnd = _hwnd,
|
||||
PixelSize = new SizeI(_width, _height),
|
||||
PresentOptions = PresentOptions.Immediately,
|
||||
};
|
||||
RenderTarget = D2dFactory.CreateHwndRenderTarget(rtProps, hwndProps);
|
||||
RenderTarget.TextAntialiasMode = Vortice.Direct2D1.TextAntialiasMode.Grayscale;
|
||||
}
|
||||
|
||||
private void CreateBrushes()
|
||||
{
|
||||
Red = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0f, 0f, 1f));
|
||||
Yellow = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 0f, 1f));
|
||||
Green = RenderTarget.CreateSolidColorBrush(new Color4(0.31f, 1f, 0.31f, 1f)); // 80,255,80
|
||||
White = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 1f, 1f));
|
||||
Gray = RenderTarget.CreateSolidColorBrush(new Color4(0.5f, 0.5f, 0.5f, 1f));
|
||||
LifeBrush = RenderTarget.CreateSolidColorBrush(new Color4(200 / 255f, 40 / 255f, 40 / 255f, 1f));
|
||||
ManaBrush = RenderTarget.CreateSolidColorBrush(new Color4(40 / 255f, 80 / 255f, 200 / 255f, 1f));
|
||||
BarBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(20 / 255f, 20 / 255f, 20 / 255f, 140 / 255f));
|
||||
LabelBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
|
||||
DebugTextBrush = RenderTarget.CreateSolidColorBrush(new Color4(80 / 255f, 1f, 80 / 255f, 1f));
|
||||
TimingBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 200 / 255f, 80 / 255f, 1f));
|
||||
DebugBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
|
||||
}
|
||||
|
||||
private void DisposeBrushes()
|
||||
{
|
||||
Red?.Dispose();
|
||||
Yellow?.Dispose();
|
||||
Green?.Dispose();
|
||||
White?.Dispose();
|
||||
Gray?.Dispose();
|
||||
LifeBrush?.Dispose();
|
||||
ManaBrush?.Dispose();
|
||||
BarBgBrush?.Dispose();
|
||||
LabelBgBrush?.Dispose();
|
||||
DebugTextBrush?.Dispose();
|
||||
TimingBrush?.Dispose();
|
||||
DebugBgBrush?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call after EndDraw returns D2DERR_RECREATE_TARGET.
|
||||
/// Recreates the render target and all device-dependent resources.
|
||||
/// </summary>
|
||||
public void RecreateTarget()
|
||||
{
|
||||
DisposeBrushes();
|
||||
RenderTarget?.Dispose();
|
||||
CreateRenderTarget();
|
||||
CreateBrushes();
|
||||
}
|
||||
|
||||
/// <summary>Create a text layout for measurement + drawing.</summary>
|
||||
public IDWriteTextLayout CreateTextLayout(string text, IDWriteTextFormat format, float maxWidth = 4096f, float maxHeight = 4096f)
|
||||
{
|
||||
return DWriteFactory.CreateTextLayout(text, format, maxWidth, maxHeight);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeBrushes();
|
||||
RenderTarget?.Dispose();
|
||||
LabelFormat?.Dispose();
|
||||
BarValueFormat?.Dispose();
|
||||
DebugFormat?.Dispose();
|
||||
DWriteFactory?.Dispose();
|
||||
D2dFactory?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using Avalonia.Media;
|
||||
using Poe2Trade.Navigation;
|
||||
using Poe2Trade.Screen;
|
||||
|
||||
|
|
@ -11,9 +10,19 @@ public record OverlayState(
|
|||
NavigationState NavState,
|
||||
MapPosition NavPosition,
|
||||
bool IsExploring,
|
||||
double Fps);
|
||||
double Fps,
|
||||
RenderTiming? Timing);
|
||||
|
||||
public interface IOverlayLayer
|
||||
public class RenderTiming
|
||||
{
|
||||
void Draw(DrawingContext dc, OverlayState state);
|
||||
public double SnapshotMs { get; set; }
|
||||
public double[] LayerMs { get; set; } = [];
|
||||
public string[] LayerNames { get; set; } = [];
|
||||
public double TotalRenderMs { get; set; }
|
||||
public double IntervalMs { get; set; }
|
||||
}
|
||||
|
||||
public interface ID2dOverlayLayer
|
||||
{
|
||||
void Draw(D2dRenderContext ctx, OverlayState state);
|
||||
}
|
||||
|
|
|
|||
109
src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs
Normal file
109
src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.Drawing;
|
||||
using Vortice.Direct2D1;
|
||||
using Vortice.DirectWrite;
|
||||
using Vortice.Mathematics;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||
|
||||
internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
|
||||
{
|
||||
private const float PadX = 8;
|
||||
private const float PadY = 4;
|
||||
private const float StartX = 10;
|
||||
private const float StartY = 10;
|
||||
private const float ColumnGap = 16;
|
||||
|
||||
private readonly CachedLine[] _left = new CachedLine[8];
|
||||
private readonly CachedLine[] _right = new CachedLine[8];
|
||||
|
||||
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||
{
|
||||
var rt = ctx.RenderTarget;
|
||||
int lc = 0, rc = 0;
|
||||
|
||||
// Left column: game state
|
||||
UpdateCache(ctx, _left, ref lc, $"FPS: {state.Fps:F0}", ctx.DebugTextBrush);
|
||||
UpdateCache(ctx, _left, ref lc, $"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}", ctx.DebugTextBrush);
|
||||
UpdateCache(ctx, _left, ref lc, $"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})", ctx.DebugTextBrush);
|
||||
UpdateCache(ctx, _left, ref lc, $"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms", ctx.DebugTextBrush);
|
||||
if (state.Hud is { Timestamp: > 0 } hud)
|
||||
UpdateCache(ctx, _left, ref lc, $"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}", ctx.DebugTextBrush);
|
||||
|
||||
// Right column: timing
|
||||
if (state.Timing != null)
|
||||
{
|
||||
var t = state.Timing;
|
||||
UpdateCache(ctx, _right, ref rc, $"interval: {t.IntervalMs:F1}ms", ctx.TimingBrush);
|
||||
UpdateCache(ctx, _right, ref rc, $"snapshot: {t.SnapshotMs:F2}ms", ctx.TimingBrush);
|
||||
for (int i = 0; i < t.LayerMs.Length; i++)
|
||||
UpdateCache(ctx, _right, ref rc, $"{t.LayerNames[i]}: {t.LayerMs[i]:F2}ms", ctx.TimingBrush);
|
||||
UpdateCache(ctx, _right, ref rc, $"render total: {t.TotalRenderMs:F2}ms", ctx.TimingBrush);
|
||||
}
|
||||
|
||||
// Measure columns
|
||||
Measure(_left, lc, out var leftW, out var leftH);
|
||||
Measure(_right, rc, out var rightW, out var rightH);
|
||||
|
||||
var totalW = leftW + (rc > 0 ? ColumnGap + rightW : 0);
|
||||
var totalH = Math.Max(leftH, rightH);
|
||||
|
||||
// Background
|
||||
rt.FillRectangle(
|
||||
new RectangleF(StartX - PadX, StartY - PadY, totalW + PadX * 2, totalH + PadY * 2),
|
||||
ctx.DebugBgBrush);
|
||||
|
||||
// Draw columns
|
||||
DrawColumn(rt, _left, lc, StartX, StartY);
|
||||
if (rc > 0)
|
||||
DrawColumn(rt, _right, rc, StartX + leftW + ColumnGap, StartY);
|
||||
}
|
||||
|
||||
private static void Measure(CachedLine[] col, int count, out float maxW, out float totalH)
|
||||
{
|
||||
maxW = 0;
|
||||
totalH = 0;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var m = col[i].Layout!.Metrics;
|
||||
if (m.Width > maxW) maxW = m.Width;
|
||||
totalH += m.Height;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawColumn(ID2D1RenderTarget rt, CachedLine[] col, int count, float x, float y)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
ref var entry = ref col[i];
|
||||
rt.DrawTextLayout(new System.Numerics.Vector2(x, y), entry.Layout!, entry.Brush!);
|
||||
y += entry.Layout!.Metrics.Height;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateCache(D2dRenderContext ctx, CachedLine[] col, ref int index, string value, ID2D1SolidColorBrush brush)
|
||||
{
|
||||
if (index >= col.Length) return;
|
||||
ref var entry = ref col[index];
|
||||
if (entry.Value != value)
|
||||
{
|
||||
entry.Layout?.Dispose();
|
||||
entry.Value = value;
|
||||
entry.Layout = ctx.CreateTextLayout(value, ctx.DebugFormat);
|
||||
}
|
||||
entry.Brush = brush;
|
||||
index++;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (int i = 0; i < _left.Length; i++) _left[i].Layout?.Dispose();
|
||||
for (int i = 0; i < _right.Length; i++) _right[i].Layout?.Dispose();
|
||||
}
|
||||
|
||||
private struct CachedLine
|
||||
{
|
||||
public string? Value;
|
||||
public IDWriteTextLayout? Layout;
|
||||
public ID2D1SolidColorBrush? Brush;
|
||||
}
|
||||
}
|
||||
58
src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs
Normal file
58
src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using System.Drawing;
|
||||
using Vortice.Direct2D1;
|
||||
using Vortice.DirectWrite;
|
||||
using Vortice.Mathematics;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||
|
||||
internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable
|
||||
{
|
||||
// Pre-built label layouts for 0%-100% (confirmed = red, unconfirmed = yellow)
|
||||
private readonly IDWriteTextLayout[] _confirmedLabels = new IDWriteTextLayout[101];
|
||||
private readonly IDWriteTextLayout[] _unconfirmedLabels = new IDWriteTextLayout[101];
|
||||
|
||||
public D2dEnemyBoxLayer(D2dRenderContext ctx)
|
||||
{
|
||||
for (int i = 0; i <= 100; i++)
|
||||
{
|
||||
var text = $"{i}%";
|
||||
_confirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
|
||||
_unconfirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
|
||||
}
|
||||
}
|
||||
|
||||
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||
{
|
||||
var rt = ctx.RenderTarget;
|
||||
|
||||
foreach (var enemy in state.Enemies)
|
||||
{
|
||||
var confirmed = enemy.HealthBarConfirmed;
|
||||
var boxBrush = confirmed ? ctx.Red : ctx.Yellow;
|
||||
var rect = new RectangleF(enemy.X, enemy.Y, enemy.Width, enemy.Height);
|
||||
rt.DrawRectangle(rect, boxBrush, 2f);
|
||||
|
||||
// Confidence label above the box
|
||||
var pctIndex = Math.Clamp((int)(enemy.Confidence * 100), 0, 100);
|
||||
var layout = confirmed ? _confirmedLabels[pctIndex] : _unconfirmedLabels[pctIndex];
|
||||
var textBrush = confirmed ? ctx.Red : ctx.Yellow;
|
||||
|
||||
var m = layout.Metrics;
|
||||
var labelX = enemy.X;
|
||||
var labelY = enemy.Y - m.Height - 2;
|
||||
|
||||
// Background behind label
|
||||
rt.FillRectangle(
|
||||
new RectangleF(labelX - 1, labelY - 1, m.Width + 2, m.Height + 2),
|
||||
ctx.LabelBgBrush);
|
||||
|
||||
rt.DrawTextLayout(new System.Numerics.Vector2(labelX, labelY), layout, textBrush);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var l in _confirmedLabels) l?.Dispose();
|
||||
foreach (var l in _unconfirmedLabels) l?.Dispose();
|
||||
}
|
||||
}
|
||||
72
src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs
Normal file
72
src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Drawing;
|
||||
using Vortice.Direct2D1;
|
||||
using Vortice.DirectWrite;
|
||||
using Vortice.Mathematics;
|
||||
|
||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||
|
||||
internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable
|
||||
{
|
||||
private const float BarWidth = 200;
|
||||
private const float BarHeight = 16;
|
||||
private const float BarY = 1300;
|
||||
private const float LifeBarX = 1130;
|
||||
private const float ManaBarX = 1230;
|
||||
|
||||
// Cached bar value layouts
|
||||
private string? _lifeLabel;
|
||||
private IDWriteTextLayout? _lifeLayout;
|
||||
private string? _manaLabel;
|
||||
private IDWriteTextLayout? _manaLayout;
|
||||
|
||||
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||
{
|
||||
if (state.Hud == null || state.Hud.Timestamp == 0) return;
|
||||
|
||||
DrawBar(ctx, LifeBarX, BarY, state.Hud.LifePct, ctx.LifeBrush, state.Hud.Life,
|
||||
ref _lifeLabel, ref _lifeLayout);
|
||||
DrawBar(ctx, ManaBarX, BarY, state.Hud.ManaPct, ctx.ManaBrush, state.Hud.Mana,
|
||||
ref _manaLabel, ref _manaLayout);
|
||||
}
|
||||
|
||||
private static void DrawBar(D2dRenderContext ctx, float x, float y, float pct,
|
||||
ID2D1SolidColorBrush fillBrush, Screen.HudValues? values,
|
||||
ref string? cachedLabel, ref IDWriteTextLayout? cachedLayout)
|
||||
{
|
||||
var rt = ctx.RenderTarget;
|
||||
var outer = new RectangleF(x, y, BarWidth, BarHeight);
|
||||
|
||||
// Background
|
||||
rt.FillRectangle(outer, ctx.BarBgBrush);
|
||||
// Border
|
||||
rt.DrawRectangle(outer, ctx.Gray, 1f);
|
||||
|
||||
// Fill
|
||||
var fillWidth = BarWidth * Math.Clamp(pct, 0, 1);
|
||||
if (fillWidth > 0)
|
||||
rt.FillRectangle(new RectangleF(x, y, fillWidth, BarHeight), fillBrush);
|
||||
|
||||
// Value text
|
||||
if (values != null)
|
||||
{
|
||||
var label = $"{values.Current}/{values.Max}";
|
||||
if (label != cachedLabel)
|
||||
{
|
||||
cachedLayout?.Dispose();
|
||||
cachedLabel = label;
|
||||
cachedLayout = ctx.CreateTextLayout(label, ctx.BarValueFormat);
|
||||
}
|
||||
|
||||
var m = cachedLayout!.Metrics;
|
||||
var textX = x + (BarWidth - m.Width) / 2;
|
||||
var textY = y + (BarHeight - m.Height) / 2;
|
||||
rt.DrawTextLayout(new System.Numerics.Vector2(textX, textY), cachedLayout, ctx.White);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lifeLayout?.Dispose();
|
||||
_manaLayout?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Vortice.Direct2D1" Version="3.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
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;
|
||||
|
||||
|
|
@ -202,98 +199,4 @@ 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.RandomDelay(150, 300);
|
||||
|
||||
var stashPos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||
if (!stashPos.HasValue)
|
||||
{
|
||||
DebugResult = "STASH nameplate not found. Stand near your stash.";
|
||||
return;
|
||||
}
|
||||
await Helpers.RandomDelay(300, 500);
|
||||
|
||||
// Calibrate stash
|
||||
var stashCal = await calibrator.CalibrateOpenPanel();
|
||||
|
||||
// Close stash, try shop
|
||||
await _bot.Game.PressEscape();
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
|
||||
StashCalibration? shopCal = null;
|
||||
var angePos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||
if (angePos.HasValue)
|
||||
{
|
||||
await Helpers.RandomDelay(300, 500);
|
||||
// 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.RandomDelay(300, 500);
|
||||
}
|
||||
shopCal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
|
||||
await _bot.Game.PressEscape();
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
}
|
||||
|
||||
// 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 + " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Inventory;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
|
|
@ -16,11 +20,18 @@ public partial class SettingsViewModel : ObservableObject
|
|||
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
|
||||
[ObservableProperty] private bool _headless = true;
|
||||
[ObservableProperty] private bool _isSaved;
|
||||
[ObservableProperty] private string _calibrationStatus = "";
|
||||
[ObservableProperty] private string _stashCalibratedAt = "";
|
||||
[ObservableProperty] private string _shopCalibratedAt = "";
|
||||
|
||||
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
|
||||
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
|
||||
|
||||
public SettingsViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
LoadFromConfig();
|
||||
LoadTabs();
|
||||
}
|
||||
|
||||
private void LoadFromConfig()
|
||||
|
|
@ -35,6 +46,42 @@ public partial class SettingsViewModel : ObservableObject
|
|||
Headless = s.Headless;
|
||||
}
|
||||
|
||||
private void LoadTabs()
|
||||
{
|
||||
var s = _bot.Store.Settings;
|
||||
|
||||
StashTabs.Clear();
|
||||
if (s.StashCalibration != null)
|
||||
{
|
||||
foreach (var tab in s.StashCalibration.Tabs)
|
||||
StashTabs.Add(new StashTabViewModel(tab));
|
||||
StashCalibratedAt = FormatTimestamp(s.StashCalibration.CalibratedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
StashCalibratedAt = "Not calibrated";
|
||||
}
|
||||
|
||||
ShopTabs.Clear();
|
||||
if (s.ShopCalibration != null)
|
||||
{
|
||||
foreach (var tab in s.ShopCalibration.Tabs)
|
||||
ShopTabs.Add(new StashTabViewModel(tab));
|
||||
ShopCalibratedAt = FormatTimestamp(s.ShopCalibration.CalibratedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShopCalibratedAt = "Not calibrated";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(long unixMs)
|
||||
{
|
||||
if (unixMs == 0) return "Not calibrated";
|
||||
var dt = DateTimeOffset.FromUnixTimeMilliseconds(unixMs).LocalDateTime;
|
||||
return dt.ToString("yyyy-MM-dd HH:mm");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveSettings()
|
||||
{
|
||||
|
|
@ -52,6 +99,106 @@ public partial class SettingsViewModel : ObservableObject
|
|||
IsSaved = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveTabs()
|
||||
{
|
||||
_bot.Store.UpdateSettings(s =>
|
||||
{
|
||||
// Models are already updated via write-through in StashTabViewModel
|
||||
// Just trigger a save
|
||||
if (s.StashCalibration != null)
|
||||
s.StashCalibration = s.StashCalibration;
|
||||
if (s.ShopCalibration != null)
|
||||
s.ShopCalibration = s.ShopCalibration;
|
||||
});
|
||||
|
||||
CalibrationStatus = "Tabs saved!";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CalibrateStash()
|
||||
{
|
||||
try
|
||||
{
|
||||
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
||||
CalibrationStatus = "Calibrating stash...";
|
||||
|
||||
await _bot.Game.FocusGame();
|
||||
await Helpers.RandomDelay(150, 300);
|
||||
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||
if (!pos.HasValue)
|
||||
{
|
||||
CalibrationStatus = "STASH not found. Stand near your stash.";
|
||||
return;
|
||||
}
|
||||
await Helpers.RandomDelay(300, 500);
|
||||
|
||||
var cal = await calibrator.CalibrateOpenPanel();
|
||||
|
||||
await _bot.Game.PressEscape();
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
|
||||
_bot.Store.UpdateSettings(s => s.StashCalibration = cal);
|
||||
LoadTabs();
|
||||
CalibrationStatus = $"Stash calibrated — {cal.Tabs.Count} tabs found.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CalibrationStatus = $"Stash calibration failed: {ex.Message}";
|
||||
Log.Error(ex, "Stash calibration failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CalibrateShop()
|
||||
{
|
||||
try
|
||||
{
|
||||
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
||||
CalibrationStatus = "Calibrating shop...";
|
||||
|
||||
await _bot.Game.FocusGame();
|
||||
await Helpers.RandomDelay(150, 300);
|
||||
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||
if (!pos.HasValue)
|
||||
{
|
||||
CalibrationStatus = "ANGE not found. Stand near the vendor.";
|
||||
return;
|
||||
}
|
||||
await Helpers.RandomDelay(800, 1200);
|
||||
|
||||
// ANGE opens a dialog — click "Manage Shop"
|
||||
var dialogRegion = new Region(1080, 600, 400, 300);
|
||||
var managePos = await _bot.Screen.FindTextInRegion(dialogRegion, "Manage");
|
||||
if (managePos.HasValue)
|
||||
{
|
||||
await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y);
|
||||
await Helpers.RandomDelay(300, 500);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("'Manage Shop' not found in dialog region, saving debug capture");
|
||||
await _bot.Screen.SaveRegion(dialogRegion, "debug/calibrate-dialog.png");
|
||||
}
|
||||
|
||||
var cal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
|
||||
|
||||
await _bot.Game.PressEscape();
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
|
||||
_bot.Store.UpdateSettings(s => s.ShopCalibration = cal);
|
||||
LoadTabs();
|
||||
CalibrationStatus = $"Shop calibrated — {cal.Tabs.Count} tabs found.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CalibrationStatus = $"Shop calibration failed: {ex.Message}";
|
||||
Log.Error(ex, "Shop calibration failed");
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
|
||||
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
||||
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||
|
|
|
|||
42
src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs
Normal file
42
src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Poe2Trade.Core;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public partial class StashTabViewModel : ObservableObject
|
||||
{
|
||||
private readonly StashTabInfo _model;
|
||||
|
||||
public StashTabViewModel(StashTabInfo model)
|
||||
{
|
||||
_model = model;
|
||||
_name = model.Name;
|
||||
_gridCols = model.GridCols;
|
||||
_enabled = model.Enabled;
|
||||
|
||||
if (model.IsFolder)
|
||||
{
|
||||
foreach (var sub in model.SubTabs)
|
||||
SubTabs.Add(new StashTabViewModel(sub));
|
||||
}
|
||||
}
|
||||
|
||||
public StashTabInfo Model => _model;
|
||||
public int Index => _model.Index;
|
||||
public int ClickX => _model.ClickX;
|
||||
public int ClickY => _model.ClickY;
|
||||
public bool IsFolder => _model.IsFolder;
|
||||
|
||||
[ObservableProperty] private string _name;
|
||||
[ObservableProperty] private int _gridCols;
|
||||
[ObservableProperty] private bool _enabled;
|
||||
|
||||
public ObservableCollection<StashTabViewModel> SubTabs { get; } = [];
|
||||
|
||||
public static int[] GridColOptions { get; } = [12, 24];
|
||||
|
||||
partial void OnNameChanged(string value) => _model.Name = value;
|
||||
partial void OnGridColsChanged(int value) => _model.GridCols = value;
|
||||
partial void OnEnabledChanged(bool value) => _model.Enabled = value;
|
||||
}
|
||||
|
|
@ -298,7 +298,6 @@
|
|||
<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>
|
||||
|
|
@ -417,6 +416,154 @@
|
|||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Status -->
|
||||
<TextBlock Text="{Binding CalibrationStatus}" FontSize="11"
|
||||
Foreground="#3fb950" Margin="0,0,0,4"
|
||||
IsVisible="{Binding CalibrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- STASH TABS -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<DockPanel>
|
||||
<TextBlock Text="STASH TABS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" VerticalAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
|
||||
Spacing="6" HorizontalAlignment="Right">
|
||||
<Button Content="Calibrate" Command="{Binding CalibrateStashCommand}" />
|
||||
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding StashCalibratedAt, StringFormat='Last calibrated: {0}'}"
|
||||
FontSize="11" Foreground="#484f58" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding StashTabs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||
<StackPanel>
|
||||
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
Text="{Binding Index, StringFormat='#{0}'}"
|
||||
Width="28" VerticalAlignment="Center"
|
||||
FontSize="11" Foreground="#8b949e" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
|
||||
IsVisible="{Binding IsFolder}"
|
||||
FontSize="10" Foreground="#58a6ff"
|
||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
<ComboBox DockPanel.Dock="Right"
|
||||
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||
SelectedItem="{Binding GridCols}"
|
||||
Width="70" Margin="6,0,0,0"
|
||||
IsVisible="{Binding !IsFolder}" />
|
||||
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<!-- Nested sub-tabs for folders -->
|
||||
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
|
||||
IsVisible="{Binding IsFolder}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
Text="{Binding Index, StringFormat='#{0}'}"
|
||||
Width="28" VerticalAlignment="Center"
|
||||
FontSize="11" Foreground="#8b949e" />
|
||||
<ComboBox DockPanel.Dock="Right"
|
||||
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||
SelectedItem="{Binding GridCols}"
|
||||
Width="70" Margin="6,0,0,0" />
|
||||
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- SHOP TABS -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<DockPanel>
|
||||
<TextBlock Text="SHOP TABS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" VerticalAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
|
||||
Spacing="6" HorizontalAlignment="Right">
|
||||
<Button Content="Calibrate" Command="{Binding CalibrateShopCommand}" />
|
||||
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding ShopCalibratedAt, StringFormat='Last calibrated: {0}'}"
|
||||
FontSize="11" Foreground="#484f58" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ShopTabs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||
<StackPanel>
|
||||
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
Text="{Binding Index, StringFormat='#{0}'}"
|
||||
Width="28" VerticalAlignment="Center"
|
||||
FontSize="11" Foreground="#8b949e" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
|
||||
IsVisible="{Binding IsFolder}"
|
||||
FontSize="10" Foreground="#58a6ff"
|
||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
<ComboBox DockPanel.Dock="Right"
|
||||
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||
SelectedItem="{Binding GridCols}"
|
||||
Width="70" Margin="6,0,0,0"
|
||||
IsVisible="{Binding !IsFolder}" />
|
||||
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<!-- Nested sub-tabs for folders -->
|
||||
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
|
||||
IsVisible="{Binding IsFolder}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
Text="{Binding Index, StringFormat='#{0}'}"
|
||||
Width="28" VerticalAlignment="Center"
|
||||
FontSize="11" Foreground="#8b949e" />
|
||||
<ComboBox DockPanel.Dock="Right"
|
||||
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||
SelectedItem="{Binding GridCols}"
|
||||
Width="70" Margin="6,0,0,0" />
|
||||
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue