work on pathing
This commit is contained in:
parent
7d10f1d2a9
commit
3bb0315912
10 changed files with 729 additions and 113 deletions
BIN
assets/checkpoint-off.png
Normal file
BIN
assets/checkpoint-off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 719 B |
BIN
assets/checkpoint-on.png
Normal file
BIN
assets/checkpoint-on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/door.png
Normal file
BIN
assets/door.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 720 B |
|
|
@ -68,7 +68,7 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
PipelineService = pipelineService;
|
||||
|
||||
// Create consumers
|
||||
var minimapCapture = new MinimapCapture(new MinimapConfig(), pipelineService.Backend);
|
||||
var minimapCapture = new MinimapCapture(new MinimapConfig(), pipelineService.Backend, "assets");
|
||||
GameState = new GameStateDetector();
|
||||
HudReader = new HudReader();
|
||||
EnemyDetector = new EnemyDetector();
|
||||
|
|
|
|||
128
src/Poe2Trade.Navigation/IconDetector.cs
Normal file
128
src/Poe2Trade.Navigation/IconDetector.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using OpenCvSharp;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Navigation;
|
||||
|
||||
/// <summary>
|
||||
/// Detects minimap icons (doors, checkpoints) via template matching.
|
||||
/// Loads RGBA templates once, converts to grayscale for matching.
|
||||
/// </summary>
|
||||
internal class IconDetector : IDisposable
|
||||
{
|
||||
private readonly Mat _doorTemplate;
|
||||
private readonly Mat _checkpointOffTemplate;
|
||||
private readonly Mat _checkpointOnTemplate;
|
||||
|
||||
private const double DoorThreshold = 0.65;
|
||||
private const double CheckpointThreshold = 0.60;
|
||||
|
||||
public IconDetector(string assetsDir)
|
||||
{
|
||||
_doorTemplate = LoadGray(Path.Combine(assetsDir, "door.png"));
|
||||
_checkpointOffTemplate = LoadGray(Path.Combine(assetsDir, "checkpoint-off.png"));
|
||||
_checkpointOnTemplate = LoadGray(Path.Combine(assetsDir, "checkpoint-on.png"));
|
||||
|
||||
Log.Information("IconDetector loaded: door={DW}x{DH} cpOff={OW}x{OH} cpOn={NW}x{NH}",
|
||||
_doorTemplate.Width, _doorTemplate.Height,
|
||||
_checkpointOffTemplate.Width, _checkpointOffTemplate.Height,
|
||||
_checkpointOnTemplate.Width, _checkpointOnTemplate.Height);
|
||||
}
|
||||
|
||||
private static Mat LoadGray(string path)
|
||||
{
|
||||
var bgra = Cv2.ImRead(path, ImreadModes.Unchanged);
|
||||
if (bgra.Empty())
|
||||
throw new FileNotFoundException($"Icon template not found: {path}");
|
||||
|
||||
var gray = new Mat();
|
||||
if (bgra.Channels() == 4)
|
||||
{
|
||||
using var bgr = new Mat();
|
||||
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
|
||||
Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY);
|
||||
}
|
||||
else if (bgra.Channels() == 3)
|
||||
{
|
||||
Cv2.CvtColor(bgra, gray, ColorConversionCodes.BGR2GRAY);
|
||||
}
|
||||
else
|
||||
{
|
||||
gray = bgra.Clone();
|
||||
}
|
||||
|
||||
bgra.Dispose();
|
||||
return gray;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect all door icons in the frame. Returns center points in frame coords.
|
||||
/// </summary>
|
||||
public List<Point> DetectDoors(Mat grayFrame)
|
||||
=> Detect(grayFrame, _doorTemplate, DoorThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Detect all inactive checkpoint icons. Returns center points in frame coords.
|
||||
/// </summary>
|
||||
public List<Point> DetectCheckpointsOff(Mat grayFrame)
|
||||
=> Detect(grayFrame, _checkpointOffTemplate, CheckpointThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Detect all active checkpoint icons. Returns center points in frame coords.
|
||||
/// </summary>
|
||||
public List<Point> DetectCheckpointsOn(Mat grayFrame)
|
||||
=> Detect(grayFrame, _checkpointOnTemplate, CheckpointThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Greedy multi-match: find best match, record center, zero out neighborhood, repeat.
|
||||
/// </summary>
|
||||
private static List<Point> Detect(Mat grayFrame, Mat template, double threshold)
|
||||
{
|
||||
var results = new List<Point>();
|
||||
|
||||
if (grayFrame.Width < template.Width || grayFrame.Height < template.Height)
|
||||
return results;
|
||||
|
||||
using var result = new Mat();
|
||||
Cv2.MatchTemplate(grayFrame, template, result, TemplateMatchModes.CCoeffNormed);
|
||||
|
||||
var tw = template.Width;
|
||||
var th = template.Height;
|
||||
|
||||
while (true)
|
||||
{
|
||||
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
|
||||
if (maxVal < threshold)
|
||||
break;
|
||||
|
||||
// Record center point of the matched region
|
||||
var centerX = maxLoc.X + tw / 2;
|
||||
var centerY = maxLoc.Y + th / 2;
|
||||
results.Add(new Point(centerX, centerY));
|
||||
|
||||
// Zero out neighborhood to prevent re-detection
|
||||
var zeroX = Math.Max(0, maxLoc.X - tw / 2);
|
||||
var zeroY = Math.Max(0, maxLoc.Y - th / 2);
|
||||
var zeroW = Math.Min(tw * 2, result.Width - zeroX);
|
||||
var zeroH = Math.Min(th * 2, result.Height - zeroY);
|
||||
if (zeroW > 0 && zeroH > 0)
|
||||
{
|
||||
using var roi = new Mat(result, new Rect(zeroX, zeroY, zeroW, zeroH));
|
||||
roi.SetTo(Scalar.Black);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the template size for doors (used to stamp wall regions).
|
||||
/// </summary>
|
||||
public Size DoorSize => new(_doorTemplate.Width, _doorTemplate.Height);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_doorTemplate.Dispose();
|
||||
_checkpointOffTemplate.Dispose();
|
||||
_checkpointOnTemplate.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ public class MinimapCapture : IFrameConsumer, IDisposable
|
|||
private readonly MinimapConfig _config;
|
||||
private readonly WallColorTracker _colorTracker;
|
||||
private readonly IScreenCapture _backend; // kept for debug capture paths
|
||||
private readonly IconDetector? _iconDetector;
|
||||
private int _modeCheckCounter = 9; // trigger mode detection on first frame
|
||||
private MinimapMode _detectedMode = MinimapMode.Overlay;
|
||||
private int _pendingModeCount; // consecutive detections of a different mode
|
||||
|
|
@ -21,11 +22,18 @@ public class MinimapCapture : IFrameConsumer, IDisposable
|
|||
public MinimapFrame? LastFrame => _lastFrame;
|
||||
public event Action<MinimapMode>? ModeChanged;
|
||||
|
||||
public MinimapCapture(MinimapConfig config, IScreenCapture backend)
|
||||
public MinimapCapture(MinimapConfig config, IScreenCapture backend, string? assetsDir = null)
|
||||
{
|
||||
_config = config;
|
||||
_colorTracker = new WallColorTracker(config.WallLoHSV, config.WallHiHSV);
|
||||
_backend = backend;
|
||||
|
||||
// Load icon templates if assets directory provided and contains templates
|
||||
if (assetsDir != null && File.Exists(Path.Combine(assetsDir, "door.png")))
|
||||
{
|
||||
try { _iconDetector = new IconDetector(assetsDir); }
|
||||
catch (Exception ex) { Log.Warning(ex, "Failed to load icon templates, icon detection disabled"); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -115,17 +123,50 @@ public class MinimapCapture : IFrameConsumer, IDisposable
|
|||
var frameSize = bgr.Width;
|
||||
var classified = new Mat(frameSize, frameSize, MatType.CV_8UC1, Scalar.Black);
|
||||
|
||||
if (_detectedMode == MinimapMode.Corner)
|
||||
{
|
||||
classified.SetTo(new Scalar((byte)MapCell.Wall), wallMask);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var fogMask = BuildFogMask(hsv, wallMask, playerMask);
|
||||
classified.SetTo(new Scalar((byte)MapCell.Fog), fogMask);
|
||||
classified.SetTo(new Scalar((byte)MapCell.Wall), wallMask);
|
||||
}
|
||||
|
||||
// Icon detection (overlay mode only — corner has different scale)
|
||||
List<Point>? checkpointsOff = null;
|
||||
List<Point>? checkpointsOn = null;
|
||||
if (_detectedMode == MinimapMode.Overlay && _iconDetector != null)
|
||||
{
|
||||
using var grayForIcons = new Mat();
|
||||
Cv2.CvtColor(bgr, grayForIcons, ColorConversionCodes.BGR2GRAY);
|
||||
|
||||
// Doors: stamp as Wall in classified + wallMask (fills gap so BFS blocks doors)
|
||||
var doors = _iconDetector.DetectDoors(grayForIcons);
|
||||
if (doors.Count > 0)
|
||||
{
|
||||
var doorSize = _iconDetector.DoorSize;
|
||||
const int pad = 2;
|
||||
foreach (var d in doors)
|
||||
{
|
||||
var rx = Math.Max(0, d.X - doorSize.Width / 2 - pad);
|
||||
var ry = Math.Max(0, d.Y - doorSize.Height / 2 - pad);
|
||||
var rw = Math.Min(doorSize.Width + 2 * pad, frameSize - rx);
|
||||
var rh = Math.Min(doorSize.Height + 2 * pad, frameSize - ry);
|
||||
if (rw <= 0 || rh <= 0) continue;
|
||||
|
||||
var rect = new Rect(rx, ry, rw, rh);
|
||||
using var classRoi = new Mat(classified, rect);
|
||||
classRoi.SetTo(new Scalar((byte)MapCell.Wall));
|
||||
using var wallRoi = new Mat(wallMask, rect);
|
||||
wallRoi.SetTo(new Scalar(255));
|
||||
}
|
||||
Log.Debug("Icons: {Count} doors stamped as wall", doors.Count);
|
||||
}
|
||||
|
||||
// Checkpoints: don't stamp — just pass positions into MinimapFrame
|
||||
checkpointsOff = _iconDetector.DetectCheckpointsOff(grayForIcons);
|
||||
checkpointsOn = _iconDetector.DetectCheckpointsOn(grayForIcons);
|
||||
if (checkpointsOff.Count > 0 || checkpointsOn.Count > 0)
|
||||
Log.Debug("Icons: {Off} checkpoints-off, {On} checkpoints-on", checkpointsOff.Count, checkpointsOn.Count);
|
||||
}
|
||||
|
||||
// Gray for correlation tracking (player zeroed)
|
||||
var grayForCorr = new Mat();
|
||||
Cv2.CvtColor(bgr, grayForCorr, ColorConversionCodes.BGR2GRAY);
|
||||
|
|
@ -159,7 +200,9 @@ public class MinimapCapture : IFrameConsumer, IDisposable
|
|||
WallMask: wallMask,
|
||||
ClassifiedMat: classified,
|
||||
PlayerOffset: playerOffset,
|
||||
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
CheckpointsOff: checkpointsOff,
|
||||
CheckpointsOn: checkpointsOn
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -374,5 +417,6 @@ public class MinimapCapture : IFrameConsumer, IDisposable
|
|||
public void Dispose()
|
||||
{
|
||||
_lastFrame?.Dispose();
|
||||
_iconDetector?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using OpenCvSharp;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Screen;
|
||||
|
|
@ -25,15 +26,23 @@ public class NavigationExecutor : IDisposable
|
|||
private double _desiredDirX, _desiredDirY;
|
||||
private bool _directionChanged;
|
||||
|
||||
// Path following state (only touched by capture loop thread)
|
||||
private List<Point>? _currentPath;
|
||||
private int _pathIndex;
|
||||
private long _lastPathTime;
|
||||
|
||||
// Checkpoint collection
|
||||
private Point? _checkpointGoal;
|
||||
|
||||
// Valid state transitions (from → allowed targets)
|
||||
private static readonly Dictionary<NavigationState, NavigationState[]> ValidTransitions = new()
|
||||
{
|
||||
[NavigationState.Idle] = [NavigationState.Capturing],
|
||||
[NavigationState.Capturing] = [NavigationState.Processing, NavigationState.Idle],
|
||||
[NavigationState.Processing] = [NavigationState.Planning, NavigationState.Stuck, NavigationState.Capturing, NavigationState.Failed, NavigationState.Idle],
|
||||
[NavigationState.Processing] = [NavigationState.Planning, NavigationState.Moving, NavigationState.Stuck, NavigationState.Capturing, NavigationState.Failed, NavigationState.Idle],
|
||||
[NavigationState.Planning] = [NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
|
||||
[NavigationState.Moving] = [NavigationState.Capturing, NavigationState.Idle],
|
||||
[NavigationState.Stuck] = [NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
|
||||
[NavigationState.Stuck] = [NavigationState.Planning, NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
|
||||
[NavigationState.Completed] = [NavigationState.Idle],
|
||||
[NavigationState.Failed] = [NavigationState.Capturing, NavigationState.Idle],
|
||||
};
|
||||
|
|
@ -92,6 +101,10 @@ public class NavigationExecutor : IDisposable
|
|||
_capture.ResetAdaptation();
|
||||
_stopped = false;
|
||||
_stuck.Reset();
|
||||
_currentPath = null;
|
||||
_pathIndex = 0;
|
||||
_lastPathTime = 0;
|
||||
_checkpointGoal = null;
|
||||
SetState(NavigationState.Idle);
|
||||
Log.Information("Navigation reset (new area)");
|
||||
}
|
||||
|
|
@ -103,7 +116,6 @@ public class NavigationExecutor : IDisposable
|
|||
Log.Information("Starting explore loop");
|
||||
_cachedViewport = _worldMap.GetViewportSnapshot(_worldMap.Position);
|
||||
|
||||
var lastMoveTime = 0L;
|
||||
// Input loop runs concurrently — handles WASD keys + combat clicks
|
||||
var inputTask = RunInputLoop();
|
||||
|
||||
|
|
@ -128,7 +140,8 @@ public class NavigationExecutor : IDisposable
|
|||
|
||||
SetState(NavigationState.Processing);
|
||||
var mode = _capture.DetectedMode;
|
||||
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, mode);
|
||||
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, mode,
|
||||
frame.CheckpointsOff, frame.CheckpointsOn);
|
||||
if (_worldMap.LastMatchSucceeded)
|
||||
{
|
||||
_capture.CommitWallColors();
|
||||
|
|
@ -140,40 +153,87 @@ public class NavigationExecutor : IDisposable
|
|||
// Stuck detection
|
||||
_stuck.Update(pos);
|
||||
|
||||
// 2. Movement decisions — non-blocking, just post direction to input loop
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var moveInterval = _stuck.IsStuck ? 200 : _config.MovementWaitMs;
|
||||
|
||||
if (now - lastMoveTime >= moveInterval)
|
||||
// 2. Checkpoint goal check: if we're near the goal, clear it
|
||||
if (_checkpointGoal is { } goal)
|
||||
{
|
||||
lastMoveTime = now;
|
||||
var gdx = goal.X - pos.X;
|
||||
var gdy = goal.Y - pos.Y;
|
||||
if (gdx * gdx + gdy * gdy < 20 * 20)
|
||||
{
|
||||
Log.Information("Checkpoint reached at ({X},{Y})", goal.X, goal.Y);
|
||||
_checkpointGoal = null;
|
||||
_currentPath = null; // force re-path
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Re-path when needed (path consumed, stuck, or stale)
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (NeedsRepath(now))
|
||||
{
|
||||
if (_stuck.IsStuck)
|
||||
SetState(NavigationState.Stuck);
|
||||
else
|
||||
SetState(NavigationState.Planning);
|
||||
|
||||
// BFS finds path through explored cells to nearest frontier
|
||||
var direction = _worldMap.FindNearestUnexplored(pos);
|
||||
(double dirX, double dirY)? direction = null;
|
||||
|
||||
if (_checkpointGoal is { } cpGoal)
|
||||
{
|
||||
// Try to path to checkpoint goal
|
||||
direction = _worldMap.FindPathToTarget(pos, cpGoal);
|
||||
if (direction == null)
|
||||
{
|
||||
Log.Information("Checkpoint unreachable, clearing goal");
|
||||
_checkpointGoal = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (_checkpointGoal == null)
|
||||
{
|
||||
// Look for nearby checkpoint to collect opportunistically
|
||||
var nearCp = _worldMap.GetNearestCheckpointOff(pos);
|
||||
if (nearCp != null)
|
||||
{
|
||||
_checkpointGoal = nearCp.Value;
|
||||
direction = _worldMap.FindPathToTarget(pos, nearCp.Value);
|
||||
if (direction != null)
|
||||
Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y);
|
||||
else
|
||||
_checkpointGoal = null; // unreachable, fall through to frontier
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to normal frontier exploration
|
||||
if (direction == null)
|
||||
{
|
||||
Log.Information("Map fully explored");
|
||||
SetState(NavigationState.Completed);
|
||||
break;
|
||||
direction = _worldMap.FindNearestUnexplored(pos);
|
||||
if (direction == null)
|
||||
{
|
||||
Log.Information("Map fully explored");
|
||||
SetState(NavigationState.Completed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
SetState(NavigationState.Moving);
|
||||
// Post direction to input loop (thread-safe)
|
||||
lock (_dirLock)
|
||||
{
|
||||
_desiredDirX = direction.Value.dirX;
|
||||
_desiredDirY = direction.Value.dirY;
|
||||
_directionChanged = true;
|
||||
}
|
||||
_currentPath = _worldMap.LastBfsPath;
|
||||
_pathIndex = 0;
|
||||
_lastPathTime = now;
|
||||
|
||||
if (_stuck.IsStuck)
|
||||
_stuck.Reset(); // reset after re-routing
|
||||
_stuck.Reset();
|
||||
}
|
||||
|
||||
// 3. Every frame: follow path waypoints → post direction to input loop
|
||||
var dir = FollowPath(pos);
|
||||
if (dir != null)
|
||||
{
|
||||
SetState(NavigationState.Moving);
|
||||
lock (_dirLock)
|
||||
{
|
||||
_desiredDirX = dir.Value.dirX;
|
||||
_desiredDirY = dir.Value.dirY;
|
||||
_directionChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -304,6 +364,61 @@ public class NavigationExecutor : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
|
||||
/// </summary>
|
||||
private bool NeedsRepath(long nowMs)
|
||||
{
|
||||
if (_currentPath == null || _pathIndex >= _currentPath.Count) return true;
|
||||
if (_stuck.IsStuck) return true;
|
||||
if (nowMs - _lastPathTime >= 3000) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follow the current BFS path: advance past passed waypoints, then compute
|
||||
/// direction toward a look-ahead point ~30px ahead on the path.
|
||||
/// </summary>
|
||||
private (double dirX, double dirY)? FollowPath(MapPosition pos)
|
||||
{
|
||||
if (_currentPath == null || _pathIndex >= _currentPath.Count)
|
||||
return null;
|
||||
|
||||
var px = pos.X;
|
||||
var py = pos.Y;
|
||||
|
||||
// Advance past waypoints the player has already passed (within 15px)
|
||||
while (_pathIndex < _currentPath.Count - 1)
|
||||
{
|
||||
var wp = _currentPath[_pathIndex];
|
||||
var dx = wp.X - px;
|
||||
var dy = wp.Y - py;
|
||||
if (dx * dx + dy * dy > 15 * 15) break;
|
||||
_pathIndex++;
|
||||
}
|
||||
|
||||
// Find look-ahead target: walk forward until >= 30px from player
|
||||
var target = _currentPath[^1]; // default to path end
|
||||
for (var i = _pathIndex; i < _currentPath.Count; i++)
|
||||
{
|
||||
var wp = _currentPath[i];
|
||||
var dx = wp.X - px;
|
||||
var dy = wp.Y - py;
|
||||
if (dx * dx + dy * dy >= 30 * 30)
|
||||
{
|
||||
target = wp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (tdx / len, tdy / len);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the best enemy target from detection snapshot.
|
||||
/// Prefers health-bar-confirmed enemies, then closest to screen center.
|
||||
|
|
@ -385,7 +500,8 @@ public class NavigationExecutor : IDisposable
|
|||
}
|
||||
|
||||
var stitchStart = sw.Elapsed.TotalMilliseconds;
|
||||
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, _capture.DetectedMode);
|
||||
var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, _capture.DetectedMode,
|
||||
frame.CheckpointsOff, frame.CheckpointsOn);
|
||||
if (_worldMap.LastMatchSucceeded)
|
||||
_capture.CommitWallColors();
|
||||
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ public record MinimapFrame(
|
|||
Mat WallMask,
|
||||
Mat ClassifiedMat,
|
||||
Point2d PlayerOffset,
|
||||
long Timestamp
|
||||
long Timestamp,
|
||||
List<Point>? CheckpointsOff = null,
|
||||
List<Point>? CheckpointsOn = null
|
||||
) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Poe2Trade.Navigation;
|
|||
/// Last BFS result for visualization.
|
||||
/// </summary>
|
||||
internal record BfsResult(
|
||||
List<Point> Path, // canvas coords: player → target frontier (subsampled)
|
||||
List<Point> BestFrontier, // frontier cells in the chosen direction (canvas coords)
|
||||
List<Point> OtherFrontier, // frontier cells in other directions (canvas coords)
|
||||
double DirX, double DirY, // chosen direction (unit vector)
|
||||
|
|
@ -22,10 +23,11 @@ internal class PathFinder
|
|||
public BfsResult? LastResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// BFS through walkable (Explored) cells to find the best frontier direction.
|
||||
/// Instead of stopping at the nearest frontier, runs the full BFS and counts
|
||||
/// frontier cells reachable per first-step direction. Prefers directions with
|
||||
/// more frontier cells (corridors) over directions with few (dead-end rooms).
|
||||
/// BFS through walkable (Explored) cells to find the best frontier cluster.
|
||||
/// Runs full BFS, collects frontier cells (explored cells bordering unknown),
|
||||
/// groups them into connected components, then picks the cluster with the
|
||||
/// highest score = gain / (cost + 1) where gain = cluster size and cost = BFS
|
||||
/// distance to the nearest cell in the cluster.
|
||||
/// </summary>
|
||||
public (double dirX, double dirY)? FindNearestUnexplored(Mat canvas, int canvasSize, MapPosition pos, int searchRadius = 400)
|
||||
{
|
||||
|
|
@ -37,39 +39,42 @@ internal class PathFinder
|
|||
var size = canvasSize;
|
||||
var rr = searchRadius / step;
|
||||
var gridW = 2 * rr + 1;
|
||||
var gridLen = gridW * gridW;
|
||||
|
||||
var visited = new bool[gridW * gridW];
|
||||
// Propagate first step from start during BFS (avoids per-frontier trace-back)
|
||||
var firstStepX = new short[gridW * gridW];
|
||||
var firstStepY = new short[gridW * gridW];
|
||||
var visited = new bool[gridLen];
|
||||
var dist = new short[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;
|
||||
visited[startGy * gridW + startGx] = true;
|
||||
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];
|
||||
|
||||
// Count frontier cells per first-step direction, and collect positions
|
||||
var frontierCounts = new Dictionary<int, int>();
|
||||
var firstStepCoords = new Dictionary<int, (short gx, short gy)>();
|
||||
var frontierByKey = new Dictionary<int, List<Point>>();
|
||||
// Step A: BFS flood-fill, recording dist and parents
|
||||
var isFrontier = new bool[gridLen];
|
||||
var frontierCells = new List<(int gx, int gy)>();
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (gx, gy) = queue.Dequeue();
|
||||
var cellIdx = gy * gridW + gx;
|
||||
|
||||
// Map grid coords back to canvas coords
|
||||
var wx = cx + (gx - rr) * step;
|
||||
var wy = cy + (gy - rr) * step;
|
||||
|
||||
// Check if this explored cell borders Unknown/Fog (= frontier)
|
||||
// Step B: Check if this explored cell borders Unknown/Fog (= frontier)
|
||||
if (gx != startGx || gy != startGy)
|
||||
{
|
||||
var cellIdx = gy * gridW + gx;
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var nx = wx + dxs[d] * step;
|
||||
|
|
@ -78,17 +83,9 @@ internal class PathFinder
|
|||
var neighbor = canvas.At<byte>(ny, nx);
|
||||
if (neighbor == (byte)MapCell.Unknown || neighbor == (byte)MapCell.Fog)
|
||||
{
|
||||
var fsKey = firstStepY[cellIdx] * gridW + firstStepX[cellIdx];
|
||||
if (frontierCounts.TryGetValue(fsKey, out var cnt))
|
||||
frontierCounts[fsKey] = cnt + 1;
|
||||
else
|
||||
{
|
||||
frontierCounts[fsKey] = 1;
|
||||
firstStepCoords[fsKey] = (firstStepX[cellIdx], firstStepY[cellIdx]);
|
||||
frontierByKey[fsKey] = [];
|
||||
}
|
||||
frontierByKey[fsKey].Add(new Point(wx, wy));
|
||||
break; // don't double-count this cell
|
||||
isFrontier[cellIdx] = true;
|
||||
frontierCells.Add((gx, gy));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,65 +105,264 @@ internal class PathFinder
|
|||
if (nwx < 0 || nwx >= size || nwy < 0 || nwy >= size) continue;
|
||||
|
||||
var cell = canvas.At<byte>(nwy, nwx);
|
||||
if (cell != (byte)MapCell.Explored) continue;
|
||||
if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue;
|
||||
|
||||
visited[idx] = true;
|
||||
// Propagate first step: direct neighbors of start ARE the first step
|
||||
if (gx == startGx && gy == startGy)
|
||||
{
|
||||
firstStepX[idx] = (short)ngx;
|
||||
firstStepY[idx] = (short)ngy;
|
||||
}
|
||||
else
|
||||
{
|
||||
var parentIdx = gy * gridW + gx;
|
||||
firstStepX[idx] = firstStepX[parentIdx];
|
||||
firstStepY[idx] = firstStepY[parentIdx];
|
||||
}
|
||||
dist[idx] = (short)(dist[cellIdx] + 1);
|
||||
parentX[idx] = (short)gx;
|
||||
parentY[idx] = (short)gy;
|
||||
queue.Enqueue((ngx, ngy));
|
||||
}
|
||||
}
|
||||
|
||||
if (frontierCounts.Count == 0)
|
||||
if (frontierCells.Count == 0)
|
||||
{
|
||||
Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius);
|
||||
LastResult = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pick direction with the most frontier cells (prefers corridors over dead ends)
|
||||
var bestKey = -1;
|
||||
var bestCount = 0;
|
||||
foreach (var (key, count) in frontierCounts)
|
||||
// Step C: Cluster frontiers via 8-connected flood-fill
|
||||
var clusterVisited = new bool[gridLen];
|
||||
var clusterCount = 0;
|
||||
var bestClusterGain = 0;
|
||||
var bestClusterCost = 0;
|
||||
var bestClusterScore = -1.0;
|
||||
var bestEntryGx = -1;
|
||||
var bestEntryGy = -1;
|
||||
List<Point>? bestFrontierPts = null;
|
||||
var otherFrontier = new List<Point>();
|
||||
var clusterQueue = new Queue<(int gx, int gy)>(256);
|
||||
|
||||
foreach (var (fgx, fgy) in frontierCells)
|
||||
{
|
||||
if (count > bestCount)
|
||||
var fIdx = fgy * gridW + fgx;
|
||||
if (clusterVisited[fIdx]) continue;
|
||||
|
||||
clusterCount++;
|
||||
// Flood-fill this cluster
|
||||
var clusterCells = new List<(int gx, int gy)>();
|
||||
var minDist = dist[fIdx];
|
||||
var entryGx = fgx;
|
||||
var entryGy = fgy;
|
||||
|
||||
clusterVisited[fIdx] = true;
|
||||
clusterQueue.Enqueue((fgx, fgy));
|
||||
|
||||
while (clusterQueue.Count > 0)
|
||||
{
|
||||
bestCount = count;
|
||||
bestKey = key;
|
||||
var (cgx, cgy) = clusterQueue.Dequeue();
|
||||
clusterCells.Add((cgx, cgy));
|
||||
|
||||
var cIdx = cgy * gridW + cgx;
|
||||
if (dist[cIdx] < minDist)
|
||||
{
|
||||
minDist = dist[cIdx];
|
||||
entryGx = cgx;
|
||||
entryGy = cgy;
|
||||
}
|
||||
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var ngx = cgx + dxs[d];
|
||||
var ngy = cgy + dys[d];
|
||||
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
|
||||
var nIdx = ngy * gridW + ngx;
|
||||
if (clusterVisited[nIdx] || !isFrontier[nIdx]) continue;
|
||||
clusterVisited[nIdx] = true;
|
||||
clusterQueue.Enqueue((ngx, ngy));
|
||||
}
|
||||
}
|
||||
|
||||
// Step D: Score this cluster
|
||||
var gain = clusterCells.Count;
|
||||
var cost = (int)minDist;
|
||||
var score = gain / (cost + 1.0);
|
||||
|
||||
// Convert cluster cells to canvas coords
|
||||
var pts = new List<Point>(gain);
|
||||
foreach (var (cgx, cgy) in clusterCells)
|
||||
pts.Add(new Point(cx + (cgx - rr) * step, cy + (cgy - rr) * step));
|
||||
|
||||
if (score > bestClusterScore)
|
||||
{
|
||||
// Demote previous best to "other"
|
||||
if (bestFrontierPts != null)
|
||||
otherFrontier.AddRange(bestFrontierPts);
|
||||
|
||||
bestClusterScore = score;
|
||||
bestClusterGain = gain;
|
||||
bestClusterCost = cost;
|
||||
bestEntryGx = entryGx;
|
||||
bestEntryGy = entryGy;
|
||||
bestFrontierPts = pts;
|
||||
}
|
||||
else
|
||||
{
|
||||
otherFrontier.AddRange(pts);
|
||||
}
|
||||
}
|
||||
|
||||
var (bestGx, bestGy) = firstStepCoords[bestKey];
|
||||
var dirX = (double)(bestGx - startGx);
|
||||
var dirY = (double)(bestGy - startGy);
|
||||
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (len < 0.001) return (1, 0);
|
||||
// Step E: Trace path from entry cell of winning cluster back to start
|
||||
var rawPath = new List<Point>();
|
||||
var traceGx = bestEntryGx;
|
||||
var traceGy = bestEntryGy;
|
||||
while (traceGx != startGx || traceGy != startGy)
|
||||
{
|
||||
var wx = cx + (traceGx - rr) * step;
|
||||
var wy = cy + (traceGy - rr) * step;
|
||||
rawPath.Add(new Point(wx, wy));
|
||||
var tIdx = traceGy * gridW + traceGx;
|
||||
var pgx = parentX[tIdx];
|
||||
var pgy = parentY[tIdx];
|
||||
traceGx = pgx;
|
||||
traceGy = pgy;
|
||||
}
|
||||
rawPath.Add(new Point(cx, cy));
|
||||
rawPath.Reverse(); // player → frontier
|
||||
|
||||
// Subsample: every 4th point (~8px apart on canvas with step=2)
|
||||
var path = new List<Point>();
|
||||
for (var i = 0; i < rawPath.Count; i += 4)
|
||||
path.Add(rawPath[i]);
|
||||
if (path.Count == 0 || path[^1] != rawPath[^1])
|
||||
path.Add(rawPath[^1]); // always include endpoint
|
||||
|
||||
// Direction from start toward first path segment
|
||||
var dirX = (double)(path[Math.Min(1, path.Count - 1)].X - cx);
|
||||
var dirY = (double)(path[Math.Min(1, path.Count - 1)].Y - cy);
|
||||
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (len < 0.001) { dirX = 1; dirY = 0; len = 1; }
|
||||
dirX /= len;
|
||||
dirY /= len;
|
||||
|
||||
// Store result for visualization
|
||||
var bestFrontier = frontierByKey[bestKey];
|
||||
var otherFrontier = new List<Point>();
|
||||
foreach (var (key, pts) in frontierByKey)
|
||||
{
|
||||
if (key != bestKey)
|
||||
otherFrontier.AddRange(pts);
|
||||
}
|
||||
LastResult = new BfsResult(bestFrontier, otherFrontier, dirX, dirY, cx, cy);
|
||||
// Step F: Store result for visualization
|
||||
LastResult = new BfsResult(path, bestFrontierPts!, otherFrontier, dirX, dirY, cx, cy);
|
||||
|
||||
Log.Debug("BFS: {DirCount} directions, best={Best} frontier cells, dir=({Dx:F2},{Dy:F2})",
|
||||
frontierCounts.Count, bestCount, dirX, dirY);
|
||||
Log.Debug("BFS: {ClusterCount} clusters, best={Gain} cells cost={Cost} score={Score:F1}, dir=({Dx:F2},{Dy:F2}), path={PathLen} waypoints",
|
||||
clusterCount, bestClusterGain, bestClusterCost, bestClusterScore, dirX, dirY, path.Count);
|
||||
return (dirX, dirY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BFS to a specific target point (e.g. checkpoint). Same half-res grid as FindNearestUnexplored.
|
||||
/// Terminates when a cell within ~10px of the target is reached.
|
||||
/// Returns direction toward the target, or null if unreachable.
|
||||
/// </summary>
|
||||
public (double dirX, double dirY)? FindPathToTarget(Mat canvas, int canvasSize, MapPosition pos, Point target, int searchRadius = 400)
|
||||
{
|
||||
var cx = (int)Math.Round(pos.X);
|
||||
var cy = (int)Math.Round(pos.Y);
|
||||
|
||||
const int step = 2;
|
||||
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));
|
||||
|
||||
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
|
||||
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
|
||||
|
||||
const int arrivalDist = 10;
|
||||
const int arrivalDist2 = arrivalDist * arrivalDist;
|
||||
|
||||
var foundGx = -1;
|
||||
var foundGy = -1;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (gx, gy) = queue.Dequeue();
|
||||
var wx = cx + (gx - rr) * step;
|
||||
var wy = cy + (gy - rr) * step;
|
||||
|
||||
// Check if we've reached the target
|
||||
var dtx = wx - target.X;
|
||||
var dty = wy - target.Y;
|
||||
if (dtx * dtx + dty * dty <= arrivalDist2)
|
||||
{
|
||||
foundGx = gx;
|
||||
foundGy = gy;
|
||||
break;
|
||||
}
|
||||
|
||||
for (var d = 0; d < 8; d++)
|
||||
{
|
||||
var ngx = gx + dxs[d];
|
||||
var ngy = gy + dys[d];
|
||||
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
|
||||
|
||||
var idx = ngy * gridW + ngx;
|
||||
if (visited[idx]) continue;
|
||||
|
||||
var nwx = cx + (ngx - rr) * step;
|
||||
var nwy = cy + (ngy - rr) * step;
|
||||
if (nwx < 0 || nwx >= canvasSize || nwy < 0 || nwy >= canvasSize) continue;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if (foundGx < 0)
|
||||
{
|
||||
Log.Debug("BFS target: unreachable target=({TX},{TY}) from ({CX},{CY})", target.X, target.Y, cx, cy);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trace path back to start
|
||||
var rawPath = new List<Point>();
|
||||
var traceGx = foundGx;
|
||||
var traceGy = foundGy;
|
||||
while (traceGx != startGx || traceGy != startGy)
|
||||
{
|
||||
var wx = cx + (traceGx - rr) * step;
|
||||
var wy = cy + (traceGy - rr) * step;
|
||||
rawPath.Add(new Point(wx, wy));
|
||||
var tIdx = traceGy * gridW + traceGx;
|
||||
var pgx = parentX[tIdx];
|
||||
var pgy = parentY[tIdx];
|
||||
traceGx = pgx;
|
||||
traceGy = pgy;
|
||||
}
|
||||
rawPath.Add(new Point(cx, cy));
|
||||
rawPath.Reverse();
|
||||
|
||||
// Subsample every 4th point
|
||||
var path = new List<Point>();
|
||||
for (var i = 0; i < rawPath.Count; i += 4)
|
||||
path.Add(rawPath[i]);
|
||||
if (path.Count == 0 || path[^1] != rawPath[^1])
|
||||
path.Add(rawPath[^1]);
|
||||
|
||||
// Direction from start toward first path segment
|
||||
var dirX = (double)(path[Math.Min(1, path.Count - 1)].X - cx);
|
||||
var dirY = (double)(path[Math.Min(1, path.Count - 1)].Y - cy);
|
||||
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
|
||||
if (len < 0.001) { dirX = 1; dirY = 0; len = 1; }
|
||||
dirX /= len;
|
||||
dirY /= len;
|
||||
|
||||
// Store result for visualization (reuse BfsResult — frontiers empty for target mode)
|
||||
LastResult = new BfsResult(path, [], [], dirX, dirY, cx, cy);
|
||||
|
||||
Log.Debug("BFS target: path={PathLen} waypoints to ({TX},{TY}), dir=({Dx:F2},{Dy:F2})",
|
||||
path.Count, target.X, target.Y, dirX, dirY);
|
||||
return (dirX, dirY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,15 @@ public class WorldMap : IDisposable
|
|||
private Mat? _prevWallMask; // for frame deduplication
|
||||
private readonly PathFinder _pathFinder = new();
|
||||
|
||||
// Checkpoint tracking (canvas coordinates)
|
||||
private readonly List<(Point Pos, long LastSeenMs)> _checkpointsOff = [];
|
||||
private readonly List<(Point Pos, long LastSeenMs)> _checkpointsOn = [];
|
||||
private const int CheckpointDedupRadius = 20;
|
||||
|
||||
public MapPosition Position => _position;
|
||||
public bool LastMatchSucceeded { get; private set; }
|
||||
public int CanvasSize => _canvasSize;
|
||||
internal List<Point>? LastBfsPath => _pathFinder.LastResult?.Path;
|
||||
|
||||
private const int GrowMargin = 500;
|
||||
private const int GrowAmount = 2000; // 1000px added per side
|
||||
|
|
@ -64,7 +70,8 @@ public class WorldMap : IDisposable
|
|||
/// Match current wall mask against the accumulated map to find position,
|
||||
/// then stitch walls and paint explored area.
|
||||
/// </summary>
|
||||
public MapPosition MatchAndStitch(Mat classifiedMat, Mat wallMask, MinimapMode mode = MinimapMode.Overlay)
|
||||
public MapPosition MatchAndStitch(Mat classifiedMat, Mat wallMask, MinimapMode mode = MinimapMode.Overlay,
|
||||
List<Point>? checkpointsOff = null, List<Point>? checkpointsOn = null)
|
||||
{
|
||||
EnsureCapacity();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
|
@ -103,6 +110,7 @@ public class WorldMap : IDisposable
|
|||
|
||||
StitchWithConfidence(classifiedMat, _position, boosted: true, mode: mode);
|
||||
PaintExploredCircle(_position);
|
||||
MergeCheckpoints(_position, classifiedMat.Width, checkpointsOff, checkpointsOn);
|
||||
LastMatchSucceeded = true; // signal caller to update viewport
|
||||
if (_consecutiveMatchFails >= 30)
|
||||
{
|
||||
|
|
@ -140,6 +148,7 @@ public class WorldMap : IDisposable
|
|||
var stitchStart = sw.Elapsed.TotalMilliseconds;
|
||||
StitchWithConfidence(classifiedMat, _position, boosted: false, mode: mode);
|
||||
PaintExploredCircle(_position);
|
||||
MergeCheckpoints(_position, classifiedMat.Width, checkpointsOff, checkpointsOn);
|
||||
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
|
||||
|
||||
var posDx = _position.X - prevPos.X;
|
||||
|
|
@ -323,24 +332,23 @@ public class WorldMap : IDisposable
|
|||
dstRoi.Set(row, col, (byte)MapCell.Explored);
|
||||
}
|
||||
|
||||
// Mark fog on canvas
|
||||
// Mark fog on canvas (on top of Unknown or Explored — not Wall)
|
||||
if (isCorner)
|
||||
{
|
||||
// Corner minimap: fog is clean, accept all fog pixels
|
||||
for (var row = 0; row < h; row++)
|
||||
for (var col = 0; col < w; col++)
|
||||
{
|
||||
if (srcRoi.At<byte>(row, col) != (byte)MapCell.Fog) continue;
|
||||
if (dstRoi.At<byte>(row, col) == (byte)MapCell.Unknown)
|
||||
var dst = dstRoi.At<byte>(row, col);
|
||||
if (dst == (byte)MapCell.Unknown || dst == (byte)MapCell.Explored)
|
||||
dstRoi.Set(row, col, (byte)MapCell.Fog);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overlay: restrict fog to ring around player (filters spell effect noise)
|
||||
var fogInner2 = _config.ExploredRadius * _config.ExploredRadius;
|
||||
var fogOuter = _config.ExploredRadius + 5;
|
||||
var fogOuter2 = fogOuter * fogOuter;
|
||||
// Overlay: exclude small area near player center (spell effect noise)
|
||||
const int fogInner = 30;
|
||||
const int fogInner2 = fogInner * fogInner;
|
||||
|
||||
for (var row = 0; row < h; row++)
|
||||
for (var col = 0; col < w; col++)
|
||||
|
|
@ -349,10 +357,10 @@ public class WorldMap : IDisposable
|
|||
|
||||
var fx = srcX + col - halfSize;
|
||||
var fy = srcY + row - halfSize;
|
||||
var dist2 = fx * fx + fy * fy;
|
||||
if (dist2 < fogInner2 || dist2 > fogOuter2) continue;
|
||||
if (fx * fx + fy * fy < fogInner2) continue;
|
||||
|
||||
if (dstRoi.At<byte>(row, col) == (byte)MapCell.Unknown)
|
||||
var dst = dstRoi.At<byte>(row, col);
|
||||
if (dst == (byte)MapCell.Unknown || dst == (byte)MapCell.Explored)
|
||||
dstRoi.Set(row, col, (byte)MapCell.Fog);
|
||||
}
|
||||
}
|
||||
|
|
@ -360,8 +368,10 @@ public class WorldMap : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark explored area: circle around player position, overwrite Unknown and Fog cells.
|
||||
/// Called by MatchAndStitch after stitch in both bootstrap and match-success branches.
|
||||
/// Mark explored area: circle around player position.
|
||||
/// Unknown cells are marked Explored within the full radius.
|
||||
/// Fog cells are only cleared in the inner portion (fogClearRadius),
|
||||
/// preserving fog at the edge so it stays visible on the viewport.
|
||||
/// </summary>
|
||||
private void PaintExploredCircle(MapPosition position)
|
||||
{
|
||||
|
|
@ -369,6 +379,8 @@ public class WorldMap : IDisposable
|
|||
var pcy = (int)Math.Round(position.Y);
|
||||
var r = _config.ExploredRadius;
|
||||
var r2 = r * r;
|
||||
var fogClear = r - 20;
|
||||
var fogClear2 = fogClear * fogClear;
|
||||
|
||||
var y0 = Math.Max(0, pcy - r);
|
||||
var y1 = Math.Min(_canvasSize - 1, pcy + r);
|
||||
|
|
@ -380,9 +392,12 @@ public class WorldMap : IDisposable
|
|||
{
|
||||
var dx = x - pcx;
|
||||
var dy = y - pcy;
|
||||
if (dx * dx + dy * dy > r2) continue;
|
||||
var d2 = dx * dx + dy * dy;
|
||||
if (d2 > r2) continue;
|
||||
var cell = _canvas.At<byte>(y, x);
|
||||
if (cell == (byte)MapCell.Unknown || cell == (byte)MapCell.Fog)
|
||||
if (cell == (byte)MapCell.Unknown)
|
||||
_canvas.Set(y, x, (byte)MapCell.Explored);
|
||||
else if (cell == (byte)MapCell.Fog && d2 <= fogClear2)
|
||||
_canvas.Set(y, x, (byte)MapCell.Explored);
|
||||
}
|
||||
}
|
||||
|
|
@ -430,6 +445,9 @@ public class WorldMap : IDisposable
|
|||
public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 400)
|
||||
=> _pathFinder.FindNearestUnexplored(_canvas, _canvasSize, pos, searchRadius);
|
||||
|
||||
public (double dirX, double dirY)? FindPathToTarget(MapPosition pos, Point target, int searchRadius = 400)
|
||||
=> _pathFinder.FindPathToTarget(_canvas, _canvasSize, pos, target, searchRadius);
|
||||
|
||||
public byte[] GetMapSnapshot()
|
||||
{
|
||||
Cv2.ImEncode(".png", _canvas, out var buf);
|
||||
|
|
@ -456,7 +474,7 @@ public class WorldMap : IDisposable
|
|||
else if (v == (byte)MapCell.Wall)
|
||||
colored.Set(r, c, new Vec3b(26, 45, 61));
|
||||
else if (v == (byte)MapCell.Fog)
|
||||
colored.Set(r, c, new Vec3b(55, 40, 28));
|
||||
colored.Set(r, c, new Vec3b(120, 70, 40));
|
||||
}
|
||||
|
||||
// BFS overlay: frontier cells + direction line
|
||||
|
|
@ -489,6 +507,32 @@ public class WorldMap : IDisposable
|
|||
var ey = (int)(py2 + bfs.DirY * lineLen);
|
||||
Cv2.ArrowedLine(colored, new Point(px2, py2), new Point(ex, ey),
|
||||
new Scalar(0, 220, 0), 2, tipLength: 0.3);
|
||||
|
||||
// BFS path polyline (bright green)
|
||||
if (bfs.Path.Count >= 2)
|
||||
{
|
||||
var viewPath = new Point[bfs.Path.Count];
|
||||
for (var i = 0; i < bfs.Path.Count; i++)
|
||||
viewPath[i] = new Point(bfs.Path[i].X - x0, bfs.Path[i].Y - y0);
|
||||
Cv2.Polylines(colored, [viewPath], isClosed: false,
|
||||
color: new Scalar(0, 255, 0), thickness: 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint markers
|
||||
foreach (var (cp, _) in _checkpointsOff)
|
||||
{
|
||||
var cpx = cp.X - x0;
|
||||
var cpy = cp.Y - y0;
|
||||
if (cpx >= 0 && cpx < viewSize && cpy >= 0 && cpy < viewSize)
|
||||
Cv2.Circle(colored, new Point(cpx, cpy), 5, new Scalar(100, 100, 100), -1); // gray
|
||||
}
|
||||
foreach (var (cp, _) in _checkpointsOn)
|
||||
{
|
||||
var cpx = cp.X - x0;
|
||||
var cpy = cp.Y - y0;
|
||||
if (cpx >= 0 && cpx < viewSize && cpy >= 0 && cpy < viewSize)
|
||||
Cv2.Circle(colored, new Point(cpx, cpy), 5, new Scalar(220, 200, 0), -1); // bright cyan (BGR)
|
||||
}
|
||||
|
||||
// Player dot (orange, on top)
|
||||
|
|
@ -501,6 +545,90 @@ public class WorldMap : IDisposable
|
|||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert frame-relative checkpoint positions to canvas coords and merge with existing lists.
|
||||
/// When a checkpoint-on appears near a checkpoint-off, remove the off entry.
|
||||
/// </summary>
|
||||
private void MergeCheckpoints(MapPosition position, int frameSize,
|
||||
List<Point>? offPoints, List<Point>? onPoints)
|
||||
{
|
||||
var halfSize = frameSize / 2;
|
||||
var canvasX = (int)Math.Round(position.X) - halfSize;
|
||||
var canvasY = (int)Math.Round(position.Y) - halfSize;
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var r2 = CheckpointDedupRadius * CheckpointDedupRadius;
|
||||
|
||||
if (offPoints != null)
|
||||
{
|
||||
foreach (var fp in offPoints)
|
||||
{
|
||||
var cp = new Point(canvasX + fp.X, canvasY + fp.Y);
|
||||
if (!IsDuplicate(_checkpointsOff, cp, r2))
|
||||
_checkpointsOff.Add((cp, now));
|
||||
}
|
||||
}
|
||||
|
||||
if (onPoints != null)
|
||||
{
|
||||
foreach (var fp in onPoints)
|
||||
{
|
||||
var cp = new Point(canvasX + fp.X, canvasY + fp.Y);
|
||||
|
||||
// Remove matching off-checkpoint (it got activated)
|
||||
for (var i = _checkpointsOff.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var dx = _checkpointsOff[i].Pos.X - cp.X;
|
||||
var dy = _checkpointsOff[i].Pos.Y - cp.Y;
|
||||
if (dx * dx + dy * dy <= r2)
|
||||
{
|
||||
_checkpointsOff.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsDuplicate(_checkpointsOn, cp, r2))
|
||||
_checkpointsOn.Add((cp, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDuplicate(List<(Point Pos, long LastSeenMs)> list, Point pt, int r2)
|
||||
{
|
||||
foreach (var (pos, _) in list)
|
||||
{
|
||||
var dx = pos.X - pt.X;
|
||||
var dy = pos.Y - pt.Y;
|
||||
if (dx * dx + dy * dy <= r2)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the nearest unactivated checkpoint within maxDist canvas pixels, or null.
|
||||
/// </summary>
|
||||
public Point? GetNearestCheckpointOff(MapPosition pos, int maxDist = 200)
|
||||
{
|
||||
var px = (int)Math.Round(pos.X);
|
||||
var py = (int)Math.Round(pos.Y);
|
||||
var maxDist2 = maxDist * maxDist;
|
||||
|
||||
Point? best = null;
|
||||
var bestDist2 = int.MaxValue;
|
||||
foreach (var (cp, _) in _checkpointsOff)
|
||||
{
|
||||
var dx = cp.X - px;
|
||||
var dy = cp.Y - py;
|
||||
var d2 = dx * dx + dy * dy;
|
||||
if (d2 <= maxDist2 && d2 < bestDist2)
|
||||
{
|
||||
bestDist2 = d2;
|
||||
best = cp;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_canvas.Dispose();
|
||||
|
|
@ -514,6 +642,8 @@ public class WorldMap : IDisposable
|
|||
_frameCount = 0;
|
||||
_consecutiveMatchFails = 0;
|
||||
LastMatchSucceeded = false;
|
||||
_checkpointsOff.Clear();
|
||||
_checkpointsOn.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue