Эх сурвалжийг харах

Slightly better performance

Axel Nordh 1 сар өмнө
parent
commit
13deb1eab6

+ 378 - 173
Assets/Scripts/AIAgent.cs

@@ -24,7 +24,8 @@ public class AIAgent : MonoBehaviour
     private float actualMovementSpeed; // Per-agent speed variation to reduce synchronization
 
     [Header("Pathfinding")]
-    [SerializeField] private bool showPath = true;
+    [Tooltip("Disable for large agent counts to avoid LineRenderer overhead.")]
+    [SerializeField] private bool showPath = false;
     [SerializeField] private LineRenderer pathRenderer;
     [SerializeField] private Color pathColor = Color.yellow;
 
@@ -46,6 +47,7 @@ public class AIAgent : MonoBehaviour
     private bool commitedToExit = false; // Track if agent has committed to a specific hallway exit
     private float nextRandomWait = 0f; // Random wait time before exploring new areas
     private float agentRandomOffset = 0f; // Per-agent random offset to desync movement
+    private bool pathRequestPending = false; // True while waiting for async pathfinding result
 
     // Intelligence-driven disposition (seeded at spawn from Intelligence stat)
     // Will drive group-up logic, risk assessment, and future combat decisions
@@ -57,6 +59,26 @@ public class AIAgent : MonoBehaviour
     private AgentGroup currentGroup = null;
     private bool isGroupFollower = false; // True when this agent defers movement to the leader
 
