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;
}