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(); + } +}