diff --git a/src/LargeGridPathfinding/Agent.cs b/src/LargeGridPathfinding/Agent.cs index aa0b962..cb06c87 100644 --- a/src/LargeGridPathfinding/Agent.cs +++ b/src/LargeGridPathfinding/Agent.cs @@ -5,15 +5,33 @@ using System.Collections.Generic; namespace LargeGridPathfinding; +/// +/// Represents a moving entity that follows a precomputed grid path. +/// internal class Agent(Vector2 position) { + /// + /// Gets or sets the current world-grid position as floating point coordinates. + /// public Vector2 Position { get; set; } = position; + /// + /// Gets the integer grid cell for the current position. + /// public Point GridPosition => new Point((int)Math.Round(Position.X), (int)Math.Round(Position.Y)); + /// + /// Gets or sets the next waypoint currently being approached. + /// public Vector2 NextPosition { get; set; } + /// + /// Gets or sets the current target destination. + /// public Point? Destination { get; set; } + /// + /// Gets or sets the active path. + /// public List? Path { get; set; } } \ No newline at end of file diff --git a/src/LargeGridPathfinding/GridFiller.cs b/src/LargeGridPathfinding/GridFiller.cs index e2cb58d..8845b51 100644 --- a/src/LargeGridPathfinding/GridFiller.cs +++ b/src/LargeGridPathfinding/GridFiller.cs @@ -9,6 +9,9 @@ using System.Threading.Tasks; namespace LargeGridPathfinding; +/// +/// Maintains grid labels/weights and generates rectangular walkable zones. +/// public class GridFiller { private const int RecalculationRadius = 24; @@ -21,6 +24,9 @@ public class GridFiller public int Width { get; } public int Height { get; } + /// + /// Initializes an empty grid with default tile weights. + /// public GridFiller(int width, int height) { Width = width; @@ -38,6 +44,9 @@ public class GridFiller } } + /// + /// Fills the selected region with maximal same-weight rectangles. + /// public void FillGrid(int? x1 = null, int? y1 = null, int? x2 = null, int? y2 = null, bool fillAll = false, IProgress? totalProgress = null, IProgress? calculatingCandidatesProgress = null, IProgress? placingCandidatesProgress = null) { lock (mutationLock) @@ -185,16 +194,25 @@ public class GridFiller } } + /// + /// Places a single obstacle rectangle. + /// public void PlaceObstacle(Rectangle rectangle) { PlaceObstacles([rectangle]); } + /// + /// Places obstacle rectangles and optionally recalculates affected zones. + /// public void PlaceObstacles(IEnumerable rectangles, bool recalculate = true) { _ = PlaceObstaclesWithAffected(rectangles, recalculate); } + /// + /// Places obstacles and returns changed zone labels. + /// public HashSet PlaceObstaclesWithAffected(IEnumerable 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 affected = [.. zonesBefore]; affected.SymmetricExceptWith(zonesAfter); // zones removed OR added @@ -286,16 +304,25 @@ public class GridFiller } } + /// + /// Removes a single obstacle rectangle. + /// public void RemoveObstacle(Rectangle rectangle) { RemoveObstacles([rectangle]); } + /// + /// Removes obstacle rectangles and optionally recalculates affected zones. + /// public void RemoveObstacles(IEnumerable rectangles, bool recalculate = true) { _ = RemoveObstaclesWithAffected(rectangles, recalculate); } + /// + /// Removes obstacles and returns changed zone labels. + /// public HashSet RemoveObstaclesWithAffected(IEnumerable rectangles, bool recalculate = true) { lock (mutationLock) @@ -384,21 +411,33 @@ public class GridFiller } } + /// + /// Sets a single tile weight. + /// public void SetTileWeight(int x, int y, int weight) { SetTileWeights([new Point(x, y)], weight); } + /// + /// Resets a single tile weight to default. + /// public void ResetTileWeight(int x, int y) { ResetTileWeights([new Point(x, y)]); } + /// + /// Sets weights for tiles and optionally recalculates affected zones. + /// public void SetTileWeights(IEnumerable points, int weight, bool recalculate = true) { _ = SetTileWeightsWithAffected(points, weight, recalculate); } + /// + /// Sets weights and returns changed zone labels. + /// public HashSet SetTileWeightsWithAffected(IEnumerable points, int weight, bool recalculate = true) { lock (mutationLock) @@ -465,9 +504,17 @@ public class GridFiller } } - public void ResetTileWeights(IEnumerable points) + public HashSet ResetTileWeightsWithAffected(IEnumerable points, bool recalculate = true) { - SetTileWeights(points, 1); + return SetTileWeightsWithAffected(points, 1, recalculate); + } + + /// + /// Resets weights for multiple tiles to default. + /// + public void ResetTileWeights(IEnumerable 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); } diff --git a/src/LargeGridPathfinding/InputHelper.cs b/src/LargeGridPathfinding/InputHelper.cs index 4849499..367dd16 100644 --- a/src/LargeGridPathfinding/InputHelper.cs +++ b/src/LargeGridPathfinding/InputHelper.cs @@ -5,8 +5,14 @@ using MonoGame.Extended.Input; namespace LargeGridPathfinding; +/// +/// Input extension helpers for game controls. +/// internal static class InputHelper { + /// + /// Converts WASD keyboard input into a movement vector. + /// public static Vector2 GetMovementDirection(this KeyboardStateExtended keyboardState) { Vector2 movementDirection = Vector2.Zero; diff --git a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs index 3cdaf63..120b263 100644 --- a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs +++ b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs @@ -15,6 +15,9 @@ using System.Threading.Tasks; namespace LargeGridPathfinding; +/// +/// Main game host that handles UI, map editing, rendering, and background path updates. +/// public class LargeGridPathfindingGame : Game { private enum BrushMode @@ -42,10 +45,24 @@ public class LargeGridPathfindingGame : Game PenalizeStretchedRectangles } + /// + /// Supported map presets for startup generation. + /// private enum StartupMapPreset { + /// + /// Procedural room-like map with random walls and doors. + /// Rooms, + + /// + /// No generated obstacles. + /// Empty, + + /// + /// Dense 3x3 room lattice with deterministic offset doors for heavy zone fragmentation. + /// 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); } + /// + /// Builds the initial grid and starts the path update worker using the startup configuration. + /// 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 + /// + /// Handles graph/path refresh work on a dedicated long-running task. + /// 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]; } -} +} \ No newline at end of file diff --git a/src/LargeGridPathfinding/Pathfinder.cs b/src/LargeGridPathfinding/Pathfinder.cs index e2c8fb9..cb6e1e9 100644 --- a/src/LargeGridPathfinding/Pathfinder.cs +++ b/src/LargeGridPathfinding/Pathfinder.cs @@ -9,6 +9,9 @@ using System.Threading.Tasks; namespace LargeGridPathfinding; +/// +/// Builds a zone adjacency graph and computes paths across rectangular zones. +/// public class Pathfinder { private readonly ConcurrentDictionary rectanglesSource; @@ -19,11 +22,17 @@ public class Pathfinder private Dictionary rectangleMap; private Dictionary> adjacencyList; + /// + /// Returns the current zone adjacency list. + /// public Dictionary> GetAdjacencyList() { return adjacencyList; } + /// + /// Returns the zone label at a grid point, or 0 when out of bounds. + /// 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]; } + /// + /// Initializes a pathfinder over a mutable grid/rectangle source. + /// public Pathfinder(ConcurrentDictionary rectangles, int[,] grid, int[,] weightGrid, bool pathRandomization = false, bool penalizeStretchedRectangles = false) { rectanglesSource = rectangles; @@ -45,6 +57,9 @@ public class Pathfinder adjacencyList = []; } + /// + /// Finds a path between two grid points as coordinate waypoints. + /// public List? 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; } + /// + /// Rebuilds the full adjacency graph from current rectangles. + /// public void BuildGraph() { rectangleMap = new Dictionary(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; } + /// + /// Incrementally refreshes affected parts of the graph after local grid edits. + /// public void IncrementalUpdateGraph(HashSet affectedZones) { if (affectedZones.Count == 0) @@ -484,4 +507,4 @@ public class Pathfinder return possibleTransitions[possibleTransitions.Count / 2]; } -} +} \ No newline at end of file diff --git a/src/LargeGridPathfinding/ProgressTracker.cs b/src/LargeGridPathfinding/ProgressTracker.cs index 1fab90a..81dafc5 100644 --- a/src/LargeGridPathfinding/ProgressTracker.cs +++ b/src/LargeGridPathfinding/ProgressTracker.cs @@ -3,20 +3,32 @@ using System.Collections.Generic; namespace LargeGridPathfinding; +/// +/// Tracks named progress entries for long-running operations. +/// internal class ProgressTracker { private readonly List progresses = []; + /// + /// Adds a determinate progress entry. + /// public ProgressData AddProgress(string name, out IProgress progress) { return AddProgress(name, false, false, out progress); } + /// + /// Adds an optionally indeterminate progress entry. + /// public ProgressData AddProgress(string name, bool indeterminate, out IProgress progress) { return AddProgress(name, indeterminate, false, out progress); } + /// + /// Adds a progress entry with full control over removal behavior. + /// public ProgressData AddProgress(string name, bool indeterminate, bool disableAutoRemove, out IProgress progress) { CustomProgress newProgress = new CustomProgress(); @@ -33,6 +45,9 @@ internal class ProgressTracker return progressData; } + /// + /// Removes a previously added progress entry. + /// public void RemoveProgress(ProgressData progressData) { lock (progresses) @@ -41,6 +56,9 @@ internal class ProgressTracker } } + /// + /// Returns active progress entries. + /// public IReadOnlyList 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); } } + /// + /// Immutable metadata with mutable progress value for one tracked task. + /// 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; } + /// + /// Minimal IProgress implementation exposing ProgressChanged events. + /// public class CustomProgress() : IProgress { public EventHandler? ProgressChanged { get; set; }