From 1550b094ed907730802478fe2f994dacc273d1be Mon Sep 17 00:00:00 2001
From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com>
Date: Wed, 8 Jul 2026 18:56:19 +0200
Subject: [PATCH] Refactor to use ECS
---
src/LargeGridPathfinding/Agent.cs | 42 -
.../Components/AgentComponent.cs | 15 +
.../LargeGridPathfinding.csproj | 6 +-
.../LargeGridPathfindingGame.cs | 1413 +----------------
src/LargeGridPathfinding/ProgressTracker.cs | 2 +-
.../Screens/GameplayScreen.cs | 467 ++++++
.../Screens/StartupMenuScreen.cs | 171 ++
.../Services/GridService.cs | 143 ++
.../Services/PathfindingService.cs | 162 ++
.../Systems/AgentMovementSystem.cs | 230 +++
.../Systems/AgentRenderSystem.cs | 89 ++
.../Systems/GridEditSystem.cs | 392 +++++
.../Systems/GridRenderSystem.cs | 150 ++
.../Systems/PathRenderSystem.cs | 127 ++
14 files changed, 1974 insertions(+), 1435 deletions(-)
delete mode 100644 src/LargeGridPathfinding/Agent.cs
create mode 100644 src/LargeGridPathfinding/Components/AgentComponent.cs
create mode 100644 src/LargeGridPathfinding/Screens/GameplayScreen.cs
create mode 100644 src/LargeGridPathfinding/Screens/StartupMenuScreen.cs
create mode 100644 src/LargeGridPathfinding/Services/GridService.cs
create mode 100644 src/LargeGridPathfinding/Services/PathfindingService.cs
create mode 100644 src/LargeGridPathfinding/Systems/AgentMovementSystem.cs
create mode 100644 src/LargeGridPathfinding/Systems/AgentRenderSystem.cs
create mode 100644 src/LargeGridPathfinding/Systems/GridEditSystem.cs
create mode 100644 src/LargeGridPathfinding/Systems/GridRenderSystem.cs
create mode 100644 src/LargeGridPathfinding/Systems/PathRenderSystem.cs
diff --git a/src/LargeGridPathfinding/Agent.cs b/src/LargeGridPathfinding/Agent.cs
deleted file mode 100644
index efda6bc..0000000
--- a/src/LargeGridPathfinding/Agent.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using Microsoft.Xna.Framework;
-
-using System;
-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; }
-
- ///
- /// Index of the current target waypoint within Path.
- ///
- public int PathIndex { get; set; }
-}
\ No newline at end of file
diff --git a/src/LargeGridPathfinding/Components/AgentComponent.cs b/src/LargeGridPathfinding/Components/AgentComponent.cs
new file mode 100644
index 0000000..6acfb20
--- /dev/null
+++ b/src/LargeGridPathfinding/Components/AgentComponent.cs
@@ -0,0 +1,15 @@
+using Microsoft.Xna.Framework;
+
+using System.Collections.Generic;
+
+namespace LargeGridPathfinding.Components;
+
+public class AgentComponent
+{
+ public Vector2 Position { get; set; }
+ public Vector2 NextPosition { get; set; }
+ public Point? Destination { get; set; }
+ public List? Path { get; set; }
+ public int PathIndex { get; set; }
+ public Point GridPosition => new((int)Position.X, (int)Position.Y);
+}
diff --git a/src/LargeGridPathfinding/LargeGridPathfinding.csproj b/src/LargeGridPathfinding/LargeGridPathfinding.csproj
index 3ba61c7..ffcdb41 100644
--- a/src/LargeGridPathfinding/LargeGridPathfinding.csproj
+++ b/src/LargeGridPathfinding/LargeGridPathfinding.csproj
@@ -24,9 +24,9 @@
-
-
-
+
+
+
diff --git a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs
index f509008..a4efaa6 100644
--- a/src/LargeGridPathfinding/LargeGridPathfindingGame.cs
+++ b/src/LargeGridPathfinding/LargeGridPathfindingGame.cs
@@ -1,119 +1,20 @@
using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Graphics;
-using Microsoft.Xna.Framework.Input;
-using MonoGame.Extended;
-using MonoGame.Extended.Input;
+using MonoGame.Extended.Screens;
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
+using LargeGridPathfinding.Screens;
namespace LargeGridPathfinding;
-///
-/// Main game host that handles UI, map editing, rendering, and background path updates.
-///
public class LargeGridPathfindingGame : Game
{
- private enum BrushMode
- {
- Weight,
- Obstacle
- }
-
- private enum PendingOperationKind
- {
- SetWeight,
- ResetWeight,
- PlaceObstacle,
- RemoveObstacle
- }
-
- private enum StartupMenuItem
- {
- GridWidth,
- GridHeight,
- AgentCount,
- MapPreset,
- ObstacleDivisor,
- PathRandomization,
- 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
- }
-
- private readonly record struct PendingOperation(PendingOperationKind Kind, int Weight);
-
- private readonly ConcurrentDictionary temporaryIndicators = [];
- private readonly Dictionary pendingOperations = [];
- private readonly object pendingOperationsLock = new();
- private readonly ProgressTracker progressTracker = new ProgressTracker();
- private readonly List agents = [];
- private readonly ConcurrentQueue<(Agent agent, List? path)> pendingPathResults = new();
- private readonly HashSet affectedZones = [];
- private readonly object affectedZonesLock = new();
- private SpriteBatch spriteBatch = null!;
- private SpriteBatch uiSpriteBatch = null!;
- private SpriteFont uiFont = null!;
- private OrthographicCamera camera = null!;
- private GridFiller gridFiller = null!;
- private Pathfinder pathfinder = null!;
- private int frameCount;
- private bool gridChanged;
- private const int SectorShift = 7;
- private const int SectorSize = 1 << SectorShift;
- private int sectorsX;
- private int sectorsY;
- private List[,] agentSectors = null!;
- private bool inputBlocked = true;
- private bool showZones = true;
- private bool showGrid = false;
- private bool showPaths = false;
- private BrushMode brushMode = BrushMode.Obstacle;
- private int paintWeight = 5;
- private Vector2? previousMousePosition;
- private bool wasShiftLeftDown;
- private bool wasShiftRightDown;
- private Point? fillSelectionStart;
- private Point? fillSelectionCurrent;
- private bool batchProcessingScheduled;
- private bool startupMenuActive = true;
- private StartupMenuItem startupSelectedItem;
- private int startupGridWidth = 1000;
- private int startupGridHeight = 1000;
- private int startupAgentCount = 1000;
- private StartupMapPreset startupMapPreset = StartupMapPreset.Rooms;
- private int startupObstacleDivisor = 2;
- private bool startupPathRandomization;
- private bool startupPenalizeStretchedRectangles;
+ private readonly GraphicsDeviceManager graphics;
+ private readonly ScreenManager screenManager;
+ private readonly ProgressTracker progressTracker = new();
public LargeGridPathfindingGame()
{
- _ = new GraphicsDeviceManager(this)
+ graphics = new GraphicsDeviceManager(this)
{
PreferredBackBufferWidth = 1920,
PreferredBackBufferHeight = 1080
@@ -124,1301 +25,35 @@ public class LargeGridPathfindingGame : Game
Window.Title = "Large Grid Pathfinding";
Window.AllowUserResizing = true;
- }
- protected override void Initialize()
- {
- camera = new OrthographicCamera(GraphicsDevice)
- {
- MinimumZoom = 0.1f,
- MaximumZoom = 2,
- Zoom = 0.5f,
- };
-
- base.Initialize();
+ screenManager = new ScreenManager();
+ Components.Add(screenManager);
}
protected override void LoadContent()
{
- spriteBatch = new SpriteBatch(GraphicsDevice);
- uiSpriteBatch = new SpriteBatch(GraphicsDevice);
- uiFont = Content.Load("ManoloMono");
+ screenManager.ShowScreen(new StartupMenuScreen(this));
+ base.LoadContent();
}
protected override void Update(GameTime gameTime)
{
- if (!IsActive)
+ // Check if startup screen has been confirmed and transition to gameplay
+ if (screenManager.ActiveScreen is StartupMenuScreen startupScreen && startupScreen.Initialized)
{
- return;
- }
-
- frameCount++;
-
- if (pathfinder is not null)
- {
- // Drain pending path results — background thread enqueues, main thread applies
- while (pendingPathResults.TryDequeue(out (Agent agent, List? path) result))
- {
- Agent agent = result.agent;
- List? path = result.path;
- agent.Path = path;
- if (path?.Count > 0)
- {
- agent.Position = path[0];
- agent.NextPosition = path.Count > 1 ? path[1] : path[0];
- agent.PathIndex = path.Count > 1 ? 1 : 0;
- agent.Destination = new Point((int)path[^1].X, (int)path[^1].Y);
- }
- }
-
- // Update agent positions (sector-culled — only agents near the camera)
- int[,] grid = gridFiller.Grid;
- float dt = gameTime.GetElapsedSeconds();
- float step = 10f * dt;
- float sqrStep = step * step;
-
- // Rebuild sectors periodically so agents crossing boundaries are repositioned
- if ((frameCount & 127) == 0)
- {
- RebuildSectors();
- }
-
- // View culling bounds (cell coordinates — BoundingRectangle is in world units, /10 to match agent positions)
- RectangleF viewBounds = camera.BoundingRectangle;
- float margin = 5f;
- float cellViewLeft = (viewBounds.Left / 10f) - margin;
- float cellViewRight = (viewBounds.Right / 10f) + margin;
- float cellViewTop = (viewBounds.Top / 10f) - margin;
- float cellViewBottom = (viewBounds.Bottom / 10f) + margin;
-
- int minSX = Math.Max(0, (int)(cellViewLeft / SectorSize) - 1);
- int maxSX = Math.Min(sectorsX - 1, (int)(cellViewRight / SectorSize) + 1);
- int minSY = Math.Max(0, (int)(cellViewTop / SectorSize) - 1);
- int maxSY = Math.Min(sectorsY - 1, (int)(cellViewBottom / SectorSize) + 1);
-
- for (int sx = minSX; sx <= maxSX; sx++)
- {
- for (int sy = minSY; sy <= maxSY; sy++)
- {
- List? sector = agentSectors[sx, sy];
- if (sector is null)
- {
- continue;
- }
-
- foreach (Agent agent in sector)
- {
- List? path = agent.Path;
- if (path is null || agent.PathIndex >= path.Count)
- {
- continue;
- }
-
- Vector2 currentPos = agent.Position;
- bool inView = currentPos.X >= cellViewLeft && currentPos.X <= cellViewRight
- && currentPos.Y >= cellViewTop && currentPos.Y <= cellViewBottom;
-
- Vector2 targetPos = agent.NextPosition;
- float dx = targetPos.X - currentPos.X;
- float dy = targetPos.Y - currentPos.Y;
- float sqrDist = (dx * dx) + (dy * dy);
-
- if (sqrDist < 0.01f)
- {
- if (agent.PathIndex == path.Count - 1)
- {
- agent.Path = null;
- agent.Destination = null;
- }
- else
- {
- agent.NextPosition = path[++agent.PathIndex];
- }
- }
- else
- {
- Vector2 nextPosition;
- if (sqrDist <= sqrStep)
- {
- nextPosition = targetPos;
- }
- else
- {
- float t = step / MathF.Sqrt(sqrDist);
- nextPosition = new Vector2(currentPos.X + (dx * t), currentPos.Y + (dy * t));
- }
-
- agent.Position = nextPosition;
-
- if (inView)
- {
- int newX = (int)(nextPosition.X + 0.5f);
- int newY = (int)(nextPosition.Y + 0.5f);
-
- if (grid[newY, newX] < 0)
- {
- agent.Position = currentPos;
- agent.Path = null;
- }
- }
- }
- }
- }
- }
-
- // Off-screen agents: catch up the full elapsed distance (position only, no grid check)
- int totalAgents = agents.Count;
- int offScreenBudget = Math.Max(1000, totalAgents / 1000);
- float catchUpStep = step * (totalAgents / (float)offScreenBudget);
- int startIndex = frameCount * offScreenBudget % totalAgents;
-
- for (int i = 0; i < offScreenBudget; i++)
- {
- Agent a = agents[(startIndex + i) % totalAgents];
-
- List? p = a.Path;
- if (p is null || a.PathIndex >= p.Count)
- {
- continue;
- }
-
- Vector2 pos = a.Position;
- if (pos.X >= cellViewLeft && pos.X <= cellViewRight
- && pos.Y >= cellViewTop && pos.Y <= cellViewBottom)
- {
- continue;
- }
-
- float remaining = catchUpStep;
-
- while (remaining > 0.01f)
- {
- Vector2 currentPos = a.Position;
- Vector2 targetPos = a.NextPosition;
- float dx = targetPos.X - currentPos.X;
- float dy = targetPos.Y - currentPos.Y;
- float sqrDist = (dx * dx) + (dy * dy);
-
- if (sqrDist < 0.01f)
- {
- if (a.PathIndex == p.Count - 1)
- {
- a.Path = null;
- a.Destination = null;
- break;
- }
-
- a.NextPosition = p[++a.PathIndex];
- continue;
- }
-
- if (sqrDist <= remaining * remaining)
- {
- a.Position = targetPos;
- remaining -= MathF.Sqrt(sqrDist);
-
- if (a.PathIndex == p.Count - 1)
- {
- a.Path = null;
- a.Destination = null;
- break;
- }
-
- a.NextPosition = p[++a.PathIndex];
- }
- else
- {
- float t = remaining / MathF.Sqrt(sqrDist);
- a.Position = new Vector2(currentPos.X + (dx * t), currentPos.Y + (dy * t));
- break;
- }
- }
- }
- }
-
- MouseExtended.Update();
- KeyboardExtended.Update();
-
- MouseStateExtended mouseState = MouseExtended.GetState();
- KeyboardStateExtended keyboardState = KeyboardExtended.GetState();
- bool ctrlPressed = keyboardState.IsKeyDown(Keys.LeftControl) || keyboardState.IsKeyDown(Keys.RightControl);
- bool shiftPressed = keyboardState.IsKeyDown(Keys.LeftShift) || keyboardState.IsKeyDown(Keys.RightShift);
- bool shiftLeftDown = shiftPressed && mouseState.IsButtonDown(MouseButton.Left);
- bool shiftLeftClicked = shiftLeftDown && !wasShiftLeftDown;
- bool shiftRightDown = shiftPressed && mouseState.IsButtonDown(MouseButton.Right);
- bool shiftRightClicked = shiftRightDown && !wasShiftRightDown;
- wasShiftLeftDown = shiftLeftDown;
- wasShiftRightDown = shiftRightDown;
-
- if (startupMenuActive)
- {
- if (keyboardState.WasKeyPressed(Keys.Up))
- {
- startupSelectedItem = (StartupMenuItem)Math.Max(0, (int)startupSelectedItem - 1);
- }
- else if (keyboardState.WasKeyPressed(Keys.Down))
- {
- startupSelectedItem = (StartupMenuItem)Math.Min((int)StartupMenuItem.PenalizeStretchedRectangles, (int)startupSelectedItem + 1);
- }
-
- int direction = 0;
- if (keyboardState.WasKeyPressed(Keys.Left))
- {
- direction = shiftPressed ? ctrlPressed ? -100 : -10 : -1;
- }
- else if (keyboardState.WasKeyPressed(Keys.Right))
- {
- direction = shiftPressed ? ctrlPressed ? 100 : 10 : 1;
- }
-
- if (direction != 0)
- {
- switch (startupSelectedItem)
- {
- 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 * 1000), 100, 1000000);
- 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;
- }
- }
-
- if (keyboardState.WasKeyPressed(Keys.Enter))
- {
- startupMenuActive = false;
- StartInitializationTask(startupGridWidth, startupGridHeight, startupAgentCount, startupMapPreset, startupObstacleDivisor, startupPathRandomization, startupPenalizeStretchedRectangles);
- }
-
- base.Update(gameTime);
- return;
- }
-
- float movementSpeed = (float)Math.Pow(200, 2 - camera.Zoom);
-
- movementSpeed = Math.Clamp(movementSpeed, 1000, 20000);
-
- camera.Move(keyboardState.GetMovementDirection() * movementSpeed * gameTime.GetElapsedSeconds());
-
- if (mouseState.DeltaScrollWheelValue < 0)
- {
- camera.ZoomIn(0.1f);
- }
- else if (mouseState.DeltaScrollWheelValue > 0)
- {
- camera.ZoomOut(0.1f);
- }
-
- if (inputBlocked)
- {
- return;
- }
-
- // Toggle debug options
-
- if (keyboardState.WasKeyPressed(Keys.Z))
- {
- showZones = !showZones;
- }
-
- if (keyboardState.WasKeyPressed(Keys.G))
- {
- showGrid = !showGrid;
- }
-
- if (keyboardState.WasKeyPressed(Keys.P))
- {
- showPaths = !showPaths;
- }
-
- if (keyboardState.WasKeyPressed(Keys.D1) || keyboardState.WasKeyPressed(Keys.NumPad1))
- {
- paintWeight = 1;
- }
- else if (keyboardState.WasKeyPressed(Keys.D2) || keyboardState.WasKeyPressed(Keys.NumPad2))
- {
- paintWeight = 2;
- }
- else if (keyboardState.WasKeyPressed(Keys.D3) || keyboardState.WasKeyPressed(Keys.NumPad3))
- {
- paintWeight = 3;
- }
- else if (keyboardState.WasKeyPressed(Keys.D4) || keyboardState.WasKeyPressed(Keys.NumPad4))
- {
- paintWeight = 4;
- }
- else if (keyboardState.WasKeyPressed(Keys.D5) || keyboardState.WasKeyPressed(Keys.NumPad5))
- {
- paintWeight = 5;
- }
- else if (keyboardState.WasKeyPressed(Keys.D6) || keyboardState.WasKeyPressed(Keys.NumPad6))
- {
- paintWeight = 6;
- }
- else if (keyboardState.WasKeyPressed(Keys.D7) || keyboardState.WasKeyPressed(Keys.NumPad7))
- {
- paintWeight = 7;
- }
- else if (keyboardState.WasKeyPressed(Keys.D8) || keyboardState.WasKeyPressed(Keys.NumPad8))
- {
- paintWeight = 8;
- }
- else if (keyboardState.WasKeyPressed(Keys.D9) || keyboardState.WasKeyPressed(Keys.NumPad9))
- {
- paintWeight = 9;
- }
-
- if (keyboardState.WasKeyPressed(Keys.O))
- {
- brushMode = brushMode == BrushMode.Weight ? BrushMode.Obstacle : BrushMode.Weight;
- }
-
- if (keyboardState.WasKeyPressed(Keys.F))
- {
- Debug.WriteLine("Filling grid...");
-
- _ = progressTracker.AddProgress("Filling grid", out IProgress fillingGridProgressReporter);
- _ = progressTracker.AddProgress("Calculating candidates", out IProgress calculatingCandidatesProgressReporter);
- _ = progressTracker.AddProgress("Placing zones", out IProgress placingCandidatesProgressReporter);
-
- bool showZonesBefore = showZones;
- bool showGridBefore = showGrid;
- bool showPathsBefore = showPaths;
- showZones = true;
- showGrid = false;
- showPaths = false;
- inputBlocked = true;
-
- _ = Task.Run(() =>
- {
- gridFiller.FillGrid(fillAll: true, totalProgress: fillingGridProgressReporter, calculatingCandidatesProgress: calculatingCandidatesProgressReporter, placingCandidatesProgress: placingCandidatesProgressReporter);
- gridChanged = true;
- inputBlocked = false;
- showZones = showZonesBefore;
- showGrid = showGridBefore;
- showPaths = showPathsBefore;
- });
- }
-
- if (shiftPressed)
- {
- if (fillSelectionStart.HasValue && TryGetMouseGridPoint(mouseState, out Point hoverGridPoint))
- {
- fillSelectionCurrent = hoverGridPoint;
- }
- else
- {
- fillSelectionCurrent = null;
- }
-
- if ((shiftLeftClicked || shiftRightClicked) && TryGetMouseGridPoint(mouseState, out Point clickedGridPoint))
- {
- if (!fillSelectionStart.HasValue)
- {
- fillSelectionStart = clickedGridPoint;
- fillSelectionCurrent = clickedGridPoint;
- }
- else
- {
- List selectionPoints = GetRectanglePoints(fillSelectionStart.Value, clickedGridPoint);
- Color indicatorColor = brushMode == BrushMode.Weight ? Color.Orange : Color.Red;
-
- foreach (Point point in selectionPoints)
- {
- temporaryIndicators[point.ToVector2()] = indicatorColor;
- }
-
- PendingOperation operation;
- if (brushMode == BrushMode.Weight && shiftLeftClicked)
- {
- operation = new PendingOperation(PendingOperationKind.SetWeight, paintWeight);
- }
- else if (brushMode == BrushMode.Weight && shiftRightClicked)
- {
- operation = new PendingOperation(PendingOperationKind.ResetWeight, 0);
- }
- else if (brushMode == BrushMode.Obstacle && shiftLeftClicked)
- {
- operation = new PendingOperation(PendingOperationKind.PlaceObstacle, 0);
- }
- else if (brushMode == BrushMode.Obstacle && shiftRightClicked)
- {
- operation = new PendingOperation(PendingOperationKind.RemoveObstacle, 0);
- }
- else
- {
- throw new InvalidOperationException("Invalid brush mode or mouse button state.");
- }
-
- EnqueuePendingOperations(selectionPoints, operation);
- fillSelectionStart = null;
- fillSelectionCurrent = null;
- }
- }
-
- previousMousePosition = null;
- base.Update(gameTime);
- return;
- }
- else
- {
- fillSelectionStart = null;
- fillSelectionCurrent = null;
- fillSelectionCurrent = null;
- }
-
- // Paint and clear tiles via mouse input
-
- if (mouseState.IsButtonDown(MouseButton.Left))
- {
- Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
- Vector2 gridPosition = new Vector2((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
-
- if (gridPosition.X < 0 || gridPosition.X >= gridFiller.Width || gridPosition.Y < 0 || gridPosition.Y >= gridFiller.Height || temporaryIndicators.ContainsKey(gridPosition))
- {
- return;
- }
-
- int cellValue = gridFiller.Grid[(int)gridPosition.Y, (int)gridPosition.X];
- if ((brushMode == BrushMode.Weight && cellValue < 0) || (brushMode == BrushMode.Obstacle && cellValue < 0))
- {
- return;
- }
-
- Vector2? previousGridPosition = null;
- if (previousMousePosition is not null)
- {
- previousGridPosition = new Vector2((int)previousMousePosition.Value.X / 10, (int)previousMousePosition.Value.Y / 10);
- }
-
- Color indicatorColor = brushMode == BrushMode.Weight ? Color.Orange : Color.Red;
- BrushMode currentBrushMode = brushMode;
- int currentPaintWeight = paintWeight;
- previousMousePosition = mousePosition;
-
- List brushPoints = GetBrushPoints(gridPosition, previousGridPosition);
- foreach (Point point in brushPoints)
- {
- temporaryIndicators[point.ToVector2()] = indicatorColor;
- }
-
- PendingOperation pendingOperation = currentBrushMode == BrushMode.Weight
- ? new PendingOperation(PendingOperationKind.SetWeight, currentPaintWeight)
- : new PendingOperation(PendingOperationKind.PlaceObstacle, 0);
-
- EnqueuePendingOperations(brushPoints, pendingOperation);
- }
- else if (mouseState.IsButtonDown(MouseButton.Right))
- {
- Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
- Vector2 gridPosition = new Vector2((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
-
- if (gridPosition.X < 0 || gridPosition.X >= gridFiller.Width || gridPosition.Y < 0 || gridPosition.Y >= gridFiller.Height || temporaryIndicators.ContainsKey(gridPosition))
- {
- return;
- }
-
- int cellValue = gridFiller.Grid[(int)gridPosition.Y, (int)gridPosition.X];
- if ((brushMode == BrushMode.Weight && cellValue < 0) || (brushMode == BrushMode.Obstacle && cellValue >= 0))
- {
- return;
- }
-
- Vector2? previousGridPosition = null;
- if (previousMousePosition is not null)
- {
- previousGridPosition = new Vector2((int)previousMousePosition.Value.X / 10, (int)previousMousePosition.Value.Y / 10);
- }
-
- Color indicatorColor = brushMode == BrushMode.Weight ? Color.LightGray : Color.Yellow;
- BrushMode currentBrushMode = brushMode;
- previousMousePosition = mousePosition;
-
- List brushPoints = GetBrushPoints(gridPosition, previousGridPosition);
- foreach (Point point in brushPoints)
- {
- temporaryIndicators[point.ToVector2()] = indicatorColor;
- }
-
- PendingOperation pendingOperation = currentBrushMode == BrushMode.Weight
- ? new PendingOperation(PendingOperationKind.ResetWeight, 0)
- : new PendingOperation(PendingOperationKind.RemoveObstacle, 0);
-
- EnqueuePendingOperations(brushPoints, pendingOperation);
- }
- else
- {
- previousMousePosition = null;
+ screenManager.ReplaceScreen(new GameplayScreen(
+ this,
+ startupScreen.ProgressTracker,
+ startupScreen.GridWidth,
+ startupScreen.GridHeight,
+ startupScreen.AgentCount,
+ startupScreen.MapPreset,
+ startupScreen.ObstacleDivisor,
+ startupScreen.PathRandomization,
+ startupScreen.PenalizeStretchedRectangles
+ ));
}
base.Update(gameTime);
}
-
- protected override void Draw(GameTime gameTime)
- {
- GraphicsDevice.Clear(Color.CornflowerBlue);
-
- if (gridFiller is null)
- {
- uiSpriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
-
- 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);
-
- string[] lines =
- [
- $"Grid Width: {startupGridWidth}",
- $"Grid Height: {startupGridHeight}",
- $"Agent Count: {startupAgentCount}",
- $"Map Preset: {startupMapPreset}",
- $"Obstacle Divisor: {startupObstacleDivisor} (lower = more obstacles)",
- $"Path Randomization: {startupPathRandomization}",
- $"Penalize Stretched Rectangles: {startupPenalizeStretchedRectangles}"
- ];
-
- for (int i = 0; i < lines.Length; i++)
- {
- Color color = i == (int)startupSelectedItem ? Color.DarkBlue : Color.Black;
- string prefix = i == (int)startupSelectedItem ? "# " : " ";
- uiSpriteBatch.DrawString(uiFont, $"{prefix}{lines[i]}", new Vector2(40, 100 + (i * 28)), color);
- }
- }
- else
- {
- uiSpriteBatch.DrawString(uiFont, "Initializing simulation...", new Vector2(40, 30), Color.Black);
- }
-
- IReadOnlyList startupProgresses = progressTracker.GetProgresses();
- for (int i = 0; i < startupProgresses.Count; i++)
- {
- ProgressTracker.ProgressData progressData = startupProgresses[i];
- string progress = progressData.Indeterminate ? "..." : progressData.Progress.ToString("P0");
- uiSpriteBatch.DrawString(uiFont, $"{progressData.Name}: {progress}", new Vector2(40, 300 + (i * 24)), Color.Black);
- }
-
- uiSpriteBatch.End();
- base.Draw(gameTime);
- return;
- }
-
- Matrix transformMatrix = camera.GetViewMatrix();
-
- int yStart = Math.Max(0, (int)(camera.BoundingRectangle.Top / 10) - 2);
- int xStart = Math.Max(0, (int)(camera.BoundingRectangle.Left / 10) - 2);
- int yEnd = Math.Min(gridFiller.Height, (int)(camera.BoundingRectangle.Bottom / 10) + 2);
- int xEnd = Math.Min(gridFiller.Width, (int)(camera.BoundingRectangle.Right / 10) + 2);
-
- int[,] grid = gridFiller.Grid;
- Rectangle cellRect = new Rectangle(0, 0, 10, 10);
-
- Color[] colorLookup = [
- Color.Blue, Color.Cyan, Color.Magenta, Color.Yellow, Color.Orange,
- Color.DarkMagenta, Color.DarkCyan, Color.Tan, Color.RosyBrown,
- Color.DarkKhaki, Color.DarkSalmon, Color.DarkSlateGray,
- Color.DarkTurquoise, Color.DarkGoldenrod, Color.Aqua, Color.Aquamarine,
- Color.Bisque, Color.DarkSlateBlue, Color.BlueViolet, Color.Brown,
- Color.BurlyWood, Color.CadetBlue, Color.Chartreuse, Color.Chocolate,
- Color.Coral, Color.CornflowerBlue, Color.Crimson, Color.DarkBlue
- ];
-
- // Draw grid cells
- spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
-
- spriteBatch.FillRectangle(new Rectangle(0, 0, gridFiller.Width * 10, gridFiller.Height * 10), Color.White, layerDepth: 0.4f);
-
- if (showZones)
- {
- Dictionary rectangles = new Dictionary(gridFiller.PlacedRectangles);
-
- foreach ((int label, Rectangle rectangle) in rectangles)
- {
- Color color = colorLookup[label % 28];
-
- spriteBatch.FillRectangle(new Rectangle(rectangle.X * 10, rectangle.Y * 10, rectangle.Width * 10, rectangle.Height * 10), Color.Lerp(color, Color.Gray, 0.55f), layerDepth: 0.2f);
- }
- }
-
- for (int y = yStart; y < yEnd; y++)
- {
- for (int x = xStart; x < xEnd; x++)
- {
- cellRect.X = x * 10;
- cellRect.Y = y * 10;
-
- int cellValue = grid[y, x];
- int cellWeight = gridFiller.WeightGrid[y, x];
-
- if (cellWeight > 1 && cellValue >= 0)
- {
- float blendFactor = Math.Clamp((cellWeight - 1) / 8f, 0f, 1f);
- Color weightColor = Color.Lerp(Color.LightYellow, Color.DarkOrange, blendFactor);
-
- if (showZones)
- {
- spriteBatch.DrawRectangle(cellRect, weightColor * 0.75f, layerDepth: 0.25f);
- }
- else
- {
- spriteBatch.FillRectangle(cellRect, weightColor * 0.75f, layerDepth: 0.25f);
- }
- }
-
- if (cellValue < 0)
- {
- spriteBatch.FillRectangle(cellRect, Color.Red, layerDepth: 0.3f);
- }
-
- if (temporaryIndicators.TryGetValue(new Vector2(x, y), out Color indicatorColor))
- {
- spriteBatch.DrawCircle(cellRect.Center.ToVector2(), 5, 10, indicatorColor, 2f, layerDepth: 0.2f);
- }
-
- if (showGrid)
- {
- spriteBatch.DrawRectangle(cellRect, Color.Black, layerDepth: 0.2f);
- }
- }
- }
-
- if (fillSelectionStart.HasValue)
- {
- Point selectionEnd = fillSelectionCurrent ?? fillSelectionStart.Value;
- int left = Math.Min(fillSelectionStart.Value.X, selectionEnd.X);
- int top = Math.Min(fillSelectionStart.Value.Y, selectionEnd.Y);
- int right = Math.Max(fillSelectionStart.Value.X, selectionEnd.X);
- int bottom = Math.Max(fillSelectionStart.Value.Y, selectionEnd.Y);
-
- Rectangle selectionRectangle = new Rectangle(left * 10, top * 10, (right - left + 1) * 10, (bottom - top + 1) * 10);
- spriteBatch.FillRectangle(selectionRectangle, Color.LightBlue * 0.2f, layerDepth: 0.24f);
- spriteBatch.DrawRectangle(selectionRectangle, Color.Blue, 2f, layerDepth: 0.19f);
- }
-
- spriteBatch.End();
-
- // Draw paths in a separate batch if needed (sector-culled)
- if (showPaths)
- {
- spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
-
- float viewLeft = camera.BoundingRectangle.Left - 10;
- float viewRight = camera.BoundingRectangle.Right + 10;
- float viewTop = camera.BoundingRectangle.Top - 10;
- float viewBottom = camera.BoundingRectangle.Bottom + 10;
-
- int pathMinSX = Math.Max(0, (int)(viewLeft / 10f / SectorSize) - 1);
- int pathMaxSX = Math.Min(sectorsX - 1, (int)(viewRight / 10f / SectorSize) + 1);
- int pathMinSY = Math.Max(0, (int)(viewTop / 10f / SectorSize) - 1);
- int pathMaxSY = Math.Min(sectorsY - 1, (int)(viewBottom / 10f / SectorSize) + 1);
-
- for (int sx = pathMinSX; sx <= pathMaxSX; sx++)
- {
- for (int sy = pathMinSY; sy <= pathMaxSY; sy++)
- {
- List? sector = agentSectors[sx, sy];
- if (sector is null)
- {
- continue;
- }
-
- foreach (Agent agent in sector)
- {
- List? path = agent.Path;
- if (path is null || path.Count < 2)
- {
- continue;
- }
-
- for (int i = 0; i < path.Count - 1; i++)
- {
- Vector2 start = (path[i] * 10) + new Vector2(5, 5);
- Vector2 end = (path[i + 1] * 10) + new Vector2(5, 5);
-
- bool startInView = start.X >= viewLeft && start.X <= viewRight
- && start.Y >= viewTop && start.Y <= viewBottom;
- bool endInView = end.X >= viewLeft && end.X <= viewRight
- && end.Y >= viewTop && end.Y <= viewBottom;
-
- bool segmentVisible = !((start.X < viewLeft && end.X < viewLeft) ||
- (start.X > viewRight && end.X > viewRight) ||
- (start.Y < viewTop && end.Y < viewTop) ||
- (start.Y > viewBottom && end.Y > viewBottom));
-
- if (segmentVisible)
- {
- spriteBatch.DrawLine(start, end, Color.Gray, 2f, layerDepth: 0.1f);
- }
-
- if (startInView)
- {
- spriteBatch.DrawCircle(start, 5, 10, i == 0 ? Color.Green : Color.Gray, 2f, layerDepth: 0.1f);
- }
-
- if (i == path.Count - 2 && endInView)
- {
- spriteBatch.DrawCircle(end, 5, 10, Color.Red, 2f, layerDepth: 0.1f);
- }
- }
- }
- }
- }
-
- spriteBatch.End();
- }
-
- // Draw agents (sector-culled)
- spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
-
- int drawMinSX = Math.Max(0, (xStart / SectorSize) - 1);
- int drawMaxSX = Math.Min(sectorsX - 1, (xEnd / SectorSize) + 1);
- int drawMinSY = Math.Max(0, (yStart / SectorSize) - 1);
- int drawMaxSY = Math.Min(sectorsY - 1, (yEnd / SectorSize) + 1);
-
- for (int sx = drawMinSX; sx <= drawMaxSX; sx++)
- {
- for (int sy = drawMinSY; sy <= drawMaxSY; sy++)
- {
- List? sector = agentSectors[sx, sy];
- if (sector is null)
- {
- continue;
- }
-
- foreach (Agent agent in sector)
- {
- Vector2 pos = agent.Position;
- if (pos.X >= xStart && pos.X <= xEnd && pos.Y >= yStart && pos.Y <= yEnd)
- {
- spriteBatch.DrawCircle((pos + new Vector2(0.5f, 0.5f)) * 10, 5, 10, Color.Blue, 2f, layerDepth: 0.1f);
- }
- }
- }
- }
-
- spriteBatch.End();
-
- // Draw UI elements
- uiSpriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
-
- uiSpriteBatch.DrawString(uiFont, $"FPS: {1 / gameTime.GetElapsedSeconds():0}", new Vector2(10, 10), Color.Black);
- uiSpriteBatch.DrawString(uiFont, $"Grid: {gridFiller.Width}x{gridFiller.Height}", new Vector2(10, 30), Color.Black);
- uiSpriteBatch.DrawString(uiFont, $"Zones: {gridFiller.PlacedRectangles.Count}", new Vector2(10, 50), Color.Black);
- uiSpriteBatch.DrawString(uiFont, $"Agents: {agents.Count}", new Vector2(10, 70), Color.Black);
- uiSpriteBatch.DrawString(uiFont, $"Brush mode: {brushMode} [O]", new Vector2(10, 90), Color.Black);
- uiSpriteBatch.DrawString(uiFont, $"Weight: {paintWeight} [Keys 1-9, Weight mode]", new Vector2(10, 110), Color.Black);
- uiSpriteBatch.DrawString(uiFont, "Area fill: hold Shift + left click twice", new Vector2(10, 130), Color.Black);
-
- IReadOnlyList progresses = progressTracker.GetProgresses();
-
- for (int i = 0; i < progresses.Count; i++)
- {
- ProgressTracker.ProgressData progressData = progresses[i];
-
- string progress = progressData.Indeterminate ? "..." : progressData.Progress.ToString("P0");
-
- uiSpriteBatch.DrawString(uiFont, $"{progressData.Name}: {progress}", new Vector2(10, 150 + (i * 20)), Color.Black);
- }
-
- uiSpriteBatch.End();
-
- 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(() =>
- {
- for (int i = 0; i < agentCount; i++)
- {
- int x = Random.Shared.Next(0, width - 1);
- int y = Random.Shared.Next(0, height - 1);
-
- agents.Add(new(new Vector2(x, y)));
- }
-
- List obstacles = [];
-
- if (mapPreset == StartupMapPreset.Rooms)
- {
- Debug.WriteLine("Generating random obstacles...");
-
- 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));
-
- for (int i = 0; i < obstacleIterations; i++)
- {
- int x = Random.Shared.Next(0, width - 1);
- int y = Random.Shared.Next(0, height - 1);
- int w = Random.Shared.Next(minObstacleSize, maxObstacleSize);
- int h = Random.Shared.Next(minObstacleSize, maxObstacleSize);
-
- Rectangle wall1 = new(x, y, w, 1);
- Rectangle wall2 = new(x, y, 1, h);
- Rectangle wall3 = new(x + w - 1, y, 1, h);
- Rectangle wall4 = new(x, y + h - 1, w, 1);
-
- int doorSide = Random.Shared.Next(4);
- if (doorSide == 0)
- {
- obstacles.Add(wall1); obstacles.Add(wall2); obstacles.Add(wall3);
- int doorX = x + Random.Shared.Next(1, w - 2);
- obstacles.Add(new Rectangle(x, y + h - 1, doorX - x, 1));
- obstacles.Add(new Rectangle(doorX + 2, y + h - 1, x + w - (doorX + 2), 1));
- }
- else if (doorSide == 1)
- {
- obstacles.Add(wall2); obstacles.Add(wall3); obstacles.Add(wall4);
- int doorX = x + Random.Shared.Next(1, w - 2);
- obstacles.Add(new Rectangle(x, y, doorX - x, 1));
- obstacles.Add(new Rectangle(doorX + 2, y, x + w - (doorX + 2), 1));
- }
- else if (doorSide == 2)
- {
- obstacles.Add(wall1); obstacles.Add(wall3); obstacles.Add(wall4);
- int doorY = y + Random.Shared.Next(1, h - 2);
- obstacles.Add(new Rectangle(x, y, 1, doorY - y));
- obstacles.Add(new Rectangle(x, doorY + 2, 1, y + h - (doorY + 2)));
- }
- else
- {
- obstacles.Add(wall1); obstacles.Add(wall2); obstacles.Add(wall4);
- int doorY = y + Random.Shared.Next(1, h - 2);
- obstacles.Add(new Rectangle(x + w - 1, y, 1, doorY - y));
- obstacles.Add(new Rectangle(x + w - 1, doorY + 2, 1, y + h - (doorY + 2)));
- }
- }
- }
- else if (mapPreset == StartupMapPreset.WorstCase)
- {
- Debug.WriteLine("Generating connected worst-case 3x3 rooms with offset doors...");
- const int roomSize = 3;
- const int pitch = roomSize + 1; // 3 walkable + 1 wall
-
- for (int y = pitch; y < height; y += pitch)
- {
- int runStart = 0;
- int rowIndex = y / pitch;
-
- for (int x = 0; x < width; x++)
- {
- 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;
-
- if (isDoor)
- {
- if (x > runStart)
- {
- obstacles.Add(new Rectangle(runStart, y, x - runStart, 1));
- }
-
- runStart = x + 1;
- }
- }
-
- if (runStart < width)
- {
- obstacles.Add(new Rectangle(runStart, y, width - runStart, 1));
- }
- }
-
- for (int x = pitch; x < width; x += pitch)
- {
- int runStart = 0;
- int colIndex = x / pitch;
-
- for (int y = 0; y < height; y++)
- {
- 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;
-
- if (isDoor)
- {
- if (y > runStart)
- {
- obstacles.Add(new Rectangle(x, runStart, 1, y - runStart));
- }
-
- runStart = y + 1;
- }
- }
-
- if (runStart < height)
- {
- obstacles.Add(new Rectangle(x, runStart, 1, height - runStart));
- }
- }
- }
- else
- {
- Debug.WriteLine("Using empty map preset.");
- }
-
- Debug.WriteLine("Initializing grid...");
-
- ProgressTracker.ProgressData progressData = progressTracker.AddProgress("Initializing grid", true, out _);
- gridFiller = new GridFiller(width, height);
- gridFiller.PlaceObstacles(obstacles, false);
- progressTracker.RemoveProgress(progressData);
-
- Debug.WriteLine("Filling grid...");
-
- _ = progressTracker.AddProgress("Filling grid", out IProgress fillingGridProgressReporter);
- _ = progressTracker.AddProgress("Calculating candidates", out IProgress calculatingCandidatesProgressReporter);
- _ = progressTracker.AddProgress("Placing zones", out IProgress placingCandidatesProgressReporter);
-
- gridFiller.FillGrid(totalProgress: fillingGridProgressReporter, calculatingCandidatesProgress: calculatingCandidatesProgressReporter, placingCandidatesProgress: placingCandidatesProgressReporter);
-
- pathfinder = new Pathfinder(gridFiller.PlacedRectangles, gridFiller.Grid, gridFiller.WeightGrid, pathRandomization, penalizeStretchedRectangles);
- RebuildSectors();
- gridChanged = true;
- inputBlocked = false;
- showZones = false;
- showPaths = false;
-
- StartUpdatePathTask();
- });
- }
-
- ///
- /// Handles graph/path refresh work on a dedicated long-running task.
- ///
- private void StartUpdatePathTask()
- {
- _ = Task.Factory.StartNew(async () =>
- {
- while (true)
- {
- try
- {
- HashSet localAffectedZones;
- lock (affectedZonesLock)
- {
- if (affectedZones.Count == 0)
- {
- localAffectedZones = [];
- }
- else
- {
- localAffectedZones = [.. affectedZones];
- affectedZones.Clear();
- }
- }
-
- if (localAffectedZones.Count > 0)
- {
- gridChanged = false;
- ProgressTracker.ProgressData progressDataUpdateGraph = progressTracker.AddProgress("Updating graph", true, out _);
-
- // Use incremental update instead of full rebuild
- pathfinder.IncrementalUpdateGraph(localAffectedZones);
- progressTracker.RemoveProgress(progressDataUpdateGraph);
- }
- else if (gridChanged)
- {
- // Full rebuild only if grid changed but no zones tracked (e.g., initial grid fill)
- gridChanged = false;
- ProgressTracker.ProgressData progressDataBuildGraph = progressTracker.AddProgress("Building graph", true, out _);
- pathfinder.BuildGraph();
- progressTracker.RemoveProgress(progressDataBuildGraph);
- }
-
- // Efficiently collect agents without paths (avoid LINQ allocation on hot path)
- List agentsRequirePathList = new List(agents.Count);
-
- foreach (Agent agent in agents)
- {
- if (agent.Path is null)
- {
- agentsRequirePathList.Add(agent);
- }
- }
-
- if (agentsRequirePathList.Count != 0)
- {
- int agentCount = agentsRequirePathList.Count;
-
- int batchSize = Math.Max(64, agentCount / (Environment.ProcessorCount * 4));
-
- ProgressTracker.ProgressData progressDataPaths = progressTracker.AddProgress($"Calculating {agentCount} paths", out IProgress progress);
-
- ParallelOptions parallelOptions = new ParallelOptions
- {
- MaxDegreeOfParallelism = Environment.ProcessorCount
- };
-
- long lastProgressReport = Environment.TickCount64;
- int pathsCalculated = 0;
-
- _ = Parallel.ForEach(Partitioner.Create(0, agentCount, batchSize), parallelOptions, range =>
- {
- for (int i = range.Item1; i < range.Item2; i++)
- {
- Agent agent = agentsRequirePathList[i];
-
- List? path = CalculatePath(agent.GridPosition, agent.Destination, maxNodes: int.MaxValue) ?? CalculatePath(maxNodes: int.MaxValue);
-
- pendingPathResults.Enqueue((agent, path));
- }
-
- int local = Interlocked.Add(ref pathsCalculated, range.Item2 - range.Item1);
- long now = Environment.TickCount64;
- if (now - lastProgressReport > 100)
- {
- progress.Report((float)local / agentCount);
- _ = Interlocked.Exchange(ref lastProgressReport, now);
- }
- });
-
- progress.Report(1.0f);
- progressTracker.RemoveProgress(progressDataPaths);
- }
-
- if (agentsRequirePathList.Count < 1000)
- {
- await Task.Delay(100);
- }
- }
- catch (Exception ex)
- {
- Trace.WriteLine(ex);
- }
- }
- }, TaskCreationOptions.LongRunning);
- }
-
- private void RebuildSectors()
- {
- int sx = (gridFiller.Width + SectorSize - 1) / SectorSize;
- int sy = (gridFiller.Height + SectorSize - 1) / SectorSize;
-
- List[,] newSectors = new List[sx, sy];
-
- foreach (Agent agent in agents)
- {
- int x = Math.Clamp((int)(agent.Position.X / SectorSize), 0, sx - 1);
- int y = Math.Clamp((int)(agent.Position.Y / SectorSize), 0, sy - 1);
- (newSectors[x, y] ??= []).Add(agent);
- }
-
- sectorsX = sx;
- sectorsY = sy;
- agentSectors = newSectors;
- }
-
- private List? CalculatePath(Point? start = null, Point? goal = null, int maxNodes = int.MaxValue)
- {
- int width = gridFiller.Width;
- int height = gridFiller.Height;
- int[,] g = gridFiller.Grid;
-
- if (start is null)
- {
- do
- {
- start = new Point(Random.Shared.Next(0, width - 1), Random.Shared.Next(0, height - 1));
- }
- while (g[start.Value.Y, start.Value.X] <= 0);
- }
-
- if (goal is null)
- {
- do
- {
- goal = new Point(Random.Shared.Next(0, width - 1), Random.Shared.Next(0, height - 1));
- }
- while (g[goal.Value.Y, goal.Value.X] <= 0);
- }
-
- return pathfinder.FindPath(start.Value, goal.Value, maxNodes);
- }
-
- private void EnqueuePendingOperations(IEnumerable points, PendingOperation operation)
- {
- lock (pendingOperationsLock)
- {
- foreach (Point point in points)
- {
- pendingOperations[point] = operation;
- }
-
- if (batchProcessingScheduled)
- {
- return;
- }
-
- batchProcessingScheduled = true;
- }
-
- _ = Task.Run(ProcessPendingOperations);
- }
-
- private void ProcessPendingOperations()
- {
- while (true)
- {
- KeyValuePair[] operationsBatch;
-
- lock (pendingOperationsLock)
- {
- if (pendingOperations.Count == 0)
- {
- batchProcessingScheduled = false;
- return;
- }
-
- operationsBatch = [.. pendingOperations];
- pendingOperations.Clear();
- }
-
- IGrouping[] weightGroups = [.. operationsBatch
- .Where(op => op.Value.Kind == PendingOperationKind.SetWeight)
- .GroupBy(op => op.Value.Weight, op => op.Key)];
-
- Point[] resetWeightPoints = [.. operationsBatch
- .Where(op => op.Value.Kind == PendingOperationKind.ResetWeight)
- .Select(op => op.Key)];
-
- Rectangle[] placeObstacleRectangles = [.. operationsBatch
- .Where(op => op.Value.Kind == PendingOperationKind.PlaceObstacle)
- .Select(op => new Rectangle(op.Key.X, op.Key.Y, 1, 1))];
-
- Rectangle[] removeObstacleRectangles = [.. operationsBatch
- .Where(op => op.Value.Kind == PendingOperationKind.RemoveObstacle)
- .Select(op => new Rectangle(op.Key.X, op.Key.Y, 1, 1))];
-
- // Track affected zones for incremental graph updates
- lock (affectedZonesLock)
- {
- foreach (IGrouping weightGroup in weightGroups)
- {
- affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(weightGroup, weightGroup.Key));
- }
-
- if (resetWeightPoints.Length > 0)
- {
- affectedZones.UnionWith(gridFiller.ResetTileWeightsWithAffected(resetWeightPoints));
- }
-
- if (placeObstacleRectangles.Length > 0)
- {
- affectedZones.UnionWith(gridFiller.PlaceObstaclesWithAffected(placeObstacleRectangles));
- }
-
- if (removeObstacleRectangles.Length > 0)
- {
- affectedZones.UnionWith(gridFiller.RemoveObstaclesWithAffected(removeObstacleRectangles));
- }
- }
-
- foreach (KeyValuePair operation in operationsBatch)
- {
- _ = temporaryIndicators.TryRemove(operation.Key.ToVector2(), out _);
- }
-
- gridChanged = true;
- }
- }
-
- private bool TryGetMouseGridPoint(MouseStateExtended mouseState, out Point gridPoint)
- {
- Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
- Point point = new Point((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
-
- if (point.X < 0 || point.X >= gridFiller.Width || point.Y < 0 || point.Y >= gridFiller.Height)
- {
- gridPoint = default;
- return false;
- }
-
- gridPoint = point;
- return true;
- }
-
- private static List GetRectanglePoints(Point start, Point end)
- {
- int minX = Math.Min(start.X, end.X);
- int minY = Math.Min(start.Y, end.Y);
- int maxX = Math.Max(start.X, end.X);
- int maxY = Math.Max(start.Y, end.Y);
-
- List points = new((maxX - minX + 1) * (maxY - minY + 1));
-
- for (int y = minY; y <= maxY; y++)
- {
- for (int x = minX; x <= maxX; x++)
- {
- points.Add(new Point(x, y));
- }
- }
-
- return points;
- }
-
- private static List GetBrushPoints(Vector2 currentGridPosition, Vector2? previousGridPosition)
- {
- HashSet points = [];
- Point currentPoint = new Point((int)currentGridPosition.X, (int)currentGridPosition.Y);
- _ = points.Add(currentPoint);
-
- if (previousGridPosition is null)
- {
- return [.. points];
- }
-
- float distance = Vector2.Distance(previousGridPosition.Value, currentGridPosition);
- int steps = Math.Max(1, (int)Math.Ceiling(distance * 10));
-
- for (int i = 0; i <= steps; i++)
- {
- float t = i / (float)steps;
- Vector2 interpolatedPosition = Vector2.Lerp(previousGridPosition.Value, currentGridPosition, t);
- Point interpolatedPoint = new Point((int)Math.Round(interpolatedPosition.X), (int)Math.Round(interpolatedPosition.Y));
- _ = points.Add(interpolatedPoint);
- }
-
- return [.. points];
- }
-}
\ No newline at end of file
+}
diff --git a/src/LargeGridPathfinding/ProgressTracker.cs b/src/LargeGridPathfinding/ProgressTracker.cs
index 81dafc5..74e2c74 100644
--- a/src/LargeGridPathfinding/ProgressTracker.cs
+++ b/src/LargeGridPathfinding/ProgressTracker.cs
@@ -6,7 +6,7 @@ namespace LargeGridPathfinding;
///
/// Tracks named progress entries for long-running operations.
///
-internal class ProgressTracker
+public class ProgressTracker
{
private readonly List progresses = [];
diff --git a/src/LargeGridPathfinding/Screens/GameplayScreen.cs b/src/LargeGridPathfinding/Screens/GameplayScreen.cs
new file mode 100644
index 0000000..3f1177f
--- /dev/null
+++ b/src/LargeGridPathfinding/Screens/GameplayScreen.cs
@@ -0,0 +1,467 @@
+using LargeGridPathfinding.Components;
+using LargeGridPathfinding.Services;
+using LargeGridPathfinding.Systems;
+
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.Input;
+using MonoGame.Extended.Screens;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading.Tasks;
+
+namespace LargeGridPathfinding.Screens;
+
+public class GameplayScreen : GameScreen
+{
+ private readonly ProgressTracker progressTracker;
+ private readonly int startupGridWidth;
+ private readonly int startupGridHeight;
+ private readonly int startupAgentCount;
+ private readonly int startupObstacleDivisor;
+ private readonly bool startupPathRandomization;
+ private readonly bool startupPenalizeStretchedRectangles;
+ private readonly StartupMenuScreen.StartupMapPreset startupMapPreset;
+
+ private SpriteBatch spriteBatch = null!;
+ private SpriteBatch uiSpriteBatch = null!;
+ private SpriteFont uiFont = null!;
+ private OrthographicCamera camera = null!;
+ private GridService? gridService;
+ private PathfindingService? pathfindingService;
+ private List agents = [];
+ private World? world;
+ private GridEditSystem? gridEditSystem;
+ private readonly ConcurrentDictionary temporaryIndicators = [];
+ private bool inputBlocked = true;
+ private bool showZones = true;
+ private bool showGrid;
+ private bool showPaths;
+ private bool initializationComplete;
+ private bool worldBuilt;
+
+ public GameplayScreen(
+ Game game,
+ ProgressTracker progressTracker,
+ int gridWidth,
+ int gridHeight,
+ int agentCount,
+ StartupMenuScreen.StartupMapPreset mapPreset,
+ int obstacleDivisor,
+ bool pathRandomization,
+ bool penalizeStretchedRectangles)
+ : base(game)
+ {
+ this.progressTracker = progressTracker;
+ startupGridWidth = gridWidth;
+ startupGridHeight = gridHeight;
+ startupAgentCount = agentCount;
+ startupMapPreset = mapPreset;
+ startupObstacleDivisor = obstacleDivisor;
+ startupPathRandomization = pathRandomization;
+ startupPenalizeStretchedRectangles = penalizeStretchedRectangles;
+ }
+
+ public override void Initialize()
+ {
+ camera = new OrthographicCamera(GraphicsDevice)
+ {
+ MinimumZoom = 0.1f,
+ MaximumZoom = 2,
+ Zoom = 0.5f,
+ };
+
+ base.Initialize();
+ }
+
+ public override void LoadContent()
+ {
+ base.LoadContent();
+ spriteBatch = new SpriteBatch(GraphicsDevice);
+ uiSpriteBatch = new SpriteBatch(GraphicsDevice);
+ uiFont = Content.Load("ManoloMono");
+
+ StartInitializationTask();
+ }
+
+ public override void Update(GameTime gameTime)
+ {
+ if (!Game.IsActive)
+ {
+ return;
+ }
+
+ if (!initializationComplete)
+ {
+ return;
+ }
+
+ if (!worldBuilt)
+ {
+ return;
+ }
+
+ // Drain pending path results from background worker
+ if (pathfindingService is not null)
+ {
+ while (pathfindingService.PendingPathResults.TryDequeue(out (AgentComponent agent, List? path) result))
+ {
+ AgentComponent agent = result.agent;
+ List? path = result.path;
+ agent.Path = path;
+ if (path?.Count > 0)
+ {
+ agent.Position = path[0];
+ agent.NextPosition = path.Count > 1 ? path[1] : path[0];
+ agent.PathIndex = path.Count > 1 ? 1 : 0;
+ agent.Destination = new Point((int)path[^1].X, (int)path[^1].Y);
+ }
+ }
+ }
+
+ // Process pending grid operations in background
+ ProcessPendingOperations();
+
+ // Camera movement
+ KeyboardExtended.Update();
+ MouseExtended.Update();
+
+ MouseStateExtended mouseState = MouseExtended.GetState();
+ KeyboardStateExtended keyboardState = KeyboardExtended.GetState();
+
+ float movementSpeed = (float)Math.Pow(200, 2 - camera.Zoom);
+ movementSpeed = Math.Clamp(movementSpeed, 1000, 20000);
+ camera.Move(keyboardState.GetMovementDirection() * movementSpeed * gameTime.GetElapsedSeconds());
+
+ if (mouseState.DeltaScrollWheelValue < 0)
+ {
+ camera.ZoomIn(0.1f);
+ }
+ else if (mouseState.DeltaScrollWheelValue > 0)
+ {
+ camera.ZoomOut(0.1f);
+ }
+
+ // Run ECS world update (calls all registered IUpdateSystems)
+ world!.Update(gameTime);
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ GraphicsDevice.Clear(Color.CornflowerBlue);
+
+ if (!initializationComplete || gridService is null)
+ {
+ uiSpriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
+ uiSpriteBatch.DrawString(uiFont, "Initializing simulation...", new Vector2(40, 30), Color.Black);
+
+ IReadOnlyList progresses = progressTracker.GetProgresses();
+ for (int i = 0; i < progresses.Count; i++)
+ {
+ ProgressTracker.ProgressData progressData = progresses[i];
+ string progress = progressData.Indeterminate ? "..." : progressData.Progress.ToString("P0");
+ uiSpriteBatch.DrawString(uiFont, $"{progressData.Name}: {progress}", new Vector2(40, 300 + (i * 24)), Color.Black);
+ }
+
+ uiSpriteBatch.End();
+ return;
+ }
+
+ // Run ECS world draw (calls all registered IDrawSystems)
+ if (worldBuilt)
+ {
+ world!.Draw(gameTime);
+ }
+
+ // Draw UI overlay
+ uiSpriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
+
+ uiSpriteBatch.DrawString(uiFont, $"FPS: {1 / gameTime.GetElapsedSeconds():0}", new Vector2(10, 10), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, $"Grid: {gridService.Width}x{gridService.Height}", new Vector2(10, 30), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, $"Zones: {gridService.Filler.PlacedRectangles.Count}", new Vector2(10, 50), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, $"Agents: {agents.Count}", new Vector2(10, 70), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, $"Brush mode: {gridEditSystem?.CurrentBrushMode ?? GridEditSystem.BrushMode.Obstacle} [O]", new Vector2(10, 90), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, $"Weight: {gridEditSystem?.PaintWeight ?? 0} [Keys 1-9, Weight mode]", new Vector2(10, 110), Color.Black);
+ uiSpriteBatch.DrawString(uiFont, "Area fill: hold Shift + left click twice", new Vector2(10, 130), Color.Black);
+
+ IReadOnlyList currentProgresses = progressTracker.GetProgresses();
+ for (int i = 0; i < currentProgresses.Count; i++)
+ {
+ ProgressTracker.ProgressData progressData = currentProgresses[i];
+ string progress = progressData.Indeterminate ? "..." : progressData.Progress.ToString("P0");
+ uiSpriteBatch.DrawString(uiFont, $"{progressData.Name}: {progress}", new Vector2(10, 150 + (i * 20)), Color.Black);
+ }
+
+ uiSpriteBatch.End();
+ }
+
+ private void BuildECSWorld()
+ {
+ if (gridService is null)
+ {
+ return;
+ }
+
+ AgentMovementSystem agentMovementSystem = new AgentMovementSystem(gridService, camera, agents);
+
+ gridEditSystem = new GridEditSystem(
+ gridService,
+ camera,
+ temporaryIndicators,
+ progressTracker,
+ () => inputBlocked,
+ v => inputBlocked = v,
+ () => showZones,
+ v => showZones = v,
+ () => showGrid,
+ v => showGrid = v,
+ () => showPaths,
+ v => showPaths = v
+ );
+
+ GridRenderSystem gridRenderSystem = new GridRenderSystem(
+ spriteBatch,
+ gridService,
+ camera,
+ temporaryIndicators,
+ () => showZones,
+ () => showGrid,
+ () => gridEditSystem.FillSelectionStart,
+ () => gridEditSystem.FillSelectionCurrent
+ );
+
+ PathRenderSystem pathRenderSystem = new PathRenderSystem(
+ spriteBatch,
+ camera,
+ () => showPaths,
+ () => agentMovementSystem.SectorsX,
+ () => agentMovementSystem.SectorsY,
+ () => agentMovementSystem.AgentSectors
+ );
+
+ AgentRenderSystem agentRenderSystem = new AgentRenderSystem(
+ spriteBatch,
+ camera,
+ () => agentMovementSystem.SectorsX,
+ () => agentMovementSystem.SectorsY,
+ () => agentMovementSystem.AgentSectors
+ );
+
+ world = new WorldBuilder()
+ .AddSystem(agentMovementSystem)
+ .AddSystem(gridEditSystem)
+ .AddSystem(gridRenderSystem)
+ .AddSystem(pathRenderSystem)
+ .AddSystem(agentRenderSystem)
+ .Build();
+
+ // Create ECS entities for each agent
+ foreach (AgentComponent agent in agents)
+ {
+ world.CreateEntity().Attach(agent);
+ }
+
+ worldBuilt = true;
+ }
+
+ private void StartInitializationTask()
+ {
+ _ = Task.Run(() =>
+ {
+ List agentList = new(startupAgentCount);
+
+ for (int i = 0; i < startupAgentCount; i++)
+ {
+ int x = Random.Shared.Next(0, startupGridWidth - 1);
+ int y = Random.Shared.Next(0, startupGridHeight - 1);
+ agentList.Add(new AgentComponent { Position = new Vector2(x, y) });
+ }
+
+ List obstacles = [];
+
+ if (startupMapPreset == StartupMenuScreen.StartupMapPreset.Rooms)
+ {
+ Debug.WriteLine("Generating random obstacles...");
+
+ int obstacleIterations = Math.Max(0, (startupGridWidth + startupGridHeight) / Math.Max(1, startupObstacleDivisor));
+ int minObstacleSize = 5;
+ int minDimension = Math.Min(startupGridWidth, startupGridHeight);
+ int scaledByDimension = (int)Math.Round(Math.Pow(minDimension, 0.4) * 2.1);
+ int maxByDimension = Math.Max(minObstacleSize + 1, minDimension / 4);
+ int maxObstacleSize = Math.Max(minObstacleSize + 1, Math.Min(scaledByDimension, maxByDimension));
+
+ for (int i = 0; i < obstacleIterations; i++)
+ {
+ int x = Random.Shared.Next(0, startupGridWidth - 1);
+ int y = Random.Shared.Next(0, startupGridHeight - 1);
+ int w = Random.Shared.Next(minObstacleSize, maxObstacleSize);
+ int h = Random.Shared.Next(minObstacleSize, maxObstacleSize);
+
+ Rectangle wall1 = new(x, y, w, 1);
+ Rectangle wall2 = new(x, y, 1, h);
+ Rectangle wall3 = new(x + w - 1, y, 1, h);
+ Rectangle wall4 = new(x, y + h - 1, w, 1);
+
+ int doorSide = Random.Shared.Next(4);
+ if (doorSide == 0)
+ {
+ obstacles.Add(wall1); obstacles.Add(wall2); obstacles.Add(wall3);
+ int doorX = x + Random.Shared.Next(1, w - 2);
+ obstacles.Add(new Rectangle(x, y + h - 1, doorX - x, 1));
+ obstacles.Add(new Rectangle(doorX + 2, y + h - 1, x + w - (doorX + 2), 1));
+ }
+ else if (doorSide == 1)
+ {
+ obstacles.Add(wall2); obstacles.Add(wall3); obstacles.Add(wall4);
+ int doorX = x + Random.Shared.Next(1, w - 2);
+ obstacles.Add(new Rectangle(x, y, doorX - x, 1));
+ obstacles.Add(new Rectangle(doorX + 2, y, x + w - (doorX + 2), 1));
+ }
+ else if (doorSide == 2)
+ {
+ obstacles.Add(wall1); obstacles.Add(wall3); obstacles.Add(wall4);
+ int doorY = y + Random.Shared.Next(1, h - 2);
+ obstacles.Add(new Rectangle(x, y, 1, doorY - y));
+ obstacles.Add(new Rectangle(x, doorY + 2, 1, y + h - (doorY + 2)));
+ }
+ else
+ {
+ obstacles.Add(wall1); obstacles.Add(wall2); obstacles.Add(wall4);
+ int doorY = y + Random.Shared.Next(1, h - 2);
+ obstacles.Add(new Rectangle(x + w - 1, y, 1, doorY - y));
+ obstacles.Add(new Rectangle(x + w - 1, doorY + 2, 1, y + h - (doorY + 2)));
+ }
+ }
+ }
+ else if (startupMapPreset == StartupMenuScreen.StartupMapPreset.WorstCase)
+ {
+ Debug.WriteLine("Generating connected worst-case 3x3 rooms with offset doors...");
+ const int roomSize = 3;
+ const int pitch = roomSize + 1;
+
+ for (int y = pitch; y < startupGridHeight; y += pitch)
+ {
+ int runStart = 0;
+ int rowIndex = y / pitch;
+
+ for (int x = 0; x < startupGridWidth; x++)
+ {
+ int local = x % pitch;
+ int baseX = x - local;
+ bool oddSegment = (baseX / pitch % 2) == 1;
+ int offset = ((rowIndex % 2 == 0) ^ oddSegment) ? 1 : 3;
+ bool isDoor = local == offset;
+
+ if (isDoor)
+ {
+ if (x > runStart)
+ {
+ obstacles.Add(new Rectangle(runStart, y, x - runStart, 1));
+ }
+ runStart = x + 1;
+ }
+ }
+
+ if (runStart < startupGridWidth)
+ {
+ obstacles.Add(new Rectangle(runStart, y, startupGridWidth - runStart, 1));
+ }
+ }
+
+ for (int x = pitch; x < startupGridWidth; x += pitch)
+ {
+ int runStart = 0;
+ int colIndex = x / pitch;
+
+ for (int y = 0; y < startupGridHeight; y++)
+ {
+ int local = y % pitch;
+ int baseY = y - local;
+ bool oddSegment = (baseY / pitch % 2) == 1;
+ int offset = ((colIndex % 2 == 0) ^ oddSegment) ? 1 : 3;
+ bool isDoor = local == offset;
+
+ if (isDoor)
+ {
+ if (y > runStart)
+ {
+ obstacles.Add(new Rectangle(x, runStart, 1, y - runStart));
+ }
+ runStart = y + 1;
+ }
+ }
+
+ if (runStart < startupGridHeight)
+ {
+ obstacles.Add(new Rectangle(x, runStart, 1, startupGridHeight - runStart));
+ }
+ }
+ }
+ else
+ {
+ Debug.WriteLine("Using empty map preset.");
+ }
+
+ Debug.WriteLine("Initializing grid...");
+
+ ProgressTracker.ProgressData progressData = progressTracker.AddProgress("Initializing grid", true, out _);
+ GridFiller gf = new GridFiller(startupGridWidth, startupGridHeight);
+ gf.PlaceObstacles(obstacles, false);
+ progressTracker.RemoveProgress(progressData);
+
+ Debug.WriteLine("Filling grid...");
+
+ _ = progressTracker.AddProgress("Filling grid", out IProgress fillingGridProgressReporter);
+ _ = progressTracker.AddProgress("Calculating candidates", out IProgress calculatingCandidatesProgressReporter);
+ _ = progressTracker.AddProgress("Placing zones", out IProgress placingCandidatesProgressReporter);
+
+ gf.FillGrid(totalProgress: fillingGridProgressReporter, calculatingCandidatesProgress: calculatingCandidatesProgressReporter, placingCandidatesProgress: placingCandidatesProgressReporter);
+
+ GridService gs = new GridService(gf);
+ Pathfinder pf = new Pathfinder(gf.PlacedRectangles, gf.Grid, gf.WeightGrid, startupPathRandomization, startupPenalizeStretchedRectangles);
+ PathfindingService pfs = new PathfindingService(pf, gs, agentList, progressTracker);
+
+ gridService = gs;
+ pathfindingService = pfs;
+ agents = agentList;
+ gs.GridChanged = true;
+ inputBlocked = false;
+ showZones = false;
+ showPaths = false;
+
+ initializationComplete = true;
+
+ // Build the ECS world (must be on main thread — deferred to next Update)
+ BuildECSWorld();
+
+ pfs.Start();
+ });
+ }
+
+ private void ProcessPendingOperations()
+ {
+ if (gridService is null)
+ {
+ return;
+ }
+
+ if (gridService.TryDequeueBatch(out GridService.PendingOperationEntry[]? batch))
+ {
+ _ = Task.Run(() =>
+ {
+ gridService.ProcessOperationBatch(batch);
+
+ foreach (GridService.PendingOperationEntry entry in batch)
+ {
+ _ = temporaryIndicators.TryRemove(entry.Point.ToVector2(), out _);
+ }
+ });
+ }
+ }
+}
diff --git a/src/LargeGridPathfinding/Screens/StartupMenuScreen.cs b/src/LargeGridPathfinding/Screens/StartupMenuScreen.cs
new file mode 100644
index 0000000..53afbef
--- /dev/null
+++ b/src/LargeGridPathfinding/Screens/StartupMenuScreen.cs
@@ -0,0 +1,171 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using Microsoft.Xna.Framework.Input;
+
+using MonoGame.Extended.Input;
+using MonoGame.Extended.Screens;
+
+using System;
+using System.Collections.Generic;
+
+namespace LargeGridPathfinding.Screens;
+
+public class StartupMenuScreen : GameScreen
+{
+ private enum StartupMenuItem
+ {
+ GridWidth,
+ GridHeight,
+ AgentCount,
+ MapPreset,
+ ObstacleDivisor,
+ PathRandomization,
+ PenalizeStretchedRectangles
+ }
+
+ public enum StartupMapPreset
+ {
+ Rooms,
+ Empty,
+ WorstCase
+ }
+
+ private SpriteFont uiFont = null!;
+ private readonly ProgressTracker progressTracker = new();
+ private StartupMenuItem startupSelectedItem;
+ private int startupGridWidth = 1000;
+ private int startupGridHeight = 1000;
+ private int startupAgentCount = 1000;
+ private StartupMapPreset startupMapPreset = StartupMapPreset.Rooms;
+ private int startupObstacleDivisor = 2;
+ private bool startupPathRandomization;
+ private bool startupPenalizeStretchedRectangles;
+ private bool initialized;
+
+ public int GridWidth => startupGridWidth;
+ public int GridHeight => startupGridHeight;
+ public int AgentCount => startupAgentCount;
+ public int ObstacleDivisor => startupObstacleDivisor;
+ public bool PathRandomization => startupPathRandomization;
+ public bool PenalizeStretchedRectangles => startupPenalizeStretchedRectangles;
+ public ProgressTracker ProgressTracker => progressTracker;
+ public bool Initialized => initialized;
+
+ public StartupMapPreset MapPreset => startupMapPreset;
+
+ public StartupMenuScreen(Game game) : base(game)
+ {
+ }
+
+ public override void LoadContent()
+ {
+ base.LoadContent();
+ uiFont = Content.Load("ManoloMono");
+ }
+
+ public override void Update(GameTime gameTime)
+ {
+ KeyboardExtended.Update();
+
+ KeyboardStateExtended keyboardState = KeyboardExtended.GetState();
+ bool shiftPressed = keyboardState.IsKeyDown(Keys.LeftShift) || keyboardState.IsKeyDown(Keys.RightShift);
+ bool ctrlPressed = keyboardState.IsKeyDown(Keys.LeftControl) || keyboardState.IsKeyDown(Keys.RightControl);
+
+ if (keyboardState.WasKeyPressed(Keys.Up))
+ {
+ startupSelectedItem = (StartupMenuItem)Math.Max(0, (int)startupSelectedItem - 1);
+ }
+ else if (keyboardState.WasKeyPressed(Keys.Down))
+ {
+ startupSelectedItem = (StartupMenuItem)Math.Min((int)StartupMenuItem.PenalizeStretchedRectangles, (int)startupSelectedItem + 1);
+ }
+
+ int direction = 0;
+ if (keyboardState.WasKeyPressed(Keys.Left))
+ {
+ direction = shiftPressed ? ctrlPressed ? -100 : -10 : -1;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.Right))
+ {
+ direction = shiftPressed ? ctrlPressed ? 100 : 10 : 1;
+ }
+
+ if (direction != 0)
+ {
+ switch (startupSelectedItem)
+ {
+ 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 * 1000), 100, 1000000);
+ 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;
+ }
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.Enter))
+ {
+ initialized = true;
+ }
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ GraphicsDevice.Clear(Color.CornflowerBlue);
+
+ var spriteBatch = new SpriteBatch(GraphicsDevice);
+ spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp);
+
+ spriteBatch.DrawString(uiFont, "Startup Configuration", new Vector2(40, 30), Color.Black);
+ spriteBatch.DrawString(uiFont, "Arrow Up/Down: Select Arrow Left/Right: Change Enter: Start", new Vector2(40, 60), Color.Black);
+
+ string[] lines =
+ [
+ $"Grid Width: {startupGridWidth}",
+ $"Grid Height: {startupGridHeight}",
+ $"Agent Count: {startupAgentCount}",
+ $"Map Preset: {startupMapPreset}",
+ $"Obstacle Divisor: {startupObstacleDivisor} (lower = more obstacles)",
+ $"Path Randomization: {startupPathRandomization}",
+ $"Penalize Stretched Rectangles: {startupPenalizeStretchedRectangles}"
+ ];
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ Color color = i == (int)startupSelectedItem ? Color.DarkBlue : Color.Black;
+ string prefix = i == (int)startupSelectedItem ? "# " : " ";
+ spriteBatch.DrawString(uiFont, $"{prefix}{lines[i]}", new Vector2(40, 100 + (i * 28)), color);
+ }
+
+ IReadOnlyList startupProgresses = progressTracker.GetProgresses();
+ for (int i = 0; i < startupProgresses.Count; i++)
+ {
+ ProgressTracker.ProgressData progressData = startupProgresses[i];
+ string progress = progressData.Indeterminate ? "..." : progressData.Progress.ToString("P0");
+ spriteBatch.DrawString(uiFont, $"{progressData.Name}: {progress}", new Vector2(40, 300 + (i * 24)), Color.Black);
+ }
+
+ spriteBatch.End();
+ }
+}
diff --git a/src/LargeGridPathfinding/Services/GridService.cs b/src/LargeGridPathfinding/Services/GridService.cs
new file mode 100644
index 0000000..93b3c4e
--- /dev/null
+++ b/src/LargeGridPathfinding/Services/GridService.cs
@@ -0,0 +1,143 @@
+using Microsoft.Xna.Framework;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LargeGridPathfinding.Services;
+
+public class GridService
+{
+ private readonly GridFiller gridFiller;
+ private readonly object pendingOperationsLock = new();
+ private readonly Dictionary pendingOperations = [];
+ private readonly HashSet affectedZones = [];
+ private readonly object affectedZonesLock = new();
+ private bool batchProcessingScheduled;
+ private bool gridChanged;
+
+ public GridFiller Filler => gridFiller;
+ public int[,] Grid => gridFiller.Grid;
+ public int[,] WeightGrid => gridFiller.WeightGrid;
+ public int Width => gridFiller.Width;
+ public int Height => gridFiller.Height;
+ public bool GridChanged
+ {
+ get => gridChanged;
+ set => gridChanged = value;
+ }
+
+ public HashSet AffectedZones => affectedZones;
+
+ public GridService(GridFiller gridFiller)
+ {
+ this.gridFiller = gridFiller;
+ }
+
+ public void EnqueueOperations(IEnumerable points, PendingOperation operation)
+ {
+ lock (pendingOperationsLock)
+ {
+ foreach (Point point in points)
+ {
+ pendingOperations[point] = operation;
+ }
+
+ if (batchProcessingScheduled)
+ {
+ return;
+ }
+
+ batchProcessingScheduled = true;
+ }
+ }
+
+ public bool TryDequeueBatch(out PendingOperationEntry[] batch)
+ {
+ lock (pendingOperationsLock)
+ {
+ if (pendingOperations.Count == 0)
+ {
+ batchProcessingScheduled = false;
+ batch = [];
+ return false;
+ }
+
+ batch = [.. pendingOperations.Select(kvp => new PendingOperationEntry(kvp.Key, kvp.Value))];
+ pendingOperations.Clear();
+ return true;
+ }
+ }
+
+ public void ProcessOperationBatch(PendingOperationEntry[] batch)
+ {
+ IGrouping[] weightGroups = [.. batch
+ .Where(op => op.Operation.Kind == PendingOperationKind.SetWeight)
+ .GroupBy(op => op.Operation.Weight, op => op.Point)];
+
+ Point[] resetWeightPoints = [.. batch
+ .Where(op => op.Operation.Kind == PendingOperationKind.ResetWeight)
+ .Select(op => op.Point)];
+
+ Rectangle[] placeObstacleRectangles = [.. batch
+ .Where(op => op.Operation.Kind == PendingOperationKind.PlaceObstacle)
+ .Select(op => new Rectangle(op.Point.X, op.Point.Y, 1, 1))];
+
+ Rectangle[] removeObstacleRectangles = [.. batch
+ .Where(op => op.Operation.Kind == PendingOperationKind.RemoveObstacle)
+ .Select(op => new Rectangle(op.Point.X, op.Point.Y, 1, 1))];
+
+ lock (affectedZonesLock)
+ {
+ foreach (IGrouping weightGroup in weightGroups)
+ {
+ affectedZones.UnionWith(gridFiller.SetTileWeightsWithAffected(weightGroup, weightGroup.Key));
+ }
+
+ if (resetWeightPoints.Length > 0)
+ {
+ affectedZones.UnionWith(gridFiller.ResetTileWeightsWithAffected(resetWeightPoints));
+ }
+
+ if (placeObstacleRectangles.Length > 0)
+ {
+ affectedZones.UnionWith(gridFiller.PlaceObstaclesWithAffected(placeObstacleRectangles));
+ }
+
+ if (removeObstacleRectangles.Length > 0)
+ {
+ affectedZones.UnionWith(gridFiller.RemoveObstaclesWithAffected(removeObstacleRectangles));
+ }
+ }
+
+ gridChanged = true;
+ }
+
+ public HashSet TakeAffectedZones()
+ {
+ lock (affectedZonesLock)
+ {
+ if (affectedZones.Count == 0)
+ {
+ return [];
+ }
+
+ HashSet result = [.. affectedZones];
+ affectedZones.Clear();
+ return result;
+ }
+ }
+
+ public readonly record struct PendingOperation(PendingOperationKind Kind, int Weight);
+
+ public readonly record struct PendingOperationEntry(Point Point, PendingOperation Operation);
+
+ public enum PendingOperationKind
+ {
+ SetWeight,
+ ResetWeight,
+ PlaceObstacle,
+ RemoveObstacle
+ }
+}
diff --git a/src/LargeGridPathfinding/Services/PathfindingService.cs b/src/LargeGridPathfinding/Services/PathfindingService.cs
new file mode 100644
index 0000000..eb3ddb1
--- /dev/null
+++ b/src/LargeGridPathfinding/Services/PathfindingService.cs
@@ -0,0 +1,162 @@
+using Microsoft.Xna.Framework;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using LargeGridPathfinding.Components;
+
+namespace LargeGridPathfinding.Services;
+
+public class PathfindingService
+{
+ private readonly Pathfinder pathfinder;
+ private readonly GridService gridService;
+ private readonly List agents;
+ private readonly ConcurrentQueue<(AgentComponent agent, List? path)> pendingPathResults = new();
+ private readonly ProgressTracker progressTracker;
+ private CancellationTokenSource? cts;
+ private Task? workerTask;
+
+ public ConcurrentQueue<(AgentComponent agent, List? path)> PendingPathResults => pendingPathResults;
+ public Pathfinder Pathfinder => pathfinder;
+
+ public PathfindingService(Pathfinder pathfinder, GridService gridService, List agents, ProgressTracker progressTracker)
+ {
+ this.pathfinder = pathfinder;
+ this.gridService = gridService;
+ this.agents = agents;
+ this.progressTracker = progressTracker;
+ }
+
+ public void Start()
+ {
+ cts = new CancellationTokenSource();
+ workerTask = Task.Factory.StartNew(() => WorkerLoop(cts.Token), TaskCreationOptions.LongRunning);
+ }
+
+ public void Stop()
+ {
+ cts?.Cancel();
+ }
+
+ public List? CalculatePath(Point? start = null, Point? goal = null, int maxNodes = int.MaxValue)
+ {
+ int width = gridService.Width;
+ int height = gridService.Height;
+ int[,] g = gridService.Grid;
+
+ if (start is null)
+ {
+ start = RandomPoint(width, height, g);
+ }
+
+ if (goal is null)
+ {
+ goal = RandomPoint(width, height, g);
+ }
+
+ return pathfinder.FindPath(start.Value, goal.Value, maxNodes);
+ }
+
+ private static Point RandomPoint(int width, int height, int[,] grid)
+ {
+ Point p;
+ do
+ {
+ p = new Point(Random.Shared.Next(0, width - 1), Random.Shared.Next(0, height - 1));
+ }
+ while (grid[p.Y, p.X] <= 0);
+ return p;
+ }
+
+ private void WorkerLoop(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ HashSet localAffectedZones = gridService.TakeAffectedZones();
+
+ if (localAffectedZones.Count > 0)
+ {
+ gridService.GridChanged = false;
+ ProgressTracker.ProgressData progressDataUpdateGraph = progressTracker.AddProgress("Updating graph", true, out _);
+ pathfinder.IncrementalUpdateGraph(localAffectedZones);
+ progressTracker.RemoveProgress(progressDataUpdateGraph);
+ }
+ else if (gridService.GridChanged)
+ {
+ gridService.GridChanged = false;
+ ProgressTracker.ProgressData progressDataBuildGraph = progressTracker.AddProgress("Building graph", true, out _);
+ pathfinder.BuildGraph();
+ progressTracker.RemoveProgress(progressDataBuildGraph);
+ }
+
+ List agentsRequirePathList = new(agents.Count);
+
+ foreach (AgentComponent agent in agents)
+ {
+ if (agent.Path is null)
+ {
+ agentsRequirePathList.Add(agent);
+ }
+ }
+
+ if (agentsRequirePathList.Count != 0)
+ {
+ int agentCount = agentsRequirePathList.Count;
+ int batchSize = Math.Max(64, agentCount / (Environment.ProcessorCount * 4));
+
+ ProgressTracker.ProgressData progressDataPaths = progressTracker.AddProgress($"Calculating {agentCount} paths", out IProgress progress);
+
+ ParallelOptions parallelOptions = new()
+ {
+ MaxDegreeOfParallelism = Environment.ProcessorCount
+ };
+
+ long lastProgressReport = Environment.TickCount64;
+ int pathsCalculated = 0;
+
+ _ = Parallel.ForEach(Partitioner.Create(0, agentCount, batchSize), parallelOptions, range =>
+ {
+ for (int i = range.Item1; i < range.Item2; i++)
+ {
+ AgentComponent agent = agentsRequirePathList[i];
+ List? path = CalculatePath(agent.GridPosition, agent.Destination, maxNodes: int.MaxValue) ?? CalculatePath(maxNodes: int.MaxValue);
+ pendingPathResults.Enqueue((agent, path));
+ }
+
+ int local = Interlocked.Add(ref pathsCalculated, range.Item2 - range.Item1);
+ long now = Environment.TickCount64;
+ if (now - lastProgressReport > 100)
+ {
+ progress.Report((float)local / agentCount);
+ _ = Interlocked.Exchange(ref lastProgressReport, now);
+ }
+ });
+
+ progress.Report(1.0f);
+ progressTracker.RemoveProgress(progressDataPaths);
+ }
+
+ if (agentsRequirePathList.Count < 1000)
+ {
+ Task.Delay(100).Wait();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine(ex);
+ }
+ }
+ }
+}
diff --git a/src/LargeGridPathfinding/Systems/AgentMovementSystem.cs b/src/LargeGridPathfinding/Systems/AgentMovementSystem.cs
new file mode 100644
index 0000000..274399d
--- /dev/null
+++ b/src/LargeGridPathfinding/Systems/AgentMovementSystem.cs
@@ -0,0 +1,230 @@
+using Microsoft.Xna.Framework;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.ECS.Systems;
+
+using System;
+using System.Collections.Generic;
+
+using LargeGridPathfinding.Components;
+using LargeGridPathfinding.Services;
+
+namespace LargeGridPathfinding.Systems;
+
+public class AgentMovementSystem : EntityUpdateSystem
+{
+ private const int SectorShift = 7;
+ private const int SectorSize = 1 << SectorShift;
+
+ private readonly GridService gridService;
+ private readonly OrthographicCamera camera;
+ private readonly List agentList;
+ private ComponentMapper agentMapper = null!;
+ private int sectorsX;
+ private int sectorsY;
+ private List[,] agentSectors = null!;
+ private int frameCount;
+
+ public int SectorsX => sectorsX;
+ public int SectorsY => sectorsY;
+ public List[,] AgentSectors => agentSectors;
+
+ public AgentMovementSystem(GridService gridService, OrthographicCamera camera, List agentList)
+ : base(Aspect.All(typeof(AgentComponent)))
+ {
+ this.gridService = gridService;
+ this.camera = camera;
+ this.agentList = agentList;
+ }
+
+ public override void Initialize(IComponentMapperService mapperService)
+ {
+ agentMapper = mapperService.GetMapper();
+ RebuildSectors();
+ }
+
+ public override void Update(GameTime gameTime)
+ {
+ int[,] grid = gridService.Grid;
+ float dt = gameTime.GetElapsedSeconds();
+ float step = 10f * dt;
+ float sqrStep = step * step;
+
+ frameCount++;
+
+ if ((frameCount & 127) == 0)
+ {
+ RebuildSectors();
+ }
+
+ RectangleF viewBounds = camera.BoundingRectangle;
+ float margin = 5f;
+ float cellViewLeft = (viewBounds.Left / 10f) - margin;
+ float cellViewRight = (viewBounds.Right / 10f) + margin;
+ float cellViewTop = (viewBounds.Top / 10f) - margin;
+ float cellViewBottom = (viewBounds.Bottom / 10f) + margin;
+
+ int minSX = Math.Max(0, (int)(cellViewLeft / SectorSize) - 1);
+ int maxSX = Math.Min(sectorsX - 1, (int)(cellViewRight / SectorSize) + 1);
+ int minSY = Math.Max(0, (int)(cellViewTop / SectorSize) - 1);
+ int maxSY = Math.Min(sectorsY - 1, (int)(cellViewBottom / SectorSize) + 1);
+
+ for (int sx = minSX; sx <= maxSX; sx++)
+ {
+ for (int sy = minSY; sy <= maxSY; sy++)
+ {
+ List? sector = agentSectors[sx, sy];
+ if (sector is null)
+ {
+ continue;
+ }
+
+ foreach (AgentComponent agent in sector)
+ {
+ List? path = agent.Path;
+ if (path is null || agent.PathIndex >= path.Count)
+ {
+ continue;
+ }
+
+ Vector2 currentPos = agent.Position;
+ bool inView = currentPos.X >= cellViewLeft && currentPos.X <= cellViewRight
+ && currentPos.Y >= cellViewTop && currentPos.Y <= cellViewBottom;
+
+ Vector2 targetPos = agent.NextPosition;
+ float dx = targetPos.X - currentPos.X;
+ float dy = targetPos.Y - currentPos.Y;
+ float sqrDist = (dx * dx) + (dy * dy);
+
+ if (sqrDist < 0.01f)
+ {
+ if (agent.PathIndex == path.Count - 1)
+ {
+ agent.Path = null;
+ agent.Destination = null;
+ }
+ else
+ {
+ agent.NextPosition = path[++agent.PathIndex];
+ }
+ }
+ else
+ {
+ Vector2 nextPosition;
+ if (sqrDist <= sqrStep)
+ {
+ nextPosition = targetPos;
+ }
+ else
+ {
+ float t = step / MathF.Sqrt(sqrDist);
+ nextPosition = new Vector2(currentPos.X + (dx * t), currentPos.Y + (dy * t));
+ }
+
+ agent.Position = nextPosition;
+
+ if (inView)
+ {
+ int newX = (int)(nextPosition.X + 0.5f);
+ int newY = (int)(nextPosition.Y + 0.5f);
+
+ if (grid[newY, newX] < 0)
+ {
+ agent.Position = currentPos;
+ agent.Path = null;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ int totalAgents = agentList.Count;
+ int offScreenBudget = Math.Max(1000, totalAgents / 1000);
+ float catchUpStep = step * (totalAgents / (float)offScreenBudget);
+ int startIndex = frameCount * offScreenBudget % totalAgents;
+
+ for (int i = 0; i < offScreenBudget; i++)
+ {
+ AgentComponent a = agentList[(startIndex + i) % totalAgents];
+
+ List? p = a.Path;
+ if (p is null || a.PathIndex >= p.Count)
+ {
+ continue;
+ }
+
+ Vector2 pos = a.Position;
+ if (pos.X >= cellViewLeft && pos.X <= cellViewRight
+ && pos.Y >= cellViewTop && pos.Y <= cellViewBottom)
+ {
+ continue;
+ }
+
+ float remaining = catchUpStep;
+
+ while (remaining > 0.01f)
+ {
+ Vector2 currentPos = a.Position;
+ Vector2 targetPos = a.NextPosition;
+ float ddx = targetPos.X - currentPos.X;
+ float ddy = targetPos.Y - currentPos.Y;
+ float sqrDist = (ddx * ddx) + (ddy * ddy);
+
+ if (sqrDist < 0.01f)
+ {
+ if (a.PathIndex == p.Count - 1)
+ {
+ a.Path = null;
+ a.Destination = null;
+ break;
+ }
+
+ a.NextPosition = p[++a.PathIndex];
+ continue;
+ }
+
+ if (sqrDist <= remaining * remaining)
+ {
+ a.Position = targetPos;
+ remaining -= MathF.Sqrt(sqrDist);
+
+ if (a.PathIndex == p.Count - 1)
+ {
+ a.Path = null;
+ a.Destination = null;
+ break;
+ }
+
+ a.NextPosition = p[++a.PathIndex];
+ }
+ else
+ {
+ float t = remaining / MathF.Sqrt(sqrDist);
+ a.Position = new Vector2(currentPos.X + (ddx * t), currentPos.Y + (ddy * t));
+ break;
+ }
+ }
+ }
+ }
+
+ public void RebuildSectors()
+ {
+ int sx = (gridService.Width + SectorSize - 1) / SectorSize;
+ int sy = (gridService.Height + SectorSize - 1) / SectorSize;
+
+ List[,] newSectors = new List[sx, sy];
+
+ foreach (AgentComponent agent in agentList)
+ {
+ int x = Math.Clamp((int)(agent.Position.X / SectorSize), 0, sx - 1);
+ int y = Math.Clamp((int)(agent.Position.Y / SectorSize), 0, sy - 1);
+ (newSectors[x, y] ??= []).Add(agent);
+ }
+
+ sectorsX = sx;
+ sectorsY = sy;
+ agentSectors = newSectors;
+ }
+}
diff --git a/src/LargeGridPathfinding/Systems/AgentRenderSystem.cs b/src/LargeGridPathfinding/Systems/AgentRenderSystem.cs
new file mode 100644
index 0000000..9a7e8ad
--- /dev/null
+++ b/src/LargeGridPathfinding/Systems/AgentRenderSystem.cs
@@ -0,0 +1,89 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.ECS.Systems;
+
+using System;
+using System.Collections.Generic;
+
+using LargeGridPathfinding.Components;
+
+namespace LargeGridPathfinding.Systems;
+
+public class AgentRenderSystem : EntityDrawSystem
+{
+ private const int SectorSize = 1 << 7;
+
+ private readonly SpriteBatch spriteBatch;
+ private readonly OrthographicCamera camera;
+ private readonly Func getSectorsX;
+ private readonly Func getSectorsY;
+ private readonly Func[,]> getAgentSectors;
+ private ComponentMapper agentMapper = null!;
+
+ public AgentRenderSystem(
+ SpriteBatch spriteBatch,
+ OrthographicCamera camera,
+ Func getSectorsX,
+ Func getSectorsY,
+ Func[,]> getAgentSectors)
+ : base(Aspect.All(typeof(AgentComponent)))
+ {
+ this.spriteBatch = spriteBatch;
+ this.camera = camera;
+ this.getSectorsX = getSectorsX;
+ this.getSectorsY = getSectorsY;
+ this.getAgentSectors = getAgentSectors;
+ }
+
+ public override void Initialize(IComponentMapperService mapperService)
+ {
+ agentMapper = mapperService.GetMapper();
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ Matrix transformMatrix = camera.GetViewMatrix();
+
+ int yStart = Math.Max(0, (int)(camera.BoundingRectangle.Top / 10) - 2);
+ int xStart = Math.Max(0, (int)(camera.BoundingRectangle.Left / 10) - 2);
+ int yEnd = Math.Min((int)(camera.BoundingRectangle.Bottom / 10) + 2, 1000000);
+ int xEnd = Math.Min((int)(camera.BoundingRectangle.Right / 10) + 2, 1000000);
+
+ int sectorsX = getSectorsX();
+ int sectorsY = getSectorsY();
+ var agentSectors = getAgentSectors();
+
+ spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
+
+ int drawMinSX = Math.Max(0, (xStart / SectorSize) - 1);
+ int drawMaxSX = Math.Min(sectorsX - 1, (xEnd / SectorSize) + 1);
+ int drawMinSY = Math.Max(0, (yStart / SectorSize) - 1);
+ int drawMaxSY = Math.Min(sectorsY - 1, (yEnd / SectorSize) + 1);
+
+ for (int sx = drawMinSX; sx <= drawMaxSX; sx++)
+ {
+ for (int sy = drawMinSY; sy <= drawMaxSY; sy++)
+ {
+ List? sector = agentSectors[sx, sy];
+ if (sector is null)
+ {
+ continue;
+ }
+
+ foreach (AgentComponent agent in sector)
+ {
+ Vector2 pos = agent.Position;
+ if (pos.X >= xStart && pos.X <= xEnd && pos.Y >= yStart && pos.Y <= yEnd)
+ {
+ spriteBatch.DrawCircle((pos + new Vector2(0.5f, 0.5f)) * 10, 5, 10, Color.Blue, 2f, layerDepth: 0.1f);
+ }
+ }
+ }
+ }
+
+ spriteBatch.End();
+ }
+}
diff --git a/src/LargeGridPathfinding/Systems/GridEditSystem.cs b/src/LargeGridPathfinding/Systems/GridEditSystem.cs
new file mode 100644
index 0000000..75440f8
--- /dev/null
+++ b/src/LargeGridPathfinding/Systems/GridEditSystem.cs
@@ -0,0 +1,392 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.ECS.Systems;
+using MonoGame.Extended.Input;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+using LargeGridPathfinding.Services;
+
+namespace LargeGridPathfinding.Systems;
+
+public class GridEditSystem : IUpdateSystem
+{
+ public enum BrushMode
+ {
+ Weight,
+ Obstacle
+ }
+
+ private readonly GridService gridService;
+ private readonly OrthographicCamera camera;
+ private readonly ConcurrentDictionary temporaryIndicators;
+ private readonly ProgressTracker progressTracker;
+ private readonly Func isInputBlocked;
+ private readonly Action setInputBlocked;
+ private readonly Func getShowZones;
+ private readonly Action setShowZones;
+ private readonly Func getShowGrid;
+ private readonly Action setShowGrid;
+ private readonly Func getShowPaths;
+ private readonly Action setShowPaths;
+
+ private BrushMode brushMode = BrushMode.Obstacle;
+ private int paintWeight = 5;
+ private Vector2? previousMousePosition;
+ private bool wasShiftLeftDown;
+ private bool wasShiftRightDown;
+ private Point? fillSelectionStart;
+ private Point? fillSelectionCurrent;
+
+ public BrushMode CurrentBrushMode => brushMode;
+ public int PaintWeight => paintWeight;
+ public Point? FillSelectionStart => fillSelectionStart;
+ public Point? FillSelectionCurrent => fillSelectionCurrent;
+
+ public GridEditSystem(
+ GridService gridService,
+ OrthographicCamera camera,
+ ConcurrentDictionary temporaryIndicators,
+ ProgressTracker progressTracker,
+ Func isInputBlocked,
+ Action setInputBlocked,
+ Func getShowZones,
+ Action setShowZones,
+ Func getShowGrid,
+ Action setShowGrid,
+ Func getShowPaths,
+ Action setShowPaths)
+ {
+ this.gridService = gridService;
+ this.camera = camera;
+ this.temporaryIndicators = temporaryIndicators;
+ this.progressTracker = progressTracker;
+ this.isInputBlocked = isInputBlocked;
+ this.setInputBlocked = setInputBlocked;
+ this.getShowZones = getShowZones;
+ this.setShowZones = setShowZones;
+ this.getShowGrid = getShowGrid;
+ this.setShowGrid = setShowGrid;
+ this.getShowPaths = getShowPaths;
+ this.setShowPaths = setShowPaths;
+ }
+
+ public void Initialize(World world) { }
+
+ public void Dispose() { }
+
+ public void Update(GameTime gameTime)
+ {
+ MouseStateExtended mouseState = MouseExtended.GetState();
+ KeyboardStateExtended keyboardState = KeyboardExtended.GetState();
+ bool shiftPressed = keyboardState.IsKeyDown(Keys.LeftShift) || keyboardState.IsKeyDown(Keys.RightShift);
+ bool shiftLeftDown = shiftPressed && mouseState.IsButtonDown(MouseButton.Left);
+ bool shiftLeftClicked = shiftLeftDown && !wasShiftLeftDown;
+ bool shiftRightDown = shiftPressed && mouseState.IsButtonDown(MouseButton.Right);
+ bool shiftRightClicked = shiftRightDown && !wasShiftRightDown;
+ wasShiftLeftDown = shiftLeftDown;
+ wasShiftRightDown = shiftRightDown;
+
+ if (isInputBlocked())
+ {
+ return;
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.Z))
+ {
+ setShowZones(!getShowZones());
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.G))
+ {
+ setShowGrid(!getShowGrid());
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.P))
+ {
+ setShowPaths(!getShowPaths());
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.D1) || keyboardState.WasKeyPressed(Keys.NumPad1))
+ {
+ paintWeight = 1;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D2) || keyboardState.WasKeyPressed(Keys.NumPad2))
+ {
+ paintWeight = 2;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D3) || keyboardState.WasKeyPressed(Keys.NumPad3))
+ {
+ paintWeight = 3;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D4) || keyboardState.WasKeyPressed(Keys.NumPad4))
+ {
+ paintWeight = 4;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D5) || keyboardState.WasKeyPressed(Keys.NumPad5))
+ {
+ paintWeight = 5;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D6) || keyboardState.WasKeyPressed(Keys.NumPad6))
+ {
+ paintWeight = 6;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D7) || keyboardState.WasKeyPressed(Keys.NumPad7))
+ {
+ paintWeight = 7;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D8) || keyboardState.WasKeyPressed(Keys.NumPad8))
+ {
+ paintWeight = 8;
+ }
+ else if (keyboardState.WasKeyPressed(Keys.D9) || keyboardState.WasKeyPressed(Keys.NumPad9))
+ {
+ paintWeight = 9;
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.O))
+ {
+ brushMode = brushMode == BrushMode.Weight ? BrushMode.Obstacle : BrushMode.Weight;
+ }
+
+ if (keyboardState.WasKeyPressed(Keys.F))
+ {
+ Debug.WriteLine("Filling grid...");
+
+ _ = progressTracker.AddProgress("Filling grid", out IProgress fillingGridProgressReporter);
+ _ = progressTracker.AddProgress("Calculating candidates", out IProgress calculatingCandidatesProgressReporter);
+ _ = progressTracker.AddProgress("Placing zones", out IProgress placingCandidatesProgressReporter);
+
+ bool showZonesBefore = getShowZones();
+ bool showGridBefore = getShowGrid();
+ bool showPathsBefore = getShowPaths();
+ setShowZones(true);
+ setShowGrid(false);
+ setShowPaths(false);
+ setInputBlocked(true);
+
+ _ = System.Threading.Tasks.Task.Run(() =>
+ {
+ gridService.Filler.FillGrid(fillAll: true, totalProgress: fillingGridProgressReporter, calculatingCandidatesProgress: calculatingCandidatesProgressReporter, placingCandidatesProgress: placingCandidatesProgressReporter);
+ gridService.GridChanged = true;
+ setInputBlocked(false);
+ setShowZones(showZonesBefore);
+ setShowGrid(showGridBefore);
+ setShowPaths(showPathsBefore);
+ });
+ }
+
+ if (shiftPressed)
+ {
+ if (fillSelectionStart.HasValue && TryGetMouseGridPoint(mouseState, out Point hoverGridPoint))
+ {
+ fillSelectionCurrent = hoverGridPoint;
+ }
+ else
+ {
+ fillSelectionCurrent = null;
+ }
+
+ if ((shiftLeftClicked || shiftRightClicked) && TryGetMouseGridPoint(mouseState, out Point clickedGridPoint))
+ {
+ if (!fillSelectionStart.HasValue)
+ {
+ fillSelectionStart = clickedGridPoint;
+ fillSelectionCurrent = clickedGridPoint;
+ }
+ else
+ {
+ List selectionPoints = GetRectanglePoints(fillSelectionStart.Value, clickedGridPoint);
+ Color indicatorColor = brushMode == BrushMode.Weight ? Color.Orange : Color.Red;
+
+ foreach (Point point in selectionPoints)
+ {
+ temporaryIndicators[point.ToVector2()] = indicatorColor;
+ }
+
+ GridService.PendingOperation operation;
+ if (brushMode == BrushMode.Weight && shiftLeftClicked)
+ {
+ operation = new GridService.PendingOperation(GridService.PendingOperationKind.SetWeight, paintWeight);
+ }
+ else if (brushMode == BrushMode.Weight && shiftRightClicked)
+ {
+ operation = new GridService.PendingOperation(GridService.PendingOperationKind.ResetWeight, 0);
+ }
+ else if (brushMode == BrushMode.Obstacle && shiftLeftClicked)
+ {
+ operation = new GridService.PendingOperation(GridService.PendingOperationKind.PlaceObstacle, 0);
+ }
+ else if (brushMode == BrushMode.Obstacle && shiftRightClicked)
+ {
+ operation = new GridService.PendingOperation(GridService.PendingOperationKind.RemoveObstacle, 0);
+ }
+ else
+ {
+ throw new InvalidOperationException("Invalid brush mode or mouse button state.");
+ }
+
+ gridService.EnqueueOperations(selectionPoints, operation);
+ fillSelectionStart = null;
+ fillSelectionCurrent = null;
+ }
+ }
+
+ previousMousePosition = null;
+ return;
+ }
+ else
+ {
+ fillSelectionStart = null;
+ fillSelectionCurrent = null;
+ }
+
+ if (mouseState.IsButtonDown(MouseButton.Left))
+ {
+ Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
+ Vector2 gridPosition = new((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
+
+ if (gridPosition.X < 0 || gridPosition.X >= gridService.Width || gridPosition.Y < 0 || gridPosition.Y >= gridService.Height || temporaryIndicators.ContainsKey(gridPosition))
+ {
+ return;
+ }
+
+ int cellValue = gridService.Grid[(int)gridPosition.Y, (int)gridPosition.X];
+ if ((brushMode == BrushMode.Weight && cellValue < 0) || (brushMode == BrushMode.Obstacle && cellValue < 0))
+ {
+ return;
+ }
+
+ Vector2? previousGridPosition = null;
+ if (previousMousePosition is not null)
+ {
+ previousGridPosition = new Vector2((int)previousMousePosition.Value.X / 10, (int)previousMousePosition.Value.Y / 10);
+ }
+
+ Color indicatorColor = brushMode == BrushMode.Weight ? Color.Orange : Color.Red;
+ BrushMode currentBrushMode = brushMode;
+ int currentPaintWeight = paintWeight;
+ previousMousePosition = mousePosition;
+
+ List brushPoints = GetBrushPoints(gridPosition, previousGridPosition);
+ foreach (Point point in brushPoints)
+ {
+ temporaryIndicators[point.ToVector2()] = indicatorColor;
+ }
+
+ GridService.PendingOperation pendingOperation = currentBrushMode == BrushMode.Weight
+ ? new GridService.PendingOperation(GridService.PendingOperationKind.SetWeight, currentPaintWeight)
+ : new GridService.PendingOperation(GridService.PendingOperationKind.PlaceObstacle, 0);
+
+ gridService.EnqueueOperations(brushPoints, pendingOperation);
+ }
+ else if (mouseState.IsButtonDown(MouseButton.Right))
+ {
+ Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
+ Vector2 gridPosition = new((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
+
+ if (gridPosition.X < 0 || gridPosition.X >= gridService.Width || gridPosition.Y < 0 || gridPosition.Y >= gridService.Height || temporaryIndicators.ContainsKey(gridPosition))
+ {
+ return;
+ }
+
+ int cellValue = gridService.Grid[(int)gridPosition.Y, (int)gridPosition.X];
+ if ((brushMode == BrushMode.Weight && cellValue < 0) || (brushMode == BrushMode.Obstacle && cellValue >= 0))
+ {
+ return;
+ }
+
+ Vector2? previousGridPosition = null;
+ if (previousMousePosition is not null)
+ {
+ previousGridPosition = new Vector2((int)previousMousePosition.Value.X / 10, (int)previousMousePosition.Value.Y / 10);
+ }
+
+ Color indicatorColor = brushMode == BrushMode.Weight ? Color.LightGray : Color.Yellow;
+ BrushMode currentBrushMode = brushMode;
+ previousMousePosition = mousePosition;
+
+ List brushPoints = GetBrushPoints(gridPosition, previousGridPosition);
+ foreach (Point point in brushPoints)
+ {
+ temporaryIndicators[point.ToVector2()] = indicatorColor;
+ }
+
+ GridService.PendingOperation pendingOperation = currentBrushMode == BrushMode.Weight
+ ? new GridService.PendingOperation(GridService.PendingOperationKind.ResetWeight, 0)
+ : new GridService.PendingOperation(GridService.PendingOperationKind.RemoveObstacle, 0);
+
+ gridService.EnqueueOperations(brushPoints, pendingOperation);
+ }
+ else
+ {
+ previousMousePosition = null;
+ }
+ }
+
+ private bool TryGetMouseGridPoint(MouseStateExtended mouseState, out Point gridPoint)
+ {
+ Vector2 mousePosition = camera.ScreenToWorld(mouseState.Position.ToVector2());
+ Point point = new((int)mousePosition.X / 10, (int)mousePosition.Y / 10);
+
+ if (point.X < 0 || point.X >= gridService.Width || point.Y < 0 || point.Y >= gridService.Height)
+ {
+ gridPoint = default;
+ return false;
+ }
+
+ gridPoint = point;
+ return true;
+ }
+
+ private static List GetRectanglePoints(Point start, Point end)
+ {
+ int minX = Math.Min(start.X, end.X);
+ int minY = Math.Min(start.Y, end.Y);
+ int maxX = Math.Max(start.X, end.X);
+ int maxY = Math.Max(start.Y, end.Y);
+
+ List points = new((maxX - minX + 1) * (maxY - minY + 1));
+
+ for (int y = minY; y <= maxY; y++)
+ {
+ for (int x = minX; x <= maxX; x++)
+ {
+ points.Add(new Point(x, y));
+ }
+ }
+
+ return points;
+ }
+
+ private static List GetBrushPoints(Vector2 currentGridPosition, Vector2? previousGridPosition)
+ {
+ HashSet points = [];
+ Point currentPoint = new((int)currentGridPosition.X, (int)currentGridPosition.Y);
+ _ = points.Add(currentPoint);
+
+ if (previousGridPosition is null)
+ {
+ return [.. points];
+ }
+
+ float distance = Vector2.Distance(previousGridPosition.Value, currentGridPosition);
+ int steps = Math.Max(1, (int)Math.Ceiling(distance * 10));
+
+ for (int i = 0; i <= steps; i++)
+ {
+ float t = i / (float)steps;
+ Vector2 interpolatedPosition = Vector2.Lerp(previousGridPosition.Value, currentGridPosition, t);
+ Point interpolatedPoint = new((int)Math.Round(interpolatedPosition.X), (int)Math.Round(interpolatedPosition.Y));
+ _ = points.Add(interpolatedPoint);
+ }
+
+ return [.. points];
+ }
+}
diff --git a/src/LargeGridPathfinding/Systems/GridRenderSystem.cs b/src/LargeGridPathfinding/Systems/GridRenderSystem.cs
new file mode 100644
index 0000000..21bfe78
--- /dev/null
+++ b/src/LargeGridPathfinding/Systems/GridRenderSystem.cs
@@ -0,0 +1,150 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.ECS.Systems;
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+using LargeGridPathfinding.Services;
+
+namespace LargeGridPathfinding.Systems;
+
+public class GridRenderSystem : IDrawSystem
+{
+ private const int SectorShift = 7;
+ private const int SectorSize = 1 << SectorShift;
+
+ private readonly SpriteBatch spriteBatch;
+ private readonly GridService gridService;
+ private readonly OrthographicCamera camera;
+ private readonly ConcurrentDictionary temporaryIndicators;
+ private readonly Func getShowZones;
+ private readonly Func getShowGrid;
+ private readonly Func getFillSelectionStart;
+ private readonly Func getFillSelectionCurrent;
+
+ private static readonly Color[] ColorLookup =
+ [
+ Color.Blue, Color.Cyan, Color.Magenta, Color.Yellow, Color.Orange,
+ Color.DarkMagenta, Color.DarkCyan, Color.Tan, Color.RosyBrown,
+ Color.DarkKhaki, Color.DarkSalmon, Color.DarkSlateGray,
+ Color.DarkTurquoise, Color.DarkGoldenrod, Color.Aqua, Color.Aquamarine,
+ Color.Bisque, Color.DarkSlateBlue, Color.BlueViolet, Color.Brown,
+ Color.BurlyWood, Color.CadetBlue, Color.Chartreuse, Color.Chocolate,
+ Color.Coral, Color.CornflowerBlue, Color.Crimson, Color.DarkBlue
+ ];
+
+ public GridRenderSystem(
+ SpriteBatch spriteBatch,
+ GridService gridService,
+ OrthographicCamera camera,
+ ConcurrentDictionary temporaryIndicators,
+ Func getShowZones,
+ Func getShowGrid,
+ Func getFillSelectionStart,
+ Func getFillSelectionCurrent)
+ {
+ this.spriteBatch = spriteBatch;
+ this.gridService = gridService;
+ this.camera = camera;
+ this.temporaryIndicators = temporaryIndicators;
+ this.getShowZones = getShowZones;
+ this.getShowGrid = getShowGrid;
+ this.getFillSelectionStart = getFillSelectionStart;
+ this.getFillSelectionCurrent = getFillSelectionCurrent;
+ }
+
+ public void Initialize(World world) { }
+
+ public void Dispose() { }
+
+ public void Draw(GameTime gameTime)
+ {
+ Matrix transformMatrix = camera.GetViewMatrix();
+
+ int yStart = Math.Max(0, (int)(camera.BoundingRectangle.Top / 10) - 2);
+ int xStart = Math.Max(0, (int)(camera.BoundingRectangle.Left / 10) - 2);
+ int yEnd = Math.Min(gridService.Height, (int)(camera.BoundingRectangle.Bottom / 10) + 2);
+ int xEnd = Math.Min(gridService.Width, (int)(camera.BoundingRectangle.Right / 10) + 2);
+
+ int[,] grid = gridService.Grid;
+ Rectangle cellRect = new(0, 0, 10, 10);
+
+ spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
+
+ spriteBatch.FillRectangle(new Rectangle(0, 0, gridService.Width * 10, gridService.Height * 10), Color.White, layerDepth: 0.4f);
+
+ if (getShowZones())
+ {
+ Dictionary rectangles = new(gridService.Filler.PlacedRectangles);
+
+ foreach ((int label, Rectangle rectangle) in rectangles)
+ {
+ Color color = ColorLookup[label % 28];
+ spriteBatch.FillRectangle(new Rectangle(rectangle.X * 10, rectangle.Y * 10, rectangle.Width * 10, rectangle.Height * 10), Color.Lerp(color, Color.Gray, 0.55f), layerDepth: 0.2f);
+ }
+ }
+
+ for (int y = yStart; y < yEnd; y++)
+ {
+ for (int x = xStart; x < xEnd; x++)
+ {
+ cellRect.X = x * 10;
+ cellRect.Y = y * 10;
+
+ int cellValue = grid[y, x];
+ int cellWeight = gridService.WeightGrid[y, x];
+
+ if (cellWeight > 1 && cellValue >= 0)
+ {
+ float blendFactor = Math.Clamp((cellWeight - 1) / 8f, 0f, 1f);
+ Color weightColor = Color.Lerp(Color.LightYellow, Color.DarkOrange, blendFactor);
+
+ if (getShowZones())
+ {
+ spriteBatch.DrawRectangle(cellRect, weightColor * 0.75f, layerDepth: 0.25f);
+ }
+ else
+ {
+ spriteBatch.FillRectangle(cellRect, weightColor * 0.75f, layerDepth: 0.25f);
+ }
+ }
+
+ if (cellValue < 0)
+ {
+ spriteBatch.FillRectangle(cellRect, Color.Red, layerDepth: 0.3f);
+ }
+
+ if (temporaryIndicators.TryGetValue(new Vector2(x, y), out Color indicatorColor))
+ {
+ spriteBatch.DrawCircle(cellRect.Center.ToVector2(), 5, 10, indicatorColor, 2f, layerDepth: 0.2f);
+ }
+
+ if (getShowGrid())
+ {
+ spriteBatch.DrawRectangle(cellRect, Color.Black, layerDepth: 0.2f);
+ }
+ }
+ }
+
+ Point? fillSelectionStart = getFillSelectionStart();
+ if (fillSelectionStart.HasValue)
+ {
+ Point selectionEnd = getFillSelectionCurrent() ?? fillSelectionStart.Value;
+ int left = Math.Min(fillSelectionStart.Value.X, selectionEnd.X);
+ int top = Math.Min(fillSelectionStart.Value.Y, selectionEnd.Y);
+ int right = Math.Max(fillSelectionStart.Value.X, selectionEnd.X);
+ int bottom = Math.Max(fillSelectionStart.Value.Y, selectionEnd.Y);
+
+ Rectangle selectionRectangle = new(left * 10, top * 10, (right - left + 1) * 10, (bottom - top + 1) * 10);
+ spriteBatch.FillRectangle(selectionRectangle, Color.LightBlue * 0.2f, layerDepth: 0.24f);
+ spriteBatch.DrawRectangle(selectionRectangle, Color.Blue, 2f, layerDepth: 0.19f);
+ }
+
+ spriteBatch.End();
+ }
+}
diff --git a/src/LargeGridPathfinding/Systems/PathRenderSystem.cs b/src/LargeGridPathfinding/Systems/PathRenderSystem.cs
new file mode 100644
index 0000000..bceb104
--- /dev/null
+++ b/src/LargeGridPathfinding/Systems/PathRenderSystem.cs
@@ -0,0 +1,127 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+using MonoGame.Extended;
+using MonoGame.Extended.ECS;
+using MonoGame.Extended.ECS.Systems;
+
+using System;
+using System.Collections.Generic;
+
+using LargeGridPathfinding.Components;
+
+namespace LargeGridPathfinding.Systems;
+
+public class PathRenderSystem : EntityDrawSystem
+{
+ private const int SectorSize = 1 << 7;
+
+ private readonly SpriteBatch spriteBatch;
+ private readonly OrthographicCamera camera;
+ private readonly Func getShowPaths;
+ private readonly Func getSectorsX;
+ private readonly Func getSectorsY;
+ private readonly Func[,]> getAgentSectors;
+ private ComponentMapper agentMapper = null!;
+
+ public PathRenderSystem(
+ SpriteBatch spriteBatch,
+ OrthographicCamera camera,
+ Func getShowPaths,
+ Func getSectorsX,
+ Func getSectorsY,
+ Func[,]> getAgentSectors)
+ : base(Aspect.All(typeof(AgentComponent)))
+ {
+ this.spriteBatch = spriteBatch;
+ this.camera = camera;
+ this.getShowPaths = getShowPaths;
+ this.getSectorsX = getSectorsX;
+ this.getSectorsY = getSectorsY;
+ this.getAgentSectors = getAgentSectors;
+ }
+
+ public override void Initialize(IComponentMapperService mapperService)
+ {
+ agentMapper = mapperService.GetMapper();
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ if (!getShowPaths())
+ {
+ return;
+ }
+
+ Matrix transformMatrix = camera.GetViewMatrix();
+ spriteBatch.Begin(SpriteSortMode.Deferred, samplerState: SamplerState.PointClamp, transformMatrix: transformMatrix);
+
+ float viewLeft = camera.BoundingRectangle.Left - 10;
+ float viewRight = camera.BoundingRectangle.Right + 10;
+ float viewTop = camera.BoundingRectangle.Top - 10;
+ float viewBottom = camera.BoundingRectangle.Bottom + 10;
+
+ int sectorsX = getSectorsX();
+ int sectorsY = getSectorsY();
+ var agentSectors = getAgentSectors();
+
+ int pathMinSX = Math.Max(0, (int)(viewLeft / 10f / SectorSize) - 1);
+ int pathMaxSX = Math.Min(sectorsX - 1, (int)(viewRight / 10f / SectorSize) + 1);
+ int pathMinSY = Math.Max(0, (int)(viewTop / 10f / SectorSize) - 1);
+ int pathMaxSY = Math.Min(sectorsY - 1, (int)(viewBottom / 10f / SectorSize) + 1);
+
+ for (int sx = pathMinSX; sx <= pathMaxSX; sx++)
+ {
+ for (int sy = pathMinSY; sy <= pathMaxSY; sy++)
+ {
+ List? sector = agentSectors[sx, sy];
+ if (sector is null)
+ {
+ continue;
+ }
+
+ foreach (AgentComponent agent in sector)
+ {
+ List? path = agent.Path;
+ if (path is null || path.Count < 2)
+ {
+ continue;
+ }
+
+ for (int i = 0; i < path.Count - 1; i++)
+ {
+ Vector2 start = (path[i] * 10) + new Vector2(5, 5);
+ Vector2 end = (path[i + 1] * 10) + new Vector2(5, 5);
+
+ bool startInView = start.X >= viewLeft && start.X <= viewRight
+ && start.Y >= viewTop && start.Y <= viewBottom;
+ bool endInView = end.X >= viewLeft && end.X <= viewRight
+ && end.Y >= viewTop && end.Y <= viewBottom;
+
+ bool segmentVisible = !((start.X < viewLeft && end.X < viewLeft) ||
+ (start.X > viewRight && end.X > viewRight) ||
+ (start.Y < viewTop && end.Y < viewTop) ||
+ (start.Y > viewBottom && end.Y > viewBottom));
+
+ if (segmentVisible)
+ {
+ spriteBatch.DrawLine(start, end, Color.Gray, 2f, layerDepth: 0.1f);
+ }
+
+ if (startInView)
+ {
+ spriteBatch.DrawCircle(start, 5, 10, i == 0 ? Color.Green : Color.Gray, 2f, layerDepth: 0.1f);
+ }
+
+ if (i == path.Count - 2 && endInView)
+ {
+ spriteBatch.DrawCircle(end, 5, 10, Color.Red, 2f, layerDepth: 0.1f);
+ }
+ }
+ }
+ }
+ }
+
+ spriteBatch.End();
+ }
+}