mirror of
https://github.com/Stone-Red-Code/LargeGridPathfinding.git
synced 2026-09-04 00:56:10 +02:00
Add xml docs
This commit is contained in:
@@ -5,15 +5,33 @@ using System.Collections.Generic;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a moving entity that follows a precomputed grid path.
|
||||
/// </summary>
|
||||
internal class Agent(Vector2 position)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current world-grid position as floating point coordinates.
|
||||
/// </summary>
|
||||
public Vector2 Position { get; set; } = position;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the integer grid cell for the current position.
|
||||
/// </summary>
|
||||
public Point GridPosition => new Point((int)Math.Round(Position.X), (int)Math.Round(Position.Y));
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the next waypoint currently being approached.
|
||||
/// </summary>
|
||||
public Vector2 NextPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current target destination.
|
||||
/// </summary>
|
||||
public Point? Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active path.
|
||||
/// </summary>
|
||||
public List<Vector2>? Path { get; set; }
|
||||
}
|
||||
@@ -9,6 +9,9 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Maintains grid labels/weights and generates rectangular walkable zones.
|
||||
/// </summary>
|
||||
public class GridFiller
|
||||
{
|
||||
private const int RecalculationRadius = 24;
|
||||
@@ -21,6 +24,9 @@ public class GridFiller
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes an empty grid with default tile weights.
|
||||
/// </summary>
|
||||
public GridFiller(int width, int height)
|
||||
{
|
||||
Width = width;
|
||||
@@ -38,6 +44,9 @@ public class GridFiller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills the selected region with maximal same-weight rectangles.
|
||||
/// </summary>
|
||||
public void FillGrid(int? x1 = null, int? y1 = null, int? x2 = null, int? y2 = null, bool fillAll = false, IProgress<float>? totalProgress = null, IProgress<float>? calculatingCandidatesProgress = null, IProgress<float>? placingCandidatesProgress = null)
|
||||
{
|
||||
lock (mutationLock)
|
||||
@@ -185,16 +194,25 @@ public class GridFiller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places a single obstacle rectangle.
|
||||
/// </summary>
|
||||
public void PlaceObstacle(Rectangle rectangle)
|
||||
{
|
||||
PlaceObstacles([rectangle]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places obstacle rectangles and optionally recalculates affected zones.
|
||||
/// </summary>
|
||||
public void PlaceObstacles(IEnumerable<Rectangle> rectangles, bool recalculate = true)
|
||||
{
|
||||
_ = PlaceObstaclesWithAffected(rectangles, recalculate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places obstacles and returns changed zone labels.
|
||||
/// </summary>
|
||||
public HashSet<int> PlaceObstaclesWithAffected(IEnumerable<Rectangle> rectangles, bool recalculate = true)
|
||||
{
|
||||
lock (mutationLock)
|
||||
@@ -266,7 +284,7 @@ public class GridFiller
|
||||
|
||||
Debug.WriteLine("Placed obstacle");
|
||||
|
||||
// Return zones that changed (added, removed, or had their rectangle modified)
|
||||
// Symmetric diff captures both split and merge operations from recalculation.
|
||||
HashSet<int> affected = [.. zonesBefore];
|
||||
affected.SymmetricExceptWith(zonesAfter); // zones removed OR added
|
||||
|
||||
@@ -286,16 +304,25 @@ public class GridFiller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single obstacle rectangle.
|
||||
/// </summary>
|
||||
public void RemoveObstacle(Rectangle rectangle)
|
||||
{
|
||||
RemoveObstacles([rectangle]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes obstacle rectangles and optionally recalculates affected zones.
|
||||
/// </summary>
|
||||
public void RemoveObstacles(IEnumerable<Rectangle> rectangles, bool recalculate = true)
|
||||
{
|
||||
_ = RemoveObstaclesWithAffected(rectangles, recalculate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes obstacles and returns changed zone labels.
|
||||
/// </summary>
|
||||
public HashSet<int> RemoveObstaclesWithAffected(IEnumerable<Rectangle> rectangles, bool recalculate = true)
|
||||
{
|
||||
lock (mutationLock)
|
||||
@@ -384,21 +411,33 @@ public class GridFiller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a single tile weight.
|
||||
/// </summary>
|
||||
public void SetTileWeight(int x, int y, int weight)
|
||||
{
|
||||
SetTileWeights([new Point(x, y)], weight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets a single tile weight to default.
|
||||
/// </summary>
|
||||
public void ResetTileWeight(int x, int y)
|
||||
{
|
||||
ResetTileWeights([new Point(x, y)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets weights for tiles and optionally recalculates affected zones.
|
||||
/// </summary>
|
||||
public void SetTileWeights(IEnumerable<Point> points, int weight, bool recalculate = true)
|
||||
{
|
||||
_ = SetTileWeightsWithAffected(points, weight, recalculate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets weights and returns changed zone labels.
|
||||
/// </summary>
|
||||
public HashSet<int> SetTileWeightsWithAffected(IEnumerable<Point> points, int weight, bool recalculate = true)
|
||||
{
|
||||
lock (mutationLock)
|
||||
@@ -465,9 +504,17 @@ public class GridFiller
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetTileWeights(IEnumerable<Point> points)
|
||||
public HashSet<int> ResetTileWeightsWithAffected(IEnumerable<Point> points, bool recalculate = true)
|
||||
{
|
||||
SetTileWeights(points, 1);
|
||||
return SetTileWeightsWithAffected(points, 1, recalculate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets weights for multiple tiles to default.
|
||||
/// </summary>
|
||||
public void ResetTileWeights(IEnumerable<Point> points, bool recalculate = true)
|
||||
{
|
||||
SetTileWeights(points, 1, recalculate);
|
||||
}
|
||||
|
||||
private bool IsAreaFree(int x, int y, int w, int h, int requiredWeight)
|
||||
@@ -648,6 +695,7 @@ public class GridFiller
|
||||
if (removedRectangles.Count == 0)
|
||||
{
|
||||
applyChanges();
|
||||
// Even if no existing zone was removed, edits may have opened space that needs fresh zoning.
|
||||
FillGrid(left, top, right, bottom);
|
||||
return;
|
||||
}
|
||||
@@ -657,6 +705,8 @@ public class GridFiller
|
||||
int fillRight = Math.Max(right, removedRectangles.Max(r => r.Right));
|
||||
int fillBottom = Math.Max(bottom, removedRectangles.Max(r => r.Bottom));
|
||||
|
||||
// Expanding fill bounds to include removed rectangle extents avoids edge artifacts
|
||||
// where old zone boundaries would otherwise leave suboptimal splits near the edit area.
|
||||
applyChanges();
|
||||
FillGrid(fillLeft, fillTop, fillRight, fillBottom);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ using MonoGame.Extended.Input;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Input extension helpers for game controls.
|
||||
/// </summary>
|
||||
internal static class InputHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts WASD keyboard input into a movement vector.
|
||||
/// </summary>
|
||||
public static Vector2 GetMovementDirection(this KeyboardStateExtended keyboardState)
|
||||
{
|
||||
Vector2 movementDirection = Vector2.Zero;
|
||||
|
||||
@@ -15,6 +15,9 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Main game host that handles UI, map editing, rendering, and background path updates.
|
||||
/// </summary>
|
||||
public class LargeGridPathfindingGame : Game
|
||||
{
|
||||
private enum BrushMode
|
||||
@@ -42,10 +45,24 @@ public class LargeGridPathfindingGame : Game
|
||||
PenalizeStretchedRectangles
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported map presets for startup generation.
|
||||
/// </summary>
|
||||
private enum StartupMapPreset
|
||||
{
|
||||
/// <summary>
|
||||
/// Procedural room-like map with random walls and doors.
|
||||
/// </summary>
|
||||
Rooms,
|
||||
|
||||
/// <summary>
|
||||
/// No generated obstacles.
|
||||
/// </summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>
|
||||
/// Dense 3x3 room lattice with deterministic offset doors for heavy zone fragmentation.
|
||||
/// </summary>
|
||||
WorstCase
|
||||
}
|
||||
|
||||
@@ -78,7 +95,6 @@ public class LargeGridPathfindingGame : Game
|
||||
private Point? fillSelectionCurrent;
|
||||
private bool batchProcessingScheduled;
|
||||
private bool startupMenuActive = true;
|
||||
private readonly bool startupInitializationStarted;
|
||||
private StartupMenuItem startupSelectedItem;
|
||||
private int startupGridWidth = 1000;
|
||||
private int startupGridHeight = 1000;
|
||||
@@ -216,21 +232,27 @@ public class LargeGridPathfindingGame : Game
|
||||
case StartupMenuItem.GridWidth:
|
||||
startupGridWidth = Math.Clamp(startupGridWidth + (direction * 100), 500, 30000);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.GridHeight:
|
||||
startupGridHeight = Math.Clamp(startupGridHeight + (direction * 100), 500, 30000);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.AgentCount:
|
||||
startupAgentCount = Math.Clamp(startupAgentCount + (direction * 100), 100, 200000);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.MapPreset:
|
||||
startupMapPreset = (StartupMapPreset)Math.Clamp((int)startupMapPreset + direction, (int)StartupMapPreset.Rooms, (int)StartupMapPreset.WorstCase);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.ObstacleDivisor:
|
||||
startupObstacleDivisor = Math.Clamp(startupObstacleDivisor + direction, 1, 50);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.PathRandomization:
|
||||
startupPathRandomization = direction > 0 || (direction >= 0 && startupPathRandomization);
|
||||
break;
|
||||
|
||||
case StartupMenuItem.PenalizeStretchedRectangles:
|
||||
startupPenalizeStretchedRectangles = direction > 0 || (direction >= 0 && startupPenalizeStretchedRectangles);
|
||||
break;
|
||||
@@ -515,7 +537,7 @@ public class LargeGridPathfindingGame : Game
|
||||
{
|
||||
uiSpriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
|
||||
|
||||
if (startupMenuActive && !startupInitializationStarted)
|
||||
if (startupMenuActive)
|
||||
{
|
||||
uiSpriteBatch.DrawString(uiFont, "Startup Configuration", new Vector2(40, 30), Color.Black);
|
||||
uiSpriteBatch.DrawString(uiFont, "Arrow Up/Down: Select Arrow Left/Right: Change Enter: Start", new Vector2(40, 60), Color.Black);
|
||||
@@ -748,6 +770,9 @@ public class LargeGridPathfindingGame : Game
|
||||
base.Draw(gameTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the initial grid and starts the path update worker using the startup configuration.
|
||||
/// </summary>
|
||||
private void StartInitializationTask(int width, int height, int agentCount, StartupMapPreset mapPreset, int obstacleDivisor, bool pathRandomization, bool penalizeStretchedRectangles)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
@@ -769,7 +794,10 @@ public class LargeGridPathfindingGame : Game
|
||||
int obstacleIterations = Math.Max(0, (width + height) / Math.Max(1, obstacleDivisor));
|
||||
int minObstacleSize = 5;
|
||||
int minDimension = Math.Min(width, height);
|
||||
// Using a sub-linear exponent keeps small maps close to previous behavior,
|
||||
// but prevents obstacle size from exploding on huge maps (e.g. 10k x 10k).
|
||||
int scaledByDimension = (int)Math.Round(Math.Pow(minDimension, 0.4) * 2.1);
|
||||
// This upper cap prevents very long walls on thin maps where one dimension is small.
|
||||
int maxByDimension = Math.Max(minObstacleSize + 1, minDimension / 4);
|
||||
int maxObstacleSize = Math.Max(minObstacleSize + 1, Math.Min(scaledByDimension, maxByDimension));
|
||||
|
||||
@@ -832,6 +860,9 @@ public class LargeGridPathfindingGame : Game
|
||||
int local = x % pitch;
|
||||
int baseX = x - local;
|
||||
bool oddSegment = (baseX / pitch % 2) == 1;
|
||||
// We intentionally alternate door offsets between left and right side of each room segment.
|
||||
// If we always place centered doors, many transitions become symmetric and produce fewer
|
||||
// distinct rectangular splits. Offsetting increases boundary complexity while staying connected.
|
||||
int offset = ((rowIndex % 2 == 0) ^ oddSegment) ? 1 : 3; // left/right offset
|
||||
bool isDoor = local == offset;
|
||||
|
||||
@@ -862,6 +893,8 @@ public class LargeGridPathfindingGame : Game
|
||||
int local = y % pitch;
|
||||
int baseY = y - local;
|
||||
bool oddSegment = (baseY / pitch % 2) == 1;
|
||||
// Same idea vertically: alternating top/bottom door offsets avoids repetitive straight corridors
|
||||
// and keeps the whole lattice navigable with a high number of zone boundaries.
|
||||
int offset = ((colIndex % 2 == 0) ^ oddSegment) ? 1 : 3; // top/bottom offset
|
||||
bool isDoor = local == offset;
|
||||
|
||||
@@ -912,7 +945,9 @@ public class LargeGridPathfindingGame : Game
|
||||
});
|
||||
}
|
||||
|
||||
// Handles pathfinding calculations in a separate task
|
||||
/// <summary>
|
||||
/// Handles graph/path refresh work on a dedicated long-running task.
|
||||
/// </summary>
|
||||
private void StartUpdatePathTask()
|
||||
{
|
||||
_ = Task.Factory.StartNew(async () =>
|
||||
@@ -1100,7 +1135,7 @@ public class LargeGridPathfindingGame : Game
|
||||
|
||||
if (resetWeightPoints.Length > 0)
|
||||
{
|
||||
affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(resetWeightPoints, 1));
|
||||
affectedZones.UnionWith(gridFiller.ResetTileWeightsWithAffected(resetWeightPoints));
|
||||
}
|
||||
|
||||
if (placeObstacleRectangles.Length > 0)
|
||||
@@ -1182,4 +1217,4 @@ public class LargeGridPathfindingGame : Game
|
||||
|
||||
return [.. points];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a zone adjacency graph and computes paths across rectangular zones.
|
||||
/// </summary>
|
||||
public class Pathfinder
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, Rectangle> rectanglesSource;
|
||||
@@ -19,11 +22,17 @@ public class Pathfinder
|
||||
private Dictionary<int, Rectangle> rectangleMap;
|
||||
private Dictionary<int, List<int>> adjacencyList;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current zone adjacency list.
|
||||
/// </summary>
|
||||
public Dictionary<int, List<int>> GetAdjacencyList()
|
||||
{
|
||||
return adjacencyList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the zone label at a grid point, or 0 when out of bounds.
|
||||
/// </summary>
|
||||
public int GetGridValue(Point gridPoint)
|
||||
{
|
||||
if (gridPoint.X < 0 || gridPoint.Y < 0 || gridPoint.X >= grid.GetLength(1) || gridPoint.Y >= grid.GetLength(0))
|
||||
@@ -34,6 +43,9 @@ public class Pathfinder
|
||||
return grid[gridPoint.Y, gridPoint.X];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a pathfinder over a mutable grid/rectangle source.
|
||||
/// </summary>
|
||||
public Pathfinder(ConcurrentDictionary<int, Rectangle> rectangles, int[,] grid, int[,] weightGrid, bool pathRandomization = false, bool penalizeStretchedRectangles = false)
|
||||
{
|
||||
rectanglesSource = rectangles;
|
||||
@@ -45,6 +57,9 @@ public class Pathfinder
|
||||
adjacencyList = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a path between two grid points as coordinate waypoints.
|
||||
/// </summary>
|
||||
public List<Vector2>? FindPath(Point startPoint, Point goalPoint)
|
||||
{
|
||||
if (!IsInBounds(startPoint.X, startPoint.Y) || !IsInBounds(goalPoint.X, goalPoint.Y))
|
||||
@@ -115,6 +130,9 @@ public class Pathfinder
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the full adjacency graph from current rectangles.
|
||||
/// </summary>
|
||||
public void BuildGraph()
|
||||
{
|
||||
rectangleMap = new Dictionary<int, Rectangle>(rectanglesSource);
|
||||
@@ -131,6 +149,8 @@ public class Pathfinder
|
||||
{
|
||||
if (!rectangleMap.TryGetValue(rectangleLabel, out Rectangle rectangle))
|
||||
{
|
||||
// Returning a very large cost keeps stale nodes from being attractive
|
||||
// if they slip through due to transient graph inconsistencies.
|
||||
return int.MaxValue / 4;
|
||||
}
|
||||
|
||||
@@ -247,7 +267,7 @@ public class Pathfinder
|
||||
graph[id] = [];
|
||||
}
|
||||
|
||||
// For small graphs, use sequential algorithm (less overhead)
|
||||
// For small graphs, sequential processing is usually faster than parallel setup/synchronization.
|
||||
if (count < 50)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
@@ -273,7 +293,7 @@ public class Pathfinder
|
||||
}
|
||||
else
|
||||
{
|
||||
// For larger graphs, use parallelization with partitioning
|
||||
// For larger graphs, parallel pair-checking pays off despite synchronization overhead.
|
||||
object[] locks = new object[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
@@ -296,7 +316,7 @@ public class Pathfinder
|
||||
{
|
||||
if (AreRectanglesAdjacent(rect1, rect2))
|
||||
{
|
||||
// Use per-rectangle locks to minimize contention
|
||||
// Per-index locks reduce contention compared to one global graph lock.
|
||||
lock (locks[i])
|
||||
{
|
||||
graph[id1].Add(id2);
|
||||
@@ -314,6 +334,9 @@ public class Pathfinder
|
||||
return graph;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally refreshes affected parts of the graph after local grid edits.
|
||||
/// </summary>
|
||||
public void IncrementalUpdateGraph(HashSet<int> affectedZones)
|
||||
{
|
||||
if (affectedZones.Count == 0)
|
||||
@@ -484,4 +507,4 @@ public class Pathfinder
|
||||
|
||||
return possibleTransitions[possibleTransitions.Count / 2];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,32 @@ using System.Collections.Generic;
|
||||
|
||||
namespace LargeGridPathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks named progress entries for long-running operations.
|
||||
/// </summary>
|
||||
internal class ProgressTracker
|
||||
{
|
||||
private readonly List<ProgressData> progresses = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a determinate progress entry.
|
||||
/// </summary>
|
||||
public ProgressData AddProgress(string name, out IProgress<float> progress)
|
||||
{
|
||||
return AddProgress(name, false, false, out progress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an optionally indeterminate progress entry.
|
||||
/// </summary>
|
||||
public ProgressData AddProgress(string name, bool indeterminate, out IProgress<float> progress)
|
||||
{
|
||||
return AddProgress(name, indeterminate, false, out progress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a progress entry with full control over removal behavior.
|
||||
/// </summary>
|
||||
public ProgressData AddProgress(string name, bool indeterminate, bool disableAutoRemove, out IProgress<float> progress)
|
||||
{
|
||||
CustomProgress<float> newProgress = new CustomProgress<float>();
|
||||
@@ -33,6 +45,9 @@ internal class ProgressTracker
|
||||
return progressData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added progress entry.
|
||||
/// </summary>
|
||||
public void RemoveProgress(ProgressData progressData)
|
||||
{
|
||||
lock (progresses)
|
||||
@@ -41,6 +56,9 @@ internal class ProgressTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns active progress entries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressData> GetProgresses()
|
||||
{
|
||||
return progresses;
|
||||
@@ -50,12 +68,16 @@ internal class ProgressTracker
|
||||
{
|
||||
progressData.Progress = progress;
|
||||
|
||||
// Auto-removal keeps the HUD uncluttered once tasks finish.
|
||||
if (progress >= 1 && !progressData.DoNotRemove)
|
||||
{
|
||||
RemoveProgress(progressData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable metadata with mutable progress value for one tracked task.
|
||||
/// </summary>
|
||||
public class ProgressData(string name, bool indeterminate, bool doNotRemove)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
@@ -64,6 +86,9 @@ internal class ProgressTracker
|
||||
public bool DoNotRemove { get; } = doNotRemove;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal IProgress implementation exposing ProgressChanged events.
|
||||
/// </summary>
|
||||
public class CustomProgress<T>() : IProgress<T>
|
||||
{
|
||||
public EventHandler<T>? ProgressChanged { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user