using UnityEngine; using System.Collections.Generic; using System.Linq; /// /// AI Agent that navigates the maze with limited knowledge /// Only knows about the room it's currently in and rooms visited by same character type /// public class AIAgent : MonoBehaviour { [Header("Agent Identity")] [SerializeField] private string agentCharacterType = "Default"; [SerializeField] private int agentId; [Header("Agent Personalization")] private string agentName; private AgentStats agentStats; [Header("AI Settings")] [SerializeField] private float movementSpeed = 2f; [SerializeField] private float pathUpdateInterval = 0.5f; [SerializeField] private float stoppingDistance = 0.1f; private float actualMovementSpeed; // Per-agent speed variation to reduce synchronization [Header("Pathfinding")] [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; private MazeController mazeController; private MazeData maze; private MazePathfinder pathfinder; private AIRoomMemory roomMemory; private List currentPath = new(); private int currentPathIndex = 0; private float lastPathUpdate = 0f; private Vector2Int currentRoom = Vector2Int.zero; private Vector2Int targetRoom = Vector2Int.zero; private Vector2Int targetExitTile = Vector2Int.zero; // Target hallway exit to move toward private Queue recentRooms = new Queue(); // Track last N rooms to avoid backtracking private const int RECENT_ROOMS_BUFFER_SIZE = 10; // Larger buffer = less backtracking private MazeRoom lastRoomExitedFrom = null; // Track which room we exited to prevent immediate backtracking private bool hasReachedGoal = false; // Track if agent has reached the goal room 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 private float groupUpAffinity; // 0-1: likelihood to seek allies over going solo private float riskTolerance; // 0-1: willingness to enter dangerous/unknown situations private bool knowsExitLocation = false; // True once agent has "seen" the exit room // ----- Group membership ----- 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; /// Agent advantage multiplier on hit rolls (can improve with experience/weapons later). private const float AGENT_HIT_ADVANTAGE = 1.3f; private bool isDead = false; /// True while a monster is within melee range – halts path-following. private bool isInCombat = false; /// Monsters this agent is currently fighting (for fight tracking). private HashSet fightingMonsters = new(); // ----- Room danger / avoidance ----- /// Room IDs the agent has decided to avoid (too dangerous to fight through). private readonly HashSet avoidedRooms = new(); /// Speed threshold: agents above this fraction of max speed may try to run through. 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 _goalRooms; // Constant set for O(1) Contains checks void Start() { mazeController = FindAnyObjectByType(); if (mazeController == null) { Debug.LogError("AIAgent: MazeController not found in scene!"); enabled = false; return; } maze = mazeController.GetCurrentMaze(); if (maze == null) { Debug.LogError("AIAgent: Current maze is null!"); enabled = false; return; } 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(maze.GetRoomsByType(MazeRoom.RoomType.End)); if (_goalRooms.Count == 0) Debug.LogWarning($"[Agent] No goal rooms found! Agents will never exit."); else Debug.Log($"[Agent] {_goalRooms.Count} goal room(s) cached for exit detection."); // Initialize personalization agentName = AgentNameGenerator.GenerateRandomName(); agentStats = new AgentStats(); gameObject.name = $"Agent_{agentId} ({agentName})"; // Seed intelligence-driven disposition from stats 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) { int startIndex = agentId % maze.StartPoints.Count; // Distribute agents across start points Vector2Int startPos = maze.StartPoints[startIndex]; // Get the start room and spawn randomly within it MazeRoom startRoom = maze.GetRoomAtTile(startPos.x, startPos.y); if (startRoom != null) { // Spawn at random position within start room Vector2Int randomPosInRoom = new Vector2Int( Random.Range(startRoom.MinX + 1, startRoom.MaxX), Random.Range(startRoom.MinY + 1, startRoom.MaxY) ); transform.position = new Vector3(randomPosInRoom.x + 0.5f, 1f, randomPosInRoom.y + 0.5f); } else { // Fallback to exact start point transform.position = new Vector3(startPos.x + 0.5f, 1f, startPos.y + 0.5f); } // Rotate to be visible from above - 90 degrees around X axis transform.rotation = Quaternion.Euler(90, 0, 0); UpdateCurrentRoom(); // This will add to recentRooms and visit room } else { Debug.LogError("AIAgent: No start points found in maze!"); enabled = false; return; } // Setup per-agent random offset to desync decision timings (prevents all agents moving in sync) agentRandomOffset = Random.Range(0f, pathUpdateInterval * 0.5f); nextRandomWait = Time.time + Random.Range(2f, 4f); // Random initial wait before first decision // Scale movement speed by the agent's Speed stat: // Speed=1 → ~1× base, Speed=100 → 2× base, plus ±10% desync jitter float speedMultiplier = 1f + (agentStats.Speed / 100f); actualMovementSpeed = movementSpeed * speedMultiplier * Random.Range(0.9f, 1.1f); // 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) { pathRenderer = gameObject.AddComponent(); pathRenderer.startWidth = 0.1f; pathRenderer.endWidth = 0.1f; pathRenderer.material = new Material(Shader.Find("Sprites/Default")); pathRenderer.startColor = pathColor; pathRenderer.endColor = pathColor; } } void Update() { if (maze == null) return; // Dead bodies persist forever - just stop updating logic if (isDead) { return; } // Agent has reached the exit - stop all movement if (hasReachedGoal) { 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 check for exit rooms UpdateCurrentRoom(); // Followers still fight monsters in range CombatUpdate(); return; } // Fight any adjacent monsters CombatUpdate(); // Update current room UpdateCurrentRoom(); // 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 – suppressed while fighting if (!isInCombat) FollowPath(); // 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; /// Returns true if this agent is within world units of the main camera. 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); /// /// Updates the current room based on position. /// Only queries the maze when the agent has moved to a different tile. /// Also checks if we've reached the goal room immediately. /// 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) { // CHECK FOR GOAL ROOM FIRST - every tile, regardless of room change if (!hasReachedGoal && _goalRooms.Contains(room)) { hasReachedGoal = true; currentPath.Clear(); currentPathIndex = 0; commitedToExit = false; // If this agent is in a group, mark all members as having reached the goal if (currentGroup != null) { foreach (var member in currentGroup.Members) { if (member != null && !member.HasReachedGoal) { member.hasReachedGoal = true; } } } Debug.Log($"[Agent {agentId}] {agentName} reached the exit!"); return; // Stop immediately } if (currentRoom.x != room.Id) { currentRoom = new Vector2Int(room.Id, 0); roomMemory.VisitRoom(room.Id); // Track recent rooms to avoid immediate backtracking recentRooms.Enqueue(room.Id); if (recentRooms.Count > RECENT_ROOMS_BUFFER_SIZE) recentRooms.Dequeue(); } } } /// /// Updates pathfinding to reach the goal /// Agent only knows about current room and visited rooms /// NEW LOGIC: Pick a hallway exit from current room and move straight to it /// private void UpdatePathToGoal() { if (maze.ExitPoints.Count == 0) { Debug.LogWarning($"AIAgent {agentId}: No exit points in maze!"); return; } Vector2Int currentPos = WorldToTile(transform.position); MazeRoom currentRoomData = maze.GetRoomAtTile(currentPos.x, currentPos.y); // CHECK FOR GOAL ROOM FIRST - this should work even while following path if (currentRoomData != null && _goalRooms.Contains(currentRoomData)) { if (!hasReachedGoal) { hasReachedGoal = true; currentPath.Clear(); currentPathIndex = 0; commitedToExit = false; // If this agent is in a group, mark all members as having reached the goal if (currentGroup != null) { foreach (var member in currentGroup.Members) { if (member != null && !member.HasReachedGoal) { member.hasReachedGoal = true; } } } } return; // Stay stopped } // If we already have a valid path and we're following it, don't recalculate (stay committed) if (currentPath.Count > 0 && currentPathIndex < currentPath.Count) { return; // Keep following existing path } // 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) { if (currentPath.Count == 0 && currentRoomData != null) { 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 { currentPath = FindPathInRoom(currentPos, capturedExit, capturedRoom); currentPathIndex = 0; pathRequestPending = false; } return; } } // If in hallway (no room), just keep moving along current path // The hallway pathfinding will naturally move us toward exits if (currentRoomData == null) { // If we have a current path, keep following it through hallway if (currentPath.Count > 0 && currentPathIndex < currentPath.Count) { return; // Keep following path } // In hallway with no path - find next room to enter // PRIORITY 1: Find unvisited rooms, avoid the room we just exited // Collect all unvisited rooms List unvisitedRooms = new(); foreach (var room in _allRooms) { // Skip the room we just exited (backtracking prevention) if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id) continue; // Prioritize unvisited rooms if (!roomMemory.HasVisited(room.Id)) { unvisitedRooms.Add(room); } } MazeRoom targetRoom = null; // Random decision: 70% closest unvisited, 30% random unvisited (increases exploration variety) if (unvisitedRooms.Count > 0) { if (Random.value < 0.7f && unvisitedRooms.Count > 0) { // Pick closest unvisited room float closestUnvisitedDistance = float.MaxValue; foreach (var room in unvisitedRooms) { Vector2Int roomCenter = room.GetCenter(); float distance = Vector2Int.Distance(currentPos, roomCenter); if (distance < closestUnvisitedDistance) { closestUnvisitedDistance = distance; targetRoom = room; } } } else { // Pick random unvisited room for variety targetRoom = unvisitedRooms[Random.Range(0, unvisitedRooms.Count)]; } } // If no unvisited room found, pick nearest room (except the one we came from) if (targetRoom == null) { float closestDistance = float.MaxValue; foreach (var room in _allRooms) { // Skip the room we just exited if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id) continue; Vector2Int roomCenter = room.GetCenter(); float distance = Vector2Int.Distance(currentPos, roomCenter); if (distance < closestDistance) { closestDistance = distance; targetRoom = room; } } } // Exit-room caution: if the chosen target is the exit room and we spotted // it through a short corridor, smarter agents may hesitate or route around. // (Low intelligence agents rush straight in; high intelligence agents are cautious // and may wait for allies — once group logic is implemented.) if (targetRoom != null) { if (_goalRooms.Contains(targetRoom)) { knowsExitLocation = true; // High-intelligence agents (INT > 50) pause briefly to "assess" // before committing — placeholder for future ally/fight evaluation if (agentStats.Intelligence > 50) { nextRandomWait = Time.time + (agentStats.Intelligence / 100f) * 2f; } } } if (targetRoom != null) { // Request async path – result arrives next frame via callback if (PathfindingScheduler.Instance != null) { pathRequestPending = true; var capturedRoom = targetRoom; PathfindingScheduler.Instance.RequestPath(new PathRequest { AgentId = agentId, Start = currentPos, Goal = capturedRoom.GetCenter(), IsHallwayMode = true, Callback = result => { pathRequestPending = false; if (result.Count > 0) { currentPath = result; currentPathIndex = 0; } // else: fallback handled next update cycle } }); } else { // Fallback: synchronous (scheduler not ready yet) currentPath = FindPathToNearestRoom(currentPos, targetRoom); currentPathIndex = 0; } return; } Debug.LogWarning($"AIAgent {agentId}: In hallway but no reachable rooms!"); return; } // MAIN LOGIC: In regular room, find a hallway exit and path to it // Get all hallway tiles adjacent to this room List hallwayExits = FindHallwayExits(currentRoomData, currentPos); if (hallwayExits.Count > 0) { // Peek through each exit: if the corridor leads directly to the exit room // or a dangerous room, apply intelligence/danger avoidance logic. List safeExits = new List(hallwayExits); 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)) { 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; // No alternative – must go through // Pick a random hallway exit from the (possibly filtered) list Vector2Int chosenExit = safeExits[Random.Range(0, safeExits.Count)]; // Request path asynchronously via scheduler if (PathfindingScheduler.Instance != null) { 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 { // 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 { Debug.LogWarning($"AIAgent {agentId}: No hallway exits found from room {currentRoomData.Id}"); commitedToExit = false; } } /// /// Chooses the next room to move to /// Searches around the current room to find adjacent rooms /// Avoids recently visited rooms to prevent backtracking /// private MazeRoom ChooseNextRoom(MazeRoom currentRoom) { // Find all adjacent rooms by checking walkable tiles in all directions from room boundaries List connectedRooms = new(); Vector2Int roomCenter = currentRoom.GetCenter(); // Check from each direction outward from the room center Vector2Int[] directions = new Vector2Int[] { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right, Vector2Int.up + Vector2Int.left, Vector2Int.up + Vector2Int.right, Vector2Int.down + Vector2Int.left, Vector2Int.down + Vector2Int.right }; // Shuffle directions for randomness for (int i = directions.Length - 1; i > 0; i--) { int randomIndex = Random.Range(0, i + 1); var temp = directions[i]; directions[i] = directions[randomIndex]; directions[randomIndex] = temp; } // Try each direction and look for tiles that lead to other rooms foreach (var dir in directions) { for (int dist = 1; dist < 20; dist++) // Search up to 20 tiles away { Vector2Int testPos = roomCenter + (dir * dist); if (!maze.IsInBounds(testPos.x, testPos.y) || !maze.IsWalkable(testPos.x, testPos.y)) continue; MazeRoom testRoom = maze.GetRoomAtTile(testPos.x, testPos.y); if (testRoom != null && testRoom.Id != currentRoom.Id && !connectedRooms.Contains(testRoom)) { connectedRooms.Add(testRoom); break; // Found a room in this direction, move to next direction } } } if (connectedRooms.Count == 0) { Debug.LogWarning($"AIAgent {agentId}: No adjacent rooms found from room {currentRoom.Id}"); return null; } // Filter out recently visited rooms (to avoid backtracking) List nonRecentRooms = connectedRooms.Where(r => !recentRooms.Contains(r.Id)).ToList(); // PRIORITY 1: Strongly prefer completely unvisited rooms (not recently visited either) var completelyUnvisited = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList(); if (completelyUnvisited.Count > 0) { MazeRoom chosen = completelyUnvisited[Random.Range(0, completelyUnvisited.Count)]; return chosen; } // PRIORITY 2: Try non-recent rooms even if visited by this character type if (nonRecentRooms.Count > 0) { // Among non-recent rooms, prefer unvisited by this agent's character type var unvisitedByType = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList(); if (unvisitedByType.Count > 0) { MazeRoom chosen = unvisitedByType[Random.Range(0, unvisitedByType.Count)]; return chosen; } // Otherwise just pick a random non-recent room MazeRoom chosen2 = nonRecentRooms[Random.Range(0, nonRecentRooms.Count)]; return chosen2; } // PRIORITY 3: If all rooms are recent, pick one that's been visited least recently // Find the room in connectedRooms that was added to recentRooms earliest (will be dequeued first) MazeRoom leastRecentRoom = connectedRooms[0]; foreach (var room in connectedRooms) { if (!roomMemory.HasVisited(room.Id)) { leastRecentRoom = room; break; } } Debug.Log($"AIAgent {agentId}: All rooms recent, choosing least-recent room {leastRecentRoom.Id}"); return leastRecentRoom; } /// /// Finds the nearest room from a position (used when in hallway) /// private MazeRoom FindNearestRoom(Vector2Int position) { MazeRoom nearest = null; float nearestDistance = float.MaxValue; foreach (var room in maze.Rooms) { Vector2Int roomCenter = room.GetCenter(); float distance = Vector2Int.Distance(position, roomCenter); if (distance < nearestDistance) { nearestDistance = distance; nearest = room; } } return nearest; } /// /// Finds all hallway exit tiles adjacent to a room /// These are walkable tiles just outside the room boundary /// /// /// Peeks along a corridor starting from a hallway exit tile. /// Follows walkable non-room tiles up to a short distance and returns /// the first room found at the other end, or null if no room is close. /// Used by intelligence logic to detect if an exit leads straight to the goal. /// private MazeRoom PeekCorridorDestination(Vector2Int exitTile, MazeRoom sourceRoom, int maxPeekDistance = 12) { // BFS outward from the exit tile, staying in non-room (hallway) tiles var visited = new HashSet { exitTile }; var queue = new Queue(); queue.Enqueue(exitTile); while (queue.Count > 0) { Vector2Int tile = queue.Dequeue(); Vector2Int[] dirs = { new(tile.x + 1, tile.y), new(tile.x - 1, tile.y), new(tile.x, tile.y + 1), new(tile.x, tile.y - 1) }; foreach (var next in dirs) { if (!maze.IsInBounds(next.x, next.y) || !maze.IsWalkable(next.x, next.y)) continue; if (visited.Contains(next)) continue; MazeRoom nextRoom = maze.GetRoomAtTile(next.x, next.y); if (nextRoom != null && nextRoom.Id != sourceRoom.Id) return nextRoom; // Found the room at the end of this corridor // Only continue through hallway tiles and within peek distance if (nextRoom == null && Vector2Int.Distance(exitTile, next) < maxPeekDistance) { visited.Add(next); queue.Enqueue(next); } } } return null; } private List FindHallwayExits(MazeRoom room, Vector2Int currentPos) { List exits = new(); HashSet addedExits = new(); // Check all tiles on the boundary of the room // North boundary for (int x = room.MinX; x <= room.MaxX; x++) { int y = room.MinY - 1; if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y))) { exits.Add(new Vector2Int(x, y)); addedExits.Add(new Vector2Int(x, y)); } } // South boundary for (int x = room.MinX; x <= room.MaxX; x++) { int y = room.MaxY + 1; if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y))) { exits.Add(new Vector2Int(x, y)); addedExits.Add(new Vector2Int(x, y)); } } // West boundary for (int y = room.MinY; y <= room.MaxY; y++) { int x = room.MinX - 1; if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y))) { exits.Add(new Vector2Int(x, y)); addedExits.Add(new Vector2Int(x, y)); } } // East boundary for (int y = room.MinY; y <= room.MaxY; y++) { int x = room.MaxX + 1; if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y))) { exits.Add(new Vector2Int(x, y)); addedExits.Add(new Vector2Int(x, y)); } } return exits; } /// /// 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²). /// private List FindPathToNearestRoom(Vector2Int start, MazeRoom targetRoom) { var openSet = new MinHeap(); var cameFrom = new Dictionary(); var gScore = new Dictionary { [start] = 0f }; var targetCenter = targetRoom.GetCenter(); openSet.Enqueue(start, Heuristic(start, targetCenter)); const int maxIterations = 2000; int iterations = 0; while (openSet.Count > 0 && iterations++ < maxIterations) { Vector2Int current = openSet.Dequeue(); // Reached the target room MazeRoom roomAtCurrent = maze.GetRoomAtTile(current.x, current.y); if (roomAtCurrent != null && roomAtCurrent.Id == targetRoom.Id) return ReconstructPath(cameFrom, current); 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 nb in dirs) { if (!maze.IsInBounds(nb.x, nb.y) || !maze.IsWalkable(nb.x, nb.y)) continue; float tentativeG = gScore[current] + 1f; if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG) { 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); } } } return new List(); } private static float Heuristic(Vector2Int a, Vector2Int b) => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y); /// /// Finds a boundary tile of the current room that points toward the next room /// private Vector2Int FindBoundaryTileToward(MazeRoom currentRoom, MazeRoom nextRoom, Vector2Int currentPos) { Vector2Int nextRoomCenter = nextRoom.GetCenter(); Vector2Int closestBoundary = currentRoom.GetCenter(); float closestDistance = float.MaxValue; // Check all boundary tiles of current room for (int x = currentRoom.MinX; x <= currentRoom.MaxX; x++) { for (int y = currentRoom.MinY; y <= currentRoom.MaxY; y++) { // Only check boundary and walkable tiles if ((x == currentRoom.MinX || x == currentRoom.MaxX || y == currentRoom.MinY || y == currentRoom.MaxY) && maze.IsWalkable(x, y)) { // Find boundary tile closest to next room center float distToNext = Vector2Int.Distance(new Vector2Int(x, y), nextRoomCenter); if (distToNext < closestDistance) { closestDistance = distToNext; closestBoundary = new Vector2Int(x, y); } } } } return closestBoundary; } /// /// 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²). /// private List FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room) { var openSet = new MinHeap(); var cameFrom = new Dictionary(); var gScore = new Dictionary { [start] = 0f }; openSet.Enqueue(start, Heuristic(start, goal)); const int maxIterations = 1000; int iterations = 0; while (openSet.Count > 0 && iterations++ < maxIterations) { Vector2Int current = openSet.Dequeue(); if (current == goal) return ReconstructPath(cameFrom, current); foreach (var nb in GetRoomNeighbors(current, room)) { float tentativeG = gScore[current] + 1f; if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG) { 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); } } } return new List(); } /// /// Gets walkable neighbors within a room or at room boundary /// Allows pathfinding to reach hallway exits outside room /// private List GetRoomNeighbors(Vector2Int position, MazeRoom room) { var neighbors = new List(); Vector2Int[] directions = new[] { new Vector2Int(position.x + 1, position.y), new Vector2Int(position.x - 1, position.y), new Vector2Int(position.x, position.y + 1), new Vector2Int(position.x, position.y - 1), }; foreach (var dir in directions) { // Allow tiles within room OR immediately adjacent to room (boundary) bool inBounds = maze.IsInBounds(dir.x, dir.y); bool isWalkable = maze.IsWalkable(dir.x, dir.y); bool inRoom = room.Contains(dir.x, dir.y); bool nearBoundary = (dir.x == room.MinX - 1 || dir.x == room.MaxX + 1 || dir.y == room.MinY - 1 || dir.y == room.MaxY + 1); if (inBounds && isWalkable && (inRoom || nearBoundary)) { neighbors.Add(dir); } } return neighbors; } /// /// Reconstructs path from A* results. /// Uses Add+Reverse (O(n)) instead of Insert(0,...) (O(n²)). /// private static List ReconstructPath(Dictionary cameFrom, Vector2Int current) { var path = new List(); while (cameFrom.TryGetValue(current, out var prev)) { path.Add(current); current = prev; } path.Add(current); // start node path.Reverse(); return path; } /// /// Follows the current path /// private void FollowPath() { if (currentPath.Count == 0) return; Vector2Int currentTarget = currentPath[currentPathIndex]; // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level) // Keep Y at 1f to stay above the maze floor Vector3 targetWorldPos = new Vector3(currentTarget.x + 0.5f, 1f, currentTarget.y + 0.5f); Vector3 direction = (targetWorldPos - transform.position).normalized; // Move directly instead of using Translate (no rigidbody) transform.position += direction * actualMovementSpeed * Time.deltaTime; // Check if we've exited the room (entered hallway) Vector2Int currentPos = WorldToTile(transform.position); MazeRoom roomAtPos = maze.GetRoomAtTile(currentPos.x, currentPos.y); if (roomAtPos == null && currentRoom.x != -1) { // We've entered a hallway - track which room we came from to prevent immediate backtracking lastRoomExitedFrom = maze.GetRoomById(currentRoom.x); commitedToExit = false; // No longer committed to that exit, now in hallway targetExitTile = Vector2Int.zero; // Clear the target exit } // Move to next waypoint when close enough if (Vector3.Distance(transform.position, targetWorldPos) < stoppingDistance) { currentPathIndex++; if (currentPathIndex >= currentPath.Count) { currentPath.Clear(); } } } /// /// Updates the path visualization /// private void UpdatePathVisualization() { if (currentPath.Count == 0) { pathRenderer.positionCount = 0; return; } var positions = new Vector3[currentPath.Count]; for (int i = 0; i < currentPath.Count; i++) { // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level) // Keep Y at 1f to stay above the maze floor positions[i] = new Vector3(currentPath[i].x + 0.5f, 1f, currentPath[i].y + 0.5f); } pathRenderer.positionCount = positions.Length; pathRenderer.SetPositions(positions); } /// /// Converts world position to tile coordinate /// Maze coordinates: X,Y ← World: X,Z (Y=0 is ground level) /// private Vector2Int WorldToTile(Vector3 worldPos) { return new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z)); } /// /// Gets agent ID /// public int AgentId => agentId; /// /// Gets agent character type /// public string CharacterType => agentCharacterType; /// /// Gets current room /// public Vector2Int CurrentRoom => currentRoom; /// /// Gets room memory /// public AIRoomMemory RoomMemory => roomMemory; /// /// Gets agent ID (public accessor for triggers) /// public int GetAgentId() => agentId; /// /// Called by ExitRoomTrigger when agent enters the goal room /// Immediately stops all movement /// Also marks all group members as having reached the goal /// public void StopAtGoal() { hasReachedGoal = true; currentPath.Clear(); currentPathIndex = 0; commitedToExit = false; // If this agent is in a group, mark all members as having reached the goal if (currentGroup != null) { foreach (var member in currentGroup.Members) { if (member != null && !member.HasReachedGoal) { member.hasReachedGoal = true; } } } } /// /// Gets whether this agent has reached the goal /// public bool HasReachedGoal => hasReachedGoal; /// True once the agent's health reaches 0. public bool IsDead => isDead; /// Current health points (mirrors AgentStats). public int CurrentHealth => agentStats?.CurrentHealth ?? 0; // ------------------------------------------------------------------ // // Combat // // ------------------------------------------------------------------ // /// /// Called by monsters when they deal damage to this agent. /// 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(); // End all fights this agent is in foreach (var monster in fightingMonsters) { if (monster != null) FightTracker.Instance.EndFight(this, monster); } fightingMonsters.Clear(); // Change visual to show dead (grayscale/dark with white X) var renderer = GetComponent(); if (renderer != null) { var material = new Material(renderer.material); material.color = new Color(0.4f, 0.4f, 0.4f, 0.9f); // Dark gray, opaque renderer.material = material; } // Add X marker BEFORE disabling (must happen while enabled) AddDeadMarker(); // Disable pathfinding AFTER adding marker if (pathRenderer != null) pathRenderer.enabled = false; Debug.Log($"[Agent {agentId}] {agentName} has died!"); // Notify the manager so death stats can be tracked var manager = FindAnyObjectByType(); manager?.RegisterAgentDeath(this); // Leave group cleanly currentGroup?.RemoveMember(this); } /// /// Adds a white X marker to indicate the body is dead. /// Uses a child GameObject so it doesn't conflict with the path LineRenderer. /// private void AddDeadMarker() { try { if (gameObject == null) return; // Use a dedicated child GameObject so it doesn't clash with pathRenderer var markerGO = new GameObject("DeadMarker"); markerGO.transform.SetParent(transform, worldPositionStays: false); markerGO.transform.localPosition = Vector3.zero; var lineRenderer = markerGO.AddComponent(); if (lineRenderer == null) { Debug.LogWarning("Failed to add LineRenderer component"); return; } // Set material first before configuring the renderer Shader lineShader = Shader.Find("Unlit/Color"); if (lineShader == null) lineShader = Shader.Find("Sprites/Default"); if (lineShader == null) lineShader = Shader.Find("Standard"); if (lineShader != null) { lineRenderer.material = new Material(lineShader); } else { Debug.LogWarning("No suitable shader found for dead marker"); return; } // Now configure the line lineRenderer.positionCount = 4; lineRenderer.useWorldSpace = true; float offset = 0.35f; // Lift the X marker above the body so the top-down camera can see it Vector3 markerBase = transform.position + Vector3.up * 0.6f; // Draw a white X in XZ plane (visible from top-down camera) lineRenderer.SetPosition(0, markerBase + Vector3.left * offset + Vector3.forward * offset); lineRenderer.SetPosition(1, markerBase + Vector3.right * offset + Vector3.back * offset); lineRenderer.SetPosition(2, markerBase + Vector3.right * offset + Vector3.forward * offset); lineRenderer.SetPosition(3, markerBase + Vector3.left * offset + Vector3.back * offset); lineRenderer.startWidth = 0.15f; lineRenderer.endWidth = 0.15f; lineRenderer.startColor = Color.white; lineRenderer.endColor = Color.white; } catch (System.Exception ex) { Debug.LogWarning($"Failed to add dead marker: {ex.Message}\n{ex.StackTrace}"); } } /// /// Combat tick: find the nearest in-range monster and attack it. /// Sets isInCombat to halt movement while a monster is close. /// 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()) { 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; // Track fight start if (nearestMonster != null && !fightingMonsters.Contains(nearestMonster)) { fightingMonsters.Add(nearestMonster); FightTracker.Instance.StartFight(this, nearestMonster); } // Track fight end for monsters no longer in range var monstersToRemove = new List(); foreach (var monster in fightingMonsters) { if (monster == null || monster.IsDead) { monstersToRemove.Add(monster); } else { float dist = (monster.transform.position - transform.position).sqrMagnitude; if (dist > weapon.MeleeRange * weapon.MeleeRange) { monstersToRemove.Add(monster); } } } foreach (var monster in monstersToRemove) { fightingMonsters.Remove(monster); if (monster != null) FightTracker.Instance.EndFight(this, monster); } 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(); manager?.RegisterMonsterKill(); } } else { Debug.Log($"[Agent {agentId}] {agentName} missed monster"); } } // ------------------------------------------------------------------ // // Room danger assessment // // ------------------------------------------------------------------ // /// /// Returns the perceived danger score of a room. /// Uses MonsterSpawner's threat lookup (0 = safe, higher = more dangerous). /// private int GetRoomThreat(MazeRoom room) { if (room == null) return 0; return MonsterSpawner.GetRoomThreat(room.Id); } /// /// 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. /// 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 } /// /// Gets agent's personalized name /// public string AgentName => agentName; /// /// Gets agent's stats /// public AgentStats Stats => agentStats; /// /// 0-1 tendency to seek allies over going solo (driven by Intelligence) /// public float GroupUpAffinity => groupUpAffinity; /// /// 0-1 willingness to take risks in combat or unknown rooms (driven by Intelligence) /// public float RiskTolerance => riskTolerance; /// /// Whether this agent has spotted the exit room (through corridor peeking or direct entry) /// public bool KnowsExitLocation => knowsExitLocation; void OnMouseDown() { // If this agent is in a group, clicking any member shows the group panel if (currentGroup != null) AgentInfoPanel.ShowGroupInfo(currentGroup); else AgentInfoPanel.ShowAgentInfo(this); } public void SetShowPath(bool show) { showPath = show; if (pathRenderer != null) { pathRenderer.enabled = show; } } public bool GetShowPath() => showPath; // ------------------------------------------------------------------ // // Group API // // ------------------------------------------------------------------ // /// Called by AgentGroup when this agent is added. public void JoinGroup(AgentGroup group) { currentGroup = group; isGroupFollower = group.Leader != this; Debug.Log($"[Agent {agentId}] {agentName} joined group {group.GroupId} as {(isGroupFollower ? "follower" : "leader")}"); } /// Called by AgentGroup when this agent leaves or group dissolves. public void LeaveGroup() { currentGroup = null; isGroupFollower = false; // Restore own renderer var mr = GetComponent(); if (mr != null) mr.enabled = true; var lr = GetComponent(); if (lr != null) lr.enabled = showPath; } /// The group this agent belongs to, or null if solo. public AgentGroup Group => currentGroup; /// True if another agent is driving this agent's position. public bool IsGroupFollower => isGroupFollower; /// True if this agent leads a group (is not a follower but group exists). public bool IsGroupLeader => currentGroup != null && !isGroupFollower; }