+    // ----- Combat -----
+    private Weapon weapon;
+    private float lastAttackTime = 0f;
+    private const float ATTACK_COOLDOWN = 1.0f;
+    /// <summary>Agent advantage multiplier on hit rolls (can improve with experience/weapons later).</summary>
+    private const float AGENT_HIT_ADVANTAGE = 1.3f;
+    private bool isDead = false;
+    /// <summary>True while a monster is within melee range – halts path-following.</summary>
+    private bool isInCombat = false;
+
+    // ----- Room danger / avoidance -----
+    /// <summary>Room IDs the agent has decided to avoid (too dangerous to fight through).</summary>
+    private readonly HashSet<int> avoidedRooms = new();
+    /// <summary>Speed threshold: agents above this fraction of max speed may try to run through.</summary>
+    private const float RUN_THROUGH_SPEED_FRACTION = 0.6f; // Speed stat > 60 enables sprinting
+
+    // ----- Cached maze data (set once in Start, never changes) -----
+    private MazeRoom[] _allRooms;          // Array copy is faster to iterate than List
+    private HashSet<MazeRoom> _goalRooms;  // Constant set for O(1) Contains checks
+
     void Start()
     {
         mazeController = FindAnyObjectByType<MazeController>();
@@ -78,6 +100,10 @@ public class AIAgent : MonoBehaviour
         pathfinder = new MazePathfinder(maze);
         roomMemory = AIRoomMemoryManager.GetMemory(agentCharacterType);
 
+        // Cache room data once – avoids List allocations and LINQ on every Update
+        _allRooms = maze.Rooms.ToArray();
+        _goalRooms = new HashSet<MazeRoom>(maze.GetRoomsByType(MazeRoom.RoomType.End));
+
         // Initialize personalization
         agentName = AgentNameGenerator.GenerateRandomName();
         agentStats = new AgentStats();
@@ -87,6 +113,9 @@ public class AIAgent : MonoBehaviour
         groupUpAffinity = agentStats.GroupUpAffinity;
         riskTolerance = agentStats.RiskTolerance;
 
+        // Equip fists as default weapon
+        weapon = Weapon.Fists();
+
         // Initialize with a random start point
         if (maze.StartPoints.Count > 0)
         {
@@ -130,7 +159,8 @@ public class AIAgent : MonoBehaviour
         float speedMultiplier = 1f + (agentStats.Speed / 100f);
         actualMovementSpeed = movementSpeed * speedMultiplier * Random.Range(0.9f, 1.1f);
 
-        Debug.Log($"[Agent {agentId}] {agentName} | {agentStats} | GroupUp: {groupUpAffinity:P0} | Risk: {riskTolerance:P0}");
+        // Suppress per-agent spawn log for large counts; enable only when debugging individuals
+        // Debug.Log($"[Agent {agentId}] {agentName} | {agentStats} | GroupUp: {groupUpAffinity:P0} | Risk: {riskTolerance:P0}");
 
         // Setup path renderer
         if (showPath && pathRenderer == null)
@@ -147,42 +177,70 @@ public class AIAgent : MonoBehaviour
 
     void Update()
     {
-        if (maze == null) return;
+        if (maze == null || isDead) return;
 
         // Followers defer all movement to the group leader
         if (isGroupFollower)
         {
             if (currentGroup != null && currentGroup.Leader != null)
                 transform.position = currentGroup.Leader.transform.position;
+            // Followers still fight monsters in range
+            CombatUpdate();
             return;
         }
 
+        // Fight any adjacent monsters
+        CombatUpdate();
+
         // Update current room
         UpdateCurrentRoom();
 
-        // Update pathfinding periodically with agent-specific offset to desync behavior
-        if (Time.time - lastPathUpdate > pathUpdateInterval + agentRandomOffset)
+        // Update pathfinding periodically – skip if an async request is already in-flight
+        if (!pathRequestPending && Time.time - lastPathUpdate > pathUpdateInterval + agentRandomOffset)
         {
             UpdatePathToGoal();
             lastPathUpdate = Time.time;
         }
 
-        // Move along path
-        FollowPath();
+        // Move along path – suppressed while fighting
+        if (!isInCombat)
+            FollowPath();
 
-        // Debug visualization
-        if (showPath && pathRenderer != null)
+        // Debug visualization – only update for agents near the camera to save draw calls
+        if (showPath && pathRenderer != null && IsNearCamera(80f))
         {
             UpdatePathVisualization();
         }
     }
 
+    private static Camera _mainCam;
+    private static float _lastCamFetch;
+
+    /// <summary>Returns true if this agent is within <paramref name="sqrDist"/> world units of the main camera.</summary>
+    private bool IsNearCamera(float sqrDist)
+    {
+        if (_mainCam == null || Time.time - _lastCamFetch > 2f)
+        {
+            _mainCam = Camera.main;
+            _lastCamFetch = Time.time;
+        }
+        if (_mainCam == null) return true;
+        return (transform.position - _mainCam.transform.position).sqrMagnitude < sqrDist * sqrDist;
+    }
+
+    private Vector2Int _lastRoomCheckTile = new Vector2Int(-9999, -9999);
+
     /// <summary>
-    /// Updates the current room based on position
+    /// Updates the current room based on position.
+    /// Only queries the maze when the agent has moved to a different tile.
     /// </summary>
     private void UpdateCurrentRoom()
     {
         Vector2Int tilePos = WorldToTile(transform.position);
+        // Skip if we're still on the same tile – saves GetRoomAtTile call every frame
+        if (tilePos == _lastRoomCheckTile) return;
+        _lastRoomCheckTile = tilePos;
+
         MazeRoom room = maze.GetRoomAtTile(tilePos.x, tilePos.y);
 
         if (room != null)
@@ -218,8 +276,7 @@ public class AIAgent : MonoBehaviour
         MazeRoom currentRoomData = maze.GetRoomAtTile(currentPos.x, currentPos.y);
 
         // CHECK FOR GOAL ROOM FIRST - this should work even while following path
-        var goalRooms = maze.GetRoomsByType(MazeRoom.RoomType.End);
-        if (currentRoomData != null && goalRooms.Contains(currentRoomData))
+        if (currentRoomData != null && _goalRooms.Contains(currentRoomData))
         {
             if (!hasReachedGoal)
             {
@@ -249,19 +306,42 @@ public class AIAgent : MonoBehaviour
             return; // Keep following existing path
         }
 
-        // If we've committed to reaching an exit and haven't reached it yet, keep trying
-        if (commitedToExit && targetExitTile != Vector2Int.zero)
+        // If we've committed to reaching an exit and haven't reached it yet, keep trying.
+        // Guard with pathRequestPending to avoid writing currentPath from two places.
+        if (commitedToExit && targetExitTile != Vector2Int.zero && !pathRequestPending)
         {
-            // Try to find a path to the committed exit
-            if (currentPath.Count == 0)
+            if (currentPath.Count == 0 && currentRoomData != null)
             {
-                currentPath = FindPathInRoom(currentPos, targetExitTile, currentRoomData);
-                currentPathIndex = 0;
-                if (currentPath.Count > 0)
+                pathRequestPending = true;
+                var capturedExit = targetExitTile;
+                var capturedRoom = currentRoomData;
+                if (PathfindingScheduler.Instance != null)
+                {
+                    PathfindingScheduler.Instance.RequestPath(new PathRequest
+                    {
+                        AgentId = agentId,
+                        Start = currentPos,
+                        Goal = capturedExit,
+                        RoomContext = capturedRoom,
+                        IsHallwayMode = false,
+                        Callback = result =>
+                        {
+                            pathRequestPending = false;
+                            if (result.Count > 0)
+                            {
+                                currentPath = result;
+                                currentPathIndex = 0;
+                            }
+                        }
+                    });
+                }
+                else
                 {
-                    Debug.Log($"AIAgent {agentId}: Re-committed to exit {targetExitTile}");
-                    return;
+                    currentPath = FindPathInRoom(currentPos, capturedExit, capturedRoom);
+                    currentPathIndex = 0;
+                    pathRequestPending = false;
                 }
+                return;
             }
         }
 
@@ -280,7 +360,7 @@ public class AIAgent : MonoBehaviour
 
             // Collect all unvisited rooms
             List<MazeRoom> unvisitedRooms = new();
-            foreach (var room in maze.Rooms)
+            foreach (var room in _allRooms)
             {
                 // Skip the room we just exited (backtracking prevention)
                 if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
@@ -324,7 +404,7 @@ public class AIAgent : MonoBehaviour
             if (targetRoom == null)
             {
                 float closestDistance = float.MaxValue;
-                foreach (var room in maze.Rooms)
+                foreach (var room in _allRooms)
                 {
                     // Skip the room we just exited
                     if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
@@ -346,8 +426,7 @@ public class AIAgent : MonoBehaviour
             //  and may wait for allies — once group logic is implemented.)
             if (targetRoom != null)
             {
-                var goalRoomsCheck = maze.GetRoomsByType(MazeRoom.RoomType.End);
-                if (goalRoomsCheck.Contains(targetRoom))
+                if (_goalRooms.Contains(targetRoom))
                 {
                     knowsExitLocation = true;
                     // High-intelligence agents (INT > 50) pause briefly to "assess"
@@ -361,33 +440,36 @@ public class AIAgent : MonoBehaviour
 
             if (targetRoom != null)
             {
-                currentPath = FindPathToNearestRoom(currentPos, targetRoom);
-                currentPathIndex = 0;
-
-                if (currentPath.Count > 0)
-                {
-                    bool isUnvisited = !roomMemory.HasVisited(targetRoom.Id);
-                    return;
-                }
-                else
+                // Request async path – result arrives next frame via callback
+                if (PathfindingScheduler.Instance != null)
                 {
-                    //                    Debug.LogWarning($"AIAgent {agentId}: Failed to path to room {targetRoom.Id} from hallway at {currentPos}. Trying any adjacent room.");
-                    // Fallback: try to find ANY adjacent room and move directly toward it
-                    foreach (var room in maze.Rooms)
+                    pathRequestPending = true;
+                    var capturedRoom = targetRoom;
+                    PathfindingScheduler.Instance.RequestPath(new PathRequest
                     {
-                        if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
-                            continue;
-
-                        currentPath = FindPathToNearestRoom(currentPos, room);
-                        if (currentPath.Count > 0)
+                        AgentId = agentId,
+                        Start = currentPos,
+                        Goal = capturedRoom.GetCenter(),
+                        IsHallwayMode = true,
+                        Callback = result =>
                         {
-                            return;
+                            pathRequestPending = false;
+                            if (result.Count > 0)
+                            {
+                                currentPath = result;
+                                currentPathIndex = 0;
+                            }
+                            // else: fallback handled next update cycle
                         }
-                    }
-
-                    Debug.LogError($"AIAgent {agentId}: STUCK in hallway at {currentPos} - no paths found to any room!");
-                    return;
+                    });
                 }
+                else
+                {
+                    // Fallback: synchronous (scheduler not ready yet)
+                    currentPath = FindPathToNearestRoom(currentPos, targetRoom);
+                    currentPathIndex = 0;
+                }
+                return;
             }
 
             Debug.LogWarning($"AIAgent {agentId}: In hallway but no reachable rooms!");
@@ -403,48 +485,80 @@ public class AIAgent : MonoBehaviour
         if (hallwayExits.Count > 0)
         {
             // Peek through each exit: if the corridor leads directly to the exit room
-            // and the agent has enough intelligence, avoid that exit for now and pick another.
+            // or a dangerous room, apply intelligence/danger avoidance logic.
             List<Vector2Int> safeExits = new List<Vector2Int>(hallwayExits);
-            if (!knowsExitLocation)
+            foreach (var exit in hallwayExits)
             {
-                var goalRoomsList = maze.GetRoomsByType(MazeRoom.RoomType.End);
-                foreach (var exit in hallwayExits)
+                MazeRoom peekedRoom = PeekCorridorDestination(exit, currentRoomData);
+                if (peekedRoom == null) continue;
+
+                // Check if exit leads to the goal room
+                if (!knowsExitLocation && _goalRooms.Contains(peekedRoom))
                 {
-                    MazeRoom peekedRoom = PeekCorridorDestination(exit, currentRoomData);
-                    if (peekedRoom != null && goalRoomsList.Contains(peekedRoom))
-                    {
-                        knowsExitLocation = true;
-                        // Low-intelligence agents rush the exit; high-intelligence ones
-                        // reconsider (they may want allies or a better moment)
-                        if (agentStats.Intelligence > 40 && safeExits.Count > 1)
-                        {
-                            safeExits.Remove(exit); // Avoid this corridor for now
-                        }
-                    }
+                    knowsExitLocation = true;
+                    if (agentStats.Intelligence > 40 && safeExits.Count > 1)
+                        safeExits.Remove(exit);
+                }
+
+                // Check if exit leads to a monster-heavy room
+                if (ShouldAvoidRoom(peekedRoom) && safeExits.Count > 1)
+                {
+                    avoidedRooms.Add(peekedRoom.Id);
+                    safeExits.Remove(exit);
+                    Debug.Log($"[Agent {agentId}] {agentName}: avoiding room {peekedRoom.Id} (threat {MonsterSpawner.GetRoomThreat(peekedRoom.Id)})");
                 }
             }
-            if (safeExits.Count == 0) safeExits = hallwayExits; // Fallback: all exits
+            if (safeExits.Count == 0) safeExits = hallwayExits; // No alternative – must go through
 
             // Pick a random hallway exit from the (possibly filtered) list
             Vector2Int chosenExit = safeExits[Random.Range(0, safeExits.Count)];
 
-            // Path directly to that exit
-            currentPath = FindPathInRoom(currentPos, chosenExit, currentRoomData);
-            currentPathIndex = 0;
-
-            if (currentPath.Count > 0)
+            // Request path asynchronously via scheduler
+            if (PathfindingScheduler.Instance != null)
             {
-                targetExitTile = chosenExit;
-                commitedToExit = true;
-
-                // Add random delay before next decision to increase variety
-                nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
-
+                pathRequestPending = true;
+                var capturedExit = chosenExit;
+                var capturedRoom = currentRoomData;
+                PathfindingScheduler.Instance.RequestPath(new PathRequest
+                {
+                    AgentId = agentId,
+                    Start = currentPos,
+                    Goal = capturedExit,
+                    RoomContext = capturedRoom,
+                    IsHallwayMode = false,
+                    Callback = result =>
+                    {
+                        pathRequestPending = false;
+                        if (result.Count > 0)
+                        {
+                            currentPath = result;
+                            currentPathIndex = 0;
+                            targetExitTile = capturedExit;
+                            commitedToExit = true;
+                            nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
+                        }
+                        else
+                        {
+                            commitedToExit = false;
+                        }
+                    }
+                });
             }
             else
             {
-                Debug.LogWarning($"AIAgent {agentId}: Could not path to hallway exit");
-                commitedToExit = false;
+                // Fallback: synchronous
+                currentPath = FindPathInRoom(currentPos, chosenExit, currentRoomData);
+                currentPathIndex = 0;
+                if (currentPath.Count > 0)
+                {
+                    targetExitTile = chosenExit;
+                    commitedToExit = true;
+                    nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
+                }
+                else
+                {
+                    commitedToExit = false;
+                }
             }
         }
         else
@@ -670,80 +784,56 @@ public class AIAgent : MonoBehaviour
     }
 
     /// <summary>
-    /// Finds a path from current position to the nearest room (when in hallway)
+    /// Finds a path from current position to the nearest room (when in hallway).
+    /// Uses a min-heap open set for O(n log n) instead of O(n²).
     /// </summary>
     private List<Vector2Int> FindPathToNearestRoom(Vector2Int start, MazeRoom targetRoom)
     {
-        // Simple pathfinding through hallways using A*
-        var openSet = new List<Vector2Int> { start };
+        var openSet = new MinHeap<Vector2Int>();
         var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
-        var gScore = new Dictionary<Vector2Int, float> { [start] = 0 };
+        var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
         var targetCenter = targetRoom.GetCenter();
-        var fScore = new Dictionary<Vector2Int, float> { [start] = Vector2Int.Distance(start, targetCenter) };
 
+        openSet.Enqueue(start, Heuristic(start, targetCenter));
+
+        const int maxIterations = 2000;
         int iterations = 0;
-        const int maxIterations = 2000; // Increased from 500 to handle larger mazes
 
-        while (openSet.Count > 0 && iterations < maxIterations)
+        while (openSet.Count > 0 && iterations++ < maxIterations)
         {
-            iterations++;
-
-            // Find node with lowest fScore
-            Vector2Int current = openSet[0];
-            float lowestF = fScore[current];
-
-            for (int i = 1; i < openSet.Count; i++)
-            {
-                if (fScore[openSet[i]] < lowestF)
-                {
-                    current = openSet[i];
-                    lowestF = fScore[current];
-                }
-            }
+            Vector2Int current = openSet.Dequeue();
 
-            // If we reached the target room, we're done
-            MazeRoom currentRoom = maze.GetRoomAtTile(current.x, current.y);
-            if (currentRoom != null && currentRoom.Id == targetRoom.Id)
-            {
+            // Reached the target room
+            MazeRoom roomAtCurrent = maze.GetRoomAtTile(current.x, current.y);
+            if (roomAtCurrent != null && roomAtCurrent.Id == targetRoom.Id)
                 return ReconstructPath(cameFrom, current);
-            }
 
-            openSet.Remove(current);
-
-            // Check neighbors
-            Vector2Int[] neighbors = new[]
-            {
-                new Vector2Int(current.x + 1, current.y),
-                new Vector2Int(current.x - 1, current.y),
-                new Vector2Int(current.x, current.y + 1),
-                new Vector2Int(current.x, current.y - 1),
+            Vector2Int[] dirs = {
+                new(current.x + 1, current.y), new(current.x - 1, current.y),
+                new(current.x, current.y + 1), new(current.x, current.y - 1)
             };
-
-            foreach (var neighbor in neighbors)
+            foreach (var nb in dirs)
             {
-                if (!maze.IsInBounds(neighbor.x, neighbor.y) || !maze.IsWalkable(neighbor.x, neighbor.y))
-                    continue;
+                if (!maze.IsInBounds(nb.x, nb.y) || !maze.IsWalkable(nb.x, nb.y)) continue;
 
-                float tentativeG = gScore[current] + 1;
-
-                if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
+                float tentativeG = gScore[current] + 1f;
+                if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
                 {
-                    cameFrom[neighbor] = current;
-                    gScore[neighbor] = tentativeG;
-                    fScore[neighbor] = tentativeG + Vector2Int.Distance(neighbor, targetCenter);
-
-                    if (!openSet.Contains(neighbor))
-                    {
-                        openSet.Add(neighbor);
-                    }
+                    cameFrom[nb] = current;
+                    gScore[nb] = tentativeG;
+                    float f = tentativeG + Heuristic(nb, targetCenter);
+                    if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
+                    else openSet.Enqueue(nb, f);
                 }
             }
         }
 
-        //        Debug.LogWarning($"AIAgent {agentId}: FindPathToNearestRoom FAILED after {iterations} iterations. Start: {start}, Target room {targetRoom.Id} center: {targetCenter}");
         return new List<Vector2Int>();
     }
 
+    private static float Heuristic(Vector2Int a, Vector2Int b)
+        => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
+
     /// <summary>
     /// Finds a boundary tile of the current room that points toward the next room
     /// </summary>
@@ -777,59 +867,37 @@ public class AIAgent : MonoBehaviour
     }
 
     /// <summary>
-    /// Finds a path within a single room (limited knowledge)
+    /// Finds a path within a single room (limited knowledge).
+    /// Uses a min-heap open set for O(n log n) performance instead of O(n²).
     /// </summary>
     private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
     {
-        // Use simple A* within room bounds
-        var openSet = new List<Vector2Int> { start };
+        var openSet = new MinHeap<Vector2Int>();
         var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
-        var gScore = new Dictionary<Vector2Int, float> { [start] = 0 };
-        var fScore = new Dictionary<Vector2Int, float> { [start] = Vector2Int.Distance(start, goal) };
+        var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
+
+        openSet.Enqueue(start, Heuristic(start, goal));
 
-        int iterations = 0;
         const int maxIterations = 1000;
+        int iterations = 0;
 
-        while (openSet.Count > 0 && iterations < maxIterations)
+        while (openSet.Count > 0 && iterations++ < maxIterations)
         {
-            iterations++;
-
-            // Find node with lowest fScore
-            Vector2Int current = openSet[0];
-            float lowestF = fScore[current];
-
-            for (int i = 1; i < openSet.Count; i++)
-            {
-                if (fScore[openSet[i]] < lowestF)
-                {
-                    current = openSet[i];
-                    lowestF = fScore[current];
-                }
-            }
+            Vector2Int current = openSet.Dequeue();
 
             if (current == goal)
-            {
                 return ReconstructPath(cameFrom, current);
-            }
-
-            openSet.Remove(current);
 
-            // Check only neighbors within the room
-            var neighbors = GetRoomNeighbors(current, room);
-            foreach (var neighbor in neighbors)
+            foreach (var nb in GetRoomNeighbors(current, room))
             {
-                float tentativeG = gScore[current] + 1;
-
-                if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
+                float tentativeG = gScore[current] + 1f;
+                if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
                 {
-                    cameFrom[neighbor] = current;
-                    gScore[neighbor] = tentativeG;
-                    fScore[neighbor] = tentativeG + Vector2Int.Distance(neighbor, goal);
-
-                    if (!openSet.Contains(neighbor))
-                    {
-                        openSet.Add(neighbor);
-                    }
+                    cameFrom[nb] = current;
+                    gScore[nb] = tentativeG;
+                    float f = tentativeG + Heuristic(nb, goal);
+                    if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
+                    else openSet.Enqueue(nb, f);
                 }
             }
         }
@@ -871,16 +939,19 @@ public class AIAgent : MonoBehaviour
     }
 
     /// <summary>
-    /// Reconstructs path from A* results
+    /// Reconstructs path from A* results.
+    /// Uses Add+Reverse (O(n)) instead of Insert(0,...) (O(n²)).
     /// </summary>
-    private List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
+    private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
     {
-        var path = new List<Vector2Int> { current };
-        while (cameFrom.ContainsKey(current))
+        var path = new List<Vector2Int>();
+        while (cameFrom.TryGetValue(current, out var prev))
         {
-            current = cameFrom[current];
-            path.Insert(0, current);
+            path.Add(current);
+            current = prev;
         }
+        path.Add(current); // start node
+        path.Reverse();
         return path;
     }
 
@@ -906,7 +977,7 @@ public class AIAgent : MonoBehaviour
         if (roomAtPos == null && currentRoom.x != -1)
         {
             // We've entered a hallway - track which room we came from to prevent immediate backtracking
-            lastRoomExitedFrom = maze.Rooms.FirstOrDefault(r => r.Id == currentRoom.x);
+            lastRoomExitedFrom = maze.GetRoomById(currentRoom.x);
             commitedToExit = false; // No longer committed to that exit, now in hallway
             targetExitTile = Vector2Int.zero; // Clear the target exit
         }
@@ -1009,6 +1080,140 @@ public class AIAgent : MonoBehaviour
     /// </summary>
     public bool HasReachedGoal => hasReachedGoal;
 
+    /// <summary>True once the agent's health reaches 0.</summary>
+    public bool IsDead => isDead;
+
+    /// <summary>Current health points (mirrors AgentStats).</summary>
+    public int CurrentHealth => agentStats?.CurrentHealth ?? 0;
+
+    // ------------------------------------------------------------------ //
+    //  Combat                                                              //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Called by monsters when they deal damage to this agent.
+    /// </summary>
+    public void TakeDamage(int amount, Monster source)
+    {
+        if (isDead) return;
+        agentStats.ApplyDamage(amount);
+        if (agentStats.IsDead)
+            Die();
+    }
+
+    private void Die()
+    {
+        if (isDead) return;
+        isDead = true;
+        currentPath.Clear();
+        Debug.Log($"[Agent {agentId}] {agentName} has died!");
+
+        // Notify the manager so death stats can be tracked
+        var manager = FindAnyObjectByType<AIAgentManager>();
+        manager?.RegisterAgentDeath(this);
+
+        // Leave group cleanly
+        currentGroup?.RemoveMember(this);
+
+        Destroy(gameObject, 0.1f);
+    }
+
+    /// <summary>
+    /// Combat tick: find the nearest in-range monster and attack it.
+    /// Sets isInCombat to halt movement while a monster is close.
+    /// </summary>
+    private void CombatUpdate()
+    {
+        // Scan for the nearest living monster within melee range
+        Monster nearestMonster = null;
+        float bestDist = weapon.MeleeRange * weapon.MeleeRange;
+
+        foreach (var m in FindObjectsByType<Monster>())
+        {
+            if (m.IsDead) continue;
+            float sqDist = (m.transform.position - transform.position).sqrMagnitude;
+            if (sqDist < bestDist)
+            {
+                bestDist = sqDist;
+                nearestMonster = m;
+            }
+        }
+
+        // Update combat lock – halts FollowPath while a monster is adjacent
+        isInCombat = nearestMonster != null;
+
+        if (nearestMonster == null) return;
+
+        // Attack on cooldown
+        if (Time.time - lastAttackTime < ATTACK_COOLDOWN) return;
+        lastAttackTime = Time.time;
+
+        // Agents have advantage on hit
+        if (weapon.TryHit(AGENT_HIT_ADVANTAGE))
+        {
+            int dmg = weapon.RollDamage();
+            bool killed = nearestMonster.TakeDamage(dmg);
+            Debug.Log($"[Agent {agentId}] {agentName} hit monster for {dmg} (monster HP left: {nearestMonster.CurrentHealth})");
+            if (killed)
+            {
+                var manager = FindAnyObjectByType<AIAgentManager>();
+                manager?.RegisterMonsterKill();
+            }
+        }
+        else
+        {
+            Debug.Log($"[Agent {agentId}] {agentName} missed monster");
+        }
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Room danger assessment                                              //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Returns the perceived danger score of a room.
+    /// Uses MonsterSpawner's threat lookup (0 = safe, higher = more dangerous).
+    /// </summary>
+    private int GetRoomThreat(MazeRoom room)
+    {
+        if (room == null) return 0;
+        return MonsterSpawner.GetRoomThreat(room.Id);
+    }
+
+    /// <summary>
+    /// Decides whether to avoid, run through, or fight through a room.
+    /// Called before committing to an exit that leads to a dangerous room.
+    /// Returns true if the agent should avoid the target room.
+    /// </summary>
+    private bool ShouldAvoidRoom(MazeRoom targetRoom)
+    {
+        if (targetRoom == null) return false;
+        int threat = GetRoomThreat(targetRoom);
+        if (threat == 0) return false;
+
+        // Estimate own fighting power: health * (Strength / 50)
+        float power = agentStats.CurrentHealth * (agentStats.Strength / 50f);
+        // Risk tolerance 0=cautious, 1=reckless
+        float dangerThreshold = Mathf.Lerp(0.5f, 2.0f, riskTolerance);
+
+        bool tooRisky = threat > power * dangerThreshold;
+
+        if (!tooRisky) return false;
+
+        // Fast agents (Speed > 60) might choose to sprint through instead of avoiding
+        if (agentStats.Speed > 60)
+        {
+            float runChance = (agentStats.Speed - 60f) / 40f; // 0 at spd=60, 1 at spd=100
+            if (Random.value < runChance)
+            {
+                Debug.Log($"[Agent {agentId}] {agentName}: sprinting through dangerous room {targetRoom.Id}!");
+                return false; // Will run, not avoid
+            }
+        }
+
+        return true; // Should avoid
+    }
+
     /// <summary>
     /// Gets agent's personalized name
     /// </summary>

+ 30 - 2
Assets/Scripts/AIAgentManager.cs

@@ -17,7 +17,8 @@ public class AIAgentManager : MonoBehaviour
     [SerializeField] private GameObject agentPrefab;
 
     [Header("Visual Settings")]
-    [SerializeField] private bool showAgentPaths = true;
+    [Tooltip("Show path debug lines for all agents. Disable for large agent counts (>100) for major performance gains.")]
+    [SerializeField] private bool showAgentPaths = false;
     [SerializeField] private Color agentPathColor = Color.yellow;
     [SerializeField] private Material agentMaterial;
 
@@ -32,6 +33,32 @@ public class AIAgentManager : MonoBehaviour
     private bool isInitialized = false;
     private float lastGroupCheck = 0f;
 
+    // ---- Death statistics ----
+    private int agentsKilledByMonsters = 0;
+    private int monstersKilledByAgents = 0;
+
+    /// <summary>How many agents have been killed by monsters this run.</summary>
+    public int AgentsKilledByMonsters => agentsKilledByMonsters;
+
+    /// <summary>How many monsters agents have killed this run.</summary>
+    public int MonstersKilledByAgents => monstersKilledByAgents;
+
+    /// <summary>
+    /// Called by a dying AIAgent so the manager can update stats and clean up.
+    /// </summary>
+    public void RegisterAgentDeath(AIAgent agent)
+    {
+        agentsKilledByMonsters++;
+        activeAgents.Remove(agent);
+        Debug.Log($"[Manager] Agent {agent.AgentName} killed. Total killed: {agentsKilledByMonsters}");
+    }
+
+    /// <summary>Called by an agent after landing a killing blow on a monster.</summary>
+    public void RegisterMonsterKill()
+    {
+        monstersKilledByAgents++;
+    }
+
     void Awake()
     {
         mazeController = GetComponent<MazeController>();
@@ -198,7 +225,8 @@ public class AIAgentManager : MonoBehaviour
         activeAgents.Add(agent);
         nextAgentId++;
 
-        Debug.Log($"Spawned agent {nextAgentId - 1} ({agentCharacterType}). Total agents: {activeAgents.Count}");
+        // Suppress per-agent spawn log for large counts (1000 Debug.Log calls is significant overhead)
+        // Debug.Log($"Spawned agent {nextAgentId - 1} ({agentCharacterType}). Total agents: {activeAgents.Count}");
         return agent;
     }
 

+ 7 - 2
Assets/Scripts/AgentGroup.cs

@@ -38,9 +38,14 @@ public class AgentGroup
     public int CombinedConstitution { get; private set; }
 
     /// <summary>
-    /// Combined group health: sum of all members' individual health values (10 + Constitution each)
+    /// Combined group current health: sum of all members' current HP.
     /// </summary>
-    public int CombinedHealth => members.Count > 0 ? members.Sum(a => a.Stats.Health) : 0;
+    public int CombinedHealth => members.Count > 0 ? members.Sum(a => a.Stats.CurrentHealth) : 0;
+
+    /// <summary>
+    /// Combined group max health: sum of all members' max HP.
+    /// </summary>
+    public int CombinedMaxHealth => members.Count > 0 ? members.Sum(a => a.Stats.MaxHealth) : 0;
 
     public int AvgStrength => members.Count > 0 ? (int)members.Average(a => a.Stats.Strength) : 0;
     public int AvgSpeed => members.Count > 0 ? (int)members.Average(a => a.Stats.Speed) : 0;

+ 2 - 2
Assets/Scripts/AgentInfoPanel.cs

@@ -125,7 +125,7 @@ public class AgentInfoPanel : MonoBehaviour
             UpdateStatDisplay("DexterityBar", "DexterityValue", group.CombinedDexterity);
             UpdateStatDisplay("IntelligenceBar", "IntelligenceValue", group.CombinedIntelligence);
             UpdateStatDisplay("ConstitutionBar", "ConstitutionValue", group.CombinedConstitution);
-            UpdateStatDisplay("HealthBar", "HealthValue", group.CombinedHealth, maxValue: group.Size * 110);
+            UpdateStatDisplay("HealthBar", "HealthValue", group.CombinedHealth, maxValue: group.CombinedMaxHealth);
             var totalLabel = panelRoot.Q<Label>("TotalStatsValue");
             if (totalLabel != null)
                 totalLabel.text = (group.CombinedStrength + group.CombinedSpeed + group.CombinedMagic
@@ -140,7 +140,7 @@ public class AgentInfoPanel : MonoBehaviour
             UpdateStatDisplay("DexterityBar", "DexterityValue", stats.Dexterity);
             UpdateStatDisplay("IntelligenceBar", "IntelligenceValue", stats.Intelligence);
             UpdateStatDisplay("ConstitutionBar", "ConstitutionValue", stats.Constitution);
-            UpdateStatDisplay("HealthBar", "HealthValue", stats.Health, maxValue: 110);
+            UpdateStatDisplay("HealthBar", "HealthValue", stats.CurrentHealth, maxValue: stats.MaxHealth);
             var totalLabel = panelRoot.Q<Label>("TotalStatsValue");
             if (totalLabel != null)
                 totalLabel.text = stats.GetTotalStats().ToString();

+ 20 - 3
Assets/Scripts/AgentStats.cs

@@ -14,9 +14,17 @@ public class AgentStats
     public int Constitution { get; private set; }
 
     /// <summary>
-    /// Health is derived from Constitution: 10 + Constitution (range: 11–110)
+    /// Max health derived from Constitution: 10 + Constitution (range: 11–110)
     /// </summary>
-    public int Health => 10 + Constitution;
+    public int MaxHealth => 10 + Constitution;
+
+    /// <summary>
+    /// Current health – starts at MaxHealth and decreases when taking damage.
+    /// </summary>
+    public int CurrentHealth { get; private set; }
+
+    /// <summary>True once CurrentHealth reaches 0.</summary>
+    public bool IsDead => CurrentHealth <= 0;
 
     private const int STAT_TOTAL_POOL = 60; // (6 stats * 100) / 10
     private const int NUM_STATS = 6;
@@ -26,6 +34,15 @@ public class AgentStats
     public AgentStats()
     {
         GenerateRandomStats();
+        CurrentHealth = MaxHealth; // initialise after stats are generated
+    }
+
+    /// <summary>Apply damage; returns actual damage dealt.</summary>
+    public int ApplyDamage(int amount)
+    {
+        int actual = Mathf.Min(amount, CurrentHealth);
+        CurrentHealth -= actual;
+        return actual;
     }
 
     /// <summary>
@@ -102,6 +119,6 @@ public class AgentStats
 
     public override string ToString()
     {
-        return $"STR: {Strength} | SPD: {Speed} | MAG: {Magic} | DEX: {Dexterity} | INT: {Intelligence} | CON: {Constitution} | Total: {GetTotalStats()}";
+        return $"STR: {Strength} | SPD: {Speed} | MAG: {Magic} | DEX: {Dexterity} | INT: {Intelligence} | CON: {Constitution} | HP: {CurrentHealth}/{MaxHealth} | Total: {GetTotalStats()}";
     }
 }

+ 27 - 4
Assets/Scripts/AgentStatsUIController.cs

@@ -14,11 +14,15 @@ public class AgentStatsUIController : MonoBehaviour
 
     private Label totalAgentsLabel;
     private Label exitReachedLabel;
+    private Label killedLabel;
+    private Label monstersKilledLabel;
     private AIAgentManager agentManager;
     private MazeController mazeController;
     private float lastUpdateTime = 0f;
     private int lastTotalCount = -1;
     private int lastExitCount = -1;
+    private int lastKilledCount = -1;
+    private int lastMonstersKilledCount = -1;
 
     void Start()
     {
@@ -52,15 +56,14 @@ public class AgentStatsUIController : MonoBehaviour
 
         totalAgentsLabel = root.Q<Label>("TotalAgentsLabel");
         exitReachedLabel = root.Q<Label>("ExitReachedLabel");
+        killedLabel = root.Q<Label>("KilledLabel");
+        monstersKilledLabel = root.Q<Label>("MonstersKilledLabel");
 
         if (totalAgentsLabel == null)
-        {
             Debug.LogError("AgentStatsUIController: TotalAgentsLabel not found in UXML!");
-        }
         if (exitReachedLabel == null)
-        {
             Debug.LogError("AgentStatsUIController: ExitReachedLabel not found in UXML!");
-        }
+        // killedLabel / monstersKilledLabel are optional – no error if absent
 
         // Find managers
         mazeController = FindAnyObjectByType<MazeController>();
@@ -101,6 +104,8 @@ public class AgentStatsUIController : MonoBehaviour
     {
         int totalAgents = agentManager.GetActiveAgentCount();
         int agentsAtExit = GetAgentsAtExit();
+        int agentsKilled = agentManager.AgentsKilledByMonsters;
+        int monstersKilled = agentManager.MonstersKilledByAgents;
 
         // Only update if values changed
         if (totalAgents != lastTotalCount)
@@ -120,6 +125,24 @@ public class AgentStatsUIController : MonoBehaviour
                 lastExitCount = agentsAtExit;
             }
         }
+
+        if (agentsKilled != lastKilledCount)
+        {
+            if (killedLabel != null)
+            {
+                killedLabel.text = agentsKilled.ToString();
+                lastKilledCount = agentsKilled;
+            }
+        }
+
+        if (monstersKilled != lastMonstersKilledCount)
+        {
+            if (monstersKilledLabel != null)
+            {
+                monstersKilledLabel.text = monstersKilled.ToString();
+                lastMonstersKilledCount = monstersKilled;
+            }
+        }
     }
 
     /// <summary>

+ 22 - 38
Assets/Scripts/MazeAIEntity.cs

@@ -175,69 +175,53 @@ public class MazeAIEntity : MonoBehaviour
     /// </summary>
     private List<Vector2Int> FindPathInKnownArea(Vector2Int start, Vector2Int goal, HashSet<Vector2Int> knownTiles)
     {
-        // Simple A* within known tiles
-        var openSet = new List<Vector2Int> { start };
+        // A* within known tiles using min-heap open set (O(n log n) vs O(n²))
+        var openSet = new MinHeap<Vector2Int>();
         var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
-        var gScore = new Dictionary<Vector2Int, float> { [start] = 0 };
-        var fScore = new Dictionary<Vector2Int, float> { [start] = Vector2Int.Distance(start, goal) };
+        var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
+
+        float h0 = Mathf.Abs(start.x - goal.x) + Mathf.Abs(start.y - goal.y);
+        openSet.Enqueue(start, h0);
 
         while (openSet.Count > 0)
         {
-            // Find node with lowest fScore
-            Vector2Int current = openSet[0];
-            float lowestF = fScore[current];
-
-            for (int i = 1; i < openSet.Count; i++)
-            {
-                if (fScore[openSet[i]] < lowestF)
-                {
-                    current = openSet[i];
-                    lowestF = fScore[current];
-                }
-            }
+            Vector2Int current = openSet.Dequeue();
 
             if (current == goal)
-            {
                 return ReconstructPath(cameFrom, current);
-            }
-
-            openSet.Remove(current);
 
-            // Check neighbors
             foreach (var neighbor in maze.GetAdjacentWalkable(current.x, current.y))
             {
-                if (!knownTiles.Contains(neighbor)) continue; // Only path through known tiles
+                if (!knownTiles.Contains(neighbor)) continue;
 
-                float tentativeG = gScore[current] + 1; // Assume cost of 1
-
-                if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
+                float tentativeG = gScore[current] + 1f;
+                if (!gScore.TryGetValue(neighbor, out float existingG) || tentativeG < existingG)
                 {
                     cameFrom[neighbor] = current;
                     gScore[neighbor] = tentativeG;
-                    fScore[neighbor] = tentativeG + Vector2Int.Distance(neighbor, goal);
-
-                    if (!openSet.Contains(neighbor))
-                    {
-                        openSet.Add(neighbor);
-                    }
+                    float f = tentativeG + Mathf.Abs(neighbor.x - goal.x) + Mathf.Abs(neighbor.y - goal.y);
+                    if (openSet.Contains(neighbor, out _)) openSet.UpdatePriority(neighbor, f);
+                    else openSet.Enqueue(neighbor, f);
                 }
             }
         }
 
-        return new List<Vector2Int>(); // No path found
+        return new List<Vector2Int>();
     }
 
     /// <summary>
-    /// Reconstructs the path from A* results
+    /// Reconstructs the path from A* results using Add+Reverse (O(n)) instead of Insert(0,...) (O(n²))
     /// </summary>
-    private List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
+    private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
     {
-        var path = new List<Vector2Int> { current };
-        while (cameFrom.ContainsKey(current))
+        var path = new List<Vector2Int>();
+        while (cameFrom.TryGetValue(current, out var prev))
         {
-            current = cameFrom[current];
-            path.Insert(0, current);
+            path.Add(current);
+            current = prev;
         }
+        path.Add(current);
+        path.Reverse();
         return path;
     }
 

+ 24 - 0
Assets/Scripts/MazeController.cs

@@ -17,6 +17,9 @@ public class MazeController : MonoBehaviour
     [SerializeField] private bool spawnAIAgents = true;
     [SerializeField] private AIAgentManager agentManager;
 
+    [Header("Monsters")]
+    [SerializeField] private MonsterSpawner monsterSpawner;
+
     private MazeData currentMaze;
     private MazeGenerator generator;
 
@@ -47,6 +50,10 @@ public class MazeController : MonoBehaviour
             }
         }
 
+        // Find MonsterSpawner if not assigned
+        if (monsterSpawner == null)
+            monsterSpawner = FindAnyObjectByType<MonsterSpawner>();
+
         if (generateOnStart)
         {
             GenerateMaze();
@@ -77,11 +84,24 @@ public class MazeController : MonoBehaviour
             meshMazeRenderer.RefreshMesh();
         }
 
+        // Bake thread-safe maze snapshot for the pathfinding scheduler
+        if (PathfindingScheduler.Instance == null)
+        {
+            var schedulerGO = new GameObject("PathfindingScheduler");
+            schedulerGO.transform.parent = transform;
+            schedulerGO.AddComponent<PathfindingScheduler>();
+        }
+        PathfindingScheduler.Instance.BakeMaze(currentMaze);
+
         // Reset AI agents for new maze
         if (spawnAIAgents && agentManager != null)
         {
             agentManager.ResetForNewMaze();
         }
+
+        // Spawn monsters now that the maze is ready
+        if (monsterSpawner != null)
+            monsterSpawner.SpawnForMaze(currentMaze, mazeConfig);
     }
 
     /// <summary>
@@ -153,6 +173,10 @@ public class MazeController : MonoBehaviour
         {
             agentManager.ResetForNewMaze();
         }
+
+        // Spawn monsters for the new maze
+        if (monsterSpawner != null)
+            monsterSpawner.SpawnForMaze(currentMaze, mazeConfig);
     }
 
     /// <summary>

+ 23 - 8
Assets/Scripts/MazeData.cs

@@ -16,6 +16,10 @@ public class MazeData
     public List<Vector2Int> ExitPoints { get; private set; } = new();
 
     private int nextRoomId = 0;
+    // O(1) room lookup by ID – avoids scanning Rooms list every call
+    private readonly Dictionary<int, MazeRoom> _roomById = new();
+    // Cached typed-room lists – invalidated when a room is added
+    private readonly Dictionary<MazeRoom.RoomType, List<MazeRoom>> _roomsByType = new();
 
     public MazeData(int width, int height)
     {
@@ -134,33 +138,44 @@ public class MazeData
     }
 
     /// <summary>
-    /// Adds a room to the maze
+    /// Adds a room to the maze and registers it in the fast-lookup dictionary.
     /// </summary>
     public MazeRoom AddRoom(int minX, int minY, int maxX, int maxY, MazeRoom.RoomType roomType = MazeRoom.RoomType.Normal)
     {
         var room = new MazeRoom(nextRoomId++, minX, minY, maxX, maxY, roomType);
         Rooms.Add(room);
+        _roomById[room.Id] = room;
+        _roomsByType.Clear(); // invalidate type cache
         return room;
     }
 
     /// <summary>
-    /// Gets all rooms of a specific type
+    /// Gets all rooms of a specific type. Result is cached after the first call.
     /// </summary>
     public List<MazeRoom> GetRoomsByType(MazeRoom.RoomType type)
     {
-        return Rooms.Where(r => r.Type == type).ToList();
+        if (!_roomsByType.TryGetValue(type, out var list))
+        {
+            list = Rooms.Where(r => r.Type == type).ToList();
+            _roomsByType[type] = list;
+        }
+        return list;
     }
 
     /// <summary>
-    /// Gets the room at a specific tile position
+    /// Gets a room by its ID in O(1).
+    /// </summary>
+    public MazeRoom GetRoomById(int id)
+        => _roomById.TryGetValue(id, out var r) ? r : null;
+
+    /// <summary>
+    /// Gets the room at a specific tile position. O(1) via dictionary lookup.
     /// </summary>
     public MazeRoom GetRoomAtTile(int x, int y)
     {
         var tile = GetTile(x, y);
-        if (tile != null && tile.RoomId >= 0)
-        {
-            return Rooms.FirstOrDefault(r => r.Id == tile.RoomId);
-        }
+        if (tile != null && tile.RoomId >= 0 && _roomById.TryGetValue(tile.RoomId, out var room))
+            return room;
         return null;
     }
 

+ 21 - 31
Assets/Scripts/MazePathfinder.cs

@@ -33,66 +33,56 @@ public class MazePathfinder
     }
 
     /// <summary>
-    /// Finds a path from start to goal using A* algorithm
+    /// Finds a path from start to goal using A* with a min-heap open set (O(n log n)).
     /// </summary>
     public List<Vector2Int> FindPath(Vector2Int start, Vector2Int goal)
     {
-        var openSet = new List<PathNode>();
+        // Min-heap keyed by FCost
+        var openHeap = new MinHeap<PathNode>();
         var closedSet = new HashSet<Vector2Int>();
+        // Fast lookup: position → best node in open set
+        var openLookup = new Dictionary<Vector2Int, PathNode>();
 
         var startNode = new PathNode(start, null, 0, Heuristic(start, goal));
-        openSet.Add(startNode);
+        openHeap.Enqueue(startNode, startNode.FCost);
+        openLookup[start] = startNode;
 
-        while (openSet.Count > 0)
+        while (openHeap.Count > 0)
         {
-            // Find node with lowest F cost
-            int current = 0;
-            for (int i = 1; i < openSet.Count; i++)
-            {
-                if (openSet[i].FCost < openSet[current].FCost)
-                {
-                    current = i;
-                }
-            }
-
-            var currentNode = openSet[current];
+            var currentNode = openHeap.Dequeue();
+            openLookup.Remove(currentNode.Position);
 
             if (currentNode.Position == goal)
-            {
                 return ReconstructPath(currentNode);
-            }
 
-            openSet.RemoveAt(current);
             closedSet.Add(currentNode.Position);
 
-            // Check neighbors
-            var neighbors = GetNeighbors(currentNode.Position);
-            foreach (var neighbor in neighbors)
+            foreach (var neighbor in GetNeighbors(currentNode.Position))
             {
-                if (closedSet.Contains(neighbor))
-                    continue;
+                if (closedSet.Contains(neighbor)) continue;
 
                 float movementCost = GetMovementCost(neighbor);
-                float tentativeGCost = currentNode.GCost + movementCost;
+                float tentativeG = currentNode.GCost + movementCost;
 
-                var existingNode = openSet.FirstOrDefault(n => n.Position == neighbor);
-                if (existingNode != null)
+                if (openLookup.TryGetValue(neighbor, out var existingNode))
                 {
-                    if (tentativeGCost < existingNode.GCost)
+                    if (tentativeG < existingNode.GCost)
                     {
                         existingNode.Parent = currentNode;
-                        existingNode.GCost = tentativeGCost;
+                        existingNode.GCost = tentativeG;
+                        openHeap.UpdatePriority(existingNode, existingNode.FCost);
                     }
                 }
                 else
                 {
-                    var hCost = Heuristic(neighbor, goal);
-                    openSet.Add(new PathNode(neighbor, currentNode, tentativeGCost, hCost));
+                    float hCost = Heuristic(neighbor, goal);
+                    var newNode = new PathNode(neighbor, currentNode, tentativeG, hCost);
+                    openHeap.Enqueue(newNode, newNode.FCost);
+                    openLookup[neighbor] = newNode;
                 }
             }
         }
 
-        // No path found
         return new List<Vector2Int>();
     }
 

+ 265 - 0
Assets/Scripts/Monster.cs

@@ -0,0 +1,265 @@
+using UnityEngine;
+using System.Collections;
+using System.Collections.Generic;
+
+/// <summary>
+/// A monster that lives in a room.
+/// Behaviour:
+///   - Idles / wanders aimlessly within its spawn room when no agents are present.
+///   - When an agent enters the room, closes in to melee range and attacks each tick.
+///   - Each monster has its own health pool and 1d4 fist weapon.
+///   - When killed it fires MonsterKilled and destroys itself.
+///   - Represented as a red sphere identical in size to a solo agent.
+/// </summary>
+public class Monster : MonoBehaviour
+{
+    // ------------------------------------------------------------------ //
+    //  Inspector                                                           //
+    // ------------------------------------------------------------------ //
+    [Header("Stats")]
+    [SerializeField] private int maxHealth = 10;
+    [SerializeField] private float movementSpeed = 1.5f;
+
+    [Header("Combat")]
+    [SerializeField] private float attackCooldown = 1.2f;   // seconds between attacks
+    [SerializeField] private float aggroRange = 12f;      // range at which monsters notice agents entering
+
+    [Header("Wander")]
+    [SerializeField] private float wanderRadius = 3f;
+    [SerializeField] private float wanderInterval = 2.5f;
+
+    // ------------------------------------------------------------------ //
+    //  Runtime state                                                       //
+    // ------------------------------------------------------------------ //
+    private int currentHealth;
+    private Weapon weapon;
+    private MazeRoom homeRoom;          // The room this monster belongs to
+    private MazeData maze;
+
+    private AIAgent target;             // Current attack target
+    private float lastAttackTime;
+    private float lastWanderTime;
+    private Vector3 wanderTarget;
+    private bool isDead;
+
+    // ------------------------------------------------------------------ //
+    //  Public interface                                                    //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Difficulty modifier applied during spawn (see MonsterSpawner).
+    /// Higher = more health and slightly faster.
+    /// </summary>
+    public float DifficultyMultiplier { get; private set; } = 1f;
+
+    public int MaxHealth => maxHealth;
+    public int CurrentHealth => currentHealth;
+    public bool IsDead => isDead;
+    public MazeRoom HomeRoom => homeRoom;
+
+    /// <summary>
+    /// Convenience: threat value used by agents to estimate room danger.
+    /// Sum of all alive monster health in a room.
+    /// </summary>
+    public int ThreatValue => isDead ? 0 : currentHealth;
+
+    /// <summary>Fires when this monster is killed. Passes itself as argument.</summary>
+    public System.Action<Monster> OnMonsterKilled;
+
+    // ------------------------------------------------------------------ //
+    //  Initialisation                                                      //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Called by MonsterSpawner immediately after instantiation.
+    /// </summary>
+    public void Init(MazeRoom room, MazeData mazeData, float difficultyMultiplier = 1f)
+    {
+        homeRoom = room;
+        maze = mazeData;
+        DifficultyMultiplier = difficultyMultiplier;
+
+        maxHealth = Mathf.RoundToInt(maxHealth * difficultyMultiplier);
+        currentHealth = maxHealth;
+        movementSpeed = movementSpeed * Mathf.Lerp(1f, 1.3f, difficultyMultiplier - 1f);
+        weapon = Weapon.Fists();
+
+        wanderTarget = transform.position;
+        lastWanderTime = Time.time;
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Unity loop                                                          //
+    // ------------------------------------------------------------------ //
+
+    void Update()
+    {
+        if (isDead || maze == null) return;
+
+        AcquireTarget();
+
+        if (target != null)
+            CombatUpdate();
+        else
+            WanderUpdate();
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Target acquisition                                                  //
+    // ------------------------------------------------------------------ //
+
+    private void AcquireTarget()
+    {
+        // Drop dead targets
+        if (target != null && (target == null || target.IsDead || target.HasReachedGoal))
+        {
+            target = null;
+        }
+
+        if (target != null) return;
+
+        // Find the nearest live agent within aggro range
+        float bestDist = aggroRange * aggroRange;
+        AIAgent best = null;
+
+        foreach (var agent in FindObjectsByType<AIAgent>(FindObjectsSortMode.None))
+        {
+            if (agent.IsDead) continue;
+
+            // Only aggro agents that are in or entering this room
+            Vector2Int agentTile = WorldToTile(agent.transform.position);
+            MazeRoom agentRoom = maze.GetRoomAtTile(agentTile.x, agentTile.y);
+
+            if (agentRoom == null || agentRoom.Id != homeRoom.Id) continue;
+
+            float sqDist = (agent.transform.position - transform.position).sqrMagnitude;
+            if (sqDist < bestDist)
+            {
+                bestDist = sqDist;
+                best = agent;
+            }
+        }
+
+        target = best;
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Combat                                                              //
+    // ------------------------------------------------------------------ //
+
+    private void CombatUpdate()
+    {
+        float dist = Vector3.Distance(transform.position, target.transform.position);
+
+        if (dist > weapon.MeleeRange)
+        {
+            // Close in
+            Vector3 dir = (target.transform.position - transform.position).normalized;
+            transform.position += dir * movementSpeed * Time.deltaTime;
+        }
+        else
+        {
+            // Attack
+            if (Time.time - lastAttackTime >= attackCooldown)
+            {
+                lastAttackTime = Time.time;
+                PerformAttack();
+            }
+        }
+    }
+
+    private void PerformAttack()
+    {
+        if (target == null || target.IsDead) return;
+
+        // Monsters use base hit chance — no advantage modifier
+        if (weapon.TryHit(1f))
+        {
+            int dmg = weapon.RollDamage();
+            target.TakeDamage(dmg, this);
+            Debug.Log($"[Monster] hit {target.AgentName} for {dmg} (HP left: {target.CurrentHealth})");
+        }
+        else
+        {
+            Debug.Log($"[Monster] missed {target.AgentName}");
+        }
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Wander (no target)                                                  //
+    // ------------------------------------------------------------------ //
+
+    private void WanderUpdate()
+    {
+        // Move toward wander target
+        if (Vector3.Distance(transform.position, wanderTarget) > 0.2f)
+        {
+            Vector3 dir = (wanderTarget - transform.position).normalized;
+            transform.position += dir * (movementSpeed * 0.5f) * Time.deltaTime;
+        }
+
+        // Pick new wander target periodically
+        if (Time.time - lastWanderTime > wanderInterval)
+        {
+            lastWanderTime = Time.time;
+            PickNewWanderTarget();
+        }
+    }
+
+    private void PickNewWanderTarget()
+    {
+        if (homeRoom == null) return;
+
+        // Random tile inside home room
+        int x = Random.Range(homeRoom.MinX + 1, homeRoom.MaxX);
+        int y = Random.Range(homeRoom.MinY + 1, homeRoom.MaxY);
+        wanderTarget = new Vector3(x + 0.5f, 1f, y + 0.5f);
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Damage / death                                                      //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Deal damage to this monster. Called by agents during their attack.
+    /// Returns true if this hit killed the monster.
+    /// </summary>
+    public bool TakeDamage(int amount)
+    {
+        if (isDead) return false;
+
+        currentHealth -= amount;
+
+        if (currentHealth <= 0)
+        {
+            Die();
+            return true;
+        }
+        return false;
+    }
+
+    private void Die()
+    {
+        if (isDead) return;
+        isDead = true;
+        currentHealth = 0;
+        Debug.Log($"[Monster] in room {homeRoom?.Id} died.");
+        OnMonsterKilled?.Invoke(this);
+        Destroy(gameObject);
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Helpers                                                             //
+    // ------------------------------------------------------------------ //
+
+    private Vector2Int WorldToTile(Vector3 worldPos)
+        => new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
+
+    // ------------------------------------------------------------------ //
+    //  Mouse interaction – click to inspect (future)                       //
+    // ------------------------------------------------------------------ //
+    void OnMouseDown()
+    {
+        Debug.Log($"[Monster] Room {homeRoom?.Id} | HP {currentHealth}/{maxHealth} | Difficulty ×{DifficultyMultiplier:F1}");
+    }
+}

+ 2 - 0
Assets/Scripts/Monster.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2702e6e7362c99f44999985c4fa92363

+ 297 - 0
Assets/Scripts/MonsterSpawner.cs

@@ -0,0 +1,297 @@
+using UnityEngine;
+using System.Collections.Generic;
+using System.Linq;
+
+/// <summary>
+/// Spawns monsters into the maze based on MazeConfig settings.
+///
+/// Spawning rules
+/// ─────────────
+/// • Monster rooms  (RoomType.Boss + Normal rooms chosen by MonsterAreaDensity %)
+///     → 2–4 monsters, difficulty multiplier scales with room distance from start.
+///
+/// • Hallway tiles  (walkable tiles not inside any room)
+///     → sparse single monsters; much lower density, difficulty ≤ 1.
+///
+/// Monster prefab is a red sphere identical in size to a solo agent.
+///
+/// Exposes a static room-threat lookup so AIAgents can query danger before entering.
+/// </summary>
+public class MonsterSpawner : MonoBehaviour
+{
+    // ------------------------------------------------------------------ //
+    //  Inspector                                                           //
+    // ------------------------------------------------------------------ //
+    [Header("Prefab (auto-created if null)")]
+    [SerializeField] private GameObject monsterPrefab;
+
+    [Header("Room spawn")]
+    [Tooltip("Min monsters in a monster room")]
+    [SerializeField] private int minMonstersPerRoom = 2;
+    [Tooltip("Max monsters in a monster room")]
+    [SerializeField] private int maxMonstersPerRoom = 4;
+    [Tooltip("Extra monsters added per Boss room")]
+    [SerializeField] private int bossRoomBonusMonsters = 3;
+    [Tooltip("Difficulty multiplier range for deepest rooms (1 = weakest, 2 = strongest)")]
+    [SerializeField] private float maxDifficultyMultiplier = 2f;
+
+    [Header("Hallway spawn")]
+    [Tooltip("Probability that any given hallway tile spawns a monster (0–1)")]
+    [SerializeField][Range(0f, 0.05f)] private float hallwaySpawnChance = 0.005f;
+    [Tooltip("Difficulty multiplier cap for hallway monsters")]
+    [SerializeField] private float hallwayMaxDifficulty = 1f;
+
+    // ------------------------------------------------------------------ //
+    //  Runtime                                                             //
+    // ------------------------------------------------------------------ //
+    private MazeData maze;
+    private MazeConfig config;
+
+    /// <summary>
+    /// Room ID → list of living monsters. Agents can query this to assess danger.
+    /// Hallway monsters are stored under key -1.
+    /// </summary>
+    private static readonly Dictionary<int, List<Monster>> roomMonsters = new();
+
+    // ------------------------------------------------------------------ //
+    //  Public entry point – called by MazeController after generation     //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Called by MazeController once the maze has been fully generated.
+    /// Destroys any previously spawned monsters and re-spawns for the new maze.
+    /// </summary>
+    public void SpawnForMaze(MazeData mazeData, MazeConfig mazeConfig)
+    {
+        // Destroy any monsters left over from a previous maze
+        foreach (var m in FindObjectsByType<Monster>())
+            Destroy(m.gameObject);
+
+        maze = mazeData;
+        config = mazeConfig;
+
+        if (maze == null) { Debug.LogError("MonsterSpawner: Maze is null!"); return; }
+
+        if (monsterPrefab == null) monsterPrefab = CreateDefaultMonsterPrefab();
+
+        roomMonsters.Clear();
+        SpawnRoomMonsters();
+        SpawnHallwayMonsters();
+        Debug.Log("[MonsterSpawner] Spawning complete.");
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Public query API                                                    //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>
+    /// Total threat in a room (sum of living-monster health).
+    /// Returns 0 for rooms with no monsters or already cleared.
+    /// </summary>
+    public static int GetRoomThreat(int roomId)
+    {
+        if (!roomMonsters.TryGetValue(roomId, out var list)) return 0;
+        return list.Where(m => m != null && !m.IsDead).Sum(m => m.ThreatValue);
+    }
+
+    /// <summary>How many living monsters are in a room.</summary>
+    public static int GetLivingMonsterCount(int roomId)
+    {
+        if (!roomMonsters.TryGetValue(roomId, out var list)) return 0;
+        return list.Count(m => m != null && !m.IsDead);
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Spawning                                                            //
+    // ------------------------------------------------------------------ //
+
+    private void SpawnRoomMonsters()
+    {
+        if (!config.UseMonsterAreas) return;
+
+        var allRooms = maze.Rooms;
+        if (allRooms.Count == 0) return;
+
+        // Build a "depth" estimate: BFS distance from start rooms.
+        var depthMap = BuildRoomDepthMap();
+        float maxDepth = depthMap.Values.Count > 0 ? depthMap.Values.Max() : 1f;
+
+        // Rooms eligible for monsters
+        var eligibleRooms = allRooms.Where(r =>
+            r.Type != MazeRoom.RoomType.Safe &&
+            r.Type != MazeRoom.RoomType.End &&
+            !r.IsStart
+        ).ToList();
+
+        // Filter down by MonsterAreaDensity %
+        int targetCount = Mathf.RoundToInt(eligibleRooms.Count * (config.MonsterAreaDensity / 100f));
+        Shuffle(eligibleRooms);
+        var chosenRooms = eligibleRooms.Take(targetCount).ToList();
+
+        // Always include all Boss rooms in the chosen set
+        foreach (var boss in allRooms.Where(r => r.Type == MazeRoom.RoomType.Boss))
+            if (!chosenRooms.Contains(boss)) chosenRooms.Add(boss);
+
+        foreach (var room in chosenRooms)
+        {
+            int count = Random.Range(minMonstersPerRoom, maxMonstersPerRoom + 1);
+            if (room.Type == MazeRoom.RoomType.Boss) count += bossRoomBonusMonsters;
+
+            // Difficulty: rooms farther from start are harder
+            float depth = depthMap.TryGetValue(room.Id, out float d) ? d : 0f;
+            float t = maxDepth > 0 ? depth / maxDepth : 0f;
+            float difficulty = Mathf.Lerp(1f, maxDifficultyMultiplier, t);
+
+            SpawnMonstersInRoom(room, count, difficulty);
+        }
+    }
+
+    private void SpawnHallwayMonsters()
+    {
+        if (!config.UseMonsterAreas) return;
+
+        // Iterate walkable tiles that are NOT inside any room
+        for (int x = 0; x < maze.Width; x++)
+        {
+            for (int y = 0; y < maze.Height; y++)
+            {
+                if (!maze.IsWalkable(x, y)) continue;
+                if (maze.GetRoomAtTile(x, y) != null) continue; // skip room tiles
+
+                if (Random.value > hallwaySpawnChance) continue;
+
+                float difficulty = Random.Range(1f, hallwayMaxDifficulty);
+                SpawnSingleMonster(null, new Vector3(x + 0.5f, 1f, y + 0.5f), difficulty);
+            }
+        }
+    }
+
+    private void SpawnMonstersInRoom(MazeRoom room, int count, float difficulty)
+    {
+        for (int i = 0; i < count; i++)
+        {
+            Vector2Int tile = room.GetRandomPoint();
+            Vector3 worldPos = new Vector3(tile.x + 0.5f, 1f, tile.y + 0.5f);
+            SpawnSingleMonster(room, worldPos, difficulty);
+        }
+    }
+
+    private void SpawnSingleMonster(MazeRoom room, Vector3 worldPos, float difficulty)
+    {
+        GameObject go = Instantiate(monsterPrefab, worldPos, Quaternion.Euler(90, 0, 0));
+        var monster = go.GetComponent<Monster>();
+        if (monster == null) monster = go.AddComponent<Monster>();
+
+        // Use a placeholder room for hallway monsters so Init doesn't crash
+        MazeRoom spawnRoom = room ?? new MazeRoom(-1, 0, 0, 0, 0);
+        monster.Init(spawnRoom, maze, difficulty);
+
+        // Track in lookup
+        int key = room?.Id ?? -1;
+        if (!roomMonsters.ContainsKey(key)) roomMonsters[key] = new List<Monster>();
+        roomMonsters[key].Add(monster);
+
+        // Remove from list on death
+        monster.OnMonsterKilled += m => { roomMonsters[key]?.Remove(m); };
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Helpers                                                             //
+    // ------------------------------------------------------------------ //
+
+    /// <summary>BFS from all start-rooms to compute depth per room.</summary>
+    private Dictionary<int, float> BuildRoomDepthMap()
+    {
+        var depth = new Dictionary<int, float>();
+        var queue = new Queue<int>();
+
+        foreach (var room in maze.Rooms.Where(r => r.IsStart))
+        {
+            depth[room.Id] = 0f;
+            queue.Enqueue(room.Id);
+        }
+
+        // Build adjacency via room connectivity (rooms that share hallway tiles)
+        var adj = BuildRoomAdjacency();
+
+        while (queue.Count > 0)
+        {
+            int id = queue.Dequeue();
+            if (!adj.TryGetValue(id, out var neighbours)) continue;
+            foreach (int nId in neighbours)
+            {
+                if (!depth.ContainsKey(nId))
+                {
+                    depth[nId] = depth[id] + 1f;
+                    queue.Enqueue(nId);
+                }
+            }
+        }
+
+        return depth;
+    }
+
+    private Dictionary<int, List<int>> BuildRoomAdjacency()
+    {
+        var adj = new Dictionary<int, List<int>>();
+
+        foreach (var room in maze.Rooms)
+            adj[room.Id] = new List<int>();
+
+        // For each walkable non-room tile, check if it borders two different rooms
+        for (int x = 0; x < maze.Width; x++)
+        {
+            for (int y = 0; y < maze.Height; y++)
+            {
+                if (!maze.IsWalkable(x, y)) continue;
+                MazeRoom r1 = maze.GetRoomAtTile(x, y);
+                if (r1 == null) continue;
+
+                Vector2Int[] dirs = { new(x + 1, y), new(x - 1, y), new(x, y + 1), new(x, y - 1) };
+                foreach (var d in dirs)
+                {
+                    if (!maze.IsInBounds(d.x, d.y) || !maze.IsWalkable(d.x, d.y)) continue;
+                    MazeRoom r2 = maze.GetRoomAtTile(d.x, d.y);
+                    if (r2 == null || r2.Id == r1.Id) continue;
+                    if (!adj[r1.Id].Contains(r2.Id)) adj[r1.Id].Add(r2.Id);
+                    if (!adj[r2.Id].Contains(r1.Id)) adj[r2.Id].Add(r1.Id);
+                }
+            }
+        }
+
+        return adj;
+    }
+
+    private static void Shuffle<T>(List<T> list)
+    {
+        for (int i = list.Count - 1; i > 0; i--)
+        {
+            int j = Random.Range(0, i + 1);
+            (list[i], list[j]) = (list[j], list[i]);
+        }
+    }
+
+    // ------------------------------------------------------------------ //
+    //  Prefab factory                                                      //
+    // ------------------------------------------------------------------ //
+
+    private GameObject CreateDefaultMonsterPrefab()
+    {
+        var go = new GameObject("MonsterPrefab");
+        go.transform.localScale = Vector3.one * 0.5f;
+
+        go.AddComponent<SphereCollider>().radius = 1f;
+
+        var mf = go.AddComponent<MeshFilter>();
+        mf.mesh = Resources.GetBuiltinResource<Mesh>("Sphere.fbx");
+
+        var mr = go.AddComponent<MeshRenderer>();
+        var mat = new Material(Shader.Find("Universal Render Pipeline/Lit")
+                  ?? Shader.Find("Standard"));
+        mat.color = Color.red;
+        mr.material = mat;
+
+        go.AddComponent<Monster>();
+        return go;
+    }
+}

+ 2 - 0
Assets/Scripts/MonsterSpawner.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f5eda8df098f6bd43912303d69be6e8b

+ 379 - 0
Assets/Scripts/PathfindingScheduler.cs

@@ -0,0 +1,379 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+
+/// <summary>
+/// Thread-safe min-heap (binary heap) priority queue for A* open sets.
+/// Lower fScore = higher priority.
+/// </summary>
+public class MinHeap<T>
+{
+    private readonly List<(float priority, T item)> _heap = new();
+
+    public int Count => _heap.Count;
+
+    public void Enqueue(T item, float priority)
+    {
+        _heap.Add((priority, item));
+        BubbleUp(_heap.Count - 1);
+    }
+
+    public T Dequeue()
+    {
+        var top = _heap[0].item;
+        int last = _heap.Count - 1;
+        _heap[0] = _heap[last];
+        _heap.RemoveAt(last);
+        if (_heap.Count > 0) SiftDown(0);
+        return top;
+    }
+
+    public T Peek() => _heap[0].item;
+
+    public bool Contains(T item, out float priority)
+    {
+        for (int i = 0; i < _heap.Count; i++)
+        {
+            if (EqualityComparer<T>.Default.Equals(_heap[i].item, item))
+            {
+                priority = _heap[i].priority;
+                return true;
+            }
+        }
+        priority = float.MaxValue;
+        return false;
+    }
+
+    public void UpdatePriority(T item, float newPriority)
+    {
+        for (int i = 0; i < _heap.Count; i++)
+        {
+            if (EqualityComparer<T>.Default.Equals(_heap[i].item, item))
+            {
+                _heap[i] = (newPriority, item);
+                BubbleUp(i);
+                SiftDown(i);
+                return;
+            }
+        }
+    }
+
+    private void BubbleUp(int i)
+    {
+        while (i > 0)
+        {
+            int parent = (i - 1) / 2;
+            if (_heap[parent].priority <= _heap[i].priority) break;
+            (_heap[parent], _heap[i]) = (_heap[i], _heap[parent]);
+            i = parent;
+        }
+    }
+
+    private void SiftDown(int i)
+    {
+        int n = _heap.Count;
+        while (true)
+        {
+            int smallest = i;
+            int left = 2 * i + 1, right = 2 * i + 2;
+            if (left < n && _heap[left].priority < _heap[smallest].priority) smallest = left;
+            if (right < n && _heap[right].priority < _heap[smallest].priority) smallest = right;
+            if (smallest == i) break;
+            (_heap[smallest], _heap[i]) = (_heap[i], _heap[smallest]);
+            i = smallest;
+        }
+    }
+}
+
+/// <summary>
+/// A single pathfinding request queued by an AIAgent.
+/// </summary>
+public class PathRequest
+{
+    public int AgentId;
+    public Vector2Int Start;
+    public Vector2Int Goal;
+    public MazeRoom RoomContext;   // null = open hallway A*
+    public bool IsHallwayMode;     // true = FindPathToNearestRoom style
+    public Action<List<Vector2Int>> Callback; // Called on main thread with result
+}
+
+/// <summary>
+/// Schedules A* pathfinding requests on worker threads and delivers results back
+/// to the main thread on the next frame.
+///
+/// Usage: instead of calling FindPathInRoom() directly in Update(), agents call
+///   PathfindingScheduler.Instance.RequestPath(request)
+/// and supply a callback that will fire on the main thread.
+///
+/// Worker threads process up to <see cref="MaxConcurrentJobs"/> requests in parallel.
+/// This keeps Unity's main thread free for rendering and physics while the CPU
+/// cores that were sitting idle (the user observed CPU at ~1%) do the heavy lifting.
+/// </summary>
+public class PathfindingScheduler : MonoBehaviour
+{
+    public static PathfindingScheduler Instance { get; private set; }
+
+    [Header("Threading")]
+    [Tooltip("How many pathfinding jobs may run simultaneously. Match to logical CPU cores - 2.")]
+    [SerializeField] private int maxConcurrentJobs = 6;
+
+    [Header("Budget")]
+    [Tooltip("Maximum path results to dispatch to agents per Unity frame. Prevents main-thread spikes.")]
+    [SerializeField] private int resultsPerFrame = 50;
+
+    // Pending requests waiting for a worker
+    private readonly ConcurrentQueue<PathRequest> _pendingQueue = new();
+
+    // Completed results ready to dispatch on the main thread
+    private readonly ConcurrentQueue<(Action<List<Vector2Int>> callback, List<Vector2Int> result)> _resultQueue = new();
+
+    // Counts active background jobs so we don't exceed the limit
+    private int _activeJobs = 0;
+
+    // Read-only copy of maze data shared across threads (set once after maze generation)
+    private MazeData _maze;
+
+    // ---- Snapshot arrays copied from MazeData for thread-safe read access ----
+    private bool[,] _walkable;   // [x, y] → is walkable
+    private int _mazeWidth, _mazeHeight;
+    // Room tile lookup (room ID per tile, -1 = no room)
+    private int[,] _roomIdMap;
+    // Rooms snapshot (read-only after bake)
+    private MazeRoom[] _rooms;
+
+    void Awake()
+    {
+        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
+        Instance = this;
+    }
+
+    /// <summary>
+    /// Must be called once the maze is fully generated before any agents run.
+    /// Bakes thread-safe copies of maze data.
+    /// </summary>
+    public void BakeMaze(MazeData maze)
+    {
+        _maze = maze;
+        _mazeWidth = maze.Width;
+        _mazeHeight = maze.Height;
+
+        _walkable = new bool[_mazeWidth, _mazeHeight];
+        _roomIdMap = new int[_mazeWidth, _mazeHeight];
+
+        for (int x = 0; x < _mazeWidth; x++)
+            for (int y = 0; y < _mazeHeight; y++)
+            {
+                _walkable[x, y] = maze.IsWalkable(x, y);
+                _roomIdMap[x, y] = -1;
+            }
+
+        // Build room-id map
+        _rooms = maze.Rooms.ToArray();
+        foreach (var room in _rooms)
+            for (int x = room.MinX; x <= room.MaxX; x++)
+                for (int y = room.MinY; y <= room.MaxY; y++)
+                    if (x >= 0 && y >= 0 && x < _mazeWidth && y < _mazeHeight)
+                        _roomIdMap[x, y] = room.Id;
+
+        Debug.Log($"[PathfindingScheduler] Baked {_mazeWidth}x{_mazeHeight} maze, {_rooms.Length} rooms, maxJobs={maxConcurrentJobs}");
+    }
+
+    /// <summary>
+    /// Enqueue a pathfinding request. The callback fires on the main thread.
+    /// </summary>
+    public void RequestPath(PathRequest request)
+    {
+        _pendingQueue.Enqueue(request);
+    }
+
+    void Update()
+    {
+        if (_maze == null) return;
+
+        // Dispatch pending requests to worker threads
+        while (_activeJobs < maxConcurrentJobs && _pendingQueue.TryDequeue(out var req))
+        {
+            Interlocked.Increment(ref _activeJobs);
+            Task.Run(() => ProcessRequest(req));
+        }
+
+        // Deliver completed results back on the main thread
+        int dispatched = 0;
+        while (dispatched < resultsPerFrame && _resultQueue.TryDequeue(out var result))
+        {
+            try { result.callback?.Invoke(result.result); }
+            catch (Exception e) { Debug.LogException(e); }
+            dispatched++;
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // Worker thread methods — NO Unity API calls allowed here
+    // -----------------------------------------------------------------------
+
+    private void ProcessRequest(PathRequest req)
+    {
+        try
+        {
+            List<Vector2Int> path;
+
+            if (req.IsHallwayMode)
+                path = FindPathHallway(req.Start, req.Goal);
+            else if (req.RoomContext != null)
+                path = FindPathInRoom(req.Start, req.Goal, req.RoomContext);
+            else
+                path = FindPathHallway(req.Start, req.Goal);
+
+            _resultQueue.Enqueue((req.Callback, path));
+        }
+        catch (Exception e)
+        {
+            // Log safely; Unity's Debug.Log is thread-safe for logging
+            Debug.LogException(e);
+            _resultQueue.Enqueue((req.Callback, new List<Vector2Int>()));
+        }
+        finally
+        {
+            Interlocked.Decrement(ref _activeJobs);
+        }
+    }
+
+    /// <summary>
+    /// Thread-safe A* within a room (limited to room tiles + 1-tile boundary).
+    /// Uses a min-heap open set instead of a linear list → O(n log n) vs O(n²).
+    /// </summary>
+    private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
+    {
+        var openSet = new MinHeap<Vector2Int>();
+        var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
+        var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
+
+        float h0 = Heuristic(start, goal);
+        openSet.Enqueue(start, h0);
+
+        const int maxIter = 1000;
+        int iter = 0;
+
+        while (openSet.Count > 0 && iter++ < maxIter)
+        {
+            Vector2Int current = openSet.Dequeue();
+
+            if (current == goal)
+                return ReconstructPath(cameFrom, current);
+
+            foreach (var nb in GetRoomNeighbors(current, room))
+            {
+                float tentG = gScore[current] + 1f;
+                if (!gScore.TryGetValue(nb, out float existingG) || tentG < existingG)
+                {
+                    cameFrom[nb] = current;
+                    gScore[nb] = tentG;
+                    float f = tentG + Heuristic(nb, goal);
+                    if (openSet.Contains(nb, out _))
+                        openSet.UpdatePriority(nb, f);
+                    else
+                        openSet.Enqueue(nb, f);
+                }
+            }
+        }
+
+        return new List<Vector2Int>();
+    }
+
+    /// <summary>
+    /// Thread-safe A* through hallways (not room-constrained).
+    /// Used when agent is navigating open corridors toward a target tile.
+    /// </summary>
+    private List<Vector2Int> FindPathHallway(Vector2Int start, Vector2Int goal)
+    {
+        var openSet = new MinHeap<Vector2Int>();
+        var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
+        var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
+
+        openSet.Enqueue(start, Heuristic(start, goal));
+
+        const int maxIter = 5000;
+        int iter = 0;
+
+        while (openSet.Count > 0 && iter++ < maxIter)
+        {
+            Vector2Int current = openSet.Dequeue();
+
+            if (current == goal)
+                return ReconstructPath(cameFrom, current);
+
+            foreach (var nb in GetWalkableNeighbors(current))
+            {
+                float tentG = gScore[current] + 1f;
+                if (!gScore.TryGetValue(nb, out float existingG) || tentG < existingG)
+                {
+                    cameFrom[nb] = current;
+                    gScore[nb] = tentG;
+                    float f = tentG + Heuristic(nb, goal);
+                    if (openSet.Contains(nb, out _))
+                        openSet.UpdatePriority(nb, f);
+                    else
+                        openSet.Enqueue(nb, f);
+                }
+            }
+        }
+
+        return new List<Vector2Int>();
+    }
+
+    // ---- Thread-safe helpers (access only _walkable / _roomIdMap arrays) ----
+
+    private static readonly Vector2Int[] _dirs = {
+        new(1,0), new(-1,0), new(0,1), new(0,-1)
+    };
+
+    private List<Vector2Int> GetWalkableNeighbors(Vector2Int pos)
+    {
+        var result = new List<Vector2Int>(4);
+        foreach (var d in _dirs)
+        {
+            int nx = pos.x + d.x, ny = pos.y + d.y;
+            if (nx >= 0 && ny >= 0 && nx < _mazeWidth && ny < _mazeHeight && _walkable[nx, ny])
+                result.Add(new Vector2Int(nx, ny));
+        }
+        return result;
+    }
+
+    private List<Vector2Int> GetRoomNeighbors(Vector2Int pos, MazeRoom room)
+    {
+        var result = new List<Vector2Int>(4);
+        foreach (var d in _dirs)
+        {
+            int nx = pos.x + d.x, ny = pos.y + d.y;
+            if (nx < 0 || ny < 0 || nx >= _mazeWidth || ny >= _mazeHeight) continue;
+            if (!_walkable[nx, ny]) continue;
+
+            bool inRoom = room.Contains(nx, ny);
+            bool nearBoundary = nx == room.MinX - 1 || nx == room.MaxX + 1 ||
+                                ny == room.MinY - 1 || ny == room.MaxY + 1;
+            if (inRoom || nearBoundary)
+                result.Add(new Vector2Int(nx, ny));
+        }
+        return result;
+    }
+
+    private static float Heuristic(Vector2Int a, Vector2Int b)
+        => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
+
+    private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
+    {
+        var path = new List<Vector2Int>();
+        while (cameFrom.TryGetValue(current, out var prev))
+        {
+            path.Add(current);
+            current = prev;
+        }
+        path.Add(current); // start node
+        path.Reverse();
+        return path;
+    }
+}

+ 2 - 0
Assets/Scripts/PathfindingScheduler.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d059ca5d3189a4049ae19e274516362f

+ 65 - 0
Assets/Scripts/Weapon.cs

@@ -0,0 +1,65 @@
+using UnityEngine;
+
+/// <summary>
+/// Describes a weapon's attack characteristics.
+/// All combat goes through this so we can easily add more weapon types later.
+/// </summary>
+[System.Serializable]
+public class Weapon
+{
+    public string Name;
+
+    /// <summary>Number of dice to roll for damage.</summary>
+    public int DamageDiceCount;
+
+    /// <summary>Faces on each damage die (e.g. 4 for d4).</summary>
+    public int DamageDiceFaces;
+
+    /// <summary>Flat bonus added on top of the dice roll.</summary>
+    public int DamageBonus;
+
+    /// <summary>
+    /// Base hit-chance (0-1). Multiplied by attacker advantage when attacking.
+    /// Default fists: 0.65 base. Can be tuned per weapon in the future.
+    /// </summary>
+    public float BaseHitChance;
+
+    /// <summary>Melee reach in world-units.</summary>
+    public float MeleeRange;
+
+    public Weapon(string name, int diceCount, int diceFaces, int damageBonus = 0,
+                  float baseHitChance = 0.65f, float meleeRange = 1.5f)
+    {
+        Name = name;
+        DamageDiceCount = diceCount;
+        DamageDiceFaces = diceFaces;
+        DamageBonus = damageBonus;
+        BaseHitChance = baseHitChance;
+        MeleeRange = meleeRange;
+    }
+
+    /// <summary>Roll the damage dice and return the result.</summary>
+    public int RollDamage()
+    {
+        int total = DamageBonus;
+        for (int i = 0; i < DamageDiceCount; i++)
+            total += Random.Range(1, DamageDiceFaces + 1);
+        return Mathf.Max(1, total); // Always deal at least 1 damage
+    }
+
+    /// <summary>
+    /// Attempt to hit a target.
+    /// <paramref name="attackerAdvantage"/> is a 0-1 multiplier on top of BaseHitChance.
+    /// 1.0 = full chance, 0.5 = halved, 1.5 = boosted (capped at 0.95 to never guarantee a hit).
+    /// </summary>
+    public bool TryHit(float attackerAdvantage = 1f)
+    {
+        float chance = Mathf.Clamp(BaseHitChance * attackerAdvantage, 0.05f, 0.95f);
+        return Random.value <= chance;
+    }
+
+    // ---- Preset factories ----
+
+    /// <summary>Default unarmed fists: 1d4 damage, 65 % base hit chance.</summary>
+    public static Weapon Fists() => new Weapon("Fists", 1, 4, 0, 0.65f, 1.5f);
+}

+ 2 - 0
Assets/Scripts/Weapon.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: acea5c91c58d94449acc12cdff159765

+ 21 - 0
Assets/Settings/Scenes/URP2DSceneTemplate.unity

@@ -199,6 +199,7 @@ GameObject:
   m_Component:
   - component: {fileID: 262480514}
   - component: {fileID: 262480513}
+  - component: {fileID: 262480515}
   m_Layer: 0
   m_Name: MazeController
   m_TagString: Untagged
@@ -249,6 +250,7 @@ MonoBehaviour:
   meshMazeRenderer: {fileID: 0}
   spawnAIAgents: 1
   agentManager: {fileID: 928897497}
+  monsterSpawner: {fileID: 0}
 --- !u!4 &262480514
 Transform:
   m_ObjectHideFlags: 0
@@ -264,6 +266,25 @@ Transform:
   m_Children: []
   m_Father: {fileID: 0}
   m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &262480515
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 262480512}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: f5eda8df098f6bd43912303d69be6e8b, type: 3}
+  m_Name: 
+  m_EditorClassIdentifier: Assembly-CSharp::MonsterSpawner
+  monsterPrefab: {fileID: 0}
+  minMonstersPerRoom: 2
+  maxMonstersPerRoom: 4
+  bossRoomBonusMonsters: 3
+  maxDifficultyMultiplier: 2
+  hallwaySpawnChance: 0.005
+  hallwayMaxDifficulty: 1
 --- !u!1 &519420028
 GameObject:
   m_ObjectHideFlags: 0

+ 7 - 0
Assets/UI/AgentStatsPanel.uss

@@ -59,3 +59,10 @@
     -unity-font-style: bold;
     min-width: 60px;
 }
+
+.stat-value-danger {
+    font-size: 24px;
+    color: #e74c3c;
+    -unity-font-style: bold;
+    min-width: 60px;
+}

+ 8 - 0
Assets/UI/AgentStatsPanel.uxml

@@ -12,6 +12,14 @@
                 <ui:Label text="Reached Exit:" class="stat-label" />
                 <ui:Label name="ExitReachedLabel" text="0" class="stat-value-success" />
             </ui:VisualElement>
+            <ui:VisualElement class="stat-row">
+                <ui:Label text="Killed by Monsters:" class="stat-label" />
+                <ui:Label name="KilledLabel" text="0" class="stat-value-danger" />
+            </ui:VisualElement>
+            <ui:VisualElement class="stat-row">
+                <ui:Label text="Monsters Killed:" class="stat-label" />
+                <ui:Label name="MonstersKilledLabel" text="0" class="stat-value-success" />
+            </ui:VisualElement>
         </ui:VisualElement>
     </ui:VisualElement>
 </ui:UXML>

+ 15 - 15
UserSettings/Layouts/default-6000.dwlt

@@ -74,7 +74,7 @@ MonoBehaviour:
   m_MinSize: {x: 200, y: 56}
   m_MaxSize: {x: 16192, y: 8096}
   vertical: 0
-  controlID: 119
+  controlID: 121
   draggingID: 0
 --- !u!114 &4
 MonoBehaviour:
@@ -646,9 +646,9 @@ MonoBehaviour:
     m_TextWithWhitespace: "UI Builder\u200B"
   m_Pos:
     serializedVersion: 2
-    x: 1678
+    x: 1522
     y: 79
-    width: 1120
+    width: 1276
     height: 828
   m_SerializedDataModeController:
     m_DataMode: 0
@@ -1700,7 +1700,7 @@ MonoBehaviour:
     m_OverlaysVisible: 1
     m_DynamicPanelBehavior: 0
   m_SearchFilter:
-    m_NameFilter: stat
+    m_NameFilter: 
     m_ClassNames: []
     m_AssetLabels: []
     m_AssetBundleNames: []
@@ -1714,7 +1714,7 @@ MonoBehaviour:
     m_Globs: []
     m_ProductIds: 
     m_AnyWithAssetOrigin: 0
-    m_OriginalText: stat
+    m_OriginalText: 
     m_ImportLogFlags: 0
     m_FilterByTypeIntersection: 0
   m_ViewMode: 1
@@ -1734,11 +1734,11 @@ MonoBehaviour:
       m_Data: 70296
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 65346
-    - m_Data: 69964
-    - m_Data: 69966
-    - m_Data: 69968
-    - m_Data: 69970
+    - m_Data: 65358
+    - m_Data: 69978
+    - m_Data: 69980
+    - m_Data: 69982
+    - m_Data: 69984
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: 
@@ -1772,11 +1772,11 @@ MonoBehaviour:
       m_Data: 0
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 65346
-    - m_Data: 69964
-    - m_Data: 69966
-    - m_Data: 69968
-    - m_Data: 69970
+    - m_Data: 65358
+    - m_Data: 69978
+    - m_Data: 69980
+    - m_Data: 69982
+    - m_Data: 69984
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: